项目概览:小智 AI 聊天机器人的核心能力与价值¶
生成时间: 2026-05-25T14:55:26.556606Z | 页面数: 28 | 语言: zh
目录¶
入门指南¶
核心架构¶
硬件抽象层¶
音频管线¶
通信协议¶
MCP 协议与设备控制¶
系统管理与运维¶
构建与部署¶
入门指南¶
项目概览:小智 AI 聊天机器人的核心能力与价值¶
小智 AI 聊天机器人 是一个基于 ESP32 芯片的开源语音交互终端项目。它以 "AI 大模型 + 物联网控制" 为核心设计理念,让开发者能够用低成本硬件构建一个会说、会听、会思考、会控制设备的智能助手。本项目采用 MIT 开源协议,任何人都可以免费使用,包括商业用途。
Sources: README.md
一句话理解小智¶
你可以把小智看作一个运行在单片机上的"随身 AI 助理"——对着它说话,它能听懂你的问题(流式语音识别 ASR),交给云端大模型思考回答(LLM),再以自然语音回复你(TTS)。同时,它还能通过 MCP 协议控制你身边的硬件设备,比如开关灯、驱动舵机、读取传感器等。
graph LR
A[👤 用户语音] --> B[🎤 麦克风采集]
B --> C[🔊 离线唤醒词检测]
C --> D[📤 OPUS 编码上传]
D --> E[☁️ 云端 ASR / LLM / TTS]
E --> F[📥 OPUS 解码播放]
F --> G[🔈 扬声器输出]
H[🖥️ MCP 协议] --> I[💡 LED / 舵机 / GPIO]
E -.-> H
上图展示了小智最核心的 人机语音对话链路(顶部)和 设备控制链路(底部)。语音数据经过 OPUS 高效压缩后传输至云端,云端处理后返回语音和 MCP 控制指令。
Sources: audio_service.h
核心能力一览¶
下表总结了小智项目的六大核心能力维度:
| 能力维度 | 具体实现 | 关键价值 |
|---|---|---|
| 语音交互 | 离线唤醒 + 流式 ASR + LLM + TTS 全链路 | 无需按键,"说句话"即可唤醒 |
| 视觉反馈 | OLED / LCD / LVGL 三级显示抽象,支持表情、状态栏 | 设备状态一目了然,支持自定义主题 |
| 设备控制 | MCP 协议(JSON-RPC 2.0),支持 Speaker、LED、舵机、GPIO 等 | 大模型可主动操控物理设备 |
| 多模联网 | Wi-Fi / ML307 4G Cat.1 / RNDIS 以太网 | 适应家庭、户外、工业等多种场景 |
| 多平台支持 | ESP32-C3 / ESP32-S3 / ESP32-C5 / ESP32-C6 / ESP32-P4 | 覆盖乐鑫主流芯片,选型灵活 |
| 开箱即用 | 70+ 开发板现成配置,官方服务器免费使用 | 零代码开发环境也能烧录体验 |
Sources: README.md
系统架构全景¶
小智的软件架构采用分层设计,从底层硬件到上层应用逐级抽象,确保代码在 70 多种开发板上共享同一套核心逻辑。下面这张架构图帮助你建立全局认知:
graph TB
subgraph "应用层 Application Layer"
APP[Application 主控<br/>事件驱动循环 + 状态机]
MCP[McpServer<br/>JSON-RPC 设备控制]
OTA[Ota<br/>固件升级]
end
subgraph "服务层 Service Layer"
AS[AudioService<br/>三任务音频管线]
PROTO[Protocol<br/>WebSocket / MQTT+UDP]
DISP[Display<br/>OLED / LCD / LVGL]
LED[Led<br/>单灯 / 灯环 / GPIO]
end
subgraph "硬件抽象层 HAL"
BOARD[Board 抽象基类<br/>统一接口 + DECLARE_BOARD]
CODEC[AudioCodec<br/>ES8311 / ES8388 / ES8374]
NET[NetworkInterface<br/>Wi-Fi / 4G / RNDIS]
end
subgraph "硬件层 Hardware"
ESP[ESP32-S3 / C3 / P4 等芯片]
PERIPHERALS[麦克风 / 扬声器 / 屏幕 / LED / 按键]
end
APP --> AS
APP --> PROTO
APP --> MCP
APP --> OTA
APP --> DISP
APP --> LED
AS --> CODEC
PROTO --> NET
DISP --> BOARD
LED --> BOARD
BOARD --> ESP
BOARD --> PERIPHERALS
架构要点:
- Application 是整个系统的调度中心,基于 FreeRTOS EventGroup 实现事件驱动循环
- Board 是硬件抽象的核心——每个开发板只需实现
config.h+DECLARE_BOARD宏即可接入
- AudioService 采用三任务模型(输入采集、输出播放、OPUS 编解码),通过队列解耦
- Protocol 抽象了通信层,支持 WebSocket 和 MQTT+UDP 两种协议灵活切换
Sources: application.h, board.h, audio_service.h
设备状态机:11 种状态的完整生命周期¶
小智在任何时刻都处于一个确定的状态。状态之间的切换有严格的合法性校验,确保设备不会出现"边升级边对话"之类的冲突场景:
stateDiagram-v2
[*] --> Unknown
Unknown --> Starting : 上电启动
Starting --> WifiConfiguring : 未配网
Starting --> Idle : 就绪
WifiConfiguring --> Idle : 配网成功
Idle --> Connecting : 发起会话
Idle --> Upgrading : OTA 固件升级
Connecting --> Listening : 开始拾音
Connecting --> Speaking : 接收回复
Listening --> Speaking : 用户说完
Listening --> Idle : 超时/取消
Speaking --> Idle : 播放完毕
Speaking --> Listening : 被打断(新唤醒)
Upgrading --> Idle : 升级完成
Idle --> AudioTesting : 进入音频测试
AudioTesting --> Idle : 测试结束
Connecting --> FatalError : 网络异常
Idle --> FatalError : 系统错误
这 11 个状态的定义位于 device_state.h,状态转换的校验逻辑在 DeviceStateMachine 类中实现,支持观察者模式:任何组件都可以注册回调,在状态变化时自动响应。
Sources: device_state.h, device_state_machine.h
音频管线:三任务协作模型¶
语音交互是小智的灵魂。AudioService 内部用 三个 FreeRTOS 任务 组成了高效流水线:
| 任务名称 | 职责 | 数据流向 |
|---|---|---|
audio_input_task |
从 Codec 采集 PCM 麦克风数据,送入编码队列 | MIC → 编码队列 |
opus_codec_task |
OPUS 编码(上行)和 OPUS 解码(下行) | 编码队列 → 发送队列 / 解码队列 → 播放队列 |
audio_output_task |
从播放队列取出解码后的 PCM,送到扬声器 | 播放队列 → Speaker |
关键设计决策:以 OPUS 数据包为队列边界,而非原始 PCM。这是因为 OPUS 压缩后的数据量仅为 PCM 的约 1/8,大幅节省内存和传输带宽。编码帧长默认 60ms,在音质和延迟间取得平衡。
Sources: audio_service.h, audio_service.h
通信协议:双通道设计¶
小智支持两种通信方式,在编译时通过 sdkconfig 选择:
| 协议 | 适用场景 | 文档 |
|---|---|---|
| WebSocket | 标准 HTTP 升级,适合大多数网络环境 | websocket.md |
| MQTT + UDP | 控制信令与音频数据分离,支持加密传输 | mqtt-udp.md |
两种协议对外暴露统一的 Protocol 抽象接口(模板方法模式),Application 层无需关心底层实现差异。
Sources: protocol.h
MCP 设备控制:让大模型"动手"¶
MCP(Model Context Protocol)是本项目的核心创新点。它基于 JSON-RPC 2.0 规范,允许云端大模型以标准化格式下发设备控制指令。设备端的 McpServer 负责:
- 工具注册:每个设备能力(如控制 LED、读取温度)被封装为一个 MCP Tool
- 参数校验:
PropertyList提供布尔、整数、字符串三种类型参数,支持范围约束和默认值
- 回调执行:验证通过后,调用注册的回调函数执行实际硬件操作
这意味着你可以对大模型说"把灯调成暖黄色",大模型理解意图后通过 MCP 指令控制 GPIO 输出——语义理解到物理控制的闭环就此打通。
Sources: mcp_server.h, README.md
支持 70+ 开发板:Board 抽象层的威力¶
小智通过一个巧妙的抽象层,让同一套代码运行在 70 多种形态各异的硬件上:
- 每个开发板只需要一个
boards/<name>/目录,内含config.h(GPIO 引脚定义)和config.json(烧录信息)
board.h定义了统一的Board抽象基类,所有设备能力(音频编解码器、屏幕、网络、LED)都通过虚函数暴露
DECLARE_BOARD(YourBoardClass)宏在编译时注入具体实现,Application 层通过Board::GetInstance()透明访问
部分代表性硬件:乐鑫 ESP32-S3-BOX3、M5Stack CoreS3、立创 ESP32-S3 开发板、LILYGO T-Circle-S3、SenseCAP Watcher 等。
Sources: board.h, config.h, README.md
项目文件结构速览¶
xiaozhi-esp32/
├── main/
│ ├── main.cc # 程序入口,初始化 NVS 和 Application
│ ├── application.cc/h # 主控:事件循环、状态调度
│ ├── device_state.h # 11 种设备状态枚举
│ ├── device_state_machine.cc/h # 状态机:转换校验 + 观察者通知
│ ├── audio/ # 音频管线
│ │ ├── audio_service.cc/h # 三任务模型,OPUS 编解码
│ │ ├── audio_codec.cc/h # 音频编解码器抽象接口
│ │ ├── codecs/ # ES8311 / ES8388 / ES8374 等驱动
│ │ ├── processors/ # 音频前端处理(AEC / VAD)
│ │ └── wake_words/ # 唤醒词实现(ESP-SR / 自定义)
│ ├── protocols/ # 通信协议
│ │ ├── protocol.h # 抽象基类
│ │ ├── websocket_protocol.cc/h
│ │ └── mqtt_protocol.cc/h
│ ├── boards/ # 70+ 开发板配置
│ │ ├── common/board.h # Board 抽象基类 + DECLARE_BOARD
│ │ ├── common/wifi_board.cc/h
│ │ └── <board-name>/ # 每个开发板的 config.h + 实现
│ ├── display/ # 显示系统(OLED / LCD / LVGL)
│ ├── led/ # LED 系统(单灯 / 灯环 / GPIO)
│ ├── mcp_server.cc/h # MCP 协议设备端实现
│ ├── ota.cc/h # OTA 固件升级
│ ├── settings.cc/h # NVS 持久化存储
│ ├── assets.cc/h # 资源文件管理(表情、字体、唤醒词模型)
│ └── system_info.cc/h # 系统诊断工具
├── partitions/ # 分区表(v1 / v2)
├── scripts/ # 构建与发布脚本
├── docs/ # 开发者文档(中英双语)
├── CMakeLists.txt # CMake 构建入口
└── sdkconfig.defaults.* # 各芯片平台的默认配置
Sources: 综合自仓库目录结构 main/
版本说明¶
当前主分支为 v2 版本(项目版本号 2.2.6),与 v1 的分区表不兼容,因此无法通过 OTA 从 v1 升级到 v2。v1 稳定版为 1.9.2,可通过 git checkout v1 切换,维护至 2026 年 2 月。分区表详情参见 partitions/v2/README.md。
Sources: README.md, CMakeLists.txt
相关开源生态¶
小智是一个开放生态的核心节点,周边有多个社区维护的配套项目:
- 服务端(自建服务器):Python、Java、Golang 三种语言实现,均与小智设备端协议兼容
- 其他客户端:Python CLI、Android App、Linux 客户端、移远 QuecPython 固件——它们使用相同的通信协议
- 工具链:xiaozhi-assets-generator 提供在线可视化编辑唤醒词、字体、表情和聊天背景
这个生态意味着你可以选择官方免费服务器快速体验,也可以搭建私有服务器做深度定制,甚至把协议移植到自己的硬件平台上。
Sources: README.md
技术栈关键特性总结¶
| 特性 | 选型 / 实现 |
|---|---|
| RTOS | FreeRTOS(ESP-IDF 原生支持) |
| 音频编码 | OPUS,16kHz 单声道,60ms 帧长 |
| 离线唤醒 | ESP-SR(MultiNet 模型),支持自定义唤醒词 |
| JSON 解析 | cJSON 轻量库 |
| 显示框架 | LVGL(需 LCD 的开发板) |
| 加密 | mbedTLS(HTTPS / MQTT TLS) |
| 构建系统 | CMake + ESP-IDF v5.4+ |
| 编码规范 | Google C++ Style |
| CI/CD | GitHub Actions,按矩阵自动构建 70+ 变体 |
Sources: audio_service.h, sdkconfig.defaults.esp32s3, build.yml
接下来读什么¶
现在你已经建立了对小智项目的整体认知。根据你的兴趣方向,推荐以下阅读路径:
- 想立即上手体验?直接阅读 快速开始:固件烧录与设备接入
- 想搭建开发环境?参考 开发环境搭建:ESP-IDF 与 VSCode 配置
- 想深入理解架构?进入 系统架构全景:从麦克风到云端大模型的完整数据流
- 想适配自己的硬件?跳转到 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD
快速开始:固件烧录与设备接入¶
本文档面向零基础开发者,帮助你完成小智 AI 聊天机器人的固件烧录与首次设备接入。你将获得一块能够连接官方服务器、与大模型进行语音对话的智能硬件。如果你已经拥有支持的 ESP32 开发板,整个流程可以在 15 分钟内完成,无需搭建任何开发环境。
Sources: README_zh.md
阅读前提与路径指引¶
在开始之前,建议你已阅读 项目概览:小智 AI 聊天机器人的核心能力与价值,了解项目的功能边界和技术栈。完成本页操作后,你可以继续阅读 开发环境搭建:ESP-IDF 与 VSCode 配置 进入源码开发阶段,或阅读 Board 抽象层设计:统一管理 70+ 开发板的秘诀 了解硬件适配原理。
Sources: README_zh.md
整体流程架构¶
从拿到一块 ESP32 开发板到设备能够与 AI 对话,需要经历固件烧录、网络接入、服务器注册三个阶段。下图展示了完整的端到端流程:
flowchart TD
A["<b>准备硬件</b><br/>ESP32 开发板 + USB 数据线"] --> B{"<b>选择烧录方式</b>"}
B -->|"新手推荐"| C["<b>免开发环境烧录</b><br/>下载预编译固件"]
B -->|"开发者"| D["<b>源码编译烧录</b><br/>idf.py build & flash"]
C --> E["<b>烧录工具</b><br/>Web 串口工具 / esptool"]
D --> E
E --> F["<b>首次上电</b><br/>设备自动启动"]
F --> G{"<b>WiFi 已配置?</b>"}
G -->|"否"| H["<b>进入配网模式</b><br/>热点配网 / BluFi 蓝牙配网"]
G -->|"是"| I["<b>连接服务器</b><br/>xiaozhi.me 官方服务"]
H --> I
I --> J{"<b>设备已激活?</b>"}
J -->|"否"| K["<b>设备激活</b><br/>通过激活码绑定账号"]
J -->|"是"| L["<b>就绪!</b><br/>开始语音对话"]
K --> L
固件烧录的本质是将编译好的二进制程序写入 ESP32 芯片的 Flash 存储器。设备接入则包含两个层面:网络接入(让设备连接互联网)和服务接入(让设备找到并注册到 AI 服务器)。预编译固件默认指向官方服务器 xiaozhi.me,个人用户注册账号后可免费使用 Qwen 实时模型。
Sources: README_zh.md | main.cc | application.cc
支持的硬件平台¶
小智 AI 聊天机器人基于乐鑫 ESP32 系列芯片,支持以下芯片平台和 Flash 配置:
| 芯片型号 | 典型开发板示例 | Flash 大小推荐 | PSRAM 要求 | 备注 |
|---|---|---|---|---|
| ESP32-S3 | M5Stack CoreS3、乐鑫 ESP-BOX-3、立创·实战派 | 16MB(标准)/ 8MB | 需要(Octo PSRAM) | 最主流平台,支持最完整 |
| ESP32-C3 | 虾哥 Mini C3、神奇按钮 C3 | 16MB(使用 16m_c3.csv 分区表) |
不需要 | 低成本平台,assets 分区限制 4MB |
| ESP32-C5 | Movecall Moji2.0、Espressif Spot-C5 | 16MB | 需要 | 较新平台,搭载低功耗 WiFi 6 |
| ESP32-C6 | 微雪 ESP32-C6 系列 | 16MB | 需要 | 同样支持 WiFi 6 BLE 5 |
| ESP32-P4 | 微雪 ESP32-P4-NANO、M5Stack Tab5 | 16MB / 32MB | 需要 | 高性能平台,带 MIPI-CSI 摄像头接口 |
| ESP32(原版) | ESP32 DevKitC、AtomMatrix+Echo Base | 16MB | 不需要 | 经典芯片,功能受限(无 PSRAM) |
项目仓库的 main/boards/ 目录下已集成 70+ 种开源硬件的板级支持。每个开发板都有独立的配置目录,包含引脚定义 (config.h)、编译配置 (config.json) 和板级初始化代码。
Sources: boards 目录树 | sdkconfig.defaults.esp32s3 | sdkconfig.defaults.esp32c3 | idf_component.yml
设备唯一标识与激活机制¶
每个设备通过 MAC 地址 生成唯一标识(Device-Id),同时板级代码会生成软件 UUID(Client-Id)。部分设备还支持通过 eFuse 烧录序列号(Serial-Number),用于更安全的设备激活验证。这些标识在设备首次连接服务器时被上报,服务器据此进行版本检查、配置下发和设备激活。
Sources: ota.cc | system_info.cc | board.h
方式一:免开发环境烧录(新手推荐)¶
对于第一次操作的新手,强烈建议先不要搭建开发环境,直接使用官方预编译的固件包进行烧录。这是最快、最不容易出错的路径。
Sources: README_zh.md
步骤 1:获取预编译固件¶
固件通过 GitHub Actions 自动构建,每次推送到 main 分支时触发全量编译。你可以通过以下渠道获取:
- 官方 Releases 页面:访问 GitHub Releases,下载对应你开发板的 ZIP 包(例如
v2.2.6_bread-compact-wifi.zip)
- GitHub Actions 构建产物:在 Actions 页面选择最新的
Build Boards工作流运行,下载xiaozhi_<开发板名称>_<commit-hash>命名的 artifact
- 第三方社区分发:飞书文档《小智 AI 聊天机器人百科全书》中提供了整合好的烧录工具与固件包
ZIP 包内包含一个 merged-binary.bin 文件,这是已经合并了 bootloader、分区表和应用程序的单文件固件,烧录地址固定为 0x0。
Sources: build.yml | release.py | download_github_runs.py
步骤 2:选择烧录工具¶
| 烧录工具 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| ESP Web Serial Tool(网页工具) | 所有新手 | 无需安装任何软件,浏览器直接烧录 | 需要 Chrome/Edge 浏览器,依赖 Web Serial API |
| esptool.py(命令行) | 有一定命令行基础 | 功能完整,支持高级选项 | 需要 Python 环境和 pip 安装 |
| ESP Flash Download Tool(乐鑫官方 GUI) | Windows 用户 | 图形化界面,直观操作 | 仅支持 Windows |
网页工具推荐:访问 ESP Web Serial Tool(https://espressif.github.io/esptool-js/),选择芯片型号(如 ESP32-S3),将 merged-binary.bin 拖入页面,地址设为 0x0,点击 "Program" 即可。注意:你必须使用支持 Web Serial API 的浏览器(Chrome 89+ 或 Edge 89+)。
esptool.py 命令行:安装后执行以下命令(以 ESP32-S3、端口 COM3 为例):
pip install esptool
esptool.py --chip esp32s3 --port COM3 --baud 921600 write_flash 0x0 merged-binary.bin
步骤 3:连接开发板并烧录¶
- 使用 USB 数据线(注意:必须是数据线,不能是仅充电线)将开发板连接到电脑
- 部分开发板需要手动进入下载模式:按住 BOOT 按钮 → 按一下 EN/RST 按钮 → 松开 BOOT 按钮
- 确认电脑识别到串口(Windows 下在设备管理器中查看
COM端口号;Linux 下为/dev/ttyUSB0;macOS 下为/dev/cu.usbserial-*)
- 执行烧录,等待进度条完成
- 烧录完成后按 EN/RST 复位按钮,或重新拔插 USB 线,设备自动启动
Sources: sdkconfig.defaults | partitions/v2/README.md
方式二:源码编译烧录(开发者路径)¶
如果你计划修改代码或适配自定义硬件,需要搭建完整开发环境。简要流程如下(详细步骤请参考 开发环境搭建:ESP-IDF 与 VSCode 配置):
flowchart LR
A["克隆仓库"] --> B["安装 ESP-IDF<br/>v5.5.2+"]
B --> C["idf.py set-target<br/>选择芯片"]
C --> D["idf.py menuconfig<br/>选择开发板"]
D --> E["idf.py build<br/>编译"]
E --> F["idf.py flash<br/>烧录"]
F --> G["idf.py monitor<br/>查看日志"]
关键要点:
- ESP-IDF 版本要求:
>= 5.5.2(在main/idf_component.yml中声明)
- 选择开发板:在
menuconfig→Xiaozhi Assistant→Board Type中选择你的开发板型号,或者在命令行通过-DCONFIG_BOARD_TYPE_XXX=y直接指定
- 自动化构建:项目提供
python scripts/release.py <board_type>脚本,可一键完成set-target→build→merge-bin→zip全流程
Sources: idf_component.yml | release.py | CMakeLists.txt
首次上电:设备配网与服务器接入¶
烧录完成后,设备首次上电的启动流程如下:
sequenceDiagram
participant Device as "ESP32 设备"
participant WiFi as "WiFi 路由器"
participant Phone as "手机(配网用)"
participant Server as "xiaozhi.me 服务器"
Device->>Device: 初始化 NVS、显示屏、音频编解码器
Device->>Device: 检查已保存的 WiFi 凭据
alt 无已保存 WiFi
Device->>Device: 进入配网模式(Hotspot / BluFi)
Device-->>Phone: 开启热点 "Xiaozhi-XXXX" 或广播 BLE
Phone->>Device: 发送 SSID + 密码
Device->>WiFi: 连接 WiFi
else 有已保存 WiFi
Device->>WiFi: 直接连接
end
WiFi-->>Device: 连接成功
Device->>Server: HTTP POST 版本检查请求<br/>(携带 Device-Id, Client-Id, 系统信息)
Server-->>Device: 返回 firmware 信息 + 激活状态
alt 设备未激活
Server-->>Device: 返回 activation.code
Device->>Device: 屏幕显示激活码
Phone->>Server: 用户在 xiaozhi.me 输入激活码
Server-->>Device: 激活完成,下发 MQTT/WebSocket 配置
else 设备已激活
Server-->>Device: 下发通信配置
end
Device->>Server: 建立 WebSocket / MQTT 长连接
Device->>Device: 进入 Idle 状态,等待唤醒
Sources: application.cc | device_state.h | ota.cc
WiFi 配网方式详解¶
小智固件支持两种配网方式(二选一,不可同时启用):
| 配网方式 | 技术原理 | 优点 | 适用条件 |
|---|---|---|---|
| 热点配网(Hotspot) | 设备开启 WiFi 热点,手机连接热点后通过网页配置 | 通用性强,无需额外 App | 默认方式,几乎所有芯片支持 |
| BluFi 蓝牙配网 | 通过 BLE 与手机通信,使用 EspBlufi App 配网 | 用户体验更好,支持加密传输 | 需要芯片支持 BLE(ESP32-S3/C3/C5/C6 等) |
在 menuconfig → WiFi Configuration Method 中选择配网方式。如果启用 BluFi,必须同时关闭 Hotspot 选项。IDF 5.5.2 中 BluFi 蓝牙名称为 "Xiaozhi-Blufi"。
Sources: blufi_zh.md | wifi_board.h
设备激活与账号绑定¶
设备首次连接官方服务器 xiaozhi.me 时,服务器会根据设备唯一标识判断激活状态:
- 未激活设备:服务器返回
activation.code(6 位激活码),设备屏幕会显示该激活码
- 用户操作:在 xiaozhi.me 注册账号 → 登录控制台 → 输入激活码完成绑定
- 激活成功:服务器下发 MQTT/WebSocket 通信配置,设备自动重连并进入待机状态
- 配置同步:服务器同时下发
mqtt和websocket配置段,设备持久化存储到 NVS 中
激活机制支持 Challenge-Response 验证(activation.challenge 字段),用于更高安全等级的设备认证场景。支持 eFuse 序列号的设备会使用 Activation-Version: 2 协议头进行增强验证。
Sources: ota.cc | settings.h | ota.h
分区表设计概述¶
烧录时需要了解固件的分区布局。小智 v2 版本使用全新的分区表设计:
block-beta
columns 6
block:partition_layout:6
nvs["nvs<br/>16KB<br/>非易失存储"]
otadata["otadata<br/>8KB<br/>OTA 数据"]
phy_init["phy_init<br/>4KB<br/>PHY 初始化"]
ota_0["ota_0<br/>4MB<br/>应用程序 A"]
ota_1["ota_1<br/>4MB<br/>应用程序 B"]
assets["assets<br/>8MB<br/>SPIFFS 资源文件"]
end
v2 分区表(16MB Flash 为例)的关键变化:
| 特性 | v1 版本 | v2 版本 |
|---|---|---|
| 应用程序分区大小 | 6MB × 2 | 4MB × 2(更紧凑) |
| 资源存储方式 | 固定 model 分区 (960KB) |
独立 assets 分区 (8MB),支持网络动态下载 |
| OTA 升级 | 应用固件升级 | 应用固件 + 资源文件独立更新 |
| 自定义唤醒词 | 需重新编译烧录 | 网络下载,无需重刷固件 |
| 主题/表情/字体 | 内置不可变 | assets 分区存储,支持网页端在线自定义 |
重要提示:v1 与 v2 分区表互不兼容,无法从 v1 通过 OTA 升级到 v2。使用 v1 的硬件需要通过手动烧录迁移到 v2 版本。v1 稳定版本为 1.9.2(git checkout v1),持续维护到 2026 年 2 月。
不同 Flash 大小对应的分区表文件:
| Flash 大小 | 分区表文件 | ota_0/ota_1 大小 | assets 大小 | 适用场景 |
|---|---|---|---|---|
| 4MB | partitions/v2/4m.csv |
1.5MB | 1MB | 极低成本设备 |
| 8MB | partitions/v2/8m.csv |
3MB | 2MB | AtomS3R Echo Base 等 |
| 16MB | partitions/v2/16m.csv |
4MB | 8MB | 标准配置,推荐 |
| 16MB (C3) | partitions/v2/16m_c3.csv |
4MB | 4MB (受 mmap 页限制) | ESP32-C3 系列 |
| 32MB | partitions/v2/32m.csv |
4MB | 16MB | 高端设备 (ESP32-P4) |
Sources: partitions/v2/README.md | partitions/v2/16m.csv | partitions/v2/8m.csv | sdkconfig.defaults | README_zh.md
设备启动后的状态流转¶
理解设备的启动状态机有助于排查首次接入问题。设备上电后经历以下状态:
| 状态 | 含义 | 触发条件 |
|---|---|---|
Starting |
系统初始化中 | 上电复位 |
WifiConfiguring |
配网模式 | 无已保存 WiFi 凭据 |
Idle |
空闲待机 | 网络已连接,等待唤醒 |
Connecting |
连接服务器中 | 唤醒词触发 / 手动按键 |
Listening |
监听用户语音 | 服务器连接成功,开始录音 |
Speaking |
播放 AI 回复 | 服务器返回 TTS 音频 |
Activating |
设备激活中 | 首次连接,等待用户绑定 |
Upgrading |
OTA 固件升级中 | 检测到新版本 |
FatalError |
致命错误 | 网络/硬件异常 |
所有状态转换由 DeviceStateMachine 严格控制,确保设备行为的可预测性。例如,在 Speaking 状态下不响应唤醒词,在 Upgrading 状态下暂停所有音频处理。
Sources: device_state.h | device_state_machine.h | application.h
常见问题排查¶
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 电脑无法识别串口 | USB 线为充电线(无数据功能)/ 驱动未安装 | 更换数据线;Windows 安装 CP210x/CH340 驱动 |
烧录失败 Invalid head of packet |
芯片未进入下载模式 | 按住 BOOT → 按 EN → 松开 BOOT,再试 |
| 烧录后屏幕无显示 | OLED/I2C 引脚不匹配 / 烧录了错误的 board 固件 | 确认下载的固件名称与开发板匹配 |
| 设备反复进入配网模式 | WiFi 凭据存储异常 / NVS 分区损坏 | 重新配网,或在串口 monitor 中查看日志确认 NVS 状态 |
| 激活码不显示 | 网络未连接 / OTA URL 不可达 | 检查路由器是否允许设备联网;确认 DNS 解析正常 |
| 激活后无法对话 | MQTT/WebSocket 配置未正确下发 | 在 xiaozhi.me 控制台检查设备在线状态 |
NVS flash 相关错误 |
NVS 分区损坏或空间不足 | 固件会自动擦除并重新初始化 NVS(参见 main.cc) |
Sources: main.cc | settings.h
下一步¶
完成固件烧录和设备接入后,你可以:
- 深入配置:阅读 sdkconfig 配置详解:芯片平台与功能开关 了解编译期配置项
- 自定义硬件:阅读 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD 适配你自己的 PCB 设计
- 理解系统架构:阅读 系统架构全景:从麦克风到云端大模型的完整数据流 建立全局认知
- 了解通信协议:阅读 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计 理解设备与服务器的数据交互
开发环境搭建:ESP-IDF 与 VSCode 配置¶
本文档面向初次接触 ESP32 嵌入式开发的新手,系统性地讲解如何搭建小智 AI 聊天机器人(xiaozhi-esp32)项目的本地编译环境。阅读完本文后,你将能够从源码编译固件并烧录到自己的开发板上。如果你只想快速体验固件而无需搭建开发环境,请先阅读 快速开始:固件烧录与设备接入。
前置条件¶
在开始搭建开发环境之前,请确认你具备以下条件。
硬件要求:
- 一台电脑(Windows / Linux / macOS 均可;Linux 编译速度最快且免去驱动问题)
- 一块 ESP32 系列开发板(支持 ESP32-S3、ESP32-C3、ESP32-C5、ESP32-C6、ESP32-P4 等)
- USB 数据线(用于烧录和串口调试)
软件先决条件:
- Git(用于克隆源码仓库)
- 一个现代的代码编辑器 —— 推荐 VSCode 或 Cursor
- Python 3.6+(ESP-IDF 内置的 Python 脚本依赖)
Sources: README_zh.md
ESP-IDF 环境安装¶
小智项目基于 ESP-IDF v5.5.2 或更高版本构建,这是 Espressif 官方提供的 IoT 开发框架。目前有两条主流安装路径:VSCode 图形化安装(推荐新手)和 命令行手动安装(推荐有经验的开发者)。
Sources: idf_component.yml
方案一:VSCode + ESP-IDF 插件(推荐新手)¶
这是最便捷的方式,由 Espressif 官方维护的 VSCode 插件会一键完成 ESP-IDF 下载、工具链安装和环境变量配置。
安装步骤:
flowchart TD
A[安装 VSCode 或 Cursor] --> B[打开扩展面板 Ctrl+Shift+X]
B --> C[搜索并安装 ESP-IDF 插件]
C --> D[按 F1 输入: ESP-IDF Configure]
D --> E[选择 Express 快速安装]
E --> F[选择 ESP-IDF v5.5.2 版本]
F --> G[等待下载完成]
G --> H[环境搭建完成]
- 打开 VSCode(或 Cursor),进入扩展市场(快捷键
Ctrl+Shift+X),搜索 "ESP-IDF" 并安装由 Espressif 官方发布的插件。
- 安装完成后,按
F1键打开命令面板,输入ESP-IDF: Configure ESP-IDF Extension,然后选择 Express 快速安装模式。
- 在版本选择界面,务必选择 v5.5.2 或以上版本(当前项目的最低要求)。插件会自动下载对应版本的 ESP-IDF 框架以及交叉编译工具链,整个过程约需下载 2-3 GB 文件,请保持网络畅通。
- 安装完成后,终端中输入
idf.py --version验证安装是否成功。
Sources: build.yml
方案二:Linux / macOS 命令行安装¶
对于 Linux 或 macOS 用户,可以直接通过命令行安装 ESP-IDF:
# 1. 安装前置依赖(Ubuntu/Debian 示例)
sudo apt-get install git wget flex bison gperf python3 python3-pip \
python3-venv cmake ninja-build ccache libffi-dev libssl-dev \
dfu-util libusb-1.0-0
# 2. 克隆 ESP-IDF 仓库
mkdir -p ~/esp
cd ~/esp
git clone --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
git checkout v5.5.2
git submodule update --init --recursive
# 3. 运行安装脚本
./install.sh esp32,esp32s3,esp32c3,esp32c6,esp32p4
# 4. 激活环境变量(每次打开终端都需要执行)
source ~/esp/esp-idf/export.sh
💡 提示:可以将
source export.sh加入~/.bashrc或~/.zshrc,避免每次手动激活。推荐使用alias get_idf='source ~/esp/esp-idf/export.sh'这样的别名方式。
Sources: idf_component.yml
方案三:Docker 容器(CI 环境复现)¶
小智项目的 GitHub Actions CI 使用 espressif/idf:v5.5.2 官方 Docker 镜像,你可以用同样的镜像在本地构建:
docker run --rm -it -v $(pwd):/project -w /project \
espressif/idf:v5.5.2 bash -c "idf.py build"
这种方式无需手动配置任何环境变量,适合快速验证编译或在 CI/CD 流水线中使用。
Sources: build.yml
获取源代码¶
# 克隆主仓库(默认 v2 分支)
git clone https://github.com/78/xiaozhi-esp32.git
cd xiaozhi-esp32
# 克隆后务必拉取子模块
git submodule update --init --recursive
⚠️ 注意:如果忘记执行
git submodule update --init --recursive,后续编译阶段会因缺失子模块依赖而报错。
如果你需要维护旧版本硬件,可以通过 git checkout v1 切换到 v1 分支。v1 分支将持续维护到 2026 年 2 月,但需注意 v1 与 v2 分区表不兼容,无法通过 OTA 互升级。
Sources: README_zh.md
项目结构速览¶
在正式编译之前,先认识一下项目关键目录和文件:
xiaozhi-esp32/
├── main/ # 主应用代码
│ ├── CMakeLists.txt # 组件构建脚本(含所有板卡选择逻辑)
│ ├── Kconfig.projbuild # Kconfig 菜单(板卡类型、多语言、资源选项)
│ ├── idf_component.yml # IDF 组件依赖声明
│ ├── boards/ # 70+ 开发板的具体实现
│ ├── audio/ # 音频编解码与服务
│ ├── display/ # 显示系统(OLED/LCD/LVGL)
│ ├── protocols/ # 通信协议(WebSocket/MQTT)
│ └── ...
├── partitions/v2/ # v2 版本分区表文件
│ ├── 4m.csv, 8m.csv, 16m.csv, 16m_c3.csv, 32m.csv
│ └── README.md
├── scripts/ # 构建辅助脚本
│ ├── release.py # 多板卡批量编译脚本
│ ├── build_default_assets.py
│ └── spiffs_assets/ # SPIFFS 资源打包工具
├── sdkconfig.defaults # 通用 sdkconfig 默认值
├── sdkconfig.defaults.esp32s3 # 各芯片专属 sdkconfig 覆写
├── CMakeLists.txt # 顶层 CMake(项目版本 2.2.6)
└── .clang-format # Google C++ 代码风格配置
Sources: CMakeLists.txt, idf_component.yml
选择目标芯片与开发板¶
小智项目通过 Kconfig 菜单系统 管理芯片目标和开发板类型,你需要通过 idf.py menuconfig(或 idf.py set-target)进行配置。
第一步:设置目标芯片¶
# 在项目根目录下执行
idf.py set-target esp32s3 # 替换为你的芯片型号
支持的芯片目标:esp32、esp32s3、esp32c3、esp32c5、esp32c6、esp32p4。不同芯片在内存、PSRAM、无线能力上存在差异:
| 芯片 | PSRAM 支持 | Wi-Fi | 典型 Flash | 适用场景 |
|---|---|---|---|---|
| ESP32 | 可选 | ✅ 2.4GHz | 4MB | 基础音频设备 |
| ESP32-S3 | ✅ Octal/Quad | ✅ 2.4GHz | 16MB | 主力平台,带 LCD 交互 |
| ESP32-C3 | ❌ | ✅ 2.4GHz | 16MB | 低成本低功耗设备 |
| ESP32-C5 | ❌ | ✅ 双频 | 16MB | Wi-Fi 6 新平台 |
| ESP32-C6 | ❌ | ✅ 双频 | 16MB | Wi-Fi 6 + BLE 5.0 |
| ESP32-P4 | ✅ 高速 | ❌ (需协处理) | 16MB | 高性能计算,带摄像头 |
执行 set-target 后,项目会自动加载 sdkconfig.defaults 和对应芯片的 sdkconfig.defaults.esp32xx 配置文件,写入 sdkconfig 文件。
Sources: sdkconfig.defaults, sdkconfig.defaults.esp32s3, sdkconfig.defaults.esp32c3, sdkconfig.defaults.esp32p4
第二步:选择开发板类型¶
执行 idf.py menuconfig 进入图形配置界面,导航到 Xiaozhi Assistant → Board Type,从 70+ 开发板列表中选择你的板卡。
idf.py menuconfig
# 进入路径: Xiaozhi Assistant → Board Type → 选择你的开发板
flowchart LR
A["idf.py set-target esp32s3"] --> B["idf.py menuconfig"]
B --> C["Xiaozhi Assistant 菜单"]
C --> D["选择 Board Type"]
D --> E["自动设置分区表、唤醒词、字体等"]
E --> F["保存退出,生成 sdkconfig"]
正确的 Board Type 选择会自动关联对应的分区表文件和唤醒词模型配置。例如,选择 ESP32-S3 系列板卡时默认使用 partitions/v2/16m.csv;选择 ESP32-C3 时则使用 partitions/v2/16m_c3.csv(assets 分区缩减至 4MB)。
Sources: Kconfig.projbuild, main/CMakeLists.txt
有关 sdkconfig 配置项的详细解释,请参阅 sdkconfig 配置详解:芯片平台与功能开关。
编译构建¶
完成目标芯片和开发板配置后,即可开始编译:
# 完整构建(首次编译会下载依赖组件,耗时较长)
idf.py build
# 如需清理后重新构建
idf.py fullclean && idf.py build
首次编译注意事项:
- IDF 组件管理器会自动从 Espressif Component Registry 下载
idf_component.yml中声明的依赖(如espressif/esp-sr、lvgl/lvgl、espressif/esp_codec_dev等 50+ 组件),请确保网络畅通。
- 组件会被缓存到
managed_components/目录,后续增量编译无需重新下载。
- 如果遇到下载失败,可以设置镜像环境变量:
export IDF_COMPONENT_REGISTRY_URL=https://components.espressif.com
Sources: idf_component.yml, CMakeLists.txt
编译产物说明:
| 产物文件 | 位置 | 说明 |
|---|---|---|
xiaozhi.bin |
build/xiaozhi.bin |
应用程序固件 |
merged-binary.bin |
build/merged-binary.bin |
合并固件(含 bootloader + 分区表 + 应用) |
assets.bin |
build/assets.bin |
SPIFFS 资源分区(唤醒模型、字体、表情) |
固件烧录¶
将开发板通过 USB 连接电脑后,使用以下命令烧录:
# 烧录并打开串口监视器(115200 波特率)
idf.py flash monitor
# 仅烧录不监视
idf.py flash
# 指定串口号(Windows 示例)
idf.py -p COM3 flash monitor
首次烧录前检查清单:
- 确认 USB 驱动已正确安装(ESP32-S3 通常使用 CH343/CP210x 芯片,Linux 免驱,Windows 需手动安装驱动)
- 确认开发板已进入下载模式(部分板卡需按住 BOOT 按钮再按 RESET)
- 确认串口号正确(Linux 下通常为
/dev/ttyUSB0,macOS 下为/dev/tty.usbserial-*)
烧录完成后,按下开发板的 RESET 键,设备将自动启动并连接到默认服务器。
如果你希望不搭建开发环境直接烧录预编译固件,请参考 快速开始:固件烧录与设备接入。
代码格式化配置¶
小智项目采用 Google C++ 代码风格 并通过 clang-format 工具统一格式化。项目根目录已提供 .clang-format 配置文件。
flowchart LR
A[编写代码] --> B[clang-format -i 文件]
B --> C[格式化完成]
C --> D["VSCode: 保存时自动格式化"]
安装 clang-format:
| 操作系统 | 安装命令 |
|---|---|
| Windows | winget install LLVM 或 choco install llvm |
| Ubuntu/Debian | sudo apt install clang-format |
| Fedora | sudo dnf install clang-tools-extra |
| macOS | brew install clang-format |
在 VSCode 中启用保存时自动格式化:
- 安装 C/C++ 扩展。
- 打开设置 (
Ctrl+,),搜索format on save,勾选Editor: Format On Save。
- 确保
C_Cpp: Clang_format_style设置为file(使用项目根目录的.clang-format)。
Sources: code_style_zh.md, .clang-format
批量编译与自动化构建¶
小智项目维护了 70+ 个开发板变体,通过 scripts/release.py 脚本实现一键批量编译。
# 编译单个开发板变体
python scripts/release.py bread-compact-wifi --name bread-compact-wifi
# 列出所有可编译的变体
python scripts/release.py --list-boards --json
该脚本会遍历 main/boards/ 下每个子目录中的 config.json,收集所有构建变体信息(包括芯片目标、sdkconfig 覆写、分区表等),再逐个调用 idf.py 编译并合并固件。项目的 GitHub Actions CI 使用同样的脚本,在 push 到 main 分支时编译全部变体,PR 时则据变更范围按需编译。
Sources: release.py, build.yml
有关 CI/CD 流水线的完整分析,请参见 自动化构建脚本与固件发布流水线。
常见问题排查¶
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
idf.py: command not found |
未激活 ESP-IDF 环境 | 执行 source ~/esp/esp-idf/export.sh 或在 VSCode 中重新加载 ESP-IDF 终端 |
| 编译时组件下载失败 | 网络不通或镜像不可用 | 设置 IDF_COMPONENT_REGISTRY_URL 环境变量指向可用镜像 |
CMake Error: BOARD_TYPE not set |
未通过 menuconfig 选择开发板 | 执行 idf.py menuconfig 选择 Board Type |
Partition table not found |
芯片与分区表不匹配 | 检查 sdkconfig 中 CONFIG_PARTITION_TABLE_CUSTOM_FILENAME 路径是否正确 |
| 烧录失败 "Wrong boot mode" | GPIO 引脚未进入下载模式 | 按住 BOOT → 按 RESET → 松开 BOOT |
| Windows 找不到串口 | USB 驱动未安装 | 查看设备管理器,安装 CH343/CP210x 驱动 |
| 编译 ESP32-P4 失败 | P4 需要启用实验性功能 | 在 sdkconfig 中开启 CONFIG_IDF_EXPERIMENTAL_FEATURES=y |
submodule 相关编译错误 |
子模块未拉取 | 执行 git submodule update --init --recursive |
下一步阅读¶
完成开发环境搭建并成功烧录固件后,建议按以下路径深入理解项目:
- sdkconfig 配置详解:芯片平台与功能开关 —— 深入理解 Kconfig 菜单中的各项配置如何影响固件行为
- 系统架构全景:从麦克风到云端大模型的完整数据流 —— 理解整个系统的数据流转和模块协作
- Board 抽象层设计:统一管理 70+ 开发板的秘诀 —— 了解硬件抽象层如何支撑多板卡生态
- 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD —— 如果你想为自己的硬件定制适配代码
sdkconfig 配置详解:芯片平台与功能开关¶
本文档系统性地解析小智 AI 聊天机器人固件的 三层 sdkconfig 配置体系:ESP-IDF 原生配置基线、芯片平台特化覆盖、以及项目级 Kconfig 功能开关。理解这套配置系统是自定义固件构建、适配新开发板、以及深入调试的基础。
Sources: CMakeLists.txt
配置体系架构:三层继承模型¶
小智项目采用 ESP-IDF 标准的多层 sdkconfig 合并机制。构建时,配置按以下优先级依次叠加,后者覆盖前者:
flowchart TD
A["📄 sdkconfig.defaults\n(通用基线配置,79 行)"] --> C["🔧 idf.py set-target <chip>"]
B["📄 sdkconfig.defaults.esp32<suffix>\n(芯片特化覆盖,6~32 行)"] --> C
C --> D["📋 Kconfig.projbuild\n(项目菜单系统,978 行)"]
D --> E["⚙️ 开发板 config.json\n(sdkconfig_append 字段)"]
E --> F["📦 最终 sdkconfig\n(合并后的完整配置)"]
文件的物理角色:
| 文件 | 作用 | 典型行数 |
|---|---|---|
sdkconfig.defaults |
所有芯片通用的编译选项、组件开关、LVGL 精简配置 | 79 行 |
sdkconfig.defaults.esp32 |
ESP32 特有:Flash 4MB、分区表路径、唤醒词模型、看门狗超时 | 7 行 |
sdkconfig.defaults.esp32s3 |
ESP32S3 特有:PSRAM 分配、Wi-Fi buffer 调优、USB Host、LVGL 快照 | 32 行 |
sdkconfig.defaults.esp32c3 |
ESP32C3 特有:分区表路径(16m_c3.csv)、精简 Wi-Fi、关闭 IPv6 |
15 行 |
sdkconfig.defaults.esp32c5 |
ESP32C5 特有:240MHz CPU、唤醒词模型、Wi-Fi buffer | 15 行 |
sdkconfig.defaults.esp32c6 |
ESP32C6 特有:Flash 模式 QIO、分区表路径、唤醒词模型 | 6 行 |
sdkconfig.defaults.esp32p4 |
ESP32P4 特有:PSRAM XIP、200MHz SPI RAM、JPEG 编码器、从核 ESP32C6 | 32 行 |
合并逻辑:ESP-IDF 构建系统在执行 idf.py set-target <target> 时,自动查找 sdkconfig.defaults.<target>(其中 <target> 如 esp32s3),将其追加到 sdkconfig.defaults 的配置之上。随后 menuconfig 加载 Kconfig.projbuild 提供项目级菜单。
Sources: sdkconfig.defaults, sdkconfig.defaults.esp32s3, Kconfig.projbuild
通用基线配置(sdkconfig.defaults)¶
所有芯片平台共享的配置集中在 sdkconfig.defaults,涵盖以下关键类别:
编译器与 C++ 运行时¶
CONFIG_COMPILER_OPTIMIZATION_SIZE=y # 以体积为优化目标
CONFIG_COMPILER_CXX_EXCEPTIONS=y # 启用 C++ 异常
CONFIG_COMPILER_CXX_EXCEPTIONS_EMG_POOL_SIZE=1024
CONFIG_COMPILER_CXX_RTTI=y # 启用运行时类型识别
项目使用 C++ 开发(.cc 源文件),依赖异常和 RTTI 特性。OPTIMIZATION_SIZE 确保固件体积可控——这在仅有 4MB Flash 的 ESP32 上至关重要。
Sources: sdkconfig.defaults
Bootloader 配置¶
CONFIG_BOOTLOADER_COMPILER_OPTIMIZATION_PERF=y # Bootloader 追求性能
CONFIG_BOOTLOADER_LOG_LEVEL_NONE=y # 关闭 bootloader 日志
CONFIG_BOOTLOADER_SKIP_VALIDATE_ALWAYS=y # 跳过固件校验(加速启动)
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y # 启用 OTA 回滚
APP_ROLLBACK_ENABLE 是 OTA 安全更新的核心保障:新固件启动后需显式标记为"验证通过",否则自动回滚到旧版本。SKIP_VALIDATE_ALWAYS 在开发阶段加速迭代,但生产环境应谨慎。
Sources: sdkconfig.defaults
分区表配置¶
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/v2/16m.csv"
CONFIG_PARTITION_TABLE_OFFSET=0x8000
默认指向 v2 版本的 16MB 分区表。关键设计:sdkconfig.defaults 设置通用默认值,但芯片特化文件会覆盖 CUSTOM_FILENAME:
| 芯片 | 覆盖的分区表 | 原因 |
|---|---|---|
| ESP32 | partitions/v2/4m.csv |
ESP32 通常仅 4MB Flash,使用单 factory 分区(无 OTA 双分区) |
| ESP32C3 | partitions/v2/16m_c3.csv |
C3 的 mmap 页面有限,assets 分区缩减至 4MB |
| 其他 | partitions/v2/16m.csv(继承自 defaults) |
标准 16MB 布局:ota_0 4MB + ota_1 4MB + assets 8MB |
Sources: sdkconfig.defaults, sdkconfig.defaults.esp32, partitions/v2/README.md
Wi-Fi 与网络栈优化¶
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=6
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=8
CONFIG_ESP_WIFI_IRAM_OPT=n
CONFIG_ESP_WIFI_RX_IRAM_OPT=n
CONFIG_NEWLIB_NANO_FORMAT=y
这些参数经过精细调优以降低内存占用。关闭 IRAM 优化牺牲少量 Wi-Fi 性能换取更多 DRAM;NANO_FORMAT 使用精简版 printf 系列函数。注意:ESP32S3 和 C3 的特化文件会进一步降低 STATIC_RX_BUFFER_NUM 至 3,以适配 PSRAM 场景下的不同内存策略。
Sources: sdkconfig.defaults, sdkconfig.defaults.esp32s3
LVGL 图形库精简¶
CONFIG_LV_USE_ANIMIMG=n
CONFIG_LV_USE_CALENDAR=n
CONFIG_LV_USE_CHART=n
CONFIG_LV_USE_KEYBOARD=n
# ... 共禁用 14 个不常用控件
小智设备以语音交互为主、屏幕显示为辅,大量 LVGL 控件(日历、图表、键盘、菜单等)被显式禁用以节省 Flash 空间。仅保留核心控件:Label、Button、Image、Textarea 等。同时启用 LODEPNG 用于 PNG 解码、IMGFONT 用于自定义字体图标。
Sources: sdkconfig.defaults
其他关键配置¶
| 配置项 | 值 | 用途 |
|---|---|---|
CONFIG_ESP_TASK_WDT_TIMEOUT_S |
10 | 任务看门狗超时,防止单任务死锁 |
CONFIG_ESP_MAIN_TASK_STACK_SIZE |
8192 | 主任务栈 8KB |
CONFIG_MBEDTLS_DYNAMIC_BUFFER |
y | TLS 动态内存分配,降低静态内存 |
CONFIG_CODEC_I2C_BACKWARD_COMPATIBLE |
n | 关闭音频 Codec I2C 旧版兼容 |
CONFIG_UART_ISR_IN_IRAM |
y | UART ISR 驻留 IRAM,修复 ML307 FIFO 溢出 |
CONFIG_MBEDTLS_SSL_RENEGOTIATION |
n | 禁用 TLS 重协商,修复 ESP_SSL 错误 |
Sources: sdkconfig.defaults
芯片平台差异化配置¶
ESP32(原始双核 Xtensa)¶
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/v2/4m.csv"
CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=20
ESP32 的 Flash 通常仅有 4MB,因此分区表使用 4m.csv——仅包含单个 factory 分区(3MB),不支持 OTA 双分区。唤醒词使用 WN9_NIHAOXIAOZHI_TTS 模型("你好小智")。看门狗超时延长至 20 秒,因为双核 Xtensa 处理器在音频编解码任务中可能更耗时。
Sources: sdkconfig.defaults.esp32
ESP32S3(双核 Xtensa + PSRAM + AI 加速)¶
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=2048
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=98304
ESP32S3 是主力芯片平台,配置最为丰富。PSRAM 以 Octal 模式运行在 80MHz,使用 MALLOC_ALWAYSINTERNAL 策略:小于 2048 字节的分配使用内部 SRAM,更大则使用 PSRAM。预留 96KB 内部 SRAM 给 Wi-Fi 和 LWIP 协议栈。此外:
- Cache 优化:32KB 指令 Cache + 64B 数据 Cache Line,最大化 AI 推理吞吐
- USB Host:
CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE=1536支持 RNDIS 4G 网卡
- LVGL:启用
LV_USE_SNAPSHOT支持屏幕截图功能
Sources: sdkconfig.defaults.esp32s3
ESP32C3(单核 RISC-V + 低功耗)¶
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/v2/16m_c3.csv"
CONFIG_SR_WN_WN9S_NIHAOXIAOZHI=y
C3 使用精简版分区表 16m_c3.csv,assets 分区缩减至 4MB。唤醒词模型切换为 WN9S_NIHAOXIAOZHI(无 TTS 后缀的轻量版)。Wi-Fi 配置极其精简:关闭 WPA3 SAE、ESP-NOW 加密数为 0、空闲任务栈仅 768 字节、关闭 IPv6。这些取舍使 C3 能在单核 160MHz RISC-V 上流畅运行。
Sources: sdkconfig.defaults.esp32c3
ESP32C5 / C6(新一代 RISC-V)¶
# C5 特有
CONFIG_USE_ESP_WAKE_WORD=y # 使用无 AFE 的唤醒词模型
# C6 特有
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/v2/16m_c3.csv"
C5 是目前唯一默认启用 USE_ESP_WAKE_WORD 的平台——这是不依赖 AFE(Audio Front End)的轻量唤醒方案。C6 配置最简洁,仅 6 行,继承绝大多数默认值。
Sources: sdkconfig.defaults.esp32c5, sdkconfig.defaults.esp32c6
ESP32P4(旗舰级双核 RISC-V 400MHz + 从核 C6)¶
CONFIG_SPIRAM_SPEED_200M=y
CONFIG_SPIRAM_XIP_FROM_PSRAM=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=10240
CONFIG_FREERTOS_HZ=1000
CONFIG_COMPILER_OPTIMIZATION_PERF=y
CONFIG_SLAVE_IDF_TARGET_ESP32C6=y
P4 是性能旗舰:200MHz PSRAM、XIP 直接从 PSRAM 执行代码、主任务栈 10KB、FreeRTOS 时钟 1000Hz、编译器优化为 PERF。独特的 SLAVE_IDF_TARGET_ESP32C6=y 表明 P4 通过 ESP-Hosted 方案由从核 C6 处理 Wi-Fi/BT 连接。同时启用硬件 JPEG 编码器和 ISP Pipeline Controller 以支持摄像头。
Sources: sdkconfig.defaults.esp32p4
Kconfig.projbuild:项目功能开关全景¶
main/Kconfig.projbuild(978 行)定义了 menu "Xiaozhi Assistant" 下的所有项目级配置选项。这是开发者通过 idf.py menuconfig 交互式配置的入口。
开发板选择(BOARD_TYPE)¶
70+ 种开发板的枚举式选择,按 IDF_TARGET 做条件依赖。核心模式:
choice BOARD_TYPE
prompt "Board Type"
default BOARD_TYPE_BREAD_COMPACT_WIFI
config BOARD_TYPE_ESP_BOX_3
bool "Espressif ESP-BOX-3"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_XMINI_C3_V3
bool "Xmini C3 V3"
depends on IDF_TARGET_ESP32C3
# ... 70+ 条目
endchoice
每个 BOARD_TYPE_* 的选择会触发 main/CMakeLists.txt 中对应的条件分支,设置 BOARD_TYPE 变量、字体大小、表情分辨率等构建参数。例如选择 ESP_BOX_3 会设置 font_noto_basic_20_4 字体和 noto-emoji_128 表情包。
Sources: Kconfig.projbuild, CMakeLists.txt
唤醒词方案(WAKE_WORD_TYPE)¶
系统提供四种唤醒词方案:
| 方案 | 配置宏 | 适用芯片 | 特点 |
|---|---|---|---|
| 禁用 | WAKE_WORD_DISABLED |
全部 | 使用按键对话,无语音唤醒 |
| 无 AFE 唤醒 | USE_ESP_WAKE_WORD |
C3/C5/C6, ESP32+PSRAM | 直接使用 Wakenet 模型,无音频前端处理 |
| AFE 唤醒 | USE_AFE_WAKE_WORD |
S3/P4 + PSRAM | 完整的音频前端:AEC + VAD + 唤醒,支持回声消除 |
| 自定义唤醒词 | USE_CUSTOM_WAKE_WORD |
S3/P4 + PSRAM | 使用 Multinet 模型,通过 CUSTOM_WAKE_WORD 配置拼音 |
USE_AFE_WAKE_WORD 是 S3/P4 平台的默认推荐方案,配合 SEND_WAKE_WORD_DATA 可将唤醒词音频发送至服务器作为对话首条消息。
Sources: Kconfig.projbuild
音频处理管线¶
config USE_AUDIO_PROCESSOR
bool "Enable Audio Noise Reduction"
default y
depends on (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM
config USE_DEVICE_AEC
bool "Enable Device-Side AEC"
depends on USE_AUDIO_PROCESSOR && (BOARD_TYPE_ESP_BOX_3 || ...)
音频降噪仅对 S3/P4 且具备 PSRAM 的平台开放。设备端 AEC(回声消除)需要物理声学隔离和干净的参考信号路径,因此只对特定开发板(如 ESP-BOX-3、立创实战派等)开放。此外还有实验性的服务端 AEC(USE_SERVER_AEC)和音频调试器(USE_AUDIO_DEBUGGER,通过 UDP 发送音频数据)。
Sources: Kconfig.projbuild
显示风格¶
choice DISPLAY_STYLE
config USE_DEFAULT_MESSAGE_STYLE # 默认消息风格
config USE_WECHAT_MESSAGE_STYLE # 微信消息风格
config USE_EMOTE_MESSAGE_STYLE # 表情动画风格(ESP-BOX 系列等)
USE_EMOTE_MESSAGE_STYLE 依赖特定开发板(ESP-BOX、ESP-VoCat、立创实战派等),启用后自动切换 FLASH_EXPRESSION_ASSETS 资源包。
Sources: Kconfig.projbuild
Wi-Fi 配网方式¶
menu "WiFi Configuration Method"
config USE_HOTSPOT_WIFI_PROVISIONING # 热点配网(默认)
config USE_ACOUSTIC_WIFI_PROVISIONING # 声波配网
config USE_ESP_BLUFI_WIFI_PROVISIONING # 蓝牙 Blufi 配网
三种配网方式可共存。BLUFI 选项通过 select 关键字自动启用 BT 相关组件,在 release.py 的 _AUTO_SELECT_RULES 中有硬编码的依赖展开逻辑。
Sources: Kconfig.projbuild, release.py
摄像头配置¶
menu "Camera Configuration"
depends on !IDF_TARGET_ESP32 # ESP32 不支持摄像头
config XIAOZHI_CAMERA_ALLOW_JPEG_INPUT
config XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER # P4 硬件 JPEG
config XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE # P4 PPA 硬件旋转
ESP32 被排除在摄像头功能之外。P4 平台独享硬件 JPEG 编解码和 PPA 图像旋转能力。XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP 提供字节序交换应对特殊传感器。
Sources: Kconfig.projbuild
语言与资源¶
choice "Default Language"
default LANGUAGE_ZH_CN
config LANGUAGE_ZH_CN # 中文
config LANGUAGE_JA_JP # 日语
# ... 共 39 种语言
choice "Flash Assets"
config FLASH_NONE_ASSETS
config FLASH_DEFAULT_ASSETS
config FLASH_CUSTOM_ASSETS
config FLASH_EXPRESSION_ASSETS
39 种语言覆盖全球主流语种。资源烧录选项与显示风格联动:选择表情风格则强制使用 FLASH_EXPRESSION_ASSETS。
Sources: Kconfig.projbuild
配置工作流:从 menuconfig 到构建¶
实际开发中,标准的配置流程如下:
flowchart LR
A["1️⃣ idf.py set-target esp32s3"] --> B["2️⃣ 加载 sdkconfig.defaults"]
B --> C["3️⃣ 加载 sdkconfig.defaults.esp32s3\n(覆盖同名项)"]
C --> D["4️⃣ idf.py menuconfig\n(交互式修改 Kconfig.projbuild)"]
D --> E["5️⃣ 生成最终 sdkconfig"]
E --> F["6️⃣ idf.py build\n(CMake 读取 CONFIG_* 宏)"]
在 CI/CD 自动化构建中(release.py),流程为:
- 读取
config.json确定target和sdkconfig_append
- 执行
idf.py set-target <target>应用 baseline + chip-specific defaults
- 将
sdkconfig_append(含BOARD_TYPE_*及其他覆盖项)追加写入sdkconfig
- 执行
idf.py build并传入-DBOARD_NAME和-DBOARD_TYPE宏
Sources: release.py, build.yml
关键设计原则与调优指南¶
内存优先策略¶
小智固件在 ESP32 系列上的核心瓶颈是内存。所有配置决策围绕"压缩内存占用、释放音频/ML 所需空间"展开:
- Wi-Fi buffer 调优:默认
STATIC_RX_BUFFER_NUM=6,S3/C3 进一步降至 3。每个静态 RX buffer 约 1.6KB,减少 3 个即节省约 5KB
- mbedTLS 动态化:
DYNAMIC_BUFFER和DYNAMIC_FREE_CONFIG_DATA将 TLS 内存从静态分配转为按需分配
- NANO 格式化:
NEWLIB_NANO_FORMAT移除 printf 浮点支持,节省约 2KB ROM
PSRAM 分配策略¶
ESP32S3 和 P4 的 PSRAM 配置采用"小内存内部、大内存外部"策略:
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=2048 # ≤2KB 用内部 SRAM
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=98304 # 预留 96KB 给 Wi-Fi/LWIP
这确保高频小对象(任务栈、队列元素)使用快速内部 SRAM,而音频缓冲区、LVGL 帧缓冲等大块内存使用 PSRAM。
分区表版本选择¶
| 场景 | 推荐分区表 | Flash 要求 |
|---|---|---|
| ESP32 开发板(4MB Flash) | v2/4m.csv |
4MB |
| ESP32S3/C3/C5/C6(16MB Flash) | v2/16m.csv 或 16m_c3.csv(C3) |
16MB |
| ESP32S3/P4(32MB Flash) | v2/32m.csv |
32MB |
v2 分区表相较 v1 的核心变化是将 model 分区替换为更灵活的 assets 分区(SPIFFS 文件系统),支持运行时从网络下载唤醒词模型、主题、字体和表情包。
Sources: partitions/v2/README.md
后续阅读建议¶
理解 sdkconfig 配置体系后,建议按以下路径深入:
- 多语言与资源文件管理:了解
LANGUAGE_*选项如何映射到 assets 分区中的字体和翻译文件
- CMake 构建系统与 ESP-IDF 组件依赖管理:深入
main/CMakeLists.txt如何根据BOARD_TYPE选择源文件和字体配置
- 分区表设计:v1 与 v2 版本的存储布局迁移:全面对比两代分区表的布局差异与迁移策略
- 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD:从零开始适配一块新开发板,包含
config.json的sdkconfig_append字段使用范例
多语言与资源文件管理¶
小智 AI 聊天机器人(xiaozhi-esp32)是一套运行在 ESP32 系列芯片上的嵌入式系统,面向全球用户。它构建了一套从编译期 Kconfig 选择、到 Python 脚本代码生成、再到 C++ 运行时加载的完整多语言与资源管理体系。本文将从数据流转视角,系统性地解析这套架构的设计思路与实现细节。
体系全景:三层架构与数据流向¶
整个多语言与资源文件管理体系可分为三层:编译期决策层(Kconfig → CMake → Python 脚本)、嵌入式资源层(内存映射的 SPIFFS 分区)和运行时消费层(C++ Assets 类与 Lang 命名空间)。三层之间通过 lang_config.h(语言头文件)、assets.bin(资源二进制包)和 EMBED_FILES(嵌入式音频)三种载体传递数据。
flowchart TB
subgraph 编译期决策层
Kconfig["Kconfig.projbuild<br/>LANGUAGE_XX_XX<br/>BOARD_TYPE<br/>FLASH_DEFAULT_ASSETS"]
CMake["CMakeLists.txt<br/>if(CONFIG_LANGUAGE_XX_XX)<br/>→ LANG_DIR<br/>→ BUILTIN_TEXT_FONT<br/>→ DEFAULT_EMOJI_COLLECTION"]
GenLang["scripts/gen_lang.py<br/>JSON → .h 代码生成<br/>(en-US 回退机制)"]
BuildAssets["scripts/build_default_assets.py<br/>字体 + 表情 + SR 模型 → assets.bin"]
end
subgraph 嵌入式资源层
LangHeader["assets/lang_config.h<br/>Lang::Strings::XXX<br/>Lang::Sounds::OGG_XXX"]
AssetsBin["assets.bin (SPIFFS分区)<br/>index.json + 字体 + 表情图片"]
EmbeddedOgg["EMBED_FILES<br/>*.ogg 音频文件编译进固件"]
end
subgraph 运行时消费层
LcdDisplay["LcdDisplay<br/>Lang::Strings::INITIALIZING"]
Application["Application<br/>Lang::Strings::XXX<br/>Lang::Sounds::OGG_XXX"]
AssetsClass["Assets 类<br/>LvglStrategy / EmoteStrategy<br/>Download / Apply"]
end
Kconfig --> CMake
CMake --> GenLang --> LangHeader
CMake --> BuildAssets --> AssetsBin
CMake --> EmbeddedOgg
LangHeader --> LcdDisplay
LangHeader --> Application
EmbeddedOgg --> Application
AssetsBin --> AssetsClass
Sources: CMakeLists.txt, gen_lang.py, Kconfig.projbuild
语言系统:43 种语言的 JSON 驱动代码生成¶
目录结构¶
语言资源文件位于 main/assets/locales/ 目录下,每种语言一个子目录(如 zh-CN、en-US、ja-JP),共支持 43 种语言。每个语言目录内含两类文件:
| 文件 | 用途 | 示例 |
|---|---|---|
language.json |
字符串资源定义(JSON 键值对) | "INITIALIZING": "正在初始化..." |
*.ogg |
语言特定的音效文件(14 个标准文件) | welcome.ogg、activation.ogg、0.ogg~9.ogg |
另外,main/assets/common/ 目录存放与语言无关的公共音效(如 exclamation.ogg、success.ogg、vibration.ogg)。
main/assets/
├── common/ # 公共音效(5 个文件)
│ ├── exclamation.ogg
│ ├── low_battery.ogg
│ ├── popup.ogg
│ ├── success.ogg
│ └── vibration.ogg
└── locales/
├── en-US/ # 基准语言(完整音效 + 完整字符串)
│ ├── language.json
│ ├── 0.ogg ~ 9.ogg
│ ├── activation.ogg
│ ├── err_pin.ogg
│ ├── err_reg.ogg
│ ├── upgrade.ogg
│ ├── welcome.ogg
│ └── wificonfig.ogg
├── zh-CN/ # 其他语言(可按需覆盖)
├── ja-JP/
├── ...
└── sr-RS/ # 仅语言 JSON,无音效(回退到 en-US)
└── language.json
Sources: locales 目录结构
language.json 数据结构¶
每个 language.json 由 language.type(语言标识)和 strings(字符串键值对)两部分组成。en-US 作为基准语言,拥有最完整的 51 个字符串条目;其他语言按需覆盖,未覆盖的键自动回退到 en-US。
{
"language": { "type": "zh-CN" },
"strings": {
"INITIALIZING": "正在初始化...",
"LISTENING": "聆听中...",
"SPEAKING": "说话中...",
"BATTERY_NEED_CHARGE": "电量低,请充电",
"HELLO_MY_FRIEND": "你好,我的朋友!"
}
}
字符串键对应代码中的 Lang::Strings::KEY_NAME 常量,覆盖了设备从启动、配网、连接、交互到升级的全部生命周期提示语。
Sources: en-US/language.json, zh-CN/language.json, ja-JP/language.json
代码生成引擎:scripts/gen_lang.py¶
gen_lang.py 是语言系统的核心代码生成脚本,由 CMake 的 add_custom_command 在构建时自动调用。它的工作流程如下:
flowchart LR
A["language.json<br/>(目标语言)"] --> B["gen_lang.py"]
C["language.json<br/>(en-US 基准)"] --> B
B --> D["合并字符串<br/>(en-US 基准 + 目标语言覆盖)"]
B --> E["合并音效引用<br/>(目标语言优先,缺口回退 en-US)"]
D --> F["lang_config.h<br/>Lang::Strings::XXX"]
E --> G["lang_config.h<br/>Lang::Sounds::OGG_XXX"]
核心合并逻辑:以 en-US 的 strings 字典为基准,用目标语言的 strings 覆盖同名键。这意味着 目标语言只需定义与 en-US 不同的那部分字符串,未定义的键自动继承 en-US 的英文值。音效文件的处理同理——如果目标语言目录缺少某个 .ogg 文件,生成的头文件中其 Lang::Sounds 常量仍然可用,但实际音频将来自 en-US 目录(由 CMake 的 EMBED_FILES 自动补全)。
生成的头文件模板如下(以 zh-CN 为例):
// Auto-generated language config
// Language: zh-CN with en-US fallback
#pragma once
#include <string_view>
#ifndef zh_cn
#define zh_cn // 预设语言标志
#endif
namespace Lang {
constexpr const char* CODE = "zh-CN";
namespace Strings {
constexpr const char* INITIALIZING = "正在初始化...";
constexpr const char* LISTENING = "聆听中...";
// ... 更多字符串常量
}
namespace Sounds {
extern const char ogg_0_start[] asm("_binary_0_ogg_start");
extern const char ogg_0_end[] asm("_binary_0_ogg_end");
static const std::string_view OGG_0 {
static_cast<const char*>(ogg_0_start),
static_cast<size_t>(ogg_0_end - ogg_0_start)
};
// ... 更多音效常量
}
}
关键在于 Lang::Sounds 使用 ESP-IDF 的二进制嵌入机制——asm("_binary_X_ogg_start") 语法引用了 CMake EMBED_FILES 自动生成的符号,使音频文件直接编译进固件,无需文件系统即可访问。
Sources: gen_lang.py
Kconfig 语言选择与 CMake 联动¶
用户通过 menuconfig 中的 "Default Language" 选项(Kconfig.projbuild)选择目标语言,该选择定义了对应的 CONFIG_LANGUAGE_XX_XX 宏。CMakeLists.txt 根据这些宏映射到语言目录名:
| Kconfig 选项 | LANG_DIR | 语言 |
|---|---|---|
CONFIG_LANGUAGE_ZH_CN |
zh-CN |
简体中文 |
CONFIG_LANGUAGE_EN_US |
en-US |
英语 |
CONFIG_LANGUAGE_JA_JP |
ja-JP |
日语 |
CONFIG_LANGUAGE_KO_KR |
ko-KR |
韩语 |
CONFIG_LANGUAGE_SR_RS |
sr-RS |
塞尔维亚语 |
| ... | ... | ...(共 43 种) |
CMake 随后执行两步关键操作:
第一步:收集音效文件并嵌入固件。使用 file(GLOB LANG_SOUNDS ...) 收集目标语言的 OGG 文件。如果目标语言不是 en-US,则额外收集 en-US 目录中的 OGG 文件,但只添加目标语言中缺失的那些——实现透明音频回退。公共音效(common/*.ogg)无条件加入。所有音效通过 idf_component_register(... EMBED_FILES ${LANG_SOUNDS} ${COMMON_SOUNDS} ...) 嵌入固件。
第二步:生成语言配置头文件。通过 add_custom_command 调用 gen_lang.py --language "${LANG_DIR}" --output "${LANG_HEADER}",生成 assets/lang_config.h,并声明对 language.json 和 gen_lang.py 本身的依赖,确保源文件变更时自动重新生成。
Sources: CMakeLists.txt 语言选择, Kconfig.projbuild
运行时使用模式¶
生成的头文件 assets/lang_config.h 被 application.cc 和 lcd_display.cc 引用。代码中使用模式高度一致:
字符串获取 — Lang::Strings::KEY_NAME 返回 constexpr const char*:
// lcd_display.cc — 初始化状态文本
lv_label_set_text(status_label_, Lang::Strings::INITIALIZING);
// application.cc — 网络事件提示
display->ShowNotification(Lang::Strings::SCANNING_WIFI, 30000);
display->SetStatus(Lang::Strings::REGISTERING_NETWORK);
音效播放 — Lang::Sounds::OGG_NAME 返回 std::string_view,直接传入音频服务:
// application.cc — 错误告警带音效
Alert(Lang::Strings::ERROR, Lang::Strings::PIN_ERROR, "triangle_exclamation", Lang::Sounds::OGG_ERR_PIN);
// 激活完成播放成功音效
audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS);
// 激活码数字逐个播报
audio_service_.PlaySound(Lang::Sounds::OGG_0); // '0' 的音频
Sources: application.cc, lcd_display.cc, lcd_display.cc
资源文件管理:SPIFFS 分区与 assets.bin¶
两套资源系统对比¶
项目根据显示类型提供了两套资源管理策略:
| 维度 | LVGL 策略 (LvglStrategy) | 表情动画策略 (EmoteStrategy) |
|---|---|---|
| 触发条件 | CONFIG_HAVE_LVGL 启用 |
USE_EMOTE_MESSAGE_STYLE 启用 |
| 分区承载格式 | mmap_assets_table 二进制索引表 |
表情动画引擎原生分区格式 |
| 字体加载 | CBin 字体 + 动态主题切换 | 无字体需求 |
| 表情加载 | PNG/GIF 图片 + 别名映射 | EAF 动画文件 |
| 皮肤支持 | index.json → 明暗主题色/背景图 |
无 |
两种策略通过 Assets 类中的策略模式实现,构造函数根据编译宏自动选择:
Assets::Assets() {
#if HAVE_LVGL
strategy_ = std::make_unique<Assets::LvglStrategy>();
#else
strategy_ = std::make_unique<Assets::EmoteStrategy>();
#endif
InitializePartition();
}
分区二进制格式¶
LVGL 策略下的 assets.bin 采用以下二进制布局:
| 偏移 | 大小 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 字节 | total_files |
文件总数 |
| 4 | 4 字节 | checksum |
数据体校验和(累加取低 16 位) |
| 8 | 4 字节 | data_length |
数据体总长度 |
| 12 | total_files × 44 |
mmap_assets_table[] |
文件索引表(每项 name[32] + size[4] + offset[4] + width[2] + height[2]) |
| 12+表格大小 | 变长 | 文件数据区域 | 每个文件以 0x5A5A 魔术字开头 |
初始化时 ESP32 通过 spi_flash_mmap 将整个分区映射到内存,然后校验 checksum 并构建 std::map<std::string, Asset> 索引。获取资源时通过 GetAssetData 跳过 2 字节 0x5A5A 魔术字后返回数据指针。
Sources: build_default_assets.py pack_assets_simple, assets.cc LvglStrategy
index.json 索引结构¶
assets.bin 中必须包含 index.json 文件,它充当资源清单,指导运行时如何消费各组件:
{
"version": 1,
"srmodels": "srmodels.bin",
"text_font": "font_puhui_common_20_4.bin",
"emoji_collection": [
{"name": "neutral", "file": "neutral.png"},
{"name": "happy", "file": "happy.gif"},
{"name": "laughing", "file": "happy.gif"}
],
"skin": {
"light": {
"text_color": "#000000",
"background_color": "#FFFFFF",
"background_image": "wallpaper.bin"
},
"dark": {
"text_color": "#FFFFFF",
"background_color": "#000000"
}
},
"hide_subtitle": false
}
LvglStrategy::Apply() 解析此 JSON,依次加载 SR 模型、文本字体、表情集合和皮肤配置,动态注入到 LVGL 主题管理器中,实现无需重启的主题切换。
Sources: assets.cc LvglStrategy::Apply, build_default_assets.py generate_index_json
资源下载与动态更新¶
Assets::Download() 支持从 URL 远程下载新的 assets.bin,写入 SPIFFS 分区后调用 Assets::Apply() 重新加载。这一机制配合 OTA 版本检查实现了资源的热更新:
// application.cc — 检测到新资源版本
char message[256];
snprintf(message, sizeof(message), Lang::Strings::FOUND_NEW_ASSETS, download_url.c_str());
Alert(Lang::Strings::LOADING_ASSETS, message, "cloud_arrow_down", Lang::Sounds::OGG_UPGRADE);
bool success = assets.Download(download_url, [this, display](int progress, size_t speed) -> void {
char buffer[32];
snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, speed / 1024);
Schedule([display, message = std::string(buffer)]() {
display->SetChatMessage("system", message.c_str());
});
});
下载进度通过回调实时显示在设备屏幕上。下载完成后调用 assets.Apply() 重新解析分区内容,更新字体、表情和主题。
Sources: application.cc CheckAssetsVersion
构建系统集成:从 Kconfig 到 assets.bin¶
build_default_assets.py 构建流水线¶
build_default_assets.py 是将零散的资源组件打包成 assets.bin 的核心脚本,由 CMake 的 build_default_assets_bin() 函数调用。其输入参数包括:
| 参数 | 来源 | 说明 |
|---|---|---|
--sdkconfig |
SDK 配置文件路径 | 读取 wakenet/multinet 模型选择和唤醒词类型 |
--builtin_text_font |
CMake 变量 BUILTIN_TEXT_FONT |
如 font_puhui_basic_20_4 |
--emoji_collection |
CMake 变量 DEFAULT_EMOJI_COLLECTION |
如 noto-emoji_128 |
--esp_sr_model_path |
ESP-SR 组件路径 | wakenet/multinet 模型目录 |
--xiaozhi_fonts_path |
xiaozhi-fonts 组件路径 | 字体文件和表情图片目录 |
脚本内部流程:
- 读取 sdkconfig — 解析
CONFIG_SR_WN_*和CONFIG_SR_MN_*选项,确定唤醒词模型和语音命令模型列表
- 处理 SR 模型 — 将 wakenet 和 multinet 模型目录合并打包为
srmodels.bin(格式:{model_num}[model_info_t...][model_data...])
- 处理文本字体 — 将
font_xxx_basic_xxx映射为font_xxx_common_xxx.bin(如font_puhui_basic_20_4→font_puhui_common_20_4.bin),从 xiaozhi-fonts 组件复制
- 处理表情集合 — 从 xiaozhi-fonts 组件的
png/或gif/子目录复制图片,生成表情别名列表(Otto GIF 特别处理:staticstate→["neutral", "relaxed", "sleepy", "idle"])
- 生成 index.json — 汇总所有组件信息
- 打包 — 使用
pack_assets_simple()将assets/目录下所有文件打包为 SPIFFS 兼容的二进制格式
Sources: build_default_assets.py
字体与表情的板级定制¶
不同开发板因屏幕分辨率和芯片性能差异,需要不同的字体字号和表情尺寸。CMakeLists.txt 中通过 BOARD_TYPE 条件分支统一管理:
| 开发板示例 | BUILTIN_TEXT_FONT | DEFAULT_EMOJI_COLLECTION |
|---|---|---|
| Bread Compact WiFi | font_puhui_basic_14_1 |
无(使用内置 Font Awesome) |
| ESP-BOX-3 | font_noto_basic_20_4 |
noto-emoji_128 |
| Kevin Yuying 313LCD | font_puhui_basic_30_4 |
twemoji_64 |
| Otto Robot | font_puhui_16_4 |
otto-gif |
字体命名规则:font_{字体族}_basic_{字号}_{像素深度}。其中 basic 表示精简字符集(仅 ASCII),运行时由 build_default_assets.py 自动替换为 common(完整字符集)版本。noto 字体族在处理中文等多字节语言时还会替换为 qwen 版本(通义千问字体),确保中日韩等语言字符集完整覆盖。
Sources: CMakeLists.txt 板级配置, build_default_assets.py get_text_font_path
扩展指南:添加新语言¶
向项目贡献新语言翻译,只需遵循以下步骤:
- 在
main/assets/locales/下创建以 BCP-47 语言标签命名的目录(如fr-CA)
- 创建
language.json,至少包含language.type和strings中与en-US不同的键值对(其余自动回退)
- (可选)录制 14 个标准 OGG 音频文件:
0.ogg~9.ogg(数字读法)、activation.ogg、err_pin.ogg、err_reg.ogg、upgrade.ogg、welcome.ogg、wificonfig.ogg
- 在
main/Kconfig.projbuild的choice "Default Language"中追加config LANGUAGE_FR_CA选项
- 在
main/CMakeLists.txt的if/elseif链中追加elseif(CONFIG_LANGUAGE_FR_CA)→set(LANG_DIR "fr-CA")
注意:即使没有提供任何音频文件,系统也能正常工作——所有音频将自动回退到 en-US。但提示音将使用英文语音,可能影响部分用户的体验。
Sources: sr-RS 仅 JSON 示例
阅读建议:理解多语言系统后,建议继续阅读 系统架构全景:从麦克风到云端大模型的完整数据流,了解这些字符串和音频资源在整体数据流中的消费位置;也可阅读 显示系统架构:OLED / LCD / LVGL 三层次抽象 了解字体和表情资源如何在显示屏上渲染。
核心架构¶
系统架构全景:从麦克风到云端大模型的完整数据流¶
本文档以「一条音频数据的旅程」为线索,系统性地拆解小智 AI 聊天机器人从物理麦克风采集到云端大模型响应的完整数据流。你将理解 Application 主控如何通过事件驱动循环编排各子系统、三任务音频管线如何实现高效的 Opus 编解码、双通道协议层如何在 WebSocket 与 MQTT+UDP 之间灵活切换,以及设备状态机如何确保 11 种状态的合法转换。我们采用「自顶向下、逐层深入」的叙述方式,从中控枢纽出发,逐步展开每一层的内部机制。
Sources: application.h
一、架构总览:分层协作与数据流向¶
小智的系统架构遵循「感知—处理—传输—响应」的闭环模型。从垂直视角看,整个系统可划分为五层:硬件抽象层(Board) 提供跨 70+ 开发板的统一接口;音频管线层(AudioService) 负责音频采集、前端处理与 Opus 编解码;协议通信层(Protocol) 通过 WebSocket 或 MQTT+UDP 与云端交互;应用控制层(Application) 以事件驱动循环统筹全局;设备管理层(MCP/OTA/StateMachine) 处理设备控制、固件升级与状态迁移。
从数据流视角看,系统同时维护上行链路(麦克风 → 云端大模型)和下行链路(云端大模型 → 扬声器)两条并发的实时流。上行链路将 16kHz/16bit 单声道 PCM 音频压缩为 Opus 编码帧后发送至云端,下行链路则接收云端返回的 Opus 音频流并解码播放。这两条链路共享同一个 OpusCodecTask 线程但使用独立的队列,由 AudioService 统一调度。
Sources: audio_service.h
graph TB
subgraph 硬件层
MIC[麦克风]
SPK[扬声器]
BTN[物理按键]
LED[LED 指示灯]
LCD[显示屏]
end
subgraph Board抽象层
AC[AudioCodec<br/>I2S 驱动]
NET[NetworkInterface<br/>WiFi/4G/RNDIS]
DISP[Display]
LED_C[Led]
end
subgraph 音频管线
AW[AudioProcessor<br/>AEC/VAD/NS]
WW[WakeWord<br/>唤醒词检测]
ENC[Opus Encoder]
DEC[Opus Decoder]
end
subgraph 应用控制
APP[Application<br/>事件驱动主循环]
SM[DeviceStateMachine<br/>状态机]
MCP[McpServer<br/>设备控制]
end
subgraph 协议层
WS[WebSocket Protocol]
MQTT[MQTT + UDP Protocol]
end
subgraph 云端
ASR[语音识别 ASR]
LLM[大语言模型]
TTS[语音合成 TTS]
end
MIC -->|I2S DMA| AC
AC -->|PCM 16kHz| AW
AW -->|处理后 PCM| ENC
ENC -->|Opus Packet| APP
APP -->|Audio Send Queue| WS
APP -->|Audio Send Queue| MQTT
WS -->|TLS| ASR
MQTT -->|UDP| ASR
ASR --> LLM
LLM --> TTS
TTS -->|Opus Packet| WS
TTS -->|Opus Packet| MQTT
WS --> APP
MQTT --> APP
APP --> DEC
DEC -->|PCM| AC
AC -->|I2S DMA| SPK
WW -->|唤醒事件| APP
BTN -->|GPIO 中断| APP
APP --> SM
APP --> MCP
SM -.->|状态变更| LED
SM -.->|状态变更| LCD
APP -.->|调度通知| LCD
style APP fill:#ff9800,color:#fff
style SM fill:#4caf50,color:#fff
style ENC fill:#2196f3,color:#fff
style DEC fill:#2196f3,color:#fff
阅读指引:上图展示了整个系统的宏观数据流。建议你带着这张图阅读后续章节——每深入一层时,回顾图中的对应模块以保持全局视角。对音频管线感兴趣的读者可跳转至 AudioService 核心管线,通信协议细节见 通信协议总览。
二、中控枢纽:Application 的事件驱动主循环¶
Application 是整个系统的「大脑」,采用单例模式确保全局唯一的控制实例。其运行生命周期分为三个阶段:构建(Application())→ 初始化(Initialize())→ 事件循环(Run())。app_main() 函数仅做三件事——初始化 NVS Flash、获取 Application 单例、调用 Initialize() 和 Run()——之后便进入永不返回的事件循环。
Sources: main.cc
2.1 事件位掩码系统¶
Application 使用 FreeRTOS 的 EventGroup 实现高效的事件驱动模型。系统定义了 13 个事件位,通过 xEventGroupWaitBits() 以 portMAX_DELAY 阻塞等待任意事件触发:
| 事件宏 | 触发源 | 含义 |
|---|---|---|
MAIN_EVENT_SCHEDULE |
Application::Schedule() |
主线程延迟执行队列中的回调任务 |
MAIN_EVENT_SEND_AUDIO |
AudioService 回调 | 上行编码队列有数据就绪,可发送至网络 |
MAIN_EVENT_WAKE_WORD_DETECTED |
WakeWord 检测 | 本地唤醒词被触发 |
MAIN_EVENT_VAD_CHANGE |
AudioProcessor VAD | 语音活动检测状态变化(说话/静音) |
MAIN_EVENT_ERROR |
Protocol 网络错误回调 | 网络通信异常 |
MAIN_EVENT_ACTIVATION_DONE |
ActivationTask 完成 | 设备激活流程完毕(版本检查+协议初始化) |
MAIN_EVENT_CLOCK_TICK |
硬件定时器(1Hz) | 每秒触发,用于状态栏更新和堆统计 |
MAIN_EVENT_NETWORK_CONNECTED |
Board 网络回调 | 网络连接建立成功 |
MAIN_EVENT_NETWORK_DISCONNECTED |
Board 网络回调 | 网络连接断开 |
MAIN_EVENT_TOGGLE_CHAT |
按键或 MCP 工具 | 切换聊天状态(开始/停止对话) |
MAIN_EVENT_START_LISTENING |
按键或 MCP 工具 | 显式开始录音 |
MAIN_EVENT_STOP_LISTENING |
按键或 MCP 工具 | 显式停止录音 |
MAIN_EVENT_STATE_CHANGED |
DeviceStateMachine | 设备状态发生迁移 |
Sources: application.h
2.2 初始化流程¶
Initialize() 按严格的依赖顺序启动各子系统:
- 状态设置:进入
kDeviceStateStarting
- 显示初始化:
display->SetupUI()并显示设备信息
- 音频启动:获取 AudioCodec →
audio_service_.Initialize(codec)→ 注册回调 →audio_service_.Start()
- 状态监听:向状态机注册变更监听器(触发
MAIN_EVENT_STATE_CHANGED)
- 定时器启动:每秒触发
MAIN_EVENT_CLOCK_TICK
- MCP 工具注册:添加通用工具和用户专属工具
- 网络事件回调:注册完整的
NetworkEventCallback(覆盖 Wi-Fi / 4G 的扫描、连接、断开、错误等 12 种事件)
- 网络启动:
board.StartNetwork()异步启动网络连接
Sources: application.cc
设计洞察:网络回调中特定事件会直接触发状态转换或事件位。例如
NetworkEvent::Connected同时设置MAIN_EVENT_NETWORK_CONNECTED位,而 Modem 错误事件则会直接调用Alert()在屏幕上显示错误。这种设计将网络层的变化「翻译」为应用层可消费的统一事件。
2.3 Schedule 机制:线程安全的延迟执行¶
系统大量使用 Application::Schedule() 将回调推迟到主线程执行。该机制通过互斥锁保护的 std::deque<std::function<void()>> 队列实现——任何线程都可以调用 Schedule() 安全地将任务入队,然后设置 MAIN_EVENT_SCHEDULE 事件位。主循环检测到该事件后,在持有锁的情况下将整个队列交换到本地,解锁后逐一执行。
这一模式解决了 ESP32 多线程环境中的典型问题:FreeRTOS 任务、中断服务例程、网络回调可能在不同优先级和核心上运行,而 UI 更新(Display、LED)必须在主线程中执行。
Sources: application.cc, application.h
三、设备状态机:11 种状态的合法迁移规则¶
DeviceStateMachine 是保证系统行为可预测的核心约束层。它使用 std::atomic<DeviceState> 存储当前状态,所有状态迁移必须通过 TransitionTo() 方法,该方法在执行前验证转换的合法性。
Sources: device_state_machine.h
3.1 状态定义与语义¶
| 状态 | 值 | 语义 |
|---|---|---|
kDeviceStateUnknown |
0 | 初始未知状态(程序启动前) |
kDeviceStateStarting |
1 | 系统正在初始化各子系统 |
kDeviceStateWifiConfiguring |
2 | Wi-Fi 配网模式 |
kDeviceStateIdle |
3 | 空闲待机(等待唤醒或按键) |
kDeviceStateConnecting |
4 | 正在连接云端音频通道 |
kDeviceStateListening |
5 | 正在录音并上传音频 |
kDeviceStateSpeaking |
6 | 正在播放云端返回的语音 |
kDeviceStateUpgrading |
7 | 正在下载并升级固件 |
kDeviceStateActivating |
8 | 正在进行设备激活(版本检查+协议初始化) |
kDeviceStateAudioTesting |
9 | 音频自测模式(配网时测试麦克风) |
Sources: device_state.h
3.2 状态迁移图¶
stateDiagram-v2
[*] --> Unknown
Unknown --> Starting
Starting --> WifiConfiguring
Starting --> Activating
WifiConfiguring --> Activating
WifiConfiguring --> AudioTesting
AudioTesting --> WifiConfiguring
Activating --> Upgrading
Activating --> Idle
Activating --> WifiConfiguring
Upgrading --> Idle
Upgrading --> Activating
Idle --> Connecting
Idle --> Listening
Idle --> Speaking
Idle --> Activating
Idle --> Upgrading
Idle --> WifiConfiguring
Connecting --> Idle
Connecting --> Listening
Listening --> Speaking
Listening --> Idle
Speaking --> Listening
Speaking --> Idle
FatalError --> [*]
Sources: device_state_machine.cc
3.3 状态变更的级联效应¶
当 TransitionTo() 成功执行后,它会通过观察者模式通知所有注册的监听器。Application 在初始化时注册了一个全局监听器,该监听器简单地设置 MAIN_EVENT_STATE_CHANGED 事件位。主循环中的 HandleStateChangedEvent() 才是状态变更的真正执行者——它根据新状态配置音频管线、更新 UI、控制 LED:
| 新状态 | 音频管线配置 | 显示配置 |
|---|---|---|
Idle |
关闭语音处理,开启唤醒词检测 | 状态栏「待机」,表情「neutral」 |
Connecting |
— | 状态栏「连接中」 |
Listening |
开启语音处理,按配置开启/关闭唤醒词 | 状态栏「聆听中」,可选播放提示音 |
Speaking |
关闭语音处理(非实时模式),仅 AFE 唤醒词保持 | 状态栏「说话中」,重置解码器 |
WifiConfiguring |
关闭语音处理和唤醒词 | — |
Sources: application.cc
四、音频管线:三任务模型与数据队列¶
AudioService 是整个系统性能最关键的子模块。它采用三任务并行模型——AudioInputTask、AudioOutputTask、OpusCodecTask——每个任务运行在独立的 FreeRTOS 任务上下文中,通过互斥锁和条件变量协调的队列系统交换数据。
4.1 双数据流设计¶
系统维护两条并发的音频处理路径:
上行路径(MIC → Cloud):
AudioInputTask → [10ms PCM Frames] → AudioProcessor/WakeWord → [60ms PCM Frames]
→ EncodeQueue → OpusCodecTask → [Opus Packets] → SendQueue
→ MAIN_EVENT_SEND_AUDIO → Application → Protocol → Cloud
下行路径(Cloud → Speaker):
Cloud → Protocol → Application (on_incoming_audio) → DecodeQueue
→ OpusCodecTask → [PCM Frames] → PlaybackQueue → AudioOutputTask → Codec → Speaker
Sources: audio_service.h
4.2 任务分工与队列约束¶
| 任务 | 栈大小 | 优先级 | 核心 | 职责 |
|---|---|---|---|---|
audio_input |
6KB | 8 | Core 0 | 以 10ms 间隔从 AudioCodec 读取 PCM,分发给 WakeWord 和 AudioProcessor;也负责 AudioTesting 模式的 60ms 帧采集 |
audio_output |
4KB | 4 | 任意 | 从 PlaybackQueue 取出 PCM 帧,送入 AudioCodec 播放;记录时间戳用于 Server AEC |
opus_codec |
24KB | 2 | 任意 | 从 EncodeQueue 取出 PCM 编码为 Opus → SendQueue;从 DecodeQueue 取出 Opus 解码为 PCM → PlaybackQueue |
这三个任务通过以下队列衔接(均为 std::deque<std::unique_ptr<T>>):
| 队列 | 最大容量 | 数据单元 | 流向 |
|---|---|---|---|
audio_encode_queue_ |
2 | AudioTask(含 PCM) |
Input → Codec |
audio_send_queue_ |
40(2400ms/60ms) | AudioStreamPacket(含 Opus) |
Codec → Application → Network |
audio_decode_queue_ |
40(2400ms/60ms) | AudioStreamPacket(含 Opus) |
Network → Application → Codec |
audio_playback_queue_ |
2 | AudioTask(含 PCM) |
Codec → Output |
Sources: audio_service.h
4.3 编解码参数配置¶
Opus 编码器使用以下固定配置:
- 采样率:16kHz(MIC 输入若不为 16kHz 则通过硬件重采样器转换)
- 帧长:60ms(即每帧 960 个采样点)
- 比特率:自动(
ESP_OPUS_BITRATE_AUTO)
- 声道:单声道
- DTX:开启(静音时不编码以降低带宽)
- VBR:开启(可变比特率以平衡质量与带宽)
- FEC:关闭(不进行前向纠错)
解码器参数则根据服务器 Hello 消息中声明的 sample_rate 和 frame_duration 动态调整。若服务器输出采样率与 Codec 输出采样率不一致,系统会自动创建输出重采样器。
Sources: audio_service.h, audio_service.cc
4.4 电源感知的音频管理¶
AudioService 包含一个 1 秒周期定时器 audio_power_timer_,持续跟踪最后输入/输出时间。若超过 15 秒(AUDIO_POWER_TIMEOUT_MS)无音频活动,系统会自动关闭 AudioCodec 的输入和输出通道以降低功耗。当再次需要音频时,ReadAudioData() 或 AudioOutputTask() 会自动重新启用对应通道。
Sources: audio_service.h, audio_service.cc
五、音频前端处理:AEC、VAD 与噪声抑制¶
AudioProcessor 是一个抽象接口,定义了音频前端的标准行为:初始化、喂数据、启停、输出回调、VAD 回调、获取喂入帧大小、设备端 AEC 开关。其核心实现 AfeAudioProcessor 封装了 Espressif 的 AFE(Audio Front-End) 库,在单个组件中集成了回声消除(AEC)、语音活动检测(VAD)和噪声抑制(NS)。
Sources: audio_processor.h, afe_audio_processor.h
5.1 AEC 的三种工作模式¶
系统通过 AecMode 枚举定义三种回声消除策略,由 sdkconfig 中的 CONFIG_USE_DEVICE_AEC 和 CONFIG_USE_SERVER_AEC 编译选项控制(二者互斥):
| 模式 | 配置宏 | 工作方式 | 优缺点 |
|---|---|---|---|
kAecOff |
两者均未定义 | 无回声消除;ListeningMode 默认 AutoStop | 简单,但无法实现全双工打断 |
kAecOnDeviceSide |
CONFIG_USE_DEVICE_AEC |
AFE 在本地执行 AEC,消除扬声器回声 | 占用设备算力,但延迟更低 |
kAecOnServerSide |
CONFIG_USE_SERVER_AEC |
设备将播放时间戳随音频帧上传,服务器端执行 AEC | 节省设备算力,但对网络抖动敏感 |
当设备端 AEC 开启时,AfeAudioProcessor 需要 AudioCodec 支持参考信号输入(input_reference_),即同时采集麦克风信号和扬声器播放信号,由 AFE 算法进行回声消除。
Sources: application.h, application.cc
5.2 VAD 与状态联动¶
AFE 内置的 VAD 模块实时判断当前是否为语音活动状态。当 VAD 状态变化时,AudioProcessor 触发 on_vad_change 回调 → AudioService 更新 voice_detected_ 并转发给 Application → Application 设置 MAIN_EVENT_VAD_CHANGE → 主循环在 Listening 状态下更新 LED 指示。
在 kListeningModeAutoStop 模式下,VAD 检测到的静音会触发服务器自动停止收音;而 kListeningModeManualStop 模式则完全由用户按键控制,VAD 仅用于视觉反馈。
Sources: audio_service.cc, application.cc
六、协议层:WebSocket 与 MQTT+UDP 双通道¶
Protocol 抽象基类定义了一套统一的接口(Start、OpenAudioChannel、CloseAudioChannel、SendAudio、SendText 等),并管理六个回调类型的注册。两个具体实现——WebsocketProtocol 和 MqttProtocol——共享相同的上层语义但使用截然不同的传输方式。
Sources: protocol.h
6.1 协议选择逻辑¶
InitializeProtocol() 根据 OTA 配置决定使用哪种协议:
if (ota_->HasMqttConfig()) {
protocol_ = std::make_unique<MqttProtocol>();
} else if (ota_->HasWebsocketConfig()) {
protocol_ = std::make_unique<WebsocketProtocol>();
} else {
protocol_ = std::make_unique<MqttProtocol>(); // 默认使用 MQTT
}
Sources: application.cc
6.2 WebSocket 协议¶
WebSocket 协议支持三个二进制版本(v1/v2/v3),通过 Protocol-Version 头部与服务器协商:
- v1:原始 Opus 字节流,无额外头部
- v2:
BinaryProtocol2(12 字节头部:version + type + reserved + timestamp + payload_size)
- v3:
BinaryProtocol3(4 字节头部:type + reserved + payload_size)
连接流程:创建 WebSocket → 设置 HTTP 头(Authorization、Protocol-Version、Device-Id、Client-Id)→ 注册 OnData/OnDisconnected 回调 → 发起连接 → 发送 Hello 消息 → 等待服务器 Hello 回应(10 秒超时)→ 触发 on_audio_channel_opened_ 回调。
Sources: websocket_protocol.cc, protocol.h
6.3 MQTT+UDP 混合协议¶
MQTT 协议采用控制与音频分离的架构:MQTT 通道承载 JSON 控制消息(Hello、Goodbye、StartListening、StopListening 等),UDP 通道承载实时 Opus 音频流。UDP 音频传输使用 AES 加密,通过 Server Hello 消息中下发的密钥和 Nonce 进行加解密。
该设计解决了 MQTT 协议在低延迟音频传输上的固有限制——MQTT 基于 TCP,存在头部开销和重传延迟,而 UDP 能提供更低的端到端延迟,适合实时语音交互场景。
Sources: mqtt_protocol.h, mqtt-udp.md
6.4 Protocol 核心回调¶
| 回调 | 触发时机 | Application 处理 |
|---|---|---|
OnConnected |
传输层连接建立 | 关闭 Alert 弹窗 |
OnNetworkError |
通信异常 | 存储错误消息 → 设置 MAIN_EVENT_ERROR → 降至 Idle 并弹窗 |
OnIncomingAudio |
收到二进制音频帧 | 仅在 Speaking 状态下推入 AudioService 解码队列 |
OnIncomingJson |
收到 JSON 消息 | 解析 type 字段,分发到 tts/stt/llm/mcp/system/alert 处理器 |
OnAudioChannelOpened |
音频通道就绪 | 切换至 PERFORMANCE 电源模式 |
OnAudioChannelClosed |
音频通道关闭 | 切换至 LOW_POWER 电源模式 → Schedule 降至 Idle |
Sources: application.cc
七、完整的会话生命周期:从唤醒到结束¶
下面通过一次典型语音对话的完整时序,串联所有子系统:
sequenceDiagram
participant User as 用户
participant Mic as 麦克风
participant WW as WakeWord
participant APP as Application
participant SM as StateMachine
participant PROC as AudioProcessor
participant ENC as Opus Encoder
participant WS as WebsocketProtocol
participant Cloud as 云端服务
participant DEC as Opus Decoder
participant Spk as 扬声器
Note over APP: State: Idle
Note over WW: 持续监听唤醒词
User->>Mic: 说出「小智小智」
Mic->>WW: PCM (10ms帧)
WW->>APP: MAIN_EVENT_WAKE_WORD_DETECTED
APP->>SM: TransitionTo(Connecting)
SM-->>APP: MAIN_EVENT_STATE_CHANGED
APP->>WS: OpenAudioChannel()
WS->>Cloud: WebSocket 连接 + Hello
Cloud-->>WS: Server Hello
WS-->>APP: on_audio_channel_opened_
APP->>SM: TransitionTo(Listening)
SM-->>APP: MAIN_EVENT_STATE_CHANGED
APP->>PROC: EnableVoiceProcessing(true)
APP->>WS: SendStartListening(kListeningModeAutoStop)
User->>Mic: 说出问题
Mic->>PROC: PCM (10ms帧)
PROC->>PROC: AEC + VAD + NS
PROC->>ENC: PCM (60ms帧)
ENC->>APP: MAIN_EVENT_SEND_AUDIO
APP->>WS: SendAudio(Opus Packet)
WS->>Cloud: Opus 音频流
Cloud->>Cloud: ASR → LLM → TTS
Cloud-->>WS: tts.start JSON
WS-->>APP: on_incoming_json
APP->>SM: TransitionTo(Speaking)
Cloud-->>WS: Opus 音频流
WS-->>APP: on_incoming_audio
APP->>DEC: PushPacketToDecodeQueue
DEC->>Spk: PCM 播放
Cloud-->>WS: tts.stop JSON
WS-->>APP: on_incoming_json
APP->>SM: TransitionTo(Listening)
Note over User: 用户停止说话
PROC->>APP: VAD: silent
Cloud-->>WS: stt.stop → CloseAudioChannel
WS-->>APP: on_audio_channel_closed_
APP->>SM: TransitionTo(Idle)
Note over WW: 恢复唤醒词监听
7.1 关键时序说明¶
唤醒阶段:当 WakeWord 在 Idle 状态下检测到唤醒词时,HandleWakeWordDetectedEvent() 首先通过 EncodeWakeWord() 将触发音频编码为 Opus(用于服务器端二次验证),然后判断是否需要新建音频通道。若需要(首次或通道已关闭),先转换至 Connecting 状态,再通过 Schedule 推迟执行 ContinueWakeWordInvoke()——因为 OpenAudioChannel() 可能阻塞约 1 秒,不应在中断上下文中执行。
Sources: application.cc
来回对话:在 Listening 与 Speaking 之间循环是系统运行的主要模式。每次服务器返回 tts.stop 时,若当前模式为 AutoStop 则自动切回 Listening;若为 ManualStop 则降至 Idle 等待用户再次按键。Speaking 状态下若检测到新的唤醒词,系统会通过 AbortSpeaking(kAbortReasonWakeWordDetected) 中止当前播放并清空发送队列中的残留数据,然后立即开始新一轮聆听。
Sources: application.cc
八、辅助子系统一览¶
8.1 McpServer:设备端 JSON-RPC 2.0¶
McpServer 实现了设备端的 MCP(Model Context Protocol)服务,允许云端大模型通过结构化的 JSON-RPC 2.0 消息控制设备硬件(LED、显示屏、GPIO 等)。它使用 PropertyList 进行参数校验,支持布尔、整数、字符串三种参数类型及范围限制。详见 MCP 协议交互流程。
Sources: mcp_server.h
8.2 OTA:固件升级与设备激活¶
Ota 类负责三个关键任务:固件版本检查(CheckVersion)、设备激活(Activate)和固件升级(Upgrade)。激活流程通过后台任务 ActivationTask 执行,在激活过程中设备处于 kDeviceStateActivating 状态。若检测到新固件,UpgradeFirmware() 会先关闭音频通道,停止音频服务,然后以 PERFORMANCE 电源模式全速下载固件,下载完成后重启。
Sources: application.cc, ota.h
8.3 Board 抽象层:统一管理 70+ 开发板¶
Board 采用工厂模式通过 DECLARE_BOARD 宏注入具体板卡类,对外暴露统一的 GetAudioCodec()、GetDisplay()、GetNetwork()、GetLed() 等接口。网络连接支持 Wi-Fi、ML307 4G 模组和 RNDIS(USB 网络共享)三种模式,通过 NetworkEventCallback 将底层网络事件统一转化为应用层事件。详见 Board 抽象层设计。
Sources: board.h
8.4 Display 与 LED:用户反馈层¶
Display 抽象基类提供 SetStatus()、SetEmotion()、SetChatMessage() 等语义化接口,背后支持 OLED(SSD1306 等)、LCD(SPI 接口)和 LVGL(高级图形框架)三种实现。LED 系统同样抽象为 Led 基类,支持单灯(GPIO)、灯环(WS2812)和 GPIO 控制三种形态,通过 OnStateChanged() 根据当前设备状态呈现不同的灯光模式。详见 显示系统架构 和 LED 指示灯系统。
Sources: display.h
九、关键设计模式与架构决策¶
| 模式 | 应用位置 | 收益 |
|---|---|---|
| 单例模式 | Application、Board、McpServer | 全局唯一的控制点,避免多实例导致的资源竞争 |
| 观察者模式 | DeviceStateMachine 的 StateChangeListener | 解耦状态变更与业务响应,各子系统独立注册监听器 |
| 策略模式 | Protocol(WebSocket vs MQTT)、AudioProcessor(AFE vs NoOp)、WakeWord(AFE vs ESP vs Custom) | 编译时或运行时切换实现,无需修改上层逻辑 |
| 工厂模式 | Board(DECLARE_BOARD 宏→create_board()) |
编译时选择具体板卡,其余代码通过基类接口编程 |
| 生产者-消费者 | AudioService 的三任务+四队列 | 解耦音频采集、编解码、播放的速率差异,以队列缓冲平滑处理 |
| 命令模式 | Application::Schedule() 延迟执行队列 | 线程安全的跨任务任务调度,UI 操作始终在主线程执行 |
十、阅读路线建议¶
完成本文档后,建议按以下顺序深入各子系统:
- Application 主控与事件驱动循环:深入理解事件处理函数的内部逻辑和 Schedule 机制的更多细节
- 设备状态机:状态定义与合法转换规则:了解状态机的完整迁移规则和边界条件
- AudioService 核心管线:三任务模型与数据队列:深入音频管线的内部实现和性能优化策略
- 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计:理解两种协议的设计动机和详细消息格式
- Board 抽象层设计:统一管理 70+ 开发板的秘诀:学习硬件抽象层的架构设计方法
Application 主控与事件驱动循环¶
Application 是整个小智 AI 聊天机器人固件的中央协调器(Central Coordinator),它以单例模式存在,在 ESP32 上构建了一套基于 FreeRTOS 事件组(Event Groups)的事件驱动架构。本节将深入解析 Application 如何将设备状态机、音频管线、通信协议和用户交互编织成一个协调运行的整体。
核心架构:单例 + 事件循环¶
在 app_main() 入口函数中,整个系统的启动路径极其简洁——获取 Application 单例,调用 Initialize() 进行资源准备,然后进入 Run() 永不返回的事件循环。这种设计将系统的复杂性完全封装在 Application 内部,对外暴露为一个"点火即走"的黑盒。
graph TD
A["app_main()"] --> B["Application::GetInstance()"]
B --> C["Initialize()"]
C --> D["Run() — 无限事件循环"]
subgraph "Initialize 阶段"
C1["Display SetupUI"] --> C2["AudioService 初始化"]
C2 --> C3["注册音频回调到事件组"]
C3 --> C4["注册状态变更监听器"]
C4 --> C5["启动 1Hz 时钟定时器"]
C5 --> C6["MCP 工具注册"]
C6 --> C7["注册网络事件回调"]
C7 --> C8["异步启动网络连接"]
end
subgraph "Run 事件循环"
D1["xEventGroupWaitBits<br/>阻塞等待任意事件"] --> D2["按优先级依次处理事件"]
D2 --> D1
end
Application 类采用 Meyers Singleton 模式,通过删除拷贝构造和赋值运算符确保全局唯一实例。GetInstance() 返回静态局部变量的引用,天然线程安全。
Sources: main.cc, application.h
事件系统:FreeRTOS Event Groups 驱动的消息总线¶
Application 定义了一套基于 FreeRTOS Event Groups 的事件机制。Event Groups 是 FreeRTOS 提供的轻量级同步原语——每个事件位是一个独立的标志,多个事件可以同时被设置并在一次等待中全部获取。这比传统消息队列更适合"一对多通知"场景:音频回调、网络回调、MCP 回调等来自不同任务的生产者,都可以通过 xEventGroupSetBits() 向主循环发信号。
13 个事件位定义与语义¶
| 事件宏 | 位掩码 | 触发源 | 语义 |
|---|---|---|---|
MAIN_EVENT_SCHEDULE |
1 << 0 |
任何任务调用 Schedule() |
有延迟任务需要在主循环中执行 |
MAIN_EVENT_SEND_AUDIO |
1 << 1 |
AudioService 的发送队列就绪回调 | 编码后的音频数据可发送到服务器 |
MAIN_EVENT_WAKE_WORD_DETECTED |
1 << 2 |
AudioService 的唤醒词检测回调 | 检测到预设唤醒词 |
MAIN_EVENT_VAD_CHANGE |
1 << 3 |
AudioService 的 VAD 状态变更回调 | 语音活动检测状态发生变化 |
MAIN_EVENT_ERROR |
1 << 4 |
Protocol 的网络错误回调 | 通信层发生错误需处理 |
MAIN_EVENT_ACTIVATION_DONE |
1 << 5 |
ActivationTask 完成 | 设备激活流程执行完毕 |
MAIN_EVENT_CLOCK_TICK |
1 << 6 |
esp_timer 1Hz 周期定时器 |
每秒触发一次的状态栏更新 |
MAIN_EVENT_NETWORK_CONNECTED |
1 << 7 |
Board 网络事件回调 | 网络连接建立成功 |
MAIN_EVENT_NETWORK_DISCONNECTED |
1 << 8 |
Board 网络事件回调 | 网络连接断开 |
MAIN_EVENT_TOGGLE_CHAT |
1 << 9 |
MCP 工具或物理按键 | 用户触发对话切换 |
MAIN_EVENT_START_LISTENING |
1 << 10 |
MCP 工具或外部调用 | 用户要求开始聆听 |
MAIN_EVENT_STOP_LISTENING |
1 << 11 |
MCP 工具或外部调用 | 用户要求停止聆听 |
MAIN_EVENT_STATE_CHANGED |
1 << 12 |
DeviceStateMachine 监听器 | 设备状态发生转移 |
事件优先级隐含在 Run() 方法的 if 分支顺序中:ERROR 优先处理,然后是网络连接/断开、激活完成、状态变更、用户交互事件,最后是音频发送和时钟滴答。
Sources: application.h, application.cc
事件循环机制详解¶
Run() 方法首先将主任务优先级提升至 10(高于大多数 FreeRTOS 任务),然后进入一个无限 while(true) 循环。每次迭代通过 xEventGroupWaitBits 阻塞等待任意事件位的置位——参数 pdTRUE 表示获取后自动清除,pdFALSE 表示不等待所有位,portMAX_DELAY 表示无限阻塞。
while (true) {
auto bits = xEventGroupWaitBits(event_group_, ALL_EVENTS, pdTRUE, pdFALSE, portMAX_DELAY);
// 按顺序处理各事件...
}
这种设计的精妙之处在于:多个事件可以在单次迭代中批量处理。例如,当网络连接成功时,MAIN_EVENT_NETWORK_CONNECTED 和 MAIN_EVENT_STATE_CHANGED 可能同时被置位,主循环在一次等待返回后依次处理两者,避免了不必要的上下文切换。
Sources: application.cc
Schedule 机制:线程安全的延迟任务调度¶
Schedule() 是 Application 提供的最重要的线程安全工具之一。它将一个可调用对象(std::function<void()>)放入受互斥锁保护的 std::deque 任务队列,然后设置 MAIN_EVENT_SCHEDULE 事件位。当主循环处理 SCHEDULE 事件时,会一次性取出所有积压任务并在主任务上下文中执行。
sequenceDiagram
participant Caller as 调用者(任意任务)
participant Sched as Schedule()
participant Main as Main Loop
Caller->>Sched: Schedule(callback)
Sched->>Sched: lock(mutex_)
Sched->>Sched: main_tasks_.push_back(callback)
Sched->>Sched: unlock(mutex_)
Sched->>Sched: xEventGroupSetBits(SCHEDULE)
Main->>Main: xEventGroupWaitBits → SCHEDULE
Main->>Main: lock(mutex_)
Main->>Main: tasks = std::move(main_tasks_)
Main->>Main: unlock(mutex_)
loop 每个任务
Main->>Main: task()
end
这个机制解决了 FreeRTOS 多任务编程中的经典问题:外设驱动、显示模块、音频编解码器等通常不是线程安全的,必须在同一任务上下文中操作。例如,Protocol 的 OnIncomingJson 回调运行在协议栈任务中,但它通过 Schedule() 将 UI 更新操作转发到主任务,从而避免了对 Display 的并发访问。
Sources: application.cc, application.cc
设备状态机集成¶
Application 内嵌了一个 DeviceStateMachine 实例,提供带校验的状态转移。所有状态变更都通过 SetDeviceState() → TransitionTo() 路径进行,如果转移非法(例如从 kDeviceStateFatalError 逃逸),状态机会打印警告日志并拒绝转移。
状态转移图¶
stateDiagram-v2
[*] --> Unknown
Unknown --> Starting
Starting --> WifiConfiguring
Starting --> Activating
WifiConfiguring --> Activating
WifiConfiguring --> AudioTesting
AudioTesting --> WifiConfiguring
Activating --> Idle
Activating --> Upgrading
Activating --> WifiConfiguring
Upgrading --> Idle
Upgrading --> Activating
Idle --> Connecting
Idle --> Listening
Idle --> Speaking
Idle --> Activating
Idle --> Upgrading
Idle --> WifiConfiguring
Connecting --> Idle
Connecting --> Listening
Listening --> Speaking
Listening --> Idle
Speaking --> Listening
Speaking --> Idle
FatalError: 不可逃逸
Application 在 Initialize() 中注册了一个状态变更监听器:任何状态转移都会触发 MAIN_EVENT_STATE_CHANGED 事件。HandleStateChangedEvent() 是状态变更的集中处理枢纽——根据新状态配置音频管线(启用/禁用语音处理、唤醒词检测)、刷新显示(状态栏、表情)、控制 LED 指示灯。
Sources: device_state_machine.cc, application.cc, application.cc
关键状态的行为配置¶
| 状态 | 音频处理 | 唤醒词检测 | 显示状态 | LED 行为 |
|---|---|---|---|---|
| Idle | 禁用 | 启用 | STANDBY | OnStateChanged |
| Connecting | — | — | CONNECTING | OnStateChanged |
| Listening | 启用 | 默认关闭(可配置) | LISTENING | OnStateChanged |
| Speaking | 非实时模式禁用 | 仅 AFE 唤醒词 | SPEAKING | OnStateChanged |
| WifiConfiguring | 禁用 | 禁用 | — | — |
在 Listening 状态下,如果 play_popup_on_listening_ 标志被设置(通常是唤醒词打断正在播放的场景),会播放一个提示音。在 Speaking 状态下,只有基于 AFE(Audio Front-End)的硬件唤醒词才能继续工作,软件唤醒词检测会被关闭以节省 CPU 资源。
Sources: application.cc
1Hz 时钟定时器:周期性地基¶
构造函数中创建了一个基于 esp_timer 的周期定时器,以 1 秒为间隔设置 MAIN_EVENT_CLOCK_TICK。主循环处理该事件时:
- 递增
clock_ticks_计数器
- 调用
display->UpdateStatusBar()刷新状态栏(时间、电量、网络信号等)
- 每 10 秒打印一次堆内存统计信息(通过
SystemInfo::PrintHeapStats())
状态变更时 clock_ticks_ 会被重置为零,这为某些需要"进入某状态后 N 秒执行"的逻辑提供了时间基准。
Sources: application.cc, application.cc
网络事件处理:连接生命周期的中枢¶
Application 在初始化时通过 Board::SetNetworkEventCallback() 注册了一个覆盖 12 种网络事件的回调。这个回调运行在 Board 层的网络任务上下文中,因此内部使用 xEventGroupSetBits 异步通知主循环,避免跨任务阻塞。
网络连接与激活流程¶
sequenceDiagram
participant Board as Board (网络任务)
participant App as Application (主循环)
participant OTA as Ota
participant Proto as Protocol
Board->>App: MAIN_EVENT_NETWORK_CONNECTED
App->>App: HandleNetworkConnectedEvent()
alt 状态为 Starting 或 WifiConfiguring
App->>App: SetDeviceState(Activating)
App->>App: xTaskCreate(ActivationTask)
Note over App: 后台任务执行激活
App->>OTA: CheckVersion()
App->>OTA: Activate()
App->>App: InitializeProtocol()
App->>App: xEventGroupSetBits(ACTIVATION_DONE)
end
App->>App: 更新状态栏
HandleNetworkConnectedEvent() 判断当前状态:如果设备处于 Starting 或 WifiConfiguring(尚未激活),则创建独立的 ActivationTask 任务异步执行 OTA 版本检查、激活码验证和协议初始化,避免阻塞主循环。如果设备已处于正常运行状态(Idle 等),只需刷新状态栏。
HandleNetworkDisconnectedEvent() 则负责清理:如果当前处于 Connecting、Listening 或 Speaking 状态,主动关闭音频通道,防止资源泄漏。
Sources: application.cc, application.cc, application.cc
协议层集成:策略模式的选择与回调绑定¶
InitializeProtocol() 是协议层与 Application 的黏合点。它根据 OTA 配置决定使用 MQTT 还是 WebSocket 协议,然后绑定六个关键回调:
| 回调 | 触发时机 | 主循环响应 |
|---|---|---|
OnConnected |
协议底层连接建立 | 调用 DismissAlert() 清除告警 |
OnNetworkError |
协议层发生错误 | 设置 MAIN_EVENT_ERROR,进入 Idle 并告警 |
OnIncomingAudio |
收到服务器音频包 | 推入 AudioService 解码队列 |
OnAudioChannelOpened |
音频通道打开 | 提升功耗等级,检查采样率匹配 |
OnAudioChannelClosed |
音频通道关闭 | 降低功耗等级,回到 Idle 状态 |
OnIncomingJson |
收到 JSON 控制消息 | 解析消息类型(tts/stt/llm/mcp/alert 等)并调度到主循环 |
OnIncomingJson 是最复杂的回调,它解析六种 JSON 消息类型:
- tts(文本转语音):控制 Speaking 状态的进入/退出,以及句首文本的显示
- stt(语音转文本):在屏幕上显示用户的语音识别结果
- llm(大语言模型):更新设备表情动画
- mcp(Model Context Protocol):转发到 McpServer 进行工具调用
- system:处理系统命令(目前仅支持 reboot)
- alert:来自服务器的告警通知
协议选择由 OTA 配置中的标志决定:HasMqttConfig() 优先于 HasWebsocketConfig(),若两者都未指定则默认使用 MQTT。
Sources: application.cc, protocol.h
用户交互事件:来自 MCP 和物理按键的统一入口¶
ToggleChatState()、StartListening()、StopListening() 三个方法构成了用户交互的异步事件入口。它们都不直接执行业务逻辑,而是通过 xEventGroupSetBits 向主循环发送事件。这样设计的好处是:无论触发源是 MCP JSON-RPC 调用、GPIO 按键中断、还是触摸屏回调,都汇入同一条处理路径。
Toggle Chat 的状态分发逻辑¶
HandleToggleChatEvent() 根据当前状态做出不同响应,体现了"一键多用"的交互哲学:
| 当前状态 | 响应行为 |
|---|---|
| Activating | 打断激活流程,回到 Idle |
| WifiConfiguring | 进入音频测试模式 |
| AudioTesting | 退出音频测试模式 |
| Idle(通道关闭) | 打开音频通道 → 进入 Listening |
| Idle(通道已开) | 直接进入 Listening |
| Speaking | 中止说话(AbortSpeaking) |
| Listening | 关闭音频通道 → 回到 Idle |
这种设计使单个物理按键可以在不同上下文中承担"激活/对话/打断/退出"等多种功能。
Sources: application.cc, application.cc
唤醒词检测事件:无缝打断与重连¶
HandleWakeWordDetectedEvent() 是系统响应能力和用户体验的核心。唤醒词检测发生在 AudioService 的音频输入任务中,通过回调设置事件位后由主循环处理:
- Idle 状态:编码唤醒词语音数据 → 打开音频通道 → 发送到服务器开始对话
- Speaking 状态:中止当前播放 → 清空发送队列避免残留数据 → 重新开始聆听
- Listening 状态:向服务器发送新的
start listening命令 → 重置解码器 → 播放提示音 → 重新启用唤醒词检测
- Activating 状态:打断激活流程回到 Idle
在 Speaking 状态被打断时,play_popup_on_listening_ 标志被设置为 true,等待状态实际转移到 Listening 后再播放提示音——这是因为 EnableVoiceProcessing() 内部的 ResetDecoder() 会清空播放队列,过早播放会导致提示音丢失。
Sources: application.cc
音频数据的发送循环¶
MAIN_EVENT_SEND_AUDIO 的处理逻辑简洁高效:当 AudioService 的编码队列有数据就绪时,回调设置该事件位,主循环在每次迭代中通过 while 循环清空整个发送队列:
if (bits & MAIN_EVENT_SEND_AUDIO) {
while (auto packet = audio_service_.PopPacketFromSendQueue()) {
if (protocol_ && !protocol_->SendAudio(std::move(packet))) {
break;
}
}
}
如果 SendAudio() 返回 false(通常因为发送缓冲区满),循环立即中断,等待下一次事件触发后继续发送。这种"尽力发送"策略配合 AudioService 的队列深度限制,确保了在弱网环境下不会无限堆积数据导致内存溢出。
Sources: application.cc
AEC 回声消除模式管理¶
Application 维护三种 AEC(Acoustic Echo Cancellation)模式:
| 模式 | 含义 | 默认聆听模式 |
|---|---|---|
kAecOff |
关闭回声消除 | AutoStop(VAD 自动检测静音后停止) |
kAecOnDeviceSide |
设备端 AEC | Realtime(持续实时传输) |
kAecOnServerSide |
服务器端 AEC(使用时间戳对齐) | Realtime |
AEC 模式通过 Kconfig 编译时配置决定,存储在构造函数中。SetAecMode() 允许运行时切换,切换时如果音频通道已打开会主动关闭,迫使下一次对话使用新的模式参数重建通道。
GetDefaultListeningMode() 是连接 AEC 模式和聆听模式的关键桥梁:AEC 关闭时使用 AutoStop(省电且自然),AEC 开启时使用 Realtime(充分利用回声消除能力实现全双工对话)。
Sources: application.h, application.cc, application.cc, application.cc
固件升级与系统重启¶
UpgradeFirmware() 展示了 Application 如何在保持用户知情的前提下执行高风险操作:先关闭音频通道释放网络资源,弹出升级提示并播放音效,进入 Upgrading 状态,停止音频服务以释放 CPU 和内存,然后通过 Ota::Upgrade() 下载并写入固件。升级过程中通过 Schedule() 将进度回调转发到主循环更新显示。
如果升级失败,系统能够优雅降级:重启音频服务、恢复功耗等级、显示错误提示并继续正常运行。如果升级成功,调用 Reboot() → esp_restart() 重启设备。
Reboot() 方法展示了清理顺序的重要性:先关闭音频通道,再重置协议对象,最后停止音频服务,在 1 秒延迟后调用 esp_restart()——这个延迟确保日志输出和显示更新能够完成。
Sources: application.cc, application.cc
与系统其他模块的关系¶
graph TD
App["Application<br/>单例·主循环"]
App -->|"拥有"| SM["DeviceStateMachine<br/>状态校验与转移"]
App -->|"拥有"| AS["AudioService<br/>三任务音频管线"]
App -->|"拥有"| Proto["Protocol (unique_ptr)<br/>MQTT 或 WebSocket"]
App -->|"拥有"| OTA["Ota (unique_ptr)<br/>版本检查与升级"]
App -->|"读取"| Board["Board (Singleton)<br/>Display / LED / 网络"]
App -->|"读取"| MCP["McpServer (Singleton)<br/>JSON-RPC 工具调用"]
App -->|"读取"| Assets["Assets (Singleton)<br/>资源分区管理"]
App -->|"读取"| Settings["Settings<br/>NVS 持久化存储"]
SM -->|"MAIN_EVENT_STATE_CHANGED"| App
AS -->|"SEND_AUDIO / WAKE_WORD / VAD"| App
Proto -->|"ERROR / IncomingAudio / IncomingJson"| App
Board -->|"NETWORK_CONNECTED / DISCONNECTED"| App
MCP -->|"TOGGLE_CHAT / START_LISTENING / STOP_LISTENING"| App
Application 通过拥有(owns)和引用(refers to)两种关系编织整个系统。设备状态机、音频服务和协议对象是其直接组成部分(unique_ptr 或值成员),而 Board、McpServer、Assets、Settings 则是通过单例访问的外部服务。这种"核心拥有 + 外部引用"的架构使得 Application 始终保持对关键生命周期的控制,同时避免了对全局状态的过度耦合。
Sources: application.h
阅读建议¶
- 理解状态机定义与转移规则,请继续阅读 设备状态机:状态定义与合法转换规则
- 深入音频管线的内部实现,请参阅 AudioService 核心管线:三任务模型与数据队列
- 了解协议层的双通道设计,请跳转 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计
- 查看 MCP 工具如何触发 Application 事件,请阅读 MCP 工具注册与调用:PropertyList 参数校验与回调机制
- 返回系统全景视图,可回顾 系统架构全景:从麦克风到云端大模型的完整数据流
设备状态机:状态定义与合法转换规则¶
设备状态机(DeviceStateMachine)是小智 AI 聊天机器人固件的核心控制中枢,负责管理设备从开机启动到语音对话全流程中 11 种状态的统一定义、合法性校验与变更通知。它通过严格的转换规则矩阵确保设备行为可预测,并通过观察者模式将状态变更广播给主控循环、显示屏、LED 指示灯和音频管线等组件。
Sources: device_state.h, device_state_machine.h
状态定义全览¶
DeviceState 枚举定义了设备生命周期的全部 11 种状态。下表按语义分组逐一说明每个状态的业务含义与触发条件。
| 枚举值 | 语义含义 | 典型触发条件 |
|---|---|---|
kDeviceStateUnknown |
未知初始状态 | 上电后、状态机对象构造完成但尚未初始化 |
kDeviceStateStarting |
系统启动中 | Application::Initialize() 开始执行,加载 UI、启动音频服务 |
kDeviceStateWifiConfiguring |
Wi-Fi / 配网模式 | 设备未配置或用户触发配网,等待网络连接 |
kDeviceStateAudioTesting |
音频测试模式 | 在 Wi-Fi 配置模式下用户按下按钮,测试麦克风和扬声器 |
kDeviceStateActivating |
设备激活中 | 网络就绪后,执行 OTA 版本检查、协议初始化等握手流程 |
kDeviceStateIdle |
空闲待机 | 激活完成或对话结束,等待用户交互(按键或语音唤醒) |
kDeviceStateConnecting |
音频通道连接中 | 用户触发开始对话,OpenAudioChannel() 正在建立 WebSocket/MQTT 音频通道 |
kDeviceStateListening |
聆听用户语音 | 音频通道建立成功,VAD 检测用户语音并发送到服务端 |
kDeviceStateSpeaking |
播放 TTS 语音 | 服务端返回 TTS 音频流,设备解码播放 |
kDeviceStateUpgrading |
固件/资源升级中 | 下载新版固件或 assets 资源包,阻塞式写入分区 |
kDeviceStateFatalError |
致命错误 | 发生不可恢复错误,状态机终止(无出边) |
关键观察:
kDeviceStateFatalError是一个终态(terminal state),一旦进入,任何后续的TransitionTo()调用都将返回false,设备无法自行恢复。
Sources: device_state.h, device_state_machine.cc
状态机架构:线程安全与观察者模式¶
DeviceStateMachine 类采用了「原子状态存储 + 互斥回调管理」的架构,在嵌入式多任务环境中保证了线程安全性。
类结构¶
classDiagram
class DeviceStateMachine {
-atomic~DeviceState~ current_state_
-vector~pair~int, StateCallback~~ listeners_
-int next_listener_id_
-mutex mutex_
+GetState() DeviceState
+TransitionTo(DeviceState) bool
+CanTransitionTo(DeviceState) bool
+AddStateChangeListener(StateCallback) int
+RemoveStateChangeListener(int)
+GetStateName(DeviceState) const char*
-IsValidTransition(DeviceState, DeviceState) bool
-NotifyStateChange(DeviceState, DeviceState)
}
note for DeviceStateMachine "current_state_ 使用 std::atomic 保证读写原子性\nlisteners_ 使用 std::mutex 保护回调列表"
核心设计决策:
current_state_使用std::atomic<DeviceState>:使得GetState()可以被任意线程无锁读取,这对CanEnterSleepMode()这类需要频繁查询状态的场景至关重要。
listeners_由std::mutex保护:回调添加/移除和通知操作互斥执行,NotifyStateChange中先拷贝回调列表再解锁遍历,避免了回调执行期间的死锁风险。
- 不可复制/移动:删除拷贝构造和赋值运算符,确保状态机实例全局唯一。
Sources: device_state_machine.h, device_state_machine.cc
观察者回调机制¶
Application 在初始化时注册状态变更监听器,回调中通过 FreeRTOS 事件组发出 MAIN_EVENT_STATE_CHANGED 事件:
state_machine_.AddStateChangeListener([this](DeviceState old_state, DeviceState new_state) {
xEventGroupSetBits(event_group_, MAIN_EVENT_STATE_CHANGED);
});
主事件循环收到 MAIN_EVENT_STATE_CHANGED 后调用 HandleStateChangedEvent(),该函数根据新状态执行对应的 UI 更新、LED 控制、音频管线启停(详见下文「各状态下的系统行为」)。
Sources: application.cc, application.cc
合法转换规则矩阵¶
IsValidTransition() 方法定义了状态机的完整有向图。下表以「源状态 → 允许的目标状态列表」形式呈现全部规则。迁移到相同状态(no-op)在所有源状态下均允许,表中不再单独列出。
| 源状态 | 允许转换到的目标状态 |
|---|---|
| Unknown | → Starting |
| Starting | → WifiConfiguring、Activating |
| WifiConfiguring | → Activating、AudioTesting |
| AudioTesting | → WifiConfiguring |
| Activating | → Upgrading、Idle、WifiConfiguring |
| Upgrading | → Idle、Activating |
| Idle | → Connecting、Listening、Speaking、Activating、Upgrading、WifiConfiguring |
| Connecting | → Idle、Listening |
| Listening | → Speaking、Idle |
| Speaking | → Listening、Idle |
| FatalError | (无出边) |
当调用 TransitionTo(new_state) 且目标状态不合法时,方法返回 false 并打印 ESP_LOGW 级别的警告日志,不会修改当前状态。
Sources: device_state_machine.cc
转换规则的可视化表达¶
下方状态图以 Mermaid 语法描述了完整的转换关系。实线箭头表示允许的迁移方向,标签注释了典型触发原因。
stateDiagram-v2
[*] --> Unknown
Unknown --> Starting : 系统上电
Starting --> WifiConfiguring : 无Wi-Fi凭据
Starting --> Activating : 网络已配置
WifiConfiguring --> Activating : 网络连接成功
WifiConfiguring --> AudioTesting : 用户按按钮
AudioTesting --> WifiConfiguring : 用户再按按钮
Activating --> Idle : 激活完成
Activating --> Upgrading : 检测到新版本
Activating --> WifiConfiguring : 激活失败/网络断开
Upgrading --> Activating : 升级取消/资产下载完成
Upgrading --> Idle : 升级失败
Idle --> Connecting : 用户按键/唤醒词
Idle --> Listening : 手动模式(无AEC)
Idle --> WifiConfiguring : 触发配网
Idle --> Activating : 重新激活
Idle --> Upgrading : MCP触发升级
Idle --> Speaking : (预留)
Connecting --> Listening : 通道建立成功
Connecting --> Idle : 通道建立失败
Listening --> Speaking : TTS开始
Listening --> Idle : 停止聆听/超时
Speaking --> Listening : TTS结束(自动)
Speaking --> Idle : TTS结束(手动)/中止
完整生命周期:从启动到对话的六阶段流转¶
以下是设备典型一次完整交互经历的状态序列。理解这条主路径有助于把握状态机的整体设计意图。
flowchart TD
A["Unknown<br/>初始状态"] --> B["Starting<br/>系统启动"]
B --> C{"网络状态"}
C -->|"有Wi-Fi凭据"| D["Activating<br/>版本检查 + 协议握手"]
C -->|"无Wi-Fi凭据"| E["WifiConfiguring<br/>配网模式"]
E --> D
D --> F{"升级检查"}
F -->|"有新版本"| G["Upgrading<br/>下载固件/资源"]
G --> D
F -->|"无需升级"| H["Idle<br/>待机"]
H --> I["Connecting<br/>打开音频通道"]
I --> J["Listening<br/>聆听"]
J --> K["Speaking<br/>播放回复"]
K --> J
K --> H
J --> H
阶段一:启动到网络就绪(Unknown → Starting → WifiConfiguring/Activating)¶
main.cc 中的 app_main() 依次调用 Initialize() 和 Run()。Initialize() 第一行即完成 Unknown → Starting 的迁移:
void Application::Initialize() {
auto& board = Board::GetInstance();
SetDeviceState(kDeviceStateStarting);
// ... 初始化显示、音频服务、MCP 工具、网络回调 ...
board.StartNetwork(); // 异步启动网络
}
网络事件的回调根据连接状态驱动下一步迁移:MAIN_EVENT_NETWORK_CONNECTED 触发 Starting/WifiConfiguring → Activating;若断开则回到 WifiConfiguring。
Sources: main.cc, application.cc, application.cc
阶段二:激活流程(Activating → Upgrading → Idle)¶
激活任务 ActivationTask() 在独立 FreeRTOS 任务中运行,按顺序执行三个动作:
- CheckAssetsVersion():检查 NVS 中的
download_url,若存在则 →Upgrading下载新资源包,完成后回到Activating
- CheckNewVersion():检查固件版本,需要升级则 →
Upgrading
- InitializeProtocol():根据 OTA 配置选择 MQTT 或 WebSocket 协议,注册各回调
全部完成后置位 MAIN_EVENT_ACTIVATION_DONE,主循环调用 HandleActivationDoneEvent() 执行 Activating → Idle。
void Application::HandleActivationDoneEvent() {
ESP_LOGI(TAG, "Activation done");
SystemInfo::PrintHeapStats();
SetDeviceState(kDeviceStateIdle); // 关键迁移点
// ... 释放 OTA 对象、降低功耗 ...
}
Sources: application.cc, application.cc, application.cc
阶段三:对话循环(Idle → Connecting → Listening → Speaking)¶
这是用户交互的核心路径,可由物理按键(ToggleChatState → MAIN_EVENT_TOGGLE_CHAT)或语音唤醒词(MAIN_EVENT_WAKE_WORD_DETECTED)触发。
按键对话流程(HandleToggleChatEvent):
| 当前状态 | 行为 |
|---|---|
| Activating | 中断激活,→ Idle |
| WifiConfiguring | 进入音频测试,→ AudioTesting |
| AudioTesting | 退出音频测试,→ WifiConfiguring |
| Idle | 打开音频通道,→ Connecting → Listening |
| Speaking | 中止播放(AbortSpeaking) |
| Listening | 关闭音频通道,→ Idle |
唤醒词对话流程(HandleWakeWordDetectedEvent)遵循相同模式,额外处理了 Speaking/Listening 状态下的打断逻辑:检测到唤醒词后先中止当前播放,清空发送队列,然后重新开始聆听。
Sources: application.cc, application.cc
阶段四:服务端 TTS 驱动(Speaking ↔ Listening)¶
服务端通过 JSON 消息控制 Speaking 状态的进出:
- TTS 开始:收到
{"type":"tts","state":"start"}→SetDeviceState(kDeviceStateSpeaking)
- TTS 结束:收到
{"type":"tts","state":"stop"}→ 根据聆听模式决定:ManualStop模式回到Idle,其他模式回到Listening
} else if (strcmp(state->valuestring, "stop") == 0) {
Schedule([this]() {
if (GetDeviceState() == kDeviceStateSpeaking) {
if (listening_mode_ == kListeningModeManualStop) {
SetDeviceState(kDeviceStateIdle);
} else {
SetDeviceState(kDeviceStateListening);
}
}
});
}
Sources: application.cc
各状态下的系统行为汇总¶
HandleStateChangedEvent() 是状态变更的统一处理入口,根据新状态协调显示屏、LED、音频管线和唤醒词检测的行为。下表汇总了每个主动状态的具体操作。
| 状态 | 显示屏状态栏 | 表情 | 音频管线 | 唤醒词检测 |
|---|---|---|---|---|
| Idle | STANDBY |
neutral |
停止语音处理 | 启用 |
| Connecting | CONNECTING |
neutral |
— | — |
| Listening | LISTENING |
neutral |
启用语音处理,发送 StartListening |
默认关闭(可配置开启) |
| Speaking | SPEAKING |
— | 停止语音处理(Realtime 模式除外),重置解码器 | 仅 AFE 唤醒词可用 |
| WifiConfiguring | — | — | 停止语音处理 | 关闭 |
特别注意:
Listening状态下若play_popup_on_listening_标志为真(由唤醒词打断 Speaking 触发),会在音频管线启动后播放提示音(OGG_POPUP),以确保ResetDecoder不会清除待播放的音效。
Sources: application.cc
聆听模式与状态转换的交互¶
协议层定义了三种 ListeningMode,它们直接影响 Speaking 结束后的目标状态:
| 模式 | 枚举值 | Speaking → 结束目标 | 适用场景 |
|---|---|---|---|
| AutoStop | kListeningModeAutoStop |
→ Listening(自动连续对话) | 默认模式(无 AEC 时) |
| ManualStop | kListeningModeManualStop |
→ Idle(需再次按键) | 按键对话模式 |
| Realtime | kListeningModeRealtime |
→ Listening(全双工) | 需要 AEC 支持 |
默认模式由 AEC 配置决定:aec_mode_ == kAecOff 时使用 AutoStop,否则使用 Realtime。这一设计基于一个基本事实——全双工实时对话只有在回声消除开启时才有实用价值。
Sources: protocol.h, application.cc
错误处理与异常路径¶
网络错误¶
MAIN_EVENT_ERROR 事件触发后,无论当前状态如何,强制回到 Idle 并弹出错误提示:
if (bits & MAIN_EVENT_ERROR) {
SetDeviceState(kDeviceStateIdle);
Alert(Lang::Strings::ERROR, last_error_message_.c_str(), "circle_xmark", Lang::Sounds::OGG_EXCLAMATION);
}
音频通道关闭¶
协议层检测到通道关闭时(OnAudioChannelClosed 回调),通过 Schedule 异步回到 Idle,保证线程安全:
protocol_->OnAudioChannelClosed([this, &board]() {
board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
Schedule([this]() {
auto display = Board::GetInstance().GetDisplay();
display->SetChatMessage("system", "");
SetDeviceState(kDeviceStateIdle);
});
});
非法转换拒绝¶
当代码尝试执行非法状态转换时(例如从 FatalError 迁移到任意状态,或从 Listening 直接跳到 Activating),TransitionTo() 返回 false 并记录警告日志,系统保持当前状态不变,杜绝了状态混乱的潜在风险。
Sources: application.cc, application.cc, device_state_machine.cc
扩展指南:添加新状态¶
若需为设备增加新状态(例如视频通话状态),需修改以下三个文件:
device_state.h:在枚举中添加新值,置于kDeviceStateFatalError之前
device_state_machine.cc:在IsValidTransition()的 switch 语句中添加新状态的入边和出边规则,同时在STATE_STRINGS[]数组中添加对应的日志名称
application.cc:在HandleStateChangedEvent()的 switch 语句中添加新状态下的 UI/音频/唤醒词行为
遵循以上三步即可安全扩展状态机,现有的合法性校验框架会自动覆盖新状态。
下一步建议:了解状态机如何驱动音频管线,请阅读 AudioService 核心管线:三任务模型与数据队列;若想理解状态机如何与通信协议协作,请参阅 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计。
硬件抽象层¶
Board 抽象层设计:统一管理 70+ 开发板的秘诀¶
小智 AI 聊天机器人项目面临一个典型的嵌入式系统挑战:如何在单一代码库中优雅地管理 70 余种互不兼容的 ESP32 系列开发板?每种开发板的 GPIO 引脚映射、音频编解码芯片、显示屏驱动、网络接入方式(Wi-Fi / 4G / RNDIS)乃至电源管理策略都截然不同。如果采用条件编译宏堆砌的方式,代码将迅速退化为不可维护的「意大利面条」。本文深入剖析 Board 抽象层的设计哲学、继承体系与构建时多态机制,揭示其如何通过约 100 行核心抽象代码支撑起 70+ 开发板的统一管理。
Sources: board.h
核心架构:五层继承体系与工厂模式¶
Board 抽象层的设计遵循经典的「模板方法模式 + 工厂模式」组合。基类 Board 定义纯虚接口,中间层(WifiBoard、Ml307Board、Nt26Board、RndisBoard)实现特定网络类型的通用逻辑,叶子层(各具体开发板类)完成硬件初始化细节。整个继承体系呈五层树状结构:
classDiagram
class Board {
<<abstract>>
+GetInstance()$ Board&
+GetBoardType() string
+GetAudioCodec()* AudioCodec*
+GetDisplay() Display*
+GetLed() Led*
+GetNetwork()* NetworkInterface*
+StartNetwork()* void
+GetBoardJson()* string
+GetDeviceStatusJson()* string
+SetPowerSaveLevel()* void
#uuid_ string
}
class WifiBoard {
+GetBoardType() string
+StartNetwork() void
+GetNetwork() NetworkInterface*
+EnterWifiConfigMode() void
-connect_timer_ esp_timer
}
class Ml307Board {
+GetBoardType() string
+StartNetwork() void
+GetNetwork() NetworkInterface*
-modem_ unique_ptr~AtModem~
}
class Nt26Board {
+GetBoardType() string
+StartNetwork() void
+GetNetwork() NetworkInterface*
-modem_ unique_ptr~UartEthModem~
}
class RndisBoard {
+GetBoardType() string
+StartNetwork() void
+GetNetwork() NetworkInterface*
}
class DualNetworkBoard {
+SwitchNetworkType() void
+GetCurrentBoard() Board&
-current_board_ unique_ptr~Board~
-network_type_ NetworkType
}
class EspBox3Board {
+GetAudioCodec() AudioCodec*
+GetDisplay() Display*
+GetBacklight() Backlight*
}
class CompactWifiBoard {
+GetAudioCodec() AudioCodec*
+GetDisplay() Display*
+GetLed() Led*
}
class CompactMl307Board {
+GetAudioCodec() AudioCodec*
+GetDisplay() Display*
+GetLed() Led*
}
Board <|-- WifiBoard
Board <|-- Ml307Board
Board <|-- Nt26Board
Board <|-- RndisBoard
Board <|-- DualNetworkBoard
WifiBoard <|-- EspBox3Board
WifiBoard <|-- CompactWifiBoard
DualNetworkBoard <|-- CompactMl307Board
关键设计决策在于:Board 基类不包含任何网络相关的具体实现,而是将 GetNetwork() 和 StartNetwork() 声明为纯虚函数,由中间层子类分别实现 WiFi 配网流程、4G 模组 AT 指令交互、USB RNDIS 驱动加载等完全不同质的网络栈。
Sources: board.h, wifi_board.h, ml307_board.h, dual_network_board.h
构建时多态:DECLARE_BOARD 宏的注册魔法¶
Board 抽象层最精巧的设计在于「构建时多态」——它利用链接期替换而非虚函数表来实现跨平台的多态行为。核心机制藏在一个看似简单的宏定义中:
#define DECLARE_BOARD(BOARD_CLASS_NAME) \
void* create_board() { \
return new BOARD_CLASS_NAME(); \
}
每个开发板的 .cc 文件末尾都调用 DECLARE_BOARD(MyBoardClass),向全局命名空间注入一个 create_board() 函数。CMake 构建系统确保只有被选中的开发板文件参与编译,因此链接时只会存在唯一一个 create_board() 实现。Board::GetInstance() 通过调用 create_board() 获取具体实例:
static Board& GetInstance() {
static Board* instance = static_cast<Board*>(create_board());
return *instance;
}
这种设计的精妙之处在于:上层应用代码完全不感知具体开发板类型。application.cc 中始终使用 Board::GetInstance().GetAudioCodec()、Board::GetInstance().GetDisplay() 等基类接口,而具体调用哪个子类实现,完全由编译时选择的 BOARD_TYPE Kconfig 配置决定。无需任何 #ifdef BOARD_TYPE_XXX 分支判断。
Sources: board.h, board.h, compact_wifi_board.cc, esp_box3_board.cc
三层配置体系:Kconfig → CMake → config.h¶
将 70+ 开发板纳入统一构建系统,需要一套严谨的三层配置映射机制,从用户菜单选择逐级传递到底层硬件定义:
flowchart TD
A["Kconfig.projbuild<br/>用户选择 BOARD_TYPE"] --> B["CMakeLists.txt<br/>映射 BOARD_TYPE → 目录名"]
B --> C["file(GLOB BOARD_SOURCES<br/>boards/{BOARD_TYPE}/*.cc)"]
C --> D["target_compile_definitions<br/>BOARD_TYPE, BOARD_NAME"]
D --> E["config.h<br/>GPIO 引脚 / 采样率 / 屏幕参数"]
D --> F["config.json<br/>芯片型号 / Flash 大小 / sdkconfig"]
C --> G["DECLARE_BOARD 注册<br/>create_board() 实现"]
第一层:Kconfig.projbuild — 这是用户交互界面。choice BOARD_TYPE 菜单罗列了全部 70+ 开发板选项,每个选项附带 depends on IDF_TARGET_XXX 约束,确保 ESP32-S3 开发板不会在 ESP32-C3 工程中被误选。例如 ESP-BOX-3 必须依赖 IDF_TARGET_ESP32S3,而 Xmini C3 系列则限定 IDF_TARGET_ESP32C3。
Sources: Kconfig.projbuild
第二层:CMakeLists.txt — 这是一个长达 820 行的条件分支结构,将 CONFIG_BOARD_TYPE_XXX 映射为 BOARD_TYPE(目录名)、BOARD_NAME(编译标识)、以及 BUILTIN_TEXT_FONT、BUILTIN_ICON_FONT、DEFAULT_EMOJI_COLLECTION 等资源常量。例如:
| Kconfig 选项 | BOARD_TYPE 目录 | 芯片目标 | 内置字体 |
|---|---|---|---|
BOARD_TYPE_ESP_BOX_3 |
esp-box-3 |
esp32s3 | font_noto_basic_20_4 |
BOARD_TYPE_BREAD_COMPACT_WIFI |
bread-compact-wifi |
esp32s3 | font_puhui_basic_14_1 |
BOARD_TYPE_XMINI_C3 |
xmini-c3 |
esp32c3 | font_puhui_basic_14_1 |
BOARD_TYPE_M5STACK_CORE_S3 |
m5stack-core-s3 |
esp32s3 | font_puhui_basic_20_4 |
随后通过 file(GLOB BOARD_SOURCES boards/${BOARD_TYPE}/*.cc) 自动抓取该目录下所有源码文件,并将其链接到主组件中。对于多级目录结构(如 waveshare/esp32-s3-touch-lcd-3.5),则通过 MANUFACTURER 变量区分。
Sources: CMakeLists.txt
第三层:config.h — 每个开发板目录下的硬件配置头文件,定义 GPIO 引脚映射、音频采样率、I2C/I2S 参数、显示屏分辨率等物理层面的常量。这些宏被开发板的 .cc 实现文件引用,完成具体硬件的初始化。
第四层:config.json — 面向 scripts/release.py 自动化构建脚本的元数据文件。指定目标芯片型号(target)、固件包名称(name)、以及额外 sdkconfig 覆盖项(sdkconfig_append)。这使得 CI/CD 流水线能够批量为所有开发板生成固件,而不需要手动逐一配置 menuconfig。
Sources: config.json
网络接入多态:四条网络通道的统一抽象¶
不同开发板采用截然不同的网络接入方案,Board 抽象层通过中间层子类封装了四条互不干扰的网络通道:
| 网路通道 | 中间层类 | 适用场景 | 核心依赖 |
|---|---|---|---|
| WiFi | WifiBoard |
大多数消费级开发板 | WifiManager + WifiStation |
| 4G 蜂窝 (ML307) | Ml307Board |
移动场景 / 无 WiFi 环境 | AT 指令驱动 AtModem |
| 4G 蜂窝 (NT26) | Nt26Board |
USB 以太网模组 | UartEthModem 驱动 |
| USB RNDIS | RndisBoard |
ESP32-P4/S3 的 USB Host 网络 | iot_usbh_rndis |
| 双模切换 | DualNetworkBoard |
WiFi + ML307 双网卡开发板 | 内部持有 WifiBoard 或 Ml307Board 实例 |
WifiBoard 实现了完整的 WiFi 配网状态机:上电检测已保存 SSID → 尝试连接(60 秒超时)→ 超时进入配网模式(AP 热点 / BluFi / 声波配网三选一)。连接成功后通过 NetworkEventCallback 回调通知 Application 层触发 MAIN_EVENT_NETWORK_CONNECTED 事件。
Sources: wifi_board.cc, ml307_board.h, rndis_board.h, nt26_board.h
DualNetworkBoard 的设计尤为精巧:它同时继承自 Board,但内部持有一个 std::unique_ptr<Board> current_board_,保存当前活动的 WifiBoard 或 Ml307Board 实例。所有接口调用均转发到 current_board_。用户双击 Boot 按钮即可在 WiFi 和 4G 之间切换,网络类型偏好被持久化到 NVS 存储中。
Sources: dual_network_board.h, compact_ml307_board.cc
硬件资源抽象:AudioCodec、Display 与 Backlight¶
Board 基类为三类硬件资源提供了默认实现(返回空/nullptr),具体开发板按需重写:
音频编解码器:GetAudioCodec() 是唯一一个在 Board 中声明为纯虚函数的资源获取方法 — 因为语音交互是小智的核心功能,不存在「无音频」的开发板。每个开发板根据其使用的 DAC/ADC 芯片返回 Es8311AudioCodec、Es8388AudioCodec、BoxAudioCodec(ES8311 + ES7210 双芯片方案)、NoAudioCodecSimplex(I2S 直连 MEMS 麦克风 + 功放)等不同实现。
Sources: esp_box3_board.cc, compact_wifi_board.cc
显示屏:GetDisplay() 默认返回 NoDisplay(一个什么都不做的空实现)。有屏幕的开发板在构造函数中初始化 SpiLcdDisplay(基于 SPI 的 LCD/OLED)、OledDisplay(SSD1306/SH1106 I²C OLED)、EmoteDisplay(LVGL 表情动画显示)或 LvglDisplay(通用 LVGL 图形界面)。
背光控制:GetBacklight() 默认返回 nullptr。需要 PWM 背光的 LCD 开发板通常返回一个 PwmBacklight 实例,支持平滑渐变调光(通过 esp_timer 逐步调整 PWM 占空比)。
Sources: board.cc, backlight.h
统一网络事件回调:跨通道的事件抽象¶
尽管 WiFi 和 4G 的网络状态语义完全不同,Board 抽象层通过 NetworkEvent 枚举实现了统一的事件抽象:
| 网络事件 | 触发场景 | data 参数 |
|---|---|---|
Scanning |
WiFi 扫描中 | 无 |
Connecting |
正在连接 (WiFi/4G) | SSID 或网络名称 |
Connected |
连接成功 | SSID 或网络名称 |
Disconnected |
连接断开 | 无 |
WifiConfigModeEnter/Exit |
配网模式进入/退出 | 无 |
ModemDetecting |
4G 模组检测中 | 无 |
ModemErrorNoSim |
未检测到 SIM 卡 | 无 |
ModemErrorRegDenied |
网络注册被拒 | 无 |
ModemErrorInitFailed |
模组初始化失败 | 无 |
ModemErrorTimeout |
操作超时 | 无 |
WifiBoard 内部将 WifiManager 的底层事件(WifiEvent)转换为 NetworkEvent,再通过 NetworkEventCallback 回调通知 Application。4G 模组类也遵循相同的回调协议,使得上层应用只需监听 MAIN_EVENT_NETWORK_CONNECTED / MAIN_EVENT_NETWORK_DISCONNECTED 两个事件,完全无需关心底层是 WiFi 还是蜂窝网络。
Sources: board.h, wifi_board.cc
系统信息上报:GetSystemInfoJson 的聚合模式¶
当云端请求设备系统信息时,Board::GetSystemInfoJson() 构建一个涵盖芯片信息、固件版本、分区表、显示屏参数和开发板特定信息的完整 JSON。这是一个典型的聚合查询实现:基类负责通用字段(Flash 大小、MAC 地址、芯片型号、分区布局),然后调用 GetBoardJson() 委托子类补充网络相关信息。
WifiBoard::GetBoardJson() 补充 SSID、RSSI、IP 地址等 WiFi 特有字段;Ml307Board::GetBoardJson() 则补充 ICCID、IMEI、信号强度、网络注册状态等蜂窝网络字段。GetDeviceStatusJson() 则专注于运行时状态:音频音量、屏幕亮度、电池电量、WiFi 信号强度等。
Sources: board.cc, wifi_board.cc, wifi_board.cc
公共组件库:boards/common 的复用范式¶
boards/common/ 目录汇集了跨开发板共享的基础设施组件,避免在每个开发板目录中重复造轮子:
| 组件文件 | 功能 | 使用者 |
|---|---|---|
board.cc/.h |
Board 基类实现、UUID 生成、系统信息 JSON | 所有开发板 |
wifi_board.cc/.h |
WiFi 连接管理、配网流程 | WiFi 开发板 |
ml307_board.cc/.h |
ML307 4G 模组 AT 交互 | 4G 开发板 |
dual_network_board.cc/.h |
WiFi/4G 双模切换 | 双网络开发板 |
button.cc/.h |
按键抽象(单击/双击/长按/多次击) | 几乎所有开发板 |
backlight.cc/.h |
PWM 背光控制 + 平滑渐变 | LCD 开发板 |
adc_battery_monitor.cc/.h |
ADC 电池电压检测 | 电池供电开发板 |
axp2101.cc/.h / sy6970.cc/.h |
电源管理芯片驱动 | 含 PMIC 的开发板 |
esp32_camera.cc/.h |
ESP32-S3 摄像头驱动 | 带摄像头的开发板 |
power_save_timer.cc/.h / sleep_timer.cc/.h |
低功耗定时器 | 电池供电开发板 |
press_to_talk_mcp_tool.cc/.h |
按键对讲 MCP 工具注册 | 支持 MCP 的开发板 |
system_reset.cc/.h |
系统复位工具 | 需要远程重启的开发板 |
knob.cc/.h |
旋转编码器抽象 | 带旋钮的开发板 |
这些公共组件通过 CMakeLists.txt 统一添加到 SOURCES 列表中,所有开发板均可引用。芯片相关的条件编译(如 ESP32 目标排除 ml307_board.cc)也在此集中管理。
Sources: CMakeLists.txt, button.h, backlight.h
新增开发板的五步流程¶
基于上述抽象层设计,为小智项目新增一款开发板只需遵循标准化流程:
flowchart LR
A["1. 创建目录<br/>boards/{品牌}-{型号}"] --> B["2. 编写 config.h<br/>GPIO 引脚 / 音频 / 屏幕参数"]
B --> C["3. 编写 config.json<br/>target / builds / sdkconfig"]
C --> D["4. 编写 board.cc<br/>继承 WifiBoard/Ml307Board<br/>实现 GetAudioCodec/GetDisplay<br/>末尾调用 DECLARE_BOARD"]
D --> E["5. 注册到构建系统<br/>Kconfig.projbuild 添加选项<br/>CMakeLists.txt 添加映射"]
第 4 步是关键:开发板的 .cc 文件需要:
- 选择一个合适的中间层基类继承(WiFi?4G?双模?)
- 在构造函数中完成 I²C/SPI 总线初始化、显示屏驱动初始化、按键绑定
- 重写
GetAudioCodec()创建并返回正确的 AudioCodec 静态实例
- 重写
GetDisplay()返回构造的 Display 对象
- 可选择重写
GetLed()、GetBacklight()、GetBatteryLevel()等
- 末尾调用
DECLARE_BOARD(MyBoardClass)完成注册
Sources: custom-board_zh.md, custom-board_zh.md
架构评审:优点与权衡¶
优点:
- 零运行时开销:构建时多态避免了虚函数表间接调用的开销,也避免了
#ifdef分支对代码可读性的侵蚀
- 强隔离性:修改一款开发板的配置不会影响其他任何开发板的编译结果
- 高可扩展性:新增开发板只需创建目录 + 三个文件 + 两处注册,无需触碰核心代码
- 丰富的默认实现:
GetDisplay()返回NoDisplay、GetLed()返回NoLed、GetBatteryLevel()返回 false,开发板「按需重写」
权衡:
- CMakeLists.txt 的线性膨胀:820 行的 if-elseif 链随着开发板数量增长而不堪重负。理想情况下可以考虑基于目录名称的约定优于配置(Convention over Configuration)策略,通过目录结构自动推断 BOARD_TYPE、芯片目标等信息
- config.h 缺乏 schema 校验:GPIO 引脚宏的命名约定(
AUDIO_I2S_GPIO_*、DISPLAY_SPI_*等)在 70+ 开发板中高度一致但未强制执行,容易引入不一致的宏命名
- 单例模式的测试困难:
Board::GetInstance()的静态局部变量使得单元测试难以替换开发板实现,可考虑引入依赖注入机制
阅读下一步¶
祝贺你完成了 Board 抽象层设计的深入剖析!理解这套架构后,建议按以下路径继续探索:
- 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD — 动手实践,为你的硬件平台编写开发板适配代码
- 显示系统架构:OLED / LCD / LVGL 三层次抽象 — 深入理解 Display 抽象层的设计,与 Board 层紧密协作
- LED 指示灯系统:单灯、灯环与 GPIO 控制 — 了解 Board 层如何通过
GetLed()提供统一的 LED 控制接口
- 音频编解码器集成:ES8311 / ES8388 / ES8374 等芯片适配 — 了解
GetAudioCodec()返回的各种 AudioCodec 实现细节
- 网络连接管理:Wi-Fi / ML307 4G / RNDIS 多模接入 — 深入了解四类网络通道的底层驱动实现
自定义开发板开发指南:从 config.h 到 DECLARE_BOARD¶
本文档系统性地解析小智 AI 项目中"自定义开发板"的完整开发流程——从硬件引脚配置文件 config.h 开始,贯穿构建系统集成,最终抵达 DECLARE_BOARD 宏所触发的运行时多态实例化。你将理解每一层抽象的设计意图、掌握每一个配置文件的作用边界,并能够独立完成新开发板的适配工作。
架构全景:Board 抽象层的注册与实例化机制¶
在深入编码细节之前,有必要先建立对 Board 抽象层运行时机制的清晰认知。整个系统的核心设计围绕着编译期选择 + 运行期多态这一双重策略展开。
DECLARE_BOARD 宏是整个机制的枢纽。它定义在 board.h 中,展开后生成一个名为 create_board() 的全局工厂函数,该函数仅做一件事:在堆上 new 一个具体的 Board 子类实例并返回其 void* 指针。
// 来源: board.h
#define DECLARE_BOARD(BOARD_CLASS_NAME) \
void* create_board() { \
return new BOARD_CLASS_NAME(); \
}
Board::GetInstance() 是系统中访问板级能力的唯一入口——它内部调用 create_board() 获取实例,缓存在一个局部静态指针中,后续所有上层代码(Application、AudioService、Display 系统等)均通过 Board::GetInstance().GetAudioCodec() 这类调用获取硬件能力。
sequenceDiagram
participant App as Application
participant Board as Board::GetInstance()
participant Factory as create_board()
participant Concrete as MyCustomBoard
App->>Board: GetInstance()
Board->>Factory: 首次调用 create_board()
Factory->>Concrete: new MyCustomBoard()
Concrete-->>Concrete: 构造函数: InitializeI2c()<br/>InitializeSpi()<br/>InitializeDisplay()<br/>InitializeButtons()
Concrete-->>Factory: 返回 Board*
Factory-->>Board: 缓存为 static instance
Board-->>App: 返回 Board&
App->>Board: GetAudioCodec()
Board->>Concrete: 虚函数调用 → 返回 Es8311AudioCodec&
这种设计的精妙之处在于:所有 70+ 开发板在编译时互斥(通过 Kconfig 的 choice BOARD_TYPE 确保只选一个),编译产物的二进制中只包含被选中那块板的 create_board() 实现,完全没有虚表开销之外的冗余代码。
第一步:理解 Board 类继承体系¶
选择正确的基类是开发板适配的第一个关键决策。小智项目提供了四条继承链路,对应四种网络连接模式:
classDiagram
class Board {
<<abstract>>
+GetBoardType() string
+GetAudioCodec() AudioCodec*
+GetDisplay() Display*
+GetBacklight() Backlight*
+GetLed() Led*
+GetNetwork() NetworkInterface*
+StartNetwork() void
+SetPowerSaveLevel(level) void
+GetBoardJson() string
}
class WifiBoard {
-connect_timer_
-in_config_mode_
+EnterWifiConfigMode()
+TryWifiConnect()
}
class Ml307Board {
-modem_
-tx_pin_, rx_pin_, dtr_pin_
+NetworkTask()
}
class RndisBoard {
-rndis_eth_driver
-s_rndis_netif
}
class DualNetworkBoard {
-current_board_
-network_type_
+SwitchNetworkType()
+GetCurrentBoard() Board&
}
Board <|-- WifiBoard : Wi-Fi 连接
Board <|-- Ml307Board : 4G 模块 (ML307)
Board <|-- RndisBoard : USB RNDIS 网卡
Board <|-- DualNetworkBoard : Wi-Fi / 4G 双模切换
Sources: board.h, wifi_board.h, ml307_board.h, rndis_board.h, dual_network_board.h
| 基类 | 适用场景 | 关键需重写的方法 | 典型开发板示例 |
|---|---|---|---|
WifiBoard |
仅 Wi-Fi 连接的开发板(最常见) | GetAudioCodec(), GetDisplay(), GetBacklight() |
lichuang-c3-dev, esp-box-3, aipi-lite |
Ml307Board |
仅 4G 蜂窝网络的开发板 | 同上 + 构造函数中传入 TX/RX 引脚 | 大多数 *-ml307 变体 |
RndisBoard |
USB RNDIS 网卡连接(ESP32-S3/P4) | 同上 | esp32s3-korvo2-v3-rndis |
DualNetworkBoard |
Wi-Fi 与 4G 可切换的双模开发板 | 同上 + 构造函数传入 ML307 引脚 | bread-compact-ml307, xingzhi-cube-0.96oled-ml307 |
决策建议:如果开发板只有 Wi-Fi,直接继承 WifiBoard;如果有 4G 模块且需要 Wi-Fi 做备份,使用 DualNetworkBoard;如果仅有 4G 模块且不需要 Wi-Fi,使用 Ml307Board。
第二步:创建 config.h —— 硬件引脚与参数的单一真相源¶
config.h 是每个开发板目录下最重要的文件,它通过 #define 宏集中定义了所有硬件引脚映射和显示参数。上层代码(包括 boards/common/ 下的通用组件和具体板级 .cc 文件)通过 #include "config.h" 引用这些宏,实现了硬件配置与业务逻辑的解耦。
配置分区与宏命名规范¶
一个完整的 config.h 按职责可划分为六个逻辑区:
| 分区 | 关键宏 | 说明 |
|---|---|---|
| 音频采样率 | AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE |
通常为 24000(24kHz)或 16000(16kHz) |
| I2S 引脚 | AUDIO_I2S_GPIO_MCLK, _WS, _BCLK, _DIN, _DOUT |
I2S 音频总线引脚;若使用 Simplex 模式则有 _MIC_* 和 _SPK_* 两组 |
| 音频编解码器 | AUDIO_CODEC_I2C_SDA_PIN, _SCL_PIN, AUDIO_CODEC_PA_PIN, AUDIO_CODEC_ES8311_ADDR |
I2C 总线引脚、功放使能引脚、编解码器 I2C 地址 |
| 按钮 | BOOT_BUTTON_GPIO, VOLUME_UP_BUTTON_GPIO, VOLUME_DOWN_BUTTON_GPIO |
各类按钮的 GPIO;未使用的设为 GPIO_NUM_NC |
| 显示 | DISPLAY_WIDTH, DISPLAY_HEIGHT, DISPLAY_SPI_SCK_PIN, DISPLAY_SPI_MOSI_PIN, DISPLAY_SPI_CS_PIN, DISPLAY_DC_PIN, DISPLAY_BACKLIGHT_PIN, DISPLAY_MIRROR_X/Y, DISPLAY_SWAP_XY |
屏幕尺寸、SPI/并口引脚、镜像/旋转设置 |
| 电源 | POWER_CONTROL_PIN, POWER_CHARGE_DETECT_PIN, POWER_ADC_UNIT, POWER_ADC_CHANNEL |
电源管理与电池检测(可选) |
以下是一个典型的 config.h 示例,来自 lichuang-c3-dev 开发板:
// 来源: lichuang-c3-dev/config.h
#ifndef _BOARD_CONFIG_H_
#define _BOARD_CONFIG_H_
#include <driver/gpio.h>
#define AUDIO_INPUT_SAMPLE_RATE 24000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_10
#define AUDIO_I2S_GPIO_WS GPIO_NUM_12
#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_8
#define AUDIO_I2S_GPIO_DIN GPIO_NUM_7
#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_11
#define AUDIO_CODEC_PA_PIN GPIO_NUM_13
#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_0
#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_1
#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR
#define BUILTIN_LED_GPIO GPIO_NUM_NC
#define BOOT_BUTTON_GPIO GPIO_NUM_9
#define DISPLAY_SPI_SCK_PIN GPIO_NUM_3
#define DISPLAY_SPI_MOSI_PIN GPIO_NUM_5
#define DISPLAY_DC_PIN GPIO_NUM_6
#define DISPLAY_SPI_CS_PIN GPIO_NUM_4
#define DISPLAY_WIDTH 320
#define DISPLAY_HEIGHT 240
#define DISPLAY_MIRROR_X true
#define DISPLAY_MIRROR_Y false
#define DISPLAY_SWAP_XY true
#define DISPLAY_OFFSET_X 0
#define DISPLAY_OFFSET_Y 0
#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_2
#define DISPLAY_BACKLIGHT_OUTPUT_INVERT true
#endif // _BOARD_CONFIG_H_
Sources: config.h
对于使用 Simplex I2S 模式(输入和输出使用不同的 I2S 外设)的开发板,config.h 中需要定义两组独立的 I2S 引脚宏,并用 #define AUDIO_I2S_METHOD_SIMPLEX 标记:
// 来源: bread-compact-ml307/config.h (节选)
#define AUDIO_I2S_METHOD_SIMPLEX
#define AUDIO_I2S_MIC_GPIO_WS GPIO_NUM_4
#define AUDIO_I2S_MIC_GPIO_SCK GPIO_NUM_5
#define AUDIO_I2S_MIC_GPIO_DIN GPIO_NUM_6
#define AUDIO_I2S_SPK_GPIO_DOUT GPIO_NUM_7
#define AUDIO_I2S_SPK_GPIO_BCLK GPIO_NUM_15
#define AUDIO_I2S_SPK_GPIO_LRCK GPIO_NUM_16
Sources: config.h
常见陷阱:如果你使用 GPIO_NUM_NC 表示未连接引脚,请确保后续代码(如 Backlight 的构造)能正确处理此值。例如 PwmBacklight 在引脚为 GPIO_NUM_NC 时会静默退化为空操作。
第三步:编写板级初始化代码(.cc 文件)¶
板级 .cc 文件承载了从硬件初始化到能力暴露的全部逻辑。一个典型的开发板类结构可以用以下流程图概括:
flowchart TD
A[构造函数 MyCustomBoard()] --> B[InitializeI2c()]
B --> C[InitializeSpi()]
C --> D[InitializeDisplay()]
D --> E[InitializeButtons()]
E --> F[InitializeTools()]
F --> G[GetBacklight()->SetBrightness(100)]
H[重写虚函数] --> I[GetAudioCodec()]
H --> J[GetDisplay()]
H --> K[GetBacklight()]
H --> L[GetLed()]
M[文件末尾] --> N["DECLARE_BOARD(MyCustomBoard)"]
3.1 I2C 总线初始化¶
编解码器(如 ES8311、ES8388)和部分显示屏(如 SSD1306 OLED)通过 I2C 总线通信。初始化这段总线是第一步:
void InitializeI2c() {
i2c_master_bus_config_t i2c_bus_cfg = {
.i2c_port = I2C_NUM_0,
.sda_io_num = AUDIO_CODEC_I2C_SDA_PIN,
.scl_io_num = AUDIO_CODEC_I2C_SCL_PIN,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.intr_priority = 0,
.trans_queue_depth = 0,
.flags = {
.enable_internal_pullup = 1,
},
};
ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &codec_i2c_bus_));
}
Sources: lichuang_c3_dev_board.cc
关键点:i2c_port 的选择要注意与 SPI 等其它外设的 dma_channel 不冲突。ESP32-S3 有 2 个 I2C 控制器(I2C_NUM_0 和 I2C_NUM_1),如果遇到奇怪的 I2C 读写失败,优先检查端口冲突。
3.2 SPI 总线与显示屏初始化¶
对于 SPI 接口的 LCD(如 ST7789、ILI9341),SPI 总线初始化与 LCD 面板初始化是分离的两个步骤:
void InitializeSpi() {
spi_bus_config_t buscfg = {};
buscfg.mosi_io_num = DISPLAY_SPI_MOSI_PIN;
buscfg.miso_io_num = GPIO_NUM_NC;
buscfg.sclk_io_num = DISPLAY_SPI_SCK_PIN;
buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t);
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
}
void InitializeSt7789Display() {
esp_lcd_panel_io_handle_t panel_io = nullptr;
esp_lcd_panel_handle_t panel = nullptr;
esp_lcd_panel_io_spi_config_t io_config = {};
io_config.cs_gpio_num = DISPLAY_SPI_CS_PIN;
io_config.dc_gpio_num = DISPLAY_DC_PIN;
io_config.spi_mode = 2; // ST7789 通常使用 SPI Mode 2
io_config.pclk_hz = 80 * 1000 * 1000;
io_config.trans_queue_depth = 10;
io_config.lcd_cmd_bits = 8;
io_config.lcd_param_bits = 8;
ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI2_HOST, &io_config, &panel_io));
esp_lcd_panel_dev_config_t panel_config = {};
panel_config.reset_gpio_num = GPIO_NUM_NC;
panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB;
panel_config.bits_per_pixel = 16;
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel));
esp_lcd_panel_reset(panel);
esp_lcd_panel_init(panel);
esp_lcd_panel_invert_color(panel, true);
esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY);
esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y);
display_ = new SpiLcdDisplay(panel_io, panel,
DISPLAY_WIDTH, DISPLAY_HEIGHT,
DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y,
DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY);
}
Sources: lichuang_c3_dev_board.cc
可用的 Display 子类包括:
| Display 子类 | 适用场景 | 构造参数要点 |
|---|---|---|
SpiLcdDisplay |
SPI 接口的彩色 LCD(ST7789/ILI9341/GC9A01 等) | panel_io, panel, 宽高, 偏移, 镜像/旋转 |
RgbLcdDisplay |
RGB 并口 LCD(如 GC9503) | 同上,但底层使用 RGB 接口 |
OledDisplay |
I2C 接口的单色 OLED(SSD1306/SH1106) | panel_io, panel, 宽高, 镜像 |
NoDisplay |
无显示屏的开发板 | 无需参数(由 Board::GetDisplay() 默认返回) |
EmoteDisplay |
ESP-BOX-3 风格的表情动画显示 | 由 CONFIG_USE_EMOTE_MESSAGE_STYLE 控制 |
3.3 音频编解码器获取¶
GetAudioCodec() 返回一个 static 局部对象,确保整个生命周期中编解码器只构造一次:
virtual AudioCodec* GetAudioCodec() override {
static Es8311AudioCodec audio_codec(
codec_i2c_bus_, I2C_NUM_0,
AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE,
AUDIO_I2S_GPIO_MCLK, AUDIO_I2S_GPIO_BCLK, AUDIO_I2S_GPIO_WS,
AUDIO_I2S_GPIO_DOUT, AUDIO_I2S_GPIO_DIN,
AUDIO_CODEC_PA_PIN, AUDIO_CODEC_ES8311_ADDR);
return &audio_codec;
}
Sources: lichuang_c3_dev_board.cc
可用的 AudioCodec 子类及其适用芯片:
| AudioCodec 子类 | 芯片型号 | 典型场景 |
|---|---|---|
Es8311AudioCodec |
ES8311 DAC + ES7210 ADC(或单独 ES8311) | 大多数带编解码器的开发板 |
Es8388AudioCodec |
ES8388 | ESP32-S3-Korvo-2 等 |
Es8389AudioCodec |
ES8389 | 部分 Waveshare 开发板 |
Es8374AudioCodec |
ES8374 | 少数特定型号 |
BoxAudioCodec |
ES8311 + ES7210(ESP-BOX 专用封装) | ESP-BOX、ESP-BOX-3 |
NoAudioCodecSimplex |
无编解码器,I2S 直连功放/PDM 麦克风 | Simplex I2S 模式的简易开发板 |
NoAudioCodecDuplex |
无编解码器,I2S 全双工直连 | Duplex I2S 模式的简易开发板 |
3.4 按钮初始化¶
按钮系统通过 Button 类(定义在 boards/common/button.h)提供了单击、双击、长按、按住等事件回调:
void InitializeButtons() {
boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting) {
EnterWifiConfigMode(); // 启动阶段单击 → 进入配网模式
return;
}
app.ToggleChatState(); // 其他阶段单击 → 切换对话状态
});
}
Sources: lichuang_c3_dev_board.cc
对于 DualNetworkBoard 的子类,按钮回调中需要额外判断当前网络类型以决定行为——例如 Wi-Fi 模式下长按进入配网,4G 模式下双击切换网络:
boot_button_.OnDoubleClick([this]() {
if (app.GetDeviceState() == kDeviceStateStarting ||
app.GetDeviceState() == kDeviceStateWifiConfiguring) {
SwitchNetworkType(); // 双击切换 4G ↔ Wi-Fi
}
});
Sources: compact_ml307_board.cc
3.5 DECLARE_BOARD 宏:注册的终点¶
在 .cc 文件的最后一行(必须在全局作用域),调用 DECLARE_BOARD(YourBoardClassName) 完成注册。这行代码生成了 create_board() 函数,它是编译系统连接 Kconfig 选项与运行时实例化的桥梁:
DECLARE_BOARD(LichuangC3DevBoard);
Sources: lichuang_c3_dev_board.cc
关键约束:一个固件镜像中只能有一个 create_board() 定义——CMake 通过 file(GLOB BOARD_SOURCES ...) 只收集被选中开发板目录下的 .cc 文件,确保不会出现多重定义。
第四步:创建 config.json —— 自动化构建配置¶
config.json 是为 scripts/release.py 脚本服务的编译描述文件。当你运行 python scripts/release.py my-custom-board 时,脚本读取此文件自动完成目标芯片设置、sdkconfig 配置和编译。
{
"target": "esp32s3",
"builds": [
{
"name": "my-custom-board",
"sdkconfig_append": [
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y",
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\"",
"CONFIG_USE_DEVICE_AEC=y"
]
}
]
}
Sources: config.json, config.json
| 字段 | 说明 | 示例值 |
|---|---|---|
target |
目标芯片型号 | esp32, esp32s3, esp32c3, esp32c6, esp32p4 |
builds[].name |
固件包名称,建议与目录名一致 | my-custom-board |
builds[].sdkconfig_append |
追加的 Kconfig 宏定义数组 | Flash 大小、分区表路径、AEC 开关等 |
一个 config.json 可以包含多个 builds 条目,例如 bread-compact-wifi 同时构建 128×32 和 128×64 两种 OLED 配置的固件:
{
"target": "esp32s3",
"builds": [
{ "name": "bread-compact-wifi", "sdkconfig_append": ["CONFIG_OLED_SSD1306_128X32=y"] },
{ "name": "bread-compact-wifi-128x64", "sdkconfig_append": ["CONFIG_OLED_SSD1306_128X64=y"] }
]
}
Sources: config.json
第五步:注册到 Kconfig 构建菜单¶
打开 main/Kconfig.projbuild,在 choice BOARD_TYPE 中添加你的开发板选项。这使开发者能够通过 idf.py menuconfig 在 Xiaozhi Assistant → Board Type 中选中你的板子:
choice BOARD_TYPE
prompt "Board Type"
default BOARD_TYPE_BREAD_COMPACT_WIFI
# ... 其他开发板选项 ...
config BOARD_TYPE_MY_CUSTOM_BOARD
bool "My Custom Board (我的自定义开发板)"
depends on IDF_TARGET_ESP32S3
endchoice
Sources: Kconfig.projbuild
命名规范:BOARD_TYPE_<大写目录名>,其中连字符 - 转换为下划线 _。例如目录 my-custom-board → Kconfig 项 BOARD_TYPE_MY_CUSTOM_BOARD。
depends on 约束了该开发板只能在特定芯片平台上被选择。如果你的板子使用 ESP32-S3,就写 depends on IDF_TARGET_ESP32S3;如果是通用开发板可省略此约束,但强烈建议加上以避免芯片不匹配导致的编译错误。
第六步:注册到 CMake 构建系统¶
打开 main/CMakeLists.txt,在 elseif 链中添加你的开发板配置块。这段代码做了四件事:
- 设置
BOARD_TYPE变量(指向boards/下的子目录名)
- 选择合适的字体大小(
BUILTIN_TEXT_FONT和BUILTIN_ICON_FONT)
- 可选地设置表情集合(
DEFAULT_EMOJI_COLLECTION)
- 可选地设置
MANUFACTURER(用于waveshare等有多级子目录的组织)
elseif(CONFIG_BOARD_TYPE_MY_CUSTOM_BOARD)
set(BOARD_TYPE "my-custom-board")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
endif()
Sources: CMakeLists.txt
CMake 靠 BOARD_TYPE 变量进行文件发现——在 CMakeLists.txt 的后半部分,有一个 file(GLOB BOARD_SOURCES ...) 指令基于 BOARD_TYPE(和可选的 MANUFACTURER)自动收集 .cc 和 .c 文件:
if(MANUFACTURER)
file(GLOB BOARD_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/boards/${MANUFACTURER}/${BOARD_TYPE}/*.cc
${CMAKE_CURRENT_SOURCE_DIR}/boards/${MANUFACTURER}/${BOARD_TYPE}/*.c
)
else()
file(GLOB BOARD_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.cc
${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.c
)
endif()
list(APPEND SOURCES ${BOARD_SOURCES})
Sources: CMakeLists.txt
随后 BOARD_TYPE 和 BOARD_NAME 被作为编译期宏注入 C++ 代码:
target_compile_definitions(${COMPONENT_LIB}
PRIVATE BOARD_TYPE=\"${BOARD_TYPE}\" BOARD_NAME=\"${BOARD_NAME}\"
...
)
这使得 board.cc 中的构造函数能够在日志中输出 UUID=... SKU=<BOARD_NAME>——这个 BOARD_NAME 值正是 OTA 服务器用来区分不同开发板固件通道的关键标识。
Sources: CMakeLists.txt, board.cc
字体与表情选择参考¶
字体大小必须与显示屏分辨率匹配,否则界面会溢出或过小:
| 屏幕分辨率 | 推荐文本字体 | 推荐图标字体 | 推荐表情集合 |
|---|---|---|---|
| 128×32 / 128×64 (OLED) | font_puhui_basic_14_1 |
font_awesome_14_1 |
—(OLED 不支持表情) |
| 128×128 ~ 240×240 | font_puhui_basic_16_4 |
font_awesome_16_4 |
twemoji_32 |
| 240×320 ~ 320×240 | font_puhui_basic_20_4 或 font_noto_basic_20_4 |
font_awesome_20_4 |
twemoji_64 |
| 320×480 及以上 | font_puhui_basic_30_4 或 font_noto_basic_30_4 |
font_awesome_30_4 |
twemoji_64 或 noto-emoji_128 |
Sources: CMakeLists.txt(各类开发板的字体配置对比)
第七步:编译、烧录与调试¶
使用 menuconfig 手动编译¶
idf.py set-target esp32s3 # 设置目标芯片
idf.py fullclean # 清理旧配置
idf.py menuconfig # 在 Xiaozhi Assistant → Board Type 中选择你的开发板
idf.py build # 编译
idf.py flash monitor # 烧录并查看串口日志
使用 release.py 自动化编译¶
如果你的 config.json 配置正确,一行命令即可完成全流程:
python scripts/release.py my-custom-board
此脚本会读取 config.json → 设置目标芯片 → 应用 sdkconfig_append → 编译 → 打包固件为 zip。
完整流程速查¶
从零开始创建一个新开发板的完整步骤映射:
flowchart LR
A["1. 创建目录<br/>boards/my-custom-board/"] --> B["2. config.h<br/>定义所有硬件引脚"]
B --> C["3. .cc 文件<br/>实现 Board 子类"]
C --> D["4. DECLARE_BOARD<br/>注册工厂函数"]
D --> E["5. config.json<br/>配置自动化构建"]
E --> F["6. Kconfig<br/>添加菜单选项"]
F --> G["7. CMakeLists.txt<br/>字体/构建集成"]
G --> H["8. 编译 & 烧录"]
| 步骤 | 文件 | 关键内容 |
|---|---|---|
| 1 | boards/<name>/ |
创建目录,命名格式:品牌-型号 |
| 2 | config.h |
AUDIO_I2S_*, AUDIO_CODEC_*, DISPLAY_*, BOOT_BUTTON_GPIO 等 |
| 3 | <name>.cc |
继承 WifiBoard/Ml307Board/DualNetworkBoard,实现构造函数与虚函数 |
| 4 | 同上 | 文件末尾:DECLARE_BOARD(ClassName); |
| 5 | config.json |
target, builds[].name, sdkconfig_append |
| 6 | Kconfig.projbuild |
config BOARD_TYPE_XXX + depends on |
| 7 | CMakeLists.txt |
elseif(CONFIG_BOARD_TYPE_XXX) + 字体/表情设置 |
| 8 | 终端 | idf.py set-target → menuconfig → build → flash |
常见问题与排查指南¶
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
编译报错 undefined reference to create_board() |
Kconfig 中选择了开发板但 CMakeLists.txt 未配置对应的 BOARD_TYPE |
检查 CMakeLists.txt 中 elseif 链的 BOARD_TYPE 是否与目录名一致 |
| 显示屏花屏或颜色异常 | SPI Mode、RGB 字节序、镜像/反转设置不正确 | 逐一调整 spi_mode(通常 ST7789 用 0 或 2)、rgb_ele_order、invert_color、mirror_x/y |
| 音频无输出 | PA 引脚未使能或编解码器 I2C 地址错误 | 检查 AUDIO_CODEC_PA_PIN 电平、用逻辑分析仪抓 I2C 确认地址响应 |
| 编译产物中包含了错误开发板的代码 | file(GLOB) 收集了多余文件 |
检查 boards/ 下是否有同名 .cc 文件;确保 MANUFACTURER 变量设置正确 |
| OTA 固件被覆盖 | 你的自定义固件使用了已有开发板的 BOARD_NAME |
务必创建新目录和新名称,不要覆盖已有开发板的配置 |
进阶话题:板级自定义的深度扩展¶
掌握了基本流程后,你可以进一步探索以下高级定制:
- 自定义音频编解码器:在
boards/<name>/下编写继承AudioCodec的子类(参考df-k10/k10_audio_codec.cc),并在板级.cc中返回其实例。
- 自定义 LED 控制器:继承
Led实现OnStateChanged(),可实现 WS2812 灯环、GPIO 多灯等效果(参考led/circular_strip.cc、kevin-c3/led_strip_control.cc)。
- MCP 工具注册:在
InitializeTools()中实例化LampController、PressToTalkMcpTool等 MCP 工具类,使 AI 能控制硬件设备(参考compact_ml307_board.cc中的LampController)。
- 电源管理器:实现
power_manager.h中定义的接口,支持电池电量检测与深度睡眠(参考aipi-lite/aipi-lite.cc中的PowerManager+PowerSaveTimer)。
- LVGL 图形界面:在
lvgl_display/体系下实现 GPU 加速渲染,适用于高分辨率触摸屏(参考esp-s3-lcd-ev-board的 RGB 接口实现)。
Sources: df_k10_board.cc, compact_ml307_board.cc, aipi-lite.cc, esp-s3-lcd-ev-board.cc
延伸阅读¶
完成自定义开发板的适配后,建议按以下顺序深入理解相关子系统:
- Board 抽象层设计:统一管理 70+ 开发板的秘诀 — 理解 Board 抽象层的完整设计哲学
- 显示系统架构:OLED / LCD / LVGL 三层次抽象 — 深入了解显示子系统
- 音频编解码器集成:ES8311 / ES8388 / ES8374 等芯片适配 — 音频编解码器选型与适配
- CMake 构建系统与 ESP-IDF 组件依赖管理 — 构建系统深入理解
- OTA 固件升级:版本检查、激活验证与安全更新 — 理解
BOARD_NAME在 OTA 通道中的作用
显示系统架构:OLED / LCD / LVGL 三层次抽象¶
小智 AI 聊天机器人需要支持超过 70 种开发板,其中涵盖 OLED 单色屏、SPI 彩屏、RGB 并口屏、MIPI DSI 屏等多种显示硬件。为了在"统一接口"与"差异化呈现"之间取得平衡,显示子系统采用 Display → LvglDisplay → 具体面板 的三层抽象设计,并在 LVGL 路径之外额外保留了一条 EmoteDisplay 非 LVGL 渲染通道。本文将逐层剖析这一架构的类继承关系、构造流程、UI 布局策略以及线程安全机制。
一、类继承全景¶
在深入代码之前,先通过类图建立全局视角。下图中实线箭头表示继承关系,虚线表示创建关系:
classDiagram
class Display {
<<abstract>>
+SetStatus(status)
+SetEmotion(emotion)
+SetChatMessage(role, content)
+ShowNotification(text, duration)
+SetTheme(theme)
+SetupUI()
+SetPowerSaveMode(on)
-Lock(timeout)*
-Unlock()*
#width_ int
#height_ int
}
class NoDisplay {
+Lock() true
+Unlock() empty
}
class LvglDisplay {
<<abstract>>
+SetStatus(status)
+ShowNotification(text, duration)
+UpdateStatusBar(update_all)
+SetPowerSaveMode(on)
+SnapshotToJpeg(data, quality)
#display_ lv_display_t*
#network_label_ lv_obj_t*
#status_label_ lv_obj_t*
#notification_label_ lv_obj_t*
#mute_label_ lv_obj_t*
#battery_label_ lv_obj_t*
}
class OledDisplay {
-panel_io_ handle
-panel_ handle
+SetupUI()
+SetChatMessage(role, content)
+SetEmotion(emotion)
}
class LcdDisplay {
#panel_io_ handle
#panel_ handle
#gif_controller_ LvglGif*
+SetupUI()
+SetEmotion(emotion)
+SetChatMessage(role, content)
+SetPreviewImage(image)
+SetTheme(theme)
+ClearChatMessages()
}
class SpiLcdDisplay
class RgbLcdDisplay
class MipiLcdDisplay
class EmoteDisplay {
-emote_handle_ handle
+SetEmotion(emotion)
+SetStatus(status)
+SetChatMessage(role, content)
+StopAnimDialog()
+InsertAnimDialog(name, duration)
}
Display <|-- NoDisplay
Display <|-- LvglDisplay
Display <|-- EmoteDisplay
LvglDisplay <|-- OledDisplay
LvglDisplay <|-- LcdDisplay
LcdDisplay <|-- SpiLcdDisplay
LcdDisplay <|-- RgbLcdDisplay
LcdDisplay <|-- MipiLcdDisplay
Sources: display.h, lvgl_display.h, oled_display.h, lcd_display.h, emote_display.h
二、第一层:Display 抽象基类 — 统一接口契约¶
Display 是所有显示实现的根类,定义了上层应用(如 Application::Initialize())所依赖的全部虚方法。其职责是 规定"能做什么"而不关心"怎么做"。
核心接口列表:
| 方法 | 用途 | 默认行为 |
|---|---|---|
SetStatus(const char* status) |
更新状态栏文本(如"聆听中""待命") | 仅打 ESP_LOGW 日志 |
SetEmotion(const char* emotion) |
切换表情图标(neutral/happy/sad 等) | 仅打 ESP_LOGW 日志 |
SetChatMessage(const char* role, const char* content) |
显示聊天消息(role: user/assistant/system) | 日志输出 |
ShowNotification(const char* notification, int duration_ms) |
临时通知,超时自动隐藏 | 日志输出 |
SetTheme(Theme* theme) |
切换主题并持久化到 NVS | 保存主题名到 NVS |
SetupUI() |
构建 LVGL 控件树(仅调用一次) | 标记 setup_ui_called_ |
SetPowerSaveMode(bool on) |
进入/退出省电模式 | 日志输出 |
基类同时定义了纯虚方法 Lock(int timeout_ms) 和 Unlock(),强制每个子类实现自己的互斥策略——这是线程安全的核心约束。
NoDisplay 类为空实现,用于无屏开发板(如纯面包板),其 Lock() 直接返回 true,Unlock() 为空操作。
Sources: display.h, display.cc
DisplayLockGuard:RAII 互斥锁¶
所有 LVGL 对象操作必须在持有锁的前提下进行,DisplayLockGuard 采用 RAII 模式保证锁的正确获取与释放:
DisplayLockGuard(Display *display) {
if (!display_->Lock(30000)) { // 最多等待 30 秒
ESP_LOGE("Display", "Failed to lock display");
}
}
~DisplayLockGuard() {
display_->Unlock();
}
LVGL 体系下,Lock() 实际调用 lvgl_port_lock()(基于互斥量),Unlock() 调用 lvgl_port_unlock()。EmoteDisplay 体系下,Lock/Unlock 为空操作——因为 emote 库自身管理内部任务同步。
Sources: display.h, lcd_display.cc
三、第二层:LvglDisplay — 状态栏逻辑中枢¶
LvglDisplay 继承 Display,是 OLED 和 LCD 两个分支的公共父类。它不再是一个抽象外壳,而是承担了实质性的 状态栏逻辑——包括网络图标、电池电量、静音状态、低电量弹窗以及通知定时器。具体面板的构造函数中完成 LVGL 端口初始化与显示注册后,LvglDisplay 的公共控件指针(network_label_、status_label_、battery_label_ 等)便可被 UpdateStatusBar() 统一操作。
通知定时器¶
构造时创建一个 esp_timer,当 ShowNotification() 被调用时,它先将 status_label_ 隐藏、显示 notification_label_,然后启动一次性定时器。定时器到期后恢复 status_label_ 并隐藏通知标签——这是一个经典的"Toast"模式。
Sources: lvgl_display.cc
UpdateStatusBar() 的状态聚合¶
该方法每秒钟被 Application 主循环的时钟定时器触发一次(见 esp_timer_start_periodic(clock_timer_handle_, 1000000)),依次完成:
- 静音检测:比较
AudioCodec::output_volume()是否为 0,更新静音图标
- 时钟刷新:空闲状态下每 10 秒用
strftime更新时间显示(格式%H:%M)
- 电池状态:获取电量百分比与充放电状态,映射到 Font Awesome 五段电池图标;电量低于 20% 且非充电时弹出低电量警告
- 网络图标:每 10 秒通过
Board::GetNetworkStateIcon()获取网络状态图标
电池与网络的更新前会通过 esp_pm_lock_acquire(pm_lock_) 持有电源管理锁,避免 ESP32 在更新期间降频导致 SPI/I2C 通信异常。
Sources: lvgl_display.cc, application.cc
省电模式与截图¶
SetPowerSaveMode() 在进入省电时设置 "sleepy" 表情并清空聊天消息,退出时恢复 "neutral"。SnapshotToJpeg() 利用 LVGL 的 lv_snapshot_take() 获取当前屏幕的 RGB565 缓冲区,再通过回调式 JPEG 编码器(image_to_jpeg_cb)输出压缩数据——这用于向服务器回传设备当前画面。
Sources: lvgl_display.cc
四、第三层 A:OledDisplay — 低资源单色屏方案¶
OledDisplay 面向 SSD1306 驱动的 128×64 或 128×32 单色 OLED,通过 I2C 总线通信。其构造过程分为两步:
步骤 1:LVGL 端口初始化
lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG();
port_cfg.task_priority = 1;
port_cfg.task_stack = 6144;
lvgl_port_init(&port_cfg);
步骤 2:注册单色显示
lvgl_port_display_cfg_t 中 .monochrome = true、.buffer_size = width_ * height_(全帧缓冲)是关键差异。LVGL 会将渲染结果转换为 1bpp 位图通过 I2C 刷新到 SSD1306。
Sources: oled_display.cc
双布局适配:128×64 vs 128×32¶
SetupUI() 根据 height_ 分发到两种布局:
| 分辨率 | 布局结构 | 差异点 |
|---|---|---|
| 128×64 | top_bar_(16px 图标栏) → content_(左右分栏) |
有独立的顶部图标栏和左右内容区 |
| 128×32 | container_(flex) → side_bar_(图标) + content_(文字) |
图标与文字水平排列,无独立顶部栏 |
128×64 布局将网络、静音、电池图标置于 16 像素高的 top_bar_,下方 content_ 分为 content_left_(表情图标)和 content_right_(聊天消息),实现了在低像素密度下的清晰信息分层。
Sources: oled_display.cc
完整集成示例¶
以 xingzhi-cube-0.96oled-wifi 开发板为例,其 InitializeSsd1306Display() 方法完整展示了从 I2C 总线初始化到 OledDisplay 实例化的流程:
// 1. 创建 I2C 主总线
i2c_master_bus_config_t bus_config = {
.sda_io_num = DISPLAY_SDA_PIN,
.scl_io_num = DISPLAY_SCL_PIN,
};
i2c_new_master_bus(&bus_config, &display_i2c_bus_);
// 2. 创建 I2C 面板 IO(SSD1306 地址 0x3C,400kHz)
esp_lcd_panel_io_i2c_config_t io_config = {
.dev_addr = 0x3C, .scl_speed_hz = 400 * 1000,
};
esp_lcd_new_panel_io_i2c_v2(display_i2c_bus_, &io_config, &panel_io_);
// 3. 安装 SSD1306 驱动
esp_lcd_new_panel_ssd1306(panel_io_, &panel_config, &panel_);
// 4. 创建 OledDisplay 实例
display_ = new OledDisplay(panel_io_, panel_,
DISPLAY_WIDTH, DISPLAY_HEIGHT,
DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y);
Sources: xingzhi-cube-0.96oled-wifi.cc
五、第三层 B:LcdDisplay — 彩色屏与多接口适配¶
LcdDisplay 是彩色 LCD 的基类,提供两套 UI 布局(通过 #if CONFIG_USE_WECHAT_MESSAGE_STYLE 编译期切换)、GIF 动画支持、主题切换等丰富功能。其三个子类分别对应三种硬件接口协议:
| 子类 | 接口协议 | 关键差异 |
|---|---|---|
SpiLcdDisplay |
SPI(4 线) | lvgl_port_add_disp(),单缓冲,40MHz SPI |
RgbLcdDisplay |
RGB 并口(24bit) | lvgl_port_add_disp_rgb(),双缓冲+避撕裂,50ms 刷新周期 |
MipiLcdDisplay |
MIPI DSI | lvgl_port_add_disp_dsi(),软件旋转,50 行缓冲 |
三种显示在构造时均执行相同的初始化序列:先绘制白色背景清屏 → lv_init() → lvgl_port_init() → 配置 lvgl_port_display_cfg_t → 注册显示 → 设置偏移量(lv_display_set_offset)。
Sources: lcd_display.cc
主题系统:LvglTheme 与 LvglThemeManager¶
每个 LcdDisplay 构造时调用 InitializeLcdThemes(),注册 light(浅色) 和 dark(深色) 两套主题。主题对象包含以下可配置属性:
LvglTheme:
├── 颜色: background, text, chat_background, user_bubble,
│ assistant_bubble, system_bubble, border, low_battery
├── 字体: text_font, icon_font, large_icon_font
├── 背景图片: background_image
├── 表情集合: emoji_collection (Twemoji32 / Twemoji64)
└── 间距倍率: spacing (默认 2px 基准)
主题选择持久化在 NVS 的 "display" 命名空间下,键为 "theme"。SetTheme() 不仅保存设置,还会实时更新屏幕所有控件的颜色、字体和背景图——这是一种运行时热切换设计。
LvglThemeManager 是单例(GetInstance()),维护 std::map<std::string, LvglTheme*> 注册表,供所有显示实例查询。
Sources: lcd_display.cc, lvgl_theme.h
表情与 GIF 动画¶
SetEmotion() 的执行逻辑是一个三级回退链:
flowchart TD
A[SetEmotion emotion] --> B{emoji_collection 中有对应图片?}
B -->|有| C{图片是 GIF?}
C -->|是| D[创建 LvglGif 控制器 → 开始播放]
C -->|否| E[lv_image_set_src 静态显示]
B -->|无| F{font_awesome 中有对应 UTF8 字符?}
F -->|有| G[lv_label_set_text 显示字符合成表情]
F -->|无| H[不显示任何内容]
LvglGif 封装了 gifdec.c 解码器,通过 SetFrameCallback 回调在 LVGL 定时器中逐帧更新 emoji_image_ 的图像源。当切换到新表情时,旧的 GIF 控制器会被停止并销毁,避免内存泄漏。
表情资源来自 EmojiCollection,项目提供了两套尺寸:Twemoji32(32×32px,21 种表情)和 Twemoji64(64×64px),均为编译期链接的外部 lv_image_dsc_t 常量。
Sources: lcd_display.cc, emoji_collection.cc
两套 UI 布局对照¶
CONFIG_USE_WECHAT_MESSAGE_STYLE 开关控制编译时布局选择:
| 维度 | WeChat 风格(启用) | 默认风格(禁用) |
|---|---|---|
| 聊天区域 | content_(flex column 容器,最大 20~40 条消息气泡) |
bottom_bar_(底部单行/多行标签) |
| 消息显示 | 按 role 区分的左/中/右对齐气泡 | 单行滚动文字 |
| 表情位置 | 顶部居中,消息存在时隐藏 | 屏幕中央 emoji_box_ |
| 图片预览 | 嵌入消息流的气泡 | preview_image_ 居中覆盖层,5 秒自动消失 |
| 主题色 | 用户气泡绿底、助手灰底、系统透明 | 无气泡概念 |
WeChat 风格支持最多 MAX_MESSAGES 条历史消息(ESP32S3 为 20 条,ESP32P4 为 40 条),超出时自动删除最早的消息。相邻的系统消息会被合并(后一条覆盖前一条),避免重复提示。
默认风格的 bottom_bar_ 有两种子模式:CONFIG_USE_MULTILINE_CHAT_MESSAGE 启用时使用 LV_LABEL_LONG_WRAP 多行显示且高度自适应,禁用时使用 LV_LABEL_LONG_SCROLL_CIRCULAR 单行横向滚动。
Sources: lcd_display.cc, lcd_display.cc
开发板集成示例¶
以 bread-compact-wifi-lcd 为例,InitializeLcdDisplay() 展示了 SPI LCD 的完整初始化流程:
// 1. 初始化 SPI 总线
spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO);
// 2. 创建 SPI 面板 IO(40MHz,8bit 指令/参数)
esp_lcd_new_panel_io_spi(SPI3_HOST, &io_config, &panel_io);
// 3. 安装 LCD 驱动(ST7789 / ILI9341 / GC9A01 三选一)
esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel);
// 4. 面板初始化序列
esp_lcd_panel_reset(panel);
esp_lcd_panel_init(panel);
esp_lcd_panel_invert_color(panel, DISPLAY_INVERT_COLOR);
esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY);
esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y);
// 5. 创建 SpiLcdDisplay(内部完成 LVGL 初始化)
display_ = new SpiLcdDisplay(panel_io, panel,
DISPLAY_WIDTH, DISPLAY_HEIGHT,
DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y,
DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY);
config.h 中的 LCD_TYPE_* 宏通过编译期条件选择驱动芯片型号,而 DISPLAY_OFFSET_X/Y 等参数处理不同面板的物理偏移。
Sources: compact_wifi_board_lcd.cc, config.h
六、并行路径:EmoteDisplay — 非 LVGL 的表情动画引擎¶
EmoteDisplay(namespace emote)是一条完全独立于 LVGL 的渲染路径。它使用 emote 库(expression_emote.h)作为渲染引擎,直接绕过 LVGL 的控件树体系。当编译配置启用了 CONFIG_USE_EMOTE_MESSAGE_STYLE 时,display.h 不会定义 HAVE_LVGL 宏,因此整个 LVGL 路径被排除。
EmoteDisplay 的初始化流程:
InitializeEmote()
├── 创建 emote_config_t(30fps, 双缓冲, swap=true)
├── emote_init() 启动内部渲染任务(优先级 5, 栈 6KB)
├── 注册 FlushCallback → esp_lcd_panel_draw_bitmap()
└── 注册 OnFlushIoReady → emote_notify_flush_finished()
与 LVGL 体系的关键差异:
| 维度 | LVGL 路径 | Emote 路径 |
|---|---|---|
| 渲染机制 | LVGL 控件树 + lvgl_port 刷新 | emote 库内部状态机 + 直接 draw_bitmap |
| 表情实现 | Twemoji 静态图片 + GIF 动画 | emote 内置表情动画系统 |
| 状态传递 | SetStatus("聆听中") |
emote_set_event_msg(EMOTE_MGR_EVT_LISTEN) |
| 消息显示 | LVGL Label 文本渲染 | emote_set_event_msg(EMOTE_MGR_EVT_SPEAK) |
| 线程同步 | lvgl_port_lock/unlock (互斥量) |
无外部锁(emote 内部任务管理) |
| 资源占用 | 较高(LVGL 堆内存 + 帧缓冲) | 较低(emote 精简渲染) |
EmoteDisplay 还提供了动画对话接口 StopAnimDialog() 和 InsertAnimDialog(),允许插入带有持续时间的表情动画序列——这是 LVGL 路径中不存在的高级表情功能。
Sources: emote_display.cc, emote_display.cc, display.h
七、全生命周期:从构造到销毁¶
以下时序图展示了一个典型 LCD 开发板从开机到 UI 就绪的完整调用链:
sequenceDiagram
participant Board as Board 构造函数
participant Lcd as LcdDisplay
participant LVGL as LVGL Port
participant App as Application
Board->>Board: InitializeSpi() / InitializeLcdDisplay()
Board->>Lcd: new SpiLcdDisplay(panel_io, panel, w, h, ...)
Lcd->>Lcd: InitializeLcdThemes() → 注册 light/dark
Lcd->>Lcd: 读取 NVS "display/theme" → 设置 current_theme_
Lcd->>Lcd: 绘制白色清屏
Lcd->>LVGL: lv_init() + lvgl_port_init()
Lcd->>LVGL: lvgl_port_add_disp(&cfg) → display_
Lcd-->>Board: 返回 display*
Board-->>App: GetDisplay() 返回 display*
App->>Lcd: display->SetupUI()
Lcd->>Lcd: 构建控件树(top_bar, status_bar, emoji_box, bottom_bar)
App->>Lcd: display->SetChatMessage("system", user_agent)
App->>App: 启动 clock_timer → 每秒 UpdateStatusBar()
销毁时遵循 从子到父、从 LVGL 到硬件 的顺序:先删除 LVGL 控件对象(lv_obj_del),再 lv_display_delete,最后 esp_lcd_panel_del 和 esp_lcd_panel_io_del。LcdDisplay 还会额外停止 GIF 控制器和预览定时器。
Sources: application.cc, lcd_display.cc
八、扩展指南:添加新显示类型¶
若需适配新的显示硬件(如 ePaper 墨水屏),需要以下步骤:
- 实现新的 Display 子类:继承
Display(不走 LVGL)或LvglDisplay(走 LVGL),实现Lock/Unlock和 UI 方法
- 在 Board 子类中实例化:在
GetDisplay()中返回新显示对象的指针
- 若使用 LVGL:调用
lvgl_port_add_disp()注册显示,确保lvgl_port_display_cfg_t配置正确
- 若不走 LVGL:自行管理帧缓冲和刷新回调(参考 EmoteDisplay 的
OnFlushCallback模式)
项目已有的三层抽象使得新增显示类型只需关注硬件差异,无需修改 Application 或 Board 基类的任何代码。
阅读建议:本文档描述了显示系统的静态架构。若要理解显示内容如何随设备状态动态变化,请继续阅读 Application 主控与事件驱动循环;若需了解 Board 层如何组织显示硬件配置,请参阅 Board 抽象层设计:统一管理 70+ 开发板的秘诀;若想自定义开发板的显示配置,可参考 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD。
LED 指示灯系统:单灯、灯环与 GPIO 控制¶
小智 AI 聊天机器人的 LED 指示灯系统是一套基于多态设计的硬件抽象,为不同开发板的指示灯硬件提供统一的状态指示接口。系统支持三种物理 LED 类型——WS2812 可寻址单灯、WS2812 可寻址灯环、以及普通 GPIO 直驱 LED,并通过设备状态机的回调机制实现全自动的状态驱动灯光变化。
系统架构总览¶
LED 系统的核心设计遵循「面向接口编程」原则:所有 LED 实现类继承自抽象基类 Led,Board 层通过覆写 GetLed() 返回具体实例,Application 层则在状态变化时统一调用 OnStateChanged() 完成灯光更新。这种设计使得上层代码完全不感知底层 LED 的物理形态差异。
classDiagram
class Led {
<<abstract>>
+OnStateChanged()* void
}
class NoLed {
+OnStateChanged() void
}
class SingleLed {
-led_strip_handle_t led_strip_
-uint8_t r_, g_, b_
-esp_timer_handle_t blink_timer_
+OnStateChanged() void
-SetColor(r,g,b) void
-TurnOn() void
-TurnOff() void
-Blink(times, interval) void
-StartContinuousBlink(interval) void
}
class CircularStrip {
-led_strip_handle_t led_strip_
-vector~StripColor~ colors_
-esp_timer_handle_t strip_timer_
+OnStateChanged() void
+SetAllColor(color) void
+SetSingleColor(index, color) void
+SetMultiColors(colors) void
+Blink(color, interval) void
+Breathe(low, high, interval) void
+Scroll(low, high, length, interval) void
+FadeOut(interval) void
+SetBrightness(default, low) void
}
class GpioLed {
-ledc_channel_config_t ledc_channel_
-uint32_t duty_
-esp_timer_handle_t blink_timer_
+OnStateChanged() void
+TurnOn() void
+TurnOff() void
+SetBrightness(brightness) void
-StartFadeTask() void
-OnFadeEnd() void
}
class Board {
+GetLed() Led*
}
class Application {
+HandleStateChangedEvent() void
}
class DeviceStateMachine {
+TransitionTo(state) bool
+AddStateChangeListener(cb) int
}
Led <|-- NoLed
Led <|-- SingleLed
Led <|-- CircularStrip
Led <|-- GpioLed
Board --> Led : creates & returns
Application --> Board : GetLed()
Application --> DeviceStateMachine : listens to
DeviceStateMachine --> Application : MAIN_EVENT_STATE_CHANGED
Application --> Led : OnStateChanged()
图中展示的调用链路是:「设备状态机触发状态迁移 → Application 收到 MAIN_EVENT_STATE_CHANGED 事件 → HandleStateChangedEvent() 中调用 Board::GetInstance().GetLed()->OnStateChanged() → 各 LED 实现根据当前状态执行对应的灯光效果」。
Sources: led.h | application.cc
Led 抽象基类与 NoLed 空实现¶
Led 基类极其精简,仅声明一个纯虚方法 OnStateChanged()。这是设计上的刻意克制——LED 系统不需要复杂的控制接口,它唯一的外部驱动源就是「设备状态发生了变化」。
class Led {
public:
virtual ~Led() = default;
virtual void OnStateChanged() = 0;
};
NoLed 是 Led 的空实现,其 OnStateChanged() 为空函数体。当开发板没有配备指示灯硬件时(BUILTIN_LED_GPIO 被定义为 GPIO_NUM_NC),Board::GetLed() 的默认实现返回一个 static NoLed 实例,确保上层代码无需空指针检查即可安全调用。
SingleLed:WS2812 单灯控制¶
SingleLed 适用于绝大多数只配备一颗 WS2812 可寻址 RGB LED 的开发板。它基于 ESP-IDF 的 led_strip 组件,通过 RMT(Remote Control)外设产生符合 WS2812 时序的控制信号。
硬件初始化¶
构造函数接收一个 GPIO 引脚号,配置 RMT 设备并关联到 LED Strip 句柄。关键参数包括:
| 参数 | 值 | 说明 |
|---|---|---|
color_component_format |
LED_STRIP_COLOR_COMPONENT_FMT_GRB |
WS2812 的颜色顺序是 GRB,非传统 RGB |
led_model |
LED_MODEL_WS2812 |
芯片型号 |
resolution_hz |
10 MHz | RMT 分辨率 |
如果传入的 GPIO 是 GPIO_NUM_NC(Not Connected),则仅输出警告日志而不初始化硬件,后续所有操作静默忽略——这保证了与 NoLed 一致的容错行为。
Sources: single_led.cc
亮度常量与分层策略¶
SingleLed 定义了三档亮度级别,在不同设备状态下交替使用,以直观区分设备模式:
| 常量 | 数值 | 用途 |
|---|---|---|
DEFAULT_BRIGHTNESS |
4 | 启动、配网、连接中的通用亮度(蓝色调) |
HIGH_BRIGHTNESS |
16 | 语音检测到时的强调亮度(红色调) |
LOW_BRIGHTNESS |
2 | 语音空闲时的待机亮度(红色调) |
这些值是 8-bit 通道值(0-255),DEFAULT_BRIGHTNESS=4 看似很低,但 WS2812 在暗环境中已经足够醒目,同时避免了刺眼的问题。
Sources: single_led.cc
闪烁机制¶
闪烁通过 ESP 高精度定时器(esp_timer)实现周期性回调。核心变量是 blink_counter_,每次定时器触发时递减:
blink_counter_ = times × 2 // 每个"次"闪烁 = 一次亮 + 一次灭
在 OnBlinkTimer() 中,用 blink_counter_ & 1 判断当前是亮周期还是灭周期。当计数器归零时自动停止定时器。BLINK_INFINITE(定义为 -1)用于无限闪烁场景(如 kDeviceStateStarting),此时 blink_counter_ 永远为负奇数/偶数交替,不会自动停止。
Sources: single_led.cc
状态到灯光的完整映射¶
OnStateChanged() 是 SingleLed 的核心方法。它通过 Application::GetInstance().GetDeviceState() 获取当前设备状态,然后执行对应的颜色和闪烁策略:
| 设备状态 | 颜色 | 行为 | 语义 |
|---|---|---|---|
Starting |
蓝色 (0,0,4) | 100ms 周期连续闪烁 | 系统正在启动,等待网络就绪 |
WifiConfiguring |
蓝色 (0,0,4) | 500ms 周期连续闪烁 | 进入配网模式,等待用户操作 |
Idle |
— | 熄灭 | 待机状态,无任务 |
Connecting |
蓝色 (0,0,4) | 常亮 | 连接服务器中 |
Listening |
红 (16,0,0) / 红 (2,0,0) | 常亮,亮度随 VAD 变化 | 拾音中,有声/无声亮度不同 |
AudioTesting |
同上 | 常亮 | 音频测试模式,行为同 Listening |
Speaking |
绿色 (0,4,0) | 常亮 | 正在播放 TTS 语音 |
Upgrading |
绿色 (0,4,0) | 100ms 周期连续闪烁 | OTA 固件升级中 |
Activating |
绿色 (0,4,0) | 500ms 周期连续闪烁 | 激活验证中 |
Listening 状态的独特之处在于它进一步调用了 app.IsVoiceDetected() 来判断当前是否有语音活动,从而在 HIGH_BRIGHTNESS(有声)和 LOW_BRIGHTNESS(无声)之间动态切换。这个刷新由 MAIN_EVENT_VAD_CHANGE 事件触发,而非状态机迁移触发。
Sources: single_led.cc | application.cc
CircularStrip:WS2812 灯环控制¶
CircularStrip 面向配备多颗 WS2812 LED 的开发板(常见如 8 灯、3 灯环形排列),提供丰富的动画效果。它同样基于 led_strip 组件,但 max_leds 参数决定灯珠数量。
架构差异:定时器回调 vs 直接控制¶
与 SingleLed 在 OnBlinkTimer() 中硬编码亮/灭逻辑不同,CircularStrip 采用更通用的设计——通过 std::function<void()> strip_callback_ 存储当前动画的回调函数,定时器每次触发时加锁执行该回调。这种「策略模式」使得 Blink / Breathe / Scroll / FadeOut 等动画可以共享同一套定时器基础设施。
Sources: circular_strip.cc
动画能力一览¶
| 方法 | 效果 | 核心参数 |
|---|---|---|
SetAllColor(color) |
全部灯珠设为同一颜色 | StripColor |
SetSingleColor(index, color) |
指定索引灯珠设色 | uint8_t index, StripColor |
SetMultiColors(colors) |
逐一设色(不同灯珠不同色) | std::vector<StripColor> |
Blink(color, interval_ms) |
全部灯珠同步闪烁 | 颜色 + 间隔 |
Breathe(low, high, interval_ms) |
全部灯珠呼吸渐变 | 低色 → 高色循环 |
Scroll(low, high, length, interval_ms) |
跑马灯效果 | 背景色 + 高亮色 + 高亮长度 |
FadeOut(interval_ms) |
全部灯珠渐灭(RGB 逐次 ÷2) | 间隔 |
Rainbow(low, high, interval_ms) |
彩虹渐变(内部方法,未暴露) | 低色 → 高色 |
Scroll 动画是最复杂的:它将 length 个连续灯珠设为高亮色,其余为低亮色,然后每帧偏移一位(offset = (offset + 1) % max_leds_),形成旋转流动效果。
Sources: circular_strip.h | circular_strip.cc
状态映射¶
CircularStrip 对设备状态的响应比 SingleLed 更丰富:
| 设备状态 | 效果 | 颜色 |
|---|---|---|
Starting |
Scroll 跑马灯(长度=3, 100ms) | 蓝紫 (0, low, default) 在暗背景上滚动 |
WifiConfiguring |
Blink 闪烁(500ms) | 蓝紫 |
Idle |
FadeOut 渐灭(50ms/步) | 从当前色逐渐 ÷2 直到全灭 |
Connecting |
全灯常亮 | 蓝紫 |
Listening / AudioTesting |
全灯常亮 | 红色 |
Speaking |
全灯常亮 | 绿色 |
Upgrading |
Blink 闪烁(100ms) | 绿色 |
Activating |
Blink 闪烁(500ms) | 绿色 |
SetBrightness(default, low) 方法允许运行时调整 default_brightness_ 和 low_brightness_,并通过 LedStripControl 暴露为 MCP 工具供大模型远程调节。
Sources: circular_strip.cc
GpioLed:GPIO PWM LED 控制¶
GpioLed 是为普通单色 LED(非 WS2812 可寻址类型)设计的驱动类。它使用 ESP32 的 LEDC(LED PWM Controller) 外设产生 PWM 信号,通过调节占空比实现亮度控制。
为什么需要 GpioLed?¶
WS2812 需要专用的 RMT 通道和精确的时序,而大量低成本开发板仅提供一个直接连接到 GPIO 的普通 LED。GpioLed 填补了这个空白——它让这些开发板也能享受完整的状态指示能力,包括亮度变化、闪烁和呼吸效果。
Sources: gpio_led.h
LEDC 配置¶
构造函数初始化 LEDC 定时器和通道:
| 参数 | 值 | 说明 |
|---|---|---|
duty_resolution |
LEDC_TIMER_13_BIT |
13 位分辨率,占空比范围 0-8191 |
freq_hz |
4000 Hz | 4kHz PWM 频率,远超肉眼可感知的闪烁阈值 |
speed_mode |
LEDC_LOW_SPEED_MODE |
低速模式(对 LED 控制足够) |
output_invert |
可配置 | 支持共阳极 LED(低电平点亮) |
构造函数提供三个重载版本,逐步增加可配置性:最简单的仅需 GPIO;中等的可指定输出反相;完整的可指定 LEDC 定时器和通道编号(避免与其他 PWM 外设冲突)。
Sources: gpio_led.cc
亮度映射¶
亮度值以百分比(0-100)表示,内部转换为占空比:
void GpioLed::SetBrightness(uint8_t brightness) {
if (brightness == 100) {
duty_ = LEDC_DUTY; // 8191, 即 100%
} else {
duty_ = brightness * LEDC_DUTY / 100;
}
}
不同设备状态使用不同亮度级别(均为百分比值):
| 状态 | 亮度 (%) | 行为 |
|---|---|---|
Starting / WifiConfiguring |
50 | 连续闪烁 |
Idle |
5 | 微亮常亮(类似呼吸灯待机) |
Connecting |
50 | 常亮 |
Listening (有/无声) |
100 / 10 | 呼吸渐变 |
Speaking |
75 | 常亮 |
Upgrading |
25 | 连续闪烁 |
Activating |
35 | 连续闪烁 |
Sources: gpio_led.cc | gpio_led.cc
呼吸渐变的硬件实现¶
GpioLed 的呼吸效果利用了 LEDC 硬件的 fade 功能——这是一种硬件自动渐变机制,无需 CPU 持续干预。调用 ledc_set_fade_with_time() 设置目标占空比和渐变时间后,ledc_fade_start() 启动硬件自动过渡。当渐变完成时,硬件触发 LEDC_FADE_END_EVT 中断,回调 FadeCallback(这是一个 IRAM_ATTR 标记的 ISR 安全函数),该回调通过 FreeRTOS 任务通知唤醒 EventTask,再由 EventTask 调用 OnFadeEnd() 翻转渐变方向(亮→灭→亮→灭……),形成连续的呼吸循环。
sequenceDiagram
participant App as Application
participant GL as GpioLed
participant LEDC as LEDC Hardware
participant ISR as FadeCallback(ISR)
participant Task as EventTask
App->>GL: OnStateChanged() → StartFadeTask()
GL->>LEDC: ledc_set_fade_with_time(LEDC_DUTY, 1000ms)
GL->>LEDC: ledc_fade_start()
Note over LEDC: 硬件自动渐变 (1000ms)
LEDC-->>ISR: LEDC_FADE_END_EVT 中断
ISR->>Task: xTaskNotifyFromISR()
Task->>GL: OnFadeEnd()
GL->>LEDC: ledc_set_fade_with_time(0, 1000ms) → 反向渐变
Note over LEDC: 硬件自动渐变 (1000ms)
LEDC-->>ISR: LEDC_FADE_END_EVT 中断
ISR->>Task: xTaskNotifyFromISR()
Task->>GL: OnFadeEnd()
GL->>LEDC: ledc_set_fade_with_time(LEDC_DUTY, 1000ms) → 循环
这种设计将 CPU 占用降到最低——在 1 秒的渐变过程中,CPU 完全空闲,只在渐变结束时处理一次中断。
Sources: gpio_led.cc | gpio_led.cc
与状态机的集成¶
LED 系统通过两条路径接收更新通知。
主路径:状态变化事件¶
Application 在初始化时向 DeviceStateMachine 注册了一个状态变化监听器:
state_machine_.AddStateChangeListener([this](DeviceState old_state, DeviceState new_state) {
xEventGroupSetBits(event_group_, MAIN_EVENT_STATE_CHANGED);
});
当 HandleStateChangedEvent() 被触发时,它首先调用 led->OnStateChanged(),然后再处理 UI 更新、语音处理启停等。LED 更新被放在最前面,确保视觉反馈延迟最小。
Sources: application.cc | application.cc
辅路径:VAD 变化事件¶
在 Listening 状态下,VAD(Voice Activity Detection,语音活动检测)的变化不会触发状态迁移(状态仍是 Listening),但 LED 需要通过亮度变化来反馈。因此,在 Run() 主循环中额外监听 MAIN_EVENT_VAD_CHANGE 并直接调用 led->OnStateChanged():
if (bits & MAIN_EVENT_VAD_CHANGE) {
if (GetDeviceState() == kDeviceStateListening) {
auto led = Board::GetInstance().GetLed();
led->OnStateChanged();
}
}
SingleLed 利用 app.IsVoiceDetected() 的返回值在 HIGH_BRIGHTNESS 和 LOW_BRIGHTNESS 之间切换颜色,实现了「说话时灯更亮」的交互效果。
Sources: application.cc
设备状态与 LED 行为完整映射¶
下表综合了三种 LED 实现在所有设备状态下的行为。注意 NoLed 在所有状态下均无动作。
| 状态枚举 | SingleLed | CircularStrip | GpioLed |
|---|---|---|---|
Unknown |
— | — | — |
Starting |
蓝闪 100ms | 蓝紫跑马灯 100ms | 50% 亮闪 100ms |
WifiConfiguring |
蓝闪 500ms | 蓝紫闪烁 500ms | 50% 亮闪 500ms |
Idle |
熄灭 | 渐灭 (FadeOut) | 5% 微亮常亮 |
Connecting |
蓝常亮 | 蓝紫常亮 | 50% 常亮 |
Listening |
红亮 (VAD 切换亮度) | 红常亮 | 呼吸渐变 (VAD 切换亮度) |
AudioTesting |
同 Listening | 红常亮 | 呼吸渐变 |
Speaking |
绿常亮 | 绿常亮 | 75% 常亮 |
Upgrading |
绿闪 100ms | 绿闪烁 100ms | 25% 亮闪 100ms |
Activating |
绿闪 500ms | 绿闪烁 500ms | 35% 亮闪 500ms |
FatalError |
— | — | — |
三种实现的颜色语义是一致的:蓝色系 = 网络/系统就绪中,红色系 = 拾音/音频,绿色系 = 播放/升级。这种一致性降低了用户在更换硬件平台时的认知负担。
开发板集成模式¶
开发板通过覆写 Board::GetLed() 来接入 LED 系统。以下是三种典型集成模式。
模式一:静态 SingleLed(最常用)¶
适用于大多数配备单颗 WS2812 的开发板。使用 static 局部变量确保单例:
// 在开发板 .cc 文件中
#include "led/single_led.h"
virtual Led* GetLed() override {
static SingleLed led(BUILTIN_LED_GPIO);
return &led;
}
BUILTIN_LED_GPIO 在 config.h 中定义。如设置为 GPIO_NUM_NC,SingleLed 会降级为无操作(类似 NoLed)。
Sources: compact_wifi_board.cc | xmini_c3_board.cc
模式二:成员指针 CircularStrip¶
适用于配备多灯珠灯环的开发板。LED 对象作为 Board 成员,在构造过程中创建,并可选地包装 LedStripControl 以暴露 MCP 控制接口:
// 在开发板 .cc 文件中
#include "led/circular_strip.h"
#include "led_strip_control.h"
class KevinBoxBoard : public WifiBoard {
private:
CircularStrip* led_strip_;
// ...
void InitializeTools() {
led_strip_ = new CircularStrip(BUILTIN_LED_GPIO, 8); // 8 灯环
new LedStripControl(led_strip_); // 注册 MCP 工具
}
public:
virtual Led* GetLed() override {
return led_strip_;
}
};
LedStripControl 在构造时自动向 McpServer 注册 6 个远程控制工具(见下一节),使大模型可以动态调整灯光亮度、颜色和动画。
Sources: kevin_c3_board.cc | df_k10_board.cc
模式三:不覆写(默认 NoLed)¶
如果开发板没有指示灯硬件,完全不需要覆写 GetLed()。Board 基类默认返回 NoLed 实例,系统正常运行且无额外开销。
Sources: board.cc
MCP 远程控制(LedStripControl)¶
LedStripControl 是一个辅助类,它将 CircularStrip 的能力通过 MCP 协议暴露给云端大模型,使 AI 可以直接控制物理 LED 灯环。目前仅在 kevin-c3 和 df-k10 开发板上使用。
它注册的 MCP 工具如下:
| 工具路径 | 功能 | 参数 |
|---|---|---|
self.led_strip.get_brightness |
获取亮度等级 (0-8) | 无 |
self.led_strip.set_brightness |
设置亮度等级 (0-8) | level (int, 0-8) |
self.led_strip.set_single_color |
设置单颗灯珠颜色 | index (int), red, green, blue (0-255) |
self.led_strip.set_all_color |
设置全部灯珠颜色 | red, green, blue (0-255) |
self.led_strip.blink |
闪烁效果 | red, green, blue, interval (ms) |
self.led_strip.scroll |
跑马灯效果 | red, green, blue, length, interval |
亮度等级映射为非线性的指数关系:brightness = 2^level - 1,即等级 0=0(关),等级 4=15(默认),等级 8=255(最大)。这种映射符合人眼对亮度的对数感知特性。亮度设置通过 Settings 持久化到 NVS,断电重启后保持。
Sources: led_strip_control.cc
小结与阅读指引¶
LED 系统是小智项目中「简单但完整」的典范:基类仅一个方法,却通过多态支持了三种截然不同的硬件形态;通过事件驱动模型实现了与设备状态机的松耦合集成;通过 LedStripControl 将物理灯光纳入了 MCP 可控范围。理解 LED 系统后,建议按以下路径继续深入:
- 要理解状态机如何驱动 LED 更新,阅读 设备状态机:状态定义与合法转换规则
- 要了解 Board 层如何统一管理 LED 和其他外设,阅读 Board 抽象层设计:统一管理 70+ 开发板的秘诀
- 要了解 LED 控制如何通过 MCP 协议暴露给大模型,阅读 MCP 工具注册与调用:PropertyList 参数校验与回调机制
- 如果你正在适配自己的开发板,阅读 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD
音频管线¶
AudioService 核心管线:三任务模型与数据队列¶
AudioService 是小智 AI 聊天机器人音频子系统的中央调度器,负责协调从麦克风采集到云端大模型传输、再到扬声器播放的完整音频数据流。本文档深入剖析其三任务并发模型、六大数据队列、双向数据管线以及配套的电源管理机制,帮助高级开发者理解音频处理的并行架构设计。
架构全景:三任务、六队列、双数据流¶
AudioService 的核心设计思想是将音频管线中计算特征差异巨大的阶段分配给独立的 FreeRTOS 任务,通过线程安全的队列进行解耦,从而实现高效的并行处理。系统运行 三个 FreeRTOS 任务,并维护 六个关键数据队列,支撑上行(采集编码发送)和下行(接收解码播放)两条数据流。
以下是完整的架构视图(实线框为任务,虚线框为队列,箭头表示数据流向):
graph TD
subgraph Hardware["硬件层"]
MIC[🎤 麦克风] -->|I2S RX| CODEC[AudioCodec]
CODEC -->|I2S TX| SPK[🔊 扬声器]
end
subgraph AudioInputTask["AudioInputTask (Priority 8, Core 0)"]
READ[ReadAudioData<br/>重采样至 16kHz]
READ --> WW[WakeWord<br/>唤醒词检测]
READ --> AP[AudioProcessor<br/>AEC / VAD / NS]
end
subgraph OpusCodecTask["OpusCodecTask (Priority 2)"]
ENC[Opus Encoder<br/>PCM → Opus 压缩]
DEC[Opus Decoder<br/>Opus → PCM 解压]
end
subgraph AudioOutputTask["AudioOutputTask (Priority 4)"]
PLAY[OutputData<br/>PCM 写入 I2S]
end
subgraph Queues["数据队列层 (audio_queue_mutex_ + cv)"]
EQ[audio_encode_queue_<br/>MAX=2 PCM帧]
SQ[audio_send_queue_<br/>MAX=40 Opus包]
DQ[audio_decode_queue_<br/>MAX=40 Opus包]
PQ[audio_playback_queue_<br/>MAX=2 PCM帧]
TQ[audio_testing_queue_<br/>MAX=166 Opus包]
TSQ[timestamp_queue_<br/>MAX=3 时间戳]
end
subgraph AppLayer["Application 主任务 (Priority 10)"]
SEND[PopPacketFromSendQueue<br/>→ Protocol::SendAudio]
RECV[PushPacketToDecodeQueue<br/>← Protocol::OnIncomingAudio]
end
subgraph Server["云端"]
CLOUD[🤖 大模型服务器]
end
AP -->|PCM帧| EQ
EQ -->|PCM帧| ENC
ENC -->|Opus包| SQ
SQ -->|Opus包| SEND
SEND -->|网络| CLOUD
CLOUD -->|网络| RECV
RECV -->|Opus包| DQ
DQ -->|Opus包| DEC
DEC -->|PCM帧| PQ
PQ -->|PCM帧| PLAY
READ -->|10ms PCM chunk| WW
READ -->|10ms PCM chunk| AP
WW -.->|唤醒词回调| AppLayer
AP -.->|VAD状态回调| AppLayer
CODEC --- READ
PLAY --- CODEC
图中揭示了几个关键设计抉择:(1)编解码任务合并为一个 OpusCodecTask——因为编码和解码操作的 CPU 密集特征相似,共用任务可以减少上下文切换;(2)发送队列和接收队列的容量远大于中间队 列(40 vs 2),因为 Opus 压缩包远小于 PCM 原始帧,在同等内存约束下可以缓冲更多的网络传输单元。
Sources: audio_service.h
三任务模型深入解析¶
AudioInputTask:音频采集与预处理¶
AudioInputTask 是整个音频管线的起点,优先级 8(使用音频处理器时)或 8(不使用音频处理器时),在使用音频处理器时被固定在 Core 0 上运行,栈大小分别为 2048×3 或 2048×2 字。其核心职责是从 AudioCodec 读取原始 PCM 数据,并根据当前激活的子系统(EventGroup 控制)将数据路由到 WakeWord 或 AudioProcessor。
// AudioInputTask 核心循环(简化)
while (true) {
EventBits_t bits = xEventGroupWaitBits(event_group_,
AS_EVENT_AUDIO_TESTING_RUNNING |
AS_EVENT_WAKE_WORD_RUNNING |
AS_EVENT_AUDIO_PROCESSOR_RUNNING, ...);
// 路径 1:音频测试模式 → 直接入编码队列
if (bits & AS_EVENT_AUDIO_TESTING_RUNNING) { ... }
// 路径 2:唤醒词 + 音频处理
if (bits & (AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING)) {
ReadAudioData(data, 16000, 160); // 每次读取 10ms (160 samples)
if (bits & AS_EVENT_WAKE_WORD_RUNNING) wake_word_->Feed(data);
if (bits & AS_EVENT_AUDIO_PROCESSOR_RUNNING) audio_processor_->Feed(std::move(data));
}
}
每次循环读取 160 个采样点(10ms @ 16kHz),这是一个精细的粒度选择:足够小以保证低延迟,又足够大以避免过于频繁的任务调度。如果 AudioCodec 的硬件采样率不是 16kHz,ReadAudioData 方法会通过 input_resampler_(基于 ESP AFE 速率转换器)进行重采样。
Sources: audio_service.cc
AudioOutputTask:音频播放¶
AudioOutputTask 优先级为 4,栈大小 2048×2(使用音频处理器时)或 2048(不使用时),没有核心亲和性限制。该任务采用经典的「生产者-消费者」模式:等待 audio_playback_queue_ 中有数据可用,取出后直接调用 codec_->OutputData() 将 PCM 写入 I2S 发送通道。
// AudioOutputTask 核心循环(简化)
while (true) {
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
audio_queue_cv_.wait(lock, [this]() {
return !audio_playback_queue_.empty() || service_stopped_;
});
auto task = std::move(audio_playback_queue_.front());
audio_playback_queue_.pop_front();
lock.unlock();
if (!codec_->output_enabled()) {
codec_->EnableOutput(true); // 自动唤醒输出通道
}
codec_->OutputData(task->pcm);
last_output_time_ = std::chrono::steady_clock::now(); // 更新活动时间戳
}
值得注意的是,AudioOutputTask 会在播放输出前自动调用 codec_->EnableOutput(true) 以确保 DAC 通道处于激活状态——这与下文讨论的电源管理机制紧密配合。此外,当启用服务端 AEC 模式(CONFIG_USE_SERVER_AEC)时,每个播放帧的 timestamp 会被记录到 timestamp_queue_ 中,供后续上行包带上参考时间戳。
Sources: audio_service.cc
OpusCodecTask:编解码合并任务¶
OpusCodecTask 优先级最低(2),但栈空间最大(2048×12 字),因为 Opus 编解码库在栈上分配大量临时缓冲区。该任务是整个管线的「转换中枢」,同时处理编码和解码两个方向的工作。
编码路径:从 audio_encode_queue_ 取出 PCM 帧(960 采样点 = 60ms @ 16kHz),通过 esp_opus_enc_process() 压缩为 Opus 包,根据任务类型(AudioTaskType)分别推入 audio_send_queue_(上行发送)或 audio_testing_queue_(音频测试)。
解码路径:从 audio_decode_queue_ 取出 Opus 包,根据包中携带的 sample_rate 和 frame_duration 动态调整解码器配置(SetDecodeSampleRate),解码为 PCM 后推入 audio_playback_queue_ 等待播放。
// OpusCodecTask 等待条件:编码队列有数据且发送队列未满,或解码队列有数据且播放队列未满
audio_queue_cv_.wait(lock, [this]() {
return service_stopped_ ||
(!audio_encode_queue_.empty() && audio_send_queue_.size() < MAX_SEND_PACKETS_IN_QUEUE) ||
(!audio_decode_queue_.empty() && audio_playback_queue_.size() < MAX_PLAYBACK_TASKS_IN_QUEUE);
});
将编码和解码合并到同一任务中的设计意图是:两者共享同一个 audio_queue_mutex_ 和条件变量,合并后避免了两个独立任务在锁竞争上的开销,同时 Opus 编解码的 CPU 密集特征相似,不会因合并而降低吞吐量。
Sources: audio_service.cc
任务优先级与实时性分析¶
| 任务 | 优先级 | 栈大小 (words) | 核心亲和性 | 关键职责 |
|---|---|---|---|---|
| AudioInputTask | 8 | 4096 / 6144 | Core 0(当有音频处理器时) | 麦克风读取、数据路由 |
| AudioOutputTask | 4 | 2048 / 4096 | 无 | 扬声器播放 |
| OpusCodecTask | 2 | 24576 | 无 | Opus 编解码 |
优先级设计反映了实时性要求:输入 > 输出 > 编解码。音频输入必须及时读取以避免 I2S DMA 缓冲区溢出(硬件约束);输出次之,因为人耳对音频间断的容忍度低于轻微的延迟;编解码作为纯计算任务优先级最低,可以在 CPU 空闲时批量处理。Application 主任务以优先级 10 运行,高于所有音频任务,确保事件分发不被音频处理阻塞。
Sources: audio_service.cc, application.cc
数据队列系统:六队列协同¶
AudioService 使用六条 std::deque 队列,所有队列操作受 audio_queue_mutex_ 互斥锁和 audio_queue_cv_ 条件变量保护。下面是每条队列的精确语义:
| 队列 | 元素类型 | 最大容量 | 含义与用途 |
|---|---|---|---|
audio_encode_queue_ |
unique_ptr<AudioTask> |
2 | 待编码的 PCM 帧(960 samples / 60ms),由 AudioProcessor 输出端填充 |
audio_send_queue_ |
unique_ptr<AudioStreamPacket> |
40(2400ms / 60ms) | 已编码的 Opus 包,等待 Application 取出并通过网络发送 |
audio_decode_queue_ |
unique_ptr<AudioStreamPacket> |
40(2400ms / 60ms) | 从服务器接收的 Opus 包,等待解码 |
audio_playback_queue_ |
unique_ptr<AudioTask> |
2 | 已解码的 PCM 帧,等待 AudioOutputTask 播放 |
audio_testing_queue_ |
unique_ptr<AudioStreamPacket> |
~166(10000ms / 60ms) | 音频测试模式下的编码结果,停止测试后移入 decode_queue 进行回放 |
timestamp_queue_ |
uint32_t |
3 | 服务端 AEC 所需的播放时间戳,供上行包携带参考信号 |
容量设计原理:MAX_ENCODE_TASKS_IN_QUEUE 和 MAX_PLAYBACK_TASKS_IN_QUEUE 均设置为 2,这是最小的安全缓冲深度——一个正在被消费者处理,一个等待处理。MAX_SEND_PACKETS_IN_QUEUE 和 MAX_DECODE_PACKETS_IN_QUEUE 设置为 2400 / 60 = 40,即 2.4 秒 的音频数据量,作为对抗网络抖动的缓冲。2400ms 这个数值是经过权衡的选择:太小则不足以吸收抖动,太大则引入过多延迟。
Sources: audio_service.h
AudioTask 与 AudioStreamPacket 数据结构¶
队列元素分为两类。AudioTask 承载 PCM 原始数据,用于编码前的输入和解码后的输出:
struct AudioTask {
AudioTaskType type; // EncodeToSendQueue / EncodeToTestingQueue / DecodeToPlaybackQueue
std::vector<int16_t> pcm; // PCM 采样数据(16-bit, mono)
uint32_t timestamp; // 时间戳(用于服务端 AEC)
};
AudioStreamPacket 承载压缩后的 Opus 数据,用于网络传输队列:
struct AudioStreamPacket {
int sample_rate = 0; // 采样率(如 16000、24000)
int frame_duration = 0; // 帧时长(ms,通常 60ms)
uint32_t timestamp = 0; // 时间戳
std::vector<uint8_t> payload; // Opus 编码数据
};
AudioStreamPacket 同时出现在发送队列和接收队列中,这一统一设计使得编解码任务可以用相同的队列操作逻辑处理两个方向的数据。
Sources: audio_service.h, protocol.h
上行数据流:从麦克风到云端¶
上行管线(Uplink)始于硬件麦克风,终于网络数据包。以下逐阶段描述:
阶段 1:硬件采集与重采样。AudioInputTask 通过 ReadAudioData() 从 AudioCodec 的 I2S RX 通道读取 PCM 数据。如果硬件采样率不是 16kHz,input_resampler_(ESP 音频前端速率转换器,配置为 ESP_AE_RATE_CVT_PERF_TYPE_SPEED 模式)会进行实时重采样。同时更新 last_input_time_ 以维持电源管理计时器。
阶段 2:音频处理。处理后的 10ms PCM chunk 被送入 AfeAudioProcessor。该处理器内部有自己的 audio_communication 任务(优先级 3),调用 ESP AFE(Audio Front-End)库执行声学回声消除(AEC)、噪声抑制(NS)和语音活动检测(VAD)。AfeAudioProcessor 内部维护了 input_buffer_ 和 output_buffer_,将不定长的 AFE fetch 输出拼接为固定 60ms 帧后,通过 output_callback_ 回调给 AudioService。
阶段 3:入队编码。AudioService 的 OnOutput 回调调用 PushTaskToEncodeQueue(kAudioTaskTypeEncodeToSendQueue, std::move(data))。该方法在获取 audio_queue_mutex_ 后,检查 timestamp_queue_ 中是否有可用的服务端 AEC 时间戳,然后等待 audio_encode_queue_ 有空位(通过 audio_queue_cv_.wait 阻塞),最后将 AudioTask 推入队列并通知 OpusCodecTask。
阶段 4:Opus 编码。OpusCodecTask 取出 PCM 帧(必须恰好 960 个采样点 = 60ms),调用 esp_opus_enc_process() 进行压缩。编码器配置为 16kHz 单声道、自动比特率、开启 DTX(不连续传输)和 VBR(可变比特率),复杂度设为 0(最低 CPU 开销)。编码后的 Opus 包被推入 audio_send_queue_,然后通过 callbacks_.on_send_queue_available() 触发主任务的 MAIN_EVENT_SEND_AUDIO 事件。
阶段 5:网络发送。Application 主循环响应 MAIN_EVENT_SEND_AUDIO,循环调用 audio_service_.PopPacketFromSendQueue() 获取 Opus 包,再通过 protocol_->SendAudio(std::move(packet)) 将其经 WebSocket 或 MQTT+UDP 通道发送至云端服务器。
Sources: audio_service.cc (ReadAudioData), afe_audio_processor.cc (AudioProcessorTask), audio_service.cc (PushTaskToEncodeQueue), application.cc (MAIN_EVENT_SEND_AUDIO handler)
下行数据流:从云端到扬声器¶
下行管线(Downlink)是上行管线的逆向过程:
阶段 1:网络接收。Application 在 InitializeProtocol() 中注册 on_incoming_audio 回调,当协议层接收到 Opus 音频包时,如果当前设备状态为 kDeviceStateSpeaking,则调用 audio_service_.PushPacketToDecodeQueue(std::move(packet))。
阶段 2:解码队列管理。PushPacketToDecodeQueue 检查 audio_decode_queue_ 是否已达最大容量(40 个包)。如果队列满且调用者设置 wait=true(例如播放本地 OGG 音效时),则阻塞等待;否则立即返回 false。推入成功后通知 OpusCodecTask。
阶段 3:动态解码器适配。OpusCodecTask 在解码前调用 SetDecodeSampleRate(packet->sample_rate, packet->frame_duration)。如果云端返回的采样率与当前解码器配置不匹配,该方法会关闭旧解码器、创建新解码器,并可能需要重建 output_resampler_(例如将 24kHz 的 TTS 音频重采样到硬件支持的输出采样率)。
阶段 4:Opus 解码。使用 esp_opus_dec_decode() 将 Opus 包解码为 PCM 帧(帧大小取决于采样率和帧时长),并通过 decoder_mutex_ 保护解码器实例的线程安全。解码后如果需要重采样,则通过 output_resampler_ 转换采样率,最终推入 audio_playback_queue_。
阶段 5:硬件播放。AudioOutputTask 从 audio_playback_queue_ 取出 PCM 数据,调用 codec_->OutputData() 将数据写入 I2S TX 通道,驱动扬声器发声。同时更新 last_output_time_。
本地音效回放的特殊路径:PlaySound() 方法通过 OggDemuxer 将嵌入固件的 OGG 音效(如提示音、警告音)解封装为 Opus 包,逐包调用 PushPacketToDecodeQueue(packet, true)(阻塞模式),从而复用同一条下行解码播放管线,无需额外的音效播放逻辑。
Sources: application.cc (on_incoming_audio), audio_service.cc (PushPacketToDecodeQueue), audio_service.cc (SetDecodeSampleRate), audio_service.cc (PlaySound)
EventGroup 控制模型:四种运行模式的切换¶
AudioService 使用 FreeRTOS EventGroup 的四个 bit 来控制内部子系统的启停状态,而非创建/销毁任务:
| Event Bit | 值 | 含义 | 触发场景 |
|---|---|---|---|
AS_EVENT_AUDIO_TESTING_RUNNING |
(1 << 0) | 音频测试模式激活 | Boot 按钮在配网模式下触发 |
AS_EVENT_WAKE_WORD_RUNNING |
(1 << 1) | 唤醒词检测激活 | 空闲态(Idle)、应答态(Speaking) |
AS_EVENT_AUDIO_PROCESSOR_RUNNING |
(1 << 2) | 音频处理器激活 | 拾音态(Listening) |
AS_EVENT_PLAYBACK_NOT_EMPTY |
(1 << 3) | 播放队列非空 | 用于决策唤醒词是否暂停播放 |
AudioInputTask 通过 xEventGroupWaitBits 阻塞等待这三个运行标志位中的任意一个被置位。当标志位变化时(例如从唤醒词检测切换到音频处理),任务无需重启——它只是开始向不同的子系统 Feed 数据。这种「模式切换而非任务切换」的设计避免了动态任务管理的复杂性,同时确保了状态转换的低延迟。
具体模式切换由 Application 的状态机驱动。例如,当设备进入 kDeviceStateListening 状态时,HandleStateChangedEvent() 会调用 audio_service_.EnableVoiceProcessing(true)(置位 AS_EVENT_AUDIO_PROCESSOR_RUNNING)和 audio_service_.EnableWakeWordDetection(false)(清除 AS_EVENT_WAKE_WORD_RUNNING),从而在 AudioInputTask 的一次循环中完成数据路由切换。
Sources: audio_service.h, audio_service.cc (EnableWakeWordDetection / EnableVoiceProcessing), application.cc (HandleStateChangedEvent)
音频电源管理¶
为了在电池供电场景下节省功耗,AudioService 实现了一套基于计时器的音频电源管理机制。核心逻辑在 CheckAndUpdateAudioPowerState() 中执行,由一个周期为 1 秒的 audio_power_timer_ 驱动:
┌──────────────────────────────────────────────────────────┐
│ CheckAndUpdateAudioPowerState() │
│ │
│ now = steady_clock::now() │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ input_elapsed > 15s && input_enabled()? │ │
│ │ → codec_->EnableInput(false) 关 ADC │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ output_elapsed > 15s && output_enabled()? │ │
│ │ → 如果是双工且输入开启 → 保留 TX 时钟 │ │
│ │ → 否则 → codec_->EnableOutput(false) 关 DAC│ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 输入输出都关闭? → esp_timer_stop() 停计时器 │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
关键设计细节:(1)超时阈值 AUDIO_POWER_TIMEOUT_MS 为 15 秒——足够覆盖正常的对话间隙,又不会过长浪费电源;(2)last_input_time_ 在每次 ReadAudioData 成功时更新,last_output_time_ 在每次 OutputData 时更新;(3)当输入和输出均被关闭后,计时器自身也会停止,直到下一次 ReadAudioData 或 PushPacketToDecodeQueue 触发通道重新使能并重启计时器;(4)特殊的双工保护逻辑:当 codec_->duplex() 为 true 且输入通道活跃时,输出通道的 TX 时钟不会关闭,因为某些硬件平台(如 ES8388)在关闭 TX 时钟后会导致 RX 通道也停止工作。
Sources: audio_service.cc (CheckAndUpdateAudioPowerState)
核心配置常量速查¶
| 常量 | 值 | 说明 |
|---|---|---|
OPUS_FRAME_DURATION_MS |
60 | Opus 帧时长(毫秒),决定处理单元的大小 |
MAX_ENCODE_TASKS_IN_QUEUE |
2 | 编码队列最大深度(PCM 帧) |
MAX_PLAYBACK_TASKS_IN_QUEUE |
2 | 播放队列最大深度(PCM 帧) |
MAX_DECODE_PACKETS_IN_QUEUE |
40 | 解码队列最大深度(Opus 包),= 2400ms / 60ms |
MAX_SEND_PACKETS_IN_QUEUE |
40 | 发送队列最大深度(Opus 包) |
AUDIO_TESTING_MAX_DURATION_MS |
10000 | 音频测试最大录制时长 |
MAX_TIMESTAMPS_IN_QUEUE |
3 | 服务端 AEC 时间戳缓冲数 |
AUDIO_POWER_TIMEOUT_MS |
15000 | 音频电源自动关闭超时 |
AUDIO_POWER_CHECK_INTERVAL_MS |
1000 | 电源状态检查间隔 |
encoder_frame_size_ |
960 samples | 每帧 PCM 采样数(16000 × 60 / 1000) |
组件交互与下一步阅读¶
AudioService 处于整个音频子系统的中心位置,它与多个组件存在紧密交互:
- 上行方向:
AudioCodec(硬件抽象) → AudioService →AudioProcessor(AEC/VAD/NS) →Protocol(网络发送)。详细编解码器适配见 音频编解码器集成,音频前端处理见 音频前端处理。
- 下行方向:
Protocol(网络接收) → AudioService →AudioCodec(扬声器输出)。
- 控制方向:
Application通过EnableWakeWordDetection()、EnableVoiceProcessing()等方法控制 AudioService 内部的模式切换。唤醒词检测细节见 离线语音唤醒。
- 网络协议层:
AudioStreamPacket的结构定义位于Protocol头文件,WebSocket 和 MQTT+UDP 的音频传输细节见 通信协议总览。
此外,AudioService 的初始化、启动流程由 Application 主控与事件驱动循环 协调,其运行状态受 设备状态机 驱动。
音频编解码器集成:ES8311 / ES8388 / ES8374 等芯片适配¶
本文档深入剖析小智项目中音频编解码器的抽象层次设计,覆盖 ES8311、ES8388、ES8374、ES8389 等主流 DAC/ADC 芯片的适配模式,以及开发板如何通过 config.h + board.cc 的标准模式声明并实例化编解码器。
类继承体系与架构全景¶
音频编解码器子系统采用「基类接口 + 多态实现」的经典设计,核心目标是让 AudioService(音频管线调度器)与具体硬件芯片解耦。整个体系分三层:最上层是 AudioService 通过 AudioCodec* 指针操作编解码器,中间层是各芯片驱动类(Es8311AudioCodec、Es8388AudioCodec 等)实现纯虚接口 Read()/Write(),底层是 ESP-IDF 的 esp_codec_dev 组件提供的硬件抽象 API(audio_codec_if_t、audio_codec_data_if_t、audio_codec_ctrl_if_t、audio_codec_gpio_if_t)。
classDiagram
class AudioCodec {
<<abstract>>
+duplex_ : bool
+input_reference_ : bool
+input_sample_rate_ : int
+output_sample_rate_ : int
+input_channels_ : int
+output_channels_ : int
+output_volume_ : int
+input_gain_ : float
+tx_handle_ : i2s_chan_handle_t
+rx_handle_ : i2s_chan_handle_t
+SetOutputVolume(volume)
+EnableInput(enable)
+EnableOutput(enable)
+OutputData(data)
+InputData(data) bool
+Start()
#Read(dest, samples)* int
#Write(data, samples)* int
}
class Es8311AudioCodec {
-data_if_ : audio_codec_data_if_t*
-ctrl_if_ : audio_codec_ctrl_if_t*
-codec_if_ : audio_codec_if_t*
-dev_ : esp_codec_dev_handle_t
-pa_pin_ : gpio_num_t
+CreateDuplexChannels(mclk, bclk, ws, dout, din)
}
class Es8388AudioCodec {
-output_dev_ : esp_codec_dev_handle_t
-input_dev_ : esp_codec_dev_handle_t
+CreateDuplexChannels(mclk, bclk, ws, dout, din)
}
class Es8374AudioCodec {
-output_dev_ : esp_codec_dev_handle_t
-input_dev_ : esp_codec_dev_handle_t
+CreateDuplexChannels(mclk, bclk, ws, dout, din)
}
class Es8389AudioCodec {
-output_dev_ : esp_codec_dev_handle_t
-input_dev_ : esp_codec_dev_handle_t
+CreateDuplexChannels(mclk, bclk, ws, dout, din)
}
class BoxAudioCodec {
-out_codec_if_ : audio_codec_if_t*
-in_codec_if_ : audio_codec_if_t*
-output_dev_ : esp_codec_dev_handle_t
-input_dev_ : esp_codec_dev_handle_t
+CreateDuplexChannels(mclk, bclk, ws, dout, din)
}
class NoAudioCodec {
-data_if_mutex_ : mutex
}
class DummyAudioCodec {
+Read() int
+Write() int
}
AudioCodec <|-- Es8311AudioCodec
AudioCodec <|-- Es8388AudioCodec
AudioCodec <|-- Es8374AudioCodec
AudioCodec <|-- Es8389AudioCodec
AudioCodec <|-- BoxAudioCodec
AudioCodec <|-- NoAudioCodec
AudioCodec <|-- DummyAudioCodec
NoAudioCodec <|-- NoAudioCodecDuplex
NoAudioCodec <|-- NoAudioCodecSimplex
NoAudioCodec <|-- NoAudioCodecSimplexPdm
核心设计要点:
AudioCodec基类持有 I2S 发送/接收句柄(tx_handle_/rx_handle_),但 I2S 通道的创建完全交由子类在构造函数中完成——这允许不同芯片灵活选择 STD 模式、TDM 模式甚至 PDM 模式。
- 子类必须实现的纯虚函数只有
Read(int16_t*, int)和Write(const int16_t*, int),它们直接调用esp_codec_dev_read()/esp_codec_dev_write()完成数据搬运。
InputData()/OutputData()是非虚的模板方法:它们将std::vector<int16_t>转换为裸指针后调用Read()/Write(),上层AudioService只与这两个方法交互,不感知具体芯片。
Sources: audio_codec.h, audio_codec.cc
AudioCodec 基类:统一操作契约¶
基类定义了编解码器的完整状态模型,包含三大类信息:
| 类别 | 字段 | 默认值 | 含义 |
|---|---|---|---|
| 拓扑 | duplex_ |
false |
是否全双工(输入输出共享 I2S 时钟) |
| 拓扑 | input_reference_ |
false |
是否启用参考通道(用于 AEC 回声消除) |
| 格式 | input_sample_rate_ |
0 |
输入采样率(Hz) |
| 格式 | output_sample_rate_ |
0 |
输出采样率(Hz) |
| 格式 | input_channels_ |
1 |
输入通道数(AEC 模式下为 2) |
| 格式 | output_channels_ |
1 |
输出通道数(通常为 1) |
| 控制 | output_volume_ |
70 |
输出音量(0-100),从 NVS 持久化恢复 |
| 控制 | input_gain_ |
0.0 |
输入增益(dB),各芯片构造函数中覆盖 |
| 运行时 | input_enabled_ |
false |
输入是否已使能 |
| 运行时 | output_enabled_ |
false |
输出是否已使能 |
Start() 方法在编解码器首次初始化时被 AudioService::Initialize() 调用。它从 NVS 读取持久化的音量值(键名为 "output_volume"),若读取值 ≤ 0 则回退到默认值 10。这是一个关键的容错机制——防止因 NVS 损坏导致输出静音。
SetOutputVolume() 不仅更新内存中的音量值,还会写回 NVS 持久化存储,确保设备重启后音量设置不丢失。EnableInput()/EnableOutput() 是一个「开关守卫」模式:若状态未变化则直接返回,避免重复的硬件操作。
Sources: audio_codec.h, audio_codec.cc
编解码器实现变体总览¶
项目在 main/audio/codecs/ 下维护了五类通用编解码器实现,此外部分开发板在自身的 board 目录下提供了定制实现。下表从芯片拓扑、I2S 模式、输入输出架构三个维度进行对比:
| 编解码器类 | 芯片组合 | 工作模式 | I2S 输入 | I2S 输出 | 设备句柄 | 典型开发板 |
|---|---|---|---|---|---|---|
| Es8311AudioCodec | ES8311 单芯片 | IN_OUT |
STD 模式 | STD 模式 | 单 dev_(动态创建/销毁) |
AtomS3 Echo Base, AtomMatrix Echo Base, ESP-SparkBot |
| Es8388AudioCodec | ES8388 单芯片 | BOTH |
STD 模式 | STD 模式 | 分离 input_dev_ + output_dev_ |
部分第三方开发板 |
| Es8374AudioCodec | ES8374 单芯片 | BOTH |
STD 模式 | STD 模式 | 分离 input_dev_ + output_dev_ |
部分第三方开发板 |
| Es8389AudioCodec | ES8389 单芯片 | BOTH |
STD 模式 | STD 模式 | 分离 input_dev_ + output_dev_ |
部分第三方开发板 |
| BoxAudioCodec | ES8311(输出) + ES7210(输入) | 分离 DAC/ADC | TDM 模式(4 槽) | STD 模式 | 分离 input_dev_ + output_dev_ |
ESP-BOX, ESP-BOX-3, Korvo2 V3 |
| BoxAudioCodecLite | ES8156(输出) + ES7243E(输入) | 分离 DAC/ADC | TDM 模式(4 槽) | STD 模式 | 分离 input_dev_ + output_dev_ |
ESP-BOX-Lite |
| NoAudioCodecDuplex | 无 I²C 控制芯片 | 直接 I2S | STD 模式 | STD 模式 | 直接操作 I2S 句柄 | 面包板类开发板 |
| NoAudioCodecSimplex | 无 I²C 控制芯片 | 双 I2S 端口 | STD 模式 | STD 模式 | 直接操作 I2S 句柄 | 自定义单工硬件 |
| DummyAudioCodec | 无硬件 | 空操作 | 返回 0 | 返回 0 | 无 | 单元测试 / CI 构建 |
ES8311 单芯片编解码器¶
ES8311 是一款低功耗单声道音频编解码器,同时集成 ADC 和 DAC。其实现最显著的特征是使用 单一 esp_codec_dev_handle_t dev_ 句柄(类型为 ESP_CODEC_DEV_TYPE_IN_OUT),而非分离的输入/输出句柄。设备句柄的创建和销毁通过 UpdateDeviceState() 方法按需管理:
stateDiagram-v2
[*] --> Closed: dev_ == nullptr
Closed --> Opened: input或output使能
Opened --> Closed: input和output均关闭
Opened --> Opened: 调整音量/增益
note right of Opened: esp_codec_dev_open + 配置采样率/增益/音量
note left of Closed: esp_codec_dev_close + dev_ = nullptr
构造函数中关键的芯片配置参数包括 use_mclk(是否使用外部 MCLK 时钟)、pa_pin(功放使能引脚)、pa_inverted(功放引脚电平反转)、hw_gain.pa_voltage(功放供电电压 5.0V)和 hw_gain.codec_dac_voltage(DAC 供电电压 3.3V)。这些参数直接影响音频放大器的增益计算。
ES8311 不支持参考通道(input_reference_ = false),这意味着使用该芯片的开发板无法利用 AEC 回声消除的参考信号——若需 AEC,应选用 ES8388 或 BoxAudioCodec 方案。
Sources: es8311_audio_codec.h, es8311_audio_codec.cc
ES8388 双设备编解码器¶
ES8388 是顺芯(Everest)半导体的立体声音频编解码器。与 ES8311 不同,ES8388 的驱动采用 分离输入/输出设备句柄 架构:output_dev_(ESP_CODEC_DEV_TYPE_OUT)和 input_dev_(ESP_CODEC_DEV_TYPE_IN),各自独立管理打开/关闭生命周期。
ES8388 是项目中唯一原生支持参考通道的单芯片编解码器。当 input_reference_ 为 true 时,输入通道数设为 2,其中通道 0 为麦克风信号、通道 1 为参考信号(来自播放内容的回路)。在 EnableInput() 中,参考通道通过写寄存器 0x09 统一配置左右声道 PGA 增益(高 4 位左声道 = 11,低 4 位右声道 = 0),而非使用 esp_codec_dev_set_in_gain()。
EnableOutput() 在使能输出时额外将模拟输出音量寄存器(HP_LVOL=46, HP_RVOL=47, SPK_LVOL=48, SPK_RVOL=49)设置为 0dB(值 30),这是因为 ES8388 上电默认模拟音量为 -45dB。这个初始化步骤若被遗漏,会导致输出几乎听不到声音——这是实际调试中的常见陷阱。
Sources: es8388_audio_codec.h, es8388_audio_codec.cc
ES8374 / ES8389 编解码器¶
ES8374 和 ES8389 在架构上高度一致,均采用分离输入/输出设备句柄、标准 I2S 双工通道模式。与 ES8388 的主要差异体现在:
- 芯片配置结构体:分别使用
es8374_codec_cfg_t和es8389_codec_cfg_t,字段略有不同(ES8389 额外支持use_mclk、hw_gain配置)。
- 默认输入增益:ES8374 为 30,ES8389 为 40——这反映了不同芯片的麦克风偏置电路差异。
- 输入使能策略:ES8374 和 ES8389 在
EnableInput()中直接使用esp_codec_dev_set_in_gain()设置增益,无需像 ES8388 那样手动操作寄存器。
ES8389 的代码中还出现了一个跨芯片兼容的条件编译宏 I2S_HW_VERSION_2,用于在 ESP32-P4 等新芯片上适配 I2S 硬件版本 2 的额外字段(ext_clk_freq_hz、left_align、big_endian、bit_order_lsb)。
Sources: es8374_audio_codec.h, es8374_audio_codec.cc, es8389_audio_codec.h, es8389_audio_codec.cc
BoxAudioCodec:双芯片分离架构¶
BoxAudioCodec 是为 ESP-BOX 系列官方开发板设计的编解码器,其核心特征是输出和输入使用两颗独立芯片:ES8311 仅作为 DAC 负责播放(ESP_CODEC_DEV_WORK_MODE_DAC),ES7210 作为 ADC 负责录音。两颗芯片通过同一条 I2C 总线但不同地址访问。
I2S 通道创建是 BoxAudioCodec 最复杂的设计:TX 通道使用标准 STD 模式(输出到 ES8311),RX 通道使用 TDM 模式(从 ES7210 输入 4 路麦克风数据)。TDM 配置中 slot_mask 设置为 I2S_TDM_SLOT0 | I2S_TDM_SLOT1 | I2S_TDM_SLOT2 | I2S_TDM_SLOT3,对应 ES7210 的四路麦克风。ES7210 的麦克风选择通过 es7210_codec_cfg_t.mic_selected 字段指定,当前配置为 ES7210_SEL_MIC1 | ES7210_SEL_MIC2 | ES7210_SEL_MIC3 | ES7210_SEL_MIC4。
BoxAudioCodec 支持参考通道(input_reference_),当启用时输入通道数设为 2(ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0) 和 ESP_CODEC_DEV_MAKE_CHANNEL_MASK(1)),并使用 esp_codec_dev_set_in_channel_gain() 而非 esp_codec_dev_set_in_gain() 来分别控制每个通道的增益。
Sources: box_audio_codec.h, box_audio_codec.cc
BoxAudioCodecLite:ESP-BOX-Lite 专用变体¶
BoxAudioCodecLite 位于 board 目录而非通用 codecs 目录(main/boards/esp-box-lite/),说明它是特定硬件的定制方案。它使用 ES8156 作为 DAC、ES7243E 作为 ADC,同样采用 STD 模式 TX + TDM 模式 RX 的双 I2S 通道架构。
与 BoxAudioCodec 的重要区别在于:BoxAudioCodecLite 在内部维护了一个参考缓冲区 ref_buffer_(大小 960×2=1920 个 int16_t 样本),通过 read_pos_ 和 write_pos_ 指针管理。当 input_reference_ 为 true 时,Read() 方法从参考缓冲区中混入回声参考信号。这是一种软件模拟的参考通道——不需要硬件回环电路即可实现基本的 AEC 功能,适用于 ESP-BOX-Lite 这样的低成本硬件。
Sources: box_audio_codec_lite.h, box_audio_codec_lite.cc
NoAudioCodec:无 I²C 控制的纯 I2S 编解码器¶
NoAudioCodec 及其子类(NoAudioCodecDuplex、NoAudioCodecSimplex、NoAudioCodecSimplexPdm)用于没有 I²C 控制接口的简单音频硬件——例如 INMP441 MEMS 麦克风直接输出 I2S 信号,MAX98357A 功放芯片直接接收 I2S 信号。这类芯片不需要寄存器配置,只需正确设置 I2S 时钟和数据线。
- NoAudioCodecDuplex:麦克风和扬声器共享同一组 I2S 时钟(BCLK/WS),适合面包板快速原型。
- NoAudioCodecSimplex:使用两个独立的 I2S 端口(I2S_NUM_0 用于扬声器,I2S_NUM_1 用于麦克风),分别配置各自的 BCLK/WS/Data 引脚,支持
slot_mask参数控制左右声道选择。
- NoAudioCodecSimplexPdm:麦克风端使用 PDM 模式(常用于数字 MEMS 麦克风),扬声器端仍使用 STD 模式。PDM 模式下仅需 CLK 和 DIN 两根线,无需 WS 信号。
NoAudioCodec 的数据位宽设为 32 位(I2S_DATA_BIT_WIDTH_32BIT),这是为了兼容那些输出 24 位或 32 位采样数据的 I2S 麦克风。实际的 16 位有效数据位于高 16 位或低 16 位,上层 AudioService 的重采样器会处理格式转换。
Sources: no_audio_codec.h, no_audio_codec.cc
DummyAudioCodec:测试占位符¶
DummyAudioCodec 的 Read() 和 Write() 均直接返回 0,不执行任何 I2S 操作。它用于 CI 编译验证和单元测试场景,确保代码在没有真实硬件的环境下仍能编译通过。
Sources: dummy_audio_codec.h, dummy_audio_codec.cc
开发板集成模式:config.h + board.cc¶
每个开发板通过两个文件定义其音频硬件配置,这是 Board 抽象层的核心约定:
config.h:硬件引脚与参数声明¶
config.h 使用 C 预处理器宏定义所有硬件相关参数。音频编解码器相关的核心宏如下表:
| 宏名 | 含义 | 示例值 |
|---|---|---|
AUDIO_INPUT_SAMPLE_RATE |
输入采样率(Hz) | 24000 或 16000 |
AUDIO_OUTPUT_SAMPLE_RATE |
输出采样率(Hz) | 通常与输入相同 |
AUDIO_INPUT_REFERENCE |
是否启用 AEC 参考通道 | true / false |
AUDIO_I2S_GPIO_MCLK |
I2S 主时钟引脚 | GPIO_NUM_2 或 GPIO_NUM_NC |
AUDIO_I2S_GPIO_WS |
I2S 字选信号引脚 | GPIO_NUM_47 |
AUDIO_I2S_GPIO_BCLK |
I2S 位时钟引脚 | GPIO_NUM_17 |
AUDIO_I2S_GPIO_DIN |
I2S 数据输入引脚(麦克风) | GPIO_NUM_16 |
AUDIO_I2S_GPIO_DOUT |
I2S 数据输出引脚(扬声器) | GPIO_NUM_15 |
AUDIO_CODEC_PA_PIN |
功放使能引脚 | GPIO_NUM_46 或 GPIO_NUM_NC |
AUDIO_CODEC_I2C_SDA_PIN |
I²C 数据线 | GPIO_NUM_8 |
AUDIO_CODEC_I2C_SCL_PIN |
I²C 时钟线 | GPIO_NUM_18 |
AUDIO_CODEC_ES8311_ADDR |
ES8311 I²C 地址 | ES8311_CODEC_DEFAULT_ADDR |
AUDIO_CODEC_ES7210_ADDR |
ES7210 I²C 地址(仅 BoxAudioCodec) | ES7210_CODEC_DEFAULT_ADDR |
以 ESP-BOX 的 config.h 为例,它定义了输入/输出采样率均为 24000Hz、启用参考通道、指定 MCLK 为 GPIO_NUM_2、PA 引脚为 GPIO_NUM_46,以及 ES8311 和 ES7210 的 I²C 地址。
Sources: esp-box/config.h, atoms3-echo-base/config.h
board.cc:编解码器实例化¶
board.cc 在开发板类的 GetAudioCodec() 方法中创建编解码器实例。标准模式如下:
1. 初始化 I²C 总线(InitializeI2c())
2. 在 GetAudioCodec() 中以 static 局部变量创建编解码器,传入 I²C 句柄和所有 config.h 宏
3. 返回指向该静态实例的指针
使用 static 局部变量确保了单例语义——编解码器在整个程序生命周期内只创建一次、只初始化一次 I2S 通道。以 AtomS3 Echo Base 开发板为例:
virtual AudioCodec* GetAudioCodec() override {
static Es8311AudioCodec audio_codec(
i2c_bus_, // I²C 总线句柄
I2C_NUM_1, // I²C 端口号
AUDIO_INPUT_SAMPLE_RATE, // 输入采样率
AUDIO_OUTPUT_SAMPLE_RATE, // 输出采样率
AUDIO_I2S_GPIO_MCLK, // MCLK 引脚
AUDIO_I2S_GPIO_BCLK, // BCLK 引脚
AUDIO_I2S_GPIO_WS, // WS 引脚
AUDIO_I2S_GPIO_DOUT, // 数据输出引脚
AUDIO_I2S_GPIO_DIN, // 数据输入引脚
AUDIO_CODEC_GPIO_PA, // PA 引脚
AUDIO_CODEC_ES8311_ADDR, // I²C 地址
false); // use_mclk = false(无外部 MCLK)
return &audio_codec;
}
而 ESP-BOX 使用 BoxAudioCodec,构造函数额外接收 AUDIO_CODEC_ES7210_ADDR 参数指定 ES7210 的 I²C 地址。这种参数化设计使得同一套编解码器驱动可以适配不同 PCB 布局的开发板,只需修改 config.h 中的引脚宏即可。
Sources: atoms3_echo_base.cc, esp_box_board.cc
I2S 通道创建:所有编解码器的共享模式¶
尽管各芯片的 I2S 配置细节不同,但 CreateDuplexChannels() 方法在所有编解码器中遵循统一的流程:
flowchart TD
A[i2s_new_channel] --> B[创建 tx_handle_ + rx_handle_]
B --> C[填充 i2s_std_config_t / i2s_tdm_config_t]
C --> D[设置时钟: sample_rate + MCLK_MULTIPLE_256]
D --> E[设置时隙: 16bit + STEREO/MONO + 对齐方式]
E --> F[设置 GPIO: MCLK/BCLK/WS/DOUT/DIN]
F --> G[i2s_channel_init_std_mode / _tdm_mode]
G --> H[i2s_channel_enable]
关键参数统一性:
- DMA 描述符数:
AUDIO_CODEC_DMA_DESC_NUM = 6
- DMA 帧数:
AUDIO_CODEC_DMA_FRAME_NUM = 240
- I2S 角色:
I2S_ROLE_MASTER(ESP32 作为主设备提供时钟)
- MCLK 倍频:
I2S_MCLK_MULTIPLE_256
- 数据位宽:
I2S_DATA_BIT_WIDTH_16BIT
对于 BoxAudioCodec 和 BoxAudioCodecLite,RX 通道额外配置 bclk_div = 8,这是因为 TDM 模式下 4 个时隙需要更高的 BCLK 频率(BCLK = 采样率 × 位宽 × 时隙数 = 24000 × 16 × 4 = 1.536 MHz,÷8 后作为分频器的输入参考)。
Sources: es8388_audio_codec.cc, es8311_audio_codec.cc, box_audio_codec.cc
AudioService 如何驱动编解码器¶
AudioService 持有 AudioCodec* 指针,通过标准接口完成所有音频 I/O 操作。Initialize() 方法调用 codec_->Start() 初始化硬件,然后根据编解码器参数(input_sample_rate()、output_sample_rate()、input_channels())创建 Opus 编码器/解码器和必要的重采样器。
音频输入任务(AudioInputTask)运行在优先级为 8 的 FreeRTOS 任务中,通过 ReadAudioData() 方法循环读取 PCM 数据:
sequenceDiagram
participant Task as AudioInputTask
participant AS as AudioService
participant Codec as AudioCodec
participant HW as 硬件芯片
Task->>AS: ReadAudioData(data, 16000, samples)
AS->>Codec: input_enabled()?
alt 输入未使能
AS->>Codec: EnableInput(true)
Codec->>HW: esp_codec_dev_open + I2S enable
end
AS->>Codec: InputData(data)
Codec->>HW: esp_codec_dev_read / i2s_channel_read
alt 需要重采样
AS->>AS: esp_ae_rate_cvt_convert (24kHz→16kHz)
end
AS-->>Task: 16kHz PCM 数据
Task->>AS: Feed to AudioProcessor
ReadAudioData() 体现了两个关键设计模式:
- 惰性使能:当检测到输入未使能时自动调用
EnableInput(true),无需上层关心硬件状态。
- 透明重采样:若编解码器采样率不是 16kHz(Opus 编码器要求),自动通过
input_resampler_转换。重采样器的配置使用codec_->input_channels()确保多通道(含参考通道)也能正确处理。
Sources: audio_service.h, audio_service.cc, application.cc
功放控制与电源管理¶
编解码器驱动统一管理功放使能引脚(PA Pin)。当输出使能时,EnableOutput() 将 PA 引脚置高(或根据 pa_inverted_ 参数反转),禁用时置低。ES8311 还额外支持 pa_inverted_ 参数,用于适配那些低电平使能的功放电路。
AudioService 通过 audio_power_timer_(每秒触发一次)检测输入/输出活动状态。若超过 AUDIO_POWER_TIMEOUT_MS(15 秒)无音频活动,自动调用 EnableInput(false) 和 EnableOutput(false) 关闭编解码器以降低功耗。当有新的音频数据需要处理时,ReadAudioData() 会自动重新使能输入。
Sources: audio_service.cc, es8311_audio_codec.cc
添加新编解码器的步骤¶
基于上述架构分析,添加一款新的音频编解码器芯片需要以下步骤,遵循项目中已有的成熟模式:
步骤一:创建编解码器类¶
在 main/audio/codecs/ 下创建 xxx_audio_codec.h 和 xxx_audio_codec.cc,继承 AudioCodec 并实现:
- 构造函数:初始化
duplex_,input_reference_,input_channels_,input_sample_rate_,output_sample_rate_,input_gain_等基类字段,然后调用CreateDuplexChannels()创建 I2S 通道,最后按序创建data_if_→ctrl_if_→gpio_if_→codec_if_→esp_codec_dev_handle_t。
Read()/Write():调用esp_codec_dev_read()/esp_codec_dev_write()。
EnableInput()/EnableOutput():使用互斥锁保护,检查状态变化,调用esp_codec_dev_open()/esp_codec_dev_close()。
SetOutputVolume():调用esp_codec_dev_set_out_vol()后委托给AudioCodec::SetOutputVolume()完成 NVS 持久化。
步骤二:在 board 层集成¶
- 在目标开发板的
config.h中添加AUDIO_CODEC_XXX_ADDR宏定义 I²C 地址。
- 在
board.cc的GetAudioCodec()中引入新编解码器的头文件,使用static局部变量创建实例,传入 I²C 总线句柄和所有配置参数。
步骤三:配置 I2S 模式¶
根据芯片数据手册确定:标准 I2S(STD)还是 TDM 模式、是否需要 MCLK 外部时钟、是否需要 bclk_div 分频器、时隙掩码设置(单声道用 I2S_STD_SLOT_LEFT,立体声用 I2S_STD_SLOT_BOTH,TDM 用 i2s_tdm_slot_mask_t 位掩码)。
已有实现可作为模板:若芯片同时集成 ADC/DAC,参考 Es8388AudioCodec;若仅作为 DAC 配合独立 ADC 使用,参考 BoxAudioCodec;若无 I²C 控制接口,参考 NoAudioCodecDuplex 或 NoAudioCodecSimplex。
相关的自定义开发板指南请参考 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD,音频管线的整体架构请参考 AudioService 核心管线:三任务模型与数据队列。
离线语音唤醒:ESP-SR 与自定义唤醒词实现¶
本文档深入剖析小智 AI 聊天机器人固件中离线语音唤醒的完整实现架构,包括 ESP-SR(Espressif Speech Recognition)框架下的三种唤醒词方案、自定义唤醒词配置机制、与设备状态机的协作流程,以及从麦克风输入到服务端通知的端到端数据流。
Sources: wake_word.h
架构概览:三种唤醒方案的统一抽象¶
唤醒词系统围绕一个统一的抽象基类 WakeWord 构建,定义了检测引擎的标准生命周期接口。所有具体实现都遵循相同的 API 契约,使得上层 AudioService 和 Application 无须关心底层差异。系统根据芯片平台和模型资源自动选择合适的方案。
classDiagram
class WakeWord {
<<abstract>>
+Initialize(codec, models_list) bool
+Feed(data) void
+OnWakeWordDetected(callback) void
+Start() void
+Stop() void
+GetFeedSize() size_t
+EncodeWakeWordData() void
+GetWakeWordOpus(opus) bool
+GetLastDetectedWakeWord() string
}
class EspWakeWord {
-esp_wn_iface_t* wakenet_iface_
-model_iface_data_t* wakenet_data_
-srmodel_list_t* wakenet_model_
适用于 ESP32 / ESP32C3 / ESP32C5 / ESP32C6
}
class AfeWakeWord {
-esp_afe_sr_iface_t* afe_iface_
-esp_afe_sr_data_t* afe_data_
-char* wakenet_model_
-deque~vector~ wake_word_pcm_
-deque~vector~ wake_word_opus_
适用于 ESP32S3 / ESP32P4
}
class CustomWakeWord {
-esp_mn_iface_t* multinet_
-model_iface_data_t* multinet_model_data_
-deque~Command~ commands_
-string language_
-float threshold_
适用于 ESP32S3 / ESP32P4
}
WakeWord <|-- EspWakeWord
WakeWord <|-- AfeWakeWord
WakeWord <|-- CustomWakeWord
三种方案的核心差异在于底层 ESP-SR 子系统的选择:
| 特性 | EspWakeWord | AfeWakeWord | CustomWakeWord |
|---|---|---|---|
| 底层引擎 | WakeNet(纯唤醒词检测) | AFE + WakeNet(前端处理 + 唤醒) | MultiNet(命令词识别) |
| 支持芯片 | ESP32 / C3 / C5 / C6 | ESP32S3 / P4 | ESP32S3 / P4 |
| PSRAM 要求 | 部分需要(ESP32 需 PSRAM) | 必须 | 必须 |
| AEC 回声消除 | 不支持 | 支持(通过 AFE) | 不支持 |
| 自定义唤醒词 | 需烧录固件模型 | 需烧录固件模型 | 可通过 index.json 动态配置 |
| 唤醒词数量 | 1 个(模型决定) | 多个(模型决定) | 多个(commands 列表) |
| 唤醒词数据编码 | 不支持 | 支持(Opus 编码后发送) | 支持(Opus 编码后发送) |
| 典型模型 | wn9_nihaoxiaozhi |
wn9_nihaoxiaozhi_tts |
multinet 系列 |
Sources: esp_wake_word.cc | afe_wake_word.cc | custom_wake_word.cc
方案选择逻辑:SetModelsList 的自动路由¶
唤醒词方案的选择并非通过 Kconfig 直接指定,而是在 AudioService::SetModelsList() 中通过模型类型检测自动决策。这一设计允许同一个固件镜像在不同模型分区下启用不同的唤醒方案。
flowchart TD
SetModelsList["AudioService::SetModelsList(models_list)"]
CheckChip{"芯片平台?"}
ESP32S3_P4["ESP32S3 / ESP32P4"]
OtherChip["ESP32 / C3 / C5 / C6"]
CheckMN{"含 MultiNet 模型<br/>(ESP_MN_PREFIX)?"}
CheckWN{"含 WakeNet 模型<br/>(ESP_WN_PREFIX)?"}
CheckWN2{"含 WakeNet 模型<br/>(ESP_WN_PREFIX)?"}
UseCustom["创建 CustomWakeWord"]
UseAfe["创建 AfeWakeWord"]
UseEsp["创建 EspWakeWord"]
NullWake["wake_word_ = nullptr<br/>(无唤醒词)"]
SetModelsList --> CheckChip
CheckChip -->|是| ESP32S3_P4
CheckChip -->|否| OtherChip
ESP32S3_P4 --> CheckMN
CheckMN -->|是| UseCustom
CheckMN -->|否| CheckWN
CheckWN -->|是| UseAfe
CheckWN -->|否| NullWake
OtherChip --> CheckWN2
CheckWN2 -->|是| UseEsp
CheckWN2 -->|否| NullWake
关键代码逻辑:模型前缀 ESP_MN_PREFIX(MultiNet)的优先级高于 ESP_WN_PREFIX(WakeNet),这意味着当资源分区同时包含两种模型时,系统会优先使用 CustomWakeWord。
Sources: audio_service.cc
Kconfig 配置体系¶
唤醒词系统通过 Kconfig.projbuild 提供五层配置选项,覆盖从方案选择到运行时行为的完整控制面。
第一层:方案选择(互斥)¶
choice WAKE_WORD_TYPE
default USE_AFE_WAKE_WORD if (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM
default WAKE_WORD_DISABLED
| 选项 | 目标芯片 | 底层实现 |
|---|---|---|
WAKE_WORD_DISABLED |
所有平台 | 编译时完全禁用唤醒词代码 |
USE_ESP_WAKE_WORD |
C3 / C5 / C6 / ESP32+PSRAM | EspWakeWord |
USE_AFE_WAKE_WORD |
S3 / P4 + PSRAM | AfeWakeWord(带 AFE 前端处理) |
USE_CUSTOM_WAKE_WORD |
S3 / P4 + PSRAM | CustomWakeWord(MultiNet 可定制) |
第二层:自定义唤醒词参数(仅 USE_CUSTOM_WAKE_WORD 时有效)¶
| 配置项 | 默认值 | 说明 |
|---|---|---|
CUSTOM_WAKE_WORD |
"xiao tu dou" |
拼音格式,空格分隔的词条 |
CUSTOM_WAKE_WORD_DISPLAY |
"小土豆" |
唤醒后发送至服务端的问候文本 |
CUSTOM_WAKE_WORD_THRESHOLD |
20(范围 1-99,越小越灵敏) |
MultiNet 检测阈值百分比 |
第三层:唤醒词数据发送¶
CONFIG_SEND_WAKE_WORD_DATA:控制唤醒后是否将缓存的前 2 秒 Opus 音频数据发送至服务端作为对话首条消息。此功能仅在 AfeWakeWord 和 CustomWakeWord 中生效——EspWakeWord 不实现 EncodeWakeWordData()。
第四层:监听中途唤醒¶
CONFIG_WAKE_WORD_DETECTION_IN_LISTENING:在设备处于聆听状态(kDeviceStateListening)时是否保持唤醒词检测。开启后用户可以在对话中途用唤醒词打断当前交互;默认关闭以节省 CPU 资源。
第五层:模型文件选择(SDK Config 默认值)¶
不同芯片平台的 sdkconfig.defaults.* 预置了对应的模型枚举:
# ESP32S3 / ESP32(16kHz TTS 优化版)
CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=y
# ESP32C3 / C5(轻量版)
CONFIG_SR_WN_WN9S_NIHAOXIAOZHI=y
Sources: Kconfig.projbuild | sdkconfig.defaults.esp32s3 | sdkconfig.defaults.esp32c3
核心实现详解¶
EspWakeWord:纯 WakeNet 直通方案¶
EspWakeWord 是最精简的实现,直接调用 ESP-SR 的 WakeNet 接口(esp_wn_iface_t)。其工作原理如下:
- 初始化:通过
esp_srmodel_init("model")加载默认模型分区,取第一个 WakeNet 模型(ESP_WN_PREFIX),以DET_MODE_95(95% 检测置信度)模式创建检测实例。
- 音频投喂:
Feed()方法每次接收 10ms(160 采样点)的 16kHz 单声道数据。若输入为双声道则自动提取左声道。
- 检测逻辑:累积数据到内部缓冲区,按模型要求的
chunksize分块送入wakenet_iface_->detect()。返回正值表示检测到唤醒词,负值表示未检测到。
- 生命周期:
Start()/Stop()通过std::atomic<bool> running_原子标志控制,无内部独立任务——检测在调用线程(AudioInputTask)中同步执行。
关键限制:不支持唤醒词 PCM 缓存和 Opus 编码,EncodeWakeWordData() 和 GetWakeWordOpus() 均为空实现。这意味着 EspWakeWord 方案不能将唤醒词音频随对话流发送。
Sources: esp_wake_word.cc
AfeWakeWord:AFE 前端 + WakeNet 联合方案¶
AfeWakeWord 在 WakeNet 之前引入了 ESP 音频前端(Audio Front-End, AFE),提供多麦克风通道映射、AEC 回声消除等增强特性。架构上采用生产者-消费者双线程模型:
sequenceDiagram
participant Input as AudioInputTask
participant Feed as AfeWakeWord::Feed()
participant Buffer as input_buffer_
participant AFE as esp_afe_sr_data_t
participant Detect as AudioDetectionTask
participant Callback as Application
Input->>Feed: Feed(10ms PCM)
Feed->>Buffer: 追加至 input_buffer_
Feed->>AFE: afe_iface_->feed() 按 chunk 送入 AFE
Note over AFE: 内部处理:AEC → 降噪 → 波束成形
Detect->>AFE: afe_iface_->fetch_with_delay()
AFE-->>Detect: afe_fetch_result_t (含 wakeup_state)
Detect->>Detect: StoreWakeWordData() 缓存最近 2 秒
alt wakeup_state == WAKENET_DETECTED
Detect->>Detect: Stop() 停止检测
Detect->>Callback: wake_word_detected_callback_(last_detected_wake_word_)
end
线程架构细节:
- 生产者(
AudioInputTask):调用Feed()将 10ms 原始 PCM 追加到input_buffer_,然后分块送入 AFE 实例。使用input_buffer_mutex_保护并发访问。
- 消费者(
AudioDetectionTask):独立 FreeRTOS 任务(栈 4096 字节,优先级 3),在Initialize()中创建。循环调用fetch_with_delay()阻塞等待 AFE 输出结果,检查res->wakeup_state。
AFE 配置关键参数:
afe_config_t* afe_config = afe_config_init(input_format.c_str(), models_, AFE_TYPE_SR, AFE_MODE_HIGH_PERF);
afe_config->aec_init = codec_->input_reference(); // 有参考通道则启用 AEC
afe_config->aec_mode = AEC_MODE_SR_HIGH_PERF; // 语音识别优化模式
afe_config->afe_perferred_core = 1; // 绑定 CPU 核心 1
afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM; // 优先使用 PSRAM
输入格式字符串(如 "MM"、"MMR")中 M 表示麦克风通道,R 表示参考通道(回声消除用)。
唤醒词数据编码:检测到唤醒词后,系统会通过独立任务 encode_wake_word(栈 4096×6 字节,优先级 2)将缓存的最近 2 秒 PCM 数据编码为 Opus 包。编码任务使用静态分配内存(xTaskCreateStatic),Opus 包通过条件变量供 PopWakeWordPacket() 逐帧拉取。
Sources: afe_wake_word.cc | afe_wake_word.h
CustomWakeWord:MultiNet 自定义命令词¶
CustomWakeWord 是唯一支持运行时动态配置唤醒词的方案。它使用 ESP-SR 的 MultiNet(esp_mn_iface_t)引擎,本质上是一个命令词识别器而非纯粹的唤醒词检测器——这意味着它支持多个命令词,且每个命令词可映射不同的 action。
配置来源的双路径:
| 场景 | models_list 参数 |
配置来源 |
|---|---|---|
| 编译时 Kconfig | nullptr |
CONFIG_CUSTOM_WAKE_WORD、CONFIG_CUSTOM_WAKE_WORD_DISPLAY、CONFIG_CUSTOM_WAKE_WORD_THRESHOLD |
| 运行时资源分区 | 非空指针 | 从 index.json 的 multinet_model 节点解析 |
index.json 的 Multinet 配置格式:
{
"multinet_model": {
"language": "cn",
"duration": 3000,
"threshold": 0.2,
"commands": [
{"command": "ni hao xiao zhi", "text": "你好小智", "action": "wake"},
{"command": "da kai deng", "text": "打开灯", "action": "gpio"},
{"command": "guan bi deng", "text": "关闭灯", "action": "gpio"}
]
}
}
language:MultiNet 语言模型标识(如"cn"中文),对应模型名中的ESP_MN_PREFIX过滤
duration:识别超时时间(毫秒),连续duration毫秒无命令词触发ESP_MN_STATE_TIMEOUT
threshold:检测阈值(浮点数),越高越不敏感
commands:命令词数组,action为"wake"的词条才能触发唤醒流程
检测流程与 MultiNet 状态机:
Feed() 方法在每次送入 chunk 后调用 multinet_->detect(),返回三态枚举:
| 返回值 | 含义 | 处理 |
|---|---|---|
ESP_MN_STATE_DETECTED |
检测到命令词 | 遍历结果数组,仅 action == "wake" 的词触发唤醒回调 |
ESP_MN_STATE_TIMEOUT |
识别窗口超时 | 调用 multinet_->clean() 重置状态 |
| 其他 | 继续检测 | 累积下一个 chunk |
检测到唤醒词后调用 multinet_->clean() 清除内部状态,同时 Stop() 停止检测并触发回调。PCM 缓存和 Opus 编码逻辑与 AfeWakeWord 完全一致。
Sources: custom_wake_word.cc | custom_wake_word.h
与 AudioService 的集成¶
唤醒词子系统在 AudioService 中是与音频处理器并列的独立管道,二者共享 AudioInputTask 的音频源但互斥运行:
flowchart LR
subgraph AudioInputTask
Mic[麦克风] --> Read["ReadAudioData()"]
Read -->|10ms PCM| Switch{EventGroup 检查}
Switch -->|WAKE_WORD_RUNNING| WakeFeed["wake_word_->Feed()"]
Switch -->|AUDIO_PROCESSOR_RUNNING| ProcFeed["audio_processor_->Feed()"]
Switch -->|AUDIO_TESTING_RUNNING| TestFeed["PushTaskToEncodeQueue"]
end
三个运行标志位通过 FreeRTOS EventGroup 管理,允许同时开启或独立控制。EnableWakeWordDetection() 和 EnableVoiceProcessing() 分别控制唤醒词和音频处理管线。切换模式时会重置输入重采样器(esp_ae_rate_cvt_reset)以避免缓冲区溢出。
Sources: audio_service.cc | audio_service.cc
与设备状态机的协作¶
唤醒词检测事件通过回调链传递至 Application::HandleWakeWordDetectedEvent(),触发跨状态的行为决策:
stateDiagram-v2
[*] --> Idle: 唤醒词检测
Idle --> Connecting: OpenAudioChannel
Connecting --> Listening: SetListeningMode
state Speaking {
[*] --> Abort: AbortSpeaking(kAbortReasonWakeWordDetected)
Abort --> ResumeListening: 重新开启聆听
}
state Listening {
[*] --> Interrupt: 检测到唤醒词
Interrupt --> ResumeListening: SendStartListening + EnableWakeWordDetection
}
Listening --> Idle: StopListening
Speaking --> Idle: TTS 播放完毕
- Idle 状态:编码并发送唤醒词数据(如果启用
CONFIG_SEND_WAKE_WORD_DATA),然后打开音频通道进入聆听
- Speaking 状态:中断当前 TTS 播放(
AbortSpeaking),清空发送队列避免残留数据,重新进入聆听
- Listening 状态:发送
start_listening重新开始,播放提示音,重新启用唤醒词检测(因为检测到后自动 Stop 了)
- Activating 状态:退出激活流程回到 Idle
CONFIG_WAKE_WORD_DETECTION_IN_LISTENING 在 HandleStateChangedEvent 中被消费:当设备进入 kDeviceStateListening 时,仅当方案为 AfeWakeWord(具备 AFE 前端)时保持唤醒词开启——这是因为纯 WakeNet 在聆听模式下无法有效区分设备播放与用户语音。
Sources: application.cc | application.cc
AFE 的复用设计:唤醒词与音频处理器的共享实例¶
一个关键架构细节:AfeWakeWord 和 AfeAudioProcessor 各自创建独立的 AFE 实例(esp_afe_sr_data_t),但共享同一套 AFE 配置接口。两者差异如下:
| 维度 | AfeWakeWord | AfeAudioProcessor |
|---|---|---|
| AFE 类型 | AFE_TYPE_SR(语音识别) |
AFE_TYPE_VC(语音通信) |
| AEC 模式 | AEC_MODE_SR_HIGH_PERF |
AEC_MODE_VOIP_HIGH_PERF |
| VAD 配置 | 由 AFE 内部管理 | 可独立配置 vad_mode、vad_min_noise_ms |
| NS(噪声抑制) | 内置 | 可配置 ns_model_name |
| 输出目标 | 触发唤醒回调 | 输出处理后的 PCM 给编码器 |
| 任务名 | audio_detection |
audio_communication |
这对分离设计使得唤醒词检测和通话处理可以针对不同场景独立优化 AFE 参数。
Sources: afe_wake_word.cc | afe_audio_processor.cc
自定义唤醒词实战指南¶
方案一:Kconfig 编译时配置(适用于 USE_CUSTOM_WAKE_WORD)¶
在 menuconfig 中设置:
Xiaozhi Assistant → Wake Word Implementation Type → Multinet model (Custom Wake Word)
Xiaozhi Assistant → Custom Wake Word = "xiao tu dou"
Xiaozhi Assistant → Custom Wake Word Display = "小土豆"
Xiaozhi Assistant → Custom Wake Word Threshold (%) = 20
编译后固件将使用拼音 "xiao tu dou" 作为唤醒词,阈值为 20%。
方案二:index.json 运行时配置(适用于 OTA 资源更新)¶
将包含 multinet_model 配置的 index.json 放入资源分区,系统启动时自动加载。此方式支持多个命令词且无需重新编译固件。
方案三:外部烧录模型文件(适用于 EspWakeWord / AfeWakeWord)¶
这两种方案的唤醒词由 ESP-SR 模型文件决定(如 wn9_nihaoxiaozhi_tts),需要替换模型文件并重新烧录。可通过 ESP 官方语音模型生成工具创建自定义 WakeNet 模型。
性能与资源考量¶
| 指标 | EspWakeWord | AfeWakeWord | CustomWakeWord |
|---|---|---|---|
| RAM 占用 | 最低(仅 WakeNet 状态) | 较高(AFE 实例 + 2s PCM 缓存) | 中等(MultiNet 状态 + 2s PCM 缓存) |
| CPU 占用 | 低(同步检测,无独立任务) | 高(独立任务 + AFE 处理) | 中等(同步检测,无 AFE) |
| 任务栈 | 无额外任务 | audio_detection: 4096B + encode_wake_word: 4096×6B |
encode_wake_word: 4096×7B |
| PSRAM | 部分平台需要 | 必须 | 必须 |
| 检测延迟 | 低(直接推理) | 受 AFE 前端处理影响 | 低(直接推理) |
AfeWakeWord 的 encode_wake_word 任务使用 xTaskCreateStatic 静态分配,栈内存来自 PSRAM(MALLOC_CAP_SPIRAM),任务控制块来自内部 SRAM(MALLOC_CAP_INTERNAL),体现了 ESP32 对稀缺 SRAM 资源的精细管理。
Sources: afe_wake_word.cc | custom_wake_word.cc
后续阅读¶
- 深入理解音频管线架构:AudioService 核心管线:三任务模型与数据队列
- 了解前端音频处理:音频前端处理:AEC 回声消除、VAD 与噪声抑制
- 理解设备状态机如何消费唤醒事件:设备状态机:状态定义与合法转换规则
- 查看通信协议中的唤醒词数据发送:通信协议总览:WebSocket 与 MQTT+UDP 双通道设计
音频前端处理:AEC 回声消除、VAD 与噪声抑制¶
本文档深入解析小智 AI 聊天机器人设备端的音频前端处理管线,涵盖 AEC(声学回声消除)、VAD(语音活动检测) 和 噪声抑制(NS) 三大核心能力的架构设计、配置方式与运行时行为。文档面向需要理解 AFE(Audio Front-End)集成细节、调试音频质量或为新硬件适配参考通道的高级开发者。
架构总览:AudioProcessor 抽象层¶
音频前端处理的核心是一个策略模式的抽象接口 — AudioProcessor,它将麦克风输入流的实时处理逻辑与上层 AudioService 解耦。该接口定义了统一的初始化、数据馈入、启停控制以及输出回调协议,使得不同的处理引擎可以无缝替换。
classDiagram
class AudioProcessor {
<<interface>>
+Initialize(codec, frame_duration_ms, models_list)
+Feed(data)
+Start()
+Stop()
+IsRunning() bool
+OnOutput(callback)
+OnVadStateChange(callback)
+GetFeedSize() size_t
+EnableDeviceAec(enable)
}
class AfeAudioProcessor {
-afe_iface_ : esp_afe_sr_iface_t*
-afe_data_ : esp_afe_sr_data_t*
-input_buffer_ : vector
-output_buffer_ : vector
-AudioProcessorTask()
}
class NoAudioProcessor {
-output_buffer_ : vector
-is_running_ : atomic
}
AudioProcessor <|-- AfeAudioProcessor
AudioProcessor <|-- NoAudioProcessor
项目提供两种实现:AfeAudioProcessor 基于 ESP-ADF(Espressif Audio Development Framework)的 Audio Front-End 模块,将 AEC、VAD、NS 集成为一个统一的实时处理管线;NoAudioProcessor 则是一个简洁的透传实现,仅完成立体声到单声道的转换和帧缓冲,适用于资源受限或不需要前端处理的场景。
编译期通过 Kconfig 开关 CONFIG_USE_AUDIO_PROCESSOR 选择具体实现:该选项依赖于 ESP32S3 / ESP32P4 芯片且须启用 PSRAM,当条件不满足时自动回退到 NoAudioProcessor。
Sources: audio_processor.h, afe_audio_processor.h, audio_service.cc
AfeAudioProcessor:ESP-ADF 音频前端集成¶
AfeAudioProcessor 是小智设备的默认音频处理引擎,它将 ESP-ADF 的 esp_afe_sr_iface_t 接口封装为符合 AudioProcessor 契约的实现。其核心架构是一个独立的 FreeRTOS 任务 "audio_communication"(优先级 3,栈空间 4096 字节),在该任务中执行 Feed → 内部处理 → Fetch 的循环。
AFE 配置:三合一管线¶
AFE 在一个统一的处理管线中同时完成回声消除、语音检测和噪声抑制。初始化时,AfeAudioProcessor::Initialize() 按以下顺序构建配置:
| 配置项 | 值 | 含义 |
|---|---|---|
afe_type |
AFE_TYPE_VC |
Voice Communication 模式,面向双向通话优化 |
afe_mode |
AFE_MODE_HIGH_PERF |
高性能模式,牺牲部分功耗换取更优处理质量 |
aec_mode |
AEC_MODE_VOIP_HIGH_PERF |
VoIP 级别的高性能回声消除 |
vad_mode |
VAD_MODE_0 |
VAD 模式 0,适合低信噪比环境 |
vad_min_noise_ms |
100 | 最小静音判定时长为 100ms |
ns_init |
取决于模型可用性 | 若 NS 模型存在则启用 |
afe_ns_mode |
AFE_NS_MODE_NET |
基于神经网络(NSNet)的噪声抑制 |
agc_init |
false |
自动增益控制关闭 |
memory_alloc_mode |
AFE_MEMORY_ALLOC_MORE_PSRAM |
优先使用外部 PSRAM 分配内存 |
模型选择通过 esp_srmodel_filter() 函数从模型列表中筛选:NS 模型以 ESP_NSNET_PREFIX 为前缀,VAD 模型以 ESP_VADN_PREFIX 为前缀。若模型加载失败(返回 nullptr),对应的功能模块将被禁用——这是一种优雅降级策略,确保设备在模型文件缺失时仍可正常工作。
Sources: afe_audio_processor.cc
参考通道与输入格式¶
AEC 正常运行的前提是硬件能够提供 参考信号(Reference Signal)——即扬声器正在播放的音频信号的精确副本。在 I2S 总线上,参考信号占用一个额外的输入通道,与麦克风通道共同构成多通道输入流。
输入格式字符串(input_format)直观地描述了通道布局:
"M"— 仅麦克风通道(无参考),AEC 无法工作
"MR"— 单麦克风 + 单参考(如 ESP-BOX-3),AEC 可用
"MMR"— 双麦克风 + 单参考(如 Korvo2 V3),AEC 可用
参考通道数量由 AudioCodec::input_reference() 查询得到。当 input_reference_ 为 true 时,编解码器输入通道数变为 2(input_channels_ = 2),其中一个为麦克风信号,另一个为参考信号。
硬件层面上,AUDIO_INPUT_REFERENCE 宏在板级 config.h 中定义并传递给 BoxAudioCodec 构造函数。目前支持参考通道的编解码器组合为 ES7210(ADC,四路麦克风输入)+ ES8311/ES8388(DAC),此类组合广泛用于 ESP-BOX-3、Korvo2 V3 等官方开发板,以及 Waveshare、立创·实战派等第三方兼容板。
Sources: afe_audio_processor.cc, audio_codec.h, box_audio_codec.cc, esp_box3_board.cc, config.h (esp-box-3)
Feed / Fetch 处理循环¶
AFE 管线采用经典的生产者-消费者异步模型。生产者是 AudioInputTask,以 10ms 为周期(160 采样点 @ 16kHz)从编解码器获取原始 PCM 数据并调用 Feed();消费者是 AFE 内部任务和 AudioProcessorTask,通过 fetch_with_delay() 以阻塞方式获取处理后的音频。
Feed 阶段(在 AudioInputTask 上下文中执行):
- 将输入数据追加到线程安全的
input_buffer_
- 以 AFE 要求的 chunk size(如 512 采样点)为单位,连续调用
afe_iface_->feed()将数据送入 AFE 内部缓冲区
- 从
input_buffer_中移除已消费的 chunk
Fetch 阶段(在 "audio_communication" 任务上下文中执行):
- 调用
afe_iface_->fetch_with_delay()阻塞等待处理结果
- 检查
res->vad_state:当状态由VAD_SILENCE变为VAD_SPEECH时触发vad_state_change_callback_(true),反之触发false
- 将处理后的 PCM 数据(
res->data)追加到output_buffer_
- 当
output_buffer_累积足够 60ms 帧(=frame_samples_= 960 采样点 @ 16kHz)时,通过output_callback_传递给编码队列
sequenceDiagram
participant IN as AudioInputTask
participant AFE as AfeAudioProcessor<br/>(input_buffer_)
participant TASK as audio_communication<br/>Task
participant ENC as Opus Encoder
loop 每10ms
IN->>AFE: Feed(160 samples)
AFE->>AFE: 累积至chunk size
AFE->>AFE: afe_iface_->feed()
end
loop 每AFE帧周期
TASK->>AFE: fetch_with_delay()
AFE-->>TASK: 处理结果 + VAD状态
TASK->>TASK: 检查VAD变化,回调通知
TASK->>AFE: 追加至output_buffer_
alt buffer >= 960 samples (60ms)
TASK->>ENC: output_callback_(960 samples)
end
end
这种设计的关键优势在于:Feed 调用是轻量的、非阻塞的(仅缓冲和触发 AFE 内部处理),保证 AudioInputTask 不会因处理延迟而丢失麦克风数据;而耗时的 AFE 计算在独立任务中异步完成。
Sources: afe_audio_processor.cc (Feed), afe_audio_processor.cc (AudioProcessorTask fetch loop)
VAD 状态通知与输出帧组装¶
VAD 状态变化通过 vad_state_change_callback_ 向上层 AudioService 报告,最终触发 Application 层的 MAIN_EVENT_VAD_CHANGE 事件。该事件用于实时更新 LED 指示灯状态(如语音交互时切换灯效),但不直接改变设备状态——设备状态的切换由云端下发的 tts / stt JSON 消息驱动。
处理后的音频输出采用 帧对齐缓冲 策略:AFE fetch 返回的数据块大小不一定等于编码器要求的帧大小(60ms = 960 samples),因此 output_buffer_ 承担聚合缓冲的职责。当缓冲区精确等于 frame_samples_ 时,整体移动(std::move);当超过时,拷贝一帧并擦除已消费部分。
Sources: afe_audio_processor.cc, application.cc (VAD event handling)
AEC 模式与运行时行为¶
小智设备支持三种 AEC 工作模式,由编译期 Kconfig 和运行时 SetAecMode() 共同决定:
| 模式 | 枚举值 | AFE 内部配置 | 默认侦听模式 | 硬件要求 |
|---|---|---|---|---|
| 关闭 AEC | kAecOff |
aec_init=false, vad_init=true |
AutoStop(自动停止) |
无 |
| 设备端 AEC | kAecOnDeviceSide |
aec_init=true, vad_init=false |
Realtime(实时) |
需参考通道 |
| 服务端 AEC | kAecOnServerSide |
aec_init=false, vad_init=true |
Realtime(实时) |
需服务端支持 |
三种模式在编译期互斥:CONFIG_USE_DEVICE_AEC 和 CONFIG_USE_SERVER_AEC 同时启用时会触发 #error 编译错误。模式选择直接影响 GetDefaultListeningMode() 的返回值——AEC 关闭时使用 AutoStop 模式(静音自动停止),AEC 启用时使用 Realtime 模式(持续传输,由服务端判断语音边界),这是因为 AEC 场景需要连续的双向音频流来维持回声消除自适应滤波器的收敛状态。
运行时可通过双击 BOOT 按钮切换设备端 AEC 的开启/关闭(仅限启用了 CONFIG_USE_DEVICE_AEC 的板卡):
// esp_box3_board.cc L85-L90
boot_button_.OnDoubleClick([this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateIdle) {
app.SetAecMode(app.GetAecMode() == kAecOff ? kAecOnDeviceSide : kAecOff);
}
});
SetAecMode() 通过 EnableDeviceAec(enable) 最终调用 AFE 接口的 enable_aec() / disable_aec() 和对应的 disable_vad() / enable_vad() 方法——因为 AEC 和 VAD 在 AFE 内部共享资源,启用 AEC 时需禁用 VAD(AEC 管线自身包含语音检测能力)。
Sources: Kconfig.projbuild, application.cc, application.cc, afe_audio_processor.cc, esp_box3_board.cc
AfeWakeWord:唤醒词检测中的 AFE 复用¶
AfeWakeWord 是 ESP-ADF AFE 的另一种使用模式,它同样实例化了一套 AFE 管线,但配置为 AFE_TYPE_SR(Speech Recognition,语音识别模式),专用于唤醒词检测。其 AEC 配置为 AEC_MODE_SR_HIGH_PERF,并根据编解码器的参考通道能力决定是否启用 AEC(afe_config->aec_init = codec_->input_reference())。
与 AfeAudioProcessor 的关键区别在于:AfeWakeWord 通过 res->wakeup_state == WAKENET_DETECTED 检测唤醒词触发,而非通过 VAD 状态。检测到唤醒词后,它会存储约 2 秒的 PCM 数据(通过 StoreWakeWordData() 维护一个滑动窗口),并在需要时将这段音频编码为 Opus 发送给服务端做声纹识别("谁在说话")。
这种设计意味着在支持参考通道的板卡上,唤醒词检测和音频通信都可以享受 AEC 带来的回声抑制效果,两者各自运行独立的 AFE 实例(分别在 "audio_detection" 和 "audio_communication" 任务中),由 AudioInputTask 根据当前设备状态决定将原始 PCM 送入哪个(或同时送入两个)管线。
Sources: afe_wake_word.cc, afe_wake_word.cc
Kconfig 配置全景¶
音频前端处理的完整 Kconfig 配置项如下表所示,它们共同决定了编译出的固件中音频管线的能力边界:
| 配置键 | 类型 | 默认值 | 依赖条件 | 说明 |
|---|---|---|---|---|
CONFIG_USE_AUDIO_PROCESSOR |
bool | y |
ESP32S3/P4 + PSRAM | 启用 AfeAudioProcessor,否则回退 NoAudioProcessor |
CONFIG_USE_DEVICE_AEC |
bool | n |
USE_AUDIO_PROCESSOR + 特定板卡 |
设备端回声消除(需参考通道硬件) |
CONFIG_USE_SERVER_AEC |
bool | n |
USE_AUDIO_PROCESSOR |
服务端回声消除(需服务端配合) |
CONFIG_USE_AUDIO_DEBUGGER |
bool | n |
— | 启用 UDP 音频调试数据发送 |
CONFIG_AUDIO_DEBUG_UDP_SERVER |
string | "192.168.2.100:8000" |
USE_AUDIO_DEBUGGER |
调试数据的目标 UDP 地址 |
CONFIG_WAKE_WORD_DETECTION_IN_LISTENING |
bool | n |
AFE/Custom 唤醒词 | 侦听期间保持唤醒词检测(可打断对话) |
CONFIG_USE_DEVICE_AEC 的白名单包含约 30 种板卡类型,涵盖 ESP-BOX 系列、Korvo2 V3、Waveshare 多款触控屏板、立创·实战派、征辰科技 Camera 等。这些板卡的共同特点是使用 BoxAudioCodec(ES7210 + ES8311/ES8388)且 AUDIO_INPUT_REFERENCE 为 true。
Sources: Kconfig.projbuild
AudioDebugger:UDP 音频调试¶
当 CONFIG_USE_AUDIO_DEBUGGER 启用时,AudioService::ReadAudioData() 在每次成功读取原始麦克风数据后,会将 PCM 数据通过 UDP Socket 发送到由 CONFIG_AUDIO_DEBUG_UDP_SERVER 配置的目标地址(格式 IP:PORT)。这为音频质量调优提供了实时监听能力——开发者可以在 PC 端运行 scripts/audio_debug_server.py 接收并分析原始音频波形。
调试器在 AudioService 中以惰性初始化方式创建(首次 ReadAudioData() 调用时),发送未经任何前端处理的原始 PCM 数据。这一定位使其成为排查硬件问题的首选工具:若调试器收到的数据已存在噪声或失真,问题定位于麦克风/编解码器/PCB 层面;若原始数据正常但 AFE 输出异常,问题定位于 AFE 配置。
Sources: audio_debugger.cc, audio_service.cc, audio_debug_server.py
NoAudioProcessor:透传降级路径¶
当 CONFIG_USE_AUDIO_PROCESSOR 未启用(如目标芯片为 ESP32 或 ESP32C3 且无 PSRAM),AudioService 实例化 NoAudioProcessor。其实现极为简洁:仅做立体声到单声道的左通道提取(当 input_channels() == 2 时),然后缓冲至 60ms 帧边界后回调输出。
该处理器忽略 OnVadStateChange() 设置的回调——无 VAD 能力意味着设备无法自动检测用户是否在说话,因此 AutoStop 侦听模式将依赖服务端下发的 stt stop 消息来决定何时停止采集。EnableDeviceAec() 被调用时仅打印错误日志,不产生实际效果。
Sources: no_audio_processor.cc, audio_service.cc
与 AudioService 的集成关系¶
整个音频前端处理管线在 AudioService 中的编排如下:
- 初始化阶段 (
AudioService::Initialize):根据CONFIG_USE_AUDIO_PROCESSOR创建AfeAudioProcessor或NoAudioProcessor,注册OnOutput回调(将处理后的 PCM 推送至编码队列)和OnVadStateChange回调(更新voice_detected_并通知 Application 层)。
- 运行阶段 (
AudioInputTask):以 10ms 为周期读取麦克风数据,若AS_EVENT_AUDIO_PROCESSOR_RUNNING事件位被置位,则将数据送入audio_processor_->Feed()。当同时进行唤醒词检测时,同一份数据也被送入wake_word_->Feed()。
- 启停控制 (
EnableVoiceProcessing):该方法管理AS_EVENT_AUDIO_PROCESSOR_RUNNING事件位。首次启用时调用audio_processor_->Initialize()(惰性初始化,模型列表由SetModelsList()预先注入),并重置输入重采样器以防止前序模式(如唤醒词检测)的残留数据导致缓冲区溢出。
- 编码桥接:处理后的 PCM 通过
OnOutput回调进入PushTaskToEncodeQueue(),然后经由OpusCodecTask编码为 Opus 包,最终进入audio_send_queue_等待网络发送。
graph TD
MIC[麦克风] -->|I2S| CODEC[AudioCodec]
CODEC -->|160 samples/10ms| AIT[AudioInputTask]
AIT -->|Feed| AFP[AfeAudioProcessor<br/>AEC + VAD + NS]
AFP -->|fetch| AT[audio_communication Task]
AT -->|OnOutput 960 samples/60ms| ENC[OpusCodecTask]
ENC -->|Opus Packet| SND[audio_send_queue_]
SND -->|PopPacketFromSendQueue| NET[网络发送]
AFP -->|OnVadStateChange| ASVC[AudioService::voice_detected_]
ASVC -->|MAIN_EVENT_VAD_CHANGE| APP[Application 主循环]
Sources: audio_service.cc (Initialize), audio_service.cc (AudioInputTask), audio_service.cc (EnableVoiceProcessing)
阅读下一步¶
音频前端处理是音频管线的"清洁"环节,理解其架构后建议继续阅读以下相关文档:
- AudioService 核心管线:三任务模型与数据队列:了解音频数据如何在 Input / Output / OpusCodec 三个任务间流转
- 离线语音唤醒:ESP-SR 与自定义唤醒词实现:深入了解 AfeWakeWord 的唤醒词检测机制
- 音频编解码器集成:ES8311 / ES8388 / ES8374 等芯片适配:了解参考通道在硬件层面如何实现
- 系统架构全景:从麦克风到云端大模型的完整数据流:俯瞰端到端数据流
通信协议¶
通信协议总览:WebSocket 与 MQTT+UDP 双通道设计¶
小智 AI 聊天机器人支持两种底层通信协议——WebSocket 和 MQTT+UDP 混合协议,它们共享同一套上层消息语义(JSON 控制消息 + Opus 音频流),但在传输层架构上有着本质差异。本文档从架构层面剖析两者的设计哲学、共性与分歧,帮助开发者理解协议选型的权衡依据。
Sources: protocol.h
统一抽象基类:Protocol 接口设计¶
两种协议并非孤立存在——它们继承自同一个抽象基类 Protocol。这个基类定义了一套完整的回调接口与消息发送方法,使得上层的 Application 无需关心底层是 WebSocket 还是 MQTT+UDP,只需面向接口编程。
classDiagram
class Protocol {
<<abstract>>
+Start() bool
+OpenAudioChannel() bool
+CloseAudioChannel(bool) void
+IsAudioChannelOpened() bool
+SendAudio(unique_ptr~AudioStreamPacket~) bool
+SendWakeWordDetected(string) void
+SendStartListening(ListeningMode) void
+SendStopListening() void
+SendAbortSpeaking(AbortReason) void
+SendMcpMessage(string) void
#SendText(string) bool
#SetError(string) void
#IsTimeout() bool
-server_sample_rate_ : int
-server_frame_duration_ : int
-session_id_ : string
-error_occurred_ : bool
-last_incoming_time_ : time_point
}
class WebsocketProtocol {
-websocket_ : unique_ptr~WebSocket~
-version_ : int
-event_group_handle_ : EventGroupHandle
+Start() bool
+OpenAudioChannel() bool
+CloseAudioChannel(bool) void
+SendAudio(unique_ptr~AudioStreamPacket~) bool
-GetHelloMessage() string
-ParseServerHello(cJSON*) void
-SendText(string) bool
}
class MqttProtocol {
-mqtt_ : unique_ptr~Mqtt~
-udp_ : unique_ptr~Udp~
-aes_ctx_ : mbedtls_aes_context
-local_sequence_ : uint32
-remote_sequence_ : uint32
-reconnect_timer_ : esp_timer_handle
+Start() bool
+OpenAudioChannel() bool
+CloseAudioChannel(bool) void
+SendAudio(unique_ptr~AudioStreamPacket~) bool
-StartMqttClient(bool) bool
-GetHelloMessage() string
-ParseServerHello(cJSON*) void
-DecodeHexString(string) string
-SendText(string) bool
}
Protocol <|-- WebsocketProtocol
Protocol <|-- MqttProtocol
基类中已经实现了大部分 JSON 消息的构造逻辑——SendAbortSpeaking、SendWakeWordDetected、SendStartListening、SendStopListening、SendMcpMessage 等方法均使用字符串拼接生成标准 JSON,然后调用纯虚函数 SendText 完成实际发送。子类只需要实现 SendText 和 SendAudio 这两个核心传输方法,以及通道的打开/关闭逻辑。
Sources: protocol.cc, protocol.h
协议选择机制¶
设备并非在编译期锁定协议类型,而是在运行时根据 OTA 服务器下发(或本地 NVS 存储)的配置动态决定。关键代码位于 Application::InitializeProtocol():
if (ota_->HasMqttConfig()) {
protocol_ = std::make_unique<MqttProtocol>();
} else if (ota_->HasWebsocketConfig()) {
protocol_ = std::make_unique<WebsocketProtocol>();
} else {
protocol_ = std::make_unique<MqttProtocol>(); // 默认回退
}
这意味着同一份固件可以在不重新编译的情况下,通过 OTA 配置切换通信协议。Ota 类通过 HasMqttConfig() 和 HasWebsocketConfig() 两个标志位来判断服务器下发的配置类型。
Sources: application.cc, ota.h
双协议架构对比¶
下表从多个维度对两种协议进行系统比较:
| 维度 | WebSocket | MQTT + UDP |
|---|---|---|
| 控制通道 | WebSocket 文本帧 | MQTT Publish/Subscribe |
| 音频通道 | WebSocket 二进制帧 | UDP 加密数据报 |
| 连接模型 | 单 TCP 连接 | MQTT (TCP) + UDP 双通道 |
| 音频加密 | TLS 全通道加密 | AES-CTR 音频独立加密 |
| 实时性 | 中等(TCP 有序交付) | 高(UDP 无连接,低延迟) |
| 可靠性 | 高(TCP 保证交付) | 中等(UDP 可能丢包) |
| 防火墙友好度 | 高(标准 443 端口) | 低(需额外开放 UDP 端口) |
| 二进制协议版本 | 支持 v1/v2/v3 | 仅使用 UDP 专用格式 |
| 连接保持 | 按需连接 | MQTT 长连接 + 断线自动重连 |
| 超时检测 | 基类 120 秒 | 基类 120 秒 |
| 实现复杂度 | 低(约 255 行) | 高(约 390 行) |
Sources: websocket_protocol.cc, mqtt_protocol.cc, mqtt-udp_zh.md
握手流程:Hello 消息交换¶
无论哪种协议,建立音频通道的第一步都是 Hello 消息交换——设备端发送自身能力声明,服务器返回会话参数。这是两种协议的共同交互模式,但细节上存在差异。
sequenceDiagram
participant Device as ESP32 设备
participant Server as 服务器
Note over Device,Server: 共同阶段:Hello 握手
Device->>Server: Hello (type, version, transport, audio_params, features)
Server->>Device: Hello Response (session_id, audio_params, [udp 配置])
alt WebSocket 模式
Note over Device,Server: 单通道,直接进入业务交互
Device->>Server: JSON 控制消息 (文本帧)
Device->>Server: Opus 音频数据 (二进制帧)
Server->>Device: JSON 控制消息 (文本帧)
Server->>Device: Opus 音频数据 (二进制帧)
else MQTT+UDP 模式
Note over Device,Server: 先建立 UDP 加密通道
Device->>Server: UDP 连接
Note over Device,Server: 双通道并行
Device->>Server: JSON 控制消息 (MQTT Publish)
Device->>Server: 加密 Opus 音频 (UDP 数据报)
Server->>Device: JSON 控制消息 (MQTT Publish)
Server->>Device: 加密 Opus 音频 (UDP 数据报)
end
WebSocket 的 hello 消息中 transport 字段为 "websocket",二进制协议版本(v1/v2/v3)由 Settings 中的 version 配置决定。请求头携带 Authorization(Bearer Token)、Protocol-Version、Device-Id(MAC 地址)、Client-Id(UUID)四类元数据。
MQTT+UDP 的 hello 消息中 transport 字段为 "udp",版本固定为 3。服务器的 hello 响应中额外包含 udp 对象,内含 UDP 服务器地址、端口、AES 密钥和随机数(均为十六进制字符串),用于后续建立加密 UDP 音频通道。
Sources: websocket_zh.md, mqtt-udp_zh.md, websocket_protocol.cc, mqtt_protocol.cc
WebSocket 协议:简约的单通道方案¶
WebSocket 协议的核心理念是 一切通过同一条 TCP 连接传输。JSON 控制消息走文本帧(opcode=0x1),Opus 音频数据走二进制帧(opcode=0x2),WebSocket 协议本身就能区分二者。
二进制协议版本¶
为解决单纯的 Opus 裸流缺乏元数据的问题,WebSocket 协议支持三种二进制封装格式:
| 版本 | 结构体 | 元数据内容 | 适用场景 |
|---|---|---|---|
| v1 | 无封装,直接 Opus | 无 | 最简单的部署,依赖 WebSocket 帧类型区分 |
| v2 | BinaryProtocol2 (12字节头) |
version, type, timestamp, payload_size | 服务器端 AEC,需要时间戳对齐 |
| v3 | BinaryProtocol3 (4字节头) |
type, payload_size | 轻量级元数据,JSON 也可走二进制帧 |
v2 协议携带 32 位毫秒时间戳,使服务器能够进行回声消除(AEC)所需的时间对齐。v3 则是一个简化版本,通过 type 字段(0=OPUS, 1=JSON)在二进制帧内区分数据类型。
Sources: protocol.h, websocket_zh.md
连接生命周期¶
WebSocket 采用按需连接模式:设备在空闲状态时不保持 WebSocket 连接,仅在用户触发语音交互时才调用 OpenAudioChannel() 建立连接。连接建立后等待服务器 hello 响应(超时 10 秒),成功则触发 on_audio_channel_opened_ 回调进入 Listening 状态,会话结束则调用 CloseAudioChannel() 直接销毁 WebSocket 对象。
Sources: websocket_protocol.cc
MQTT+UDP 协议:控制与音频分离¶
MQTT+UDP 混合协议的设计哲学是关注点分离——将可靠但延迟不敏感的控制信令交给 MQTT(基于 TCP),将实时性敏感的音频流交给 UDP,两者在物理和逻辑上都彼此独立。
双通道交互模型¶
flowchart LR
subgraph Device[ESP32 设备]
MQTT_Client[MQTT 客户端]
UDP_Client[UDP 客户端]
AES[AES-CTR 加密引擎]
end
subgraph Server[服务器]
MQTT_Broker[MQTT Broker]
UDP_Server[UDP 服务器]
end
MQTT_Client <-->|TLS :8883\n控制消息/JSON| MQTT_Broker
UDP_Client -->|加密 Opus 音频| UDP_Server
UDP_Server -->|加密 Opus 音频| UDP_Client
AES -.->|加密/解密| UDP_Client
MQTT 通道在设备启动时即建立连接(Start() 调用 StartMqttClient()),并保持长连接。当 MQTT 连接断开时,内置的 60 秒间隔自动重连定时器会尝试恢复连接。UDP 通道则是按需建立——仅在 hello 握手成功、获取加密参数后才创建 UDP socket 并连接服务器。
Sources: mqtt_protocol.h, mqtt_protocol.cc
UDP 音频加密与序列号保护¶
UDP 通道的音频数据采用 AES-CTR 模式加密。加密过程中,nonce 的不同字节位被赋予了特定语义:
| Nonce 偏移 | 长度 | 内容 | 目的 |
|---|---|---|---|
| [0:1] | 2 字节 | 服务器下发的 nonce 前缀 | 会话唯一性 |
| [2:3] | 2 字节 | payload_size(网络字节序) |
防篡改 |
| [4:7] | 4 字节 | 保留 | — |
| [8:11] | 4 字节 | timestamp(网络字节序) |
时间验证 |
| [12:15] | 4 字节 | sequence(网络字节序) |
防重放 |
发送端 local_sequence_ 单调递增,接收端验证 remote_sequence_ 的连续性。UDP 数据包头部包含 type(固定 0x01)、flags、payload_len、ssrc、timestamp、sequence 六个字段,接收端会拒绝序列号倒退的旧数据包,对轻微跳跃则记录警告后继续处理。
Sources: mqtt_protocol.cc, mqtt_protocol.cc, mqtt-udp_zh.md
MQTT 的 Goodbye 消息¶
MQTT+UDP 协议中引入了一个 WebSocket 没有的概念:Goodbye 消息。当设备主动关闭音频通道时,会通过 MQTT 发送一条 {"type":"goodbye"} 消息通知服务器。反之,当服务器主动发送 goodbye 时,设备端会解析 session_id 并关闭通道,且不再回复 goodbye(避免 ping-pong 死循环)。
Sources: mqtt_protocol.cc, mqtt-udp_zh.md
共享的上层消息语义¶
无论底层协议如何,设备与服务器之间的 JSON 消息类型完全一致。以下为所有通信类型及其流向:
| 消息 type | 方向 | 主要字段 | 业务含义 |
|---|---|---|---|
hello |
双向 | version, transport, audio_params, features, [udp] | 握手协商 |
listen |
设备→服务器 | state (start/stop/detect), mode (auto/manual/realtime) | 录音状态通知 |
abort |
设备→服务器 | reason (wake_word_detected/...) | 中断当前输出 |
stt |
服务器→设备 | text | 语音识别结果 |
tts |
服务器→设备 | state (start/stop/sentence_start), text | TTS 播放控制 |
llm |
服务器→设备 | emotion, text | 表情/情感指令 |
mcp |
双向 | payload (JSON-RPC 2.0) | 物联网设备控制 |
system |
服务器→设备 | command (reboot) | 系统级命令 |
custom |
服务器→设备 | payload | 自定义扩展消息(可选) |
goodbye |
双向(仅 MQTT) | session_id | 通道关闭通知 |
所有这些消息共享相同的 session_id 字段用于会话关联。features 对象在 hello 消息中声明设备能力——目前包含 mcp(是否支持 MCP 协议)和 aec(是否请求服务器端 AEC)。
Sources: websocket_zh.md, mqtt-udp_zh.md, protocol.cc
MCP 协议:跨传输层的统一控制面¶
MCP(Model Context Protocol)是推荐用于物联网控制的新一代协议,它并非另一种传输层协议,而是运行在 WebSocket 或 MQTT 之上的应用层协议。所有 MCP 消息通过 type: "mcp" 的 JSON 消息承载,内部 payload 为标准的 JSON-RPC 2.0 格式。
// 服务器下发工具调用(经 WebSocket 文本帧或 MQTT Publish)
{
"session_id": "xxx",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.light.set_rgb",
"arguments": { "r": 255, "g": 0, "b": 0 }
},
"id": 1
}
}
这种设计使得 MCP 协议天然具备传输层无关性——无论设备当前使用 WebSocket 还是 MQTT+UDP,服务器下发的控制指令都以完全相同的格式送达设备端,由 McpServer 统一处理。
Sources: websocket_zh.md, protocol.cc
超时与错误处理机制¶
基类 Protocol 提供了统一的超时检测:IsTimeout() 方法检查距离最后一次收到数据是否超过 120 秒,超时后将 error_occurred_ 标记为 true,导致 IsAudioChannelOpened() 返回 false,从而触发上层状态机回退到 Idle。
两种协议在错误处理上的差异体现在重连策略:
| 场景 | WebSocket | MQTT+UDP |
|---|---|---|
| 初始连接失败 | 立即报错,触发 on_network_error_ |
立即报错,但保持重连定时器 |
| 通道断开 | 回调 on_audio_channel_closed_,回到 Idle |
同上。MQTT 连接断开额外触发 60 秒重连 |
| Server Hello 超时 | 10 秒超时 → 错误 | 10 秒超时 → 错误 |
| 数据传输失败 | 调用 SetError → 触发错误事件 |
同上 |
Sources: protocol.cc, websocket_protocol.cc, mqtt_protocol.cc
协议选型决策指南¶
选择哪种协议取决于部署环境和对实时性/可靠性的权衡:
| 场景 | 推荐协议 | 理由 |
|---|---|---|
| 家庭 Wi-Fi 环境 + 标准路由器 | WebSocket | 部署简单,单端口,防火墙友好 |
| 4G 蜂窝网络(如 ML307) | MQTT+UDP | MQTT 长连接更省电,UDP 降低音频延迟 |
| 企业内网 / 对延迟敏感的应用 | MQTT+UDP | 音频走 UDP 绕过 TCP 队头阻塞 |
| 穿透复杂防火墙 / 仅开放 443 | WebSocket | WebSocket over TLS 伪装为 HTTPS 流量 |
| 需要离线消息 / QoS 等级控制 | MQTT+UDP | MQTT 原生支持 QoS 0/1/2 |
| 快速原型验证 | WebSocket | 代码量少,调试方便 |
Sources: mqtt-udp_zh.md
文件结构导航¶
通信协议相关核心文件一览:
main/protocols/
├── protocol.h # 抽象基类:AudioStreamPacket, 回调接口, 公共消息方法
├── protocol.cc # 基类实现:JSON 消息构造, 超时检测
├── websocket_protocol.h # WebSocket 协议子类声明
├── websocket_protocol.cc # WebSocket 协议实现:连接, 握手, 二进制版本
├── mqtt_protocol.h # MQTT+UDP 协议子类声明
└── mqtt_protocol.cc # MQTT+UDP 协议实现:MQTT 控制, UDP 加密音频
main/
├── application.h # Application 持有 protocol_ 成员
└── application.cc # InitializeProtocol() 动态选择协议
docs/
├── websocket_zh.md # WebSocket 协议详细文档
└── mqtt-udp_zh.md # MQTT+UDP 协议详细文档
阅读建议¶
本文档作为协议层的总览入口,建议按以下路径深入阅读:
- WebSocket 协议详解:握手、消息格式与二进制版本 — 深入 WebSocket 协议的握手流程、JSON 消息结构和三种二进制协议版本的实现细节
- MQTT+UDP 混合协议:控制与音频分离的加密传输 — 详细解析 MQTT 控制通道与 UDP 加密音频通道的设计与实现
- MCP 协议交互流程:JSON-RPC 2.0 的设备端实现 — 了解 MCP 协议如何在不同传输层上运行,以及设备端的工具注册与调用机制
- Application 主控与事件驱动循环 — 理解 Application 层如何通过事件驱动模型调度协议层的生命周期
- 系统架构全景:从麦克风到云端大模型的完整数据流 — 将协议层置于全局架构中,理解数据如何在各层之间流动
WebSocket 协议详解:握手、消息格式与二进制版本¶
本文深入剖析小智设备端 WebSocket 协议的完整实现:从 Protocol 基类的抽象契约、WebsocketProtocol 的握手与消息处理,到三种二进制协议版本的内存布局与序列化细节。读者将理解 WebSocket 通道如何在 Application 事件驱动架构中打开与关闭,以及 JSON 文本帧与 Opus 二进制帧如何协同完成语音交互全流程。
协议定位与架构分层¶
在进入具体实现之前,有必要先理解 WebSocket 协议在小智系统中的位置。通信层面存在一个两级抽象:Protocol 基类定义了所有通信协议(WebSocket 与 MQTT+UDP)必须实现的统一接口,而 WebsocketProtocol 是这一接口的 WebSocket 具体实现。Application 主控类持有 std::unique_ptr<Protocol> protocol_ 的基类指针,通过多态调用完全解耦上层业务逻辑与底层传输机制。
这一设计的核心价值在于:Application 的状态机驱动逻辑(切换 Listening / Speaking 状态、发送 StartListening / AbortSpeaking 消息)无需知晓底层究竟是 WebSocket 还是 MQTT。协议选择由 OTA 配置决定 —— 当服务器下发的配置中包含 websocket 参数时,InitializeProtocol() 创建 WebsocketProtocol 实例;若包含 mqtt 参数则创建 MqttProtocol。
Sources: protocol.h, application.cc
classDiagram
class Protocol {
<<abstract>>
+OpenAudioChannel() bool
+CloseAudioChannel(bool) void
+SendAudio(unique_ptr~AudioStreamPacket~) bool
+SendText(string) bool
#server_sample_rate_
#server_frame_duration_
#on_incoming_audio_
#on_incoming_json_
}
class WebsocketProtocol {
-EventGroupHandle_t event_group_handle_
-unique_ptr~WebSocket~ websocket_
-int version_
+OpenAudioChannel() bool
+SendAudio(unique_ptr~AudioStreamPacket~) bool
-GetHelloMessage() string
-ParseServerHello(cJSON*) void
}
class MqttProtocol {
-unique_ptr~Mqtt~ mqtt_
-unique_ptr~Udp~ udp_
+OpenAudioChannel() bool
+SendAudio(unique_ptr~AudioStreamPacket~) bool
}
class Application {
-unique_ptr~Protocol~ protocol_
+InitializeProtocol() void
}
Protocol <|-- WebsocketProtocol
Protocol <|-- MqttProtocol
Application --> Protocol
握手全流程:从 OpenAudioChannel 到 Server Hello¶
WebSocket 通道的打开并非简单的 TCP 连接,而是一段包含 HTTP Upgrade 握手、客户端 Hello、服务端 Hello 确认的三阶段同步过程。整个过程在 OpenAudioChannel() 方法中同步执行,调用方(Application)会在 kDeviceStateConnecting 状态下等待其返回。
第一阶段:配置加载与连接建立¶
OpenAudioChannel() 首先从 NVS 命名空间 "websocket" 读取三项关键配置:
| 配置键 | 类型 | 用途 | 默认值 |
|---|---|---|---|
url |
string | WebSocket 服务器地址(如 ws://192.168.1.100:8000) |
空(必填) |
token |
string | 鉴权令牌,若不含空格则自动添加 "Bearer " 前缀 |
空 |
version |
int | 二进制协议版本号(1 / 2 / 3) | 1 |
然后通过 Board::GetInstance().GetNetwork()->CreateWebSocket(1) 创建底层 WebSocket 连接对象,并设置四个 HTTP 请求头:
Authorization:Bearer <token>,用于服务器鉴权
Protocol-Version: 二进制协议版本号(与 hello 消息体内的version字段一致)
Device-Id: 设备物理网卡 MAC 地址(只读硬件标识)
Client-Id: 软件生成的 UUID(擦除 NVS 或重新烧录固件时会重置)
Sources: websocket_protocol.cc
第二阶段:客户端 Hello¶
WebSocket 连接建立后,设备立即发送一条 JSON 格式的 "hello" 消息,向服务器声明自身能力:
{
"type": "hello",
"version": 2,
"features": {
"aec": true,
"mcp": true
},
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
GetHelloMessage() 使用 cJSON 动态构建此消息。其中 features 对象按编译条件动态填充:"aec": true 仅在 CONFIG_USE_SERVER_AEC 启用时出现;"mcp": true 始终为真,因为 MCP 协议已全面取代旧版 IoT 协议。frame_duration 的值来自编译期常量 OPUS_FRAME_DURATION_MS(默认 60ms),代表了设备端 Opus 编码器的帧时长。
Sources: websocket_protocol.cc
第三阶段:等待 Server Hello(含超时机制)¶
发送 hello 后,设备调用 xEventGroupWaitBits() 阻塞等待 WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT 事件位,超时时间为 10 秒。此事件位在 ParseServerHello() 中被设置 —— 该函数由 WebSocket 的 OnData 回调在收到 type: "hello" 的文本帧时触发。
ParseServerHello() 执行三项校验与数据提取:
- transport 校验: 必须为
"websocket",否则记录错误并放弃连接
- session_id 提取: 若服务器下发
session_id,保存到Protocol基类的session_id_成员,后续所有消息都将携带此 ID
- 音频参数协商: 若服务器在
audio_params中指定了sample_rate和frame_duration,覆盖基类默认值(24000Hz / 60ms),用于下行音频解码
Sources: websocket_protocol.cc, websocket_protocol.cc
sequenceDiagram
participant App as Application
participant WS as WebsocketProtocol
participant Svr as WebSocket Server
App->>WS: OpenAudioChannel()
WS->>WS: 读取 NVS 配置 (url, token, version)
WS->>WS: CreateWebSocket + 设置请求头
WS->>Svr: HTTP Upgrade (Authorization, Protocol-Version, Device-Id, Client-Id)
Svr-->>WS: 101 Switching Protocols
WS->>Svr: JSON hello (type, version, features, audio_params)
Svr-->>WS: JSON hello (transport, session_id, audio_params)
WS->>WS: ParseServerHello() → 设置 event bit
WS-->>App: return true (通道就绪)
二进制协议版本:三种帧格式的深度对比¶
设备在 SendAudio() 中根据 version_ 成员选择不同的序列化策略。三种版本的演进反映了开发者的需求迭代:v1 追求零开销,v2 引入时间戳以支持服务器端 AEC,v3 则精简头部以降低带宽。
版本 1:原始 Opus 透传(默认)¶
最简实现:直接将 Opus 编码后的 payload 作为 WebSocket binary 帧发送,不附加任何元数据。服务器端必须通过带外方式(hello 消息中的 audio_params)获知采样率和帧时长。
// version_ == 1: 零拷贝透传
return websocket_->Send(packet->payload.data(), packet->payload.size(), true);
适用场景: 设备端不启用服务器端 AEC(CONFIG_USE_SERVER_AEC=n),服务器无需时间戳对齐。
版本 2:带时间戳的全功能协议¶
结构体 BinaryProtocol2 采用固定 16 字节头部,所有多字节字段使用网络字节序(大端),通过 htons / htonl 进行主机序到网络序的转换。
| 字段 | 偏移 | 大小 | 字节序 | 含义 |
|---|---|---|---|---|
version |
0 | 2 | 大端 (htons) | 协议版本号(始终为 2) |
type |
2 | 2 | 大端 (htons) | 消息类型:0 = OPUS 音频,1 = JSON |
reserved |
4 | 4 | — | 保留字段(当前固定为 0) |
timestamp |
8 | 4 | 大端 (htonl) | 毫秒级时间戳,用于服务器端 AEC 回声对齐 |
payload_size |
12 | 4 | 大端 (htonl) | 负载字节数 |
payload |
16 | N | — | Opus 编码音频数据或 JSON 文本 |
在接收方向,OnData 回调中对 BinaryProtocol2 执行对应的 ntohs / ntohl 转换,并将解析出的 timestamp 和 payload 封装为 AudioStreamPacket 投递给 on_incoming_audio_ 回调。
Sources: protocol.h, websocket_protocol.cc
版本 3:精简头部协议¶
BinaryProtocol3 将头部压缩至 4 字节,适合带宽敏感场景。但代价是丢失了时间戳信息,无法支持服务器端 AEC。
| 字段 | 偏移 | 大小 | 字节序 | 含义 |
|---|---|---|---|---|
type |
0 | 1 | 本机序 | 消息类型(0 = OPUS, 1 = JSON) |
reserved |
1 | 1 | — | 保留字段 |
payload_size |
2 | 2 | 大端 (htons) | 负载字节数 |
payload |
4 | N | — | Opus 编码音频或 JSON |
Sources: protocol.h, websocket_protocol.cc
三种版本的选择决策¶
| 维度 | v1 | v2 | v3 |
|---|---|---|---|
| 头部大小 | 0 字节 | 16 字节 | 4 字节 |
| 时间戳 | ✗ | ✓ (毫秒级) | ✗ |
| 支持服务器端 AEC | ✗ | ✓ | ✗ |
| 支持 JSON over Binary | ✗ | ✓ (type=1) | ✓ (type=1) |
| 适用场景 | 简单直连、无 AEC | 服务器端 AEC 开启 | 带宽敏感、无 AEC |
版本选择通过 NVS 中的 version 配置键指定,在 OpenAudioChannel() 中读取。注意: v2 和 v3 中虽然定义了 type=1 表示 JSON 负载,但当前代码中 SendAudio() 始终硬编码 type = 0(仅发送 OPUS 音频),JSON 消息始终通过独立的 SendText() 方法以 WebSocket text 帧发送。二进制 JSON 通道目前是预留能力。
文本消息机制:JSON 帧的类型分发¶
WebSocket 协议的核心设计哲学之一是文本与二进制分离:控制信令走 JSON 文本帧,音频数据走二进制帧。WebsocketProtocol 在 OnData 回调中通过 binary 标志进行分流:
binary == true → on_incoming_audio_() → Application::音频解码播放
binary == false → cJSON解析 → type字段分发 → on_incoming_json_()
一个特殊处理是:当 type 为 "hello" 时,消息在协议层内部被拦截,由 ParseServerHello() 处理,不透传给 Application。这是正确的分层设计 —— 握手是传输层的职责,不应泄露到业务层。
Sources: websocket_protocol.cc
文本消息的发送则通过基类 Protocol 中实现的一组辅助方法完成,这些方法在基类中构建 JSON 字符串后调用子类的 SendText():
| 基类方法 | 构造的消息 | 触发时机 |
|---|---|---|
SendStartListening(mode) |
{"type":"listen","state":"start","mode":"auto\|manual\|realtime"} |
设备进入 Listening 状态 |
SendStopListening() |
{"type":"listen","state":"stop"} |
设备退出 Listening 状态 |
SendAbortSpeaking(reason) |
{"type":"abort"[, "reason":"wake_word_detected"]} |
用户打断 TTS 播放 |
SendWakeWordDetected(wake_word) |
{"type":"listen","state":"detect","text":"<唤醒词>"} |
唤醒词被检测到 |
SendMcpMessage(payload) |
{"type":"mcp","payload":<JSON-RPC 2.0>} |
MCP 工具调用结果 |
所有这些消息都自动携带 session_id 字段,确保服务器能将消息关联到正确的会话。
Sources: protocol.cc
Application 中的回调集成:六个关键钩子¶
InitializeProtocol() 为 protocol_ 注册了六个回调,这些回调构成了 WebSocket 通道与 Application 事件循环之间的桥梁:
flowchart LR
subgraph WS["WebsocketProtocol"]
OC[OnConnected]
OE[OnNetworkError]
IA[OnIncomingAudio]
IJ[OnIncomingJson]
AO[OnAudioChannelOpened]
AC[OnAudioChannelClosed]
end
subgraph APP["Application"]
DA[DismissAlert]
EE[MAIN_EVENT_ERROR]
PD[PushPacketToDecodeQueue]
JP[JSON 解析 → 状态转换/MCP]
HP[高性能模式 + 采样率检查]
LP[低功耗模式 + 回 Idle]
end
OC --> DA
OE --> EE
IA --> PD
IJ --> JP
AO --> HP
AC --> LP
其中 OnIncomingJson 是业务逻辑最密集的回调。它直接解析 JSON 的 type 字段并执行对应操作:"tts" 触发 Speaking/Listening 状态切换;"stt" 将识别文本显示到屏幕;"llm" 更新表情动画;"mcp" 转发给 McpServer::ParseMessage();"system" 处理 reboot 等系统指令。
一个重要的线程安全设计:所有 UI 操作(display->SetChatMessage()、SetEmotion())和状态转换都通过 Schedule() 投递到主任务执行,避免在 WebSocket 回调线程中直接操作 FreeRTOS 不安全的对象。
Sources: application.cc
超时与错误处理¶
WebSocket 通道具备两层超时保护:
- 握手超时:
OpenAudioChannel()中xEventGroupWaitBits等待 Server Hello 最长 10 秒。超时后调用SetError(Lang::Strings::SERVER_TIMEOUT),触发MAIN_EVENT_ERROR事件使设备回到 Idle 状态。
- 通道空闲超时: 基类
Protocol::IsTimeout()通过last_incoming_time_追踪最后一次收到数据的时间戳(在OnData回调末尾刷新)。若 120 秒内无任何消息(音频或 JSON),IsAudioChannelOpened()返回 false,导致 Application 判定通道已僵死。
Sources: websocket_protocol.cc, protocol.cc
连接异常断开时,OnDisconnected 回调直接触发 on_audio_channel_closed_(),Application 随之调度回 Idle 状态并切换到低功耗模式。
阅读建议¶
读者已掌握 WebSocket 协议在设备端的完整实现细节。为建立全局视角,建议按以下路径继续:
- 回到 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计 对比两种传输模式的架构差异
- 深入 MQTT+UDP 混合协议:控制与音频分离的加密传输 理解 UDP 音频通道与 AES 加密
- 探索 MCP 协议交互流程:JSON-RPC 2.0 的设备端实现 了解通过
type:"mcp"消息承载的物联网控制协议
- 回到 Application 主控与事件驱动循环 理解
OnIncomingJson回调在事件循环中的完整处理路径
MQTT+UDP 混合协议:控制与音频分离的加密传输¶
本文档从源码层面深入解析小智 AI 聊天机器人设备端 MQTT+UDP 混合通信协议的架构设计、数据流路径、加密机制与状态管理。与 WebSocket 协议作为平行替代方案,MQTT+UDP 协议通过控制通道与音频通道的彻底分离,在实时性与传输效率之间取得了独特的平衡点。
1. 架构全景:双通道设计的动机与原理¶
在语音交互系统中,存在两类性质迥异的网络流量。控制消息(STT 结果、TTS 状态、MCP 指令、设备状态同步)属于低吞吐、高可靠性需求;音频数据(Opus 编码的语音流)则是高吞吐、低延迟优先。传统 WebSocket 方案将两者复用在同一条 TCP 连接上,虽然简化了握手和加密逻辑,却不可避免地引入 TCP 队头阻塞(Head-of-Line Blocking)——当音频帧堆积时,控制消息的交付时延随之抖动。
MQTT+UDP 混合协议将这两类流量解耦到不同传输层:
- MQTT(TCP/TLS)承载控制面:利用 MQTT Broker 的主题发布/订阅模型分发 JSON 消息,天然支持心跳保活、QoS 分级、遗嘱消息和断线重连。
- UDP 承载数据面:Opus 音频帧直接通过 UDP 数据报发送,零连接开销,配合 AES-CTR 流密码实现线速加解密。
graph TB
subgraph Device["ESP32 设备端"]
APP["Application<br/>主控 & 事件循环"]
AUDIO["AudioService<br/>三任务音频管线"]
MQTTP["MqttProtocol<br/>控制通道管理器"]
end
subgraph Server["服务器端"]
BROKER["MQTT Broker<br/>TLS :8883"]
UDP_SRV["UDP Server<br/>AES-CTR 加密"]
end
APP -->|"JSON 控制消息"| MQTTP
MQTTP -->|"Publish/Subscribe"| BROKER
AUDIO -->|"Opus 音频帧"| MQTTP
MQTTP -->|"加密 Opus 数据报"| UDP_SRV
BROKER -->|"Hello / STT / TTS / MCP"| MQTTP
UDP_SRV -->|"加密 Opus 数据报"| MQTTP
MQTTP -->|"解密音频包"| AUDIO
数据流方向汇总:
| 方向 | 通道 | 内容 | 编码格式 |
|---|---|---|---|
| 设备 → 服务器 | MQTT | Hello、Listen、Abort、MCP、Goodbye | JSON |
| 设备 → 服务器 | UDP | 麦克风采集的语音帧 | Opus → AES-CTR |
| 服务器 → 设备 | MQTT | Hello、STT、TTS、LLM、MCP、System、Alert | JSON |
| 服务器 → 设备 | UDP | TTS 合成的语音帧 | Opus → AES-CTR |
Sources: mqtt-udp_zh.md, mqtt-udp.md
2. 协议选择与初始化流程¶
设备启动时,Application::ActivationTask() 会通过 OTA 服务向服务器查询配置信息。服务器在激活响应中通过 has_mqtt_config_ 标志指示设备使用 MQTT+UDP 协议还是 WebSocket 协议。这一决策在 InitializeProtocol() 中落地:
// application.cc: InitializeProtocol()
if (ota_->HasMqttConfig()) {
protocol_ = std::make_unique<MqttProtocol>();
} else if (ota_->HasWebsocketConfig()) {
protocol_ = std::make_unique<WebsocketProtocol>();
} else {
// 兜底:默认使用 MQTT
protocol_ = std::make_unique<MqttProtocol>();
}
这种工厂式的选择机制使得同一固件镜像可以无缝适应不同服务器部署策略——开发者只需在服务端切换配置下发,无需重新烧录固件。
协议对象创建后,Application 立即向 MqttProtocol 注册六个回调接口,建立与音频管线、显示系统和状态机的耦合:
| 回调 | 注册函数 | 触发场景 |
|---|---|---|
on_incoming_audio_ |
OnIncomingAudio |
UDP 解密出 Opus 帧 → 推入解码队列 |
on_incoming_json_ |
OnIncomingJson |
MQTT 收到 JSON → 解析 STT/TTS/MCP 等 |
on_audio_channel_opened_ |
OnAudioChannelOpened |
UDP 通道建立 → 提升功耗等级 |
on_audio_channel_closed_ |
OnAudioChannelClosed |
通道关闭 → 降功耗、回 Idle |
on_network_error_ |
OnNetworkError |
连接错误 → 触发错误事件 |
on_connected_ / on_disconnected_ |
OnConnected / OnDisconnected |
连接状态变更 → UI 更新 |
回调注册完成后调用 protocol_->Start(),正式触发 MQTT 连接。
Sources: application.cc, application.h, ota.h
3. MQTT 控制通道:连接、Hello 握手与消息路由¶
3.1 连接建立¶
MqttProtocol::StartMqttClient() 从 NVS 持久化存储中读取 MQTT 配置参数:
Settings("mqtt", false):
- endpoint → 服务器地址:端口(默认端口 8883)
- client_id → 设备唯一标识
- username → 认证用户名
- password → 认证密码
- keepalive → 心跳间隔(默认 240 秒)
- publish_topic → 设备发布消息的主题
连接参数通过 Board::GetInstance().GetNetwork()->CreateMqtt(0) 创建的平台特定 MQTT 客户端实例来建立。此处传参 0 表示使用默认网络接口;对于 4G/Wi-Fi 双模设备,可通过不同参数选择数据通道。
连接建立后注册三个核心回调:
OnDisconnected:触发on_disconnected_通知上层,同时启动 60 秒延迟重连定时器(MQTT_RECONNECT_INTERVAL_MS = 60000)。
OnConnected:触发on_connected_,停止重连定时器。
OnMessage:解析 JSON 消息的type字段,分派到ParseServerHello(hello)、通道关闭逻辑(goodbye)或on_incoming_json_(其他所有类型)。
断开重连采用指数退避策略的基础:定时器以固定 60 秒间隔触发,但在 reconnect_timer_ 回调中会检查设备当前是否处于 Idle 状态才执行重连,避免在活跃会话中产生不必要的网络抖动。
Sources: mqtt_protocol.cc, mqtt_protocol.h
3.2 Hello 消息交换¶
Hello 握手是 MQTT+UDP 协议中最关键的协商步骤,它完成三件事:声明传输方式、协商音频参数、下发加密密钥。
设备端发送的 Hello(由 GetHelloMessage() 构造):
{
"type": "hello",
"version": 3,
"transport": "udp",
"features": {
"mcp": true,
"aec": true // 仅当 CONFIG_USE_SERVER_AEC 开启
},
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60 // OPUS_FRAME_DURATION_MS 宏定义
}
}
transport: "udp" 字段是区分 MQTT+UDP 与纯 MQTT 模式的关键——服务器据此返回 UDP 连接信息。features.mcp 始终为 true,表示设备支持 MCP 物联网控制协议。
服务器应答的 Hello(由 ParseServerHello() 解析):
{
"type": "hello",
"transport": "udp",
"session_id": "xxx",
"audio_params": {
"sample_rate": 24000,
"frame_duration": 60
},
"udp": {
"server": "192.168.1.100",
"port": 8888,
"key": "0123456789ABCDEF...", // 32 hex → 128-bit AES key
"nonce": "0123456789ABCDEF..." // 32 hex → 128-bit AES nonce
}
}
ParseServerHello() 提取 session_id、服务器端采样率、UDP 地址端口,以及关键的加密参数。key 和 nonce 均为十六进制字符串,通过 DecodeHexString() 转换为 16 字节二进制数据。mbedtls_aes_setkey_enc() 以 128 位密钥初始化 AES 上下文。此时 local_sequence_ 和 remote_sequence_ 重置为零,然后设置 MQTT_PROTOCOL_SERVER_HELLO_EVENT 事件位,唤醒在 OpenAudioChannel() 中阻塞等待的调用者。
等待超时设定为 10 秒(pdMS_TO_TICKS(10000)),超时则向上层报告 SERVER_TIMEOUT 错误。
Sources: mqtt_protocol.cc, mqtt-udp_zh.md
3.3 JSON 控制消息全集¶
设备端通过基类 Protocol 提供的模板方法发送标准消息。每种消息类型在 protocol.cc 中由专用方法构造 JSON:
| 方法 | 消息类型 | 关键字段 | 触发场景 |
|---|---|---|---|
SendStartListening |
listen |
state: "start", mode: "auto"/"manual"/"realtime" |
用户按键 / 唤醒词触发 |
SendStopListening |
listen |
state: "stop" |
手动停止 / 静音超时 |
SendWakeWordDetected |
listen |
state: "detect", text: wake_word |
唤醒词识别完成 |
SendAbortSpeaking |
abort |
reason: "wake_word_detected" 或空 |
打断 TTS 播放 |
SendMcpMessage |
mcp |
payload: {jsonrpc response} |
MCP 工具执行结果 |
服务器端下行的消息类型在 Application::InitializeProtocol() 的 on_incoming_json_ 回调中集中分发:
| type 值 | 处理逻辑 | 状态转换 |
|---|---|---|
tts |
根据 state 子字段:start → Speaking;stop → Listening/Idle;sentence_start → 显示文本 |
Speaking / Listening |
stt |
显示用户识别文本 | 无状态变化 |
llm |
更新显示屏表情(emotion 字段) |
无状态变化 |
mcp |
转发 payload 到 McpServer::ParseMessage() |
无状态变化 |
system |
处理 command:reboot 触发重启 |
无状态变化 |
alert |
调用 Alert() 显示状态/消息/表情 |
无状态变化 |
custom |
条件编译(CONFIG_RECEIVE_CUSTOM_MESSAGE) |
无状态变化 |
Sources: protocol.cc, application.cc
4. UDP 音频通道:数据包格式、加密与序列号机制¶
4.1 通道建立¶
MqttProtocol::OpenAudioChannel() 在执行 Hello 握手成功后,通过 Board 的网络接口创建 UDP socket(network->CreateUdp(2),参数 2 同样用于多网卡选择)。随后向该 UDP socket 注册 OnMessage 回调——这是整个音频接收链路的入口点。
UDP socket 建立后,立即调用 on_audio_channel_opened_,这会触发 Application 将功耗等级提升至 PERFORMANCE(禁用 CPU 频率调节,确保音频编解码的低延迟),同时校验服务器采样率与设备 DAC 输出采样率是否一致。
Sources: mqtt_protocol.cc, application.cc
4.2 加密数据包格式¶
每个 UDP 数据报的结构固定为 16 字节头部 + 变长密文载荷:
Offset Size Field
------ ---- -----
0 1B type 固定 0x01(Opus 音频包)
1 1B flags 保留,当前未使用
2 2B payload_len 载荷长度(网络字节序)
4 4B ssrc 同步源标识符
8 4B timestamp 时间戳(网络字节序)
12 4B sequence 序列号(网络字节序)
16 var payload AES-CTR 加密的 Opus 音频数据
头部 16 字节恰好等于 AES-CTR 的 nonce 长度(128 位),这一设计并非巧合——整个头部直接复用为加密 counter block 的 nonce 部分,避免了额外的 nonce 传输开销。
Sources: mqtt_protocol.cc, mqtt-udp_zh.md
4.3 AES-CTR 加密与解密详解¶
该协议使用 mbedTLS 库的 mbedtls_aes_crypt_ctr() 实现 CTR 模式的流加密。CTR(Counter)模式的核心思想是将块密码转换为流密码:加密和解密使用完全相同的算法,通过加密一个递增的计数器产生密钥流,再与明文/密文异或。
发送端(设备 → 服务器) 的加密流程在 SendAudio() 中实现:
- 以服务器下发的
aes_nonce_(16 字节)为模板,构造本次数据包的 nonce block。
- 将
payload_len(网络字节序)写入 nonce[2:4],将timestamp(网络字节序)写入 nonce[8:12],将++local_sequence_(网络字节序)写入 nonce[12:16]。
- 调用
mbedtls_aes_crypt_ctr()加密 Opus 载荷,密文追加在 nonce 头部之后。
- 通过
udp_->Send(encrypted)发送整个数据报。
接收端(服务器 → 设备) 的解密流程在 OnMessage lambda 中实现:
- 验证数据报最小长度(不小于
aes_nonce_.size()即 16 字节)。
- 检查
data[0] == 0x01(类型校验)。
- 从头部提取
timestamp和sequence。
- 序列号校验:如果
sequence < remote_sequence_,视为重放攻击,直接丢弃。如果sequence != remote_sequence_ + 1,记录警告但仍继续处理(容忍丢包引起的跳跃)。
- 使用头部作为 nonce,调用
mbedtls_aes_crypt_ctr()解密载荷。
- 构造
AudioStreamPacket,设置采样率、帧时长、时间戳,推入on_incoming_audio_回调。
// 关键加密代码段 (SendAudio)
std::string nonce(aes_nonce_);
*(uint16_t*)&nonce[2] = htons(packet->payload.size());
*(uint32_t*)&nonce[8] = htonl(packet->timestamp);
*(uint32_t*)&nonce[12] = htonl(++local_sequence_);
// nonce 被直接写入加密数据的前 16 字节
std::string encrypted;
encrypted.resize(aes_nonce_.size() + packet->payload.size());
memcpy(encrypted.data(), nonce.data(), nonce.size());
size_t nc_off = 0;
uint8_t stream_block[16] = {0};
mbedtls_aes_crypt_ctr(&aes_ctx_, packet->payload.size(), &nc_off,
(uint8_t*)nonce.c_str(), stream_block,
(uint8_t*)packet->payload.data(),
(uint8_t*)&encrypted[nonce.size()]);
设计要点:payload_len、timestamp 和 sequence 被嵌入 nonce 的特定偏移位置,使得每次加密的 counter block 都具有唯一性——即使相同的 Opus 帧被重传,由于序列号不同,产生的密文也完全不同。这种 nonce 构造方式遵循了 AES-CTR 的安全要求:同一密钥下,nonce+counter 组合绝不重复。
Sources: mqtt_protocol.cc, mqtt_protocol.cc
4.4 序列号与防重放¶
序列号机制分布在两个独立的计数器上:
| 计数器 | 作用域 | 初值 | 递增时机 |
|---|---|---|---|
local_sequence_ |
设备发送 | Hello 后重置为 0 | 每次 SendAudio() 调用时 ++ |
remote_sequence_ |
设备接收 | Hello 后重置为 0 | 每次成功解密后更新为包序列号 |
接收端的序列号检查实现了宽松的单调递增策略:
sequence < remote_sequence_→ 严格拒绝(可能是重放攻击或网络重复包)。
sequence == remote_sequence_ + 1→ 正常情况。
sequence > remote_sequence_ + 1→ 记录警告但继续处理,允许因 UDP 丢包导致的跳跃。
这种"警告但不拒绝"的容错策略是务实的:UDP 本身不保证有序交付,在网络抖动场景下强制要求连续序列号会导致大量音频帧被丢弃,反而严重损害语音质量。同时,将序列号从警告阈值中排除在加密 nonce 之外,确保即使轻微乱序,解密仍能正确进行。
Sources: mqtt_protocol.cc, mqtt-udp_zh.md
5. 连接状态管理与生命周期¶
5.1 协议层状态机¶
虽然 MqttProtocol 没有定义显式的状态枚举,但可通过关键资源的生命周期推断出隐式状态:
stateDiagram
direction LR
[*] --> MqttDisconnected: 构造
MqttDisconnected --> MqttConnecting: Start()
MqttConnecting --> MqttConnected: on_connected_ 回调
MqttConnecting --> MqttDisconnected: 连接失败
MqttConnected --> HelloWait: OpenAudioChannel()
HelloWait --> UdpReady: ParseServerHello() + Event
HelloWait --> MqttConnected: 10s 超时
UdpReady --> Streaming: 开始收发音频
Streaming --> UdpReady: 停止音频流
UdpReady --> MqttConnected: CloseAudioChannel()
MqttConnected --> MqttDisconnected: 断开 / Goodbye
MqttDisconnected --> MqttConnecting: 60s 重连定时器
状态转换的核心同步原语是 FreeRTOS 的 EventGroup。OpenAudioChannel() 在发送 Hello 消息后调用 xEventGroupWaitBits() 阻塞等待 MQTT_PROTOCOL_SERVER_HELLO_EVENT 位,此位由 ParseServerHello() 在成功解析服务器应答后设置。这种生产者-消费者模型将异步的 MQTT 消息到达事件转换为了 OpenAudioChannel() 调用者的同步返回。
Sources: mqtt_protocol.cc, mqtt_protocol.h
5.2 通道可用性判断¶
IsAudioChannelOpened() 同时检查三个条件:
bool MqttProtocol::IsAudioChannelOpened() const {
return udp_ != nullptr && !error_occurred_ && !IsTimeout();
}
udp_ != nullptr:UDP socket 对象已创建且未释放。CloseAudioChannel()会udp_.reset()将其置空。
!error_occurred_:未发生不可恢复错误(由SetError()设置,如SERVER_NOT_FOUND、SERVER_TIMEOUT等)。
!IsTimeout():基类Protocol::IsTimeout()检查距离last_incoming_time_是否超过 120 秒。每次收到有效数据包(MQTT 消息或 UDP 音频帧)都会刷新此时间戳。
这三个条件形成了纵深防御:UDP socket 存活(资源层)、无逻辑错误(业务层)、未超时静默(活性层)。
Sources: mqtt_protocol.cc, protocol.cc
5.3 Goodbye 协商与通道关闭¶
协议设计了两种关闭场景:
- 设备主动关闭:调用
CloseAudioChannel(true),先发送 Goodbye JSON 消息通过 MQTT 通知服务器,再释放 UDP socket。这是用户按停止键或对话结束的正常流程。
- 服务器主动关闭:MQTT 收到
type: "goodbye"消息后,调用CloseAudioChannel(false),仅释放 UDP socket 不发送 Goodbye 回复,避免客户端-服务器之间的无限 "再见乒乓"。
这一不对称设计体现了协议设计中的工程务实性——谁发起关闭,谁负责通知对方;收到通知的一方静默清理即可。
Sources: mqtt_protocol.cc, mqtt_protocol.cc
6. 与 WebSocket 协议的对比分析¶
小智设备支持两种协议模式,通过服务器的 OTA 配置动态选择。下表从多个维度对比两者的工程特性:
| 维度 | MQTT+UDP | WebSocket |
|---|---|---|
| 控制通道 | MQTT (TCP/TLS, 端口 8883) | WebSocket over TLS |
| 音频通道 | UDP (AES-CTR 加密) | WebSocket 二进制帧 (TLS) |
| 传输层协议 | TCP + UDP 双栈 | 仅 TCP |
| 队头阻塞 | 无(控制与数据物理隔离) | 有(TCP 有序交付约束) |
| 音频延迟 | 低(UDP 零连接开销) | 中(受 TCP 拥塞控制影响) |
| 数据可靠性 | 音频允许丢包,控制消息由 MQTT QoS 保证 | 全部可靠(TCP 重传) |
| 加密复杂度 | AES-CTR(轻量流密码) | TLS 完整握手 + 对称加密 |
| NAT 穿透 | 困难(UDP 需额外 STUN/TURN) | 容易(TCP 天然支持) |
| 防火墙友好度 | 低(UDP 端口需单独放行) | 高(仅需 443 端口) |
| 代码复杂度 | 高(双通道状态管理、序列号、加解密) | 低(单连接,TLS 透明加密) |
| 心跳机制 | MQTT KeepAlive(240s 默认) | WebSocket Ping/Pong |
| Broker 依赖 | 需要 MQTT Broker 基础设施 | 无需中间件 |
选型建议:在可控网络环境(如家庭局域网、专线)中,MQTT+UDP 提供了更低的音频延迟和更好的控制-数据隔离;在复杂网络环境(如公共 Wi-Fi、企业防火墙)中,WebSocket 的 TCP/TLS 方案更具穿透力和运维简便性。
Sources: mqtt-udp_zh.md, websocket_protocol.h
7. 安全机制纵深分析¶
7.1 密钥分发路径¶
UDP 音频加密的 128 位 AES 密钥和 nonce 从不硬编码在固件中,而是通过以下路径安全下发:
服务器 ──TLS 加密──→ MQTT Broker ──TLS 加密──→ 设备端
↑ ↑
key + nonce DecodeHexString()
(32 hex chars) (16 bytes binary)
MQTT 连接本身使用 TLS(默认端口 8883),因此 Hello 响应中的 key 和 nonce 字段在传输层已受到 TLS 加密保护。AES-CTR 在此之上提供了应用层的第二层加密,形成"TLS 保护密钥分发 + AES-CTR 保护音频载荷"的分层防御。
7.2 防重放纵深¶
三层递进的防重放机制:
- 序列号单调递增(应用层):拒绝
sequence < remote_sequence_的包。
- 时间戳嵌入 nonce(密码学层):即使序列号被绕过的攻击者构造合法包,也无法产生正确的 AES-CTR 密钥流——因为 nonce 中的 timestamp 字段必须匹配。
- 超时静默断开(会话层):120 秒无数据即标记不可用,限制攻击窗口。
7.3 内存安全¶
构造和析构过程中的资源管理采用了防御性编程:
alive_标志(std::shared_ptr<std::atomic<bool>>):析构函数首先将*alive_ = false,所有异步回调(如Schedulelambda)在执行前检查此标志,防止 use-after-free。
channel_mutex_:保护udp_指针在SendAudio()和CloseAudioChannel()之间的并发访问。
- 加密上下文生命周期:
mbedtls_aes_init()在ParseServerHello()中调用,mbedtls_aes_free()在 mbedTLS 内部管理,与aes_ctx_对象生命周期绑定。
Sources: mqtt_protocol.cc, mqtt_protocol.h
8. 性能优化策略¶
8.1 功耗联动¶
音频通道的打开与关闭直接联动设备功耗等级:
OpenAudioChannel → on_audio_channel_opened_ → SetPowerSaveLevel(PERFORMANCE)
CloseAudioChannel → on_audio_channel_closed_ → SetPowerSaveLevel(LOW_POWER)
在 PERFORMANCE 模式下,ESP32 的 CPU 频率锁定在最大值,确保 Opus 编解码和 AES-CTR 加解密在音频帧周期(60ms)内完成。通道关闭后降回 LOW_POWER,允许动态调频以延长电池寿命。
8.2 内存布局优化¶
加密数据包的构造使用了精心设计的布局复用:16 字节 nonce 头部恰好是加密算法需要的 counter block,无需额外的内存分配存储 nonce。encrypted.resize(aes_nonce_.size() + packet->payload.size()) 一次性分配完整数据报空间,nonce 直接 memcpy 到头部,密文紧随其后。
8.3 并发模型¶
channel_mutex_ 的锁粒度极小——仅在 SendAudio() 中保护 udp_ 空指针检查和发送操作,在 OpenAudioChannel() 中保护 udp_ 的创建。JSON 消息的发送路径(SendText())完全无锁,因为 MQTT 客户端自身的线程安全性由底层库保证。
Sources: mqtt_protocol.cc, application.cc
9. 阅读路径建议¶
理解 MQTT+UDP 协议后,建议按以下路径继续探索相关主题:
- 向上游:回顾 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计 了解两种协议的总体设计决策。
- 对照阅读:WebSocket 协议详解:握手、消息格式与二进制版本 提供了平行方案的实现细节。
- 下游消费方:AudioService 核心管线:三任务模型与数据队列 阐述了加密音频帧解密后如何进入解码管线。
- 控制面延伸:MCP 协议交互流程:JSON-RPC 2.0 的设备端实现 解释了 MQTT 通道承载的 MCP 消息如何被处理。
- 网络基础设施:网络连接管理:Wi-Fi / ML307 4G / RNDIS 多模接入 涵盖了
CreateMqtt(0)和CreateUdp(2)的底层网络栈实现。
MCP 协议与设备控制¶
MCP 协议交互流程:JSON-RPC 2.0 的设备端实现¶
本文档深入剖析小智 AI 聊天机器人 ESP32 设备端 MCP(Model Context Protocol)协议的完整实现:从消息在传输层上的封装格式、JSON-RPC 2.0 的核心交互流程,到 McpServer / McpTool / PropertyList 三层类体系的内部分工与线程安全模型。阅读本文需要你已经理解系统的基础通信管道——建议先浏览 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计。
Sources: mcp_server.h, mcp_server.cc
一、消息封装:传输层信封 + JSON-RPC 2.0 荷载¶
MCP 消息并不独立占用一个 TCP 连接,而是在已经建立好的 WebSocket 或 MQTT 通道上以 JSON 文本帧的形式传输。消息的最外层由 Protocol::SendMcpMessage 统一封装,内部 payload 字段则是一份完整的 JSON-RPC 2.0 消息。
{
"session_id": "uuid-xxxx",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "...", "arguments": { ... } },
"id": 3
}
}
这个双层结构的设计意图是让 MCP 与 TTS/STT/LLM 等消息共享同一条物理链路——type 字段充当多路分用的判别键,当 type 为 "mcp" 时,payload 才会被取出并送入 McpServer::ParseMessage 进行处理。
Sources: protocol.cc, application.cc
下表总结了 JSON-RPC 2.0 四个标准角色的语义以及它们在设备端的实现方法:
| 角色 | JSON-RPC 字段要求 | 设备端实现方法 | 示例方法 |
|---|---|---|---|
| Request(请求) | method + params + id |
McpServer::ParseMessage 解析后分发 |
tools/call、initialize |
| Response(成功响应) | id + result |
McpServer::ReplyResult 拼装 |
工具执行成功返回 |
| Error(错误响应) | id + error |
McpServer::ReplyError 拼装 |
未知工具、参数缺失 |
| Notification(通知) | method + params,无 id |
设备主动调用 SendMcpMessage |
notifications/state_changed |
Sources: mcp_server.cc, mcp_server.h
二、完整的交互时序¶
下图展示从设备上电到工具调用完成的完整 MCP 消息流。蓝色箭头代表后台 API(MCP 客户端)发起的请求,橙色箭头代表 ESP32 设备(MCP 服务器)的回复。
sequenceDiagram
participant ESP32 as ESP32 设备
participant Backend as 后台 API
Note over ESP32: 设备启动 → 连接 Wi-Fi/4G
ESP32->>Backend: Hello (type: "hello", features.mcp: true)
Note over Backend: 识别 MCP 能力
Backend->>ESP32: initialize { capabilities: { vision: {...} } }
ESP32-->>Backend: result: { protocolVersion, serverInfo }
Backend->>ESP32: tools/list { cursor: "" }
ESP32-->>Backend: result: { tools: [...], nextCursor: "..." }
loop 分页拉取
Backend->>ESP32: tools/list { cursor: nextCursor }
ESP32-->>Backend: result: { tools: [...], nextCursor: "..." }
end
Backend->>ESP32: tools/call { name: "self.xxx", arguments: {...} }
alt 执行成功
ESP32-->>Backend: result: { content: [...], isError: false }
else 执行失败
ESP32-->>Backend: error: { code: -32601, message: "..." }
end
opt 设备主动通知
ESP32->>Backend: notifications/state_changed
end
Sources: mcp-protocol_zh.md, mcp_server.cc
2.1 第一步:能力通告(Hello 消息)¶
设备在传输层连接成功后发送 Hello 消息,其中 features.mcp = true 是后台判断设备是否支持 MCP 的唯二依据。这个 Hello 消息本身不是 MCP 消息——它是由 Protocol 子类在连接建立后自动发出的传输层握手帧,MCP 的 initialize 请求必须在此之后。
Sources: mcp-protocol_zh.md
2.2 第二步:initialize — 会话初始化与能力协商¶
initialize 是 MCP 协议规定的会话握手,后台通过 params.capabilities 告知设备自己具备的能力。当前设备端仅解析 capabilities.vision 字段:如果后台提供了 url(图片上传地址)和 token,设备会将它们存入 Camera 对象,后续 self.camera.take_photo 工具调用时直接使用该地址上传照片。
设备返回自己的 protocolVersion(固定 "2024-11-05")、serverInfo.name(即宏 BOARD_NAME)和 serverInfo.version(固件版本号)。
Sources: mcp_server.cc, mcp_server.cc
2.3 第三步:tools/list — 工具发现与分页¶
tools/list 返回设备上所有已注册工具的元数据(名称、描述、输入参数的 JSON Schema)。为了适配嵌入式设备有限的内存和 MTU,该接口使用基于游标(cursor)的分页机制:首次请求 cursor 为空字符串,如果返回的 nextCursor 非空,后台必须使用该游标值发起下一次请求,直到 nextCursor 为空。
分页的触发条件是单次响应 payload 超过 8000 字节的阈值。代码中采用「尽可能多塞入工具、一旦超限立即截断并设置 nextCursor」的策略,而非预先计算精确大小,这使得实现简洁且在绝大多数场景下(工具少于 20 个)不会触发分页。
Sources: mcp_server.cc
2.4 第四步:tools/call — 工具调用与参数校验¶
tools/call 请求到达后,执行路径如下:
- 工具查找:在
tools_向量中按名称线性查找目标工具,未找到则返回 JSON-RPC 错误码-32601(Method not found)。
- 参数校验:遍历工具的
PropertyList,将请求中的argumentsJSON 对象按名称逐一匹配并完成类型转换(bool ↔cJSON_IsBool,int ↔cJSON_IsNumber,string ↔cJSON_IsString)。如果某参数既无默认值又未在请求中提供,立即返回错误。
- 线程调度:所有工具的回调函数都通过
Application::Schedule投递到主任务(main task)中执行——这是整个 MCP 实现中最重要的线程安全约束。回调中可以直接操作 GPIO、I2C、显示屏等外设,而无需加锁。
- 返回值转换:回调返回
ReturnValue(std::variant<bool, int, std::string, cJSON*, ImageContent*>),统一转换为{ "content": [{ "type": "text", "text": "..." }], "isError": false }格式。
Sources: mcp_server.cc, mcp_server.h
2.5 设备主动通知¶
当 method 以 "notifications" 开头时,ParseMessage 直接 return,不做任何响应——这是 JSON-RPC Notification 的标准语义(无 id 字段意味着服务器不应回复)。设备端也可主动发起通知(例如状态变化),通过 Application::SendMcpMessage 将 JSON-RPC Notification 荷载发送出去,通过 mcp_broadcast_callback_ 可让其他模块监听外发消息。
Sources: mcp_server.cc, application.cc
三、设备端类体系:从 Property 到 McpServer 的三层抽象¶
MCP 设备端实现由四个核心类构成,它们的分工清晰且耦合度低。理解这个类体系是读懂源码的关键前提。
classDiagram
class Property {
-name_: string
-type_: PropertyType
-value_: variant~bool,int,string~
-has_default_value_: bool
-min_value_: optional~int~
-max_value_: optional~int~
+to_json() string
+set_value(T value)
}
class PropertyList {
-properties_: vector~Property~
+operator[](name) Property
+GetRequired() vector~string~
+to_json() string
}
class McpTool {
-name_: string
-description_: string
-properties_: PropertyList
-callback_: function
-user_only_: bool
+Call(PropertyList) string
+to_json() string
}
class McpServer {
-tools_: vector~McpTool*~
+AddTool(name, desc, props, cb)
+AddUserOnlyTool(name, desc, props, cb)
+ParseMessage(cJSON*)
-ReplyResult(id, result)
-ReplyError(id, message)
-GetToolsList(id, cursor, withUserTools)
-DoToolCall(id, tool_name, arguments)
}
PropertyList o-- Property
McpTool --> PropertyList
McpServer --> McpTool
Sources: mcp_server.h
3.1 Property:参数的最小单元¶
Property 封装单个工具参数的元信息:名称、类型(布尔 / 整数 / 字符串)、默认值、以及仅对整数类型生效的 [min, max] 范围约束。其构造函数有四种重载组合,对应「必填 vs 可选」×「无范围 vs 有范围」的四种场景。
set_value 模板方法利用 C++17 的 if constexpr 在编译期对整数类型插入范围检查逻辑,对于非整数类型则不生成无效代码。
Sources: mcp_server.h
3.2 PropertyList:参数集合与必填推断¶
PropertyList 是一个轻量的 std::vector<Property> 包装器,核心价值在于 GetRequired() 方法:遍历所有 Property,将没有默认值的参数名收集为 required 数组。这使得工具注册时无需手动标注哪些参数是必填的——框架自动从 Property 的构造方式推断。
Sources: mcp_server.h
3.3 McpTool:回调封装与返回值标准化¶
McpTool 将工具的元数据(名称、描述、参数列表)与执行逻辑(callback_)绑定在一起。其 Call 方法是整个执行链的终点:调用 callback_(properties) 获得 ReturnValue,再将这个 variant 统一转换为 MCP 标准响应格式——文本类型用 "type": "text",图片类型用 "type": "image"(通过 ImageContent 的 Base64 编码)。
user_only_ 标志控制工具在 tools/list 中的可见性:对于 AI 模型自动调用的 tools/list(withUserTools=false),这些工具被完全隐藏;只有配套 App 传入 withUserTools=true 时才会出现。这一机制保证了「重启设备」「升级固件」等危险操作不会被大模型意外触发。
Sources: mcp_server.h, mcp_server.cc
3.4 McpServer:单例调度中心¶
McpServer 使用经典的 Meyers 单例模式,全局唯一。它持有 std::vector<McpTool*> 作为工具注册表,对外暴露两套 API:
| API | 用途 | 可见性 |
|---|---|---|
AddTool |
注册 AI 可调用的工具 | 默认 tools/list 可见 |
AddUserOnlyTool |
注册仅用户可调用的工具 | 需 withUserTools=true |
ParseMessage 是消息分发的总入口。它先校验 jsonrpc 版本字段,然后按 method 值分派到 initialize / tools/list / tools/call 三个处理分支。未识别的 method 返回「Method not implemented」错误。以 notifications 开头的 method 直接静默忽略。
Sources: mcp_server.h, mcp_server.cc
四、消息路由:从网络帧到回调函数的完整路径¶
下图追踪一条 MCP 请求从网络到达 ESP32 到工具回调被执行的全路径:
flowchart LR
A[WebSocket / MQTT 帧] --> B[Protocol::OnIncomingJson]
B --> C{type == "mcp"?}
C -->|是| D[McpServer::ParseMessage]
C -->|否| E[TTS / STT / LLM 处理]
D --> F{method?}
F -->|initialize| G[ParseCapabilities + ReplyResult]
F -->|tools/list| H[GetToolsList → ReplyResult]
F -->|tools/call| I[DoToolCall]
I --> J[参数校验]
J --> K[Application::Schedule]
K --> L[主任务执行回调]
L --> M[McpTool::Call]
M --> N[ReplyResult / ReplyError]
N --> O[Application::SendMcpMessage]
O --> P[Protocol::SendMcpMessage]
关键设计决策体现在两处。第一,OnIncomingJson 回调在协议层的工作线程(如 WebSocket 接收线程)中被触发,但 ParseMessage 的大部分逻辑是同步执行的——只有 DoToolCall 中实际的工具回调通过 Schedule 投递到主任务。这种设计避免了在协议线程中执行可能阻塞的 I/O 操作,同时保持了简单请求(如 tools/list)的低延迟。第二,ReplyResult 和 ReplyError 也通过 SendMcpMessage → Schedule 间接发送,确保所有外发消息都在主任务上下文中完成,消除了数据竞争。
Sources: application.cc, mcp_server.cc, mcp_server.cc
五、内置工具全景¶
Application::Initialize() 在设备启动时调用 McpServer::AddCommonTools() 和 McpServer::AddUserOnlyTools(),注册所有平台通用的内置工具。随后各开发板的 InitializeTools() 追加板级特有的工具。
5.1 AI 可调用工具(Common Tools)¶
| 工具名称 | 参数 | 功能 | 适用条件 |
|---|---|---|---|
self.get_device_status |
无 | 返回设备实时状态(音量、屏幕、电池、网络等)的 JSON | 所有设备 |
self.audio_speaker.set_volume |
volume (int, 0–100) |
设置扬声器音量 | 所有设备 |
self.screen.set_brightness |
brightness (int, 0–100) |
调节屏幕亮度 | 有背光控制的设备 |
self.screen.set_theme |
theme (string, "light" / "dark") |
切换 UI 主题 | LVGL 设备且主题管理器可用 |
self.camera.take_photo |
question (string) |
拍照并基于问题描述图片内容 | 有摄像头的设备 |
值得注意的是 AddCommonTools 在插入通用工具前会先备份再恢复开发板的专属工具列表,确保通用工具排在前面以利用 LLM 的 prompt cache 特性加速响应。
Sources: mcp_server.cc
5.2 仅用户可调用的工具(User-Only Tools)¶
| 工具名称 | 参数 | 功能 |
|---|---|---|
self.get_system_info |
无 | 返回系统信息 JSON(芯片型号、固件版本、分区表等) |
self.reboot |
无 | 延迟 1 秒后重启设备 |
self.upgrade_firmware |
url (string) |
从指定 URL 下载固件并升级 |
self.screen.get_info |
无 | 返回屏幕宽度、高度、是否单色 |
self.screen.snapshot |
url (string), quality (int, 1–100) |
截屏并以 multipart/form-data 上传 JPEG |
self.screen.preview_image |
url (string) |
下载并预览图片到屏幕 |
self.assets.set_download_url |
url (string) |
设置资源分区的下载地址 |
Sources: mcp_server.cc
六、线程安全模型¶
整个 MCP 子系统的线程安全依赖一条简单的规则:所有可能修改共享状态的操作都必须通过 Application::Schedule 在 main task 中执行。这条规则的具体体现:
McpServer::ParseMessage可以在任意线程被调用(它由OnIncomingJson在协议工作线程触发),其内部的 JSON 解析和参数校验是纯函数操作,线程安全。
DoToolCall在校验完参数后,将实际的callback_调用通过闭包捕获参数、投递到Schedule队列中——而Schedule使用std::mutex保护任务队列,并在MAIN_EVENT_SCHEDULE事件中批量执行。
ReplyResult/ReplyError调用Application::SendMcpMessage,后者同样通过Schedule将发送动作推迟到主任务。
因此,工具回调的实现者无需关心任何线程安全问题——回调总是在主任务上下文中被调用,可以安全地访问任何硬件外设或全局状态。
Sources: mcp_server.cc, application.cc, application.cc
七、阅读下一步¶
本文聚焦 MCP 协议在设备端的交互流程与消息路由机制。如果你需要了解如何为自定义开发板注册新的 MCP 工具(包括 PropertyList 参数定义、回调函数签名、AddUserOnlyTool 的使用场景),请继续阅读 MCP 工具注册与调用:PropertyList 参数校验与回调机制。
若要理解 MCP 消息的底层传输通道——WebSocket 和 MQTT+UDP 如何承载这些 JSON-RPC 帧——请参考 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计 和其后续详解页面。
MCP 工具注册与调用:PropertyList 参数校验与回调机制¶
本文深入剖析小智 ESP32 设备端 MCP 协议的核心实现——工具注册系统、PropertyList 参数校验框架以及基于主线程调度的回调执行机制。阅读本文前,建议先了解 MCP 协议交互流程 中 JSON-RPC 2.0 消息格式与会话初始化流程。
架构全景:从网络消息到设备动作¶
MCP 工具系统位于设备端通信栈的最上层。当后台 API 通过 WebSocket 或 MQTT 下发一条 tools/call 请求时,消息经过三层路由最终触发硬件动作:
flowchart TD
NET["网络层 (WebSocket / MQTT)"] -->|"type: mcp"| APP["Application::OnIncomingJson"]
APP -->|"payload 分发"| MCP["McpServer::ParseMessage"]
MCP -->|"method 路由"| CALL["DoToolCall"]
CALL -->|"参数填充 + 校验"| VAL["Property::set_value 范围检查"]
CALL -->|"主线程调度"| SCHED["Application::Schedule"]
SCHED -->|"MAIN_EVENT_SCHEDULE"| CB["回调函数执行"]
CB -->|"ReturnValue"| REPLY["ReplyResult / ReplyError"]
关键设计原则:所有工具回调均在主任务(Main Task)中通过事件循环串行执行,彻底避免了多线程竞态问题。网络消息到达后,McpServer::ParseMessage 在协议栈的回调线程中完成 JSON 解析与参数校验,随后通过 Application::Schedule 将实际的工具执行逻辑投递到主任务队列。
Sources: application.cc, mcp_server.cc
Property 参数定义系统¶
Property 体系是 MCP 工具参数校验的基石,提供了类型安全、范围约束、默认值支持等完整的参数描述能力。
类型系统与构造语义¶
支持的三种基础类型由 PropertyType 枚举定义:
| 类型 | 枚举值 | JSON Schema 映射 | 典型场景 |
|---|---|---|---|
| 布尔 | kPropertyTypeBoolean |
"type": "boolean" |
开关控制 |
| 整数 | kPropertyTypeInteger |
"type": "integer" |
音量、亮度、步数 |
| 字符串 | kPropertyTypeString |
"type": "string" |
主题名、URL、模式选择 |
源码: mcp_server.h
Property 类提供四种构造函数,通过不同参数组合表达参数的"必填 / 可选 / 范围约束"语义:
classDiagram
class Property {
-string name_
-PropertyType type_
-variant~bool,int,string~ value_
-bool has_default_value_
-optional~int~ min_value_
-optional~int~ max_value_
+to_json() string
+set_value~T~(T value)
+value~T~() T
}
| 构造函数 | 含义 | has_default_value_ | 范围约束 |
|---|---|---|---|
Property(name, type) |
必填参数——调用时必须提供,否则返回错误 | false |
无 |
Property(name, type, default) |
可选参数——未提供时使用默认值 | true |
无 |
Property(name, type, min, max) |
必填 + 范围约束——整数参数必须在 [min, max] 内 | false |
有 |
Property(name, type, default, min, max) |
可选 + 范围约束——带默认值的范围整数 | true |
有 |
Sources: mcp_server.h
范围校验机制¶
整数范围校验发生在两个层面。第一层在 Property 构造时检查默认值是否在范围内(若不满足则抛出 std::invalid_argument);第二层在 tools/call 参数填充时,通过 set_value<int>() 的模板特化进行运行时范围检查:
// 源码摘录:set_value 中的范围检查
template<typename T>
inline void set_value(const T& value) {
if constexpr (std::is_same_v<T, int>) {
if (min_value_.has_value() && value < min_value_.value()) {
throw std::invalid_argument("Value is below minimum allowed: " + std::to_string(min_value_.value()));
}
if (max_value_.has_value() && value > max_value_.value()) {
throw std::invalid_argument("Value exceeds maximum allowed: " + std::to_string(max_value_.value()));
}
}
value_ = value;
}
范围超限时抛出的异常会被 DoToolCall 中的 try-catch 捕获,并转换为 JSON-RPC Error 响应返回给后台 API。
Sources: mcp_server.h, mcp_server.cc
PropertyList:参数集合与 JSON Schema 生成¶
PropertyList 封装了一组 Property,并负责生成符合 JSON Schema 规范的参数描述。其核心方法是 GetRequired(),通过检查每个 Property 的 has_default_value() 标志自动区分必填参数和可选参数——没有默认值的即为必填:
std::vector<std::string> GetRequired() const {
std::vector<std::string> required;
for (auto& property : properties_) {
if (!property.has_default_value()) {
required.push_back(property.name());
}
}
return required;
}
to_json() 将所有 Property 序列化为 { "param_name": { "type": "...", ... } } 的 JSON 对象,供 McpTool::to_json() 嵌入 inputSchema.properties 中。
Sources: mcp_server.h, mcp_server.h
McpTool:工具封装与序列化¶
McpTool 是单个工具的全部元数据和执行逻辑的载体,包含四个核心属性:
| 属性 | 类型 | 说明 |
|---|---|---|
name_ |
std::string |
工具唯一标识,如 self.audio_speaker.set_volume |
description_ |
std::string |
自然语言功能描述,供大模型理解 |
properties_ |
PropertyList |
输入参数定义 |
callback_ |
std::function<ReturnValue(const PropertyList&)> |
工具被调用时的执行逻辑 |
user_only_ |
bool |
若为 true,工具仅对用户可见,AI 不会收到该工具的列表 |
to_json():生成符合 MCP 规范的 Tool 描述¶
McpTool::to_json() 的输出严格遵守 MCP 协议的 Tool 对象格式,将 inputSchema 构造为标准的 JSON Schema 对象:
{
"name": "self.audio_speaker.set_volume",
"description": "Set the volume of the audio speaker...",
"inputSchema": {
"type": "object",
"properties": {
"volume": { "type": "integer", "minimum": 0, "maximum": 100 }
},
"required": ["volume"]
}
}
当 user_only_ 为 true 时,工具描述中会附加 annotations: { audience: ["user"] } 标记,后台 API 据此过滤,确保用户专属工具(如重启、固件升级)不会暴露给 AI 模型。
Sources: mcp_server.h
Call():回调执行与结果序列化¶
Call() 方法负责调用 callback_ 并处理 ReturnValue 的多种可能类型:
flowchart LR
RV[ReturnValue] -->|bool| F1["→ text: 'true' / 'false'"]
RV -->|int| F2["→ text: std::to_string"]
RV -->|std::string| F3["→ text: 原字符串"]
RV -->|cJSON*| F4["→ text: cJSON_PrintUnformatted"]
RV -->|ImageContent*| F5["→ type: image, data: base64"]
最终输出为标准 MCP 响应格式:{ "content": [{ "type": "text", "text": "..." }], "isError": false }。当回调抛出异常时,异常在 DoToolCall 层被捕获,转而生成 JSON-RPC Error 响应。
Sources: mcp_server.h
McpServer:注册中心与消息路由¶
McpServer 采用单例模式,是整个 MCP 工具系统的中枢——它既是工具注册的唯一入口,也是所有入站 JSON-RPC 请求的调度器。
工具注册的三个入口¶
classDiagram
class McpServer {
-vector~McpTool*~ tools_
+AddCommonTools()
+AddUserOnlyTools()
+AddTool(name, description, properties, callback)
+AddUserOnlyTool(name, description, properties, callback)
+AddTool(McpTool* tool)
+ParseMessage(json)
}
| 方法 | 用途 | 调用时机 |
|---|---|---|
AddCommonTools() |
注册 AI 可调用的通用工具(音量、亮度、摄像头等) | Application::Initialize() 中一次性调用 |
AddUserOnlyTools() |
注册仅用户可调用的工具(重启、固件升级等) | Application::Initialize() 中一次性调用 |
AddTool(McpTool*) |
底层注册接口,含重复检测 | 被上述方法和自定义开发板调用 |
工具排序策略:AddCommonTools() 先将原有的自定义工具移出,在前端注册完通用工具后再将自定义工具追加到列表末尾。这样做的目的是将常用工具放在前面,利用 LLM 的 prompt cache 特性加速响应时间。
Sources: mcp_server.cc, application.cc
ParseMessage:JSON-RPC 方法路由¶
ParseMessage 解析 JSON-RPC 2.0 消息并按 method 字段进行路由:
| method | 处理函数 | 说明 |
|---|---|---|
initialize |
内联处理 | 解析客户端能力(如视觉 URL),返回服务器信息 |
tools/list |
GetToolsList() |
分页返回工具列表,8000 字节载荷限制 |
tools/call |
DoToolCall() |
参数校验 + 主线程调度执行 |
notifications/* |
直接返回 | JSON-RPC Notification,无需响应 |
| 其他 | ReplyError() |
返回 "Method not implemented" |
Initialize 阶段的关键操作是 ParseCapabilities,它从客户端参数中提取 vision.url 和 vision.token,配置摄像头服务的图片解释后端地址——这打通了视觉能力的数据通路。
Sources: mcp_server.cc, mcp_server.cc
tools/list 分页机制¶
工具列表序列化受 8000 字节载荷限制。GetToolsList 逐个追加工具 JSON,当累积大小超过限制时停止并设置 nextCursor 指向当前工具名——客户端需携带该 cursor 发起下一次请求以获取剩余工具。分页机制确保即使注册了大量工具,响应也不会超出传输层限制。
Sources: mcp_server.cc
tools/call 完整调用链路(核心流程)¶
这是整个 MCP 工具系统最关键的执行路径。以下是从收到 JSON-RPC 请求到返回响应的完整流程:
sequenceDiagram
participant NET as 网络消息
participant APP as Application
participant MCP as McpServer
participant TOOL as McpTool
participant MAIN as 主任务事件循环
NET->>APP: type: "mcp", payload: { method: "tools/call", ... }
APP->>MCP: ParseMessage(payload)
rect rgb(240, 248, 255)
Note over MCP: 1. 参数校验阶段 (协议回调线程)
MCP->>MCP: 查找工具 (find_if by name)
MCP->>MCP: 遍历 PropertyList,从 JSON 提取值
MCP->>MCP: set_value 触发范围检查
MCP->>MCP: 检查必填参数是否缺失
end
rect rgb(255, 248, 240)
Note over MCP,MAIN: 2. 主线程调度
MCP->>APP: Schedule(callback)
APP->>MAIN: 投递到 main_tasks_ 队列
MAIN->>MAIN: xEventGroupSetBits(MAIN_EVENT_SCHEDULE)
end
rect rgb(240, 255, 240)
Note over MAIN: 3. 回调执行 (主任务)
MAIN->>TOOL: Call(validated_arguments)
TOOL->>TOOL: callback_(arguments)
TOOL->>TOOL: 格式化 ReturnValue → JSON
TOOL-->>MAIN: result JSON string
MAIN->>MCP: ReplyResult(id, result)
end
MCP->>APP: SendMcpMessage(payload)
APP->>NET: 通过网络发送 JSON-RPC Response
第一阶段:参数校验(协议回调线程)¶
DoToolCall 首先通过工具名查找到对应的 McpTool 对象。然后遍历该工具注册时的 PropertyList,对每个 Property 从 tool_arguments JSON 中提取对应类型的值:
// 源码摘录:参数类型匹配与填充
for (auto& argument : arguments) {
bool found = false;
if (cJSON_IsObject(tool_arguments)) {
auto value = cJSON_GetObjectItem(tool_arguments, argument.name().c_str());
if (argument.type() == kPropertyTypeBoolean && cJSON_IsBool(value)) {
argument.set_value<bool>(value->valueint == 1);
found = true;
} else if (argument.type() == kPropertyTypeInteger && cJSON_IsNumber(value)) {
argument.set_value<int>(value->valueint); // 范围检查在此触发
found = true;
} else if (argument.type() == kPropertyTypeString && cJSON_IsString(value)) {
argument.set_value<std::string>(value->valuestring);
found = true;
}
}
if (!argument.has_default_value() && !found) {
ReplyError(id, "Missing valid argument: " + argument.name());
return; // 必填参数缺失,直接返回错误
}
}
校验策略:类型不匹配的字段被静默忽略(found 保持 false),随后根据 has_default_value() 决定是否报错——必填参数缺失会立即返回 JSON-RPC Error,可选参数则使用默认值继续。
Sources: mcp_server.cc
第二阶段:主线程调度¶
参数校验通过后,DoToolCall 不会直接调用回调,而是通过 Application::Schedule 将回调投递到主任务的事件队列:
app.Schedule([this, id, tool_iter, arguments = std::move(arguments)]() {
try {
ReplyResult(id, (*tool_iter)->Call(arguments));
} catch (const std::exception& e) {
ReplyError(id, e.what());
}
});
Application::Schedule 在互斥锁保护下将 lambda 追加到 main_tasks_ 双端队列,然后通过 FreeRTOS 事件组置位 MAIN_EVENT_SCHEDULE。主事件循环 Application::Run() 在检测到该事件后,批量取出并执行所有待处理任务。
为什么必须调度到主线程? 工具回调中常常操作硬件(GPIO、I2C、SPI)、更新显示、调用音频服务——这些操作大多不是线程安全的。通过统一在主任务中串行执行,从根本上消除了并发问题。
Sources: mcp_server.cc, application.h, application.cc
第三阶段:结果回传¶
McpTool::Call() 执行回调后将 ReturnValue 序列化为 JSON,由 ReplyResult 封装成完整的 JSON-RPC Response,最终通过 Application::SendMcpMessage 调用底层协议栈的 SendText() 回传给后台。
SendMcpMessage 同时触发 mcp_broadcast_callback_——这是一个可选的广播回调,允许其他组件(如本地 WebSocket 调试服务器)监听所有 MCP 响应消息。
Sources: mcp_server.cc, application.cc, protocol.cc
ReturnValue 返回值机制¶
回调函数的返回值类型定义为 std::variant<bool, int, std::string, cJSON*, ImageContent*>,允许工具回调以最自然的方式表达执行结果:
| 类型 | 使用场景 | 最终输出 |
|---|---|---|
bool |
操作成功/失败 | "true" 或 "false" |
int |
数值结果 | std::to_string() 转换 |
std::string |
状态描述或 JSON 字符串 | 直接作为文本内容 |
cJSON* |
结构化数据(如设备状态) | cJSON_PrintUnformatted() 序列化 |
ImageContent* |
图片数据 | Base64 编码后作为 type: "image" 内容 |
ImageContent 是一种特殊返回值,用于摄像头拍照等场景。它在构造时自动将二进制图片数据通过 mbedtls 进行 Base64 编码,序列化时生成 { "type": "image", "mimeType": "...", "data": "..." } 格式的内容项。
当回调需要表示错误时,推荐抛出 std::runtime_error 异常而非返回错误码。异常会被 DoToolCall 中的 try-catch 捕获,统一转为 { "error": { "message": "..." } } 格式的 JSON-RPC Error 响应,确保错误信息能清晰传递到后台 API。
Sources: mcp_server.h, mcp_server.h, mcp_server.h
开发板自定义工具注册¶
自定义开发板通过 McpServer::GetInstance().AddTool() 在板级初始化阶段注入专属工具。以下对比三种典型场景的实现模式:
无参数工具——查询类¶
mcp_server.AddTool("self.electron.get_status", "获取机器人状态,返回 moving 或 idle",
PropertyList(), // 空参数列表
[this](const PropertyList& properties) -> ReturnValue {
return is_action_in_progress_ ? "moving" : "idle";
});
范围约束工具——控制类¶
mcp_server.AddTool("self.electron.head_move",
"头部运动。action: 1=抬头, 2=低头, 3=点头, 4=回中心, 5=连续点头",
PropertyList({
Property("action", kPropertyTypeInteger, 3, 1, 5), // 必填,范围 [1,5],默认 3
Property("steps", kPropertyTypeInteger, 1, 1, 10), // 可选,默认 1
Property("speed", kPropertyTypeInteger, 1000, 500, 1500), // 可选,默认 1000
Property("angle", kPropertyTypeInteger, 5, 1, 15) // 可选,默认 5
}),
[this](const PropertyList& properties) -> ReturnValue {
int action = properties["action"].value<int>();
// ... 硬件操作
return true;
});
持久化配置工具¶
PressToTalkMcpTool 展示了如何将 MCP 工具与 Settings 持久化存储结合——工具回调修改 Settings 中的配置项,设备重启后配置依然生效:
void PressToTalkMcpTool::Initialize() {
Settings settings("vendor");
press_to_talk_enabled_ = settings.GetInt("press_to_talk", 0) != 0;
mcp_server.AddTool("self.set_press_to_talk",
"Switch between press to talk mode and click to talk mode.",
PropertyList({ Property("mode", kPropertyTypeString) }),
[this](const PropertyList& properties) -> ReturnValue {
auto mode = properties["mode"].value<std::string>();
if (mode == "press_to_talk") {
SetPressToTalkEnabled(true);
return true;
} else if (mode == "click_to_talk") {
SetPressToTalkEnabled(false);
return true;
}
throw std::runtime_error("Invalid mode: " + mode);
});
}
Sources: electron_bot_controller.cc, press_to_talk_mcp_tool.cc, mcp_controller.cc
注册时机与顺序¶
工具注册发生在 Application::Initialize() 过程中——AddCommonTools() 和 AddUserOnlyTools() 在应用层显式调用,而开发板自定义工具则在板级构造函数(或专门的初始化函数如 InitializeElectronBotController())中通过单例获取 McpServer 实例并注册。由于 McpServer 是全局单例且工具以 vector 存储,注册顺序决定了工具在 tools/list 响应中的出现顺序——这也是 AddCommonTools 刻意将通用工具前移的原因。
Sources: application.cc, electron_bot.cc
总结与设计要点¶
整个 MCP 工具系统围绕三个核心设计原则构建:
- 声明式参数定义:Property / PropertyList 框架将参数的类型、必填性、范围约束声明与校验逻辑一体化,大幅减少工具回调中的样板校验代码。范围检查在
set_value中自动触发,开发者无需手动编写边界判断。
- 主线程串行化执行:通过
Application::Schedule将所有工具回调统一投递到主任务事件循环,消除并发安全隐患。代价是长时间阻塞的回调会拖慢整个系统——对于耗时操作(如 HTTP 请求、摄像头抓拍),应在回调内部通过降低任务优先级或异步等待来缓解。
- 异常驱动的错误处理:工具回调通过抛出异常表达错误,由框架层统一捕获并转换为 JSON-RPC Error 响应。这避免了在每个回调中手动构造错误返回值的重复代码。
对于在自定义开发板上添加 MCP 工具的开发者,推荐遵循以下实践:工具命名采用 self.<module>.<action> 的层次结构;整数值参数优先使用范围约束构造器;长时间操作(如固件下载、HTTP 上传)务必在回调内调整任务优先级以避免阻塞事件循环。
系统管理与运维¶
OTA 固件升级:版本检查、激活验证与安全更新¶
小智 AI 聊天机器人的 OTA(Over-The-Air)升级系统是一套多层次的远程更新框架,不仅支持主固件的安全下载与刷写,还涵盖资源包(Assets)独立更新、设备激活验证以及服务器配置同步。本章将深入剖析从 HTTP 版本检查到固件写入 Flash 的完整链路,阐述其中涉及的版本比较策略、HMAC 挑战应答安全机制以及分区回滚保护的设计原理。
阅读本章前,建议先了解 系统架构全景:从麦克风到云端大模型的完整数据流 和 分区表设计:v1 与 v2 版本的存储布局迁移,以建立对设备整体启动流程和 Flash 布局的认知基础。
OTA 系统架构总览¶
小智的 OTA 系统由 Ota 核心类(定义于 ota.h / ota.cc)与 Application 生命周期管理(定义于 application.h / application.cc)协同构成。其职责可划分为四个正交维度:版本检查(向服务端查询最新固件与配置)、激活验证(基于 eFuse 序列号与 HMAC-SHA256 的挑战应答)、固件下载与刷写(利用 ESP-IDF 原生 OTA API 写入 Flash 并切换启动分区)、以及资源包更新(通过 Assets 类独立下载 SPIFFS 分区内容)。
flowchart TB
subgraph AppTask["Application 主事件循环"]
NET_CONNECT["MAIN_EVENT_NETWORK_CONNECTED"]
ACTIVATION_DONE["MAIN_EVENT_ACTIVATION_DONE"]
end
subgraph ActTask["ActivationTask (独立 FreeRTOS 任务)"]
CV["CheckAssetsVersion()"]
CNV["CheckNewVersion()"]
IP["InitializeProtocol()"]
end
subgraph OtaCore["Ota 核心类"]
CHK["CheckVersion()<br/>HTTP → JSON 解析"]
ACT["Activate()<br/>HMAC 挑战应答"]
UPG["Upgrade() (静态)<br/>HTTP 下载 + OTA 刷写"]
MCV["MarkCurrentVersionValid()<br/>取消回滚"]
end
subgraph Server["服务端 API"]
OTA_API["/ota/ (版本检查)"]
ACT_API["/ota/activate (激活验证)"]
FW_URL["固件二进制 URL"]
end
subgraph Flash["ESP32 Flash"]
OTA0["ota_0 分区"]
OTA1["ota_1 分区"]
ASSETS["assets 分区 (SPIFFS)"]
OTADATA["otadata 分区"]
end
NET_CONNECT -->|"xTaskCreate"| ActTask
CV -->|"下载资源包"| ASSETS
CNV --> CHK
CHK -->|"HTTP POST/GET"| OTA_API
CHK -->|"有激活挑战"| ACT
ACT -->|"POST /activate"| ACT_API
CNV -->|"有新固件"| UPG
UPG -->|"HTTP GET 下载"| FW_URL
UPG -->|"esp_ota_write"| OTA0
UPG -->|"esp_ota_write"| OTA1
UPG -->|"esp_ota_set_boot_partition"| OTADATA
MCV -->|"esp_ota_mark_app_valid_cancel_rollback"| OTADATA
IP -->|"MQTT 或 WebSocket"| Server
ActTask -->|"xEventGroupSetBits"| ACTIVATION_DONE
架构要点:Ota 对象是一个临时生命周期的对象——它在 ActivationTask 中被创建,在激活完成后被销毁(ota_.reset()),因此每次设备启动时都会重新执行版本检查。这种设计避免了持久化状态带来的不一致风险,但代价是每次冷启动都会产生一次 HTTP 请求。
Sources: ota.h, ota.cc, application.cc
版本检查:CheckVersion 的 JSON 协议解析¶
Ota::CheckVersion() 是整个 OTA 系统的入口方法,它向服务端发送设备系统信息并解析返回的 JSON 响应。请求 URL 优先从 NVS 设置的 ota_url 键读取,若未设置则回退到 Kconfig 编译期默认值 CONFIG_OTA_URL(默认为 https://api.tenclass.net/xiaozhi/ota/)。
sequenceDiagram
participant Device as 设备 (Ota)
participant HTTP as Http 客户端
participant Server as OTA 服务端
Device->>Device: GetCheckVersionUrl()<br/>读取 NVS 或 CONFIG_OTA_URL
Device->>HTTP: SetupHttp() 设置请求头
Note over Device,HTTP: Activation-Version, Device-Id, Client-Id,<br/>Serial-Number, User-Agent, Accept-Language
Device->>Server: POST/GET + SystemInfo JSON
Server-->>Device: JSON 响应
Device->>Device: cJSON_Parse → 解析五大段
Note over Device: firmware / activation / mqtt /<br/>websocket / server_time
HTTP 请求头构建¶
SetupHttp() 方法构造的请求头承载了设备身份标识的核心信息:
| 请求头 | 值来源 | 用途 |
|---|---|---|
Activation-Version |
has_serial_number_ ? "2" : "1" |
告知服务端设备是否具备 eFuse 序列号,决定激活协议版本 |
Device-Id |
SystemInfo::GetMacAddress() |
设备 MAC 地址,作为设备唯一标识 |
Client-Id |
Board::GetUuid() |
软件生成的 UUID(基于 MAC 的 MD5),用于客户端追踪 |
Serial-Number |
eFuse USER_DATA 块读取 |
硬件烧录的 32 字节序列号(仅 has_serial_number_=true 时发送) |
User-Agent |
BOARD_NAME/version |
开发板型号与固件版本拼接 |
Accept-Language |
Lang::CODE |
设备当前语言设置 |
Sources: ota.cc
服务端 JSON 响应解析¶
CheckVersion() 将返回的 JSON 解析为五个逻辑段,每段对应一类配置数据:
1. Firmware 段(固件更新信息)¶
{
"firmware": {
"version": "2.0.0",
"url": "https://example.com/xiaozhi.bin",
"force": 0
}
}
版本比较由 IsNewVersionAvailable() 实现,采用标准的语义化版本比较算法:按 . 分割为整数数组,从左到右逐位比较。若 force 字段为 1,则忽略版本号直接标记 has_new_version_ = true,支持强制推送场景。
ParseVersion("0.10.5") → [0, 10, 5]
IsNewVersionAvailable("0.9.1", "0.10.0") → true (10 > 9)
IsNewVersionAvailable("1.0.0", "1.0.0") → false
IsNewVersionAvailable("1.0", "1.0.0") → true (长度更长)
2. Activation 段(激活验证数据)¶
{
"activation": {
"message": "请在手机上输入验证码",
"code": "123456",
"challenge": "random-challenge-string",
"timeout_ms": 30000
}
}
当存在 challenge 字段时,has_activation_challenge_ 被置为 true,后续 CheckNewVersion() 将进入激活循环。code 是一个展示给用户的 6 位数字验证码,设备通过 TTS 逐位朗读。timeout_ms 控制激活轮询的超时间隔(默认 30000ms)。
3. MQTT / WebSocket 段(通信协议配置)¶
这两个段的结构相同——服务端下发一个 JSON 对象,键值对直接写入对应 NVS 命名空间:
| JSON 段 | NVS 命名空间 | 说明 |
|---|---|---|
mqtt |
"mqtt" |
MQTT Broker 地址、端口、用户名、密码等 |
websocket |
"websocket" |
WebSocket 服务地址等 |
写入操作仅在值发生变化时执行(settings.GetString(key) != new_value),减少不必要的 NVS 擦写。InitializeProtocol() 根据 HasMqttConfig() / HasWebsocketConfig() 标志位选择协议栈实例。
Sources: ota.cc, application.cc
4. Server Time 段(系统时间同步)¶
服务端下发 UTC 时间戳和时区偏移,设备通过 settimeofday() 系统调用同步本地时钟。这对于需要精确时间的场景(如 TTS 缓存判定、日志时间戳)至关重要。
Sources: ota.cc
激活验证:基于 eFuse 与 HMAC-SHA256 的挑战应答¶
对于部署了硬件安全策略的设备(通过 eFuse 烧录了 32 字节序列号),服务端可以在 CheckVersion 响应中下发 challenge 字段,触发激活验证流程。
eFuse 序列号读取¶
在 Ota 构造函数中,系统尝试从 eFuse 的 USER_DATA 块读取 32 字节(256 位)序列号。读取成功后 has_serial_number_ 被置为 true,此时 SetupHttp() 会在请求头中添加 Activation-Version: 2 和 Serial-Number 字段,告知服务端该设备支持硬件级身份认证。
Ota 构造函数逻辑:
esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA, serial_number, 32*8)
→ 若首字节为 0x00 → has_serial_number_ = false
→ 否则 → serial_number_ = 32字节字符串, has_serial_number_ = true
Sources: ota.cc
HMAC 挑战应答流程¶
sequenceDiagram
participant Device as Ota 设备
participant EFUSE as eFuse (HMAC_KEY0)
participant Server as OTA 服务端
Server->>Device: CheckVersion 响应<br/>{activation: {challenge: "abc123"}}
Device->>Device: CheckNewVersion() 检测到<br/>has_activation_challenge_ = true
loop 最多 10 次轮询
Device->>Device: GetActivationPayload()<br/>计算 HMAC(challenge)
Note over Device,EFUSE: esp_hmac_calculate(HMAC_KEY0,<br/>challenge, hmac_result)
Device->>Server: POST /ota/activate<br/>{algorithm, serial_number, challenge, hmac}
alt 200 OK
Server-->>Device: 激活成功
else 202 Accepted
Server-->>Device: ESP_ERR_TIMEOUT<br/>等待 3 秒后重试
else 其他
Server-->>Device: 等待 10 秒后重试
end
end
GetActivationPayload() 构造的激活请求体包含四个字段:
{
"algorithm": "hmac-sha256",
"serial_number": "<32字节 eFuse 序列号>",
"challenge": "<服务端下发的随机挑战码>",
"hmac": "<HMAC-SHA256(challenge) 的十六进制表示>"
}
HMAC 计算依赖芯片的硬件安全模块:esp_hmac_calculate() 使用 eFuse 中预烧录的 HMAC_KEY0 对 challenge 进行 SHA-256 哈希消息认证码计算,结果为一个 32 字节的 digest,再格式化为 64 字符的十六进制字符串。这一机制确保了只有拥有正确硬件密钥的设备才能完成激活。
Sources: ota.cc
固件下载与刷写:ESP-IDF 原生 OTA 机制¶
当 CheckVersion 判定存在新版本且无需激活验证(或激活已通过)时,CheckNewVersion() 调用 Application::UpgradeFirmware(),后者最终落到 Ota::Upgrade() 静态方法执行实际的固件下载与刷写。
Upgrade 方法详解¶
Ota::Upgrade() 是一个同步阻塞的静态方法,其执行流程严格遵循 ESP-IDF OTA 规范:
flowchart TD
A["开始 Upgrade(url, callback)"] --> B["esp_ota_get_next_update_partition()<br/>获取非运行中的 ota_X 分区"]
B -->|"NULL"| FAIL["返回 false"]
B --> C["HTTP GET 固件 URL"]
C -->|"非 200"| FAIL
C --> D["获取 Content-Length<br/>分配 4KB heap 缓冲区"]
D --> E["循环读取 HTTP 数据"]
E --> F{"已检查镜像头?"}
F -->|"否"| G["累积数据直到 ≥ sizeof(esp_image_header_t)<br/>+ sizeof(esp_image_segment_header_t)<br/>+ sizeof(esp_app_desc_t)"]
G --> H["esp_ota_begin() 初始化 OTA 写入"]
H -->|"失败"| ABORT["esp_ota_abort() → 返回 false"]
H --> I["继续循环"]
F -->|"是"| I
I --> J["buffer 满 4KB 或读取完毕"]
J --> K["esp_ota_write() 写入 Flash"]
K -->|"失败"| ABORT
K --> L{"HTTP 读取完毕?"}
L -->|"否"| E
L -->|"是"| M["esp_ota_end() 校验镜像"]
M -->|"ESP_ERR_OTA_VALIDATE_FAILED"| ABORT
M --> N["esp_ota_set_boot_partition()<br/>切换启动分区"]
N --> O["返回 true → 设备重启动"]
进度回调与速率计算¶
在下载循环中,每隔 1 秒(通过 esp_timer_get_time() 差值判断)计算一次进度百分比和下载速率:
progress = total_read * 100 / content_length
speed = recent_read (每秒增量, 单位 B/s)
回调函数 callback(progress, speed) 透传到 Application 层,在 UI 上显示 "XX% YYkB/s" 格式的进度文本。
Sources: ota.cc
下载缓冲区的内存管理¶
固件下载使用 heap_caps_malloc(PAGE_SIZE, MALLOC_CAP_INTERNAL) 分配 4096 字节的内部 SRAM 缓冲区,而非使用外部 PSRAM。这一设计的考量在于:
- Flash 写入需要 DMA 对齐:内部 SRAM 天然满足 DMA 的对齐要求
- 避免 PSRAM 的带宽瓶颈:ESP32-S3 的 PSRAM 通过 SPI 访问,持续高吞吐读写可能影响整体系统性能
- 4KB 对齐:与 Flash 扇区大小匹配,减少写入放大
镜像头预校验¶
在正式调用 esp_ota_begin() 之前,代码会累积至少 sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t) 字节的数据,从中提取 esp_app_desc_t 结构。这一预读机制允许在 OTA 写入开始前就验证镜像的基本完整性,避免写入无效数据后才发现问题。
Sources: ota.cc
分区回滚保护:MarkCurrentVersionValid¶
ESP-IDF 的 OTA 机制在 esp_ota_end() 成功后,新固件处于 ESP_OTA_IMG_PENDING_VERIFY 状态。若设备在启动后未能调用 esp_ota_mark_app_valid_cancel_rollback() 确认固件有效,则 Bootloader 会在下次启动时自动回滚到上一个版本。
Ota::MarkCurrentVersionValid() 正是这一确认步骤的执行者:当 CheckNewVersion() 没有发现新版本时(即当前版本已是最新),它检查当前运行分区是否为 factory(工厂分区始终有效),若非 factory 则调用 esp_ota_mark_app_valid_cancel_rollback() 标记当前固件为有效,取消回滚计时器。
调用时机:
- CheckNewVersion() 中 has_new_version_ == false 且无激活挑战时
- 只有当设备成功启动并确认"无需更新"时才执行
Sources: ota.cc, application.cc
激活生命周期:ActivationTask 完整流程¶
ActivationTask 是在网络连接成功后由 HandleNetworkConnectedEvent() 创建的独立 FreeRTOS 任务(栈大小 8KB,优先级 2)。它按顺序执行三个步骤,完成后向主事件循环发送 MAIN_EVENT_ACTIVATION_DONE 信号。
stateDiagram-v2
[*] --> ActivationTask: 网络连接成功
state ActivationTask {
[*] --> CheckAssetsVersion: ota_ = new Ota()
CheckAssetsVersion --> CheckNewVersion: assets 下载完成或跳过
CheckNewVersion --> InitializeProtocol: 版本检查完成
InitializeProtocol --> [*]: 协议栈初始化
CheckNewVersion --> CheckNewVersion: 有激活挑战 → 轮询激活
CheckNewVersion --> UpgradeFirmware: 有新固件 → 下载刷写
UpgradeFirmware --> [*]: 升级成功 → 重启
UpgradeFirmware --> CheckNewVersion: 升级失败 → 继续正常流程
}
ActivationTask --> 主事件循环: xEventGroupSetBits(MAIN_EVENT_ACTIVATION_DONE)
InitializeProtocol --> ota_.reset(): 释放 Ota 对象
CheckNewVersion 的重试策略¶
网络请求本身不可靠,CheckNewVersion() 采用了带指数退避的重试机制:
| 重试次数 | 退避等待 | 累计最大耗时 |
|---|---|---|
| 第 1 次 | 10 秒 | ~10s |
| 第 2 次 | 20 秒 | ~30s |
| 第 3 次 | 40 秒 | ~70s |
| ... | ×2 每次 | ... |
| 第 10 次 | 5120 秒 | — 但到达 MAX_RETRY 后直接退出 |
每次重试间隔中,会检查用户是否按下了设备按钮(状态变为 kDeviceStateIdle),若是则立即跳出等待循环,尊重用户中止意图。
当重试次数达到 MAX_RETRY = 10 后,系统放弃版本检查但不会阻止设备正常工作——CheckNewVersion() 直接 return,ActivationTask 继续执行 InitializeProtocol(),设备以"离线模式"启动。这是典型的优雅降级策略。
Sources: application.cc, application.cc
CheckAssetsVersion:独立的资源包更新通道¶
在与固件 OTA 并行的维度上,CheckAssetsVersion() 管理 assets 分区(SPIFFS 格式)的独立下载。它的触发条件是 NVS "assets" 命名空间中存在 download_url 键:
- 读取
download_url,若不为空则进入下载流程
- 立即擦除该键(防止重复下载)
- 通过
Assets::Download()下载资源包到assets分区
- 调用
Assets::Apply()重新加载主题、字体、音效等资源
- 若下载失败,弹出错误提示但允许继续
资源包下载也支持进度回调,格式与固件下载一致("XX% YYkB/s")。
Sources: application.cc, assets.h
InitializeProtocol:根据服务端配置选择通信协议¶
激活任务的最后一步是根据 OTA 响应中下发的配置初始化通信协议栈:
if (ota_->HasMqttConfig()) → protocol_ = new MqttProtocol()
else if (ota_->HasWebsocketConfig()) → protocol_ = new WebSocketProtocol()
else → protocol_ = new MqttProtocol() // 默认回退
协议初始化后注册了五个回调:OnConnected(连接成功)、OnNetworkError(网络错误)、OnIncomingAudio(接收音频)、OnAudioChannelOpened/Closed(音频通道状态)、OnIncomingJson(JSON 消息处理)。这些回调构成了设备与云端大模型的完整通信管线。
Sources: application.cc
安全模型总览¶
小智 OTA 系统的安全层采用纵深防御策略,从设备身份标识到固件完整性验证,形成了一条完整的信任链:
flowchart LR
subgraph Identity["设备身份层"]
MAC["MAC 地址"]
UUID["软件 UUID"]
SN["eFuse 序列号<br/>(硬件烧录)"]
end
subgraph Auth["认证层"]
AV1["Activation-Version: 1<br/>(仅 MAC + UUID)"]
AV2["Activation-Version: 2<br/>(MAC + UUID + SN + HMAC)"]
end
subgraph Transport["传输层"]
HTTPS["TLS 加密 HTTP"]
end
subgraph Integrity["完整性层"]
OTA_CHECK["esp_ota_end()<br/>镜像校验"]
ROLLBACK["Rollback 回滚保护"]
end
Identity --> Auth --> Transport --> Integrity
| 安全层级 | 实现机制 | 防护目标 |
|---|---|---|
| 设备身份 | eFuse 序列号 + MAC + UUID | 防止设备伪造 |
| 激活认证 | HMAC-SHA256 挑战应答 | 防止未授权设备接入 |
| 传输加密 | TLS (HTTPS) | 防止固件被篡改或窃听 |
| 完整性校验 | esp_ota_end() 内部 CRC/SHA 验证 |
防止刷入损坏固件 |
| 回滚保护 | ESP_OTA_IMG_PENDING_VERIFY + MarkCurrentVersionValid |
防止因固件缺陷导致设备变砖 |
错误处理与异常路径¶
整个 OTA 流程覆盖了以下异常路径,确保在任何环节失败时设备仍能正常工作:
| 异常场景 | 处理策略 | 代码位置 |
|---|---|---|
| OTA URL 未配置 | ESP_ERR_INVALID_ARG,直接返回 |
CheckVersion() L84-L87 |
| HTTP 连接失败 | 进入重试循环(指数退避,最多 10 次) | CheckNewVersion() L400-L420 |
| 版本检查 10 次全部失败 | 放弃检查,继续初始化协议栈(离线运行) | CheckNewVersion() L403 |
| JSON 解析失败 | ESP_ERR_INVALID_RESPONSE,触发重试 |
CheckVersion() L107-L110 |
| 激活超时 (HTTP 202) | 3 秒后重试,最多 10 次 | CheckNewVersion() L440-L443 |
| 激活失败 (非 200/202) | 10 秒后重试,最多 10 次 | CheckNewVersion() L443-L445 |
| 固件下载失败 | 重启 AudioService,恢复低功耗模式,继续运行 | UpgradeFirmware() L1020-L1028 |
| esp_ota_write 失败 | esp_ota_abort() 终止写入,释放 buffer,返回 false |
Upgrade() L344-L348 |
| 镜像校验失败 | ESP_ERR_OTA_VALIDATE_FAILED,记录日志,返回 false |
Upgrade() L358-L360 |
| 用户按键中断激活 | 检测 kDeviceStateIdle,跳出等待循环 |
CheckNewVersion() L419-L421 |
文档源码索引¶
| 源码文件 | 主要内容 |
|---|---|
| ota.h | Ota 类接口定义:CheckVersion、Activate、Upgrade、MarkCurrentVersionValid 等方法声明及成员变量 |
| ota.cc | Ota 类完整实现:eFuse 读取、HTTP 请求构造、JSON 解析、版本比较、HMAC 激活、固件下载刷写 |
| application.h | Application 单例类接口:事件位定义、ActivationTask、CheckNewVersion、UpgradeFirmware、Reboot 声明 |
| application.cc | Application 实现:生命周期管理、激活任务流程、重试策略、协议初始化 |
| assets.h | Assets 单例类接口:资源包下载、应用策略模式支持 LVGL / Emote 两种资源策略 |
| board.h | Board 抽象基类:GetNetwork() 返回 NetworkInterface,用于创建 HTTP 客户端 |
| system_info.h / system_info.cc | 系统信息工具类:MAC 地址、芯片型号、User-Agent 生成 |
| Kconfig.projbuild | Kconfig 配置项:CONFIG_OTA_URL 默认值定义 |
| partitions/v2/ | V2 分区表:ota_0 / ota_1 / assets 分区布局 |
继续阅读¶
- Settings 持久化存储:基于 NVS 的键值读写 — 理解 OTA 过程中 MQTT/WebSocket 配置如何持久化到 NVS
- 分区表设计:v1 与 v2 版本的存储布局迁移 — 深入理解 ota_0/ota_1/assets 分区的布局与迁移策略
- 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计 — 了解 OTA 下发的协议配置如何驱动通信协议栈选择
- 网络连接管理:Wi-Fi / ML307 4G / RNDIS 多模接入 — 理解 OTA 的数据通道如何在多种网络模式下工作
Settings 持久化存储:基于 NVS 的键值读写¶
在嵌入式设备中,持久化存储是保证用户配置在断电重启后不丢失的核心机制。小智 AI 聊天机器人基于 ESP-IDF 框架的 NVS(Non-Volatile Storage,非易失性存储) 库,封装了一个轻量级的 C++ 配置管理类 Settings,为系统中各个子系统提供命名空间隔离的键值读写能力。本文将从数据结构设计、命名空间划分、读写模式控制三个维度,系统性地解析这套持久化存储方案。
NVS 基础概念¶
NVS 是 ESP-IDF 提供的一个基于 Flash 分区的键值存储系统。它在 nvs 分区上以命名空间(namespace)隔离不同应用的数据,每个命名空间内存储若干 键值对。NVS 内部使用磨损均衡(wear-leveling)算法延长 Flash 寿命,并支持原子性的跨键值提交(commit)操作。小智项目的 Settings 类正是对 NVS C API 的 C++ RAII 封装。
Sources: settings.h
Settings 类的数据结构设计¶
Settings 类的核心数据结构极为精简,仅包含四个成员变量:
classDiagram
class Settings {
-string ns_
-nvs_handle_t nvs_handle_
-bool read_write_
-bool dirty_
+Settings(ns, read_write)
+~Settings()
+GetString(key, default) string
+SetString(key, value)
+GetInt(key, default) int32_t
+SetInt(key, value)
+GetBool(key, default) bool
+SetBool(key, value)
+EraseKey(key)
+EraseAll()
}
| 成员变量 | 类型 | 作用 |
|---|---|---|
ns_ |
std::string |
NVS 命名空间名称,用于数据隔离 |
nvs_handle_ |
nvs_handle_t |
NVS 句柄,0 表示未初始化或已关闭 |
read_write_ |
bool |
读写模式标志,false 时拒绝写入操作 |
dirty_ |
bool |
脏标志,标记是否有未提交的写入操作 |
这种设计的核心思想是 单一职责:每个 Settings 实例只管理一个命名空间,类的生命周期由 RAII 自动控制资源的打开与关闭。
Sources: settings.h
构造与析构:RAII 资源管理¶
Settings 采用 RAII(Resource Acquisition Is Initialization) 模式管理 NVS 资源,确保在任何代码路径下资源都能被正确释放。
构造函数:打开命名空间¶
Settings::Settings(const std::string& ns, bool read_write)
: ns_(ns), read_write_(read_write) {
nvs_open(ns.c_str(), read_write_ ? NVS_READWRITE : NVS_READONLY, &nvs_handle_);
}
构造时直接调用 nvs_open 打开指定命名空间。read_write 参数控制 NVS 的打开模式:
true→NVS_READWRITE:允许读写操作
false→NVS_READONLY:仅允许读取,写入操作将被拒绝并输出警告日志
Sources: settings.cc
析构函数:延迟提交与资源释放¶
Settings::~Settings() {
if (nvs_handle_ != 0) {
if (read_write_ && dirty_) {
ESP_ERROR_CHECK(nvs_commit(nvs_handle_));
}
nvs_close(nvs_handle_);
}
}
析构时的行为遵循一个关键的设计模式——延迟提交(Lazy Commit):
- 检查句柄是否有效(不为 0)
- 仅在
read_write_为 true 且dirty_标志被置位时,才调用nvs_commit将缓存的写入操作真正持久化到 Flash
- 无论是否提交,始终调用
nvs_close释放 NVS 句柄
这意味着:在整个 Settings 实例的生命周期内,所有 Set* 操作都只是修改了 NVS 的内存缓存,直到析构时才一次性写入 Flash。这种 批量化写入 策略减少了 Flash 擦写次数,延长了 Flash 寿命。
Sources: settings.cc
键值读写操作详解¶
Settings 支持三种基础数据类型:字符串(String)、整数(Int32) 和 布尔值(Bool)。所有读操作都遵循相同的模式:先检查句柄有效性 → 尝试读取 → 失败时返回默认值。
字符串读写¶
sequenceDiagram
participant Caller
participant Settings
participant NVS
Note over Caller,NVS: 读取字符串流程
Caller->>Settings: GetString(key, default)
Settings->>Settings: nvs_handle_ == 0?
alt 句柄无效
Settings-->>Caller: return default_value
end
Settings->>NVS: nvs_get_str(handle, key, nullptr, &length)
alt key 不存在
Settings-->>Caller: return default_value
end
NVS-->>Settings: 返回 length
Settings->>Settings: value.resize(length)
Settings->>NVS: nvs_get_str(handle, key, value.data(), &length)
NVS-->>Settings: 填充 value
Settings->>Settings: 去除尾部 '\0' 字符
Settings-->>Caller: return value
读取字符串时采用 两阶段读取 策略:首先以 nullptr 作为缓冲区指针调用 nvs_get_str 获取字符串长度,然后分配合适大小的缓冲区再次调用获取实际内容。这种方式避免了固定大小缓冲区可能导致的截断问题。最后通过 pop_back 循环去除 NVS 可能追加的多余空字符。
Sources: settings.cc
写入字符串直接调用 nvs_set_str,并设置 dirty_ 标志:
void Settings::SetString(const std::string& key, const std::string& value) {
if (read_write_) {
ESP_ERROR_CHECK(nvs_set_str(nvs_handle_, key.c_str(), value.c_str()));
dirty_ = true;
} else {
ESP_LOGW(TAG, "Namespace %s is not open for writing", ns_.c_str());
}
}
如果实例以只读模式打开,写入操作被静默拒绝并输出警告日志,这是一种 fail-safe 的安全设计。
Sources: settings.cc
整数读写¶
整数类型直接映射到 NVS 的 nvs_get_i32 / nvs_set_i32,底层存储为 32 位有符号整数。读取失败时返回调用者指定的默认值(通常为 0)。
Sources: settings.cc
布尔值读写¶
布尔值在 NVS 中以 uint8_t 类型存储(nvs_get_u8 / nvs_set_u8),约定 0 表示 false,非 0 表示 true:
bool Settings::GetBool(const std::string& key, bool default_value) {
if (nvs_handle_ == 0) return default_value;
uint8_t value;
if (nvs_get_u8(nvs_handle_, key.c_str(), &value) != ESP_OK) return default_value;
return value != 0;
}
写入时明确将 true 映射为 1,false 映射为 0,保证存储格式的一致性和可预测性。
Sources: settings.cc
键值删除操作¶
提供两种粒度的删除能力:
| 方法 | 功能 | 容错行为 |
|---|---|---|
EraseKey(key) |
删除单个键 | 若键不存在(ESP_ERR_NVS_NOT_FOUND),不视为错误 |
EraseAll() |
清空当前命名空间所有键值 | 直接调用 nvs_erase_all |
EraseKey 对 "键不存在" 情况的宽容处理,避免了调用者需要预先检查键是否存在的繁琐步骤。
Sources: settings.cc
命名空间架构:全系统配置地图¶
小智项目使用 命名空间隔离 策略组织持久化配置。每个子系统拥有独立的 NVS 命名空间,通过特定的 Settings 实例进行访问。下图展示了完整的命名空间划分:
graph TB
subgraph NVS Flash Partition
subgraph "命名空间: board"
BOARD_UUID["uuid: 设备唯一标识<br/>(首次启动自动生成)"]
end
subgraph "命名空间: wifi"
WIFI_OTA["ota_url: OTA 服务器地址"]
WIFI_SLEEP["sleep_mode: 休眠模式开关"]
end
subgraph "命名空间: mqtt"
MQTT_KEYS["endpoint / client_id<br/>username / password<br/>keepalive / publish_topic"]
end
subgraph "命名空间: websocket"
WS_KEYS["url / token / version"]
end
subgraph "命名空间: display"
DISP_BRIGHT["brightness: 屏幕亮度 0-100"]
DISP_THEME["theme: 主题名称(light/dark)"]
end
subgraph "命名空间: audio"
AUDIO_VOL["output_volume: 输出音量"]
end
subgraph "命名空间: assets"
ASSETS_URL["download_url: 资源包下载地址"]
end
end
各命名空间使用详情¶
下表汇总了每个命名空间在生产代码中的实际使用场景:
| 命名空间 | 使用位置 | 访问模式 | 存储内容 |
|---|---|---|---|
"board" |
Board::Board() |
读写 | 设备 UUID(首次生成后持久化) |
"wifi" |
Ota::GetCheckVersionUrl() |
只读 | OTA 版本检查服务器 URL |
"wifi" |
PowerSaveTimer::SetEnabled() |
只读 | sleep_mode 布尔值 |
"mqtt" |
Ota::CheckVersion() |
读写 | 服务端下发的 MQTT 连接参数 |
"mqtt" |
MqttProtocol::StartMqttClient() |
只读 | 读取已存储的 MQTT 连接参数 |
"websocket" |
Ota::CheckVersion() |
读写 | 服务端下发的 WebSocket 连接参数 |
"websocket" |
WebsocketProtocol::OpenAudioChannel() |
只读 | 读取已存储的 WebSocket 连接参数 |
"display" |
Backlight::RestoreBrightness() / SetBrightness() |
只读 / 读写 | 屏幕亮度值(0-100) |
"display" |
LcdDisplay::LcdDisplay() / Display::SetTheme() |
只读 / 读写 | 主题名称("light" 或 "dark") |
"audio" |
AudioCodec::Start() / SetOutputVolume() |
只读 / 读写 | 输出音量值 |
"assets" |
Application::CheckAssetsVersion() |
读写 | 待下载资源包的临时 URL |
Sources: board.cc | ota.cc | ota.cc | mqtt_protocol.cc | websocket_protocol.cc | backlight.cc | display.cc | audio_codec.cc | application.cc
读写分离模式:安全性与性能平衡¶
项目中存在一个重要的设计模式:同一命名空间在不同上下文中以不同的读写模式打开。
典型案例:MQTT 配置的双模式访问¶
在 Ota::CheckVersion() 中,服务端返回的 MQTT 配置需要被 写入 NVS:
// ota.cc - 写入模式
Settings settings("mqtt", true); // read_write = true
settings.SetString(item->string, item->valuestring);
而在 MqttProtocol::StartMqttClient() 中,仅需 读取 已存储的配置:
// mqtt_protocol.cc - 只读模式
Settings settings("mqtt", false); // read_write = false
auto endpoint = settings.GetString("endpoint");
这种设计带来了两个好处:
- 安全性:协议运行时的代码无法意外修改配置,防止运行时逻辑错误破坏持久化数据
- 性能:只读模式不需要在析构时调用
nvs_commit,减少不必要的 Flash 写入
同样的模式也出现在 "display" 命名空间:Backlight::RestoreBrightness() 以只读模式读取亮度值,而 SetBrightness(permanent=true) 时才以读写模式持久化。
写入保护日志¶
当尝试在只读实例上调用 Set* 方法时,系统会输出警告日志帮助调试:
W (Settings) Namespace mqtt is not open for writing
这是一个轻量级的运行时检查机制,无需额外的线程同步或锁。
UUID 持久化:设备身份的一劳永逸¶
设备 UUID 的持久化是 Settings 最典型的应用场景。UUID 用于在服务端唯一标识一台设备,必须在设备整个生命周期中保持不变:
Board::Board() {
Settings settings("board", true);
uuid_ = settings.GetString("uuid");
if (uuid_.empty()) {
uuid_ = GenerateUuid(); // 使用硬件随机数生成 UUID v4
settings.SetString("uuid", uuid_); // 持久化到 NVS
}
}
流程如下:
flowchart TD
A[设备启动] --> B[打开 'board' 命名空间]
B --> C{读取 uuid 键}
C -->|存在| D[使用已有 UUID]
C -->|不存在| E[调用硬件 RNG 生成 UUID v4]
E --> F[写入 NVS 持久化]
F --> D
D --> G[后续所有请求携带此 UUID]
UUID 使用 ESP32 的硬件随机数生成器(esp_fill_random),按照 RFC 4122 的 UUID v4 规范设置版本位和变体位,保证全局唯一性。由于 UUID 仅在首次启动时生成并持久化,此后每次启动都能读到相同的值。
Sources: board.cc
服务端配置下发:OTA 过程中的动态 NVS 写入¶
设备激活时,Ota::CheckVersion() 会解析服务端返回的 JSON 响应,动态地将通信协议配置写入 NVS。这是一个关键的 配置下发(Configuration Provisioning) 流程:
sequenceDiagram
participant Device
participant OTA Server
Device->>OTA Server: POST /api/v2/ota (设备信息)
OTA Server-->>Device: JSON { mqtt: {...}, websocket: {...} }
alt 包含 mqtt 配置
Device->>NVS: Settings("mqtt", true)
loop 遍历 mqtt JSON 对象
Device->>NVS: SetString / SetInt (逐键写入)
end
Device->>NVS: ~Settings() → nvs_commit()
end
alt 包含 websocket 配置
Device->>NVS: Settings("websocket", true)
loop 遍历 websocket JSON 对象
Device->>NVS: SetString / SetInt (逐键写入)
end
Device->>NVS: ~Settings() → nvs_commit()
end
值得注意的优化细节:服务端下发的配置仅在值与现有值不同时才写入,避免触发不必要的 dirty 标记:
// ota.cc 中的增量更新逻辑
if (settings.GetString(item->string) != item->valuestring) {
settings.SetString(item->string, item->valuestring);
}
这种 增量更新 策略进一步减少了 Flash 写入次数,与 dirty_ 延迟提交机制形成双重保护。
Sources: ota.cc
延迟提交机制的内存模型¶
dirty_ 标志的设计值得单独剖析。以下是写入操作的生命周期:
stateDiagram-v2
[*] --> Clean: Settings 构造
Clean --> Dirty: SetString / SetInt / SetBool
Dirty --> Dirty: 更多 Set* 操作
Dirty --> Committed: ~Settings() 析构时 nvs_commit()
Committed --> [*]
note right of Clean: dirty_ = false
note right of Dirty: dirty_ = true
note right of Committed: 数据已持久化到 Flash
关键行为:nvs_commit() 仅在析构函数中调用,且必要条件为 read_write_ && dirty_。这意味着:
- 如果在
Settings实例生命周期内从未进行任何写入操作,析构时不会触发 Flash 写入
- 无论进行了多少次
Set*调用,整个生命周期仅执行一次 Flash 提交
- 如果程序异常崩溃(析构函数未执行),所有未提交的写入将丢失
对于需要在实例生命周期内主动提交的场景(如 OTA 过程中的关键配置),调用者只需让 Settings 实例提前析构(例如离开作用域):
{
Settings settings("mqtt", true);
settings.SetString("endpoint", "mqtt.example.com:8883");
// ... 更多写入操作
} // 作用域结束,析构函数在此处提交所有变更
这种设计将"何时提交"的决策权留给了调用方的代码结构,而非引入显式的 Commit() 方法,保持了接口的简洁性。
总结与最佳实践¶
Settings 类通过约 100 行代码实现了嵌入式场景下安全、高效的持久化配置管理,其核心设计原则包括:
| 设计原则 | 实现方式 | 价值 |
|---|---|---|
| 命名空间隔离 | 每个子系统使用独立的 NVS 命名空间 | 防止键名冲突,模块解耦 |
| RAII 资源管理 | 构造打开句柄,析构关闭并提交 | 消除资源泄漏风险 |
| 延迟批量提交 | dirty 标志 + 析构时 commit | 减少 Flash 擦写,延长寿命 |
| 读写模式分离 | 构造参数控制 NVS 打开模式 | 运行时安全,防止误修改 |
| 默认值容错 | 所有 Get* 方法支持默认值 | 简化调用者代码,无需检查键是否存在 |
在实际开发中,如果需要为新的功能模块添加持久化配置,推荐的模式是:
- 确定一个唯一的命名空间名称(如
"my_feature")
- 在初始化代码中以只读模式打开,读取配置并应用默认值
- 在需要持久化修改的代码中以读写模式打开,调用
Set*方法
- 依赖 RAII 自动提交,无需手动管理 Flash 写入
如需了解更多关于 NVS 分区的底层存储布局(如 v1 与 v2 分区表差异),请参阅 分区表设计:v1 与 v2 版本的存储布局迁移。如需了解网络配置如何与 Settings 协同工作,请参阅 网络连接管理:Wi-Fi / ML307 4G / RNDIS 多模接入。
网络连接管理:Wi-Fi / ML307 4G / RNDIS 多模接入¶
本文档深度解析小智 AI 聊天机器人(xiaozhi-esp32)的多模网络接入架构——从 Board 抽象层的网络接口定义,到 Wi-Fi、ML307 4G 模组、RNDIS USB 以太网三种接入方式的独立实现,再到 DualNetworkBoard 的双网无缝切换机制。读者将理解网络事件的完整传播链路、Wi-Fi 配网的三种途径(热点 / 蓝牙 / 声波),以及不同板卡如何通过继承体系注入各自的网络行为。
Sources: board.h
网络接入架构总览¶
小智的网络接入系统采用三层抽象 + 事件驱动的设计范式。顶层 Board 基类定义了所有板卡必须实现的网络相关虚函数,包括 GetNetwork()(获取底层网络接口)、StartNetwork()(启动异步连接)和 SetNetworkEventCallback()(注册事件回调)。中间层由四个专用板卡类——WifiBoard、Ml307Board、RndisBoard、Nt26Board——分别承载不同的接入技术,以及一个 DualNetworkBoard 组合类实现双网动态切换。底层则依赖 ESP-IDF 组件生态中的 NetworkInterface 抽象(EspNetwork、AtModem、UartEthModem)完成实际的协议栈交互。
网络事件通过 NetworkEventCallback 回调函数(类型为 std::function<void(NetworkEvent, const std::string&)>)自底向上传播:Board 层产生的原始事件沿回调链传递至 Application 层,触发事件组标志位(MAIN_EVENT_NETWORK_CONNECTED / MAIN_EVENT_NETWORK_DISCONNECTED),最终在主事件循环中被统一处理。这种设计将网络状态的变更与业务逻辑解耦,使得同一种板卡实现可以适配不同的通信协议(WebSocket 或 MQTT+UDP)。
Sources: board.h, application.h
graph TB
subgraph "Board 抽象层"
Board["Board<br/>GetNetwork() / StartNetwork()<br/>SetNetworkEventCallback()"]
Wifi["WifiBoard<br/>Wi-Fi 管理 + 配网"]
ML307["Ml307Board<br/>AT 指令驱动 4G"]
RNDIS["RndisBoard<br/>USB CDC-RNDIS"]
NT26["Nt26Board<br/>UART 以太网模组"]
Dual["DualNetworkBoard<br/>Wi-Fi ⇄ 4G 切换"]
end
subgraph "底层接口"
EspNet["EspNetwork<br/>ESP-NETIF 协议栈"]
AtModem["AtModem<br/>AT 指令 Modem"]
UartEth["UartEthModem<br/>UART 以太网"]
end
subgraph "应用层"
App["Application<br/>网络事件处理<br/>→ 激活 / 协议初始化"]
end
Board --> Wifi
Board --> ML307
Board --> RNDIS
Board --> NT26
Board --> Dual
Dual -.-> Wifi
Dual -.-> ML307
Wifi --> EspNet
ML307 --> AtModem
RNDIS --> EspNet
NT26 --> UartEth
Wifi -- "NetworkEventCallback" --> App
ML307 -- "NetworkEventCallback" --> App
RNDIS -- "NetworkEventCallback" --> App
NT26 -- "NetworkEventCallback" --> App
Dual -- "委托给当前板卡" --> App
上图展示了四种独立板卡类和一种组合板卡类的继承/委托关系。DualNetworkBoard 不直接处理网络逻辑,而是在内部持有一个 std::unique_ptr<Board> 指针,根据用户在 Settings 中的选择动态创建 WifiBoard 或 Ml307Board 实例,并将所有 Board 接口调用委托给当前活跃的子板卡。
Sources: dual_network_board.h, dual_network_board.cc
网络事件系统¶
NetworkEvent 枚举定义了 10 种网络事件,覆盖从扫描、连接到失败的全生命周期:
| 事件 | 触发场景 | 携带数据 |
|---|---|---|
Scanning |
Wi-Fi 正在扫描可用网络 | 无 |
Connecting |
正在连接指定网络 | SSID 或运营商名称 |
Connected |
网络连接成功 | SSID 或运营商名称 |
Disconnected |
网络断开 | 无 |
WifiConfigModeEnter |
进入 Wi-Fi 配网模式 | 无 |
WifiConfigModeExit |
退出 Wi-Fi 配网模式 | 无 |
ModemDetecting |
正在检测 4G 模组 | 无 |
ModemErrorNoSim |
未检测到 SIM 卡 | 无 |
ModemErrorRegDenied |
网络注册被拒绝 | 无 |
ModemErrorInitFailed |
模组初始化失败 | 无 |
ModemErrorTimeout |
操作超时 | 无 |
Sources: board.h
Application::Initialize() 在启动阶段注册全局网络回调,将 Board 层的事件翻译为 UI 状态更新和事件组标志位操作。例如 NetworkEvent::Connected 触发 MAIN_EVENT_NETWORK_CONNECTED 标志位,推动设备从 kDeviceStateStarting 或 kDeviceStateWifiConfiguring 过渡到 kDeviceStateActivating,进而启动激活任务(版本检查、协议初始化)。而当 NetworkEvent::Disconnected 到来时,若设备正处于通话状态,协议层会立刻关闭音频通道以避免资源泄露。
Sources: application.cc, application.cc
Wi-Fi 接入:WifiBoard¶
WifiBoard 是小智项目最常用的网络接入方式,服务于 70+ 种板卡中的大多数 Wi-Fi 型号。其核心依赖是两个外部组件:78/esp-wifi-connect(提供 WifiManager 和 SsidManager)和 ESP-IDF 内建的 EspNetwork。
连接流程¶
StartNetwork() 调用后,流程分两条路径:
- 已有凭据:若
SsidManager中存在已保存的 SSID,WifiBoard启动一个 60 秒的连接超时定时器,然后调用WifiManager::StartStation()尝试连接。若超时仍未成功,OnWifiConnectTimeout()回调会自动停止 STA 模式并进入配网模式。
- 无凭据:延迟 1.5 秒(等待板卡版本信息显示完毕后),直接进入 Wi-Fi 配置模式。三种配网方式由编译期 Kconfig 互斥选择:
Sources: wifi_board.cc
三种 Wi-Fi 配网方式¶
| 配网方式 | 编译宏 | 原理 | 用户体验 |
|---|---|---|---|
| 热点配网 (SoftAP) | CONFIG_USE_HOTSPOT_WIFI_PROVISIONING |
设备开启 AP 模式,广播 Xiaozhi-XXXX 热点;手机连接后通过浏览器访问 192.168.4.1 的 Web 页面完成配网 |
最通用,无需额外 App |
| 蓝牙配网 (BluFi) | CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING |
通过 BLE 与手机上的 EspBluFi App 通信,使用 DH 密钥交换 + AES 加密传输 Wi-Fi 凭据 | 安全性高,但需要安装 App |
| 声波配网 (Acoustic) | CONFIG_USE_ACOUSTIC_WIFI_PROVISIONING |
手机播放编码后的音频信号(AFSK 调制:Mark=1800Hz, Space=1500Hz),设备麦克风接收后通过 Goertzel 算法解调还原凭据 | 无需 UI 交互,通过 afsk_demod.h 中定义的 audio_wifi_config::ReceiveWifiCredentialsFromAudio() 实现 |
Sources: wifi_board.cc, afsk_demod.h
信号强度图标¶
WifiBoard::GetNetworkStateIcon() 根据 RSSI 值映射到 Font Awesome 图标:
| RSSI 范围 | 图标 | 含义 |
|---|---|---|
| ≥ -65 dBm | FONT_AWESOME_WIFI |
强信号 |
| -75 ~ -65 dBm | FONT_AWESOME_WIFI_FAIR |
中等信号 |
| < -75 dBm | FONT_AWESOME_WIFI_WEAK |
弱信号 |
| 未连接 | FONT_AWESOME_WIFI_SLASH |
无连接 |
| 配网模式 | FONT_AWESOME_WIFI |
等待配网 |
Sources: wifi_board.cc
功耗策略映射¶
WifiBoard 将通用的 PowerSaveLevel 枚举映射为 WifiPowerSaveLevel,利用 ESP-IDF 的 Wi-Fi 省电协议(Modem Sleep / Light Sleep)在不同场景下自动调节:
| PowerSaveLevel | WifiPowerSaveLevel | 场景 |
|---|---|---|
LOW_POWER |
WIFI_PS_MIN_MODEM |
空闲待机 |
BALANCED |
WIFI_PS_MAX_MODEM |
中等负载 |
PERFORMANCE |
WIFI_PS_NONE |
音频通话中 |
Sources: wifi_board.cc
4G 蜂窝接入:Ml307Board 与 Nt26Board¶
小智支持两种 4G 蜂窝模组方案:基于 AT 指令的 ML307(及兼容的 EC801E)和基于 UART 以太网透传的 NT26。两者都继承了 Board 基类,但底层驱动机制截然不同。
Ml307Board:AT 指令驱动¶
Ml307Board 通过 UART 与 ML307 模组通信,依赖 78/esp-ml307 组件提供的 AtModem 类。构造时接收 TX/RX/DTR 三个 GPIO 引脚号,在独立 FreeRTOS 任务中执行网络初始化。
模组检测流程采用重试机制:最多 30 次调用 AtModem::Detect() 进行波特率同步和模组识别,每次失败后等待 1 秒。检测成功后注册 OnNetworkStateChanged 回调,再调用 WaitForNetworkReady() 等待网络注册(最多重试 6 次,每次间隔 10 秒)。WaitForNetworkReady() 返回的 NetworkStatus 枚举值直接映射到 NetworkEvent 中的错误事件——ErrorInsertPin 对应 ModemErrorNoSim,ErrorRegistrationDenied 对应 ModemErrorRegDenied,ErrorTimeout 对应 ModemErrorTimeout。
Sources: ml307_board.cc
Ml307Board::GetBoardJson() 返回比 Wi-Fi 板卡更丰富的运营商信息,包括模组版本号(revision)、运营商名称(carrier)、信号强度(csq)、IMEI、ICCID 和完整的网络注册状态(cereg),这些信息通过 MQTT/WebSocket 上报至服务器,用于 OTA 版本匹配和设备身份识别。
Sources: ml307_board.cc
信号强度图标根据 CSQ 值(0-31)分五档:
| CSQ 范围 | 图标 |
|---|---|
| 20-31 | FONT_AWESOME_SIGNAL_STRONG |
| 15-19 | FONT_AWESOME_SIGNAL_GOOD |
| 10-14 | FONT_AWESOME_SIGNAL_FAIR |
| 0-9 | FONT_AWESOME_SIGNAL_WEAK |
| 未就绪 / CSQ=-1 | FONT_AWESOME_SIGNAL_OFF |
Sources: ml307_board.cc
Nt26Board:UART 以太网透传¶
与 ML307 的 AT 指令方式不同,NT26 模组通过 78/uart-eth-modem 组件提供的 UartEthModem 类,将蜂窝网络封装为标准以太网接口。这意味着 Nt26Board::GetNetwork() 返回的是 EspNetwork 而非 AtModem——对上层协议栈而言,NT26 建立的连接与 Wi-Fi 无异。
Nt26Board 的独特之处在于它实现了真正的 SetPowerSaveLevel():通过 esp_pm_lock_acquire() / esp_pm_lock_release() 在 BALANCED/PERFORMANCE 模式下锁定 CPU 最高频率,在 LOW_POWER 模式下释放锁以允许动态调频,从而兼顾 4G 数据传输的实时性和待机功耗。
Sources: nt26_board.cc, nt26_board.h
RNDIS USB 以太网接入¶
RndisBoard 是一种特殊的网络接入方式:设备通过 USB OTG 接口连接外部模块(如 4G 上网卡或 Android 手机的 USB 网络共享),利用 RNDIS(Remote NDIS)协议建立以太网连接。该方案仅在 ESP32-S3 和 ESP32-P4 芯片上受支持(依赖 USB Host 外设),典型应用包括 ESP32-S3-Korvo2-V3-RNDIS 板卡。
StartNetwork() 的执行是同步阻塞的:初始化 NVS、TCP/IP 协议栈、事件循环,安装 USB Host CDC 驱动,然后通过 install_rndis() 注册一个匹配任意 VID/PID 的 RNDIS 以太网驱动,最后调用 xEventGroupWaitBits() 无限期等待 EVENT_GOT_IP_BIT 标志位。这种阻塞设计是因为 RNDIS 设备在 USB 插入的瞬间即可被识别和配置,无需像 Wi-Fi 那样经历扫描和重试的超时逻辑。
事件处理上,RndisBoard 直接监听 IOT_ETH_EVENT 事件(来自 iot_usbh_rndis 组件),将 IOT_ETH_EVENT_CONNECTED / IOT_ETH_EVENT_DISCONNECTED 映射为 NetworkEvent::Connected / NetworkEvent::Disconnected。由于 RNDIS 是有线等效连接,GetNetworkStateIcon() 固定返回 FONT_AWESOME_SIGNAL_STRONG。
Sources: rndis_board.cc, rndis_board.cc
双网切换:DualNetworkBoard¶
DualNetworkBoard 是 Board 抽象层中最精巧的设计。它并非独立实现网络接入,而是作为一个策略容器,内部持有 std::unique_ptr<Board> 指针,在 Wi-Fi 和 ML307 之间按需切换其指向的具体板卡实例。
持久化与初始化¶
网络类型选择通过 Settings("network", true) 持久化在 NVS 中,键名为 "type"(0=Wi-Fi,1=ML307)。构造时从 Settings 读取并调用 InitializeCurrentBoard() 按需创建对应的子板卡对象。默认值可以通过构造函数参数 default_net_type 指定,允许不同板卡有不同的出厂网络偏好。
Sources: dual_network_board.cc
切换机制¶
SwitchNetworkType() 方法将新选择写入 NVS 后,调用 Application::Reboot() 触发系统重启——这是一种简单可靠的状态重置策略,避免了运行时热切换带来的资源清理复杂性(如关闭当前 Modem、重置 Wi-Fi 状态机、重新建立 MQTT/WebSocket 连接等)。切换前会在显示屏上短暂提示 SWITCH_TO_4G_NETWORK 或 SWITCH_TO_WIFI_NETWORK。
Sources: dual_network_board.cc
接口委托¶
所有 Board 虚函数——GetBoardType()、StartNetwork()、SetNetworkEventCallback()、GetNetwork()、GetNetworkStateIcon()、SetPowerSaveLevel()、GetBoardJson()、GetDeviceStatusJson()——均通过 current_board_-> 前缀委托给当前活跃的子板卡。这意味着上层代码(如 Application)无需感知 DualNetworkBoard 的存在,完全透明。
Sources: dual_network_board.cc
实际板卡示例¶
以 bread-compact-ml307 板卡为例,其板卡类 CompactMl307Board 继承自 DualNetworkBoard 而非 Ml307Board,使得这块搭载了 ML307 模组的板卡同时也保留了切换到 Wi-Fi 的能力。在按钮逻辑中,双击 BOOT 按钮(当设备处于 kDeviceStateStarting 或 kDeviceStateWifiConfiguring 状态时)会触发 SwitchNetworkType(),为用户提供了便捷的网络切换入口。
Sources: compact_ml307_board.cc
网络事件与主循环的集成¶
从 Board 层到 Application 主循环,网络事件的传播路径如下:
sequenceDiagram
participant Board as Board 层 (WifiBoard/ML307/...)
participant App as Application::Initialize
participant EG as EventGroup
participant Loop as Application::Run
Board->>Board: StartNetwork() 启动异步连接
Board-->>App: NetworkEvent::Connected (回调)
App->>EG: xEventGroupSetBits(MAIN_EVENT_NETWORK_CONNECTED)
Loop->>Loop: xEventGroupWaitBits() 唤醒
Loop->>Loop: HandleNetworkConnectedEvent()
Loop->>Loop: SetDeviceState(kDeviceStateActivating)
Loop->>Loop: xTaskCreate(ActivationTask)
Note over Loop: 版本检查 → 协议初始化 → 空闲就绪
当网络断开时,HandleNetworkDisconnectedEvent() 检查当前状态:若正处于 Connecting、Listening 或 Speaking 状态,立即关闭音频通道。随后更新状态栏显示网络中断图标。
Sources: application.cc
自定义板卡接入网络的方式¶
每种板卡的 config.h 除了定义音频、显示、按键等 GPIO 引脚外,还通过选择继承的 Board 基类确定其网络接入方式。下表展示了不同板卡的网络类型配置模式:
| 板卡示例 | 继承基类 | config.h 关键宏 | 网络方式 |
|---|---|---|---|
bread-compact-wifi |
WifiBoard |
无特殊网络宏 | Wi-Fi (SoftAP / BluFi / 声波配网) |
bread-compact-ml307 |
DualNetworkBoard |
ML307_TX_PIN, ML307_RX_PIN |
4G + Wi-Fi 双网切换 |
bread-compact-nt26 |
Nt26Board |
NT26_TX_PIN, NT26_RX_PIN, NT26_DTR_PIN, NT26_RI_PIN |
NT26 4G 模组 |
esp32s3-korvo2-v3-rndis |
RndisBoard |
无特殊网络宏 | RNDIS USB 以太网 |
板卡开发者在创建新板卡时,只需在 .cc 文件中继承相应的 Board 基类,并在 config.h 中定义必要的 GPIO 引脚(若使用蜂窝模组),网络逻辑便自动注入。详细流程参见 自定义开发板开发指南:从 config.h 到 DECLARE_BOARD。
Sources: compact_wifi_board.cc, compact_ml307_board.cc, config.h (nt26)
与通信协议层的衔接¶
网络接入层(Board)与通信协议层(Protocol)通过 Board::GetNetwork() 方法解耦。该方法返回一个 NetworkInterface* 指针,协议层通过该接口建立 WebSocket 或 MQTT 连接,无需关心底层是 Wi-Fi、4G 还是 RNDIS。
协议初始化发生在 Application::InitializeProtocol() 中,此时网络已连接就绪。该方法根据 OTA 服务器返回的配置决定使用 MqttProtocol 还是 WebsocketProtocol,并将协议事件(OnConnected、OnNetworkError、OnIncomingAudio 等)注册到 Application 的主事件循环中。协议层通过 NetworkInterface 发送和接收数据,而 Board 层通过 NetworkEvent 向上报告网络状态变化——两条正交的信息流保证了关注点分离。
Sources: application.cc, board.h
阅读建议¶
网络连接管理是理解小智系统启动流程的关键环节。在掌握本文内容后,建议按以下路径深入:
- 启动流程全貌:阅读 Application 主控与事件驱动循环 了解
MAIN_EVENT_NETWORK_CONNECTED之后的状态机转换和激活流程
- 协议层细节:继续阅读 通信协议总览:WebSocket 与 MQTT+UDP 双通道设计 理解网络连接建立后的数据传输通道
- 配网用户体验:对于 Wi-Fi 板卡,
[自定义开发板开发指南](10-zi-ding-yi-kai-fa-ban-kai-fa-zhi-nan-cong-config-h-dao-declare_board)的配网交互部分可与本文的三种配网方式对照阅读
- 功耗优化:4G 模组的
SetPowerSaveLevel()实现可直接衔接 电源管理与低功耗策略
电源管理与低功耗策略¶
本文档深入解析小智 AI 聊天机器人项目中电源管理与低功耗策略的完整架构——从硬件抽象层的电池监测、PMU 驱动,到系统级的分级节能模式、空闲睡眠定时器,再到与设备状态机和音频管线的联动机制。目标是帮助高级开发者理解各组件如何协同工作,使电池供电设备在保持唤醒词检测能力的同时最大化续航时间。
分级节能架构¶
小智项目的电源管理采用三级节能模型,通过 PowerSaveLevel 枚举在 Board 基类中定义,由 Application 主控根据设备运行状态动态切换。三级节能并非独立存在的代码路径,而是贯穿了整个系统的多个子系统(Wi-Fi、CPU 频率、睡眠定时器、外设电源)的统一调度信号。
graph TD
A["Application 主控"] --> B["Board::SetPowerSaveLevel"]
B --> C["PERFORMANCE<br/>全速模式"]
B --> D["BALANCED<br/>平衡模式"]
B --> E["LOW_POWER<br/>低功耗模式"]
C --> C1["WiFi: PERFORMANCE<br/>无省电"]
C --> C2["CPU: max freq"]
C --> C3["唤醒睡眠定时器"]
C --> C4["音频编解码器: 全开"]
E --> E1["WiFi: LOW_POWER<br/>省电模式"]
E --> E2["CPU: 降频 + light sleep"]
E --> E3["启动空闲倒计时"]
E --> E4["音频编解码器: 可关闭"]
D --> D1["WiFi: BALANCED"]
Sources: board.h application.cc
三级节能级别的触发时机¶
| 级别 | 触发场景 | CPU 策略 | WiFi 策略 | 音频策略 |
|---|---|---|---|---|
| PERFORMANCE | 音频通道打开、OTA 升级、资源下载 | 最大频率,禁止 light sleep | WiFi 高性能模式 | 编解码器全开 |
| BALANCED | 中间过渡状态(当前较少使用) | 保持较高频率 | WiFi 平衡模式 | — |
| LOW_POWER | 设备空闲、音频通道关闭、激活完成 | 降频 + 允许 light sleep | WiFi 省电模式 | 可关闭输入 |
PERFORMANCE 模式的触发集中在两个关键路径:音频通道打开时(OnAudioChannelOpened 回调)和 OTA/资源下载时。切换到该模式后,系统以最低延迟运行,确保语音交互的实时性。一旦音频通道关闭(OnAudioChannelClosed 回调)或升级完成,系统立即降回 LOW_POWER 模式。
Sources: application.cc application.cc application.cc
PowerSaveTimer:空闲睡眠定时器¶
PowerSaveTimer 是实现设备自动休眠的核心组件。它基于 esp_timer 每秒触发一次 tick,累计空闲秒数,分两阶段执行节能动作:浅度睡眠 和 自动关机。
sequenceDiagram
participant Timer as PowerSaveTimer<br/>(1Hz Tick)
participant App as Application
participant PM as esp_pm
participant Codec as AudioCodec
participant Display as Display
Note over Timer: seconds_to_sleep=60<br/>seconds_to_shutdown=300
loop 每秒 tick
Timer->>App: CanEnterSleepMode()?
App-->>Timer: true (仅 Idle 状态 + 无音频通道)
alt ticks < 60
Note over Timer: 继续累积
else ticks >= 60 AND not in_sleep_mode
Timer->>Timer: in_sleep_mode = true
Timer->>App: on_enter_sleep_mode_ 回调
Timer->>PM: max_freq = cpu_max_freq<br/>light_sleep_enable = true
Timer->>Codec: EnableInput(false)
Timer->>App: EnableWakeWordDetection(false)
Timer->>Display: SetPowerSaveMode(true)
else ticks >= 300
Timer->>App: on_shutdown_request_ 回调
Note over App: 切断系统电源 (GPIO)
end
end
Sources: power_save_timer.h power_save_timer.cc
进入睡眠模式的关键操作¶
当 CanEnterSleepMode() 返回 true 且空闲秒数达到阈值时,PowerSaveTimer 执行以下操作序列:
- 回调通知:调用
on_enter_sleep_mode_回调,供 Board 层执行自定义操作(如背光降至最低)。
- 禁用唤醒词检测:保存当前唤醒词运行状态后关闭,释放 CPU 资源。
- 禁用音频输入:通过
AudioCodec::EnableInput(false)关闭 ADC 采集。
- 配置 ESP 电源管理:调用
esp_pm_configure将 CPU 最大频率限制为指定值,并启用light_sleep_enable,允许 RTOS 在空闲时自动进入 light sleep。
值得注意的是,PowerSaveTimer 使用 esp_pm_configure 配置 CPU 频率上限而非直接进入 sleep——真正的 light sleep 由 FreeRTOS 的 idle task 在 CPU 空闲时自动触发,这是一种非侵入式的降功耗策略。
Sources: power_save_timer.cc
唤醒流程¶
当用户按键、触摸或音频通道重新打开时,WakeUp() 方法被调用:
void PowerSaveTimer::WakeUp() {
ticks_ = 0; // 重置空闲计数器
if (in_sleep_mode_) {
// 恢复 CPU 频率
esp_pm_config_t pm_config = {
.max_freq_mhz = cpu_max_freq_,
.min_freq_mhz = cpu_max_freq_, // 锁频到最大值
.light_sleep_enable = false,
};
esp_pm_configure(&pm_config);
// 恢复唤醒词检测
audio_service.EnableWakeWordDetection(true);
}
}
恢复时 min_freq_mhz 被设为与 max_freq_mhz 相同值,确保 CPU 不会降频,保证语音交互的实时响应。
Sources: power_save_timer.cc
与 Settings 的联动¶
PowerSaveTimer::SetEnabled 在启用前会检查 NVS 中的 sleep_mode 键值(命名空间 "wifi")。如果用户通过 MCP 协议或配置文件将 sleep_mode 设为 false,电源节省定时器会被永久禁用,设备始终保持全速运行。
Sources: power_save_timer.cc
SleepTimer:ESP Light Sleep / Deep Sleep¶
SleepTimer 是比 PowerSaveTimer 更激进的休眠策略实现。它直接调用 esp_light_sleep_start() 使芯片进入 light sleep 状态(CPU 暂停、Wi-Fi 关闭、RAM 保持),并支持配置 deep sleep 超时实现完全断电。
graph LR
A["设备空闲"] --> B["SleepTimer<br/>每秒 tick"]
B --> C{"ticks >=<br/>light_sleep<br/>阈值?"}
C -->|是| D["进入 light sleep"]
D --> E["esp_light_sleep_start()"]
E --> F{"唤醒原因?"}
F -->|"定时器(30s)"| E
F -->|"GPIO/外部中断"| G["退出 sleep 循环"]
G --> H["WakeUp()<br/>恢复系统"]
C -->|否| I{"ticks >=<br/>deep_sleep<br/>阈值?"}
I -->|是| J["esp_deep_sleep_start()"]
Sources: sleep_timer.h sleep_timer.cc
Light Sleep 循环机制¶
SleepTimer 的设计有别于 PowerSaveTimer——它不是依赖 ESP-IDF 的自动 light sleep,而是在 Application::Schedule 回调中主动调用 esp_light_sleep_start()。Light sleep 期间 CPU 完全停止,仅由定时器或 GPIO 唤醒。每次定时器唤醒(30 秒后),系统检查是否需要继续 sleep,形成循环直到被外部事件打断。在进入 light sleep 前,LVGL 显示端口被暂停(lvgl_port_stop),唤醒后恢复(lvgl_port_resume)。
Sources: sleep_timer.cc
两种定时器的选择策略¶
不同开发板根据硬件特性选择使用 PowerSaveTimer 或 SleepTimer:
| 特性 | PowerSaveTimer | SleepTimer |
|---|---|---|
| CPU 状态 | 持续运行,仅降频 | 暂停(light sleep)/ 断电(deep sleep) |
| 唤醒延迟 | 无 | 数百微秒(light sleep) |
| RAM 保持 | 始终保持 | Light sleep 保持,deep sleep 丢失 |
| 适用场景 | 需要快速响应唤醒词 | 长时间无人交互时深度节能 |
| 构造函数参数 | cpu_max_freq, seconds_to_sleep, seconds_to_shutdown |
seconds_to_light_sleep, seconds_to_deep_sleep |
Sources: power_save_timer.h sleep_timer.h
电池监测与充电管理¶
小智项目支持四种电池监测方案,分布于 boards/common 公共组件和各板级 power_manager.h 中。所有方案均遵循统一的接口模式(GetBatteryLevel / IsCharging / IsDischarging),通过 Board::GetBatteryLevel 虚函数暴露给上层。
电池监测方案对比¶
| 方案 | 实现类 | 硬件依赖 | 适用板卡 | 电量检测方式 |
|---|---|---|---|---|
| ESP-IDF 库方案 | AdcBatteryMonitor |
ADC + 分压电阻 + 充电 GPIO | 通用面包板 | adc_battery_estimation 库 |
| I²C PMU 方案 | Axp2101 |
AXP2101 电源管理芯片 | ESP-BOX 系列 | 读取 PMU 寄存器 |
| I²C 充电芯片方案 | Sy6970 |
SY6970 充电管理芯片 | 部分定制板 | 读取电压寄存器 + 线性插值 |
| 板级 ADC 方案 | 各板 PowerManager |
ADC + GPIO 充电检测 | 30+ 开发板 | ADC 采样 + 分压计算 + 线性插值 |
Sources: adc_battery_monitor.h axp2101.h sy6970.h
板级 PowerManager 的通用模式¶
大多数板卡在 power_manager.h 中实现了结构相似的 PowerManager 类,其核心设计模式为:
- 周期性定时器:每秒触发一次
CheckBatteryStatus()
- 充电状态检测:通过 GPIO 读取充电指示引脚电平
- ADC 滑动窗口:维护最近 N 次(通常 3~5 次)ADC 采样值,取平均以滤除噪声
- 分段线性插值:将 ADC 平均值映射到 0-100% 电量,使用硬件特定的电压-电量对照表
- 低电量阈值:默认 20% 触发低电量回调
- 满电保护:电量达 100% 时强制返回非充电状态
各板的差异主要在于 ADC 通道选择、分压电阻比例和电压-电量映射表。例如,esp32s3-korvo2-v3 使用 ADC 校准(adc_cali)提高精度,atk-dnesp32s3-box2-wifi 通过 IO 扩展器(XL9555)控制充电检测。
Sources: power_manager.h (esp32-cgc-144) power_manager.h (atk-dnesp32s3-box0) power_manager.h (esp32s3-korvo2-v3)
AXP2101 PMU 驱动¶
Axp2101 是针对 X-Powers AXP2101 电源管理芯片的 I²C 驱动,继承自 I2cDevice。它提供充电状态、电量百分比、芯片温度和电源关断功能。电量直接通过寄存器 0xA4 读取(芯片内部已计算),无需软件插值。
bool Axp2101::IsCharging() {
return GetBatteryCurrentDirection() == 1; // 寄存器 0x01 bits[6:5]
}
void Axp2101::PowerOff() {
uint8_t value = ReadReg(0x10);
WriteReg(0x10, value | 0x01); // 设置关机位
}
Sources: axp2101.cc
SY6970 充电芯片驱动¶
Sy6970 针对 Silergy SY6970 充电管理芯片,通过 I²C 读取充电状态和电池电压。其电量估算采用电压线性映射法:将电池电压从最低工作电压(3200mV)到充电目标电压线性映射到 0-100%。
int Sy6970::GetBatteryLevel() {
int battery_voltage = GetBatteryVoltage(); // 寄存器 0x0E
int charge_voltage_limit = GetChargeTargetVoltage(); // 寄存器 0x06
level = (battery_voltage - 3200) / (charge_voltage_limit - 3200) * 100;
}
Sources: sy6970.cc
背光功耗管理¶
Backlight 抽象类通过 PWM 控制显示屏背光亮度,同时承担两项功耗相关功能:
- 亮度持久化:
RestoreBrightness()从 NVS 的"display"命名空间读取上次保存的亮度值(默认 75%),实现掉电记忆。
- 平滑渐变:
SetBrightness()使用 5ms 周期的定时器逐步调整 PWM 占空比,避免亮度突变造成的视觉冲击和瞬时电流尖峰。当permanent=true时,新亮度值会写入 NVS 持久化。
PwmBacklight 使用 LEDC 外设的 10-bit 分辨率(0-1023),PWM 频率默认 25kHz——高于人耳可听范围,避免电感啸叫。
Sources: backlight.h backlight.cc
显示屏节能模式¶
LvglDisplay::SetPowerSaveMode 在进入/退出节能模式时调整显示内容:
- 进入节能:清空聊天消息,表情切换为
"sleepy"
- 退出节能:清空聊天消息,表情切换为
"neutral"
此外,LvglDisplay 在构造函数中创建了 esp_pm_lock(ESP_PM_APB_FREQ_MAX),在 UpdateStatusBar 期间短暂持有该锁,防止更新状态栏过程中 CPU 降频导致 LVGL 渲染延迟。
Sources: lvgl_display.cc lvgl_display.cc
低电量告警¶
低电量检测在 LvglDisplay::UpdateStatusBar() 中每时钟 tick 执行一次。当电池图标为 FONT_AWESOME_BATTERY_EMPTY(电量 0-19%)且正在放电时:
- 显示低电量弹窗(LVGL
low_battery_popup_对象)
- 通过
Application::Schedule播放OGG_LOW_BATTERY音频提示
弹窗在电量恢复至 20% 以上或插入充电器时自动隐藏。
Sources: lvgl_display.cc
极限低电量自动断电¶
部分板卡在 PowerManager 或板级初始化中实现了硬件级别的低电量保护。例如 atk-dnesp32s3-box0 在其 wake_update_timer 回调中周期性检查 power_manager_->low_voltage_:当 ADC 原始值低于安全阈值(如 2877)且未连接外部电源时,系统依次执行:
- 停止电量监测定时器
- 关闭充电控制引脚(
CHG_CTRL_PIN)
- 拉低系统电源使能引脚(
SYS_POW_PIN),切断整板电源
这是一种不可逆的硬件断电,防止锂电池过放损坏。
Sources: atk_dnesp32s3_box0.cc
WiFi 省电映射¶
WifiBoard::SetPowerSaveLevel 将系统级 PowerSaveLevel 映射为 ESP-IDF WiFi 驱动的省电级别:
| 系统级别 | WiFi 级别 | ESP-IDF 对应 |
|---|---|---|
PERFORMANCE |
WifiPowerSaveLevel::PERFORMANCE |
禁用 WiFi 省电(最小延迟) |
BALANCED |
WifiPowerSaveLevel::BALANCED |
中等省电 |
LOW_POWER |
WifiPowerSaveLevel::LOW_POWER |
最大省电(DTIM 间隔长) |
此映射通过 WifiManager::SetPowerSaveLevel 最终调用 ESP-IDF 的 esp_wifi_set_ps 实现。
Sources: wifi_board.cc
进入睡眠模式的条件判断¶
Application::CanEnterSleepMode() 是睡眠定时器的准入控制函数,只有同时满足以下三个条件才允许进入睡眠:
- 设备状态为 Idle:非激活、非连接、非监听、非说话状态
- 无打开的音频通道:
protocol_不存在或IsAudioChannelOpened()为 false
- 音频服务空闲:
audio_service_.IsIdle()为 true(无播放中的声音、无待发送的音频数据)
此设计确保任何正在进行的语音交互都不会被睡眠流程中断。
Sources: application.cc
SystemReset:工厂复位与 NVS 清除¶
SystemReset 类管理两个 GPIO 按钮:复位 NVS 闪存按钮和恢复出厂设置按钮。在构造函数中配置为带上拉的输入模式,CheckButtons() 方法在启动时被调用:
- NVS 复位(短按对应引脚):擦除 NVS 分区 → 重新初始化 → 清除所有持久化配置
- 出厂复位(长按对应引脚):先执行 NVS 复位 → 再擦除
otadata分区 → 3 秒后重启
出厂复位的设计利用了 ESP-IDF 的 OTA 回滚机制:擦除 otadata 后,bootloader 将回退到 factory 分区启动。
Sources: system_reset.h system_reset.cc
板卡级电源控制特殊功能¶
部分板卡实现了超过通用框架的电源管理能力:
yunliao-s3 具备完整的电源路径控制:
Start5V()/Shutdown5V():控制 5V 升压电路使能
Start4G()/Shutdown4G():独立控制 4G 模组电源
Enable4G()/Disable4G():4G 模组使能引脚
Sleep():GPIO 配置 RTC 唤醒源后进入 deep sleep,休眠前在 NVS 中设置sleep_flag,启动时CheckStartup()检测该标志决定是否自动重入睡眠
- 电池电量通过脉冲计数方式实现:
MON_BATT_PIN的 GPIO 中断累计脉冲,周期性换算为电量百分比
jiuchuan-s3 集成了 PowerController 状态机和深度休眠关机流程:
- 当电源状态变为
SHUTDOWN时,配置 RTC GPIO 唤醒源(PWR_BUTTON_GPIO),拉低PWR_EN_GPIO切断系统电源,最后调用esp_deep_sleep_start()
Sources: power_manager.h (yunliao-s3) power_manager.cc (yunliao-s3) power_manager.h (jiuchuan-s3)
关键设计模式总结¶
| 设计模式 | 实现位置 | 目的 |
|---|---|---|
| 策略模式 | PowerSaveLevel → WiFi/CPU/Sleep 分发 |
统一的三级节能调度接口 |
| 观察者模式 | OnChargingStatusChanged / OnLowBatteryStatusChanged |
电池状态变更通知 |
| 模板方法模式 | Board::GetBatteryLevel 虚函数 |
各板自定义电池监测实现 |
| 回调注入 | OnEnterSleepMode / OnExitSleepMode / OnShutdownRequest |
Board 层自定义睡眠行为 |
| 守卫条件 | CanEnterSleepMode() |
确保安全状态下才进入睡眠 |
相关页面¶
- 系统架构全景:从麦克风到云端大模型的完整数据流 — 理解电源管理与整体数据流的关系
- Application 主控与事件驱动循环 —
SetPowerSaveLevel和CanEnterSleepMode的调用上下文
- 设备状态机:状态定义与合法转换规则 — 睡眠准入的
kDeviceStateIdle状态
- Board 抽象层设计:统一管理 70+ 开发板的秘诀 — 各板
power_manager.h的实现模板
- 显示系统架构:OLED / LCD / LVGL 三层次抽象 — 低电量弹窗和
SetPowerSaveMode的实现细节
- Settings 持久化存储:基于 NVS 的键值读写 —
sleep_mode和brightness的持久化
- 网络连接管理:Wi-Fi / ML307 4G / RNDIS 多模接入 — WiFi 省电级别的底层实现
- sdkconfig 配置详解:芯片平台与功能开关 — Kconfig 中电源相关编译选项
构建与部署¶
CMake 构建系统与 ESP-IDF 组件依赖管理¶
本文档深入解析小智 AI 聊天机器人(xiaozhi-esp32)的 CMake 构建体系,涵盖从顶层 CMakeLists.txt 到 ESP-IDF 组件管理器、Kconfig 配置系统、分区表设计、资产构建管线以及 CI/CD 自动化流水线的完整技术细节。阅读本文后,你将理解项目如何实现 70+ 种开发板的统一构建、多语言资源自动生成、以及芯片级条件编译的精妙设计。
构建系统全景架构¶
小智项目的构建系统是一个典型的分层 ESP-IDF 工程架构。其设计哲学可以用三个关键词概括:声明式配置(Kconfig 驱动编译选项)、动态组件发现(CMake 运行时探测依赖路径)、条件编译隔离(芯片目标 + 板卡类型双维度控制源文件)。
下面是构建系统的整体架构关系图:
flowchart TB
subgraph 入口层
A["CMakeLists.txt<br/>(项目根)"]
end
subgraph 配置层
B["sdkconfig.defaults<br/>(基础配置)"]
C["sdkconfig.defaults.esp32<br/>/esp32s3/esp32c3/...<br/>(芯片级覆盖)"]
D["Kconfig.projbuild<br/>(GUI 交互配置)"]
end
subgraph 组件依赖层
E["idf_component.yml<br/>(60+ 外部组件)"]
F["ESP-IDF Component Registry<br/>(远程仓库)"]
end
subgraph 构建核心层
G["main/CMakeLists.txt<br/>(1231 行核心逻辑)"]
H["find_component_by_pattern<br/>(动态组件发现)"]
I["idf_component_register<br/>(源文件/头文件/依赖注册)"]
end
subgraph 代码生成层
J["gen_lang.py<br/>(语言头文件生成)"]
K["build_default_assets.py<br/>(资产打包)"]
end
subgraph 分区与烧录层
L["partitions/v2/*.csv<br/>(分区表定义)"]
M["esptool_py_flash_to_partition<br/>(资产烧录)"]
end
subgraph CI/CD 层
N[".github/workflows/build.yml<br/>(GitHub Actions)"]
O["scripts/release.py<br/>(批量构建与打包)"]
end
A --> G
B --> D
C --> D
D --> G
E --> F
E --> G
G --> H
G --> I
G --> J
G --> K
G --> L
L --> M
N --> O
O --> G
Sources: CMakeLists.txt, main/CMakeLists.txt, main/idf_component.yml, main/Kconfig.projbuild
顶层 CMakeLists.txt:构建入口的精简设计¶
项目根目录的 CMakeLists.txt 仅有 14 行,却蕴含了两个关键决策。第一,通过 add_compile_options(-Wno-missing-field-initializers) 抑制指定初始化器的警告——这在 C++ 与 C 混合编译、且大量使用结构体部分初始化的嵌入式场景中至关重要。第二,idf_build_set_property(MINIMAL_BUILD ON) 启用了 ESP-IDF 的最小化构建模式:CMake 将仅编译 main 组件及其传递依赖链上的组件,而非 IDF 框架中所有已安装组件。对于一个需要控制固件体积的嵌入式项目,这意味着显著的构建加速和二进制瘦身。
项目版本号 PROJECT_VER "2.2.6" 直接硬编码在 CMakeLists.txt 中,同时被 scripts/release.py 和 scripts/versions.py 读取用于固件发布和版本比对。
Sources: CMakeLists.txt
main/CMakeLists.txt:构建核心的六阶段流水线¶
main/CMakeLists.txt 是全项目最长的构建文件(1231 行),其逻辑可以解构为六个顺序执行的阶段:
阶段一:源文件注册与头文件声明¶
项目采用显式枚举而非 GLOB_RECURSE 递归扫描来管理源文件,这是一种嵌入式领域的常见最佳实践——避免因未跟踪的文件变更导致增量构建失效。所有 .cc 文件按模块分组列入 SOURCES 变量:
| 模块路径 | 功能域 | 代表性文件 |
|---|---|---|
audio/audio_codec.cc |
音频编解码 | ES8311 / ES8388 / ES8374 适配 |
audio/audio_service.cc |
音频管线核心 | 三任务模型调度 |
audio/demuxer/ |
流解复用 | OGG 解复用器 |
display/ |
显示系统 | OLED / LCD / LVGL 三层次 |
led/ |
LED 指示 | 单灯 / 灯环 / GPIO |
protocols/ |
通信协议 | WebSocket / MQTT |
application.cc |
主控逻辑 | 事件循环与调度 |
mcp_server.cc |
MCP 协议 | JSON-RPC 2.0 设备端 |
device_state_machine.cc |
状态机 | 设备状态转换 |
Sources: main/CMakeLists.txt
阶段二:公共 Board 基础设施注入¶
所有开发板共享一套 boards/common/ 下的公共基础设施,这些文件通过 list(APPEND ...) 无条件加入编译。核心组件包括:
| 公共组件 | 职责 |
|---|---|
board.cc |
Board 基类实现 |
wifi_board.cc |
Wi-Fi 网络抽象 |
ml307_board.cc |
ML307 4G 模组支持 |
dual_network_board.cc |
Wi-Fi + 4G 双网切换 |
adc_battery_monitor.cc |
ADC 电池电量检测 |
button.cc |
按键抽象 |
backlight.cc |
背光控制 |
press_to_talk_mcp_tool.cc |
按键对讲 MCP 工具 |
system_reset.cc |
系统复位 |
Sources: main/CMakeLists.txt
阶段三:动态组件发现¶
find_component_by_pattern() 是一个关键的辅助函数,它利用 ESP-IDF 的 idf_build_get_property(BUILD_COMPONENTS) 在运行时获取实际参与编译的组件列表,然后通过正则匹配找到关键组件的安装路径。这解决了两个问题:
- ESP-SR 语音识别模型的路径不可预知:ESP-SR 作为外部组件被 IDF Component Manager 下载到临时目录,其路径因版本和环境而异
- 字体组件版本灵活:
xiaozhi-fonts组件同样通过 Component Manager 管理,路径动态变化
function(find_component_by_pattern PATTERN COMPONENT_VAR PATH_VAR)
foreach(COMPONENT ${build_components})
if(COMPONENT MATCHES "${PATTERN}")
set(${COMPONENT_VAR} ${COMPONENT} PARENT_SCOPE)
idf_component_get_property(COMPONENT_PATH ${COMPONENT} COMPONENT_DIR)
set(${PATH_VAR} "${COMPONENT_PATH}" PARENT_SCOPE)
break()
endif()
endforeach()
endfunction()
Sources: main/CMakeLists.txt
阶段四:板卡选择与字体/表情配置¶
这是整个构建系统中最长的代码段——一个横跨数百行的 if-elseif 链,将 Kconfig 中选中的 CONFIG_BOARD_TYPE_* 映射为三个维度的构建变量:
- BOARD_TYPE:板卡目录名,用于后续
file(GLOB ...)收集板卡源文件
- 字体配置:
BUILTIN_TEXT_FONT(正文字体)和BUILTIN_ICON_FONT(图标字体),决定内置到 assets 分区的字体资源
- 表情配置:
DEFAULT_EMOJI_COLLECTION(默认表情集)和EMOTE_RESOLUTION(表情动画分辨率)
部分板卡还设置了特殊的 MANUFACTURER 变量,用于区分 boards/<manufacturer>/<board_type>/ 的组织结构。例如所有 Waveshare 开发板位于 boards/waveshare/ 子目录下,立创 EDA 课程示例位于 boards/lceda-course-examples/ 下。
Sources: main/CMakeLists.txt
阶段五:条件编译与芯片隔离¶
Kconfig 选择完成后,构建系统通过芯片目标(CONFIG_IDF_TARGET_*)和功能开关(CONFIG_USE_*)进行进一步的条件过滤:
芯片级排除(ESP32 原始芯片资源受限):
if(CONFIG_IDF_TARGET_ESP32)
list(REMOVE_ITEM SOURCES
"audio/codecs/box_audio_codec.cc"
"audio/codecs/es8388_audio_codec.cc"
"display/lvgl_display/jpg/image_to_jpeg.cpp"
"boards/common/ml307_board.cc"
"boards/common/dual_network_board.cc"
...
)
endif()
芯片级追加(S3/P4 支持视频与 RNDIS):
if(CONFIG_IDF_TARGET_ESP32S3 OR CONFIG_IDF_TARGET_ESP32P4)
list(APPEND SOURCES "boards/common/esp_video.cc"
"boards/common/rndis_board.cc")
endif()
功能开关:
CONFIG_USE_AUDIO_PROCESSOR决定使用afe_audio_processor.cc还是no_audio_processor.cc
CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING控制是否加入蓝牙配网支持
- 唤醒词选择:ESP32S3/P4 使用
afe_wake_word.cc+custom_wake_word.cc,其他芯片降级为esp_wake_word.cc
Sources: main/CMakeLists.txt
阶段六:组件注册与资产绑定¶
最终通过 idf_component_register() 将所有变量封装为一个可链接的 ESP-IDF 组件:
idf_component_register(SRCS ${SOURCES}
EMBED_FILES ${LANG_SOUNDS} ${COMMON_SOUNDS}
INCLUDE_DIRS ${INCLUDE_DIRS}
WHOLE_ARCHIVE
PRIV_REQUIRES
esp_pm esp_psram esp_netif
esp_driver_gpio esp_driver_uart esp_driver_spi
esp_driver_i2c esp_driver_i2s esp_driver_jpeg
esp_app_format app_update spi_flash console
efuse bt fatfs ${MAIN_PRIV_REQUIRES_EXTRA}
)
关键参数解读:
| 参数 | 含义 | 影响 |
|---|---|---|
SRCS |
源文件列表 | 经过前面五个阶段筛洗后的最终文件集 |
EMBED_FILES |
嵌入二进制文件 | 语言音频 .ogg 和公共音效直接编译进固件 |
WHOLE_ARCHIVE |
全量链接 | 确保所有 .o 文件中的符号都被保留(防止弱符号被优化) |
PRIV_REQUIRES |
私有依赖 | 这些 ESP-IDF 组件仅在 main 内部可见,不会泄露到上层 |
Sources: main/CMakeLists.txt
ESP-IDF 组件依赖管理:idf_component.yml 深度解析¶
小智项目通过 ESP-IDF Component Manager(一个基于 YAML 清单的包管理器)管理 60+ 个外部依赖。main/idf_component.yml 是这份依赖声明的载体,其版本约束策略展现了精细的工程判断:
版本约束策略对比¶
| 约束类型 | 语法示例 | 使用场景 | 代表组件 |
|---|---|---|---|
| 精确锁定 | ==1.2.0 |
存在已知兼容性断点的组件 | esp_lcd_ili9341, esp_io_expander_tca9554 |
| 兼容范围 | ~1.5.6(等效 >=1.5.6, <1.6.0) |
信任补丁向后兼容 | esp_codec_dev, esp-sr |
| 宽松升级 | ^2.0.3(等效 >=2.0.3, <3.0.0) |
信任小版本兼容 | esp_lcd_co5300, led_strip |
| 通配接受 | * |
调试阶段或完全信任上游 | esp_lcd_jd9365(仅 ESP32P4) |
芯片目标条件依赖¶
Component Manager 最强大的特性是 rules 条件语法,允许同一依赖声明针对不同芯片目标呈现不同行为:
espressif/esp32-camera:
version: ^2.1.6
rules:
- if: target in [esp32s3] # 仅 S3 有摄像头接口
espressif/esp_hosted:
version: 2.0.17
rules:
- if: target in [esp32h2, esp32p4] # 仅 H2/P4 需要 hosted 模式
espfriends/servo_dog_ctrl:
version: ^0.1.8
rules:
- if: target in [esp32c3] # 特定机器人仅在 C3 上运行
这种设计使得同一份 idf_component.yml 可以服务于所有 7 种芯片目标(ESP32 / S3 / C3 / C5 / C6 / H2 / P4),避免了多份清单的维护负担。
组件生态分类¶
| 功能域 | 代表组件 | 说明 |
|---|---|---|
| LCD 驱动 | esp_lcd_ili9341, esp_lcd_gc9a01, esp_lcd_st77916 等 12+ |
覆盖市面主流 SPI/RGB/MIPI 屏幕 |
| 触摸驱动 | esp_lcd_touch_ft5x06, esp_lcd_touch_gt911, esp_lcd_touch_cst816s 等 |
电容触摸控制器 |
| 音频编解码 | esp_audio_codec, esp_codec_dev |
ES8311/ES8388/ES8374 等芯片支持 |
| 语音算法 | esp-sr(语音识别), esp_audio_effects(音效处理) |
唤醒词 + 回声消除 + 噪声抑制 |
| GUI 框架 | lvgl/lvgl, esp_lvgl_port |
LVGL 9.5.0 图形库 |
| 网络连接 | esp-ml307(4G 模组), esp-wifi-connect |
多模网络接入 |
| 字体资源 | xiaozhi-fonts |
预编译的多尺寸中英文位图字体 |
| 图像处理 | esp_new_jpeg, image_player, esp_emote_expression |
JPEG 解码 / GIF 播放 / 表情动画 |
| 电源管理 | adc_battery_estimation |
电池电量估算 |
Sources: main/idf_component.yml
Kconfig 配置系统:从图形界面到编译宏的双向通道¶
main/Kconfig.projbuild 是项目向 idf.py menuconfig 暴露的可视化配置菜单。它定义了三个核心配置维度,并通过 Kconfig 的 depends on 机制实现了芯片目标的约束校验:
配置维度速览¶
graph LR
subgraph "Kconfig.projbuild"
A["BOARD_TYPE<br/>(70+ 板卡选项)"]
B["Flash Assets<br/>(资产烧录策略)"]
C["Default Language<br/>(30+ 语言)"]
end
A -->|"depends on IDF_TARGET"| D["芯片目标约束"]
B -->|"depends on USE_EMOTE_MESSAGE_STYLE"| E["表情模式约束"]
C -->|"无约束"| F["全平台通用"]
A --> G["CONFIG_BOARD_TYPE_*<br/>→ CMake if-elseif 链"]
B --> H["CONFIG_FLASH_*_ASSETS<br/>→ 资产构建决策"]
C --> I["CONFIG_LANGUAGE_*<br/>→ gen_lang.py 参数"]
Board Type 的芯片约束设计¶
每个板卡选项都通过 depends on IDF_TARGET_* 与其物理硬件绑定。例如:
BOARD_TYPE_ESP_HI仅当IDF_TARGET_ESP32C3时可选
BOARD_TYPE_ESP_P4_FUNCTION_EV_BOARD仅当IDF_TARGET_ESP32P4时可选
BOARD_TYPE_BREAD_COMPACT_WIFI仅当IDF_TARGET_ESP32S3时可选
这种约束在 menuconfig 界面中自动隐藏不兼容的板卡选项,从根本上杜绝了"选了 A 芯片却编译 B 芯片专用板卡"的错误。
资产烧录策略¶
Kconfig 提供了四种资产处理策略,它们通过分区表检测和构建脚本的配合实现:
| 策略 | Kconfig 符号 | 行为 |
|---|---|---|
| 不烧录资产 | FLASH_NONE_ASSETS |
跳过资产分区操作 |
| 烧录默认资产 | FLASH_DEFAULT_ASSETS |
调用 build_default_assets.py 根据板卡配置自动打包 |
| 烧录自定义资产 | FLASH_CUSTOM_ASSETS |
使用用户指定的本地文件或远程 URL |
| 烧录表情资产 | FLASH_EXPRESSION_ASSETS |
为表情模式构建专用资产包(需要 USE_EMOTE_MESSAGE_STYLE) |
Sources: main/Kconfig.projbuild
SDK 配置分层:芯片级覆盖与平台差异化¶
项目通过 sdkconfig.defaults* 文件族实现了三层配置覆盖:
flowchart TD
BASE["sdkconfig.defaults<br/>(所有芯片共享基础配置)"] --> MERGE["ESP-IDF 构建系统<br/>自动合并"]
CHIP["sdkconfig.defaults.esp32[s3|c3|c5|c6|p4]<br/>(芯片特定覆盖)"] --> MERGE
MENUCONFIG["idf.py menuconfig<br/>(用户手动调整)"] --> MERGE
MERGE --> FINAL["sdkconfig<br/>(最终生效配置)"]
各芯片差异化配置对比¶
| 配置项 | ESP32 | ESP32-S3 | ESP32-C3 | ESP32-P4 |
|---|---|---|---|---|
| Flash 默认大小 | 4MB | 16MB | 16MB | 16MB |
| 分区表 | partitions/v2/4m.csv |
partitions/v2/16m.csv |
partitions/v2/16m_c3.csv |
partitions/v2/16m.csv |
| PSRAM | 不支持 | OCT 80MHz | 不支持 | 200MHz XIP |
| 唤醒词模型 | WN9_NIHAOXIAOZHI_TTS | WN9_NIHAOXIAOZHI_TTS | WN9S_NIHAOXIAOZHI(轻量) | WN9_NIHAOXIAOZHI_TTS |
| CPU 频率 | 默认 | 240MHz | 默认 | 性能优化 |
| LVGL 快照 | 未启用 | 启用 | 未启用 | 启用 |
这种分层设计确保了新增芯片支持时只需创建一个轻量级的 sdkconfig.defaults.esp32xx 文件,而非维护多套完整配置。
Sources: sdkconfig.defaults, sdkconfig.defaults.esp32, sdkconfig.defaults.esp32s3, sdkconfig.defaults.esp32c3, sdkconfig.defaults.esp32p4
代码生成管线:语言头文件与资产打包¶
构建系统包含两条并行运行的代码生成管线,它们通过 CMake 的 add_custom_command 集成到构建图中:
管线一:语言头文件生成¶
gen_lang.py 脚本将 assets/locales/<LANG>/language.json 和对应 .ogg 音频文件编译为一个 C++ 头文件 assets/lang_config.h:
add_custom_command(
OUTPUT ${LANG_HEADER}
COMMAND python ${PROJECT_DIR}/scripts/gen_lang.py
--language "${LANG_DIR}"
--output "${LANG_HEADER}"
DEPENDS
${LANG_JSON}
${PROJECT_DIR}/scripts/gen_lang.py
COMMENT "Generating ${LANG_DIR} language config"
)
该脚本的核心智能在于 en-US 基准回退机制:对于非英文语言,缺失的字符串键值会自动从 en-US 基准中填补,缺失的音频文件也会使用 en-US 版本。这意味着翻译贡献者无需一次性补全所有条目的翻译——只要有 en-US 基准,任何不完整的翻译都能正常工作。
Sources: main/CMakeLists.txt, scripts/gen_lang.py
管线二:默认资产打包¶
build_default_assets.py 根据板卡配置将分散的资源合并为单一的 assets.bin:
flowchart LR
FONTS["字体组件<br/>xiaozhi-fonts"] -->|"BUILTIN_TEXT_FONT<br/>BUILTIN_ICON_FONT"| BUILD["build_default_assets.py"]
EMOJI["表情组件<br/>emoji-collections"] -->|"DEFAULT_EMOJI_COLLECTION"| BUILD
SR["语音模型<br/>ESP-SR models"] -->|"ESP_SR_MODEL_PATH"| BUILD
EXTRA["额外资源<br/>ESP-HI 表情等"] -->|"DEFAULT_ASSETS_EXTRA_FILES"| BUILD
BUILD --> OUTPUT["generated_assets.bin"]
OUTPUT --> FLASH["esptool_py_flash_to_partition<br/>→ assets 分区"]
这一管线仅在检测到分区表包含 assets 分区时激活(即 v2 分区表)。对于 v1 分区表,资产嵌入在应用固件中,无需此步骤。
Sources: main/CMakeLists.txt, scripts/build_default_assets.py
分区表演进:v1 到 v2 的架构跃迁¶
分区表是 ESP32 固件存储的"磁盘布局图"。小智项目的分区表设计经历了从 v1 到 v2 的重要跃迁:
架构对比¶
| 维度 | v1(Legacy) | v2(Current) |
|---|---|---|
| 资产存储 | model 分区(960KB,固定内容) |
assets 分区(SPIFFS,2MB-16MB 可配置) |
| 应用分区 | ota_0/ota_1 各 6MB | ota_0/ota_1 各 4MB |
| 资产更新 | 必须随固件 OTA | 可通过 HTTP 独立下载更新 |
| 内容灵活性 | 仅存储语音模型 | 支持唤醒词模型 + 主题 + 字体 + 音效 + 表情包 |
| 内存映射 | 固定偏移 | SPIFFS 动态分配,支持 mmap 高效访问 |
v2 的核心创新在于将资产从编译时绑定的 model 分区迁移到独立、可运行时更新的 assets 分区,使设备在出厂后仍能通过网络获取新的语音模型、主题皮肤和表情包,而无需经历完整的固件 OTA 流程。
多尺寸适配¶
| 分区表文件 | Flash 大小 | Assets 分区大小 | 适用场景 |
|---|---|---|---|
v2/4m.csv |
4MB | 约 1MB | ESP32 原始芯片 |
v2/8m.csv |
8MB | 2MB | 中等资源设备 |
v2/16m.csv |
16MB | 8MB | 默认,S3/C5/C6/P4 |
v2/16m_c3.csv |
16MB | 4MB | C3(mmap 页数受限) |
v2/32m.csv |
32MB | 16MB | 大容量 NOR Flash |
ESP32-C3 的 16m_c3.csv 是一个特例:由于 C3 芯片的 MMU 内存映射页数限制,即使物理 Flash 为 16MB,assets 分区也只能使用 4MB。
Sources: partitions/v2/README.md, partitions/v2/16m.csv
CI/CD 流水线:基于文件变更的智能增量构建¶
.github/workflows/build.yml 实现了一套精巧的 CI 策略,平衡了构建完整性与资源消耗:
两阶段 Job 设计¶
flowchart TD
PUSH["push to main"] --> ALL["编译全部变体"]
PR["pull_request"] --> CHANGED["分析变更文件"]
CHANGED -->|"main/* 但非 main/boards/*"| ALL
CHANGED -->|"main/boards/common/*"| ALL
CHANGED -->|"main/boards/<board>/*"| FILTER["仅编译受影响的板卡"]
CHANGED -->|"其他文件(文档/脚本)"| SKIP["跳过编译"]
这个逻辑的精妙之处在于:当贡献者仅提交某个板卡的配置文件时,CI 只构建该板卡的固件;但如果修改了 main/ 核心代码或 boards/common/ 公共基础设施,则触发全量构建——因为任何板卡都可能受到影响。
构建矩阵与并行化¶
release.py 的 --list-boards --json 输出一个 JSON 数组,每个元素包含 board、name、full_name 三元组。GitHub Actions 的 matrix.include 策略将此数组分发为并行 job,每个 job 在 espressif/idf:v5.5.2 Docker 容器中独立构建一个板卡变体。得益于 fail-fast: false 设置,单个板卡的构建失败不会中断其他板卡的构建。
Sources: .github/workflows/build.yml
与项目其他模块的关联¶
构建系统是连接"开发环境"与"最终固件"的桥梁。以下是你可能感兴趣的关联主题:
- 分区表设计:v1 与 v2 版本的存储布局迁移 — 深入了解分区表的 v1→v2 迁移细节和 SPIFFS 资产管理
- 自动化构建脚本与固件发布流水线 — 深入
release.py和versions.py的完整发布流程
- sdkconfig 配置详解:芯片平台与功能开关 — 理解 Kconfig 配置项对编译结果的精确控制
- Board 抽象层设计:统一管理 70+ 开发板的秘诀 — 查看构建系统如何通过
DECLARE_BOARD宏加载板卡驱动
- 多语言与资源文件管理 — 了解语言文件、音频资源和字体如何组织与打包
- 开发环境搭建:ESP-IDF 与 VSCode 配置 — 返回开发环境配置,了解如何运行构建命令
分区表设计:v1 与 v2 版本的存储布局迁移¶
ESP32 的分区表(Partition Table)是固件存储布局的核心配置文件,它定义了 Flash 芯片上各逻辑区域的起始偏移、大小和类型。小智 AI 聊天机器人在其版本演进中,经历了一次重大的分区架构重构——从 v1 的「静态模型分区」模型升级到 v2 的「动态资源分区」模型。本文系统性地解析两种版本的设计理念、完整布局对比、构建系统集成方式,以及存储布局迁移的技术细节。
分区表在 ESP-IDF 中的角色¶
在 ESP-IDF 框架中,分区表是一个 CSV 格式的配置文件,由 bootloader 在启动时解析。每条记录包含五个字段:Name(名称)、Type(类型)、SubType(子类型)、Offset(偏移量)、Size(大小)。ESP32 支持的分区类型包括 app(固件)、data(数据),子类型进一步细化用途——factory 为出厂固件、ota_0/ota_1 为 OTA 双槽位、nvs 为非易失性键值存储、spiffs 为 SPIFFS 文件系统。小智项目采用自定义分区表(CONFIG_PARTITION_TABLE_CUSTOM=y),通过芯片平台默认配置文件(sdkconfig.defaults.esp32*)为不同芯片指定不同的 CSV 文件路径,从而实现在同一套代码库中为 4MB 到 32MB 的 Flash 容量提供适配的分区布局。
Sources: sdkconfig.defaults
v1 分区架构:围绕 model 分区的静态布局¶
v1 版本的分区设计围绕一个名为 model 的 SPIFFS 分区构建,其核心假设是:设备出厂时将所有必需资源(唤醒词模型、字体、表情图片)预烧录到该分区,运行时只读访问。与 model 分区相伴的是 NVS 配置区、OTA 数据区和应用固件分区(单槽 factory 或双槽 ota_0/ota_1)。
v1 所有变体详解¶
v1 提供了 7 种分区变体,以应对不同 Flash 容量和特殊需求:
| CSV 文件 | Flash 容量 | 核心布局 | 特殊说明 |
|---|---|---|---|
4m.csv |
4MB | nvs(16K) + otadata(8K) + phy_init(4K) + model(960K) + factory(3M) | 单槽出厂固件,无 OTA 回退能力 |
4m_esp-hi.csv |
4MB | nvs(16K) + otadata(8K) + phy_init(4K) + model(832K) + factory(2200K) + assets_A(700K) | 针对 ESP-HI 开发板的紧凑型混合布局,首次出现 assets 概念 |
8m.csv |
8MB | nvs(16K) + otadata(8K) + phy_init(4K) + model(960K) + ota_0(3.5M) + ota_1(3.5M) | 标准双槽 OTA |
16m.csv |
16MB | nvs(16K) + otadata(8K) + phy_init(4K) + model(960K) + ota_0(6M) + ota_1(6M) | 最大的 v1 标准布局 |
16m_custom_wakeword.csv |
16MB | nvs(16K) + otadata(8K) + phy_init(4K) + model(4M) + ota_0(6M) + ota_1(6M) | 为自定义唤醒词提供 4MB 模型空间 |
16m_echoear.csv |
16MB | nvs(16K) + otadata(8K) + phy_init(4K) + model(960K) + ota_0(5M) + ota_1(5M) + assets_A(4M) | 预演 v2 设计:同时保留 model 和 assets_A |
32m.csv |
32MB | nvsfactory(200K) + nvs(840K) + otadata(8K) + phy_init(4K) + model(960K) + ota_0(12M) + ota_1(12M) | 豪华配置,app 分区对齐 1MB 边界 |
Sources: 4m.csv, 8m.csv, 16m.csv, 16m_custom_wakeword.csv, 16m_echoear.csv, 32m.csv, 4m_esp-hi.csv
model 分区统一使用 spiffs 子类型和 0xF0000(960KB)大小——唯一的例外是 16m_custom_wakeword.csv(4MB)和 4m_esp-hi.csv(832KB)。这意味着在 v1 中,资源存储上限被硬编码为分区容量,无法通过网络更新。16m_echoear.csv 和 4m_esp-hi.csv 中出现的 assets_A 分区(同为 spiffs 子类型)是 v2 设计的早期探索,但缺乏完整的下载和应用机制。
v1 的核心局限¶
v1 架构的根本问题在于资源不可更新:model 分区的内容在固件编译时通过 scripts/spiffs_assets/build.py 打包生成 assets.bin,与固件一同烧录。任何唤醒词模型、字体或主题的变更都需要重新构建和烧录整个固件。此外,model 分区固定为 960KB,对于日益增长的个性化需求(多语言字体、高清表情包、多唤醒词模型)容量捉襟见肘。
Sources: build.py
v2 分区架构:资产化的动态资源模型¶
v2 版本的核心变革是将 model 分区替换为更大的 assets 分区,并为其构建了完整的运行时下载、校验、内存映射与应用机制。assets 分区不再承载预烧录的静态模型,而是在设备首次启动时从云端动态下载。
v2 所有变体详解¶
| CSV 文件 | Flash 容量 | 核心布局 | 特殊说明 |
|---|---|---|---|
4m.csv |
4MB | nvs(16K) + otadata(8K) + phy_init(4K) + factory(3M-16K) + assets(1M) | 单槽 + 1MB 资源空间 |
8m.csv |
8MB | nvs(16K) + otadata(8K) + phy_init(4K) + ota_0(3M-16K) + ota_1(3M-16K) + assets(2M) | 双槽 + 2MB 资源 |
16m.csv |
16MB | nvs(16K) + otadata(8K) + phy_init(4K) + ota_0(4M-16K) + ota_1(4M-16K) + assets(8M) | 默认配置,应用与资源平衡 |
16m_c3.csv |
16MB | nvs(16K) + otadata(8K) + phy_init(4K) + ota_0(4M-16K) + ota_1(4M-16K) + assets(4MB) | C3/C5/C6 优化版,受限于 mmap 页数 |
32m.csv |
32MB | nvsfactory(200K) + nvs(840K) + otadata(8K) + phy_init(4K) + ota_0(4M) + ota_1(4M) + assets(16M) | 旗舰配置,16MB 资源空间 |
Sources: 4m.csv, 8m.csv, 16m.csv, 16m_c3.csv, 32m.csv
v2 布局的显著特征是 app 分区普遍缩减(以 16MB 为例,从 6MB 降至约 4MB),为 assets 腾出空间。16m_c3.csv 特别值得注意:ESP32-C3 芯片的 spi_flash_mmap_get_free_pages(SPI_FLASH_MMAP_DATA) 可返回的最大空闲 mmap 页数有限,若 assets 分区超过 4MB,esp_partition_mmap 调用将因页数不足而失败。因此该变体将 assets 限制为 4000K(约 4MB),确保运行时 mmap 成功。
Assets 分区的 SPIFFS 文件系统格式¶
assets 分区虽使用 SPIFFS 子类型,但其内部采用自定义的二进制打包格式。在 LVGL 策略下,分区头部结构为:
Offset 0: uint32_t stored_files // 文件数量
Offset 4: uint32_t stored_chksum // 数据区校验和(低 16 位有效)
Offset 8: uint32_t stored_len // 数据区字节长度
Offset 12: mmap_assets_table[] // 文件索引表(stored_files 条)
Offset 12 + N*sizeof(table): 数据区 // 各文件连续存储
每个 mmap_assets_table 条目(40 字节)包含 asset_name[32](文件名)、asset_size(文件大小)、asset_offset(相对于数据区起始的偏移)、asset_width 和 asset_height(图像尺寸)。每个文件数据段以 ZZ 魔数(两字节 0x5A 0x5A)开头作为有效性标记,实际数据紧接其后。
分区初始化时,Assets 类通过 esp_partition_find_first 按标签 "assets" 定位分区,然后调用 esp_partition_mmap 将整个分区映射到内存地址空间。随后读取头部校验和字段,对数据区重新计算累加校验和(byte-wise sum 取低 16 位),不匹配则拒绝加载。这种设计以极低开销提供了基本的数据完整性保障。
v1 → v2 存储布局迁移详解¶
关键变化对照¶
graph LR
subgraph v1["v1 布局 (16MB)"]
A1[nvs<br/>16KB] --> B1[otadata<br/>8KB] --> C1[phy_init<br/>4KB] --> D1[model<br/>960KB SPIFFS] --> E1[ota_0<br/>6MB] --> F1[ota_1<br/>6MB]
end
subgraph v2["v2 布局 (16MB)"]
A2[nvs<br/>16KB] --> B2[otadata<br/>8KB] --> C2[phy_init<br/>4KB] --> E2[ota_0<br/>~4MB] --> F2[ota_1<br/>~4MB] --> G2[assets<br/>8MB SPIFFS]
end
D1 -.->|"替换为"| G2
style D1 fill:#f9d5d5,stroke:#d32f2f
style G2 fill:#d4edda,stroke:#28a745
迁移的核心变化可以概括为三点:
- model → assets 替换:旧的 960KB 静态模型分区被可扩展的 8MB(16MB Flash 下)动态资源分区取代。名称从
model变为assets,标签常量同步为"assets"。
- app 分区瘦身:ota_0/ota_1 从 6MB 缩减至约 4MB,为 assets 腾出 8MB 空间。对于 32MB Flash 的旗舰设备,app 分区从 12MB 降至 4MB,assets 扩张至 16MB。
- NVS 保底不变:nvs、otadata、phy_init 三个元数据分区在大小和偏移上完全保持一致(16KB / 8KB / 4KB),确保 NVS 中的 Wi-Fi 凭据和设备配置在迁移后无须重新设置。
Sources: v2/README.md
构建系统中的分区表选择策略¶
分区表的选择通过 sdkconfig.defaults.* 文件驱动,不同芯片平台对应不同的默认 CSV:
| 芯片平台 | 默认分区表 | Flash 大小预设 |
|---|---|---|
| ESP32 | partitions/v2/4m.csv |
4MB |
| ESP32S3 | partitions/v2/16m.csv(全局默认) |
16MB |
| ESP32C3 | partitions/v2/16m_c3.csv |
16MB |
| ESP32C5 | partitions/v2/16m.csv(全局默认) |
16MB |
| ESP32C6 | partitions/v2/16m_c3.csv |
16MB |
| ESP32P4 | partitions/v2/16m.csv(全局默认) |
16MB |
全局默认值定义在 sdkconfig.defaults 中:CONFIG_PARTITION_TABLE_CUSTOM=y、CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/v2/16m.csv"。各芯片平台配置文件可以通过覆写 CONFIG_PARTITION_TABLE_CUSTOM_FILENAME 来选择不同布局。ESP32C3 和 ESP32C6 共享 16m_c3.csv(4MB assets),因为这两个 RISC-V 芯片的 mmap 资源池相似。
Sources: sdkconfig.defaults, sdkconfig.defaults.esp32, sdkconfig.defaults.esp32c3, sdkconfig.defaults.esp32c6
Assets 分区的运行时生命周期¶
初始化流程¶
当 Application::ActivationTask 在网络连接就绪后调用 CheckAssetsVersion() 时,流程进入下面这条链路:
sequenceDiagram
participant App as Application
participant Assets as Assets (单例)
participant Strategy as LvglStrategy/EmoteStrategy
participant Flash as ESP Partition API
participant HTTP as HTTP Client
App->>Assets: partition_valid() ?
Assets->>Flash: esp_partition_find_first("assets")
alt 分区不存在
Flash-->>Assets: nullptr
Assets-->>App: false → 跳过
else 分区存在
App->>Settings: GetString("download_url")
alt download_url 非空
App->>Assets: Download(url, callback)
Assets->>HTTP: GET → 逐扇区擦写
Assets->>Assets: InitializePartition() 重初始化
end
App->>Assets: Apply()
Assets->>Strategy: Apply()
Strategy->>Flash: esp_partition_mmap() → 解析索引
Strategy-->>App: 字体/表情/模型已加载
end
初始化入口是 Assets::InitializePartition(),它通过策略模式委托给 LvglStrategy 或 EmoteStrategy。LVGL 策略的初始化检查 mmap 空闲页数是否足够容纳整个分区——不足则直接失败并记录日志。此检查在 ESP32-C3 上尤为重要,解释了为何 16m_c3.csv 将 assets 限制为 4MB。
Sources: application.cc, assets.cc
资源下载机制¶
当服务器下发新的 assets 下载 URL 时,Assets::Download() 方法执行以下流程:
- 调用
UnApplyPartition()解除当前 mmap 映射。
- 通过 HTTP GET 获取整个 assets 二进制文件(
.bin格式)。
- 验证
content_length ≤ partition->size,防止越界写入。
- 以扇区(sector)为单位进行增量擦除:每写入跨越扇区边界时,先擦除目标扇区再写入。扇区大小通过
esp_partition_get_main_flash_sector_size()获取(通常为 4KB)。
- 下载完成后调用
InitializePartition()重建 mmap 映射并校验头部。
这种流式擦写策略避免了先将整个文件保存到 RAM 再刷写的内存压力——ESP32 的可用 DRAM 通常只有数百 KB,而 assets 文件可达 8MB。
Sources: assets.cc
与 OTA 固件升级的协同¶
v2 的 OTA 升级机制(通过 Ota::Upgrade() 方法)与 assets 分区完全解耦:OTA 只升级 ota_0 或 ota_1 中的应用固件,不触碰 assets 分区。这意味着 assets 可以在不重启设备的情况下独立更新(热更新),而固件升级则需要重启到新分区。这两条更新路径通过 Application::ActivationTask() 中的 CheckAssetsVersion() → CheckNewVersion() 顺序调用实现协同:先更新资源(静默),再检查固件升级(可能需要重启)。
Sources: application.cc, ota.cc
v1 到 v2 的物理迁移路径¶
从 v1 迁移到 v2 本质上是一次「分区表重烧」操作。流程如下:
- 备份 NVS 数据(可选):nvs 分区在 v1 和 v2 中位置、大小完全相同(0x9000, 16KB),但为防万一,建议先备份 Wi-Fi 凭据。
- 烧录 v2 分区表:使用
idf.py partition-table-flash或通过完整固件烧录工具,将对应的 v2 CSV 烧入 0x8000 偏移处。
- 烧录 v2 固件:v2 应用的 binary 需要烧录到新的 ota_0 起始地址(v2 为 0x20000,v1 为 0x100000)。
- 首次启动下载:设备启动后,
Application::CheckAssetsVersion()检测到 assets 分区为空或校验失败,触发 HTTP 下载。
关键注意事项:v1 的 model 分区内容不会自动迁移到 v2 的 assets 分区。这是因为 v1 的资源打包格式与 v2 的 mmap_assets_table 索引格式不兼容。用户需要从云端重新下载完整的 assets 包。
Sources: v2/README.md
策略模式:LVGL 与 Emote 两条路径¶
Assets 类内部使用策略模式处理不同显示后端的资源加载差异:
| 策略 | 适用场景 | mmap 方式 | 校验机制 |
|---|---|---|---|
LvglStrategy |
LVGL 图形界面(LCD 屏) | esp_partition_mmap 直接映射全分区 |
累加校验和 + ZZ 魔数 |
EmoteStrategy |
Emote 表情显示(OLED 屏) | 通过 emote_mount_assets 委托给 emote 库 |
由 emote 库内部处理 |
LvglStrategy 在初始化时遍历 mmap 根地址的索引表,构建 std::map<std::string, Asset> 内存索引。后续通过 GetAssetData() 按名称查找时,直接计算 mmap_root_ + asset.offset 定位数据,跳过 ZZ 魔数后返回。这种内存映射设计使得资源访问为零拷贝,对运行时性能几乎无影响。
EmoteStrategy 则依赖 emote 组件库的 emote_get_asset_data_by_name API,通过分区标签 "assets" 访问 SPIFFS 文件系统。两种策略共享相同的 FindPartition() 分区定位逻辑(按 ESP_PARTITION_TYPE_ANY + "assets" 标签查找),但后续的资源索引方式完全不同。
Sources: assets.h, assets.cc, assets.cc
设计总结与最佳实践¶
v2 分区架构的核心设计原则可归纳为三条:
- 固件与资源解耦:应用固件和动态资源通过独立分区实现生命周期分离,使得资源可以热更新而不影响系统稳定性。
- 容量弹性伸缩:同一套代码通过不同的 CSV 配置适配 4MB 到 32MB 的 Flash 容量,assets 分区大小与 Flash 总容量成正比。
- 校验即信任:所有从分区加载的资源必须通过校验和验证,防止因 Flash 位翻转或下载不完整导致的运行时崩溃。
对于自定义开发板的开发者,选择分区表时需考虑:首先根据 Flash 容量选择对应的 v2 CSV 文件;其次若使用 ESP32-C3/C6 等 RISC-V 芯片,需使用 16m_c3.csv 以避免 mmap 页耗尽;最后确保 assets 分区的实际大小不超过 spi_flash_mmap_get_free_pages(SPI_FLASH_MMAP_DATA) * 64KB。
Sources: v2/README.md, assets.cc
推荐阅读:
- OTA 固件升级:版本检查、激活验证与安全更新 —— 理解 OTA 升级与 assets 更新的协同关系
- Settings 持久化存储:基于 NVS 的键值读写 —— 了解 download_url 等配置如何在 NVS 分区中存储
- CMake 构建系统与 ESP-IDF 组件依赖管理 —— 掌握分区表在构建流程中的选择机制
自动化构建脚本与固件发布流水线¶
本文档深入剖析小智 ESP32 项目的自动化构建系统与固件发布流水线,涵盖从代码提交到固件分发的全链路自动化过程。你将理解:GitHub Actions 如何根据变更智能选择构建目标、release.py 如何通过 config.json 驱动 70+ 板卡的多变体编译、以及版本的二进制解析与 OSS 分发机制。
流水线全景:从 Git Push 到 OTA 升级¶
整个构建与发布流水线由四个紧密协作的阶段构成。第一阶段是 CI 触发与变体选择——GitHub Actions 根据 git diff 分析变更范围,决定是编译全部板卡还是仅编译受影响的板卡。第二阶段是 矩阵化并行构建——利用 release.py 读取各板卡目录下的 config.json,为每个变体生成正确的 sdkconfig 追加配置并调用 idf.py 完成编译、合并与打包。第三阶段是 产物下载与重命名——download_github_runs.py 通过 GitHub API 拉取构建产物,去除哈希后缀并添加语义化版本前缀。第四阶段是 版本解析与云端分发——versions.py 从 merged-binary.bin 中提取芯片 ID、闪存大小、固件版本等元数据,上传至阿里云 OSS 并向版本服务器注册,最终由设备端 ota.cc 通过 HTTP 查询并拉取升级。
flowchart LR
A["Git Push / PR"] --> B["GitHub Actions\nbuild.yml"]
B --> C{"prepare Job\n变更分析"}
C -->|"push to main"| D["全量编译\n所有变体"]
C -->|"PR"| E["增量编译\n仅受影响板卡"]
D --> F["Matrix Build Job\nidf.py 编译"]
E --> F
F --> G["Upload Artifact\nmerged-binary.bin"]
G --> H["download_github_runs.py\n下载与重命名"]
H --> I["versions.py\n二进制解析"]
I --> J["上传 OSS + 注册版本服务器"]
J --> K["设备 OTA\nCheckVersion() 拉取升级"]
Sources: build.yml, release.py, download_github_runs.py, versions.py
GitHub Actions 工作流:智能增量构建¶
CI 配置文件位于 .github/workflows/build.yml,定义了触发条件与双 Job 架构。工作流在 push 到 main 或 ci/* 分支时触发,也在 pull_request 到 main 分支时触发。
prepare Job:变体选择策略¶
prepare Job 是整个工作流的调度中枢,它通过 outputs.variants 向后续 Job 传递需要编译的板卡变体列表。核心逻辑分为三步:
第一步:获取全量变体列表。 执行 python scripts/release.py --list-boards --json,该命令遍历 main/boards/ 下所有 config.json 文件,收集每个板卡的每个 builds[] 条目,输出包含 board、name、full_name 三个字段的 JSON 数组。
第二步:根据事件类型分支决策。 若为 push 事件(即合并到 main 后的代码推送),直接返回全量变体,确保发布版本覆盖所有硬件。若为 pull_request 事件,进入增量分析逻辑。
第三步:变更文件分析。 使用 git diff --name-only 获取 PR 的变更文件列表,然后按以下规则逐文件判定:
| 变更范围 | 判定结果 | 原因 |
|---|---|---|
main/*(但不含 main/boards/*) |
全量编译 | 核心代码改动影响所有板卡 |
main/boards/common/* |
全量编译 | 公共 Board 层改动影响所有板卡 |
main/boards/<board_name>/* |
仅编译该板卡 | 只有特定板卡的配置或代码变更 |
| 其他路径(如 scripts、docs) | 跳过程序编译 | 非关键代码变更 |
当前两种条件触发时,NEED_ALL 标志置为 1,直接输出全量变体。当仅有特定板卡变更时,通过 jq 工具从全量列表中过滤出匹配的变体,最终输出精简后的 JSON 数组。若过滤后数组为空(即没有代码变更),build Job 会被 if 条件跳过。
Sources: build.yml
build Job:矩阵化并行编译¶
build Job 使用 GitHub Actions 的 matrix 策略实现规模化并行编译。关键设计决策包括:
- 容器化构建环境:使用
espressif/idf:v5.5.2Docker 镜像,确保 ESP-IDF 工具链版本一致性,避免环境差异导致的编译失败。
fail-fast: false:单个变体的编译失败不会终止其他变体的构建任务。这对于包含 70+ 板卡的项目至关重要——某个板卡的配置问题不应阻塞其他板卡的发布。
- Matrix 注入:
matrix.include: ${{ fromJson(needs.prepare.outputs.variants) }}将prepareJob 输出的 JSON 数组展开为矩阵参数,每个变体生成一个独立的并行 Job。
每个矩阵 Job 内部执行 python scripts/release.py ${{ matrix.board }} --name ${{ matrix.name }},该命令针对特定板卡和变体名称执行完整编译流程。产物统一上传为 xiaozhi_${{ matrix.full_name }}_${{ github.sha }} 格式的 Artifact,其中 full_name 形如 bread-compact-ml307 或 waveshare-esp32-p4-nano-10.1-a,github.sha 为提交哈希,确保每次构建的产物可追溯。
flowchart TB
subgraph "prepare Job"
A["release.py --list-boards --json"] --> B["全量变体 JSON"]
C["git diff --name-only"] --> D{"变更分析"}
D -->|"核心代码/common 层变更"| E["输出全量变体"]
D -->|"仅特定 board 变更"| F["jq 过滤变体"]
D -->|"无代码变更"| G["输出空数组"]
end
subgraph "build Job(矩阵并行)"
H["Matrix: 变体1"] --> I["release.py board --name name"]
J["Matrix: 变体2"] --> K["release.py board --name name"]
L["Matrix: 变体N"] --> M["release.py board --name name"]
I --> N["Upload merged-binary.bin"]
K --> N
M --> N
end
E --> H
F --> H
Sources: build.yml
release.py:编译编排核心¶
release.py 是整个构建系统的中枢脚本,承担三个核心职责:变体发现、配置解析、编译执行。它不仅被 CI 调用,也可以在开发者的本地环境中独立运行。
变体发现:_collect_variants() 与 config.json 规范¶
_collect_variants() 函数递归遍历 main/boards/ 目录下所有 config.json 文件(跳过 common 目录),为每个构建变体生成标准化的元数据。该过程同时执行严格的 manufacturer(制造商)一致性校验:
| 目录结构 | config.json 中 manufacturer 要求 | 校验规则 |
|---|---|---|
boards/<board_name>/config.json(无子目录) |
禁止定义 manufacturer | 若定义了 manufacturer,报错提示应移至子目录 |
boards/<manufacturer>/<board_name>/config.json |
必须定义 manufacturer,且值与目录名一致 | 若缺失或不匹配,报错终止 |
这一校验确保了板卡的组织结构与元数据的一致性,防止因目录迁移遗留错误配置。
每个 config.json 中的 builds[] 数组定义了该板卡的所有变体。例如 bread-compact-ml307 包含两个变体:默认的 128×32 OLED 版本和 bread-compact-ml307-128x64,后者仅在 sdkconfig_append 中追加了 CONFIG_OLED_SSD1306_128X64=y 一行配置。
Sources: release.py, bread-compact-ml307 config.json
板卡配置解析:_resolve_board_config()¶
ESP-IDF 的 Kconfig 系统中,每个板卡通过 CONFIG_BOARD_TYPE_XXX 符号进行条件编译。release.py 需要确定当前变体应设置哪个 Kconfig 符号。决策链如下:
- 优先显式声明:若
config.json的sdkconfig_append中已包含CONFIG_BOARD_TYPE_XXX=y,直接使用。
- 从 CMakeLists.txt 反向匹配:在
main/CMakeLists.txt中搜索与BOARD_TYPE字符串匹配的if(CONFIG_BOARD_TYPE_XXX)守卫语句。
- 多候选消歧义:当同一
BOARD_TYPE字符串对应多个 Kconfig 符号时(通常是同一板卡的不同芯片变体),按IDF_TARGET_<chip>依赖关系匹配;若仍有歧义,按符号名称中包含目标芯片字符串匹配;最终退化为选择第一个候选。
Sources: release.py
Kconfig 自动依赖注入:_AUTO_SELECT_RULES¶
ESP-IDF 的 menuconfig 在用户选择一个选项时会自动勾选其依赖项(select 语句),但直接向 sdkconfig 追加配置行不会触发这一机制。_AUTO_SELECT_RULES 字典手动维护了一组自动依赖规则。当前定义的规则是:
CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING
→ CONFIG_BT_ENABLED=y
→ CONFIG_BT_BLUEDROID_ENABLED=y
→ CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y
→ CONFIG_BT_BLE_50_FEATURES_SUPPORTED=n
→ CONFIG_BT_BLE_BLUFI_ENABLE=y
→ CONFIG_MBEDTLS_DHM_C=y
当某个变体的 sdkconfig_append 中包含 BLUFI Wi-Fi 配网选项时,所有蓝牙依赖项和加密库选项会被自动追加,且不会重复添加已有项。
Sources: release.py
编译执行:release() 函数¶
release() 函数针对单个板卡的单个变体执行完整的编译-打包流程,每个步骤都有明确的错误处理和跳过逻辑:
- 版本号获取:从根
CMakeLists.txt中读取PROJECT_VER(当前为2.2.6)。
- 产物去重检查:若
releases/v{version}_{full_name}.zip已存在,跳过编译。这使得 CI 重跑时无需重复构建。
- sdkconfig 追加:将解析好的
CONFIG_BOARD_TYPE_XXX=y和变体自定义的sdkconfig_append写入sdkconfig文件。
idf.py set-target {target}:设置芯片目标(如esp32s3、esp32p4)。
idf.py -DBOARD_NAME={name} -DBOARD_TYPE={board_type} build:传入 CMake 宏定义后执行编译。BOARD_NAME用于固件内部标识,BOARD_TYPE用于选择正确的板卡源文件。
idf.py merge-bin:合并引导程序、分区表和应用程序为单一merged-binary.bin。
- ZIP 打包:将
merged-binary.bin压缩为releases/v{version}_{full_name}.zip。
Sources: release.py
CLI 模式概览¶
release.py 支持三种运行模式:
| 模式 | 命令示例 | 用途 |
|---|---|---|
| 列表模式 | python release.py --list-boards --json |
CI prepare Job 获取全量变体列表 |
| 编译模式 | python release.py bread-compact-ml307 --name bread-compact-ml307-128x64 |
CI build Job 或本地编译特定变体 |
| 打包模式 | python release.py(无参数) |
对当前已编译的 build 目录执行 merge-bin + zip |
编译模式中,board 参数可以是具体板卡名、all(编译所有板卡的所有变体),或省略以触发打包模式。
Sources: release.py
config.json:板卡变体配置规范¶
每个板卡目录(main/boards/<board>/)下的 config.json 是驱动构建系统的核心元数据文件。它的结构直接决定了 CI 矩阵的维度。
基本字段说明¶
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
target |
string | 是 | ESP-IDF 芯片目标,如 esp32s3、esp32p4、esp32c3 |
manufacturer |
string | 条件必需 | 制造商标识。当板卡位于子目录时必需且必须与目录名一致 |
builds |
array | 是 | 构建变体列表,至少包含一个变体 |
builds[].name |
string | 是 | 变体名称,必须包含板卡叶子目录名(用于校验一致性) |
builds[].sdkconfig_append |
array | 否 | 追加到 sdkconfig 的 Kconfig 配置行列表 |
配置示例对比¶
简单单变体板卡(atk-dnesp32s3):
{
"target": "esp32s3",
"builds": [
{
"name": "atk-dnesp32s3",
"sdkconfig_append": [
"CONFIG_CAMERA_OV2640=y"
]
}
]
}
多变体板卡(bread-compact-ml307 的两个 OLED 分辨率变体):
{
"target": "esp32s3",
"builds": [
{ "name": "bread-compact-ml307", "sdkconfig_append": ["CONFIG_OLED_SSD1306_128X32=y"] },
{ "name": "bread-compact-ml307-128x64", "sdkconfig_append": ["CONFIG_OLED_SSD1306_128X64=y"] }
]
}
带 manufacturer 的子目录板卡(waveshare/esp32-p4-nano):
{
"manufacturer": "waveshare",
"target": "esp32p4",
"builds": [
{
"name": "esp32-p4-nano-10.1-a",
"sdkconfig_append": [
"CONFIG_CAMERA_OV5647=y",
"CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y"
]
},
{
"name": "esp32-p4-nano-10.1-a-p4x",
"sdkconfig_append": [
"CONFIG_CAMERA_OV5647=y"
]
}
]
}
子目录结构下的 full_name 生成为 {manufacturer}-{name},例如 waveshare-esp32-p4-nano-10.1-a。
Sources: atk-dnesp32s3 config.json, bread-compact-ml307 config.json, waveshare/esp32-p4-nano config.json
产物下载与重命名:download_github_runs.py¶
当 CI 流水线完成构建后,download_github_runs.py 脚本负责从 GitHub Actions 拉取产物并进行语义化重命名。它设计的核心使用场景是:维护者手动运行此脚本,从指定的 GitHub Actions Run 中批量下载所有板卡固件并整理到 releases/<version>/ 目录。
工作流程¶
- 解析 GitHub URL:从形如
https://github.com/78/xiaozhi-esp32/actions/runs/18866246016的 URL 中提取owner、repo、run_id。
- 分页获取 Artifacts:通过 GitHub REST API(
/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts)获取所有构建产物,支持 100 条/页的分页遍历。
- 重命名规则:原始 Artifact 名称格式为
xiaozhi_{full_name}_{sha}(如xiaozhi_atk-dnesp32s3_43ef2f4e...),重命名后为v{version}_{full_name}.zip(如v2.0.4_atk-dnesp32s3.zip)。规则包括:
- 去除
xiaozhi_前缀
- 去除 40 位以上的十六进制哈希后缀
- 添加
v{version}_前缀
- 断点续传:若目标文件已存在则跳过下载。
调用方式¶
python scripts/download_github_runs.py 2.0.4 https://github.com/78/xiaozhi-esp32/actions/runs/18866246016
脚本依赖 .env 文件中的 GITHUB_TOKEN 环境变量进行 API 认证。
Sources: download_github_runs.py
版本解析与云端分发:versions.py¶
versions.py 是发布流水线的最后一环,它将 ZIP 包中的 merged-binary.bin 解析为结构化元数据,上传至阿里云 OSS 并向版本服务器注册。这一环节是设备 OTA 升级能力的基础——设备通过 HTTP 查询版本服务器获取最新固件信息。
二进制解析流程¶
脚本从 merged-binary.bin 中提取多层信息:
- 查找 app 分区:在偏移
0x8000至0xC000范围内扫描分区表,寻找type=0x00(app 类型)的分区,获取其偏移和大小。
- 验证 ESP-IDF 镜像头:检查 app 分区数据的首字节是否为
0xE9(ESP-IDF 固件镜像标识)。
- 提取芯片与闪存信息:从镜像头中读取
chip_id(偏移0x0C)和flash_size(偏移0x03高 4 位)。
- 解析 app_desc 结构:通过
0xABCD5432魔数定位应用程序描述符,提取版本号、项目名称、编译时间、IDF 版本和 ELF SHA256 哈希。
- 提取纯净固件镜像:裁剪填充字节和校验和,输出
xiaozhi.bin。
芯片 ID 与闪存大小映射¶
| 芯片 ID | 芯片型号 | 闪存编码 | 实际大小 |
|---|---|---|---|
0x0005 |
ESP32-C3 | 0x02 |
4 MB |
0x0009 |
ESP32-S3 | 0x03 |
8 MB |
0x000D |
ESP32-C6 | 0x04 |
16 MB |
0x0012 |
ESP32-P4 | — | — |
分发动作¶
flowchart LR
A["releases/v2.2.6_xxx.zip"] --> B["解压 merged-binary.bin"]
B --> C["read_binary()\n提取元数据"]
C --> D["生成 info.json"]
D --> E["upload_dir_to_oss()\n上传 xiaozhi.bin 至阿里云 OSS"]
D --> F["post_info_to_server()\n注册版本信息到服务器"]
- OSS 上传:使用
oss2SDK 将xiaozhi.bin上传至firmwares/{tag}/路径,返回公开访问 URL。
- 服务器注册:通过 HTTP POST 将包含
tag、url、chip_id、flash_size、board、application等字段的 JSON 发送至VERSIONS_SERVER_URL。
Sources: versions.py
辅助脚本与资源构建¶
除了核心的编译与发布脚本,项目还维护了一套资源构建工具链,用于生成固件中嵌入的语言资源、字库、表情包和语音模型。
gen_lang.py:多语言头文件生成¶
gen_lang.py 从 main/assets/locales/{lang_code}/language.json 读取翻译数据,以 en-US 为基准语言进行合并(fallback 机制),生成包含字符串常量和音效资源引用的 C++ 头文件。输出文件 main/assets/lang_config.h 被固件编译时包含。
关键设计:当目标语言的某个字符串缺失时,自动回退到 en-US 的对应条目;音效文件同理——若目标语言目录下不存在某 .ogg 文件,则使用 en-US 目录下的版本。
python scripts/gen_lang.py --language zh-CN --output main/assets/lang_config.h
Sources: gen_lang.py
build_default_assets.py:默认资源构建¶
该脚本是 ESP-IDF 构建流程的组成部分(通过 CMake 自定义命令调用),在编译阶段生成 assets.bin 文件。它整合了:
- 语音唤醒模型(wakenet / multinet):将 ESP-SR 模型文件打包为
srmodels.bin
- 文本字库:复制指定字体文件至资源目录
- 表情包:复制 PNG/GIF 表情文件并生成
index.json索引
- LVGL 图像转换:生成 LVGL 9.x 兼容的图片资源配置
最终通过 SPIFFS 文件系统生成器将所有资源打包为单一二进制镜像。
Sources: build_default_assets.py
spiffs_assets/build_all.py:批量资源组合构建¶
该脚本通过笛卡尔积方式组合多种唤醒模型、字库和表情包,批量调用 build.py 生成全套 SPIFFS 资源镜像。典型组合矩阵:3 种唤醒模型 × 5 种字库 × 3 种表情包 = 45 个资源镜像。输出文件命名遵循 {wakenet}-{font}-{emoji}.bin 规范,存放于 spiffs_assets/build/final/ 目录。
Sources: build_all.py
从 CI 到设备:OTA 升级闭环¶
构建流水线的最终消费者是运行在现场的设备。ota.cc 中的 CheckVersion() 方法与版本服务器交互完成升级检测:
- 设备启动后,通过
GetCheckVersionUrl()获取 OTA 服务器 URL(优先从 NVS 读取,否则使用CONFIG_OTA_URL编译期默认值)。
- 发起 HTTP 请求,携带
Device-Id(MAC 地址)、Client-Id(UUID)、User-Agent和Activation-Version头。
- 服务器响应 JSON,包含
firmware.version、firmware.url、以及可选的mqtt/websocket配置更新。
- 若服务器返回的版本号高于当前固件版本,设备通过
esp_otaAPI 执行固件下载和切换。
这一闭环使 release.py 编译出的 merged-binary.bin 经 versions.py 上传分发后,最终能无缝触达所有已部署设备。
Sources: ota.cc, versions.py
脚本工具速查表¶
| 脚本 | 路径 | 核心功能 |
|---|---|---|
release.py |
scripts/release.py |
变体发现、sdkconfig 解析、编译编排、打包 |
download_github_runs.py |
scripts/download_github_runs.py |
从 GitHub Actions 下载产物并重命名 |
versions.py |
scripts/versions.py |
二进制解析、OSS 上传、版本服务器注册 |
gen_lang.py |
scripts/gen_lang.py |
多语言头文件生成(含 en-US fallback) |
build_default_assets.py |
scripts/build_default_assets.py |
默认 SPIFFS 资源构建(模型/字库/表情) |
build.py |
scripts/spiffs_assets/build.py |
单组 SPIFFS 资源构建 |
build_all.py |
scripts/spiffs_assets/build_all.py |
批量多组合 SPIFFS 资源构建 |
spiffs_assets_gen.py |
scripts/spiffs_assets/spiffs_assets_gen.py |
底层 SPIFFS 镜像生成(含 LVGL 图片转换) |
pack_model.py |
scripts/spiffs_assets/pack_model.py |
多模型文件打包为 srmodels.bin |
下一步阅读¶
理解了构建流水线如何将源代码转化为可刷写的固件后,建议深入以下主题:
- 分区表设计:v1 与 v2 版本的存储布局迁移:理解
merged-binary.bin内部的存储布局如何随版本演进,以及分区表在 OTA 升级中的作用。
- OTA 固件升级:版本检查、激活验证与安全更新:深入设备端 OTA 的完整实现,包括激活码验证、HMAC 安全校验和固件切换逻辑。
- sdkconfig 配置详解:芯片平台与功能开关:理解
sdkconfig_append中各 Kconfig 选项的含义及其对固件行为的影响。