feat(plip): firmware skeleton on ES8388 dev kit

Bringup target: AI-Thinker ESP32-A1S Audio Kit V2.2
(ES8388 codec). End target: custom PLIP PCB with Si3210
SLIC + RJ9 retro handset (per
docs/superpowers/specs/2026-04-08-plip-telephone-design.md).

Three FreeRTOS tasks share state via a queue:
- phone_task (core 1): off-hook GPIO interrupt + ring
- audio_task (core 0): drains command queue, routes to
  ES8388 (dev kit) / Si3210 (PCB) via ESP32-audioI2S
- network_task (core 0): WiFi station + REST server

REST contract for the Zacus master ESP32 to drive PLIP:
- POST /ring   {duration_ms}
- POST /play   {source: "sd:/..." | "http://tower:8001/..."}
- POST /stop
- GET  /status -> {off_hook, playing}

Pin assignments live in platformio.ini build_flags so the
dev kit -> PCB swap stays mechanical (just rewire to
Si3210 SPI + INT and switch the codec init in
audio_task).

MVP scope (option b in the brainstorm):
- ring on demand
- off-hook GPIO -> notify master
- MP3 playback from SD
- WiFi REST endpoint
Out of v1 (deliberately deferred):
- Bluetooth Classic A2DP/HFP (still in the design doc)
- Si3210 driver (when PCB lands)
- OTA via desktop FirmwareManager (after bringup stable)

Files:
- platformio.ini (espressif32@6.5.0, esp32dev board with
  custom partitions + ESP32-A1S build flags)
- partitions/plip_4mb.csv (2x OTA app slots + LittleFS)
- src/main.cpp + 3 task .cpp files (skeleton, log-only)
- README.md (architecture, REST contract, pin map,
  9-step roadmap, submodule conversion)
- CLAUDE.md (nested guidance for Claude Code)
- .gitignore

Inlined for now; convert to a git submodule once a remote
URL is provisioned (procedure documented in README).
Root CLAUDE.md "Firmware" surface and "Where to Look"
table updated.

Acceptance: directory layout + build config + skeleton
sources land. No compile validation in this commit
(PlatformIO not on the workstation per the Q1=ESP32-A1S
dev kit bringup decision).
This commit is contained in:
L'électron rare
2026-05-03 13:21:43 +02:00
parent be2079a4e5
commit 07433dfdc5
10 changed files with 397 additions and 2 deletions
+5 -2
View File
@@ -41,7 +41,9 @@ YAML scenario → compile_runtime3.py → Runtime 3 IR → ESP32 / Web player
Key surfaces: 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`. - **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). - **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`. - **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). - **MCP hardware**: `tools/dev/mcp_hardware_server.py` (stdio, 6 tools).
- **Desktop hub**: `desktop/` (Electron, macOS, bundles V3 frontends, talks USB serial). - **Desktop hub**: `desktop/` (Electron, macOS, bundles V3 frontends, talks USB serial).
@@ -56,7 +58,8 @@ Key surfaces:
| Zacus Studio macOS app | `desktop/` | | Zacus Studio macOS app | `desktop/` |
| Add or change a contract spec | `specs/` | | Add or change a contract spec | `specs/` |
| Python tests (Runtime 3, NPC) | `tests/` | | 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 ## Canonical Files
+7
View File
@@ -0,0 +1,7 @@
.pio/
.vscode/
.DS_Store
*.elf
*.bin
*.hex
build/
+36
View File
@@ -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.
+120
View File
@@ -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 <url>
git push -u origin main
# 3. From the parent repo (le-mystere-professeur-zacus):
git rm -r PLIP_FIRMWARE
git submodule add <url> PLIP_FIRMWARE
git commit -m "chore: PLIP_FIRMWARE -> submodule"
```
This mirrors the existing `ESP32_ZACUS/` submodule layout.
+8
View File
@@ -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,
1 # Partitions for PLIP firmware on 4 MB flash (ESP32-A1S Audio Kit)
2 # Layout: bootloader + 2 OTA app slots + LittleFS data
3 # Name, Type, SubType, Offset, Size, Flags
4 nvs, data, nvs, 0x9000, 0x5000,
5 otadata, data, ota, 0xe000, 0x2000,
6 app0, app, ota_0, 0x10000, 0x180000,
7 app1, app, ota_1, 0x190000, 0x180000,
8 storage, data, spiffs, 0x310000, 0xF0000,
+43
View File
@@ -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
+61
View File
@@ -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 <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
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);
}
+39
View File
@@ -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 <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
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));
}
+33
View File
@@ -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 <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
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);
}
+45
View File
@@ -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 <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
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);
}