把 LLM 接进来(MCP)
rha 对 LLM 的定位只有一句话:模型是界面和规则作者,不是控制回路里的一环。温度穿过
28°C 要不要开风扇,这个判断永远由引擎按 automations/ 里写死
的规则做;模型的位置在两侧——事前帮你把「热了就开风扇,但半夜别开」翻译成一条能过校验的
规则,事后回答「昨晚风扇为什么自己开了」。
这条路走 MCP。daemon 内置一个 MCP server,暴露十个工具,模型通过它读状态、控设备、查 规则的评估记录、以及写规则。
MCP server 本身是纯 HTTP API 客户端——它不碰引擎内部,只是替模型去打 /api/*。同一
份实现服务两种传输,所以两边能用的工具完全一样。
rha mcp:stdio,挂在本机 agent 上
Section titled “rha mcp:stdio,挂在本机 agent 上”rha mcpClaude Code 的 mcpServers 里:
{ "rha": { "command": "rha", "args": ["--config", "/etc/rha", "mcp"] } }连哪台 daemon、用什么 token,按这个顺序定:--url / --token(或同名的 RHA_URL /
RHA_TOKEN 环境变量)两个都给了就直接用,否则回落到读 --config 指向的配置目录
(默认 /etc/rha)。
这个回落有个前提容易忽略:读配置目录要有权限。生产部署里 /etc/rha 是
root:rha 0750,桌面用户根本读不了,rha mcp 会直接报 cannot load config。在自己电脑
上挂远程 daemon 时把两个都显式给出来,配置目录那条路就完全不会走:
{ "rha": { "command": "rha", "args": ["mcp"], "env": { "RHA_URL": "http://192.168.52.40:8420", "RHA_TOKEN": "..." } } }daemon 内置 /mcp:streamable HTTP
Section titled “daemon 内置 /mcp:streamable HTTP”daemon 起来就有,不用额外开关。它是无会话的(每个 POST 独立处理),所以多个 agent 同时接进来不会互相干扰,daemon 重启也不会留下一堆悬挂会话。
{ "rha": { "type": "http", "url": "http://192.168.52.40:8420/mcp", "headers": { "Authorization": "Bearer ${RHA_TOKEN}" } } }内置的这份 MCP server 指向 daemon 自己的 loopback 地址,用的是同一个 token——也就是
说它和你手动 curl /api/entities 走的是同一条路,没有第二套实现、也没有绕过认证的近路。
| 工具 | 答什么问题 |
|---|---|
list_entities |
家里现在什么样。全部实体的当前值、可用性、语义类、取值域 |
list_devices |
有哪些设备、人话名字是什么、每台名下挂着哪些实体 |
get_state |
单个实体现在是什么值 |
history |
这个值过去怎么变的 |
control |
把一个开关设成某个值 |
list_rules |
当前生效的规则有哪些 |
get_trace |
这条规则最近为什么触发 / 为什么没触发 |
upsert_rule |
新增或覆盖一条规则 |
delete_rule |
删掉一条规则(连文件一起) |
upload_dashboard |
传一个 Web 面板(见自建面板) |
读状态时,三个字段问的是三件事
Section titled “读状态时,三个字段问的是三件事”list_entities 回的每个实体上有一组容易被混作一谈的标志位,模型看错会给出很有把握的错
误结论:
available: false—— 设备失联了。stale: true—— 这是 daemon 重启后从快照恢复的旧值,adapter 还没回填真实数据。设备 可能好好的,只是这个数字还不是「现在」。poll_tier—— 轮询节奏。fast(被规则引用,15 秒)/slow(默认 5 分钟)/ null 表示 根本不轮询:BLE 温湿度计、MQTT 推送这类设备的值是它自己送上来的,没有「问一遍」这个 动作,所以「多久没更新了」在这里不是异常信号。
另外两个字段是给画界面用的:class 是跨生态的语义类(fan / temperature / battery
…),为 null 是常态不是遗漏——一台插座 28 个实体里真正有跨生态语义的只有一两个;
domain 是数值的取值域({"enum":[1,2,3,4]} 或 {"range":{"min":0,"max":100}}),没有它
就别画滑条。
history 有两个坑,画曲线之前先看
Section titled “history 有两个坑,画曲线之前先看”limit 截的是窗口里最早的 N 条不是最近的;hourly: true 时 limit 完全无效。这两条
在自建面板那页展开讲了,MCP 这边参数含义完全一样。
默认窗口是最近 24 小时(to 默认现在,from 默认 to - 24h),limit 默认 500、上限
5000。hourly 只对数值实体有效,非数值的会报 entity ... is not numeric。没开
[storage] 时整个工具报错,不是返回空数组。
control 只能作用于可写实体
Section titled “control 只能作用于可写实体”返回的是命令结局:ok / failed / timeout,不是「请求已收到」。但要注意 ok 的
含义随协议而变——MQTT 那种单向下发的,ok 只表示消息发出去了,设备真动了没有要看状态
主题回来的新值。
upsert_rule:这条闭环是重点
Section titled “upsert_rule:这条闭环是重点”一句「热了就开风扇,但半夜别开」变成一条真正生效的规则,中间是这样的:
- 模型起草一段 TOML(完整的
[[rule]]文本),调upsert_rule。 - daemon 拿它跟当前整份配置一起走一遍完整校验——和你敲
rha check是同一套:实体存 不存在、类型对不对得上、branches里的命名动作声明了没有、规则名重不重复。 - 通过才落盘并原子生效;没通过一个字节都不写,错误信息原样返回给模型。
- 模型照着诊断改一版,再试。
关键在第 3 步。诊断不是 400 Bad Request 这种废话,是 trigger entity "ghost.x" does not exist 这种直接点名的话——模型能读懂,也就能自己修。
name 同时是文件名,只能用小写字母、数字、下划线;成功返回的是热加载后的统计
({entities, rules, actions}),可以直接确认新规则确实进去了。
delete_rule 会连规则文件一起删掉,这是没有回收站的操作。
改规则之前,先 get_trace
Section titled “改规则之前,先 get_trace”用户问「为什么没生效」时,答案几乎总在 trace 里。它记的是每一次评估:什么事件进来、条件 的实际值和判定、以及动作跑完没有。
比如一条被条件挡住的记录,detail 里是这个形状:
blocked thermo_living.temperature=27.5>28 -> false——不是「条件不满足」,是「实际读到 27.5,跟 28 比,false」。这直接回答了「昨晚风扇为什么 自己开了」这类问题,不用去翻日志。
outcome 是一张封闭的词汇表:
| outcome | 含义 |
|---|---|
fired |
触发了,动作序列已派发 |
done |
动作序列跑完(detail 里有失败动作的说明) |
no_match |
相关事件到了但没穿越阈值(或 for 的持续时长内又退回去了) |
blocked |
条件不满足,detail 写明哪个条件、实际值多少 |
throttled |
被最小触发间隔挡住(默认每规则 1 秒) |
skipped |
上一次还在跑,且 mode = "ignore" |
restarted |
上一次被打断(mode = "restart") |
llm |
走了 llm 动作,detail 里有选中的分支、来源和耗时 |
顺序是先读 trace,再解释,最后才改规则。跳过第一步去改规则,改的往往是没坏的那一条
——throttled 和 blocked 长得完全不像,但都表现为「规则没生效」。
两条通道,通过任意一条都行,错的 token 一样 401:
Authorization: Bearer <RHA_TOKEN>http://192.168.52.40:8420/mcp?token=<RHA_TOKEN>?token= 这条是为了那些只能填一个 URL、没有地方放自定义 header 的 MCP 客户端。
mcp_allowed_hosts:默认只认 loopback
Section titled “mcp_allowed_hosts:默认只认 loopback”/mcp 比 REST API 多一道 Host 白名单,防的是 DNS rebinding——浏览器里的恶意页面把自
己的域名解析到你的内网地址,再从页面里往这个端口发请求。
默认只放行 loopback。所以哪怕 listen 已经改成 0.0.0.0:8420,从另一台机器直连
/mcp 仍然会被 403 Host header is not allowed 挡掉,而同一时刻 REST API 是通的——这个
不对称是有意的,也是最容易让人以为「MCP 坏了」的一个现象。
跨机使用要把对方地址栏里用的 host[:port] 显式列出来:
[api]listen = "0.0.0.0:8420"token = "${RHA_TOKEN}"mcp_allowed_hosts = ["192.168.52.40:8420"]配了别的 host 不会顶掉 loopback,本机挂载照常。改完要重启进程——
[api] 段不参与热重载,rha reload 对它既不报错也不生效。
反代、隧道(Cloudflare Tunnel、frp)后面 Host 事先不可知,逐个列举不现实,那就填 ["*"]:
mcp_allowed_hosts = ["*"]你接进来的 agent 能做什么
Section titled “你接进来的 agent 能做什么”十个工具里有两个是真正有分量的:control 能开关你家的设备,upsert_rule / delete_rule
能改自动化规则。MCP 没有只读模式——挂上去就是全都有。
所以这个 token 的分发范围就是权限边界:拿到它的人(或 agent)能读全屋状态、能控设备、能 改规则。想给别人一个「只能看」的入口,MCP 不是那条路——面板的 会话 cookie 权限更低一些,但也仍然能开关设备。
仓库里带了一份配套的 Claude skill(skills/rha-assistant/),把上面这些约定写成了给
agent 看的规约——实体命名、先看 trace 再改规则、写规则前先确认实体存在。挂 MCP 的同时把
它也放进去,能省掉相当一部分来回。
规则里的 llm 动作:另一个方向的插槽
Section titled “规则里的 llm 动作:另一个方向的插槽”上面讲的是「模型在外面,通过 MCP 操作 rha」。反过来还有一条:规则执行到一半,把某个判断
交给模型。写法和字段表见 automations/*.toml 的 llm 动作。
这里只补一条设计意图:timeout 和 fallback 是必填的,模型也只能从你声明的
branches 里选一个名字(越界的选择、超时、网络错、协议不符,一律降级到 fallback)。
换句话说——LLM 完全失效时,这条规则的行为仍然是确定的,就是执行 fallback 那一支。
连 [llm] 段都没配也不报错,只是每次都走 fallback。
代价是要知道哪些数据会离开本机:entities 里显式列出的实体,加上触发实体(后者
总会被带入,即使没写进 entities);配了 snapshot 时还会带上那张图。除此之外不发送任
何实体状态、配置或历史。