跳转到内容

把 LLM 接进来(MCP)

rha 对 LLM 的定位只有一句话:模型是界面和规则作者,不是控制回路里的一环。温度穿过 28°C 要不要开风扇,这个判断永远由引擎按 automations/ 里写死 的规则做;模型的位置在两侧——事前帮你把「热了就开风扇,但半夜别开」翻译成一条能过校验的 规则,事后回答「昨晚风扇为什么自己开了」。

这条路走 MCP。daemon 内置一个 MCP server,暴露十个工具,模型通过它读状态、控设备、查 规则的评估记录、以及写规则。

MCP server 本身是纯 HTTP API 客户端——它不碰引擎内部,只是替模型去打 /api/*。同一 份实现服务两种传输,所以两边能用的工具完全一样。

Terminal window
rha mcp

Claude 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 起来就有,不用额外开关。它是无会话的(每个 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] 时整个工具报错,不是返回空数组。

返回的是命令结局:ok / failed / timeout,不是「请求已收到」。但要注意 ok 的 含义随协议而变——MQTT 那种单向下发的,ok 只表示消息发出去了,设备真动了没有要看状态 主题回来的新值。

一句「热了就开风扇,但半夜别开」变成一条真正生效的规则,中间是这样的:

  1. 模型起草一段 TOML(完整的 [[rule]] 文本),调 upsert_rule。
  2. daemon 拿它跟当前整份配置一起走一遍完整校验——和你敲 rha check 是同一套:实体存 不存在、类型对不对得上、branches 里的命名动作声明了没有、规则名重不重复。
  3. 通过才落盘并原子生效;没通过一个字节都不写,错误信息原样返回给模型。
  4. 模型照着诊断改一版,再试。

关键在第 3 步。诊断不是 400 Bad Request 这种废话,是 trigger entity "ghost.x" does not exist 这种直接点名的话——模型能读懂,也就能自己修。

name 同时是文件名,只能用小写字母、数字、下划线;成功返回的是热加载后的统计 ({entities, rules, actions}),可以直接确认新规则确实进去了。

delete_rule 会连规则文件一起删掉,这是没有回收站的操作。

用户问「为什么没生效」时,答案几乎总在 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 比 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 = ["*"]

十个工具里有两个是真正有分量的: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 时还会带上那张图。除此之外不发送任 何实体状态、配置或历史。