跳转到内容

架构

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 把内核的实体暴露 出去。中间那层的存在理由,就是让这两边不必互相认识。

rha 的世界里只有一种东西:实体(entity)。它是一个可读或可控的量。

plug_bedroom.switch_s2_on_p1 开关状态 bool
plug_bedroom.power_consumption 功率 float
thermo_6a20.temperature 温度 float
fan_circulator.fan_s2_fan_level 档位 int

实体 id 是 设备名.能力名,没有嵌套。这不是偷懒,是刻意的:

  • 规则按实体寻址(entity = "thermo_6a20.temperature")
  • 存储存实体的时间序列
  • API 服务实体
  • 命令发给实体

一个扁平命名空间就能满足全部四种用法,而且它没有“层级该怎么切”这种争议。

字段 说明
kind sensor(只读)/ switch(可写)/ trigger(只推事件、读不到值)
type bool / int / float / str
unit 原始单位。各 adapter 的词汇不统一,见下
class 语义类。北向协议的公共词汇表,见下
domain 数值实体的取值域(枚举或区间),北向换算刻度要用

一台设备也有自己的事实:显示名、厂商、型号。它们不挂在实体上 —— 一台插座 25 个实体各存一份同样的型号字符串是荒唐的。

所以设备是并排的第二张平表,按设备名索引:

thermo_6a20 → { label: "书桌温度计", manufacturer: "Xiaomi", model: "lywsd03mmc" }

设备 → 实体 这个关系不存,因为它已经被实体 id 的前缀完全确定了。存一份就有了 第二个真相源,两边不一致时没有裁决者。

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

因为 N 个 adapter + M 个 bridge = N+M 张映射表,而不是 N×M。

没有中间层的话,「小米插座怎么变成 HomeKit 的插座」「小米插座怎么变成 MQTT 的 discovery 消息」「石头扫地机怎么变成 HomeKit 的……」——每加一个协议,都要为已有的 每一种设备重写一遍。

有了 class 这层公共词汇之后:adapter 只需回答「我这个实体是什么语义」,bridge 只需 回答「这个语义在我的协议里长什么样」。两边都不认识对方。

一个 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 实现一种外部生态协议。它拿到的是已经过滤好、带 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,包括「实体根本不存在」(配置错误,永久)与「设备任务没在跑」 (暂时)——这两者结局不同,必须分开报。

一台设备一个监督任务,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 一片空白。

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 就是一台桥接配件