第 3 章 设备状态机:device_state.h + device_state_machine.{h,cc}
三份文件加起来 261 行,是整个项目的"红绿灯指挥官"。本章把状态枚举、合法转换表、观察者机制、线程安全设计、和
Application怎么用它讲清楚。
3.1 三份文件的角色
| 文件 | 行数 | 角色 |
|---|---|---|
device_state.h | 17 | 只放一个 enum DeviceState,11 个值 |
device_state_machine.h | 83 | 状态机类声明 |
device_state_machine.cc | 161 | 状态机实现(转换表 + 观察者通知) |
为什么把 enum 单独拆个头?因为很多地方只用 enum 不用 StateMachine(比如 application.h 的 GetDeviceState() 返回类型只需要 enum,不想 include 整个 StateMachine)——最小化头文件依赖。
3.2 device_state.h —— 11 个状态枚举
enum DeviceState {
kDeviceStateUnknown,
kDeviceStateStarting,
kDeviceStateWifiConfiguring,
kDeviceStateIdle,
kDeviceStateConnecting,
kDeviceStateListening,
kDeviceStateSpeaking,
kDeviceStateUpgrading,
kDeviceStateActivating,
kDeviceStateAudioTesting,
kDeviceStateFatalError
};
| 值 | 含义 | 设备此时在干什么 |
|---|---|---|
Unknown | 刚 new 出来,从未推进过 | 状态机初始值,立即应被改成 Starting |
Starting | 启动中 | NVS 已初始化、屏幕已开,等网络 |
WifiConfiguring | WiFi 配网中 | 设备建 AP 或开 BluFi,用户在手机/网页填 SSID + 密码 |
Idle | 待机 | 等唤醒词,可以休眠 |
Connecting | 正在连音频通道 | 主动调 OpenAudioChannel() 后到收到 OnConnected 之前 |
Listening | 听用户说话 | MIC 开、AFE 处理、Opus 编码、上传 |
Speaking | 设备说话(播 TTS) | 网络下载 Opus、解码、I²S 喂喇叭 |
Upgrading | 升级中 | OTA 下载新固件 或 下载新 assets.bin |
Activating | 激活中 | 跑 ActivationTask(拉配置、显示激活码) |
AudioTesting | 音频测试 | 用于配网期间录音回放,看 MIC/喇叭通了没 |
FatalError | 致命错误 | 单向状态——一旦进入再也出不来 |
注意命名风格:Google C++ 风格的 k 前缀 + CamelCase。整个项目 enum 都这么命名。
3.3 device_state_machine.h 类声明
完整 83 行已在第 1 章引用过。这里只点关键设计:
3.3.1 公开接口
DeviceState GetState() const; // 读当前状态
bool TransitionTo(DeviceState new_state); // 尝试切换
bool CanTransitionTo(DeviceState target) const; // 试探:能切吗
using StateCallback = std::function<void(DeviceState, DeviceState)>;
int AddStateChangeListener(StateCallback callback); // 加监听者,返 id
void RemoveStateChangeListener(int listener_id); // 按 id 移除
static const char* GetStateName(DeviceState state); // 调试打印
3.3.2 私有成员
std::atomic<DeviceState> current_state_{kDeviceStateUnknown};
std::vector<std::pair<int, StateCallback>> listeners_;
int next_listener_id_{0};
std::mutex mutex_;
设计要点:
- 状态本身用
std::atomic<DeviceState>包起来——读GetState()不加锁、不阻塞,任意任务都能高频读。 - 监听者列表用 mutex 保护,因为可能边遍历边添加/删除。
- 监听者 ID 用一个自增整数,加监听者时返回 id;移除时按 id 删。这种 ID 模式比传函数指针/lambda hash 简单。
StateCallback是std::function<void(old, new)>——签名固定,回调函数可以是 lambda、可以是 bound member function、可以是普通函数指针。
3.3.3 私有方法
bool IsValidTransition(DeviceState from, DeviceState to) const;
void NotifyStateChange(DeviceState old_state, DeviceState new_state);
转换合法性表 + 通知所有监听者。
3.4 device_state_machine.cc 逐段拆解
3.4.1 状态名字符串表(9-22 行)
static const char* const STATE_STRINGS[] = {
"unknown", "starting", "wifi_configuring", "idle", "connecting",
"listening", "speaking", "upgrading", "activating", "audio_testing",
"fatal_error", "invalid_state"
};
11 个状态对应 11 个字符串,再加一个 invalid_state 兜底。
const char* DeviceStateMachine::GetStateName(DeviceState state) {
if (state >= 0 && state <= kDeviceStateFatalError) {
return STATE_STRINGS[state];
}
return STATE_STRINGS[kDeviceStateFatalError + 1];
}
边界检查很谨慎:枚举值可能被强转过来不合法,所以做了上下界检查。注意 enum 默认从 0 开始递增,所以 static_cast<int>(kDeviceStateFatalError) 就是 10,下标取到 STATE_STRINGS[10] = "fatal_error"。
3.4.2 合法转换表(34-102 行)—— 整个状态机的核心
bool DeviceStateMachine::IsValidTransition(DeviceState from, DeviceState to) const {
if (from == to) return true; // ★ 同状态视为合法的 no-op
switch (from) {
case kDeviceStateUnknown:
return to == kDeviceStateStarting;
case kDeviceStateStarting:
return to == kDeviceStateWifiConfiguring ||
to == kDeviceStateActivating;
case kDeviceStateWifiConfiguring:
return to == kDeviceStateActivating ||
to == kDeviceStateAudioTesting;
case kDeviceStateAudioTesting:
return to == kDeviceStateWifiConfiguring;
case kDeviceStateActivating:
return to == kDeviceStateUpgrading ||
to == kDeviceStateIdle ||
to == kDeviceStateWifiConfiguring;
case kDeviceStateUpgrading:
return to == kDeviceStateIdle ||
to == kDeviceStateActivating;
case kDeviceStateIdle:
return to == kDeviceStateConnecting ||
to == kDeviceStateListening ||
to == kDeviceStateSpeaking ||
to == kDeviceStateActivating ||
to == kDeviceStateUpgrading ||
to == kDeviceStateWifiConfiguring;
case kDeviceStateConnecting:
return to == kDeviceStateIdle ||
to == kDeviceStateListening;
case kDeviceStateListening:
return to == kDeviceStateSpeaking ||
to == kDeviceStateIdle;
case kDeviceStateSpeaking:
return to == kDeviceStateListening ||
to == kDeviceStateIdle;
case kDeviceStateFatalError:
return false; // ★ 单向死状态
default:
return false;
}
}
这就是状态转换图的代码表达:
┌───────────┐
│ Unknown │
└─────┬─────┘
▼
┌───────────┐
│ Starting │
└────┬──┬───┘
│ │
┌──────────┘ └────────────┐
▼ ▼
┌───────────────────┐ ┌──────────────┐
│ WifiConfiguring │◄───┤ Activating │
│ │ │ │
│ ◄─►AudioTesting │ │ │
└────────┬──────────┘ └──────┬───────┘
│ │
└──────┐ ┌─────┘
▼ ▼
┌───────────────┐
│ Upgrading │ ──► ?
└───────┬───────┘
▼
┌───────────────┐
│ Idle │ ◄──┐
└──┬──┬──┬──┬───┘ │
│ │ │ │ │
┌────┘ │ │ └─────►Activating
▼ ▼ ▼ Upgrading
Connecting Listening Speaking
│ │ │
└────► Listening◄──┘
│ │ ▲
│ ▼ │
Speaking
(Idle ←─ Listening / Speaking)
┌───────────┐
│ FatalError│ ◄── 任何地方(但代码里目前没人去这里)
└───────────┘ (单向死状态)
要点:
- 没人能进入 FatalError 的代码路径——这个状态在代码里被定义但目前没被设置过,是给以后留的"出大事就锁死设备"的逃生口。
- 从 idle 出口最多——idle 是中心枢纽,6 种合法去向。
- listening ⇄ speaking 可以双向跳——支持打断(说话时被新唤醒进 listening)和半双工对话。
- WifiConfiguring 和 AudioTesting 互相切——用于配网时录音测试场景。
- Activating → WifiConfiguring:激活时如果发现配置丢失/无效,回去重新配网。
- Upgrading → Activating:升级失败后回去重试激活。
设计哲学:用一张表写死所有合法转换比"在每个调用点判断"清晰得多。后人想改流程只看这一张表即可。
3.4.3 TransitionTo 实现(108-131 行)
bool DeviceStateMachine::TransitionTo(DeviceState new_state) {
DeviceState old_state = current_state_.load();
if (old_state == new_state) return true; // 同状态 no-op
if (!IsValidTransition(old_state, new_state)) {
ESP_LOGW(TAG, "Invalid state transition: %s -> %s",
GetStateName(old_state), GetStateName(new_state));
return false;
}
current_state_.store(new_state);
ESP_LOGI(TAG, "State: %s -> %s",
GetStateName(old_state), GetStateName(new_state));
NotifyStateChange(old_state, new_state);
return true;
}
逐行:
current_state_.load()atomic 读,无锁;- 同状态直接成功——
Application::SetDeviceState(state)经常重复调,避免重复打日志和触发回调; - 合法性检查不通过:打 WARN 日志、返回 false(调用方应该自己看返回值,但代码里大多数地方都没看——因为合法的状态序列都是经过 application.cc 设计好的,意外非法多半是 bug);
current_state_.store(new_state)atomic 写;- 打 INFO 日志,记录"X -> Y";
- 调
NotifyStateChange通知监听者。
潜在并发问题:第 1 步读和第 4 步写之间,可能有别的任务也在 TransitionTo。但是项目里几乎所有 SetDeviceState 调用都在主循环上下文里(通过 Schedule()),所以实际不会真的并发。即使并发,atomic 的 load/store 也不会出现"撕裂值"。
3.4.4 监听者增删(133-146 行)
int DeviceStateMachine::AddStateChangeListener(StateCallback callback) {
std::lock_guard<std::mutex> lock(mutex_);
int id = next_listener_id_++;
listeners_.emplace_back(id, std::move(callback));
return id;
}
void DeviceStateMachine::RemoveStateChangeListener(int listener_id) {
std::lock_guard<std::mutex> lock(mutex_);
listeners_.erase(
std::remove_if(listeners_.begin(), listeners_.end(),
[listener_id](const auto& p) { return p.first == listener_id; }),
listeners_.end());
}
经典的 erase-remove idiom——std::remove_if 把不满足条件的元素往前挪、要删的元素挪到末尾,返回新逻辑末尾的迭代器,然后 erase 真正删除尾巴。
emplace_back(id, std::move(callback)) 直接在 vector 末尾就地构造一个 pair<int, StateCallback>,少一次拷贝。
3.4.5 通知监听者(148-161 行)—— 解锁后调用,避免回调死锁
void DeviceStateMachine::NotifyStateChange(DeviceState old_state, DeviceState new_state) {
std::vector<StateCallback> callbacks_copy;
{
std::lock_guard<std::mutex> lock(mutex_);
callbacks_copy.reserve(listeners_.size());
for (const auto& [id, cb] : listeners_) {
callbacks_copy.push_back(cb);
}
}
for (const auto& cb : callbacks_copy) {
cb(old_state, new_state);
}
}
这是状态机最值得学的设计:
- 第一段:拿锁、复制一份所有回调到本地 vector、放锁;
- 第二段:在没有锁的情况下挨个调用回调。
为什么不能持锁回调?因为回调里可能再次进入状态机(比如调 AddStateChangeListener 或 TransitionTo),就会自己锁自己(mutex 不可重入)。复制一份后释放锁,回调里随便玩。
代价:每次状态变要复制 N 个 std::function 对象。但这里 N 一般只有 1-2 个(实际上 Application 里只有一个监听者),代价可忽略。
如果项目里监听者很多,可以换 shared_ptr<vector> 的 copy-on-write 优化。这里没必要——简单优先。
for (const auto& [id, cb] : listeners_) 用 C++17 结构化绑定展开 pair。
3.5 状态机怎么被 Application 用?
回顾第 2 章的几个关键点,串起来理解:
3.5.1 注册监听者(application.cc:89-91)
state_machine_.AddStateChangeListener([this](DeviceState old_state, DeviceState new_state) {
xEventGroupSetBits(event_group_, MAIN_EVENT_STATE_CHANGED);
});
Application 只有一个监听者——而且它做的事极其简单:set 一个事件位。真正的副作用(点灯、起音频任务、刷 UI)都在 HandleStateChangedEvent 里跑在主循环上下文中。
这种"监听者只翻译事件、不做副作用"的设计是状态机能在任意任务里被调用而不出问题的关键。
3.5.2 提交状态变化(application.cc:57-59)
bool Application::SetDeviceState(DeviceState state) {
return state_machine_.TransitionTo(state);
}
只是个 trampoline,方便外面调用。返回 false 也很少有人 check(合法转换由开发者保证)。
3.5.3 全项目的 SetDeviceState 调用清单
| 在哪里 | 干什么 |
|---|---|
application.cc::Initialize 开头 | kDeviceStateStarting |
HandleNetworkConnectedEvent | kDeviceStateActivating(开始激活) |
HandleActivationDoneEvent | kDeviceStateIdle(激活完进待机) |
CheckAssetsVersion | kDeviceStateUpgrading / kDeviceStateActivating(下资源) |
HandleToggleChat/StartListeningEvent(在 Idle 时) | kDeviceStateConnecting(连音频通道) |
OnIncomingJson tts:start | kDeviceStateSpeaking |
OnIncomingJson tts:stop | kDeviceStateIdle 或 kDeviceStateListening |
OnAudioChannelClosed | kDeviceStateIdle(通道断了回待机) |
HandleErrorEvent | kDeviceStateIdle(出错回待机) |
SetListeningMode | kDeviceStateListening(每次设 mode 后必进 listening) |
HandleStopListeningEvent(在 Listening 时) | kDeviceStateIdle |
HandleWakeWordDetectedEvent(在 Activating 时) | kDeviceStateIdle(取消激活) |
UpgradeFirmware | kDeviceStateUpgrading |
每一处状态变化都有明确语义。建议读到任何 SetDeviceState(...) 时回到第 3.4.2 节那张转换表,对一下"现在在哪、能不能去那"。
3.6 状态机用到的技术清单
| 技术 | 应用 |
|---|---|
enum + 命名约定 | 11 个状态 |
std::atomic<T> | 无锁状态读写 |
std::function<> + lambda | 监听者回调 |
std::vector<std::pair<id, callback>> | 监听者表 |
std::mutex + std::lock_guard | 监听者表互斥 |
| erase-remove idiom | 删除特定 id |
| C++17 结构化绑定 | for (const auto& [id, cb] : listeners_) |
| switch case 合法转换表 | 状态机核心 |
| 解锁后回调 | 防止 mutex 重入 |
| 单向死状态 | FatalError |
| 同状态 no-op | 重复 set 不重复触发 |
ESP_LOGI/W | 标准 ESP-IDF 日志,状态变化全程可见 |
3.7 一段典型日志(帮你理解整条链路)
打开 ESP-IDF monitor,正常使用一次(说一句"你好小智",收到回答)会看到大概这样的状态日志:
I StateMachine: State: unknown -> starting
I StateMachine: State: starting -> activating
I StateMachine: State: activating -> idle
I Application: Wake word detected: 你好小智
I StateMachine: State: idle -> connecting
I StateMachine: State: connecting -> listening
>> 你好小智
I StateMachine: State: listening -> speaking
<< 你好,有什么可以帮你的?
I StateMachine: State: speaking -> idle (服务器发完 tts:stop)
排查 bug 时最先看这一行日志——状态没切到预期值,就是状态机层面的问题。
3.8 看完本章你应该掌握的
- 11 个状态各代表什么
- 合法转换表(能默写主干)
- 状态机为什么用
atomic + 解锁后回调(线程安全 + 避免重入) - 监听者列表用 erase-remove + id 管理
- Application 唯一的监听者只做"翻译成事件位"这件事,副作用都在主循环里
- 看到一行
State: X -> Y日志知道接下来HandleStateChangedEvent会做什么
下一章进入 audio/ 子系统——整个项目最复杂、最有学习价值的部分(686 行的 audio_service.cc + 多种唤醒词/AFE/codec 实现)。