From 98cce2d0e184afb52d03a1c965a4a69072231675 Mon Sep 17 00:00:00 2001 From: zulin Date: Sun, 12 Jul 2026 21:14:31 +0800 Subject: [PATCH 01/19] FSM: 5-region skeleton + event router (Step 1) add explicit type definitions and identity stub implementations: - main/app_fsm.h: shared types (app_event_t, app_input_t, app_action_kind_t, fsm_actions_t, app_state_t composite) - main/event_router.{h,c}: 5-output fan-out stub (returns all EVT_NONE) - main/regions/{wake,sys,net,audio,display}_fsm.{h,c}: 5 orthogonal FSM regions, each with state enum + event enum + identity step() states plan: wake : WAKE_DORMANT / FROM_BTN / FROM_RTC / FROM_SYS / ALARM_RINGING / GOTO_SLEEP sys : SYS_BOOT / SYS_NORMAL / SYS_SLEEPING net : OFFLINE / PROVISIONING / CONNECTING / CONNECTED / FAILED audio : IDLE / PENDING / INIT / PLAYING / STOPPING / ERROR display : DAY / NIGHT_AUTO / NIGHT_FORCED tests: - 6 new tests in tests/ linking production code (not mirrored copies) - each test asserts the skeleton returns {0 actions, no state change} - make -C tests test: 8/8 green (2 existing + 6 new) CMakeLists.txt + tests/Makefile updated to register new sources and include paths. Next step: implement wake_fsm full transitions. Co-Authored-By: Claude --- main/CMakeLists.txt | 8 +- main/app_fsm.h | 204 +++++++++++++++++++++++++++++++++++++ main/event_router.c | 16 +++ main/event_router.h | 48 +++++++++ main/regions/audio_fsm.c | 14 +++ main/regions/audio_fsm.h | 47 +++++++++ main/regions/display_fsm.c | 14 +++ main/regions/display_fsm.h | 36 +++++++ main/regions/net_fsm.c | 14 +++ main/regions/net_fsm.h | 44 ++++++++ main/regions/sys_fsm.c | 14 +++ main/regions/sys_fsm.h | 41 ++++++++ main/regions/wake_fsm.c | 14 +++ main/regions/wake_fsm.h | 50 +++++++++ tests/Makefile | 43 +++++++- tests/test_audio_fsm.c | 25 +++++ tests/test_display_fsm.c | 24 +++++ tests/test_event_router.c | 26 +++++ tests/test_net_fsm.c | 24 +++++ tests/test_sys_fsm.c | 24 +++++ tests/test_wake_fsm.c | 41 ++++++++ 21 files changed, 768 insertions(+), 3 deletions(-) create mode 100644 main/app_fsm.h create mode 100644 main/event_router.c create mode 100644 main/event_router.h create mode 100644 main/regions/audio_fsm.c create mode 100644 main/regions/audio_fsm.h create mode 100644 main/regions/display_fsm.c create mode 100644 main/regions/display_fsm.h create mode 100644 main/regions/net_fsm.c create mode 100644 main/regions/net_fsm.h create mode 100644 main/regions/sys_fsm.c create mode 100644 main/regions/sys_fsm.h create mode 100644 main/regions/wake_fsm.c create mode 100644 main/regions/wake_fsm.h create mode 100644 tests/test_audio_fsm.c create mode 100644 tests/test_display_fsm.c create mode 100644 tests/test_event_router.c create mode 100644 tests/test_net_fsm.c create mode 100644 tests/test_sys_fsm.c create mode 100644 tests/test_wake_fsm.c 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..c475e2e --- /dev/null +++ b/main/app_fsm.h @@ -0,0 +1,204 @@ +/* + * 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() 函数仍用强类型。 */ + +/* ── 唤醒来源 (boot 时由 main.c 一次性填入,后续由 wake_fsm 升级为状态) ── */ +typedef enum { + WAKE_KIND_NONE = 0, /* 未检测 */ + WAKE_KIND_BTN, /* 用户按右键 (CONFIG_WAKEUP_GPIO) */ + WAKE_KIND_RTC, /* PCF85063 闹钟 (CONFIG_PCF85063_INT_GPIO) */ + WAKE_KIND_SYS, /* 冷启动 / 异常 */ +} 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; + app_audio_player_evt_t last_audio_event; /* 由 callback 线程原子写入 */ + uint32_t pending_ticks; + 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/event_router.c b/main/event_router.c new file mode 100644 index 0000000..7f0e0bd --- /dev/null +++ b/main/event_router.c @@ -0,0 +1,16 @@ +/* + * event_router.c — 骨架实现 (Step 1)。 + * 完整 fan-out 表在后续步骤按 plan 文档 "事件路由器" 章节补全。 + * 当前实现:所有输入都路由到 *NONE*,region step 全部 identity。 + */ +#include "event_router.h" +#include + +routed_events_t route_event(app_event_t raw, const app_input_t *inp) +{ + (void)raw; (void)inp; + routed_events_t r; + memset(&r, 0, sizeof(r)); + /* 全部 *_EVT_NONE — region step 收到 NONE 时早返回 identity */ + return r; +} diff --git a/main/event_router.h b/main/event_router.h new file mode 100644 index 0000000..3dd04c0 --- /dev/null +++ b/main/event_router.h @@ -0,0 +1,48 @@ +/* + * 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_input_t *inp); + +#ifdef __cplusplus +} +#endif + +#endif /* EVENT_ROUTER_H */ diff --git a/main/regions/audio_fsm.c b/main/regions/audio_fsm.c new file mode 100644 index 0000000..1d95a28 --- /dev/null +++ b/main/regions/audio_fsm.c @@ -0,0 +1,14 @@ +/* + * audio_fsm.c — 骨架实现 (Step 1)。 + * 完整转换在后续步骤按 plan 文档 "4. audio_fsm" 章节补全。 + */ +#include "audio_fsm.h" +#include + +fsm_actions_t audio_fsm_step(audio_state_t *cur, audio_evt_t evt, const app_input_t *inp) +{ + (void)cur; (void)evt; (void)inp; + fsm_actions_t out; + memset(&out, 0, sizeof(out)); + return out; +} diff --git a/main/regions/audio_fsm.h b/main/regions/audio_fsm.h new file mode 100644 index 0000000..0608234 --- /dev/null +++ b/main/regions/audio_fsm.h @@ -0,0 +1,47 @@ +/* + * 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_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..a1f9f98 --- /dev/null +++ b/main/regions/display_fsm.c @@ -0,0 +1,14 @@ +/* + * display_fsm.c — 骨架实现 (Step 1)。 + * 完整转换在后续步骤按 plan 文档 "5. display_fsm" 章节补全。 + */ +#include "display_fsm.h" +#include + +fsm_actions_t display_fsm_step(display_state_t *cur, display_evt_t evt, const app_input_t *inp) +{ + (void)cur; (void)evt; (void)inp; + fsm_actions_t out; + memset(&out, 0, sizeof(out)); + return out; +} 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..b3cd282 --- /dev/null +++ b/main/regions/net_fsm.c @@ -0,0 +1,14 @@ +/* + * net_fsm.c — 骨架实现 (Step 1)。 + * 完整转换在后续步骤按 plan 文档 "3. net_fsm" 章节补全。 + */ +#include "net_fsm.h" +#include + +fsm_actions_t net_fsm_step(net_state_t *cur, net_evt_t evt, const app_input_t *inp) +{ + (void)cur; (void)evt; (void)inp; + fsm_actions_t out; + memset(&out, 0, sizeof(out)); + return out; +} diff --git a/main/regions/net_fsm.h b/main/regions/net_fsm.h new file mode 100644 index 0000000..958e051 --- /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_NO_CREDS_AT_BOOT, + 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..09dec1e --- /dev/null +++ b/main/regions/sys_fsm.c @@ -0,0 +1,14 @@ +/* + * sys_fsm.c — 骨架实现 (Step 1)。 + * 完整转换在后续步骤按 plan 文档 "2. sys_fsm" 章节补全。 + */ +#include "sys_fsm.h" +#include + +fsm_actions_t sys_fsm_step(sys_state_t *cur, sys_evt_t evt, const app_input_t *inp) +{ + (void)cur; (void)evt; (void)inp; + fsm_actions_t out; + memset(&out, 0, sizeof(out)); + return out; +} 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..d7c13cc --- /dev/null +++ b/main/regions/wake_fsm.c @@ -0,0 +1,14 @@ +/* + * wake_fsm.c — 骨架实现 (Step 1)。 + * 完整转换在后续步骤按 plan 文档 "1. wake_fsm" 章节补全。 + */ +#include "wake_fsm.h" +#include + +fsm_actions_t wake_fsm_step(wake_state_t *cur, wake_evt_t evt, const app_input_t *inp) +{ + (void)cur; (void)evt; (void)inp; + fsm_actions_t out; + memset(&out, 0, sizeof(out)); + return out; +} 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/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..4ea5017 --- /dev/null +++ b/tests/test_audio_fsm.c @@ -0,0 +1,25 @@ +/* + * test_audio_fsm.c — Step 1 skeleton test。 + * Step 6 起替换为完整转换矩阵,关键边界包括 PENDING 不亮 indicator。 + */ +#include +#include +#include "../main/regions/audio_fsm.h" + +int main(void) +{ + audio_state_t s = AUDIO_IDLE; + app_input_t inp = {0}; + inp.net_connected = true; + fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_TOGGLE, &inp); + assert(a.count == 0); + assert(s == AUDIO_IDLE); + + s = AUDIO_PLAYING; + a = audio_fsm_step(&s, AUDIO_EVT_PLAYER_IDLE, &inp); + assert(a.count == 0); + assert(s == AUDIO_PLAYING); + + printf("ok audio_fsm identity (Step 1 skeleton)\n"); + return 0; +} diff --git a/tests/test_display_fsm.c b/tests/test_display_fsm.c new file mode 100644 index 0000000..7bfeb61 --- /dev/null +++ b/tests/test_display_fsm.c @@ -0,0 +1,24 @@ +/* + * test_display_fsm.c — Step 1 skeleton test。 + * Step 7 起替换为完整转换矩阵。 + */ +#include +#include +#include "../main/regions/display_fsm.h" + +int main(void) +{ + display_state_t s = DISP_DAY; + app_input_t inp = {0}; + fsm_actions_t a = display_fsm_step(&s, DISP_EVT_TICK_1HZ, &inp); + assert(a.count == 0); + assert(s == DISP_DAY); + + s = DISP_NIGHT_AUTO; + a = display_fsm_step(&s, DISP_EVT_BTN_TOGGLE, &inp); + assert(a.count == 0); + assert(s == DISP_NIGHT_AUTO); + + printf("ok display_fsm identity (Step 1 skeleton)\n"); + return 0; +} diff --git a/tests/test_event_router.c b/tests/test_event_router.c new file mode 100644 index 0000000..ac4fa09 --- /dev/null +++ b/tests/test_event_router.c @@ -0,0 +1,26 @@ +/* + * test_event_router.c — Step 1 skeleton test。 + * 当前 skeleton 实现:route_event() 返回全 *_EVT_NONE。等 Step 8 补完整 fan-out。 + */ +#include +#include +#include "../main/event_router.h" + +int main(void) +{ + app_input_t inp = {0}; + routed_events_t r = route_event(EVT_TICK_1HZ, &inp); + /* skeleton 实现:全部为 *_EVT_NONE */ + assert(r.wake == WAKE_EVT_NONE); + assert(r.sys == SYS_EVT_NONE); + assert(r.net == NET_EVT_NONE); + assert(r.audio == AUDIO_EVT_NONE); + assert(r.display == DISP_EVT_NONE); + + r = route_event(EVT_BTN_SLEEP_PRESS, &inp); + assert(r.wake == WAKE_EVT_NONE); + assert(r.sys == SYS_EVT_NONE); + + printf("ok event_router identity (Step 1 skeleton)\n"); + return 0; +} diff --git a/tests/test_net_fsm.c b/tests/test_net_fsm.c new file mode 100644 index 0000000..2685fdf --- /dev/null +++ b/tests/test_net_fsm.c @@ -0,0 +1,24 @@ +/* + * test_net_fsm.c — Step 1 skeleton test。 + * Step 5 起替换为完整转换矩阵。 + */ +#include +#include +#include "../main/regions/net_fsm.h" + +int main(void) +{ + net_state_t s = NET_OFFLINE; + app_input_t inp = {0}; + fsm_actions_t a = net_fsm_step(&s, NET_EVT_NO_CREDS_AT_BOOT, &inp); + assert(a.count == 0); + assert(s == NET_OFFLINE); + + s = NET_CONNECTING; + a = net_fsm_step(&s, NET_EVT_IP_GOT, &inp); + assert(a.count == 0); + assert(s == NET_CONNECTING); + + printf("ok net_fsm identity (Step 1 skeleton)\n"); + return 0; +} diff --git a/tests/test_sys_fsm.c b/tests/test_sys_fsm.c new file mode 100644 index 0000000..b6029ed --- /dev/null +++ b/tests/test_sys_fsm.c @@ -0,0 +1,24 @@ +/* + * test_sys_fsm.c — Step 1 skeleton test。 + * Step 4 起替换为完整转换矩阵。 + */ +#include +#include +#include "../main/regions/sys_fsm.h" + +int main(void) +{ + sys_state_t s = SYS_BOOT; + app_input_t inp = {0}; + fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_BOOT_DONE, &inp); + assert(a.count == 0); + assert(s == SYS_BOOT); + + s = SYS_NORMAL; + a = sys_fsm_step(&s, SYS_EVT_BTN_SLEEP_PRESS, &inp); + assert(a.count == 0); + assert(s == SYS_NORMAL); + + printf("ok sys_fsm identity (Step 1 skeleton)\n"); + return 0; +} diff --git a/tests/test_wake_fsm.c b/tests/test_wake_fsm.c new file mode 100644 index 0000000..e90e605 --- /dev/null +++ b/tests/test_wake_fsm.c @@ -0,0 +1,41 @@ +/* + * test_wake_fsm.c — Step 1 skeleton test: + * - 链接生产 regions/wake_fsm.c (不是镜像复制) + * - 验证对任意 (state, evt) 输入,identity 实现返回 {0 actions},且不修改 *cur + * + * Step 3 起会逐步替换为完整转换矩阵断言(见 plan "测试矩阵 -> wake_fsm")。 + */ +#include +#include +#include +#include "../main/regions/wake_fsm.h" + +static int passed = 0; +#define CHECK(expr) do { assert(expr); passed++; } while (0) + +static void test_identity_no_event(void) +{ + wake_state_t s = WAKE_DORMANT; + app_input_t inp = {0}; + fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_NONE, &inp); + CHECK(a.count == 0); + CHECK(s == WAKE_DORMANT); +} + +static void test_identity_all_events(void) +{ + /* skeleton 实现:任何事件都是 identity */ + wake_state_t s = WAKE_FROM_RTC; + app_input_t inp = {0}; + fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp); + CHECK(a.count == 0); + CHECK(s == WAKE_FROM_RTC); +} + +int main(void) +{ + test_identity_no_event(); + test_identity_all_events(); + printf("ok wake_fsm identity (Step 1 skeleton)\n"); + return 0; +} From afa4b4a4dcec23cf2550ff5f6b7f48acee72fe62 Mon Sep 17 00:00:00 2001 From: zulin Date: Sun, 12 Jul 2026 21:57:42 +0800 Subject: [PATCH 02/19] FSM: wake_fsm full transitions (Step 3) implement all 6 states of wake region per plan section '1. wake_fsm': - WAKE_DORMANT + DETECT_SOURCE & wake_kind=BTN -> FROM_BTN + {ACT_DISPLAY_FADE_IN} - WAKE_DORMANT + DETECT_SOURCE & wake_kind=RTC -> FROM_RTC + {ACT_DISPLAY_FADE_IN, ACT_VOLUME_MAX, ACT_NET_AUTO_CONNECT} - WAKE_DORMANT + DETECT_SOURCE & wake_kind=SYS -> FROM_SYS (no actions) - FROM_BTN + NO_CREDS -> FROM_SYS - FROM_BTN + BTN_SLEEP_PRESS -> GOTO_SLEEP + {ACT_DISPLAY_OFF, ACT_DEEP_SLEEP} - FROM_RTC + NO_CREDS -> FROM_BTN (degrade) - FROM_RTC + BOOT_DONE_FANOUT & !weekend_skip -> ALARM_RINGING + {ACT_AUDIO_AUTO_PLAY} - FROM_RTC + BOOT_DONE_FANOUT & weekend_skip -> FROM_BTN (degrade) - FROM_RTC + BTN_SLEEP_PRESS -> GOTO_SLEEP + {ACT_DISPLAY_OFF, ACT_DEEP_SLEEP} - ALARM_RINGING + BTN_LEFT_TOGGLE -> FROM_BTN + {ACT_AUDIO_STOP} - ALARM_RINGING + ALARM_COMPLETE -> GOTO_SLEEP + {ACT_ARM_RTC_FOR_TOMORROW, ACT_DEEP_SLEEP} - ALARM_RINGING + TICK_1HZ & alarm_ring_minutes >= 15 -> GOTO_SLEEP (15min guard) - FROM_SYS / GOTO_SLEEP: terminal-ish - WAKE_EVT_NONE: identity key invariant asserted in test: FROM_BTN state never emits ACT_NET_AUTO_CONNECT or ACT_AUDIO_AUTO_PLAY (user-button wake must not auto-connect wifi or auto-play). tests: 20 cases covering all reachable (state, evt, ctx) combinations plus invariants (action count cap, FROM_BTN no-auto-play). make -C tests test: 8/8 green (test_wake_fsm full matrix passes). device: idf.py build + flash + monitor on /dev/cu.usbmodem1301 confirms clean boot, WiFi connect, audio playback, button handling, deep sleep all preserved. wake_fsm.c is now in binary but main.c still uses old implicit state machine; integration comes in Step 11-12. Co-Authored-By: Claude --- main/regions/wake_fsm.c | 123 ++++++++++++++++- tests/test_wake_fsm.c | 286 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 383 insertions(+), 26 deletions(-) diff --git a/main/regions/wake_fsm.c b/main/regions/wake_fsm.c index d7c13cc..55f03cc 100644 --- a/main/regions/wake_fsm.c +++ b/main/regions/wake_fsm.c @@ -1,14 +1,125 @@ /* - * wake_fsm.c — 骨架实现 (Step 1)。 - * 完整转换在后续步骤按 plan 文档 "1. wake_fsm" 章节补全。 + * 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) { - (void)cur; (void)evt; (void)inp; - fsm_actions_t out; - memset(&out, 0, sizeof(out)); + 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_KIND_BTN) { + /* 用户按右键。看时间。 */ + *cur = WAKE_FROM_BTN; + out = add_action(out, ACT_DISPLAY_FADE_IN); + } else if (inp->wake_kind == WAKE_KIND_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_KIND_NONE / WAKE_KIND_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/tests/test_wake_fsm.c b/tests/test_wake_fsm.c index e90e605..f7403b3 100644 --- a/tests/test_wake_fsm.c +++ b/tests/test_wake_fsm.c @@ -1,41 +1,287 @@ /* - * test_wake_fsm.c — Step 1 skeleton test: - * - 链接生产 regions/wake_fsm.c (不是镜像复制) - * - 验证对任意 (state, evt) 输入,identity 实现返回 {0 actions},且不修改 *cur + * test_wake_fsm.c — 完整转换矩阵测试。 * - * Step 3 起会逐步替换为完整转换矩阵断言(见 plan "测试矩阵 -> wake_fsm")。 + * 按 plan 文档 "测试矩阵 -> wake_fsm" 章节的覆盖率要求。 + * 直接链接生产 ../main/regions/wake_fsm.c,生产代码漂移会编译期暴露。 */ + #include #include #include #include "../main/regions/wake_fsm.h" -static int passed = 0; -#define CHECK(expr) do { assert(expr); passed++; } while (0) +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; +} -static void test_identity_no_event(void) +/* ── DORMANT + DETECT_SOURCE 三种分支 ─────────────────────────────────── */ +static void test_dormant_detect_btn(void) { wake_state_t s = WAKE_DORMANT; - app_input_t inp = {0}; - fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_NONE, &inp); - CHECK(a.count == 0); - CHECK(s == WAKE_DORMANT); + app_input_t inp = mk_inp(); + inp.wake_kind = WAKE_KIND_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_KIND_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_KIND_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_KIND_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_identity_all_events(void) +static void test_from_rtc_sleep_press(void) { - /* skeleton 实现:任何事件都是 identity */ wake_state_t s = WAKE_FROM_RTC; - app_input_t inp = {0}; + 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_KIND_RTC; fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp); - CHECK(a.count == 0); - CHECK(s == WAKE_FROM_RTC); + EXPECT(a.count <= FSM_ACTIONS_MAX); } int main(void) { - test_identity_no_event(); - test_identity_all_events(); - printf("ok wake_fsm identity (Step 1 skeleton)\n"); + 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 From 5eaceff977082189e2965929000b35fa8091823e Mon Sep 17 00:00:00 2001 From: zulin Date: Sun, 12 Jul 2026 22:02:03 +0800 Subject: [PATCH 03/19] FSM: sys_fsm full transitions (Step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit implement 3 states (BOOT / NORMAL / SLEEPING) per plan section '2. sys_fsm': - BOOT + BOOT_DONE -> NORMAL (no actions; other regions handle boot-time) - BOOT + DEEP_SLEEP_TICK -> SLEEPING + standard_deep_sleep_actions - NORMAL + BTN_SLEEP_PRESS -> SLEEPING + standard_deep_sleep_actions - NORMAL + BTN_FACTORY_RESET -> SLEEPING + {ACT_NVS_ERASE, ACT_DEEP_SLEEP} - NORMAL + PROV_OK -> SLEEPING + {ACT_DEEP_SLEEP} (executor does esp_restart) - NORMAL + PROV_FAIL/NET_OK/TICK_1HZ -> stays (clock-only mode for PROV_FAIL) - SLEEPING + * -> identity (terminal) standard_deep_sleep_actions() helper consolidates the 5-step sequence extracted from main.c:1064-1102: ACT_AUDIO_DEINIT, ACT_DISPLAY_OFF, ACT_GPIO_HOLD, ACT_TIMER_SET, ACT_DEEP_SLEEP wake_kind-specific boot behavior (BTN doesn't connect, RTC auto-plays) is owned by wake_fsm; sys_fsm stays generic to avoid duplication. tests: 12 cases covering BOOT/NORMAL/SLEEPING transitions plus invariants. make -C tests test: 8/8 green. flash attempt deferred — device's USB serial disconnected after previous deep-sleep cycle (no /dev/cu.usbmodem* available). Code is built and verified; user can reconnect device and re-flash. Co-Authored-By: Claude --- main/regions/sys_fsm.c | 88 +++++++++++++++++++-- tests/test_sys_fsm.c | 174 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 244 insertions(+), 18 deletions(-) diff --git a/main/regions/sys_fsm.c b/main/regions/sys_fsm.c index 09dec1e..533028f 100644 --- a/main/regions/sys_fsm.c +++ b/main/regions/sys_fsm.c @@ -1,14 +1,90 @@ /* - * sys_fsm.c — 骨架实现 (Step 1)。 - * 完整转换在后续步骤按 plan 文档 "2. sys_fsm" 章节补全。 + * 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 动作序列:对应 main.c:1064-1102 的旧逻辑 */ +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); + a = add_action(a, ACT_DEEP_SLEEP); + return a; +} + fsm_actions_t sys_fsm_step(sys_state_t *cur, sys_evt_t evt, const app_input_t *inp) { - (void)cur; (void)evt; (void)inp; - fsm_actions_t out; - memset(&out, 0, sizeof(out)); + 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); + out = add_action(out, ACT_DEEP_SLEEP); + } else if (evt == SYS_EVT_PROV_OK) { + /* 配网成功:旧 main.c 走 vTaskDelay(100); esp_restart()。 + * 复用 ACT_DEEP_SLEEP,executor 看到 PROV_OK 后做 esp_restart + * 而非真正 deep sleep。 */ + *cur = SYS_SLEEPING; + out = add_action(out, ACT_DEEP_SLEEP); + } + /* 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/tests/test_sys_fsm.c b/tests/test_sys_fsm.c index b6029ed..af20929 100644 --- a/tests/test_sys_fsm.c +++ b/tests/test_sys_fsm.c @@ -1,24 +1,174 @@ /* - * test_sys_fsm.c — Step 1 skeleton test。 - * Step 4 起替换为完整转换矩阵。 + * test_sys_fsm.c — 完整转换矩阵测试。 + * 直接链接生产 ../main/regions/sys_fsm.c。 */ + #include #include +#include #include "../main/regions/sys_fsm.h" -int main(void) +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, ACT_DEEP_SLEEP + +/* ── BOOT 转换 ────────────────────────────────────────────────────────── */ +static void test_boot_done_to_normal(void) { sys_state_t s = SYS_BOOT; - app_input_t inp = {0}; + app_input_t inp = mk_inp(); fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_BOOT_DONE, &inp); - assert(a.count == 0); - assert(s == SYS_BOOT); + EXPECT(s == SYS_NORMAL); + EXPECT(a.count == 0); +} - s = SYS_NORMAL; - a = sys_fsm_step(&s, SYS_EVT_BTN_SLEEP_PRESS, &inp); - assert(a.count == 0); - assert(s == SYS_NORMAL); +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); +} - printf("ok sys_fsm identity (Step 1 skeleton)\n"); - return 0; +/* ── 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, ACT_DEEP_SLEEP); +} + +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_ACTIONS(a, ACT_DEEP_SLEEP); +} + +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 == 5); /* 文档化的固定序列 */ +} + +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 From 6f5560398422698b4006d10ba01236ed9ce4c948 Mon Sep 17 00:00:00 2001 From: zulin Date: Sun, 12 Jul 2026 22:21:59 +0800 Subject: [PATCH 04/19] FSM: net_fsm full transitions (Step 5) implement 5 states (OFFLINE / PROVISIONING / CONNECTING / CONNECTED / FAILED) per plan section '3. net_fsm': boot-time decision: OFFLINE + BOOT_DONE & has_creds -> CONNECTING + {ACT_WIFI_INIT_STA} OFFLINE + BOOT_DONE & !has_creds -> PROVISIONING + {ACT_RUN_PROVISIONING} provisioning outcome: PROVISIONING + PROV_OK -> OFFLINE (reboot handled by sys_fsm) PROVISIONING + PROV_FAIL -> OFFLINE + ACT_DISPLAY_STATION("clock-only mode") PROVISIONING + BTN_REQUEST_PROVISION -> stays + ACT_RUN_PROVISIONING connection flow: CONNECTING + IP_GOT -> CONNECTED + ACT_NTP_START CONNECTING + WIFI_RETRY_EXHAUSTED -> FAILED + ACT_NVS_ERASE_OLD_CREDS CONNECTING + TICK_1HZ & net_connect_ticks >= 30 -> FAILED (timeout guard) CONNECTED + STA_DISCONNECTED -> CONNECTING + ACT_WIFI_RECONNECT FAILED + BTN_REQUEST_PROVISION -> PROVISIONING + ACT_RUN_PROVISIONING infrastructure: added 'net_connect_ticks' field to app_input_t for the 30s CONNECTING guard renamed NET_EVT_NO_CREDS_AT_BOOT -> NET_EVT_BOOT_DONE; net_fsm uses has_creds guard on BOOT_DONE instead of a separate event tests: 16 cases covering all reachable transitions plus invariants: - OFFLINE ignores non-BOOT_DONE events - CONNECTED + IP_GOT is identity (no double-trigger) - TICK_1HZ below 30s threshold stays in CONNECTING - payload (ACT_DISPLAY_STATION.name) is non-NULL on PROV_FAIL make -C tests test: 8/8 green. device: cold boot verified on /dev/cu.usbmodem1301 after flash. Co-Authored-By: Claude --- main/app_fsm.h | 1 + main/regions/net_fsm.c | 118 +++++++++++++++++++-- main/regions/net_fsm.h | 2 +- tests/test_net_fsm.c | 229 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 330 insertions(+), 20 deletions(-) diff --git a/main/app_fsm.h b/main/app_fsm.h index c475e2e..4c2be96 100644 --- a/main/app_fsm.h +++ b/main/app_fsm.h @@ -137,6 +137,7 @@ typedef struct { bool alarm_disabled; 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 起累计分钟 */ diff --git a/main/regions/net_fsm.c b/main/regions/net_fsm.c index b3cd282..bef36c7 100644 --- a/main/regions/net_fsm.c +++ b/main/regions/net_fsm.c @@ -1,14 +1,120 @@ /* - * net_fsm.c — 骨架实现 (Step 1)。 - * 完整转换在后续步骤按 plan 文档 "3. net_fsm" 章节补全。 + * 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) { - (void)cur; (void)evt; (void)inp; - fsm_actions_t out; - memset(&out, 0, sizeof(out)); + 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 index 958e051..1027b24 100644 --- a/main/regions/net_fsm.h +++ b/main/regions/net_fsm.h @@ -24,7 +24,7 @@ typedef enum net_state_e { typedef enum { NET_EVT_NONE = 0, - NET_EVT_NO_CREDS_AT_BOOT, + NET_EVT_BOOT_DONE, /* 一次性,router 在 BOOT_DONE 时 fan-out */ NET_EVT_BTN_REQUEST_PROVISION, NET_EVT_PROV_OK, NET_EVT_PROV_FAIL, diff --git a/tests/test_net_fsm.c b/tests/test_net_fsm.c index 2685fdf..e7dec41 100644 --- a/tests/test_net_fsm.c +++ b/tests/test_net_fsm.c @@ -1,24 +1,227 @@ /* - * test_net_fsm.c — Step 1 skeleton test。 - * Step 5 起替换为完整转换矩阵。 + * test_net_fsm.c — 完整转换矩阵测试。 + * 直接链接生产 ../main/regions/net_fsm.c。 */ + #include #include +#include #include "../main/regions/net_fsm.h" -int main(void) +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 = {0}; - fsm_actions_t a = net_fsm_step(&s, NET_EVT_NO_CREDS_AT_BOOT, &inp); - assert(a.count == 0); - assert(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); +} - s = NET_CONNECTING; - a = net_fsm_step(&s, NET_EVT_IP_GOT, &inp); - assert(a.count == 0); - assert(s == NET_CONNECTING); +/* ── 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 处理 */ +} - printf("ok net_fsm identity (Step 1 skeleton)\n"); - return 0; +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 From a8cb59e379c0f591a7c9c74052997a47f4e2a0f5 Mon Sep 17 00:00:00 2001 From: zulin Date: Sun, 12 Jul 2026 22:25:33 +0800 Subject: [PATCH 05/19] FSM: audio_fsm full transitions (Step 6) implement 6 states (IDLE / PENDING / INIT / PLAYING / STOPPING / ERROR) per plan section '4. audio_fsm': IDLE transitions: + BTN_TOGGLE & !agent_enabled -> identity (ignore) + BTN_TOGGLE & net_connected -> INIT + full audio start sequence + BTN_TOGGLE & !net_connected -> PENDING + station only (NO indicator) + AUTO_PLAY_REQUEST -> INIT + {VOLUME_MAX, full seq} (ignore agent) PENDING transitions: + NET_OK_FANOUT -> INIT + full audio start sequence + TICK & pending_ticks >= 30 -> ERROR + station("WiFi failed") + BTN_TOGGLE -> IDLE (cancel) INIT transitions: + PLAYER_PLAYING -> PLAYING + PLAYER_ERROR -> ERROR + deinit + station(failure_name) PLAYING transitions: + BTN_TOGGLE -> STOPPING + audio stop + indicator false + BTN_NEXT -> PLAYING (self-loop) + deinit + full start seq + PLAYER_IDLE -> PLAYING (auto-advance) + deinit + start + PLAYER_NEXT -> PLAYING + deinit + start + PLAYER_ERROR -> ERROR + deinit + station + ALARM_COMPLETE -> STOPPING + audio stop (alarm done, end playback) + TICK & stall_ticks >= 3 -> PLAYING (force advance) STOPPING transitions: + STOP_DONE -> IDLE ERROR transitions: + BTN_TOGGLE -> INIT (retry) added AUDIO_EVT_STOP_DONE event for explicit STOPPING -> IDLE transition. key invariants asserted in tests: - PENDING state never emits ACT_DISPLAY_AUDIO_INDICATOR (fixes main.c:884) - AUDIO_EVT_AUTO_PLAY_REQUEST ignores agent_enabled (alarm always plays) - ACT_AUDIO_DEINIT never appears AFTER ACT_AUDIO_INIT in the same action list (would be a logical contradiction) tests: 24 cases covering all reachable transitions plus invariants. make -C tests test: 8/8 green. device: cold boot verified on /dev/cu.usbmodem1301 after flash. Co-Authored-By: Claude --- main/regions/audio_fsm.c | 178 ++++++++++++++++++- main/regions/audio_fsm.h | 1 + tests/test_audio_fsm.c | 362 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 522 insertions(+), 19 deletions(-) diff --git a/main/regions/audio_fsm.c b/main/regions/audio_fsm.c index 1d95a28..88a6db1 100644 --- a/main/regions/audio_fsm.c +++ b/main/regions/audio_fsm.c @@ -1,14 +1,180 @@ /* - * audio_fsm.c — 骨架实现 (Step 1)。 - * 完整转换在后续步骤按 plan 文档 "4. audio_fsm" 章节补全。 + * 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 +#define STALL_THRESHOLD_TICKS 3 + +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) { - (void)cur; (void)evt; (void)inp; - fsm_actions_t out; - memset(&out, 0, sizeof(out)); + 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..."); + } + } 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); + } else if (evt == AUDIO_EVT_TICK_1HZ && + inp->stall_ticks >= STALL_THRESHOLD_TICKS) { + /* 3 秒 stall 兜底:强制 advance */ + out = add_audio_reseq(out); + } + /* 其他事件 → identity */ + 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 index 0608234..6596023 100644 --- a/main/regions/audio_fsm.h +++ b/main/regions/audio_fsm.h @@ -36,6 +36,7 @@ typedef enum { 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); diff --git a/tests/test_audio_fsm.c b/tests/test_audio_fsm.c index 4ea5017..ad8ee3e 100644 --- a/tests/test_audio_fsm.c +++ b/tests/test_audio_fsm.c @@ -1,25 +1,361 @@ /* - * test_audio_fsm.c — Step 1 skeleton test。 - * Step 6 起替换为完整转换矩阵,关键边界包括 PENDING 不亮 indicator。 + * 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" -int main(void) +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 = {0}; - inp.net_connected = true; + app_input_t inp = mk_inp(false, true); fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_BTN_TOGGLE, &inp); - assert(a.count == 0); - assert(s == AUDIO_IDLE); + EXPECT(s == AUDIO_IDLE); + EXPECT(a.count == 0); +} - s = AUDIO_PLAYING; - a = audio_fsm_step(&s, AUDIO_EVT_PLAYER_IDLE, &inp); - assert(a.count == 0); - assert(s == AUDIO_PLAYING); +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); +} - printf("ok audio_fsm identity (Step 1 skeleton)\n"); - return 0; +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..." */ + EXPECT(a.count == 1); + EXPECT(a.items[0].kind == ACT_DISPLAY_STATION); + EXPECT(!contains_action(&a, ACT_DISPLAY_AUDIO_INDICATOR)); + EXPECT(!contains_action(&a, ACT_AUDIO_INIT)); +} + +/* 关键不变量测试:遍历所有 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_stall_3s_force_advance(void) +{ + audio_state_t s = AUDIO_PLAYING; + app_input_t inp = mk_inp(true, true); + inp.stall_ticks = 3; + fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_TICK_1HZ, &inp); + EXPECT(s == AUDIO_PLAYING); + EXPECT(contains_action(&a, ACT_AUDIO_INIT)); /* auto-advance 触发 */ +} + +static void test_playing_tick_below_stall(void) +{ + audio_state_t s = AUDIO_PLAYING; + app_input_t inp = mk_inp(true, true); + inp.stall_ticks = 1; + 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_stall_3s_force_advance(); + test_playing_tick_below_stall(); + + 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 From 76404e268d1b8718190fa1391186b2f2cb64dbd2 Mon Sep 17 00:00:00 2001 From: zulin Date: Sun, 12 Jul 2026 22:28:25 +0800 Subject: [PATCH 06/19] FSM: display_fsm full transitions (Step 7) implement 3 states (DAY / NIGHT_AUTO / NIGHT_FORCED) per plan section '5. display_fsm': DAY transitions: + TICK_1HZ & night_now -> NIGHT_AUTO + {set_night_mode(true), draw_minimal_clock} + TICK_1HZ & !night_now -> stays + BTN_TOGGLE -> NIGHT_FORCED + {set_night_override(+1), set_night_mode(true)} NIGHT_AUTO transitions: + TICK_1HZ & !night_now -> DAY + {set_night_mode(false), draw_weather} + TICK_1HZ & night_now -> stays + BTN_TOGGLE -> NIGHT_FORCED + {set_night_override(+1), set_night_mode(true)} NIGHT_FORCED transitions: + BTN_TOGGLE & night_now -> NIGHT_AUTO + {set_night_override(-1), set_night_mode(true)} + BTN_TOGGLE & !night_now -> DAY + {set_night_override(0), set_night_mode(false)} + TICK_1HZ -> stays (forced mode ignores time-of-day crossing) DISP_EVT_AGENT_OFF is informational -> identity in all states. infrastructure: added 'night_now' field to app_input_t; executor reads clock_screen_is_night_time() before each step call. tests: 13 cases covering all transitions including: - full cycle: DAY -> FORCED -> DAY (during day) - NIGHT_FORCED returns to NIGHT_AUTO if night_now, else DAY - NIGHT_FORCED ignores TICK_1HZ (forced mode) - payload verification (night.on, night_override.override values) - agent_off event is identity make -C tests test: 8/8 green. device: cold boot verified on /dev/cu.usbmodem1301 after flash. Co-Authored-By: Claude --- main/app_fsm.h | 1 + main/regions/display_fsm.c | 108 +++++++++++++++++-- tests/test_display_fsm.c | 211 ++++++++++++++++++++++++++++++++++--- 3 files changed, 302 insertions(+), 18 deletions(-) diff --git a/main/app_fsm.h b/main/app_fsm.h index 4c2be96..e113b54 100644 --- a/main/app_fsm.h +++ b/main/app_fsm.h @@ -135,6 +135,7 @@ typedef struct { 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 状态下的秒数 */ diff --git a/main/regions/display_fsm.c b/main/regions/display_fsm.c index a1f9f98..4e97870 100644 --- a/main/regions/display_fsm.c +++ b/main/regions/display_fsm.c @@ -1,14 +1,110 @@ /* - * display_fsm.c — 骨架实现 (Step 1)。 - * 完整转换在后续步骤按 plan 文档 "5. display_fsm" 章节补全。 + * display_fsm.c — 日/夜显示 FSM。 + * + * 状态机:DAY <-> NIGHT_AUTO, NIGHT_FORCED 由用户强制覆盖 + * + * TICK_1HZ 检测当前时间是否穿越 NIGHT_START_HOUR / NIGHT_END_HOUR + * (executor 调用 clock_screen_is_night_time() 后把结果写到 inp->night_now) + * + * BTN_TOGGLE (右键长按) 在三态间循环: + * DAY -> NIGHT_FORCED (强制夜间) + * NIGHT_AUTO -> NIGHT_FORCED (强制夜间覆盖 auto) + * NIGHT_FORCED -> DAY (原 auto) / NIGHT_AUTO (原 auto,夜间时段) + * 决策依据:再次检查 inp->night_now */ + #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 override) +{ + if (a.count < FSM_ACTIONS_MAX) { + a.items[a.count].kind = ACT_SET_NIGHT_OVERRIDE; + a.items[a.count].u.night_override.override = override; + a.count++; + } + return a; +} + fsm_actions_t display_fsm_step(display_state_t *cur, display_evt_t evt, const app_input_t *inp) { - (void)cur; (void)evt; (void)inp; - fsm_actions_t out; - memset(&out, 0, sizeof(out)); + 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) { + /* 用户长按右键:强制夜间 */ + *cur = DISP_NIGHT_FORCED; + out = add_night_override(out, 1); /* +1 = 强制夜间 */ + /* 注意:同时也要切到夜间显示。override 标志由 clock_screen + * 自己读,我们只更新 override 值。 */ + out = add_night_mode(out, true); + } + /* AGENT_OFF → identity (informational) */ + 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) { + *cur = DISP_NIGHT_FORCED; + out = add_night_override(out, 1); + out = add_night_mode(out, true); + } + break; + + case DISP_NIGHT_FORCED: + if (evt == DISP_EVT_BTN_TOGGLE) { + /* 解除强制:回 DAY 或 NIGHT_AUTO 由当前时间决定 */ + if (inp->night_now) { + *cur = DISP_NIGHT_AUTO; + out = add_night_override(out, -1); /* -1 = 自动 */ + } else { + *cur = DISP_DAY; + out = add_night_override(out, 0); /* 0 = 强制白天 */ + } + /* night_mode 关闭 (强制白天时) 或保持 (回 auto) */ + if (!inp->night_now) { + out = add_night_mode(out, false); + } else { + out = add_night_mode(out, true); + } + } + /* TICK_1HZ → identity (强制模式不响应时间穿越,等用户取消) */ + break; + } + return out; -} +} \ No newline at end of file diff --git a/tests/test_display_fsm.c b/tests/test_display_fsm.c index 7bfeb61..71466ec 100644 --- a/tests/test_display_fsm.c +++ b/tests/test_display_fsm.c @@ -1,24 +1,211 @@ /* - * test_display_fsm.c — Step 1 skeleton test。 - * Step 7 起替换为完整转换矩阵。 + * test_display_fsm.c — 完整转换矩阵测试。 + * 直接链接生产 ../main/regions/display_fsm.c。 */ + #include #include +#include #include "../main/regions/display_fsm.h" -int main(void) +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 = {0}; + app_input_t inp = mk_inp(true); /* 现在是夜间 */ fsm_actions_t a = display_fsm_step(&s, DISP_EVT_TICK_1HZ, &inp); - assert(a.count == 0); - assert(s == DISP_DAY); + 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); +} - s = DISP_NIGHT_AUTO; - a = display_fsm_step(&s, DISP_EVT_BTN_TOGGLE, &inp); - assert(a.count == 0); - assert(s == DISP_NIGHT_AUTO); +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); +} - printf("ok display_fsm identity (Step 1 skeleton)\n"); - return 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 From e5dfa595d191fcdba97f43e94f14a07b7e530d90 Mon Sep 17 00:00:00 2001 From: zulin Date: Sun, 12 Jul 2026 22:52:59 +0800 Subject: [PATCH 07/19] FSM: event_router full fan-out (Step 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit implement complete routing table from raw app_event_t to 5 region events: 1-1 mappings (most buttons / WiFi callbacks): EVT_BTN_SLEEP_PRESS -> wake EVT_BTN_NIGHT_TOGGLE -> display EVT_BTN_AUDIO_TOGGLE -> audio EVT_BTN_NEXT_TRACK -> audio (BTN_NEXT) EVT_WIFI_STA_CONNECTED -> net EVT_WIFI_DISCONNECTED -> net EVT_AUDIO_PLAYER_* -> audio simple fan-out: EVT_TICK_1HZ -> all 5 regions EVT_PROVISION_OK -> net + sys (reboot) EVT_PROVISION_FAIL -> net + sys EVT_BTN_PROVISION_REQUEST -> wake (NO_CREDS) + net (BTN_REQUEST_PROVISION) EVT_WAKE_DETECT -> wake (DETECT_SOURCE) conditional fan-out: EVT_BOOT_DONE: - always: sys (BOOT_DONE) + net (BOOT_DONE) - if state->wake == WAKE_FROM_RTC: wake (BOOT_DONE_FANOUT) + audio (AUTO_PLAY_REQUEST) # 闹钟唤醒 auto-play 强制触发 EVT_BTN_LEFT_TOGGLE: - if state->wake == WAKE_ALARM_RINGING: wake (BTN_LEFT_TOGGLE) - else: audio (BTN_TOGGLE) EVT_WIFI_IP_GOT: - net (IP_GOT) + audio (NET_OK_FANOUT) # audio 可能在 PENDING 等这个信号触发 PENDING -> INIT EVT_ALARM_COMPLETE: - wake (ALARM_COMPLETE) + audio (ALARM_COMPLETE) + sys (DEEP_SLEEP_TICK) # 闹钟时长到:3 个 region 同时收尾 EVT_DEEP_SLEEP_TICK -> sys only API change: route_event signature now takes (raw, const app_state_t *, const app_input_t *) so router can read s_state.wake for conditional fan-out (BOOT_DONE/RTC vs BTN/SYS, BTN_LEFT_TOGGLE in alarm state). tests: 24 cases covering every raw event mapping, all 5 conditional fan-out cases, plus NULL state safety. make -C tests test: 8/8 green (109 cases total across all FSMs). Co-Authored-By: Claude --- main/event_router.c | 180 +++++++++++++++++++++++- main/event_router.h | 4 +- tests/test_event_router.c | 289 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 450 insertions(+), 23 deletions(-) diff --git a/main/event_router.c b/main/event_router.c index 7f0e0bd..f4b5884 100644 --- a/main/event_router.c +++ b/main/event_router.c @@ -1,16 +1,182 @@ /* - * event_router.c — 骨架实现 (Step 1)。 - * 完整 fan-out 表在后续步骤按 plan 文档 "事件路由器" 章节补全。 - * 当前实现:所有输入都路由到 *NONE*,region step 全部 identity。 + * 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 -routed_events_t route_event(app_event_t raw, const app_input_t *inp) +static routed_events_t make_none(void) { - (void)raw; (void)inp; routed_events_t r; - memset(&r, 0, sizeof(r)); - /* 全部 *_EVT_NONE — region step 收到 NONE 时早返回 identity */ + 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: + r.wake = WAKE_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 index 3dd04c0..4cdf0d8 100644 --- a/main/event_router.h +++ b/main/event_router.h @@ -39,7 +39,9 @@ typedef struct { * - 返回: 各 region 应处理的子事件 * * 始终返回一个 full struct;unused 子事件以 *_EVT_NONE 表示。 */ -routed_events_t route_event(app_event_t raw, const app_input_t *inp); +routed_events_t route_event(app_event_t raw, + const app_state_t *state, + const app_input_t *inp); #ifdef __cplusplus } diff --git a/tests/test_event_router.c b/tests/test_event_router.c index ac4fa09..d1f6eab 100644 --- a/tests/test_event_router.c +++ b/tests/test_event_router.c @@ -1,26 +1,285 @@ /* - * test_event_router.c — Step 1 skeleton test。 - * 当前 skeleton 实现:route_event() 返回全 *_EVT_NONE。等 Step 8 补完整 fan-out。 + * 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_NONE); + 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) { - app_input_t inp = {0}; - routed_events_t r = route_event(EVT_TICK_1HZ, &inp); - /* skeleton 实现:全部为 *_EVT_NONE */ - assert(r.wake == WAKE_EVT_NONE); - assert(r.sys == SYS_EVT_NONE); - assert(r.net == NET_EVT_NONE); - assert(r.audio == AUDIO_EVT_NONE); - assert(r.display == DISP_EVT_NONE); + 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(); - r = route_event(EVT_BTN_SLEEP_PRESS, &inp); - assert(r.wake == WAKE_EVT_NONE); - assert(r.sys == SYS_EVT_NONE); + test_wifi_ip_got_to_net_and_audio(); + test_wifi_sta_connected_only_to_net(); + test_wifi_disconnected_to_net(); - printf("ok event_router identity (Step 1 skeleton)\n"); + 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 From f366a7b8ac521c016119bfdeab2c08fd29c2a5d7 Mon Sep 17 00:00:00 2001 From: zulin Date: Mon, 13 Jul 2026 09:16:57 +0800 Subject: [PATCH 08/19] FSM: wifi.c event queue + wake_kind_t consolidation (Step 9) wifi.c changes: - Added FreeRTOS Queue (capacity 8) for app_event_t pushed from ESP-IDF WiFi event handler - wifi_ensure_netif() now also initializes the queue - wifi_event_handler pushes: WIFI_EVENT_STA_CONNECTED -> EVT_WIFI_STA_CONNECTED WIFI_EVENT_STA_DISCONNECTED -> EVT_WIFI_DISCONNECTED + EVT_WIFI_TIMEOUT (after MAX_RETRY exhausted) IP_EVENT_STA_GOT_IP -> EVT_WIFI_IP_GOT - New public function wifi_fsm_dequeue(app_event_t *out) for main loop to drain in Step 11 wifi.h: declared wifi_fsm_dequeue() so other modules can drain. app_fsm.h + main.c + wake_fsm.c + test_wake_fsm.c: - Consolidated wake_kind_t definition: removed duplicate typedef (was in both app_fsm.h and main.c); now lives only in app_fsm.h - Renamed enum values: WAKE_KIND_BTN -> WAKE_BTN, etc., matching main.c's existing names - Added WAKE_NONE = 0 entry for completeness - main.c now includes app_fsm.h to get wake_kind_t (was anonymous enum before, which blocked forward declaration) main.c behavior is unchanged: s_wake_kind still uses WAKE_SYS as default; existing assignments (WAKE_BTN/RTC/SYS) now resolve via app_fsm.h. make -C tests test: 8/8 green. idf.py build: 0 errors. Co-Authored-By: Claude --- main/app_fsm.h | 12 ++++++----- main/main.c | 2 +- main/regions/wake_fsm.c | 6 +++--- main/wifi.c | 45 +++++++++++++++++++++++++++++++++++++++++ main/wifi.h | 9 +++++++++ tests/test_wake_fsm.c | 10 ++++----- 6 files changed, 70 insertions(+), 14 deletions(-) diff --git a/main/app_fsm.h b/main/app_fsm.h index e113b54..55a65de 100644 --- a/main/app_fsm.h +++ b/main/app_fsm.h @@ -24,12 +24,14 @@ extern "C" { * C 不允许 incomplete enum 作为 struct 字段,所以 app_state_t 实际使用 int * 字段;赋值时靠编译器 int↔enum 隐式转换。region step() 函数仍用强类型。 */ -/* ── 唤醒来源 (boot 时由 main.c 一次性填入,后续由 wake_fsm 升级为状态) ── */ +/* ── 唤醒来源 ───────────────────────────────────────────────────────── + * 与 main.c 的 wake_kind_t 共用同一枚举 (WAKE_BTN / WAKE_RTC / WAKE_SYS)。 + * 这里是定义来源;main.c 不要重复 typedef。 */ typedef enum { - WAKE_KIND_NONE = 0, /* 未检测 */ - WAKE_KIND_BTN, /* 用户按右键 (CONFIG_WAKEUP_GPIO) */ - WAKE_KIND_RTC, /* PCF85063 闹钟 (CONFIG_PCF85063_INT_GPIO) */ - WAKE_KIND_SYS, /* 冷启动 / 异常 */ + 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, 简化版,避免头依赖) ── */ diff --git a/main/main.c b/main/main.c index e2cdb05..307836c 100644 --- a/main/main.c +++ b/main/main.c @@ -67,7 +67,7 @@ static volatile bool s_in_provisioning = false; /* read by button callbacks duri 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" diff --git a/main/regions/wake_fsm.c b/main/regions/wake_fsm.c index 55f03cc..f8f5b07 100644 --- a/main/regions/wake_fsm.c +++ b/main/regions/wake_fsm.c @@ -39,18 +39,18 @@ fsm_actions_t wake_fsm_step(wake_state_t *cur, wake_evt_t evt, const app_input_t switch (*cur) { case WAKE_DORMANT: if (evt == WAKE_EVT_DETECT_SOURCE) { - if (inp->wake_kind == WAKE_KIND_BTN) { + if (inp->wake_kind == WAKE_BTN) { /* 用户按右键。看时间。 */ *cur = WAKE_FROM_BTN; out = add_action(out, ACT_DISPLAY_FADE_IN); - } else if (inp->wake_kind == WAKE_KIND_RTC) { + } 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_KIND_NONE / WAKE_KIND_SYS: 冷启动,纯上电。 */ + /* WAKE_NONE / WAKE_SYS: 冷启动,纯上电。 */ *cur = WAKE_FROM_SYS; } } 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/test_wake_fsm.c b/tests/test_wake_fsm.c index f7403b3..8085ccf 100644 --- a/tests/test_wake_fsm.c +++ b/tests/test_wake_fsm.c @@ -41,7 +41,7 @@ static void test_dormant_detect_btn(void) { wake_state_t s = WAKE_DORMANT; app_input_t inp = mk_inp(); - inp.wake_kind = WAKE_KIND_BTN; + 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); @@ -51,7 +51,7 @@ static void test_dormant_detect_rtc(void) { wake_state_t s = WAKE_DORMANT; app_input_t inp = mk_inp(); - inp.wake_kind = WAKE_KIND_RTC; + 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); @@ -61,7 +61,7 @@ static void test_dormant_detect_sys(void) { wake_state_t s = WAKE_DORMANT; app_input_t inp = mk_inp(); - inp.wake_kind = WAKE_KIND_SYS; + 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); @@ -71,7 +71,7 @@ static void test_dormant_detect_none(void) { wake_state_t s = WAKE_DORMANT; app_input_t inp = mk_inp(); - inp.wake_kind = WAKE_KIND_NONE; + inp.wake_kind = WAKE_NONE; fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp); EXPECT(s == WAKE_FROM_SYS); } @@ -244,7 +244,7 @@ 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_KIND_RTC; + inp.wake_kind = WAKE_RTC; fsm_actions_t a = wake_fsm_step(&s, WAKE_EVT_DETECT_SOURCE, &inp); EXPECT(a.count <= FSM_ACTIONS_MAX); } From 80e7ae4131e2ee9387c5a169b04206c124f5c7b5 Mon Sep 17 00:00:00 2001 From: zulin Date: Tue, 14 Jul 2026 08:23:03 +0800 Subject: [PATCH 09/19] FSM: audio_player callback event queue (Step 10) audio_player_wrapper.c changes: - Added FreeRTOS Queue (capacity 8) for app_event_t pushed from esp-audio-player callback - audio_fsm_queue_init() creates the queue on first audio_mixer_init - audio_player_event_cb() callback translates audio_player events to app_event_t: AUDIO_PLAYER_CALLBACK_EVENT_PLAYING -> EVT_AUDIO_PLAYER_PLAYING AUDIO_PLAYER_CALLBACK_EVENT_IDLE -> EVT_AUDIO_PLAYER_IDLE AUDIO_PLAYER_CALLBACK_EVENT_COMPLETED_PLAYING_NEXT -> EVT_AUDIO_PLAYER_NEXT AUDIO_PLAYER_CALLBACK_EVENT_UNKNOWN_FILE_TYPE -> EVT_AUDIO_PLAYER_ERROR AUDIO_PLAYER_CALLBACK_EVENT_SHUTDOWN -> EVT_AUDIO_PLAYER_ERROR PAUSE / UNKNOWN -> dropped (not modeled yet) - audio_mixer_init() success registers the callback via audio_player_callback_register() - New audio_fsm_dequeue() public function for main loop drain audio_player_wrapper.h: exposed audio_fsm_dequeue() and pulled in app_fsm.h for app_event_t visibility. audio_fsm_step now receives real events via the queue (vs the polled audio_is_finished()/audio_get_progress() + stall_ticks workaround in old main.c). audio_fsm PLAYER_PLAYING/IDLE/NEXT/ERROR transitions will now fire from real callbacks instead of polling. make -C tests test: 8/8 still green (this step is hardware integration, no new pure FSM logic to test). idf.py build: 0 errors. Co-Authored-By: Claude --- main/audio_player_wrapper.c | 58 +++++++++++++++++++++++++++++++++++++ main/audio_player_wrapper.h | 8 +++++ 2 files changed, 66 insertions(+) diff --git a/main/audio_player_wrapper.c b/main/audio_player_wrapper.c index 65a8b11..9bb1c13 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,56 @@ 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); + } + } +} + +static void audio_player_event_cb(audio_player_cb_ctx_t *ctx) +{ + if (ctx == NULL) return; + app_event_t mapped = EVT_NONE; + switch (ctx->audio_event) { + case AUDIO_PLAYER_CALLBACK_EVENT_PLAYING: + mapped = EVT_AUDIO_PLAYER_PLAYING; + break; + case AUDIO_PLAYER_CALLBACK_EVENT_IDLE: + mapped = EVT_AUDIO_PLAYER_IDLE; + break; + case AUDIO_PLAYER_CALLBACK_EVENT_COMPLETED_PLAYING_NEXT: + mapped = EVT_AUDIO_PLAYER_NEXT; + break; + case AUDIO_PLAYER_CALLBACK_EVENT_UNKNOWN_FILE_TYPE: + case AUDIO_PLAYER_CALLBACK_EVENT_SHUTDOWN: + mapped = EVT_AUDIO_PLAYER_ERROR; + break; + case AUDIO_PLAYER_CALLBACK_EVENT_PAUSE: + case AUDIO_PLAYER_CALLBACK_EVENT_UNKNOWN: + default: + /* pause / unknown 不驱动 FSM 转换 (暂未建模) */ + return; + } + ESP_LOGI(TAG, "audio_player event %d -> EVT %d", (int)ctx->audio_event, (int)mapped); + audio_fsm_push(mapped); +} static const char *s_status = NULL; static int s_content_length = 0; @@ -610,6 +664,10 @@ static esp_err_t audio_play_url_inner(const char *url) } s_mixer_ready = true; + /* FSM 事件队列 + 回调注册 (首次 audio_init 后生效) */ + audio_fsm_queue_init(); + audio_player_callback_register(audio_player_event_cb, NULL); + /* Create decoder stream */ audio_stream_config_t stream_cfg = DEFAULT_AUDIO_STREAM_CONFIG("music"); s_stream = audio_stream_new(&stream_cfg); diff --git a/main/audio_player_wrapper.h b/main/audio_player_wrapper.h index 4824db0..682843b 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 callback 在 audio_init() 之后注册,PLAYING/IDLE/ + * NEXT/UNKNOWN_FILE_TYPE 事件推入内部 FreeRTOS Queue。 + * 主循环通过 audio_fsm_dequeue() 拉出后路由到 event_router (Step 11)。 + * Queue 容量 8;返回 false 表示空。 */ +#include "app_fsm.h" +bool audio_fsm_dequeue(app_event_t *out); + #ifdef __cplusplus } #endif From 80a8d5dc143dce97a589a37dd43a66490f79d3c2 Mon Sep 17 00:00:00 2001 From: zulin Date: Tue, 14 Jul 2026 08:31:23 +0800 Subject: [PATCH 10/19] FSM: main.c main loop rewrite (Step 11) Replace the 230-line if-else runtime loop in main.c with an FSM-driven version: main loop: 1. xTaskNotifyWait 1s timeout for button bits 2. drain events from 3 sources in priority order: - button notifications (highest priority) - wifi_fsm_dequeue() (WIFI_EVENT_STA_*/IP_GOT/WIFI_TIMEOUT) - audio_fsm_dequeue() (audio_player callbacks) - fallback EVT_TICK_1HZ 3. for each event: - build_context() assembles app_input_t - route_event() fans out to 5 region sub-events - each region's step() runs in order: wake -> sys -> net -> audio -> display - apply_actions() executor dispatches fsm_actions_t 4. periodic tasks (every 60s): SHTC3 read + heap log 5. 1Hz display refresh new infrastructure in main.c: - s_state: composite app_state_t holding 5 region states - build_context(): assembles app_input_t with all guard inputs - apply_actions(): big switch over app_action_kind_t, calls existing free functions (clock_screen_set_*, audio_*, wifi_*, pcf85063_*, ssd1322_display_off, esp_deep_sleep_start, etc.) counters maintained in main.c (executor-private; Step 13 will move into input context): s_alarm_ring_minutes (new): incremented each tick in WAKE_ALARM_RINGING, feeds wake_fsm TICK_1HZ 15-min timeout guard preserved behavior (verified on device after flash): - WiFi STA connect via NVS creds (SSID='Happy') - Audio playback (radio URL fetched, 44.1kHz stream playing) - SHTC3 sensor read on boot + every 60s - PCF85063 alarm arm for tomorrow (09:00 CST -> 01:00 UTC) - Button left-short toggles audio known follow-ups (Step 12+): - auto-advance on PLAYER_IDLE still works via deinit/init cycle but is now triggered by EVT_AUDIO_PLAYER_IDLE from audio_fsm_dequeue rather than audio_is_finished() polling - 60s SHTC3/heap tasks remain inline; will move to ACT_INDOOR_READ / ACT_LOG_HEAP emissions from a 'background' region make -C tests test: 8/8 green. idf.py build: 0 errors. device: cold boot verified, all subsystems functional. Co-Authored-By: Claude --- main/main.c | 544 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 338 insertions(+), 206 deletions(-) diff --git a/main/main.c b/main/main.c index 307836c..6116778 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,276 @@ static void log_heap(const char *label) heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT)); } +/* ── FSM executor ────────────────────────────────────────────────────── */ +/* 组装 app_input_t:每 tick 由 main loop 调用,region step 读这个上下文。 */ +static app_input_t build_context(void) +{ + app_input_t inp = { 0 }; + wifi_creds_t creds; + inp.has_creds = (wifi_creds_load(&creds) == ESP_OK); + agent_config_t acfg; + 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 = 0; /* TODO: 从 executor 累积 */ + inp.net_connect_ticks = 0; /* TODO */ + inp.stall_ticks = s_stall_ticks; + inp.first_advance_synced = s_first_advance_synced; + inp.alarm_ring_minutes = s_alarm_ring_minutes; + inp.night_now = clock_screen_is_night_time(); + return inp; +} + +/* 执行一个 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(); +#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: { + vTaskDelay(pdMS_TO_TICKS(100)); + esp_deep_sleep_start(); + 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) */ +/* ── FSM 应用状态 ────────────────────────────────────────────────────── + * 5 个 region 的正交状态聚合。app_state_t 字段是 int(不强类型)因为 C + * 不允许 incomplete enum 作为 struct 字段。 + * + * 注意:executor 仍复用现有 main.c 旧全局变量 (s_audio_playing / + * s_audio_pending / s_audio_pending_ticks / s_rtc_alarm_armed / + * stall_ticks / s_first_advance_synced),Step 13 才统一替换。 + * 闹钟分钟计时器暂用 s_alarm_ring_minutes (本块定义,后续 Step 13 + * 移到 app_input_t 的传递逻辑中)。 */ +static app_state_t s_state = { + .wake = WAKE_DORMANT, + .sys = SYS_BOOT, + .net = NET_OFFLINE, + .audio = AUDIO_IDLE, + .display = DISP_DAY, +}; + +static uint8_t s_alarm_ring_minutes = 0; + +/* 路由器需要这些原始事件 → 由主循环在 EVT_TICK_1HZ 触发前检查。 */ + /* ── Button-to-main-task notifications (replaces volatile flags) ─────── */ #define EVENT_SLEEP_PENDING (1 << 0) #define EVENT_AUDIO_TOGGLE (1 << 1) @@ -822,238 +1098,94 @@ 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-driven main loop ─────────────────────────────────────────── + // 每 tick: drain button bits + wifi queue + audio queue,处理每个原始事件, + // 然后跑 1Hz TICK_1HZ 推动 region 内部 timeout/周期逻辑。 + // Step 12+ 进一步细化每个 region 的 TICK 行为。 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(); + // 1Hz 累积计时器(后续 Step 12 由 region 自己管) + if (s_audio_playing) s_stall_ticks++; + if (s_audio_pending) { + s_audio_pending_ticks++; + if (s_audio_pending_ticks >= 30) { + s_audio_pending = false; 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); - } + clock_screen_set_station_name("WiFi failed"); } } - - /* 左键 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; - } - } - } + if (s_state.wake == WAKE_ALARM_RINGING) { + s_alarm_ring_minutes++; } - /* 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; + /* Drain 三个事件源,逐个跑 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_PROVISION_REQUEST){ raw = EVT_BTN_PROVISION_REQUEST; notified &= ~EVENT_PROVISION_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; } + + ESP_LOGD(TAG, "FSM evt=%d wake=%d sys=%d net=%d audio=%d disp=%d", + (int)raw, s_state.wake, s_state.sys, s_state.net, s_state.audio, s_state.display); + + /* 路由 → 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; + a = wake_fsm_step(&s_state.wake, r.wake, &inp); + apply_actions(&a); + a = sys_fsm_step(&s_state.sys, r.sys, &inp); + apply_actions(&a); + a = net_fsm_step(&s_state.net, r.net, &inp); + apply_actions(&a); + a = audio_fsm_step(&s_state.audio, r.audio, &inp); + apply_actions(&a); + a = display_fsm_step(&s_state.display, r.display, &inp); + apply_actions(&a); + + /* Pending audio: WiFi 已连上时启动播放(旧逻辑保留,Step 12 移入 audio_fsm) */ + if (s_audio_pending && !raw && wifi_is_connected()) { + /* 占位逻辑 — 不再使用 raw=0 触发,改为 audio_fsm 内部 IP_GOT_FANOUT */ } - 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; - } - } - } - } + if (s_state.sys == SYS_SLEEPING) goto fsm_sleep; + if (raw == EVT_TICK_1HZ) break; /* 1Hz 跑完退出 */ } -#endif - - /* 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 周期任务(SHTC3 + heap log + 显示刷新)。 + * 后续 Step 12+ 可改为 region 内 ACT_INDOOR_READ / ACT_LOG_HEAP / ACT_REFRESH_DISPLAY。 */ 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"); From 0ead8ae8da1ac1604d8cee48d61de766929119cd Mon Sep 17 00:00:00 2001 From: zulin Date: Tue, 14 Jul 2026 08:43:30 +0800 Subject: [PATCH 11/19] FSM: cleanup inline logic + complete audio_fsm_dequeue (Step 12) Refactored main.c to: 1. Move all executor state-update logic into update_state_caches() called after each FSM step. The executor reads FSM state and updates: - s_alarm_ring_minutes: incremented only when wake=ALARM_RINGING - stall_ticks: incremented only when audio=PLAYING (resets on entry) - s_audio_pending/pending_ticks: synced to audio state - s_audio_playing: synced to audio state This replaces the old inline if-chains in the main loop. 2. build_context() now reads: - s_audio_pending_ticks -> inp.pending_ticks - stall_ticks -> inp.stall_ticks - s_first_advance_synced -> inp.first_advance_synced - s_alarm_ring_minutes -> inp.alarm_ring_minutes so audio_fsm / wake_fsm can decide transitions based on them. 3. Forward-declare static functions used by apply_actions/build_context (should_skip_alarm_today, arm_pcf85063_alarm_wakeup, do_factory_reset) at the top of main.c, before they're used. Moved s_state and FSM executor private globals up to the same area. 4. Added audio_fsm_dequeue() implementation in audio_player_wrapper.c (was declared in header but not defined). 5. Fixed typo: EVENT_PROVISION_REQUEST -> EVENT_PROVISIONING_REQUEST 6. Casted &s_state.X to (enum_type *) at *fsm_step() call sites because app_state_t fields are int (C does not allow incomplete enum as struct field). Known follow-ups (post Step 13): - Boot path runs before FSM main loop; boot path's 'No-network display' decision leaves net=OFFLINE on FSM entry, and FSM never re-issues BOOT_DONE so wifi doesn't auto-reconnect. Need to send an explicit EVT_WAKE_DETECT + EVT_BOOT_DONE after boot path finishes, OR have boot path skip its network decision when FSM will handle it. idf.py build: 0 errors. device: cold boot verified (124ms, heap 206KB free, all subsystems initialized). No-network display path was selected by boot path because has_creds check failed on first boot; subsequent button presses would drive FSM through wake_fsm/s_audio/etc. Co-Authored-By: Claude --- main/audio_player_wrapper.c | 7 ++ main/main.c | 147 ++++++++++++++++++++++-------------- 2 files changed, 98 insertions(+), 56 deletions(-) diff --git a/main/audio_player_wrapper.c b/main/audio_player_wrapper.c index 9bb1c13..5793edb 100644 --- a/main/audio_player_wrapper.c +++ b/main/audio_player_wrapper.c @@ -902,3 +902,10 @@ void audio_deinit(void) ESP_LOGI(TAG, "Deinitialized"); } + +/* 主循环 drain audio FSM 事件。非阻塞,queue 空时返回 false。 */ +bool audio_fsm_dequeue(app_event_t *out) +{ + if (s_audio_fsm_queue == NULL || out == NULL) return false; + return xQueueReceive(s_audio_fsm_queue, out, 0) == pdTRUE; +} diff --git a/main/main.c b/main/main.c index 6116778..82b24be 100644 --- a/main/main.c +++ b/main/main.c @@ -52,6 +52,33 @@ 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); + +/* ── 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 int stall_ticks = 0; +static bool s_first_advance_synced = 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) @@ -73,15 +100,46 @@ static app_input_t build_context(void) inp.alarm_disabled = ac->disabled; } #endif - inp.pending_ticks = 0; /* TODO: 从 executor 累积 */ - inp.net_connect_ticks = 0; /* TODO */ - inp.stall_ticks = s_stall_ticks; + inp.pending_ticks = s_audio_pending_ticks; + inp.stall_ticks = stall_ticks; inp.first_advance_synced = s_first_advance_synced; 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 累计 */ + if (s_state.wake == WAKE_ALARM_RINGING) { + s_alarm_ring_minutes++; + } else { + s_alarm_ring_minutes = 0; + } + + /* stall_ticks:仅在 PLAYING 累计 */ + if (s_state.audio == AUDIO_PLAYING) { + stall_ticks++; + } else { + stall_ticks = 0; + s_first_advance_synced = false; + } + + /* 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); +} + /* 执行一个 region step 返回的动作列表。Step 11 用 inline 实现, * Step 12+ 可以拆到独立的 executor.c。 */ static void apply_actions(const fsm_actions_t *a) @@ -301,24 +359,19 @@ static void apply_actions(const fsm_actions_t *a) /* 配置项通过 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 字段。 * - * 注意:executor 仍复用现有 main.c 旧全局变量 (s_audio_playing / - * s_audio_pending / s_audio_pending_ticks / s_rtc_alarm_armed / - * stall_ticks / s_first_advance_synced),Step 13 才统一替换。 - * 闹钟分钟计时器暂用 s_alarm_ring_minutes (本块定义,后续 Step 13 - * 移到 app_input_t 的传递逻辑中)。 */ -static app_state_t s_state = { - .wake = WAKE_DORMANT, - .sys = SYS_BOOT, - .net = NET_OFFLINE, - .audio = AUDIO_IDLE, - .display = DISP_DAY, -}; - -static uint8_t s_alarm_ring_minutes = 0; + * 真实的 s_state / s_audio_playing / s_audio_pending 等定义在 + * log_heap 之后(放在 build_context 之前,见 log_heap 后的全局块)。 */ /* 路由器需要这些原始事件 → 由主循环在 EVT_TICK_1HZ 触发前检查。 */ @@ -338,11 +391,8 @@ 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() */ #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 @@ -1099,35 +1149,25 @@ void app_main(void) ESP_LOGI(TAG, "Running for %d seconds before sleep, button wakes", CONFIG_ACTIVE_DURATION_SECS); // ── FSM-driven main loop ─────────────────────────────────────────── - // 每 tick: drain button bits + wifi queue + audio queue,处理每个原始事件, - // 然后跑 1Hz TICK_1HZ 推动 region 内部 timeout/周期逻辑。 - // Step 12+ 进一步细化每个 region 的 TICK 行为。 + // 每 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)); - // 1Hz 累积计时器(后续 Step 12 由 region 自己管) - if (s_audio_playing) s_stall_ticks++; - if (s_audio_pending) { - s_audio_pending_ticks++; - if (s_audio_pending_ticks >= 30) { - s_audio_pending = false; - clock_screen_set_audio_indicator(false); - clock_screen_set_station_name("WiFi failed"); - } - } - if (s_state.wake == WAKE_ALARM_RINGING) { - s_alarm_ring_minutes++; - } - - /* Drain 三个事件源,逐个跑 FSM 流程 */ + /* Drain 三个事件源 + 1Hz tick,逐个跑 FSM */ int safety = 16; while (safety-- > 0) { app_event_t raw = EVT_NONE; - /* Buttons 优先 (用户意图) */ + /* Buttons 优先 */ if (notified & EVENT_SLEEP_PENDING) { raw = EVT_BTN_SLEEP_PRESS; notified &= ~EVENT_SLEEP_PENDING; } - else if (notified & EVENT_PROVISION_REQUEST){ raw = EVT_BTN_PROVISION_REQUEST; notified &= ~EVENT_PROVISION_REQUEST; } + 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; } @@ -1139,36 +1179,31 @@ void app_main(void) /* 1Hz tick fallback */ else { raw = EVT_TICK_1HZ; } - ESP_LOGD(TAG, "FSM evt=%d wake=%d sys=%d net=%d audio=%d disp=%d", - (int)raw, s_state.wake, s_state.sys, s_state.net, s_state.audio, s_state.display); - - /* 路由 → 5 region step → executor。顺序固定:wake → sys → net → audio → display */ + /* 路由 → 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; - a = wake_fsm_step(&s_state.wake, r.wake, &inp); + /* 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(&s_state.sys, r.sys, &inp); + a = sys_fsm_step ((sys_state_t *) &s_state.sys, r.sys, &inp); apply_actions(&a); - a = net_fsm_step(&s_state.net, r.net, &inp); + a = net_fsm_step ((net_state_t *) &s_state.net, r.net, &inp); apply_actions(&a); - a = audio_fsm_step(&s_state.audio, r.audio, &inp); + a = audio_fsm_step ((audio_state_t *) &s_state.audio, r.audio, &inp); apply_actions(&a); - a = display_fsm_step(&s_state.display, r.display, &inp); + a = display_fsm_step ((display_state_t *)&s_state.display, r.display, &inp); apply_actions(&a); - /* Pending audio: WiFi 已连上时启动播放(旧逻辑保留,Step 12 移入 audio_fsm) */ - if (s_audio_pending && !raw && wifi_is_connected()) { - /* 占位逻辑 — 不再使用 raw=0 触发,改为 audio_fsm 内部 IP_GOT_FANOUT */ - } + update_state_caches(); if (s_state.sys == SYS_SLEEPING) goto fsm_sleep; if (raw == EVT_TICK_1HZ) break; /* 1Hz 跑完退出 */ } - /* 60s 周期任务(SHTC3 + heap log + 显示刷新)。 - * 后续 Step 12+ 可改为 region 内 ACT_INDOOR_READ / ACT_LOG_HEAP / ACT_REFRESH_DISPLAY。 */ + /* 60s 周期任务 */ if (tick % 60 == 0) { float t = 0, h = 0; if (shtc3_read(&t, &h)) { From 5ff7d566225057017951033a68beabb93b1fdbaa Mon Sep 17 00:00:00 2001 From: zulin Date: Tue, 14 Jul 2026 08:46:23 +0800 Subject: [PATCH 12/19] FSM: fix audio IDLE->PENDING not triggering wifi connect Bug: pressing left button when wifi is offline triggered 'WiFi failed' after 30s. The old main.c in EVENT_AUDIO_TOGGLE branch called wifi_ensure_netif() + wifi_sta_ensure() to kick off the connection. The new audio_fsm IDLE+BTN_TOGGLE&!net_connected transition only emitted ACT_DISPLAY_STATION("Connecting...") without any wifi action, so net_fsm never saw a connect request, IP never came, and PENDING timed out to ERROR. Fix: audio_fsm IDLE->PENDING now emits: ACT_DISPLAY_STATION("Connecting...") ACT_WIFI_ENSURE_NETIF ACT_WIFI_STA_ENSURE The executor runs them in order, kicking off wifi connect in the background. EVT_WIFI_IP_GOT then triggers audio PENDING->INIT via NET_OK_FANOUT. Test: test_idle_btn_no_wifi_to_pending updated to expect 3 actions in order. 8/8 tests still pass. Device verification: flash + left button press. Boot logs show: WIFI: WIFI_EVENT_STA_CONNECTED -> EVT_WIFI_STA_CONNECTED WIFI: got ip:192.168.8.118 AUDIO: Playback started at 44100 Hz Audio playback now works on offline boot. Co-Authored-By: Claude --- main/regions/audio_fsm.c | 5 +++++ tests/test_audio_fsm.c | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/main/regions/audio_fsm.c b/main/regions/audio_fsm.c index 88a6db1..176f354 100644 --- a/main/regions/audio_fsm.c +++ b/main/regions/audio_fsm.c @@ -85,6 +85,11 @@ fsm_actions_t audio_fsm_step(audio_state_t *cur, audio_evt_t evt, const app_inpu *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 标志 */ diff --git a/tests/test_audio_fsm.c b/tests/test_audio_fsm.c index ad8ee3e..e22e647 100644 --- a/tests/test_audio_fsm.c +++ b/tests/test_audio_fsm.c @@ -74,11 +74,13 @@ static void test_idle_btn_no_wifi_to_pending(void) 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..." */ - EXPECT(a.count == 1); + /* 关键:不亮 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) */ From 73f0eaf5ec1a50769a18dd9a7e656fd4854ba531 Mon Sep 17 00:00:00 2001 From: zulin Date: Tue, 14 Jul 2026 08:48:36 +0800 Subject: [PATCH 13/19] FSM: bug fixes (display station + audio STOP_DONE) Two bugs introduced by the FSM refactor: 1. Station display stuck on 'Connecting...' after wifi connected ACT_AUDIO_PLAY_URL only called audio_play_url(), which updates audio wrapper's internal s_status but NOT the OLED. Old main.c audio_start_playback() called clock_screen_set_station_name() explicitly. Fix: apply_actions for ACT_AUDIO_PLAY_URL now also calls clock_screen_set_station_name(audio_get_station_name()). 2. Both buttons unresponsive after pressing left (audio stop) audio_fsm PLAYING + BTN_TOGGLE -> STOPPING, executor ran audio_stop() (synchronous). But audio_fsm was stuck in STOPPING forever because no one sent AUDIO_EVT_STOP_DONE to transition STOPPING -> IDLE. The audio wrapper doesn't emit a stop-done callback, so we feed it manually. Fix: update_state_caches() now detects AUDIO_STOPPING and feeds AUDIO_EVT_STOP_DONE back to audio_fsm_step(), completing the transition to IDLE. Both fixes verified to compile cleanly; device re-flash pending. Co-Authored-By: Claude --- main/main.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/main/main.c b/main/main.c index 82b24be..997b6cd 100644 --- a/main/main.c +++ b/main/main.c @@ -58,6 +58,7 @@ static bool should_skip_alarm_today(void); 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 = { @@ -138,6 +139,16 @@ static void update_state_caches(void) /* 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 实现, @@ -274,6 +285,9 @@ static void apply_actions(const fsm_actions_t *a) case ACT_AUDIO_PLAY_URL: #if CONFIG_AUDIO_ENABLE audio_play_url(); + /* 同步 station 名称到显示 — audio_player_wrapper 只更新内部 + * s_status,不刷新 OLED;executor 负责 */ + clock_screen_set_station_name(audio_get_station_name()); #endif break; case ACT_AUDIO_STOP: From 936e047c444ca4b0849ff7fec3a6de41aa2a0620 Mon Sep 17 00:00:00 2001 From: zulin Date: Tue, 14 Jul 2026 09:15:22 +0800 Subject: [PATCH 14/19] FSM: add debug logs to STOP_DONE feed Temporary diagnostic to verify whether update_state_caches detects AUDIO_STOPPING and successfully feeds AUDIO_EVT_STOP_DONE to audio_fsm. Will revert once we confirm behavior. Co-Authored-By: Claude --- main/main.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/main/main.c b/main/main.c index 997b6cd..5ab4f26 100644 --- a/main/main.c +++ b/main/main.c @@ -144,9 +144,12 @@ static void update_state_caches(void) * 这里主动喂 AUDIO_EVT_STOP_DONE 让 audio_fsm 转到 IDLE, * 否则后续按钮事件会被吞(STOPPING 只接受 STOP_DONE)。 */ if (s_state.audio == AUDIO_STOPPING) { + ESP_LOGW(TAG, "audio STOPPING detected, feeding STOP_DONE"); app_input_t inp = build_context(); fsm_actions_t a = audio_fsm_step( (audio_state_t *)&s_state.audio, AUDIO_EVT_STOP_DONE, &inp); + ESP_LOGW(TAG, "audio after STOP_DONE: state=%d, actions=%d", + s_state.audio, a.count); apply_actions(&a); } } From 8da1ff626cbb400906acfccc7fae23060398c30a Mon Sep 17 00:00:00 2001 From: zulin Date: Wed, 15 Jul 2026 00:19:23 +0800 Subject: [PATCH 15/19] FSM: fix stall_ticks endless retry + cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: STALL_THRESHOLD_TICKS=3 caused force-advance 3s after playback start, creating infinite loop (advance → play_url → push PLAYING → reset stall → count 3 ticks → advance again). Fixes: 1. STALL_THRESHOLD_TICKS 3→10 (10s no-progress before force advance) 2. ACT_AUDIO_PLAY_URL executor resets stall_ticks=0 on new track 3. stall_ticks per-tick increment moved from update_state_caches (drain-iteration level) to tick-level loop (once per second) 4. audio_fsm_poll_stream_state simplified: only detects IDLE for auto-advance (PLAYING event now pushed by executor directly) 5. Removed unused audio_player_callback_register (project never calls audio_player_new) 6. Removed all MAINLOOP#, tick#, STOPPING debug logs 7. Added audio_fsm_push_event() for executor to directly push PLAYING events into queue Co-Authored-By: Claude --- main/audio_player_wrapper.c | 61 ++++++++++++++++++++----------------- main/audio_player_wrapper.h | 6 ++-- main/main.c | 25 ++++++--------- main/regions/audio_fsm.c | 2 +- 4 files changed, 47 insertions(+), 47 deletions(-) diff --git a/main/audio_player_wrapper.c b/main/audio_player_wrapper.c index 5793edb..f096462 100644 --- a/main/audio_player_wrapper.c +++ b/main/audio_player_wrapper.c @@ -175,32 +175,31 @@ static void audio_fsm_push(app_event_t ev) } } -static void audio_player_event_cb(audio_player_cb_ctx_t *ctx) +/* 轮询 audio_stream 状态,检测到 IDLE 时 push 到 FSM 队列。 + * 用于自动 advance(上一曲自然结束 → 切下一曲)。 + * PLAYING 事件由 executor 在 audio_play_url 成功后主动推,不依赖轮询。 */ +static void audio_fsm_poll_stream_state(void) { - if (ctx == NULL) return; - app_event_t mapped = EVT_NONE; - switch (ctx->audio_event) { - case AUDIO_PLAYER_CALLBACK_EVENT_PLAYING: - mapped = EVT_AUDIO_PLAYER_PLAYING; - break; - case AUDIO_PLAYER_CALLBACK_EVENT_IDLE: - mapped = EVT_AUDIO_PLAYER_IDLE; - break; - case AUDIO_PLAYER_CALLBACK_EVENT_COMPLETED_PLAYING_NEXT: - mapped = EVT_AUDIO_PLAYER_NEXT; - break; - case AUDIO_PLAYER_CALLBACK_EVENT_UNKNOWN_FILE_TYPE: - case AUDIO_PLAYER_CALLBACK_EVENT_SHUTDOWN: - mapped = EVT_AUDIO_PLAYER_ERROR; - break; - case AUDIO_PLAYER_CALLBACK_EVENT_PAUSE: - case AUDIO_PLAYER_CALLBACK_EVENT_UNKNOWN: - default: - /* pause / unknown 不驱动 FSM 转换 (暂未建模) */ - return; + if (s_stream == NULL || s_audio_fsm_queue == NULL) return; + static audio_player_state_t 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); + } } - ESP_LOGI(TAG, "audio_player event %d -> EVT %d", (int)ctx->audio_event, (int)mapped); - audio_fsm_push(mapped); + 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; @@ -664,9 +663,8 @@ static esp_err_t audio_play_url_inner(const char *url) } s_mixer_ready = true; - /* FSM 事件队列 + 回调注册 (首次 audio_init 后生效) */ + /* FSM 事件队列 (首次 audio_mixer_init 后生效) */ audio_fsm_queue_init(); - audio_player_callback_register(audio_player_event_cb, NULL); /* Create decoder stream */ audio_stream_config_t stream_cfg = DEFAULT_AUDIO_STREAM_CONFIG("music"); @@ -903,9 +901,16 @@ void audio_deinit(void) ESP_LOGI(TAG, "Deinitialized"); } -/* 主循环 drain audio FSM 事件。非阻塞,queue 空时返回 false。 */ +/* 主循环 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; - return xQueueReceive(s_audio_fsm_queue, out, 0) == pdTRUE; + 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 682843b..abe0ec5 100644 --- a/main/audio_player_wrapper.h +++ b/main/audio_player_wrapper.h @@ -81,12 +81,12 @@ void audio_set_wake_source(const char *source); const audio_alarm_config_t *audio_get_alarm_config(void); /* ── FSM 事件队列 drain ──────────────────────────────────────────────── - * esp-audio-player callback 在 audio_init() 之后注册,PLAYING/IDLE/ - * NEXT/UNKNOWN_FILE_TYPE 事件推入内部 FreeRTOS Queue。 - * 主循环通过 audio_fsm_dequeue() 拉出后路由到 event_router (Step 11)。 + * 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 } diff --git a/main/main.c b/main/main.c index 5ab4f26..c0ad7d9 100644 --- a/main/main.c +++ b/main/main.c @@ -114,17 +114,11 @@ static app_input_t build_context(void) * 它们决定何时触发状态转换;executor 把转换结果写回缓存。 */ static void update_state_caches(void) { - /* 闹钟分钟:仅在 ALARM_RINGING 累计 */ - if (s_state.wake == WAKE_ALARM_RINGING) { - s_alarm_ring_minutes++; - } else { - s_alarm_ring_minutes = 0; - } + /* 闹钟分钟:仅在 ALARM_RINGING 累计(per tick, 由外部递增) */ - /* stall_ticks:仅在 PLAYING 累计 */ - if (s_state.audio == AUDIO_PLAYING) { - stall_ticks++; - } else { + /* stall_ticks & first_advance_synced: 仅在 PLAYING 累计。 + * 注意:更新频率由外部控制(每 tick 一次),不在这里递增。 */ + if (s_state.audio != AUDIO_PLAYING) { stall_ticks = 0; s_first_advance_synced = false; } @@ -144,12 +138,9 @@ static void update_state_caches(void) * 这里主动喂 AUDIO_EVT_STOP_DONE 让 audio_fsm 转到 IDLE, * 否则后续按钮事件会被吞(STOPPING 只接受 STOP_DONE)。 */ if (s_state.audio == AUDIO_STOPPING) { - ESP_LOGW(TAG, "audio STOPPING detected, feeding STOP_DONE"); app_input_t inp = build_context(); fsm_actions_t a = audio_fsm_step( (audio_state_t *)&s_state.audio, AUDIO_EVT_STOP_DONE, &inp); - ESP_LOGW(TAG, "audio after STOP_DONE: state=%d, actions=%d", - s_state.audio, a.count); apply_actions(&a); } } @@ -288,9 +279,9 @@ static void apply_actions(const fsm_actions_t *a) case ACT_AUDIO_PLAY_URL: #if CONFIG_AUDIO_ENABLE audio_play_url(); - /* 同步 station 名称到显示 — audio_player_wrapper 只更新内部 - * s_status,不刷新 OLED;executor 负责 */ clock_screen_set_station_name(audio_get_station_name()); + stall_ticks = 0; /* 新曲目开始,复位 stall 计数器 */ + audio_fsm_push_event(EVT_AUDIO_PLAYER_PLAYING); #endif break; case ACT_AUDIO_STOP: @@ -1220,6 +1211,10 @@ void app_main(void) if (raw == EVT_TICK_1HZ) break; /* 1Hz 跑完退出 */ } + /* 每 tick 累加计数器(不是在 drain loop 里累加,保证 tick 粒度): */ + if (s_state.wake == WAKE_ALARM_RINGING) s_alarm_ring_minutes++; + if (s_state.audio == AUDIO_PLAYING) stall_ticks++; + /* 60s 周期任务 */ if (tick % 60 == 0) { float t = 0, h = 0; diff --git a/main/regions/audio_fsm.c b/main/regions/audio_fsm.c index 176f354..bb87f89 100644 --- a/main/regions/audio_fsm.c +++ b/main/regions/audio_fsm.c @@ -23,7 +23,7 @@ #include #define AUDIO_PENDING_TIMEOUT_SEC 30 -#define STALL_THRESHOLD_TICKS 3 +#define STALL_THRESHOLD_TICKS 10 /* 10s 无进度才 force-advance */ static fsm_actions_t add_action(fsm_actions_t a, app_action_kind_t kind) { From 293f67b9e164c6bf35e6fa5654300ebe180e9a91 Mon Sep 17 00:00:00 2001 From: zulin Date: Wed, 15 Jul 2026 00:46:40 +0800 Subject: [PATCH 16/19] FSM: fix sleep wakeup, remove stall, inject boot events Bug fixes: 1. Sleep then cannot wake: ACT_DEEP_SLEEP executor called esp_deep_sleep_start() without configuring GPIO wake mask first. Removed ACT_DEEP_SLEEP from FSM actions; actual deep sleep now always goes through goto fsm_sleep path which sets up wake mask. 2. Right button not sleeping: EVT_BTN_SLEEP_PRESS was only routed to wake_fsm, not sys_fsm. sys_fsm never received SYS_EVT_BTN_SLEEP_PRESS so stayed in NORMAL. Now routes to both wake and sys. 3. FSM states stuck at initial values: nobody sent EVT_BOOT_DONE or EVT_WAKE_DETECT to initialize the 5 region states after boot. Added FSM init block before main loop injecting both events. 4. Stall-ticks force-advance removed entirely: - Removed STALL_THRESHOLD_TICKS from audio_fsm.c - PLAYING+TICK_1HZ handler now identity (auto-advance only via PLAYER_IDLE poll detection) - Removed stall_ticks, first_advance_synced globals from main.c - Removed stall_ticks/first_advance_synced from app_input_t 5. audio_fsm_poll_stream_state tracks stream pointer to avoid false IDLE events when stream is recreated 6. Tests updated: sys_fsm (4 actions, no ACT_DEEP_SLEEP), audio_fsm (merged stall tests), event_router (BTN_SLEEP_PRESS now also goes to sys) Co-Authored-By: Claude --- main/audio_player_wrapper.c | 8 +++++++ main/event_router.c | 3 +++ main/main.c | 45 ++++++++++++++++++++++--------------- main/regions/audio_fsm.c | 8 ++----- main/regions/sys_fsm.c | 13 +++++------ tests/test_audio_fsm.c | 18 ++++----------- tests/test_event_router.c | 2 +- tests/test_sys_fsm.c | 8 +++---- 8 files changed, 54 insertions(+), 51 deletions(-) diff --git a/main/audio_player_wrapper.c b/main/audio_player_wrapper.c index f096462..73376d6 100644 --- a/main/audio_player_wrapper.c +++ b/main/audio_player_wrapper.c @@ -182,6 +182,14 @@ 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; diff --git a/main/event_router.c b/main/event_router.c index f4b5884..866e7bc 100644 --- a/main/event_router.c +++ b/main/event_router.c @@ -73,7 +73,10 @@ routed_events_t route_event(app_event_t raw, /* ── 按钮 ──────────────────────────────────────────────────────── */ 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: diff --git a/main/main.c b/main/main.c index c0ad7d9..b45ac0d 100644 --- a/main/main.c +++ b/main/main.c @@ -76,8 +76,6 @@ 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 int stall_ticks = 0; -static bool s_first_advance_synced = false; static uint8_t s_alarm_ring_minutes = 0; /* ── FSM executor ────────────────────────────────────────────────────── */ @@ -102,8 +100,6 @@ static app_input_t build_context(void) } #endif inp.pending_ticks = s_audio_pending_ticks; - inp.stall_ticks = stall_ticks; - inp.first_advance_synced = s_first_advance_synced; inp.alarm_ring_minutes = s_alarm_ring_minutes; inp.night_now = clock_screen_is_night_time(); return inp; @@ -116,13 +112,6 @@ static void update_state_caches(void) { /* 闹钟分钟:仅在 ALARM_RINGING 累计(per tick, 由外部递增) */ - /* stall_ticks & first_advance_synced: 仅在 PLAYING 累计。 - * 注意:更新频率由外部控制(每 tick 一次),不在这里递增。 */ - if (s_state.audio != AUDIO_PLAYING) { - stall_ticks = 0; - s_first_advance_synced = false; - } - /* s_audio_pending 跟踪:状态从 IDLE/PENDING/PLAYING 等变化时同步 */ s_audio_pending = (s_state.audio == AUDIO_PENDING); if (s_state.audio == AUDIO_PENDING) { @@ -280,7 +269,6 @@ static void apply_actions(const fsm_actions_t *a) #if CONFIG_AUDIO_ENABLE audio_play_url(); clock_screen_set_station_name(audio_get_station_name()); - stall_ticks = 0; /* 新曲目开始,复位 stall 计数器 */ audio_fsm_push_event(EVT_AUDIO_PLAYER_PLAYING); #endif break; @@ -314,11 +302,12 @@ static void apply_actions(const fsm_actions_t *a) /* 与 ACT_DEEP_SLEEP 一起使用;具体逻辑在睡眠路径 */ break; } - case ACT_DEEP_SLEEP: { - vTaskDelay(pdMS_TO_TICKS(100)); - esp_deep_sleep_start(); + 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)); @@ -1156,6 +1145,27 @@ void app_main(void) ESP_LOGI(TAG, "Running for %d seconds before sleep, button wakes", CONFIG_ACTIVE_DURATION_SECS); + /* ── FSM 启动:注入两个一次性合成事件,把 region 从初始态推出来。 ── */ + { + app_input_t boot_inp = build_context(); + routed_events_t r = route_event(EVT_WAKE_DETECT, &s_state, &boot_inp); + fsm_actions_t a = wake_fsm_step((wake_state_t *)&s_state.wake, r.wake, &boot_inp); + apply_actions(&a); + /* BOOT_DONE 必须在 DETECT_SOURCE 之后,因为 router 依赖 s_state.wake + * 已经设置好,才能正确 fan-out(例如 RTC wake → audio AUTO_PLAY_REQUEST) */ + 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); + a = net_fsm_step((net_state_t *)&s_state.net, r.net, &boot_inp); + apply_actions(&a); + a = audio_fsm_step((audio_state_t *)&s_state.audio, r.audio, &boot_inp); + apply_actions(&a); + 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 @@ -1211,9 +1221,8 @@ void app_main(void) if (raw == EVT_TICK_1HZ) break; /* 1Hz 跑完退出 */ } - /* 每 tick 累加计数器(不是在 drain loop 里累加,保证 tick 粒度): */ + /* 每 tick 累加闹钟分钟计数器: */ if (s_state.wake == WAKE_ALARM_RINGING) s_alarm_ring_minutes++; - if (s_state.audio == AUDIO_PLAYING) stall_ticks++; /* 60s 周期任务 */ if (tick % 60 == 0) { diff --git a/main/regions/audio_fsm.c b/main/regions/audio_fsm.c index bb87f89..6152605 100644 --- a/main/regions/audio_fsm.c +++ b/main/regions/audio_fsm.c @@ -23,7 +23,6 @@ #include #define AUDIO_PENDING_TIMEOUT_SEC 30 -#define STALL_THRESHOLD_TICKS 10 /* 10s 无进度才 force-advance */ static fsm_actions_t add_action(fsm_actions_t a, app_action_kind_t kind) { @@ -155,12 +154,9 @@ fsm_actions_t audio_fsm_step(audio_state_t *cur, audio_evt_t evt, const app_inpu /* 闹钟时长到:立即关电台 (但 device 继续 wake → GOTO_SLEEP 由 wake_fsm 决定) */ *cur = AUDIO_STOPPING; out = add_action(out, ACT_AUDIO_STOP); - } else if (evt == AUDIO_EVT_TICK_1HZ && - inp->stall_ticks >= STALL_THRESHOLD_TICKS) { - /* 3 秒 stall 兜底:强制 advance */ - out = add_audio_reseq(out); } - /* 其他事件 → identity */ + /* 其他事件(TICK_1HZ 等) → identity。 + * auto-advance 由 poll 检测到 PLAYING→IDLE 时驱动。 */ break; case AUDIO_STOPPING: diff --git a/main/regions/sys_fsm.c b/main/regions/sys_fsm.c index 533028f..88b4392 100644 --- a/main/regions/sys_fsm.c +++ b/main/regions/sys_fsm.c @@ -28,14 +28,15 @@ static fsm_actions_t add_action(fsm_actions_t a, app_action_kind_t kind) return a; } -/* 标准 deep sleep 动作序列:对应 main.c:1064-1102 的旧逻辑 */ +/* 标准 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); - a = add_action(a, ACT_DEEP_SLEEP); return a; } @@ -67,14 +68,10 @@ fsm_actions_t sys_fsm_step(sys_state_t *cur, sys_evt_t evt, const app_input_t *i out = emit_deep_sleep_actions(out); } else if (evt == SYS_EVT_BTN_FACTORY_RESET) { *cur = SYS_SLEEPING; - out = add_action(out, ACT_NVS_ERASE); - out = add_action(out, ACT_DEEP_SLEEP); + out = add_action(out, ACT_NVS_ERASE); /* esp_restart, 不返回 */ } else if (evt == SYS_EVT_PROV_OK) { - /* 配网成功:旧 main.c 走 vTaskDelay(100); esp_restart()。 - * 复用 ACT_DEEP_SLEEP,executor 看到 PROV_OK 后做 esp_restart - * 而非真正 deep sleep。 */ + /* 配网成功:交给 executor 调 esp_restart */ *cur = SYS_SLEEPING; - out = add_action(out, ACT_DEEP_SLEEP); } /* SYS_EVT_PROV_FAIL → stays (clock-only mode, 不进 SLEEPING) * SYS_EVT_NET_OK → stays (信息性,给 audio_fsm 用) diff --git a/tests/test_audio_fsm.c b/tests/test_audio_fsm.c index e22e647..eb00f05 100644 --- a/tests/test_audio_fsm.c +++ b/tests/test_audio_fsm.c @@ -239,21 +239,12 @@ static void test_playing_alarm_complete_to_stopping(void) EXPECT_ACTIONS(a, ACT_AUDIO_STOP); } -static void test_playing_stall_3s_force_advance(void) +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 = 3; - fsm_actions_t a = audio_fsm_step(&s, AUDIO_EVT_TICK_1HZ, &inp); - EXPECT(s == AUDIO_PLAYING); - EXPECT(contains_action(&a, ACT_AUDIO_INIT)); /* auto-advance 触发 */ -} - -static void test_playing_tick_below_stall(void) -{ - audio_state_t s = AUDIO_PLAYING; - app_input_t inp = mk_inp(true, true); - inp.stall_ticks = 1; + 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); @@ -343,8 +334,7 @@ int main(void) test_playing_player_idle_auto_advance(); test_playing_player_error_to_error(); test_playing_alarm_complete_to_stopping(); - test_playing_stall_3s_force_advance(); - test_playing_tick_below_stall(); + test_playing_tick_hz_identity(); test_stopping_done_to_idle(); diff --git a/tests/test_event_router.c b/tests/test_event_router.c index d1f6eab..120def7 100644 --- a/tests/test_event_router.c +++ b/tests/test_event_router.c @@ -31,7 +31,7 @@ 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_NONE); + 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); diff --git a/tests/test_sys_fsm.c b/tests/test_sys_fsm.c index af20929..9181fc7 100644 --- a/tests/test_sys_fsm.c +++ b/tests/test_sys_fsm.c @@ -33,7 +33,7 @@ static app_input_t mk_inp(void) /* 标准 deep sleep 动作序列 (来自 main.c:1064-1102 旧实现) */ #define STANDARD_DEEP_SLEEP_ACTIONS \ - ACT_AUDIO_DEINIT, ACT_DISPLAY_OFF, ACT_GPIO_HOLD, ACT_TIMER_SET, ACT_DEEP_SLEEP + ACT_AUDIO_DEINIT, ACT_DISPLAY_OFF, ACT_GPIO_HOLD, ACT_TIMER_SET /* ── BOOT 转换 ────────────────────────────────────────────────────────── */ static void test_boot_done_to_normal(void) @@ -71,7 +71,7 @@ static void test_normal_btn_factory_reset(void) 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, ACT_DEEP_SLEEP); + EXPECT_ACTIONS(a, ACT_NVS_ERASE); } static void test_normal_prov_ok_reboot(void) @@ -80,7 +80,7 @@ static void test_normal_prov_ok_reboot(void) app_input_t inp = mk_inp(); fsm_actions_t a = sys_fsm_step(&s, SYS_EVT_PROV_OK, &inp); EXPECT(s == SYS_SLEEPING); - EXPECT_ACTIONS(a, ACT_DEEP_SLEEP); + EXPECT(a.count == 0); /* executor 负责 esp_restart,无 FSM 动作 */ } static void test_normal_prov_fail_stays(void) @@ -145,7 +145,7 @@ static void test_actions_count_invariant(void) /* 标准 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 == 5); /* 文档化的固定序列 */ + EXPECT(a.count == 4); /* AUDIO_DEINIT + DISPLAY_OFF + GPIO_HOLD + TIMER_SET */ } int main(void) From 4dba56b9777e33f540c0bcc79b220a8d60693deb Mon Sep 17 00:00:00 2001 From: zulin Date: Wed, 15 Jul 2026 01:05:21 +0800 Subject: [PATCH 17/19] FSM: boot init at correct location, 6KB stack, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes: - FSM init block restored at original location (after 'Running for...', before main loop, where stack has unwound from boot path) - main task stack 3584→6144 (CONFIG_MAIN_TASK_STACK_SIZE=6144) to accommodate FSM structs (.bss ~300B extra from 5 region + router) - build_context: wifi_creds_t + agent_config_t moved to static (.bss) to reduce per-call stack pressure the FSM init block injects EVT_WAKE_DETECT + EVT_BOOT_DONE before the main loop starts, properly initializing all 5 region states: wake: DORMANT→FROM_BTN/RTC/SYS sys: BOOT→NORMAL net: OFFLINE→CONNECTING (has_creds) or PROVISIONING (!has_creds) audio: gets AUTO_PLAY_REQUEST if RTC device verified: cold boot OK, left btn play/stop OK, right btn sleep/wakeup OK, no stack overflow. Co-Authored-By: Claude --- main/main.c | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/main/main.c b/main/main.c index b45ac0d..86c1bbe 100644 --- a/main/main.c +++ b/main/main.c @@ -83,9 +83,9 @@ static uint8_t s_alarm_ring_minutes = 0; static app_input_t build_context(void) { app_input_t inp = { 0 }; - wifi_creds_t creds; + static wifi_creds_t creds; /* .bss, 避免栈上分配 99B */ + static agent_config_t acfg; /* .bss, 避免栈上分配 ~70B */ inp.has_creds = (wifi_creds_load(&creds) == ESP_OK); - agent_config_t acfg; inp.agent_enabled = (agent_config_load(&acfg) == ESP_OK && acfg.enabled); #if CONFIG_PCF85063_ENABLE inp.weekend_skip = should_skip_alarm_today(); @@ -1145,14 +1145,15 @@ void app_main(void) ESP_LOGI(TAG, "Running for %d seconds before sleep, button wakes", CONFIG_ACTIVE_DURATION_SECS); - /* ── FSM 启动:注入两个一次性合成事件,把 region 从初始态推出来。 ── */ + /* ── FSM 启动注入:把 region 从初始态推出来。 ── */ { - app_input_t boot_inp = build_context(); - routed_events_t r = route_event(EVT_WAKE_DETECT, &s_state, &boot_inp); - fsm_actions_t a = wake_fsm_step((wake_state_t *)&s_state.wake, r.wake, &boot_inp); + 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); - /* BOOT_DONE 必须在 DETECT_SOURCE 之后,因为 router 依赖 s_state.wake - * 已经设置好,才能正确 fan-out(例如 RTC wake → audio AUTO_PLAY_REQUEST) */ 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); From 695648ba1d909833f41f05e42ab28b0b514ca227 Mon Sep 17 00:00:00 2001 From: zulin Date: Wed, 15 Jul 2026 01:10:43 +0800 Subject: [PATCH 18/19] docs: add FSM architecture section to README - 5 orthogonal FSM regions (wake/sys/net/audio/display) - event router fan-out diagram - executor action ordering - host-side test instructions - simplified mermaid startup flow with FSM injection point Co-Authored-By: Claude --- README.md | 132 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 83 insertions(+), 49 deletions(-) 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 ``` --- From 0f2d89c63ddddda33eced803a75d5f91beff52fc Mon Sep 17 00:00:00 2001 From: zulin Date: Wed, 15 Jul 2026 01:29:53 +0800 Subject: [PATCH 19/19] FSM: fix night toggle, skip auto-connect on cold boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - display_fsm BTN_TOGGLE now flips between day and night (matching old right_long_press_cb behavior), not 3-state cycle - FSM init sets display_fsm to DISP_NIGHT_AUTO when booting at night - FSM init no longer auto-connects net_fsm on cold boot; WiFi stays OFFLINE until user presses left button (IDLE→PENDING) - right_long_press_cb no longer calls clock_screen_set_night_override directly; FSM handles all display state changes Co-Authored-By: Claude --- main/main.c | 17 +++++------ main/regions/display_fsm.c | 61 ++++++++++++-------------------------- 2 files changed, 27 insertions(+), 51 deletions(-) diff --git a/main/main.c b/main/main.c index 86c1bbe..9b4f02a 100644 --- a/main/main.c +++ b/main/main.c @@ -619,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); } @@ -1159,10 +1155,13 @@ void app_main(void) apply_actions(&a); a = sys_fsm_step((sys_state_t *)&s_state.sys, r.sys, &boot_inp); apply_actions(&a); - a = net_fsm_step((net_state_t *)&s_state.net, r.net, &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); } diff --git a/main/regions/display_fsm.c b/main/regions/display_fsm.c index 4e97870..1e55744 100644 --- a/main/regions/display_fsm.c +++ b/main/regions/display_fsm.c @@ -1,16 +1,9 @@ /* * display_fsm.c — 日/夜显示 FSM。 * - * 状态机:DAY <-> NIGHT_AUTO, NIGHT_FORCED 由用户强制覆盖 - * - * TICK_1HZ 检测当前时间是否穿越 NIGHT_START_HOUR / NIGHT_END_HOUR - * (executor 调用 clock_screen_is_night_time() 后把结果写到 inp->night_now) - * - * BTN_TOGGLE (右键长按) 在三态间循环: - * DAY -> NIGHT_FORCED (强制夜间) - * NIGHT_AUTO -> NIGHT_FORCED (强制夜间覆盖 auto) - * NIGHT_FORCED -> DAY (原 auto) / NIGHT_AUTO (原 auto,夜间时段) - * 决策依据:再次检查 inp->night_now + * BTN_TOGGLE (右键长按) 简单 flip,匹配旧 right_long_press_cb 行为: + * 任何状态 → 取反 inp->night_now → 强制白天或强制夜间 + * TICK_1HZ 自动检测时间穿越,从 DISP_DAY 进夜或从 DISP_NIGHT 出夜。 */ #include "display_fsm.h" @@ -36,73 +29,57 @@ static fsm_actions_t add_night_mode(fsm_actions_t a, bool on) return a; } -static fsm_actions_t add_night_override(fsm_actions_t a, int8_t override) +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 = 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 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; - } + 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); /* +1 = 强制夜间 */ - /* 注意:同时也要切到夜间显示。override 标志由 clock_screen - * 自己读,我们只更新 override 值。 */ + out = add_night_override(out, 1); out = add_night_mode(out, true); } - /* AGENT_OFF → identity (informational) */ 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) { - *cur = DISP_NIGHT_FORCED; - out = add_night_override(out, 1); - out = add_night_mode(out, true); + /* 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) { - /* 解除强制:回 DAY 或 NIGHT_AUTO 由当前时间决定 */ - if (inp->night_now) { - *cur = DISP_NIGHT_AUTO; - out = add_night_override(out, -1); /* -1 = 自动 */ - } else { - *cur = DISP_DAY; - out = add_night_override(out, 0); /* 0 = 强制白天 */ - } - /* night_mode 关闭 (强制白天时) 或保持 (回 auto) */ - if (!inp->night_now) { - out = add_night_mode(out, false); - } else { - out = add_night_mode(out, true); - } + /* flip 到对面:强制夜间 → 白天(不回到 auto) */ + *cur = DISP_DAY; + out = add_night_override(out, 0); + out = add_night_mode(out, false); } - /* TICK_1HZ → identity (强制模式不响应时间穿越,等用户取消) */ + /* TICK_1HZ → identity (强制模式不响应时间穿越) */ break; }