Microduck 架构图
本文是对 pollen-robotics/microduck(机载运行时,Rust)
和 pollen-robotics/microduck_rl(RL 训练管线,Python)
的图解。全部事实取自仓库设计文档,每张图末尾标注出处;图是投影,文档是真值——两者不一致时以文档为准。
生成日期:2026-09-03
配套前传:《Microduck 硬件入门》——本文默认你看得懂 UART / I²C / 舵机;
不懂的话先读它再回来。它讲硬件(鸭身上有哪些零件、电怎么变成 0 和 1),本文讲软件(7 个守护进程怎么分工)。
1. 系统总拓扑:一块板子上的七个守护进程
一台 Microduck = 一块 RK3566 板(Radxa Zero 3)上的 7 个 systemd 服务,
通过 Unix socket + JSON-RPC 2.0(NDJSON,一行一个对象) 通信。
核心不变量:只有 robotd 能碰电机;其余所有客户端只发「意图」。
要点(出处:microduck/docs/design/architecture.md §1):
- 恢复路径三件套(
configd/updaterd/btd,图中绿色)不依赖 robotd:
没有 systemd 依赖、没有 ML 运行时、没有媒体栈。控制环起不来的机器人
恰恰最需要配 WiFi / 更新 / 回滚。
mediad / padd 允许依赖 robotd:没有摄像头和手柄的机器人仍是能更新的机器人。
tofd 是异类:拥有一个传感器,只发布帧(tof.stream),自己不去读别的服务。
2. robotd 的 50Hz 控制环:一个 tick 里发生什么
50 Hz 循环,每 tick 两次总线事务(外加每秒一次慢速传感器读)。
安全层 safety 是唯一持有 RobotIo 写句柄的模块——由 Rust borrow checker
在类型系统层面强制,不是靠约定。
四个 driving 条件缺一不可(robotd-design.md §1.4):
enabled ∧ policy loaded ∧ 本 tick 读到传感器 ∧ ¬limp-fall。
读失败时绝不编造观测——喂给策略一个不存在的机器人比停下来更糟。
细节出处:microduck/docs/design/robotd-design.md §1.4(tick)、§2.2(61 维观测布局)、
§2.3(策略优先级链)、§2.4(safety 与 limp_fall)、§4.1(快照不等待)。
3. IPC 模式:控制面 / 数据面分离
两条规则(robotd-design.md §4.1,architecture.md §2):
- 没有反向通道:健康是发布的,不是被询问的——卡死的循环能把自己报成
unhealthy,而不是把调用方挂死。
- 数据面永远不过 socket:
robotd 不需要视频帧,需要的是特征
("球在 (x,y)"、"有人")。把推理放在传感器旁(mediad),控制环订阅后
读最新值缓存;mediad 卡住只会降级感知,不会给电机控制加抖动。
4. 更新系统:整目录交换 + 健康门 + 自动回滚
更新是交换不是打补丁。一个 release 落盘为
/opt/robot/daemon/releases/<ver>/,current 符号链接指向在用版本。
关键设计(出处:updater-design.md §3–§5、§8;architecture.md §1.1):
- 验证顺序:manifest 签名 → 下载 → sha256 → artifact 签名。
- 健康只回答「release 能不能背锅的事」:循环速率、总线、IMU 参与健康判定;
电池、温度只是描述,绝不是回滚输入——否则低电机器人换上什么版都不健康。
safeToRestart 为 false 时不重启 robotd:行进中重启电机控制就是摔。
- 没网也救得回来:
rollback / reset-to-golden 只操作本地磁盘上的 release,
BLE 也带得动。离线机器人可恢复,只是不可升级。
5. 一个 API,多种传输
所有传输适配同一个 crate(duck-ipc-proto)定义的 API;
btd 是纯转发,不拥有任何状态。
出处:architecture.md §4.1、§5.2、§5.3;robotd-design.md §3.1、§3.5
(维护命名空间 init/校准/原始关节写不进远程传输)。
6. 训练 → 部署管线:从 MuJoCo 到鸭子
策略在 microduck_rl 训练,ONNX 部署到 robotd,
社区经 HF Hub 分享新技能。
出厂 7 个动作策略随 release 目录分发(robotd-design.md §2.3);
社区策略一条命令安装:sudo robotctl policy add bow <user>/microduck-polite-bow。
出处:microduck_rl/README.md、microduck_rl/AGENTS.md、robotd-design.md §2.2–2.3。
7. crate 边界:编译器当警察用
Rust workspace 的分法本身就是架构约束——
「daemon 关注点不许漏进控制代码」由编译器执行,不靠自觉。
出处:robotd-design.md §1.3。测试无需硬件:FakeIo(可脚本化、可冻结、可故障注入)
让 cargo test 不需要板子、网络或 Docker。
第二部分:模块深挖
前面七章是「系统长什么样」。接下来八章打开每个模块,看里面怎么转、
为什么这样设计。素材还是仓库设计文档,每张图后面都标了出处。
8. robotd:一条 UART 要精打细算
8a. 总线上谁说了算
15 个舵机加一块 IMU 板,共用一条 UART,没有第二条。serialport 设了
TIOCEXCL,但它只能拦住普通用户再打开端口;robotd 以 root 运行,
root 不怕这个标志。所以「独占」不是靠系统强制,而是靠规则:每个可能
抢端口的人,都被单独安排到别处。
启动时 check_and_fix_config 会检查并纠正几项 EEPROM 配置。其中最狠的是
return_delay_time=0:XL330 出厂默认 250,对应每台 500 µs 回转延迟,16 台
加起来就是 8 ms,占掉 20 ms tick 预算的 40%。新换上的舵机或被恢复出厂设置的
舵机,往往就带着 250 回来;这条检查干掉了一整类「为什么这台鸭子跑得比别人慢」
的问题。
位置环写入 P 增益时,I 和 D 强制写 0。这是 runtime 的默认,不是新决策:
这两个是 RAM 寄存器,断电就丢;如果不清零,工厂的 D 会留在那里,机器在同样
kP 下就是会更软。既然没人专门调过这个参数,那就直接钉死,不暴露给用户。
每次 sync_read 是「全有或全无」:rustypot 等所有 id 都回答,一台沉默
整笔失败。失败时调用方保留上一拍数据,不把一次丢包当成新闻。
电压读 15 台舵机后取平均——它们接的是同一块电池,单点读只是更吵;有舵机
回 0 时直接剔除,不会把它当半块没电。温度不取平均,而是按名字报最热的
那个关节:深蹲时膝盖比嘴热得多,15 台平均值会盖住真正逼近过热关断的那一台。
IMU 陈旧度也会一直计数:连续拿到同一块数据就记下来,半秒后写进 journal;
限频是因为一块停刷的板子每 tick 一条告警会淹掉日志。
出处:robotd-design.md §1.1、§2.1。
8b. 61 维观测向量:精确到槽位
每个 alpha 策略都是 obs[1,61] → actions[1,14]——walk / stand / ground pick /
ball kick / sit 全部验证过,所以只有一种布局。命令块是唯一有过疑问的部分,
直接从原型 control_step 里读出来的,不是猜的:
关节全程排除嘴:action 14 维映射回 15 个电机槽时索引 9 留零。
51 维旧布局、49 维轮式、85 维追踪布局随硬件变体一起消失。
一切在加载时校验,不是推理时:观测宽度、动作数、ONNX Runtime 是否存在。
加载前还有一次预热推理——把首次调用的开销挪出热路径(否则一次 cold-start
看起来和丢拍一模一样),顺便证明 dylib 解析成功。ort 在缺 ONNX Runtime 时
会在惰性路径上 expect 崩掉——无法 catch 成错误,控制线程死掉、健康永远显示
「循环一拍没跑」——所以 policy::ensure_runtime 用与 ort 相同的加载规则
先行探测,把缺库变成普通错误。policy.enabled 把「不要策略」(健康,台架
测更新器的正确配置)和「策略坏了」(不健康,回滚)分开,合并两者要么让台架
鸭看起来坏了、要么让不可用的 bundle 过健康门。ONNX Runtime 是板级前置条件
(scripts/install.sh 装),不进 release——~20 MB 乘每次更新,换来的是装好
却不能走的鸭子,代价是健康回答里带上搜索过的路径。
出处:robotd-design.md §2.2、§2.3、§2.5(DEFAULT_POSITION 必须与训练环境的
HOME_FRAME 一致——策略观测的是相对 home 的关节角,不一致 = 14 个观测槽整体偏移)。
8c. bring-up 状态机与策略优先级链
robotd 绝不因为进程启动而动鸭子:启动时读当前位置、采纳为目标、不碰扭矩。
Dynamixel 在进程死后保持最后下发的目标,所以更新重启不留缝——站着的鸭子
穿过一次更新而无感。带电上电(bring-up)是状态机不是标志位,因为
set_torque 是每关节一笔总线事务,循环内反复施加会把 16 笔写塞进每拍:
robot.init / robot.relax 是同一对转移的直发版本,作为请求由循环每拍取一次
(后到的请求顶掉没读的——20 ms 内先叫站起再叫趴下,第二个才是本意)。
两者都不可达 BLE:一个让鸭子拍地板的手机按钮不该提供,且起身会同时动
所有关节——要发起的人正看着它。
出处:robotd-design.md §2.3、§3.3。
8d. 跌倒是第三个事件:limp_fall 时序
「倒没倒」的判定(投影重力过阈 + 0.2s 去抖)只管报告,不管任何门——躺着的
鸭子照样被使能、驱动、发技能,因为趴在地上恰恰是最需要这些调用能通的时候。
但报告用的判定对软着陆是错的:重力过 fall_gravity_z 且持续 200ms 时鸭子
已经在地上了,值得出手的窗口早已关死。所以 limp_fall 跑在变化率上,
而不是位置上:
买到的不是落地本身,而是落地之后的起身:站立策略把一只安静的、姿态已知
的鸭子干净地起起来;对一只挣扎的,则要在地板上用走路增益试好几轮——电机
负载大头就在那。另一个会不经请求动鸭子的东西是没电:
safety.battery_empty_shutdown 让平滑电压(EMA ~10s,负载跌落打不穿)触到
6.6 V 空电底线时,先坐下、再关板。
出处:robotd-design.md §2.4、§2.4.1。
15. microduck_rl 深挖:sim2real 是全部意义所在
15a. 任务族矩阵:13 族 × 地形 × Backlash 孪生
uv run list-envs 打印活的注册表。每个主任务都有 Backlash 孪生
(task id 里 MicroDuck 前插 -Backlash-):每个舵机关节串一个 ±1°
齿轮间隙(合计 2°)的无驱动 passive_<joint>_backlash 铰链。关键
sim2real 细节:真实编码器在间隙的输出侧,所以固件 PD 仿真
(BacklashEncoderBamActuator)和 joint_pos/joint_vel 观测都
穿过间隙读(qpos[servo] + qpos[backlash])——观测和动作维度
不变,ONNX 导出与 runtime 零改动。
关节布局(14 舵机,walk/groundcontact 模型上 ctrl 序号 = 关节序号):
0–4 左腿(hip_yaw, hip_roll, hip_pitch, knee, ankle),5–8 颈/头,9–13 右腿。
轮式/间隙模型上 passive 关节是交错的——mdp 函数里绝不硬编码关节序号,
一律走 _servo_joint_ids 助手(曾让 standup 的 [0-4, 9-13] 直接指到轮子上,
被 cfg 测试锁死)。无驱动关节统一命名 passive_*(轮子、间隙铰链),
执行器/观测/奖励的筛选正则是 ^(?!passive_).*。
出处:microduck_rl/README.md Tasks/Backlash 节、AGENTS.md Invariants、
docs/roller_standup_policy_summary.md(关节交错警告)。
15b. BAM M6 执行器模型:sim2real 的主战场
这个尺度上——14 个 XL330 微型舵机驱动 ~800 g 双足——执行器保真度
就是 sim2real 差距的大头,所以执行器一直建模到电压控制律这一级,
而不是理想 PD:
观测归一化器必须烤进 ONNX 图,导出只能走 scripts/export.py。sim 里
play 会暗中帮你套归一化器,所以这个 bug 在 sim 里看不出来;手工转换的
checkpoint 上机就废。
训练时也不要给动作加低通滤波。如果非要加,必须同时给 runtime 也加上对应
开关,并且做迁移测试——只改一边一定坏。25 cm 的鸭子 3.5–5.5 rad/s
自然翻滚,别按人的直觉设速度上限;抑制暴力靠惩罚冲击和抖动
(|a_z|、action_rate、是否双脚支撑),而不是限制转速。
IMU 的域随机化以零为中心,训练的是对「偏了多少」的容忍,不是对系统性
安装偏置的补偿。安装偏置要上机校准,不是训练能学会的。
出处:microduck_rl/README.md Actuator model、AGENTS.md
Invariants / Sim2real footguns。
15c. 案例研究:一次「反暴力」修正的完整教训
roller_standup 上真机后动作很暴力,头砸地,从背侧起不来。关键线索是:
sim 里看回放也一样暴力。所以不是 sim2real gap,也不是 checkpoint 太嫩,
就是奖励设计错了。这个案例集中了 RL 奖励工程里最贵的三课:
命令槽的三条军规(AGENTS.md Commands 节):永远不为零的命令输入才有
活权重——每个槽从第 0 步起保持小非零采样范围,即使奖励权重为 0;
全零命令必须显式训练(uniform 采样几乎采不到全 0,而那正是部署怠速态);
稀有关键命令区要显式分桶(原地转独立采样前只占 2% 经验、永远练不会)。
预算:简单技能 ~1000 iters @4096 envs,步态/恢复类 4000–6000。
出处:docs/roller_standup_policy_summary.md(修正全过程)、AGENTS.md
Reward design / Commands 节。
9. updaterd 深挖:更新是流水线,恢复是洋葱
总览 §4 画了「交换 + 健康门 + 回滚」的骨架;这一节拆到每一步。
原则一句话:每层恢复机制存在的意义,都是因为上一层自己也可能死。
9a. update apply 的 18 步流水线
步骤 11 之前没有任何东西碰活路径——前 10 步失败时旧 release 原样在跑,
没有东西需要撤销。步骤 11 起任何失败一律回滚:换回 current、对旧
release 重跑 on_apply、确认 boot 计数器、写日志:
延迟重启的三个承重细节:用 systemd-run 临时单元而不是子进程
(systemctl restart updaterd 杀整个 cgroup,子进程就在 cgroup 里,
会在重启自己父母的中途被杀);先释放更新锁再 spawn(fork 会复制
所有打开的描述符,包括别的引擎持有的锁——测试里表现为无关操作报
Busy);失败只记日志不报告(更新成功了不因一个 restart 没排上
而报失败,代价是某个 daemon 留在旧二进制直到下次开机)。
签名与降级:验证顺序是 manifest 签名 → 下载 → sha256 → artifact 签名,
无签名字节永不进活路径。签名只证明「是我们的、没被改」,不证明
「是新的」——所以 Target::Latest 拒绝降级(WOULD_DOWNGRADE,挡的是
被操纵的镜像),Target::Exact 放行(那是运维的点对点回退,镜像骗不出
这个动作)。冻结攻击是明确接受的遗留面:v1 采纳的对策是上报
「上次成功检查是 47 天前」把静默攻击变可见,而不是给 manifest 加过期
时间(那要求 CI 定期重签,漏一次全舰队告警)。密钥从第一张镜像起就是
一组三把 release key(不同口令、不同暴露面:CI/密码管理器/离线)
- 一把 dev key(仅开发板信任集),丢一把钥匙不等于重刷全舰队。
无人值守路径有一个known_bad 死循环保险:没有它,坏 release =
舰队级无出口陷阱——check 说有 → apply → 门失败 → 回滚 → 等 6h → 再来,
每轮重新下载、重写 eMMC、重启 robotd,不收敛。守卫从 journal 取每个版本
的最新结局,release 哪天真成功了自动解除;且只管无人值守——运维
手动 apply 仍可重试,因为运维可能已修好原因。
出处:restart-order.md §2、updater-design.md §5.4/§7/§8.1/§8.4。
9b. 重启集合是推导出来的,不是维护出来的
更新要重启哪些 unit?答案是从 release 自己推:
units_shipped = release 自带 systemd/*.service ∪ updater.toml 的 on_apply.units,
减去 NEVER_RESTART(写死在代码里——那是这两个 daemon 的本质属性,
不是运维该有权力配错的选项)。今天推出来恰好是
configd, mediad, padd, robotd, tofd(字典序,每块板、每个测试一致)。
判定「什么算 unit」的规则同样机械:有 [Install] 段才算——没有的
由 timer 或别的 unit 拉起,生命周期不归更新管(robot-boot-check.service
正是靠没有 [Install] 免于被更新中途重启自己)。
但「安排了重启」≠「重启发生了」。所以每个 daemon 启动时往
/run/<service>/identity.json 自报身份(exe 来自 /proc/self/exe,
穿过 current 符号链接,说出它真正从哪个 release 目录启动),
updaterd 每次启动时对账:
各转移路径的差异一张表记牢(restart-order.md §3):只有 apply 和
select 跑 hooks 和自检;rollback / reset-to-golden / 健康门内回滚 /
boot 计数器回退都不欠延迟重启——前两者的后半是因为 updaterd/btd 从来
没离开过要回退回去的那个 release。
出处:restart-order.md §1、§5、§3。
9c. 兜底之兜底:180 秒开机救援网
三层保护(self-test、延迟重启、启动对账)全部预设一件事:updaterd
能启动。boot 计数器数的是 updaterd 的启动次数而非字面意义的开机
——updaterd 起不来就不计数,于是「永不变砖」的闭环闭在唯一可能就是
伤亡的那个进程里。救援网必须活在那之外:
救援网自己有三重「拒绝开火」:Conflicts=shutdown.target(关机途中
被杀的 daemon 会被记成 failed,没这个一次时机不当的 poweroff 最终把
机器人重启进 golden);运行时间守卫(开机十分钟后才触发的就不是这个
timer,不归它管);oneshot 不带 [Install](否则装它的那次更新会
enable --now 它,对着合法地重启中的 daemon 做回滚检查)。
顺带一条运维军规(updater-design.md §9.1,同一形状的翻车发生了
四次):fresh install 会做的,hook 也必须做。问题别问「release
需不需要」——那是判断,已经放跑过四回;问机械问题「install.sh 是否
写这个文件/跑这条命令」,一个 grep 就有答案。hook 步骤必须幂等且
「无事可做时必须便宜」(每次更新、每块板都跑,10 秒的空转 = 永久给
每次更新加 10 秒),hook 跑的脚本必须打进包里、脚本依赖的东西必须
跟着走,可选硬件永不致命(没相机/没蓝牙/没 NPU 的板子仍是鸭子)。
every_install_sh_step_reaches_an_updated_board 测试是强制函数:install.sh
的每个调用点要么 hook 也做,要么写明理由入表。
出处:boot-recovery-net.md 全文、restart-order.md §4、
updater-design.md §9.1。
10. mediad:媒体管线 + 远程入口
先解释一个选择:为什么不用 webrtcbin,而是用 webrtcsink(gst-plugins-rs)。
后者自带信令协议、会话模型、按 viewer 分配的编码器管理,所以这些都不用
自己写;剩下只需设计「控制面」。信令服务器直接跑在 mediad 进程里
(run-signalling-server),不需要单独建、单独装、单独守护一个二进制。
Debian 没给 webrtcsink 打包,这是唯一的反对票,但「协议不用自己写」
把它压倒了——远程桥接未来代理的也正是这个协议。
10a. 一条会话四条流,一条管线两次分叉
v1 只开 control 这条信道,不是临时妥协,而是主动避开问题:SCTP
可靠有序,所以 intents.rs 的 last-writer-wins 天然成立,第一版没有
排序 bug 要修。代价是队头阻塞:丢一个包,后面的 RPC 全卡住。坏链路
下你会觉得「整个机器都停了」,而不是「摇杆延迟」。解决方式是再开一条
teleop 信道,不是靠调参。
但 teleop 开了之后要加序号。maxRetransmits: 0 的 SCTP 会乱序:
80 ms 前的摇杆指令可能后到,并覆盖当前指令。机器人按过期命令走,
任何地方都不报错。这不是偶发竞态,是这条信道的正常特性;症状又像
调参不好,所以提前写下来。
至于板上为什么不设鉴权,这是故意选的,不是忘了。出厂 PIN 是共享的
000000,每台都一样,加一道 PIN 门只给首连添麻烦,买到零安全。远程
路径的认证被移到了桥接层:客户端先向 rendezvous 服务 OAuth 登录,
机器人侧 relay 也带账户 token 向外连——会话到达机器人之前已经双向认证。
对 rendezvous 服务走 OAuth,机器人侧 relay 持账户 token 向外连——
会话到达前已双向认证。真正没覆盖的部署错误是把信令端口直接端口转发
到公网:没有桥认证过任何东西,LAN 推理也不适用,而板上没有任何
东西会注意到。两样东西刻意留在 WebRTC 子集之外:system.pairingPin
(能改 PIN 的 LAN 对等端能把手机锁在恢复路径外)、update.* 变更
(apply 会重启 mediad 掐断会话;只读 update 调用第一天就在)。
出处:remote-webrtc.md §1–§6、§8。
10b. 35% 丢帧的两种独立病因(都是实测)
头摄像头跑到 29.3 fps 的路上有两个互不相干的修复,难查的原因是
任何单独立上都不动数字——让每一个看起来都像没用:
还有一类 bug 是这种设计邀请来的形状:GStreamer 信号线程里
tokio::spawn 会以不可回卷的 panic 杀进程(journal 只有
thread caused non-unwinding panic + g_closure_invoke 背栈,没有原因)——
那些 handler 里不许 panic;编解码协商失败是 warning 不是 error——
mpph264enc 模板不列 constrained-baseline 时 H.264 会从 offer 里
消失、悄悄协商成 VP8、会话死掉,而可见错误来自四个元素之外的
videorate 抱怨 NV12。所以 mediad 把 GStreamer 调试日志和管线
bus 一起桥进 journal——没有这个,以上一样都看不见。
出处:remote-webrtc.md §0(全部标注 measured)。
11. btd 深挖:窄管道上的窄子集
一个 service,一个 characteristic。客户端读一次 API 版本,往里写
NDJSON 请求字节,订阅它收答案——与所有其他传输相同的 JSON-RPC 行。
换行符就是双向帧定界,这不是侥幸而是性质:serde_json 把字符串内
换行转义成 \n,序列化对象里永远不出现裸 0x0A。长度前缀会是只有
BLE 才要实现的方言。重组上限 8 KiB,因为那个缓冲无线电范围内任何人
都够得着。
11a. 路由子集是安全边界,PIN 是传输层检查
两层授权严格分开:socket mode 0660 + robot 组决定谁能连上说话;
allow_users(按名字不按 uid——sysusers 动态分配,写死数字在下一块
板就错)决定谁能做变更调用,只读调用完全跳过第二层(支持人员能查
不允许改的鸭子)。btd 无特权而 configd 是 root,看着反了其实
不然:解析无线电范围内任何人字节的进程要在边界的安全一侧
——btd 只见无类型字节,configd 只见带对等凭证的本地 socket 上的
有类型 JSON。SO_PEERCRED 只报主 gid 是这里的坑:SupplementaryGroups=
能过 socket mode 这关、再过不去了——漏看它曾让 BLE 上一切变更调用报
PERMISSION_DENIED 而只读全通,读起来像玄学而非配置错误。
出处:app-path-design.md §3、§4、§5。
11b. 四条 lane:每个 daemon 一条连接一次只伺候一个请求
板子实测出的 bug 定了这个设计。每个 daemon 每连接一次一请求:读
一行、等整个调用、再读下一行。btd 起初每个服务开一条 socket,
于是应用最先要的两类序列全坏:
广播节律也是实测出来的:btd 注册广告没给 interval,BlueZ 用内核
默认 1.28s——机器人是房间里最强的信号(−36 dBm),两分钟里被听到
的次数比什么都低一个数量级(16 次 vs 智能插头 130 次),静默高达 31s。
8 秒的扫描窗口落进静默就 no robot found。广播间隔是机器人的属性,
所以是 btd 的责任:100–150ms(普通外设都用这个;不是规范允许的
20ms——一根天线还要载游戏pad LE 链路和 wifi)。装上后:120s 内
151 次到达、最长静默 3.8s、≥8s 的静默清零。
出处:app-path-design.md §3.4、§3.5(全部 measured)。
12. configd 深挖:配网是它存在的理由
一句话定形:btd 无所拥有,所以 configd 必须存在;configd 端出 PIN,
btd 才能配对;btd 路由一个方法,configd 就得回答一个——两者是一个
feature,一起落地。板子自带的是 netplan + systemd-networkd,被三个
实测发现否掉换成 NetworkManager:wpa_supplicant 的 D-Bus 实例根本
不持有任何接口;netplan 是配置生成器,没有扫描 API,netplan apply 答「配置已应用」而不是「关联是否成功」——配网流程最需要的两个
问题它一个都不答。BadKey(密码错)是 NM 迁移的全部意义:最常见的
配网失败必须能说出来,否则用户无事可做。NM 的 device state reason 7
映射为 BadKey/NotFound/Timeout,未映射的 reason 永不映射成
BadKey——那会让人循环重输本来正确的密码。
测试哲学值得一提:FakeNet 就是规格——三个 wifi bug 里它全都先
有正确行为,而 NM 实现漂移了(报了别网名的 connected、留下坏 key 的
profile、堆叠重复 profile)。问题是检查方向:套件验证 fake 对契约,
没有东西验证 NM 对两者。诚实结论:configd 的 wifi 行为测试过,跑在
机器人上的代码没有——每个 bug 都是上硬件一次会话里手工发现的。
出处:app-path-design.md §2、§2.2、§8.2、§6。
13. tofd 与 padd:一个只发布,一个只发意图
13a. tofd:为什么一个传感器值得一个守护进程
头部 8×8 ToF 深度矩阵单独跑一个进程,有三个原因(architecture.md
§1):带起 VL53L5/8CX 要经 I²C 上传 ~90 KB 固件,耗时数秒;I²C 总线
与音频编解码器共享;多数鸭子其实没装这个传感器——为它跑重试循环的
进程不该是管电机的那个。它也不进 mediad:深度是总线上的一台
传感器,不是媒体流,而且在还没有摄像头的时候它就能派上用场。
控制环本身不用深度,挪出去没有损失。
出处:architecture.md §1、robotd-design.md §4.5。
13b. padd:手柄是客户端,配对不是它的事
padd 读 gilrs,把摇杆转成意图,发到 robotd 的 socket——跟 app、
SDK、远程客户端走同一条路径。开发时不用写代码:一条 ssh 端口转发
ssh -L /tmp/robotd.sock:/run/robotd.sock,手柄在笔记本,鸭子的
robotd 在板上,padd 直接跟本地 socket 说话。
走同一条路径的好处是:app 将来依赖的输入通道,就是开发者每天用手柄
踩的那条,它不会悄悄坏掉。
出处:robotd-design.md §4.3、architecture.md §1、boot-recovery-net.md
成员资格表。
14. 库 crate 深挖:duck-control、kinematics、odometry
总览 §7 画了 crate 边界;这节拆边界内三个最重的库。
duck-control 管「从总线读到写回总线之间」的所有事:机器人模型、
总线层、IMU、观测向量、策略、安全。这个 crate 不依赖 tokio、socket 或
systemd。safety 模块独占 RobotIo 的唯一写句柄——Rust borrow checker
在编译期就保证,别人只能提议目标,发不到总线上去。
RobotIo trait 有两个实现:真总线 DynamixelIo,以及测试用的 FakeIo。
控制循环里还挂着几个非控制小模块。它们没有独立设计页,不是缺页,而是
规则如此:一个服务值得单独写设计页的前提,是有第二个读者需要看懂它。
这些模块各只有一个实现、一个消费者,所以决策就写在模块头注释里。
sound.rs:一个 aplay 子进程。新声音启动时杀掉旧的,因为 PCM 是
独占的。
theremin.rs:把 15 Hz 的 ToF 深度转成音符和嘴开度,循环里采样但不
等待。
chorale.rs:多鸭合唱。id 最小的当指挥,指挥维护座次表,btd 只
传信标,不动脑子。
pet-detect/:在板载麦克风的 40 频带 log-mel 窗口上跑一个 ~20 KB 的
CNN,独立 worker。
soc.rs:读 sysfs 里的 SoC 热区。它不能挂在 RobotIo 后面,因为电机
总线死掉时它仍然要能报温度。
出处:robotd-design.md §1.3、§2.1、§2.5、§4.4、§4.5。