本文是规范性消息参考:客户端需实现的连接、帧格式、控制消息和状态机。关于协议为何如此设计——以及固件移植指南(包括从 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(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)。
使用两种帧类型:
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.md,Future 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
}
}
服务器选择上行编解码器;设备遵从。选择是受能力约束的,而非强行指定:
opus;运营人员可全局设置 pcm,或按设备设置)。audio.supported_codecs 中声明了该编解码器时才选择它(将缺失的列表视为 [audio.codec])。audio.codec。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"
}
当 state 为 sentence_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 的缺失视为“无变化”。
会话通道和工具通道刻意承载不同类型的流量。这一边界之所以重要,是因为工具通道是可选的,而工具注册表是 AI↔固件契约(参见 docs/design.md,目标 #3)。
| 属于会话通道(控制消息) | 属于工具通道(MCP 工具) |
|---|---|
任何控制会话传输本身的内容——音频编解码器/模式(hello、audio_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 的。注册表区分两类:
declare_capabilities(scope) 是参考性的平台工具:服务器(作为 MCP 客户端)调用它来查询设备在某个 scope 内支持的内容——如 "audio"(编解码器、采样率)、"sensors"、"display"、"ota"。scope 参数让一个工具就能回答不断增长的能力面,而无需增殖工具或频繁改动协议。
这与 hello 中精简的 audio.supported_codecs 声明互补——而非替代:握手只承载在音频开始之前、且不依赖工具通道时必须知道的内容;declare_capabilities 是面向其他一切的、更丰富的按需查询面。基本的上行编解码器协商并不需要它。
在打开会话之前,设备通过 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_s、free_heap、rssi、battery_pct、crash_count)。平台将最新快照存储在设备记录上(其背后还有一个有界的历史时间序列),且不约束其键。一个单调递增的 crash_count/重启计数器,是服务器据以在历史窗口内做差分以计算崩溃率信号的字段。Bearer 令牌(启用时为按设备令牌,否则为共享令牌)。运营人员的主令牌同样有效;未知或已退役的设备会被拒绝(403)。last_seen),并可携带 firmware_version,以在两次配网之间保持设备上报版本的最新。参见 docs/devices.md 中的 Device health telemetry。
协议版本由设备在 Protocol-Version 升级头中声明,并在 hello 交换中回显。当前版本为 1。
若服务器不支持所请求的版本,它会在升级完成之前以 HTTP 400 拒绝该连接。
音频协商相关新增(audio.supported_codecs、input_audio、audio_mode、PCM 编解码器)和浏览器凭据相关新增(iotta.v1 / bearer.* / device.* 子协议)两者都是在版本 1 内增量且向后兼容的——无需提升版本号:
supported_codecs 并忽略 input_audio/audio_mode 的设备被视为仅支持 Opus;省略 input_audio 的服务器意味着“使用我声明的 audio”。TLS 要求(非环回须 wss://)是一项传输策略默认值,而非协议变更。(原理:protocol-design.md。)