架构
rha 是一个进程:读四个 TOML 文件,接管一批设备,把它们暴露给外部生态,并按规则 自动化。没有 UI —— CLI、REST API 与 MCP 就是界面。
这一页讲的是它内部怎么组织的。想直接跑起来看 快速开始, 想查字段去配置参考。
flowchart BT
subgraph dev["设备"]
D1["小米插座 · 温度计<br/>扫地机 · 门铃"]
end
subgraph south["南向 adapter"]
A["miot · mibeacon · ESPHome<br/>MQTT · roborock · reolink · fake"]
end
subgraph core["内核"]
I["Ingest<br/><i>唯一的事件发布者</i>"]
R["Registry<br/><i>状态的唯一权威</i>"]
B["Bus<br/><i>有界广播</i>"]
E["规则引擎"]
S["存储 SQLite"]
I --> R
I --> B
B --> E
B --> S
end
subgraph north["北向 bridge"]
H["HomeKit"]
end
subgraph out["外部"]
O1["「家庭」App"]
O2["CLI · REST API · MCP"]
end
D1 -->|"设备协议"| A
A -->|"Report"| I
B -->|"Event"| H
H --> O1
R --> O2
南在下,北在上 —— 这就是「南向 / 北向」的字面意思。图里画的是上行;下行(命令) 沿同一条链路反向走,见下面。
左右两侧是对偶的:adapter 把外面的设备接进来,bridge 把内核的实体暴露 出去。中间那层的存在理由,就是让这两边不必互相认识。
核心领域模型:一张平表
Section titled “核心领域模型:一张平表”rha 的世界里只有一种东西:实体(entity)。它是一个可读或可控的量。
plug_bedroom.switch_s2_on_p1 开关状态 boolplug_bedroom.power_consumption 功率 floatthermo_6a20.temperature 温度 floatfan_circulator.fan_s2_fan_level 档位 int实体 id 是 设备名.能力名,没有嵌套。这不是偷懒,是刻意的:
- 规则按实体寻址(
entity = "thermo_6a20.temperature") - 存储存实体的时间序列
- API 服务实体
- 命令发给实体
一个扁平命名空间就能满足全部四种用法,而且它没有“层级该怎么切”这种争议。
实体带什么信息
Section titled “实体带什么信息”| 字段 | 说明 |
|---|---|
kind |
sensor(只读)/ switch(可写)/ trigger(只推事件、读不到值) |
type |
bool / int / float / str |
unit |
原始单位。各 adapter 的词汇不统一,见下 |
class |
语义类。北向协议的公共词汇表,见下 |
domain |
数值实体的取值域(枚举或区间),北向换算刻度要用 |
设备是第二张表,不是树
Section titled “设备是第二张表,不是树”一台设备也有自己的事实:显示名、厂商、型号。它们不挂在实体上 —— 一台插座 25 个实体各存一份同样的型号字符串是荒唐的。
所以设备是并排的第二张平表,按设备名索引:
thermo_6a20 → { label: "书桌温度计", manufacturer: "Xiaomi", model: "lywsd03mmc" }设备 → 实体 这个关系不存,因为它已经被实体 id 的前缀完全确定了。存一份就有了
第二个真相源,两边不一致时没有裁决者。
语义类:内核唯一的公共词汇
Section titled “语义类:内核唯一的公共词汇”kind 只说得清「能不能写」,说不清「这是什么」。一台插座从官方 spec 生成出来有
23 个 switch:真正的插座开关、上电默认状态、童锁、指示灯模式、充电保护功率……
北向协议需要分辨它们。
于是有了 class,一个封闭枚举:
Outlet Light Fan Switch 可控形态Speed Swing Lock 附属控制量Temperature Humidity Battery Power Weight Button 测量量它刻意很窄,而且刻意不照抄 HomeKit 的服务列表。 Power(功率)与 Weight
(体重)在 HAP 里没有标准对应物 —— 照抄的话这两个语义根本进不来,将来接 MQTT 或
Home Assistant 时又得重新发明。内核的词汇表与任何单一协议的对象模型必须分开。
封闭枚举的代价是加一个 class 就要改内核;这是特性不是缺点。新增语义时编译器会指着 每一个 bridge 说「你没处理它」—— 开放字符串没有这个保障,加了新语义一个 bridge 处理了、另一个静默漏掉,没人会发现。
这是整个设计最核心的一处。
| 南向 adapter | 北向 bridge | |
|---|---|---|
| 方向 | 设备 → 内核 | 内核 → 外部生态 |
| 上行 | 设备 → Report |
Event → 外部协议 |
| 下行 | Command → 设备 |
外部请求 → Router |
| 离线校验 | describe() |
describe() |
| 监督 | 一台设备一个任务 | 一个 bridge 一个任务 |
| 现有实现 | miot / mibeacon / ESPHome / MQTT / roborock / reolink / fake | HomeKit / MQTT / Google Home |
为什么中间要有一层
Section titled “为什么中间要有一层”因为 N 个 adapter + M 个 bridge = N+M 张映射表,而不是 N×M。
没有中间层的话,「小米插座怎么变成 HomeKit 的插座」「小米插座怎么变成 MQTT 的 discovery 消息」「石头扫地机怎么变成 HomeKit 的……」——每加一个协议,都要为已有的 每一种设备重写一遍。
有了 class 这层公共词汇之后:adapter 只需回答「我这个实体是什么语义」,bridge 只需
回答「这个语义在我的协议里长什么样」。两边都不认识对方。
南向:adapter
Section titled “南向:adapter”一个 adapter 实现一种设备协议(不是一个品牌)。它要回答两件事:
fn describe(&self, device: &DeviceSpec) -> Result<Vec<EntityMeta>, AdapterError>;fn run_device(&self, ctx: AdapterCtx) -> AdapterFut;describe—— 这台设备有哪些实体。必须离线、无副作用,因为rha check要靠它在不碰网络的前提下校验整份配置。run_device—— 跑一台设备:轮询或监听,把变化作为Report送上去, 从队列里取Command下发。
上行只有一种消息:
enum Report { Value { entity, value }, // 读到了值 Trigger { entity }, // 触发了一次事件(门铃、按键) Availability { entity, available }, // 在线/掉线 CommandDone { id, target, outcome },// 命令的结局}北向:bridge
Section titled “北向:bridge”一个 bridge 实现一种外部生态协议。它拿到的是已经过滤好、带 class、按 id 排好 序的导出集,加上设备表:
struct BridgeCtx { spec, // 这个 bridge 的配置 exports, // Vec<EntityMeta> —— 该导出去的实体, 顺序稳定 devices, // BTreeMap<String, DeviceMeta> —— 显示名/厂商/型号 events, // 状态变化的订阅 registry, // 冷启动拉全量快照 commands, // 外部请求由此下发 state_dir, // 本 bridge 私有的持久化目录(配对密钥这类) shutdown,}导出集的顺序是稳定的(按实体 id 排序)。HomeKit 的配件编号是按顺序分配的, 顺序一抖动,已配对的 iPhone 那边看到的就是「开关变成了温度计」。
把平表组装成协议要的形状是 bridge 的职责,不是内核的 —— 三个协议要的形状完全
不同:HAP 是 Accessory/Service/Characteristic 三层树,MQTT discovery 是扁平 topic
加一个 device 关联,Matter 是 Endpoint/Cluster/Attribute。内核只回答逐实体的
「这是什么量」。
flowchart TB D["设备"] -->|"轮询 / 监听"| A["adapter"] A -->|"Report"| I["Ingest"] I -->|"写"| R["Registry<br/><i>状态的唯一权威</i>"] I -->|"Event"| B["Bus"] B --> E["规则引擎"] B --> G["各 bridge"] B --> S["存储<br/><i>只落 hot 实体</i>"]
Ingest 是唯一的事件发布者:它把 Report 规范化成 Event(去重、补时间戳、
判断值有没有真的变),写 Registry,再发 Bus。
Bus 有界,慢消费者会被落下(Lagged)而不会反压发布者 —— 一个卡住的 bridge
不能拖慢整个内核。代价是订阅者被落下之后必须回 Registry 重新拉一遍全量,而不是
假装没发生。
flowchart TB SRC["规则引擎 · API / MCP · bridge"] -->|"Command"| RT["Router"] RT -->|"按 entity.device() 查队列"| Q["该设备的命令队列"] Q --> A["adapter"] --> D["设备"] RT -.->|"实体不存在 / 设备没在跑"| CD["CommandDone<br/>Ok · Failed · Timeout"] A -.->|"结局"| CD
Router 按 entity.device() 直接查队列 —— 实体 id 本身就带着设备名,不需要额外的
映射表。
命令必有结局:Ok / Failed(原因) / Timeout,三者必居其一。所有失败路径都
收敛到 CommandDone,包括「实体根本不存在」(配置错误,永久)与「设备任务没在跑」
(暂时)——这两者结局不同,必须分开报。
监督:内核永不因子系统死亡
Section titled “监督:内核永不因子系统死亡”一台设备一个监督任务,adapter 崩了就指数退避重启(1s 起,封顶 60s,健康运行 60 秒后重置)。重启期间它的实体被标为不可用,其他设备完全不受影响。
bridge 的监督同构,但有一处关键区别:bridge 挂掉不标任何实体不可用。
bridge 故障是「外面看不见 rha 了」,不是「设备离线了」——混起来会让一次 HomeKit
故障把规则里所有 unavailable 条件一起点亮,那是彻头彻尾的假警报。
从官方 spec 生成型号表之后,一台插座会暴露 25+ 个实体,而其中真正被规则用到的通常 只有一两个。全部按 15 秒轮询是白白给设备和历史库加压。
- hot = 被规则引用的 + 被 bridge 导出的实体。每轮
poll_ms问一遍,落历史库。 - cold = 其余。按
cold_poll_ms慢速轮询,不落历史库(只在内存里)。
「导出到 HomeKit」等价于「有人会实时看它、点它」,所以 bridge 的导出集必须并进 hot —— 不并的话这些实体走 cold(默认 300 秒),外部改动最长 5 分钟才反映到「家庭」。
cold 是慢速轮询而不是完全不轮询:后者看起来更省,但一份还没写规则的全新配置会
因此所有实体都没有值,rha status 一片空白。
配置的生命周期
Section titled “配置的生命周期”flowchart TB T["四个 TOML<br/>rha.toml · devices.toml<br/>bridges.toml · automations/"] --> C["rha check<br/><i>离线, 不碰网络</i>"] C -->|"有错"| ERR["一次报出<b>全部</b>错误<br/><i>而不是遇到第一个就停</i>"] C -->|"全过"| V["Validated"] V --> S["serve"] S --> S1["N 个设备监督任务"] S --> S2["M 个 bridge 监督任务"] S --> S3["规则引擎"] S --> S4["API / MCP"]
check 是离线的:它调用每个 adapter 的 describe 与每个 bridge 的 describe,
但全程不碰网络。这条契约让「改完配置先校验」成为一个零风险、零等待的动作。
它刻意收集全部错误再一起报,而不是遇到第一个就退出 —— 改一个错、跑一次、再发现 下一个,那种循环最消耗耐心。
| 接口 | 用途 |
|---|---|
| CLI | check / serve / status / spec-gen / miot-token 等 |
| REST API | 读实体、下发命令、增删规则。token 鉴权 |
| MCP | 让 LLM 客户端直接操作 rha(含 control 与规则增删) |
| Web 面板 | /d/<名字> —— daemon 只提供画布,页面由 AI agent 按你家的实体清单画好上传,daemon 原样端出来 |
| bridge | HomeKit —— 对生态而言 rha 就是一台桥接配件 |
- 快速开始 —— 不需要真实设备,跑通完整链路
- devices.toml —— 设备怎么配,
label与class怎么写 - bridges.toml 与 HomeKit —— 实体怎么变成「家庭」里的配件