Язык автоматизаций Rocket-home (спецификация)¶
Автоматизации Rocket-home — небольшой скриптовый язык: правило читается как «ON триггер: IF условие THEN действия». У языка две формы представления:
Каноническая форма (AST) — структурированный JSON, который хранится и исполняется (аналог «байткода»). Единственный источник истины — JSON Schema
openapi/schemas/automation-contract.v1.schema.json.Поверхностный синтаксис (display) — человекочитаемая запись условия (call/инфикс-нотация), которую рисует редактор/превью. Display выводится из AST детерминированным форматтером и никогда не парсится обратно из строки.
Термины — см. глоссарий.
Модель исполнения¶
Автоматизацию запускает триггер (расписание kind:"time" или событие устройства
kind:"device"). После запуска воркер перебирает condition_blocks по порядку и выполняет
then_actions первого блока, чьё condition истинно (condition: null = всегда истина).
Ни один блок не подошёл — ничего не выполняется.
Условие вычисляется относительно входного payload события. Для операторов переходов
(changed_to, changed_from_to, crosses_up, crosses_down) сравниваются предыдущее
(prev/триггерное) и текущее значения одного свойства.
Опережающее включение (готовность к времени)¶
Для процессов с временем выхода в рабочий режим (баня греется ~2 ч, тёплый пол, прогрев авто) разовый
триггер поддерживает опережающий запуск: пользователь задаёт целевое время one_time_at = T
(к которому должно быть готово) и длительность подготовки lead_seconds; then-действия — обычные
(включить нагреватель и т.п.). Janus считает фактический запуск:
next_due_at_utc = clamp(now, T − lead_seconds)
Семантика: запланировано более чем за lead → подготовка стартует ровно в T − lead;
менее чем за lead → немедленно (клампится в текущую минуту); T уже прошло →
не срабатывает (next_due = null). Гранулярность минутная (как у всех разовых триггеров).
Область: только schedule_type: "one_time" + time_mode: "fixed". hmq2 не меняется — он
исполняет по next_due_at_utc (one_time_at в due-логике не участвует; хранится как целевое время
для отображения). lead_seconds = null — обычный разовый триггер. Повторяющиеся (cron) и solar —
вне текущей реализации.
Краевые случаи: (1) toggle off→on внутри окна (T−lead, T) пересчитывает next_due = now →
подготовка стартует повторно немедленно (осознанно: «включили — подготовь к цели»). (2) Если воркер
недоступен во время старта подготовки дольше grace-окна (5 мин), запуск пропускается — общее поведение
любого разового триггера. (3) delete_after_run в v2 не действует: разовый триггер деактивируется,
а не удаляется.
Действия (then_actions)¶
Четыре типа шагов: device_command (публикация в MQTT), pause (задержка на
duration_minutes), message (уведомление по каналу) и wait_until (ожидание состояния).
Шаги блока выполняются линейно по порядку.
``wait_until`` (v2.1) — блокирует последовательный run, пока condition по свойству целевого
устройства (device_id — любого, не только триггерного) не станет истинным ЛИБО не истечёт
timeout_minutes (обязателен, 1..720); затем run продолжается.
{ "type": "wait_until",
"device_id": "<uuid>",
"condition": { "op": ">=", "left": { "var": "temperature" }, "right": 60 }, // тот же ConditionNode, что и «Если»
"timeout_minutes": 120,
"send_get": true } // опц., best-effort
condition— полноценныйConditionNode(тот же формат, что условие блока; допускаетand/orиargRefв позиции значения). Вычисляется над последним наблюдённым payload целевого устройства (стейт-стор брокера, обновляется на каждую публикацию устройства). Только пороговые операторы (=,≥,<,EXISTS…): операторы перехода (CHANGED_TO,CROSSES_*) в ожидании запрещены (валидатор → 422) — раннер вычисляет предикат по одному payload (prev = nil), поэтому они никогда не сработали бы и ожидание молча висело бы до таймаута.send_get: true— на входе в ожидание брокер публикует/getцелевому устройству, провоцируя свежую публикацию статуса. Best-effort: устройства без поддержки/getего игнорируют и просто ждут до таймаута.По таймауту шаг завершается и run идёт дальше (как истёкший
pause).Персистентность: абсолютный дедлайн (
wait_ends_at_utc) и наблюдённый payload (captured_payload) WAL-персистятся — незавершённое ожидание переживает рестарт (досып до дедлайна, повторный опрос состояния).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 триггера при материализации (см. раздел «Аргументы»).Версия:
wait_until— аддитивный тип действия (MINOR);schema_versionне бампится, PG-миграции нет. Старый воркер, встретивwait_until, грациозно не стартует run (без паники).
Тип-модель¶
Константы типизированы самим JSON: число (24, 20.5) сравнивается численно, строка
("ON") — как строка. Отдельного «числового литерала без кавычек» не существует — тип несёт JSON.
Числовые сравнения (
<,>,crosses_*) требуют приводимости к числу; иначе — ложь.changed_to/changed_from_toнормализуют строки в нижний регистр (bool/enum ON/OFF сравниваются без учёта регистра).
Поверхностный синтаксис (грамматика, EBNF)¶
Запись display; регистр именованных токенов — SCREAMING_SNAKE (отделяет ключевые слова от
имён свойств). Это НЕ ввод — только отображение AST.
expr = or_expr ;
or_expr = and_expr { "OR" and_expr } ;
and_expr = unary { "AND" unary } ;
unary = [ "NOT" ] primary ;
primary = "(" expr ")" | comparison | named_call | exists_call ;
comparison = operand sym_op operand ; (* инфикс *)
sym_op = "=" | "!=" | "<" | ">" | "<=" | ">=" ;
named_call = NAME "(" operand { "," value } ")" ; (* call: CROSSES_UP(temperature, 24, 2) *)
exists_call = "EXISTS" "(" property ")" ;
NAME = "CHANGED_TO" | "CHANGED_FROM_TO" | "CROSSES_UP" | "CROSSES_DOWN" ;
operand = property | value ;
property = FUNC "(" var { "," digit } ")" | var ; (* ROUND(temperature, 0) | temperature *)
FUNC = "ROUND" | "BOOL" | "UPPER" | "LOWER" ;
var = ident { "." ident } ; (* Switch.state *)
value = number | string ;
Каноническая форма (AST) — краткий справочник¶
Полная схема — в JSON Schema. Значения op/fn на wire — lowercase.
// группа
{ "op": "and"|"or", "not": false, "children": [ <node>, ... ] }
// символьное сравнение
{ "op": "="|"!="|"<"|">"|"<="|">=", "left": <operand>, "right": <operand> }
// именованные операторы
{ "op": "exists", "not": false, "left": <property> }
{ "op": "changed_to", "left": <property>, "to": <scalar> }
{ "op": "changed_from_to", "left": <property>, "from": <scalar>, "to": <scalar> }
{ "op": "crosses_up"|"crosses_down", "left": <property>, "threshold": <number>, "hysteresis": 0 }
// операнды
{ "var": "temperature" } // свойство
{ "var": "temperature", "fn": "round", "digits": 0 } // fn: round|bool|upper|lower
24 "ON" // типизированные константы
{ "arg": "target_temp" } // ссылка на аргумент (v2, schema_version 2)
Семантика операторов¶
Оператор (wire / display) |
Семантика |
|---|---|
|
Сравнение текущих значений операндов (числовое либо строковое по типу). |
|
Свойство присутствует в источнике состояния. |
|
|
|
|
|
Фронт вверх: |
|
Симметрично вниз ( |
Функции-обёртки операнда: round (digits 0..3), bool, upper, lower.
Аргументы (schema_version 2)¶
Аргумент — именованный типизированный параметр автоматизации: определение задаётся при создании, а значения подставляются в условие и действия при исполнении. Это превращает автоматизацию в переиспользуемый шаблон.
Определения — массив automation.arguments (опционально). Определение несёт метаданные
(label/description/constraints) для UI и валидации:
{ "name": "target_temp", "type": "number", "required": true,
"label": "Целевая температура",
"constraints": { "min": 5, "max": 35, "step": 0.5, "unit": "°C" } }
name—^[a-z][a-z0-9_]*$, уникален в пределах автоматизации.type—number|string|boolean|enum|datetime(datetimeна wire = ISO-8601 строка).default— опциональный дефолт; матчитtypeиconstraints.constraints(опц., по типу):number→min/max/step/unit;string→min_length/max_length/pattern;enum→options(непустой, обязателен для enum);datetime→min/max. Блок расширяемый (новые ключи аддитивны). hmq2 метаданные не читает.Мета-формат «продолжительность»:
{ "type": "number", "constraints": { "format": "duration", "max": 43200 } }— значение целое число секунд, потолок 43200 (12 ч); в UI вводится через диалог часы/минуты, а не сырым числом. Отдельного базового типа нет: duration — этоnumberс маркеромformat(наследует численные сравнения, пороги и{{arg:}}). Сервер требует целое в[0, 43200]; hmq2 подставляет число как есть.
Значения — per-schedule: timeTrigger.arguments (карта {name: value}) переопределяет дефолт
для конкретного расписания. Эффективное значение при исполнении:
time_trigger.arguments[name] ?? automation.default(name).
Правило all-defaults: обязательный аргумент без дефолта должен получать значение при добавлении
расписания. У device-триггерных автоматизаций момента ввода нет → все объявленные аргументы обязаны
иметь default (иначе запись отвергается, 422).
Ссылки на значения. В условии — новый вид операнда (каноническая форма):
{ "arg": "target_temp" } // рядом с { "var": ... } и типизированной константой
argRef допустим везде, где ждётся операнд/константа/число: left/right сравнения, to/from
переходов, threshold/hysteresis порогов. В числовых позициях аргумент обязан быть number/datetime.
В строковых полях действий (payload, topic, body, subject, to) — плейсхолдеры
(namespaced, во избежание коллизий имён):
{{arg:name}}— значение аргумента.{{prop:path}}— текущее значение свойства устройства (атрибут статуса), резолвится в живое значение в момент исполнения (справочник сигналов —GET /devices/{id}/condition-signals).{{prop:<device_id>.path}}— device-квалифицированная форма: значение свойства конкретного устройства, захваченного предшествующим шагомwait_until(см. «Действия» → late-binding). Резолв ленивый, на исполнении, изcaptured_payloadэтого шага.
Плейсхолдер заменяется строковым рендером значения (число → 50, строка → как есть, bool →
true, datetime → ISO); JSON-кавычки в payload ставит автор ({"mode":"{{arg:mode}}"}).
Синтаксис {{…}} зарезервирован.
Display. Аргумент — первоклассный токен и в поверхностном синтаксисе: в условии рендерится
чипом (leb-arg, глиф @name) рядом со свойством/функцией/константой; в превью действия
(DescribeTopicMessage) плейсхолдеры показываются как label аргумента / имя свойства, не сырым
{{…}}.
Ограничения (текущая реализация hmq2). Условие вычисляется только на device-триггерах (time-триггеры
матчат лишь always-true блоки) → arg в условии практически резолвится дефолтом на device-путях;
per-schedule значения важны прежде всего в действиях. {{prop:…}} требует доступа к состоянию
устройства при исполнении: для device-триггера оно на руках, для расписаний берётся из стейт-стора брокера
(если недоступно — ограничивается device-триггерами). Снятие ограничения условия на time-триггерах — отдельный follow-up.
Версионирование и расширяемость (SemVer)¶
Контракт версионируется семантически. Мажор несёт automation.schema_version (дефолт 1). JSON Schema —
файл automation-contract.v1.schema.json (внутренняя version растёт, $id сохраняется для
непрерывности ссылок). Актуальная версия схемы — 2.0.0 (аргументы).
schema_version стампится сервером по факту использования, не по объявлению. Автоматизация, которая
ссылается на аргумент (argRef в условии или токен {{arg:…}} в строке действия), получает
schema_version = 2; иначе остаётся 1. Просто объявить arguments без ссылок — версия остаётся 1
(старый hmq2 исполняет литерально). hmq2 knownSchemaVersion = 2; встретив schema_version новее
известной, hmq2 пропускает автоматизацию целиком (fail-safe), причём проверка версии идёт до чтения
новых колонок.
Порядок выката колонок (v2): новые колонки automations.arguments / automation_time_triggers.arguments
добавляет только Janus-миграция (инвариант «эволюция схемы в Janus»). Janus выкатывается первым —
иначе новый hmq2 упрётся в несуществующие колонки. Старый hmq2 их игнорирует и fail-safe-скипает
schema_version=2, поэтому Janus-first безопасен (arg-автоматизации не исполняются до апдейта 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.