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.
Rotozoom, dot sphere et ray corridor portes depuis le FxEngine
Arduino (boucles de rendu seules, sans CapsAllocator ni timelines).
Rendu 240x160 RGB565 en PSRAM double en pixels vers un lv_canvas
plein ecran derriere logo+scroller ; phases A 7s puis 3x3s ;
fallback starfield si l'alloc PSRAM echoue. Build vert (35% libre).