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 能碰电机;其余所有客户端只发「意图」。

传感器

核心服务

传输层(可替换,不拥有状态)

客户端(都不拥有机器人状态)

BLE/USB → intents

BLE

ssh → /run/*.sock

WebRTC 信令

https 拉取

robot.* / update.* / net.* 子集

JSON-RPC 中继

一条 UART,独占(TIOEXCL)

tof.stream 发布

tof.stream

🎮 padd
手柄

📱 手机 App

💻 robotctl
ssh 上的 CLI

🌐 浏览器 / 远程 peer / LLM

📦 GitHub Releases
+ HF Hub

btd
BLE GATT 转发子集 API

mediad
WebRTC 网关 + 摄像头流
TCP :8080 控制台 / :8443 信令

robotd ★
50Hz 控制环 · 策略 · 安全
唯一持有电机写权限

configd
WiFi · 机器人名 · 配对 PIN · 手柄绑定

updaterd
验证 · 原子交换 · 健康门 · 回滚

tofd
头部 8×8 ToF 深度矩阵
只发布数据,不读别的服务

Dynamixel 总线
/dev/ttyS2 · 1 Mbps
15 舵机 + IMU 板 = 16 设备

HAT 的 I²C 总线

要点(出处:microduck/docs/design/architecture.md §1):


2. robotd 的 50Hz 控制环:一个 tick 里发生什么

50 Hz 循环,每 tick 两次总线事务(外加每秒一次慢速传感器读)。 安全层 safety唯一持有 RobotIo 写句柄的模块——由 Rust borrow checker 在类型系统层面强制,不是靠约定。

每个 tick(20 ms,MissedTickBehavior::Skip)

跌倒中 policy 不驱动

发布

发布

Dynamixel 总线
sync_read 寄存器 124–136
IMU 板 id=200 + 15 个舵机

① read()
一次 sync_read:IMU 四元数 + 关节状态

② Observation::build
[f32; 61]
gyro(3) · proj_gravity(3) · joint_pos(14)
joint_vel(14) · last_action(14) · command(13)

③ Policy::infer(ONNX)
优先级链:roulade > kick > ground pick >
sit/rise > stand(按 |twist|) > walk
输出 [f32; 14](不含嘴)

④ targets = home pose + action_scale × action
+ 头部/腿部一阶低通

⑤ safety.apply ★
· 拒绝非有限值
· 钳到执行器行程内
· 跌倒判定只报告,不门控

⑥ sync_write 目标位置

意图槽(原子快照,永不阻塞循环)
twist · head · 技能旗标
deadman:意图停发 → 速度归零

limp_fall 预测器
外推 ġ = −ω × g,倾角≈26° 且仍在倾
接管目标序列:软落地 → 等陀螺仪安静 → 1s 站起

atomics:tick 数 / 实际频率 / missed / fallen

robot.state 帧(仅有人订阅才组装,
有界 broadcast,慢客户端丢帧不背压)

四个 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 模式:控制面 / 数据面分离

控制线程(独立 runtime,50 Hz)

IPC 线程(tokio 多线程)

robot.move / robot.head
(notifications,无回复)

robot.init / robot.relax / 技能
(requests,有回复)

atomic load,每 tick 一次

每 tick 取一次

发布(永不反向询问)

send(仅订阅时)

robot.health / safeToRestart

robot.state(按订阅者降采样)

意图槽
ArcSwap + 时间戳
last-writer-wins

电源请求 / 技能旗标
每 tick 取一次

atomics
健康数据

有界 broadcast
telemetry

read

decide

write

publish

客户端
padd / robotctl / btd / mediad

两条规则(robotd-design.md §4.1,architecture.md §2):


4. 更新系统:整目录交换 + 健康门 + 自动回滚

更新是交换不是打补丁。一个 release 落盘为 /opt/robot/daemon/releases/<ver>/current 符号链接指向在用版本。

healthy

不健康 / 超时

GitHub Releases
manifest + tar.zst + .minisig
(模型走 HF Hub,同样自签 minisign)

robotctl update apply
(或手机经 btd 触发,btd 只转发不搬运)

验证 manifest 签名
(minisign,多把可信公钥)

下载 artifact → sha256 → artifact 签名
无签名字节永不进 live 路径

解包到 releases//

原子移动 current 符号链接

on_apply:重启 release 附带的全部 unit
★ 除 updaterd 和 btd(NEVER_RESTART,
答复发完后 5s 经 systemd-run 定时重启)

self_test_updaterd:只读模式跑新二进制
(架构错 / 缺库 / 拒绝旧配置 → 直接回滚)

健康门
30s 内问 robotd:robot.health

保持

自动回滚:current 指回旧版

兜底之兜底:开机 3 分钟的 /bin/sh 救援网
(在 updaterd 之外——专为 updaterd 起不来)

boot 计数器:crash-loop 次数超限 → 换 golden

关键设计(出处:updater-design.md §3–§5、§8;architecture.md §1.1):


5. 一个 API,多种传输

所有传输适配同一个 crate(duck-ipc-proto)定义的 API; btd 是纯转发,不拥有任何状态。

一份 API 定义(serde 类型 + 方法)

BLE

UDS

WS

RTC

robot.* intents 进 / state 出
update.* · net.* · pad.* · system.*
API 版本号握手 · 每传输各自鉴权

BLE(btd)
子集:配网 / 状态 / 更新触发
配对即物理在场鉴权
payload 永不过 BLE

Unix socket
robotctl · 板上 SDK
0660+组 = 免费鉴权
SO_PEERCRED = 审计+二次授权

WebSocket
服务端 agent / LLM
get_frame → JPEG 按需 1–2fps
几十行代码就能控鸭子

WebRTC DataChannel(mediad)
control:可靠有序 → 调用
teleop:不可靠无序 → 遥杆+遥测
(重传 80ms 前的摇杆指令不如丢掉)

📱 手机

💻 CLI/SDK

🤖 LLM agent
(高层控制:'去厨房'
反应控制仍在本地 robotd)

👤 远程临场(人)
<200ms glass-to-glass

出处:architecture.md §4.1、§5.2、§5.3;robotd-design.md §3.1、§3.5 (维护命名空间 init/校准/原始关节写不进远程传输)。


6. 训练 → 部署管线:从 MuJoCo 到鸭子

策略在 microduck_rl 训练,ONNX 部署到 robotd, 社区经 HF Hub 分享新技能。

microduck_rl(GPU · mjlab/MuJoCo Warp + PPO)

约束

约束

环境:13 任务族 ×
Flat/Rough/Backlash 变体
行走 · 起身 · 坐立 · 叼物 · 踢球 ·
翻滚 · 轮滑系列

BAM M6 执行器模型
XL330 到电压控制律级:
反电动势 · 库仑/Stribeck/负载摩擦
DR:电池电压·压降·延迟·摩擦

PPO
4096 envs · 50Hz
简单招式 ~1000 iters
步态/恢复类 4000–6000

export.py → ONNX
★ 观测归一化器烤进图
(手工转 checkpoint = 上机即废)

publish → HF Hub
policy.onnx + schema-2 manifest
上车前检查 [1,61]→[1,14]、
拒绝 NaN/常量输出

robotd 运行时
加载校验 61入/14出
61 维观测契约全家族共享 →
walk / stand / trick 热切换

61 维观测契约(全家族共享)
48 本体感受 + 13 命令块
[twist(3) head_pose(4) body_pose(6)]
不用的槽零填充,绝不删槽

出厂 7 个动作策略随 release 目录分发(robotd-design.md §2.3); 社区策略一条命令安装:sudo robotctl policy add bow &lt;user&gt;/microduck-polite-bow

出处:microduck_rl/README.mdmicroduck_rl/AGENTS.mdrobotd-design.md §2.2–2.3。


7. crate 边界:编译器当警察用

Rust workspace 的分法本身就是架构约束—— 「daemon 关注点不许漏进控制代码」由编译器执行,不靠自觉

库 crate(无 tokio · 无 socket · 无 systemd)

进程(有 socket / systemd / tokio)

robotd

padd

updater → updaterd

robotctl

mediad

duck-control ★
模型·总线·IMU·RobotIo·obs·policy·safety
safety 独占唯一 RobotIo 写句柄

kinematics
MJCF · FK · 头部链

odometry
足底接触锚定 + IMU 航向

robotd-params

sounds · pet-detect

duck-ipc-proto
wire 契约:仅 serde
无 tokio / http / crypto

出处:robotd-design.md §1.3。测试无需硬件:FakeIo(可脚本化、可冻结、可故障注入) 让 cargo test 不需要板子、网络或 Docker。


第二部分:模块深挖

前面七章是「系统长什么样」。接下来八章打开每个模块,看里面怎么转、 为什么这样设计。素材还是仓库设计文档,每张图后面都标了出处。

8. robotd:一条 UART 要精打细算

8a. 总线上谁说了算

15 个舵机加一块 IMU 板,共用一条 UART,没有第二条。serialport 设了 TIOCEXCL,但它只能拦住普通用户再打开端口;robotd 以 root 运行, root 不怕这个标志。所以「独占」不是靠系统强制,而是靠规则:每个可能 抢端口的人,都被单独安排到别处。

一次 sync_read 全读:寄存器 124–136(pwm·电流·速度·位置)

谁能打开 /dev/ttyS2 —— 各有各的挡法

需要 daemon 停止

已 mask

Conflicts=

独立于 RobotIo

控制循环
daemon 活着就独占

robotd init 子命令
逃生舱:daemon 没起来时用
daemon 运行中用 = 两写者交错 = 看起来像硬件故障
→ 日常走 robot.init IPC,循环内部代发

serial-getty@ttyS2
Armbian 默认在 UART2 跑登录控制台
agetty 占住端口 = 所有舵机隐形
setup-board.sh 已 mask;fuser -v 是排查命令

旧 runtime
同一条总线,二选一
systemd units 用 Conflicts= 互斥

/dev/ttyS2 · 1 Mbps · Dynamixel 协议 v2
duck_control::bus::DynamixelIo

id 200 · imu_to_dxl v2 板
片上 SFLP 四元数
排在 id 向量最前 = 抢在舵机前应答
没有 IMU 抽象层,就是总线上一台设备

id 20–24
左腿 5 舵机

id 30–34
颈·头·嘴 5 舵机

id 10–14
右腿 5 舵机

每 1s 一次 slow_sensors
寄存器 144–146:电压 + 温度
故意不并入 tick 读:中间隔着 12 字节没人要的轨迹寄存器
widened 读 = 每舵机 22 字节 × 50Hz,只为 1Hz 的数据

SoC 热区(sysfs,robotd/src/soc.rs)
第三个温度源,根本不在总线上
电机总线死掉时它还在回答——
「板子过热」和「舵机全死」症状相同,能看到两个数字才分得开

启动时 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 里读出来的,不是猜的:

command 块展开(三处「看起来对其实错」)

Observation [f32; 61]

[0–3) gyro
陀螺仪

[3–6) proj_gravity
躯干系投影重力

[6–20) joint_pos 14
相对 home 姿态

[20–34) joint_vel 14

[34–48) last_action 14

[48–61) command 13

[48–51) vx · vy · vyaw

[51–55) neck_pitch · head_pitch
head_yaw · head_roll
头部目标走命令,不叠在策略输出上——
两头都加 = 脖子弯两次

[55–57) body x · y
硬编码 0 = 「未绑定」
全 0 是名义编码不是占位符

[57–60) body z · roll · pitch
★ 顺序是 z roll pitch
不是 z pitch roll——
swap 后两个会往侧面倒

[60] body yaw
硬编码 0

关节全程排除嘴: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 笔写塞进每拍:

每拍策略选择(优先级链,形状承自 runtime)

bring-up 状态机(robot.enable 推进)

robot.enable
门 1:策略已加载
门 2:本拍有新鲜采样
(躺地上不拦——地板上叫它起来
正是这个调用存在的意义)

robot.relax 清 enabled
否则下一拍又把它站起来

Limp
启动即入,不碰扭矩

Homing
扭矩上,2s 斜坡

Ready

策略驱动

技能窗口推进/到期
roulade 窗口 · kick 计时
ground-pick 相位 · sit↔stand

命令按当前技能重编码

net 选择
roulade > kick > ground pick >
sit/rise > stand(按 |twist| 或强制) > walk

ONNX action [1,14]

targets = home pose
+ action_scale × action

头/腿一阶低通
(α 就是训练用的那组,
不一致 = 迁移劣化)

两个故意不「修」的原型怪癖:
① kick 窗口跑站立调参(观测命令全 0,站立切换恰好因此触发)
② sit/rise 的 rise 也跑站立增益,sit 不跑
——踢腿和起立都是对着这些怪癖调出来的

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.apply恢复序列fall::limp_fall(duck_control::fall)控制循环 控制循环 50Hzsafety.apply恢复序列fall::limp_fall(duck_control::fall)控制循环 控制循环 50Hzdriving = false(四条件里的 ¬limp-fall)策略整段不驱动,目标来自序列全程 twist 钳在 0「交接」无仪式:命令幅值自动选中 stand 网络站起就是它每拍:陀螺仪 ω + 投影重力 g(同一个 12 字节 IMU 块,零额外总线流量)ġ = −ω × g 精确成立(g 随躯干旋转)外推 ~0.3s —— 微分 SFLP 四元数会把滤波滞后加进这个唯一价值在「早」的数字上,不行触发 = 已倾 ≈26° ∧ 仍在倾 ∧ 预测过阈3 拍去抖。误报是鸭子「自己摔的」比它想避免的硬着陆更糟 → 默认故意调在偏迟一侧gain_limp,目标跟随关节下落(软落地)等陀螺仪安静~1s 斜坡回站立姿态拒绝非有限 · 钳行程序列目标走 apply 进电机——无豁免、无后门(早版本的 fall_limp 门 + fall_recover 自动起身都被删了:需要被恢复逻辑绕过的安全规则不是安全规则)

买到的不是落地本身,而是落地之后的起身:站立策略把一只安静的、姿态已知 的鸭子干净地起起来;对一只挣扎的,则要在地板上用走路增益试好几轮——电机 负载大头就在那。另一个会不经请求动鸭子的东西是没电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 零改动。

技能族

运动族(Flat/Rough 双地形)

约束

约束

约束

轮滑族(全 Flat,模型=passive 轮子)

Velocity-Rollers
穿轮滑鞋的速度跟踪

Velocity-Swizzle
对称八字冰舞

RollerCrouch / RollerSlope
滑行蹲 / 下坡

RollerStandUp
从地面起身到轮上

Spin
轮上原地快速旋转

Velocity
主任务:速度命令 + 头部姿态命令走
(walk 模型剥了躯干/头碰撞——摔变便宜)

VelStand
走路 + 摔倒恢复,一个策略装下

StandUp
俯卧/仰卧/坐姿起身→站住
+ 身体姿态控制

SitStand
命令式坐↔站,一个策略,温和,头可命令

GroundPick
蹲下嘴尖触地再回站
(相位驱动,daemon 管,不可 publish)

BallKick
踢 70mm/15g 球,actor 对球致盲

Roulade
过头前滚翻,落回双脚

61 维观测契约(全家族共享)
48 本体感受 + 13 命令块
twist(3) head_pose(4) body_pose(6)
不用的槽零填充——绝不删槽
= runtime 任意时刻热换策略的前提

runtime 热换:walk / recover / trick
infer_policy.py 排练的正是这件事
(G 捡地 Y 坐站 R 滚翻 K/L 踢)

Backlash 孪生
±1° 齿隙串每个关节
编码器在输出侧 → 观测穿过间隙
与主任务镜像同一机器人模型
(A/B 比较不被模型差异污染)

关节布局(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:

部署排练(scripts/infer_policy.py)

CPU MuJoCo 里用训练同款 BAM M6
--vin / --vin-drop-gain / --kp-fw
把 DR 范围钉到一个值
--no-bam 才退回 XML PD

上车前检查:
ONNX [1,61]→[1,14]、拒 NaN/常量输出、
观测归一化器已烤进图

每环境域随机化(FrictionDRBamActuator)

电池电压

负载下的压降

命令延迟

摩擦幅度

BAM M6 XL330 模型(每个舵机)

电压控制律
(不是力矩控制律)

反电动势
(转速快 = 可用力矩变小)

摩擦:库仑 + Stribeck + 负载相关
(由执行器计算,dof_frictionloss 在 BAM 下已清零)

两个 BAM 后果(不变量):
① 独立 env cfg 必须注册 expand_bam_friction_fields 启动事件
② 关节摩擦 DR 必须缩放执行器的 friction_scale——
随机化 dof_frictionloss 是静默空操作

DR 不得跨 reset 累积:
一个累积的质心随机化器劣化了所有长跑数月

观测归一化器必须烤进 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 奖励工程里最贵的三课:

① 双负变正:gentle_rise
trunk_vertical_accel_penalty 返回 -|a_z|
权重 -0.02 又负一次
= +0.02·|a_z| —— 躯干加速越暴,越给钱
日志里唯一的正罚项就是证据

修正:权重改 +0.02,幅度故意小
翻身时 |a_z| 本来就高,权重太大等于卡住动作
真正的软着陆杠杆:joint_torque_rate_l2 -2e-3 → -0.2
(罚力矩变化,不罚动作本身)

② 头部冲击罚把策略冻住了:
照搬 velstand 的 head_impact -1.0 后
策略收敛到躺平不动
——从背侧起身,头肩是唯一支点
罚它等于封死唯一出路,最难的 case 最先死

躺着也能拿钱:
pose_stand_legs 在躺平时仍有 +7.72/8
腿在 HOME 位,几乎白送
要让「躺着」净负,只能靠 height_stand_l1(+30)
别削弱它

③ 三个改动一起上,
出了问题分不清是谁干的
——下次一次只改一处

踩过的几条规则:
· 每个 Episode_Reward 罚项必须 ≤ 0,否则符号自检亮红灯
· RL 会按奖励字面意思优化,任何没写死的自由度都会被钻空子
· 「到达 X」类奖励要限流,先到早收等于花钱买暴力
· 坏状态(摔倒/躺平)上绝不负正奖励,否则策略会停在最便宜的合格姿势里种田
· 动作阻断器(body_ang_vel)给轻权重,平滑项(action_rate)等技能成型后再加
· 罚项看贡献量不看权重——同样权重在 4 倍大的任务栈里只弱 4 倍

命令槽的三条军规(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 计数器、写日志:

healthy / degraded

不健康 / 钩子失败 / 超时

任何非零退出

restart 失败

SelfTest 失败

① preflight:时钟 NTP / 机器人停稳 / 无远程会话

② 取 manifest · 验 manifest 签名
版本/min_hw_rev/model_api/pin/降级检查

③ 第二次 preflight:磁盘空间(需求来自 manifest)

④ 下载到 releases/.staging-/dl/

⑤ 验 sha256 → 验 artifact 签名

⑥ 解包到 .staging-/root/

⑦ hooks/preinstall(上限 10 分钟)
ONNX Runtime 低于底线则安装
跑 setup-gstreamer.sh(~100MB apt)
——旧 release 还在跑,慢更新 ≠ 危险状态

⑧ rename 进 releases//
⑨ 先武装 boot 计数器(在 swap 之前!)
⑩ 原子换 current 符号链接

⑪ hooks/postinstall(上限 120s)
sysusers → 装全部 unit → daemon-reload
→ enable --now 有 [Install] 的 unit
(--now 对运行中 unit 是 no-op,不碰 updaterd/btd)

⑫ on_apply:逐个 systemctl restart
configd → mediad → padd → robotd → tofd
(绝不批量:一批里一个不认识整批失败)

⑬ 新 bin/updaterd --self-test(10s 上限)
只读模式:装配置、建引擎、不碰状态就退出
错架构/缺库/拒收 updater.toml → 便宜回滚不用重启
失败带着 stderr 最后一行进回滚原因

⑭ 健康门
每 500ms 轮询 robotd socket,30s 上限
healthy / degraded 都提交,其余回滚

⑮ 确认 boot 计数器 · 修剪旧 release(保 golden)

⑯ 释放更新锁 · systemd-run --on-active=5s
安排 updaterd 和 btd 的延迟重启

⑰ 答复发出去(robotctl / app / BLE)

⑱ 步骤16后 ~5s:updaterd、btd 换到新二进制

回滚:current 指回旧版
对旧 release 重跑 on_apply
journal 记账

NEVER_RESTART 的两个理由:
updaterd = 正在做更新的进程
btd = 更新可能正走它的传输
但「延迟到下次开机再换」曾是真的 bug:
resident updaterd 用 v3 拒了 v4 的 robotctl,
btd 修复对着从没跑过的二进制测试——
所以答复上线路 5s 后必须换

延迟重启的三个承重细节: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/密码管理器/离线)

无人值守路径有一个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 每次启动时对账:

/run//identity.json
{ service, version, revision, built_at, exe, pid }
自报而非外读:进程知道自己的版本,读自己 exe 无需特权
RuntimeDirectory= 归 unit 自己的 User=
unit 停止时 systemd 删目录 → 停止的 daemon 留不下冒名身份

verdict_for(纯函数)
发布的 release vs 当前 active release

Current:一致
静默不动

Restarted:不一致
systemctl restart,warn 日志

ReportedOnly:不一致且是 updaterd 自己
只记日志,永不自己重启自己——
继任者对 active release 有异议就会重启→再异议→循环,
而它是唯一拥有恢复逻辑的进程

Unknown:无身份文件
停了的 unit / 太老的 build,一样处理:不动
(每次启动都去启动别人停掉的 unit = 覆盖运维的决定)

RestartFailed:尝试失败
error 日志

updaterd 落后的修法(§5 不修它):
robotctl update apply → already_current + stale 名单 + 排期重启
systemctl restart updaterd 手工等效

比较是双向的:任何不一致都算 stale
= 显式 rollback 把 btd 留在「超前于 current」的 release 也能抓到
btd 自愈;updaterd 是唯一不自愈的(by design)

各转移路径的差异一张表记牢(restart-order.md §3):只有 applyselect 跑 hooks 和自检;rollback / reset-to-golden / 健康门内回滚 / boot 计数器回退都不欠延迟重启——前两者的后半是因为 updaterd/btd 从来 没离开过要回退回去的那个 release。

出处:restart-order.md §1、§5、§3。

9c. 兜底之兜底:180 秒开机救援网

三层保护(self-test、延迟重启、启动对账)全部预设一件事:updaterd 能启动。boot 计数器数的是 updaterd 的启动次数而非字面意义的开机 ——updaterd 起不来就不计数,于是「永不变砖」的闭环闭在唯一可能就是 伤亡的那个进程里。救援网必须活在那之外:

有成员倒下

没有

robot-boot-check.timer
OnBootSec=180(不是 OnFailure=)
板子比看起来慢:hci0 要 ~73s 才存在,
bluetooth.service 其中 26s 阻塞在 dbus 后
→ 慢启动 ≠ 失败启动;且 Restart=always 的 unit
永远到不了 failed 态,OnFailure 根本不会触发

谓词(/bin/sh,装在 /usr/local/sbin):
任一成员 unit 满足任一条:
ActiveState=failed ∨ NRestarts ≥ 3
误报代价 = 一个好 release;漏报代价 = 一次诊断
→ 宁可漏

成员资格规则:
只收「缺硬件时等待而不是退出」的 unit
robotd ✓ updaterd ✓ btd ✓ configd ✓
padd ✗(无 socket 时干净退出是设计)
→ 成员倒了 = 二进制坏了(错架构/缺库/启动 panic)
= 回滚能修的病;缺无线电/电机没电不是

robot-rescue:读两个符号链接,零解析
mv -T 把临时链接 rename 压过 current
(ln -sfn 有 exec 不到的窗口;裸 mv 会把链接挪进 releases/)
记录 breadcrumb → reboot(--reboot 可选)

礼貌拒绝(点名原因):无 golden 链接 /
golden 指向未安装的版本 /
current 已是 golden(= 故障在硬件,再换只是重启循环)

循环保险 ×2:
① current==golden 即拒(成功救援后恰是此状态)
② breadcrumb: updaterd 启动时先 record_rescue 再 record_boot
(顺序承重:武装中的 trial 会把 current 从 golden 挪走,
救援网和永不变砖承诺互相拆台)
breadcrumb 是 key=value 不是 JSON:
出事的板子上 sh 引号套 JSON = 谁都读不了

at=1786453421
install_dir=/opt/robot/daemon
from=1.2.0 to=1.0.0
because=boot check: robotd.service (failed, 7 restarts)

什么都不做

为什么 golden 而不是 previous:
坏的是恢复路径本身时,previous 可能也坏
golden 有长期保证且 prune 永不删它
rescue 解析 updater.toml 会死于它要治的病
(最可能的获救对象正是拒收 updater.toml 的新 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. 一条会话四条流,一条管线两次分叉

一个 PeerConnection 装下所有东西(DTLS/SCTP 不可跨进程拆,mediad 独占)

管线:在编码器**之前**分叉原始帧

v4l2src 采集
UYVY 单平面(不是 NM12!)

tee — NV12 全程,零转换

queue → mpph264enc → h264parse → webrtcsink
(每分支自己的 queue:无 queue 的 tee 会让慢读者拖停视频轨)

queue(leaky, 1) → appsink
最新帧快照,后值胜,非阻塞
给 get_frame JPEG / 感知用
——从编码分支取帧 = 解码刚编完的东西

video track
头摄像头 · mpph264enc 硬编码
constrained-baseline (42e01f)

audio track
麦克风 · 双向(临场感)

datachannel 'control'
可靠有序 → JSON-RPC 2.0 每行一对象
与 unix socket / BLE 同一份 wire

datachannel 'teleop'
不可靠无序 maxRetransmits:0
重传 80ms 前的摇杆指令不如丢掉
(v1 只开 control)

btd 的 route.rs 模式平移:
{允许? / 哪个 socket 回答 / 走哪条 lane} 三合一表
对 proto::Call 的 match 穷尽——加方法不改表就编译失败
不关联回复:订阅是通知流,关联会弄坏它

robotd / configd / updaterd 的 unix socket

两个端口,人只见一个:
:8080 axum 服务控制台页(include_str! 内嵌,非文件安装)
:8443 webrtcsink 自带信令服务器
页面由 mediad 填入信令 URL——--port 改了没有第二处存旧值

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 的路上有两个互不相干的修复,难查的原因是 任何单独立上都不动数字——让每一个看起来都像没用:

症状:硬编码器路径只有 ~19.7 fps(目标 29.3)

病因 ① 采集池深度
rkisp 不实现 V4L2_CID_MIN_BUFFERS_FOR_CAPTURE
GStreamer own_min 从 0 算出 2 个缓冲
3 是悬崖不是斜坡:2 缓冲 = 19.7,3+ = 29.2
修复 = ALLOCATION query 带 GstVideoMeta + 首池 min 非零

病因 ② 像素格式
rkisp 在单平面格式外还提供非连续双平面 NM12
都映射到 GStreamer NV12,v4l2src 偏爱多平面那个
任何池深都跑不满
修复 = 显式要 UYVY 单平面
(mpph264enc 收 UYVY,4:2:2→4:2:0 在 RGA 上转换,零 CPU)

被测量排除的嫌疑人(逐个量过,不是吵赢的):
v4l2-ctl 两种格式都到 29.2 · sensor 上报 1/30 间隔
·驱动无 S_PARM · mpph264enc 裸编 720p 130fps
·DMABuf caps 不是快的原因

方法论教训(比上面都贵):
四种帧率各被当成采集率,没有一种是——
rkvenc 中断数的是编码器消费、在丢帧队列之后
v4l2src 'lost frames' 只数序号缺口、源一慢就沉默
tee 原始分支计数器躲在一个故意漏的 1 缓冲队列后面
/dev/video1 是 ISP 自路径不是主路径
→ mediad 改在 tee 之前的 pad 上计量:驱动与它之间无损耗环节的唯一位置

拥塞控制也是 CPU 设置:
rtpgccbwe 单线程吃 7.6% 核,是进程内最大 CPU 消耗者
随像素走的(采集+原始拷贝)合计 0.6%
→ 选小分辨率不省 CPU:带宽不饱和时估计器把 360p
拉回 720p 的码率花在每像素质量上
quality 是一档枚举(1080p30/720p30/720p15/360p30)
而不是宽高帧率三个数——组不出来的组合 = 起不来的管线
= 连 control 信道一起死(和 video 是 BUNDLE)

还有一类 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 是传输层检查

未过 PIN

过 PIN

拒绝

允许

📱 手机 / duckctl

唯一 characteristic
读 = API_VERSION;写 = NDJSON 请求;订阅 = 答案
(两个 characteristic 的常规形状被否了:BlueZ 把写和订阅
报成两个事件,得按设备地址猜关联)

btd 会话门
(system.authenticate 由传输自己回答 Route::Local)

未认证只放行 hello
(它报的只有版本——GATT 读本来就告诉未认证客户端同一件事)

PIN 检查三条承重细节:
·每次从 configd 现取不缓存(改 PIN 下次尝试即生效)
·按字符串比较(042042 ≠ 42042,数字解析会让它们相等)
·三次关会话,attempts_remaining 回给客户端
——百万种猜测的六位 PIN,配给是让爆破变贵的唯一手段
重连成本 = 完整 BLE connect + bond

route.rs 三合一表:允许? / 谁的 socket / 哪条 lane
对 Call 穷尽 match——加方法不改表就编译失败
(7 个 net.*/system.* 方法加进来时真断过构建)

拒绝名单,各有一个理由:
system.pairingPin / setPairingPin —— 承重的一条:
未配对端能读或改的 PIN = 配对演戏;btd 改走 unix socket 取
update.pin —— 误触锁死更新还自报最新,像正确的失败
update.resetToGolden —— 事实上的恢复出厂,永不过无线电
robot.move 等运动类 —— 详见总览 §5
(update.rollback/select 原在名单上后来移出:
健康门管不了「装上了、能跑、但走得更糟」的 release,
那种只有拿着手机的人能回退)

转发到 configd / updaterd / robotd 的 unix socket
btd 永不解析回复——订阅通知流必须全量到达

为什么 PIN 不能走 BLE 配对协议本身(实测逼出来的):
LE passkey entry 一边显示一边输入,角色由 IO 能力定
机器人填不了'输入'角色;显示侧按规范随机生成 passkey
→ 印在机身上的固定 PIN 在 BLE 配对协议里不可表达
= Just-Works 加密 + 应用层 PIN(选定方案)
⚠ 加密目前默认关(--require-pairing 存在但 off):
encrypt_read 在 macOS 上让读挂死(无提示无错误无重试)
开它 = 每个客户端全挂 → 可用压倒安全,明文上线,
每次启动 warn 日志保持可见。交给人之前必须翻默认值

两层授权严格分开: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, 于是应用最先要的两类序列全坏:

翻车现场(都实测过):
① update.apply 然后 update.status
→ status 行排在 updaterd 几分钟不读的 socket 里,
客户端超时什么都没听到,机器人在正常更新
② update.subscribe 然后 update.apply
→ 更糟:stream_progress 占住连接永不读下一个请求
apply 写进没人读的 socket:不跑、不答、不报错
——主人按了更新,机器人什么都不做,任何地方都无错可查

修法:按「占用连接多久」分 lane,
lane 与权限、目标服务写在同一张 route.rs 表里
(穷尽 match 连带覆盖它——新长方法必须有人选 lane)

Prompt lane
一次查询的工夫
hello · update.status · net.status
system.* · robot.health · pad.status

Slow lane
几秒——网络或扫频
update.check · net.scan

Operation lane
要多久有多久,且变更机器人
update.apply · update.rollback
net.connect · pad.pair

Stream lane
永远占着,不答任何东西
update.subscribe

分组即排队,而每组排队都是对的:
updaterd 用文件锁单飞变更、第二个报 BUSY
update.check 故意不在 Operation lane:
更新进行中问它有即时答案,排队会把 BUSY
变成几分钟后才转出来的转圈
每服务每会话至多 4 socket,实际 2。
一调用一连接更整洁,但需要 btd 知道调用何时结束
= 需要解析回复——它永不解析(§3),
这个性质正是让路由子集保持'传输'而非第二套 API 实现

广播节律也是实测出来的: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 是配置生成器,没有扫描 APInetplan apply 答「配置已应用」而不是「关联是否成功」——配网流程最需要的两个 问题它一个都不答。BadKey(密码错)是 NM 迁移的全部意义:最常见的 配网失败必须能说出来,否则用户无事可做。NM 的 device state reason 7 映射为 BadKey/NotFound/Timeout未映射的 reason 永不映射成 BadKey——那会让人循环重输本来正确的密码。

配网五难,全部实测:
① daemon 不等网络(btd 只 After=dbus+bluetooth,configd 只 After=NM
,都不等 network-online)→ 没有 AP 板子也起来伺候 BLE
② profile 存活于重启(autoconnect 默认开,重连全归 NM,configd 不进重连循环)
③ 扫描要等扫描(RequestScan 返回的是 NM *接受了*,没扫完;
关联时缓存里常常只有当前 AP。等 LastScan 前进,上限 10s)
④ 结果读 activation 不读 device ★最重的 bug:
poll 设备态 → 设备在旧网上一直 ACTIVATED,
新 activation 在旁边失败 → connect('不存在的网', psk:'lol')
答 connected 并报错网名。给从未发生的入网报成功是能给出的最坏答案
⑤ 重配是替换不是堆积(AddAndActivate 永远新增,NM 容忍同名共存:
手机打错密码 → BadKey → 正确密码 → 板上留两个 profile,
重启后谁 autoconnect 听天由命。先删后加;失败时删掉 NM 加的坏 profile)

机器人身份(§8.2 落地):
SoC 序列号 /proc/device-tree/serial-number
烧在芯片里、过 reflash 不变、无需供应步骤
默认名 = duck- + 序列号 SHA-256 的 4 个 hex(duck-c51b)
哈希而非切片:没人保证芯片 id 哪段会变;
SHA-256 而非 std hasher:后者跨 Rust 版本不稳,
工具链一升级全舰队改名,没人会联系起这两件事
两个名字都得设:广播 Local Name + GAP 0x2A00
(只设一个:Linux 上首连前叫 duck-c51b、首连后变回 hostname)
广播还带 IPv4(company id 0xFFFF 4 字节):
0.0.0.0 = 无网络,无字段 = 旧 release,三态可分
SSID 装不下:32 字节 vs 剩 6 字节预算

btd 每 5s reconcile 广播名/地址(不事件驱动):
btd 转发 setName 不读回复(读回复是这 daemon 刻意不做的),
转发完立刻重问 = 和刚转发的写竞态;
轮询还覆盖走 robotctl 的改名(根本不经过 btd)

测试哲学值得一提: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:深度是总线上的一台 传感器,不是媒体流,而且在还没有摄像头的时候它就能派上用场。 控制环本身不用深度,挪出去没有损失。

/run/tofd/tof.sock · tof.stream
(订阅走 tofd 自己的 socket,
绝不经过 robotd 中转)

VL53L5/8CX
8×8 深度矩阵
(没装的板子照跑 tofd 并如实上报)

HAT 的 I²C 总线
(与音频编解码器共享)

tofd
管一个传感器
向外发布 tof.stream
自己不去读别的服务

mediad
消费深度(感知在传感器旁)

robotd
theremin:15Hz 深度 → 音高+嘴开度
50Hz 循环采样、永不等待

想投影到机器人坐标系?
消费方自己拿 tof.stream + robot.state
走 kinematics 的头部 FK 合成
——tofd 只发布传感器的视图,
不假装算得了它没有的几何

出处:architecture.md §1、robotd-design.md §4.5。

13b. padd:手柄是客户端,配对不是它的事

paddgilrs,把摇杆转成意图,发到 robotd 的 socket——跟 app、 SDK、远程客户端走同一条路径。开发时不用写代码:一条 ssh 端口转发 ssh -L /tmp/robotd.sock:/run/robotd.sock,手柄在笔记本,鸭子的 robotd 在板上,padd 直接跟本地 socket 说话。

走同一条路径的好处是:app 将来依赖的输入通道,就是开发者每天用手柄 踩的那条,它不会悄悄坏掉

🎮 手柄
BLE / USB

padd(padd.service,开机即跑)
gilrs → intents
自己的 crate:把游戏柄栈挡在 robotctl 之外
——那可是坏机器人上必须能用的工具
无特权:input + robot 组成员身份是它的全部
无手柄也安全:不发任何东西,deadman 摁住鸭子

robotd /run/robotd.sock
robot.move / robot.head 通知 50Hz
robot.stop / robot.enable 请求

手柄配对(bonding)→ configd
不是 padd 的事:bond 需要 root + BlueZ,
而 padd 持有任一样就不再是在练 app 将用的 API
(architecture §1:configd 管配对的原因)

raw 输入抽头 /run/padd/pad.sock
(pad.input) —— robotctl 的输入监视走这里

无 robotd socket 时干净退出(每 5s 重试)
—— 所以救援网不带它:
救援网只收「缺硬件时该等待而不是退出」的 unit,
padd 退出是设计选择,把它当成 crash 才叫误诊

出处: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

odometry:接触锚定 + IMU 航向

足底接触锚定算法:
一只脚掌角 = 地面锚点,
躯干世界位姿 = 经 kinematics FK 从锚点推
另一角降到它之下时锚点才移动
→ 迈步永不让估计跳变
航向 = IMU 积分 yaw,
世界系 = 开机时鸭子面朝的方向
无磁力计、无漂移修正:这是相对运动,
所有消费者都必须这么对待

成本:每 tick 两次链求值、零额外总线流量
所以它跑循环自己的 50Hz 而不是原型的 100Hz
「不做 odometry」的决定是被反转的:
反对理由从来不是算术,是没有东西读答案
——monitor 的路径图现在读

kinematics:MJCF · FK · 头部链

MJCF 模型 + 前向运动学
头部链 / 手部链

两个消费者:odometry + robotd 头部 FK
不拆 crate 就会各长一份模型拷贝

trait RobotIo 的两个实现,都不做 macOS cfg 门控

DynamixelIo
真总线(只有开真端口那两个入口被门控)

FakeIo
样本可脚本化、可冻结、可按需失败
cargo test 不需要板子/网络/Docker
——最可能被没有板子的人改的代码,恰恰能编译检查

duck-control(无 tokio · 无 socket · 无 systemd)

robot model:15 关节 Rust 常量表
JOINT_NAMES 来自 duck-ipc-proto(wire 按位置索引,
两边顺序不许漂——const 断言把'不许'变'不能')
DEFAULT_POSITION 必须等于训练环境 HOME_FRAME:
策略观测的是相对 home 的关节角,不一致 = 14 槽整体偏移

bus 层:DynamixelIo
rustypot 之上薄薄一层,数字全部借自 runtime 且注明出处

safety ★ 独占 RobotIo 唯一写句柄
拒绝非有限 · 钳执行器行程 · deadman
跌倒判定只报告不门控

控制循环里还挂着几个非控制小模块。它们没有独立设计页,不是缺页,而是 规则如此:一个服务值得单独写设计页的前提,是有第二个读者需要看懂它。 这些模块各只有一个实现、一个消费者,所以决策就写在模块头注释里。

出处:robotd-design.md §1.3、§2.1、§2.5、§4.4、§4.5。