跳转到内容

bridge(北向)

一个 bridge 实现一种外部生态协议。它把内核那张扁平的实体表,组装成那个生态要的形状: HomeKit 要 Accessory / Service / Characteristic 三层树,MQTT discovery 要扁平 topic 加一个 device 关联,Matter 要 Endpoint / Cluster / Attribute。

它是南向 adapter 的对偶。想先看全局,回 架构。

pub trait BridgeFactory: Send + Sync {
fn id(&self) -> &'static str;
fn describe(&self, spec: &BridgeSpec, exports: &[EntityMeta])
-> Result<Vec<EntityId>, BridgeError>;
fn run(&self, ctx: BridgeCtx) -> BridgeFut;
fn params_schema(&self) -> Option<serde_json::Value> { None }
}

describe 的返回值是**「这些实体里我实际认得哪些」**——不是「配置对不对」那么简单。

有些 class 在某个协议里没有对应物(HomeKit 就没有 Power(功率)与 Weight(体重)的 标准服务)。让 bridge 显式返回它认得的子集,rha check 才能把「你导出了 10 个,实际只有 7 个能出去」这件事当场说清楚,而不是等用户在「家庭」里数控件。

pub struct BridgeCtx {
pub spec: BridgeSpec, // 这个 bridge 的配置
pub exports: Vec<EntityMeta>, // 该导出的实体, 顺序稳定
pub devices: BTreeMap<String, DeviceMeta>, // 显示名 / 厂商 / 型号
pub events: broadcast::Receiver<Event>, // 状态变化
pub registry: Arc<Registry>, // 冷启动 / Lagged 后拉全量
pub commands: Arc<Router>, // 外部请求由此下发
pub state_dir: PathBuf, // 本 bridge 私有的持久化目录
pub shutdown: CancellationToken,
}

exports 已经过滤好、已带 class、按实体 id 排好序——bridge 不需要自己筛。

devices 是并排的第二张表,按设备名索引。设备级的事实(显示名、厂商、型号)在这里, 不在 EntityMeta 上。

flowchart TB
  C["rha check"] -->|"describe()"| D["认得哪些实体<br/><i>离线, 不碰网络</i>"]
  D --> S["serve"]
  S --> SUP["supervise_bridge"]
  SUP -->|"run(ctx)"| R["跑起来"]
  R -->|"冷启动"| REG["从 Registry 拉全量快照"]
  R -->|"订阅"| EV["Event → 推给外部协议"]
  R -->|"外部请求"| CMD["Router.dispatch"]
  R -.->|"返回 Err / panic"| B["指数退避<br/>1s → 60s 封顶"]
  B -->|"重启"| SUP

1. 导出集的顺序是稳定的,别打乱它

Section titled “1. 导出集的顺序是稳定的,别打乱它”

exports 按实体 id 排序。HomeKit 的配件编号是按顺序分配的,而已配对的控制端会缓存 这些编号——顺序一抖动,iPhone 那边看到的就是「开关变成了温度计」。

任何在 bridge 内部引入 HashMap 迭代顺序的地方都是隐患。这类 bug 的现象是「有时 好使」,最难查。

2. bridge 挂掉不标任何实体不可用

Section titled “2. bridge 挂掉不标任何实体不可用”

监督模型与设备同构(指数退避重启),但有一处关键区别。

bridge 故障是「外面看不见 rha 了」,不是「设备离线了」。混起来会让一次 HomeKit 故障把 规则里所有 unavailable 条件一起点亮——那是彻头彻尾的假警报,而且用户会去查设备。

3. 状态存 state_dir,别塞进 Storage

Section titled “3. 状态存 state_dir,别塞进 Storage”

配对密钥这类东西存在 ctx.state_dir(<配置目录>/bridges/<name>/)。

刻意不放进 Storage trait:那是时序数据的窄接口,往里加「任意 KV」会污染它。而且存储 是可以整个关掉的(enabled = false),配对状态不能跟着一起消失。

以接 MQTT discovery 为例。

  1. 建 crate 并加 feature

    Terminal window
    cargo new --lib crates/rha-bridge-mqtt

    在 crates/rha/Cargo.toml 里加一个 feature。编进去 ≠ 桥在跑:feature 只决定 "mqtt" 是不是一个认得的 bridge 类型,起不起桥由 bridges.toml 决定。

  2. 决定 class → 协议对象的映射

    这是这一层全部的工作量。内核的语义类 是封闭枚举,你要为每一个变体回答「它在我的协议里长什么样」。

    编译器会帮你:match 漏掉一个变体就编译不过。这正是 class 做成封闭枚举的理由。

  3. 实现 describe

    校验配置,并返回认得的子集。建议照 HomeKit 的做法:先把树/消息集组装出来, 再从组装结果反推,避免两处分叉。

    fn describe(&self, spec: &BridgeSpec, exports: &[EntityMeta])
    -> Result<Vec<EntityId>, BridgeError>
    {
    let cfg: MqttParams = spec.params.clone().try_into()
    .map_err(|e| BridgeError::msg(format!("{e}")))?;
    let tree = build(&cfg, exports); // 与 run 用**同一条**建树路径
    Ok(tree.bound_entities())
    }
  4. 实现 run

    四件事:冷启动从 registry 拉全量、订阅 events 推变化、把外部请求转成 commands.dispatch、shutdown 触发时干净退出。

    async fn run(ctx: BridgeCtx) -> Result<(), BridgeError> {
    // 1. 冷启动: 先把当前值全推一遍, 否则外部看到的是一片空白
    for m in &ctx.exports {
    if let Some(rec) = ctx.registry.get(&m.id) { publish(&m.id, &rec.value); }
    }
    loop {
    tokio::select! {
    _ = ctx.shutdown.cancelled() => return Ok(()),
    ev = ctx.events.recv() => match ev {
    Ok(e) => publish_event(e),
    // **Lagged 必须回 registry 重新拉全量** —— 总线是有界的,
    // 慢消费者会被落下, 假装没发生就会永久少几个值
    Err(broadcast::error::RecvError::Lagged(_)) => resync(&ctx),
    Err(_) => return Ok(()),
    },
    }
    }
    }
  5. 实现 params_schema,注册进 bridge_factories()(crates/rha-daemon/src/daemon.rs), 加文档站页面与 sidebar 条目,然后在 linux 上重新生成 config-schema.json。

    这几步与 adapter 那边完全一样,不再重复。

这两条是四轮真机失败换来的,接任何新生态之前值得先看。

拿控制端当参照物,验不出配件端的合规问题

Section titled “拿控制端当参照物,验不出配件端的合规问题”

写 HomeKit bridge 时,最初拿 aiohomekit(Home Assistant 的 HomeKit 控制端)当参照物, 逐字节对齐。密码学、分帧、握手流水都验得很扎实——但它有一个结构性盲区:

控制端只回答「这份数据我能不能解析」,永远不回答「一个合规的配件该长什么样」。

下面每一条它都照单全收,而 iOS 每一条都拒:

缺陷 iOS 的反应
配件表里没有 aid=1 桥自身 静默,全部 “No Response”
AccessoryInformation 缺 FirmwareRevision 同上
Outlet 缺 OutletInUse 同上
带 pr 的特征省掉了 value “accessory out of compliance”

解法是再加一个配件端参照物(HAP-python),生成一份等价配件表逐字段 diff。 一次就把上面全暴露了。

接新生态时,先找一个与你角色相同的成熟实现当参照物。

先确保自己能看见对端问了什么

Section titled “先确保自己能看见对端问了什么”

前两轮只能从 TCP 的 bytes_sent/bytes_received 反推 iOS 在哪一步放弃,于是只能猜。 加上请求级 debug 日志之后,第一次跑就看清了坐标:

GET /accessories → 200, 6389B
hap 连接被对端关闭 authenticated=true ← 读完当场关掉, 一条特征都不读

「读完配件表就断」直接把范围从「加密/网络」缩到「配件表内容」。排查协议问题时, 分不清「我们答错了」与「对端根本没问」,就等于在猜。