6.1 / 心跳快照(设备 ← 桌面)
{
"total": 3,
"running": 1,
"waiting": 1,
"msg": "approve: Bash",
"entries": ["10:42 git push", "10:41 yarn test", "10:39 reading file..."],
"tokens": 184502,
"tokens_today": 31200,
"prompt": {
"id": "req_abc123",
"tool": "Bash",
"hint": "rm -rf /tmp/foo"
}
}
关键合约
频率合约
状态变化时发,最长 10 秒一次保活。30 秒收不到当连接断。设备必须做"30s 没数据"超时逻辑,不能依赖 BLE 链路状态。
幂等合约
每次心跳是完整快照,不是增量。设备可以丢弃旧心跳,只看最新一条。极大简化了状态机——不需要 reconcile,直接覆盖。
预聚合合约
entries 已是字符串、不是结构化日志;tokens 已累加;prompt.hint 已截断。所有聚合在桌面端做,设备只显示。把复杂度推给主机端,设备只做"哑终端"。
6.2 / 权限审批(设备 → 桌面)
{"cmd":"permission","id":"req_abc123","decision":"once"}
{"cmd":"permission","id":"req_abc123","decision":"deny"}
合约:
- · id 必须 byte-for-byte 等于上一条心跳的 prompt.id
- · decision 只有两个值:once 或 deny
- · 没有 always / forever / whitelist——协议层主动不暴露持久授权
最后一点是关键。Claude Code 桌面端本身有"始终允许"模式,Buddy 故意不让设备触发。
设计意图
物理摩擦是 feature,不是 bug。如果允许"在 Buddy 上点一次永久允许 git push",设备就从"审批员"退化成"麻木盖章机"。Buddy 的产品价值在于每次审批的"瞥一眼 + 按一次"轻摩擦——这个摩擦本身在阻止一类错误。一旦允许 always,整个产品价值就垮了。
这种"协议刻意不暴露能力"的克制设计很少见。多数协议设计者会想"提供给客户端,让客户端选要不要用"——Buddy 反过来:禁止客户端拥有这个选择。这是产品决策固化在协议里的例子。
6.3 / Turn 事件(设备 ← 桌面,异步)
{
"evt": "turn",
"role": "assistant",
"content": [{"type": "text", "text": "..."}]
}
每个 Claude 回复完成后触发一次。content 数组是 SDK 原生格式(text / tool_use / tool_result)。超过 4 KB 的事件被丢弃。
为什么 4 KB?工程约束驱动:
- · BLE Notify 实测吞吐 ~10–20 KB/s(取决于连接参数)
- · 4 KB 一帧约 200–400 ms 传输完
- · 超过 4 KB 的回复一般是大段代码或长解释,反正小屏幕展示不下
- · 4 KB 也够 ESP32 RAM 缓冲一帧(这块 SoC 默认 RAM 320 KB)
设计意图:Turn 不是为了让设备完整复制对话,是为了触发"Claude 说话了"动画。如果想要完整记录,应该用别的传输(USB 直连、WiFi)。Buddy 在 BLE 上的角色是"状态指示器",不是"日志同步器"。
6.4 / 状态上报 ack(桌面 ← 设备)
{
"ack": "status",
"ok": true,
"data": {
"name": "Clawd",
"sec": true,
"bat": {"pct": 87, "mV": 4012, "mA": -120, "usb": true},
"sys": {"up": 8412, "heap": 84200},
"stats": {"appr": 42, "deny": 3, "vel": 8, "nap": 12, "lvl": 5}
}
}
桌面 poll,设备答。协议里最弱合约的部分——每个字段都是可选,设备不支持就不填。
stats.lvl 是设备自己跟踪的"等级"(每 5 万 token 升一级),上报给桌面。这个状态桌面不维护——设备的 NVS 是单一真相源。又一个克制设计:让设备拥有它显示的状态,桌面只是消费方。
6.5 / Folder Push — 流式角色包传输
协议里最复杂的部分。Hardware Buddy 窗口的 drop target 收到一个文件夹后触发:
桌面: {"cmd":"char_begin","name":"bufo","total":184320}
设备: {"ack":"char_begin","ok":true}
— 每个文件 —
桌面: {"cmd":"file","path":"manifest.json","size":412}
设备: {"ack":"file","ok":true}
桌面: {"cmd":"chunk","d":"<base64>"}
设备: {"ack":"chunk","ok":true,"n":4096}
… 重复 chunk 直到 size 字节传完 …
桌面: {"cmd":"file_end"}
设备: {"ack":"file_end","ok":true,"n":412}
— 包结束 —
桌面: {"cmd":"char_end"}
设备: {"ack":"char_end","ok":true}
设计要点
CHUNK 级 ACK = 流控
桌面不发下个 chunk 直到拿到上个 ack——应用层 stop-and-wait。BLE 链路层有 ACK,但应用层这一层让协议感知"设备 flash 写慢了"——设备只要拖延 ack,桌面自然降速。
n 字段 = 进度
返回累积字节数,给桌面提供 progress bar 数据源——不用单独算。
PATH 校验 = 接收方责任
协议规范要求接收方拒绝 .. 和绝对路径。防御纵深做成协议合规——桌面被攻破往设备发恶意 path 时,设备自身仍要拒绝。
内容无关 = 协议复用
GIF、配置、固件镜像都行。意味着协议未来可以在不修改 wire format 的情况下扩展支持新内容类型——OTA、wallpaper、sound pack。协议 = 抽象,不是具体功能。
6.6 / 角色包 manifest 格式
Folder Push 传输的内容里,manifest.json 的 schema:
{
"name": "bufo",
"colors": {
"body": "#6B8E23",
"bg": "#000000",
"text": "#FFFFFF",
"textDim": "#808080",
"ink": "#000000"
},
"states": {
"sleep": "sleep.gif",
"idle": ["idle_0.gif", "idle_1.gif", "idle_2.gif"],
"busy": "busy.gif",
"attention": "attention.gif",
"celebrate": "celebrate.gif",
"dizzy": "dizzy.gif",
"heart": "heart.gif"
}
}
- · GIF 宽度固定 96px、高度上限 ~140px(M5StickCPlus 屏幕 135×240 竖屏)
- · 整包 < 1.8 MB;gifsicle --lossy=80 -O3 --colors 64 一般压 40–60%
- · idle 可以是数组——每次循环结束切换到下一个 GIF(待机轮播)
- · tools/prep_character.py 批量调整尺寸;tools/flash_character.py 跳过 BLE 直接 USB 烧录
这个 manifest 是 Buddy 协议里唯一一处具体内容格式的强约束。其他所有 wire format 都是结构(消息族),manifest 是数据(资产)。把内容格式独立成一份小 schema 的好处:协议不变,资产可以独立演化——v2 加 sound 字段挂音效文件,老设备读到忽略,新设备启用。