跳转到内容

adapter(南向)

一个 adapter 实现一种设备协议——不是一个品牌,也不是一台设备。miot 接的是所有说 MIoT 协议的设备(小米、云米、智米、德尔玛……),mibeacon 接的是所有广播 MiBeacon 帧的 BLE 设备。

它的职责只有两件:告诉内核这台设备有哪些实体,以及跑这台设备。

想先看全局,回 架构。

pub trait AdapterFactory: Send + Sync {
fn id(&self) -> &'static str;
fn describe(&self, device: &DeviceSpec) -> Result<Vec<EntityMeta>, AdapterError>;
fn run_device(&self, ctx: AdapterCtx) -> AdapterFut;
// 以下三个有默认实现
fn polls(&self) -> bool { true }
fn params_schema(&self) -> Option<serde_json::Value> { None }
fn device_identity(&self, device: &DeviceSpec) -> Option<DeviceIdentity> { None }
}
方法 回答什么 必须实现
id 配置里 adapter = "…" 写的那个名字 是
describe 这台设备有哪些实体,各是什么量 是
run_device 跑一台设备:上报值、收命令 是
polls 是主动轮询还是被动接收 否,默认 true
params_schema 私有参数的 JSON Schema,喂文档站 否,但注册了就必须(见下)
device_identity 厂商与型号 否,但注册了就必须

后两个的默认实现返回 None/不做事,是为了「加这个方法不必同时改掉所有实现」。代价是 新增 adapter 时忘了写不会有编译错误——所以有覆盖测试兜着:注册进 factories() 的 必须实现它们,漏了 cargo test 就红。

flowchart TB
  C["rha check"] -->|"describe()"| D["拿到实体清单<br/><i>离线, 不碰网络</i>"]
  D --> S["serve"]
  S --> SUP["supervise<br/><i>一台设备一个任务</i>"]
  SUP -->|"run_device(ctx)"| R["跑起来"]
  R -->|"Report"| K["内核"]
  K -->|"Command"| R
  R -.->|"返回 Err / panic"| B["指数退避<br/>1s → 60s 封顶"]
  B -->|"重启"| SUP

supervise 在设备任务失败时把它自己的实体标为不可用,退避后重启。同 adapter 的其他 设备完全不受影响——因为它们各自在自己的任务里。

rha check 靠它在不碰网络的前提下校验整份配置。这条契约让「改完配置先校验」成为一个 零风险、零等待的动作。

碰网络会毁掉三件事:check 变慢且会因设备离线而失败、doctor 不能当静态体检用、 CI 里跑不了。

所以 describe 只能看 DeviceSpec.params——用户写在 devices.toml 里的东西,加上 adapter 自己编译期就有的知识(型号表、协议常量)。

2. 一次 run_device 只驱动一台设备

Section titled “2. 一次 run_device 只驱动一台设备”

adapter 里不该有「按设备分发」这一层。 多设备的并发由内核的监督层负责,命令由 Router 按设备名直投到对应队列。

自己在 adapter 里管一批设备,等于把重试、退避、隔离各实现一遍,而且一台设备的故障会 拖累同一个任务里的其他设备——那正是监督粒度定在设备级要避免的。

收到 Command 之后,无论成败都要回一条 Report::CommandDone:

Report::CommandDone {
id: cmd.id,
target: cmd.target,
outcome: CommandOutcome::Ok, // 或
CommandOutcome::Failed("设备回了错误码 7".into()), // 或
CommandOutcome::Timeout,
}

不回的那条命令会一直挂到内核的超时兜底为止。调用方(规则、API、HomeKit)拿不到结果, 用户看到的是「点了没反应,也没有错误」。

它决定这个 adapter 名下的实体参不参与轮询分层。

被动接收的(BLE 广播、Webhook、长连接推送)必须返回 false——对一台被动监听广播的 温湿度计说「它是 cold(轮询得慢)」是错的:它压根不轮询,值是设备自己送上来的。

以接一个虚构的 tuya 协议为例。

  1. 建 crate

    Terminal window
    cargo new --lib crates/rha-adapter-tuya

    工作区是 members = ["crates/*"],不用手动登记。

  2. 定义私有参数结构体

    用户写在 [[device]] 里的那些字段。加 deny_unknown_fields——拼错一个键该 报错,不该静默忽略。凭据用 Secret(它的 Debug 打码成 <redacted>,不会漏进日志)。

    #[derive(Deserialize, schemars::JsonSchema)]
    #[serde(deny_unknown_fields)]
    pub(crate) struct TuyaDevice {
    /// 设备在局域网里的地址。
    pub ip: String,
    /// 本地密钥,从 Tuya 云端一次性取得。
    pub local_key: rha_config::Secret,
    }

    doc comment 就是文档站上的字段说明——params_schema 会把它带出去, 一个字都不用另写。

  3. 实现 describe

    把参数解析出来,产出实体清单。给每个实体标上 class——那是北向协议唯一认得的 语义,不标的实体导不出去。

    fn describe(&self, device: &DeviceSpec) -> Result<Vec<EntityMeta>, AdapterError> {
    let dev: TuyaDevice = device.params.clone().try_into()
    .map_err(|e| AdapterError::msg(format!("{e}")))?;
    Ok(vec![EntityMeta {
    id: EntityId::parse(&format!("{}.switch", device.name))
    .map_err(|e| AdapterError::msg(e.to_string()))?,
    kind: EntityKind::Switch,
    value_type: ValueType::Bool,
    unit: None,
    class: Some(DeviceClass::Outlet),
    domain: None,
    }])
    }
  4. 实现 run_device

    一个循环,tokio::select! 同时等两件事:轮询到点了、或者来了命令。

    fn run_device(&self, mut ctx: AdapterCtx) -> AdapterFut {
    Box::pin(async move {
    let mut tick = tokio::time::interval(Duration::from_secs(15));
    loop {
    tokio::select! {
    _ = tick.tick() => {
    // 只问 hot 的那些, 见 ctx.hot
    let v = read_from_device().await?;
    let _ = ctx.reports.send(Report::Value { entity, value: v }).await;
    }
    cmd = ctx.commands.recv() => {
    // 通道关闭 = 内核在收摊, 干净退出
    let Some(cmd) = cmd else { return Ok(()) };
    let outcome = write_to_device(&cmd).await;
    let _ = ctx.reports.send(Report::CommandDone {
    id: cmd.id, target: cmd.target, outcome,
    }).await;
    }
    }
    }
    })
    }

    ctx.commands.recv() 返回 None 表示内核关闭了通道——直接 return Ok(()), 别当成错误,否则监督层会以为你崩了然后重启你。

  5. 实现 params_schema 与 device_identity

    fn params_schema(&self) -> Option<serde_json::Value> {
    Some(rha_core::schema_of::<TuyaDevice>())
    }
    fn device_identity(&self, _d: &DeviceSpec) -> Option<DeviceIdentity> {
    Some(DeviceIdentity {
    manufacturer: Some("Tuya".into()),
    model: None, // 配置里没有型号, 而问设备要就得碰网络
    })
    }
  6. 注册进 factories()

    crates/rha-daemon/src/daemon.rs 的 base_factories()。

  7. 补覆盖测试要的那一条

    crates/rha-daemon/src/schema.rs 里有张「代表性参数」探针表。新增 adapter 不加一条, the_identity_probe_table_covers_exactly_the_registered_adapters 就会报 「探针表与注册表不一致」。

  8. 加文档站页面

    site/src/content/docs/reference/adapters/tuya.mdx,正文讲清楚配置怎么写、有什么 前置条件,字段表用一行组件带出来:

    <FieldTable path="adapters.tuya" />

    然后在 site/astro.config.mjs 的 sidebar 里加一项。字段表零手写——它来自你在 第 2 步写的 doc comment。

  9. 在 linux 上重新生成 schema

    Terminal window
    cargo run -p rha -- config-schema > site/src/generated/config-schema.json

    必须在 linux 上跑:mibeacon 整个 crate 在 #[cfg(target_os = "linux")] 后面, 在 macOS 上生成会静默少一个 adapter。CI 有一道新鲜度门守着这件事。

实体 id 拼出来就完了,没有校验。 EntityId::parse 会拒绝非法格式,但拼错的 能力名是合法 id ——describe 里报的名字和 run_device 里上报的名字对不上时, 值会进一个谁也没引用的实体,rha status 里多出一行、规则永远不触发,而且不报错。 把能力名抽成常量或从同一处生成。

没有 class 的实体导不出去。 这不是 bug:一台插座 25 个实体里,只有一两个在任何家居 生态里有对应物。但如果你觉得某个实体该出去却没出去,先查它有没有 class。

用户可以覆盖你推断的 class。 devices.toml 的 [device.class] 优先级高于 adapter 的推断——一个插座接了台灯,用户会标 class = "light"。别在 adapter 里假设 自己的推断是最终结果。