第 2 章 主调度器:main.cc + application.{h,cc}
本章把项目的"心脏"——
Application单例——从上电那一刻起逐段讲清楚。整份application.cc是 1055 行,本章按"成员变量 → 构造 → Initialize → Run 主循环 → 一个个事件处理器 → 协议回调注册 → 工具方法"的顺序展开。每一段先放出原文行号,再讲它在做什么、用到什么技术、为什么这么写。
2.1 main.cc(30 行,逐行)
完整内容:
extern "C" void app_main(void)
{
// Initialize NVS flash for WiFi configuration
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_LOGW(TAG, "Erasing NVS flash to fix corruption");
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
// Initialize and run the application
auto& app = Application::GetInstance();
app.Initialize();
app.Run(); // This function runs the main event loop and never returns
}
| 行 | 干什么 | 知识点 |
|---|---|---|
extern "C" void app_main(void) | ESP-IDF 框架约定的入口,名字必须是 app_main,且不能 C++ name-mangling,所以加 extern "C" | C/C++ 链接约定 |
nvs_flash_init() | 初始化非易失存储(NVS),后面 Settings、Wi-Fi 凭据、OTA 标志都要用 | ESP-IDF NVS |
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || …) | 第一次烧录或 NVS 版本变了就擦掉重来 | 故障自愈 |
ESP_ERROR_CHECK(ret) | 宏,错误时打印错误码并 abort | ESP-IDF 错误宏 |
Application::GetInstance() | 第一次访问触发构造(C++11 magic static,线程安全) | 单例模式 |
app.Initialize() | 同步走完所有"立刻能做"的事,不阻塞等网络 | — |
app.Run() | 永不返回的事件循环 | — |
app_main 自己就是一个 FreeRTOS 任务,所以 Run() 里 xEventGroupWaitBits(... portMAX_DELAY) 阻塞的就是这个任务。
2.2 application.h 解析(187 行)
2.2.1 包含的头文件
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
#include <freertos/task.h>
#include <esp_timer.h>
#include <string>
#include <mutex>
#include <deque>
#include <memory>
#include "protocol.h"
#include "ota.h"
#include "audio_service.h"
#include "device_state.h"
#include "device_state_machine.h"
| 头 | 用途 |
|---|---|
event_groups.h | FreeRTOS 事件组 API(13 个事件位都在这上面) |
task.h | xTaskCreate / vTaskDelete / vTaskDelay |
esp_timer.h | 1 秒滴答 timer |
<mutex> <deque> | Schedule() 把 lambda 推进 main_tasks_ 队列 |
protocol.h | Protocol 基类 |
ota.h | Ota(激活、升级) |
audio_service.h | AudioService |
device_state*.h | 状态枚举 + 状态机 |
注意没有 include board.h —— 这里只前向用到 Board,避免循环依赖(板子可能用到 Application)。
2.2.2 13 个事件位(事件组的核心)
#define MAIN_EVENT_SCHEDULE (1 << 0)
#define MAIN_EVENT_SEND_AUDIO (1 << 1)
#define MAIN_EVENT_WAKE_WORD_DETECTED (1 << 2)
#define MAIN_EVENT_VAD_CHANGE (1 << 3)
#define MAIN_EVENT_ERROR (1 << 4)
#define MAIN_EVENT_ACTIVATION_DONE (1 << 5)
#define MAIN_EVENT_CLOCK_TICK (1 << 6)
#define MAIN_EVENT_NETWORK_CONNECTED (1 << 7)
#define MAIN_EVENT_NETWORK_DISCONNECTED (1 << 8)
#define MAIN_EVENT_TOGGLE_CHAT (1 << 9)
#define MAIN_EVENT_START_LISTENING (1 << 10)
#define MAIN_EVENT_STOP_LISTENING (1 << 11)
#define MAIN_EVENT_STATE_CHANGED (1 << 12)
| 位 | 谁会 set 这一位 | 主循环收到后干什么 |
|---|---|---|
SCHEDULE | Schedule(lambda) 调用方 | 取出 main_tasks_ 里全部 lambda 串行执行 |
SEND_AUDIO | AudioService 编码完一帧 | 从 audio_send_queue_ 拿 Opus 包发给协议 |
WAKE_WORD_DETECTED | WakeWord 模型检测到唤醒 | 进入连接/监听 |
VAD_CHANGE | AFE 检测到"开始说话"或"结束说话" | 通知 LED 变颜色 |
ERROR | 协议层报网络错误 | 切回 idle + 弹错提示 |
ACTIVATION_DONE | 激活后台任务跑完 | 进入正常工作流 |
CLOCK_TICK | esp_timer 每 1 秒 | 刷状态栏;每 10 秒打印堆栈 |
NETWORK_CONNECTED / NETWORK_DISCONNECTED | 板级网络回调 | 进入激活 / 关闭音频通道 |
TOGGLE_CHAT / START_LISTENING / STOP_LISTENING | 按键、WakeWordInvoke、MCP 工具 | 切换聊天状态 |
STATE_CHANGED | 状态机回调 | 真正执行"切到新状态"的副作用(点灯、起音频任务、刷 UI) |
核心设计哲学:所有"我要在主任务上做点什么"都先 set 一个事件位,主循环 wait 到后处理。这样:
- 跨任务通信不用复杂的队列、信号量;
- 大量副作用在同一上下文里串行执行,几乎不需要互斥锁;
- 同一时间一组事件可以一起处理(多 bit 一起触发)。
2.2.3 AecMode 枚举
enum AecMode {
kAecOff,
kAecOnDeviceSide,
kAecOnServerSide,
};
AEC = Acoustic Echo Cancellation 回声消除。语音对话中麦克风会同时录到喇叭播的声音,必须消掉。
kAecOff:不做 AEC——靠"半双工",喇叭说话时不录音。kAecOnDeviceSide:本地 AFE 做(需要 ESP32-S3/P4 + 双麦克风 reference 信号)。kAecOnServerSide:本地把麦克风 PCM 一并把时间戳发上去,云端做。
构造函数里根据 sdkconfig 选哪一种:
#if CONFIG_USE_DEVICE_AEC && CONFIG_USE_SERVER_AEC
#error ...
#elif CONFIG_USE_DEVICE_AEC
aec_mode_ = kAecOnDeviceSide;
#elif CONFIG_USE_SERVER_AEC
aec_mode_ = kAecOnServerSide;
#else
aec_mode_ = kAecOff;
#endif
2.2.4 Application 类的成员
公开接口(节选):
static Application& GetInstance();
void Initialize();
void Run();
DeviceState GetDeviceState() const;
bool SetDeviceState(DeviceState state);
bool IsVoiceDetected() const;
void Schedule(std::function<void()>&& callback);
void Alert(...); void DismissAlert();
void AbortSpeaking(AbortReason reason);
void ToggleChatState(); void StartListening(); void StopListening();
void Reboot();
void WakeWordInvoke(const std::string& wake_word);
bool UpgradeFirmware(const std::string& url, const std::string& version = "");
bool CanEnterSleepMode();
void SendMcpMessage(const std::string& payload);
void SetAecMode(AecMode mode);
AecMode GetAecMode() const;
void PlaySound(const std::string_view& sound);
AudioService& GetAudioService();
void ResetProtocol();
私有数据:
std::mutex mutex_; // 保护 main_tasks_
std::deque<std::function<void()>> main_tasks_; // Schedule() 的目的地
std::unique_ptr<Protocol> protocol_; // WebSocket 或 MQTT,激活后才 new
EventGroupHandle_t event_group_; // 主事件组
esp_timer_handle_t clock_timer_handle_; // 1 秒滴答
DeviceStateMachine state_machine_; // 状态机(不是指针,直接持有)
ListeningMode listening_mode_; // AutoStop / ManualStop / Realtime
AecMode aec_mode_;
std::string last_error_message_;
AudioService audio_service_; // 直接持有,不是指针
std::unique_ptr<Ota> ota_; // 激活时才 new,激活完成就 reset() 释放
bool has_server_time_;
bool aborted_;
bool assets_version_checked_;
bool play_popup_on_listening_;
int clock_ticks_;
TaskHandle_t activation_task_handle_;
设计要点:
audio_service_和state_machine_直接持有(不是unique_ptr)——构造 Application 时它们一起 new,析构时一起 destroy。它俩生命期等于程序生命期。protocol_和ota_用unique_ptr——前者要等握手拿到配置才能决定 new WS 还是 MQTT;后者激活完就释放(OTA 信息只在激活时有用,丢掉省内存)。event_group_是 RTOS 句柄,构造时xEventGroupCreate(),析构时vEventGroupDelete()。
2.2.5 私有事件处理器和 helper
void HandleStateChangedEvent();
void HandleToggleChatEvent();
void HandleStartListeningEvent();
void HandleStopListeningEvent();
void HandleNetworkConnectedEvent();
void HandleNetworkDisconnectedEvent();
void HandleActivationDoneEvent();
void HandleWakeWordDetectedEvent();
void ActivationTask(); // 后台任务
void CheckAssetsVersion();
void CheckNewVersion();
void InitializeProtocol();
void ShowActivationCode(const std::string& code, const std::string& message);
void SetListeningMode(ListeningMode mode);
void OnStateChanged(DeviceState old_state, DeviceState new_state);
逐个在 2.4-2.7 节展开。
2.2.6 文件末尾的 TaskPriorityReset
class TaskPriorityReset {
public:
TaskPriorityReset(BaseType_t priority) {
original_priority_ = uxTaskPriorityGet(NULL);
vTaskPrioritySet(NULL, priority);
}
~TaskPriorityReset() {
vTaskPrioritySet(NULL, original_priority_);
}
private:
BaseType_t original_priority_;
};
这是一个 RAII 工具:构造时把当前任务的优先级临时提升到 priority,析构时恢复。用法:
{
TaskPriorityReset boost(20); // 提到优先级 20
// ... 这一段不希望被打断
} // 离开作用域自动恢复
源码里目前还没有具体调用点,但保留了这个工具,方便板子作者临时优先级。
技术点:RAII(Resource Acquisition Is Initialization)—— 用对象生命期管理资源/状态,C++ 习惯用法。
2.3 application.cc 构造与析构
2.3.1 构造函数 (23-47 行)
Application::Application() {
event_group_ = xEventGroupCreate();
#if CONFIG_USE_DEVICE_AEC && CONFIG_USE_SERVER_AEC
#error "CONFIG_USE_DEVICE_AEC and CONFIG_USE_SERVER_AEC cannot be enabled at the same time"
#elif CONFIG_USE_DEVICE_AEC
aec_mode_ = kAecOnDeviceSide;
#elif CONFIG_USE_SERVER_AEC
aec_mode_ = kAecOnServerSide;
#else
aec_mode_ = kAecOff;
#endif
esp_timer_create_args_t clock_timer_args = {
.callback = [](void* arg) {
Application* app = (Application*)arg;
xEventGroupSetBits(app->event_group_, MAIN_EVENT_CLOCK_TICK);
},
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "clock_timer",
.skip_unhandled_events = true
};
esp_timer_create(&clock_timer_args, &clock_timer_handle_);
}
| 行 | 干什么 | 技术点 |
|---|---|---|
xEventGroupCreate() | 创建事件组句柄 | FreeRTOS |
#if … #error | 编译期防止两种 AEC 同时开 | 预处理器 |
esp_timer_create_args_t 结构 | 描述一个软定时器:callback、参数、调度方式 | ESP-IDF designated initializers |
.callback = [](void* arg){ … } | 用无捕获 lambda 作为 C 回调,可以隐式转换成函数指针 | C++ lambda 兼容 C 回调 |
xEventGroupSetBits(app->event_group_, MAIN_EVENT_CLOCK_TICK) | timer 到时只 set 一个 bit | 把 timer 上下文切到主循环上下文 |
.dispatch_method = ESP_TIMER_TASK | 回调在 esp_timer 的专用任务里执行(而不是 ISR) | 避免在中断里干复杂事 |
.skip_unhandled_events = true | 卡住一段时间后只补一次 tick,不堆积 | 防止恢复后 1000 个 tick 突然涌来 |
esp_timer_create(...) | 创建 timer,还没启动 | 启动在 Initialize 里 |
为什么 callback 用 lambda 而不写一个普通函数?因为可以把上下文(this)通过 arg 字段绑进去,避免全局变量。无捕获 lambda 可以衰变成函数指针,这是 C++11 起的语言特性,专门为兼容 C API 设计。
2.3.2 析构函数 (49-55 行)
Application::~Application() {
if (clock_timer_handle_ != nullptr) {
esp_timer_stop(clock_timer_handle_);
esp_timer_delete(clock_timer_handle_);
}
vEventGroupDelete(event_group_);
}
老实的资源回收。但实际上这个析构永远不会被调用(单例 + Run() 不返回),写在这里只是好习惯。
2.4 Initialize() 逐段解读(61-164 行)
void Application::Initialize() {
auto& board = Board::GetInstance();
SetDeviceState(kDeviceStateStarting);
第一步把状态机推进 unknown → starting。SetDeviceState 内部调状态机的 TransitionTo,会触发已注册的 listener(但是此时还没注册,所以无副作用)。
auto display = board.GetDisplay();
display->SetChatMessage("system", SystemInfo::GetUserAgent().c_str());
拿屏幕,在系统消息位置贴一个"我是谁"——SystemInfo::GetUserAgent() 返回类似 xiaozhi-esp32/v2.0.0 (xiaozhi-esp32-main; esp32s3) 的字符串。
auto codec = board.GetAudioCodec();
audio_service_.Initialize(codec);
audio_service_.Start();
拿编解码器,初始化音频服务(创建 input/output/codec 三个任务,准备好 Opus 编解码器实例,但是不立刻开麦克风、不立刻起唤醒词)。Start() 之后那三个任务就在 RTOS 里跑了,但都还在"事件位 = 0"状态等触发。
AudioServiceCallbacks callbacks;
callbacks.on_send_queue_available = [this]() {
xEventGroupSetBits(event_group_, MAIN_EVENT_SEND_AUDIO);
};
callbacks.on_wake_word_detected = [this](const std::string& wake_word) {
xEventGroupSetBits(event_group_, MAIN_EVENT_WAKE_WORD_DETECTED);
};
callbacks.on_vad_change = [this](bool speaking) {
xEventGroupSetBits(event_group_, MAIN_EVENT_VAD_CHANGE);
};
audio_service_.SetCallbacks(callbacks);
把音频子系统的三个事件桥到主事件组:
- "send 队列里有 Opus 包了" → 主循环知道,去 Pop 然后发协议;
- "唤醒词触发了" → 主循环知道,决定切状态;
- "说话/不说话了"(VAD) → 主循环知道,去切 LED 颜色。
state_machine_.AddStateChangeListener([this](DeviceState old_state, DeviceState new_state) {
xEventGroupSetBits(event_group_, MAIN_EVENT_STATE_CHANGED);
});
状态机本身的回调也只是 set 一个 bit——具体副作用都到主循环的 HandleStateChangedEvent 里干。这一条至关重要,是这份代码并发安全的核心:状态机的 TransitionTo 可能在任何任务里被调用(音频任务、协议任务、按键任务、MCP 任务),但所有"切换到 listening 后要起音频处理任务"这种副作用,都在主循环里执行,不会跨任务并发。
esp_timer_start_periodic(clock_timer_handle_, 1000000);
启动那个 1 秒滴答 timer(参数单位是微秒,1000000us = 1s)。
auto& mcp_server = McpServer::GetInstance();
mcp_server.AddCommonTools();
mcp_server.AddUserOnlyTools();
注册 MCP 设备工具。AddCommonTools 把所有板子都有的工具(音量、灯光、电池电量、设备状态等)注册一遍;AddUserOnlyTools 注册只能给用户看(AI 不可见,audience=["user"])的工具。详见第 6 章。
board.SetNetworkEventCallback([this](NetworkEvent event, const std::string& data) {
auto display = Board::GetInstance().GetDisplay();
switch (event) {
case NetworkEvent::Scanning:
display->ShowNotification(Lang::Strings::SCANNING_WIFI, 30000);
xEventGroupSetBits(event_group_, MAIN_EVENT_NETWORK_DISCONNECTED);
break;
case NetworkEvent::Connecting: { ... }
case NetworkEvent::Connected: {
std::string msg = Lang::Strings::CONNECTED_TO;
msg += data;
display->ShowNotification(msg.c_str(), 30000);
xEventGroupSetBits(event_group_, MAIN_EVENT_NETWORK_CONNECTED);
break;
}
case NetworkEvent::Disconnected:
xEventGroupSetBits(event_group_, MAIN_EVENT_NETWORK_DISCONNECTED);
break;
// ... 4G modem 状态码:
case NetworkEvent::ModemDetecting: ...
case NetworkEvent::ModemErrorNoSim: ...
case NetworkEvent::ModemErrorRegDenied: ...
case NetworkEvent::ModemErrorInitFailed: ...
case NetworkEvent::ModemErrorTimeout: ...
}
});
注册网络事件回调。这个 lambda 会被板子里的 WiFi/4G 状态变化驱动。注意区分:
- 立刻就能在回调上下文里干的(如显示 notification、错误 alert)就直接干;
- 涉及到"切设备状态、起激活任务"等业务动作的,仍然只 set 事件位让主循环处理(如
MAIN_EVENT_NETWORK_CONNECTED)。
board.StartNetwork();
display->UpdateStatusBar(true);
}
最后调 board.StartNetwork() 让板子去连 WiFi 或拨号 4G(异步——Initialize() 不会阻塞等连上)。最后再立刻刷一次状态栏。
Initialize 之后此时此刻:
- 三种事件循环都在跑(主循环还没真正进入 Run,但马上会);
- 音频任务已起,但事件位都没置,所以麦克风没开、Opus 没编;
- 网络在异步尝试连;
- 屏幕显示 starting。
2.5 Run() 主事件循环(166-257 行)
void Application::Run() {
const EventBits_t ALL_EVENTS =
MAIN_EVENT_SCHEDULE | MAIN_EVENT_SEND_AUDIO | MAIN_EVENT_WAKE_WORD_DETECTED |
MAIN_EVENT_VAD_CHANGE | MAIN_EVENT_CLOCK_TICK | MAIN_EVENT_ERROR |
MAIN_EVENT_NETWORK_CONNECTED | MAIN_EVENT_NETWORK_DISCONNECTED |
MAIN_EVENT_TOGGLE_CHAT | MAIN_EVENT_START_LISTENING | MAIN_EVENT_STOP_LISTENING |
MAIN_EVENT_ACTIVATION_DONE | MAIN_EVENT_STATE_CHANGED;
while (true) {
auto bits = xEventGroupWaitBits(event_group_, ALL_EVENTS, pdTRUE, pdFALSE, portMAX_DELAY);
xEventGroupWaitBits(group, bitsToWaitFor, clearOnExit, waitForAll, timeout):
bitsToWaitFor = ALL_EVENTS:任何一位置位都唤醒;clearOnExit = pdTRUE:返回时自动清掉被触发的位——所以下一次 wait 不会重复消费同一事件;waitForAll = pdFALSE:OR 语义(任意一位即可,不是要等所有位都置);timeout = portMAX_DELAY:永久阻塞,没事件就睡着;- 返回值
bits:被触发的 bit 掩码,可能多位一起。
接下来按位分支处理:
if (bits & MAIN_EVENT_ERROR) {
SetDeviceState(kDeviceStateIdle);
Alert(Lang::Strings::ERROR, last_error_message_.c_str(), "circle_xmark", Lang::Sounds::OGG_EXCLAMATION);
}
错误事件:切回 idle + 弹一个 Alert(屏幕显示 + 错误提示音)。
if (bits & MAIN_EVENT_NETWORK_CONNECTED) HandleNetworkConnectedEvent();
if (bits & MAIN_EVENT_NETWORK_DISCONNECTED) HandleNetworkDisconnectedEvent();
if (bits & MAIN_EVENT_ACTIVATION_DONE) HandleActivationDoneEvent();
if (bits & MAIN_EVENT_STATE_CHANGED) HandleStateChangedEvent();
if (bits & MAIN_EVENT_TOGGLE_CHAT) HandleToggleChatEvent();
if (bits & MAIN_EVENT_START_LISTENING) HandleStartListeningEvent();
if (bits & MAIN_EVENT_STOP_LISTENING) HandleStopListeningEvent();
这一堆都是单独函数,2.6 节展开。
if (bits & MAIN_EVENT_SEND_AUDIO) {
while (auto packet = audio_service_.PopPacketFromSendQueue()) {
if (protocol_ && !protocol_->SendAudio(std::move(packet))) {
break;
}
}
}
有 Opus 包要发:循环 Pop 直到队空。SendAudio 返回 false 就停(多半是网络断了),让下次再试。注意用 std::move 转移 unique_ptr 所有权。
if (bits & MAIN_EVENT_WAKE_WORD_DETECTED) {
HandleWakeWordDetectedEvent();
}
if (bits & MAIN_EVENT_VAD_CHANGE) {
if (GetDeviceState() == kDeviceStateListening) {
auto led = Board::GetInstance().GetLed();
led->OnStateChanged();
}
}
VAD 变化只在 listening 时通知 LED("我在说话/我没在说话"两种颜色)。其它状态下 VAD 无关紧要。
if (bits & MAIN_EVENT_SCHEDULE) {
std::unique_lock<std::mutex> lock(mutex_);
auto tasks = std::move(main_tasks_);
lock.unlock();
for (auto& task : tasks) {
task();
}
}
Schedule() 队列的取出与执行:
- 拿锁,整个队列 move 出来到本地变量(清空 main_tasks_);
- 立刻放锁——执行 task 时不持有锁,避免重入死锁(task 里如果再 Schedule,能立即拿到锁追加进新一轮的 main_tasks_);
- 顺序执行所有 task。
这种"原子取出+循环执行"是无锁队列消费的经典写法。
if (bits & MAIN_EVENT_CLOCK_TICK) {
clock_ticks_++;
auto display = Board::GetInstance().GetDisplay();
display->UpdateStatusBar();
if (clock_ticks_ % 10 == 0) {
SystemInfo::PrintHeapStats();
}
}
}
}
1 秒滴答:刷状态栏;每 10 秒(10 个滴答)打印一次堆内存(看有没有内存泄漏)。
2.6 主循环里的事件处理器(一个个讲)
2.6.1 HandleNetworkConnectedEvent() (259-282)
void Application::HandleNetworkConnectedEvent() {
ESP_LOGI(TAG, "Network connected");
auto state = GetDeviceState();
if (state == kDeviceStateStarting || state == kDeviceStateWifiConfiguring) {
SetDeviceState(kDeviceStateActivating);
if (activation_task_handle_ != nullptr) {
ESP_LOGW(TAG, "Activation task already running");
return;
}
xTaskCreate([](void* arg) {
Application* app = static_cast<Application*>(arg);
app->ActivationTask();
app->activation_task_handle_ = nullptr;
vTaskDelete(NULL);
}, "activation", 4096 * 2, this, 2, &activation_task_handle_);
}
auto display = Board::GetInstance().GetDisplay();
display->UpdateStatusBar(true);
}
只有"启动中"或"WiFi 配网中"时连上网才触发激活。其它状态(比如已经在用、临时掉线又恢复)就不重启激活流程。
xTaskCreate(...) 起一个 8KB 栈的任务跑 ActivationTask(),跑完自杀(vTaskDelete(NULL))。再次注意:用无捕获 lambda 作为 C API 的回调,把 this 通过 arg 传进去。
任务优先级 2,比 Application 主任务(默认 5)低——优先级越大越优先,这样激活时如果有更紧急的事,主循环不会被阻塞。
2.6.2 HandleNetworkDisconnectedEvent() (284-295)
void Application::HandleNetworkDisconnectedEvent() {
auto state = GetDeviceState();
if (state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking) {
ESP_LOGI(TAG, "Closing audio channel due to network disconnection");
protocol_->CloseAudioChannel();
}
auto display = Board::GetInstance().GetDisplay();
display->UpdateStatusBar(true);
}
掉线时如果还在通话中,主动关闭音频通道——避免任务在那里干等服务器永远到不来的 ACK。
2.6.3 HandleActivationDoneEvent() (297-317)
void Application::HandleActivationDoneEvent() {
SystemInfo::PrintHeapStats();
SetDeviceState(kDeviceStateIdle);
has_server_time_ = ota_->HasServerTime();
auto display = Board::GetInstance().GetDisplay();
std::string message = std::string(Lang::Strings::VERSION) + ota_->GetCurrentVersion();
display->ShowNotification(message.c_str());
display->SetChatMessage("system", "");
audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS);
ota_.reset(); // ★ 释放 OTA 对象,省内存
auto& board = Board::GetInstance();
board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
}
激活成功后:
- 状态切回 idle;
- 显示版本号通知;
- 播一个"我准备好了"提示音;
ota_.reset()释放 Ota 对象——OTA 检查/激活只在启动时跑一次,之后不需要这个对象占内存;- 把功耗降回 LOW_POWER(激活过程占性能档以加速握手)。
2.6.4 ActivationTask() 后台流程 (319-334)
void Application::ActivationTask() {
ota_ = std::make_unique<Ota>();
CheckAssetsVersion();
CheckNewVersion();
InitializeProtocol();
xEventGroupSetBits(event_group_, MAIN_EVENT_ACTIVATION_DONE);
}
很简洁:
- new 一个 OTA 对象;
- 检查资源(字体/表情/唤醒词模型)有没有更新;
- 检查固件有没有新版本——这里也可能进入激活码循环(详见第 7 章);
- 初始化协议层(new 出 WebSocketProtocol 或 MqttProtocol);
- 发出
ACTIVATION_DONE事件,主循环接管。
这四步串行执行,里面可能用 vTaskDelay 等待,可能阻塞数秒,但因为在独立任务里,不影响主循环。
2.6.5 CheckAssetsVersion() (336-392)
精简流程:
- 查标志位防止重入;
- 从 NVS 命名空间
assets里读download_url——服务器握手时如果发现需要下新版资源会写进这个 key; - 有 URL 就:弹 alert → 切 upgrading 状态 → 提高功耗 → 调
assets.Download(url, on_progress),回调里把"进度% xKB/s"贴到聊天系统消息位; - 不论是不是下了新的,最后都
assets.Apply()把分区里的字体/表情/模型应用到 LVGL/ESP-SR; - 把表情设成 "microchip_ai"(一个"AI 头像")。
要点:
bool success = assets.Download(download_url, [display](int progress, size_t speed) -> void {
std::thread([display, progress, speed]() {
char buffer[32];
snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, speed / 1024);
display->SetChatMessage("system", buffer);
}).detach();
});
进度回调用 std::thread().detach()——单开一个线程更新 UI,这样下载任务自己不会被 UI 锁卡住。这里 std::thread 在 ESP-IDF 上会创建一个独立 RTOS 任务(pthread 实现)。
2.6.6 CheckNewVersion() (394-467) —— 重试退避 + 激活码循环
void Application::CheckNewVersion() {
const int MAX_RETRY = 10;
int retry_count = 0;
int retry_delay = 10;
while (true) {
esp_err_t err = ota_->CheckVersion();
if (err != ESP_OK) {
retry_count++;
if (retry_count >= MAX_RETRY) return;
// ... Alert 错误信息
for (int i = 0; i < retry_delay; i++) {
vTaskDelay(pdMS_TO_TICKS(1000));
if (GetDeviceState() == kDeviceStateIdle) break; // 用户手动退出
}
retry_delay *= 2; // ★ 指数退避
continue;
}
retry_count = 0;
retry_delay = 10;
if (ota_->HasNewVersion()) {
if (UpgradeFirmware(...)) return;
}
ota_->MarkCurrentVersionValid();
if (!ota_->HasActivationCode() && !ota_->HasActivationChallenge()) break;
if (ota_->HasActivationCode()) {
ShowActivationCode(ota_->GetActivationCode(), ota_->GetActivationMessage());
}
for (int i = 0; i < 10; ++i) {
esp_err_t err = ota_->Activate();
if (err == ESP_OK) break;
else if (err == ESP_ERR_TIMEOUT) vTaskDelay(pdMS_TO_TICKS(3000));
else vTaskDelay(pdMS_TO_TICKS(10000));
if (GetDeviceState() == kDeviceStateIdle) break;
}
}
}
学到的设计:
- 指数退避:失败一次延迟从 10 秒翻倍,避免对服务器爆雷;
- 可中断:等待过程中检测状态切回 idle 立刻退出,相当于"用户按了一下"就跳出激活循环;
- 激活码 + 挑战双轨:服务器可能给你一个 6 位激活码让用户去网页输入,或者一个加密挑战(带 HMAC)让设备直接证明自己是合法设备;两条路并存。
2.6.7 InitializeProtocol() (469-606) —— 整个项目的"接线总表"
这一段 130+ 行是理解整个项目的金钥匙:把"协议层收到东西"和"业务层该干什么"全部接起来。
void Application::InitializeProtocol() {
auto& board = Board::GetInstance();
auto display = board.GetDisplay();
auto codec = board.GetAudioCodec();
display->SetStatus(Lang::Strings::LOADING_PROTOCOL);
if (ota_->HasMqttConfig()) {
protocol_ = std::make_unique<MqttProtocol>();
} else if (ota_->HasWebsocketConfig()) {
protocol_ = std::make_unique<WebsocketProtocol>();
} else {
ESP_LOGW(TAG, "No protocol specified in the OTA config, using MQTT");
protocol_ = std::make_unique<MqttProtocol>();
}
工厂选择:握手时服务器告诉你走哪条协议,按需 new 出对应实现。protocol_ 是 unique_ptr<Protocol>,多态——后面操作的都是基类接口,不关心是 WS 还是 MQTT。
protocol_->OnConnected([this]() {
DismissAlert();
});
protocol_->OnNetworkError([this](const std::string& message) {
last_error_message_ = message;
xEventGroupSetBits(event_group_, MAIN_EVENT_ERROR);
});
连上就关掉之前的提示;出错就 set ERROR 位让主循环弹错。
protocol_->OnIncomingAudio([this](std::unique_ptr<AudioStreamPacket> packet) {
if (GetDeviceState() == kDeviceStateSpeaking) {
audio_service_.PushPacketToDecodeQueue(std::move(packet));
}
});
服务器发音频来:只有 speaking 状态才接受。这避免了刚断开但还有 buffer 包延迟到达时被错播。
protocol_->OnAudioChannelOpened([this, codec, &board]() {
board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
if (protocol_->server_sample_rate() != codec->output_sample_rate()) {
ESP_LOGW(TAG, "Server sample rate %d does not match device output sample rate %d, ...");
}
});
protocol_->OnAudioChannelClosed([this, &board]() {
board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
Schedule([this]() {
auto display = Board::GetInstance().GetDisplay();
display->SetChatMessage("system", "");
SetDeviceState(kDeviceStateIdle);
});
});
音频通道开就升性能档,关就降功耗。关闭后清屏并切 idle——但用 Schedule() 包一层,把切状态推到主循环上下文。
protocol_->OnIncomingJson([this, display](const cJSON* root) {
auto type = cJSON_GetObjectItem(root, "type");
if (strcmp(type->valuestring, "tts") == 0) {
auto state = cJSON_GetObjectItem(root, "state");
if (strcmp(state->valuestring, "start") == 0) {
Schedule([this]() {
aborted_ = false;
SetDeviceState(kDeviceStateSpeaking);
});
} else if (strcmp(state->valuestring, "stop") == 0) {
Schedule([this]() {
if (GetDeviceState() == kDeviceStateSpeaking) {
if (listening_mode_ == kListeningModeManualStop) {
SetDeviceState(kDeviceStateIdle);
} else {
SetDeviceState(kDeviceStateListening);
}
}
});
} else if (strcmp(state->valuestring, "sentence_start") == 0) {
auto text = cJSON_GetObjectItem(root, "text");
if (cJSON_IsString(text)) {
Schedule([this, display, message = std::string(text->valuestring)]() {
display->SetChatMessage("assistant", message.c_str());
});
}
}
}
...
JSON 大分发器,按 type 字段:
| type | state/data | 干什么 |
|---|---|---|
tts | start | 进 speaking 状态 |
stop | speaking 结束 → 根据模式回 idle 或 listening | |
sentence_start + text | 收到一句要说的话文本,贴到 assistant 聊天框 | |
stt | text | 用户语音被识别成的文字,贴到 user 聊天框 |
llm | emotion | 大模型推断的情绪,改表情 |
mcp | payload | 委托给 McpServer::ParseMessage(详见第 6 章) |
system | command=reboot | 服务器命令设备重启(一般用于强制 OTA 后) |
alert | status/message/emotion | 服务器要弹一条 alert |
custom(#if CONFIG_RECEIVE_CUSTOM_MESSAGE) | payload | 自定义消息,原样贴系统消息 |
注意所有改 UI/改状态的副作用都包在 Schedule() 里,因为 OnIncomingJson 回调跑在协议线程上,不能直接动状态机和 UI。
protocol_->Start();
}
最后启动协议——开始去连服务器、走鉴权握手等。Start 内部会触发上面注册的 OnConnected。
2.6.8 ShowActivationCode() (608-636) —— 把激活码读给用户听
void Application::ShowActivationCode(const std::string& code, const std::string& message) {
struct digit_sound {
char digit;
const std::string_view& sound;
};
static const std::array<digit_sound, 10> digit_sounds{{
{'0', Lang::Sounds::OGG_0}, {'1', Lang::Sounds::OGG_1},
... {'9', Lang::Sounds::OGG_9}
}};
Alert(Lang::Strings::ACTIVATION, message.c_str(), "link", Lang::Sounds::OGG_ACTIVATION);
for (const auto& digit : code) {
auto it = std::find_if(digit_sounds.begin(), digit_sounds.end(),
[digit](const digit_sound& ds) { return ds.digit == digit; });
if (it != digit_sounds.end()) {
audio_service_.PlaySound(it->sound);
}
}
}
弹 alert + 依次播每一位数字的提示音("1-2-3-4-5-6")。用 std::array + std::find_if 把数字字符映射到 OGG 资源。PlaySound 是阻塞的(会把声音排进 playback 队列等播完),所以六位数会顺序播报。
2.6.9 Alert() / DismissAlert() (638-656)
void Application::Alert(const char* status, const char* message, const char* emotion, const std::string_view& sound) {
ESP_LOGW(TAG, "Alert [%s] %s: %s", emotion, status, message);
auto display = Board::GetInstance().GetDisplay();
display->SetStatus(status);
display->SetEmotion(emotion);
display->SetChatMessage("system", message);
if (!sound.empty()) {
audio_service_.PlaySound(sound);
}
}
void Application::DismissAlert() {
if (GetDeviceState() == kDeviceStateIdle) {
auto display = Board::GetInstance().GetDisplay();
display->SetStatus(Lang::Strings::STANDBY);
display->SetEmotion("neutral");
display->SetChatMessage("system", "");
}
}
Alert 是"屏 + 表情 + 声音"四件套同时改的快捷方式;DismissAlert 只在 idle 时清掉提示(其它状态有自己的展示就不动)。
2.6.10 ToggleChatState/StartListening/StopListening (658-668) —— 线程安全入口
void Application::ToggleChatState() {
xEventGroupSetBits(event_group_, MAIN_EVENT_TOGGLE_CHAT);
}
void Application::StartListening() {
xEventGroupSetBits(event_group_, MAIN_EVENT_START_LISTENING);
}
void Application::StopListening() {
xEventGroupSetBits(event_group_, MAIN_EVENT_STOP_LISTENING);
}
就一行——把事件位置上,立即返回。从按键、MCP 工具、网页配网回调任何地方调都安全。真正的逻辑在 Handle* 里跑在主循环上下文。
2.6.11 HandleToggleChatEvent() (670-705)
void Application::HandleToggleChatEvent() {
auto state = GetDeviceState();
if (state == kDeviceStateActivating) {
SetDeviceState(kDeviceStateIdle);
return;
} else if (state == kDeviceStateWifiConfiguring) {
audio_service_.EnableAudioTesting(true);
SetDeviceState(kDeviceStateAudioTesting);
return;
} else if (state == kDeviceStateAudioTesting) {
audio_service_.EnableAudioTesting(false);
SetDeviceState(kDeviceStateWifiConfiguring);
return;
}
if (!protocol_) {
ESP_LOGE(TAG, "Protocol not initialized");
return;
}
if (state == kDeviceStateIdle) {
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
if (!protocol_->OpenAudioChannel()) return;
}
SetListeningMode(aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime);
} else if (state == kDeviceStateSpeaking) {
AbortSpeaking(kAbortReasonNone);
} else if (state == kDeviceStateListening) {
protocol_->CloseAudioChannel();
}
}
按一下按键的语义随当前状态变:
| 当前状态 | 按下按键 → |
|---|---|
| Activating | 退出激活码界面回 idle |
| WifiConfiguring | 进音频测试模式(边配网边录音测试) |
| AudioTesting | 退出音频测试 |
| Idle | 打开音频通道 + 进 listening |
| Speaking | 打断(abort) |
| Listening | 主动结束这次说话 |
SetListeningMode(aec_off ? auto : realtime):
- 无 AEC:用 AutoStop——说一句话停下就结束(VAD 控制),不能打断;
- 有 AEC:用 Realtime——可以边说边听服务器返回,类似全双工电话。
2.6.12 HandleStartListeningEvent / HandleStopListeningEvent (707-752)
跟 ToggleChat 类似但语义不同:
- Start:从 idle 强制进 ManualStop 模式(按住说话),从 speaking 也允许打断进 ManualStop;
- Stop:listening 中 → 发
SendStopListening给服务器并回 idle。
ManualStop 用于"按住说话松开停",跟物理按键配合。
2.6.13 HandleWakeWordDetectedEvent() (754-794)
void Application::HandleWakeWordDetectedEvent() {
if (!protocol_) return;
auto state = GetDeviceState();
if (state == kDeviceStateIdle) {
audio_service_.EncodeWakeWord(); // ★ 把刚才唤醒词那段 PCM 编成 Opus 留着
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
if (!protocol_->OpenAudioChannel()) {
audio_service_.EnableWakeWordDetection(true); // 恢复监听
return;
}
}
auto wake_word = audio_service_.GetLastWakeWord();
#if CONFIG_SEND_WAKE_WORD_DATA
// 把唤醒词那段音频上传,让服务器知道你说的是哪个唤醒词
while (auto packet = audio_service_.PopWakeWordPacket()) {
protocol_->SendAudio(std::move(packet));
}
protocol_->SendWakeWordDetected(wake_word);
SetListeningMode(aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime);
#else
play_popup_on_listening_ = true;
SetListeningMode(aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime);
#endif
} else if (state == kDeviceStateSpeaking) {
AbortSpeaking(kAbortReasonWakeWordDetected);
} else if (state == kDeviceStateActivating) {
SetDeviceState(kDeviceStateIdle); // 激活中喊唤醒词等于取消激活流程
}
}
要点:
EncodeWakeWord():唤醒词本来用于"判断是否要醒过来"的那段 PCM 不会被丢,而是先编成 Opus 留着。如果CONFIG_SEND_WAKE_WORD_DATA=y,连同唤醒词那一句一起上传,服务器才能做"识别说话人是谁"。- 说话中喊唤醒词 = 打断(这就是为啥能"打断小智正在说话")。
- 激活中喊唤醒词 = 取消激活界面回 idle。
play_popup_on_listening_ 这个 bool 是个延迟标志:进 listening 状态后 HandleStateChangedEvent 里才播 popup 音效,而不是在这里就播——因为 EnableVoiceProcessing(true) 内部会 ResetDecoder() 把队列清空,提前播的音会被清掉。这是一个"按事件先后顺序协调副作用"的小巧设计。
2.6.14 HandleStateChangedEvent() (796-854) —— 状态切换的"真正动作"
void Application::HandleStateChangedEvent() {
DeviceState new_state = state_machine_.GetState();
clock_ticks_ = 0;
auto& board = Board::GetInstance();
auto display = board.GetDisplay();
auto led = board.GetLed();
led->OnStateChanged(); // 灯先变颜色
switch (new_state) {
case kDeviceStateUnknown:
case kDeviceStateIdle:
display->SetStatus(Lang::Strings::STANDBY);
display->SetEmotion("neutral");
audio_service_.EnableVoiceProcessing(false);
audio_service_.EnableWakeWordDetection(true); // ★ 重新开唤醒词
break;
case kDeviceStateConnecting:
display->SetStatus(Lang::Strings::CONNECTING);
display->SetEmotion("neutral");
display->SetChatMessage("system", "");
break;
case kDeviceStateListening:
display->SetStatus(Lang::Strings::LISTENING);
display->SetEmotion("neutral");
if (!audio_service_.IsAudioProcessorRunning()) {
protocol_->SendStartListening(listening_mode_);
audio_service_.EnableVoiceProcessing(true); // ★ 起 AFE
audio_service_.EnableWakeWordDetection(false); // 听用户说话期间关闭唤醒检测
}
if (play_popup_on_listening_) {
play_popup_on_listening_ = false;
audio_service_.PlaySound(Lang::Sounds::OGG_POPUP); // "叮"
}
break;
case kDeviceStateSpeaking:
display->SetStatus(Lang::Strings::SPEAKING);
if (listening_mode_ != kListeningModeRealtime) {
audio_service_.EnableVoiceProcessing(false);
// 即使 speaking 也允许 AFE 唤醒词("小智停一下"),但不允许 ESP-SR 兜底唤醒词
audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
}
audio_service_.ResetDecoder(); // 清掉播放队列残留
break;
case kDeviceStateWifiConfiguring:
audio_service_.EnableVoiceProcessing(false);
audio_service_.EnableWakeWordDetection(false);
break;
default:
break;
}
}
这是把状态变化映射到三个子系统(LED / Display / AudioService)的中央处理器。把整段读懂就基本掌握了项目核心:
- 状态机管 "what state am I in";
- HandleStateChangedEvent 管 "what should I do when entering this state";
- 三个子系统 (LED、Display、AudioService) 各管自己的细节。
把"几个状态下分别该做什么"放在一处,状态机以外的代码不用知道这些细节——经典分层解耦。
2.6.15 Schedule() (856-862) —— 推任务到主循环
void Application::Schedule(std::function<void()>&& callback) {
{
std::lock_guard<std::mutex> lock(mutex_);
main_tasks_.push_back(std::move(callback));
}
xEventGroupSetBits(event_group_, MAIN_EVENT_SCHEDULE);
}
- 拿锁、push、放锁,然后置位;
- 调用方可以是任意 task,绝对线程安全;
- 主循环看到
MAIN_EVENT_SCHEDULE就批量取出执行。
这是整份代码处理"我想在主任务上跑一段代码"的统一入口。配合 lambda 用起来非常顺手。
2.6.16 其余工具方法
AbortSpeaking(reason):发协议 abort 消息。
SetListeningMode(mode):改 mode 后直接 SetDeviceState(kDeviceStateListening)——状态机 listener 会触发 HandleStateChangedEvent 把音频处理跑起来。
Reboot():
void Application::Reboot() {
if (protocol_ && protocol_->IsAudioChannelOpened()) {
protocol_->CloseAudioChannel();
}
protocol_.reset();
audio_service_.Stop();
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
}
清理 → 等 1 秒 → 调 ESP-IDF 的 esp_restart() 复位 SoC。
UpgradeFirmware(url, version) (890-940):
- 弹"正在升级"alert,播音效;
- 切到 upgrading 状态;
- 性能档 PERFORMANCE;
- 停掉音频服务(节省内存给 OTA 流);
- 调
Ota::Upgrade(url, on_progress)同步等下载完; - 成功:重启;失败:恢复音频服务、降功耗、弹错。
WakeWordInvoke(wake_word) (942-986):让外部代码(按键、MCP 工具)模拟一次唤醒。因为可能从任意上下文调,里面对状态切换都用 Schedule() 包一层。
CanEnterSleepMode() (988-1003):状态 idle 且音频通道关 且音频任务空闲,才能安全进深度睡眠。给板子的 power_save_timer 用。
SendMcpMessage(payload) (1005-1012):
void Application::SendMcpMessage(const std::string& payload) {
Schedule([this, payload = std::move(payload)]() {
if (protocol_) {
protocol_->SendMcpMessage(payload);
}
});
}
被 McpServer 调用——发本地工具回应给服务器。用 Schedule() 是为了线程安全。lambda 捕获用 payload = std::move(payload) 是 C++14 init capture,避免拷贝大字符串。
SetAecMode(mode) (1014-1039):切 AEC 模式时同时更新音频处理器配置 + 改 UI 通知 + 关闭当前音频通道(让下一次开会用新模式)。
PlaySound(sound) (1041-1043):简单透传到 audio_service。
ResetProtocol() (1045-1054):
void Application::ResetProtocol() {
Schedule([this]() {
if (protocol_ && protocol_->IsAudioChannelOpened()) {
protocol_->CloseAudioChannel();
}
protocol_.reset();
});
}
紧急情况下从任意地方释放协议资源(比如要切换协议、要省内存做 OTA 等)。
2.7 本章用到的核心技术汇总
| 技术 | 应用 |
|---|---|
| C++11 magic static | Application::GetInstance() 单例 |
std::unique_ptr<Base> | protocol_(多态指针)+ ota_(生命周期短) |
std::function + lambda | 大量回调挂钩、Schedule() 队列、init capture(C++14) |
std::move | 转移 unique_ptr<AudioStreamPacket> 所有权 |
| FreeRTOS EventGroup | 13 个事件位驱动整个主循环 |
| FreeRTOS xTaskCreate / vTaskDelete | 激活后台任务 |
| esp_timer 周期定时 | 1 秒滴答 |
std::mutex + lock_guard + unique_lock | main_tasks_ 队列保护、"原子取出+无锁执行" |
| C++ 抽象基类 + 多态 | Protocol、AudioCodec、Display、Led 等 |
| 观察者模式 | 状态机 listener |
| RAII | TaskPriorityReset、DisplayLockGuard |
| JSON 解析(cJSON) | 解析所有控制信令 |
| 预处理器条件编译 | #if CONFIG_USE_DEVICE_AEC、#if CONFIG_SEND_WAKE_WORD_DATA、#if CONFIG_RECEIVE_CUSTOM_MESSAGE |
extern "C" | app_main 兼容 ESP-IDF |
| 指数退避 | CheckNewVersion 重试 |
| 多态指针 + 工厂选择 | InitializeProtocol 决定 new WS 还是 MQTT |
2.8 看完本章你应该掌握的
app_main30 行做了哪三件事- Application 单例的 13 个事件位各自代表什么、谁会触发、主循环怎么处理
- Initialize 的 9 步初始化顺序,每一步要点
- Run 主循环的事件分发机制,为什么这种"set 位 + 主循环统一处理"的设计能避免大量锁
- ActivationTask 的串行流程(资源更新 → 版本检查 → 激活码 → 初始化协议)
- InitializeProtocol 的 8 个回调挂钩——这是项目最重要的一段
- HandleStateChangedEvent 把每个状态下三个子系统应该处于什么状态写得很清楚
Schedule()这个机制的意义——任何后台任务想动主任务的资源都用它- AEC 模式如何决定 ListeningMode(auto vs realtime),并影响是否允许打断
下一章进入 device_state_machine.{h,cc},把这台状态机本身彻底拆开。