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

iotta 通信协议 — 规范

本文是规范性消息参考:客户端需实现的连接、帧格式、控制消息和状态机。关于协议为何如此设计——以及固件移植指南(包括从 xiaozhi 移植)——参见 protocol-design.md中文版)。

概览

iotta 每次会话使用两条相互独立的连接:

连接 传输 用途
会话通道 WebSocket 音频(设备 ↔ 服务器)和会话控制消息
工具通道 基于 WebSocket 的 MCP LLM 派发给设备的工具调用

会话通道承载所有音频和会话生命周期事件。工具通道是标准的 MCP 连接;设备将其能力以 MCP 工具的形式暴露,服务器在 LLM 生成期间调用它们。

两条连接相互独立。任意一条都可以打开或关闭而不会立即使另一条失效,不过没有工具通道的会话将无法执行设备侧工具。


会话通道

连接

设备向服务器的会话端点发起 WebSocket 连接。升级握手时必须提供三个值。原生客户端(ESP32 固件、Python client/)以请求头形式提供:

请求头
Authorization Bearer <token>
Device-Id 设备的稳定唯一标识符(如 MAC 地址或配网时分配的 UUID)
Protocol-Version 1

服务器对缺失或无效令牌的连接以 WebSocket 关闭码 4001 拒绝(对不支持/缺失的协议版本或缺失的 device id 以 4000 拒绝),且发生在会话开始之前。Bearer 值要么是共享会话令牌,要么——当服务器启用按设备独立会话令牌时——是配网时签发给 Device-Id 的令牌;服务器会针对连接中的设备校验该凭据。参见 docs/devices.md 中的 Per-device session tokens

Device-Id 值对平台是不透明的——由设备自行选择的稳定标识符,通常是其 MAC 地址或配网时分配的 UUID。服务器据此匹配,但绝不解析它。

设备通过先调用配网端点来获取 session_url 以及(在启用会话认证时)Bearer 令牌——参见下文 配网 一节及 docs/devices.md

浏览器客户端:通过 Sec-WebSocket-Protocol 传递凭据

浏览器 WebSocket API 无法设置自定义请求头,因此浏览器客户端(如 Web 设备模拟器)通过它唯一能影响的那个头——Sec-WebSocket-Protocol,即所提供的子协议列表——来传递相同的三个值。这是一种成熟的做法(Kubernetes API 服务器对 WebSocket 上的 Bearer 令牌认证就采用同样方式)。客户端提供:

new WebSocket(url, [
  "iotta.v1",                       // 协议版本标记(v1)
  "bearer." + base64url(token),     // 会话 Bearer 令牌
  "device." + base64url(device_id), // Device-Id
]);
子协议 token 承载内容
iotta.v1 声明协议版本 1(替代 Protocol-Version 头)。服务器会将这一个回显为协商出的子协议,使浏览器的 WebSocket 握手得以完成。
bearer.<value> 会话 Bearer 令牌。
device.<value> Device-Id

<value> 是 UTF-8 字符串的 base64url(无填充) 编码,因此任意令牌,或包含 RFC 6455 子协议 token 语法之外字符的 device id(如 MAC 地址中的冒号),都能安全传输。凭据子协议(bearer.*device.*)由服务器读取且绝不回显;只有 iotta.v1 会被回显。

请求头是规范来源(canonical):当某个值同时以请求头形式存在时,它优先于子协议中的同名值;纯请求头客户端完全不受影响。(在协议 v1 内增量且向后兼容;其原理参见 protocol-design.md。)

传输安全(TLS)

默认情况下,对任何非环回客户端,服务器都要求会话通道运行在 TLS(wss://)之上;既非 TLS、又非来自环回对端(127.0.0.1/::1)的连接将以关闭码 4003 拒绝。明文 ws:// 仅允许来自环回,用于本地开发。浏览器本身已强制这一点——它会拒绝来自 https:// 页面的 ws://(混合内容)。TLS 的判定方式有二:直接判定(wss:// 协议),或在内置的 TLS 终止反向代理之后,通过代理设置的 X-Forwarded-Proto: https 头判定(参见 DEPLOY.md)。对于以其他方式保证 TLS 的部署,可关闭该强制(server.require_tls: false)。

帧格式

使用两种帧类型:

  • 文本帧承载 JSON 控制消息。每个文本帧都是一个带 type 字段的 JSON 对象。
  • 二进制帧以协商出的编解码器承载原始音频。无帧头。方向(设备→服务器 或 服务器→设备)决定该音频的角色,每个方向的编解码器由 hello 交换固定(见下文)。

音频格式: - 编解码器:Opus(默认)或 PCM(见 上行编解码器协商) - 设备 → 服务器:16 kHz,单声道 - 服务器 → 设备:24 kHz,单声道(始终为 Opus) - 帧时长:60 ms

编解码器

编解码器 二进制帧负载
opus 每帧一个 Opus 包。有损。默认上行编解码器,也是唯一的下行编解码器。
pcm 原始小端有符号 16 位单声道采样,每个二进制帧承载一帧的量(sample_rate × frame_duration_ms ÷ 1000 个采样)。相对设备采集为无损;路径中没有编解码器。

pcm 用于开发和诊断——为 ASR/DSP/音频硬件分析提供高保真采集(参见 docs/roadmap.mdFuture extension: device audio analysis)。它仅用于上行(设备 → 服务器);下行始终为 Opus。PCM 的比特率约为 Opus 的 10–16 倍,在假定的 WiFi 传输上这不成问题,但这也是 Opus 保持为默认的原因。

会话生命周期

Device                          Server
  |                               |
  |-- WebSocket upgrade --------> |  (Authorization, Device-Id, Protocol-Version headers)
  |<- 101 Switching Protocols --- |
  |                               |
  |-- hello ------------------>   |
  |<- hello ------------------- |
  |                               |
  |   ... session active ...      |
  |                               |
  |-- [close frame] ----------->  |  (or server initiates)

设备在 WebSocket 升级完成后立即发送 hello。服务器以自己的 hello 回应。两个 hello 交换完毕后会话即为活动状态。


控制消息

hello(设备 → 服务器)

设备在连接后立即发送。声明设备首选的上行音频配置,以及(可选地)它能够产生的编解码器。

{
  "type": "hello",
  "version": 1,
  "audio": {
    "codec": "opus",
    "sample_rate": 16000,
    "channels": 1,
    "frame_duration_ms": 60,
    "supported_codecs": ["opus", "pcm"]
  }
}

audio 是设备的首选/默认上行配置。audio.supported_codecs 是一个可选列表,列出固件能产生的每一种上行编解码器。若省略,服务器假定为 [audio.codec]——因此只会说 Opus 的设备无需任何改动,也绝不会被要求其他编解码器。

这是一个刻意精简的声明——只包含服务器在握手时为选择上行编解码器所需的信息,在音频开始之前、且不依赖(可选的)工具通道。更丰富、可查询的能力面是 declare_capabilities(scope) 工具(见下文)。

hello(服务器 → 设备)

服务器作为响应发送。为两个方向确认会话参数。audio 描述下行(服务器 → 设备,始终为 Opus)。input_audio 是服务器选定的上行(设备 → 服务器)——设备必须据此编码其音频帧。

{
  "type": "hello",
  "session_id": "<uuid>",
  "audio": {
    "codec": "opus",
    "sample_rate": 24000,
    "channels": 1,
    "frame_duration_ms": 60
  },
  "input_audio": {
    "codec": "opus",
    "sample_rate": 16000,
    "channels": 1,
    "frame_duration_ms": 60
  }
}

上行编解码器协商

服务器选择上行编解码器;设备遵从。选择是受能力约束的,而非强行指定:

  1. 服务器有一个配置的首选上行编解码器(默认 opus;运营人员可全局设置 pcm,或按设备设置)。
  2. 服务器仅当设备在 audio.supported_codecs 中声明了该编解码器时才选择它(将缺失的列表视为 [audio.codec])。
  3. 否则回退到设备声明的 audio.codec
  4. 选定结果在 input_audio 中返回。若 input_audio 被省略(如较旧的服务器),设备使用其声明的 audio

握手是权威的协商点:上行编解码器必须在任何音频帧流动之前确定。

listen(设备 → 服务器)

表示麦克风状态的变化。

{
  "type": "listen",
  "state": "start" | "stop"
}

start:设备已开始采集音频,并将发送二进制音频帧。 stop:设备已停止采集;在下一次 listen start 之前不再有音频帧。

listen start 时,设备可包含一个可选的 context 对象,其中含有本轮应提供给 LLM 的任何设备侧数据——传感器读数、NVRAM 设置、设备状态,或任何其他环境值。其结构自由;服务器原样透传给 LLM,不作解释。

{
  "type": "listen",
  "state": "start",
  "context": {
    "temperature_c": 22.5,
    "volume": 0.7,
    "location": "kitchen"
  }
}

服务器为每一轮使用最近一次收到的 context。若省略 context,服务器使用会话中上一次提供的 context;若从未发送过,则不使用任何 context。

abort(设备 → 服务器)

请求立即取消任何进行中的 TTS 播放和 LLM 生成。

{
  "type": "abort"
}

服务器尽快停止发送音频帧并停止 LLM 生成。会话返回到监听状态。

vad(服务器 → 设备)

报告服务器侧在音频流中检测到的语音活动事件。使设备可以在检测到语音时立即显示响应式 UI(如“已听到”指示),而无需等待 ASR 完成。

{
  "type": "vad",
  "state": "speech_start" | "speech_end"
}

speech_start:服务器检测到一段语音的开始。 speech_end:服务器检测到该语音段已结束。ASR 处理在此时开始。

processing(服务器 → 设备)

表示服务器已完成 ASR,正在运行 LLM 和 TTS 流水线。使设备可在语音结束到音频播放开始之间的间隙显示“思考中”状态。

{
  "type": "processing"
}

stt(服务器 → 设备)

递送最近一次用户话语的 ASR 转写文本。供显示用。

{
  "type": "stt",
  "text": "what is the weather like today"
}

tts(服务器 → 设备)

控制设备上的 TTS 播放状态。

{
  "type": "tts",
  "state": "start" | "stop" | "sentence_start"
}

statesentence_start 时,会包含一个 text 字段,内含即将朗读的句子,用于字幕显示:

{
  "type": "tts",
  "state": "sentence_start",
  "text": "The weather today is partly cloudy."
}

服务器在一段响应的第一个音频帧之前发送 tts start,在最后一个之后发送 tts stop

context(服务器 → 设备)

将任意信息性数据从服务器推送给设备。不期待响应。设备按合适的方式使用该负载——更新本地显示状态、存储值、为其下一次交互提供信息。其结构自由。

{
  "type": "context",
  "data": {
    "user_name": "Brad",
    "conversation_count": 42
  }
}

服务器可在会话期间的任意时刻发送 context,包括 TTS 播放期间或两轮之间。

error(服务器 → 设备)

表示一个非致命错误。会话保持打开。

{
  "type": "error",
  "code": "<string>",
  "message": "<human-readable description>"
}

audio_mode(服务器 → 设备)

在不重连的情况下,于会话中途切换上行编解码器。用于将已连接的设备置入(或退出)高保真诊断模式。握手是选择上行编解码器的常规场合;audio_mode 用于不那么常见的、翻转一个活动会话的情况。

{
  "type": "audio_mode",
  "input_audio": {
    "codec": "pcm",
    "sample_rate": 16000,
    "channels": 1,
    "frame_duration_ms": 60
  }
}

audio_mode(设备 → 服务器)

设备确认该指令。在发出此 ack 之前它绝不能改变自己的编码方式,以便服务器确切知道哪个二进制帧是新格式中的第一个。

{
  "type": "audio_mode",
  "state": "applied" | "unsupported",
  "input_audio": { "codec": "pcm", "sample_rate": 16000, "channels": 1, "frame_duration_ms": 60 }
}
  • applied:后续上行帧使用 input_audio。服务器在收到此 ack 时切换其解码路径。
  • unsupported:设备无法产生所请求的编解码器;服务器保持当前模式。(服务器应只请求设备已声明的编解码器,所以这是一道安全网。)

服务器只为设备在握手时声明过的编解码器请求 audio_mode。完全不理解 audio_mode 的设备应忽略它,服务器将 ack 的缺失视为“无变化”。


控制 vs. 工具:各自归属

会话通道和工具通道刻意承载不同类型的流量。这一边界之所以重要,是因为工具通道是可选的,而工具注册表是 AI↔固件契约(参见 docs/design.md,目标 #3)。

属于会话通道(控制消息) 属于工具通道(MCP 工具)
任何控制会话传输本身的内容——音频编解码器/模式(helloaudio_mode)、监听状态、abort、VAD、TTS 播放状态 LLM 在生成期间可能调用的设备硬件功能(设置音量、读取传感器、驱动硬件)
即使工具通道断开也必须可用 服务器直接调用的平台内省,如 declare_capabilities——不放入 LLM 的工具列表

规则:若丢失工具通道不得使其失效,或 LLM 永不应决定它,那它就是会话通道控制消息——而非工具。(上行编解码器选择是典型例子;其原理——整洁性原则——见 protocol-design.md。)


会话状态机

          ┌──────────────────────────────────────────────────┐
          │                                                  │
          ▼                                                  │
       Connecting                                            │
          │  (WebSocket open + hello exchanged)               │
          ▼                                                  │
        Idle ◄──────────── abort received ──────────── Speaking
          │                                                  ▲
          │  listen:start received                           │
          ▼                                                  │
       Listening ── vad:speech_start ──► Hearing             │
          │                │                                 │
          │                └── vad:speech_end                │
          │  listen:stop received  │                         │
          └──────────────────────►▼                         │
                               Processing ── tts:start sent ┘
                                  │
                                  │  (ASR → LLM → TTS pipeline runs here)
状态 说明
Connecting WebSocket 已打开;等待 hello 交换完成
Idle 会话活动;等待设备开始监听
Listening 麦克风打开;尚未检测到语音
Hearing 服务器 VAD 已在音频流中检测到活动语音
Processing 语音结束或采集停止;ASR → LLM → TTS 流水线运行中
Speaking 服务器正在向设备流式发送 TTS 音频

设备可在任意状态发送 abort。服务器收到后立即转入 Idle


工具通道

工具通道是基于 WebSocket 的标准 MCP 连接,由设备向服务器的 MCP 端点发起。设备充当 MCP 服务器(将其硬件能力暴露为工具);iotta 服务器充当 MCP 客户端(在 LLM 生成期间调用工具)。

工具通道独立于会话通道。设备应在会话 hello 交换完成后打开它,并在整个会话期间保持打开。

Device-Id 头(与会话通道相同的值)必须出现在 MCP WebSocket 升级握手中,以便服务器将工具通道关联到一个活动会话。

工具 Schema 定义由 iotta 工具 Schema 注册表管理,而非从设备动态发现。设备执行工具;服务器拥有 Schema。

LLM 工具 vs. 平台工具

并非设备暴露的每个工具都是给 LLM 的。注册表区分两类:

  • LLM 工具——在生成期间提供给模型。它们就是 AI↔固件契约。
  • 平台工具——由服务器直接调用,绝不放入 LLM 的工具列表。它们让服务器在不涉及对话的情况下内省或管理设备。

declare_capabilities(scope) 是参考性的平台工具:服务器(作为 MCP 客户端)调用它来查询设备在某个 scope 内支持的内容——如 "audio"(编解码器、采样率)、"sensors""display""ota"scope 参数让一个工具就能回答不断增长的能力面,而无需增殖工具或频繁改动协议。

这与 hello 中精简的 audio.supported_codecs 声明互补——而非替代:握手只承载在音频开始之前、且不依赖工具通道时必须知道的内容;declare_capabilities 是面向其他一切的、更丰富的按需查询面。基本的上行编解码器协商并不需要它。


配网(HTTP)

在打开会话之前,设备通过 HTTP 联系配网端点以注册并了解如何连接。这是一个朴素的请求/响应端点,独立于两条 WebSocket 通道。

Device                          Server
  |-- POST /provision --------> |  {device_id, board, firmware_version?, capabilities?}
  |<- 200 provisioning record - |  {agent, connection{session_url, protocol_version, token?}, firmware_update?}
  |                               |
  |  (if firmware_update present and wanted)
  |-- GET /provision/firmware/{board}/{version} --> |
  |<- 200 application/octet-stream (X-Firmware-Sha256) |
  |                               |
  |  ... then open the session channel using `connection` ...

响应的 connection 块携带会话 WebSocket URL、协议版本,以及——当服务器要求会话认证时——设备随后在会话通道升级握手中提供的 Bearer 令牌。firmware_update 仅在设备所属板型有更新的固件版本被发布时出现;设备从固件 URL 下载它,并在刷写前校验 X-Firmware-Sha256 摘要。

设备被允许如何注册(开放式自助注册 vs. 运营人员维护的允许列表、可选的注册令牌、按板型/按设备的规则)以及固件版本如何管理,属于设备注册表,在 docs/devices.md 中规定。上述线路形态就是固件需要实现的全部;策略是服务器侧的事。

POST /telemetry

设备按自选的节奏上报一份运行时健康快照,独立于任何打开的会话。这是 /provision(在注册时携带设备的静态事实)的运行时伴侣。

Device                          Server
  |-- POST /telemetry --------> |  {device_id, health{...}, firmware_version?}
  |<- 200 ack ---------------- |  {device_id, health_reported_at}
  • health 是一个开放对象——固件上报它所测量的任何内容(如 uptime_sfree_heaprssibattery_pctcrash_count)。平台将最新快照存储在设备记录上(其背后还有一个有界的历史时间序列),且不约束其键。一个单调递增的 crash_count/重启计数器,是服务器据以在历史窗口内做差分以计算崩溃率信号的字段。
  • 该上报以设备自身的会话凭据授权——即它在会话通道上提供的同一个 Bearer 令牌(启用时为按设备令牌,否则为共享令牌)。运营人员的主令牌同样有效;未知或已退役的设备会被拒绝(403)。
  • 该上报算作一次存活签到(它会刷新 last_seen),并可携带 firmware_version,以在两次配网之间保持设备上报版本的最新。

参见 docs/devices.md 中的 Device health telemetry


版本管理

协议版本由设备在 Protocol-Version 升级头中声明,并在 hello 交换中回显。当前版本为 1

若服务器不支持所请求的版本,它会在升级完成之前以 HTTP 400 拒绝该连接。

版本 1 内的兼容性

音频协商相关新增(audio.supported_codecsinput_audioaudio_mode、PCM 编解码器)和浏览器凭据相关新增(iotta.v1 / bearer.* / device.* 子协议)两者都是在版本 1 内增量且向后兼容的——无需提升版本号:

  • 省略 supported_codecs 并忽略 input_audio/audio_mode 的设备被视为仅支持 Opus;省略 input_audio 的服务器意味着“使用我声明的 audio”。
  • 纯请求头客户端不提供任何子协议,其读取方式与以往完全一致;当某个值同时以两种方式存在时,请求头优先。

TLS 要求(非环回须 wss://)是一项传输策略默认值,而非协议变更。(原理:protocol-design.md。)