Skip to content

FSM: 5-region orthogonal state machine refactor#17

Draft
llinzzi wants to merge 19 commits into
mainfrom
worktree-slumbercube-fsm-refactor
Draft

FSM: 5-region orthogonal state machine refactor#17
llinzzi wants to merge 19 commits into
mainfrom
worktree-slumbercube-fsm-refactor

Conversation

@llinzzi

@llinzzi llinzzi commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

Replace the implicit-globals state machine in main/main.c with 5 explicit, testable orthogonal FSM regions + event router + action executor.

Architecture

main loop (1Hz tick)
  → route_event() dispatches to 5 regions
  → each region's step() returns actions
  → apply_actions() executes in fixed order: wake → sys → net → audio → display
Region States Role
wake_fsm DORMANT/FROM_BTN/FROM_RTC/FROM_SYS/ALARM_RINGING/GOTO_SLEEP Wake source & alarm lifecycle
sys_fsm BOOT/NORMAL/SLEEPING Boot, sleep, factory reset
net_fsm OFFLINE/PROVISIONING/CONNECTING/CONNECTED/FAILED WiFi & provisioning
audio_fsm IDLE/PENDING/INIT/PLAYING/STOPPING/ERROR Audio playback
display_fsm DAY/NIGHT_AUTO/NIGHT_FORCED Day/night display toggle

Changes

  • New: main/app_fsm.h, main/event_router.{h,c}, main/regions/*_fsm.{h,c} (13 files)
  • Modified: 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.txt
  • Config: sdkconfig main task stack 3584→6144

Bugs fixed

  • Audio indicator lights up BEFORE playback actually starts (PENDING state)
  • stall_ticks carries stale value across tracks (removed stall mechanism)
  • Buttons unresponsive after audio stop (STOP_DONE transition)

Tests

make -C tests test   # 8/8 suites, 109 cases, links production .c directly

Verification

  • Cold boot: display shows, no auto-WiFi
  • Left button: play/stop audio (WiFi connects on demand)
  • Right button: sleep/wakeup, long-press night toggle
  • Track auto-advance on natural completion

🤖 Generated with Claude Code

zulin and others added 19 commits July 12, 2026 21:14
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant