跳到主要内容

第 3 章 设备状态机:device_state.h + device_state_machine.{h,cc}

三份文件加起来 261 行,是整个项目的"红绿灯指挥官"。本章把状态枚举、合法转换表、观察者机制、线程安全设计、和 Application 怎么用它讲清楚。


3.1 三份文件的角色

文件行数角色
device_state.h17只放一个 enum DeviceState,11 个值
device_state_machine.h83状态机类声明
device_state_machine.cc161状态机实现(转换表 + 观察者通知)

为什么把 enum 单独拆个头?因为很多地方只用 enum 不用 StateMachine(比如 application.hGetDeviceState() 返回类型只需要 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 已初始化、屏幕已开,等网络
WifiConfiguringWiFi 配网中设备建 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_;

设计要点:

  1. 状态本身用 std::atomic<DeviceState> 包起来——读 GetState() 不加锁、不阻塞,任意任务都能高频读。
  2. 监听者列表用 mutex 保护,因为可能边遍历边添加/删除。
  3. 监听者 ID 用一个自增整数,加监听者时返回 id;移除时按 id 删。这种 ID 模式比传函数指针/lambda hash 简单。
  4. StateCallbackstd::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│ ◄── 任何地方(但代码里目前没人去这里)
└───────────┘ (单向死状态)

要点:

  1. 没人能进入 FatalError 的代码路径——这个状态在代码里被定义但目前没被设置过,是给以后留的"出大事就锁死设备"的逃生口。
  2. 从 idle 出口最多——idle 是中心枢纽,6 种合法去向。
  3. listening ⇄ speaking 可以双向跳——支持打断(说话时被新唤醒进 listening)和半双工对话。
  4. WifiConfiguring 和 AudioTesting 互相切——用于配网时录音测试场景。
  5. Activating → WifiConfiguring:激活时如果发现配置丢失/无效,回去重新配网。
  6. 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;
}

逐行:

  1. current_state_.load() atomic 读,无锁;
  2. 同状态直接成功——Application::SetDeviceState(state) 经常重复调,避免重复打日志和触发回调;
  3. 合法性检查不通过:打 WARN 日志、返回 false(调用方应该自己看返回值,但代码里大多数地方都没看——因为合法的状态序列都是经过 application.cc 设计好的,意外非法多半是 bug);
  4. current_state_.store(new_state) atomic 写;
  5. 打 INFO 日志,记录"X -> Y";
  6. 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、放锁;
  • 第二段:在没有锁的情况下挨个调用回调。

为什么不能持锁回调?因为回调里可能再次进入状态机(比如调 AddStateChangeListenerTransitionTo),就会自己锁自己(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
HandleNetworkConnectedEventkDeviceStateActivating(开始激活)
HandleActivationDoneEventkDeviceStateIdle(激活完进待机)
CheckAssetsVersionkDeviceStateUpgrading / kDeviceStateActivating(下资源)
HandleToggleChat/StartListeningEvent(在 Idle 时)kDeviceStateConnecting(连音频通道)
OnIncomingJson tts:startkDeviceStateSpeaking
OnIncomingJson tts:stopkDeviceStateIdlekDeviceStateListening
OnAudioChannelClosedkDeviceStateIdle(通道断了回待机)
HandleErrorEventkDeviceStateIdle(出错回待机)
SetListeningModekDeviceStateListening(每次设 mode 后必进 listening)
HandleStopListeningEvent(在 Listening 时)kDeviceStateIdle
HandleWakeWordDetectedEvent(在 Activating 时)kDeviceStateIdle(取消激活)
UpgradeFirmwarekDeviceStateUpgrading

每一处状态变化都有明确语义。建议读到任何 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 实现)。