Язык автоматизаций 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)

Семантика

= != < > <= >=

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

exists / EXISTS

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

changed_to / CHANGED_TO

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

changed_from_to / CHANGED_FROM_TO

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

crosses_up / CROSSES_UP

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

crosses_down / CROSSES_DOWN

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

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

Аргументы (schema_version 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_]*$, уникален в пределах автоматизации.

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

  • default — опциональный дефолт; матчит type и constraints.

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

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

Значения — 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, строка → как есть, booltrue, 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, а у пользователя закэширован старый бандл) билдер сейчас молча отбрасывает незнакомый узел на загрузке (astToTreecreateEmptyRoot), из-за чего пересохранение может ослабить гейт. Смягчение — hard-refresh; hardening (opaque-passthrough незнакомых узлов или save-guard) — отдельный follow-up.

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