OpenHarmony 小鸿 AI 开发实战 11:WS63 Agent 与 WebSocket 协议状态机

语音设备的 WebSocket 连接成功,并不等于一次对话已经可以开始。WS63 还要知道当前是在配网、连接、唤醒、监听还是播放;要把按键、唤醒词、VAD、服务器 JSON、下行音频和断线事件串到同一条可推理的流程中;也要避免网络线程、Agent 任务和 UART 任务同时操作连接对象。

本文依据小鸿 AI 当前 OpenHarmonymini系统、LiteOS-M 与 WS63 的真实 C 源码整理。代码里的 Agent 有 7 个业务状态,Mongoose WebSocket 建连后还要完成 client hello / server hello,音频通道才算真正打开。本轮重新执行了后端协议冒烟测试,但没有声称完成新的实机弱网、路由器切换或公网长连接稳定性测试。

Agent 状态机解决的是业务顺序,不只是界面文字

agent_state.h定义了IDLEWIFI_CONFIGURINGCONNECTINGWAKEUPLISTENINGSPEAKINGFATAL_ERROR。这些名字既决定屏幕提示,也约束是否允许建立会话、发送上行音频、接收 TTS、响应按键或进入配网。

typedef enum { AGENT_STATE_IDLE = 0, AGENT_STATE_WIFI_CONFIGURING, AGENT_STATE_CONNECTING, AGENT_STATE_WAKEUP, AGENT_STATE_LISTENING, AGENT_STATE_SPEAKING, AGENT_STATE_FATAL_ERROR, AGENT_STATE_MAX, } agent_state_t;

状态与事件必须分开。状态回答“设备此刻处于什么阶段”,事件回答“刚刚发生了什么”。当前事件包括 Wi-Fi 连接变化、短按切换对话、长按配网、唤醒词、TTS 开始/停止、音频通道关闭、发送音频以及 CI1302 的 VAD 段结束。UART 回调、网络回调和按键处理不直接随意改状态,而是投递事件给 Agent 任务处理。

这样做的价值是把并发输入收敛到一处:唤醒词和短按可能同时出现,TTS stop 和 WebSocket close 也可能前后紧挨;如果每个回调都直接开关音频、刷新界面、修改状态,很快就会出现重复 close、错过 stop 或连接对象被并发访问的问题。

合法状态转换是第一道防线

set_state()不是简单赋值。它先检查旧状态到新状态是否被允许:例如LISTENING只能转到SPEAKINGIDLESPEAKING可以回到LISTENINGIDLE;已经进入FATAL_ERROR后不再接受普通转换。非法转换会被拒绝,而不是把系统推入一个看似有枚举值、实际资源状态不一致的组合。

case AGENT_STATE_LISTENING: valid = ( new_state == AGENT_STATE_SPEAKING || new_state == AGENT_STATE_IDLE); break; case AGENT_STATE_SPEAKING: valid = ( new_state == AGENT_STATE_LISTENING || new_state == AGENT_STATE_IDLE); break;

这也解释了一个容易写错的逻辑:设备已经在LISTENING时再次收到唤醒事件,不能先跳回WAKEUP,因为状态表不允许LISTENING → WAKEUP。当前实现选择重置必要标志并再次发送监听命令,保持状态图闭合。

OTA 配置把服务器地址与固件解耦

Agent 不应把 WebSocket 地址写死在业务文件里。当前 OTA 模块解析服务器响应中的 WebSocket URL、访问 token 和协议版本,再交给协议层。这样后端迁移端口、切换域名或调整协议版本时,不需要为每台设备重新改一份 Agent 代码。

文章只展示字段关系,不展示任何真实 token:

{ "websocket": { "url": "ws://server.example/hajimi/ws", "token": "...", "version": 1 } }

地址可配置不等于配置永远可信。设备仍需要限制字符串长度、检查空值,并在连接失败时回到明确状态。认证值属于部署秘密,应该由服务端环境与设备配置链管理,不应出现在公开仓库的文章、截图或日志中。

TCP 连上以后还要完成两层握手

mg_open_audio_channel()的实际顺序是:创建 WebSocket 连接并注入请求头,等待MG_EV_WS_OPEN,发送 client hello,再等待 server hello。只有服务器 hello 被正确解析后,audio_channel_opened才设为 true,并通知 Agent。

HTTP Upgrade 请求头包括Authorization(配置存在时)、Protocol-VersionDevice-IdClient-Id。随后 client hello 描述 transport 与音频参数:

return snprintf(buf, bufsize, "{\"type\":\"hello\"," "\"version\":%d," "\"features\":{\"mcp\":true}," "\"transport\":\"websocket\"," "\"audio_params\":{" "\"format\":\"%s\"," "\"sample_rate\":16000," "\"channels\":1," "\"frame_duration\":40" "}" "}", version, MG_WS_UPLINK_AUDIO_FORMAT_STR);

当前握手等待按 20 ms 一个 step 轮询,step 数是 200,即 server hello 阶段上限约 4 秒。连接失败后 Agent 层有受限重试,而不是无限紧循环。代码还在真正连接前等待 120 ms,给前一轮 close 和网络状态收敛留出时间。

WebSocket 已连接与音频通道已打开不是同一件事

Mongoose 回调收到MG_EV_WS_OPEN时只设置ws_connected。如果没有 server hello,server_hello_received仍为 false,Agent 不能把这个连接当作可用语音通道。最终的is_audio_channel_opened同时检查两个条件:

return ctx->audio_channel_opened && ctx->ws_connected;

这种双条件很重要。网络层可能已经 Upgrade 成功,但服务端因为版本、鉴权或 hello 格式不匹配而不接受会话。如果此时 CI1302 立即灌入 Opus,设备会消耗 UART 和队列资源,服务器却没有建立对应 session。把“链路可达”和“协议就绪”分开,能让失败日志更准确。

服务器断开时同样要区分“握手还没成功”和“会话曾经打开”。当前 close 分支只在会话确实建立过时通知 Agent 音频通道关闭,避免握手失败路径和阻塞中的 open 函数同时投递重复关闭事件。

协议版本决定二进制音频帧怎样拆包

当前接收路径支持三种线格式。版本 1 把整个 WebSocket binary message 当作裸 Opus;版本 2 使用 16 字节头,包含版本、类型、时间戳和 payload size;版本 3 使用 4 字节紧凑头。解析出的 payload 最终统一放入protocol_audio_packet_t,上层 Agent 不需要重复理解三套线格式。

v1: [ Opus payload ] v2: [ version/type/reserved/timestamp/size | payload ] v3: [ type/flags/size | payload ]

当 v2 或 v3 的头长、声明长度与实际 frame 不一致时,当前代码会回退为裸 Opus,而不是静默丢包。这是兼容策略,不是可以忽略协议校验的理由。线上排障仍应记录版本、消息长度与服务端日志,避免格式错误被长期掩盖。

监听、等待回答和播放之间有严格的先后关系

进入新会话时,Agent 根据voice_interrupt设置选择 auto、manual 或 realtime 监听模式,先进入CONNECTING,打开音频通道,随后依次进入WAKEUPLISTENINGWAKEUP的进入动作负责上报 detect,LISTENING的进入动作负责发送 start listening。

CI1302 上报0x0106后,Agent 不会抢在最后一段 Opus 之前发送listen/stop。它先标记 pending,等上行 staging 与发送队列排空,再发 stop;之后保持LISTENING状态但进入“等待回答”子阶段,拒绝新的普通上行。服务器发送 TTS start 后才转入SPEAKING

TTS stop 也不是收到 JSON 就立刻回待机。下行音频可能仍在播放队列中,当前实现设置 finish pending,等待播放排空并经过稳定窗口,再关闭通道、回到IDLE。这能避免最后几个音频帧被状态切换提前截断。

Mongoose API 由锁保护,背压从 4 KiB 开始拒绝

Mongoose 管理器由独立 poll 任务推进。活动连接使用 5 ms sleep,无活动连接时使用 30 ms,避免既让实时音频等待太久,又让空闲任务一直占用 CPU。发送、close、检查 send buffer 等 Mongoose API 操作经过同一把锁,减少 Agent 任务和 poll 任务并发触碰连接结构的风险。

#define MG_WS_AUDIO_SEND_BACKLOG_LIMIT 4096U #define MG_WS_POLL_ACTIVE_SLEEP_MS (5U) #define MG_WS_POLL_IDLE_SLEEP_MS (30U) busy = ( ctx->conn->send.len >= MG_WS_AUDIO_SEND_BACKLOG_LIMIT);

达到 4096 字节阈值后拒绝继续塞音频,是一种有界失败。它不会让 RAM 随网络变慢无限增长,但也意味着弱网下可能丢语音帧。因此验证时不能只看“WebSocket 没断”,还要看 backlog 告警、上行帧计数、VAD stop 是否最终发送,以及服务器 ASR 是否收到完整语句。

源码注释冲突必须按执行代码判定

mongoose_protocol.c顶部保留了一段旧注释,称 CI1302 的0x0105是 PCM;但同一文件当前默认MG_WS_UPLINK_AUDIO_FORMAT_STR"opus",CI1302 数据处理调用的也是 Opus uplink bridge,后端按 Opus 解码。文章不能把过期注释当作事实,也不能悄悄忽略它。

本篇采用的结论是:当前执行代码链为 Opus,旧 PCM 注释属于需要清理的维护债。判断依据是命令处理、函数调用、hello 格式宏和后端解码四处互相印证。若未来协议真的切回 PCM,就必须同时修改 payload 产生端、hello 声明、服务端解码和测试,而不是只改一个字符串。

本轮验证了什么,还没有验证什么

本轮对源文件重新建立 SHA-256 快照,并执行protocol_smoke.py。测试输出包含protocol_headers=okasr_to_llm=okvoice_reconnect=ok,也检查了模拟 TTS 下行包。它证明当前 Python 协议测试中的请求头、会话与语音链路约定仍能跑通。

但这是本地确定性冒烟,不是设备端运行证据。真实验收仍需要:在 WS63 上看见MG_EV_WS_OPEN、server hello 和 audio channel opened;对着 CI1302 说完整问题,确认 Opus 帧、VAD stop、ASR 文本与 TTS 播放顺序;拔网线或切换 AP,检查 close、重试与回待机;限速制造 backlog,确认内存稳定且下一轮会话能够恢复。

对 OpenHarmonymini+ LiteOS-M 的小设备来说,可靠的语音交互不靠一个“连接成功”日志,而靠状态、事件、协议握手、音频顺序和有界资源共同成立。下一篇将转到 Python asyncio 服务端,逐段对应 OTA、WebSocket、Session、ASR、LLM 和 TTS 如何接住这里发出的消息。