diff --git a/CLAUDE.md b/CLAUDE.md index ad956c9..61c2fbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,9 @@ YAML scenario → compile_runtime3.py → Runtime 3 IR → ESP32 / Web player Key surfaces: - **Scenario IR**: `game/scenarios/zacus_v2.yaml` → `tools/scenario/compile_runtime3.py` → portable Runtime 3 IR. Contract: `specs/ZACUS_RUNTIME_3_SPEC.md`. - **Authoring**: `frontend-v3/` (pnpm monorepo: `apps/atelier/` Scratch-like studio + `apps/dashboard/` game-master live view). -- **Firmware**: `ESP32_ZACUS/` submodule (separate repo, separate CI). Freenove ESP32-S3 + PlatformIO. NPC engine, voice pipeline, vision/QR, media manager. +- **Firmware**: + - `ESP32_ZACUS/` submodule — Freenove ESP32-S3 master (NPC engine, voice pipeline, vision/QR, media manager). + - `PLIP_FIRMWARE/` — retro telephone annex (ES8388 dev kit bringup, Si3210 PCB target). REST endpoint consumed by the master ESP32. - **Voice / NPC**: Piper TTS on Tower:8001 (zacus voice = tom-medium). NPC phrases in `game/scenarios/npc_phrases.yaml`. MP3 pool generator: `tools/tts/generate_npc_pool.py`. - **MCP hardware**: `tools/dev/mcp_hardware_server.py` (stdio, 6 tools). - **Desktop hub**: `desktop/` (Electron, macOS, bundles V3 frontends, talks USB serial). @@ -56,7 +58,8 @@ Key surfaces: | Zacus Studio macOS app | `desktop/` | | Add or change a contract spec | `specs/` | | Python tests (Runtime 3, NPC) | `tests/` | -| Firmware code | `ESP32_ZACUS/` submodule (own repo) | +| Firmware code (Zacus master) | `ESP32_ZACUS/` submodule (own repo) | +| Firmware code (PLIP retro phone) | `PLIP_FIRMWARE/` (inlined; see README for submodule conversion) | ## Canonical Files diff --git a/PLIP_FIRMWARE/.gitignore b/PLIP_FIRMWARE/.gitignore new file mode 100644 index 0000000..a1dc0f1 --- /dev/null +++ b/PLIP_FIRMWARE/.gitignore @@ -0,0 +1,7 @@ +.pio/ +.vscode/ +.DS_Store +*.elf +*.bin +*.hex +build/ diff --git a/PLIP_FIRMWARE/CLAUDE.md b/PLIP_FIRMWARE/CLAUDE.md new file mode 100644 index 0000000..f87adcf --- /dev/null +++ b/PLIP_FIRMWARE/CLAUDE.md @@ -0,0 +1,36 @@ +# PLIP Firmware + +ESP32 firmware for the retro telephone annex. Bringup target: ESP32-A1S +Audio Kit (ES8388 codec). End target: custom PCB with Si3210 SLIC. + +See `README.md` for architecture, REST contract, pin map, and the +submodule conversion procedure when a remote URL is ready. + +## Build + +```bash +cd PLIP_FIRMWARE +pio run # compile +pio run -t upload # flash via USB +pio device monitor # serial @ 115200 +``` + +## Source layout + +| File | Role | +|------|------| +| `src/main.cpp` | `setup()` boots the three FreeRTOS tasks | +| `src/phone_task.cpp` | Off-hook GPIO interrupt + ring control | +| `src/audio_task.cpp` | ES8388 / Si3210 audio routing, drains a command queue | +| `src/network_task.cpp` | WiFi station + REST server (`/ring`, `/play`, `/stop`, `/status`) | + +## Anti-patterns + +- Hardcoding pin numbers in source — they live in `platformio.ini` + `build_flags` so the dev kit ↔ PCB swap stays mechanical. +- Adding I2S/SPI calls outside `audio_task` or `phone_task` — codec + access must stay single-threaded per peripheral. +- Skipping NVS for WiFi credentials in committed code. Compile-time + defaults are OK for local bringup but never merge them. +- Implementing OTA before the bringup loop (REST `/ring` round-trip) is + rock solid — chasing OTA bugs on top of audio bugs is misery. diff --git a/PLIP_FIRMWARE/README.md b/PLIP_FIRMWARE/README.md new file mode 100644 index 0000000..cba95a1 --- /dev/null +++ b/PLIP_FIRMWARE/README.md @@ -0,0 +1,120 @@ +# PLIP firmware + +Bringup target: **AI-Thinker ESP32-A1S Audio Kit V2.2** (ES8388 codec). +End target: the custom **PLIP-Téléphone** PCB with Si3210 SLIC + RJ9 retro +handset (see `../hardware/projects/plip-telephone/` and +`../docs/superpowers/specs/2026-04-08-plip-telephone-design.md`). + +We start on the dev kit so the audio + network state machine can be +debugged without waiting for the PCB. The codec is swapped (ES8388 → +Si3210) on the PCB; everything else stays. + +## Architecture + +Three FreeRTOS tasks, message-passing through a queue: + +| Task | Core | Stack | Role | +|------|------|-------|------| +| `phone` | 1 | 4096 | Off-hook GPIO interrupt (dev kit: BOOT button on GPIO4; PCB: Si3210 INT). Drives ring on the codec. | +| `audio` | 0 | 8192 | Drains `audio_command_queue`. Routes `Stop` / `PlaySdMp3` / `PlayHttpStream` through `ESP32-audioI2S`. | +| `network` | 0 | 8192 | WiFi station + REST endpoint. Translates HTTP requests into audio queue messages. | + +## REST contract + +The Zacus master ESP32 talks to PLIP over the LAN: + +| Method | Path | Body | Effect | +|--------|------|------|--------| +| POST | `/ring` | `{"duration_ms": 4000}` | Trigger ring (SLIC -72V; dev kit: short audio beep) | +| POST | `/play` | `{"source": "sd:/intro.mp3"}` or `{"source": "http://tower:8001/zacus/welcome.wav"}` | Start playback | +| POST | `/stop` | — | Stop current playback | +| GET | `/status` | — | `{"off_hook": bool, "playing": bool}` | + +The Piper TTS server on Tower:8001 streams WAV; the audio task consumes +it directly via the I2S library's `connecttohost`. + +## Build + +```bash +cd PLIP_FIRMWARE +pio run # compile +pio run -t upload # flash over USB +pio device monitor # serial monitor (115200 8N1) +``` + +PlatformIO will pull `espressif32@6.5.0` + Arduino framework + the audio +library on first build (~3 min). + +## Pin map (dev kit) + +Defined in `platformio.ini` build flags: + +``` +ES8388_ADDR = 0x10 (I2C) +IIS_BCLK = 27 (I2S bit clock) +IIS_LCLK = 26 (I2S word select) +IIS_DSIN = 25 (DAC in) +IIS_DOUT = 35 (ADC out) +SD_CS = 13 +SD_MOSI = 15 +SD_MISO = 2 +SD_SCK = 14 +OFF_HOOK_GPIO = 4 (BOOT/KEY1 stand-in) +``` + +When the PCB lands: rewire to Si3210 SPI + INT, swap the codec init in +`audio_task`, ring goes from "ES8388 beep" to "Si3210 ring control +register". The build_flags file is the only place pins live; source +code reads them via macros. + +## MVP scope (Phase 3 = b in the brainstorm) + +- Sonnerie (ring) on demand +- Off-hook GPIO interrupt → notify master +- MP3 playback from SD +- WiFi REST endpoint (4 routes above) + +**Out of scope for v1**: + +- Bluetooth Classic (A2DP sink + HFP) — the design doc specs it but the + bringup focus is REST + SD first. +- Si3210 driver — comes when the PCB arrives. +- OTA via the desktop FirmwareManager — comes after the bringup loop is + stable. + +## Roadmap + +| Step | Goal | Status | +|------|------|--------| +| 0 | Skeleton (this commit) — tasks fire `Serial.println` only | ✅ | +| 1 | I2C + ES8388 init in audio task; play a 1 kHz tone on demand | TODO | +| 2 | SD card mount + MP3 file playback | TODO | +| 3 | WiFi station + REST `/ring` `/play` `/stop` `/status` | TODO | +| 4 | Off-hook button → REST status update | TODO | +| 5 | HTTP stream from Tower:8001 Piper TTS | TODO | +| 6 | NVS provisioning via desktop NvsConfigurator | TODO | +| 7 | OTA via desktop FirmwareManager | TODO | +| 8 | Port to Si3210 SLIC on the custom PCB | TODO | +| 9 | Bluetooth A2DP sink (game-master streaming) | TODO | + +## Convert to a git submodule + +The directory is currently inlined in the parent repo for convenience. +Once you have a remote URL ready: + +```bash +# 1. Create a separate repo for PLIP_FIRMWARE on your Git host +# 2. From this directory: +git init +git add . +git commit -m "Initial commit" +git remote add origin +git push -u origin main + +# 3. From the parent repo (le-mystere-professeur-zacus): +git rm -r PLIP_FIRMWARE +git submodule add PLIP_FIRMWARE +git commit -m "chore: PLIP_FIRMWARE -> submodule" +``` + +This mirrors the existing `ESP32_ZACUS/` submodule layout. diff --git a/PLIP_FIRMWARE/partitions/plip_4mb.csv b/PLIP_FIRMWARE/partitions/plip_4mb.csv new file mode 100644 index 0000000..661e67a --- /dev/null +++ b/PLIP_FIRMWARE/partitions/plip_4mb.csv @@ -0,0 +1,8 @@ +# Partitions for PLIP firmware on 4 MB flash (ESP32-A1S Audio Kit) +# Layout: bootloader + 2 OTA app slots + LittleFS data +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x180000, +app1, app, ota_1, 0x190000, 0x180000, +storage, data, spiffs, 0x310000, 0xF0000, diff --git a/PLIP_FIRMWARE/platformio.ini b/PLIP_FIRMWARE/platformio.ini new file mode 100644 index 0000000..93e895e --- /dev/null +++ b/PLIP_FIRMWARE/platformio.ini @@ -0,0 +1,43 @@ +; PLIP firmware — bringup target: AI-Thinker ESP32-A1S Audio Kit V2.2 +; Board has ES8388 codec, SD slot, dual mic, headphone + speaker out. +; Will retarget to the custom PCB (Si3210 SLIC) once it arrives. + +[platformio] +default_envs = devkit_es8388 + +[env:devkit_es8388] +platform = espressif32@6.5.0 +; AI-Thinker ESP32-A1S has no PlatformIO board entry. esp32dev is close +; enough; we override partitions and pins via build flags / source. +board = esp32dev +framework = arduino +board_build.flash_size = 4MB +board_build.partitions = partitions/plip_4mb.csv +board_build.filesystem = littlefs +monitor_speed = 115200 +monitor_filters = time, esp32_exception_decoder +monitor_echo = yes +monitor_eol = LF +lib_deps = + esphome/ESP32-audioI2S@2.3.0 + bblanchon/ArduinoJson@6.21.5 + ; ES8388 driver. Several Arduino ports exist; pick whichever the + ; bringup engineer validates first. + schreibfaul1/ESP32-audioI2S@^2.0.0 +build_flags = + -DCORE_DEBUG_LEVEL=3 + ; ES8388 I2C address on ESP32-A1S + -DES8388_ADDR=0x10 + ; I2S pins matching ESP32-A1S V2.2 + -DIIS_BCLK=27 + -DIIS_LCLK=26 + -DIIS_DSIN=25 + -DIIS_DOUT=35 + ; SD on SPI (HSPI on ESP32-A1S) + -DSD_CS=13 + -DSD_MOSI=15 + -DSD_MISO=2 + -DSD_SCK=14 + ; Off-hook: dev kit board uses BOOT (KEY1, GPIO4) as a stand-in for + ; the SLIC interrupt. Replace with GPIO4 from Si3210 INT on the PCB. + -DOFF_HOOK_GPIO=4 diff --git a/PLIP_FIRMWARE/src/audio_task.cpp b/PLIP_FIRMWARE/src/audio_task.cpp new file mode 100644 index 0000000..2ac6979 --- /dev/null +++ b/PLIP_FIRMWARE/src/audio_task.cpp @@ -0,0 +1,61 @@ +// audio_task — drains an audio command queue and routes the source to +// ES8388 (dev kit) or Si3210 PCM (PCB). +// +// Sources: +// - SD MP3 via ESP32-audioI2S (file://...) +// - HTTP stream from Tower:8001 Piper TTS (http://... .wav/.mp3) +// - Future: A2DP sink (BT classic) — out of P3=b MVP scope. + +#include +#include +#include +#include + +namespace { + +QueueHandle_t g_queue = nullptr; + +} // namespace + +QueueHandle_t audio_command_queue() { + return g_queue; +} + +namespace { + +struct AudioCommand { + enum Kind { Stop, PlaySdMp3, PlayHttpStream }; + Kind kind; + char path[192]; +}; + +void audio_task(void*) { + Serial.println(F("[audio] task ready, awaiting commands")); + AudioCommand cmd; + for (;;) { + if (xQueueReceive(g_queue, &cmd, pdMS_TO_TICKS(100)) == pdTRUE) { + switch (cmd.kind) { + case AudioCommand::Stop: + Serial.println(F("[audio] stop")); + // TODO(bringup): audio.stopSong(); + break; + case AudioCommand::PlaySdMp3: + Serial.printf("[audio] sd: %s\n", cmd.path); + // TODO(bringup): audio.connecttoSD(cmd.path); + break; + case AudioCommand::PlayHttpStream: + Serial.printf("[audio] http: %s\n", cmd.path); + // TODO(bringup): audio.connecttohost(cmd.path); + break; + } + } + // TODO(bringup): audio.loop(); + } +} + +} // namespace + +void start_audio_task() { + g_queue = xQueueCreate(8, sizeof(AudioCommand)); + xTaskCreatePinnedToCore(audio_task, "audio", 8192, nullptr, 4, nullptr, 0); +} diff --git a/PLIP_FIRMWARE/src/main.cpp b/PLIP_FIRMWARE/src/main.cpp new file mode 100644 index 0000000..92ea25f --- /dev/null +++ b/PLIP_FIRMWARE/src/main.cpp @@ -0,0 +1,39 @@ +// PLIP firmware — bringup skeleton on ESP32-A1S Audio Kit (ES8388 codec). +// +// Three FreeRTOS tasks share state via a queue: +// - phone_task: drives the ring + handles off-hook GPIO interrupt +// - audio_task: streams MP3 from SD or PCM from network to ES8388 +// - network_task: WiFi + REST server (POST /ring, POST /play, /status) +// +// State transitions live in src/phone_state.cpp; they fire commands onto +// the audio queue. Network commands are translated to phone-state events. +// +// Pin assignments come from platformio.ini build flags so we can swap the +// dev kit for the Si3210 PCB without touching source. + +#include +#include +#include + +extern void start_phone_task(); +extern void start_audio_task(); +extern void start_network_task(); + +void setup() { + Serial.begin(115200); + delay(200); + Serial.println(F("[PLIP] boot — bringup skeleton (ES8388 dev kit)")); + + // TODO(bringup): I2C init + ES8388 codec init before audio task starts. + // TODO(bringup): SD.begin() before audio task. + // TODO(bringup): WiFi.begin() inside network_task. + + start_phone_task(); + start_audio_task(); + start_network_task(); +} + +void loop() { + // All work runs in FreeRTOS tasks. Idle loop is intentionally empty. + vTaskDelay(pdMS_TO_TICKS(1000)); +} diff --git a/PLIP_FIRMWARE/src/network_task.cpp b/PLIP_FIRMWARE/src/network_task.cpp new file mode 100644 index 0000000..a80e48a --- /dev/null +++ b/PLIP_FIRMWARE/src/network_task.cpp @@ -0,0 +1,33 @@ +// network_task — WiFi station + REST endpoint. +// +// REST contract (consumed by the Zacus master ESP32): +// POST /ring { "duration_ms": 4000 } -> trigger SLIC ring (or beep on dev kit) +// POST /play { "source": "sd:/intro.mp3" | "http://tower:8001/..." } +// POST /stop -> stop current playback +// GET /status -> { "off_hook": bool, "playing": bool } +// +// WiFi credentials come from NVS (provisioned via the desktop NvsConfigurator). +// The dev kit can fall back to compile-time defaults via build_flags for +// quick bringup; remove them once the desktop NVS flow is wired. + +#include +#include +#include + +namespace { + +void network_task(void*) { + Serial.println(F("[net] task ready")); + // TODO(bringup): WiFi.begin(ssid, password) using NVS-provisioned creds. + // TODO(bringup): wait for IP, advertise via mDNS as plip.local. + // TODO(bringup): start ESPAsyncWebServer with the 4 handlers above. + for (;;) { + vTaskDelay(pdMS_TO_TICKS(5000)); + } +} + +} // namespace + +void start_network_task() { + xTaskCreatePinnedToCore(network_task, "net", 8192, nullptr, 3, nullptr, 0); +} diff --git a/PLIP_FIRMWARE/src/phone_task.cpp b/PLIP_FIRMWARE/src/phone_task.cpp new file mode 100644 index 0000000..d529f4a --- /dev/null +++ b/PLIP_FIRMWARE/src/phone_task.cpp @@ -0,0 +1,45 @@ +// phone_task — ring control + off-hook GPIO interrupt. +// +// On the ESP32-A1S dev kit the SLIC is replaced by an ES8388 audio path +// and a button (BOOT/KEY1) acting as a stand-in for the off-hook signal. +// On the Si3210 PCB the same task drives ring control over SPI and +// listens to Si3210 INT for off-hook / on-hook transitions. + +#include +#include +#include +#include + +extern QueueHandle_t audio_command_queue(); // audio_task.cpp + +namespace { + +volatile bool g_off_hook_pending = false; + +void IRAM_ATTR on_off_hook_isr() { + g_off_hook_pending = true; +} + +void phone_task(void*) { + pinMode(OFF_HOOK_GPIO, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(OFF_HOOK_GPIO), on_off_hook_isr, FALLING); + Serial.println(F("[phone] task ready, watching off-hook")); + + for (;;) { + if (g_off_hook_pending) { + g_off_hook_pending = false; + Serial.println(F("[phone] off-hook detected")); + // TODO(bringup): debounce in software; SLIC INT will already debounce + // in hardware but the dev kit button needs ~30 ms. + // TODO(bringup): notify network_task so the Zacus master sees the event. + // TODO(bringup): tell audio_task to start the appropriate audio cue. + } + vTaskDelay(pdMS_TO_TICKS(20)); + } +} + +} // namespace + +void start_phone_task() { + xTaskCreatePinnedToCore(phone_task, "phone", 4096, nullptr, 5, nullptr, 1); +}