跳转到内容

automations/*.toml

配置目录下 automations/ 里的所有 .toml 按文件名字典序加载,[[rule]][actions.<名字>] 汇总成两张全局表。拆成几个文件纯粹是给人看的,引擎不区分——所以 规则名和命名动作名都是跨文件唯一。

[[rule]]
name = "fan_on_hot"
trigger = { entity = "thermo.temperature", above = 28.0, for = "5m" }
condition = { time = "07:00..23:00" }
action = { entity = "fan.power", set = true }
mode = "restart"

这是配置目录里唯一参与热重载的一份:改完跑 rha reload(或发 SIGHUP)即可生效, 不用重启。设备、bridge、[api] 的变更都要重启。

trigger 命中 → throttle 窗口 → condition 求值 → mode 并发判定 → 执行动作序列

顺序是固定的,每一道都会在 trace 里留下痕迹(throttled / blocked / skipped / restarted / done)。「规则为什么没跑」先看 trace,别猜。

两个反直觉的细节:节流闸排在条件之前,但计时起点只在真正执行时才刷新——被条件挡下 的那一次不占用窗口;cron 触发豁免 throttle,但条件与并发模式照过。

above / below / fired / changed / cron 必须恰好写一个。引擎里没有「区间 触发」,既要 above 又要 below 得拆成两条规则。

本页有两个 above,语义不一样,这是最容易混的一点:触发器的是边沿(跨越那一刻 触发一次),下一节条件的是电平(触发时刻当前值在不在阈值外)。所以 trigger.above = 28condition.above = 28 并不互相抵消,两张表要分开看。

fired 要配的瞬时信号实体由 adapter 产出,当前只有 reolink 有。

字段类型默认说明
abovenumber可省略

数值实体向上穿越阈值时触发, 判定是严格大于(值 > 阈值)。

这是边沿语义, 不是「每轮采样只要超标就触发」 —— 只在「上一次的值不越界、这一次 越界」的那一刻触发一次; 之后值继续待在阈值之上怎么变都不会再触发, 要等它跌回阈值 以下再重新升上来才算下一次。阈值 28 时, 29°C 升到 30°C 不触发。

几个边角:

  • 严格 >, 正好等于阈值不算越界。
  • 实体第一次上报就已越界(此前没有旧值)也算一次边沿, 会触发。所以没开 storage 快照的部署里, daemon 重启后温度依然偏高会再触发一次; 开了 storage 时旧值从快照 恢复, 不会重复触发。
  • 值没变的重复上报根本不产生事件, 不会因为设备勤上报而反复触发。
  • 类型对不上的上报(数值实体收到非数值)在入口就被整条丢弃, 连状态变化事件 都不会产生。所以它既不触发, 也不算一次「回落」 —— for 的等待计时不会因此 被取消, 到点了照样触发。
belownumber可省略

数值实体向下穿越阈值时触发, 判定是严格小于(值 < 阈值)。与 above 完全对称: 同样是边沿语义, 只在「上次不越界、这次越界」的那一刻触发一次; 正好等于阈值不算越界。

不能和 above 同时写 —— 引擎没有「区间内」触发, 两个都写会被 check 拒绝。 想要「跌破 X 或升过 Y」请写成两条规则。

changedboolean可省略

实体状态每次真的发生变化就触发, 不看变成了什么值。适合「只要开关动了就记一笔 / 通知一次」这类规则。

fired 一样只看键写没写、不看值: changed = false 等价于 changed = true

「没变的上报」不算变化 —— 设备每 30s 报同一个温度只会触发 0 次。反过来, 数值实体上 配 changed 会在每次微小波动时触发, 配合缺省 1s 的 throttle 仍然可能很吵, 数值场景通常应该用 above / below

不能配 kind = "trigger" 的实体(报 changed cannot watch trigger entity), 那种实体请用 fired

cronstring可省略

按时间表定时触发, 与任何实体无关。表达式用 croner 方言: 标准的 5 段 (分 时 日 月 周), 也可以在最前面多写一段秒变成 6 段。按本机本地时区计算下一次 触发时刻。

秒是可选的第一段, 这是最容易写错的地方: "0 7 * * *" 是每天 7:00, "*/10 * * * * *" 才是每 10 秒。段数写错会被 check 当场拒绝。

时区取的是容器 / 主机的本地时区 —— 容器时区没跟宿主对齐会整体偏几个小时。

cron 触发豁免 throttle 最小间隔, 但仍然要过 condition 和并发模式(mode) 两道闸: 上一次动作序列还在跑且 mode = "ignore"(缺省)时这次会被跳过。 不能同时写 entity, 也不能配 for。改完规则热加载会重建所有 cron 定时器。

entitystring可省略

这条规则盯哪个实体, 写实体 id(<设备名>.<实体名>, 例 thermo_demo.temperature)。除 cron 外的所有触发方式都靠它定位事件来源; 引擎收到事件时按实体 id 精确比对, 不匹配就直接跳过这条规则。

实体必须真实存在(由设备段声明): 拼错的 id 在 check 阶段就被拒 (trigger entity ... does not exist), 不会到运行期才静默失效。实体的种类 也会一并校验 —— fired 只能配 kind = "trigger" 的实体, changed 反过来不能配 trigger 实体, above / below 只能配数值实体(int / float)。

cron 触发必须不写它, 写了报 trigger entity must be absent for cron triggers

firedboolean可省略

监听「瞬时信号」实体的一次动作 —— 门铃按下、按键点击这类。这类实体没有持续状态, 只在被按下的那一刻由 adapter 上报一次, 平时读不到任何值。

它不是在 devices.toml 里声明出来的 —— 设备段没有「把某个实体声明成 trigger」 这种写法, 实体的种类由 adapter 决定。当前唯一产出 trigger 实体的是 reolink, 固定给出 <设备名>.pressed(门铃按下)和 <设备名>.motion(侦测到移动)两个。

只看这个键写没写, 不看写的值: fired = falsefired = true 行为完全一样, 都会建立 fired 触发器。想临时关掉规则请把整条规则注释掉, 别指望 fired = false

entity 必须是 kind = "trigger" 的实体, 否则 check 报 fired requires trigger entity; 普通传感器 / 开关不会产生这种事件。

forstring可省略

给阈值触发加「持续多久」的要求: 值穿越阈值后并不立刻执行动作, 而是先等这段时间, 期间没有跌回阈值内侧、且到期时当前值仍在阈值外侧, 才真正触发。写法是 for = "5m" / "30s" / "1h30m" 这类人类可读的时长。不写就是穿越那一瞬间立即触发。

只能配 above / below —— 配在 fired / changed / cron 上会被 check 拒绝 (for requires an above/below trigger)。

等待期间只要收到一次「回落」事件, 计时就被取消, 得重新穿越才会重新开始计时, 不累计。到期时会再查一次当前值兜底, 仍越界才执行; 如果计时期间实体不再上报, 到期读到的就是最后已知值。

热加载规则(rha reload / SIGHUP)会清掉所有正在等待的计时器 —— 已经等了 4 分钟的 for = "5m" 会归零重来。到期触发仍要过 throttle 这道闸(只有 cron 豁免)。

一个 condition 块只能是一种条件:实体谓词(entity + above/below/equals)、 timeallanynot 五组恰好命中一组。所以

condition = { entity = "a.b", above = 1, time = "07:00..23:00" }

不是「而且」,是配置错误。要「而且」只有 all 一条路:

[[rule]]
name = "fan_on_hot_daytime"
trigger = { entity = "thermo.temperature", above = 28.0 }
action = { entity = "fan.power", set = true }
condition.all = [
{ time = "07:00..23:00" },
{ entity = "fan.power", equals = false },
]

区间也一样要拆成 all 下的两个谓词。

字段类型默认说明
abovenumber可省略

实体当前值严格大于给定数值时条件成立, 要与 entity 一起写。

触发器above 语义不同, 这是最容易混的一点: 触发器是「跨越那一刻」触发 一次(边沿), 条件是「触发发生的那一刻当前值是不是大于阈值」(电平)。所以 trigger.above = 28 + condition.above = 28 不会互相抵消 —— 温度停在 28 以上 期间条件一直为真。

正好等于阈值不算成立。实体的值类型必须是 int / float, 对 bool / 字符串实体写 above 会在 check 阶段报错。

allarray可省略

与组合: 列表里的每个子条件都成立, 整体才成立。子条件是完整的 condition 块, 可以 继续嵌套 all / any / not

all = [](空列表)恒为真。 求值不短路 —— 所有子条件都会被求值, 因为要把 每一条的结果拼进 trace 的说明里(排查「规则为什么没跑」全靠它)。

不能与 time / 实体谓词同块并列, 要组合就把它们放进 all 的列表里。嵌套深度上限 16 层, 超了报 condition nesting too deep

anyarray可省略

或组合: 列表里任意一个子条件成立, 整体就成立。子条件同样可以继续嵌套。

any = [](空列表)恒为假 —— 与 all = [] 恰好相反, 误写空列表会让规则永远不 执行。同样不短路, 所有分支都会求值。嵌套深度上限 16 层。

belownumber可省略

实体当前值严格小于给定数值时条件成立, 要与 entity 一起写。正好等于阈值不算 成立, 同样只能用在数值实体上。

abovebelow 不能写在同一个块里表示区间(会被判成「写了不止一个谓词」而 报错)。区间请写成 all = [{ entity = "t.temp", above = 20 }, { entity = "t.temp", below = 26 }]

entitystring可省略

这条实体谓词看哪个实体, 写完整实体 id(设备名.能力名)。必须与 above / below / equals 之一搭配: 只写谓词不写 entitycondition predicate requires entity, 只写 entity 不写谓词报 condition predicate must specify exactly one of above/below/equals

实体必须已经在设备配置里存在, 否则 rha check 直接失败 (condition entity ... does not exist), 不是静默忽略。

三个运行期的坑:

  • 实体从未上报过值时谓词恒为 false(fail-closed)。kind = "trigger" 的瞬时 信号实体永远不存值, 所以对它写实体谓词永远不成立, 而 check 不会拦这一条。
  • 设备离线不会清掉最后一次的值(只改 available), 条件仍按那个旧值判定。
  • 被条件引用的实体会自动进入高频轮询集合, 不用另外配置。
equalsany可省略

实体当前值与给定值完全相等时条件成立。接受 TOML 的布尔、整数、浮点、字符串四种 标量; 数组、内联表、TOML 日期时间会在 check 阶段报 condition equals value ... unsupported

值的类型要与实体声明的类型对得上, 只做 int → float 的单向放宽: float 实体写 equals = 22 可以, int 实体写 equals = 22.0 会 check 失败。

浮点实体上是精确相等比较, 传感器读数几乎不可能命中 —— 用 above / below 表达 范围更可靠。字符串比较区分大小写, 没有任何归一化。

notobject
ConditionConfig
可省略

取反: 内层条件成立时整体不成立, 反之亦然。内层是单个 condition 块, 不是列表。

因为「无值 / 未知实体」一律算 false, not 会把它翻成 true: not = { entity = "x.y", equals = true } 在实体从未上报过值时是成立的。 想表达「确实处于某状态的反面」, 建议写成 all = [已知条件, { not = ... }], 或者直接用另一侧的谓词(例如 equals = false)。

规则的条件闸门。一个 condition 块只能是一种条件。

引擎把字段分成五组 —— ① 实体谓词(entity + above/below/equals) ② timeallanynot —— 并要求恰好命中一组, 否则报 condition must specify exactly one of entity-predicate/time/all/any/not。所以 condition = { entity = "a.b", above = 1, time = "07:00..23:00" } 不是「而且」, 而是直接的配置错误。想要「而且」只有一条路: all

组内还有第二道闸: 实体谓词里 above / below / equals 必须恰好写一个, 所以 entity + above + equals 同样是错误而非与关系。

总基调是 fail-closed: 实体不存在、或者从未上报过值, 谓词一律算 false。 唯一会把它翻成 true 的是 not, 配置时留意。嵌套上限 16 层。

condition = { time = "07:00..23:00" }

[rule.condition]
all = [
  { time = "07:00..23:00" },
  { not = { entity = "fan.power", equals = true } },
]
timestring可省略

时间窗条件, 格式固定为 "HH:MM..HH:MM", 例 "07:00..23:00"起点含、终点不含; 起点晚于终点时表示跨午夜, "23:00..02:00" 覆盖夜间。不写就没有时间限制。

daemon 所在机器的本地时区判定 —— 容器时区没跟宿主对齐会整体偏几个小时。

写法很挑: 只认 HH:MM(写秒 "07:00:00" 会 check 失败), .. 前后别留空格 ("07:00 .. 23:00" 实测解析失败), "7:00" 这种单位数小时可以。

两个易错点: 终点不含, "08:00..23:00" 在 23:00 整点已经不成立; 起止写成同一时刻 ("08:00..08:00")是恒假而不是「全天」 —— 想要全天就别写 time。 另外条件只在规则被触发的那一刻求值一次, 时间窗到点不会主动触发任何东西, 也不会 中断已经在跑的动作序列。

一个 action 表只能干一件事:set(即 entity + set)/ delay / notify / run / script / llm 六选一entityset 算同一组,所以 { entity = "...", notify = { ... } } 会被算成两个动作而报错。

单个动作写 action,多个写 actions 数组,二者二选一

[[rule]]
name = "notify_when_very_hot"
trigger = { entity = "thermo.temperature", above = 30.0 }
actions = [
{ entity = "fan.power", set = true },
{ delay = "10s" },
{ run = "push_hot" },
]
[actions.push_hot.notify]
url = "https://ntfy.sh/rha-demo"
message = "客厅超过 30 度, 已开风扇"
title = "rha"
字段类型默认说明
delaystring可省略

让当前动作序列在这里停住指定时长, 再继续执行后面的动作。只阻塞本条规则的这一次 执行, 不影响其他规则。

必须是带单位的字符串: "30s" / "5m" / "1m 30s" / "500ms" 都可以, delay = 30 这种裸数字连类型都不对。

更容易踩的是并发语义: sleep 期间这条规则算「运行中」, 缺省 mode = "ignore" 会把 这段时间内的新触发直接丢弃; 而 mode = "restart" 会 abort 掉正在 sleep 的这次 执行, 后面的动作永远不会跑。

entitystring可省略

set 搭配使用, 指定要写入的实体 id(设备名.能力名)。目标必须是可写实体 (开关类); 指向传感器或触发类实体会在 rha check 阶段报 is not a switch

它只为 set 服务, 不是所有动作的通用字段 —— 想让通知带上某个实体的读数, 这里没用。只写 entity 不写 set 会报 set action requires set value

llmobject
LlmActionConfig
可省略

把一次分支决策交给 LLM: 按 prompt 加上实体状态快照(可选再附一张图片)让模型从 branches 里挑一个命名动作名, 挑中就执行那个命名动作。

只能写在规则的 action / actions 里, 不能出现在命名动作体内 —— 否则分支 指回自身就会构成无界递归, check 会拒绝。

一次 LLM 分支决策。prompt / timeout / branches / fallback 四个都必填 (后三个类型上是可选, 但缺了 rha check 会报错), entities / snapshot 可选。

「一定会有结果」是硬契约: 没配 [llm] 段、超时、调用失败、模型答了不在 branches 里的名字, 一律落到 fallback, 绝不卡住动作序列。

[[rule]]
name = "guest_triage"
trigger = { entity = "doorbell.pressed", fired = true }

[rule.action.llm]
prompt = "判断来客类型"
timeout = "8s"
branches = ["notify_urgent", "notify_normal", "ignore"]
fallback = "notify_normal"

[rule.action.llm] 子表而不是内联表, 是因为内联表的花括号里不能换行 —— 写成 action = { llm = { prompt = "...", 然后另起一行接着写, 是 TOML 语法错误 (invalid inline table, expected }), 整个文件都加载不了。要么整条挤成一行, 要么像 上面这样拆成子表。

notifyobject
NotifyConfig
可省略

向一个 webhook 发 HTTP POST(ntfy / Bark 这类)。请求 10 秒超时; 发送失败只记 trace 和日志, 动作序列继续往下跑 —— 所以「规则跑了」不等于「通知到了」。

目标 URL 受 rha.toml[notify] 段的 SSRF 策略约束, 缺省拒私网 / 环回。

两道闸的严格程度不一样, 别拿 check 当保证: rha check 只认字面量私网 IP 和 localhost, 不做 DNS —— 用主机名指内网服务(url = "http://nas.lan:8080/topic") 照样 config ok, 到运行时才被 DNS 解析后的那道闸拒掉。而运行时被拒不算规则失败, 只往 trace 追加一条 note, 后面的动作照跑, 表现就是通知静默不达。

所以内网自建的 ntfy 要真发得出去, 得开 allow_private_networks = true, 或者把 host 加进 [notify].allowed_hosts —— 后者是排他白名单, 一旦写了, 没列进去的目标 (包括 ntfy.sh)全部被拒, 这一层倒是 check 期就会报错。

一次 webhook 通知: 以 message 为请求体 POST 到 url, titlepriority 走 HTTP 头 (X-Title / X-Priority, ntfy / Bark 这类服务的约定)。

runstring可省略

执行一个命名动作 [actions.<名字>] 里的动作序列 —— 等于把那段序列原地展开进 当前序列, 而不是另起一条并发任务。因此被调用序列里的 delay 会一并挡住外层后面 的动作。

内置名字 "ignore" 是空序列, 什么都不做, 可以当占位用; 它是保留名, 不能自己定义 同名动作。引用的名字必须已在某个 automations/*.toml 里声明, 否则 check 报 unknown named action; 名字要 snake_case。

命名动作体里不能再写 run —— 只允许一层展开, 嵌套调用会被 check 拒掉。

scriptstring可省略

一段内联写在配置里的 rhai 脚本源码(不是脚本文件路径)。

脚本里只有两个与 rha 相关的函数: state("设备.能力") 读当前值(实体不存在返回 ()), set("设备.能力", 值) 记一条待写入 —— 脚本跑完后这些写入才统一派发。

state() 读的是动作开始那一刻的全实体快照: 脚本里先 setstate 同一个 实体, 读到的仍是旧值。脚本里 set 的目标同样必须是可写实体且类型匹配, 不满足时只记 一条 script set ... rejected 的 note, 规则不会失败。

语法错误在 rha check 阶段就能发现(会用 rhai 编译一遍), 但运行期错误(非法实体 id、超出限额)只进 trace, 不会让规则报错。执行是有界的 —— 10 万操作数、64KB 字符串、1 万元素的数组 / 映射上限, 死循环会被掐断而不是挂死 daemon。

TOML 里写多行脚本建议用 '''...''' 字面串, 否则脚本内的双引号要逐个转义。

setany可省略

entity 写一个值, 等价于命令行的 rha set。派发是「发出即返回」: 引擎把命令交给 adapter 就继续下一个动作, 不等设备确认成功; 命令 id 会记进这条规则的 trace。

值的类型必须与实体的类型精确匹配, 唯一允许的宽化是整数 → 浮点。所以布尔开关 必须写 set = true / set = false; 写 set = "on"set = 1 会在 rha check 阶段直接失败。这一点与命令行 rha set fan.power on 的宽松解析不是一回事 —— CLI 那套 on/off 转换在配置文件里不生效。

只支持 bool / 整数 / 浮点 / 字符串四种标量, 数组和表会被拒。

向 ntfy / Bark 这类 webhook 发一个 HTTP POST。能不能发得出去不由这张表决定——目标 URL 要过 rha.toml[notify]那道 SSRF 闸,内网自建的接收端 默认就是被拒的。

字段类型默认说明
message*string

通知正文, 整串原样作为 HTTP 请求体 POST 出去。

不支持实体插值 —— 想让正文带上触发实体的读数, 当前版本做不到。{{entity}} 这类写法不会被替换成实体值, 会原样发出去。

${...} 不是「原样发出去」, 这是最容易踩坏的地方: ${} 之间的任意串 (含点号)都会被当成环境变量名, 在配置加载阶段就去查进程环境。所以 message = "temp is ${thermo.temperature}" 不会发出那串字面量, 而是让整份配置直接 加载失败(undefined environment variables: thermo.temperature) —— daemon 起不来、 rha check 也过不了。

插值没有转义写法, 正文里带不出字面的 ${...}。真要用它, 那必须是一个确实存在的 环境变量, 且替换只发生一次, 启动后就固定了, 与实体状态无关。

prioritystring可省略

通知优先级, 作为 HTTP 请求头 X-Priority 原样透传给接收端。不写就不发这个头, 由接收端用它自己的默认优先级。

rha 完全不校验取值域 —— 它不是枚举而是自由字符串, 合法值由你的通知服务定义 (ntfy 是 min / low / default / high / max / urgent, 或 1..5)。 写错不会rha check 报错, 而是接收端返回 4xx, rha 只在 trace 里记一条 notify <host>: 400 Bad Request 然后继续往下跑 —— 很容易变成静默失效。 与 title 一样是 HTTP 头, 控制字符会让请求发不出去。

titlestring可省略

通知标题, 作为 HTTP 请求头 X-Title 发送。不写就不发这个头, 由接收端决定默认标题。

它走的是 HTTP 头而不是正文: 含换行或其它控制字符会让整个通知请求构建失败, 这条通知发不出去, 只在 trace 里留一条错误。与 message 一样不做模板插值。

url*string

POST 的目标 URL。只允许 http / https, 且要过 [notify] 的 SSRF 策略 —— rha check 查一遍字面量, 运行时(含 DNS 解析)再查一遍。

把整条规则文件当密码文件对待: ntfy 常把 token 放 query、Bark 直接把 device key 放 path。因此两条路径都只暴露 scheme://host[:port] —— 发送失败时进日志与 trace 的那条, 以及被 [notify] 策略拒绝时 rha check 报的那条。后者尤其要紧: 它还会经 HTTP API 与 MCP 的 upsert_rule 原样返回给调用方(包括 LLM)。

rha check 只看字面量 IP 和 localhost, 不做 DNS: 一个解析到内网的公网域名只有 运行时那一层拦得住。被策略拒绝时不是整条规则失败, 而是跳过这一个动作、trace 里记 一条, 后面的动作照常执行。

键多,写成分表({ } 不能跨行,见页首那条提示):

[[rule]]
name = "ask_before_fan"
trigger = { entity = "thermo.temperature", above = 28.0 }
[rule.action.llm]
prompt = "现在该开风扇吗? 只在确实需要降温时选 turn_on_fan。"
branches = ["turn_on_fan", "ignore"]
fallback = "ignore"
timeout = "8s"
entities = ["thermo.temperature"]
[actions.turn_on_fan]
entity = "fan.power"
set = true

这个动作要配合 rha.toml[llm]——那一段不配也不报错, 只是每次都走 fallback

字段类型默认说明
branchesarray可省略

模型可选的分支名清单, 至少 2 个。每个名字必须是同一配置里已声明的命名动作 [actions.X], 或者内置的 "ignore"(选中它就什么都不做; 它是保留名, 不需要也不该 为它建 [actions.ignore])。选中的分支等价于执行一次 run = "该名字"

类型上是可选, 但不写或不足 2 个, check 报 llm action requires branches (at least 2) —— 实际必填。分支体内不能再套 llm

分支名虽然以 enum 写进给模型的工具 schema, 仍会二次校验: 模型返回集合外的名字一律 降级 fallback, 而且日志和 trace 里只记那个名字的字符数、不记名字本身 —— 它整串来自上游, 会经 MCP 喂给握有控制权限的 agent。所以排查「模型选了个奇怪的分支」 时你看不到它选了什么, 这是有意的。

entitiesarray可省略

注入上下文的实体; 触发实体的状态总会自动带入(spec §5), 这里再声明的是 额外要注入上下文的实体。

每个 id 必须真实存在, 否则 check 失败。cron 触发的规则没有触发实体, 此时不声明 entities 的话, 上下文就是字面量 (no entities declared)

这里列出的实体 + 触发实体 + snapshot 图片, 就是唯一会离开本机发给模型的数据, 其余状态、配置、历史都不发。

副作用: 列进来的实体会被算进「被规则引用」集合, 因而升级为 hot 轮询(每轮轮询并落 历史库), 不只是「读一下」。注入格式是 Rust 的 Debug 形式, 例如 thermo.temperature = Some(Float(29.0)); 值不新鲜 / 设备离线时还会追加 (stale) / (unavailable) 后缀 —— 写 prompt 时按这个形态描述给模型。

fallbackstring可省略

模型不可用时执行的分支, 必须是 branches 里的一个。类型上是可选, 但不写 check 会报 llm action requires fallback —— 实际必填。所有失败路径都走它, 因此规则行为 始终确定。

「失败」的覆盖面比直觉宽得多, 以下全部静默走 fallback、规则照常算成功: 没配 [llm] 段、配了但没有 api_key、超时、网络错、上游非 2xx、响应不是 JSON、响应里 没有 choose_branch 工具调用、模型选了 branches 之外的名字。

也就是说一个从没接通过模型的配置看起来完全正常, 只有 trace 里的 source=fallbacknote= 能分辨。想让「失败时什么都别做」, 就把它设成 "ignore"

prompt*string

给模型的判断指令。引擎会自动在它后面拼上「当前状态」(实体快照)和「从这些分支里选 一个: a, b」, 所以只写判断要求即可, 不用自己列分支名或实体值。

模型不是自由回话, 而是被强制调用一个 choose_branch 工具, 分支名以 enum 形式写进 工具 schema。所以 prompt 里让模型「输出 JSON」「回答 yes/no」这类要求没有意义 —— 它只能选分支。

snapshotstring可省略

spec §5 快照槽位: 快照 URL(如 reolink 快照端点), 附作 LLM 请求的图片输入。

决策前运行时 GET 拉取, 把字节 base64 后作为图片块随 prompt 一起发给模型。拉取失败 或被 [notify] 策略拒绝时, 退化成无图决策, 绝不阻断。无论实际返回什么类型, 都固定 按 image/jpeg 声明给上游。

rha check 完全不校验这个 URL(连语法都不看, 只有 notify 的 url 会被校验) —— 配错只在运行时留一条 warn, 规则表面一切正常, 模型只是看不到图。

它复用 notify 的同一条 SSRF 闸: 摄像头在内网时必须先开 allow_private_networks, 或把它加进 allowed_hosts, 否则永远无图。拉取用的是 notify 那个 client 的 10s 超时, 且这段时间算在动作序列里, 不受 timeout

rha 不替你做任何认证: reolink 的快照端点带的 token 需要登录才能拿到、而且会过期, 直接把它写进配置不能长期工作。

timeoutstring可省略

这次 LLM 调用的墙钟上限, humantime 格式("8s" / "500ms")。到点立刻降级到 fallback, 不等上游。上限 60s, 超过是 check 期硬错误而不是被截断。

类型上是可选, 但不写 rha check 会报 llm action requires timeout —— 实际必填

超时期间整条动作序列是阻塞的(动作顺序执行)。它同时覆盖共享 HTTP client 的 10s 缺省超时 —— 否则配 30s 会在 10s 被悄悄掐断。

但它不等于这条规则的最坏延迟: 配了 snapshot 的话, 拉图片发生在决策之前、 同样是串行的, 走的是那个共享 client 的 10s 超时、不受这里管。所以 timeout = "8s" + snapshot 的最坏阻塞是 10s + 8s, 不是 8s。

一段可复用的动作序列,靠 run = "<名字>" 或 llm 的 branches 引用。两种写法等价:

[actions.night_mode] # 单个动作
entity = "fan.power"
set = false
[[actions.multi_step]] # 序列: 重复若干段, 按书写顺序执行
delay = "1s"
[[actions.multi_step]]
entity = "fan.power"
set = true

体内不能再写 run,也不能写 llm:只允许一层展开,让「llm 选分支 → 分支是命名 动作」这条链在结构上不可能自引用。体内每一段仍然遵守上面那条「六选一」。

名字要 snake_case,跨所有 automations/*.toml 不能重名;"ignore" 是内置的空动作, 是保留名,不能自己定义同名的。

一条 [[rule]] 的必填组合是 name + trigger + (action 或非空 actions,恰好一个)。 condition / mode / throttle 三个可省,省了分别等于「无条件」「ignore」「1s」——注意 这三个默认值里没有一个是「关掉」throttle 尤其不是 0。

字段类型默认说明
actionobject
ActionConfig
可省略

这条规则要执行的单个动作。与 actions 二选一。

action 与非空 actions 必须恰好写一个 —— 两个都不写、两个都写、或者 actions = [], check 都会报 rule must specify exactly one action form。 也就是说 actions = [] 不是「空规则」, 是配置错误。

一个动作。一个 action 表只能干一件事。

set(即 entity + set)/ delay / notify / run / script / llm 六者恰好选 一个, 多选或一个都不选都会在 rha check 阶段报错。注意 entityset 算同一组, 所以 { entity = "...", notify = { ... } } 会被算成两个动作而报错。

actions = [
  { entity = "fan.power", set = true },
  { delay = "30s" },
  { notify = { url = "https://ntfy.sh/demo", message = "hot", title = "rha" } },
  { run = "night_mode" },
]
actionsarray可省略

动作序列, 按数组顺序串行执行; delay 会让整条序列在原地等待, 序列里的 run = "xxx" 会在执行时就地展开成那个命名动作的动作列表。 与 action 二选一。

失败语义是尽力而为, 不是原子: 某一步失败(例如 notify 请求出错)不会中断后面的 动作, 只往 trace 的 detail 追加一条 note, 整条规则最终仍记 done。所以 「规则跑了」不等于「通知到了」, 判断要看 trace detail。

actions = [
  { entity = "light.power", set = true },
  { delay = "10s" },
  { run = "notify_ready" },
]
conditionobject
ConditionConfig
可省略

触发之后、执行动作之前的一道额外闸门: 不成立就不执行, 并在 trace 里记一条 blocked(detail 是逐子条件的求值说明, 排「规则为什么没跑」的第一手材料)。 不写 = 无条件, 触发即执行。

只在触发那一刻求值一次: 动作序列跑起来之后不再复查。序列里带 delay 的话, 延迟结束时条件可能早已不成立, 后面的动作照样执行。

被条件挡下的这一次不刷新节流计时(last_fired 只在真正 fired 时才更新), 所以下一个事件仍可能立刻触发。

规则的条件闸门。一个 condition 块只能是一种条件。

引擎把字段分成五组 —— ① 实体谓词(entity + above/below/equals) ② timeallanynot —— 并要求恰好命中一组, 否则报 condition must specify exactly one of entity-predicate/time/all/any/not。所以 condition = { entity = "a.b", above = 1, time = "07:00..23:00" } 不是「而且」, 而是直接的配置错误。想要「而且」只有一条路: all

组内还有第二道闸: 实体谓词里 above / below / equals 必须恰好写一个, 所以 entity + above + equals 同样是错误而非与关系。

总基调是 fail-closed: 实体不存在、或者从未上报过值, 谓词一律算 false。 唯一会把它翻成 true 的是 not, 配置时留意。嵌套上限 16 层。

condition = { time = "07:00..23:00" }

[rule.condition]
all = [
  { time = "07:00..23:00" },
  { not = { entity = "fan.power", equals = true } },
]
modestring可省略

同一条规则的上一轮动作序列还没跑完时, 新触发怎么办。只有两个合法取值: "ignore"(跳过这次触发, trace 记 skipped)和 "restart"(abort 掉正在跑的 序列、从头重跑, trace 记 restarted)。缺省等同 "ignore"

只有动作序列会持续一段时间(含 delayllm、慢 notify)时这个字段才有意义; 纯 set 的序列瞬间就结束, 写什么都看不出区别。

restart硬 abort: 被打断的序列剩下的动作全部不再执行, 但已经发出去的命令 不会回滚。填这两个以外的值("queue""parallel")会让 check 直接失败 —— 不是静默回退到默认值。

name*string

规则的唯一标识 —— 节流记录、并发去重、trace 都以它为键, HTTP API 的 /api/rules/<name>/trace 也按它查。

automations/所有文件不能重名, 重名在 check 阶段直接报 duplicate rule name 并拒绝加载。

手写文件时字符集不受限(中文、空格都能过 check), 但通过 HTTP API / MCP 写规则时 name 同时是文件名 automations/<name>.toml, 只允许 [a-z0-9_]、1..=64 个字符。 取了不合规的名字, 这条规则以后就没法用 upsert_rule / delete_rule 管理 —— 一律用 snake_case 最省事。

throttlestring可省略

这条规则两次实际执行之间的最小间隔, 带单位的时长串("1s" / "500ms" / "2m30s")。窗口内到来的触发被直接丢弃, trace 记一条 throttled。 缺省 1s, 即每条规则每秒最多执行一次。

缺省不是 0, 因为规则之间会互相触发(A 开风扇 → 风扇状态变化 → 又触发 B → ……), 一个默认下限能把这种回路限成每秒一次而不是打满。确实需要更快就显式写 throttle = "0s", 那是完全关闭节流。

三个容易踩的点:

  • 节流闸排在条件之前, 但计时起点只在真正 fired 时才刷新 —— 被条件挡下的那次 不占用窗口。
  • cron 触发不受 throttle 约束, 哪怕 cron 间隔小于 throttle 也照跑。
  • 时长串必须带单位, 裸数字(throttle = "5")解析失败会导致 check 报错。

热重载后仍然存在的规则会保留它的节流计时, rha reload 不等于清零窗口。

trigger*object
TriggerConfig

什么时候考虑执行这条规则。每条规则有且只有一个 trigger。

触发只是第一道闸: 判定通过之后还要依次过 throttlecondition、并发模式三道。

规则的触发条件, 写在 [[rule]]trigger 内联表里。

above / below / fired / changed / cron 五者必须恰好写一个, 多写少写都报 trigger must specify exactly one of above/below/fired/changed/cron。所以引擎里没有 「区间触发」这种写法 —— 既要 above 又要 below 得拆成两条规则。

另外三条互斥: cron 不能配 entity(它是全局定时, 不绑实体), cron 也不能配 for, 而 entity 对其余四种触发是必填。

trigger = { entity = "thermo.temperature", above = 28.0, for = "5m" }
trigger = { entity = "doorbell.pressed", fired = true }
trigger = { cron = "0 7 * * *" }