IDF5 i2s_channel_init_std_mode() constitutes full-duplex ONLY when TX and
RX std_cfg are byte-for-byte identical (memcmp). When din/dout differ
between the two calls, the driver silently moves RX to I2S_NUM_1 which has
no BCLK/WS routing, producing permanent zeros on i2s_channel_read().
Fix: use the same i2s_std_config_t for both TX and RX init calls, with
dout=GPIO26 and din=GPIO35 both set. IDF handles GPIO direction internally.
Also clean up ES8388 register sequence:
- ADCCONTROL2 = 0x00 (LIN1/RIN1 differential, LINE IN header)
- ADCCONTROL6 = 0x00 (clear ADCSMUTE, reset default was 0x30)
- ADCPOWER = 0x00 (full ADC power-up, was 0x09)
- DACCONTROL21 = 0x80 (DAC+ADC normal mode, not line bypass 0xC0)
Verified: peak=2593, rms=2482 over 48000 samples (3s @ 16kHz).
ADCCONTROL6 (reg 0x0E) reset default = 0x30 which has ADCSMUTE=1 (bit5) — ADC output
muted by default. Writing 0x00 unmutes. Without this, ADCINSEL=0x50 (LIN2) is selected
but the signal is suppressed at the ADC output stage → peak=0.
ADCPOWER (reg 0x03) changed from 0x09 (intermediate Espressif open state) to 0x00 (full
power-up: all power-down bits cleared). Value 0x09 = bits 0+3 set (ADCPD_L + MICBIAS_PD)
— MICBIAS_PD in particular means the internal microphone bias is powered off, which can
starve the SLIC line-in path. 0x00 is the correct end state for recording mode per
Espressif esp8388_start(ES_MODULE_ADC) reference.
sdkconfig.defaults: add CONFIG_PLIP_HOOK_GPIO=23 / CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
as explicit defaults so clean builds use the SLIC SHK pin without menuconfig.
ADCCONTROL2 (reg 0x0A): was 0x00 (LINSEL=00 LINPUT1/RINPUT1 — onboard PCB mic),
now 0x50 (LINSEL=01 LINPUT2 / RINSEL=01 RINPUT2 — telephone handset mic via SLIC).
The SLIC K50835F routes the handset mic signal to the ES8388 LINPUT2/RINPUT2 pins.
Writing 0x00 meant the ADC was listening to the empty onboard mic, producing 0/0 RMS.
Writing 0x50 connects the SLIC audio path, enabling voice capture from the handset.
PGA stays at +24 dB (ADCCONTROL1 = 0xBB). ADC power-up sequence unchanged.
- board_config.h: add PLIP_SLIC_RM=18, FR=5, SHK=23, PD=19 (A1S KEY3-6 repurposed)
- slic.c/slic.h: new ESP-IDF module porting Ks0835SlicController:
* slic_init(): RM/FR output LOW, SHK input+pullup, PD open-drain HIGH (power-up)
* slic_is_offhook(): reads SHK GPIO23, HIGH = off-hook (active-high, matches A252ConfigStore default)
* slic_ring_start/stop(): RM HIGH + FreeRTOS task toggles FR at 25 Hz (20 ms period)
- CMakeLists.txt: add slic.c to SRCS, esp_driver_gpio to PRIV_REQUIRES
- Kconfig: PLIP_HOOK_GPIO default 4→23, add PLIP_HOOK_ACTIVE_HIGH (default y)
- phone.c: hook reads SHK GPIO23 via HOOK_OFFHOOK_LEVEL/HOOK_PULSE_OPEN macros (active-HIGH);
phone_ring_start/stop() now drives slic_ring_start/stop() for physical bell + audio tone
- main.c: slic_init() called early in boot_task before audio_init
Root cause fixed: SLIC was never powered (PD never released from reset state).
Hook was read on wrong GPIO (4) with wrong polarity. Ring drove only audio, not bell.
Objective 1 — screen: full-scene faithful rendering on 320x240 LVGL display.
Palette matches idf_zacus display_ui reference: bg #0055AA (Workbench blue),
symbol #FF8800 (orange / font-48), title #FFFFFF (font-24 top), subtitle
#AAAAAA (font-14 bottom). All four effects implemented:
- pulse: LVGL anim opacity COVER→50 breathing on symbol (600ms half-period)
- glitch: lv_timer 120ms flicker + X-jitter on title
- gyro: lv_arc rotating ring around symbol (1200ms full rotation)
- none: static
Objective 2 — play: real WAV PCM-16 decode + I2S streaming.
- SD card mounted best-effort via bsp_sdcard_mount() on first play CMD
- WAV header parser (chunk-walker, tolerant of non-standard ordering)
- PCM samples streamed via i2s_channel_write (same s_spk_handle as TTS)
- Embedded test cue (C5-E5-G5, 570ms, 16kHz mono, ~18 KB) baked into
embedded_wav.h — proves real WAV decode + I2S path without SD card
- Graceful fallback chain: SD file → embedded cue → 880 Hz beep
main.c: cmd_exec_set_spk_handle() called after speaker_init() to pass the
I2S TX channel handle to the CMD executor.
Tested on ESP32-S3-BOX-3 (/dev/cu.usbmodem11301):
- All four screen effects confirmed via serial log + visual observation
- play embedded://cue streams 18240 bytes PCM at 16kHz confirmed
- SD mount succeeds (card present), file not found → embedded fallback OK
- No crashes, existing voice pipeline and scenario server unaffected
Add cmd_exec.c / cmd_exec.h providing cmd_exec_handle() — a tolerant
cJSON-based dispatcher for structured ESP-NOW CMD frames per the D5
wire contract ({"op":"<verb>","a":{<args>}}, ≤200 bytes).
Supported ops (all logged):
screen — renders title/subtitle/symbol on an LVGL full-screen
overlay via bsp_display_lock; "pulse"/"flash" effect
tints the background blue.
play — logs the path + loop flag, emits an 880 Hz beep via
audio_play_tone() (no media_manager on box3_voice).
evt — logs event name (card→master direction, unusual here).
led — logs pattern (stub, no LED ring on box3_voice).
unknown — ESP_LOGW + ESP_ERR_NOT_SUPPORTED, no crash.
Wire-up in main.c:
on_mesh_text() callback installed via scenario_mesh_set_text_cb()
immediately after scenario_mesh_init(). Every SCENARIO_MESH_TEXT_CMD
frame is decoded and dispatched; EVT frames are logged and ignored.
cmd_exec.c added to CMakeLists.txt SRCS.
Channel fix in sdkconfig.defaults:
Document that CONFIG_ZACUS_WIFI_CHANNEL must match the master's
connected WiFi channel (lab: ch11) for ESP-NOW co-channel operation.
Tested on hardware (BOX-3 MAC 90:e5:b1:cc:05:f8, /dev/cu.usbmodem11301):
- Build: 2104/2104 targets, 0 errors, 0 warnings on new files.
- Flash: idf.py -p /dev/cu.usbmodem11301 flash — Done.
- Serial proof:
I (10115) zacus-voice: ESP-NOW CMD executor registered (op: screen/play/evt/led)
I (10115) cmd_exec: CMD op=screen t="ZACUS" u="" y="LA" e="pulse"
I (13327) cmd_exec: CMD op=play path="/spiffs/ambiance.wav" loop=0 (beep fallback)
W (16497) cmd_exec: CMD op="explode" unknown — ignored
All three via POST /game/espnow/cmd on master ({"ok":true,"status":"ESP_OK"}).
- voice_pipeline : mutex play_lock pris dans play_start / rendu dans play_end
(+ tous chemins d'erreur). Sérialise TTS et musique sur le canal I2S TX
partagé ; un play_start concurrent attend 2 s puis renvoie BUSY.
- media_manager : décodage MP3 via helix (chmorgan/esp-libhelix-mp3). Refactor
playback_task en dispatcher d'extension → stream_wav / stream_mp3. stream_mp3
décode frame par frame (buffers heap), downmix stéréo→mono, volume, feed I2S.
Stack tâche 5120→8192 (le décodeur consomme de la pile).
- sdkconfig.defaults : CONFIG_FATFS_LFN_HEAP — les assets SD ont des noms > 8.3
(SCENE_WIN.mp3, sonar_hint.mp3…) ; sans LFN fopen échoue ("open failed").
Validé sur Freenove : upload SCENE_WIN.mp3 (510 Ko, stéréo) sur /sdcard →
play → helix décode 22050 Hz 2ch → downmix → I2S MAX98357A. win.mp3 22050 mono
idem. Mutex : TTS et musique ne peuvent plus se chevaucher sur le DAC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- media_manager resolve_play_path : un chemin relatif préfère désormais
/sdcard/music/<f> quand il existe (asset store SD), fallback LittleFS.
Les chemins absolus (/sdcard/... | /littlefs/...) passent inchangés.
- /game/file : route le préfixe `sd/<…>` → /sdcard/<…> (≤8 Mio, exige la
carte montée) en plus de `apps/<…>` → /littlefs/apps/ (≤256 Kio).
- sd_storage.h : include <stdint.h> (uint32_t).
Validé sur Freenove : POST /game/file?path=sd/music/cue440.wav → écrit sur
la microSD ; POST /game/media/play {path:"cue440.wav"} → résolu en
/sdcard/music/cue440.wav, 32000 B streamés en ~1005 ms au MAX98357A.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Nouveau composant sd_storage : monte la carte microSD du Freenove en SDMMC
1-bit (CMD=38, CLK=39, D0=40 — pinout FREENOVE_SDMMC_* du firmware Arduino)
sur /sdcard via esp_vfs_fat_sdmmc_mount. Best-effort au boot : carte absente
= non fatal, fallback LittleFS. Lève le plafond LittleFS 5 Mo pour les assets.
Validé sur Freenove : carte ~3839 MiB montée à /sdcard au boot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remplace le stub de media_manager_play() (qui simulait 2 s) par un vrai
streaming audio :
- parse_wav_header : RIFF/WAVE 16-bit PCM mono/stéréo, positionne sur data.
- playback_task : lit le WAV par chunks et le pousse au MAX98357A via
l'I2S TX de voice_pipeline (play_start/chunk/end), avec downmix stéréo→mono
et volume logiciel. Tâche dédiée (non bloquante), stop coopératif.
- media_manager_stop() attend la fin de la tâche.
Ajoute POST /game/media/play {path} dans game_endpoint pour déclencher une
lecture (gateway/atelier → cue audio), sans passer par une décision NPC.
Validé sur Freenove : upload WAV via /game/file → POST /game/media/play →
play_start sr=16000 → 32000 B streamés en ~1006 ms (cadence DMA temps réel,
= durée du fichier) → play_end. MP3 + montage SD = slices P4 suivantes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scenario_server.c : SPIFFS_LABEL valait "storage" alors que la partition
s'appelle "spiffs" (partitions.csv) → esp_vfs_spiffs_register échouait en
ESP_ERR_NOT_FOUND, la BOX-3 ne pouvait persister aucun scénario reçu (HTTP
500 + relais ESP-NOW sans effet). Corrigé en "spiffs".
main.c + Kconfig.projbuild : ajout de CONFIG_ZACUS_WIFI_CHANNEL (défaut
0 = auto) câblé sur wifi_config.sta.channel. Sur réseau multi-AP / mesh
(même SSID sur plusieurs canaux), permet de forcer la BOX-3 co-canal avec
le master — prérequis ESP-NOW.
Validé sur matériel : master (ch6) → POST /game/scenario/relay → la BOX-3
reçoit + réassemble (679 B) via scenario_mesh, monte SPIFFS, hot-load OK,
reboot pour appliquer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frames courtes commandes/evenements sur la pile scenario_mesh
(sentinel seq=0xFFFF + kind, retrocompatible : les anciens
recepteurs les jettent via le check malformed). API additive
send_text/set_text_cb (dispatch hors callback WiFi via tache
dediee). Master : POST /game/espnow/cmd {peer|broadcast,command}
+ log des CMD/EVT recus. Teste sur FNK0102H : broadcast ESP_OK,
unicast sans recepteur ESP_ERR_TIMEOUT, alias inconnu 404.
Spec : docs/superpowers/specs/2026-06-11-espnow-cmd-evt-design.md
scenario_mesh_peer_count() cable sur le stub sante (espnow_peers
restait a 0) ; reponse POST /game/peers construite avant
cJSON_Delete (use-after-free observe sur carte : alias illisible).
Teste sur FNK0102H : register live + NVS recharge au boot
(espnow_peers=2), relay -> timeout/unknown_peer attendus.
Sur carte, la boucle display (vTaskDelay 10 ms + rendu LVGL)
tourne sous 100 Hz : le dt fixe de 10 ms faisait trainer
l'horloge virtuelle derriere la deadline murale et la phase FX
ne demarrait jamais. Mesure du dt reel par delta de ticks
(borne 100 ms) + log de l'ordre des FX tires. Verifie sur
FNK0102H : intro fx order=0,2,3 a t=9.6s, 35 s sans panic.
Ajout des 3 dernieres scenes 3D de l'original : starfield 3D
(vol en z + trainees), voxel landscape (heightfield raycast) et
wirecube v9 (cube filaire Bresenham). L'intro tire 3 effets
distincts au hasard parmi les 6 a chaque boot (Fisher-Yates),
sans rallonger les 16 s. Build vert.
Trace serie du bout-en-bout melodie : note MIDI entendue,
progression pos/count du validateur, marqueur (reset) sur fausse
note, ligne dediee a la completion. Prerequis documente au test
physique P1.