feat(idf): ESP-NOW scenario hot-load + IDF migration #1

Merged
electron merged 19 commits from feat/idf-migration into main 2026-06-09 05:45:21 +00:00
Owner

Automated PR (Claude). Builds green under ESP-IDF 5.4.4.

Automated PR (Claude). Builds green under ESP-IDF 5.4.4.
electron added 19 commits 2026-06-09 05:44:44 +00:00
P1 first slice of the voice pipeline migration (spec
docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md).

Adds idf_zacus/ alongside the Arduino tree (ui_freenove_allinone/
keeps building unchanged). The scaffold targets ESP32-S3, octal
PSRAM 80 MHz, custom partition table with OTA + 2 MB LittleFS.

main/main.c boots, initializes NVS, mounts LittleFS on /littlefs,
logs heap stats (internal + PSRAM), and idles with a 60 s
heartbeat. The orphaned 2026-04-03 ota_server component
(HTTP :80, rate-limited OTA upload, 30 s rollback watchdog) is
folded in under components/ota_server/ and its header is fixed
to include esp_http_server.h (matches the .c, port 80 = HTTP).

Not yet wired: ota_server_init() boot, Wi-Fi STA, voice/NPC
modules. Stubs for puzzle_get_battery_pct() and
puzzle_get_espnow_peer_count() let the OTA component link.
- main.c: NVS-driven Wi-Fi STA bring-up with AP fallback, ota_server_init after IP

- main/CMakeLists: REQUIRES esp_wifi/esp_netif/esp_event, fix littlefs name (joltwallet__littlefs)

- sdkconfig.defaults: Wi-Fi RX/TX buffers, AMPDU, NVS, hostname

- ota_server: rename esp_ota_ops -> app_update, replace HTTPD_429/409 (absent in IDF 5.4) by HTTPD_400
voice_pipeline: I2S capture stub (i2s_std driver), state machine. hints_client: separate component (avoids cycle), POST /hints/ask via esp_http_client + cJSON, sync + async wrappers. npc_engine.request_hint now routes through hints_client when init'd, falls back to local stub otherwise. Build green: 876 KB app, 44% free.
Slice 6 of the IDF migration: bring up the Espressif AFE + WakeNet9
pipeline on top of the existing slice-5 I2S capture stub. The active
wake word is the standard wn9_hiesp ("Hi ESP") model — custom
"Professeur Zacus" needs an Espressif training round-trip and is
tracked under P4 of the voice spec. The capture task feeds AFE,
fetches results, fires a user callback on WAKENET_DETECTED and
auto-transitions to VOICE_STATE_LISTENING. If esp-sr fails to init
(model partition missing, PSRAM exhausted, AFE alloc OOM) the
pipeline falls back to the slice-5 stub so the rest of the firmware
still boots.

- main/idf_component.yml: managed dep on espressif/esp-sr ~2.0
- partitions.csv: new 1 MB SPIFFS "model" partition for srmodels.bin
- sdkconfig.defaults: enable wn9_hiesp; disable MultiNet (off-device)
- voice_pipeline.h: new wake callback API + enable_wake_word config
- main.c: enable wake word, log + restart capture from callback

Built green on ESP-IDF 5.4 with esp-sr 2.0.5. App size 1.27 MB
(18% free in 1.5 MB factory partition); srmodels.bin 284 KB
(72% free in 1 MB model partition). No partition table grow needed
this slice.
voice_pipeline slice 9: I2S_NUM_1 TX channel for TTS playback, voice_pipeline_play_start/chunk/end APIs (clock reconfig 16k mic vs 24k F5), mute-during-TTS gate in capture_task (state==SPEAKING skips AFE feed to prevent echo retriggering wake). enable_tts_playback opt-in default false. Build green: 1.45 MB app, 8% free.
Context:
The PLIP retro-telephone annex (Si3210 SLIC + RJ9 combine) needs a
way to drive the master ESP32 voice pipeline directly from the hook
switch, so the escape-room narrative "le telephone sonne, decroche
pour parler a Zacus" works without relying on the wake-word.

Approach:
- Expose the existing ota_server httpd_handle_t via a new getter so a
  second component can register routes on the same listener (port 80)
  instead of standing up a parallel httpd.
- New voice_hook_endpoint component attaches POST /voice/hook +
  GET /voice/hook/state to that shared handle. Off-hook arms the
  pipeline (LISTENING + capture + WS streaming, bypassing wake-word);
  on-hook tears it down (stop streaming + stop capture + IDLE).
- Enable enable_tts_playback so TTS frames received over the bridge
  WS land on the MAX98357A DAC. Wake-word stays enabled (mixed mode:
  hook is primary, "hi esp" is fallback).

Changes:
- ota_server: add ota_server_get_handle() returning the static
  httpd_handle_t for late URI handler registration.
- voice_hook_endpoint: new component (CMakeLists, header, .c, README).
  POST handler validates body (<=256 B), parses cJSON, dispatches
  state on/off (idempotent), returns JSON status. GET state handler
  exposes voice_state + wake_word_active + streaming for debugging.
  Explicit GET on /voice/hook returns 405 with Allow: POST so PLIP
  integrators see an actionable error instead of a 404.
- main.c: include header, init voice_hook_endpoint after ota_server
  succeeds, flip voice_cfg.enable_tts_playback = true.
- main/CMakeLists.txt: add voice_hook_endpoint to REQUIRES.
- README.md: document the wire protocol PLIP must call (state values,
  response codes, curl examples, mixed-mode behaviour).

Impact:
- PLIP firmware can now drive the Zacus voice loop over Wi-Fi via
  REST without any code change on the master side beyond this slice.
- TTS replies from the MacStudio voice-bridge are audible on the
  master speaker (slice 9 shipped the API; slice 10 turns it on).
- App size 0x1639a0 (1.39 MB) in 0x180000 partition — 7% headroom,
  no partitions.csv resize needed.
Slice 9b: parse {type:speak_start,sample_rate,format} and {type:speak_end,duration_ms,backend,latency_ms} from voice-bridge, route binary frames to voice_pipeline_play_chunk during the speak window. State machine transitions LISTENING -> SPEAKING -> IDLE drive the mute gate added in slice 9. End-to-end TTS playback now works: ESP32 wake -> stream -> /voice/ws -> whisper -> intent -> F5-TTS PCM stream -> I2S out.
Bump factory + ota_0 + ota_1 from 0x180000 to 0x200000 (2 MB each)
to give voice_pipeline / STT / hints_client / esp-sr slices ~25% free
margin (was 7%). OTA dual-bank rollback symmetry preserved (all three
app slots same size). Storage (LittleFS) grows from 2 MB to 5 MB to
host MP3 cue pool; model (esp-sr SPIFFS) stays at 1 MB. Layout uses
12.1 MB of the 16 MB flash, leaves 3.87 MB unused for future growth.

idf.py build reports zacus_master.bin = 0x1639a0 (1.39 MB), 31% free
in 0x200000 partition. Reordered model before storage in CSV so auto
offsets cascade as planned (model at 0x620000, storage at 0x720000).

No sdkconfig change. Migration only requires erasing flash on first
deploy because partition table changed.
hints_client gains puzzle_start, attempt_failed, set_group_profile (whitelist TECH/NON_TECH/MIXED/BOTH default MIXED). npc_engine.set_step now auto-fires hints_client_puzzle_start; new helpers current_puzzle_id and report_failed_attempt. voice_dispatcher detects failure keywords (non/faux/mauvais/rate/marche pas) and bumps the failed-attempt counter before the hint fast-path. main.c reads NVS zacus/group_profile at boot. Build green: 1.40 MB / 2 MB partition (30% free).
game_endpoint gains a /game/scenario handler that accepts a Runtime 3
IR JSON (<=64 KiB), validates schema_version + non-empty steps array,
rotates the existing scenario to .bak, writes to LittleFS at
/littlefs/scenario.json on partition 'storage', and reboots after
the HTTP response flushes so the engine picks up the new IR.

ota_server bumps max_uri_handlers 8 -> 16 to fit the extra handlers
(/game/scenario plus headroom).

sdkconfig.qemu disables PSRAM + esp-sr so the firmware boots inside
qemu-system-xtensa without crashing on the unemulated octal PSRAM.
QEMU_HOWTO.md documents the workflow and the WiFi/Ethernet gap that
still blocks full HTTP smoke under emulation.
Mirrors the master's hot-load endpoint on the BOX-3 with a
self-contained httpd (no shared component) since box3_voice is
a separate IDF project. Storage uses the existing SPIFFS
partition; behaviour matches the master (validate, .bak rotate,
write, deferred restart).

main.c hooks scenario_server_start() after wifi_init_sta(); the
http server binds once the STA gets an IP.

Move idf_component.yml to main/ so the IDF Component Manager
actually fetches the managed dependencies. Pin button to 2.5.x
and esp_codec_dev to 1.2.0 to stay compatible with esp-box 1.x
legacy i2c. Set CONFIG_I2C_ENABLE_LEGACY_DRIVERS=y for the same
reason; a residual upstream BSP conflict still aborts boot on
hardware (esp-box 1.x vs driver_ng) - tracked separately.

sdkconfig.defaults documents that WiFi credentials must be set
via menuconfig and never committed.
- scenario_mesh: ESP-NOW frame protocol component (master + box3_voice):
  chunking, per-source reassembly, deferred apply off the Wi-Fi callback.
- game_endpoint: POST /game/scenario/relay (master) + shared scenario_apply.
- box3_voice: scenario receiver wiring (scenario_mesh_init).
- espnow_slave (shared by all puzzles): demux scenario frames inside the single
  recv callback so a misrouted relay can't corrupt the MSG_* stream; reassemble
  per source MAC; optional consumer hook (logs+drops by default, puzzles have no
  scenario engine). Add missing esp_mac.h include for MACSTR/MAC2STR.
- CMakeLists (scenario_mesh + 4 puzzle mains): drop invalid `esp_now` REQUIRES;
  the ESP-NOW API lives in esp_wifi. Fixes "Failed to resolve component esp_now".
- docs: scenario-mesh receiver patch note (puzzles done via defensive demux;
  PLIP out of scope — Wi-Fi/HTTP-only, no ESP-NOW stack).

Builds green under ESP-IDF 5.4.4: idf_zacus, box3_voice, p7_coffre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
electron merged commit 17e575ee0e into main 2026-06-09 05:45:21 +00:00
Sign in to join this conversation.