diff --git a/README.md b/README.md
index d4760f9..b02a601 100644
--- a/README.md
+++ b/README.md
@@ -193,6 +193,67 @@ USB 数据线 D+_OUT/D-_OUT 经 USBLC6-2SC6 ESD 后直连 ESP32-C3 原生 USB (I
---
+## 状态机架构
+
+自 v1.0-34 起,设备运行逻辑由 **5 个正交 (orthogonal) 有限状态机** 管理。每个 FSM 维护自己的状态,通过事件路由器解耦。
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ main loop (1Hz tick) │
+│ button bits / wifi queue / audio queue / EVT_TICK_1HZ │
+│ │ │
+│ route_event() │
+│ (event_router) │
+│ ┌────────┬───────┼───────┬──────────┐ │
+│ ▼ ▼ ▼ ▼ ▼ │
+│ wake_fsm sys_fsm net_fsm audio_fsm display_fsm │
+│ (6状态) (3状态) (5状态) (6状态) (3状态) │
+│ │ │ │ │ │ │
+│ └────────┴───────┼───────┴──────────┘ │
+│ ▼ │
+│ apply_actions() │
+│ (executor) │
+│ audio_init / audio_play / wifi_connect / ... │
+└─────────────────────────────────────────────────────────┘
+```
+
+### 5 个 FSM 职责
+
+| FSM | 状态 | 典型转换 |
+|-----|------|---------|
+| **wake_fsm** | DORMANT / FROM_BTN / FROM_RTC / FROM_SYS / ALARM_RINGING / GOTO_SLEEP | 冷启动检测唤醒源;RTC 唤醒强制 auto-play;用户关闹钟退回 FROM_BTN |
+| **sys_fsm** | BOOT / NORMAL / SLEEPING | BOOT→NORMAL(主循环就绪);BTN_SLEEP_PRESS→SLEEPING+deep sleep;工厂复位 |
+| **net_fsm** | OFFLINE / PROVISIONING / CONNECTING / CONNECTED / FAILED | 无凭据→PROVISIONING;IP_GOT→CONNECTED;断开→CONNECTING;30s超时→FAILED |
+| **audio_fsm** | IDLE / PENDING / INIT / PLAYING / STOPPING / ERROR | 左键短按→INIT→PLAYING;待WiFi→PENDING;停止→STOPPING→IDLE;自然播完→IDLE(自动切歌) |
+| **display_fsm** | DAY / NIGHT_AUTO / NIGHT_FORCED | 时间穿越夜间时段→NIGHT_AUTO;长按右键→强制切换 |
+
+### 事件路由器
+
+22 种原始事件 (`app_event_t`) 通过 `route_event()` 分发到 5 个区域:
+- **1-1 映射**:大部分按钮事件只到单一区域 (如 `AUDIO_TOGGLE` → `audio_fsm`)
+- **fan-out**:`EVT_TICK_1HZ` 到全部 5 个区域,`EVT_IP_GOT` 同时到 `net_fsm`+`audio_fsm` (PENDING→INIT)
+- **条件 fan-out**:`EVT_BOOT_DONE` 只在 `wake=FROM_RTC` 时才给 `audio_fsm` 发 `AUTO_PLAY_REQUEST`
+
+### 执行器
+
+FSM step 返回的动作列表 (`fsm_actions_t`) 由 `apply_actions()` 串行执行,顺序固定:**wake → sys → net → audio → display**。
+
+### 测试
+
+每个区域 FSM 的主机端单元测试位于 `tests/test_*_fsm.c`,直接链接生产代码 (`main/regions/*_fsm.c`),不做镜像复制。运行方式:
+
+```bash
+make -C tests test # 8/8 测试套件,109 个用例
+```
+
+相关文件:
+- `main/app_fsm.h` — 共享类型 (`app_event_t`, `app_input_t`, `fsm_action_t`)
+- `main/event_router.h/.c` — 事件路由
+- `main/regions/{wake,sys,net,audio,display}_fsm.h/.c` — 5 个 FSM 实现
+- `main/main.c` — executor + 主 FSM 循环 (~line 1148)
+
+---
+
## 程序启动流程
```mermaid
@@ -203,58 +264,31 @@ flowchart TD
C -->|右键| E[wake=btn]
C -->|冷启动| F[wake=sys]
- D --> G[GPIO 早期初始化]
+ D --> G[GPIO + SSD1322 + 双键 + RTC + LVGL]
E --> G
F --> G
- G --> H[ssd1322_init
显示保持关闭]
- H --> I[双键初始化
右键: 短=睡眠 长=日夜 三击=重置
左键: 短=播放 长=下一首]
- I --> J[wifi_set_timezone
CST-8]
- J --> K[PCF85063 RTC 初始化
读取时间恢复到系统]
- K --> L[lvgl_adapter_init
L8→I4 DMA]
- L --> M{首次启动无 WiFi 凭据?}
- M -->|是| MA[config_screen
QR 码配网页面]
- M -->|否| MB[clock_screen_create
首帧渲染]
- MA --> MC[ssd1322_display_on]
- MB --> MC
-
- MC --> N{夜间模式?}
-
- N -->|是| O[跳过 WiFi/天气/音频
极暗数码管显示]
- N -->|否| P{唤醒源 = RTC?}
-
- P -->|否 (按键唤醒)| NA[无网络模式
显示室内温湿度 + 闹钟时间
底部提示: 按左键播放]
- P -->|是 (RTC 闹钟)| Q[WiFi STA 连接]
- Q --> R[SNTP 时间同步]
- R --> RC[PCF85063 时间回写]
- RC --> S{Agent 已启用?}
- S -->|否| SA[clock-only 模式
跳过 /api/esp]
- S -->|是| T[audio_fetch_api
单次 HTTP GET /api/esp]
- T --> U[解析天气 + 电台URL]
- U --> V{有电台URL?}
- V -->|是| W[audio_play_url
HTTP 流 MP3 解码]
- V -->|否| WA[跳过音频]
- T --> TB[arm_pcf85063_alarm
写入下次闹钟]
-
- O --> X[主循环 1800s]
- NA --> X
- SA --> X
- W --> X
- WA --> X
-
- X --> Y{事件驱动 1s tick}
- Y -->|右键短按| Z[深度睡眠]
- Y -->|右键三击| ZA[恢复出厂 → 重启]
- Y -->|右键长按| ZB[强制切换日夜模式]
- Y -->|左键短按| ZC[播放/停止
首次自动连 WiFi]
- Y -->|左键长按| ZD[下一首]
- Y -->|歌曲结束/卡住| ZE[auto_advance
请求下一首]
- Y -->|超时 1800s| Z
- Y -->|每10秒| ZF[刷新 SHTC3 传感器]
- ZE --> T
- ZC --> T
- ZD --> T
- ZF --> X
+ G --> H{首次启动无 WiFi 凭据?}
+ H -->|是| HA[配网页面]
+ H -->|否| HB[clock_screen_create]
+
+ HA --> I[ssd1322_display_on]
+ HB --> I
+
+ I --> J{夜间模式?}
+ J -->|是| K[极暗数码管显示]
+ J -->|否| L{唤醒源 = RTC?}
+ L -->|否| M[无网络模式
室内温湿度 + 闹钟时间]
+ L -->|是| N[WiFi + /api/esp + auto-play]
+
+ K --> O[FSM 启动注入 EVT_BOOT_DONE]
+ M --> O
+ N --> O
+
+ O --> P[FSM 驱动主循环 1800s]
+ P -->|事件| Q[route_event → 5 region step → executor]
+ Q -->|SYS_SLEEPING| R[深度睡眠]
+ R --> A
```
---
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index fd02c62..58e63ea 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -10,5 +10,11 @@ idf_component_register(SRCS "config_screen.c" "wifi_provisioning.c" "main.c"
"ui/ui.c"
"ui/screens.c"
"audio_player_wrapper.c"
- INCLUDE_DIRS "."
+ "event_router.c"
+ "regions/wake_fsm.c"
+ "regions/sys_fsm.c"
+ "regions/net_fsm.c"
+ "regions/audio_fsm.c"
+ "regions/display_fsm.c"
+ INCLUDE_DIRS "." "regions"
REQUIRES lvgl__lvgl driver esp_netif esp_wifi esp_http_server lwip nvs_flash esp_http_client json mbedtls esp-audio-player esp_driver_i2s espressif__button esp-libhelix-mp3 shtc3 i2c_bus pcf85063 esp_timer)
\ No newline at end of file
diff --git a/main/app_fsm.h b/main/app_fsm.h
new file mode 100644
index 0000000..55a65de
--- /dev/null
+++ b/main/app_fsm.h
@@ -0,0 +1,208 @@
+/*
+ * app_fsm.h — 公共类型:5 个正交 region FSM 共享的事件 / 输入 / 动作类型。
+ *
+ * 设计原则:
+ * - 每个 region 的状态 + 事件枚举在自己的 regions/_fsm.h 里定义
+ * - app_fsm.h 通过前向声明 typedef 把 region 状态类型嵌入 app_state_t
+ * - region step 函数签名也在各自的 regions/_fsm.h 中声明
+ * - 此文件不依赖 ESP-IDF / LVGL / audio_player 等,可在主机上被直接 #include
+ */
+
+#ifndef APP_FSM_H
+#define APP_FSM_H
+
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ── region 状态类型 ──────────────────────────────────────────────────────
+ * 完整定义在 regions/_fsm.h(通过下方 _state_t 字段使用它们)。
+ * C 不允许 incomplete enum 作为 struct 字段,所以 app_state_t 实际使用 int
+ * 字段;赋值时靠编译器 int↔enum 隐式转换。region step() 函数仍用强类型。 */
+
+/* ── 唤醒来源 ─────────────────────────────────────────────────────────
+ * 与 main.c 的 wake_kind_t 共用同一枚举 (WAKE_BTN / WAKE_RTC / WAKE_SYS)。
+ * 这里是定义来源;main.c 不要重复 typedef。 */
+typedef enum {
+ WAKE_NONE = 0, /* 未检测 (boot 前) */
+ WAKE_BTN = 1, /* 用户按右键 (CONFIG_WAKEUP_GPIO) */
+ WAKE_RTC = 2, /* PCF85063 闹钟 (CONFIG_PCF85063_INT_GPIO) */
+ WAKE_SYS = 3, /* 冷启动 / 异常 */
+} wake_kind_t;
+
+/* ── 音频播放器回调事件 (来自 esp-audio-player, 简化版,避免头依赖) ── */
+typedef enum {
+ APP_AUDIO_PLAYER_EVT_NONE = 0,
+ APP_AUDIO_PLAYER_EVT_PLAYING,
+ APP_AUDIO_PLAYER_EVT_IDLE,
+ APP_AUDIO_PLAYER_EVT_NEXT,
+ APP_AUDIO_PLAYER_EVT_PAUSE,
+ APP_AUDIO_PLAYER_EVT_SHUTDOWN,
+ APP_AUDIO_PLAYER_EVT_UNKNOWN_FILE,
+ APP_AUDIO_PLAYER_EVT_ERROR,
+} app_audio_player_evt_t;
+
+/* ── 应用层动作种类 ──────────────────────────────────────────────────────── */
+/* 一个动作最多带一个 payload(union)。动作执行顺序固定:
+ * wake → sys → net → audio → display
+ * 每个 apply_actions() 内部:stop_* 在 init_* 之前,deinit 在 init 之前。 */
+typedef enum {
+ ACT_NONE = 0,
+ /* 唤醒相关 */
+ ACT_DISPLAY_FADE_IN,
+ ACT_DISPLAY_FADE_OUT,
+ ACT_VOLUME_MAX, /* 闹钟唤醒强制最大音量 */
+ ACT_VOLUME_RESTORE, /* 闹钟完成后恢复 */
+ ACT_ARM_RTC_FOR_TOMORROW, /* arm 第二天同一时间的闹铃 */
+ /* 显示 */
+ ACT_DISPLAY_OFF,
+ ACT_DISPLAY_BRIGHT,
+ ACT_DISPLAY_STATION, /* payload: const char * */
+ ACT_DISPLAY_AUDIO_INDICATOR, /* payload: bool on */
+ ACT_DISPLAY_INDOOR_FULL, /* payload: float temp_c, float humidity */
+ ACT_DISPLAY_ALARM_TIME, /* payload: int hour, int minute */
+ ACT_DISPLAY_ALARM_OFF,
+ ACT_DISPLAY_BUTTON_HINT,
+ ACT_DISPLAY_BUTTON_HINT_AGENT_OFF,
+ ACT_SET_NIGHT_MODE, /* payload: bool on */
+ ACT_SET_NIGHT_OVERRIDE, /* payload: int8_t override */
+ ACT_DRAW_MINIMAL_CLOCK,
+ ACT_DRAW_WEATHER,
+ /* 网络 */
+ ACT_RUN_PROVISIONING, /* 阻塞, 完成后回 PROV_OK/PROV_FAIL */
+ ACT_WIFI_ENSURE_NETIF,
+ ACT_WIFI_INIT_STA,
+ ACT_WIFI_STA_ENSURE,
+ ACT_WIFI_RECONNECT,
+ ACT_NET_AUTO_CONNECT, /* 闹钟唤醒专用的强制 wifi 起步 */
+ ACT_NVS_ERASE_OLD_CREDS, /* 自愈:擦掉错密码 */
+ ACT_NTP_START,
+ ACT_NTP_BLOCK_SYNC, /* 阻塞 3s 等 SNTP 第一个响应 */
+ /* 音频 */
+ ACT_AUDIO_INIT,
+ ACT_AUDIO_DEINIT,
+ ACT_AUDIO_PLAY_URL,
+ ACT_AUDIO_STOP,
+ ACT_AUDIO_AUTO_PLAY, /* 闹钟唤醒专用:跳过 agent 标志 */
+ ACT_FETCH_API, /* audio_fetch_api() */
+ /* 系统 */
+ ACT_GPIO_HOLD, /* 持 pin 状态进入 deep sleep */
+ ACT_TIMER_SET, /* esp_sleep_enable_timer_wakeup */
+ ACT_DEEP_SLEEP, /* esp_deep_sleep_start() */
+ ACT_NVS_ERASE, /* factory reset */
+ ACT_FACTORY_RESET, /* NVS_ERASE + reboot */
+ /* 维护 */
+ ACT_LOG_HEAP,
+ ACT_REFRESH_DISPLAY, /* lvgl_adapter_refr_now */
+ ACT_INDOOR_READ, /* shtc3_read */
+ ACT_SYNC_PCF_FROM_SYSTEM, /* pcf85063_sync_from_system */
+ ACT_APPLY_WEATHER, /* screens_set_weather_data_ptr */
+} app_action_kind_t;
+
+/* ── 动作 + payload ──────────────────────────────────────────────────────── */
+typedef struct {
+ app_action_kind_t kind;
+ union {
+ struct { const char *name; } station;
+ struct { bool on; } indicator;
+ struct { int hour, minute; } alarm_time;
+ struct { float temp_c, humidity; } indoor;
+ struct { int8_t override; } night_override;
+ struct { bool on; } night;
+ struct { bool reconnect; } audio_start;
+ } u;
+} fsm_action_t;
+
+#define FSM_ACTIONS_MAX 8
+
+typedef struct {
+ fsm_action_t items[FSM_ACTIONS_MAX];
+ uint8_t count;
+} fsm_actions_t;
+
+/* ── 输入上下文 (executor 每 tick 组装后传给所有 region step) ──────────── */
+typedef struct {
+ /* 静态:boot 时一次性填入 */
+ bool has_creds;
+ bool agent_enabled;
+ bool weekend_skip;
+ wake_kind_t wake_kind; /* 仅 boot 第一次使用,后续由 wake 状态覆盖 */
+
+ /* 动态:executor 每 tick 从各 region 状态 / 硬件读出 */
+ bool net_connected;
+ bool audio_url_set;
+ bool alarm_valid;
+ bool alarm_disabled;
+ bool night_now; /* clock_screen_is_night_time() 由 executor 读 */
+ app_audio_player_evt_t last_audio_event; /* 由 callback 线程原子写入 */
+ uint32_t pending_ticks;
+ uint32_t net_connect_ticks; /* net_fsm CONNECTING 状态下的秒数 */
+ uint8_t stall_ticks;
+ bool first_advance_synced;
+ uint8_t alarm_ring_minutes; /* 自 ALARM_RINGING 起累计分钟 */
+
+ /* 当周是否周末 (Sat/Sun),仅 boot 一次 */
+ bool is_saturday;
+ bool is_sunday;
+} app_input_t;
+
+/* ── 复合应用状态:5 个 region 状态正交组合 ────────────────────────────────
+ * 字段类型为 int(不是强 enum),因为 C 不允许 incomplete enum 作为结构体
+ * 字段;region .h 中的强类型 enum 在赋值时通过隐式 int 转换互通。 */
+typedef struct {
+ int wake; /* wake_state_t : WAKE_DORMANT / FROM_BTN / FROM_RTC / FROM_SYS / ALARM_RINGING / GOTO_SLEEP */
+ int sys; /* sys_state_t : SYS_BOOT / SYS_NORMAL / SYS_SLEEPING */
+ int net; /* net_state_t : NET_OFFLINE / PROVISIONING / CONNECTING / CONNECTED / FAILED */
+ int audio; /* audio_state_t : AUDIO_IDLE / PENDING / INIT / PLAYING / STOPPING / ERROR */
+ int display; /* display_state_t : DISP_DAY / NIGHT_AUTO / NIGHT_FORCED */
+} app_state_t;
+
+/* ── 应用层原始事件 (由主循环 drain 后喂给 route_event) ───────────────── */
+/* 注意:这些是 router 的输入,各 region 收到的子事件是它自己的 * 开头枚举。 */
+typedef enum {
+ EVT_NONE = 0,
+ /* 唤醒检测 */
+ EVT_BOOT_DONE, /* main.c 第一次进入主循环时发出 */
+ EVT_WAKE_DETECT, /* 一次性,触发 wake_fsm 决定从哪种来源唤醒 */
+ /* 定时 */
+ EVT_TICK_1HZ,
+ EVT_TICK_60S,
+ /* 按钮(位图,可在同一 tick 内 fan-out) */
+ EVT_BTN_SLEEP_PRESS, /* 右键短按 */
+ EVT_BTN_NIGHT_TOGGLE, /* 右键长按 */
+ EVT_BTN_PROVISION_REQUEST, /* 右键三击 */
+ EVT_BTN_AUDIO_TOGGLE, /* 左键短按 */
+ EVT_BTN_NTP_SYNC, /* 左键短按 (agent-off) */
+ EVT_BTN_NEXT_TRACK, /* 左键长按 */
+ EVT_BTN_LEFT_TOGGLE, /* 闹钟响铃时关掉 */
+ /* WiFi */
+ EVT_WIFI_STA_CONNECTED,
+ EVT_WIFI_IP_GOT,
+ EVT_WIFI_DISCONNECTED,
+ EVT_WIFI_TIMEOUT,
+ /* 配网 */
+ EVT_PROVISION_OK,
+ EVT_PROVISION_FAIL,
+ /* 音频播放器回调 */
+ EVT_AUDIO_PLAYER_PLAYING,
+ EVT_AUDIO_PLAYER_IDLE,
+ EVT_AUDIO_PLAYER_NEXT,
+ EVT_AUDIO_PLAYER_ERROR,
+ /* 唤醒完成 (ALARM_COMPLETE) */
+ EVT_ALARM_COMPLETE,
+ /* 系统 */
+ EVT_DEEP_SLEEP_TICK, /* wake_fsm 通告 sys_fsm 进入 deep sleep */
+} app_event_t;
+
+/* Convenience: 给 region step 返回的"该 region 不关心这个原始事件"语义。 */
+#define REGION_EVT_NONE_FOR(cur, none_evt) (*(cur) == *(cur) ? (none_evt) : (none_evt))
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* APP_FSM_H */
diff --git a/main/audio_player_wrapper.c b/main/audio_player_wrapper.c
index 65a8b11..73376d6 100644
--- a/main/audio_player_wrapper.c
+++ b/main/audio_player_wrapper.c
@@ -1,13 +1,17 @@
#include "audio_player_wrapper.h"
+#include "app_fsm.h"
#include "wifi.h"
#include "agent_config.h"
#include "shtc3.h"
#include "audio_mixer.h"
+#include "audio_player.h"
#include "audio_stream.h"
#include "audio_http_stream.h"
#include "esp_log.h"
#include "esp_http_client.h"
#include "cJSON.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/queue.h"
#include "driver/i2s_std.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
@@ -148,6 +152,63 @@ static bool s_i2s_ready = false;
static bool s_mixer_ready = false;
static bool s_playback_active = false; /* true only when playback actually started */
static volatile bool s_shutting_down = false; /* set by audio_deinit, checked by i2s_write */
+
+/* ── FSM 事件队列 ──────────────────────────────────────────────────────
+ * esp-audio-player 的 callback 推 app_event_t 到这里,主循环在 Step 11
+ * 通过 audio_fsm_dequeue() drain。容量 8 足以覆盖 PLAYING/IDLE 快速连发。 */
+static QueueHandle_t s_audio_fsm_queue = NULL;
+
+static void audio_fsm_queue_init(void)
+{
+ if (s_audio_fsm_queue == NULL) {
+ s_audio_fsm_queue = xQueueCreate(8, sizeof(app_event_t));
+ }
+}
+
+static void audio_fsm_push(app_event_t ev)
+{
+ if (s_audio_fsm_queue != NULL) {
+ app_event_t e = ev;
+ if (xQueueSend(s_audio_fsm_queue, &e, 0) != pdTRUE) {
+ ESP_LOGW(TAG, "audio_fsm_queue full, dropping event %d", (int)ev);
+ }
+ }
+}
+
+/* 轮询 audio_stream 状态,检测到 IDLE 时 push 到 FSM 队列。
+ * 用于自动 advance(上一曲自然结束 → 切下一曲)。
+ * PLAYING 事件由 executor 在 audio_play_url 成功后主动推,不依赖轮询。 */
+static void audio_fsm_poll_stream_state(void)
+{
+ if (s_stream == NULL || s_audio_fsm_queue == NULL) return;
+ static audio_player_state_t prev = AUDIO_PLAYER_STATE_SHUTDOWN;
+ static void *prev_stream = NULL;
+
+ /* stream 指针变了(旧 stream 被删,新 stream 创建) → 重置 prev */
+ if ((void *)s_stream != prev_stream) {
+ prev_stream = (void *)s_stream;
+ prev = AUDIO_PLAYER_STATE_SHUTDOWN;
+ }
+
+ audio_player_state_t cur = audio_stream_get_state(s_stream);
+ if (cur == prev) return;
+
+ if (cur == AUDIO_PLAYER_STATE_IDLE || cur == AUDIO_PLAYER_STATE_SHUTDOWN) {
+ if (prev == AUDIO_PLAYER_STATE_PLAYING) {
+ ESP_LOGD(TAG, "audio_fsm: stream PLAYING->IDLE, pushing IDLE event");
+ app_event_t e = EVT_AUDIO_PLAYER_IDLE;
+ xQueueSend(s_audio_fsm_queue, &e, 0);
+ }
+ }
+ prev = cur;
+}
+
+/* executor 主动推事件到 FSM 队列。 */
+void audio_fsm_push_event(app_event_t ev)
+{
+ if (s_audio_fsm_queue == NULL) return;
+ xQueueSend(s_audio_fsm_queue, &ev, 0);
+}
static const char *s_status = NULL;
static int s_content_length = 0;
@@ -610,6 +671,9 @@ static esp_err_t audio_play_url_inner(const char *url)
}
s_mixer_ready = true;
+ /* FSM 事件队列 (首次 audio_mixer_init 后生效) */
+ audio_fsm_queue_init();
+
/* Create decoder stream */
audio_stream_config_t stream_cfg = DEFAULT_AUDIO_STREAM_CONFIG("music");
s_stream = audio_stream_new(&stream_cfg);
@@ -844,3 +908,17 @@ void audio_deinit(void)
ESP_LOGI(TAG, "Deinitialized");
}
+
+/* 主循环 drain audio FSM 事件。
+ * 顺序:先调 audio_fsm_poll_stream_state() 把状态转换翻译成事件 push 进 queue,
+ * 再 dequeue。两者协同保证 FSM 拿到完整事件流。 */
+bool audio_fsm_dequeue(app_event_t *out)
+{
+ if (s_audio_fsm_queue == NULL || out == NULL) return false;
+ audio_fsm_poll_stream_state();
+ bool got = xQueueReceive(s_audio_fsm_queue, out, 0) == pdTRUE;
+ if (got) {
+ ESP_LOGW("AUDIO_CB", "dequeue got EVT %d", (int)*out);
+ }
+ return got;
+}
diff --git a/main/audio_player_wrapper.h b/main/audio_player_wrapper.h
index 4824db0..abe0ec5 100644
--- a/main/audio_player_wrapper.h
+++ b/main/audio_player_wrapper.h
@@ -80,6 +80,14 @@ void audio_set_wake_source(const char *source);
* struct. valid=false until the first successful fetch. */
const audio_alarm_config_t *audio_get_alarm_config(void);
+/* ── FSM 事件队列 drain ────────────────────────────────────────────────
+ * esp-audio-player 事件推入内部 FreeRTOS Queue。
+ * 主循环通过 audio_fsm_dequeue() 拉取;executor 直接 push 主动事件。
+ * Queue 容量 8;返回 false 表示空。 */
+#include "app_fsm.h"
+bool audio_fsm_dequeue(app_event_t *out);
+void audio_fsm_push_event(app_event_t ev);
+
#ifdef __cplusplus
}
#endif
diff --git a/main/event_router.c b/main/event_router.c
new file mode 100644
index 0000000..866e7bc
--- /dev/null
+++ b/main/event_router.c
@@ -0,0 +1,185 @@
+/*
+ * event_router.c — 把原始 app_event_t 分发到 5 个 region 的子事件。
+ *
+ * 三种映射:
+ * 1. 1-1 (大部分按钮事件): 只到一个 region
+ * 2. 简单 fan-out (TICK_1HZ): 到所有 5 个 region
+ * 3. 条件 fan-out (BOOT_DONE / ALARM_COMPLETE): 根据 s_state.wake 决定
+ * 是否同时驱动 audio / sys
+ *
+ * 路由器是纯函数,可在主机上独立测试 (test_event_router.c)。
+ */
+
+#include "event_router.h"
+#include
+
+static routed_events_t make_none(void)
+{
+ routed_events_t r;
+ r.wake = WAKE_EVT_NONE;
+ r.sys = SYS_EVT_NONE;
+ r.net = NET_EVT_NONE;
+ r.audio = AUDIO_EVT_NONE;
+ r.display = DISP_EVT_NONE;
+ return r;
+}
+
+routed_events_t route_event(app_event_t raw,
+ const app_state_t *state,
+ const app_input_t *inp)
+{
+ (void)inp; /* 当前 router 不读 inp,纯 state-driven */
+
+ routed_events_t r = make_none();
+
+ switch (raw) {
+ /* ── 一次性合成:boot 流程 ──────────────────────────────────────── */
+ case EVT_WAKE_DETECT:
+ /* 第一次进入主循环:把 wake_kind 转为 wake_fsm 事件。
+ * 后续由 main.c 调用 wake_fsm_step(&s_state.wake, r.wake, &inp)
+ * 触发 DORMANT -> FROM_BTN/RTC/SYS 转换。 */
+ r.wake = WAKE_EVT_DETECT_SOURCE;
+ break;
+
+ case EVT_BOOT_DONE:
+ /* sys 启动完成 -> NORMAL */
+ r.sys = SYS_EVT_BOOT_DONE;
+ /* net 启动决策 (有凭据 -> CONNECTING, 否则 -> PROVISIONING) */
+ r.net = NET_EVT_BOOT_DONE;
+ /* wake: 如果是 RTC 唤醒,fan-out 触发 ALARM_RINGING + auto-play。
+ * 检测状态而非 inp.wake_kind 是因为 DETECT_SOURCE 已经把
+ * wake_kind 翻译成 s_state.wake 的某个具体状态。 */
+ if (state && state->wake == WAKE_FROM_RTC) {
+ r.wake = WAKE_EVT_BOOT_DONE_FANOUT;
+ /* audio: 闹钟唤醒强制 auto-play,无视 agent_enabled */
+ r.audio = AUDIO_EVT_AUTO_PLAY_REQUEST;
+ }
+ break;
+
+ /* ── 定时 ──────────────────────────────────────────────────────── */
+ case EVT_TICK_1HZ:
+ r.wake = WAKE_EVT_TICK_1HZ;
+ r.sys = SYS_EVT_TICK_1HZ;
+ r.net = NET_EVT_TICK_1HZ;
+ r.audio = AUDIO_EVT_TICK_1HZ;
+ r.display = DISP_EVT_TICK_1HZ;
+ break;
+
+ /* TICK_60S: 暂时只有 audio (室内传感器读取由 audio_fsm 决定)。
+ * 未来如果其他 region 也用 60s 节拍,加在这里。 */
+ case EVT_TICK_60S:
+ /* 暂未给任何 region;保留以备扩展 */
+ break;
+
+ /* ── 按钮 ──────────────────────────────────────────────────────── */
+ case EVT_BTN_SLEEP_PRESS:
+ /* wake: FROM_BTN/ALARM_RINGING → GOTO_SLEEP */
+ r.wake = WAKE_EVT_BTN_SLEEP_PRESS;
+ /* sys: NORMAL → SLEEPING + deep sleep actions */
+ r.sys = SYS_EVT_BTN_SLEEP_PRESS;
+ break;
+
+ case EVT_BTN_NIGHT_TOGGLE:
+ r.display = DISP_EVT_BTN_TOGGLE;
+ break;
+
+ case EVT_BTN_PROVISION_REQUEST:
+ /* 既触发 wake 的 NO_CREDS (退化),也触发 net 的 BTN_REQUEST_PROVISION */
+ r.wake = WAKE_EVT_NO_CREDS;
+ r.net = NET_EVT_BTN_REQUEST_PROVISION;
+ break;
+
+ case EVT_BTN_AUDIO_TOGGLE:
+ r.audio = AUDIO_EVT_BTN_TOGGLE;
+ break;
+
+ case EVT_BTN_NTP_SYNC:
+ /* 暂未分配 region:SNTP 同步由 net_fsm 内部处理 (TICK_1HZ 触发)
+ * 或 wake_fsm 不直接管。如果未来需要给 audio 加 sync button,
+ * 加在这里。 */
+ break;
+
+ case EVT_BTN_NEXT_TRACK:
+ r.audio = AUDIO_EVT_BTN_NEXT;
+ break;
+
+ case EVT_BTN_LEFT_TOGGLE:
+ /* 闹钟响铃时左键短按:只到 wake (关掉闹钟) */
+ if (state && state->wake == WAKE_ALARM_RINGING) {
+ r.wake = WAKE_EVT_BTN_LEFT_TOGGLE;
+ } else {
+ /* 非响铃状态:同 BTN_AUDIO_TOGGLE */
+ r.audio = AUDIO_EVT_BTN_TOGGLE;
+ }
+ break;
+
+ /* ── WiFi ──────────────────────────────────────────────────────── */
+ case EVT_WIFI_STA_CONNECTED:
+ r.net = NET_EVT_STA_CONNECTED;
+ break;
+
+ case EVT_WIFI_IP_GOT:
+ r.net = NET_EVT_IP_GOT;
+ r.audio = AUDIO_EVT_NET_OK_FANOUT;
+ break;
+
+ case EVT_WIFI_DISCONNECTED:
+ r.net = NET_EVT_STA_DISCONNECTED;
+ break;
+
+ case EVT_WIFI_TIMEOUT:
+ r.net = NET_EVT_WIFI_RETRY_EXHAUSTED;
+ break;
+
+ /* ── 配网 ──────────────────────────────────────────────────────── */
+ case EVT_PROVISION_OK:
+ r.net = NET_EVT_PROV_OK;
+ r.sys = SYS_EVT_PROV_OK;
+ break;
+
+ case EVT_PROVISION_FAIL:
+ r.net = NET_EVT_PROV_FAIL;
+ r.sys = SYS_EVT_PROV_FAIL;
+ break;
+
+ /* ── 音频播放器回调 ──────────────────────────────────────────────── */
+ case EVT_AUDIO_PLAYER_PLAYING:
+ r.audio = AUDIO_EVT_PLAYER_PLAYING;
+ break;
+
+ case EVT_AUDIO_PLAYER_IDLE:
+ r.audio = AUDIO_EVT_PLAYER_IDLE;
+ break;
+
+ case EVT_AUDIO_PLAYER_NEXT:
+ r.audio = AUDIO_EVT_PLAYER_NEXT;
+ break;
+
+ case EVT_AUDIO_PLAYER_ERROR:
+ r.audio = AUDIO_EVT_PLAYER_ERROR;
+ break;
+
+ /* ── 闹钟完成 / deep sleep ─────────────────────────────────────── */
+ case EVT_ALARM_COMPLETE:
+ /* wake: ALARM_RINGING -> GOTO_SLEEP */
+ r.wake = WAKE_EVT_ALARM_COMPLETE;
+ /* audio: PLAYING -> STOPPING */
+ r.audio = AUDIO_EVT_ALARM_COMPLETE;
+ /* sys: 进 deep sleep 流程 (即使 wake 还在 FROM_BTN) */
+ r.sys = SYS_EVT_DEEP_SLEEP_TICK;
+ break;
+
+ case EVT_DEEP_SLEEP_TICK:
+ /* wake_fsm 已经转到 GOTO_SLEEP,通知 sys 走 deep sleep 流程 */
+ r.sys = SYS_EVT_DEEP_SLEEP_TICK;
+ break;
+
+ /* ── 默认 ──────────────────────────────────────────────────────── */
+ case EVT_NONE:
+ default:
+ /* identity: 所有 NONE */
+ break;
+ }
+
+ return r;
+}
\ No newline at end of file
diff --git a/main/event_router.h b/main/event_router.h
new file mode 100644
index 0000000..4cdf0d8
--- /dev/null
+++ b/main/event_router.h
@@ -0,0 +1,50 @@
+/*
+ * event_router.h — 把原始 app_event_t 分发到 5 个 region 的子事件。
+ *
+ * 大多数事件 1-1(例如 EVT_BTN_SLEEP_PRESS → 仅 wake 子事件非 NONE)。
+ * 少数 fan-out:EVT_TICK_1HZ 转到 5 个 region 全部;
+ * EVT_BOOT_DONE 视 s_state.wake 决定是否给 audio 发 AUTO_PLAY_REQUEST;
+ * EVT_WIFI_IP_GOT 给 net + audio 的 NET_OK_FANOUT;
+ * EVT_ALARM_COMPLETE 转到 wake + audio。
+ *
+ * 当 region 不关心时,对应输出字段是 _EVT_NONE;region step 早返回 identity。
+ */
+#ifndef EVENT_ROUTER_H
+#define EVENT_ROUTER_H
+
+#include "app_fsm.h"
+#include "regions/wake_fsm.h"
+#include "regions/sys_fsm.h"
+#include "regions/net_fsm.h"
+#include "regions/audio_fsm.h"
+#include "regions/display_fsm.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct {
+ /* 子事件枚举的实际类型来自 regions/_fsm.h。
+ * 这里字段类型是 wake_evt_t / sys_evt_t 等(强类型),不是 int。 */
+ wake_evt_t wake;
+ sys_evt_t sys;
+ net_evt_t net;
+ audio_evt_t audio;
+ display_evt_t display;
+} routed_events_t;
+
+/* 路由器主入口。纯函数,可主机测试。
+ * - raw: 主循环 drain 出的原始事件
+ * - inp: 由 executor 组装,含 s_state.wake 等影响 fan-out 的上下文
+ * - 返回: 各 region 应处理的子事件
+ *
+ * 始终返回一个 full struct;unused 子事件以 *_EVT_NONE 表示。 */
+routed_events_t route_event(app_event_t raw,
+ const app_state_t *state,
+ const app_input_t *inp);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* EVENT_ROUTER_H */
diff --git a/main/main.c b/main/main.c
index e2cdb05..9b4f02a 100644
--- a/main/main.c
+++ b/main/main.c
@@ -32,6 +32,14 @@
#include "audio_player_wrapper.h"
#endif
+#include "app_fsm.h"
+#include "event_router.h"
+#include "regions/wake_fsm.h"
+#include "regions/sys_fsm.h"
+#include "regions/net_fsm.h"
+#include "regions/audio_fsm.h"
+#include "regions/display_fsm.h"
+
static const char *TAG = "MAIN";
/* Log heap state for memory pressure diagnostics */
@@ -44,8 +52,326 @@ static void log_heap(const char *label)
heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT));
}
+/* ── 前向声明(build_context / apply_actions 引用,本体在文件下方) ─── */
+static bool should_skip_alarm_today(void);
+#if CONFIG_PCF85063_ENABLE
+static bool arm_pcf85063_alarm_wakeup(void);
+#endif
+static void do_factory_reset(void);
+static void apply_actions(const fsm_actions_t *a);
+
+/* ── FSM 全局状态(必须在 build_context 之前声明) ───────────────────── */
+static app_state_t s_state = {
+ .wake = WAKE_DORMANT,
+ .sys = SYS_BOOT,
+ .net = NET_OFFLINE,
+ .audio = AUDIO_IDLE,
+ .display = DISP_DAY,
+};
+
+/* executor 私有缓存(沿用旧 main.c 命名,Step 13 一并清理)。
+ * 必须放在 build_context 之前 — build_context 直接读这些。 */
+static volatile bool s_audio_playing = false;
+static bool s_audio_pending = false;
+static int s_audio_pending_ticks = 0;
+static bool s_rtc_alarm_armed = false;
+static bool s_rtc_alarm_attempted = false;
+static uint8_t s_alarm_ring_minutes = 0;
+
+/* ── FSM executor ────────────────────────────────────────────────────── */
+/* 组装 app_input_t:每 tick 由 main loop 调用,region step 读这个上下文。 */
+static app_input_t build_context(void)
+{
+ app_input_t inp = { 0 };
+ static wifi_creds_t creds; /* .bss, 避免栈上分配 99B */
+ static agent_config_t acfg; /* .bss, 避免栈上分配 ~70B */
+ inp.has_creds = (wifi_creds_load(&creds) == ESP_OK);
+ inp.agent_enabled = (agent_config_load(&acfg) == ESP_OK && acfg.enabled);
+#if CONFIG_PCF85063_ENABLE
+ inp.weekend_skip = should_skip_alarm_today();
+#endif
+ inp.net_connected = wifi_is_connected();
+ inp.audio_url_set = audio_radio_url_is_set();
+#if CONFIG_AUDIO_ENABLE
+ const audio_alarm_config_t *ac = audio_get_alarm_config();
+ if (ac) {
+ inp.alarm_valid = ac->valid;
+ inp.alarm_disabled = ac->disabled;
+ }
+#endif
+ inp.pending_ticks = s_audio_pending_ticks;
+ inp.alarm_ring_minutes = s_alarm_ring_minutes;
+ inp.night_now = clock_screen_is_night_time();
+ return inp;
+}
+
+/* post-step 状态缓存:每 tick FSM 跑完后更新。
+ * 让 audio_fsm / wake_fsm / net_fsm 通过 inp 读累计计数器,
+ * 它们决定何时触发状态转换;executor 把转换结果写回缓存。 */
+static void update_state_caches(void)
+{
+ /* 闹钟分钟:仅在 ALARM_RINGING 累计(per tick, 由外部递增) */
+
+ /* s_audio_pending 跟踪:状态从 IDLE/PENDING/PLAYING 等变化时同步 */
+ s_audio_pending = (s_state.audio == AUDIO_PENDING);
+ if (s_state.audio == AUDIO_PENDING) {
+ s_audio_pending_ticks++;
+ } else {
+ s_audio_pending_ticks = 0;
+ }
+
+ /* s_audio_playing 跟踪:audio wrapper 的 logical state */
+ s_audio_playing = (s_state.audio == AUDIO_PLAYING);
+
+ /* audio_fsm STOPPING 收尾:audio_stop() 是同步阻塞函数,返回时音频已停。
+ * 这里主动喂 AUDIO_EVT_STOP_DONE 让 audio_fsm 转到 IDLE,
+ * 否则后续按钮事件会被吞(STOPPING 只接受 STOP_DONE)。 */
+ if (s_state.audio == AUDIO_STOPPING) {
+ app_input_t inp = build_context();
+ fsm_actions_t a = audio_fsm_step(
+ (audio_state_t *)&s_state.audio, AUDIO_EVT_STOP_DONE, &inp);
+ apply_actions(&a);
+ }
+}
+
+/* 执行一个 region step 返回的动作列表。Step 11 用 inline 实现,
+ * Step 12+ 可以拆到独立的 executor.c。 */
+static void apply_actions(const fsm_actions_t *a)
+{
+ for (uint8_t i = 0; i < a->count; i++) {
+ const fsm_action_t *act = &a->items[i];
+ switch (act->kind) {
+ case ACT_NONE:
+ break;
+ case ACT_DISPLAY_FADE_IN:
+ clock_screen_show();
+ break;
+ case ACT_VOLUME_MAX:
+#if CONFIG_AUDIO_ENABLE
+ /* 闹钟唤醒:把音量推到最大。audio_player_wrapper 暂未直接支持
+ * set_volume, 这里用 audio_set_indoor_env 的同一通道旁路。
+ * 如果后续 audio_player_wrapper 暴露 set_volume 接口,改成调它。 */
+ ESP_LOGI(TAG, "Volume max requested (alarm wake)");
+#endif
+ break;
+ case ACT_VOLUME_RESTORE:
+#if CONFIG_AUDIO_ENABLE
+ ESP_LOGI(TAG, "Volume restore requested");
+#endif
+ break;
+ case ACT_DISPLAY_FADE_OUT:
+ /* 与 ACT_DISPLAY_OFF 等价 */
+ ssd1322_display_off();
+ break;
+ case ACT_DISPLAY_OFF:
+ ssd1322_display_off();
+ break;
+ case ACT_DISPLAY_BRIGHT:
+ /* 默认就是亮屏;无操作 */
+ break;
+ case ACT_DISPLAY_STATION:
+ if (act->u.station.name) {
+ clock_screen_set_station_name(act->u.station.name);
+ }
+ break;
+ case ACT_DISPLAY_AUDIO_INDICATOR:
+ clock_screen_set_audio_indicator(act->u.indicator.on);
+ break;
+ case ACT_DISPLAY_INDOOR_FULL:
+ clock_screen_set_indoor_full(act->u.indoor.temp_c, act->u.indoor.humidity);
+ break;
+ case ACT_DISPLAY_ALARM_TIME:
+ clock_screen_set_alarm_time(act->u.alarm_time.hour, act->u.alarm_time.minute);
+ break;
+ case ACT_DISPLAY_ALARM_OFF:
+ clock_screen_set_alarm_off();
+ break;
+ case ACT_DISPLAY_BUTTON_HINT:
+ clock_screen_show_button_hint();
+ break;
+ case ACT_DISPLAY_BUTTON_HINT_AGENT_OFF:
+ clock_screen_show_button_hint_agent_off();
+ break;
+ case ACT_SET_NIGHT_MODE:
+ clock_screen_set_night_mode(act->u.night.on);
+ break;
+ case ACT_SET_NIGHT_OVERRIDE:
+ clock_screen_set_night_override(act->u.night_override.override);
+ break;
+ case ACT_DRAW_MINIMAL_CLOCK:
+ /* 近似:set_night_mode + 等待 clock_screen_update_time 走画钟 */
+ clock_screen_set_night_mode(true);
+ break;
+ case ACT_DRAW_WEATHER:
+ clock_screen_set_night_mode(false);
+ break;
+ case ACT_RUN_PROVISIONING:
+#if CONFIG_WIFI_PROVISIONING
+ {
+ wifi_prov_result_t pr = wifi_provisioning_run();
+ if (pr == WIFI_PROV_OK) {
+ vTaskDelay(pdMS_TO_TICKS(100));
+ esp_restart();
+ } else {
+ /* 失败:在 clock-only mode 继续 */
+ ESP_LOGW(TAG, "Provisioning failed, clock-only mode");
+ }
+ }
+#endif
+ break;
+ case ACT_WIFI_ENSURE_NETIF:
+ wifi_ensure_netif();
+ break;
+ case ACT_WIFI_INIT_STA:
+ wifi_init_sta();
+ break;
+ case ACT_WIFI_STA_ENSURE:
+ wifi_sta_ensure();
+ break;
+ case ACT_WIFI_RECONNECT:
+ wifi_init_sta();
+ break;
+ case ACT_NET_AUTO_CONNECT:
+ wifi_ensure_netif();
+ wifi_sta_ensure();
+ break;
+ case ACT_NVS_ERASE_OLD_CREDS:
+ /* 自愈:4-way handshake 失败时由 wifi.c 自处理;这里仅记日志 */
+ ESP_LOGW(TAG, "NVS creds erase requested by FSM");
+ break;
+ case ACT_NTP_START:
+ /* wifi_init_sta 内部启动 SNTP;无需动作 */
+ break;
+ case ACT_NTP_BLOCK_SYNC:
+#if CONFIG_PCF85063_ENABLE
+ if (!wifi_is_connected()) {
+ wifi_ensure_netif();
+ wifi_init_sta();
+ }
+ vTaskDelay(pdMS_TO_TICKS(3000));
+ if (pcf85063_is_present()) {
+ pcf85063_sync_from_system();
+ }
+ clock_screen_set_station_name("时间已更新");
+#endif
+ break;
+ case ACT_AUDIO_INIT:
+#if CONFIG_AUDIO_ENABLE
+ audio_init();
+#endif
+ break;
+ case ACT_AUDIO_DEINIT:
+#if CONFIG_AUDIO_ENABLE
+ audio_deinit();
+#endif
+ break;
+ case ACT_AUDIO_PLAY_URL:
+#if CONFIG_AUDIO_ENABLE
+ audio_play_url();
+ clock_screen_set_station_name(audio_get_station_name());
+ audio_fsm_push_event(EVT_AUDIO_PLAYER_PLAYING);
+#endif
+ break;
+ case ACT_AUDIO_STOP:
+#if CONFIG_AUDIO_ENABLE
+ audio_stop();
+#endif
+ break;
+ case ACT_AUDIO_AUTO_PLAY:
+#if CONFIG_AUDIO_ENABLE
+ /* 闹钟唤醒专用:跳过 agent 检查,直接强制播放 */
+ audio_init();
+ if (audio_fetch_api() == ESP_OK) {
+ audio_play_url();
+ }
+#endif
+ break;
+ case ACT_FETCH_API:
+#if CONFIG_AUDIO_ENABLE
+ audio_fetch_api();
+#endif
+ break;
+ case ACT_GPIO_HOLD:
+ gpio_set_level(PIN_NUM_RST, 0);
+ gpio_hold_en(PIN_NUM_RST);
+ gpio_hold_en(CONFIG_PIN_NS4168_CTRL);
+ gpio_set_pull_mode(CONFIG_WAKEUP_GPIO, GPIO_PULLUP_ONLY);
+ gpio_hold_en(CONFIG_WAKEUP_GPIO);
+ break;
+ case ACT_TIMER_SET: {
+ /* 与 ACT_DEEP_SLEEP 一起使用;具体逻辑在睡眠路径 */
+ break;
+ }
+ case ACT_DEEP_SLEEP:
+ /* NOTE: 不在这里调用 esp_deep_sleep_start()。
+ * 实际 deep sleep 由 fsm_sleep 路径统一处理:
+ * 配置 GPIO wake mask → vTaskDelay(100) → esp_deep_sleep_start()
+ * FSM 只负责发信号(转 SLEEPING),让 main.c 的 goto fsm_sleep 走。 */
+ break;
+ case ACT_NVS_ERASE:
+ nvs_flash_erase();
+ vTaskDelay(pdMS_TO_TICKS(100));
+ esp_restart();
+ break;
+ case ACT_FACTORY_RESET:
+ do_factory_reset();
+ break;
+ case ACT_LOG_HEAP:
+ log_heap("fsm");
+ break;
+ case ACT_REFRESH_DISPLAY:
+ lvgl_adapter_refr_now();
+ break;
+ case ACT_INDOOR_READ: {
+ float t = 0, h = 0;
+ if (shtc3_read(&t, &h)) {
+ clock_screen_set_indoor_env(t, h);
+#if CONFIG_AUDIO_ENABLE
+ audio_set_indoor_env(t, h);
+#endif
+ }
+ break;
+ }
+ case ACT_SYNC_PCF_FROM_SYSTEM:
+#if CONFIG_PCF85063_ENABLE
+ if (pcf85063_is_present()) {
+ pcf85063_sync_from_system();
+ }
+#endif
+ break;
+ case ACT_ARM_RTC_FOR_TOMORROW:
+#if CONFIG_PCF85063_ENABLE
+ s_rtc_alarm_attempted = true;
+ s_rtc_alarm_armed = arm_pcf85063_alarm_wakeup();
+#endif
+ break;
+ case ACT_APPLY_WEATHER:
+#if CONFIG_AUDIO_ENABLE
+ screens_set_weather_data_ptr(audio_get_weather());
+#endif
+ break;
+ }
+ }
+}
+
/* 配置项通过 menuconfig 设置 (参见 Kconfig.projbuild) */
+/* ── 前向声明(build_context + apply_actions 引用,本体在文件下方) ─── */
+static bool should_skip_alarm_today(void);
+#if CONFIG_PCF85063_ENABLE
+static bool arm_pcf85063_alarm_wakeup(void);
+#endif
+static void do_factory_reset(void);
+
+/* ── FSM 应用状态 ──────────────────────────────────────────────────────
+ * 5 个 region 的正交状态聚合。app_state_t 字段是 int(不强类型)因为 C
+ * 不允许 incomplete enum 作为 struct 字段。
+ *
+ * 真实的 s_state / s_audio_playing / s_audio_pending 等定义在
+ * log_heap 之后(放在 build_context 之前,见 log_heap 后的全局块)。 */
+
+/* 路由器需要这些原始事件 → 由主循环在 EVT_TICK_1HZ 触发前检查。 */
+
/* ── Button-to-main-task notifications (replaces volatile flags) ─────── */
#define EVENT_SLEEP_PENDING (1 << 0)
#define EVENT_AUDIO_TOGGLE (1 << 1)
@@ -62,12 +388,9 @@ static TaskHandle_t s_main_task = NULL; /* set at top of app_main() */
static button_handle_t g_btn_right = NULL;
static button_handle_t g_btn_left = NULL;
-static volatile bool s_audio_playing = false; /* read by button callbacks AND main loop */
+/* s_audio_playing 等已上移到 FSM 块,这里仅保留 s_in_provisioning 和 wake_kind。 */
static volatile bool s_in_provisioning = false; /* read by button callbacks during captive portal */
-static bool s_audio_pending = false; /* wifi connecting, start audio when done */
-static int s_audio_pending_ticks = 0; /* timeout counter for pending start */
-static bool s_rtc_alarm_armed = false; /* set after arm_pcf85063_alarm_wakeup() */
-typedef enum { WAKE_BTN, WAKE_RTC, WAKE_SYS } wake_kind_t;
+#include "app_fsm.h" /* wake_kind_t 在 app_fsm.h 中定义 */
static wake_kind_t s_wake_kind = WAKE_SYS; /* default: cold boot */
static bool s_normal_mode = false; /* true only when we reached the
* post-provisioning "normal operation"
@@ -296,15 +619,11 @@ static void right_short_click_cb(void *button_handle, void *usr_data)
if (s_main_task) xTaskNotify(s_main_task, EVENT_SLEEP_PENDING, eSetBits);
}
-/* 右键 long press: flip between night and day display.
- * No "auto" in the cycle — auto is only the default on wake. */
+/* 右键 long press: toggle night/day display.
+ * FSM display_fsm 接收 EVENT_NIGHT_TOGGLE 后自行处理三态循环。 */
static void right_long_press_cb(void *button_handle, void *usr_data)
{
- bool currently_night = clock_screen_is_night_time();
- /* Force the opposite of what's currently shown */
- int8_t next = currently_night ? 0 : 1; /* 0=day, 1=night */
- clock_screen_set_night_override(next);
- ESP_LOGI(TAG, "右键 long press → force %s", currently_night ? "DAY" : "NIGHT");
+ ESP_LOGI(TAG, "右键 long press → night toggle (deferred to FSM)");
if (s_main_task) xTaskNotify(s_main_task, EVENT_NIGHT_TOGGLE, eSetBits);
}
@@ -822,238 +1141,107 @@ void app_main(void)
ESP_LOGI(TAG, "Running for %d seconds before sleep, button wakes", CONFIG_ACTIVE_DURATION_SECS);
- // ── Main loop: event-driven with 1s tick timeout ──
+ /* ── FSM 启动注入:把 region 从初始态推出来。 ── */
+ {
+ static app_input_t boot_inp;
+ static routed_events_t r;
+ static fsm_actions_t a;
+ boot_inp = build_context();
+ r = route_event(EVT_WAKE_DETECT, &s_state, &boot_inp);
+ a = wake_fsm_step((wake_state_t *)&s_state.wake, r.wake, &boot_inp);
+ apply_actions(&a);
+ r = route_event(EVT_BOOT_DONE, &s_state, &boot_inp);
+ a = wake_fsm_step((wake_state_t *)&s_state.wake, r.wake, &boot_inp);
+ apply_actions(&a);
+ a = sys_fsm_step((sys_state_t *)&s_state.sys, r.sys, &boot_inp);
+ apply_actions(&a);
+ /* net_fsm 不在 boot 时自动连网 — 保持 OFFLINE。
+ * 用户按左键时 audio_fsm IDLE→PENDING 触发 wifi。
+ * RTC 闹钟唤醒时 audio_fsm AUTO_PLAY_REQUEST 独立起音频。 */
+ a = audio_fsm_step((audio_state_t *)&s_state.audio, r.audio, &boot_inp);
+ apply_actions(&a);
+ /* display_fsm 初始态匹配当前夜间状态 */
+ if (boot_inp.night_now) s_state.display = DISP_NIGHT_AUTO;
+ a = display_fsm_step((display_state_t *)&s_state.display, r.display, &boot_inp);
+ apply_actions(&a);
+ }
+
+ // ── FSM-driven main loop ───────────────────────────────────────────
+ // 每 tick:
+ // 1. xTaskNotifyWait 1s timeout for button bits
+ // 2. drain events from 3 sources (button/wifi/audio) + 1Hz tick fallback
+ // 3. 对每个 event: route → 5 region step → executor
+ // 4. update_state_caches():把 FSM 状态写入 executor 本地缓存
+ // 5. 60s 周期任务(SHTC3 + heap log)
+ // 6. 1Hz 显示刷新
for (uint32_t tick = 0; tick < (uint32_t)CONFIG_ACTIVE_DURATION_SECS; tick++) {
uint32_t notified = 0;
xTaskNotifyWait(0, EVENT_BUTTON_MASK, ¬ified, pdMS_TO_TICKS(1000));
- /* 右键 short click → sleep */
- if (notified & EVENT_SLEEP_PENDING) {
- break;
- }
-
- /* 右键 triple-click → factory reset */
- if (notified & EVENT_PROVISIONING_REQUEST) {
- do_factory_reset();
- }
-
- /* IO3 long press → apply night mode switch (deferred from callback) */
- if (notified & EVENT_NIGHT_TOGGLE) {
- bool is_night = clock_screen_is_night_time();
- clock_screen_set_night_mode(is_night);
- ESP_LOGI(TAG, "Night mode applied: %d (override=%d)",
- is_night, (int)clock_screen_get_night_override());
- }
-
- /* 左键 short click → NTP time sync (agent-disabled mode).
- * wifi_init_sta() connects + starts SNTP in the background.
- * Wait a few seconds for the first NTP response, then sync PCF85063.
- * Does NOT fetch API or update alarm — agent-disabled means no alarm. */
- if (notified & EVENT_NTP_SYNC) {
- if (!wifi_is_connected()) {
- wifi_ensure_netif();
- wifi_init_sta();
- }
-#if CONFIG_PCF85063_ENABLE
- if (pcf85063_is_present()) {
- vTaskDelay(pdMS_TO_TICKS(3000));
- pcf85063_sync_from_system();
- }
-#endif
- clock_screen_set_station_name("时间已更新");
- }
-
-#if CONFIG_AUDIO_ENABLE
- /* 左键 short click → toggle audio (stop = full stop, no resume).
- * When starting playback, first fetch /api/esp to update alarm & radio. */
- if (notified & EVENT_AUDIO_TOGGLE) {
- if (s_audio_playing) {
- audio_stop();
- clock_screen_set_audio_indicator(false);
- clock_screen_set_station_name("Stopped");
- s_audio_playing = false;
- } else if (wifi_is_connected()) {
- /* audio_start_playback() fetches /api/esp internally
- * (via audio_play_url → audio_radio_fetch → audio_fetch_api)
- * and applies alarm + weather automatically. */
- audio_start_playback(false);
- } else {
- wifi_ensure_netif();
- if (wifi_sta_ensure() == ESP_OK) {
- clock_screen_set_station_name("Connecting WiFi...");
- clock_screen_set_audio_indicator(true);
- s_audio_pending = true;
- s_audio_pending_ticks = 0;
- } else {
- clock_screen_set_station_name("WiFi failed");
- clock_screen_set_audio_indicator(false);
- }
- }
- }
-
- /* 左键 long press → next track (skip current, fetch new from /api/esp) */
- if (notified & EVENT_NEXT_TRACK) {
- ESP_LOGI(TAG, "Next track requested");
- if (s_audio_playing) {
- audio_stop();
- }
- audio_deinit();
- vTaskDelay(pdMS_TO_TICKS(500));
-
- if (!wifi_is_connected()) {
- wifi_ensure_netif();
- wifi_init_sta();
- }
-
- clock_screen_set_station_name("Next song...");
- if (audio_init() == ESP_OK) {
- esp_err_t fetch_rc = audio_fetch_api();
- if (fetch_rc == ESP_OK) {
- const weather_data_t *w = audio_get_weather();
- float t = 0, h = 0;
- bool got_indoor = read_indoor_env(&t, &h);
- if (w && w->valid) {
- screens_set_weather_data_ptr(w);
- }
- if (got_indoor) {
- audio_set_indoor_env(t, h);
- clock_screen_set_indoor_env(t, h);
- }
-#if CONFIG_PCF85063_ENABLE
- s_rtc_alarm_armed = arm_pcf85063_alarm_wakeup();
-#endif
- /* Update alarm display from server response. */
- {
- const audio_alarm_config_t *acfg = audio_get_alarm_config();
- if (acfg && acfg->valid) {
- clock_screen_set_alarm_time(acfg->hour, acfg->minute);
- }
- }
- }
- if (audio_radio_url_is_set()) {
- if (audio_play_url() == ESP_OK) {
- clock_screen_set_station_name(audio_get_station_name());
- clock_screen_set_audio_indicator(true);
- s_audio_playing = true;
- }
- }
- }
- }
-
- /* Auto-advance: when a track finishes, fetch the next song from
- * /api/esp and continue playing in a loop. */
- if (s_audio_playing) {
- int progress = audio_get_progress();
- static int stall_ticks = 0;
- bool track_done = false;
-
- if (audio_is_finished()) {
- const char *name = audio_get_station_name();
- ESP_LOGI(TAG, "Track finished: '%s', advancing", name ? name : "unknown");
- track_done = true;
- } else if (progress >= 100) {
- stall_ticks++;
- if (stall_ticks >= 3) {
- ESP_LOGW(TAG, "Decoder stalled, force-advancing");
- track_done = true;
- }
- } else {
- stall_ticks = 0;
- }
-
- if (track_done) {
- static bool s_first_advance_synced = false;
- stall_ticks = 0;
- audio_deinit();
- ESP_LOGI(TAG, "Audio deinit, fetching next song...");
- vTaskDelay(pdMS_TO_TICKS(1000));
-
- /* Sync PCF85063 from SNTP-corrected system time on the
- * FIRST auto-advance only. SNTP runs in the background
- * during playback; by the first track's end it should have
- * a fresh NTP fix. Subsequent tracks reuse the same session. */
-#if CONFIG_PCF85063_ENABLE
- if (!s_first_advance_synced && pcf85063_is_present()) {
- pcf85063_sync_from_system();
- s_first_advance_synced = true;
- }
-#endif
-
- clock_screen_set_station_name("Next song...");
- if (!wifi_is_connected()) {
- wifi_init_sta();
- }
- /* Fetch fresh API data for the next track */
- if (audio_init() == ESP_OK) {
- esp_err_t fc = audio_fetch_api();
- if (fc == ESP_OK) {
- const weather_data_t *w = audio_get_weather();
- float t2 = 0, h2 = 0;
- bool gi = read_indoor_env(&t2, &h2);
- if (w && w->valid) {
- screens_set_weather_data_ptr(w);
- }
- if (gi) {
- audio_set_indoor_env(t2, h2);
- clock_screen_set_indoor_env(t2, h2);
- }
-#if CONFIG_PCF85063_ENABLE
- s_rtc_alarm_armed = arm_pcf85063_alarm_wakeup();
-#endif
- /* Update alarm display from server response. */
- const audio_alarm_config_t *acfg2 = audio_get_alarm_config();
- if (acfg2 && acfg2->valid) {
- clock_screen_set_alarm_time(acfg2->hour, acfg2->minute);
- }
- }
- if (audio_radio_url_is_set()) {
- if (audio_play_url() == ESP_OK) {
- clock_screen_set_station_name(audio_get_station_name());
- clock_screen_set_audio_indicator(true);
- s_audio_playing = true;
- }
- }
- }
- }
+ /* Drain 三个事件源 + 1Hz tick,逐个跑 FSM */
+ int safety = 16;
+ while (safety-- > 0) {
+ app_event_t raw = EVT_NONE;
+
+ /* Buttons 优先 */
+ if (notified & EVENT_SLEEP_PENDING) { raw = EVT_BTN_SLEEP_PRESS; notified &= ~EVENT_SLEEP_PENDING; }
+ else if (notified & EVENT_PROVISIONING_REQUEST){ raw = EVT_BTN_PROVISION_REQUEST; notified &= ~EVENT_PROVISIONING_REQUEST; }
+ else if (notified & EVENT_NIGHT_TOGGLE) { raw = EVT_BTN_NIGHT_TOGGLE; notified &= ~EVENT_NIGHT_TOGGLE; }
+ else if (notified & EVENT_AUDIO_TOGGLE) { raw = EVT_BTN_AUDIO_TOGGLE; notified &= ~EVENT_AUDIO_TOGGLE; }
+ else if (notified & EVENT_NTP_SYNC) { raw = EVT_BTN_NTP_SYNC; notified &= ~EVENT_NTP_SYNC; }
+ else if (notified & EVENT_NEXT_TRACK) { raw = EVT_BTN_NEXT_TRACK; notified &= ~EVENT_NEXT_TRACK; }
+ /* WiFi 队列 */
+ else if (wifi_fsm_dequeue(&raw)) { /* got event */ }
+ /* 音频播放器回调队列 */
+ else if (audio_fsm_dequeue(&raw)) { /* got event */ }
+ /* 1Hz tick fallback */
+ else { raw = EVT_TICK_1HZ; }
+
+ /* 路由 → 5 region step → executor。顺序:wake → sys → net → audio → display */
+ app_input_t inp = build_context();
+ routed_events_t r = route_event(raw, &s_state, &inp);
+
+ fsm_actions_t a;
+ /* s_state.* 是 int 字段(见 app_state_t 注释),需要 cast 到
+ * 强类型 enum * (C 不允许 incomplete enum 直接做 struct 字段)。 */
+ a = wake_fsm_step ((wake_state_t *) &s_state.wake, r.wake, &inp);
+ apply_actions(&a);
+ a = sys_fsm_step ((sys_state_t *) &s_state.sys, r.sys, &inp);
+ apply_actions(&a);
+ a = net_fsm_step ((net_state_t *) &s_state.net, r.net, &inp);
+ apply_actions(&a);
+ a = audio_fsm_step ((audio_state_t *) &s_state.audio, r.audio, &inp);
+ apply_actions(&a);
+ a = display_fsm_step ((display_state_t *)&s_state.display, r.display, &inp);
+ apply_actions(&a);
+
+ update_state_caches();
+
+ if (s_state.sys == SYS_SLEEPING) goto fsm_sleep;
+ if (raw == EVT_TICK_1HZ) break; /* 1Hz 跑完退出 */
}
-#endif
+ /* 每 tick 累加闹钟分钟计数器: */
+ if (s_state.wake == WAKE_ALARM_RINGING) s_alarm_ring_minutes++;
- /* Refresh indoor temp every 60s — room T/RH time constant is
- * minutes, no perceptual benefit from faster updates; combined
- * with the SHTC3 SLEEP-between-reads change this drops average
- * sensor current to ~0.13 µA. */
+ /* 60s 周期任务 */
if (tick % 60 == 0) {
float t = 0, h = 0;
if (shtc3_read(&t, &h)) {
+#if CONFIG_AUDIO_ENABLE
audio_set_indoor_env(t, h);
+#endif
clock_screen_set_indoor_env(t, h);
}
- }
-
- /* Periodic heap check every 60s */
- if (tick % 60 == 0) {
log_heap("active_loop");
}
- /* Pending audio start — WiFi was background-started, poll for IP */
- if (s_audio_pending) {
- if (wifi_is_connected()) {
- s_audio_pending = false;
- /* audio_start_playback() fetches /api/esp internally
- * and applies alarm + weather automatically. */
- audio_start_playback(false);
- } else if (++s_audio_pending_ticks >= 30) {
- s_audio_pending = false;
- clock_screen_set_audio_indicator(false);
- clock_screen_set_station_name("WiFi failed");
- }
- }
-
/* Full-screen refresh every second */
lvgl_adapter_refr_now();
}
+fsm_sleep: ;
+
ESP_LOGI(TAG, "Time to sleep, turning off display");
log_heap("pre_sleep");
diff --git a/main/regions/audio_fsm.c b/main/regions/audio_fsm.c
new file mode 100644
index 0000000..6152605
--- /dev/null
+++ b/main/regions/audio_fsm.c
@@ -0,0 +1,181 @@
+/*
+ * audio_fsm.c — 音频播放 FSM。
+ *
+ * 状态机:
+ * IDLE --(BTN/auto)--> INIT --(PLAYER_PLAYING)--> PLAYING
+ * |
+ * +-----+-----+-----+
+ * v v v
+ * STOP NEXT IDLE (auto-advance or stall)
+ * |
+ * v
+ * IDLE (after STOP_DONE)
+ *
+ * 关键不变量 (测试矩阵断言):
+ * - PENDING 状态不发出 ACT_DISPLAY_AUDIO_INDICATOR(true)
+ * (修复 main.c:884 旧 bug:WiFi 还没连就提前亮 indicator)
+ * - IDLE + AUTO_PLAY_REQUEST 不受 agent_enabled 影响
+ * (闹钟唤醒的 auto-play 必须强制,用户没禁用 agent 也不能阻挡)
+ * - INIT + PLAYER_PLAYING 转 PLAYING 后,indicator 才允许亮
+ */
+
+#include "audio_fsm.h"
+#include
+
+#define AUDIO_PENDING_TIMEOUT_SEC 30
+
+static fsm_actions_t add_action(fsm_actions_t a, app_action_kind_t kind)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = kind;
+ memset(&a.items[a.count].u, 0, sizeof(a.items[a.count].u));
+ a.count++;
+ }
+ return a;
+}
+
+static fsm_actions_t add_station(fsm_actions_t a, const char *name)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = ACT_DISPLAY_STATION;
+ a.items[a.count].u.station.name = name;
+ a.count++;
+ }
+ return a;
+}
+
+/* 标准音频启动序列 (来自 audio_start_playback in main.c:219-272) */
+static fsm_actions_t add_audio_start_seq(fsm_actions_t a)
+{
+ a = add_action(a, ACT_AUDIO_INIT);
+ a = add_action(a, ACT_FETCH_API);
+ a = add_action(a, ACT_AUDIO_PLAY_URL);
+ a = add_action(a, ACT_ARM_RTC_FOR_TOMORROW); /* 仅当 acfg->valid */
+ return a;
+}
+
+/* 切曲/auto-advance 序列 (audio_stop + audio_deinit + 全套 init) */
+static fsm_actions_t add_audio_reseq(fsm_actions_t a)
+{
+ a = add_action(a, ACT_AUDIO_STOP);
+ a = add_action(a, ACT_AUDIO_DEINIT);
+ a = add_audio_start_seq(a);
+ return a;
+}
+
+fsm_actions_t audio_fsm_step(audio_state_t *cur, audio_evt_t evt, const app_input_t *inp)
+{
+ fsm_actions_t out = { .count = 0 };
+
+ if (evt == AUDIO_EVT_NONE) {
+ return out;
+ }
+
+ switch (*cur) {
+ case AUDIO_IDLE:
+ if (evt == AUDIO_EVT_BTN_TOGGLE) {
+ if (!inp->agent_enabled) {
+ /* agent 关:按钮忽略,不放电台 */
+ /* identity */
+ } else if (inp->net_connected) {
+ *cur = AUDIO_INIT;
+ out = add_audio_start_seq(out);
+ } else {
+ *cur = AUDIO_PENDING;
+ /* 关键:不发出 ACT_DISPLAY_AUDIO_INDICATOR (修复旧 bug) */
+ out = add_station(out, "Connecting...");
+ /* 必须主动起 wifi 连接 — 旧 main.c 在 BTN_AUDIO_TOGGLE 分支
+ * 直接调 wifi_sta_ensure()。新 FSM 这里等价触发,
+ * 否则 30s 后 PENDING timeout -> ERROR "WiFi failed"。 */
+ out = add_action(out, ACT_WIFI_ENSURE_NETIF);
+ out = add_action(out, ACT_WIFI_STA_ENSURE);
+ }
+ } else if (evt == AUDIO_EVT_AUTO_PLAY_REQUEST) {
+ /* 闹钟唤醒强制 auto-play,忽略 agent 标志 */
+ *cur = AUDIO_INIT;
+ out = add_action(out, ACT_VOLUME_MAX);
+ out = add_audio_start_seq(out);
+ } else if (evt == AUDIO_EVT_AGENT_DISABLED) {
+ /* identity — agent 关闭,音频保持 IDLE */
+ }
+ break;
+
+ case AUDIO_PENDING:
+ if (evt == AUDIO_EVT_NET_OK_FANOUT) {
+ /* router 在 IP_GOT 后通知 audio */
+ *cur = AUDIO_INIT;
+ out = add_audio_start_seq(out);
+ } else if (evt == AUDIO_EVT_TICK_1HZ &&
+ inp->pending_ticks >= AUDIO_PENDING_TIMEOUT_SEC) {
+ *cur = AUDIO_ERROR;
+ out = add_station(out, "WiFi failed");
+ } else if (evt == AUDIO_EVT_BTN_TOGGLE) {
+ /* 用户取消等待,回 IDLE */
+ *cur = AUDIO_IDLE;
+ }
+ break;
+
+ case AUDIO_INIT:
+ if (evt == AUDIO_EVT_PLAYER_PLAYING) {
+ *cur = AUDIO_PLAYING;
+ /* 此时 indicator 才允许亮 (executor 检查状态再亮) */
+ } else if (evt == AUDIO_EVT_PLAYER_ERROR) {
+ *cur = AUDIO_ERROR;
+ out = add_action(out, ACT_AUDIO_DEINIT);
+ out = add_station(out, "audio_failure_station_name()");
+ }
+ /* TICK_1HZ / BTN / 其他 → identity (等待 PLAYER_PLAYING 或 ERROR) */
+ break;
+
+ case AUDIO_PLAYING:
+ if (evt == AUDIO_EVT_BTN_TOGGLE) {
+ *cur = AUDIO_STOPPING;
+ out = add_action(out, ACT_AUDIO_STOP);
+ out = add_action(out, ACT_DISPLAY_AUDIO_INDICATOR);
+ /* indicator false payload */
+ if (out.count > 0) {
+ out.items[out.count - 1].u.indicator.on = false;
+ }
+ } else if (evt == AUDIO_EVT_BTN_NEXT) {
+ /* 切曲 (同 state 重入,但走完整 deinit + init 序列) */
+ out = add_audio_reseq(out);
+ /* state 保持 PLAYING */
+ } else if (evt == AUDIO_EVT_PLAYER_IDLE) {
+ /* 自然播完:进入下一首 (auto-advance) */
+ out = add_audio_reseq(out);
+ } else if (evt == AUDIO_EVT_PLAYER_NEXT) {
+ /* audio_player 主动通知换曲 */
+ out = add_audio_reseq(out);
+ } else if (evt == AUDIO_EVT_PLAYER_ERROR) {
+ *cur = AUDIO_ERROR;
+ out = add_action(out, ACT_AUDIO_DEINIT);
+ out = add_station(out, "audio_failure_station_name()");
+ } else if (evt == AUDIO_EVT_ALARM_COMPLETE) {
+ /* 闹钟时长到:立即关电台 (但 device 继续 wake → GOTO_SLEEP 由 wake_fsm 决定) */
+ *cur = AUDIO_STOPPING;
+ out = add_action(out, ACT_AUDIO_STOP);
+ }
+ /* 其他事件(TICK_1HZ 等) → identity。
+ * auto-advance 由 poll 检测到 PLAYING→IDLE 时驱动。 */
+ break;
+
+ case AUDIO_STOPPING:
+ if (evt == AUDIO_EVT_STOP_DONE) {
+ /* executor 完成 audio_stop() 后回 IDLE */
+ *cur = AUDIO_IDLE;
+ }
+ /* 其他事件 → identity (audio_stop 还没完,等等) */
+ break;
+
+ case AUDIO_ERROR:
+ if (evt == AUDIO_EVT_BTN_TOGGLE) {
+ /* 用户按了重试 */
+ *cur = AUDIO_INIT;
+ out = add_audio_start_seq(out);
+ }
+ /* TICK_1HZ → identity (等用户干预) */
+ break;
+ }
+
+ return out;
+}
\ No newline at end of file
diff --git a/main/regions/audio_fsm.h b/main/regions/audio_fsm.h
new file mode 100644
index 0000000..6596023
--- /dev/null
+++ b/main/regions/audio_fsm.h
@@ -0,0 +1,48 @@
+/*
+ * audio_fsm.h — 音频播放 FSM
+ *
+ * 状态(6): IDLE / PENDING / INIT / PLAYING / STOPPING / ERROR
+ *
+ * 见 plan 文档 "4. audio_fsm" 章节。
+ */
+#ifndef AUDIO_FSM_H
+#define AUDIO_FSM_H
+
+#include "../app_fsm.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum audio_state_e {
+ AUDIO_IDLE = 0,
+ AUDIO_PENDING,
+ AUDIO_INIT,
+ AUDIO_PLAYING,
+ AUDIO_STOPPING,
+ AUDIO_ERROR,
+} audio_state_t;
+
+typedef enum {
+ AUDIO_EVT_NONE = 0,
+ AUDIO_EVT_BTN_TOGGLE,
+ AUDIO_EVT_BTN_NEXT,
+ AUDIO_EVT_PLAYER_PLAYING,
+ AUDIO_EVT_PLAYER_IDLE,
+ AUDIO_EVT_PLAYER_NEXT,
+ AUDIO_EVT_PLAYER_ERROR,
+ AUDIO_EVT_TICK_1HZ,
+ AUDIO_EVT_AGENT_DISABLED,
+ AUDIO_EVT_AUTO_PLAY_REQUEST, /* 来自 wake_fsm 闹钟唤醒 */
+ AUDIO_EVT_ALARM_COMPLETE, /* 闹钟结束后 wake_fsm 通知 */
+ AUDIO_EVT_NET_OK_FANOUT, /* router 在 IP_GOT 后给 audio 发 */
+ AUDIO_EVT_STOP_DONE, /* executor 调用 audio_stop() 后发出 */
+} audio_evt_t;
+
+fsm_actions_t audio_fsm_step(audio_state_t *cur, audio_evt_t evt, const app_input_t *inp);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* AUDIO_FSM_H */
diff --git a/main/regions/display_fsm.c b/main/regions/display_fsm.c
new file mode 100644
index 0000000..1e55744
--- /dev/null
+++ b/main/regions/display_fsm.c
@@ -0,0 +1,87 @@
+/*
+ * display_fsm.c — 日/夜显示 FSM。
+ *
+ * BTN_TOGGLE (右键长按) 简单 flip,匹配旧 right_long_press_cb 行为:
+ * 任何状态 → 取反 inp->night_now → 强制白天或强制夜间
+ * TICK_1HZ 自动检测时间穿越,从 DISP_DAY 进夜或从 DISP_NIGHT 出夜。
+ */
+
+#include "display_fsm.h"
+#include
+
+static fsm_actions_t add_action(fsm_actions_t a, app_action_kind_t kind)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = kind;
+ memset(&a.items[a.count].u, 0, sizeof(a.items[a.count].u));
+ a.count++;
+ }
+ return a;
+}
+
+static fsm_actions_t add_night_mode(fsm_actions_t a, bool on)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = ACT_SET_NIGHT_MODE;
+ a.items[a.count].u.night.on = on;
+ a.count++;
+ }
+ return a;
+}
+
+static fsm_actions_t add_night_override(fsm_actions_t a, int8_t o)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = ACT_SET_NIGHT_OVERRIDE;
+ a.items[a.count].u.night_override.override = o;
+ a.count++;
+ }
+ return a;
+}
+
+fsm_actions_t display_fsm_step(display_state_t *cur, display_evt_t evt,
+ const app_input_t *inp)
+{
+ fsm_actions_t out = { .count = 0 };
+ if (evt == DISP_EVT_NONE) return out;
+
+ switch (*cur) {
+ case DISP_DAY:
+ if (evt == DISP_EVT_TICK_1HZ && inp->night_now) {
+ *cur = DISP_NIGHT_AUTO;
+ out = add_night_mode(out, true);
+ out = add_action(out, ACT_DRAW_MINIMAL_CLOCK);
+ } else if (evt == DISP_EVT_BTN_TOGGLE) {
+ /* flip 到对面:白天 → 强制夜间 */
+ *cur = DISP_NIGHT_FORCED;
+ out = add_night_override(out, 1);
+ out = add_night_mode(out, true);
+ }
+ break;
+
+ case DISP_NIGHT_AUTO:
+ if (evt == DISP_EVT_TICK_1HZ && !inp->night_now) {
+ *cur = DISP_DAY;
+ out = add_night_mode(out, false);
+ out = add_action(out, ACT_DRAW_WEATHER);
+ } else if (evt == DISP_EVT_BTN_TOGGLE) {
+ /* flip 到对面:夜间 → 强制白天 */
+ *cur = DISP_DAY;
+ out = add_night_override(out, 0); /* 0 = 强制白天 */
+ out = add_night_mode(out, false);
+ }
+ break;
+
+ case DISP_NIGHT_FORCED:
+ if (evt == DISP_EVT_BTN_TOGGLE) {
+ /* flip 到对面:强制夜间 → 白天(不回到 auto) */
+ *cur = DISP_DAY;
+ out = add_night_override(out, 0);
+ out = add_night_mode(out, false);
+ }
+ /* TICK_1HZ → identity (强制模式不响应时间穿越) */
+ break;
+ }
+
+ return out;
+}
\ No newline at end of file
diff --git a/main/regions/display_fsm.h b/main/regions/display_fsm.h
new file mode 100644
index 0000000..0d43c0d
--- /dev/null
+++ b/main/regions/display_fsm.h
@@ -0,0 +1,36 @@
+/*
+ * display_fsm.h — 日/夜显示 FSM
+ *
+ * 状态(3): DAY / NIGHT_AUTO / NIGHT_FORCED
+ *
+ * 见 plan 文档 "5. display_fsm" 章节。
+ */
+#ifndef DISPLAY_FSM_H
+#define DISPLAY_FSM_H
+
+#include "../app_fsm.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum display_state_e {
+ DISP_DAY = 0,
+ DISP_NIGHT_AUTO,
+ DISP_NIGHT_FORCED,
+} display_state_t;
+
+typedef enum {
+ DISP_EVT_NONE = 0,
+ DISP_EVT_TICK_1HZ,
+ DISP_EVT_BTN_TOGGLE,
+ DISP_EVT_AGENT_OFF,
+} display_evt_t;
+
+fsm_actions_t display_fsm_step(display_state_t *cur, display_evt_t evt, const app_input_t *inp);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* DISPLAY_FSM_H */
diff --git a/main/regions/net_fsm.c b/main/regions/net_fsm.c
new file mode 100644
index 0000000..bef36c7
--- /dev/null
+++ b/main/regions/net_fsm.c
@@ -0,0 +1,120 @@
+/*
+ * net_fsm.c — 网络状态 FSM。
+ *
+ * 状态机:OFFLINE → (PROVISIONING | CONNECTING) ↔ CONNECTED,FAILED
+ *
+ * boot-time 决策(凭据在不在):
+ * BOOT_DONE & has_creds -> CONNECTING + ACT_WIFI_INIT_STA
+ * BOOT_DONE & !has_creds -> PROVISIONING + ACT_RUN_PROVISIONING
+ *
+ * Provisioning 完成:
+ * PROV_OK -> OFFLINE (executor reboot 由 sys_fsm 走 DEEP_SLEEP)
+ * PROV_FAIL -> OFFLINE + ACT_DISPLAY_STATION("clock-only mode")
+ *
+ * 连接流程:
+ * CONNECTING + IP_GOT -> CONNECTED + ACT_NTP_START
+ * CONNECTING + WIFI_RETRY_EXHAUSTED -> FAILED + ACT_NVS_ERASE_OLD_CREDS
+ * CONNECTING + TICK & ticks >= 30 -> FAILED
+ *
+ * 运行期:
+ * CONNECTED + STA_DISCONNECTED -> CONNECTING + ACT_WIFI_RECONNECT
+ * FAILED + BTN_REQUEST_PROVISION -> PROVISIONING + ACT_RUN_PROVISIONING
+ */
+
+#include "net_fsm.h"
+#include
+
+#define NET_CONNECT_TIMEOUT_SEC 30
+
+static fsm_actions_t add_action(fsm_actions_t a, app_action_kind_t kind)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = kind;
+ memset(&a.items[a.count].u, 0, sizeof(a.items[a.count].u));
+ a.count++;
+ }
+ return a;
+}
+
+/* payload-bearing helper */
+static fsm_actions_t add_station(fsm_actions_t a, const char *name)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = ACT_DISPLAY_STATION;
+ a.items[a.count].u.station.name = name;
+ a.count++;
+ }
+ return a;
+}
+
+fsm_actions_t net_fsm_step(net_state_t *cur, net_evt_t evt, const app_input_t *inp)
+{
+ fsm_actions_t out = { .count = 0 };
+
+ if (evt == NET_EVT_NONE) {
+ return out;
+ }
+
+ switch (*cur) {
+ case NET_OFFLINE:
+ if (evt == NET_EVT_BOOT_DONE) {
+ if (inp->has_creds) {
+ *cur = NET_CONNECTING;
+ out = add_action(out, ACT_WIFI_INIT_STA);
+ } else {
+ *cur = NET_PROVISIONING;
+ out = add_action(out, ACT_RUN_PROVISIONING);
+ }
+ }
+ break;
+
+ case NET_PROVISIONING:
+ if (evt == NET_EVT_PROV_OK) {
+ /* 配网成功:creds 已写入 NVS。sys_fsm 会收到 SYS_EVT_PROV_OK 走
+ * DEEP_SLEEP/reboot。这里 net_fsm 转回 OFFLINE,等下次启动
+ * 再 BOOT_DONE → CONNECTING (有凭据了)。 */
+ *cur = NET_OFFLINE;
+ } else if (evt == NET_EVT_PROV_FAIL) {
+ *cur = NET_OFFLINE;
+ out = add_station(out, "clock-only mode");
+ } else if (evt == NET_EVT_BTN_REQUEST_PROVISION) {
+ /* 用户主动要求重新配网 (例如三击右键) */
+ out = add_action(out, ACT_RUN_PROVISIONING);
+ /* 状态保持 PROVISIONING */
+ }
+ break;
+
+ case NET_CONNECTING:
+ if (evt == NET_EVT_IP_GOT) {
+ *cur = NET_CONNECTED;
+ out = add_action(out, ACT_NTP_START);
+ } else if (evt == NET_EVT_WIFI_RETRY_EXHAUSTED) {
+ /* 10 次重试结束 (wifi.c MAX_RETRY)。自愈:擦 NVS 等用户重配 */
+ *cur = NET_FAILED;
+ out = add_action(out, ACT_NVS_ERASE_OLD_CREDS);
+ } else if (evt == NET_EVT_TICK_1HZ &&
+ inp->net_connect_ticks >= NET_CONNECT_TIMEOUT_SEC) {
+ /* 30s 兜底:NET_EVT_WIFI_RETRY_EXHAUSTED 没到(罕见)也强制 FAILED */
+ *cur = NET_FAILED;
+ }
+ /* 其他事件 → identity */
+ break;
+
+ case NET_CONNECTED:
+ if (evt == NET_EVT_STA_DISCONNECTED) {
+ *cur = NET_CONNECTING;
+ out = add_action(out, ACT_WIFI_RECONNECT);
+ }
+ break;
+
+ case NET_FAILED:
+ if (evt == NET_EVT_BTN_REQUEST_PROVISION) {
+ *cur = NET_PROVISIONING;
+ out = add_action(out, ACT_RUN_PROVISIONING);
+ }
+ /* TICK_1HZ → identity (等待用户干预) */
+ break;
+ }
+
+ return out;
+}
\ No newline at end of file
diff --git a/main/regions/net_fsm.h b/main/regions/net_fsm.h
new file mode 100644
index 0000000..1027b24
--- /dev/null
+++ b/main/regions/net_fsm.h
@@ -0,0 +1,44 @@
+/*
+ * net_fsm.h — 网络状态 FSM
+ *
+ * 状态(5): OFFLINE / PROVISIONING / CONNECTING / CONNECTED / FAILED
+ *
+ * 见 plan 文档 "3. net_fsm" 章节。
+ */
+#ifndef NET_FSM_H
+#define NET_FSM_H
+
+#include "../app_fsm.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum net_state_e {
+ NET_OFFLINE = 0,
+ NET_PROVISIONING,
+ NET_CONNECTING,
+ NET_CONNECTED,
+ NET_FAILED,
+} net_state_t;
+
+typedef enum {
+ NET_EVT_NONE = 0,
+ NET_EVT_BOOT_DONE, /* 一次性,router 在 BOOT_DONE 时 fan-out */
+ NET_EVT_BTN_REQUEST_PROVISION,
+ NET_EVT_PROV_OK,
+ NET_EVT_PROV_FAIL,
+ NET_EVT_STA_CONNECTED,
+ NET_EVT_IP_GOT,
+ NET_EVT_STA_DISCONNECTED,
+ NET_EVT_WIFI_RETRY_EXHAUSTED,
+ NET_EVT_TICK_1HZ,
+} net_evt_t;
+
+fsm_actions_t net_fsm_step(net_state_t *cur, net_evt_t evt, const app_input_t *inp);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* NET_FSM_H */
diff --git a/main/regions/sys_fsm.c b/main/regions/sys_fsm.c
new file mode 100644
index 0000000..88b4392
--- /dev/null
+++ b/main/regions/sys_fsm.c
@@ -0,0 +1,87 @@
+/*
+ * sys_fsm.c — 系统生命周期 FSM。
+ *
+ * 状态机精简:SYS_BOOT → SYS_NORMAL → SYS_SLEEPING (终态)
+ *
+ * sys 负责:
+ * - BOOT_DONE: 上电初始化完成后转 NORMAL
+ * - BTN_SLEEP_PRESS: 用户按右键,完整 deep sleep 流程
+ * - BTN_FACTORY_RESET: 三击,擦 NVS + 重启
+ * - PROV_OK: 配网成功,reboot (executor 决定)
+ * - PROV_FAIL: 配网失败,留在 NORMAL (clock-only mode)
+ * - DEEP_SLEEP_TICK: wake_fsm GOTO_SLEEP 后通知 sys 走 deep sleep
+ *
+ * wake_kind-specific 的 boot 行为 (BTN 不连网, RTC auto-play) 由 wake_fsm
+ * 自己处理,sys 不重复。
+ */
+
+#include "sys_fsm.h"
+#include
+
+static fsm_actions_t add_action(fsm_actions_t a, app_action_kind_t kind)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = kind;
+ memset(&a.items[a.count].u, 0, sizeof(a.items[a.count].u));
+ a.count++;
+ }
+ return a;
+}
+
+/* 标准 deep sleep 动作序列:清理硬件资源。
+ * 注意:不包含 ACT_DEEP_SLEEP — 实际 deep sleep 由 main.c 的
+ * fsm_sleep 路径统一处理(先配 GPIO wake mask,再睡)。 */
+static fsm_actions_t emit_deep_sleep_actions(fsm_actions_t a)
+{
+ a = add_action(a, ACT_AUDIO_DEINIT);
+ a = add_action(a, ACT_DISPLAY_OFF);
+ a = add_action(a, ACT_GPIO_HOLD);
+ a = add_action(a, ACT_TIMER_SET);
+ return a;
+}
+
+fsm_actions_t sys_fsm_step(sys_state_t *cur, sys_evt_t evt, const app_input_t *inp)
+{
+ fsm_actions_t out = { .count = 0 };
+ (void)inp;
+
+ if (evt == SYS_EVT_NONE) {
+ return out;
+ }
+
+ switch (*cur) {
+ case SYS_BOOT:
+ if (evt == SYS_EVT_BOOT_DONE) {
+ *cur = SYS_NORMAL;
+ /* 无动作:net/audio/display/wake 各自处理 boot-time 转换 */
+ } else if (evt == SYS_EVT_DEEP_SLEEP_TICK) {
+ /* wake_fsm 在 BOOT 阶段就决定进 GOTO_SLEEP (罕见边缘情况,
+ * 例如冷启动配网超时,用户在配网中按右键),sys 走 deep sleep */
+ *cur = SYS_SLEEPING;
+ out = emit_deep_sleep_actions(out);
+ }
+ break;
+
+ case SYS_NORMAL:
+ if (evt == SYS_EVT_BTN_SLEEP_PRESS) {
+ *cur = SYS_SLEEPING;
+ out = emit_deep_sleep_actions(out);
+ } else if (evt == SYS_EVT_BTN_FACTORY_RESET) {
+ *cur = SYS_SLEEPING;
+ out = add_action(out, ACT_NVS_ERASE); /* esp_restart, 不返回 */
+ } else if (evt == SYS_EVT_PROV_OK) {
+ /* 配网成功:交给 executor 调 esp_restart */
+ *cur = SYS_SLEEPING;
+ }
+ /* SYS_EVT_PROV_FAIL → stays (clock-only mode, 不进 SLEEPING)
+ * SYS_EVT_NET_OK → stays (信息性,给 audio_fsm 用)
+ * SYS_EVT_TICK_1HZ → stays (心跳) */
+ break;
+
+ case SYS_SLEEPING:
+ /* 终态:任何事件都不动。executor 见到 ACT_DEEP_SLEEP 立即 deep sleep。 */
+ break;
+ }
+
+ return out;
+}
\ No newline at end of file
diff --git a/main/regions/sys_fsm.h b/main/regions/sys_fsm.h
new file mode 100644
index 0000000..79fe407
--- /dev/null
+++ b/main/regions/sys_fsm.h
@@ -0,0 +1,41 @@
+/*
+ * sys_fsm.h — 系统生命周期 FSM
+ *
+ * 状态(3): SYS_BOOT -> SYS_NORMAL -> SYS_SLEEPING
+ *
+ * 见 plan 文档 "2. sys_fsm" 章节。
+ */
+#ifndef SYS_FSM_H
+#define SYS_FSM_H
+
+#include "../app_fsm.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum sys_state_e {
+ SYS_BOOT = 0,
+ SYS_NORMAL,
+ SYS_SLEEPING,
+} sys_state_t;
+
+typedef enum {
+ SYS_EVT_NONE = 0,
+ SYS_EVT_BOOT_DONE,
+ SYS_EVT_BTN_SLEEP_PRESS,
+ SYS_EVT_BTN_FACTORY_RESET,
+ SYS_EVT_TICK_1HZ,
+ SYS_EVT_NET_OK,
+ SYS_EVT_PROV_OK,
+ SYS_EVT_PROV_FAIL,
+ SYS_EVT_DEEP_SLEEP_TICK, /* 来自 wake_fsm GOTO_SLEEP 的推进 */
+} sys_evt_t;
+
+fsm_actions_t sys_fsm_step(sys_state_t *cur, sys_evt_t evt, const app_input_t *inp);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* SYS_FSM_H */
diff --git a/main/regions/wake_fsm.c b/main/regions/wake_fsm.c
new file mode 100644
index 0000000..f8f5b07
--- /dev/null
+++ b/main/regions/wake_fsm.c
@@ -0,0 +1,125 @@
+/*
+ * wake_fsm.c — 唤醒来源与唤醒后生命周期 FSM。
+ *
+ * 完整转换矩阵见 plan 文档 "1. wake_fsm" 章节。
+ *
+ * 关键不变量:
+ * - FROM_BTN 状态绝不发出 ACT_NET_AUTO_CONNECT 或 ACT_AUDIO_AUTO_PLAY
+ * (用户按键唤醒不该自动连网或自动播放)
+ * - GOTO_SLEEP 是终态前的过渡,executor 看到 ACT_DEEP_SLEEP 立即 deep sleep
+ * - ALARM_RINGING 的 15min 兜底由 alarm_ring_minutes 计时器触发
+ * (executor 在 ALARM_RINGING entry 时把 alarm_ring_minutes 复位为 0)
+ */
+
+#include "wake_fsm.h"
+#include
+
+/* 把动作 append 到 out (最多 FSM_ACTIONS_MAX 项)。当前 wake_fsm 只发
+ * kind-only 的动作(没有 payload),所以 helper 不需要 set union。 */
+static fsm_actions_t add_action(fsm_actions_t a, app_action_kind_t kind)
+{
+ if (a.count < FSM_ACTIONS_MAX) {
+ a.items[a.count].kind = kind;
+ /* 留白 union (所有 payload 都初始化为 0) */
+ memset(&a.items[a.count].u, 0, sizeof(a.items[a.count].u));
+ a.count++;
+ }
+ return a;
+}
+
+fsm_actions_t wake_fsm_step(wake_state_t *cur, wake_evt_t evt, const app_input_t *inp)
+{
+ fsm_actions_t out = { .count = 0 };
+
+ /* NONE 事件早返回 identity — region 不关心这个原始事件 */
+ if (evt == WAKE_EVT_NONE) {
+ return out;
+ }
+
+ switch (*cur) {
+ case WAKE_DORMANT:
+ if (evt == WAKE_EVT_DETECT_SOURCE) {
+ if (inp->wake_kind == WAKE_BTN) {
+ /* 用户按右键。看时间。 */
+ *cur = WAKE_FROM_BTN;
+ out = add_action(out, ACT_DISPLAY_FADE_IN);
+ } else if (inp->wake_kind == WAKE_RTC) {
+ /* PCF85063 闹铃。强制 auto-play + 大音量 + 起 wifi。 */
+ *cur = WAKE_FROM_RTC;
+ out = add_action(out, ACT_DISPLAY_FADE_IN);
+ out = add_action(out, ACT_VOLUME_MAX);
+ out = add_action(out, ACT_NET_AUTO_CONNECT);
+ } else {
+ /* WAKE_NONE / WAKE_SYS: 冷启动,纯上电。 */
+ *cur = WAKE_FROM_SYS;
+ }
+ }
+ break;
+
+ case WAKE_FROM_BTN:
+ if (evt == WAKE_EVT_NO_CREDS) {
+ /* 用户按键唤醒但发现没 WiFi 凭据,降级为配网流程 */
+ *cur = WAKE_FROM_SYS;
+ } else if (evt == WAKE_EVT_BTN_SLEEP_PRESS) {
+ /* 用户按右键想睡。直接 deep sleep,不起 audio 不连 wifi */
+ *cur = WAKE_GOTO_SLEEP;
+ out = add_action(out, ACT_DISPLAY_OFF);
+ out = add_action(out, ACT_DEEP_SLEEP);
+ }
+ /* 其他事件 (TICK_1HZ / DETECT_SOURCE 重复 等) → identity (stays) */
+ break;
+
+ case WAKE_FROM_RTC:
+ if (evt == WAKE_EVT_NO_CREDS) {
+ /* 闹铃触发但没 WiFi,降级为只显示时间 (audio_fsm 不会 auto-play) */
+ *cur = WAKE_FROM_BTN;
+ } else if (evt == WAKE_EVT_BOOT_DONE_FANOUT) {
+ /* router 在 BOOT_DONE 后 fan-out 给 wake,通知可以放音频 */
+ if (inp->weekend_skip) {
+ /* 周末:跳闹铃,降级为只显示 */
+ *cur = WAKE_FROM_BTN;
+ } else {
+ *cur = WAKE_ALARM_RINGING;
+ out = add_action(out, ACT_AUDIO_AUTO_PLAY);
+ }
+ } else if (evt == WAKE_EVT_BTN_SLEEP_PRESS) {
+ /* 闹铃刚醒来用户按右键想再睡 — 走 deep sleep */
+ *cur = WAKE_GOTO_SLEEP;
+ out = add_action(out, ACT_DISPLAY_OFF);
+ out = add_action(out, ACT_DEEP_SLEEP);
+ }
+ /* TICK_1HZ → identity (等 BOOT_DONE_FANOUT) */
+ break;
+
+ case WAKE_ALARM_RINGING:
+ if (evt == WAKE_EVT_BTN_LEFT_TOGGLE) {
+ /* 用户按左键关掉闹钟,但设备继续运行 (退化为 FROM_BTN) */
+ *cur = WAKE_FROM_BTN;
+ out = add_action(out, ACT_AUDIO_STOP);
+ } else if (evt == WAKE_EVT_ALARM_COMPLETE) {
+ /* 闹钟时长到。arm 下次 + deep sleep */
+ *cur = WAKE_GOTO_SLEEP;
+ out = add_action(out, ACT_ARM_RTC_FOR_TOMORROW);
+ out = add_action(out, ACT_DEEP_SLEEP);
+ } else if (evt == WAKE_EVT_TICK_1HZ && inp->alarm_ring_minutes >= 15) {
+ /* 15min 兜底:防止用户没按键导致永久响铃 */
+ *cur = WAKE_GOTO_SLEEP;
+ out = add_action(out, ACT_ARM_RTC_FOR_TOMORROW);
+ out = add_action(out, ACT_DEEP_SLEEP);
+ }
+ /* 其他事件 → identity */
+ break;
+
+ case WAKE_FROM_SYS:
+ /* 冷启动 / 配网阶段。wake 状态保持,sys/net/audio/display 各自处理 */
+ /* 配网成功后由 NET_EVT_PROV_OK 等其他信号驱动后续 */
+ break;
+
+ case WAKE_GOTO_SLEEP:
+ /* 终态前过渡。executor 看到 ACT_DEEP_SLEEP 立即 deep sleep。
+ * 即便后续再有事件进来,这里也不动。 */
+ break;
+ }
+
+ return out;
+}
\ No newline at end of file
diff --git a/main/regions/wake_fsm.h b/main/regions/wake_fsm.h
new file mode 100644
index 0000000..6517a17
--- /dev/null
+++ b/main/regions/wake_fsm.h
@@ -0,0 +1,50 @@
+/*
+ * wake_fsm.h — 唤醒来源与唤醒后生命周期 FSM
+ *
+ * 状态(6):
+ * WAKE_DORMANT 抽象上电前
+ * WAKE_FROM_BTN 用户按右键。Intent: 只显示,不连网不播放
+ * WAKE_FROM_RTC 闹铃。Intent: 强制 auto-play,最大音量
+ * WAKE_FROM_SYS 冷启动 / 配网
+ * WAKE_ALARM_RINGING FROM_RTC 内的响铃子态
+ * WAKE_GOTO_SLEEP 终态前的过渡
+ *
+ * 见 plan 文档 "1. wake_fsm" 章节的完整转换矩阵。
+ */
+#ifndef WAKE_FSM_H
+#define WAKE_FSM_H
+
+#include "../app_fsm.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum wake_state_e {
+ WAKE_DORMANT = 0,
+ WAKE_FROM_BTN,
+ WAKE_FROM_RTC,
+ WAKE_FROM_SYS,
+ WAKE_ALARM_RINGING,
+ WAKE_GOTO_SLEEP,
+} wake_state_t;
+
+typedef enum {
+ WAKE_EVT_NONE = 0,
+ WAKE_EVT_DETECT_SOURCE, /* 一次性:boot 完成时由 router 发出 */
+ WAKE_EVT_BTN_SLEEP_PRESS, /* 右键短按 */
+ WAKE_EVT_BTN_LEFT_TOGGLE, /* 左键短按 (闹钟响起时关掉) */
+ WAKE_EVT_ALARM_COMPLETE, /* 15min timer 或用户主动 */
+ WAKE_EVT_NO_CREDS, /* 来自其他 region 的"无凭据"信号 */
+ WAKE_EVT_BOOT_DONE_FANOUT, /* router 在 BOOT_DONE 后追加的二次 fan-out */
+ WAKE_EVT_TICK_1HZ,
+} wake_evt_t;
+
+/* 纯函数:主机可编译。evt == WAKE_EVT_NONE 时返回 {0 actions, 不修改 *cur}。 */
+fsm_actions_t wake_fsm_step(wake_state_t *cur, wake_evt_t evt, const app_input_t *inp);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* WAKE_FSM_H */
diff --git a/main/wifi.c b/main/wifi.c
index 3d9e3a0..4045e6f 100644
--- a/main/wifi.c
+++ b/main/wifi.c
@@ -1,8 +1,10 @@
#include "wifi.h"
+#include "app_fsm.h"
#include
#include
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
+#include "freertos/queue.h"
#include "freertos/event_groups.h"
#include "esp_system.h"
#include "esp_mac.h"
@@ -48,6 +50,31 @@ static bool s_wifi_connected = false;
static bool s_using_nvs_creds = false;
static bool s_wifi_started = false;
+/* ── FSM event queue ──────────────────────────────────────────────────
+ * wifi_event_handler 把 ESP-IDF WiFi 事件转换为 app_event_t,
+ * 投递到 FreeRTOS Queue。主循环通过 wifi_fsm_dequeue() drain。
+ * 容量 8:足够覆盖快速触发的 STA_CONNECTED → IP_GOT 序列。 */
+static QueueHandle_t s_wifi_fsm_queue = NULL;
+
+static void wifi_fsm_queue_init(void)
+{
+ if (s_wifi_fsm_queue == NULL) {
+ s_wifi_fsm_queue = xQueueCreate(8, sizeof(app_event_t));
+ }
+}
+
+/* push 一个 app_event_t 到 fsm queue (non-blocking)。 */
+static void wifi_fsm_push(app_event_t ev)
+{
+ if (s_wifi_fsm_queue != NULL) {
+ /* 非阻塞写;queue 满则丢弃最旧的事件 */
+ app_event_t e = ev;
+ if (xQueueSend(s_wifi_fsm_queue, &e, 0) != pdTRUE) {
+ ESP_LOGW(TAG, "wifi_fsm_queue full, dropping event %d", (int)ev);
+ }
+ }
+}
+
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
@@ -62,6 +89,9 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base,
ESP_LOGI(TAG, "WIFI_EVENT_STA_START received");
esp_err_t err = esp_wifi_connect();
ESP_LOGI(TAG, "esp_wifi_connect returned: %s", esp_err_to_name(err));
+ } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) {
+ ESP_LOGI(TAG, "WIFI_EVENT_STA_CONNECTED -> EVT_WIFI_STA_CONNECTED");
+ wifi_fsm_push(EVT_WIFI_STA_CONNECTED);
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
s_wifi_connected = false;
if (s_suppress_auto_connect) {
@@ -99,13 +129,17 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base,
} else {
ESP_LOGW(TAG, "WiFi max retry reached, giving up");
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
+ wifi_fsm_push(EVT_WIFI_TIMEOUT);
}
+ /* 不论是否耗尽重试,都通知 FSM:连不上 */
+ wifi_fsm_push(EVT_WIFI_DISCONNECTED);
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "got ip:" IPSTR, IP2STR(&event->ip_info.ip));
s_retry_num = 0;
s_wifi_connected = true;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
+ wifi_fsm_push(EVT_WIFI_IP_GOT);
}
}
@@ -159,10 +193,21 @@ esp_err_t wifi_ensure_netif(void)
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
s_netif_inited = true;
+
+ /* FSM event queue: 主循环后续会 drain (Step 11)。 */
+ wifi_fsm_queue_init();
+
ESP_LOGI(TAG, "Netif initialized");
return ESP_OK;
}
+/* 主循环 drain wifi FSM 事件。非阻塞,queue 空时返回 false。 */
+bool wifi_fsm_dequeue(app_event_t *out)
+{
+ if (s_wifi_fsm_queue == NULL || out == NULL) return false;
+ return xQueueReceive(s_wifi_fsm_queue, out, 0) == pdTRUE;
+}
+
static bool s_wifi_inited = false;
static esp_err_t wifi_wait_connected(int timeout_ms)
diff --git a/main/wifi.h b/main/wifi.h
index 2d1eb5a..1bfebef 100644
--- a/main/wifi.h
+++ b/main/wifi.h
@@ -33,6 +33,15 @@ void wifi_suppress_auto_connect(bool suppress);
* STA mode. */
void wifi_mark_radio_started(void);
+/* ── FSM 事件队列 ────────────────────────────────────────────────────
+ * wifi_event_handler 把 ESP-IDF WiFi 事件 (STA_CONNECTED / DISCONNECTED /
+ * IP_GOT / WIFI_TIMEOUT) 转换为 app_event_t 并推入内部 FreeRTOS Queue。
+ * 主循环通过 wifi_fsm_dequeue() 拉出后路由到 event_router。
+ *
+ * Queue 容量 8。返回 false 表示 queue 空 (无需 drain)。 */
+#include "app_fsm.h"
+bool wifi_fsm_dequeue(app_event_t *out);
+
/* NVS-persisted WiFi credentials. Lengths sized for IEEE 802.11 max (32 SSID
* + 64 PSK) plus a NUL each. */
typedef struct {
diff --git a/tests/Makefile b/tests/Makefile
index 10b0708..456b15b 100644
--- a/tests/Makefile
+++ b/tests/Makefile
@@ -5,7 +5,9 @@ CC ?= cc
CFLAGS ?= -std=c99 -Wall -Wextra -Wpedantic -O2 -g
BUILDDIR = build
-TESTS = test_wifi_creds test_agent_config
+TESTS = test_wifi_creds test_agent_config \
+ test_wake_fsm test_sys_fsm test_net_fsm test_audio_fsm test_display_fsm \
+ test_event_router
.PHONY: all test clean
all: test
@@ -16,9 +18,46 @@ test: $(addprefix $(BUILDDIR)/,$(TESTS))
./$(BUILDDIR)/$$t || exit 1; \
done
-$(BUILDDIR)/test_%: test_%.c | $(BUILDDIR)
+# 单文件测试 (现有 test_*, 内联 mock 数据)
+$(BUILDDIR)/test_wifi_creds: test_wifi_creds.c | $(BUILDDIR)
$(CC) $(CFLAGS) -I. $< -o $@
+$(BUILDDIR)/test_agent_config: test_agent_config.c | $(BUILDDIR)
+ $(CC) $(CFLAGS) -I. $< -o $@
+
+# FSM 测试:同时编译 test__fsm.c 和对应生产 ../main/regions/_fsm.c
+# 这样测试链接生产代码(不是镜像复制),生产代码漂移会在编译期暴露。
+MAIN_DIR = ../main
+REGIONS = $(MAIN_DIR)/regions
+INCLUDES = -I. -I$(MAIN_DIR) -I$(REGIONS)
+
+$(BUILDDIR)/test_wake_fsm: test_wake_fsm.c $(REGIONS)/wake_fsm.c $(REGIONS)/../app_fsm.h | $(BUILDDIR)
+ $(CC) $(CFLAGS) $(INCLUDES) $< $(REGIONS)/wake_fsm.c -o $@
+
+$(BUILDDIR)/test_sys_fsm: test_sys_fsm.c $(REGIONS)/sys_fsm.c $(REGIONS)/../app_fsm.h | $(BUILDDIR)
+ $(CC) $(CFLAGS) $(INCLUDES) $< $(REGIONS)/sys_fsm.c -o $@
+
+$(BUILDDIR)/test_net_fsm: test_net_fsm.c $(REGIONS)/net_fsm.c $(REGIONS)/../app_fsm.h | $(BUILDDIR)
+ $(CC) $(CFLAGS) $(INCLUDES) $< $(REGIONS)/net_fsm.c -o $@
+
+$(BUILDDIR)/test_audio_fsm: test_audio_fsm.c $(REGIONS)/audio_fsm.c $(REGIONS)/../app_fsm.h | $(BUILDDIR)
+ $(CC) $(CFLAGS) $(INCLUDES) $< $(REGIONS)/audio_fsm.c -o $@
+
+$(BUILDDIR)/test_display_fsm: test_display_fsm.c $(REGIONS)/display_fsm.c $(REGIONS)/../app_fsm.h | $(BUILDDIR)
+ $(CC) $(CFLAGS) $(INCLUDES) $< $(REGIONS)/display_fsm.c -o $@
+
+# 事件路由器:测试 + router + 所有 region(因为 router.h include 它们)
+$(BUILDDIR)/test_event_router: test_event_router.c \
+ $(MAIN_DIR)/event_router.c \
+ $(REGIONS)/wake_fsm.c $(REGIONS)/sys_fsm.c \
+ $(REGIONS)/net_fsm.c $(REGIONS)/audio_fsm.c \
+ $(REGIONS)/display_fsm.c | $(BUILDDIR)
+ $(CC) $(CFLAGS) $(INCLUDES) $< \
+ $(MAIN_DIR)/event_router.c \
+ $(REGIONS)/wake_fsm.c $(REGIONS)/sys_fsm.c \
+ $(REGIONS)/net_fsm.c $(REGIONS)/audio_fsm.c \
+ $(REGIONS)/display_fsm.c -o $@
+
$(BUILDDIR):
@mkdir -p $@
diff --git a/tests/test_audio_fsm.c b/tests/test_audio_fsm.c
new file mode 100644
index 0000000..eb00f05
--- /dev/null
+++ b/tests/test_audio_fsm.c
@@ -0,0 +1,353 @@
+/*
+ * test_audio_fsm.c — 完整转换矩阵测试。
+ * 直接链接生产 ../main/regions/audio_fsm.c。
+ *
+ * 关键不变量(计划文档 §测试矩阵 -> audio_fsm):
+ * - IDLE + BTN_TOGGLE & !agent_enabled → identity
+ * - IDLE + BTN_TOGGLE & !net_connected → PENDING (不亮 indicator!)
+ * - PENDING + TICK_1HZ & pending_ticks >= 30 → ERROR
+ * - IDLE + AUTO_PLAY_REQUEST 不受 agent_enabled 影响
+ * - INIT/STOPPING 不能同时有 ACT_AUDIO_INIT 和 ACT_AUDIO_DEINIT
+ */
+
+#include
+#include
+#include
+#include "../main/regions/audio_fsm.h"
+
+static int failures = 0;
+#define EXPECT(cond) do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
+ failures++; \
+ } \
+} while (0)
+
+#define EXPECT_ACTIONS(a, ...) do { \
+ app_action_kind_t expected[] = { __VA_ARGS__ }; \
+ size_t n = sizeof(expected) / sizeof(expected[0]); \
+ EXPECT((a).count == n); \
+ for (size_t i = 0; i < n && i < (a).count; i++) { \
+ EXPECT((a).items[i].kind == expected[i]); \
+ } \
+} while (0)
+
+static bool contains_action(const fsm_actions_t *a, app_action_kind_t kind)
+{
+ for (uint8_t i = 0; i < a->count; i++) {
+ if (a->items[i].kind == kind) return true;
+ }
+ return false;
+}
+
+static app_input_t mk_inp(bool agent, bool wifi)
+{
+ app_input_t inp; memset(&inp, 0, sizeof(inp));
+ inp.agent_enabled = agent;
+ inp.net_connected = wifi;
+ return inp;
+}
+
+/* ── IDLE 转换 ────────────────────────────────────────────────────────── */
+static void test_idle_btn_agent_off_ignored(void)
+{
+ audio_state_t s = AUDIO_IDLE;
+ app_input_t inp = mk_inp(false, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == AUDIO_IDLE);
+ EXPECT(a.count == 0);
+}
+
+static void test_idle_btn_with_wifi_to_init(void)
+{
+ audio_state_t s = AUDIO_IDLE;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == AUDIO_INIT);
+ EXPECT_ACTIONS(a, ACT_AUDIO_INIT, ACT_FETCH_API,
+ ACT_AUDIO_PLAY_URL, ACT_ARM_RTC_FOR_TOMORROW);
+}
+
+static void test_idle_btn_no_wifi_to_pending(void)
+{
+ audio_state_t s = AUDIO_IDLE;
+ app_input_t inp = mk_inp(true, false);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == AUDIO_PENDING);
+ /* 关键:不亮 indicator,只显示 "Connecting..." + 触发 wifi 连接 */
+ EXPECT(a.count == 3);
+ EXPECT(a.items[0].kind == ACT_DISPLAY_STATION);
+ EXPECT(!contains_action(&a, ACT_DISPLAY_AUDIO_INDICATOR));
+ EXPECT(!contains_action(&a, ACT_AUDIO_INIT));
+ EXPECT(contains_action(&a, ACT_WIFI_ENSURE_NETIF));
+ EXPECT(contains_action(&a, ACT_WIFI_STA_ENSURE));
+}
+
+/* 关键不变量测试:遍历所有 PENDING 状态可能产生的动作,确认不含 INDICATOR(true) */
+static void test_pending_no_indicator_invariant(void)
+{
+ audio_evt_t evts[] = {
+ AUDIO_EVT_BTN_TOGGLE, AUDIO_EVT_TICK_1HZ, AUDIO_EVT_NET_OK_FANOUT,
+ AUDIO_EVT_PLAYER_PLAYING, AUDIO_EVT_PLAYER_ERROR,
+ AUDIO_EVT_AUTO_PLAY_REQUEST, AUDIO_EVT_ALARM_COMPLETE,
+ };
+ for (size_t i = 0; i < sizeof(evts)/sizeof(evts[0]); i++) {
+ audio_state_t s = AUDIO_PENDING;
+ app_input_t inp = mk_inp(true, false);
+ inp.pending_ticks = 5;
+ fsm_actions_t a = audio_fsm_step(&s, evts[i], &inp);
+ /* 任何 INDICATOR 动作都不该出现,且不该是 true */
+ for (uint8_t k = 0; k < a.count; k++) {
+ if (a.items[k].kind == ACT_DISPLAY_AUDIO_INDICATOR) {
+ EXPECT(!a.items[k].u.indicator.on);
+ }
+ }
+ }
+}
+
+static void test_idle_auto_play_ignores_agent(void)
+{
+ /* 关键不变量:闹钟唤醒的 auto-play 不受 agent 标志影响 */
+ audio_state_t s = AUDIO_IDLE;
+ app_input_t inp = mk_inp(false, true); /* agent=false, wifi=true */
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_AUTO_PLAY_REQUEST, &inp);
+ EXPECT(s == AUDIO_INIT);
+ /* 必须包含 VOLUME_MAX (闹钟唤醒标志) */
+ EXPECT(contains_action(&a, ACT_VOLUME_MAX));
+ EXPECT(contains_action(&a, ACT_AUDIO_INIT));
+ EXPECT(contains_action(&a, ACT_AUDIO_PLAY_URL));
+}
+
+static void test_idle_auto_play_no_wifi_still_init(void)
+{
+ /* agent=false, wifi=false:闹钟唤醒仍走 INIT (auto-play 跳过 net_connected 守卫) */
+ audio_state_t s = AUDIO_IDLE;
+ app_input_t inp = mk_inp(false, false);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_AUTO_PLAY_REQUEST, &inp);
+ EXPECT(s == AUDIO_INIT);
+ (void)a;
+}
+
+/* ── PENDING 转换 ─────────────────────────────────────────────────────── */
+static void test_pending_net_ok_to_init(void)
+{
+ audio_state_t s = AUDIO_PENDING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_NET_OK_FANOUT, &inp);
+ EXPECT(s == AUDIO_INIT);
+ EXPECT(contains_action(&a, ACT_AUDIO_INIT));
+}
+
+static void test_pending_30s_timeout_to_error(void)
+{
+ audio_state_t s = AUDIO_PENDING;
+ app_input_t inp = mk_inp(true, false);
+ inp.pending_ticks = 30;
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_TICK_1HZ, &inp);
+ EXPECT(s == AUDIO_ERROR);
+ EXPECT_ACTIONS(a, ACT_DISPLAY_STATION);
+}
+
+static void test_pending_tick_below_threshold(void)
+{
+ audio_state_t s = AUDIO_PENDING;
+ app_input_t inp = mk_inp(true, false);
+ inp.pending_ticks = 5;
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_TICK_1HZ, &inp);
+ EXPECT(s == AUDIO_PENDING);
+ EXPECT(a.count == 0);
+}
+
+static void test_pending_btn_toggle_cancel(void)
+{
+ audio_state_t s = AUDIO_PENDING;
+ app_input_t inp = mk_inp(true, false);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == AUDIO_IDLE);
+ EXPECT(a.count == 0);
+}
+
+/* ── INIT 转换 ────────────────────────────────────────────────────────── */
+static void test_init_player_playing_to_playing(void)
+{
+ audio_state_t s = AUDIO_INIT;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_PLAYER_PLAYING, &inp);
+ EXPECT(s == AUDIO_PLAYING);
+ EXPECT(a.count == 0);
+}
+
+static void test_init_player_error_to_error(void)
+{
+ audio_state_t s = AUDIO_INIT;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_PLAYER_ERROR, &inp);
+ EXPECT(s == AUDIO_ERROR);
+ EXPECT_ACTIONS(a, ACT_AUDIO_DEINIT, ACT_DISPLAY_STATION);
+}
+
+/* ── PLAYING 转换 ─────────────────────────────────────────────────────── */
+static void test_playing_btn_toggle_to_stopping(void)
+{
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == AUDIO_STOPPING);
+ EXPECT_ACTIONS(a, ACT_AUDIO_STOP, ACT_DISPLAY_AUDIO_INDICATOR);
+ EXPECT(!a.items[1].u.indicator.on); /* indicator false */
+}
+
+static void test_playing_btn_next_self_loop(void)
+{
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_NEXT, &inp);
+ EXPECT(s == AUDIO_PLAYING); /* self-loop */
+ /* 完整 deinit + init 序列 */
+ EXPECT(contains_action(&a, ACT_AUDIO_STOP));
+ EXPECT(contains_action(&a, ACT_AUDIO_DEINIT));
+ EXPECT(contains_action(&a, ACT_AUDIO_INIT));
+ EXPECT(contains_action(&a, ACT_FETCH_API));
+ EXPECT(contains_action(&a, ACT_AUDIO_PLAY_URL));
+}
+
+static void test_playing_player_idle_auto_advance(void)
+{
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_PLAYER_IDLE, &inp);
+ EXPECT(s == AUDIO_PLAYING);
+ EXPECT(contains_action(&a, ACT_AUDIO_STOP));
+ EXPECT(contains_action(&a, ACT_AUDIO_INIT));
+}
+
+static void test_playing_player_error_to_error(void)
+{
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_PLAYER_ERROR, &inp);
+ EXPECT(s == AUDIO_ERROR);
+ EXPECT_ACTIONS(a, ACT_AUDIO_DEINIT, ACT_DISPLAY_STATION);
+}
+
+static void test_playing_alarm_complete_to_stopping(void)
+{
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_ALARM_COMPLETE, &inp);
+ EXPECT(s == AUDIO_STOPPING);
+ EXPECT_ACTIONS(a, ACT_AUDIO_STOP);
+}
+
+static void test_playing_tick_hz_identity(void)
+{
+ /* stall detection removed: TICK_1HZ in PLAYING is always identity */
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ inp.stall_ticks = 99;
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_TICK_1HZ, &inp);
+ EXPECT(s == AUDIO_PLAYING);
+ EXPECT(a.count == 0);
+}
+
+/* ── STOPPING 转换 ────────────────────────────────────────────────────── */
+static void test_stopping_done_to_idle(void)
+{
+ audio_state_t s = AUDIO_STOPPING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_STOP_DONE, &inp);
+ EXPECT(s == AUDIO_IDLE);
+ EXPECT(a.count == 0);
+}
+
+/* ── ERROR 转换 ───────────────────────────────────────────────────────── */
+static void test_error_btn_toggle_retry(void)
+{
+ audio_state_t s = AUDIO_ERROR;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == AUDIO_INIT);
+ EXPECT(contains_action(&a, ACT_AUDIO_INIT));
+}
+
+/* ── 不变量 ───────────────────────────────────────────────────────────── */
+static void test_none_event_identity(void)
+{
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_NONE, &inp);
+ EXPECT(s == AUDIO_PLAYING);
+ EXPECT(a.count == 0);
+}
+
+/* 关键不变量:任何 apply_actions 中 ACT_AUDIO_INIT 与 ACT_AUDIO_DEINIT 不同时存在 */
+static void test_no_init_and_deinit_in_same_actions(void)
+{
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_NEXT, &inp);
+ EXPECT(!(contains_action(&a, ACT_AUDIO_INIT) &&
+ contains_action(&a, ACT_AUDIO_DEINIT) &&
+ /* 但 self-loop 序列里这两个是有顺序的(deinit 在 init 之前),
+ * executor 按顺序执行不会冲突。仅在 actions 中若两者相邻
+ * 紧贴且 deinit 在 init 之后才算违反 */
+ /* 这里只检查:整个 actions list 不应该 deinit 在 init 之后 */
+ false));
+ /* 正确检查:遍历 actions,检查是否有 deinit 出现在 init 之后 */
+ bool seen_init = false;
+ for (uint8_t i = 0; i < a.count; i++) {
+ if (a.items[i].kind == ACT_AUDIO_INIT) seen_init = true;
+ if (a.items[i].kind == ACT_AUDIO_DEINIT && seen_init) {
+ fprintf(stderr, "FAIL: ACT_AUDIO_DEINIT after ACT_AUDIO_INIT in actions\n");
+ failures++;
+ }
+ }
+}
+
+static void test_actions_count_invariant(void)
+{
+ audio_state_t s = AUDIO_PLAYING;
+ app_input_t inp = mk_inp(true, true);
+ fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_NEXT, &inp);
+ EXPECT(a.count <= FSM_ACTIONS_MAX);
+}
+
+int main(void)
+{
+ test_idle_btn_agent_off_ignored();
+ test_idle_btn_with_wifi_to_init();
+ test_idle_btn_no_wifi_to_pending();
+ test_idle_auto_play_ignores_agent();
+ test_idle_auto_play_no_wifi_still_init();
+
+ test_pending_net_ok_to_init();
+ test_pending_30s_timeout_to_error();
+ test_pending_tick_below_threshold();
+ test_pending_btn_toggle_cancel();
+ test_pending_no_indicator_invariant();
+
+ test_init_player_playing_to_playing();
+ test_init_player_error_to_error();
+
+ test_playing_btn_toggle_to_stopping();
+ test_playing_btn_next_self_loop();
+ test_playing_player_idle_auto_advance();
+ test_playing_player_error_to_error();
+ test_playing_alarm_complete_to_stopping();
+ test_playing_tick_hz_identity();
+
+ test_stopping_done_to_idle();
+
+ test_error_btn_toggle_retry();
+
+ test_none_event_identity();
+ test_no_init_and_deinit_in_same_actions();
+ test_actions_count_invariant();
+
+ if (failures) {
+ fprintf(stderr, "%d failures\n", failures);
+ return 1;
+ }
+ printf("ok audio_fsm full transition matrix (%d cases)\n", 24);
+ return 0;
+}
\ No newline at end of file
diff --git a/tests/test_display_fsm.c b/tests/test_display_fsm.c
new file mode 100644
index 0000000..71466ec
--- /dev/null
+++ b/tests/test_display_fsm.c
@@ -0,0 +1,211 @@
+/*
+ * test_display_fsm.c — 完整转换矩阵测试。
+ * 直接链接生产 ../main/regions/display_fsm.c。
+ */
+
+#include
+#include
+#include
+#include "../main/regions/display_fsm.h"
+
+static int failures = 0;
+#define EXPECT(cond) do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
+ failures++; \
+ } \
+} while (0)
+
+#define EXPECT_ACTIONS(a, ...) do { \
+ app_action_kind_t expected[] = { __VA_ARGS__ }; \
+ size_t n = sizeof(expected) / sizeof(expected[0]); \
+ EXPECT((a).count == n); \
+ for (size_t i = 0; i < n && i < (a).count; i++) { \
+ EXPECT((a).items[i].kind == expected[i]); \
+ } \
+} while (0)
+
+static bool contains(const fsm_actions_t *a, app_action_kind_t k)
+{
+ for (uint8_t i = 0; i < a->count; i++) if (a->items[i].kind == k) return true;
+ return false;
+}
+
+static app_input_t mk_inp(bool night_now)
+{
+ app_input_t inp; memset(&inp, 0, sizeof(inp));
+ inp.night_now = night_now;
+ return inp;
+}
+
+/* ── DAY 转换 ────────────────────────────────────────────────────────── */
+static void test_day_tick_entering_night(void)
+{
+ display_state_t s = DISP_DAY;
+ app_input_t inp = mk_inp(true); /* 现在是夜间 */
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_TICK_1HZ, &inp);
+ EXPECT(s == DISP_NIGHT_AUTO);
+ EXPECT_ACTIONS(a, ACT_SET_NIGHT_MODE, ACT_DRAW_MINIMAL_CLOCK);
+ /* night_mode = true */
+ EXPECT(a.items[0].u.night.on == true);
+}
+
+static void test_day_tick_still_day(void)
+{
+ display_state_t s = DISP_DAY;
+ app_input_t inp = mk_inp(false); /* 仍是白天 */
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_TICK_1HZ, &inp);
+ EXPECT(s == DISP_DAY);
+ EXPECT(a.count == 0);
+}
+
+static void test_day_btn_toggle_to_forced(void)
+{
+ display_state_t s = DISP_DAY;
+ app_input_t inp = mk_inp(false);
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == DISP_NIGHT_FORCED);
+ EXPECT(contains(&a, ACT_SET_NIGHT_OVERRIDE));
+ EXPECT(a.items[0].u.night_override.override == 1);
+ EXPECT(contains(&a, ACT_SET_NIGHT_MODE));
+ EXPECT(a.items[1].u.night.on == true);
+}
+
+/* ── NIGHT_AUTO 转换 ─────────────────────────────────────────────────── */
+static void test_night_auto_tick_leaving_night(void)
+{
+ display_state_t s = DISP_NIGHT_AUTO;
+ app_input_t inp = mk_inp(false); /* 离开夜间 */
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_TICK_1HZ, &inp);
+ EXPECT(s == DISP_DAY);
+ EXPECT_ACTIONS(a, ACT_SET_NIGHT_MODE, ACT_DRAW_WEATHER);
+ EXPECT(a.items[0].u.night.on == false);
+}
+
+static void test_night_auto_tick_still_night(void)
+{
+ display_state_t s = DISP_NIGHT_AUTO;
+ app_input_t inp = mk_inp(true);
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_TICK_1HZ, &inp);
+ EXPECT(s == DISP_NIGHT_AUTO);
+ EXPECT(a.count == 0);
+}
+
+static void test_night_auto_btn_toggle_to_forced(void)
+{
+ display_state_t s = DISP_NIGHT_AUTO;
+ app_input_t inp = mk_inp(true);
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == DISP_NIGHT_FORCED);
+ EXPECT(a.items[0].u.night_override.override == 1);
+}
+
+/* ── NIGHT_FORCED 转换 ───────────────────────────────────────────────── */
+static void test_forced_btn_toggle_during_night_returns_to_auto(void)
+{
+ display_state_t s = DISP_NIGHT_FORCED;
+ app_input_t inp = mk_inp(true); /* 当前仍是夜间时段 */
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == DISP_NIGHT_AUTO);
+ EXPECT(a.items[0].u.night_override.override == -1);
+}
+
+static void test_forced_btn_toggle_during_day_returns_to_day(void)
+{
+ display_state_t s = DISP_NIGHT_FORCED;
+ app_input_t inp = mk_inp(false); /* 已经白天 */
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == DISP_DAY);
+ EXPECT(a.items[0].u.night_override.override == 0);
+ EXPECT(contains(&a, ACT_SET_NIGHT_MODE));
+ /* night_mode = false (回 DAY 时关闭夜间) */
+ bool found_night_off = false;
+ for (uint8_t i = 0; i < a.count; i++) {
+ if (a.items[i].kind == ACT_SET_NIGHT_MODE) {
+ EXPECT(!a.items[i].u.night.on);
+ found_night_off = true;
+ }
+ }
+ EXPECT(found_night_off);
+}
+
+static void test_forced_tick_does_not_transition(void)
+{
+ /* 强制模式不响应时间穿越,等用户取消 */
+ display_state_t s = DISP_NIGHT_FORCED;
+ app_input_t inp = mk_inp(false); /* 已经白天 */
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_TICK_1HZ, &inp);
+ EXPECT(s == DISP_NIGHT_FORCED);
+ EXPECT(a.count == 0);
+}
+
+/* ── 不变量 ───────────────────────────────────────────────────────────── */
+static void test_none_event_identity(void)
+{
+ display_state_t s = DISP_DAY;
+ app_input_t inp = mk_inp(false);
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_NONE, &inp);
+ EXPECT(s == DISP_DAY);
+ EXPECT(a.count == 0);
+}
+
+static void test_agent_off_event_identity(void)
+{
+ /* AGENT_OFF 是 informational,不应引发任何 transition */
+ display_state_t s = DISP_DAY;
+ app_input_t inp = mk_inp(false);
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_AGENT_OFF, &inp);
+ EXPECT(s == DISP_DAY);
+ EXPECT(a.count == 0);
+}
+
+static void test_actions_count_invariant(void)
+{
+ display_state_t s = DISP_DAY;
+ app_input_t inp = mk_inp(true);
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_TICK_1HZ, &inp);
+ EXPECT(a.count <= FSM_ACTIONS_MAX);
+}
+
+/* ── 完整循环:DAY → FORCED → DAY (白天时段) ────────────────────── */
+static void test_full_cycle_day_to_forced_to_day(void)
+{
+ display_state_t s = DISP_DAY;
+ app_input_t inp = mk_inp(false);
+
+ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == DISP_NIGHT_FORCED);
+ (void)a;
+
+ fsm_actions_t a2 = display_fsm_step(&s, DISP_EVT_BTN_TOGGLE, &inp);
+ EXPECT(s == DISP_DAY);
+ (void)a2;
+}
+
+int main(void)
+{
+ test_day_tick_entering_night();
+ test_day_tick_still_day();
+ test_day_btn_toggle_to_forced();
+
+ test_night_auto_tick_leaving_night();
+ test_night_auto_tick_still_night();
+ test_night_auto_btn_toggle_to_forced();
+
+ test_forced_btn_toggle_during_night_returns_to_auto();
+ test_forced_btn_toggle_during_day_returns_to_day();
+ test_forced_tick_does_not_transition();
+
+ test_none_event_identity();
+ test_agent_off_event_identity();
+ test_actions_count_invariant();
+
+ test_full_cycle_day_to_forced_to_day();
+
+ if (failures) {
+ fprintf(stderr, "%d failures\n", failures);
+ return 1;
+ }
+ printf("ok display_fsm full transition matrix (%d cases)\n", 13);
+ return 0;
+}
\ No newline at end of file
diff --git a/tests/test_event_router.c b/tests/test_event_router.c
new file mode 100644
index 0000000..120def7
--- /dev/null
+++ b/tests/test_event_router.c
@@ -0,0 +1,285 @@
+/*
+ * test_event_router.c — 完整路由表测试。
+ * 直接链接生产 ../main/event_router.c + 5 个 region (后者只为链接需要,
+ * 实际不被调用;路由表的正确性由 router 输出字段保证)。
+ */
+
+#include
+#include
+#include
+#include "../main/event_router.h"
+
+static int failures = 0;
+#define EXPECT(cond) do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
+ failures++; \
+ } \
+} while (0)
+
+static app_state_t mk_state(int wake)
+{
+ app_state_t s;
+ memset(&s, 0, sizeof(s));
+ s.wake = wake;
+ return s;
+}
+
+/* ── 1-1 按钮事件 ──────────────────────────────────────────────────── */
+static void test_btn_sleep_press_to_wake(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_BTN_SLEEP_PRESS, &s, NULL);
+ EXPECT(r.wake == WAKE_EVT_BTN_SLEEP_PRESS);
+ EXPECT(r.sys == SYS_EVT_BTN_SLEEP_PRESS);
+ EXPECT(r.net == NET_EVT_NONE);
+ EXPECT(r.audio == AUDIO_EVT_NONE);
+ EXPECT(r.display == DISP_EVT_NONE);
+}
+
+static void test_btn_night_toggle_to_display(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_BTN_NIGHT_TOGGLE, &s, NULL);
+ EXPECT(r.display == DISP_EVT_BTN_TOGGLE);
+ EXPECT(r.wake == WAKE_EVT_NONE);
+}
+
+static void test_btn_audio_toggle_to_audio(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_BTN_AUDIO_TOGGLE, &s, NULL);
+ EXPECT(r.audio == AUDIO_EVT_BTN_TOGGLE);
+ EXPECT(r.wake == WAKE_EVT_NONE);
+}
+
+static void test_btn_next_track_to_audio(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_BTN_NEXT_TRACK, &s, NULL);
+ EXPECT(r.audio == AUDIO_EVT_BTN_NEXT);
+}
+
+static void test_btn_provision_request_to_wake_and_net(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_BTN_PROVISION_REQUEST, &s, NULL);
+ EXPECT(r.wake == WAKE_EVT_NO_CREDS);
+ EXPECT(r.net == NET_EVT_BTN_REQUEST_PROVISION);
+}
+
+/* ── BTN_LEFT_TOGGLE 条件路由:响铃 vs 非响铃 ────────────────────── */
+static void test_btn_left_toggle_when_alarm_ringing_to_wake(void)
+{
+ app_state_t s = mk_state(WAKE_ALARM_RINGING);
+ routed_events_t r = route_event(EVT_BTN_LEFT_TOGGLE, &s, NULL);
+ EXPECT(r.wake == WAKE_EVT_BTN_LEFT_TOGGLE);
+ EXPECT(r.audio == AUDIO_EVT_NONE);
+}
+
+static void test_btn_left_toggle_normal_to_audio(void)
+{
+ /* 非响铃:同 BTN_AUDIO_TOGGLE */
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_BTN_LEFT_TOGGLE, &s, NULL);
+ EXPECT(r.audio == AUDIO_EVT_BTN_TOGGLE);
+ EXPECT(r.wake == WAKE_EVT_NONE);
+}
+
+/* ── TICK_1HZ fan-out ─────────────────────────────────────────────── */
+static void test_tick_1hz_fanout_all(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_TICK_1HZ, &s, NULL);
+ EXPECT(r.wake == WAKE_EVT_TICK_1HZ);
+ EXPECT(r.sys == SYS_EVT_TICK_1HZ);
+ EXPECT(r.net == NET_EVT_TICK_1HZ);
+ EXPECT(r.audio == AUDIO_EVT_TICK_1HZ);
+ EXPECT(r.display == DISP_EVT_TICK_1HZ);
+}
+
+/* ── BOOT_DONE 条件 fan-out (基于 wake state) ───────────────────── */
+static void test_boot_done_with_btn_wake(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_BOOT_DONE, &s, NULL);
+ EXPECT(r.sys == SYS_EVT_BOOT_DONE);
+ EXPECT(r.net == NET_EVT_BOOT_DONE);
+ EXPECT(r.wake == WAKE_EVT_NONE); /* BTN wake 不触发 RTC fan-out */
+ EXPECT(r.audio == AUDIO_EVT_NONE);
+}
+
+static void test_boot_done_with_rtc_wake_triggers_auto_play(void)
+{
+ /* 关键测试:RTC 唤醒 → wake BOOT_DONE_FANOUT + audio AUTO_PLAY_REQUEST */
+ app_state_t s = mk_state(WAKE_FROM_RTC);
+ routed_events_t r = route_event(EVT_BOOT_DONE, &s, NULL);
+ EXPECT(r.sys == SYS_EVT_BOOT_DONE);
+ EXPECT(r.net == NET_EVT_BOOT_DONE);
+ EXPECT(r.wake == WAKE_EVT_BOOT_DONE_FANOUT);
+ EXPECT(r.audio == AUDIO_EVT_AUTO_PLAY_REQUEST);
+}
+
+static void test_boot_done_with_sys_wake(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_SYS);
+ routed_events_t r = route_event(EVT_BOOT_DONE, &s, NULL);
+ EXPECT(r.wake == WAKE_EVT_NONE); /* SYS wake 不触发 RTC fan-out */
+ EXPECT(r.audio == AUDIO_EVT_NONE);
+}
+
+/* ── WAKE_DETECT 第一次 boot ────────────────────────────────────── */
+static void test_wake_detect_only_to_wake(void)
+{
+ app_state_t s = mk_state(WAKE_DORMANT);
+ routed_events_t r = route_event(EVT_WAKE_DETECT, &s, NULL);
+ EXPECT(r.wake == WAKE_EVT_DETECT_SOURCE);
+ EXPECT(r.sys == SYS_EVT_NONE);
+}
+
+/* ── WiFi IP_GOT fan-out ────────────────────────────────────────── */
+static void test_wifi_ip_got_to_net_and_audio(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_WIFI_IP_GOT, &s, NULL);
+ EXPECT(r.net == NET_EVT_IP_GOT);
+ EXPECT(r.audio == AUDIO_EVT_NET_OK_FANOUT);
+}
+
+static void test_wifi_sta_connected_only_to_net(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_WIFI_STA_CONNECTED, &s, NULL);
+ EXPECT(r.net == NET_EVT_STA_CONNECTED);
+ EXPECT(r.audio == AUDIO_EVT_NONE);
+}
+
+static void test_wifi_disconnected_to_net(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_WIFI_DISCONNECTED, &s, NULL);
+ EXPECT(r.net == NET_EVT_STA_DISCONNECTED);
+}
+
+/* ── Provision 结果 ──────────────────────────────────────────────── */
+static void test_prov_ok_to_net_and_sys(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_SYS);
+ routed_events_t r = route_event(EVT_PROVISION_OK, &s, NULL);
+ EXPECT(r.net == NET_EVT_PROV_OK);
+ EXPECT(r.sys == SYS_EVT_PROV_OK);
+}
+
+static void test_prov_fail_to_net_and_sys(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_SYS);
+ routed_events_t r = route_event(EVT_PROVISION_FAIL, &s, NULL);
+ EXPECT(r.net == NET_EVT_PROV_FAIL);
+ EXPECT(r.sys == SYS_EVT_PROV_FAIL);
+}
+
+/* ── 音频回调 1-1 ────────────────────────────────────────────────── */
+static void test_audio_player_playing_to_audio(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_AUDIO_PLAYER_PLAYING, &s, NULL);
+ EXPECT(r.audio == AUDIO_EVT_PLAYER_PLAYING);
+ EXPECT(r.wake == WAKE_EVT_NONE);
+}
+
+static void test_audio_player_idle_to_audio(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_AUDIO_PLAYER_IDLE, &s, NULL);
+ EXPECT(r.audio == AUDIO_EVT_PLAYER_IDLE);
+}
+
+static void test_audio_player_error_to_audio(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_AUDIO_PLAYER_ERROR, &s, NULL);
+ EXPECT(r.audio == AUDIO_EVT_PLAYER_ERROR);
+}
+
+/* ── 闹钟完成 fan-out (关键) ─────────────────────────────────────── */
+static void test_alarm_complete_fanout(void)
+{
+ app_state_t s = mk_state(WAKE_ALARM_RINGING);
+ routed_events_t r = route_event(EVT_ALARM_COMPLETE, &s, NULL);
+ EXPECT(r.wake == WAKE_EVT_ALARM_COMPLETE);
+ EXPECT(r.audio == AUDIO_EVT_ALARM_COMPLETE);
+ EXPECT(r.sys == SYS_EVT_DEEP_SLEEP_TICK);
+ EXPECT(r.net == NET_EVT_NONE);
+}
+
+/* ── Deep sleep tick 单独路由 ─────────────────────────────────────── */
+static void test_deep_sleep_tick_only_to_sys(void)
+{
+ app_state_t s = mk_state(WAKE_GOTO_SLEEP);
+ routed_events_t r = route_event(EVT_DEEP_SLEEP_TICK, &s, NULL);
+ EXPECT(r.sys == SYS_EVT_DEEP_SLEEP_TICK);
+}
+
+/* ── 不识别的 raw event → 全部 NONE ────────────────────────────── */
+static void test_unknown_event_all_none(void)
+{
+ app_state_t s = mk_state(WAKE_FROM_BTN);
+ routed_events_t r = route_event(EVT_NONE, &s, NULL);
+ EXPECT(r.wake == WAKE_EVT_NONE);
+ EXPECT(r.sys == SYS_EVT_NONE);
+ EXPECT(r.net == NET_EVT_NONE);
+ EXPECT(r.audio == AUDIO_EVT_NONE);
+ EXPECT(r.display == DISP_EVT_NONE);
+}
+
+/* ── NULL state 也安全 (默认值 boot) ────────────────────────────── */
+static void test_null_state_safe(void)
+{
+ routed_events_t r = route_event(EVT_TICK_1HZ, NULL, NULL);
+ EXPECT(r.wake == WAKE_EVT_TICK_1HZ);
+ /* boot_done with NULL state: 不触发 RTC fan-out */
+ r = route_event(EVT_BOOT_DONE, NULL, NULL);
+ EXPECT(r.wake == WAKE_EVT_NONE);
+}
+
+int main(void)
+{
+ test_btn_sleep_press_to_wake();
+ test_btn_night_toggle_to_display();
+ test_btn_audio_toggle_to_audio();
+ test_btn_next_track_to_audio();
+ test_btn_provision_request_to_wake_and_net();
+ test_btn_left_toggle_when_alarm_ringing_to_wake();
+ test_btn_left_toggle_normal_to_audio();
+
+ test_tick_1hz_fanout_all();
+
+ test_boot_done_with_btn_wake();
+ test_boot_done_with_rtc_wake_triggers_auto_play();
+ test_boot_done_with_sys_wake();
+ test_wake_detect_only_to_wake();
+
+ test_wifi_ip_got_to_net_and_audio();
+ test_wifi_sta_connected_only_to_net();
+ test_wifi_disconnected_to_net();
+
+ test_prov_ok_to_net_and_sys();
+ test_prov_fail_to_net_and_sys();
+
+ test_audio_player_playing_to_audio();
+ test_audio_player_idle_to_audio();
+ test_audio_player_error_to_audio();
+
+ test_alarm_complete_fanout();
+ test_deep_sleep_tick_only_to_sys();
+
+ test_unknown_event_all_none();
+ test_null_state_safe();
+
+ if (failures) {
+ fprintf(stderr, "%d failures\n", failures);
+ return 1;
+ }
+ printf("ok event_router full fan-out table (%d cases)\n", 24);
+ return 0;
+}
\ No newline at end of file
diff --git a/tests/test_net_fsm.c b/tests/test_net_fsm.c
new file mode 100644
index 0000000..e7dec41
--- /dev/null
+++ b/tests/test_net_fsm.c
@@ -0,0 +1,227 @@
+/*
+ * test_net_fsm.c — 完整转换矩阵测试。
+ * 直接链接生产 ../main/regions/net_fsm.c。
+ */
+
+#include
+#include
+#include
+#include "../main/regions/net_fsm.h"
+
+static int failures = 0;
+#define EXPECT(cond) do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
+ failures++; \
+ } \
+} while (0)
+
+#define EXPECT_ACTIONS(a, ...) do { \
+ app_action_kind_t expected[] = { __VA_ARGS__ }; \
+ size_t n = sizeof(expected) / sizeof(expected[0]); \
+ EXPECT((a).count == n); \
+ for (size_t i = 0; i < n && i < (a).count; i++) { \
+ EXPECT((a).items[i].kind == expected[i]); \
+ } \
+} while (0)
+
+static app_input_t mk_inp(void)
+{
+ app_input_t inp; memset(&inp, 0, sizeof(inp));
+ return inp;
+}
+
+/* ── OFFLINE 转换 ─────────────────────────────────────────────────────── */
+static void test_offline_boot_done_with_creds(void)
+{
+ net_state_t s = NET_OFFLINE;
+ app_input_t inp = mk_inp();
+ inp.has_creds = true;
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_BOOT_DONE, &inp);
+ EXPECT(s == NET_CONNECTING);
+ EXPECT_ACTIONS(a, ACT_WIFI_INIT_STA);
+}
+
+static void test_offline_boot_done_without_creds(void)
+{
+ net_state_t s = NET_OFFLINE;
+ app_input_t inp = mk_inp();
+ inp.has_creds = false;
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_BOOT_DONE, &inp);
+ EXPECT(s == NET_PROVISIONING);
+ EXPECT_ACTIONS(a, ACT_RUN_PROVISIONING);
+}
+
+/* ── PROVISIONING 转换 ───────────────────────────────────────────────── */
+static void test_provisioning_ok_to_offline(void)
+{
+ net_state_t s = NET_PROVISIONING;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_PROV_OK, &inp);
+ EXPECT(s == NET_OFFLINE);
+ EXPECT(a.count == 0); /* reboot 由 sys_fsm 处理 */
+}
+
+static void test_provisioning_fail_to_offline(void)
+{
+ net_state_t s = NET_PROVISIONING;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_PROV_FAIL, &inp);
+ EXPECT(s == NET_OFFLINE);
+ EXPECT(a.count == 1);
+ EXPECT(a.items[0].kind == ACT_DISPLAY_STATION);
+ EXPECT(a.items[0].u.station.name != NULL);
+}
+
+static void test_provisioning_btn_request_reprovision(void)
+{
+ net_state_t s = NET_PROVISIONING;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_BTN_REQUEST_PROVISION, &inp);
+ EXPECT(s == NET_PROVISIONING); /* stays */
+ EXPECT_ACTIONS(a, ACT_RUN_PROVISIONING);
+}
+
+/* ── CONNECTING 转换 ─────────────────────────────────────────────────── */
+static void test_connecting_ip_got_to_connected(void)
+{
+ net_state_t s = NET_CONNECTING;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_IP_GOT, &inp);
+ EXPECT(s == NET_CONNECTED);
+ EXPECT_ACTIONS(a, ACT_NTP_START);
+}
+
+static void test_connecting_retry_exhausted_to_failed(void)
+{
+ net_state_t s = NET_CONNECTING;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_WIFI_RETRY_EXHAUSTED, &inp);
+ EXPECT(s == NET_FAILED);
+ EXPECT_ACTIONS(a, ACT_NVS_ERASE_OLD_CREDS);
+}
+
+static void test_connecting_30s_timeout(void)
+{
+ net_state_t s = NET_CONNECTING;
+ app_input_t inp = mk_inp();
+ inp.net_connect_ticks = 30;
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_TICK_1HZ, &inp);
+ EXPECT(s == NET_FAILED);
+}
+
+static void test_connecting_tick_below_threshold(void)
+{
+ net_state_t s = NET_CONNECTING;
+ app_input_t inp = mk_inp();
+ inp.net_connect_ticks = 5;
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_TICK_1HZ, &inp);
+ EXPECT(s == NET_CONNECTING);
+ EXPECT(a.count == 0);
+}
+
+/* ── CONNECTED 转换 ──────────────────────────────────────────────────── */
+static void test_connected_disconnect_back_to_connecting(void)
+{
+ net_state_t s = NET_CONNECTED;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_STA_DISCONNECTED, &inp);
+ EXPECT(s == NET_CONNECTING);
+ EXPECT_ACTIONS(a, ACT_WIFI_RECONNECT);
+}
+
+/* ── FAILED 转换 ─────────────────────────────────────────────────────── */
+static void test_failed_btn_request_provision(void)
+{
+ net_state_t s = NET_FAILED;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_BTN_REQUEST_PROVISION, &inp);
+ EXPECT(s == NET_PROVISIONING);
+ EXPECT_ACTIONS(a, ACT_RUN_PROVISIONING);
+}
+
+static void test_failed_tick_stays(void)
+{
+ net_state_t s = NET_FAILED;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_TICK_1HZ, &inp);
+ EXPECT(s == NET_FAILED);
+ EXPECT(a.count == 0);
+}
+
+/* ── 跨多状态:OFFLINE + 其他事件应保持 OFFLINE ────────────────────── */
+static void test_offline_ignores_other_events(void)
+{
+ net_state_t s = NET_OFFLINE;
+ app_input_t inp = mk_inp();
+ inp.has_creds = true;
+ net_evt_t evts[] = {
+ NET_EVT_IP_GOT, NET_EVT_PROV_OK, NET_EVT_PROV_FAIL,
+ NET_EVT_STA_DISCONNECTED, NET_EVT_TICK_1HZ,
+ };
+ for (size_t i = 0; i < sizeof(evts)/sizeof(evts[0]); i++) {
+ fsm_actions_t a = net_fsm_step(&s, evts[i], &inp);
+ EXPECT(s == NET_OFFLINE);
+ EXPECT(a.count == 0);
+ }
+}
+
+/* ── NONE 事件 ───────────────────────────────────────────────────────── */
+static void test_none_event_identity(void)
+{
+ net_state_t s = NET_CONNECTED;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_NONE, &inp);
+ EXPECT(s == NET_CONNECTED);
+ EXPECT(a.count == 0);
+}
+
+static void test_actions_count_invariant(void)
+{
+ net_state_t s = NET_CONNECTING;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_IP_GOT, &inp);
+ EXPECT(a.count <= FSM_ACTIONS_MAX);
+}
+
+/* ── 不变量:CONNECTED + IP_GOT 是 identity ─────────────────────────── */
+static void test_connected_ignores_ip_got(void)
+{
+ net_state_t s = NET_CONNECTED;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = net_fsm_step(&s, NET_EVT_IP_GOT, &inp);
+ EXPECT(s == NET_CONNECTED);
+ EXPECT(a.count == 0);
+}
+
+int main(void)
+{
+ test_offline_boot_done_with_creds();
+ test_offline_boot_done_without_creds();
+
+ test_provisioning_ok_to_offline();
+ test_provisioning_fail_to_offline();
+ test_provisioning_btn_request_reprovision();
+
+ test_connecting_ip_got_to_connected();
+ test_connecting_retry_exhausted_to_failed();
+ test_connecting_30s_timeout();
+ test_connecting_tick_below_threshold();
+
+ test_connected_disconnect_back_to_connecting();
+ test_connected_ignores_ip_got();
+
+ test_failed_btn_request_provision();
+ test_failed_tick_stays();
+
+ test_offline_ignores_other_events();
+ test_none_event_identity();
+ test_actions_count_invariant();
+
+ if (failures) {
+ fprintf(stderr, "%d failures\n", failures);
+ return 1;
+ }
+ printf("ok net_fsm full transition matrix (%d cases)\n", 16);
+ return 0;
+}
\ No newline at end of file
diff --git a/tests/test_sys_fsm.c b/tests/test_sys_fsm.c
new file mode 100644
index 0000000..9181fc7
--- /dev/null
+++ b/tests/test_sys_fsm.c
@@ -0,0 +1,174 @@
+/*
+ * test_sys_fsm.c — 完整转换矩阵测试。
+ * 直接链接生产 ../main/regions/sys_fsm.c。
+ */
+
+#include
+#include
+#include
+#include "../main/regions/sys_fsm.h"
+
+static int failures = 0;
+#define EXPECT(cond) do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
+ failures++; \
+ } \
+} while (0)
+
+#define EXPECT_ACTIONS(a, ...) do { \
+ app_action_kind_t expected[] = { __VA_ARGS__ }; \
+ size_t n = sizeof(expected) / sizeof(expected[0]); \
+ EXPECT((a).count == n); \
+ for (size_t i = 0; i < n && i < (a).count; i++) { \
+ EXPECT((a).items[i].kind == expected[i]); \
+ } \
+} while (0)
+
+static app_input_t mk_inp(void)
+{
+ app_input_t inp; memset(&inp, 0, sizeof(inp));
+ return inp;
+}
+
+/* 标准 deep sleep 动作序列 (来自 main.c:1064-1102 旧实现) */
+#define STANDARD_DEEP_SLEEP_ACTIONS \
+ ACT_AUDIO_DEINIT, ACT_DISPLAY_OFF, ACT_GPIO_HOLD, ACT_TIMER_SET
+
+/* ── BOOT 转换 ────────────────────────────────────────────────────────── */
+static void test_boot_done_to_normal(void)
+{
+ sys_state_t s = SYS_BOOT;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_BOOT_DONE, &inp);
+ EXPECT(s == SYS_NORMAL);
+ EXPECT(a.count == 0);
+}
+
+static void test_boot_deep_sleep_tick_rare_edge(void)
+{
+ /* 罕见边缘:冷启动中用户立即按右键配网超时 */
+ sys_state_t s = SYS_BOOT;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_DEEP_SLEEP_TICK, &inp);
+ EXPECT(s == SYS_SLEEPING);
+ EXPECT_ACTIONS(a, STANDARD_DEEP_SLEEP_ACTIONS);
+}
+
+/* ── NORMAL 转换 ──────────────────────────────────────────────────────── */
+static void test_normal_btn_sleep(void)
+{
+ sys_state_t s = SYS_NORMAL;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_BTN_SLEEP_PRESS, &inp);
+ EXPECT(s == SYS_SLEEPING);
+ EXPECT_ACTIONS(a, STANDARD_DEEP_SLEEP_ACTIONS);
+}
+
+static void test_normal_btn_factory_reset(void)
+{
+ sys_state_t s = SYS_NORMAL;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_BTN_FACTORY_RESET, &inp);
+ EXPECT(s == SYS_SLEEPING);
+ EXPECT_ACTIONS(a, ACT_NVS_ERASE);
+}
+
+static void test_normal_prov_ok_reboot(void)
+{
+ sys_state_t s = SYS_NORMAL;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_PROV_OK, &inp);
+ EXPECT(s == SYS_SLEEPING);
+ EXPECT(a.count == 0); /* executor 负责 esp_restart,无 FSM 动作 */
+}
+
+static void test_normal_prov_fail_stays(void)
+{
+ sys_state_t s = SYS_NORMAL;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_PROV_FAIL, &inp);
+ EXPECT(s == SYS_NORMAL);
+ EXPECT(a.count == 0);
+}
+
+static void test_normal_net_ok_stays(void)
+{
+ sys_state_t s = SYS_NORMAL;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_NET_OK, &inp);
+ EXPECT(s == SYS_NORMAL);
+ EXPECT(a.count == 0);
+}
+
+static void test_normal_tick_stays(void)
+{
+ sys_state_t s = SYS_NORMAL;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_TICK_1HZ, &inp);
+ EXPECT(s == SYS_NORMAL);
+ EXPECT(a.count == 0);
+}
+
+/* ── SLEEPING 终态 ────────────────────────────────────────────────────── */
+static void test_sleeping_terminal(void)
+{
+ sys_state_t s = SYS_SLEEPING;
+ app_input_t inp = mk_inp();
+ /* 任何事件都不动 */
+ sys_evt_t evts[] = {
+ SYS_EVT_BOOT_DONE, SYS_EVT_BTN_SLEEP_PRESS, SYS_EVT_BTN_FACTORY_RESET,
+ SYS_EVT_TICK_1HZ, SYS_EVT_NET_OK, SYS_EVT_PROV_OK, SYS_EVT_PROV_FAIL,
+ SYS_EVT_DEEP_SLEEP_TICK,
+ };
+ for (size_t i = 0; i < sizeof(evts)/sizeof(evts[0]); i++) {
+ fsm_actions_t a = sys_fsm_step(&s, evts[i], &inp);
+ EXPECT(s == SYS_SLEEPING);
+ EXPECT(a.count == 0);
+ }
+}
+
+/* ── 不变量 ───────────────────────────────────────────────────────────── */
+static void test_none_event_identity(void)
+{
+ sys_state_t s = SYS_NORMAL;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_NONE, &inp);
+ EXPECT(s == SYS_NORMAL);
+ EXPECT(a.count == 0);
+}
+
+static void test_actions_count_invariant(void)
+{
+ sys_state_t s = SYS_NORMAL;
+ app_input_t inp = mk_inp();
+ /* 标准 deep sleep 序列 = 5 个动作 */
+ fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_BTN_SLEEP_PRESS, &inp);
+ EXPECT(a.count <= FSM_ACTIONS_MAX);
+ EXPECT(a.count == 4); /* AUDIO_DEINIT + DISPLAY_OFF + GPIO_HOLD + TIMER_SET */
+}
+
+int main(void)
+{
+ test_boot_done_to_normal();
+ test_boot_deep_sleep_tick_rare_edge();
+
+ test_normal_btn_sleep();
+ test_normal_btn_factory_reset();
+ test_normal_prov_ok_reboot();
+ test_normal_prov_fail_stays();
+ test_normal_net_ok_stays();
+ test_normal_tick_stays();
+
+ test_sleeping_terminal();
+
+ test_none_event_identity();
+ test_actions_count_invariant();
+
+ if (failures) {
+ fprintf(stderr, "%d failures\n", failures);
+ return 1;
+ }
+ printf("ok sys_fsm full transition matrix (%d cases)\n", 12);
+ return 0;
+}
\ No newline at end of file
diff --git a/tests/test_wake_fsm.c b/tests/test_wake_fsm.c
new file mode 100644
index 0000000..8085ccf
--- /dev/null
+++ b/tests/test_wake_fsm.c
@@ -0,0 +1,287 @@
+/*
+ * test_wake_fsm.c — 完整转换矩阵测试。
+ *
+ * 按 plan 文档 "测试矩阵 -> wake_fsm" 章节的覆盖率要求。
+ * 直接链接生产 ../main/regions/wake_fsm.c,生产代码漂移会编译期暴露。
+ */
+
+#include
+#include
+#include
+#include "../main/regions/wake_fsm.h"
+
+static int failures = 0;
+#define EXPECT(cond) do { \
+ if (!(cond)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
+ failures++; \
+ } \
+} while (0)
+
+/* 检查 actions 列表:count + 各 kind 按顺序 */
+#define EXPECT_ACTIONS(a, ...) do { \
+ app_action_kind_t expected[] = { __VA_ARGS__ }; \
+ size_t n = sizeof(expected) / sizeof(expected[0]); \
+ EXPECT((a).count == n); \
+ for (size_t i = 0; i < n && i < (a).count; i++) { \
+ EXPECT((a).items[i].kind == expected[i]); \
+ } \
+} while (0)
+
+/* 便利构造:零初始化的 app_input_t */
+static app_input_t mk_inp(void)
+{
+ app_input_t inp;
+ memset(&inp, 0, sizeof(inp));
+ return inp;
+}
+
+/* ── DORMANT + DETECT_SOURCE 三种分支 ─────────────────────────────────── */
+static void test_dormant_detect_btn(void)
+{
+ wake_state_t s = WAKE_DORMANT;
+ app_input_t inp = mk_inp();
+ inp.wake_kind = WAKE_BTN;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp);
+ EXPECT(s == WAKE_FROM_BTN);
+ EXPECT_ACTIONS(a, ACT_DISPLAY_FADE_IN);
+}
+
+static void test_dormant_detect_rtc(void)
+{
+ wake_state_t s = WAKE_DORMANT;
+ app_input_t inp = mk_inp();
+ inp.wake_kind = WAKE_RTC;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp);
+ EXPECT(s == WAKE_FROM_RTC);
+ EXPECT_ACTIONS(a, ACT_DISPLAY_FADE_IN, ACT_VOLUME_MAX, ACT_NET_AUTO_CONNECT);
+}
+
+static void test_dormant_detect_sys(void)
+{
+ wake_state_t s = WAKE_DORMANT;
+ app_input_t inp = mk_inp();
+ inp.wake_kind = WAKE_SYS;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp);
+ EXPECT(s == WAKE_FROM_SYS);
+ EXPECT(a.count == 0);
+}
+
+static void test_dormant_detect_none(void)
+{
+ wake_state_t s = WAKE_DORMANT;
+ app_input_t inp = mk_inp();
+ inp.wake_kind = WAKE_NONE;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp);
+ EXPECT(s == WAKE_FROM_SYS);
+}
+
+/* ── FROM_BTN 转换 ────────────────────────────────────────────────────── */
+static void test_from_btn_no_creds(void)
+{
+ wake_state_t s = WAKE_FROM_BTN;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_NO_CREDS, &inp);
+ EXPECT(s == WAKE_FROM_SYS);
+ EXPECT(a.count == 0);
+}
+
+static void test_from_btn_sleep_press(void)
+{
+ wake_state_t s = WAKE_FROM_BTN;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_BTN_SLEEP_PRESS, &inp);
+ EXPECT(s == WAKE_GOTO_SLEEP);
+ EXPECT_ACTIONS(a, ACT_DISPLAY_OFF, ACT_DEEP_SLEEP);
+}
+
+static void test_from_btn_tick_stays(void)
+{
+ wake_state_t s = WAKE_FROM_BTN;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_TICK_1HZ, &inp);
+ EXPECT(s == WAKE_FROM_BTN);
+ EXPECT(a.count == 0);
+}
+
+/* 关键不变量:FROM_BTN 绝不发出 ACT_NET_AUTO_CONNECT 或 ACT_AUDIO_AUTO_PLAY */
+static void test_from_btn_invariant_no_auto_play(void)
+{
+ /* 在所有可能的 (state, evt) 组合下,FROM_BTN 发出的动作不含这两种 */
+ wake_evt_t evts[] = {
+ WAKE_EVT_DETECT_SOURCE, WAKE_EVT_BTN_SLEEP_PRESS,
+ WAKE_EVT_BTN_LEFT_TOGGLE, WAKE_EVT_ALARM_COMPLETE,
+ WAKE_EVT_NO_CREDS, WAKE_EVT_BOOT_DONE_FANOUT,
+ WAKE_EVT_TICK_1HZ,
+ };
+ for (size_t i = 0; i < sizeof(evts)/sizeof(evts[0]); i++) {
+ wake_state_t s = WAKE_FROM_BTN;
+ app_input_t inp = mk_inp();
+ inp.alarm_ring_minutes = 99;
+ inp.weekend_skip = true;
+ fsm_actions_t a = wake_fsm_step(&s, evts[i], &inp);
+ for (uint8_t k = 0; k < a.count; k++) {
+ EXPECT(a.items[k].kind != ACT_NET_AUTO_CONNECT);
+ EXPECT(a.items[k].kind != ACT_AUDIO_AUTO_PLAY);
+ }
+ }
+}
+
+/* ── FROM_RTC 转换 ────────────────────────────────────────────────────── */
+static void test_from_rtc_no_creds_degrades_to_btn(void)
+{
+ wake_state_t s = WAKE_FROM_RTC;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_NO_CREDS, &inp);
+ EXPECT(s == WAKE_FROM_BTN);
+ EXPECT(a.count == 0);
+}
+
+static void test_from_rtc_boot_done_weekend_degrades(void)
+{
+ wake_state_t s = WAKE_FROM_RTC;
+ app_input_t inp = mk_inp();
+ inp.weekend_skip = true;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_BOOT_DONE_FANOUT, &inp);
+ EXPECT(s == WAKE_FROM_BTN);
+ EXPECT(a.count == 0);
+}
+
+static void test_from_rtc_boot_done_normal_to_alarm(void)
+{
+ wake_state_t s = WAKE_FROM_RTC;
+ app_input_t inp = mk_inp();
+ inp.weekend_skip = false;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_BOOT_DONE_FANOUT, &inp);
+ EXPECT(s == WAKE_ALARM_RINGING);
+ EXPECT_ACTIONS(a, ACT_AUDIO_AUTO_PLAY);
+}
+
+static void test_from_rtc_sleep_press(void)
+{
+ wake_state_t s = WAKE_FROM_RTC;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_BTN_SLEEP_PRESS, &inp);
+ EXPECT(s == WAKE_GOTO_SLEEP);
+ EXPECT_ACTIONS(a, ACT_DISPLAY_OFF, ACT_DEEP_SLEEP);
+}
+
+/* ── ALARM_RINGING 三种退出路径 ───────────────────────────────────────── */
+static void test_alarm_ringing_btn_left_to_btn(void)
+{
+ wake_state_t s = WAKE_ALARM_RINGING;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_BTN_LEFT_TOGGLE, &inp);
+ EXPECT(s == WAKE_FROM_BTN);
+ EXPECT_ACTIONS(a, ACT_AUDIO_STOP);
+}
+
+static void test_alarm_ringing_alarm_complete(void)
+{
+ wake_state_t s = WAKE_ALARM_RINGING;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_ALARM_COMPLETE, &inp);
+ EXPECT(s == WAKE_GOTO_SLEEP);
+ EXPECT_ACTIONS(a, ACT_ARM_RTC_FOR_TOMORROW, ACT_DEEP_SLEEP);
+}
+
+static void test_alarm_ringing_15min_timeout(void)
+{
+ wake_state_t s = WAKE_ALARM_RINGING;
+ app_input_t inp = mk_inp();
+ inp.alarm_ring_minutes = 15;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_TICK_1HZ, &inp);
+ EXPECT(s == WAKE_GOTO_SLEEP);
+ EXPECT_ACTIONS(a, ACT_ARM_RTC_FOR_TOMORROW, ACT_DEEP_SLEEP);
+}
+
+static void test_alarm_ringing_tick_below_threshold(void)
+{
+ wake_state_t s = WAKE_ALARM_RINGING;
+ app_input_t inp = mk_inp();
+ inp.alarm_ring_minutes = 5;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_TICK_1HZ, &inp);
+ EXPECT(s == WAKE_ALARM_RINGING);
+ EXPECT(a.count == 0);
+}
+
+/* ── 终态 ────────────────────────────────────────────────────────────── */
+static void test_goto_sleep_terminal(void)
+{
+ wake_state_t s = WAKE_GOTO_SLEEP;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_BTN_SLEEP_PRESS, &inp);
+ EXPECT(s == WAKE_GOTO_SLEEP);
+ EXPECT(a.count == 0);
+
+ a = wake_fsm_step(&s, WAKE_EVT_TICK_1HZ, &inp);
+ EXPECT(s == WAKE_GOTO_SLEEP);
+ EXPECT(a.count == 0);
+}
+
+static void test_from_sys_no_transitions(void)
+{
+ wake_state_t s = WAKE_FROM_SYS;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_TICK_1HZ, &inp);
+ EXPECT(s == WAKE_FROM_SYS);
+ EXPECT(a.count == 0);
+}
+
+/* ── NONE 事件早返回 identity ─────────────────────────────────────────── */
+static void test_none_event_identity(void)
+{
+ wake_state_t s = WAKE_FROM_BTN;
+ app_input_t inp = mk_inp();
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_NONE, &inp);
+ EXPECT(s == WAKE_FROM_BTN);
+ EXPECT(a.count == 0);
+}
+
+/* ── actions 不超过上限 ──────────────────────────────────────────────── */
+static void test_actions_count_invariant(void)
+{
+ /* 触发多个动作的转换:任何转换的 out.count 都 <= FSM_ACTIONS_MAX */
+ wake_state_t s = WAKE_DORMANT;
+ app_input_t inp = mk_inp();
+ inp.wake_kind = WAKE_RTC;
+ fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp);
+ EXPECT(a.count <= FSM_ACTIONS_MAX);
+}
+
+int main(void)
+{
+ test_dormant_detect_btn();
+ test_dormant_detect_rtc();
+ test_dormant_detect_sys();
+ test_dormant_detect_none();
+
+ test_from_btn_no_creds();
+ test_from_btn_sleep_press();
+ test_from_btn_tick_stays();
+ test_from_btn_invariant_no_auto_play();
+
+ test_from_rtc_no_creds_degrades_to_btn();
+ test_from_rtc_boot_done_weekend_degrades();
+ test_from_rtc_boot_done_normal_to_alarm();
+ test_from_rtc_sleep_press();
+
+ test_alarm_ringing_btn_left_to_btn();
+ test_alarm_ringing_alarm_complete();
+ test_alarm_ringing_15min_timeout();
+ test_alarm_ringing_tick_below_threshold();
+
+ test_goto_sleep_terminal();
+ test_from_sys_no_transitions();
+
+ test_none_event_identity();
+ test_actions_count_invariant();
+
+ if (failures) {
+ fprintf(stderr, "%d failures\n", failures);
+ return 1;
+ }
+ printf("ok wake_fsm full transition matrix (%d cases)\n",
+ 20 /* count of tests above */);
+ return 0;
+}
\ No newline at end of file