Язык автоматизаций Rocket-home (спецификация)¶
Автоматизации Rocket-home — небольшой скриптовый язык: правило читается как «ON триггер: IF условие THEN действия». У языка две формы представления:
Каноническая форма (AST) — структурированный JSON, который хранится и исполняется (аналог «байткода»). Единственный источник истины — JSON Schema
openapi/schemas/automation-contract.v1.schema.json.Поверхностный синтаксис (display) — человекочитаемая запись условия (call/инфикс-нотация), которую рисует редактор/превью. Display выводится из AST детерминированным форматтером и никогда не парсится обратно из строки.
Термины — см. глоссарий.
Модель исполнения¶
Автоматизацию запускает триггер (расписание kind:"time" или событие устройства
kind:"device"). После запуска воркер перебирает condition_blocks по порядку и выполняет
then_actions первого блока, чьё condition истинно (condition: null = всегда истина).
Ни один блок не подошёл — ничего не выполняется.
Условие вычисляется относительно входного payload события. Для операторов переходов
(changed_to, changed_from_to, crosses_up, crosses_down) сравниваются предыдущее
(prev/триггерное) и текущее значения одного свойства.
Событие устройства — это его СТАТУС, а не команда ему. Адрес устройства матчится с отбрасыванием
хвостовых сегментов (правило на zigbee2mqtt/lamp ловит и zigbee2mqtt/lamp/action), но
командные подтопики — /set и /get — из этого исключены: публикация в них адресована прибору
и ничего о нём не сообщает, прибор может её и не исполнить. Иначе управление устройством из панели,
от Алисы или из бота выглядело бы как его собственное событие: запускало бы device-триггер и снимало
бы правило отмены с device_id по факту приказа, а не исполнения. Правило действует одинаково
в облаке (там команду отсекает драйверный конвейер коллектора — до диспетчера она не доходит) и на
хабе (раннер ≥ 1.13.0; до него команды наблюдались, и перенос облако⇄хаб менял поведение).
Исключение — авторский адрес правила отмены (v18): там хвост /set законен и описан ниже.
Один активный прогон на автоматизацию — инвариант, а не настройка. Пока прогон жив, следующее
срабатывание любого её триггера (в том числе очередное срабатывание расписания) не создаёт второй
прогон, а пропускается — в логах scenario_busy с идентификатором мешающего прогона. Отдельный
случай — повторная доставка того же срабатывания расписания (доставка диспетчера at-least-once):
она распознаётся по идентификатору occurrence и логируется как duplicate_trigger. Практический
смысл: длинный сеанс (пауза, ожидание статуса, удержание) никогда не «наслаивается» сам на себя, а
следующее расписание просто пропускается — как если бы его не было.
Продление при повторном срабатывании (v13). Инвариант выше остаётся, но спящий шаг может
попросить, чтобы срабатывание не пропадало: then_actions[].retrigger = "extend_while" у шагов
pause и wait_until (отсутствие поля и skip — прежнее поведение, срабатывание
отбрасывается). Тогда срабатывание, пришедшее пока прогон спит на ЭТОМ шаге, перевзводит его
дедлайн на полную длительность: прогон продолжается с того же места, шаги не переигрываются,
второй прогон не создаётся. Удачное продление логируется как run_extended.
Продление условное: дедлайн перевзводит только статус, который проходит условие блока, из
которого растёт прогон. Этим и выражается «свет горит N минут после последнего движения»: публикация «движения нет» паузу не продлевает (лог
retrigger_extend_condition_false). Предикаты уже отработавших wait_until не
перепроверяются — это не условия, а состоявшиеся ожидания, и требовать от события повторить всю
историю прогона значило бы не продлевать никогда.
Проверка вычисляется ВНЕ прогона, и отсюда два следствия. Первое: армированное состояние сигнала не
двигается — иначе автоматизация на CROSSES_* продлилась бы один раз и больше никогда, съев фронт
у самого триггерного пути. Второе: предыдущего значения нет, поэтому переходные операторы в проверке
ложны — условие, которое из-за этого не может стать истинным никогда, сервер отвергает на записи
(422), чтобы пользователь услышал об этом сразу, а не через месяц молчания.
Сдвиг срока и «дальше» по команде. Тот же дедлайн двигает и человек — заявкой спящему шагу
(POST …/run-deadline {ends_at}, POST …/run-finish-step; на шине deadline /
finish_step), в любую сторону и без повторного срабатывания. Заявка адресна текущему шагу
(индекс фиксируется под замком строки прогона) и применяется самой горутиной движка, которая на
этом шаге спит; снаружи её только будят. «Дальше» у wait_until с on_timeout: cancel
ПРОДОЛЖАЕТ цепочку — это не «никто не ответил», а просьба человека. Ждущему сообщению заявка
не адресуется по той же причине, что и продление (срок — обещание человеку), сеансу фоновой
задачи — потому что его строка держит шаблон круга. Событие на шине — run_deadline_moved с
новым сроком, run_step_finished_by_request; опоздавшая заявка — run_step_request_late.
Поле принимается только у pause и wait_until (у остальных шагов дедлайна нет; у ждущего
сообщения он есть, но двигать окно ответа задним числом бессмысленно — вопрос уже отправлен), только у
автоматизаций с триггером device/group_message (расписание своим следующим срабатыванием
паузу не продлевает) и у логики группы только в ветке direction = set — обращение за статусом
самого статуса не несёт. Всё остальное — 422. Гейты смотрят ровно на extend_while: голый
skip равен отсутствию поля, поэтому принимается где угодно и вырезается канонизацией записи —
иначе клиент, материализующий документированный дефолт (генерённый по OpenAPI, MCP), не смог бы
сохранить ни одну паузу у автоматизации по расписанию.
Отдельного безусловного режима нет, и запрещать его нечем: у блока без условия продлевает
любое срабатывание — проверять просто нечего. Движок знает и безусловное
значение (extend), но Janus его не эмитит и не принимает: одно намерение — одно значение.
Прежнее поле АВТОМАТИЗАЦИИ retrigger_policy (v10) снято. Уровень был неверен: перевзвести можно
только дедлайн, а он принадлежит конкретному ожиданию — на любом другом шаге поле было честным
no-op (retrigger_extend_noop). Отмена прогона (cancel_on) переехала на шаг тем же путём
по обратной причине: она обязана действовать на любом спящем шаге. Обоснование и порядок выката —
ADR 0017.
Режима «перезапустить» нет сознательно: сегодняшний анти-спам уведомлений держится ровно на инварианте «один активный прогон» (сообщение плюс пауза в хвосте цепочки), и перезапуск выключил бы единственный работающий механизм. Он вернётся вместе с явным кулдауном канала.
Граница инварианта — прогоны. Одношаговую тайм-автоматизацию (единственное действие
device_command в единственном блоке) планировщик публикует напрямую, прогона не создаёт; сеанса,
который мог бы пересечься, у неё нет.
Что видно снаружи: журнал исполнения. Каждый прогон — строка automation_runs; её пишет
движок (облачный воркер или, через ingest локального раннера, хаб), Janus только читает:
GET /api/locations/{location}/automations/{automation}/runs (session-only, постранично,
per_page до 100). Кроме исхода и времени строка отвечает на вопрос «почему так»: индекс
выбранного блока, список адресных операндов без показания (v22 — блок не сработал по
«неизвестно», а не по «ложно»), признак «исполнял хаб» и — у фоновой задачи — номер круга с
кольцом последних СМЕН исхода.
Именно СМЕН: ровно идущая задача следов не оставляет вовсе, а простой в границах («ни одна ветвь не подошла») пишется один раз, а не каждый круг — иначе журнал смен выродился бы в журнал кругов и стоил бы UPDATE строки прогона раз в тридцать секунд до выключения группы.
У прогонов, приехавших с ХАБА, разбор условия пуст: раннер вычисляет unresolved так же, как облако, но событие журнала его не несёт, и if_evaluated у такой строки не заполняется вовсе. История хаба отвечает «что и когда», но не «почему» — до расширения события раннера.
Исходы, при которых прогон не создаётся (ни один блок не подошёл, автоматизация уже идёт, группа
выключена), строки не оставляют по построению: у датчика, публикующего раз в десять секунд, это была
бы тысяча строк в сутки на автоматизацию. Они видны только в логах воркера
(run_skipped_no_block_matched, run_skipped_already_running, run_skipped_group_gated_off).
Предварительный запуск (готовность к времени)¶
Для процессов с временем выхода в рабочий режим (баня греется ~2 ч, тёплый пол, прогрев авто)
автоматизация несёт длительность подготовки lead_seconds: пользователь задаёт целевое время
one_time_at = T (к которому должно быть готово), а подготовка стартует заранее. then-действия —
обычные (включить нагреватель и т.п.). Janus считает фактический запуск:
next_due_at_utc = clamp(now, T − lead_seconds)
Семантика: запланировано более чем за lead → подготовка стартует ровно в T − lead;
менее чем за lead → немедленно (клампится в текущую минуту); T уже прошло →
не срабатывает (next_due = null). Гранулярность минутная (как у всех разовых триггеров).
Уровень поля — автоматизация, а не расписание: подготовка описывает её саму, поэтому её наследуют
все разовые расписания автоматизации с фиксированным временем — включая добавленные операцией
«Разовый запуск», где своего поля для подготовки нет (иначе оно там молча терялось бы). Расписания
cron и солнечные подготовку игнорируют: у повторяющегося расписания нужное опережение задаётся
его собственным временем («каждую субботу к 18:00 с подготовкой 2 ч» = «каждую субботу в 16:00»), и
отдельный механизм там был бы вторым способом сказать то же самое (ADR 0006). Задать подготовку
автоматизации без разовых расписаний можно — это легальный no-op на будущее, а не ошибка. hmq2
исполняет по next_due_at_utc (one_time_at в due-логике не участвует; хранится как целевое
время для отображения) и колонку lead_seconds не читает. lead_seconds = null — подготовки
нет.
Краевые случаи: (1) toggle off→on внутри окна (T−lead, T) пересчитывает next_due = now любому
взведённому разовому расписанию автоматизации → подготовка стартует повторно немедленно (осознанно:
«включили — подготовь к цели»). (2) Повышение lead_seconds в редакторе при уже взведённом
разовом запуске, до которого подготовка не успевает (T < now + lead), отвергается 422 на
triggers.N.one_time_at: молчаливый клампинг в текущую минуту выстрелил бы прогоном сразу, чего
пользователь не просил. Для вновь создаваемого разового запуска клампинг «немедленно» остаётся
желаемым поведением. (3) Если воркер недоступен во время старта подготовки дольше grace-окна (5 мин),
запуск пропускается — общее поведение любого разового триггера. (4) delete_after_run отрабатывает
janus, а не движок: hmq2 разовый триггер после срабатывания только деактивирует
(is_active=false, next_due_at_utc=NULL), а уборщик automations:prune-spent-runs затем
удаляет деактивированные разовые триггеры с этим флагом. Признак срабатывания — завершённый прогон
по trigger_ref_id: без него снятый с взвода вручную запуск не отличить от отработавшего.
Исключение — последний триггер автоматизации: он остаётся, иначе получилась бы автоматизация,
которую нечем запустить (triggers min:1 на записи).
Действия (then_actions)¶
Четыре типа шагов: device_command (публикация в MQTT), pause (задержка), message
(уведомление по каналу) и wait_until (ожидание состояния). Шаги блока выполняются линейно по
порядку — за одним исключением: шаг branch (v23) выбирает, какие шаги играть дальше, в тот
момент, когда цепочка до него дошла (см. «Ветвление внутри цепочки» ниже).
В редакторе device_command показан пунктом «Действие» с выбором подтипа: «Устройство» или
«Публикация в топик». Подтипы различает наличие device_id — на проводе это один и тот же тип
шага (ADR 0020, ADR 0022).
``device_command`` — публикация payload в командный топик устройства. Адрес задаётся
device_id; topic при этом производный — Janus считает его сам
(DeviceTopic::forDevice + /set) и переопределяет присланный клиентом, как и у wait_until.
Причина: пара driver/friendly_name живёт на сервере, а клиент видит лишь её
копию в payload устройства и на своих правилах собирал адрес неверно (для устройства с неизвестным
драйвером получалось {driver}/{friendly}/set, для тегированного камина в командный топик попадал
тег). То же и у device-триггера: topic_suffix считается из device_id триггера.
topic без device_id — публикация в произвольный топик локации (подтип «Публикация в
топик»; раньше этот путь существовал только для внешних клиентов, у которых устройства в Janus нет).
Адрес пишет автор, и он окончателен: /set к нему не дописывается. Топик всегда относителен
локации — mount point добавляет брокер, выйти за её пределы таким шагом нельзя. Проверки на записи
(422): без wildcard +/# (публикуют в конкретный топик, а не в маску), без ведущего и
замыкающего /, без пустых уровней и управляющих символов, не длиннее 255 символов; payload
обязан быть валидным JSON — воркер кладёт его в jsonb, и невалидный уронил бы шаг в рантайме.
Мажора этот подтип не требует: на проводе он тот же device_command, который оба движка исполняют
с самого начала.
Пустыми не могут быть оба: device_id или topic обязателен. Устройство обязано
принадлежать локации автоматизации (иначе 422 — тот же cross-tenant guard, что у остальных шагов).
{ "type": "device_command", "device_id": "…", "payload": "{\"state\":\"ON\"}" } // topic посчитает сервер
``pause`` — задержка. Длительность задаётся константой (duration_seconds, 1..43200 —
приоритетнее; либо duration_minutes, 1..720) либо ссылкой duration_arg на параметр
number с constraints.format=duration (значение — целые секунды, 0..43200).
duration_arg приоритетнее константы и позволяет вынести длительность (напр. «время парения» в
сценарии бани) в параметр, значение которого пользователь задаёт при запуске автоматизации (или
на разовом расписании). Ссылка на параметр стампит schema_version=8; hmq2 резолвит её в секунды
при материализации run (значение запекается в WAL, переживает рестарт). Редактор пишет
duration_seconds: секунды есть в UI паузы, и округление их до минут на записи было тихой потерей
введённого значения.
{ "type": "pause", "duration_arg": { "arg": "steam_time" } } // длительность = значение параметра steam_time (секунды)
``wait_until`` (v2.1) — блокирует последовательный run до таймаута (timeout_seconds,
1..43200 — приоритетнее; либо timeout_minutes, 1..720; один из них обязателен), в одном из двух
режимов: по условию — пока condition по свойству целевого устройства (device_id —
любого, не только триггерного) не станет истинным; «любой статус» (v7) — пока устройство не
пришлёт новый статус, каким бы он ни был. В обоих режимах засчитывается только статус,
полученный после входа в ожидание (v19), либо последний уже полученный статус, явно допущенный
окном свежести last_status_max_age_seconds (см. ниже).
{ "type": "wait_until",
"device_id": "<uuid>",
"condition": { "op": ">=", "left": { "var": "temperature" }, "right": 60 }, // тот же ConditionNode, что и «Если»
"timeout_minutes": 120,
"send_get": true } // опц., best-effort
{ "type": "wait_until", // режим «любой статус»
"device_id": "<uuid>",
"condition": null, // условие не задано — ждём любую свежую публикацию
"timeout_seconds": 45 }
{ "type": "wait_until", // окно свежести (v19): не ждать, если статус уже есть
"device_id": "<uuid>",
"condition": null,
"timeout_seconds": 45,
"last_status_max_age_seconds": 1800 } // «принимать последний статус не старше 30 минут»
{ "type": "wait_until", // исход по таймауту (v23)
"device_id": "<uuid>",
"condition": null,
"timeout_seconds": 30,
"on_timeout": "cancel" } // не дождались — прогон кончился, хвост не играется
condition— полноценныйConditionNode(тот же формат, что условие блока; допускаетand/orиargRefв позиции значения). Вычисляется над последним засчитанным payload целевого устройства (стейт-стор брокера, обновляется на каждую публикацию устройства). С v19 условный режим больше не завершается на значении, лежавшем в сторе до входа в шаг (в т.ч. retained сколь угодно старом): без окна свежести условие проверяется только по публикациям новееwait_baseline_at_utc. Только пороговые операторы (=,≥,<,EXISTS…): операторы перехода (CHANGED_TO,CROSSES_*) в ожидании запрещены (валидатор → 422) — раннер вычисляет предикат по одному payload (prev = nil), поэтому они никогда не сработали бы и ожидание молча висело бы до таймаута.``condition: null`` (или отсутствие) — режим «любой статус», а не незаполненное поле. Шаг завершается по первой публикации целевого устройства позже момента входа в ожидание (
wait_baseline_at_utc). Именно «позже», а не «статус известен»: в стейт-сторе почти всегда уже лежит показание, и вторая трактовка превратила бы шаг в мгновенный no-op. Практический смысл — «дождись, пока устройство ответит, и используй его статус дальше» (см. late-binding ниже).send_get: true— на входе в ожидание брокер публикует/getцелевому устройству, провоцируя свежую публикацию статуса. Best-effort: устройства без поддержки/getего игнорируют и просто ждут до таймаута. Редактор предлагает флаг только для устройств, у которых есть хотя бы одноretrievableумение/свойство; виртуальная группа не публикует MQTT вовсе, поэтому ожидание на группе не дождётся ничего (в пикере ожидания групп нет).``last_status_max_age_seconds`` (v19, окно свежести) — «принимать последний полученный статус, если он не старше N секунд» (1..43200; потолок 12 ч — консистентен с таймаутом). Задано — последний уже полученный брокером статус засчитывается наравне со свежей публикацией, когда его возраст на момент входа в ожидание не превышает N: в «любом статусе» шаг тогда завершается сразу (ослабление — не ждать нового, если недавний уже есть), в условном — недавнее значение допускается к проверке условия (без окна оно отсекается baseline-гейтом). Смысл — устройства без обратной связи (не отвечают на
/get, публикуют редко): редактор предлагает опцию только для них. Механика: штампwait_accept_after_utc= «вход в шаг − N» (RFC3339Nano, пишется один раз рядом с baseline, resume-стабилен); засчитывается наблюдение сat > baselineилиat ≥ accept_after. Окно заморожено на входе в шаг: продление (extend_while) и долгий resume двигают дедлайн, но не окно, поэтому относительно «сейчас» приём растягивается. Возраст считается от момента получения брокером, не от часов устройства; retained-переснимок при реконнекте устройства выглядит свежим. Отсутствующий/битый штамп при заданном N — опция игнорируется (строгий baseline-дефолт), не ошибка шага. Поле допустимо только уwait_until(на другом типе шага — 422); гейт «только устройства без обратной связи» — UI-only, как уsend_get.``on_timeout`` (v23) — что значит «не дождались».
continue(и отсутствие поля) — прогон идёт со следующего шага; так шаг вёл себя с самого появления, поэтому это дефолт, а голыйcontinueна записи ВЫРЕЗАЕТСЯ как no-op (иначе клиент, материализующий документированный дефолт по OpenAPI или из MCP, поднимал бы мажор на ровном месте — та же механика, что уretrigger: skip).cancel— прогон завершается на этом шаге со статусомabortedиdetail=wait_timed_out, оставшиеся шаги не выполняются.Зачем: завершиться по таймауту иначе нечем. Шаг кончается одинаково и когда дождался, и когда истёк, цепочка идёт линейно, а ветвления внутри неё в языке нет — значит «никто не ответил, гасим» до v23 не выражалось вовсе. Правило отмены этого не закрывает: оно снимает дедлайн по СОБЫТИЮ, а здесь событие как раз и не наступило.
Отличие от
cancel_onровно в причине, и когда срабатывают оба, побеждает отмена: у неё есть событие, а у таймаута только его отсутствие. Словарь общий с ждущим сообщением намеренно — «что делать по таймауту» одно понятие языка, и второй набор слов означал бы, что в редакторе один вопрос задаётся двумя способами.У фоновой задачи ``cancel`` завершает СЕАНС. Прогон у задачи один на всю её жизнь, поэтому дальше идёт разборка через
on_stop— этим и выражается «спросили, никто не ответил, гасим». Исход у такого сеанса СВОЙ и в журнале, и наружу: причина остановкиwait_timed_out, круг помечаетсяended(неokи неerror: цепочка отработала как написана, но следующего круга не будет), терминальный статус —completed, тот же, что у погасшего выключателя группы. Неaborted: у обычной автоматизации этот статус означает «хвост цепочки не сыгран», а сеанс задачи кончился ровно так, как в нём написано. И не отказ круга: десять таких кругов подряд пометили бы автоматизацию сломанной за поведение, которое автор задал сам.По таймауту шаг завершается и run идёт дальше (как истёкший
pause). Режимы расходятся в том, что попадает вcaptured_payload: по условию — последнее известное показание (информационный v7-фолбэк; при заданном окне свежести — только не старше окна, иначе протухшее уехало бы в{{prop:*}}под опцией, которая от него отгораживает), «любой статус» — ничего (нового статуса не было; устаревший payload под видом дождавшегося накормил бы следующие шаги старым значением). Асимметрия намеренная: без окна timeout-capture условного режима сохраняет поведение v7, хотя условие с v19 по лежавшему значению уже не проверяется.Персистентность: абсолютный дедлайн (
wait_ends_at_utc), baseline (wait_baseline_at_utc, с v19 нужен обоим режимам), окно свежести (wait_accept_after_utc) и наблюдённый payload (captured_payload) WAL-персистятся — незавершённое ожидание переживает рестарт (досып до дедлайна, повторный опрос состояния). Baseline и окно пишутся один раз: пересъёмка на resume съела бы ту самую публикацию, которой ожидание ждёт (или сдвинула бы окно).Ограничения стейт-стора (общие для обоих режимов, не новые): last-payload живёт в памяти пода, поэтому ожидание видит публикации, только пока run исполняется на поде, который их получает (в проде hmq — одна реплика); переполненный канал триггеров дропает публикацию, и она не доходит до стора; пустой payload стор не обновляет, т.е. устройство, публикующее пустое тело, ожидание «любого статуса» не завершит. Это же ограничивает окно свежести (v19): после рестарта/деплоя брокера «последний полученный статус» пуст, и шаг ведёт себя как без опции — ждёт новую публикацию. Соседний нюанс: зеркало стора в Redis (шов
SetWatchedTopics) сейчас дремлет без продовых вызовов, но если его снова включить, у watched-топиков появится TTL 1 ч (тише капа 12 ч) и clock-skew подов — окно свежести тогда пересмотреть.Late-binding: свойства целевого устройства из
captured_payloadдоступны последующим шагам через device-квалифицированный плейсхолдер{{prop:<device_id>.path}}(<device_id>= UUID устройства шагаwait_until;path— точечный путь в его payload). Такие токены не резолвятся при материализации run (значение ещё неизвестно) — их оставляют нетронутыми и подставляют лениво, на исполнении, изcaptured_payloadсоответствующегоwait_until(движок накапливает захваты поdevice_id; повторный захват того же устройства перекрывает прежний). Резолв идёт до WAL-записи шага, поэтому подставленный литерал переживает рестарт; аккумулятор восстанавливается из завершённых шагов при resume. Токен на устройство, ещё не захваченное (forward-ссылка), даёт пустую строку. Обычный (не device-квалифицированный){{prop:path}}по-прежнему резолвится из payload триггера при материализации (см. раздел «Параметры»).Версия: сам тип действия аддитивен (MINOR), PG-миграции нет. Но секундный таймаут и режим «любой статус» исполнимы только воркером ≥ v7, поэтому Janus стампит
schema_version=7по факту их использования (заданtimeout_secondsилиcondition = null) — пре-v7 воркер округлил бы 45 с до минуты, аnull-условие прочитал бы как нулевую, вечно ложную ноду и досидел бы до таймаута; fail-safe скип по версии честнее. Автоматизация в старой форме (условие + минуты) стампится как прежде и работает на старом воркере. Выкат — hmq2 первым: наш редактор пишетtimeout_secondsдаже при простом переоткрытии старой автоматизации, т.е. пересохранение поднимает её до 7.
Снятый обрыв цепочки по условию¶
Оборвать цепочку по условию нельзя: оба шага, которые это умели, сняты. Сначала guard (v10,
«Проверка внутри цепочки»: прогон продолжался, пока условие ИСТИННО) уступил место stop_run (v17,
«Завершение»: прогон кончался, когда условие истинно), а затем сняли и его — до выпуска, поэтому
ни одной сохранённой строки ни с тем, ни с другим типом не существует. type: "guard" и
type: "stop_run" отвергаются 422 по полю type; молча выбросить такой шаг нельзя — цепочка,
которая раньше кончалась на нём, стала бы доигрывать хвост.
Мажоры 10 (второй носитель — операнд времени, он остался) и 17 заняты навсегда; 17 больше не
штампуется. Досрочно прекратить прогон можно только правилом отмены по событию у ждущего шага
(cancel_on, раздел ниже). Решение — ADR 0022 (отменяет часть ADR 0020).
Отмена по событию (v16; авторский адрес — v18)¶
Правило cancel_on — поле ждущего шага: снять досрочно можно только дедлайн, а дедлайн есть у
шага, не у автоматизации:
{ "type": "pause", "duration_seconds": 600,
"cancel_on": {
"device_id": "<датчик движения>",
"condition": { "op": "=", "left": { "var": "occupancy", "fn": "bool" }, "right": "false" },
"mode": "finish"
} }
Адрес правила — либо устройство локации (выше), либо топик, который пишет автор (v18): прибор чужого моста, свой контроллер, под-топик прибора, которого в Janus нет:
{ "type": "pause", "duration_seconds": 600,
"cancel_on": { "topic": "sensors/hallway/state", "mode": "abort" } }
Пока прогон спит на этом шаге, каждая публикация по адресу правила проверяется условием, и при
истине прогон отменяется в режиме mode:
finishдедлайн текущего шага снимается досрочно, прогон продолжается со следующего шага. Так собирается «свет горит, пока есть движение»: пауза снимается, а выключение доигрывается сразу.
abortпрогон завершается на текущем шаге со статусом
aborted(detail=cancelled_by_event), оставшиеся шаги не выполняются. Так собирается «протечку устранили — перестать греть».
Существенные свойства:
Носители — только шаги с ДЕДЛАЙНОМ:
pause,wait_untilи сообщение в ждущем режиме (mode=confirm|prompt). На команде и на уведомлении снимать нечего — обрывать их посреди публикации значило бы прервать само действие, — и правило там отвергается (422). До v16 оно жило на автоматизации, и такой случай был законным no-op с логомcancel_noop: настройкой, которая молча ничего не делает. Переезд существует ровно затем, чтобы этого больше не было.Матчится правило ТОГО шага, на котором прогон стоит сейчас. У соседних ждущих шагов правила разные: «правило автоматизации» вычисляло бы условие по payload чужого устройства — класс ложных отмен, который переезд и закрывает.
``finish`` на ожидании читается как истёкший таймаут: условное ожидание отдаёт дальше последнее известное показание, «любой статус» не захватывает ничего. Иначе хвост цепочки видел бы в
{{prop:…}}разное в зависимости от того, ПОЧЕМУ ожидание кончилось.Адрес ровно один → иначе 422.
device_id— топик считает сервер, и осиротевшая после удаления устройства ссылка отличима от нормальной: по ней ревизия помечает автоматизацию сломанной.topic— адрес пишет автор: хвост внутри локации, без mount point (его навешивает брокер, вырваться из своей локации таким адресом нельзя), без wildcard+/#, без краевых и пустых уровней/, минимум два уровня — корень драйвера собирал бы сообщения всех приборов сразу. Матч авторского адреса префиксный по границе/: правило наsensors/hallwayловит иsensors/hallway/state; хвост/setзаконен, то есть «отменить, когда прибору приказали» — выразимо. Это единственное место, где команда считается сообщением по адресу правила: у адреса-устройства (device_id) командные подтопики исключены, см. «Модель исполнения» — там правило слушает статусы прибора, а не приказы ему. Ни ревизия ссылок, ни репойнтер адресов авторский адрес не трогают: связи с устройством у него нет, а правка по совпадению текста переписывала бы чужие адреса (ADR 0023) — переименование прибора протухший адрес молча не поправит.Условие обязательно у адреса-устройства → 422 без него: пустое правило истинно всегда, то есть первая же публикация датчика убивала бы прогон. У авторского адреса условие необязательно, и его отсутствие значит «любое сообщение в этот адрес отменяет шаг» — там нет каталога сигналов, по которому условие можно было бы собрать, а сам факт сообщения в названный автором адрес уже событие (тот же выбор, что у ожидания без условия). В jsonb такая форма хранится БЕЗ ключа
condition.Где правило работает. В облаке — на любом топике локации: сообщение, которое драйверный конвейер брокера отбрасывает (неизвестный драйвер, пропущенный под-топик, устройства нет в БД), доезжает до диспетчера отдельной веткой ингресса и только если за этим топиком следит правило с авторским адресом. Два следствия: на бесплатной локации такой PUBLISH отклоняется лимитом устройств раньше брокерского хука (адрес выглядит device-образным), а после рестарта пода первые секунды правило не отменяет ничего — индекс собирается заново (fail-closed), как и у любого вновь добавленного правила, которое начинает работать в пределах минуты. На хабе раннер видит только своё дерево
zigbee2mqtt/: адрес вне него делает автоматизацию непереносимой (блокерbare_topic_cancel). Служебные пространства ($SYS/,rocket/) в плоскость отмены не попадают вовсе.Переходные операторы запрещены (
changed_to,changed_from_to,crosses_*) → 422: армированное состояние сигнала принадлежит пути триггеров. Ссылки на параметры ({arg}) запрещены по смежной причине: отмену считает диспетчер ВНЕ прогона, а эффективные значения параметров принадлежат конкретному запуску — сравнение шло бы со значением другого. Операнд времени разрешён. Короткая формулировка обоих запретов одна: правило проверяется на каждом сообщении устройства, ещё до того как прогон найден, — поэтому сравнивать не с чем: ни с предыдущим значением (CHANGED_TO,CROSSES_*), ни со значениями параметров запуска.Доставка отмены — at-most-once. Публикация может быть потеряна на переполнении очереди брокера (там же, где теряется и триггер), поэтому «сразу» означает «по первому дошедшему сообщению».
Правило целиком — из снимка шага в WAL прогона. Бегущий прогон несёт свои шаги, поэтому ни включение отмены, ни правка её условия до него не доезжают: они действуют со следующего прогона. Так же ведёт себя вся остальная логика прогона, и оба движка — облачный и хабовый — здесь согласованы.
Версия:
schema_version=16. Воркер, знающий только v15, выбросил бы незнакомое поле при round-trip шага через свою структуру — правило исчезло бы из WAL, и пауза досиделась бы до конца, молча и не так, как просил пользователь.
Сообщение: уведомить, запросить подтверждение, запросить значения (v15)¶
Шаг message — одно семейство диалогов с человеком, по образцу JavaScript. Режим задаёт поле
mode; его отсутствие означает alert, то есть поведение, которое шаг имел всегда:
{ "type": "message", "channel": "tg", "body": "Кран перекрыт." }
{ "type": "message", "mode": "confirm", "channel": "tg", "body": "Открыть ворота?",
"confirm_label": "Открыть", "decline_label": "Не открывать",
"timeout_seconds": 600, "on_timeout": "cancel", "on_decline": "cancel" }
{ "type": "message", "mode": "prompt", "channel": "tg",
"body": "Греем сауну. На сколько включить?", "prompt_arguments": ["minutes"],
"timeout_seconds": 600, "on_timeout": "cancel", "on_decline": "cancel" }
alertсообщить и идти дальше. Кнопок нет вообще — отвечать не на что, прогон не ждёт.
confirmдве кнопки с авторскими подписями, прогон ждёт решения.
promptформа значений параметров плюс те же две кнопки.
У ждущих режимов исходов ровно три:
- согласие
цепочка играет дальше; введённые значения параметров доступны последующим шагам обычными
{{arg:…}}и{arg}-операндами — в том числе как длительность паузы (duration_arg).- отказ
разрешается полем
on_decline:cancel— прогон завершается со статусомaborted(detail=prompt_declined),continue— шаг завершается, цепочка идёт дальше, а отказ остаётся виден в журнале (result=declined,detail=prompt_declined_continue). Дефолта нет намеренно: «спросил и всё равно сделал» и «спросил и не сделал» — противоположные сценарии. До v15 отказ всегда прерывал прогон (ADR 0016, решение №5); настраиваемым его сделала авторская подпись кнопки — она снимает ловушку «кнопка не делает того, что на ней написано» (ADR 0018).- молчание
разрешается полем
on_timeout:continue— прогон идёт дальше (запрошенные параметры сохраняют свои эффективные значения, то есть ведут себя как до появления вопроса),cancel— прогон завершается (detail=prompt_timeout). Дефолта нет намеренно: молчание — самый вероятный исход, и цена ошибки у вариантов противоположная.
Существенные свойства:
Ждущий шаг ДЕРЖИТ прогон. Шаги после вопроса зависят от ответа, ждать и есть его смысл. Плата — инвариант «один активный прогон»: пока вопрос висит, автоматизация не срабатывает. Отсюда потолок таймаута 1 час (у паузы и ожидания — 12): полсуток немоты автор не выбирает осознанно.
Ответ приходит на страницу по непрозрачному токену (
/p/{token}), а не кнопкой в чате: входящего канала от мессенджер-бота не существует. Страница отвечает поPOST, а не по переходу — ссылку открывают анти-вирусные прокси и превью-боты, и «подтверждение» поGETони выполнили бы за жильца.Подписи кнопок задаёт автор (
confirm_label/decline_label, ≤ 32 символа). Пустые — страница берёт перевод по умолчанию, причём у кнопки отказа он зависит отon_decline.Значение по умолчанию в форме — эффективное значение параметра на момент вопроса (
argument_valuesлибоpreset). Его часто нет вовсе: спрашиваемый параметр как раз освобождён от требования иметь значение к моменту включения, и тогда поле открывается пустым.Первый ответ выигрывает. Ссылку пересылают и открывают повторно; второй клик ничего не переигрывает.
Нет ссылки — нет вопроса. Адрес страницы собирается из конфига брокера
localNotifications.ackBaseUrl; если он пуст, шаг падает с ошибкой, называющей настройку, вместо отправки сообщения, на которое нечем ответить (см. Вопросы жильцу из локальных прогонов).Запросить можно только объявленный параметр, и его нельзя использовать в шагах ДО вопроса и в тексте самого вопроса → 422: до ответа значения нет, и токен отрендерился бы пустотой. Определения параметров снимаются в момент вопроса — правка автоматизации, пока вопрос висит, на уже отправленную форму не влияет.
``mode=prompt`` требует непустого ``prompt_arguments``, а
confirm— его отсутствия (422): вопрос без единого поля — это подтверждение, и называться он должен так же.Ждущие поля запрещены у ``alert`` → 422 (а не тихий срез): шаг, который выглядит спрашивающим, а работает как односторонняя телеграмма, — худший вид отказа.
Запрошенный параметр не требует значения при включении. Гейт «активной автоматизации нужны значения всех обязательных параметров» такие имена пропускает — их значение и даёт жилец во время прогона. Иначе заглавный сценарий («на сколько включить?» без предустановленного значения) создавался бы выключенным.
Отмена по событию работает и настраивается на самом ждущем шаге (v16):
abortзавершает прогон,finishчитается как истёкшее окно ответа (то есть применяетon_timeout) — хвост цепочки не должен уметь отличить отменённый вопрос от протухшего. А вот продлить вопрос нельзя: дедлайн у него есть, но двигать окно ответа задним числом бессмысленно — он уже отправлен, и полеretriggerна этом шаге отвергается на записи.Локальное исполнение поддержано. Хаб задаёт вопрос событием журнала
prompt(отправляет облако — транспорта чатов на хабе нет) и ждёт; ответ возвращается вниз retained-документом{mount}/rocket/automations/answers. Требует раннера ≥ 1.6.0; топик ответов — broker-only.Версия:
schema_version=15— и только у ждущих режимов.alertмажор не поднимает: это поведение, которое шаг имел до v15, и тысячи сохранённых уведомлений не обязаны узнать о появлении режимов.
Снятый шаг ask (v12)¶
До v15 вопрос был отдельным типом шага ask. Тип снят: он делил с message канал, текст,
тему, получателя и половину валидации, а отличался ровно тем, ждёт ли прогон ответа — то есть был
режимом, оформленным как отдельная сущность. Мажор 12 занят навсегда и больше не штампуется.
Конвертации не было (сознательное отступление от правила «MAJOR = backfill + каткавер», см.
ADR 0018): на момент выката ни одной автоматизации с этим шагом в проде не существовало. Запись
шага type: "ask" отвергается на валидации, редактор показывает такой шаг нередактируемым с
объяснением, а воркер закрывает уже созданный прогон надгробием — завершает его как отменённый, а
не падает с «неизвестным типом шага».
Коридор поддержания (из пороговых триггеров)¶
Удержание измеряемого свойства датчика (напр. temperature) в коридоре low..high — не отдельный
примитив, а композиция из уже существующих: одна device-триггерная автоматизация с двумя блоками.
Движок исполняет condition_blocks по порядку и берёт первый матчащий, а пороговые операторы
(<, >, = …) вычисляются над каждым показанием датчика:
// Триггер: device (топик датчика). Автоматизация САМОСТОЯТЕЛЬНАЯ — см. «Группа» ниже.
"condition_blocks": [
{ "condition": {"op":"<","left":{"var":"temperature"},"right":63},
"then_actions": [ /* device_command: актуатор ON */ ] },
{ "condition": {"op":">","left":{"var":"temperature"},"right":67},
"then_actions": [ /* device_command: актуатор OFF */ ] }
]
Ниже low (63) → первый блок → ON; выше high (67) → второй блок → OFF; между порогами ни
один блок не матчится → актуатор держит состояние (гистерезис = зазор high−low). Гейт «одна активная
сессия на автоматизацию» не даёт наложиться повторному run.
Гейтинг по группе — ДЕКОММИССИРОВАН как механизм, но выразим как условие (v22). Прежде коридор вешали на группу (
owner_device_id), и hmq2 пропускал его run, пока on_off-регистр группы = OFF. Вход логики группы сузился до её собственных регистров (см. «Группа как устройство»), поэтому device-триггер сowner_device_idотвергается на записи (422), а гейт в рантайме hmq2 остался мёртвым кодом. Возвращать его не понадобилось: с адресным операндом «пока группа включена» пишется прямо в условии блока —onгруппы =ONвИс порогом. Прежняя формулировка «способа включать коридор из другой автоматизации в языке нет» больше не верна.Версия: обычная device-грамматика —
schema_versionстампится 1/2/3 как любая device-автоматизация.Ограничения (осознанно): fail-safe при пропаже датчика нет — если датчик замолчал, актуатор держит последнее состояние (при необходимости — отдельный watchdog-триггер). Прежний примитив
start_reading_reconciler(device_reading_reconcilers,schema_version=5) декоммиссирован.
Группа как устройство: одна логика + фоновые задачи¶
Устройство-группа (devices.kind='group') — first-class устройство: снаружи оно неотличимо от
обычного (панель и Алиса пишут в его регистры через device_expose_states), а внутри у него есть
собственное поведение. Оно описывается одной логикой плюс сколькими угодно фоновыми задачами.
Различает их роль владения — automations.group_role (logic | task): владелец у них один
и тот же, а грамматика входа разная.
Логика группы (group_role = 'logic')¶
Одна логика на группу. Инвариант держит partial unique
automations (owner_device_id) WHERE NOT is_system AND group_role = 'logic'и предпроверка во write-path (422 вместо 23505). Задачи в этот индекс не попадают — их у группы много.Вход — только обращение к самой группе. У логики допустим ровно один вид триггера —
group_message: сообщение на её MQTT-адрес ({loc}/generic/{friendly}/setили…/get), направление обязательно.timeиdeviceотвергаются (422): расписание и события чужих устройств — грамматика локации. Нужен график — заводится обычная автоматизация, которая командует группой как устройством. Прежний входgroup_expose(изменение регистра) снят вместе со своим диспетчером: он узнавал о записи ПОСЛЕ неё, то есть проверить входящий статус уже не мог.Блок принадлежит ветке. У каждого блока логики группы есть
direction, совпадающее с одним из объявленных направлений: иначе ответ на/getпрокручивал бы шаги записи. Объявленная ветка без единого блока — 422.Команда группе. Шаг
device_command, нацеленный на устройство-группу, — обычная команда: у группы есть свой адрес, шаг сохраняется как есть и уезжает на{driver}/{friendly}/set. Сообщение запускает её логику, а регистры пишет только шагset_group_exposeвнутри логики. Прежнее переписывание команды в наборset_group_exposeснято. Детектор циклов диспатча при этом остался: кольцо «группа A командует B, B командует A» отвергается на записи. Значение bool-регистра принимается только в однозначной форме (ON/OFF/true/false/1/0):TOGGLEи плейсхолдер{{arg:…}}отвергаются 422 — «инвертируй» в регистре невыразимо, а подстановка идёт по строковым полям шага, а не по значению. Текст ошибки называет блок и шаг: ключ ошибки у автоматизаций один на всё тело, и без адреса непонятно, какой из шагов править.Параметров у логики группы нет (422 на непустой
arguments; PATCH/argument-values→ 404; разовое расписание не добавляется). Параметр — шаблонизация, значение которой вводят в конкретный момент: запуск, разовое расписание, сообщение в режиме «спросить значения». У группы такого момента нет — её запускает собственный регистр, то есть железо, — а обязательный параметр без значения гасит автоматизацию, то есть молча ломает поведение устройства. Меняемое значение выражается обычной автоматизацией, которая командует группой. Ссылки ({arg},{{arg:…}},duration_arg,prompt_arguments) отдельного правила не требуют: без объявления их отвергает проверка ссылок.
Фоновая задача (group_role = 'task', v21)¶
Фоновая задача — цепочка шагов, которая повторяется, пока включён on_off-регистр группы. Это
отдельная автоматизация владельца с единственным видом входа — task
(automation_task_triggers: ветка гашения и денормализованное имя регистра-выключателя;
настроек темпа у входа нет). Прежний примитив — дути-цикл (таблицы device_duty_tasks /
device_duty_cycles и шаги start/stop_duty_cycle) — снят целиком, без обратной
совместимости: он умел ровно «одно устройство, два состояния, круг», а задача — произвольную
циклограмму, регулятор и осмысленное гашение. Ни таблиц, ни шагов, ни предохранителя больше нет;
шаг снятого типа отвергается обычной проверкой перечисления. См. ADR 0024.
Окно исполнения = прогон. Цикл крутится ВНУТРИ одного долгоживущего прогона; «один активный прогон на автоматизацию» — существующий инвариант, и «не в паузе и не в ожидании — значит идёт» получается из него даром. Именно поэтому задача не может быть веткой логики: вечный сеанс заблокировал бы обработку обращений к самой группе.
Темп задаёт ЦЕПОЧКА, а не автор входа. Окно исполнения одно на все задачи — минута — и считается от НАЧАЛА круга: ожидание равно
max(0, 60 с − длительность круга). Уложившийся в окно круг досиживает остаток; круг, шедший дольше, начинает следующий немедленно, ничего не догоняя. Троттл именно от начала, а не пауза после конца: цепочка, которая сама держит время внутренними паузами, не получает мёртвых секунд сверх своей длины, и скважность ШИМ не искажается постоянной добавкой к такту. Сверх этого есть общий потолок публикаций на локацию; исчерпав его, задача пропускает круг, но отказом это не считается и гашения не касается. См. ADR 0025 и 0026.Ресурс реле — ответственность автора. Пол периода (300 с для цепочки, обращающейся к одному адресу дважды за круг) снят вместе с самим периодом. Цепочка
ON → OFFбез пауз щёлкает раз в минуту — это ~1440 коммутаций в сутки и ресурс типового механического реле (~10⁵ циклов) примерно за два месяца. Нужен более редкий цикл — его держит пауза в конце круга (так устроены демо-сидеры: 30 с работы + 270 с отдыха). Гистерезис как форма предпочтительнее ШИМ на реле: при равной пульсации он щёлкает реже и выражается двумя шагамиwait_until.Старт — в момент ЗАПИСИ выключателя, и других входов у супервизора задач больше нет: пятисекундного тика реконсилера не существует (ADR 0028, решение 22). Писатель регистра будит его сам — и для MQTT-входа, и для шага автоматизации, и для панели с Алисой: панель шлёт команду публикацией на MQTT-адрес группы (задачей очереди), Алиса впрыскивает сообщение в тот же вход ин-процессно. Прежняя пара «запись
desired_value+ поллер моста» снята целиком, задержка везде секунды. Полноту, которую раньше давал тик, даёт сверка: о правках Janus сообщаетpg_notifyиз той же транзакции, что и сама запись, а движок делает полный проход при каждом подключении слушателя — уведомление не переживает разрыв соединения.Тело — ЦИКЛ, и блоки означают «Пока верно» (v29). Подпись у них своя — «Пока верно» / «Или пока верно», — потому что и смысл другой: у остальных автоматизаций блок выбирается один раз, на старте прогона, а здесь КАЖДЫЙ круг. Порядок разрешения прежний, первый истинный.
Не держится ни одно условие — сеанс кончается: ветка остановки отрабатывает, выключатель группы гаснет, прогон закрывается
completed, сломанной автоматизация не помечается. До v29 здесь пропускался круг, и задача, у которой не осталось ни одной причины идти, занимала нагрузку и потолок задач локации, пока человек не щёлкнет выключателем.«Держись, ничего не делай» пишется блоком с условием и ПУСТЫМ списком действий. Так выражается коридор между порогами и так же — «пережидать молчание датчика» (
not exists→ ничего). Обе половины выбора наконец выразимы: раньше пережидание было поведением ПО УМОЛЧАНИЮ и отменить его было нечем, теперь это решение автора, а умолчание — закончиться.Отсюда следствие, которое надо знать заранее: неполное покрытие условий стало несущим. Зазор между блоками (
t < 22иt >= 25приt = 23) раньше значил «держи как есть», теперь — «кончи и погаси». Это цена выразительности, а не побочный эффект.У блока есть момент сверки (
check):before(умолчание, перед кругом) илиafter— блок держится, ПОКА САМ НИ РАЗУ НЕ ОТРАБОТАЛ в этом сеансе. Долг в один круг принадлежит БЛОКУ, а не сеансу: блок, проигравший первый круг соседу, своё право не теряет, — иначе «в конце обязательно продуть» не сыграло бы ни разу у задачи, начинающейся с прогрева.После своего круга блок сверяется как обычный — и здесь стоит ловушка, стоившая живого прогона.
afterзначит «сначала круг, потом спрашиваем», а НЕ «сыграть однажды»: пустое условие после гарантированного круга значит «верно всегда», то есть блок держится вечно и соседи не сыграют ни разу. Поэтомуafterбез условия отвергается (422), а «однажды в начале» пишется условием, которое сам круг и опровергает:{ "check": "after", "condition": { "op": "=", "left": { "var": "phase", "device_id": "…" }, "right": "подготовка" }, "then_actions": [ …, { "type": "set_group_expose", "expose_name": "phase", "value": "парение" } ] }Круг первый идёт без вопросов (условие не вычисляется вовсе), а к следующему кругу тот же круг уже сделал условие ложным — и цикл уходит соседнему блоку.
Слово
untilв значении поля не используется намеренно: в Ruby/Perl/Tcl оно значит «пока НЕ», и в грамматике, где блок подписан «Пока верно», прочиталось бы как отрицание.Проверять есть чем только адресным операндом и операндом времени: события у задачи нет, и свойство без адреса она не принимает (422) — оно сравнивалось бы с пустотой. Правка условия, как и правка шагов, доезжает после выключения и включения группы: сеанс читает описание один раз.
Ветвление НЕЛЬЗЯ разложить на несколько задач: захват целей отбивает вторую задачу, пишущую в ту же нагрузку, как встречный поток — а ветви одного решения пишут именно в неё.
Параметров у задачи нет (см. логику), ретриггера и правила отмены нет (оба отвечают на повторное СРАБАТЫВАНИЕ, а задача не срабатывает — она идёт), ждущих сообщений нет (дедуп вопросов — уникальность (прогон, блок, шаг), а прогон у задачи один навсегда: следующий круг получил бы ответ предыдущего).
Ветка гашения (
on_stop) исполняется при разборке сеанса — выключили группу, выключили задачу, круги подряд отказывали. Она живёт ВНУТРИ того же прогона и только при живой лизе: отдельным прогоном её не запустить (старт требует активной автоматизации, а самая частая причина остановки — как раз выключение).Ветка гашения бывает только у задачи. Мажор 24 раздавал её любой автоматизации; он отозван (см. «Ветка гашения» ниже), и
on_stopу не-задачи отвергается 422. У задачи ветка мажора не поднимает — её грамматика не менялась. Хранится она при этом вautomations.on_stop, а не в строке входа: место переезда отката не потребовало, потому что двойной источник хуже отката. В бандле хаба ветка по-прежнему едет внутри входа (triggers[].on_stop).Исход отказа — у ШАГА, а не у задачи (
then_actions[].on_failure, v29):continue— идти дальше со следующего шага,cancel(умолчание) — закончить сеанс с гашением. «Печь не включилась» и «не ушло уведомление» — отказы разной цены, и в одной цепочке они соседствуют постоянно; прежний общий на всю задачуtriggers[].on_failure(v28) такой разницы выразить не мог и снят (422 с названной заменой).Умолчание
cancelНАМЕРЕННО расходится с умолчаниемon_timeoutу ожидания. Тамcontinueзначит «шаг отработал, дождались не того»; здесь — «действия не произошло вовсе». Тихо считать такой круг успешным опаснее всего в самом типовом случае: у циклограммы «включить → пауза → выключить» падение закрывающей команды оставило бы нагрузку включённой навсегда.Круг с проглоченным отказом (
continue) всё равно считается НЕудачным для предохранителя: иначе десять отказов подряд не наступили бы никогда, и задача с навсегда сломанной публикацией крутилась бы вечно, выглядя здоровой.Поле законно только внутри задачи и не у всякого шага (иначе 422): у
wait_untilисход уже есть (on_timeout), аcontrolне отказывает вовсе — без пригодного измерения он публикуетfail_safe.Условие продолжения сеанса (
while, v23) — СНЯТО (ADR 0031 → отменено ADR 0037). Поле больше не принимается: 422 с указанием замены. Молчаливое вырезание было бы хуже отказа — сеанс, который кончался сам, стал бы вечным, и автор узнал бы об этом от нагрузки, а не от формы.Замена — сами блоки задачи (v29): «Пока верно» и есть условие продолжения сеанса, а «пока выполняется» стало избыточным способом сказать то же самое. Форма, ради которой поле снимали, — отдельная задача-взводчик, пишущая выключатель РАБОЧЕЙ группы, — тоже осталась и упростилась:
Пока верно: exists(t) И t < 22 → включить «Нагрев» Или пока верно: exists(t) И t >= 25 → выключить «Нагрев» Или пока верно: 45 <= t < 52 → (ничего) ← коридор, сеанс держится (не держится ни одно — сеанс кончается веткой остановки и гасит свой выключатель)
Fail-safe «нет показания ⇒ гасим» стал ПРЯМЫМ: показания нет — ни одно условие не держится — сеанс кончается сам, и отдельный блок
not existsдля этого больше не нужен. Спрашиваетexistsименно «есть ли показание по адресу»: прибор, ПЕРЕСТАВШИЙ публиковать, последнее показание из хранилища не стирает — его ловит окно свежести у ожидания (v19), а не этот оператор. Ровно так же вёл себя и снятыйwhile. Композиция вдобавок разворачивается в правильную сторону: сеанс ПОЛЬЗУЕТСЯ нагрузкой, а не нагрузка подглядывает за сеансом через её регистр, — и рабочая группа становится переиспользуемой.Мажор 23 за волной остался: его несут исход ожидания и ветвление. Подробности — ADR 0037.
Потолка работы больше нет (
max_runtime_secondsснят). Вместе с ним пропал единственный предохранитель от ЗАЛИПШЕГО датчика: окно свежести показания ловит ЗАМОЛЧАВШИЙ прибор, но не тот, который бодро публикует правдоподобные 61 °C. Сеанс ограничен только выключателем группы — это осознанный размен, см. ADR 0026. Оттуда же следствие: сеанс читает своё описание ОДИН раз при старте, поэтому правка задачи доезжает после выключения и включения группы.Удаление двухфазное. Снос идущей задачи — 409: сначала выключить, дождаться остановки, потом удалять, иначе гасить нагрузку будет некому.
force=1снимает без остановки, и последствие названо в ответе. Удаление ГРУППЫ делает то же самое перед каскадом: выключает её задачи, ждёт подтверждения ограниченное время и честно предупреждает, если движок не ответил.Потолок задач на локацию — 20, и считаются только НЕ спящие: у группы без выключателя задачи не запускаются никогда, и держать их в бюджете значило бы отдавать лимит тому, что не работает. Обратная сторона закрыта проверкой при правке устройства: возвращение выключателя будит задачи группы разом, и без неё потолок обходился бы парой правок.
Исполнение на хабе. Задача ПЕРЕНОСИМА, в отличие от логики группы: та живёт входом «сообщение на адрес группы», которого на локальной шине нет вовсе, а задача трогает группу ровно одним способом — выключателем. Он приезжает на хаб ЗЕРКАЛОМ состояния групп (retained
rocket/groups, см. Локальное исполнение автоматизаций (Zigbee2MQTT)), и fail-safe у зеркала односторонний: нет документа, нет группы или регистр выключен — гейт ЗАКРЫТ. Требуется раннер≥ 1.14.0: не зная режима сеанса, он проиграл бы цепочку ровно один раз и закрыл прогон. Не переносится задача, у которой не резолвится выключатель (group_gate_unreachable) или чей регулятор зеркалит выход в регистр группы (group_mirror_unreachable) — записать в облачный регистр раннер не может.Версия:
schema_version = 21у линейной задачи (штамп несёт триггерtask, шагcontrolиpause.duration_from) и22— у ветвящейся либо с условием круга.
Ветвление внутри цепочки (branch, v23)¶
Тело шага — те же блоки «Если → Тогда», что и у самой автоматизации. Работает первый подошедший; блок без условия подходит всегда и потому играет роль «иначе». Не подошёл ни один — ветвление не делает ничего, и цепочка идёт дальше со следующего шага:
{ "type": "branch", "blocks": [
{ "condition": { "op": ">=", "left": { "var": "temperature", "device_id": "<uuid>" }, "right": 24 },
"then_actions": [ { "type": "device_command", "device_id": "<uuid>", "payload": "{\"state\":\"OFF\"}" } ] },
{ "condition": null,
"then_actions": [ { "type": "device_command", "device_id": "<uuid>", "payload": "{\"state\":\"ON\"}" } ] } ] }
Отличие от блоков автоматизации ровно одно, и в нём весь смысл. Блок выбирается ОДИН раз, когда прогон начинается; ветка — в тот момент, когда цепочка до неё дошла. После паузы или ожидания обстановка уже другая, и до v23 сказать «а теперь посмотри ещё раз» было нечем. Всё остальное — форма условия, поведение «неизвестно», порядок перебора — совпадает намеренно: автор не должен учить язык дважды.
Обрыв цепочки по условию. Пустой список шагов у ветки законен (в отличие от пустого блока
автоматизации, который означал бы прогон впустую). Подошедшая ветка закрывает собой все следующие,
поэтому ветвление последним шагом с пустой первой веткой — это ровно тот обрыв по условию, который
снял ADR 0022 вместе со шагом stop_run, но выраженный
ветвлением. Чтобы прекратить цепочку в середине, остаток переносится ВНУТРЬ ветки.
Правило продления ветвление не переигрывает. retrigger: extend_while смотрит только на
условие блока, из которого растёт прогон: ветка к моменту продления уже выбрана. Именно эта связка
убила stop_run, и здесь её нет.
Ограничения (все 422 на записи):
глубина вложения ровно одна — ветка внутри ветки отвергается. Причина не в движке, а в чтении: цепочка с двумя уровнями перестаёт читаться сверху вниз, а ради читаемости ветвление и предпочли снятому обрыву;
``control`` внутри ветки нельзя — он кладёт выход в переменную прогона, которой не появилось бы вовсе, пойди цепочка другой веткой;
``message`` в ждущем режиме внутри ветки нельзя — вопрос заполняет параметр для шагов ниже, и «сначала спросили, потом использовали» перестало бы быть свойством текста. Уведомление можно;
операторы перехода в условии ветки нельзя (
CHANGED_TO,CHANGED_FROM_TO,CROSSES_*): «предыдущее значение» принадлежит краю события, запустившего прогон, а уCROSSES_*вдобавок есть гистерезис, ключуемый идентификатором автоматизации, — второе вычисление посреди цепочки сдвинуло бы взведённый флаг под условием блока, которое его уже использовало;в теле фоновой задачи ветвления нет — развилка у неё уже есть и она верхнего уровня: блок выбирается каждый круг заново (v22).
Шаги веток и узлы их условий считаются в общих потолках тела (100 шагов, 400 узлов): потолок, который не видит вложенного, обходился бы одним шагом «Ветвление».
Исполнение — раскрытие. Дойдя до шага, прогон вставляет шаги подошедшей ветки в свой
собственный список сразу за ветвлением и пишет расширенный список в WAL. Позиция шага остаётся
числом, а не превращается в путь, — поэтому дедуп вопросов, ключ идемпотентности шага, продление,
отмена по событию и фазы прогона на шине работают без единой правки. В журнале прогона цепочка
поэтому видна развёрнутой: шаг ветвления и за ним шаги той ветки, которая сработала. Итог
самого шага — branch_taken: ветка N, шагов M либо branch_no_match: … (с причиной, если
ветка не подошла из-за отсутствующего показания). Подробности — ADR 0030.
Шаг «Регулятор» (control, v20)¶
Считает выход по измерению и уставке и кладёт его в переменную прогона (output.var), доступную
следующим шагам круга. Поля «закон» нет: закон определяется тем, какие коэффициенты ненулевые.
Живёт только внутри задачи — вне цикла такт некому повторять (422):
{ "type": "control",
"input": { "device_id": "…", "property": "temperature", "max_age_seconds": 900 },
"setpoint": { "value": 90.0 },
"gains": { "kp": 8.0, "ki": 0.02, "kd": 0.0 },
"direction": "heat",
"output": { "var": "power", "min": 0, "max": 100, "bias": 0, "deadband": 1.0 },
"fail_safe": 0 }
Такт: измерение берётся из кросс-подного стора сигналов по топику (его резолвит Janus из
input.device_id) вместе с меткой времени наблюдения. Нет значения или оно старше max_age →
выход равен fail_safe и публикуется (иначе нагреватель не гаснет). Метка не изменилась →
такт не считает, не публикует и ничего не пишет — именно это позволяет крутить задачу часто, а
датчику говорить редко. Новое измерение → dt считается измерение-к-измерению с клампом
[1 с, max_age]; интеграл копится в единицах выхода (правка kp безударна по построению) с
заморозкой при насыщении; производная берётся по измерению и требует d_filter_seconds при
kd ≠ 0 (422); само окно max_age не короче 180 секунд — трёх кругов задачи (422): уставку крутит человек, а измерение — ступенька с шагом квантования датчика.
Выход из молчания идёт через прайминг, иначе первое измерение после часа тишины дало бы виндап через
восстановление. Состояние привязано к хешу конфигурации: правка коэффициентов сбрасывает накопитель.
Мёртвая зона обязательна и это не украшение: зеркало выхода (mirror_expose) пишется в
регистр группы, а запись регистра инъектируется обратно в движок как событие устройства — контур с
окном в минуту без мёртвой зоны дал бы 1440 синтетических срабатываний в сутки.
Уставка из регистра (setpoint.expose) — это ручка на панели: человек меняет «целевую
температуру», и задача подхватывает её следующим тактом, не пересобираясь.
Длительность из значения (pause.duration_from, v20)¶
Превращает выход регулятора в скважность медленного ШИМ:
{ "type": "pause",
"duration_from": { "var": "power", "in": [0, 100], "scale": [0, 60] } }
Источник ровно один: var — переменная прогона (её кладёт регулятор ЭТОЙ же задачи) либо
expose — числовой регистр группы (так значение читается через границу задач: у двух задач разные
прогоны, и общих переменных у них нет). С константой и duration_arg поле не сочетается (422):
те вычисляются один раз, а это — каждый круг.
Вырожденный сегмент — длина, не дотянувшая до секунды, — пропускается ЦЕЛИКОМ, вместе с командой, которая его открывает: иначе получился бы щелчок ON→OFF без паузы между ними. Так нулевая мощность и означает «не включать».
Порог min_seconds, которым это выражалось раньше, снят (422 на записи). Он был обязательным
полем формы, а его подпись объясняла себя неверно: ноль отсекается сам, без всякого порога. Вместе с
полем ушла и возможность сказать «сегмент короче N секунд не играть» — если она понадобится, вернуть
её придётся как отдельную, честно названную настройку, а не как предохранитель.
Регистр держит секунды: тождественное отображение¶
Когда в источнике лежат уже секунды, а не проценты, границы обоих диапазонов делают одинаковыми:
{ "type": "pause",
"duration_from": { "expose": "duration", "in": [0, 43200], "scale": [0, 43200] } }
Потолок 43200 — потолок самой паузы (12 часов), длиннее её не бывает, поэтому обрезать такому
отображению нечего; автор просто называет его вслух.
Длина из регистра ЖИВАЯ (v30)¶
Пока пауза спит, она перечитывает свой регистр и переставляет срок: конец шага = вход в шаг плюс текущее значение. Так «париться ещё полчаса» выражается правкой настройки прибора, а не отдельной командой прогону, — и одинаково работает, кто бы настройку ни написал: панель, автоматизация, бот или Алиса, все они попадают в один и тот же регистр.
Срок двигается в любую сторону: настройка и есть истина. Значение меньше уже прошедшего времени заканчивает шаг сразу, и цепочка идёт дальше — так «хватит» выражается тем же полем, что и «ещё». Потолок и пол прежние (12 часов, секунда).
Живой источник — только регистр. Длина из переменной круга (var) остаётся снятой на входе: там
лежит выход регулятора ЭТОЙ итерации, и меняться, пока шаг спит, ему нечем. Значение ПАРАМЕТРА живым
не бывает по устройству языка: параметры запекаются при материализации прогона разом везде — в
топике, теле команды, условии и длине, — и «живой» в одном месте из десяти он вводил бы в
заблуждение. Правка значения параметра по-прежнему действует со следующего запуска.
Продление живёт один круг и не переживает рестарт пода: сеанс фоновой задачи начинает круг заново, а следующий возьмёт из регистра то, что там лежит.
Штамп — schema_version = 30, порог раннера хаба — 1.28.0. Оба обязательны, хотя форма записи
не изменилась ни на поле: воркер без волны прочитает ту же паузу и досидит длину, снятую на входе,
ничем не показав, что правку настройки проигнорировал, — а автор увидит прежний конец сеанса и
решит, что настройка не сохранилась.
Отдельный режим duration_from.mode = "seconds" (v28), делавший то же самое без диапазонов,
снят: ключ mode на записи отвергается (422), сохранённые автоматизации переписаны
миграцией. У него были свои, обратные запреты (in и scale запрещались, min_seconds
становился необязательным) и обратное поведение на пустом источнике — не «сегмента нет», а отказ
шага; в редакторе же выбрать его было нечем. Мажор 28 остался без носителей вовсе.
Пустой источник всегда значит «сегмента нет» — «мощности нет, не включать», ровно то, что нужно
ШИМ; отказом шага это не считается. Нужно, чтобы отсутствие значения кончало сеанс, — это пишется
условием блока задачи: «Пока верно: duration существует».
Плоскость автоматизаций на шине (ADR 0028)¶
У автоматизации есть собственный адрес в MQTT — внутри mount point локации:
automations/{kind}/{name} состояние ← только сервер
automations/{kind}/{name}/event фаза прогона ← только сервер
automations/{kind}/{name}/result ответ на команду ← только сервер
automations/{kind}/{name}/set команда ← клиент
automations/{kind}/{name}/get запрос состояния ← клиент
{kind} — tasks | logic | rules, из иммутабельной роли автоматизации в группе.
Отдельный сегмент под вид существует ради одной практической подписки: automations/tasks/# —
«покажи только фоновые задачи». {name} — automations.friendly_name: по умолчанию hex
идентификатора, поэтому переименование автоматизации в панели адрес не двигает.
У группы две плоскости, и это не дубль: как устройство она отвечает по generic/{friendly}
(её регистры), как автоматизации её логика и задачи говорят по automations/logic/* и
automations/tasks/* (прогоны и фазы). Первое — про то, чем группа управляет; второе — про то,
что с ней происходит.
Существенные свойства:
Пространство ``automations/`` закрыто для авторских топиков шага «публикация в топик» и правила отмены — 422 на запись. Такое ребро не видит ни автор-тайм детектор циклов (он строит граф по адресам групп), ни рантаймовый ограничитель (он ключуется устройством группы), и пара взаимных запусков раскрутилась бы без предела.
Три серверных топика клиенту публиковать нельзя. Состояние, фазу и ответ на команду объявляет только сервер; иначе любой, у кого есть логин локации, объявлял бы «прогон идёт» или «команда выполнена», и внешний клиент верил бы подделке.
Сколько автоматизация рассказывает о себе, решает она сама —
telemetry_level:off|state|events|steps, накопительно. Уровень на КАЖДОЙ автоматизации, а не на локации: шумит конкретная задача с окном в минуту, глушить нужно её. Ответ на/getуровню не подчиняется — молчание в ответ на прямой вопрос неотличимо от потери.Состояние не ретейнится. Стор брокера живёт в памяти процесса и теряется на рестарте, а подписчик не отличил бы снимок от живого. Роль «узнать состояние» играет
/get.Адрес отвечает не мгновенно после создания: резолв кэшируется на минуту, включая отрицательные вердикты (защита от потока запросов по несуществующим именам).
Команды (/set) исполняет janus, а не брокер: доменная логика тумблера (проверка графа на цикл, взвод ближайшего запуска, гашение висящих вопросов) живёт там, и второй её реализации быть не должно. Понимаются:
{"state": "ON"} включить (диалект устройств)
{"state": "OFF"} выключить
{"command": "run", "arguments": {…}} разовый запуск (у видов ``tasks`` и
``logic`` его нет — ``unsupported``)
{"command": "stop"} оборвать идущий прогон, тумблер не трогая;
у ФОНОВОЙ ЗАДАЧИ — выключить задачу (ответ
несёт "deactivated": true)
{"command": "deadline", "ends_at": "…"} сдвинуть срок спящего шага (пауза или
ожидание) на момент ISO-8601 — в любую
сторону, в окне от секунды до 12 часов
{"command": "finish_step"} закончить спящий шаг сейчас; цепочка идёт
дальше, как если бы срок наступил
{"command": "telemetry", "level": "events"} сменить уровень телеметрии
{"command": "telemetry", "level": "steps",
"minutes": 15} включить шаги на время (по умолчанию 30 мин)
Ответ приходит на .../result ВСЕГДА, когда адрес разобрался: молчание неотличимо от потери
команды. В ответе ok, при отказе — машинный error (invalid_payload, unsupported,
not_found, not_running, broken, rejected, internal) и человеческий
message. Присланный request_id возвращается эхом — это и есть способ сопоставить ответ с
командой.
Разовый запуск у выключенной автоматизации отвергается (rejected), как и у сломанной
(broken): планировщик отбирает только включённые, и «принято» было бы враньём — взведённое
расписание не сработало бы никогда, а место под потолком разовых запусков заняло бы. У логики
группы и у фоновой задачи его нет вовсе (unsupported): их вход — сама группа и её выключатель,
а разовое расписание владельцу группы write-path не добавляет.
Ретейненная команда не исполняется. Брокер ретейнит любой топик, /set исключением не
является, а сессия у подписчика чистая — снимок приходил бы заново на каждом его переподключении,
то есть один раз опубликованный с retain разовый запуск исполнялся бы при каждом рестарте
пода. Команда — не состояние; её место — обычная публикация. Ответ на такую команду не уходит:
адресат ответа — тот, кто послал её сейчас, а здесь никто ничего не посылал.
Остановка сеанса фоновой задачи выключает задачу. Сеанс живёт, пока включён гейт группы: оборви его и оставь задачу включённой — реконсилер заведёт новый сеанс на ближайшей побудке, и «остановлено» окажется «перезапущено». Выключение видно в том же тумблере, которым задачу включают обратно; скрытая «пауза до перевключения гейта» стала бы новой загадкой «почему не идёт», а выключение гейта погасило бы все задачи группы разом. Обычного прогона это не касается: у него нет гейта, который его перезапустит.
Две задержки названы честно: разовый запуск ждёт до минуты (расписания живут на минутной
сетке, «сейчас» усекается в прошлое) — а у автоматизации с подготовкой ещё и саму подготовку:
lead_seconds прибавляется к времени запуска, иначе анти-дрейф относительных пауз отверг бы
такой запуск всегда (подготовка не бывает короче минуты). Выбранное время возвращается в ответе
полем at. Остановка замечается движком на такте heartbeat — как и выключение тумблера. В
истории прогонов видно обе метки: когда попросили и когда остановилось.
Состояние — тот же диалект, что у устройств и групп (state = "ON"/"OFF"), плюс фаза
(idle | running | waiting), текущий прогон, последний исход, broken с причиной,
ближайший запуск и место исполнения. Фаза waiting означает «прогон спит на паузе или ожидании»
— это отдельный ответ на вопрос «почему ничего не происходит».
Фазы прогона (/event)¶
Состояние отвечает на вопрос «что сейчас», фазы — на другой: «что произошло». Без них внешний клиент не дождётся конца прогона и не отличит «кончился успешно» от «кончился, потому что выключили»: снимок показывает исход только последнего, и два прогона подряд сливаются в один.
Уровень events печатает жизнь прогона:
run_created run_started run_completed run_failed run_interrupted run_extended
run_stopped_deactivated run_stopped_by_request run_stopped_by_prompt
run_stopped_task_reason run_stopped_by_retired_step run_cancelled_by_event
run_skipped_already_running run_skipped_definition_gone
run_skipped_no_block_matched run_skipped_group_gated_off
task_session_started task_session_stopped task_session_interrupted
task_session_ended_by_condition task_session_ended_by_wait
task_session_broken task_session_stalled run_prompt_armed
Уровень steps добавляет шаги и круги фоновой задачи:
step_started step_succeeded step_retry_scheduled step_retry_exhausted
step_skipped_unresolved_duration stop_branch_executed
task_iteration_succeeded task_iteration_failed
task_iteration_deferred task_iteration_over_budget
Сообщение плоское: event, at, automation_id, kind, name, а при наличии —
run_id, step, step_type, reason, error. Подробности берутся из состояния по
тому же адресу.
Существенное:
Имена фаз — это имена журнала движка. Одна и та же строка видна и в логе пода, и на шине; второй словарь означал бы вопрос «а это то же самое событие?» на каждом разборе.
Список публикуемых фаз белый. В журнале движка есть и потеря лизы, и отказ claim-лока — это внутренности воркера, а не фазы прогона. Новое событие журнала наружу по умолчанию не выходит.
Круг фоновой задачи виден любым исходом. Удался, отказал, отсрочен (затор общей шины —
task_iteration_deferred, исчерпан бюджет локации —task_iteration_over_budget) или прошёл простоем в границах (task_iteration_skipped, причина — в полеreason). В журнале прогона часть этих кругов придавлена окном тишины, на шине окна нет:stepsвключают на время и ровно затем, чтобы видеть каждый круг.Ответ на ``/get`` ограничен по темпу — до 5 запросов в секунду с локации (всплеск до 60). Сверх этого вопрос сбрасывается молча: сказать «слишком часто» в топике состояния нечем.
``steps`` живёт со сроком. Это единственный дорогой уровень (до 600 сообщений в минуту с локации), поэтому включается он на время: по умолчанию 30 минут, потолок — сутки. Срок виден в
telemetry_steps_untilи возвращается в ответе на команду. Когда он истёк, действующий уровень —events: гашение возвращает к наблюдению, а не к немоте.Прогонов хаба здесь нет. Локальное исполнение присылает только итог (
run_finished), пачками и с возможным пропуском: публиковать «идёт» по событию, приехавшему через минуты, — врать в реальном времени.
Свойство другого устройства (v22)¶
Операнд с device_id читает последний известный статус названного устройства, а не payload
сработавшего триггера:
{ "op": "and", "children": [
{ "op": "=", "left": { "var": "occupancy" }, "right": true },
{ "op": "<", "left": { "var": "illuminance", "device_id": "<uuid>" }, "right": 50 } ] }
То есть «свет по движению, но только если темно» — одно правило, а не композиция с ожиданием.
Существенные свойства:
Адрес разрешает сервер. Клиент присылает
device_id; Janus резолвит топик состояния (DeviceTopic::forDevice) и кладёт его рядом вtopic_suffix, переопределяя присланное — ровно как уwait_until,cancel_onиcontrol.input. Движок читает состояние по топику (util.SignalStoreKey), поэтому голыйdevice_idбез суффикса адресным операндом не считается. Устройство обязано принадлежать локации автоматизации (иначе 422).Нет показания — «неизвестно», а не ложь. Устройство молчит, статуса ещё не приходило, свойства нет в присланном пакете — во всех трёх случаях у операнда нет ответа. «Неизвестно» не инвертируется отрицанием (иначе
НЕ (окно = открыто)на мёртвом датчике открывало бы нагрузку), проигрывает лжи вИи истине вИЛИ— то есть гасит только свою ветвь, если соседняя уже решила исход, — а на корне блока означает «блок не подошёл». Причина уезжает в журнал прогона отдельным полем: «круг ничего не сделал» — это не диагноз.``EXISTS`` — исключение. Он и ЕСТЬ вопрос «есть ли значение», поэтому для него молчание — честный ответ
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.
Числовые сравнения (
<,>,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.
Параметры (schema_version 8)¶
Параметр — именованное типизированное значение автоматизации, которым пользователь влияет на её работу, не редактируя логику. Определение задаётся в редакторе, а значения спрашиваются в момент запуска: при включении ранее остановленной автоматизации (любого вида — по расписанию, по устройству, по регистру группы) и при добавлении разового запуска.
Определения — массив automation.arguments (опционально). Определение несёт метаданные
(label/description/constraints) для UI и валидации:
{ "name": "550e8400-e29b-41d4-a716-446655440000", "type": "number",
"label": "Целевая температура",
"required": true,
"preset": 22,
"constraints": { "min": 5, "max": 35, "step": 0.5, "unit": "°C" } }
name— служебный ключ, уникален в пределах автоматизации. По нему на параметр ссылаются условие, токены{{arg:…}}иduration_arg, и он же — ключ во всех картах значений. Человеческого смысла не несёт: редактор Rocket Home генерирует его сам (канонический lowercase-UUID) и пользователю не показывает — так ключ переживает переименование параметра и перестановку списка. Паттерн допускает две формы —^(?:[a-z][a-z0-9_]*|<uuid>)$: прежние человеческие имена остаются валидными ключами (иначе сохранённые автоматизации перестали бы приниматься), а клиент вправе прислать своё имя. Uppercase-UUID отвергается: ключ и ссылка сравниваются точным равенством, регистронезависимости в исполнении нет. Клиенту следует показывать человекуlabel, аnameиспользовать только как ключ. Подробности — ADR 0011.label— название параметра для человека, единственное, чем его опознают и в интерфейсе, и в сообщениях 422 (ключ непрозрачен). Обязательно и уникально в пределах автоматизации (регистр и обрамляющие пробелы разных названий не создают); нарушение — 422 наarguments.N.label. Правило серверное и общее для всех писателей (ADR 0013). Роль названия может играть легаси-имя: у параметра с человеческим ключом отсутствующийlabelоткатывается наname; у ключа, сгенерированного редактором (UUID), фолбэка нет. Строки СОХРАНЁННОЙ автоматизации, название которых не изменилось, правилу не подчиняются — чужая безымянная автоматизация (бот, MCP) остаётся редактируемой и сохраняемой.type—number|string|boolean|enum|datetime(datetimeна wire = ISO-8601 строка).required(опц., по умолчаниюfalse) — обязательность: значение обязано быть в каждой точке ввода, иначе 422. Активной автоматизация с незаполненным обязательным параметром быть не может — запись выключает её (is_active: false), значения спрашивают при включении (значение засчитывается на любом уровне, включая собственные значения разовых расписаний).preset— предустановленное значение, необязательное; матчитtypeиconstraints. Им предзаполняется диалог ввода, и оно же — резерв исполнения. Без него у незаполненного параметра значения при исполнении нет вовсе.Относительное значение у ``datetime``. Пользователь его не настраивает: редактор ставит токен, который резолвит в дату исполнения (в UTC) сам hmq2 —
"today"для режима «дата без времени» (constraints.format=date) →YYYY-MM-DDи"today_midnight"для режима с временем →YYYY-MM-DDT00:00."today_noon"(YYYY-MM-DDT12:00) — легаси-токен режима с временем: редактор его больше не ставит, но сохранённые автоматизации им полны, поэтому он остаётся валидным на запись и резолвится по-прежнему. Токен допустим только какpreset: введённое значение всегда конкретное (иначе — 422).constraints(опц., по типу):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 подставляет число как есть.
Прежнее поле 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 (модель обязательности — ADR 0010).
Три уровня значений¶
Уровень |
Где |
Когда вводится |
|---|---|---|
Предустановленное значение |
|
в редакторе автоматизации |
Текущие значения |
|
при запуске ранее остановленной автоматизации; правятся без перезапуска |
Значения разового расписания |
|
при добавлении разового запуска и при переактивации отработавшего |
Эффективное значение при исполнении:
time_trigger.arguments[name] ?? automation.argument_values[name] ?? preset.
Правила:
значения обязаны покрывать все обязательные параметры — карта в каждой точке ввода заменяется целиком; необязательные можно не присылать (действует
preset, а без него значения нет вовсе), ноnullу них не сохраняется: записанный ключ перекрыл быpresetпустотой;запуск автоматизации с обязательными параметрами без карты значений → 422: момент ввода — часть контракта запуска (в т.ч. для Bot/MCP-клиентов). Автоматизация из одних необязательных параметров запускается и без карты;
автоматизация, у обязательного параметра которой нет значения ни на одном уровне, не может быть активной: запись возвращает её с
is_active: false, значения спрашивают при включении. Значения разовых расписаний тут засчитываются, но только если их несёт каждое расписание автоматизации: иначе один из запусков всё равно ушёл бы с пустым параметром;у повторяющегося (cron/солнечного),
device- иgroup_message-триггера своих значений нет — они исполняются на текущих значениях автоматизации; значения на таком триггере → 422;is_activeразового расписания принадлежит пользователю: тумблер автоматизации его не перезаписывает (иначе включение перевзводило бы отработавшие запуски). hmq2 фильтрует по обоим флагам, поэтому у выключенной автоматизации взведённое расписание не сработает;переактивация отработавшего разового расписания требует нового времени: прошедшее целевое время планировщик уже не выберет.
Правило одно для всех типов, включая параметр-длительность
(pause.duration_arg): проверки резолвимости значения у него нет — незаполненный параметр
законен, см. «Значения нет».
Ссылки на значения. В условии — операнд-ссылка (каноническая форма):
{ "arg": "target_temp" } // рядом с { "var": ... } и типизированной константой
argRef допустим везде, где ждётся операнд/константа/число: left/right сравнения, to/from
переходов, threshold/hysteresis порогов. В числовых позициях параметр обязан быть number.
Примечание
В threshold/hysteresis работает только number: движок приводит эти позиции через
ParseFloat, а значение datetime-параметра — ISO-строка, которая не парсится, поэтому
сравнение не срабатывало бы никогда. Сервер такую пару отвергает (422 по ключу блока,
схема 8.0.0); прежде она принималась «исторической широтой проверки», но и тогда была мёртвой.
Проверка целиком серверная (ADR 0013): своих запретов на типы у редактора условий нет.
Отдельно: нерезолвнутый или неположительный гистерезис-параметр не ошибка — полоса просто
исчезает, и crosses_* работает как детектор фронта без антидребезга.
В строковых полях действий (payload, topic, body, subject, to) — плейсхолдеры
(namespaced, во избежание коллизий имён):
{{arg:name}}— значение параметра.{{prop:path}}— значение свойства из payload сработавшего триггера (справочник сигналов —GET /devices/{id}/condition-signals). Payload есть только у триггераdeviceиgroup_message(у группы это само сообщение шины, нормализованное к именам её регистров); на автоматизации только с расписанием такой плейсхолдер отвергается на записи (422).{{prop:<device_id>.path}}— device-квалифицированная форма: значение свойства конкретного устройства, захваченного предшествующим шагомwait_until(см. «Действия» → late-binding). Резолв ленивый, на исполнении, изcaptured_payloadэтого шага. Резолв — последний проход над шагом, поэтому всё, что не удалось подставить, резолвится в пустую строку: и «шаг отработал, но ничего не наблюдал» (молчащее устройство, таймаут), и ссылка вперёд на ещё не выполнившийся шаг. Сырой текст токена не уезжает ни в сообщение, ни на шину.Путь
*— весь payload целиком, компактным JSON с отсортированными ключами:{{prop:*}}(payload триггера) и{{prop:<device_id>.*}}(захваченный payload; квалификатор обязан быть UUID устройства —nested.*это обычный путь, а не «весь payload»). Если устройство публикует не JSON-объект (голоеON, число, массив),*отдаёт этот payload как есть. Это payload конкретного сообщения, а не накопленное состояние: у z2m это обычно полный статус, но кнопка пришлёт толькоaction, и в дамп попадают служебные ключи (linkquality,battery). Автоматизация с таким плейсхолдером стампитсяschema_version = 6. В поляхtopicиtoпуть*отвергается (422) — JSON-дамп вместо топика или адресата всегда ошибка автора.
Плейсхолдер заменяется строковым рендером значения (число → 50, строка → как есть, bool →
true, datetime → ISO); JSON-кавычки в payload ставит автор ({"mode":"{{arg:mode}}"}).
Синтаксис {{…}} зарезервирован.
Display. Параметр — первоклассный токен и в поверхностном синтаксисе: в условии рендерится
чипом (leb-arg, глиф @Название) рядом со свойством/функцией/константой. Превью действия
(DescribeTopicMessage) заменяет плейсхолдер на @<ключ>, а не на сырой {{…}}, —
названий сервер не знает (в запросе их нет), поэтому подпись подставляет клиент, у которого есть
определения параметров. Клиент, который этого не делает (бот, MCP), увидит служебный ключ.
Где что доступно (с v22). Условие вычисляется на device-, group_message- и
time-триггерах, а у фоновой задачи — в начале каждого круга. Свойство БЕЗ адреса означает «то,
что пришло со статусом», поэтому там, где статуса нет (только расписание, задача), оно отвергается
на записи: сравнение с пустотой тихо ложно, а под != тихо истинно. Читать состояние в таких
автоматизациях нужно адресным операндом (см. «Свойство другого устройства»). Оба follow-up’а
прежней редакции — свойства произвольного устройства на расписании и снятие ограничения условия на
time-триггерах — этим закрыты.
{{prop:…}} по-прежнему читается из payload сработавшего триггера, снятого в момент материализации
run: для device/group_message он на руках, а на расписании его нет — голый плейсхолдер
свойства там отвергается на записи, а не подставляется пустотой.
Регистры прибора: происхождение и две границы¶
Регистр — именованное значение состояния прибора. Появляется присвоением, и создать его
вправе только сам прибор: его шаги, обработчики и фоновые задачи. Входящее сообщение регистров не
заводит — иначе посторонний клиент лепил бы прибору поля своим /set.
Умение — регистр, объявленный через справочник (origin = declared): типизирован,
валидируется по метаданным функции, участвует в панели, Алисе и матрице совместимости.
Виртуальный регистр (origin = virtual) умением не объявлен — тип берётся из значения.
Здесь живут фаза сеанса, счётчик кругов, «сколько парить». Цена названа вслух: словарной
валидации у него нет, диапазона взять неоткуда.
У регистра две независимые границы, обе ставятся руками; у умения подняты, у виртуального опущены:
published— попадает ли регистр в состояние, которое прибор отдаёт;externally_writable— вправе ли его писать входящее сообщение.
Независимы они не для симметрии. Регулируемая настройка («сколько парить») должна приходить снаружи и быть видна на карточке, не становясь умением; служебное состояние («фаза сеанса») не обязано быть видно, чтобы им пользовались собственные задачи прибора. Запрещена ровно одна комбинация: принимать запись, не публикуя (422) — это прибор, у которого выключатель есть, а лампочки нет, и клиент не может узнать, послушались его или нет.
Граница публикации действует на ПРОВОД, а не на движок. Собственные обработчики и задачи видят ВСЕ регистры; фильтр применяется к тому, что уходит на шину, в панель и Алисе — правило одно на все три выхода. Иначе условие «пока фаза = парение» читало бы signal-store, куда скрытый регистр не попал, получало бы «неизвестно» — а у фоновых задач «не знаю» это ЛОЖЬ (fail-safe), — и круг не запускался бы ни разу, без единой строки об ошибке. Прибор, которому нельзя иметь скрытое состояние, — не прибор, а витрина.
Реконсиляция умений виртуальных регистров не касается: сиротой не бывает то, у чего никогда не было умения. Регистр, который потом объявили умением, повышается в него — с типом из справочника, поднятыми границами и сохранённым значением.
Обработчик записи, привязанный к регистру (v27)¶
Логика группы обслуживала сообщение целиком: блоки «Если / Или если», работает первый подошедший.
Этого хватало, пока сообщение было про один регистр, и переставало хватать на батче: сцена
{"on":true,"ambience":"movie"} получала РОВНО ОДИН блок, и вторая половина исчезала молча.
Блок может назвать регистр (expose_name), обработчик записи которого он переопределяет.
Дальше правило простое: на каждый регистр сообщения — свой обработчик, они складываются в
цепочку в порядке объявления умений, внутри регистра работает первый подошедший блок.
Обработчик видит ВСЁ сообщение, а не только свой ключ. Условие вычисляется по целому payload, поэтому обработчик
onвправе спросить проambienceи уступить ему — именно так батч перестаёт разъезжаться на две рассылки. Отбор по своему ключу запретил бы это ровно там, где нужно.Умолчание — ПОРЕГИСТРОВОЕ. Регистр без переопределения обслуживает встроенный обработчик. До v27 сам факт наличия логики подавлял умолчание целиком, и прибор с одним описанным регистром переставал отвечать на остальные — то есть переопределение одного обработчика делало его наполовину немым.
Смешивать нельзя (422): в ветке «Запись» либо ВСЕ блоки привязаны к регистру, либо ни один. Два правила отбора за одно сообщение не объяснить, и автор не смог бы предсказать, что выполнится.
Грамматика сужена до команд (422 на прочее):
device_command,set_group_expose,apply_default. Обработчик отвечает на присвоение, пока сообщение ждёт ответа; пауза, ожидание, сообщение, ветвление и регулятор означали бы обработчик, способный зависнуть на минуты. Долгое поведение выражается фоновой задачей.Регистр обязан существовать и быть открытым для записи снаружи (422). Это не спорит с правилом «появляется присвоением»: обработчик отвечает на присвоение ИЗВНЕ, а извне пишутся только регистры с поднятой границей. Привязка к чему-то другому — не «ещё не появился», а обработчик, который не сработает НИКОГДА.
Не подошёл ни один обработчик — прогона нет вовсе: «обработчик, который ничего не записал, и есть отказ», а пустой прогон был бы записью в журнал без содержания.
Умолчание берётся по типу умения из словаря: диапазон у range, перечень значений у
mode. До v27 дефолт приводил значение только к ТИПУ регистра, и {"brightness": 999} с шины
ложился как есть — тогда как та же команда через REST панели получала 422. Одна команда, две
двери, разный результат. Весь батч применяется одной транзакцией, а побудка задач и публикация —
строго ПОСЛЕ коммита: иначе запись on разбудила бы задачу раньше, чем запишется duration.
``TOGGLE`` инвертирует булев регистр атомарно, в одном операторе с чтением: посчитай движок новое значение у себя, два одновременных нажатия дали бы одно переключение. Повтор ОДНОГО шага (ретрай доставки) инверсию не удваивает — команда из шага несёт ключ идемпотентности, и её повтор отбрасывается. Без этого «переключить» не делало бы НИЧЕГО ровно тогда, когда первая попытка не долетела.
Два шага обработчика¶
«Применить умолчание» (apply_default) — позвать встроенную запись СВОЕГО регистра. Без
него переопределение — всегда «ВМЕСТО умолчания» и никогда «вдобавок»: автор, которому нужно лишь
разослать команду участникам, сохранив обычную запись регистра, повторял бы её сам вместе со всей
валидацией словаря. Живёт только внутри обработчика (422 где угодно ещё). Мягкий, как и сам
дефолт: значение вне правил умения и ключ, которого в сообщении не было, пропускаются — обработчик
отвечает на ЧУЖОЕ сообщение, и ронять цепочку из-за ошибки отправителя было бы наказанием не тому.
«Ответить» (reply) — обработчик ЧТЕНИЯ сам решает, когда прибор отвечает состоянием. До
него /get отвечал сразу и безусловно, ещё до того как логика чтения что-либо сделает: порядок
«сначала ответ, потом работа» превращал ответ в снимок ПРОШЛОГО. Только ветка «Чтение» (422 в
записи: команда — не вопрос) и обязателен в КАЖДОМ её блоке, если хоть один его несёт: как только
логика взяла ответ на себя, немедленный ответ движка подавляется, и блок без «Ответить» оставил бы
обращение вовсе без ответа.
Запасной путь сохранён. Прогон может не завестись — занят (инвариант «один активный прогон»), ни один блок не подошёл, определение выключили между сообщением и стартом, — и тогда отвечает движок. Ответ, живущий ТОЛЬКО внутри прогона, делал бы прибор молчащим ровно тогда, когда он занят.
У обоих шагов нет полей автора: группу, регистр и источник значения впечатывает сервер из блока, в котором шаг стоит. Ошибиться в них негде, а спроси мы их у автора — место шага и его содержимое смогли бы разойтись.
Команда группе доходит до группы (v26)¶
Шаг «Действие», нацеленный на устройство-ГРУППУ, попадает в её вход — тот же, куда приходит сообщение с шины: запускает логику направления «Запись» (или встроенный дефолт, если логики нет) и будит её фоновые задачи. Группа объявлена first-class устройством, и «включить группу» обязано работать тем же шагом, что и «включить лампу».
{ "type": "device_command", "device_id": "<uuid группы>", "payload": "{\"state\":\"ON\"}" }
Что было сломано. Публикация из движка — СЕРВЕРНАЯ, а хук групповой плоскости
(plugins/bridge/group_plane.go) зовётся только на КЛИЕНТСКИХ пакетах. Команда шага уезжала
подписчикам топика и не запускала ничего: ни логику, ни задачи. При этом шаг завершался успехом, и
в журнале прогона стояло step_succeeded — цепочка шла дальше, а баня оставалась холодной.
Отсюда все обходные пути: включать группу приходилось записью её регистра (set_group_expose)
мимо её собственных правил, а длинные цепочки — держать снаружи прибора.
Как чинится. Движок подаёт команду ин-процессной инъекцией в тот же канал, которым уже
пользуется Алиса (InjectGroupCommand). Обещание шага при этом не меняется: «команда подана», а
не «прибор послушался», — ровно то же, что даёт публикация на шину.
Адресов два, и оба работают:
device_id(его кладёт редактор) и голыйtopic(generic/{friendly}/set— форма внешних клиентов, у которых устройства в Janus нет). Вердикт «этот адрес принадлежит группе» кэшируется вместе с ОТРИЦАТЕЛЬНЫМ: обычных устройств на порядки больше, и без кэша негативов каждый шаг добавлял бы запрос в БД.``/get`` тоже доезжает и остаётся вопросом: направление берётся из хвоста адреса. Иначе вопрос стал бы записью и дал бы вторую публикацию состояния на одно обращение.
Версия:
schema_version = 26у автоматизации, чей шаг адресует группу. Штамп обязателен именно из-за тишины прежнего отказа: воркер без v26 «выполнил» бы шаг, опубликовав в пустоту, и пошёл бы по цепочке дальше. Со штампом он fail-safe скипает автоматизацию целиком.Порядок выката: hmq2 ПЕРВЫМ. Штамп гейтит СТАРТ прогона; выкати Janus раньше — автоматизация со штампом 26 не подберётся ни одним воркером, включая её негрупповые шаги.
Хаб: такая автоматизация исполняется только в облаке (блокер
group_grammar). Порога версии раннера у неё НЕТ и быть не может: на хабе от группы есть лишь read-only зеркало регистров, и «с такого-то раннера поедет» было бы обещанием, которого никто не сдержит.Чего команда пока НЕ умеет:
TOGGLE. Значение пишет не сам прибор, а встроенный дефолт, и инвертировать ему нечем — строкаTOGGLEв булев регистр молча пропускается, поэтому редактор этот вариант для групп не предлагает.
Payload команды группе на записи НЕ проверяется против её регистров: что с ним делать, решает логика группы, а её в момент сохранения шага может ещё не быть. Ключ, которого у группы нет, дефолт пропускает молча.
Подробности — ADR 0034.
Ветка гашения (on_stop) — свойство фоновой ЗАДАЧИ¶
Что послать, когда сеанс задачи разбирают: выключили группу, выключили задачу, круги подряд отказывали, либо круг отказал у задачи с исходом «закончить сеанс». Ветка играется внутри того же прогона и только при живой лизе — подробности в разделе «Фоновая задача» выше.
{ "name": "Баня · сеанс",
"on_stop": [ { "type": "device_command", "device_id": "…", "payload": "{\"state\":\"OFF\"}" } ],
"triggers": [ { "kind": "task", … } ] }
Ключ on_stop — верхнего уровня (не внутри входа), но осмыслен только при входе вида ``task``:
у автоматизации без такого входа он отвергается 422 с указанием, где описывать долгое поведение.
Грамматика ветки сужена до команд (device_command, set_group_expose), не больше десяти
шагов. Пауза, ожидание или сообщение здесь означали бы гашение, которое само способно зависнуть, —
а гасят ровно тогда, когда ждать уже нельзя. Незнакомый шаг пропускается с предупреждением:
остальные команды обязаны уйти. Пустая ветка законна и остаётся самым частым состоянием —
«оставить как есть» это выбор, а не недозаполненная форма.
Ветка не делает остановку транзакционной. Она снимает нагрузку, но не откатывает необратимое: отправленное письмо остаётся отправленным, открытый клапан — если его нет в ветке — открытым.
Мажор 24 (ветка у ЛЮБОЙ автоматизации) ОТОЗВАН 01.09.2026; номер занят навсегда и не
переиспользуется. Он лечил симптом: длинную цепочку приходилось держать снаружи прибора, потому что
команда группе из шага молча не доходила, — и обычному прогону понадобилось гашение. Дыру закрывает
отдельная волна, а долгое поведение возвращается внутрь прибора, где ветка была с самого начала.
Штамп schema_version = 24 больше не выставляется, порог раннера 1.19.0 снят. См.
ADR 0032 (отозван).
Хранение переезд пережило: ветка лежит в automations.on_stop, а не в строке входа — возвращать
triggers[].on_stop значило бы восстанавливать двойной источник ради отката. В бандле хаба ветка
по-прежнему едет внутри входа задачи. set_group_expose в локальную ветку не попадает — регистр
группы на хабе read-only зеркало, и автоматизация с таким шагом непереносима (блокер
group_grammar).
Версионирование и расширяемость (SemVer)¶
Контракт версионируется семантически. Мажор несёт automation.schema_version (дефолт 1). JSON Schema —
файл automation-contract.v1.schema.json (внутренняя version растёт, $id сохраняется для
непрерывности ссылок). Актуальная версия схемы — 43.0.0: длина спящей паузы, взятая из
РЕГИСТРА прибора (duration_from.expose), стала живой — пока шаг спит, он перечитывает настройку
и переставляет свой срок, в любую сторону. Форма записи не изменилась ни на поле; изменилась
семантика, поэтому стамп грамматики — 30, порог версии раннера — 1.28.0 (ADR 0039).
До неё — 42.0.0: снят порог наименьшей длительности сегмента (duration_from.min_seconds);
грамматика не менялась, стамп остался прежним.
До неё — 41.0.0: снят режим seconds у длины паузы (duration_from.mode). Мажор 28
остался без носителей вовсе, порог 1.22.0 снят; штамп 28 больше не ставится, а сохранённые
автоматизации переписаны тождественным отображением миграцией.
До неё — 39.0.0: фоновая задача сказана как ЦИКЛ.
Блоки означают «Пока верно», сеанс кончается, когда не держится ни одно условие, у блока появился
момент сверки (check), у шага — исход отказа (on_failure), а блок без действий стал законным
способом сказать «держись, ничего не делай». Исход отказа круга у ВХОДА задачи снят. Стамп
грамматики — 29, порог версии раннера — 1.24.0 (ADR 0038).
До неё — 38.0.0: снятие условия продолжения сеанса (triggers[].while, ADR 0037); грамматика
при этом не менялась, стамп 23 остался за двумя другими носителями волны. До неё — 37.0.0: исход
отказа круга у фоновой задачи (triggers[].on_failure, снят волной 29) и режим seconds у
длины паузы (duration_from.mode); стамп грамматики — 28, порог раннера — 1.22.0.
Предыдущая — 36.0.0: обработчик записи, привязанный к
РЕГИСТРУ (condition_blocks[].expose_name), и два его шага — «Применить умолчание»
(apply_default) и «Ответить» (reply). Стамп грамматики — 27, порога версии раннера
нет: логика группы на хаб не едет вовсе. До неё — 35.0.0: команда группе из шага доходит до
группы; стамп грамматики — 26, порога версии раннера тоже нет (такая автоматизация исполняется
только в облаке). local-bundle не меняется. До неё — 34.0.0: мажоры 24 и 25 ОТОЗВАНЫ. Ветка
гашения снова бывает только у фоновой задачи (on_stop у не-задачи → 422), шаг «Регистр группы»
снова пишет ОДИН регистр (пачки writes[] нет), у адресного операнда условия нет окна свежести
(max_age_seconds). Штампы schema_version 24 и 25 больше не выставляются, номера заняты
навсегда — следующая волна берёт 26. local-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 — «исполняется вне облака» (Локальное исполнение автоматизаций (Zigbee2MQTT)). Грамматику 9 не расширяет:
автоматизация с execution_place = local штампится max(9, грамматика), чтобы поды hmq2 без
гейтинга по execution_place (включая откаченные) fail-safe-скипали её на device- и
многошаговом тайм-пути. Fast-path планировщика (одношаговая тайм-автоматизация) версию не
проверяет — его закрывает next_due_at_utc = NULL, который janus проставляет при переносе.
Возврат в облако пересчитывает штамп по фактической грамматике (понижение законно).
Порядок выката колонок: колонки automations.arguments / automations.argument_values /
automation_time_triggers.arguments добавляет только Janus-миграция (инвариант «эволюция схемы в
Janus»). Janus выкатывается первым — иначе новый hmq2 упрётся в несуществующую
argument_values. Старый hmq2 её игнорирует и fail-safe-скипает schema_version=8, поэтому
Janus-first безопасен: автоматизации с параметрами не исполняются до апдейта hmq2, но и не мисфайрят на
предустановленных значениях вместо введённых.
MAJOR (
schema_versionрастёт): удаление/переименованиеop/fn/типов действий/видов триггеров, смена формы узла/семантики/типизации. Требует координации hmq2 + backfill.MINOR (версия не меняется, аддитивно): новый
op/fn/вид узла, новый тип действия/канал/ режим расписания, новое опциональное поле с безопасным дефолтом.PATCH: правки документации/сообщений валидации; wire не меняется.
Forward-compat / fail-safe (инвариант всех потребителей):
Бэкенд-валидатор строго отвергает неизвестные значения на записи — мусор/термы новее схемы Janus в БД не попадают.
hmq2, встретив терм новее (окно выката, Janus впереди), трактует его как ошибку вычисления → блок не срабатывает + лог, НЕ падает. Поэтому MINOR-добавления rollout-safe: сначала выкатывается hmq2 (умеет новое), затем Janus начинает эмитить.
Фронт-билдер и PHP-валидатор идут в одном деплое (набор
op/fnсинхронен), поэтому фронт не встречаетop, который бэкенд отверг бы на записи. Оговорка: при устаревшем SPA-бандле в окне MINOR-выката (условие с новымopуже сохранено более свежим Janus, а у пользователя закэширован старый бандл) билдер сейчас молча отбрасывает незнакомый узел на загрузке (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.