FSM: 5-region orthogonal state machine refactor#17
Draft
llinzzi wants to merge 19 commits into
Draft
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replace the implicit-globals state machine in
main/main.cwith 5 explicit, testable orthogonal FSM regions + event router + action executor.Architecture
Changes
main/app_fsm.h,main/event_router.{h,c},main/regions/*_fsm.{h,c}(13 files)main/main.c(FSM-driven main loop),main/audio_player_wrapper.{c,h}(FSM event queue),main/wifi.c(WiFi event queue),tests/Makefile,main/CMakeLists.txtsdkconfigmain task stack 3584→6144Bugs fixed
Tests
Verification
🤖 Generated with Claude Code