iotta / docs / protocol-design.zh.md
📖 protocol-design.zh.md

iotta 通信协议 — 设计与固件指南

读者对象: 构建语音 AI 客户端的固件开发者,尤其是从 xiaozhi 移植过来的开发者。本文解释协议为何如此设计,以及它对你的固件意味着什么。规范性消息参考见 protocol-spec.md中文版)。


一句话总结

iotta 的协议刻意做得比 xiaozhi 更简单、更可靠

单条复用连接的问题。 xiaozhi 在一条连接上承载音频、控制和工具调用,还有多种传输(WebSocket、MQTT+UDP)和多种音频格式。一旦某处出错,全盘皆可能崩溃,而且状态分散在设备与服务器两端。

iotta 把这件事拆成两条清晰的连接: 1. 音频与控制(一条 WebSocket)——“这是我正在采集的,这是要播放的。” 2. 给 AI 用的工具(另一条独立的 MCP WebSocket)——“AI 想点亮我的 LED。”

如果工具通道断了,你的设备照样和服务器对话。如果服务器改变了它想要的工具,你不必刷写固件——你只需执行它要求的内容。你的固件更小、边界情况更少,协议也不会每个版本都在你脚下变动。


整洁性原则

整个设计基于一条规则:

一切控制会话传输的内容都在会话通道上。一切 LLM 可能调用的内容都在工具通道上。

这条边界是绝对的: - 如果丢失工具通道会破坏音频,那它就归会话通道。 - 如果 LLM 永不应决定某件事,它就不进入工具列表。 - 服务器总能回退到仅用会话通道的交互。

上行编解码器选择是典型例子。服务器必须在第一个音频帧之前就知道如何解码即将到来的数据——它等不了一次工具响应——所以它放在会话的 hello 握手里,而绝不作为 MCP 工具调用。


两条通道

Device                              Server
  |                                  |
  +── Session WebSocket ────────────┤  (audio + control)
  |   binary audio frames            |
  |   control JSON: listen, abort,   |
  |   vad, stt, tts, context         |
  |                                  |
  +── Tool MCP WebSocket ───────────┤  (device ↔ LLM)
  |   MCP JSON-RPC 2.0               |
  |   device exposes tools           |
  |   server calls them during LLM   |
  |                                  |

会话通道——音频生命线。 这是同步的、始终需要的路径。设备发送 hellolisten start/stop、音频帧、abort 和可选的 context;服务器发送 hello、TTS 音频帧,以及 vad / stt / tts / processing / error 控制消息。这里没有工具调用,所以工具通道断开绝不会中断对话。你的固件只需这一条稳健的 WebSocket 就能工作。而且,服务器是用多步 ASR→LLM→TTS 流水线、还是用一体化语音到语音模型来产生音频,对此毫无影响——设备只管流式发送音频、播放音频;运行时的形态是服务器的事,不是固件的事。

工具通道——MCP 边界。 可选且异步。设备充当 MCP 服务器,暴露各种能力(set_led_colorread_temperaturedeclare_capabilities(scope)……)。iotta 服务器充当 MCP 客户端:在 LLM 生成期间,当模型想用某个工具时,服务器向设备发送一个 MCP tools/call,设备执行并返回结果。工具 Schema 由服务器的注册表进行版本化管理——设备并不知道 Schema,它只执行被要求的内容。

(确切的消息形态见 protocol-spec.md。)


设计决策与权衡

1. 两条 WebSocket 而非一条。 音频绝不阻塞工具执行,工具流量也绝不拖延音频;一条通道上的 TCP 背压无法饿死另一条;每条通道只有一个职责,故障模式清晰。权衡: 设备侧多一点点簿记(两条连接而非一条),但异步 I/O 的开销可忽略,换来的清晰是值得的。

2. 服务器选择;设备遵从。 没有针对 ASR 模式、音频格式版本或传输方式的特性开关。编解码器协商只有一轮:设备的 hello 说“我能发 opuspcm”,服务器的 hello 说“发 pcm”,设备照做。没有协商循环,也没有协议版本爆炸——新编解码器先在服务器侧加入,再通过注册表推送给兼容的设备。权衡: 设备回推的余地更小——而这正是重点。

3. 配网走 HTTP,而非塞进 WebSocket。 POST /provision 注册设备并返回其会话 URL + 认证令牌;WebSocket 升级随后使用这些凭据。令牌是短时凭据材料,而非发现机制——它在配网时签发、在会话打开时校验,若过期或被吊销则设备重新配网。这干净地把业务操作(注册)与传输关切(会话认证)分开,也使按设备令牌和健康遥测(POST /telemetry)成为配网的兄弟,而非会话协议的一部分。

4. 默认 TLS(非环回)。 任何非环回客户端都必须使用 wss://;明文 ws:// 仅允许来自 127.0.0.1 用于本地开发。WiFi 设备暴露在网络上的每一台其他设备面前,被窃取的 Bearer 令牌可冒充设备——所以 TLS 是底线。内置反向代理负责终止 TLS。

5. 浏览器兼容的凭据。 两条凭据传输路径:原生客户端用 Authorization / Device-Id / Protocol-Version 请求头;浏览器(无法设置请求头)在 Sec-WebSocket-Protocol 子协议中携带相同的三个值,经 base64url 编码。iotta.v1 子协议会被回显,使浏览器的 WebSocket 握手得以完成。同一套协议既服务于 ESP32 固件,也服务于浏览器里的 JavaScript,且在协议版本 1 内向后兼容。对固件而言:用请求头即可;浏览器路径是给其他客户端的。

6. 服务器拥有工具 Schema。 Schema 存在于服务器上的版本化注册表,而非设备上。单一真相来源、显式版本化(“本轮使用了工具 Schema v2”会被记录),且设备从不校验 Schema——它执行一次调用并返回结果或错误;由服务器对照 Schema 校验。


从 xiaozhi 移植

已移除

xiaozhi iotta 原因
listen: state=detect(本地唤醒词) 不在协议中 唤醒逻辑留在设备上;服务器无需知道
mcp 消息类型(复用在内的 JSON-RPC) 独立的工具通道 MCP 是它自己的 WebSocket——更干净
llm 消息(情绪/表情) context 消息 服务器推送任意状态;设备自行决定如何处理
system 消息(重启/更新) OTA 走 HTTP 固件更新在配网时,不在会话中
alertcustom 消息类型 context 消息 一种通用的服务器→设备数据机制
MQTT + UDP 传输 仅 WebSocket 单一传输,支持 TLS,可在浏览器中工作
音频格式版本(v1/v2/v3 帧头) 原始帧 无帧头;编解码器是协商出来的,服务器知道即将到来的是什么
多模式监听(auto/manual/realtime 单一模式 设备采集;服务器做 VAD
存于 NVS 的 OTA URL 覆盖 按设备令牌 + 配网 配网一次、拿到凭据即可——无需本地存储 URL

已变化

xiaozhi iotta 变化点
hello 中的 protocol_version Protocol-Version 移到握手中
supported_codecs 可选 规范化 服务器总会返回选定的编解码器;设备总能提前知道
编解码器在握手时固定 可会话中途切换 audio_mode 可在不重连的情况下请求切换编解码器(如用于诊断)
工具 Schema 在设备上/靠推断 服务器拥有 设备只管执行——固件中无 Schema 校验
认证可选/开发时禁用 默认认证 + TLS 默认安全;本地 ws:// 仍允许用于开发

粗略移植工作量

假设你已有一份能工作的 xiaozhi 固件:

改动 工作量
打开两条 WebSocket 而非一条(区分会话与 MCP 的路由) 1–2 天
移除音频格式帧头解析(从服务器 hello 读取 input_audio 数小时
配网 HTTP 客户端(你已经在做) 约 1 天
移除 MQTT+UDP 回退,保留 WebSocket 1–2 天
简化工具执行(执行 + 返回;交由服务器校验) 数小时
移除设备侧 VAD(listen: detect)——由服务器做 VAD 2–3 天

合计: 完整移植约 1–2 周。从零开始仍然比 xiaozhi 容易——需要管理的状态更少。


固件实现

最小可用设备

  1. WiFi + 一个 WebSocket 客户端库 + JSON 解析
  2. 麦克风采集(16 kHz 单声道,Opus 编码)和扬声器播放(24 kHz,Opus 解码)
  3. 会话状态机(Idle → Listening → Processing → Speaking → Idle)
  4. hello 交换,随后在监听期间每 60 ms 发送音频帧
  5. 处理 vad / stt / tts / processing / error / context;发送 listenabort

可选但推荐: 工具通道(MCP 服务器模式 + tools/call 执行)、declare_capabilities、在 listen:start 中携带设备 context,以及用于诊断的 PCM 上行 + audio_mode

正常流程

Device firmware                         Server
  | POST /provision (device_id, board, fw) ──> |
  |<── session_url, token, agent ───────────── |
  | WebSocket upgrade (Authorization: Bearer)  |
  |<── 101 Switching Protocols ─────────────── |
  | hello (audio config) ───────────────────>  |
  |<── hello (session_id, input_audio) ──────  |
  |                                            |
  |  user presses mic                          |
  | listen:start ────────────────────────────> |
  | [binary audio frames, every 60 ms] ──────> |
  |<── vad:speech_start  (server heard you)     |
  | listen:stop ─────────────────────────────> |
  |<── stt (transcript)                         |
  |<── processing                               |
  |<── tts:start                                |
  |<── [binary Opus frames] (play to speaker)   |
  |<── tts:stop                                 |
  |  ready for next turn                        |

打断(插话)

Device is playing TTS; user presses the mic again.
| abort ───────────────────────────────────> server stops TTS, cancels LLM, returns to Idle
|<── [Opus frames stop]
| listen:start ────────────────────────────> start the next turn

工具执行(MCP)

// 服务器 → 设备:
{ "jsonrpc": "2.0", "id": "12345", "method": "tools/call",
  "params": { "name": "set_led_color", "arguments": { "r": 255, "g": 0, "b": 0 } } }

// 设备 → 服务器(成功或错误):
{ "jsonrpc": "2.0", "id": "12345", "result": { "status": "ok" } }
{ "jsonrpc": "2.0", "id": "12345", "error":  { "code": -32600, "message": "Invalid arguments" } }

declare_capabilities 的形态相同,"name": "declare_capabilities",参数为 {"scope": "audio"};设备返回该 scope 支持的内容。


常见坑

  1. 不要因等待工具响应而阻塞音频。 在一个任务里按 60 ms 节奏发送音频帧;在另一个任务里异步处理工具调用。为等工具回复而休眠的设备会饿死音频路径。
  2. abort 后要把音频排空。 收到 abort 后,继续发送帧直到 listen:stop(或超时,以防 listen:stop 丢失),然后返回 idle——不要在缓冲区中途切断。
  3. 不要假设工具 Schema。 执行服务器发来的任何参数;若它们说不通,返回一个错误。由服务器对照 Schema 校验,而不是你。
  4. POST /provision 要设置 Content-Type: application/json
  5. 遵从服务器 hello 中的 input_audio 用它指定的编解码器编码上行;若它被省略(较旧的服务器),使用你声明的编解码器。

验收清单

  • [ ] 配网成功(POST /provision → 拿到令牌、会话 URL、agent)
  • [ ] 用正确的请求头打开会话 WebSocket 并交换 hello
  • [ ] 在 listen:start 时流式发送音频;服务器回应 vad:speech_start
  • [ ] 在 listen:stop 时收到 stt,再收到 tts,并播放音频
  • [ ] 能在 TTS 播放中途打断(abort → 服务器停止发送音频)
  • [ ] 能处理多轮而无需重连
  • [ ] 能从服务器断连中恢复(在下一个配网周期重连)
  • [ ] 对服务器发来的畸形消息不崩溃
  • [ ] (可选) 能打开工具通道并执行 tools/call
  • [ ] (可选) 能处理 audio_mode 编解码器切换

参考

  • protocol-spec.md中文版)— 规范性线路格式与消息参考
  • design.md中文版)— 系统目标与设计原则
  • server/src/iotta/protocol.py — 服务器侧凭据与编解码器解析
  • server/tests/test_protocol.py — 协议边界情况测试用例