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 就红。
一台设备的一生
Section titled “一台设备的一生”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 的其他
设备完全不受影响——因为它们各自在自己的任务里。
四条不能破的规矩
Section titled “四条不能破的规矩”1. describe 必须离线、无副作用
Section titled “1. describe 必须离线、无副作用”rha check 靠它在不碰网络的前提下校验整份配置。这条契约让「改完配置先校验」成为一个
零风险、零等待的动作。
碰网络会毁掉三件事:check 变慢且会因设备离线而失败、doctor 不能当静态体检用、
CI 里跑不了。
所以 describe 只能看 DeviceSpec.params——用户写在 devices.toml 里的东西,加上
adapter 自己编译期就有的知识(型号表、协议常量)。
2. 一次 run_device 只驱动一台设备
Section titled “2. 一次 run_device 只驱动一台设备”adapter 里不该有「按设备分发」这一层。 多设备的并发由内核的监督层负责,命令由
Router 按设备名直投到对应队列。
自己在 adapter 里管一批设备,等于把重试、退避、隔离各实现一遍,而且一台设备的故障会 拖累同一个任务里的其他设备——那正是监督粒度定在设备级要避免的。
3. 命令必有结局
Section titled “3. 命令必有结局”收到 Command 之后,无论成败都要回一条 Report::CommandDone:
Report::CommandDone { id: cmd.id, target: cmd.target, outcome: CommandOutcome::Ok, // 或 CommandOutcome::Failed("设备回了错误码 7".into()), // 或 CommandOutcome::Timeout,}不回的那条命令会一直挂到内核的超时兜底为止。调用方(规则、API、HomeKit)拿不到结果, 用户看到的是「点了没反应,也没有错误」。
4. polls() 要说实话
Section titled “4. polls() 要说实话”它决定这个 adapter 名下的实体参不参与轮询分层。
被动接收的(BLE 广播、Webhook、长连接推送)必须返回 false——对一台被动监听广播的
温湿度计说「它是 cold(轮询得慢)」是错的:它压根不轮询,值是设备自己送上来的。
接一个新 adapter
Section titled “接一个新 adapter”以接一个虚构的 tuya 协议为例。
-
建 crate
Terminal window cargo new --lib crates/rha-adapter-tuya工作区是
members = ["crates/*"],不用手动登记。 -
定义私有参数结构体
用户写在
[[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会把它带出去, 一个字都不用另写。 -
实现
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,}])} -
实现
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.hotlet 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(()), 别当成错误,否则监督层会以为你崩了然后重启你。 -
实现
params_schema与device_identityfn 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, // 配置里没有型号, 而问设备要就得碰网络})} -
注册进
factories()crates/rha-daemon/src/daemon.rs的base_factories()。 -
补覆盖测试要的那一条
crates/rha-daemon/src/schema.rs里有张「代表性参数」探针表。新增 adapter 不加一条,the_identity_probe_table_covers_exactly_the_registered_adapters就会报 「探针表与注册表不一致」。 -
加文档站页面
site/src/content/docs/reference/adapters/tuya.mdx,正文讲清楚配置怎么写、有什么 前置条件,字段表用一行组件带出来:<FieldTable path="adapters.tuya" />然后在
site/astro.config.mjs的 sidebar 里加一项。字段表零手写——它来自你在 第 2 步写的 doc comment。 -
在 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 里假设
自己的推断是最终结果。