Язык автоматизаций¶
Это спецификация языка автоматизаций 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/триггерное) и текущее значения одного свойства.
«Предыдущее» — это последнее известное значение свойства, а не прошлое сообщение топика целиком:
движок копит статус прибора, накладывая каждое сообщение поверх прежнего слиянием верхнего уровня
JSON-объекта (сообщение, которое не объект, заменяет статус целиком). Прибор, который между
показаниями шлёт частичный статус ({"last_seen":…} без свойства), «было» не стирает — иначе
переход 21 → {last_seen} → 22 молча не засчитывался. Пока свойство не приходило ни разу, «было»
неизвестно, и переход не срабатывает (changed_to_needs_prev_property). Исключение — у движка нет
памяти о приборе вовсе (первое сообщение после перезапуска: статус живёт в памяти процесса). Тогда
changed_to засчитывает первый статус со значением X как переход, а changed_from_to и пороги
ждут настоящего перехода (changed_to_fires_on_first_status_without_memory,
changed_from_to_strict_without_memory). Облако и хаб считают одинаково; контракт — векторы
contracts/local-runner-vectors/conditions/transitions.json (changed_to_across_partial_status,
crosses_up_without_band_across_last_seen). База ожидания по собственному прибору по-прежнему
берёт сырое последнее сообщение.
Эхо команды — не событие (ADR 0056). На <прибор>/set zigbee2mqtt сначала публикует прежнее
состояние прибора и только потом новое. Команда (от движка, панели, Алисы, хаба) ставит удержание на
свои дискретные ключи (строка, булево; не числа и не TOGGLE) на 3 с; сообщение, в котором
удержанный ключ ≠ команде и = прежнему накопленному значению, а подтверждённых ключей нет, движок
не принимает вовсе: оно не триггер, не «было», не вход отмены и не ответ ожиданию. Значение = команде
и любое третье значение снимают удержание. Облако и хаб считают одинаково — векторы
contracts/local-runner-vectors/echo/echo.json.
Событие устройства — это его СТАТУС, а не команда ему. Адрес устройства матчится с отбрасыванием
хвостовых сегментов (правило на 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 (docs/source/dev/adr/0017-retrigger-on-the-step.md).
Режима «перезапустить» нет сознательно: сегодняшний анти-спам уведомлений держится ровно на инварианте «один активный прогон» (сообщение плюс пауза в хвосте цепочки), и перезапуск выключил бы единственный работающий механизм. Он вернётся вместе с явным кулдауном канала.
Граница инварианта — прогоны. Одношаговую тайм-автоматизацию (единственное действие
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 на записи).
Жизненный цикл разового запуска¶
Строка разового триггера — одно вхождение. Пока оно может сработать, его видно в «Расписании»
окна автоматизации; исход (прогон или пропуск) живёт только в журнале automation_runs и
показывается в «Истории». Состояние считает сервер (App\Support\OneTimeTriggerState, поле
triggers[].state):
planned/paused— взведён, момент впереди;paused— автоматизация выключена;awaiting— момент прошёл, вхождение держит движок: облачный планировщик (пока строка в его очереди —next_due_at_utcзадан, автоматизация включена, не дольше 1 ч 5 мин, после этого claim становитсяdead) или хаб (пока автоматизация в его применённом бандле, не дольше 7 суток);missed— момент прошёл, а держать вхождение некому: автоматизация была выключена, строка не поставлена в очередь (тумблер/пересборка триггеров), хаб её не держит;done— снят после момента;cancelled— снят до момента (корзина у последнего триггера).
Момент вхождения F (fire_at) — тот же ключ, что due_minute прогона: next_due_at_utc, если
задан, иначе T − lead (усечено до минуты), у солнечного облачного — светило в якорный день, у
солнечного на хабе — сам якорь.
missed разрешает janus (App\Support\OneTimeOccurrences): строка журнала skipped с
due_minute = F, claimed_by = 'janus', last_error = 'one_time_missed:<причина>'
(automation_disabled | not_scheduled) и без меток исполнения, затем is_active = false.
Это единственная форма прогона, которую пишет janus. Разбор идёт при включении автоматизации (до
записи is_active — задним числом не исполняем, иначе хаб догнал бы прошедший запуск в своём
окне опоздания) и уборщиком automations:prune-spent-runs раз в 10 минут, после окна опоздания
5 минут. Слот (automation_id, due_minute) уникален — если исход уже записал движок, своя строка
не пишется.
Запись автоматизации переносит взвод и next_due_at_utc уже наступившего вхождения как есть
(под блокировкой): пересчёт дал бы новый слот (двойной запуск) или null (потерянный).
Прошедшая минута, которой нет среди сохранённых, ложится снятой. Корзина у последнего триггера
снимает запуск, а не удаляет строку; снятая строка не мешает добавить запуск на ту же минуту,
если по ней не было прогона. Потолок 30 считает только взведённые; строк всего — не больше 90;
давно снятые (delete_after_run, старше 30 суток) уборщик удаляет без доказательства прогона.
Расписание и часовой пояс¶
Инвариант (ADR 0048): моменты — UTC, время суток — в поясе пользователя, пояс — всегда IANA.
one_time_at— момент: ISO-8601 со смещением, в ответе — UTC. Строка без смещения на всех путях записи (эндпоинты разового запуска, полное сохранение, каст модели) читается как UTC, а не в таймзоне приложения. Отtimezoneмомент не зависит; движок исполняет поnext_due_at_utc.timezone— имя IANA (Europe/Moscow). Смещение (+03:00,UTC+3) и аббревиатуры (EST) отвергаются 422: у них нет сезонных переходов, аtime.LoadLocationв hmq2 половину из них не загрузит и выключит триггер. Устаревшие имена ICU, которые отдаёт браузер (Asia/Calcutta,Europe/Kiev,Africa/Asmera), приводятся к канону (App\Support\TimeZones) и хранятся каноном. Таблица псевдонимов читается изtzdata.ziобраза (связиL): бинарных зон веткиbackwardв образе нет, а ручной список отставал бы и от браузеров, и от tzdata.У cron выражение обязательно (422
triggers.N.cron_expression): регулярное расписание без выражения раньше записывалось с пустымnext_due_at_utcи не срабатывало никогда.У солнечного расписания выражение выбирает только дни (время даёт солнце). День ищется с начала текущих местных суток: сегодняшний годится, если событие ещё впереди (раньше перебор шёл от «сейчас», и
0 0 * * *днём давал завтрашнюю полночь — сегодняшний закат пропадал в день создания и каждой правки). Хаб сверяет с выражением и дни солнечного события. Контракт — векторыcontracts/local-runner-vectors/solar/next.json(janus, раннер хаба, hmq2).Ближайший запуск cron считается по стенным часам пояса, и текущая минута в него входит: сохранение в 07:00:20 для
0 7 * * *даёт сегодняшние 07:00. Повтора нет — слот прогона уникален по(automation_id, due_minute). Перевод часов — как в классическом (Vixie) cron:«точное время» (поля минут и часов не начинаются со
*): время из весеннего разрыва срабатывает в момент перевода (30 2 * * *Europe/Berlin 28.03.2027 → 03:00 местного), повторённая осенью минута — один раз, в первое вхождение;«шаговое» (
*или шаг от*в минутах или часах): по реальному времени — пропавших весной минут нет, повторённые осенью срабатывают оба раза.
Контракт — секция
nextвекторовcontracts/local-runner-vectors/cron/cron.json({name, expr, tz, from_utc, want_utc},from_utc— «сейчас»).У cron и солнечного расписания пояс обязателен (422
triggers.N.timezone): «0 7 * * *» без пояса — не момент, а число, и раньше такой триггер молча получалnext_due_at_utc = NULL. Старые строки без поясаNextDueCalculatorпо-прежнему считает вnull/UTC, но с предупреждением в логе — исключение стало бы 500 на кнопке «включить».У разового fixed пояс информационный: в него ложится пояс браузера, в котором время набирали, и ничего из него не читается. Имя, которое поясом не является, у него не отвергается, а ложится пустым — иначе одна старая строка с фолбэком «UTC+3» запирала бы сохранение всей автоматизации (редактор присылает все триггеры). Старому cron без пояса редактор при сохранении подставляет пояс браузера: такой триггер никогда не срабатывал, переводить нечего.
Панель вводит и показывает время в поясе браузера (
resources/js/utils/timezone.js); при сохранении времени cron/solar из браузера в другом поясе расписание перебазируется на пояс браузера — редактор дописывает к подписи «по <пояс>» и предупреждает об этом в диалоге. Хаб (local-runner/schedule.js) триггер без пояса помечает негодным и молчит, как и с нечитаемым cron.Ту же обязательность зоны несёт операнд времени условия
{"now":…}— см. ниже.
Действия (then_actions)¶
Типы шагов (ScenarioThenActionType): device_command (публикация в MQTT), pause (задержка),
message (уведомление по каналу), wait_until (ожидание состояния), set_group_expose (запись
регистра группы, v3), control (регулятор, v20), branch (ветвление, v23), wait_join
(«Дождаться», v40) — каждый описан в своём разделе ниже. apply_default и reply (v27) сняты в v39 — 422, см.
«Запись» и «Чтение» логики группы (v39). Шаги блока выполняются линейно по
порядку — за одним исключением: шаг branch (v23) выбирает, какие шаги играть дальше, в тот
момент, когда цепочка до него дошла (см. «Ветвление внутри цепочки» ниже), и wait_until с
async: true (v40), который стартует и отпускает цепочку дальше (см.
Параллельное ожидание (async и wait_join, v40)).
В редакторе device_command показан пунктом «Действие» с выбором подтипа: «Устройство» или
«Публикация в топик». Подтипы различает наличие device_id — на проводе это один и тот же тип
шага (ADR 0020, ADR 0022).
Название и пояснение шага (``alias`` и ``description``, v48.1). Любой шаг любого типа и на любой
глубине (тело ветви, шаги обработчика просрочки, ветка гашения) принимает два необязательных
косметических ключа: alias — короткое имя (до 255 символов), description — пояснение автора
(до 1000).
Где имя видно (v48.2 расширила список):
в строке шага оно становится главной подписью и вытесняет техническую сводку, оставляя рядом только МАРКЕРЫ поведения: «Прогрев парилки · продлевается · шагов при просрочке: 2». Адрес прибора, таймаут, канал и текст уезжают в диалог шага, куда ведёт клик по той же строке. У паузы и шага «Регистр группы» имя печатается отдельной строкой: там главный элемент — живые поля ввода, и подпись вместо них отобрала бы у автора правку;
в подписи ветви ветвления — вместо «Ветка N»/«Иначе», и в сводке самого шага «Ветвление», как только названа хотя бы одна ветвь;
в окне идущего прогона третьим сегментом: «шаг 2 из 7 · Насыщение»;
в строке истории прогонов — только у ИДУЩЕЙ строки («Сейчас: шаг 3 из 5 · Насыщение»). У терминальной строки позиция не печатается: движок на падении шага увеличивает номер, и «упал на шаге N» назвало бы СЛЕДУЮЩИЙ шаг;
в строке списка автоматизаций чипом «Выполняется · Насыщение» у живого прогона. Это снимок на момент загрузки страницы: живого опроса прогонов у списка нет, и номера шага со сроком там намеренно нет — они читались бы как телеметрия и устаревали бы точно.
Ключ не задан — экран выглядит ровно так, как до появления этих полей.
Пояснение (description) наружу отдаёт только редактор: в ресурсе прогона его нет вовсе, и в строке шага оно прячется за значком «i».
Три свойства этих ключей стоит назвать вслух:
пустое значение удаляет ключ.
""иnullна записи означают «очистить», и в jsonb документа ключа не остаётся вовсе — иначе очистка поля оставляла бы"alias": null, который контракт (там объявлена строка) не принимает;плейсхолдеры внутри них — литеральный текст.
{{arg:…}},{{prop:…}}и{{expose:…}}в имени и пояснении не подставляются и не проверяются: подстановку делает белый список полей шага, и косметика в него не входит. По той же причине упомянутый в пояснении регистр НЕ становится используемым и не мешает снять его объявление;на хаб они не уезжают. Сборщик локального бандла вырезает оба ключа (и у шага, и у ветви), а оценка бюджета бандла их не считает: пояснительная проза не должна отбирать у автоматизации право исполняться локально. Движок их не читает вовсе — стамп грамматики из-за них не поднимался.
Имя шага у прогона на ХАБЕ поэтому считается из ОПРЕДЕЛЕНИЯ автоматизации, а не из снимка: снимка
шагов у таких строк нет, и косметики в бандле тоже. Резолв узкий намеренно — семь условий, и все
обязательные: прогон исполнял хаб; снимка нет (настоящий снимок сильнее); триггер — время либо
устройство; строка терминальная и номер шага ≥ 1 (цель — номер − 1); у автоматизации РОВНО ОДИН
блок условий (какой блок исполнялся, у хабовой строки не написано нигде); цель стоит ДО первого шага
«Ветвление»; автоматизацию не правили после прогона. Вне коридора панель молчит — назвать шаг, до
которого дело не дошло, хуже, чем не назвать никакой, потому что проверить это человеку нечем. У
сеанса фоновой задачи имя молчит ВСЕГДА: адреса шага в его строке нет ни в каком виде.
``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" } // не дождались — прогон кончился, хвост не играется
{ "type": "wait_until", // команда с подтверждением (v32)
"device_id": "<uuid>",
"command_payload": "{\"state\":\"ON\"}", // шаг САМ отдаёт команду, входя в него
"condition": { "op": "=", "left": { "var": "state" }, "right": "ON" },
"timeout_seconds": 60,
"on_timeout": "continue", // не дождались — цепочка идёт дальше…
"timeout_actions": [ // …сыграв вот это
{ "type": "set_group_expose", "device_id": "<uuid группы>",
"expose_name": "error", "value": "печь не включилась" } ] }
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.``command_payload`` (v32) — ожидание отдаёт команду САМО. Тот же JSON-payload, что у шага «Действие», публикуется на входе в шаг; дальше ожидание ждёт подтверждения статусом, проходящим его условие. Смысл не в экономии шага: команда и её проверка перестают быть двумя строками, которые можно переставить, разлучить паузой или потерять по одной, — а в теле фоновой задачи, где ветвления нет вовсе, это единственный способ спросить «а получилось ли».
Адрес команды — отдельное поле
command_topic, и резолвит его сервер (device_id→ командный топик +/set). Занятый у ожиданияtopic— это СТАТУСНЫЙ топик устройства, и у тегированного устройства это разные строки: дописав/setк статусному, движок публиковал бы в топик, который никто не слушает, а ожидание объявило бы, что устройство не ответил. Присланный клиентомcommand_topicне читается вовсе — второй источник адреса разъехался бы с первым на первом же переименовании устройства.Публикуется ровно один раз за вход в шаг: движок ставит отметку
command_sent_at_utc, потому что дедлайн и baseline переживают рестарт и поднявшийся под входит в шаг второй раз. У круга фоновой задачи отметки нет — круг приходит на свежей копии шаблона, и каждый круг обязан командовать заново. Проба у шага ровно одна:command_payloadвместе сsend_get— 422, потому что порядок двух проб автор не задавал.Условие из payload не выводится ни здесь, ни на сервере: ключ провода и имя сигнала связывает драйвер устройства («расчётный статус» — таблица, а не тождество), и угаданное за автора условие дало бы ожидание, которое не дождётся никогда. Предзаполняет его редактор, где автор видит и может поправить.
``timeout_actions`` (v32) — что сделать, если не дождались. Список действий, который играется вместе с исходом
on_timeout. Поэтому словарь исхода и остаётся прежним: «написать ошибку и идти дальше» —continueплюс обработчик, «написать ошибку и закончить сеанс» —cancelплюс он же; второго словаря не появляется.Порядок. У
continueи у обычного прогона обработчик играется сразу, до следующего шага. У сеанса фоновой задачи сcancel— после ветки гашения (on_stop), перед терминальной записью: гашение пишет «всё выключено», и причина, которую пишет обработчик («печь не отвечает»), сыгранная раньше, затиралась бы им через миллисекунды. Последнее слово — за причиной. Так ведут себя оба движка (облако и раннер хаба с 1.32.0); порога версии нет — на хабе меняется только порядок двух команд. Обработчик, отложенный за гашение, теряется, если прогон перестал быть нашим (фенс) или под умер между гашением и обработчиком, — тот же класс потерь, что у самой ветки.Исход в истории. Прогон, который кончило такое ожидание, панель называет «Таймаут ожидания» (
end_reason = wait_timeoutв ресурсе прогона и вlast_run), а не «Выполнено» или «Остановлено», и называет шаг, где это однозначно.Шагами цепочки эти действия не становятся — исполняются вложенно, как ветка гашения, и в WAL не пишутся. Итог виден в
detailшага (wait_timeout_actions_played: N из M) и в событии прогона; ветка отмены дописывает его к своему тексту, а не затирает.Внутри законны только
device_command,set_group_exposeиmessageв режиме уведомления. Довод один на все запреты (422 на паузу, второе ожидание, ветвление, регулятор и вопрос): обработчик отвечает на ПРОСРОЧКУ, и ждать внутри него значит удлинять то, что уже просрочено. Уведомление разрешено — в отличие от ветки гашения, где доставка задержала бы снятие нагрузки. Отказ обработчика не отказ шага: исход выбран автором, и подменять его отказом из-за неудачной записи ошибки нельзя.``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 триггера при материализации (см. раздел «Параметры»).``async: true`` (v40) — «Не задерживать цепочку». Шаг стартует (проба, дедлайн, baseline) и отпускает цепочку; его исход дожидается шаг
wait_join, первый шаг, читающий данные устройства, или конец прогона.retriggerиcancel_onу такого шага — 422. Подробности — Параллельное ожидание (async и wait_join, v40).Версия: сам тип действия аддитивен (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; в ресурсе прогона иlast_run—end_reason = 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 остался мёртвым кодом. Исключение — логика группы с веткой «Событие устройства» (v36): её device-триггеры гейту НЕ подлежат (клавишу на стене нажимают, когда группа выключена) и исполняются инлайн. Возвращать его не понадобилось: с адресным операндом «пока группа включена» пишется прямо в условии блока —stateгруппы =ONвИс порогом. С v38 — только в логике группы или её фоновой задаче: обычная автоматизация чужих приборов не читает (см. «Источники условия обычной автоматизации»), и коридор по группе теперь живёт у самой группы.Версия: обычная device-грамматика —
schema_versionстампится 1/2/3 как любая device-автоматизация.Ограничения (осознанно): fail-safe при пропаже датчика нет — если датчик замолчал, актуатор держит последнее состояние (при необходимости — отдельный watchdog-триггер). Прежний примитив
start_reading_reconciler(device_reading_reconcilers,schema_version=5) декоммиссирован.
Вторая форма того же коридора — фоновая задача группы (см. «Тело — ЦИКЛ» ниже): три блока «Пока верно» вместо двух блоков device-триггера, между порогами — блок с пустым списком действий. Различаются они ровно двумя вещами, и выбор между ними — это выбор между ними:
Темп. Device-триггерная форма решает на КАЖДОМ показании датчика; задача — раз в круг (минута). Быстрее задача не умеет, точнее device-триггер не нужен.
Fail-safe. У device-триггерной формы его нет вовсе; в задаче он выражается шагом
wait_untilс окном свежести (v19) иon_timeout: cancel— не дождались свежего показания, круг кончает сеанс, играет ветка гашения, гаснет выключатель группы. Операторexistsэту роль НЕ исполняет: он спрашивает «есть ли разобранный payload в хранилище», а прибор, переставший публиковать, последнее показание не стирает (в облаке оно уходит по часовому TTL зеркала, и это час, а не пятнадцать минут).
Заготовка редактора («Собрать контур» → «Включаем и выключаем, с зазором») собирает именно вторую форму: три блока, сторож в каждом и выключение в ветке остановки.
Группа как устройство: одна логика + фоновые задачи¶
Устройство-группа (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), направление обязательно. С v36 к нему добавляютсяdevice-триггеры источников ветки «Событие устройства» (раздел ниже) — они дополняют вход, а не заменяют его. Адрес триггера обязан совпадать сowner_device_id(иначе 422): подписавшийся на чужой вход становился ВТОРОЙ логикой прибора и глушил его порегистровое умолчание, а инвариант «одна логика на группу» такую строку не видел — он ключуется по владельцу (ADR 0047). Реакция на чужую группу выражается триггеромdeviceна её state-топик.timeотвергается (422): расписание — грамматика локации. Нужен график — заводится обычная автоматизация, которая командует группой как устройством. Прежний входgroup_expose(изменение регистра) снят вместе со своим диспетчером: он узнавал о записи ПОСЛЕ неё, то есть проверить входящий статус уже не мог.Блок принадлежит ветке. У каждого блока логики группы есть
direction, совпадающее с одним из объявленных направлений: иначе ответ на/getпрокручивал бы шаги записи. Объявленная ветка без единого блока — 422.Команда группе. Шаг
device_command, нацеленный на устройство-группу, — обычная команда: у группы есть свой адрес, шаг сохраняется как есть и уезжает на{driver}/{friendly}/set. Ключами сообщения служат ИМЕНА её регистров, и объявленные НАСТРОЙКИ прибора (ступень «спрашивать при запуске») редактор предлагает прямо в шаге — с авторской подписью, границами и умолчанием из объявления. Своего параметра автоматизации для настройки прибора заводить не нужно и не следует: объявление одно, и живёт оно у прибора (ADR 0042). Второе объявление рядом читается ловушкой — правка егоpresetне действует, пока значение лежит вargument_values(уровни значений см. ниже), а команда всё равно перезапишет настройку своим. По той же причине настройку прибора шаг не называет без нужды: значение настройки в сообщении — переопределение на этот запуск, и оно перетирает «Начальное значение», которое автор правит у прибора. Шаг, который только включает прибор ({"state": "ON"}), оставляет сеансу настройку прибора — как её и обещает брать пользовательская документация. Параметр автоматизации остаётся уместным там, где значение обязано отличаться ОТ ЗАПУСКА К ЗАПУСКУ, — тогда он единственный носитель, а не дубль настройки. Сообщение запускает её логику, а регистры пишет только шагset_group_exposeвнутри логики — и только у прибора-ВЛАДЕЛЬЦА (ADR 0047): цель шага обязана совпадать сowner_device_id. Прежнее переписывание команды в наборset_group_exposeснято. Детектор циклов диспатча при этом остался: кольцо «группа A командует B, B командует A» отвергается на записи. Значение bool-регистра принимается только в однозначной форме (ON/OFF/true/false/1/0):TOGGLEи плейсхолдер{{arg:…}}отвергаются 422 — «инвертируй» в регистре невыразимо, а подстановка идёт по строковым полям шага, а не по значению. Текст ошибки называет блок и шаг: ключ ошибки у автоматизаций один на всё тело, и без адреса непонятно, какой из шагов править.Пачка регистров одним шагом (v31). Шаг
set_group_exposeнесёт либо паруexpose_name/value, либоwrites[]— записи одного устройства, исполняемые ОДНОЙ транзакцией и объявляемые ОДНОЙ публикацией состояния:{ "type": "set_group_expose", "device_id": "<uuid группы>", "writes": [ { "expose_name": "state", "value": true }, { "expose_name": "phase", "value": "парение" } ] }
Снаружи устройству отдают команду, и её батч разбирает «Запись» логики или умолчание; внутри устройства команды себе нет, и поштучная запись объявляла бы промежуточное состояние — в эфире и в signal-store чужих триггеров, ожиданий и правил отмены. Обе формы разом → 422; пачку из одного элемента сервер схлопывает в одиночную пару (иначе штамп 31 уехал бы к воркеру, который поле
writesтеряет на round-trip). Регистр в пачке не повторяется, потолок — десять, порядок записи — порядок умений устройства (при равенстве — по имени). Значение не того типа откатывает ВСЮ пачку; неразвернувшаяся подстановка снимает только свою запись. Порога версии раннера у волны нет: шаг записи регистра на хаб не уезжает вовсе (блокерgroup_grammar). См. ADR 0040.Вид регистра у шага (v33).
expose_kind: property— записать ДАТЧИК группы (показание, которое считает само устройство); отсутствие илиcapability— умение, внутренний регистр или параметр под своим именем (явныйcapabilityна записи вырезается как умолчание). Датчик обязан быть объявлен у устройства (422), значение ложится в тип свойства, на проводе публикуется под ключомproperty:<имя>; такая автоматизация стампитсяschema_version = 33— старый воркер выбросил бы вид на round-trip и записал бы умение-тёзку или завёл новый регистр. Дубль в пачке считается по паре (вид, имя): уставку и показание-тёзку писать одной пачкой законно. Параметр проверяется по своему объявлению, умение — по словарю, внутренний — только по форме. См. ADR 0043.Параметры автоматизации группы ВЫВОДЯТСЯ из параметров её регистров и руками не набираются (ADR 0042, ADR 0043). Параметр (
origin = parameter: тип,label,default_value,constraints) объявляется в разделе «Регистры» редактора логики группы; сервер подставляет выведенный набор вargumentsна каждой записи, а присланное клиентом молча заменяет (422 был бы отказом за поле, которого редактор группы не показывает). Спрашивают при РУЧНОМ запуске только параметры сexternally_writable(POST api/devices/{device}/start-parameters→argument_valuesавтоматизаций устройства; панель отправляет их устройству вместе с включением одним сообщением); не спрашиваемый параметр получает вargument_valuesтекущее значение своего регистра. PATCH/argument-valuesпо-прежнему отвечает 404, а разовое расписание не добавляется: правка «без перезапуска» действует со следующего запуска, а живой канал у идущего сеанса — РЕГИСТР (программа задачи материализуется один раз при старте, ADR 0026). У параметра обязательно значение по умолчанию: без него выведенный параметр стал бы обязательным, а обязательный без значения гасит автоматизацию, то есть молча ломает поведение устройства.
Фоновая задача (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 → (ничего) ← коридор, сеанс держится (не держится ни одно — сеанс кончается веткой остановки и гасит свой выключатель)
Это же и есть двухпозиционное поддержание с гистерезисом: зазор между порогами и ЕСТЬ гистерезис — внутри него не подходит ни один из первых двух блоков, побеждает коридорный, и нагрузка держит своё состояние. Замолчавший датчик ловится не условием, а шагом-сторожем в каждом блоке:
wait_untilна датчике без условия («любой статус»), с окном свежести иon_timeout: cancel. Свежее показание отпускает его немедленно, и круг идёт дальше без задержки; свежего нет — круг кончает сеанс.Fail-safe «нет показания ⇒ гасим» стал ПРЯМЫМ: показания нет — ни одно условие не держится — сеанс кончается сам, и отдельный блок
not existsдля этого больше не нужен. Спрашиваетexistsименно «есть ли показание по адресу»: устройство, ПЕРЕСТАВШИЙ публиковать, последнее показание из хранилища не стирает — его ловит окно свежести у ожидания (v19), а не этот оператор. Ровно так же вёл себя и снятыйwhile. Композиция вдобавок разворачивается в правильную сторону: сеанс ПОЛЬЗУЕТСЯ нагрузкой, а не нагрузка подглядывает за сеансом через её регистр, — и рабочая группа становится переиспользуемой.Мажор 23 за волной остался: его несут исход ожидания и ветвление. Подробности — ADR 0037 (
docs/source/dev/adr/0037-task-session-while-condition-retired.md).Потолка работы больше нет (
max_runtime_secondsснят). Вместе с ним пропал единственный предохранитель от ЗАЛИПШЕГО датчика: окно свежести показания ловит ЗАМОЛЧАВШИЙ устройство, но не тот, который бодро публикует правдоподобные 61 °C. Сеанс ограничен только выключателем группы — это осознанный размен, см. ADR 0026. Оттуда же следствие: сеанс читает своё описание ОДИН раз при старте, поэтому правка задачи доезжает после выключения и включения группы.Удаление двухфазное. Снос идущей задачи — 409: сначала выключить, дождаться остановки, потом удалять, иначе гасить нагрузку будет некому.
force=1снимает без остановки, и последствие названо в ответе. Удаление ГРУППЫ делает то же самое перед каскадом: выключает её задачи, ждёт подтверждения ограниченное время и честно предупреждает, если движок не ответил.Потолок задач на локацию — 20, и считаются только НЕ спящие: у группы без выключателя задачи не запускаются никогда, и держать их в бюджете значило бы отдавать лимит тому, что не работает. Обратная сторона закрыта проверкой при правке устройства: возвращение выключателя будит задачи группы разом, и без неё потолок обходился бы парой правок.
Исполнение на хабе. Задача ПЕРЕНОСИМА, в отличие от логики группы: та живёт входом «сообщение на адрес группы», которого на локальной шине нет вовсе, а задача трогает группу ровно одним способом — выключателем. Он приезжает на хаб ЗЕРКАЛОМ состояния групп (retained
rocket/groups, см. Локальное исполнение), и fail-safe у зеркала односторонний: нет документа, нет группы или регистр выключен — гейт ЗАКРЫТ. Задачу целиком гейтит самый строгий порог носителяtask— сегодня≥ 1.26.0(цикл v29,LocalRunnerGates); раннер до 1.14.0, не зная режима сеанса, проиграл бы цепочку ровно один раз и закрыл прогон. Не переносится задача, у которой не резолвится выключатель (group_gate_unreachable), чей регулятор зеркалит выход в регистр группы (group_mirror_unreachable) — записать в облачный регистр раннер не может — или чей шаг читает регистр токеном{{expose:имя}}(expose_token_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 сказать «а теперь посмотри ещё раз» было нечем. Всё остальное — форма условия, поведение «неизвестно», порядок перебора — совпадает намеренно: автор не должен учить язык дважды.
У ветви есть имя. Блок ветви принимает необязательный alias — то же косметическое поле, что
у шага, и с тем же правилом «пусто значит очистить». Редактор печатает его вместо служебной подписи
«Ветка N» / «Иначе»; description у ветви нет (пояснение автор пишет шагу). В снимок шагов прогона
имя ветви не попадает — типизированная структура воркера его не объявляет, и это принято: поле
редакторное.
Обрыв цепочки по условию. Пустой список шагов у ветки законен (в отличие от пустого блока
автоматизации, который означал бы прогон впустую). Подошедшая ветка закрывает собой все следующие,
поэтому ветвление последним шагом с пустой первой веткой — это ровно тот обрыв по условию, который
снял ADR 0022 (docs/source/dev/adr/0022-stop-run-retired.md) вместе со шагом stop_run, но выраженный
ветвлением. Чтобы прекратить цепочку в середине, остаток переносится ВНУТРЬ ветки.
Правило продления ветвление не переигрывает. retrigger: extend_while смотрит только на
условие блока, из которого растёт прогон: ветка к моменту продления уже выбрана. Именно эта связка
убила stop_run, и здесь её нет.
Ограничения (все 422 на записи):
глубина вложения ровно одна — ветка внутри ветки отвергается. Причина не в движке, а в чтении: цепочка с двумя уровнями перестаёт читаться сверху вниз, а ради читаемости ветвление и предпочли снятому обрыву;
``control`` внутри ветки нельзя — он кладёт выход в переменную прогона, которой не появилось бы вовсе, пойди цепочка другой веткой;
``message`` в ждущем режиме внутри ветки нельзя — вопрос заполняет параметр для шагов ниже, и «сначала спросили, потом использовали» перестало бы быть свойством текста. Уведомление можно;
операторы перехода в условии ветки нельзя (
CHANGED_TO,CHANGED_FROM_TO,CROSSES_*): «предыдущее значение» принадлежит краю события, запустившего прогон, а уCROSSES_*вдобавок есть гистерезис, ключуемый идентификатором автоматизации, — второе вычисление посреди цепочки сдвинуло бы взведённый флаг под условием блока, которое его уже использовало;в теле фоновой задачи ветвления нет — развилка у неё уже есть и она верхнего уровня: блок выбирается каждый круг заново (v22).
Шаги веток и узлы их условий считаются в общих потолках тела (100 шагов, 400 узлов): потолок,
который не видит вложенного, обходился бы одним шагом «Ветвление». По той же причине в них идут
обработчики «если не дождались» (timeout_actions) и условия ожиданий и отмены
(wait_until, cancel_on), а у самого тела есть потолки входов (50, не считая разовых
запусков, и 90 строк разовых) и параметров (20) — App\Support\AutomationLimits.
Исполнение — раскрытие. Дойдя до шага, прогон вставляет шаги подошедшей ветки в свой
собственный список сразу за ветвлением и пишет расширенный список в WAL. Позиция шага остаётся
числом, а не превращается в путь, — поэтому дедуп вопросов, ключ идемпотентности шага, продление,
отмена по событию и фазы прогона на шине работают без единой правки. В журнале прогона цепочка
поэтому видна развёрнутой: шаг ветвления и за ним шаги той ветки, которая сработала. Итог
самого шага — branch_taken: ветка N, шагов M либо branch_no_match: … (с причиной, если
ветка не подошла из-за отсутствующего показания). Подробности — ADR 0030 (docs/source/dev/adr/0030-branch-inside-the-chain.md).
Параллельное ожидание (async и wait_join, v40)¶
Модель — Promise из JavaScript. «Ожидание» с async: true — промис без await: шаг
стартует, а цепочка идёт дальше. Шаг wait_join («Дождаться») — Promise.all /
Promise.any над такими ожиданиями. Решение и отвергнутые варианты — ADR 0055
(docs/source/dev/adr/0055-parallel-wait-promise.md):
[ { "type": "wait_until", "device_id": "<реле 1>", "async": true,
"command_payload": "{\"state\":\"ON\"}",
"condition": { "op": "=", "left": { "var": "state" }, "right": "ON" },
"timeout_seconds": 30 },
{ "type": "wait_until", "device_id": "<реле 2>", "async": true,
"command_payload": "{\"state\":\"ON\"}",
"condition": { "op": "=", "left": { "var": "state" }, "right": "ON" },
"timeout_seconds": 30 },
{ "type": "wait_join", "device_ids": ["<реле 1>", "<реле 2>"], "mode": "all",
"on_timeout": "cancel" },
{ "type": "message", "channel": "push", "subject": "Баня", "body": "Оба реле включены" } ]
Обе команды уходят сразу, худший случай — 30 секунд, а не 60.
Асинхронное ожидание. На входе в шаг всё как у обычного wait_until: проба (send_get или
command_payload) уходит сразу, дедлайн, baseline и отметка команды пишутся в WAL, пока
current_step_index стоит на шаге. Затем шаг остаётся running, а цепочка идёт дальше. Исход
(match / timeout) находит наблюдатель и кладёт в очередь прогона; в WAL его переносит главный
цикл сразу, даже посреди паузы или обычного ожидания, поэтому совпадение переживает гибель пода.
Наблюдатели незакрытых ожиданий поднимаются при резюме, проба не повторяется. Свой исход «Если не
дождались» (on_timeout, timeout_actions) у шага остаётся, но играет он в точке await, а не в
момент таймаута. retrigger и cancel_on — 422: продлевают и отменяют только текущий шаг.
``wait_join`` — {type: "wait_join", device_ids: [uuid…], mode: "all" | "any", on_timeout?,
timeout_actions?}. Ожидание называется своим устройством.
all—Promise.all: дождались всех — дальше. Первый таймаут — отказ сбора; ещё ждущие гасятся (skipped,detailnot_needed: the join settled without this wait).any—Promise.any: первое совпадение — дальше, остальные гасятся. Отказ — только если не дождались никого.Свой исход (
on_timeout/timeout_actions, те же словарь и набор шагов обработчика, что уwait_until) необязателен. Без него при отказе играет исход каждого отказавшего ожидания в порядке старта; первыйcancelобрывает прогон. Исход и у сбора, и у собираемого ожидания — 422.
Неявный await (сознательное отличие от JS). Ожидание — будущее данных устройства. Без
wait_join цепочка ждёт асинхронное ожидание на первом шаге, который читает его захват: путь
<device_id>.x в условии ветки (v38) или токен {{prop:<device_id>.…}} в строковых полях и
значении регистра. Иначе — перед завершением прогона, как top-level await. В точке неявного
await цепочка ждёт исхода каждого читаемого (в конце — каждого оставшегося) ожидания, как
Promise.allSettled: отказ одного не гасит соседнее, и только затем применяются собственные исходы
ожиданий в порядке старта; отказ с on_timeout: cancel обрывает прогон даже в самом конце (aborted,
end_reason: wait_timeout). Исход, которого никто не дождался, молча не теряется.
Правила записи (все 422):
пока асинхронное ожидание устройства X не закрыто (сбором, чтением или концом цепочки), любое другое
wait_untilна X запрещено — одно устройство не может значить два промиса;wait_joinсобирает только асинхронные ожидания выше в той же цепочке — тело блока или та же ветка ветвления, — каждое не больше одного раза;device_idsнепуст и без повторов,modeобязателен. Ветка видит внешние незакрытые ожидания (их можно прочитать), но собрать их не может; начатые в ветке закрываются в ней — не собранные и не прочитанные цепочка дожидается сразу за последним шагом ветки (движок помечает их полем WALawait_before, в определении оно — 422);asyncбывает только уwait_until,device_idsиmode: all|any— только уwait_join;ни
async, ниwait_joinнет в фоновой задаче (group_role = task), в ветке гашенияon_stopи в обработчикеtimeout_actions.
В логике группы параллельное ожидание разрешено: эхо «Записи» и ответ «Чтения» уходят после всех ожиданий.
История и фазы. «Дождаться» — ждущий шаг: wait_ends_at_utc = самый поздний дедлайн собираемых
ожиданий, на шине фаза «ждёт». Заявка «Завершить» работает на текущем шаге, в том числе на
«Дождаться»: цепочка идёт дальше без ожидания и без исхода отказа, ещё ждущие гасятся. На
асинхронное ожидание, которое ждёт в фоне, заявка не действует. Сдвинуть срок «Дождаться» нельзя
(422): своего срока у него нет. Прогон, прошедший цепочку и ждущий оставшиеся ожидания, отдаёт
current_step_tail: true. Исход без result значит «есть, но его ещё не дождались»; из новых
полей WAL — только await_before (точка await ожидания из ветки).
Версия. Использование стампит schema_version = 40 (проверяется первым, логика группы с
параллельным ожиданием тоже получает 40). Порядок выката hmq2 → janus. Хаб — раннер от
1.34.0 (гейт v40); правила сбора и чтения общие у облака и раннера — векторы
contracts/local-runner-vectors/promise/promise.json.
Шаг «Регулятор» (control, v20)¶
Считает выход по измерению и уставке и кладёт его в переменную прогона (output.var), доступную
следующим шагам круга. Поля «закон» нет: закон определяется тем, какие члены заданы.
Живёт только внутри задачи — вне цикла такт некому повторять (422):
{ "type": "control",
"input": { "device_id": "…", "property": "temperature", "max_age_seconds": 900 },
"setpoint": { "value": 90.0 },
"gains": { "band": 12.5, "ti_seconds": 400 },
"direction": "heat",
"output": { "var": "power", "min": 0, "max": 100, "bias": 0, "deadband": 1.0 },
"fail_safe": 0 }
Настройка закона размерная (v35). band — полоса: отклонение измерения от уставки, при
котором выход выходит на потолок целиком, в единицах показания. ti_seconds и td_seconds —
постоянные интегрирования и дифференцирования; пустое время означает «члена нет», а не «ноль
секунд». Движки выводят коэффициенты на входе в такт:
kp = (output.max − output.bias) / band ki = kp / ti_seconds kd = kp · td_seconds
Полоса обязательна и положительна, а output.bias обязан лежать в [min, max) (422): смещение
на потолке не оставляет полосе куда раскрываться, и kp вышел бы нулевым или отрицательным —
положительной обратной связью. Отсюда же следствие, которого у прежней формы не было: правка
верхнего предела выхода или смещения меняет kp, а вместе с ним и ki.
Прежняя форма (kp/ki/kd) на запись не принимается; строки, записанные до миграции волны
35, движки читают ещё один релиз, и эта ветка снимается следующей волной. Чем удобна размерная
форма: все четыре числа измеримы — полосу панель печатает с единицей показания из словаря, времена
считаются в секундах, — тогда как kp без масштаба выхода не значил ничего и его нельзя было ни
спросить у человека, ни показать с подписью.
Примечание
Числовые ориентиры для бытовых контуров — внешние, из опубликованных пресетов
multizone-thermostat: тёплый пол —
полоса порядка 3,3 °C при ti 6000 с, высокотемпературный радиатор — 1,25 °C при 890 с.
Это ориентир порядка величины, а не расчёт и не норматив: независимых сравнений законов
регулирования на бытовых тепловых контурах нам не известно.
Такт: измерение берётся из кросс-подного стора сигналов по топику (его резолвит Janus из
input.device_id) вместе с меткой времени наблюдения. Нет значения или оно старше max_age →
выход равен fail_safe и публикуется (иначе нагреватель не гаснет). Метка не изменилась →
такт не считает, не публикует и ничего не пишет — именно это позволяет крутить задачу часто, а
датчику говорить редко. Новое измерение → dt считается измерение-к-измерению с клампом
[1 с, max_age]; интеграл копится в единицах выхода, поэтому правка полосы не сдвигает накопленный вклад скачком, с
заморозкой при насыщении; производная берётся по измерению и требует d_filter_seconds при
непустом td_seconds (422); само окно max_age не короче 180 секунд — трёх кругов задачи (422): уставку крутит человек, а измерение — ступенька с шагом квантования датчика.
Выход из молчания идёт через прайминг, иначе первое измерение после часа тишины дало бы виндап через
восстановление. Состояние привязано к хешу конфигурации: правка полосы или времён сбрасывает накопитель; отпечаток считается по ВЫВЕДЕННЫМ коэффициентам, поэтому миграция формы сама по себе его не роняет.
Мёртвая зона обязательна и это не украшение: зеркало выхода (mirror_expose) пишется в
регистр группы, а запись регистра инъектируется обратно в движок как событие устройства — контур с
окном в минуту без мёртвой зоны дал бы 1440 синтетических срабатываний в сутки.
Уставка из регистра (setpoint.expose) — это ручка на панели: человек меняет «целевую
температуру», и задача подхватывает её следующим тактом, не пересобираясь.
Зеркало выхода целится в регистр НАЗВАННОГО вида (output.mirror_expose +
output.mirror_expose_kind, v34): умение (по умолчанию) либо датчик. Датчик — правильная цель
показания (ADR 0043): мощность тогда рисуется карточкой прибора как обычное показание, с единицами
и свежестью, и её же читает шаг токеном {{expose:power}}. Вид едет на проводе, потому что одно
имя законно живёт и умением, и датчиком: без него зеркало писало бы умение-тёзку, а автор смотрел бы
на неподвижный датчик.
Длительность из значения (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.34.0 (LocalEligibility::V30_GRAMMAR_MIN_RUNNER). Оба обязательны, хотя форма записи
не изменилась ни на поле: воркер без волны прочитает ту же паузу и досидит длину, снятую на входе,
ничем не показав, что правку настройки проигнорировал, — а автор увидит прежний конец сеанса и
решит, что настройка не сохранилась.
Отдельный режим 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 } ] }
С v38 адрес — только у логики группы и её фоновой задачи (owner_device_id задан). Обычная
автоматизация запускается расписанием либо сообщением одного устройства и чужих приборов не
читает — так задумано, чтобы не нагружать брокер; адрес у неё — 422. Пример выше в обычной
автоматизации больше не собирается.
Источники условия обычной автоматизации (v38)¶
У обычной автоматизации источников ровно два:
событие триггера — свойство без адреса (
{"var": "occupancy"}), как всегда;дождавшееся — путь
{"var": "<device_id>.<свойство>"}в условии ветки читает статус, на котором СОВПАЛО ожидание этого прибора выше ветвления. Дополнительно к событию, а не вместо него.
Правила:
«Выше» —
wait_untilсdevice_idна верхнем уровне цепочки до шага ветвления плюс ожидания в ветках ПРЕДЫДУЩИХ ветвлений того же блока (подошедшая ветка вклеивается в цепочку). Решает последнее исполненное ожидание прибора.Совпало — значение, иначе «неизвестно». Ожидание пишет исход (
captured_outcome:match|timeout; таймаут, «дальше» и отмена сfinish—timeout, отсутствие поля у прогона до v38 — тоже). Захват по таймауту (v7-фолбэк для{{prop:}}) условию не достаётся: ветка после таймаута не должна совпасть по устаревшему показанию.Что считается «дождались». У прибора с обратной связью (отвечает на
/get) — только новое событие; окно свежестиlast_status_max_age_secondsу такого прибора — 422. У прибора без обратной связи окно разрешает последний статус не старше N.Префикс — UUID строчными,
8-4-4-4-12; один шаблон у Janus, hmq2 и раннера хаба.Где 422: в условии блока (ожиданий до него не было), без ожидания этого прибора выше, у логики группы и задачи (там источник — адрес), вместе с
device_id.Версия: использование стампит
schema_version = 38; ниже этого штампа тот же путь значит путь в payload триггера, поэтому смысл старых строк не меняется. Раннер хаба — от 1.31.0.
Существенные свойства:
Адрес разрешает сервер. Клиент присылает
device_id; Janus резолвит топик состояния (DeviceTopic::forDevice) и кладёт его рядом вtopic_suffix, переопределяя присланное — ровно как уwait_until,cancel_onиcontrol.input. Движок читает состояние по топику (util.SignalStoreKey), поэтому голыйdevice_idбез суффикса адресным операндом не считается. Устройство обязано принадлежать локации автоматизации (иначе 422).Нет показания — «неизвестно», а не ложь. Устройство молчит, статуса ещё не приходило, свойства нет в присланном пакете — во всех трёх случаях у операнда нет ответа. «Неизвестно» не инвертируется отрицанием (иначе
НЕ (окно = открыто)на мёртвом датчике открывало бы нагрузку), проигрывает лжи вИи истине вИЛИ— то есть гасит только свою ветвь, если соседняя уже решила исход, — а на корне блока означает «блок не подошёл». Причина уезжает в журнал прогона отдельным полем: «круг ничего не сделал» — это не диагноз.``EXISTS`` — исключение. Он и ЕСТЬ вопрос «есть ли значение», поэтому для него молчание — честный ответ
false(аNOT EXISTS—true). Иначе вопрос стал бы невыразимым.Где адрес запрещён (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_phaseday|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.
Числовые сравнения требуют приводимости к числу. Упорядочивание (
<,>,<=,>=) трёхзначно, как у адресного операнда: обе стороны — конечные числа (в том числе числовая строка"31") — сравнение чисел; обе не числа — лексикографическое сравнение строк; число против не-числа ("ERR","unavailable","nan","inf", пустая строка, bool),nullили отсутствующее свойство/параметр — «неизвестно»: отрицание его не переворачивает, на корне блока это «блок не подошёл». Поэтомуtemperature > 30иНЕ (temperature >= 5)не срабатывают ни на статусе с ошибкой датчика, ни на статусе без температуры.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) |
Семантика |
|---|---|
|
Сравнение текущих значений операндов (числовое либо строковое по типу). |
|
Свойство присутствует в источнике состояния. |
|
|
|
|
|
Фронт вверх: |
|
Симметрично вниз ( |
Функции-обёртки операнда: round (digits 0..3), bool, upper, lower. round
от значения, которое не конечное число ("ERR", "nan", bool, null; числовая строка
"21.6" — число), даёт «нет значения»: в любом сравнении это «неизвестно», в changed_to /
changed_from_to — «перехода нет» (с любой стороны); отсутствующее свойство — как без функции.
Параметры (schema_version 8)¶
Параметр — именованное типизированное значение автоматизации, которым пользователь влияет на её работу, не редактируя логику. Определение задаётся в редакторе, а значения спрашиваются в момент запуска: при включении ранее остановленной автоматизации (любого вида — по расписанию, по устройству, по регистру группы) и при добавлении разового запуска.
У автоматизации ГРУППЫ определения не набираются: они выведены из объявлений её регистров («настройки устройства», ADR 0042), а значения спрашивают при ручном запуске самого устройства. Форма определения и правила валидации — те же, что ниже.
Определения — массив 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, и он же — ключ во всех картах значений. Человеческого смысла не несёт: редактор Рокет Хоум генерирует его сам (канонический lowercase-UUID) и пользователю не показывает — так ключ переживает переименование параметра и перестановку списка. Паттерн допускает две формы —^(?:[a-z][a-z0-9_]*|<uuid>)$: прежние человеческие имена остаются валидными ключами (иначе сохранённые автоматизации перестали бы приниматься), а клиент вправе прислать своё имя. Uppercase-UUID отвергается: ключ и ссылка сравниваются точным равенством, регистронезависимости в исполнении нет. Клиенту следует показывать человекуlabel, аnameиспользовать только как ключ. Подробности — ADR 0011 (docs/source/dev/adr/0011-automation-parameter-opaque-key.md).label— название параметра для человека, единственное, чем его опознают и в интерфейсе, и в сообщениях 422 (ключ непрозрачен). Обязательно и уникально в пределах автоматизации (регистр и обрамляющие пробелы разных названий не создают); нарушение — 422 наarguments.N.label. Правило серверное и общее для всех писателей (ADR 0013 (docs/source/dev/adr/0013-automation-parameter-label-required.md)). Роль названия может играть легаси-имя: у параметра с человеческим ключом отсутствующийlabelоткатывается наname; у ключа, сгенерированного редактором (UUID), фолбэка нет. Строки СОХРАНЁННОЙ автоматизации, название которых не изменилось, правилу не подчиняются — чужая безымянная автоматизация (бот, MCP) остаётся редактируемой и сохраняемой.type—number|string|boolean|enum|datetime(datetimeна wire = ISO-8601 строка).required(опц., по умолчаниюfalse) — обязательность: значение обязано быть в каждой точке ввода, иначе 422. Активной автоматизация с незаполненным обязательным параметром быть не может — запись выключает её (is_active: false), значения спрашивают при включении (значение засчитывается на любом уровне, включая собственные значения разовых расписаний).preset— предустановленное значение, необязательное; матчитtypeиconstraints. Им предзаполняется диалог ввода, и оно же — резерв исполнения. Без него у незаполненного параметра значения при исполнении нет вовсе.Относительное значение у ``datetime``. Пользователь его не настраивает: редактор ставит токен, который резолвится в дату исполнения по поясу локации (
locations.timezone, ADR 0049; пояса нет — сутки UTC) —"today"для режима «дата без времени» (constraints.format=date) →YYYY-MM-DDеё суток и"today_midnight"для режима с временем → полночь её суток моментом UTC со смещением (YYYY-MM-DDTHH:MM:SS+00:00, вид значения, набранного руками)."today_noon"(полдень её суток, тем же видом) — легаси-токен режима с временем: редактор его больше не ставит, но сохранённые автоматизации им полны, поэтому он остаётся валидным на запись. Разворачивают hmq2, редактор и страница ответа — одинаково, их держит векторcontracts/relative-presets/vectors.json. Токен допустим только какpreset: введённое значение всегда конкретное (иначе — 422).constraints(опц., по типу):number→min/max/step/unit;string→min_length/max_length/pattern;enum→options(непустой, обязателен для enum);datetime→min/max. Блок расширяемый (новые ключи аддитивны). При исполнении автоматизации hmq2 метаданные не читает — подставляет сохранённое значение.Варианты «Списка» (
options, с контракта 54.0.0) — объекты{"value", "label"?}:{"options": [{"value": "eco", "label": "Экономичный"}, {"value": "boost"}]}
valueхранится и подставляется: в команды, условия и{{arg:…}}уходит значение, не подпись. Регистр значим, значения уникальны.labelвидит человек при выборе (диалог запуска, страница ответа, подсказки условия); без подписи показываетсяvalue. Сервер обрезает пробелы подписи и снимает подпись, пустую или равную своему значению. Показываемые заголовки (подпись или значение) в списке уникальны; подпись, совпавшая с чужим значением, допустима. Плоский список строк (прежняя форма) на запись — 422 с ключом…constraints.options.N.value; сохранённые данные переведены миграцией (automations:canonicalize-choice-options).Мета-формат «продолжительность»:
{ "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). Значения при
исполнении тогда нет, и это означает:
Место |
Поведение |
|---|---|
|
подставляется пустая строка |
|
подставляется |
операнд условия |
сравнивается как пустая строка: |
длительность ( |
шаг не выполняется и пишется в лог: длительности «по умолчанию» нет, а подстановка нуля или бэкстопа означала бы неограниченное удержание нагрузки |
Прежнее правило гарантии (ссылка требовала preset либо required: true, иначе 422 на
arguments.N.preset) снято — оно навязывало заполнитель там, где пустое значение осмысленно.
Движок исполнения (hmq2) эту семантику реализует: подстановка в payload сохраняет JSON (null на
месте самостоятельного значения), упорядочивающие сравнения с отсутствующим операндом дают
«неизвестно» (блок не подходит и под отрицанием),
wait_until на таком предикате ждёт, шаг с незаполненной длительностью пропускается с записью в
лог, а планировщик подставляет значения и при пустой карте.
Подробности — ADR 0012 (docs/source/dev/adr/0012-automation-parameter-empty-value.md) (модель обязательности —
ADR 0010 (docs/source/dev/adr/0010-automation-parameter-required.md)).
Три уровня значений¶
Уровень |
Где |
Когда вводится |
|---|---|---|
Предустановленное значение |
|
в редакторе автоматизации |
Текущие значения |
|
при запуске ранее остановленной автоматизации; правятся без перезапуска |
Значения разового расписания |
|
при добавлении разового запуска и при переактивации отработавшего |
Эффективное значение при исполнении:
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 фильтрует по обоим флагам, поэтому у выключенной автоматизации взведённое расписание не сработает. Новое разовое расписание с моментом впереди создаётся взведённым и у выключенной автоматизации — оно ждёт включения (состояниеpaused); если к включению момент прошёл, запуск уходит в «Пропущено»;is_activeповторяющегося (cron/солнечного) расписания — тоже выбор пользователя («выключено в редакторе», API 54.3.0): принимается в теле записи, хранится и тумблером автоматизации не перезаписывается. Нет ключа (илиnull) — прежнее значение той же строки: поid, без ключаid— по времени (cron_expressionв каноне,timezone,time_mode,solar_offset_minutes);id: nullи новое расписание —true.next_due_at_utcу него взводится только при обоих флагах;переактивация отработавшего разового расписания требует нового времени: прошедшее целевое время планировщик уже не выберет.
Правило одно для всех типов, включая параметр-длительность
(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-дамп вместо топика или адресата всегда ошибка автора.{{expose:name}}— значение регистра прибора-владельца, прочитанное ЖИВЬЁМ в момент шага (v34). Так выход регулятора доезжает до плавного актуатора: мощность живёт в регистре (ADR 0035), и шаг «Действие» отдаёт её прибору —{"brightness": {{expose:power}}}— вместо связки из зеркала и отдельной автоматизации-ретранслятора. Датчик адресуется префиксом вида:{{expose:property:power}}(одно имя законно живёт и умением, и датчиком).Допустим только в автоматизации прибора-владельца — в его логике и фоновых задачах — и только в полях
payloadиvalue: у обычной автоматизации регистров нет вовсе (422), а регистр — это значение, а не адрес, поэтому вtopic/to/subject/bodyон отвергается (422). Ссылка на необъявленный регистр — тоже 422 на записи, а не молчаливый пропуск в проде.Автоматизация с токеном непереносима на хаб (блокер
expose_token_unreachable): регистр живёт в облаке, а раннер подставляет только{{prop:*}}и запечённый сборщиком{{arg:*}}— незнакомый токен он оставил бы в сообщении как есть и опубликовал прибору буквальным текстом. Блокер, а не порог версии раннера: читать облачный регистр хабу нечем в принципе.Значение подставляется своим типом:
30доедет числом,"eco"строкой,trueбулевым. Живость означает «последнее записанное», а не «актуальное»: у регистра нет свежести, и остановленный регулятор оставит там последнее число. Регистра нет вовсе — шаг пропускается целиком, ровно как пропускается вырожденный сегмент ШИМ: команда с неподставленным токеном была бы опубликована прибору мусором, а круг записал бы успех. Пропуск виден событием прогонаtask_step_skipped_no_register.
Плейсхолдер заменяется строковым рендером значения (число → 50, строка → как есть, bool →
true, datetime → ISO); JSON-кавычки в payload ставит автор ({"mode":"{{arg:mode}}"}).
Синтаксис {{…}} зарезервирован.
Display. Параметр — первоклассный токен и в поверхностном синтаксисе: в условии рендерится
чипом (leb-arg, глиф @Название) рядом со свойством/функцией/константой. Превью действия
(DescribeTopicMessage) заменяет плейсхолдер на @<ключ>, а не на сырой {{…}}, —
названий сервер не знает (в запросе их нет), поэтому подпись подставляет клиент, у которого есть
определения параметров. Клиент, который этого не делает (бот, MCP), увидит служебный ключ.
Где что доступно (с v22). Условие блока вычисляется на device- и group_message-триггерах,
а у фоновой задачи — в начале каждого круга. У расписания условия блока нет: тело — ровно один
блок с condition: null (иначе 422 по condition_blocks / condition_blocks.0.condition),
развилка — шаг branch. Свойство БЕЗ адреса означает «то, что пришло со статусом», поэтому там,
где статуса нет (расписание, задача), оно отвергается на записи: сравнение с пустотой тихо ложно, а
под != тихо истинно. С v38 у обычной автоматизации по расписанию в условии ветки читается время,
параметр или дождавшееся (<device_id>.x); адресным операндом состояние читают только логика
группы и задача (см. «Свойство другого устройства»).
{{prop:…}} по-прежнему читается из payload сработавшего триггера, снятого в момент материализации
run: для device/group_message он на руках, а на расписании его нет — голый плейсхолдер
свойства там отвергается на записи, а не подставляется пустотой.
Регистры устройства: происхождение и шкала доступа¶
Регистр — именованное значение состояния устройства. Адресуется парой (вид, имя): умение и
датчик с одним именем — два регистра (термостат: уставка range:temperature и показание
float:temperature). Регистр объявляется заранее (ADR 0045): запись в незнакомое имя —
422 на сохранении, а не новый регистр. Входящее сообщение регистров не заводит — иначе посторонний
клиент лепил бы устройству поля своим /set.
У регистра три происхождения (origin, ADR 0043 → 0045):
Умение и датчик (
declared) объявлены через справочник в редакторе устройства: типизированы, валидируются по метаданным функции, участвуют в панели, Алисе и матрице совместимости. Датчик — показание, которое считает само устройство: его пишет только шаг логики (set_group_exposeсexpose_kind: property, штамп 33), снаружи он не пишется никогда (422), значение ложится в тип свойства (float— число,text/event— строка). На проводе датчик группы лежит всегда под ключомproperty:<имя>— есть у него тёзка среди умений или нет; правило одно для статуса, signal-store условий,{{prop:…}}и зеркала хаба. В Алису попадают только умения и датчики.Внутренний регистр (
virtual) объявляет автор в разделе «Регистры» редактора логики группы: имя, тип (словарным именемfloat/toggle/text/mode) и значение. Это состояние устройства для его задач и логики — фаза сеанса, счётчик кругов; пишет его только само устройство шагом «Регистр группы», то есть автоматизация, чейowner_device_idи есть этот прибор (ADR 0047). Снаружи внутренний регистр не пишется вовсе — ни шагом, ни командой, ни REST: его ступень доступа нижеsettable. Прежнее «внешняя автоматизация делает то же тем же шагом» (ADR 0035, 0040 §6) снято — это и была протечка.Параметр (
parameter) объявляет автор там же — в точности как параметр автоматизации: тип (number/boolean/string/enum), подпись, значение по умолчанию (обязательно), ограничения (варианты перечня — те же{value, label?}; голосу Алисы сопоставляетсяvalue). Это настройка, которую пишут снаружи: панель, Алиса, входящее сообщение; проверяются они поconstraints,toggleумеетTOGGLE. Ключ параметра — имя регистра (латиница с буквы, неstate) — отклонение от ADR 0011 (ключ-UUID), названное в ADR 0043.
Доступ снаружи — одна шкала (access, ADR 0045), из которой сервер складывает три колонки
published / externally_writable / ask_on_start:
Ступень |
В UI |
Кому доступна |
|---|---|---|
|
Только внутри устройства |
внутренний регистр |
|
Показывать в статусе |
внутренний регистр; датчик — фиксированно |
|
Принимает команды |
параметр; умение — фиксированно |
|
Спрашивать при запуске |
параметр |
Шкала монотонна: «пишется ⇒ виден» и «спрашивается ⇒ пишется» — её форма, а не инварианты под
422. У умения и датчика ступень задана видом и не правится: «умение без команд» — это датчик,
«умение не виден» — это внутренний регистр. Параметр ступени settable — уставка, которую панель
правит когда угодно; asked — вопрос перед включением (ADR 0042).
Ступень задаёт происхождение (ADR 0052): в редакторе логики у объявленного автором регистра
один селектор из четырёх ступеней, и internal | shown делают его внутренним, settable |
asked — параметром. Правка ступени через границу — переобъявление: PUT devices/{id}/logic
с op: update выводит целевое origin из присланной access (ключ origin в правке не
нужен), требует type; при переходе в virtual обнуляет default_value и voice и
сохраняет живое value, при переходе в parameter умолчанием становится текущее значение,
если иного не прислали. Понижение asked при живой ссылке {{arg:}} — тот же 422, что и
прежде.
Параметры автоматизаций группы выводятся только из спрашиваемых (asked): {{arg:имя}} —
снимок того, что спросили при старте. Уставку settable и внутренний регистр шаги читают живьём:
операндом условия, длиной паузы из регистра (duration_from.expose), уставкой регулятора
(control.setpoint.expose). Понижение asked → settable при живой ссылке {{arg:}} — 422 с
именами автоматизаций.
Граница публикации действует на ПРОВОД, а не на движок. Собственная логика и задачи
видят ВСЕ регистры; фильтр применяется к тому, что уходит на шину, в панель и Алисе — правило одно
на все три выхода. Иначе условие «пока фаза = парение» читало бы signal-store, куда скрытый
регистр не попал, получало бы «неизвестно» — а у фоновых задач «не знаю» это ЛОЖЬ (fail-safe), — и
круг не запускался бы ни разу, без единой строки об ошибке. Входящее /set кладёт в
состояние условий только ключи ступени settable и выше: подсунуть условиям фальшивое
показание датчика или скрытый регистр нельзя.
Цели шагов обязаны существовать среди регистров устройства-владельца (422 с адресом шага):
set_group_expose (обе формы), duration_from.expose, control.setpoint.expose,
control.output.mirror_expose. В hmq2 запись в незнакомый регистр —
неретраебельная ошибка шага, пачка откатывается целиком (расширение ADR 0040 §7).
Реконсиляция умений внутренних регистров и параметров не касается: сиротой не бывает то, у
чего никогда не было умения. Регистр, который потом объявили умением или датчиком, повышается в
него — с типом из справочника, ступенью по виду и сохранённым значением; параметр, на который
ссылаются автоматизации, повысить нельзя (422): из arguments он пропал бы, а {{arg:…}}
осталась бы висячей.
«Запись» и «Чтение» логики группы (v39)¶
Логика группы отвечает на обращения к группе двумя ветками — direction: set («Запись») и
direction: get («Чтение») — плюс ветка «Событие устройства» (ниже). Редактор показывает их
вкладками. Подробности и отвергнутые варианты — ADR 0054
(docs/source/dev/adr/0054-group-logic-write-read-events.md).
Штамп. schema_version = 39 получает любая автоматизация с триггером group_message, то
есть каждая логика группы (AutomationWriter::schemaVersionFor). Выкат hmq2 → janus:
воркер с knownSchemaVersion < 39 такую логику пропускает. Логика группы на хаб не уезжает
(блокер group_grammar), порога раннера нет.
Проверка значения на входе¶
Проверка одна на все двери — шина, Алиса, REST панели, шаг «Действие» на группу (hmq2). Ключ регистра с негодным значением вырезается, остальные применяются:
умение — вне
rangeили перечняmodeсловаря;параметр — вне своих
constraints;внутренний регистр — вне своих
constraints, если они заданы;значение не приводится к типу регистра.
Вырезанное пишется в журнал прогона (if_evaluated.rejected: ключ → out_of_range |
type_mismatch) и в лог. Вырезано всё — группа отвечает состоянием без прогона. Значение не
приводится: условия видят то, что прислали ("ON", а не true). TOGGLE и null
проходят.
Алиса получает INVALID_VALUE по вырезанным умениям и «сделано» по остальным. Вся команда
отвергается, только если не осталось ни одного годного ключа-регистра и нет ключей, которые
толкует логика.
Шаг set_group_expose с значением вне ограничений регистра пропускается (Warn в журнале): та же
граница, что у входа. До v39 {"brightness": 999} через логику ложился в регистр как есть.
«Запись» (direction: set)¶
Блоки «Если / Тогда» над всей командой, работает первый подошедший. Регистр выбирается
условием (exists по ключу команды). Шаги — как у автоматизации по событию устройства:
device_command, set_group_expose, pause, wait_until (в том числе с send_get и
command_payload), branch, message.
Не подошёл ни один блок — умолчание: всё проверенное пишется сразу одной транзакцией и уходит эхом.
Подошёл блок — прогон, эхо после шагов. При
completed(в том числе ожидание сon_timeout: continue) одной транзакцией дописываются ключи команды, которые шаги не записали сами; одна публикация, одна побудка задач. Регистр, записанный шагомset_group_exposeэтой группы, эхо не перетирает: шаги той же группы откладывают публикацию и побудку до финала.Прогон не доработал (
wait_untilсon_timeout: cancel, сбой шага) — регистры не дописываются, группа отвечает тем, что есть.«Не писать регистр X» = шаг
set_group_exposeX ={{expose:X}}. Токен{{expose:…}}с v39 подставляется и в прогонах логики группы.TOGGLEразворачивается в конкретное значение от текущего регистра при старте прогона, а не при приёме: команда могла простоять в очереди. Условия и{{prop:state}}видят"ON"/"OFF". У умолчания разворота нет — инверсия атомарна в SQL.
Новая команда отменяет идущий прогон логики этого пода и стартует следующей: последнее слово
за пользователем. Прогон закрывается aborted с last_error = superseded; ресурс прогона и
last_run отдают end_reason: superseded, история пишет «Прервано: заменён новой командой».
Ветка гашения не играется. Прогон другого пода (окно выката) не отменяется — команда ждёт в
очереди.
422 на записи:
condition_blocks[].expose_name— «регистр выбирается условием» (обработчики регистров v27 сняты);шаги
apply_defaultиreply— где угодно, в том числе внутриbranch(AutomationValidation::validateGroupMessageBlocks;AutomationWriter::assertNoRetiredGroupGrammarотвергает их и мимо правил запроса);retriggerу шагов «Записи» и «Чтения»: продлевать нечего, новая команда отменяет прогон.
«Чтение» (direction: get)¶
Ровно один блок и без условия (второй блок, условие — 422): вопрос /get не несёт
значений, выбирать блок не из чего. Шаги готовят ответ — например, опрашивают реальное
устройство (wait_until с send_get); группа отвечает состоянием сама после последнего шага
при любом исходе. Прогон не завёлся (логика занята) — ответ сразу. Query Алисы «Чтение» не
запускает: Алиса читает регистры из БД.
/get — вопрос, а не запись: hmq2 не считает по нему порядок регистров, не кладёт его в снимок
условий и не зовёт правила отмены и ожидания. Панель спрашивает группу пустым телом
(utils/statusRequestPayload.js), как локальный раннер и драйверы hmq2.
Шаги должны быть быстрыми¶
Эхо «Записи» и ответ «Чтения» уходят после шагов, а панель ждёт ответа около 3 с. Долгое поведение — в фоновую задачу группы. Редактор это подсказывает, сервер не запрещает: потолок подтверждения в 10 с (v36) снят вместе с обработчиками.
Миграция данных v39¶
2026_10_17_000000_group_logic_v39 (App\Support\GroupLogicV39Upgrade), обратной нет:
обработчики регистров → один блок «Записи» без условия; в нём по шагу
branchна регистр в порядке умений (тот же SQL, что у hmq2messageRegisterOrder). Ветки — «есть ключ X и условие обработчика», последняя — «иначе: X ={{expose:X}}»: так сохраняется «обработчик не подошёл — регистр не пишется»;replyиapply_defaultудаляются;несколько блоков «Чтения» (или один с условием) → один без условия с
branchпо ним;retriggerснимается;логики с операторами перехода в условиях обработчиков не переводятся (в
branchони запрещены) —automation.group_logic_v39.skippedв логе, автор правит руками.
Сдвиг смысла: условия веток считаются по ходу прогона, после шагов предыдущих веток, а не все разом до него.
До v39: обработчики регистров (v27)¶
С v27 по v38 блок «Записи» мог назвать регистр (expose_name): обработчики складывались в
цепочку по порядку умений, регистр без обработчика писало встроенное умолчание, шаг
apply_default («Применить умолчание») звал его изнутри обработчика, а шаг reply
(«Ответить») переносил ответ «Чтения» в логику и был обязан стоять в каждом её блоке. Шаги
обработчика были сужены до команд и подтверждения ≤ 10 с (v36). В v39 всё это снято (ADR 0054);
сохранённые логики переведены миграцией выше.
Ветка «Событие устройства» (v36)¶
Логика группы слышит статус физического устройства: люстра-группа узнаёт, что клавишу нажали на стене, даже когда сама выключена. Подробности и отвергнутые обходы — ADR 0050.
{ "triggers": [ { "kind": "group_message", "device_id": "<группа>", "direction": "set" },
{ "kind": "device", "device_id": "<реле>" } ],
"condition_blocks": [
/* блоки «Записи» … */
{ "direction": "event", "source_device_id": "<реле>",
"condition": { "op": "and", "children": [
{ "op": "=", "left": { "var": "state_left", "fn": "upper" }, "right": "OFF" },
{ "op": "=", "left": { "var": "state_right", "fn": "upper" }, "right": "ON" } ] },
"then_actions": [ { "type": "set_group_expose", "device_id": "<группа>",
"writes": [ { "expose_name": "state", "value": true },
{ "expose_name": "program", "value": "one" } ] } ] } ] }
Источник.
source_device_id(устройство локации, НЕ группа) илиsource_topicдля внешнего источника.source_topicпри заданном устройстве стампует сервер (DeviceTopic::forDevice) — тем же правилом, чтоtopic_suffixтриггера. Каждый источник объявленdevice-триггером логики (до восьми), каждый триггер — хотя бы одним блоком.Исполнение инлайн, без прогона и без лока «один прогон на автоматизацию»: первый подошедший блок источника пишется прямо в цикле триггеров. Порядок прихода сохраняется, строк
automation_runsнет, в журнале hmq2 —logic_event_applied. Неизменная запись не публикует ничего, поэтому подтверждающее эхо петли не заводит. Гейт «владелец включён» ветку не касается.Грамматика (422 на прочее):
set_group_exposeсвоего прибора иdevice_commandНЕ-группе; без операторов перехода (прошлый статус у события не хранится) и без подстановок{{…}}(материализации нет). Блок не командует своим источником — команда вернулась бы эхом.Пока прогон «Записи» ждёт этот источник (текущий шаг —
wait_untilна нём), эхо этого источника регистры не пишет: реле на две клавиши переключает каналы по одному, и промежуточное «обе выключены» гасило бы группу вместе с её задачами.Версия:
schema_version = 36, выкат hmq2 → janus. Старый воркер принял бы блоки ветки за «подхожу любому обращению» и командовал бы реле в ответ на его же эхо.
Выдержка записи (v37)¶
Ветка событий отражает статус источника мгновенно — для зеркала это правильно, а для ПАМЯТИ нет:
реле на две клавиши выключается со стены через «горит одна», и мгновенная запись запомнила бы
режим, которого человек не выбирал. Шаг set_group_expose прямо в блоке события может нести
stable_seconds (1–3600): он пишет, только если статус источника держит ТОТ ЖЕ блок указанное
время. Остальные шаги блока исполняются сразу.
{ "direction": "event", "source_device_id": "<реле>",
"condition": { /* OFF/ON */ },
"then_actions": [
{ "type": "set_group_expose", "device_id": "<группа>",
"writes": [ { "expose_name": "state", "value": true },
{ "expose_name": "program", "value": "one" } ] },
{ "type": "set_group_expose", "device_id": "<группа>",
"expose_name": "last_program", "value": "one", "stable_seconds": 5 } ] }
Значимый отчёт — тот, где есть все переменные условий блоков этого источника. Только он взводит, отменяет и потом подтверждает выдержку; частичный (одна клавиша, только
linkquality) её не трогает.Отмена до срока: значимый отчёт с другим блоком или без совпавшего блока; эхо, отложенное к прогону «Записи»;
/set, принятый «Записью» логики (стартовал прогон или встал в очередь; до v39 — и продлил прогон)./getи регистры, обслуженные умолчанием, выдержку не трогают. Повтор того же блока срок не продлевает.Решает момент срабатывания. Запись идёт, только если ожидание то же (токен), перечитанная логика на последнем значимом статусе выбирает тот же неизменённый блок, и у логики нет живого прогона записи и команды в очереди. Мешает прогон записи — повтор через секунду, до трёх раз, затем ожидание снимается.
Где живёт: в памяти движка (реплика одна). Рестарт ожидание теряет — следующий значимый отчёт взведёт его снова. В журнале hmq2 —
logic_hold_armed|cancelled|fired|dropped.Грамматика (422 на прочее): только
set_group_exposeпрямо в блокеdirection: event.Версия:
schema_version = 37, выкат hmq2 → janus. Старый воркер записал бы сразу. Откат hmq2 ниже 37 — только после пересохранения логики без выдержки.
Команда группе доходит до группы (v26)¶
Шаг «Действие», нацеленный на устройство-ГРУППУ, попадает в её вход — тот же, куда приходит сообщение с шины: запускает логику направления «Запись» (или встроенный дефолт, если логики нет) и будит её фоновые задачи. Группа объявлена first-class устройством, и «включить группу» обязано работать тем же шагом, что и «включить лампу».
{ "type": "device_command", "device_id": "<uuid группы>", "payload": "{\"state\":\"ON\"}" }
Что было сломано. Публикация из движка — СЕРВЕРНАЯ, а хук групповой плоскости
(plugins/bridge/group_plane.go) зовётся только на КЛИЕНТСКИХ пакетах. Команда шага уезжала
подписчикам топика и не запускала ничего: ни логику, ни задачи. При этом шаг завершался успехом, и
в журнале прогона стояло step_succeeded — цепочка шла дальше, а баня оставалась холодной.
Отсюда все обходные пути: включать группу приходилось записью её регистра (set_group_expose)
мимо её собственных правил, а длинные цепочки — держать снаружи устройства. Обходной путь закрыт
окончательно: с ADR 0047 шаг записи регистра законен только у автоматизации самого прибора, и
единственная дверь снаружи — команда.
Как чинится. Движок подаёт команду ин-процессной инъекцией в тот же канал, которым уже
пользуется Алиса (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 (docs/source/dev/adr/0034-group-command-from-a-step-reaches-the-group.md).
Ветка гашения (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 (docs/source/dev/adr/0032-stop-branch-for-any-automation.md) (отозван).
Хранение переезд пережило: ветка лежит в 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 сохраняется для
непрерывности ссылок). Актуальная версия схемы — поле version самого файла (на 18.10.2026 —
56.1.0); изменения между 51.0.1 и 56.1.0 — в openapi/MIGRATION-automations.md и в журнале
версий info.description спека openapi/rocket-home-api.yaml. Последняя запись здесь —
56.1.0: параллельное ожидание v40 (ADR 0055). wait_until.async («Не задерживать
цепочку») и шаг wait_join («Дождаться», mode: all | any). MINOR для API: поле и тип шага
новые, прежние тела валидны. Стамп schema_version = 40, порядок выката hmq2 → janus, раннер
хаба от 1.34.0. См. Параллельное ожидание (async и wait_join, v40).
До неё — 56.0.0: логика группы v39 (ADR 0054). «Запись» — блоки над всей командой, без обработчиков
регистров; эхо после шагов; новая команда отменяет идущий прогон (end_reason: superseded);
«Чтение» — один блок без условия, ответ после последнего шага; проверка значения на входе группы
одна на все двери. MAJOR: ранее валидные тела получают 422 — condition_blocks[].expose_name,
шаги apply_default и reply, retrigger у шагов «Записи»/«Чтения», второй блок или
условие «Чтения». Стамп schema_version = 39 у любой автоматизации с group_message, порядок
выката hmq2 → janus, порога раннера нет. Сохранённые логики переводит миграция
2026_10_17_000000_group_logic_v39. См. «Запись» и «Чтение» логики группы (v39).
До неё (в этом разделе) — 51.0.1: только данные — у z2m-устройств
появились ключи провода (mqtt_keys: ["state"], transport яркости 0…254, ключи свойств
z2m), схема не менялась.
До неё — 51.0.0: выдержка записи в ветке «Событие
устройства» (then_actions[].stable_seconds у set_group_expose, поправка к ADR 0050). MAJOR:
поле, которое раньше молча вырезалось, вне своего места теперь даёт 422; стамп
schema_version = 37, порядок выката hmq2 → janus.
До неё — 50.0.0: логика группы слышит устройства —
ветка «Событие устройства» (direction: event, source_device_id/source_topic) и
device-триггеры у логики, подтверждение команды в обработчике записи (ADR 0050). MAJOR: новое
значение перечисления direction; стамп schema_version = 36, порядок выката hmq2 → janus,
порога раннера хаба нет — логика группы на хаб не уезжает.
До неё — 49.2.0 (минор 49.2 — «сегодня» у относительной
предустановки считается по поясу локации, ADR 0049; минор 49.1 — описание timezone, ADR 0048;
правил не меняют). В 49.0.0 регулятор настраивается РАЗМЕРНЫМИ
величинами. Вместо gains.kp/ki/kd — band (полоса в единицах показания),
ti_seconds и td_seconds; движки выводят коэффициенты на входе в такт
(kp = (max − bias) / band, ki = kp / ti, kd = kp · td), поэтому арифметика такта не
изменилась. Новым правилом стало min ≤ bias < max (иначе полосе некуда раскрываться), а прежнее
«все коэффициенты нулевые — 422» снято: пропорциональная часть теперь есть всегда. MAJOR: прежняя
форма на запись не принимается, и чисто интегральный закон с отрицательным kp перестали быть
выразимы — намеренно. Стамп — schema_version = 35, порог раннера хаба —
V35_GRAMMAR_MIN_RUNNER = 1.30.0, порядок выката hmq2 → janus. Строки, записанные раньше,
переписывает миграция 2026_09_21_000001 (она же поднимает им штамп); прежнюю форму оба движка
читают ЕЩЁ ОДИН релиз — ради окна между двумя выкатами, — и эта ветка снимается следующей волной.
До неё — 48.2.0: сводка последнего прогона
(last_run) получила имя ТЕКУЩЕГО шага (step_alias) — по нему строка списка говорит не только
«выполняется», но и что именно идёт. MINOR: новое поле чтения с безопасным дефолтом null. Ресурс
прогона (GET …/runs) в спеках не описан вовсе, поэтому расширение имён шага в истории и у хабовых
прогонов версии не двигает.
До неё — 48.1.0: у шага появилось имя. Любой шаг любого
типа принимает необязательные alias и description, блок ветви — alias, автоматизация,
регистр группы и параметр прибора — description. Пустое значение УДАЛЯЕТ ключ, плейсхолдеры внутри
этих полей остаются литеральным текстом, а на хаб они не уезжают вовсе (сборщик бандла их вырезает, и
бюджет бандла их не считает). MINOR: новое опциональное поле с безопасным дефолтом, ранее валидные
тела валидны и дальше. Стамп грамматики не поднимается — воркер косметику не исполняет и не
читает; порядок выката тот же, что у любого нового поля: сначала hmq2 (вырезание в бандле, чтобы
версия бандла не бампалась дважды), затем janus.
До неё — 48.0.0: внутренности прибора пишет и слушает
только он сам. Шаг «Регистр группы» законен лишь у автоматизации, чей owner_device_id и есть
цель шага (логика прибора или его фоновая задача), а триггер group_message — лишь на СВОЙ вход.
Прежде и то и другое проверялось по ЛОКАЦИИ, поэтому любая автоматизация писала внутренний регистр и
датчик любой группы мимо её входа, обработчиков, умолчания и шкалы доступа, а сторонний слушатель
входа становился второй логикой прибора и глушил его порегистровое умолчание. Той же правкой
перестали молча сохраняться «Ответить» и «Применить умолчание» без владельца — они ложились в строку
без адреса и не делали ничего. Сужение: ранее валидное тело теперь 422, стамп грамматики не
поднимается (воркер ничего нового не исполняет — тот же приём, что при снятии stop_run), порога
версии раннера нет. Снятая возможность названа вслух: «форсировать» внутренний регистр или датчик
снаружи больше нечем — ни шагом, ни командой, ни REST, потому что их ступень доступа ниже
settable; настройка, которую ставят снаружи, объявляется параметром. См.
ADR 0047 (docs/source/dev/adr/0047-appliance-internals-belong-to-the-appliance.md).
До неё — 47.0.0: мощность стала читаемой — плейсхолдер
{{expose:имя}} подставляет значение регистра устройства-владельца в сообщение шага, а зеркало
выхода регулятора умеет целиться в ДАТЧИК (output.mirror_expose_kind). Вместе они снимают связку
из двух автоматизаций, которой плавная подача выражалась раньше: «Регулятор → Действие
{"brightness": {{expose:power}}}» — одна цепочка вместо зеркала плюс отдельного правила на
состояние устройства. Стамп грамматики — 34 (носителя два, номер один: старый воркер ломается на
каждом одинаково тихо — токен уехал бы прибору буквальным текстом, вид зеркала выронился бы на
round-trip). Порога версии раннера нет — вместо него блокеры переносимости
(expose_token_unreachable у токена, group_mirror_unreachable у зеркала).
Примечание
До 21.09.2026 здесь стояло «порога нет: устройство-группа на хаб не уезжает вовсе». Для ЛОГИКИ
прибора это верно, но токен законен и в ФОНОВОЙ ЗАДАЧЕ, а она — единственная автоматизация
прибора-владельца, которую перенос пропускает. Задача с токеном уезжала на хаб, и со штампом
ровно 34 раннер её fail-safe-скипал (строка в журнале есть), а со штампом 35 —
грамматика v35 проверяется раньше v34, поэтому контур «полоса регулятора + токен в команде»
едет именно с ним — исполнял целиком и публиковал прибору текст {{expose:power}} вместо
мощности, молча. Закрыто блокером expose_token_unreachable.
До неё — 46.0.0: шаг «Регистр группы» умеет записать
ДАТЧИК группы (expose_kind: property) — показание, которое устройство считает само. Отсутствие
вида или capability означает умение, внутренний регистр или параметр; датчик обязан быть
объявлен у устройства, а на проводе лежит под ключом property:<имя>. Стамп грамматики — 33,
порога версии раннера нет: шаг записи регистра на хаб не уезжает вовсе (ADR 0043). Той же волной
регистры и поведение группы стали правиться одним запросом, грамматики это не коснулось (ADR 0044).
До неё — 45.0.0: шаг «Ожидание» умеет отдать команду САМ
(command_payload + серверный command_topic) и ответить на «не дождались» списком действий
(timeout_actions). До этой волны спросить «а получилось ли» можно было только снаружи цепочки —
«Действие» → «Ожидание» → «Ветвление», — а в теле фоновой задачи ветвления нет вовсе, то есть сеанс
не мог заметить, что печь не включилась. Обработчик играется вместе с исходом on_timeout (у
сеанса задачи с cancel — после ветки гашения, см. выше), поэтому словарь исхода прежний: «написать ошибку и идти дальше» — continue плюс обработчик, «написать
ошибку и закончить сеанс» — cancel плюс он же. Стамп грамматики — 32, порог версии
раннера — 1.28.0 (ADR 0041).
До неё — 44.0.0: шаг «Регистр группы» снова принимает ПАЧКУ
(set_group_expose.writes[]) — несколько регистров одного устройства одной транзакцией и с одной
публикацией состояния. Снаружи устройству отдают команду, и её батч разбирает обработчик записи;
внутри устройства команды себе нет, поэтому поштучная запись объявляла промежуточную сцену. Стамп
грамматики — 31, порога версии раннера нет: шаг записи регистра на хаб не уезжает вовсе
(ADR 0040). Номер 25, под которым та же конструкция выкатывалась в августе, остаётся отозванным —
раннер 1.20.0 с прежней семантикой уже на хабах.
До неё — 43.0.0: длина спящей паузы, взятая из
РЕГИСТРА устройства (duration_from.expose), стала живой — пока шаг спит, он перечитывает настройку
и переставляет свой срок, в любую сторону. Форма записи не изменилась ни на поле; изменилась
семантика, поэтому стамп грамматики — 30, порог версии раннера — 1.34.0 (ADR 0039, LocalEligibility::V30_GRAMMAR_MIN_RUNNER). Порог
переезжал не раз и по одной причине: он БРОНЬ под ещё не написанную хабовую половину волны 30,
а бронируемые числа выходили с другими волнами — 1.28.0 с волной 32, 1.30.0 с волной 35, затем
1.31.0, 1.32.0 и 1.33.0 с последующими выпусками раннера. Оставь мы
прежнее число, первый же хаб на нём объявил бы себя знающим живую длину и молча перестал бы
замечать правку настройки. Броня обязана указывать на невыпущенную версию.
До неё — 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-bundle — 1.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 — «исполняется вне облака» (Локальное исполнение). Грамматику 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, а у пользователя закэширован старый бандл) билдер сейчас молча отбрасывает незнакомый узел на загрузке (astToTree→createEmptyRoot), из-за чего пересохранение может ослабить гейт. Смягчение — 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.