跳到主要内容

第 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)宏,错误时打印错误码并 abortESP-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.hFreeRTOS 事件组 API(13 个事件位都在这上面)
task.hxTaskCreate / vTaskDelete / vTaskDelay
esp_timer.h1 秒滴答 timer
<mutex> <deque>Schedule() 把 lambda 推进 main_tasks_ 队列
protocol.hProtocol 基类
ota.hOta(激活、升级)
audio_service.hAudioService
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 这一位主循环收到后干什么
SCHEDULESchedule(lambda) 调用方取出 main_tasks_ 里全部 lambda 串行执行
SEND_AUDIOAudioService 编码完一帧audio_send_queue_ 拿 Opus 包发给协议
WAKE_WORD_DETECTEDWakeWord 模型检测到唤醒进入连接/监听
VAD_CHANGEAFE 检测到"开始说话"或"结束说话"通知 LED 变颜色
ERROR协议层报网络错误切回 idle + 弹错提示
ACTIVATION_DONE激活后台任务跑完进入正常工作流
CLOCK_TICKesp_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_;

设计要点:

  1. audio_service_state_machine_ 直接持有(不是 unique_ptr)——构造 Application 时它们一起 new,析构时一起 destroy。它俩生命期等于程序生命期。
  2. protocol_ota_unique_ptr——前者要等握手拿到配置才能决定 new WS 还是 MQTT;后者激活完就释放(OTA 信息只在激活时有用,丢掉省内存)。
  3. 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 → startingSetDeviceState 内部调状态机的 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 = pdFALSEOR 语义(任意一位即可,不是要等所有位都置);
  • 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() 队列的取出与执行

  1. 拿锁,整个队列 move 出来到本地变量(清空 main_tasks_);
  2. 立刻放锁——执行 task 时不持有锁,避免重入死锁(task 里如果再 Schedule,能立即拿到锁追加进新一轮的 main_tasks_);
  3. 顺序执行所有 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);
}

很简洁:

  1. new 一个 OTA 对象;
  2. 检查资源(字体/表情/唤醒词模型)有没有更新;
  3. 检查固件有没有新版本——这里也可能进入激活码循环(详见第 7 章);
  4. 初始化协议层(new 出 WebSocketProtocol 或 MqttProtocol);
  5. 发出 ACTIVATION_DONE 事件,主循环接管。

这四步串行执行,里面可能用 vTaskDelay 等待,可能阻塞数秒,但因为在独立任务里,不影响主循环。

2.6.5 CheckAssetsVersion() (336-392)

精简流程:

  1. 查标志位防止重入;
  2. 从 NVS 命名空间 assets 里读 download_url——服务器握手时如果发现需要下新版资源会写进这个 key;
  3. 有 URL 就:弹 alert → 切 upgrading 状态 → 提高功耗 → 调 assets.Download(url, on_progress),回调里把"进度% xKB/s"贴到聊天系统消息位;
  4. 不论是不是下了新的,最后都 assets.Apply() 把分区里的字体/表情/模型应用到 LVGL/ESP-SR;
  5. 把表情设成 "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 字段:

typestate/data干什么
ttsstart进 speaking 状态
stopspeaking 结束 → 根据模式回 idle 或 listening
sentence_start + text收到一句要说的话文本,贴到 assistant 聊天框
stttext用户语音被识别成的文字,贴到 user 聊天框
llmemotion大模型推断的情绪,改表情
mcppayload委托给 McpServer::ParseMessage(详见第 6 章)
systemcommand=reboot服务器命令设备重启(一般用于强制 OTA 后)
alertstatus/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 staticApplication::GetInstance() 单例
std::unique_ptr<Base>protocol_(多态指针)+ ota_(生命周期短)
std::function + lambda大量回调挂钩、Schedule() 队列、init capture(C++14)
std::move转移 unique_ptr<AudioStreamPacket> 所有权
FreeRTOS EventGroup13 个事件位驱动整个主循环
FreeRTOS xTaskCreate / vTaskDelete激活后台任务
esp_timer 周期定时1 秒滴答
std::mutex + lock_guard + unique_lockmain_tasks_ 队列保护、"原子取出+无锁执行"
C++ 抽象基类 + 多态ProtocolAudioCodecDisplayLed
观察者模式状态机 listener
RAIITaskPriorityResetDisplayLockGuard
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_main 30 行做了哪三件事
  • Application 单例的 13 个事件位各自代表什么、谁会触发、主循环怎么处理
  • Initialize 的 9 步初始化顺序,每一步要点
  • Run 主循环的事件分发机制,为什么这种"set 位 + 主循环统一处理"的设计能避免大量锁
  • ActivationTask 的串行流程(资源更新 → 版本检查 → 激活码 → 初始化协议)
  • InitializeProtocol 的 8 个回调挂钩——这是项目最重要的一段
  • HandleStateChangedEvent 把每个状态下三个子系统应该处于什么状态写得很清楚
  • Schedule() 这个机制的意义——任何后台任务想动主任务的资源都用它
  • AEC 模式如何决定 ListeningMode(auto vs realtime),并影响是否允许打断

下一章进入 device_state_machine.{h,cc},把这台状态机本身彻底拆开。