Compare commits

...

81 Commits

Author SHA1 Message Date
clement fd7af95bcf feat(box3): audio narration for the touch gamebook
CI / platformio (push) Failing after 6m54s
CI / platformio (pull_request) Failing after 16m36s
Context:
The BOX-3 touch gamebook showed text + tappable choices but was silent.
We want narration through its ES8311 speaker while the text is on
screen — and tapping a choice must cut it instantly.

Approach:
Add a dedicated async, interruptible WAV player to cmd_exec (which owns
the speaker handle), streaming straight from the SD in small chunks so
any length plays without a big malloc. The gamebook plays each passage
WAV on render; a new tap (or returning to the menu) stops it.

Changes:
- cmd_exec.c/.h: cmd_exec_play_file_async(path) + cmd_exec_stop_play().
  One persistent task streams a 16 kHz mono 16-bit WAV from the SD in
  2 KB chunks (RAM-safe), and a new request / stop flag breaks the
  stream mid-clip so playback is interruptible.
- gamebook.c: on entering a passage, play /sdcard/gamebook/<wav> if the
  pack has audio (non-fatal when absent → stays text-only); stop the
  narration when returning to the library.

Impact:
The BOX-3 reads the story aloud (16 kHz, matches AUDIO_SAMPLE_RATE)
while showing the text, and a tap cuts to the next passage. Audio is
optional — text-only packs still work.
2026-06-20 01:02:29 +02:00
clement 5519ed2f72 feat(box3): touch gamebook (tap to choose)
CI / platformio (pull_request) Failing after 9m57s
CI / platformio (push) Failing after 14m8s
Context:
The Freenove gamebook is screen+buttons, the PLIP is audio+dial. The
ESP32-S3-BOX-3 has a 320x240 touchscreen, so its natural format is a
tap-to-choose "livre dont tu es le heros": read the passage on screen,
tap a choice button to navigate.

Approach:
A new gamebook module reuses the same SD pack as the master (the JSON
passages already carry screen/text/choices[label,goto]) and builds the
UI on the already-running LVGL stack: a scrollable column with the
title, the wrapped passage text, and one tappable button per choice.

Changes:
- New gamebook.c/.h: mounts the SD (bsp_sdcard_mount), reads
  /sdcard/gamebook/{library.json, <id>.json}, shows a story list, then
  per passage renders title + text + choice buttons; tapping a choice
  goes to its target, an ending offers "back to menu". cJSON runs on
  PSRAM hooks (the expanded books are ~80 KB JSON); LVGL calls are
  wrapped in bsp_display_lock at init (button callbacks already run in
  the LVGL task).
- main.c: call gamebook_init() after the scenario server is up; it
  takes over the screen when a pack is on the SD, else no-op (the phone
  UI stays).
- CMakeLists: build gamebook.c.

Impact:
With a JSON pack on its SD, the BOX-3 is a touch gamebook: tap a story,
tap your choices. Text-only (no audio needed). Hardware-validated:
boots into a 6-story touch library.
2026-06-20 00:37:13 +02:00
clement a4efce4c20 fix(plip): raise /game/file MAX_BODY to 3 MB
CI / platformio (pull_request) Failing after 4m23s
CI / platformio (push) Failing after 6m6s
Neural-TTS (Kyutai) gamebook narration WAVs run ~1.5 MB per passage at
16 kHz, over the old 512 KB cap. Streamed in 2 KB chunks so the larger
cap stays RAM-safe.
2026-06-20 00:19:46 +02:00
clement a8af29068b feat(gamebook): full-height scrolling text + real pad mapping
CI / platformio (pull_request) Failing after 4m50s
CI / platformio (push) Failing after 10m23s
Context:
Two issues on the Freenove gamebook: (1) long passages were clipped —
the reading band was small and text overflowed; (2) the 5-way pad was
decoded wrong, so navigation felt random, and on this unit the physical
Up button is electrically dead (held = no voltage change).

Approach:
Give the reading text the whole screen height in a vertically
scrollable container, split selection from scrolling, and remap the
keys to the pad's MEASURED voltages.

Changes:
- display_ui: gamebook view reworked — compact title, a full-height
  scrollable reading container (the body wraps and scrolls, never
  clipped), and a one-line choice selector pinned at the bottom; new
  display_ui_gamebook_scroll() + reset-on-new-passage; show() gains a
  reset_scroll arg; body buffer 512 -> 1100.
- gamebook.c: controls matched to the measured ladder
  (key2=LEFT, key4=DOWN, key5=RIGHT, key1=CLICK; key3 unused; Up dead):
  Left/Right scroll the text up/down, Down cycles the answer, Click
  validates; library nav cycles tiles without needing Up.

Impact:
Long passages are fully readable (they scroll), and all four working
pad buttons do the right thing; nothing depends on the dead Up button.
Hardware-calibrated against the real pad voltages.
2026-06-19 23:56:45 +02:00
clement c2527a0a66 feat(plip): audio gamebook on the phone (dial to choose)
CI / platformio (push) Has been cancelled
CI / platformio (pull_request) Failing after 5m42s
Context:
The Freenove plays a screen+buttons "livre dont vous etes le heros".
The PLIP retro phone has no screen, so we want a phone-native version:
an audio choose-your-own-adventure where the narrator reads the passage
and its numbered choices, and the player dials a digit to choose.

Approach:
A self-contained plip_gamebook module reads a phone audio pack from the
SD (narration WAVs already include the spoken "faites le N" prompts),
plays passages through the earpiece, and maps each dialed digit to a
choice. It hooks into the existing conversation state machine and the
existing dialer (rotary/DTMF) — fully offline, no model, no network.

Changes:
- New plip_gamebook.c/.h: loads /sdcard/gamebook/{library.json,
  menu.wav, <id>.json, <id>_<passage>.wav}; pickup plays the story
  menu, digit picks a story, then each passage plays its WAV and a
  dialed digit jumps to that choice; an ending + any digit returns to
  the menu. Interrupts narration so a dial is instant.
- conversation.c: new STATE_GAMEBOOK; off-hook from idle launches the
  gamebook when a pack is on the SD (else the normal dialtone/NPC flow,
  so the phone still works without a pack); each dialed digit is fed to
  the gamebook; go_idle() tears it down on hang-up.
- CMakeLists: build plip_gamebook.c.

Impact:
With a phone pack staged on its SD, the PLIP becomes an audio gamebook:
pick up, dial to choose your story and your path, hang up to stop.
Non-breaking when no pack is present. Builds clean.
2026-06-19 23:55:41 +02:00
clement 0027970907 fix(gamebook): scroll long passages + DELETE /game/file
CI / platformio (push) Failing after 5m21s
CI / platformio (pull_request) Failing after 5m25s
Context:
Two issues surfaced with the longer kid stories: (1) long passages were
clipped on screen — the body label wrapped in a fixed 196px band and
the tail was simply cut off; (2) old orphan WAVs (demo + archived
adventures) piled up on the SD with no way to remove them, since the
master had POST /game/file but no delete.

Approach:
Make the gamebook body label scroll vertically so the whole passage
goes by, and add a DELETE verb to /game/file mirroring the POST routing
so the host can clean stale assets off the SD.

Changes:
- display_ui: gamebook body label LV_LABEL_LONG_WRAP ->
  LV_LABEL_LONG_SCROLL_CIRCULAR; body buffer 512 -> 1100 so a full
  ~1000-char passage is held and scrolled, not truncated.
- game_endpoint: new DELETE /game/file?path=sd/<…>|apps/<…> (same
  whitelist/traversal guard as POST; unlink + 200/404).
- ota_server: max_uri_handlers 22 -> 24 for the extra verb.

Impact:
Long passages are fully readable on the LCD (they scroll), and the SD
gamebook folder can be cleaned remotely. Hardware-validated: 121 orphan
files deleted, library still opens 6 stories.
2026-06-19 16:30:11 +02:00
clement a70963f64a feat(gamebook): library tile picker on boot
CI / platformio (pull_request) Failing after 3m56s
CI / platformio (push) Failing after 9m50s
Context:
The gamebook played a single hard-coded book. We want the master to
boot into a library of several stories, shown as tiles, and let the
player pick one with the pad.

Approach:
Turn the gamebook component into a two-mode machine (LIBRARY / STORY).
On boot it loads /sdcard/gamebook/library.json and shows the stories as
a tile grid; selecting one loads that book's <id>.json and plays it;
finishing a story returns to the library.

Changes:
- display_ui: new library view — a 2x3 grid of title tiles with the
  selected one highlighted (display_ui_library_show/hide); it wins the
  view selector while open.
- gamebook.c: LIBRARY/STORY modes; load library.json + per-book
  <id>.json from the SD; grid navigation (up/down +/-2, left/right
  +/-1, click opens); story ending click returns to the library.
- main.c: gamebook_init only installs the pad hook; gamebook_start()
  (which opens the library) is now called after the SD + media_manager
  are up, so the boot picker actually finds the SD pack.

Impact:
The Freenove boots straight into a story picker and runs any book from
the SD library, fully offline. Hardware-validated: boots into a
6-story library.
2026-06-19 15:24:17 +02:00
clement f58b090a34 feat: gamebook mode (CYOA) on the master
CI / platformio (pull_request) Failing after 4m23s
CI / platformio (push) Failing after 9m52s
Context:
We wanted a "livre dont vous etes le heros" playable on the Freenove
master with NO model in RAM: narration as say()-rendered WAV staged on
the SD card, and navigation on the physical 5-way pad. Nothing in the
firmware drove a branching, button-navigated story before this.

Approach:
A self-contained gamebook component reads a JSON book from the SD,
drives the display and audio, and owns the 5-way pad through a
registered display_ui key hook (so display_ui keeps no dependency on
gamebook — no cycle). A dedicated full-screen gamebook view renders
each page (title / wrapped body / choice list).

Changes:
- New gamebook component: loads /sdcard/gamebook/gamebook.json; per
  passage plays its WAV (media_manager, absolute /sdcard path) and
  renders the title, wrapped text, and a choice list with a ">" cursor.
- Navigation: D-pad up/down (and left/right) move the cursor, the
  center click confirms; endings (no choices) restart on click; a
  choice interrupts the current narration (media_manager_stop + play).
- display_ui: add display_ui_set_key_hook (consume keys when a takeover
  mode is active) and a dedicated gamebook view (title + height-bounded
  body band + reserved bottom menu band) with display_ui_gamebook_show
  / display_ui_gamebook_hide; the gamebook view wins the view selector
  while open.
- game_endpoint: POST /game/gamebook[?action=stop] to start/stop.
- ota_server: bump max_uri_handlers 20 -> 22 for the new endpoint.
- main: call gamebook_init() once the buttons are up.

Impact:
The Freenove plays a branching audio gamebook driven entirely by the
pad, fully offline (no gateway, no model). Hardware-validated end to
end: cursor navigation, click select, WAV narration from SD, endings
restart.
2026-06-19 14:30:37 +02:00
clement f3d03c637a feat(plip): add DELETE /game/file (SD/SPIFFS)
CI / platformio (push) Failing after 4m10s
CI / platformio (pull_request) Failing after 10m42s
Removes a staged file with the same sd/ vs SPIFFS path routing as the
POST handler (mounts SD on demand). Lets the host clean stale clips
when regenerating the SD voice pack. max_uri_handlers 17->18.
Validated: probe WAVs removed from /sdcard/voice (200, then 404).
2026-06-18 21:57:20 +02:00
clement 39e0498221 feat(plip): chain SD scene hint after greeting
CI / platformio (push) Failing after 4m24s
CI / platformio (pull_request) Failing after 5m51s
/debug/ring now takes an optional scene; conversation_arm_incoming
locks it and, after the greeting, queues that scene's local clip
/sdcard/voice/hint_<scene>_l1_0.wav (played back-to-back). For a
scripted offline call CONNECTED skips the gateway/model listen loop
and just waits for hang-up. Scene is cleared in go_idle so a later
dial-out call never inherits it. Validated: greeting 5.05s + WARNING
hint 6.20s from SD on pickup, no gateway/model.
2026-06-18 21:31:18 +02:00
clement da7b6ace9c feat(plip): play greeting from SD pack on pickup
CI / platformio (pull_request) Failing after 4m2s
CI / platformio (push) Failing after 9m58s
On incoming pickup the GREET state now plays the local clip
/sdcard/voice/greet_<num>.wav when present — the phone greets the
caller with no gateway and no model in RAM. Falls back to the live
gateway greeting (turn_client) only when the pack lacks a clip for
that number.
2026-06-18 18:08:29 +02:00
clement be369fa260 feat(plip): stage files onto SD via /game/file
CI / platformio (push) Failing after 10m32s
CI / platformio (pull_request) Failing after 11m45s
/game/file only wrote SPIFFS (960 KB, too small for a voice pack). A
path prefixed sd/ (or /sdcard/) now routes to the microSD: the card is
mounted on demand (audio_ensure_sd, independent of hook state) and the
parent dir is created. MAX_BODY 256->512 KB so full say() hints fit
(streamed in 2 KB chunks, RAM-safe). Enable FATFS LFN_HEAP so >8.3
filenames work (greet_0142738200.wav etc). Validated: 40-sample FR
voice pack staged to /sdcard/voice on the bench PLIP (960 MB card).
2026-06-18 17:41:53 +02:00
clement ace740a629 fix(npc): wire scene notify into apply_step
CI / platformio (push) Failing after 6m10s
CI / platformio (pull_request) Failing after 17m59s
notify_gateway_scene() + hints puzzle_start lived only in
npc_engine_set_step(), which had NO callers — the runtime->gateway
sync was dead code, so scripted incoming calls would never fire in a
real game. POST /game/step goes through game_endpoint_apply_step(),
which now resolves the step's scene_id (new field on scene_binding,
parsed straight from the IR) and calls the new
npc_engine_set_scene_by_id(). Hardware-validated: STEP_WARNING ->
scene 3 -> gateway notify -> auto-ring Professeur Zacus on the PLIP.
2026-06-18 15:52:03 +02:00
clement 4ac3981845 fix(npc): gateway scene notify non-blocking
CI / platformio (pull_request) Failing after 4m3s
CI / platformio (push) Failing after 14m44s
The scene->gateway POST ran synchronously (2 s timeout) on the scene
transition, so an offline gateway lagged EVERY scene change by 2 s.
Fire it on a detached one-shot task instead, so the game's scene
transitions never wait on the network. Frees the heap scene copy and
self-deletes. Builds clean.
2026-06-18 15:17:31 +02:00
clement 653a299ea4 feat(npc): push scene changes to voice gateway
CI / platformio (pull_request) Failing after 4m58s
CI / platformio (push) Failing after 12m35s
On each scene change (where it already signals /hints/puzzle_start), the
npc_engine now also POSTs the active SCENE_* to the voice gateway
/game/step?scene=… (best-effort, esp_http_client, Bearer token). The
phone NPCs then disguise THIS scene's hint automatically during a game.
Gateway URL/token hardcoded in main.c like the hints URL (-> NVS later).
2026-06-18 14:04:07 +02:00
clement 54022ed6cc fix(plip): VAD calib for quiet voice + ring stop
CI / platformio (pull_request) Failing after 9m38s
CI / platformio (push) Failing after 13m49s
Recalibrate the capture VAD to the quiet handset voice: onset 1.4%->0.7%
FS (was never triggering -> 'no sustained voice'), silence 0.6%->0.34%
FS and end-of-speech window 600->900 ms so a whole sentence is held
instead of cut mid-phrase. Also force slic_ring_stop() on incoming
pickup so the physical bell always stops when answered (phone.c/slic.c
s_ringing could desync, leaving the bell ringing through the call).
2026-06-17 23:47:08 +02:00
clement cfe429d885 fix(plip): debounce hook hangup against flicker
CI / platformio (pull_request) Failing after 5m23s
CI / platformio (push) Failing after 12m32s
The marginal A1S cradle contact flickers open mid-call; treating a
brief open as a hangup dropped the live conversation. Raise the
prolonged-open hangup threshold to 2.5 s and make the resync
asymmetric: a PICKUP is confirmed fast (600 ms, calls answer promptly)
while a HANGUP needs the line to stay open 2.5 s. Brief flickers no
longer end the call; a real hangup still fires ~2.5 s later.
2026-06-17 19:47:55 +02:00
clement 82759ee536 feat(plip): voice-activated two-phase capture
CI / platformio (push) Failing after 9m50s
CI / platformio (pull_request) Failing after 10m14s
Listen-loop capture now only commits when the caller actually speaks:
phase A waits for a sustained voice onset (3 frames above threshold,
rejecting the PA-mute click), phase B records until silence. Empty
captures are no longer posted, so the NPC never replies to silence.
Also: DC-blocking high-pass on the captured mono, VAD thresholds tuned
to the quiet SLIC handset mic (onset ~1.4%, silence ~0.6%), keep the
PA on during capture (muting it collapsed the mic), and add a
/debug/miccap diagnostic endpoint (raw fixed-duration mic capture).
2026-06-17 14:39:42 +02:00
clement 8c076d81d6 fix(plip): trust real SHK, drop hook forcing
CI / platformio (pull_request) Failing after 7m44s
CI / platformio (push) Failing after 11m19s
Incoming-call greeting was silently dropped: audio CMD_PLAY gated on
phone.c's debounced phone_is_offhook(), which lags/misses the pickup
while the bell rings, but the incoming flow established off-hook only
via the raw SLIC poll in conversation.c. The two flags desynced and
playback was skipped as 'on-hook' though the handset was up.

Gate playback on the real SHK line (slic_is_offhook) instead — the
single source of truth. Remove all hook forcing (s_hook_override,
phone_force_offhook, /debug/offhook): the SHK contact is reliable in
hardware, so the firmware must trust it, never override it. Builds
clean (ESP-IDF v5.4.4).
2026-06-17 09:01:48 +02:00
clement 99d79efcdd tune(plip): +24dB mic PGA + VAD thresholds
CI / platformio (push) Failing after 11m27s
2026-06-16 19:18:52 +02:00
clement 8dffe14972 fix(plip): mute earpiece during capture 2026-06-16 19:18:45 +02:00
clement d71453d32e fix(plip): report real state in /status 2026-06-16 19:18:38 +02:00
clement ccc84da785 tune(plip): default speaker volume 98%
CI / platformio (push) Failing after 15m51s
2026-06-16 10:32:11 +02:00
clement 1561e613e1 feat(plip): incoming call + fast half-duplex
- /debug/ring?number=NN arms incoming call, starts ring
- conversation_arm_incoming() + enter_incoming_greet()
- offhook with armed call skips dial/ringback, goes to GREET
- slic_is_offhook() used during ring (GPIO debounce unreliable)
- CAPTURE_SILENCE_MS 800->600 ms, settles 250->120, 200->100 ms
2026-06-16 10:32:01 +02:00
clement 4a6fc435a7 fix(plip): ES8388 undoc DLL regs for 16kHz ADC
CI / platformio (push) Failing after 13m6s
2026-06-16 09:32:36 +02:00
clement ebcfb011d3 feat(plip): /debug/es8388 register poke endpoint
CI / platformio (push) Failing after 11m53s
Add es8388_write_reg() public API and GET /debug/es8388?reg=&val=
HTTP handler to read/write codec registers at runtime without
reflashing. Bumps max_uri_handlers 16→17.
2026-06-16 08:40:59 +02:00
clement 3c0eb75465 fix(plip): robust WiFi on mesh/DFS network
STA config: channel=0 (all-channel scan), WIFI_ALL_CHANNEL_SCAN,
WIFI_CONNECT_AP_BY_SIGNAL, failure_retry_cnt=5.
Disconnect handler: retry on every disconnect event, including during
initial association — previously the first failure at boot caused an
immediate abort and a ~30s timeout before IP was acquired.
Validated on hardware: connects reliably on ch1, RSSI -32, IP in ~2.5s.
2026-06-15 22:27:01 +02:00
clement aa7ae277ed feat(plip): voice loop + DTMF + ring cadence
CI / platformio (pull_request) Failing after 4m0s
CI / platformio (push) Failing after 14m58s
- hook polarity active-HIGH + auto-resync (was LOW)
- ring cadence FT 1.5s ON / 3.5s OFF
- DTMF Goertzel decoder (dtmf.c/h) + rotary debounce
- LISTEN half-duplex: capture → /v1/voice/reply → play
- WAV playback buffered PSRAM + mono→stereo upmix
- SPIFFS mount at boot for pre-loaded greetings
- ES8388: DAC digital vol + mic PGA + GPIO INPUT_OUTPUT
- turn_client multipart + 90s timeout + fixed routing
- debug endpoints: vol/dacvol/offhook/getfile/hookmon
2026-06-15 21:12:33 +02:00
electron 37db47ad7b Merge pull request 'fix(plip): ES8388 ADC input LIN2/RIN2 — SLIC handset mic works' (#24) from fix/plip-adc-lin2 into main
CI / platformio (push) Failing after 13m12s
2026-06-15 07:51:27 +00:00
clemsail f03156b65e fix(plip): ES8388 ADC input LIN2/RIN2 — SLIC handset mic (voice captured, was reading floating LIN1)
CI / platformio (pull_request) Failing after 3m54s
2026-06-15 09:51:15 +02:00
electron b36b703f34 Merge pull request 'fix(plip): SLIC hook polarity active-LOW + faithful PD open-drain' (#23) from feat/plip-hook-polarity into main
CI / platformio (push) Has been cancelled
2026-06-15 07:43:54 +00:00
clemsail a209b3b86c fix(plip): SLIC hook polarity active-LOW (K50835F open-collector) + faithful PD open-drain
CI / platformio (pull_request) Failing after 11m29s
2026-06-15 09:43:44 +02:00
electron 8b0bd2c262 Merge pull request 'feat(plip): SLIC integration + I2S RX mic fix + /debug/ring,slic' (#22) from feat/plip-slic-integration into main
CI / platformio (push) Failing after 5m56s
2026-06-14 23:01:39 +00:00
clemsail 54d1a1ec6e fix(plip): I2S full-duplex silently broken by mismatched TX/RX gpio_cfg — ES8388 ADC/I2S RX capture path
CI / platformio (pull_request) Failing after 4m10s
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).
2026-06-15 00:57:02 +02:00
clemsail ccbe5720b0 fix(plip): SLIC PD push-pull HIGH (definite power-up) + /debug/slic gpio readback 2026-06-15 00:49:30 +02:00
clemsail 47fbc09fea fix(plip): ES8388 ADC unmute (ADCCONTROL6=0x00) + full ADC power-up (ADCPOWER=0x00)
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.
2026-06-15 00:36:43 +02:00
clemsail 0f7047215b feat(plip): ES8388 ADC line-in (LINPUT2/RINPUT2) for SLIC audio
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.
2026-06-15 00:26:49 +02:00
clemsail f531bf3e55 feat(plip): SLIC control — power-up (PD OD-HIGH), hook on SHK GPIO23, ring RM/FR
- 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.
2026-06-15 00:26:35 +02:00
clemsail 87075ba92b feat(plip): /debug/ring + /debug/ringstop — trigger ringer over HTTP 2026-06-14 23:24:36 +02:00
electron 75d6aaf6d4 Merge pull request 'feat(plip): téléphone stage 2 — turn_client + GREET (accueil PNJ depuis gateway)' (#21) from feat/plip-phone-stage2 into main
CI / platformio (push) Failing after 3m51s
2026-06-14 20:45:00 +00:00
clemsail c740bfe20a feat(plip): turn_client + GREET state — fetch & play NPC greeting from gateway /v1/voice/turn (stage 2)
CI / platformio (pull_request) Failing after 11m23s
2026-06-14 22:43:46 +02:00
electron e68e9f5f66 Merge pull request 'feat(plip): téléphone France Télécom — stage 1 (tonalités + dialer + machine à états)' (#20) from feat/plip-phone-stage1 into main
CI / platformio (push) Failing after 4m23s
2026-06-14 20:17:25 +00:00
clemsail 2710965741 fix(plip): synchronous tones_stop (I2S arbitration), MAX_DIGITS 12, drop s_state cross-task write, pulse guard
CI / platformio (pull_request) Failing after 4m21s
2026-06-14 22:15:53 +02:00
clemsail b92f138f41 feat(plip): conversation state machine — dialtone/dialing/routing/ringback/busy (stage 1) 2026-06-14 22:01:33 +02:00
clemsail 4621f451be feat(plip): rotary pulse dialing on SHK GPIO 2026-06-14 21:57:14 +02:00
clemsail e819eae5a5 feat(plip): dialer module + /debug/dial (digit accumulator) 2026-06-14 21:55:02 +02:00
clemsail ba248e92fe feat(plip): tones module — France Télécom dial/busy/ringback 2026-06-14 21:53:50 +02:00
electron 39f4cd2313 Merge pull request 'feat(plip): mute audio output when handset on-hook' (#19) from feat/plip-mute-onhook into main
CI / platformio (push) Failing after 3m57s
2026-06-14 18:45:03 +00:00
clemsail b8ba1457fb feat(plip): mute audio output when handset on-hook (gate playback on hook state)
CI / platformio (pull_request) Failing after 3m41s
2026-06-14 20:43:33 +02:00
electron 7718f4653d Merge pull request 'feat(plip): streaming file upload + playback (MAX_BODY 256K, full TTS phrases)' (#18) from feat/plip-maxbody-256k into main
CI / platformio (push) Failing after 11m35s
2026-06-14 18:30:47 +00:00
clemsail f7104b4180 feat(plip): bump /game/file MAX_BODY 64K->256K for full TTS phrases
CI / platformio (pull_request) Failing after 3m46s
2026-06-14 20:29:25 +02:00
electron b748c2d75f Merge pull request 'feat(plip): I2S RX mic capture + POST /voice/capture (STT input leg) + ES8388 ADC register fix' (#17) from feat/plip-mic-capture into main
CI / platformio (push) Failing after 4m20s
2026-06-14 16:53:01 +00:00
clemsail dd3d239784 feat(plip): I2S RX mic capture + POST /voice/capture (WAV) — STT input leg
CI / platformio (pull_request) Failing after 4m5s
- audio.c: add full-duplex I2S RX channel (s_mic_handle, GPIO35/ASDOUT)
  enabled at boot alongside TX; streaming capture API with VAD
  (audio_capture_begin/read_frame/end) avoids large buffer allocation
- audio.h: export streaming capture API
- es8388.c: fix ADC register addresses (ADCCONTROL2=0x0A, 3=0x0B, 4=0x0C,
  5=0x0D, 8=0x10, 9=0x11 — were off by 1 from Espressif reference);
  CHIP_CTL1=0x12 (record+play), DACCONTROL21=0xC0 (ADC+DAC LRCK sync),
  ADCPOWER=0x09, FSM restart pulse (CHIPPOWER 0xF0→0x00) after CTL21 write;
  fix set_volume() to use OUT1 volume regs (0x2E/0x2F) not clock-sync reg 0x2B
- net.c: POST /voice/capture streaming WAV handler (chunked, 44-byte header
  + PCM frames, VAD onset/silence, query params max_ms/silence_ms);
  GET /debug/regs for live ES8388 register dump (diagnostic aid)
- board_config.h: PLIP_I2S_DIN=GPIO35 (ES8388 ASDOUT)

Build: clean. Endpoint functional (200 OK, valid WAV structure).
RX data currently silent — suspected hardware issue (mic not connected
to LIN1/LIN2 on this specific kit instance); firmware implementation complete.
2026-06-14 18:50:22 +02:00
electron b31388dd86 Merge pull request 'feat(p5,p6): scaffold morse + nfc puzzles behind CONFIG flags (default off)' (#16) from feat/p5-p6-scaffold into main
CI / platformio (push) Failing after 11m37s
2026-06-14 15:00:31 +00:00
clemsail d5b9297cee feat(p5,p6): scaffold morse + nfc puzzles behind CONFIG flags, default off
CI / platformio (pull_request) Failing after 3m49s
2026-06-14 16:58:42 +02:00
electron 038e0a980a Merge pull request 'feat(p7): scaffold coffre actuator behind CONFIG_ZACUS_P7_COFFRE_ENABLE (default off)' (#15) from feat/p7-coffre-scaffold into main
CI / platformio (push) Failing after 4m9s
2026-06-14 14:15:26 +00:00
clemsail fc4b58f785 feat(p7): scaffold coffre actuator (servo/relay) behind CONFIG_ZACUS_P7_COFFRE_ENABLE, default off
CI / platformio (pull_request) Failing after 12m3s
2026-06-14 16:01:33 +02:00
electron 022e343750 Merge pull request 'feat(box3,plip): POST /game/cmd — trigger cmd_exec over HTTP' (#14) from feat/annex-game-cmd into main
CI / platformio (push) Failing after 4m15s
2026-06-14 12:13:33 +00:00
clemsail 875333b6f0 fix(plip): play_wav_file — SPIFFS lazy-mount for /spiffs/ paths, skip SD gate
CI / platformio (pull_request) Failing after 10m0s
2026-06-14 14:08:08 +02:00
clemsail e9413fa898 feat(box3,plip): POST /game/cmd — trigger cmd_exec over HTTP (WiFi-direct) 2026-06-14 13:54:02 +02:00
electron 39903c376d Merge pull request 'feat(plip): ESP-IDF migration — ES8388 audio + WiFi/httpd + cmd_exec + hook/ring' (#13) from feat/plip-idf-migration into main
CI / platformio (push) Failing after 5m8s
2026-06-14 11:34:21 +00:00
clemsail 309005360b feat(plip_voice): ESP-IDF port of PLIP retro telephone annex
CI / platformio (pull_request) Failing after 4m52s
New IDF project targeting AI-Thinker ESP32-A1S Audio Kit (ESP32, ES8388
codec). Replaces the Arduino PLIP_FIRMWARE with a proper IDF component
structure aligned on box3_voice / scenario_mesh infrastructure.

Phases implemented and hardware-verified:
- A: ES8388 I2C init + I2S TX; 440 Hz test tone exits the speaker
- B: WiFi STA + httpd :80 (GET /status, POST /game/scenario, /game/file)
- C: ESP-NOW CMD receiver via shared scenario_mesh lib; op=play dispatched
     to audio; screen/evt/led logged (no display on PLIP)
- D: Off-hook GPIO monitor (GPIO4=BOOT button stand-in); ring tone cadence;
     POST /voice/hook to master on pickup/hangup (200 OK confirmed)

MAC (first observed): a0:b7:65:e7:f6:44
IP (DHCP): 192.168.0.138

Files:
  plip_voice/CMakeLists.txt          - project root; EXTRA_COMPONENT_DIRS=../lib/scenario_mesh
  plip_voice/partitions.csv          - 4MB OTA dual-bank + SPIFFS (matches PLIP_FIRMWARE)
  plip_voice/sdkconfig.defaults      - ESP32 target, 4MB flash DIO, ch=11 hint (no creds)
  plip_voice/main/Kconfig.projbuild  - PLIP_ menu (SSID/pwd/channel/master_url/hook_gpio)
  plip_voice/main/board_config.h     - A1S pin map (I2C 32/33, I2S 0/27/25/26/35, PA=21)
  plip_voice/main/es8388.[ch]        - ES8388 I2C register init sequence (legacy i2c driver)
  plip_voice/main/audio.[ch]         - I2S TX + async play queue + ring task
  plip_voice/main/net.[ch]           - WiFi STA + httpd (NVS creds with Kconfig fallback)
  plip_voice/main/cmd_exec.[ch]      - D5 CMD executor (play→audio; screen/evt/led→log)
  plip_voice/main/hook_client.[ch]   - POST /voice/hook via esp_http_client
  plip_voice/main/phone.[ch]         - Off-hook GPIO ISR + debounce + ring control
  plip_voice/main/main.c             - app_main boot sequence (all four phases)

Build: IDF 5.4.4, target esp32, clean.
Flash: /dev/cu.usbserial-0001, 4MB flash verified.
2026-06-14 13:29:33 +02:00
electron bbe79deb07 Merge pull request 'feat(box3): POST /game/file over HTTP' (#12) from feat/box3-game-file into main
CI / platformio (push) Failing after 17m50s
2026-06-14 11:09:43 +00:00
clemsail 935f86d844 feat(box3): POST /game/file — write assets to SD over HTTP (WiFi-direct)
CI / platformio (pull_request) Failing after 4m26s
2026-06-14 12:58:45 +02:00
electron 29fa8eed34 Merge pull request 'feat(box3): real screen render + real WAV/I2S play in cmd_exec' (#11) from feat/box3-screen-play-real into main
CI / platformio (push) Failing after 6m5s
2026-06-14 10:02:50 +00:00
clemsail 3e702f6fd8 feat(box3): real screen rendering + WAV I2S playback for CMD executor
CI / platformio (pull_request) Failing after 9m25s
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
2026-06-14 12:01:31 +02:00
electron 0b530e70e0 Merge pull request 'feat(box3): ESP-NOW CMD executor (D5 contract)' (#10) from feat/box3-cmd-executor into main
CI / platformio (push) Failing after 9m39s
2026-06-14 09:33:59 +00:00
clemsail 735aaed648 feat(box3): implement ESP-NOW CMD executor (D5 contract)
CI / platformio (pull_request) Failing after 4m36s
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"}).
2026-06-14 11:20:45 +02:00
electron 7f52d3d759 Merge pull request 'feat(P4): media_manager vrai playback WAV + /game/media/play' (#9) from feat/p4-media-wav-playback into main
CI / platformio (push) Failing after 3m27s
2026-06-13 14:44:29 +00:00
electron c7485f272f Merge pull request 'fix(box3): SPIFFS mount + canal WiFi configurable (relais ESP-NOW)' (#8) from fix/box3-spiffs-channel into main
CI / platformio (push) Has been cancelled
2026-06-13 14:44:13 +00:00
clement fcb872c226 feat(p4): décodeur MP3 (helix) + mutex I2S TTS↔musique + FATFS LFN
CI / platformio (pull_request) Failing after 10m4s
- 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>
2026-06-13 16:42:13 +02:00
clement 8a58c7d92b feat(p4): brancher media_manager + /game/file sur /sdcard
CI / platformio (pull_request) Failing after 3m39s
- 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>
2026-06-13 16:28:16 +02:00
clement a8f0210567 feat(p4): montage microSD (SDMMC 1-bit) sur /sdcard
CI / platformio (pull_request) Failing after 6m0s
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>
2026-06-13 15:49:44 +02:00
clement c35c9673f4 feat(p4): media_manager — vrai playback WAV + endpoint /game/media/play
CI / platformio (pull_request) Failing after 3m32s
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>
2026-06-13 15:31:54 +02:00
electron 585af362f7 Merge pull request 'fix(box3): BSP esp-box-3 + rétroéclairage' (#7) from feat/idf-migration into main
CI / platformio (push) Failing after 11m6s
2026-06-10 11:21:59 +00:00
electron 43c2651777 Merge pull request 'feat(box3): téléphone à l écran + clavier tactile' (#6) from feat/idf-migration into main
CI / platformio (push) Has been cancelled
2026-06-10 10:44:16 +00:00
electron c0ccd8f114 Merge pull request 'feat(box3): annexe PLIP virtuelle (sans téléphone)' (#5) from feat/idf-migration into main
CI / platformio (push) Has been cancelled
2026-06-10 10:29:50 +00:00
electron ef1741bd09 Merge pull request 'feat(master): endpoint GET/POST /game/peers' (#4) from feat/idf-migration into main
CI / platformio (push) Has been cancelled
2026-06-10 09:56:08 +00:00
electron 593b920354 Merge pull request 'refactor: scenario_mesh unique dans lib/ partagé' (#3) from feat/idf-migration into main
CI / platformio (push) Failing after 11m30s
2026-06-10 08:52:13 +00:00
electron c077d2950f Merge pull request 'docs: PLIP re-scoped into scenario hot-load' (#2) from feat/idf-migration into main
CI / platformio (push) Failing after 11m40s
2026-06-10 08:26:24 +00:00
electron 17e575ee0e Merge pull request 'feat(idf): ESP-NOW scenario hot-load + IDF migration' (#1) from feat/idf-migration into main
CI / platformio (push) Failing after 12m41s
2026-06-09 05:45:20 +00:00
76 changed files with 10465 additions and 31 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
idf_component_register(
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c"
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c" "gamebook.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
+650
View File
@@ -0,0 +1,650 @@
// cmd_exec.c — BOX-3 executor for structured ESP-NOW CMD frames (D5 contract).
// See cmd_exec.h for the wire format spec.
//
// Objective 1 — screen: full-scene faithful rendering
// Palette: bg #0055AA / symbol #FF8800 / title #FFFFFF / subtitle #AAAAAA
// Effects: pulse (opacity breathing on symbol via LVGL anim)
// glitch (rapid opacity flicker on title via lv_timer)
// gyro (rotating arc ring around symbol)
// none (static)
//
// Objective 2 — play: real WAV streaming to I2S
// 1. Mount SD card (best-effort, /sdcard). Parse WAV header (PCM-16).
// 2. Stream PCM samples via s_spk_handle (extern, same I2S channel as TTS).
// 3. If SD absent / file not found → fallback bip + log.
// 4. Embedded test cue always available at virtual path "embedded://" or
// when the requested path starts with "embedded:" — proves WAV decode + I2S.
#include "cmd_exec.h"
#include <string.h>
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "bsp/esp-bsp.h"
#include "cJSON.h"
#include "esp_log.h"
#include "esp_vfs_fat.h"
#include "lvgl.h"
#include "embedded_wav.h" // s_embedded_wav[], EMBEDDED_WAV_SIZE
// audio_play_tone and s_spk_handle are defined in main.c
extern void audio_play_tone(float frequency, int duration_ms);
// Speaker I2S handle — obtained from main.c via a weak accessor.
// We declare a weak symbol here; main.c must define cmd_exec_set_spk_handle()
// and call it after speaker_init(). Alternatively we use the driver directly.
#include "driver/i2s_std.h"
static i2s_chan_handle_t s_spk_handle_local = NULL;
void cmd_exec_set_spk_handle(i2s_chan_handle_t h)
{
s_spk_handle_local = h;
}
#define TAG "cmd_exec"
// ─── Palette (matches idf_zacus/components/display_ui reference) ─────────────
#define COL_BG 0x0055AA // Workbench blue
#define COL_SYMBOL 0xFF8800 // Workbench orange
#define COL_TITLE 0xFFFFFF // white
#define COL_SUB 0xAAAAAA // mid grey
#define SCREEN_LOCK_MS 1000
// ─── Screen overlay state ─────────────────────────────────────────────────────
static lv_obj_t *s_overlay = NULL;
static lv_obj_t *s_title_lbl = NULL;
static lv_obj_t *s_sub_lbl = NULL;
static lv_obj_t *s_sym_lbl = NULL;
static lv_obj_t *s_gyro_arc = NULL;
// Active animations / timers (so we can stop them on next call)
static lv_timer_t *s_glitch_timer = NULL;
// ─── Effect callbacks ─────────────────────────────────────────────────────────
static void pulse_anim_cb(void *obj, int32_t v)
{
lv_obj_set_style_opa((lv_obj_t *)obj, (lv_opa_t)v, 0);
}
static void gyro_anim_cb(void *obj, int32_t v)
{
lv_arc_set_end_angle((lv_obj_t *)obj, (uint16_t)(v % 360));
}
static void glitch_timer_cb(lv_timer_t *t)
{
(void)t;
static bool flip = false;
flip = !flip;
lv_obj_set_style_opa(s_title_lbl,
flip ? LV_OPA_20 : LV_OPA_COVER, 0);
// Slight X jitter: move label 0 or 3 pixels right alternately
lv_obj_set_x(s_title_lbl, flip ? 3 : 0);
}
// ─── Stop all active effects ──────────────────────────────────────────────────
static void stop_effects_locked(void)
{
// Stop pulse animation on symbol
lv_anim_del(s_sym_lbl, pulse_anim_cb);
if (s_sym_lbl) {
lv_obj_set_style_opa(s_sym_lbl, LV_OPA_COVER, 0);
}
// Stop glitch timer
if (s_glitch_timer) {
lv_timer_pause(s_glitch_timer);
}
if (s_title_lbl) {
lv_obj_set_style_opa(s_title_lbl, LV_OPA_COVER, 0);
lv_obj_set_x(s_title_lbl, 0);
}
// Stop gyro arc
if (s_gyro_arc) {
lv_anim_del(s_gyro_arc, gyro_anim_cb);
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
}
}
// ─── Build overlay (lazy, first CMD only) ────────────────────────────────────
static void build_overlay_locked(void)
{
if (s_overlay) return;
lv_obj_t *scr = lv_screen_active();
// Full-screen panel with Workbench blue background
s_overlay = lv_obj_create(scr);
lv_obj_set_size(s_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(s_overlay, 0, 0);
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(s_overlay, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_overlay, 0, 0);
lv_obj_set_style_radius(s_overlay, 0, 0);
lv_obj_set_style_pad_all(s_overlay, 10, 0);
// Title — top, white, font 24
s_title_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_title_lbl, "");
lv_obj_set_style_text_font(s_title_lbl, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(s_title_lbl, lv_color_hex(COL_TITLE), 0);
lv_label_set_long_mode(s_title_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_title_lbl, LV_PCT(90));
lv_obj_set_style_text_align(s_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 14);
// Symbol — center, orange, font 48
s_sym_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_sym_lbl, "");
lv_obj_set_style_text_font(s_sym_lbl, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(s_sym_lbl, lv_color_hex(COL_SYMBOL), 0);
lv_obj_align(s_sym_lbl, LV_ALIGN_CENTER, 0, -10);
// Gyro arc: hidden rotating ring around symbol (gyro effect)
s_gyro_arc = lv_arc_create(s_overlay);
lv_obj_set_size(s_gyro_arc, 90, 90);
lv_obj_align(s_gyro_arc, LV_ALIGN_CENTER, 0, -10);
lv_arc_set_rotation(s_gyro_arc, 270);
lv_arc_set_bg_angles(s_gyro_arc, 0, 360);
lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(COL_SYMBOL), LV_PART_INDICATOR);
lv_obj_set_style_arc_width(s_gyro_arc, 4, LV_PART_INDICATOR);
lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_gyro_arc, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(s_gyro_arc, 0, 0);
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
// Subtitle — bottom, grey, font 14
s_sub_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_sub_lbl, "");
lv_obj_set_style_text_font(s_sub_lbl, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(s_sub_lbl, lv_color_hex(COL_SUB), 0);
lv_label_set_long_mode(s_sub_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_sub_lbl, LV_PCT(90));
lv_obj_set_style_text_align(s_sub_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(s_sub_lbl, LV_ALIGN_BOTTOM_MID, 0, -14);
// Glitch timer: created paused, resumed by glitch effect
s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL);
lv_timer_pause(s_glitch_timer);
}
// ─── op=screen ────────────────────────────────────────────────────────────────
static esp_err_t exec_screen(const cJSON *a)
{
const cJSON *title = a ? cJSON_GetObjectItemCaseSensitive(a, "t") : NULL;
const cJSON *sub = a ? cJSON_GetObjectItemCaseSensitive(a, "u") : NULL;
const cJSON *sym = a ? cJSON_GetObjectItemCaseSensitive(a, "y") : NULL;
const cJSON *eff = a ? cJSON_GetObjectItemCaseSensitive(a, "e") : NULL;
const char *t_str = cJSON_IsString(title) ? title->valuestring : "";
const char *u_str = cJSON_IsString(sub) ? sub->valuestring : "";
const char *y_str = cJSON_IsString(sym) ? sym->valuestring : "";
const char *e_str = cJSON_IsString(eff) ? eff->valuestring : "none";
ESP_LOGI(TAG, "CMD op=screen t=\"%s\" u=\"%s\" y=\"%s\" e=\"%s\"",
t_str, u_str, y_str, e_str);
if (!bsp_display_lock(SCREEN_LOCK_MS)) {
ESP_LOGW(TAG, "screen: display lock timeout");
return ESP_FAIL;
}
build_overlay_locked();
stop_effects_locked();
// Update labels
lv_label_set_text(s_title_lbl, t_str);
lv_label_set_text(s_sym_lbl, y_str);
lv_label_set_text(s_sub_lbl, u_str);
// Re-align after text update
lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 14);
lv_obj_align(s_sym_lbl, LV_ALIGN_CENTER, 0, -10);
lv_obj_align(s_sub_lbl, LV_ALIGN_BOTTOM_MID, 0, -14);
// Bring overlay to front
lv_obj_move_foreground(s_overlay);
// Apply effect
if (strcmp(e_str, "pulse") == 0) {
// Opacity breathing: COVER → 50 → COVER, 600ms per half
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, s_sym_lbl);
lv_anim_set_exec_cb(&a, pulse_anim_cb);
lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
lv_anim_set_time(&a, 600);
lv_anim_set_playback_time(&a, 600);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&a);
ESP_LOGI(TAG, "screen: pulse effect started");
} else if (strcmp(e_str, "glitch") == 0) {
// Rapid opacity + X-jitter on title
if (s_glitch_timer) {
lv_timer_resume(s_glitch_timer);
}
ESP_LOGI(TAG, "screen: glitch effect started");
} else if (strcmp(e_str, "gyro") == 0) {
// Rotating arc ring around the symbol
if (s_gyro_arc) {
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, s_gyro_arc);
lv_anim_set_exec_cb(&a, gyro_anim_cb);
lv_anim_set_values(&a, 0, 360);
lv_anim_set_time(&a, 1200);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&a);
}
ESP_LOGI(TAG, "screen: gyro effect started");
} else {
// "none" or unrecognised → static
ESP_LOGI(TAG, "screen: static (no effect)");
}
bsp_display_unlock();
return ESP_OK;
}
// ─── WAV player ───────────────────────────────────────────────────────────────
//
// WAV header layout (canonical PCM, 44 bytes):
// RIFF header : 12 bytes (RIFF, chunk size, WAVE)
// fmt chunk : 24 bytes (fmt , 16, pcm=1, channels, rate, byte_rate, align, bits)
// data chunk : 8 bytes (data, data_size)
// PCM samples : data_size bytes
#define WAV_HDR_RIFF_OFS 0
#define WAV_HDR_FMT_OFS 12
#define WAV_HDR_DATA_OFS 36
#define WAV_MIN_HEADER 44
typedef struct {
uint16_t audio_format;
uint16_t channels;
uint32_t sample_rate;
uint32_t byte_rate;
uint16_t block_align;
uint16_t bits_per_sample;
uint32_t data_offset; // offset of PCM data in the buffer/file
uint32_t data_size;
} wav_info_t;
static esp_err_t parse_wav_header(const uint8_t *buf, size_t buf_len, wav_info_t *out)
{
if (buf_len < WAV_MIN_HEADER) return ESP_ERR_INVALID_SIZE;
// RIFF
if (memcmp(buf, "RIFF", 4) != 0) return ESP_ERR_INVALID_ARG;
if (memcmp(buf + 8, "WAVE", 4) != 0) return ESP_ERR_INVALID_ARG;
// Walk chunks to find fmt and data (handles non-standard ordering / extra chunks)
size_t pos = 12;
bool got_fmt = false, got_data = false;
while (pos + 8 <= buf_len && !(got_fmt && got_data)) {
uint32_t chunk_size;
memcpy(&chunk_size, buf + pos + 4, 4);
if (memcmp(buf + pos, "fmt ", 4) == 0 && chunk_size >= 16) {
memcpy(&out->audio_format, buf + pos + 8, 2);
memcpy(&out->channels, buf + pos + 10, 2);
memcpy(&out->sample_rate, buf + pos + 12, 4);
memcpy(&out->byte_rate, buf + pos + 16, 4);
memcpy(&out->block_align, buf + pos + 20, 2);
memcpy(&out->bits_per_sample,buf + pos + 22, 2);
got_fmt = true;
} else if (memcmp(buf + pos, "data", 4) == 0) {
out->data_offset = (uint32_t)(pos + 8);
out->data_size = chunk_size;
got_data = true;
}
pos += 8 + chunk_size;
}
if (!got_fmt || !got_data) return ESP_ERR_NOT_FOUND;
if (out->audio_format != 1) {
ESP_LOGW(TAG, "WAV format %d is not PCM (1) — unsupported", out->audio_format);
return ESP_ERR_NOT_SUPPORTED;
}
return ESP_OK;
}
// Stream raw PCM-16 samples to the speaker I2S channel.
// Handles any sample rate (simple rate-adaptation via repeat/skip is NOT done here;
// the test WAV and typical cues should be 16 kHz mono = native rate).
static esp_err_t stream_pcm16(const uint8_t *pcm, size_t byte_len,
bool loop, int max_loops)
{
if (!s_spk_handle_local) {
ESP_LOGW(TAG, "play: no speaker handle — skip stream");
return ESP_ERR_INVALID_STATE;
}
int loops = 0;
do {
size_t offset = 0;
while (offset < byte_len) {
size_t chunk = (byte_len - offset < 1024) ? (byte_len - offset) : 1024;
size_t written = 0;
esp_err_t ret = i2s_channel_write(s_spk_handle_local,
pcm + offset, chunk,
&written, pdMS_TO_TICKS(500));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
return ret;
}
offset += chunk;
}
loops++;
} while (loop && (max_loops == 0 || loops < max_loops));
return ESP_OK;
}
// ─── SD card mount (best-effort, guarded) ────────────────────────────────────
static bool s_sd_mounted = false;
static void ensure_sd_mounted(void)
{
if (s_sd_mounted) return;
ESP_LOGI(TAG, "play: attempting SD card mount...");
esp_err_t ret = bsp_sdcard_mount();
if (ret == ESP_OK) {
s_sd_mounted = true;
ESP_LOGI(TAG, "play: SD card mounted at %s", BSP_SD_MOUNT_POINT);
} else {
ESP_LOGW(TAG, "play: SD card mount failed: %s (will use fallback)", esp_err_to_name(ret));
}
}
// ─── play task (off main task to allow loop without blocking CMD recv) ────────
typedef struct {
char path[128];
bool loop;
} play_args_t;
static void play_task(void *arg)
{
play_args_t *pargs = (play_args_t *)arg;
char path[128];
bool loop = pargs->loop;
strncpy(path, pargs->path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
free(pargs);
ESP_LOGI(TAG, "play_task: path=\"%s\" loop=%d", path, loop);
// Check for embedded asset path
bool use_embedded = (strncmp(path, "embedded:", 9) == 0 ||
strcmp(path, "<none>") == 0 ||
strlen(path) == 0);
if (!use_embedded) {
// Try SD card
ensure_sd_mounted();
if (!s_sd_mounted) {
ESP_LOGW(TAG, "play: SD not mounted, falling back to embedded asset");
use_embedded = true;
}
}
if (!use_embedded) {
// Build full path if not absolute
char full_path[160];
if (path[0] == '/') {
snprintf(full_path, sizeof(full_path), "%s", path);
} else {
snprintf(full_path, sizeof(full_path), "%s/%s", BSP_SD_MOUNT_POINT, path);
}
FILE *f = fopen(full_path, "rb");
if (!f) {
ESP_LOGW(TAG, "play: file not found: %s — falling back to embedded", full_path);
use_embedded = true;
} else {
// Read WAV from file
fseek(f, 0, SEEK_END);
long fsize = ftell(f);
fseek(f, 0, SEEK_SET);
if (fsize < WAV_MIN_HEADER || fsize > 512 * 1024) {
ESP_LOGW(TAG, "play: file size %ld bytes — out of range, fallback", fsize);
fclose(f);
use_embedded = true;
} else {
uint8_t *fbuf = malloc((size_t)fsize);
if (!fbuf) {
ESP_LOGW(TAG, "play: alloc failed for %ld bytes — fallback", fsize);
fclose(f);
use_embedded = true;
} else {
size_t nread = fread(fbuf, 1, (size_t)fsize, f);
fclose(f);
wav_info_t wi;
esp_err_t ret = parse_wav_header(fbuf, nread, &wi);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "play: WAV parse error %s — fallback", esp_err_to_name(ret));
free(fbuf);
use_embedded = true;
} else {
ESP_LOGI(TAG, "play: WAV ok — %" PRIu32 " Hz %" PRIu16 "-bit %" PRIu16 " ch, %" PRIu32 " bytes PCM",
wi.sample_rate, wi.bits_per_sample,
wi.channels, wi.data_size);
if (wi.bits_per_sample != 16) {
ESP_LOGW(TAG, "play: %" PRIu16 "-bit WAV — only 16-bit supported, fallback",
wi.bits_per_sample);
free(fbuf);
use_embedded = true;
} else {
// Real streaming from file
stream_pcm16(fbuf + wi.data_offset, wi.data_size, loop, 3);
free(fbuf);
use_embedded = false;
}
}
}
}
}
}
if (use_embedded) {
// Play embedded C5-E5-G5 cue (proves WAV decode + I2S path)
wav_info_t wi;
esp_err_t ret = parse_wav_header(s_embedded_wav, EMBEDDED_WAV_SIZE, &wi);
if (ret == ESP_OK && wi.bits_per_sample == 16) {
ESP_LOGI(TAG, "play: streaming embedded cue (%" PRIu32 " Hz %" PRIu16 "-bit, %" PRIu32 " bytes PCM)",
wi.sample_rate, wi.bits_per_sample, wi.data_size);
stream_pcm16(s_embedded_wav + wi.data_offset, wi.data_size, false, 1);
} else {
// Last-resort: beep
ESP_LOGW(TAG, "play: embedded WAV parse error %s — beep fallback",
esp_err_to_name(ret));
audio_play_tone(880.0f, 200);
}
}
vTaskDelete(NULL);
}
// ─── Gamebook narration player (async, streamed, interruptible) ──────────────
// One persistent task plays one WAV at a time, streaming straight from the SD
// in small chunks (no full-file malloc → any length, RAM-safe). A new request
// or a stop flag breaks the stream so tapping a choice cuts the narration.
static TaskHandle_t s_gb_task = NULL;
static char s_gb_path[160];
static volatile bool s_gb_new = false;
static volatile bool s_gb_stop = false;
static void gb_play_task(void *arg)
{
(void) arg;
uint8_t hdr[64];
uint8_t buf[2048];
for (;;) {
if (!s_gb_new) { vTaskDelay(pdMS_TO_TICKS(20)); continue; }
char path[160];
strncpy(path, s_gb_path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
s_gb_new = false;
s_gb_stop = false;
ensure_sd_mounted();
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "gb: open %s failed", path); continue; }
size_t hn = fread(hdr, 1, sizeof(hdr), f);
wav_info_t wi;
if (parse_wav_header(hdr, hn, &wi) != ESP_OK || wi.bits_per_sample != 16) {
ESP_LOGW(TAG, "gb: bad/usupported WAV %s", path);
fclose(f);
continue;
}
fseek(f, wi.data_offset, SEEK_SET);
size_t remaining = wi.data_size;
while (remaining > 0 && !s_gb_stop && !s_gb_new) {
size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf);
size_t got = fread(buf, 1, want, f);
if (got == 0) break;
size_t written = 0;
if (s_spk_handle_local) {
i2s_channel_write(s_spk_handle_local, buf, got, &written,
pdMS_TO_TICKS(500));
}
remaining -= got;
}
fclose(f);
}
}
void cmd_exec_play_file_async(const char *path)
{
if (!path || !path[0]) return;
strncpy(s_gb_path, path, sizeof(s_gb_path) - 1);
s_gb_path[sizeof(s_gb_path) - 1] = '\0';
s_gb_stop = true; // interrupt whatever is playing
s_gb_new = true; // … and pick up the new clip
if (!s_gb_task) {
xTaskCreate(gb_play_task, "gb_play", 4096, NULL, 4, &s_gb_task);
}
}
void cmd_exec_stop_play(void)
{
s_gb_stop = true;
s_gb_new = false;
}
// ─── op=play ──────────────────────────────────────────────────────────────────
static esp_err_t exec_play(const cJSON *a)
{
const cJSON *path_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
const cJSON *loop_j = a ? cJSON_GetObjectItemCaseSensitive(a, "l") : NULL;
const char *path = cJSON_IsString(path_j) ? path_j->valuestring : "embedded://cue";
bool loop = cJSON_IsNumber(loop_j) ? ((int)loop_j->valuedouble != 0) : false;
ESP_LOGI(TAG, "CMD op=play path=\"%s\" loop=%d", path, loop);
play_args_t *pargs = malloc(sizeof(play_args_t));
if (!pargs) {
ESP_LOGW(TAG, "play: alloc failed — beep fallback");
audio_play_tone(880.0f, 200);
return ESP_ERR_NO_MEM;
}
strncpy(pargs->path, path, sizeof(pargs->path) - 1);
pargs->path[sizeof(pargs->path) - 1] = '\0';
pargs->loop = loop;
// Spawn a dedicated task (4 KB stack) so we don't block the CMD receive path.
// Priority 4 = same as mic_capture.
if (xTaskCreate(play_task, "cmd_play", 4096, pargs, 4, NULL) != pdPASS) {
ESP_LOGW(TAG, "play: task create failed — beep fallback");
free(pargs);
audio_play_tone(880.0f, 200);
return ESP_FAIL;
}
return ESP_OK;
}
// ─── op=evt ───────────────────────────────────────────────────────────────────
static esp_err_t exec_evt(const cJSON *a)
{
const cJSON *name_j = a ? cJSON_GetObjectItemCaseSensitive(a, "n") : NULL;
const char *name = cJSON_IsString(name_j) ? name_j->valuestring : "<none>";
ESP_LOGI(TAG, "CMD op=evt n=\"%s\" (evt received on BOX-3 — logging only)", name);
return ESP_OK;
}
// ─── op=led ───────────────────────────────────────────────────────────────────
static esp_err_t exec_led(const cJSON *a)
{
const cJSON *pat_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
const char *pat = cJSON_IsString(pat_j) ? pat_j->valuestring : "<none>";
ESP_LOGI(TAG, "CMD op=led pattern=\"%s\" (stub — no LED driver in box3_voice)", pat);
return ESP_OK;
}
// ─── public entry point ───────────────────────────────────────────────────────
esp_err_t cmd_exec_handle(const char *data, size_t len)
{
if (!data || len == 0) return ESP_ERR_INVALID_ARG;
cJSON *root = cJSON_ParseWithLength(data, len);
if (!root) {
ESP_LOGW(TAG, "CMD parse error: not valid JSON (%.40s...)", data);
return ESP_ERR_INVALID_ARG;
}
const cJSON *op_j = cJSON_GetObjectItemCaseSensitive(root, "op");
if (!cJSON_IsString(op_j) || !op_j->valuestring || !op_j->valuestring[0]) {
ESP_LOGW(TAG, "CMD missing or invalid 'op' field");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
const char *op = op_j->valuestring;
const cJSON *a = cJSON_GetObjectItemCaseSensitive(root, "a");
esp_err_t err;
if (strcmp(op, "screen") == 0) {
err = exec_screen(a);
} else if (strcmp(op, "play") == 0) {
err = exec_play(a);
} else if (strcmp(op, "evt") == 0) {
err = exec_evt(a);
} else if (strcmp(op, "led") == 0) {
err = exec_led(a);
} else {
ESP_LOGW(TAG, "CMD op=\"%s\" unknown — ignored", op);
err = ESP_ERR_NOT_SUPPORTED;
}
cJSON_Delete(root);
return err;
}
+47
View File
@@ -0,0 +1,47 @@
// cmd_exec.h — BOX-3 executor for structured ESP-NOW CMD frames (D5 contract).
//
// Wire format (SCENARIO_MESH_TEXT_CMD, ≤200 bytes):
// {"op":"<verb>","a":{<args>}}
//
// Supported ops:
// screen {"t":"title","u":"subtitle","y":"symbol","e":"effect"} — display on LCD
// effect: "pulse"|"glitch"|"gyro"|"none"
// play {"p":"<path>","l":0|1} — stream WAV via I2S; embedded cue if no file
// evt {"n":"<name>"} — log the event (future: relay to master)
// led {"p":"<pattern>"} — log the pattern (stub)
// <any> unknown op — log warn, ignore
//
// Tolerant by design: malformed JSON, missing keys, or unknown ops log a warning
// and return without crashing. Never asserts on network input.
#pragma once
#include <stddef.h>
#include "esp_err.h"
#include "driver/i2s_std.h"
#ifdef __cplusplus
extern "C" {
#endif
// Parse and execute one CMD frame. `data` is the raw UTF-8 payload received via
// scenario_mesh text callback (NUL-terminated). Returns ESP_OK if the op was
// recognised and dispatched, ESP_ERR_INVALID_ARG on bad JSON or missing `op`,
// ESP_ERR_NOT_SUPPORTED for unknown ops (all logged, no crash).
esp_err_t cmd_exec_handle(const char *data, size_t len);
// Provide the speaker I2S channel handle so exec_play() can stream audio.
// Must be called from main.c after speaker_init(), before any CMD arrives.
void cmd_exec_set_spk_handle(i2s_chan_handle_t h);
// Play a 16 kHz mono 16-bit WAV from the SD asynchronously, streamed in chunks
// (RAM-safe for long narration) and INTERRUPTIBLE: a new call stops the current
// clip and starts the new one. Used by the touch gamebook for passage audio.
void cmd_exec_play_file_async(const char *path);
// Stop the async narration (no-op if nothing is playing).
void cmd_exec_stop_play(void);
#ifdef __cplusplus
}
#endif
File diff suppressed because it is too large Load Diff
+234
View File
@@ -0,0 +1,234 @@
// gamebook.c — see gamebook.h. Touch CYOA for the ESP32-S3-BOX-3.
#include "gamebook.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "bsp/esp-bsp.h"
#include "lvgl.h"
#include "cmd_exec.h" // async WAV narration player
static const char *TAG = "gamebook";
#define GB_DIR "/sdcard/gamebook"
#define GB_LIBRARY GB_DIR "/library.json"
#define GB_JSON_MAX (256 * 1024) // expanded books are ~80 KB of JSON
// Colours (Workbench-ish palette).
#define COL_BG 0x101820
#define COL_TITLE 0xFFCC55
#define COL_TEXT 0xF0F0F0
#define COL_BTN 0x224466
static cJSON *s_lib_root = NULL; // owns library.json
static cJSON *s_lib = NULL; // borrowed: root->"library"
static int s_lib_n = 0;
static cJSON *s_book = NULL; // owns the current <id>.json
static cJSON *s_passages = NULL; // borrowed: book->"passages"
static lv_obj_t *s_root = NULL; // scrollable full-screen column
// ── PSRAM allocators for cJSON (book trees are large) ───────────────────────
static void *gb_malloc(size_t sz) { return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); }
static void gb_free(void *p) { heap_caps_free(p); }
static cJSON *load_json(const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "open %s failed", path); return NULL; }
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0 || sz > GB_JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; }
char *buf = heap_caps_malloc((size_t)sz + 1, MALLOC_CAP_SPIRAM);
if (!buf) { fclose(f); return NULL; }
size_t rd = fread(buf, 1, (size_t)sz, f);
fclose(f);
buf[rd] = '\0';
cJSON *root = cJSON_Parse(buf);
heap_caps_free(buf);
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
return root;
}
// ── Root container (created once, cleared per view) ─────────────────────────
// All UI functions below assume the LVGL lock is held (gamebook_init takes it;
// button callbacks already run inside the LVGL task / lock).
static void ensure_root(void)
{
if (s_root) { lv_obj_clean(s_root); return; }
lv_obj_t *scr = lv_screen_active();
s_root = lv_obj_create(scr);
lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(s_root, 0, 0);
lv_obj_set_style_bg_color(s_root, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_root, 0, 0);
lv_obj_set_style_radius(s_root, 0, 0);
lv_obj_set_style_pad_all(s_root, 8, 0);
lv_obj_set_style_pad_row(s_root, 8, 0);
lv_obj_set_flex_flow(s_root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scroll_dir(s_root, LV_DIR_VER);
}
static lv_obj_t *add_title(const char *txt)
{
lv_obj_t *l = lv_label_create(s_root);
lv_obj_set_style_text_font(l, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(l, lv_color_hex(COL_TITLE), 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return l;
}
static lv_obj_t *add_text(const char *txt)
{
lv_obj_t *l = lv_label_create(s_root);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(l, lv_color_hex(COL_TEXT), 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return l;
}
static lv_obj_t *add_button(const char *txt, lv_event_cb_t cb, void *user)
{
lv_obj_t *b = lv_button_create(s_root);
lv_obj_set_width(b, LV_PCT(100));
lv_obj_set_style_bg_color(b, lv_color_hex(COL_BTN), 0);
lv_obj_set_style_pad_all(b, 10, 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, user);
lv_obj_t *l = lv_label_create(b);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return b;
}
// Forward decls
static void show_library(void);
static void enter_passage(const char *pid);
static void tile_cb(lv_event_t *e);
static void choice_cb(lv_event_t *e);
static void menu_cb(lv_event_t *e);
// ── Library menu ────────────────────────────────────────────────────────────
static void show_library(void)
{
cmd_exec_stop_play(); // silence any passage narration
if (s_book) { cJSON_Delete(s_book); s_book = NULL; s_passages = NULL; }
ensure_root();
add_title("Choisis ton histoire");
for (int i = 0; i < s_lib_n; i++) {
const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title");
add_button(cJSON_IsString(t) ? t->valuestring : "?",
tile_cb, (void *)(intptr_t)i);
}
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
}
// ── Passage ─────────────────────────────────────────────────────────────────
static void enter_passage(const char *pid)
{
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' missing", pid); return; }
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
const cJSON *text = cJSON_GetObjectItem(p, "text");
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
ensure_root();
add_title(cJSON_IsString(screen) ? screen->valuestring : "");
add_text(cJSON_IsString(text) ? text->valuestring : "");
// Narration: play this passage's WAV from the SD if the pack has audio.
// Interruptible — tapping a choice cuts it and plays the next passage.
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) {
char path[160];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, wav->valuestring);
cmd_exec_play_file_async(path);
}
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
if (n == 0) {
add_button("Revenir au menu", menu_cb, NULL);
} else {
for (int i = 0; i < n; i++) {
const cJSON *c = cJSON_GetArrayItem(choices, i);
const cJSON *lbl = cJSON_GetObjectItem(c, "label");
const cJSON *g = cJSON_GetObjectItem(c, "goto");
// goto valuestring stays valid while s_book is alive → use as user_data
add_button(cJSON_IsString(lbl) ? lbl->valuestring : "?",
choice_cb, cJSON_IsString(g) ? g->valuestring : NULL);
}
}
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, n);
}
static void load_book(int idx)
{
const cJSON *e = cJSON_GetArrayItem(s_lib, idx);
const cJSON *file = cJSON_GetObjectItem(e, "book");
if (!cJSON_IsString(file)) return;
char path[128];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, file->valuestring);
cJSON *book = load_json(path);
if (!book) return;
const cJSON *passages = cJSON_GetObjectItem(book, "passages");
const cJSON *start = cJSON_GetObjectItem(book, "start");
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
cJSON_Delete(book); return;
}
if (s_book) cJSON_Delete(s_book);
s_book = book;
s_passages = (cJSON *)passages;
ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring);
enter_passage(start->valuestring);
}
// ── Touch callbacks (run inside the LVGL task → lock already held) ───────────
static void tile_cb(lv_event_t *e) { load_book((int)(intptr_t)lv_event_get_user_data(e)); }
static void menu_cb(lv_event_t *e) { (void)e; show_library(); }
static void choice_cb(lv_event_t *e)
{
const char *goto_id = (const char *)lv_event_get_user_data(e);
if (goto_id) enter_passage(goto_id);
}
// ── Public init ─────────────────────────────────────────────────────────────
void gamebook_init(void)
{
cJSON_Hooks hooks = { .malloc_fn = gb_malloc, .free_fn = gb_free };
cJSON_InitHooks(&hooks); // keep the big book trees in PSRAM
if (bsp_sdcard_mount() != ESP_OK) {
ESP_LOGW(TAG, "no SD card — gamebook disabled");
return;
}
s_lib_root = load_json(GB_LIBRARY);
if (!s_lib_root) { ESP_LOGW(TAG, "no library.json — gamebook disabled"); return; }
s_lib = cJSON_GetObjectItem(s_lib_root, "library");
if (!cJSON_IsArray(s_lib) || cJSON_GetArraySize(s_lib) == 0) {
cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL;
ESP_LOGW(TAG, "empty library");
return;
}
s_lib_n = cJSON_GetArraySize(s_lib);
bsp_display_brightness_set(80);
if (bsp_display_lock(1000)) {
show_library();
bsp_display_unlock();
}
ESP_LOGI(TAG, "touch gamebook up (%d stories)", s_lib_n);
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
// gamebook — "livre dont tu es le héros" TACTILE pour l'ESP32-S3-BOX-3.
//
// Écran 320x240 + tactile : on lit le passage à l'écran et on TOUCHE un bouton
// pour choisir. Réutilise le pack gamebook du master sur la SD :
// /sdcard/gamebook/{library.json, <id>.json}
// où chaque passage porte {screen, text, choices:[{label, goto}]} (le champ
// "wav" éventuel est ignoré — version texte/tactile, pas d'audio requis).
//
// S'appuie sur la stack LVGL déjà démarrée par bsp_display_start() : on
// construit une UUI (menu d'histoires en liste, puis pages tactiles) et la
// navigation se fait dans les callbacks de boutons LVGL. 100% local, hors-ligne.
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Monte la SD, charge la bibliothèque et affiche le menu tactile des histoires.
// À appeler une fois depuis app_main, après bsp_display_start() et le serveur
// de fichiers (pour que la SD soit accessible). No-op si aucun pack présent.
void gamebook_init(void);
#ifdef __cplusplus
}
#endif
+41
View File
@@ -28,8 +28,10 @@
#include "scenario_server.h"
#include "plip_virtual.h"
#include "plip_ui.h"
#include "gamebook.h"
#include "stimulus.h"
#include "scenario_mesh.h"
#include "cmd_exec.h"
/* BSP header — provided by espressif/esp-box component */
#include "bsp/esp-bsp.h"
@@ -372,6 +374,28 @@ static void button_task(void *arg)
}
}
/* --------------- ESP-NOW CMD text callback --------------- */
// Called from the scenario_mesh text worker task for every CMD/EVT frame
// received via ESP-NOW. On the BOX-3 we only care about CMD (master→us).
static void on_mesh_text(uint8_t kind, const uint8_t src_mac[6], const char *text)
{
if (kind == SCENARIO_MESH_TEXT_CMD) {
ESP_LOGI(TAG, "ESP-NOW CMD from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
src_mac[0], src_mac[1], src_mac[2],
src_mac[3], src_mac[4], src_mac[5], text);
esp_err_t err = cmd_exec_handle(text, strlen(text));
if (err != ESP_OK && err != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(TAG, "cmd_exec_handle: %s", esp_err_to_name(err));
}
} else {
// EVT arriving at the BOX-3 (unusual — master echoing back).
ESP_LOGI(TAG, "ESP-NOW EVT from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
src_mac[0], src_mac[1], src_mac[2],
src_mac[3], src_mac[4], src_mac[5], text);
}
}
/* --------------- Application entry point --------------- */
void app_main(void)
@@ -399,6 +423,9 @@ void app_main(void)
/* Initialize persistent speaker output (used for test tone + TTS playback) */
speaker_init();
/* Pass speaker handle to CMD executor for real WAV playback */
cmd_exec_set_spk_handle(s_spk_handle);
/* Play test tone to verify audio output */
audio_test_tone();
@@ -437,6 +464,11 @@ void app_main(void)
} else {
ESP_LOGW(TAG, "stimulus_init failed — QR/melody generator off");
}
/* Touch gamebook: if a story pack is on the SD (/sdcard/gamebook/),
* take over the screen with a tap-to-choose "livre dont tu es le
* héros". No-op (and the phone UI stays) when no pack is present. */
gamebook_init();
}
/* Start the ESP-NOW receiver so the master can relay scenarios to us even
@@ -450,6 +482,15 @@ void app_main(void)
esp_err_to_name(mesh_err));
} else {
ESP_LOGI(TAG, "ESP-NOW scenario receiver active");
/* Register CMD text callback so the master can drive screen / audio / leds
* via POST /game/espnow/cmd {peer, command:<json>} (D5 contract). */
esp_err_t tcb_err = scenario_mesh_set_text_cb(on_mesh_text);
if (tcb_err != ESP_OK) {
ESP_LOGW(TAG, "scenario_mesh_set_text_cb: %s — CMD executor unavailable",
esp_err_to_name(tcb_err));
} else {
ESP_LOGI(TAG, "ESP-NOW CMD executor registered (op: screen/play/evt/led)");
}
}
/* TODO: Initialize ESP-SR WakeNet for wake-word detection
+142 -2
View File
@@ -14,6 +14,7 @@
#include <unistd.h>
#include "cJSON.h"
#include "cmd_exec.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_log.h"
@@ -22,6 +23,9 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
/* BSP SD card support (bsp_sdcard_mount, BSP_SD_MOUNT_POINT) */
#include "bsp/esp-bsp.h"
#define TAG "scenario_srv"
#define MAX_SCENARIO_BYTES (64 * 1024)
@@ -216,6 +220,132 @@ static esp_err_t handle_scenario_post(httpd_req_t *req) {
return send_json(req, "200 OK", buf);
}
// ---------- POST /game/cmd ----------
//
// WiFi-direct CMD endpoint: receives {op,a} JSON frames (≤512 bytes) and
// forwards them to cmd_exec_handle(). Replaces ESP-NOW CMD path for annexes.
#define MAX_CMD_BYTES 512
static esp_err_t handle_cmd_post(httpd_req_t *req) {
if (req->content_len <= 0 || req->content_len > MAX_CMD_BYTES) {
ESP_LOGW(TAG, "POST /game/cmd: bad content_len=%d", (int)req->content_len);
return send_error(req, "400 Bad Request", "body must be 1..512 bytes");
}
char body[MAX_CMD_BYTES + 1];
int total = 0;
while (total < (int)req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
ESP_LOGI(TAG, "POST /game/cmd (%d B): %.*s", total, total < 80 ? total : 80, body);
esp_err_t err = cmd_exec_handle(body, (size_t)total);
if (err == ESP_ERR_INVALID_ARG) {
return send_error(req, "400 Bad Request", "malformed cmd json or missing op");
}
return send_json(req, "200 OK", "{\"ok\":true}");
}
// ---------- POST /game/file ----------
//
// Write binary assets directly to the SD card over HTTP.
// Query param: ?path=sd/<relative/path> (only "sd/" prefix accepted)
// Route: sd/<…> → /sdcard/<…> (mkdir -p, up to 8 MiB, anti-traversal).
// Returns 503 if SD is not mounted, 403 on bad path, 200 JSON on success.
#define MAX_FILE_BYTES (8L * 1024L * 1024L)
static bool s_sd_mounted = false;
static esp_err_t ensure_sd_mounted(void) {
if (s_sd_mounted) return ESP_OK;
esp_err_t ret = bsp_sdcard_mount();
if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE /* already mounted */) {
s_sd_mounted = true;
ESP_LOGI(TAG, "file: SD mounted at %s", BSP_SD_MOUNT_POINT);
return ESP_OK;
}
ESP_LOGW(TAG, "file: SD mount failed: %s", esp_err_to_name(ret));
return ret;
}
static esp_err_t handle_file_post(httpd_req_t *req) {
char query[160], path_param[96];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
httpd_query_key_value(query, "path", path_param,
sizeof(path_param)) != ESP_OK) {
return send_error(req, "400 Bad Request", "missing path param");
}
// Only accept sd/<…> prefix; reject traversal and bare directory paths.
if (strncmp(path_param, "sd/", 3) != 0 ||
strstr(path_param, "..") ||
path_param[strlen(path_param) - 1] == '/') {
return send_error(req, "403 Forbidden", "path must start with sd/ and name a file");
}
if (req->content_len <= 0 || (long)req->content_len > MAX_FILE_BYTES) {
return send_error(req, "400 Bad Request", "file too large or empty");
}
if (ensure_sd_mounted() != ESP_OK) {
return send_error(req, "503 Service Unavailable", "sd_not_mounted");
}
// Build absolute destination: /sdcard/<…> (strip the "sd/" prefix).
char full[192];
snprintf(full, sizeof(full), "%s/%s", BSP_SD_MOUNT_POINT, path_param + 3);
// mkdir -p for all intermediate directories.
const size_t root_len = strlen(BSP_SD_MOUNT_POINT) + 1; // e.g. strlen("/sdcard/")
for (char *p = full + root_len; *p; p++) {
if (*p == '/') {
*p = '\0';
mkdir(full, 0775); // EEXIST is fine
*p = '/';
}
}
FILE *f = fopen(full, "wb");
if (!f) {
ESP_LOGE(TAG, "file: fopen %s failed (errno=%d)", full, errno);
return send_error(req, "500 Internal Server Error", "open failed");
}
char buf[1024];
int remaining = (int)req->content_len;
while (remaining > 0) {
const int want = remaining < (int)sizeof(buf) ? remaining : (int)sizeof(buf);
int got = httpd_req_recv(req, buf, want);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
fclose(f);
unlink(full);
return send_error(req, "400 Bad Request", "recv failed");
}
if (fwrite(buf, 1, (size_t)got, f) != (size_t)got) {
fclose(f);
unlink(full);
return send_error(req, "500 Internal Server Error", "write failed");
}
remaining -= got;
}
fclose(f);
char resp[192];
snprintf(resp, sizeof(resp),
"{\"status\":\"ok\",\"path\":\"%s\",\"bytes\":%d}",
path_param, (int)req->content_len);
ESP_LOGI(TAG, "file: stored %s (%d B)", full, (int)req->content_len);
return send_json(req, "200 OK", resp);
}
// ---------- public init ----------
httpd_handle_t scenario_server_handle(void) {
@@ -229,7 +359,7 @@ esp_err_t scenario_server_start(void) {
}
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 80;
cfg.max_uri_handlers = 8;
cfg.max_uri_handlers = 12;
cfg.stack_size = 8192;
esp_err_t err = httpd_start(&s_server, &cfg);
@@ -246,9 +376,19 @@ esp_err_t scenario_server_start(void) {
.uri = "/game/scenario", .method = HTTP_POST,
.handler = handle_scenario_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_file = {
.uri = "/game/file", .method = HTTP_POST,
.handler = handle_file_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_cmd = {
.uri = "/game/cmd", .method = HTTP_POST,
.handler = handle_cmd_post, .user_ctx = NULL,
};
httpd_register_uri_handler(s_server, &uri_healthz);
httpd_register_uri_handler(s_server, &uri_scenario);
httpd_register_uri_handler(s_server, &uri_file);
httpd_register_uri_handler(s_server, &uri_cmd);
ESP_LOGI(TAG, "scenario server up on :80 (GET /healthz, POST /game/scenario)");
ESP_LOGI(TAG, "scenario server up on :80 (GET /healthz, POST /game/scenario, POST /game/file, POST /game/cmd)");
return ESP_OK;
}
+6
View File
@@ -51,6 +51,12 @@ CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768
# CONFIG_ZACUS_WIFI_SSID="..."
# CONFIG_ZACUS_WIFI_PASSWORD="..."
# WiFi channel: must match the master ESP32's connected channel for ESP-NOW
# co-channel operation. The master (idf_zacus) connects to channel 11 on the
# lab multi-AP "Les cils" network. Set to 0 to scan all channels and connect
# to whichever AP responds first (may differ from master → ESP-NOW will break).
# CONFIG_ZACUS_WIFI_CHANNEL=11
# esp-box BSP uses the legacy i2c driver; esp_codec_dev uses driver_ng.
# Force-enable legacy mode so both coexist (and prevent the runtime abort
# in bsp_display_start when both drivers race to register on the same bus).
@@ -248,6 +248,30 @@ static bool s_browser_isdir[BROWSER_MAX_ENTRIES];
static uint8_t s_browser_count = 0;
static volatile uint8_t s_browser_sel = 0;
static volatile bool s_browser_open = false;
// ── Gamebook view ("livre dont vous êtes le héros") ─────────────────────────
static lv_obj_t *s_scr_gamebook;
static lv_obj_t *s_gb_title; // page title, top
static lv_obj_t *s_gb_body; // full passage text, wrapped
static lv_obj_t *s_gb_menu; // choice lines, bottom
static volatile bool s_gamebook_open = false;
static lv_obj_t *s_gb_body_cont; // scrollable container holding s_gb_body
static char s_gb_title_buf[64];
static char s_gb_body_buf[1100];
static char s_gb_menu_buf[160];
static volatile int s_gb_scroll_req = 0; // pending scroll: +pages down / -pages up
static volatile bool s_gb_scroll_home = false; // reset scroll to top on new passage
// ── Gamebook library (tile grid) ────────────────────────────────────────────
#define LIB_TILES 6
static lv_obj_t *s_scr_library;
static lv_obj_t *s_lib_tile[LIB_TILES];
static lv_obj_t *s_lib_label[LIB_TILES];
static volatile bool s_library_open = false;
static char s_lib_title_buf[LIB_TILES][48];
static int s_lib_count_buf = 0;
static int s_lib_sel_buf = 0;
static volatile bool s_browser_reload = false;
// ─── Intro — faithful port of the original cracktro ──────────────────────────
@@ -761,6 +785,93 @@ scroller:
lv_obj_set_pos(s_intro_scroll, sx, sy);
}
static void build_library_screen(void) {
s_scr_library = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_scr_library, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_scr_library, LV_OPA_COVER, 0);
lv_obj_clear_flag(s_scr_library, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *hdr = lv_label_create(s_scr_library);
lv_obj_set_style_text_color(hdr, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_text_font(hdr, &lv_font_montserrat_14, 0);
lv_label_set_text(hdr, "Choisis ton histoire");
lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 4);
for (int i = 0; i < LIB_TILES; i++) {
int col = i % 2, row = i / 2;
lv_obj_t *t = lv_obj_create(s_scr_library);
lv_obj_set_size(t, 228, 84);
lv_obj_set_pos(t, 8 + col * 236, 26 + row * 94);
lv_obj_set_style_radius(t, 8, 0);
lv_obj_set_style_bg_color(t, lv_color_hex(0x101820), 0);
lv_obj_set_style_bg_opa(t, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(t, 2, 0);
lv_obj_set_style_border_color(t, lv_color_hex(0x445566), 0);
lv_obj_set_style_pad_all(t, 6, 0);
lv_obj_clear_flag(t, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *l = lv_label_create(t);
lv_obj_set_style_text_color(l, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, 208);
lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(l, "");
lv_obj_center(l);
s_lib_tile[i] = t;
s_lib_label[i] = l;
}
}
static void build_gamebook_screen(void) {
s_scr_gamebook = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_scr_gamebook, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_scr_gamebook, LV_OPA_COVER, 0);
// Compact title line at the very top.
s_gb_title = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_title, LV_LABEL_LONG_DOT);
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 12);
lv_obj_set_style_text_align(s_gb_title, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s_gb_title, "");
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 4);
// Choice selector: a single line pinned at the very bottom. Left/Right cycle
// the selection, click confirms. Drawn last (foreground).
s_gb_menu = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_menu, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x182230), 0);
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(s_gb_menu, 5, 0);
lv_obj_set_style_text_font(s_gb_menu, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_DOT);
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
lv_obj_set_style_text_align(s_gb_menu, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s_gb_menu, "");
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_MID, 0, 0);
// Reading area: a vertically-scrollable container filling the whole height
// between the title and the choice line. Up/Down scroll it by half-pages.
s_gb_body_cont = lv_obj_create(s_scr_gamebook);
lv_obj_set_style_bg_color(s_gb_body_cont, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_gb_body_cont, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_gb_body_cont, 0, 0);
lv_obj_set_style_pad_all(s_gb_body_cont, 6, 0);
lv_obj_set_size(s_gb_body_cont, DUI_HOR_RES, 264); // 24 (title) .. 288 (menu)
lv_obj_set_pos(s_gb_body_cont, 0, 24);
lv_obj_set_scroll_dir(s_gb_body_cont, LV_DIR_VER);
lv_obj_set_scrollbar_mode(s_gb_body_cont, LV_SCROLLBAR_MODE_AUTO);
s_gb_body = lv_label_create(s_gb_body_cont);
lv_obj_set_style_text_color(s_gb_body, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(s_gb_body, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_gb_body, DUI_HOR_RES - 24); // container width minus padding
lv_label_set_text(s_gb_body, "");
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 0, 0);
}
static void build_status_screen(void) {
lv_obj_t *scr = s_scr_status;
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
@@ -853,10 +964,49 @@ static void apply_status(const display_status_t *s) {
// Active step → scene view; idle → status view. The 5-way buttons can
// override (1=force status, 2=force scene; 0=auto). Fade 240 ms, the
// original ui_manager default transition (SceneTransition::kFade, 240).
// Gamebook page: refresh its labels from the latest show() buffers, then
// apply any pending manual scroll (Up/Down) on the reading container.
if (s_gamebook_open) {
lv_label_set_text(s_gb_title, s_gb_title_buf);
lv_label_set_text(s_gb_body, s_gb_body_buf);
lv_label_set_text(s_gb_menu, s_gb_menu_buf);
if (s_gb_scroll_home) {
lv_obj_scroll_to_y(s_gb_body_cont, 0, LV_ANIM_OFF);
s_gb_scroll_home = false;
}
int req = s_gb_scroll_req;
if (req != 0) {
s_gb_scroll_req = 0;
// +1 page = read further down (content moves up): negative dy.
lv_obj_scroll_by(s_gb_body_cont, 0, -req * 130, LV_ANIM_ON);
}
}
// Library grid: refresh tile titles + highlight the selected one.
if (s_library_open) {
for (int i = 0; i < LIB_TILES; i++) {
if (i < s_lib_count_buf) {
lv_label_set_text(s_lib_label[i], s_lib_title_buf[i]);
lv_obj_clear_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN);
}
bool on = (i == s_lib_sel_buf);
lv_obj_set_style_border_color(s_lib_tile[i],
lv_color_hex(on ? COL_CODE : 0x445566), 0);
lv_obj_set_style_border_width(s_lib_tile[i], on ? 4 : 2, 0);
lv_obj_set_style_bg_color(s_lib_tile[i],
lv_color_hex(on ? 0x223040 : 0x101820), 0);
}
}
extern volatile uint8_t g_dui_view_override;
lv_obj_t *want;
if (!s_intro_done) {
want = s_scr_intro; // boot intro plays out first
} else if (s_library_open) {
want = s_scr_library; // story picker owns the screen
} else if (s_gamebook_open) {
want = s_scr_gamebook; // gamebook owns the screen while running
} else if (s_browser_open) {
want = s_scr_browser; // file browser on top of everything
} else if (s_shell_open) {
@@ -964,6 +1114,8 @@ static void display_task(void *arg) {
build_status_screen();
build_scene_screen();
build_shell_screen();
build_gamebook_screen();
build_library_screen();
build_browser_screen();
build_intro_screen();
@@ -1145,9 +1297,67 @@ extern "C" void display_ui_set_status(const display_status_t *s) {
}
}
static display_ui_key_hook_t s_key_hook = nullptr;
extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) {
s_key_hook = hook;
}
extern "C" void display_ui_gamebook_show(const char *title, const char *body,
const char *menu, bool reset_scroll) {
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
snprintf(s_gb_title_buf, sizeof(s_gb_title_buf), "%s", title ? title : "");
snprintf(s_gb_body_buf, sizeof(s_gb_body_buf), "%s", body ? body : "");
snprintf(s_gb_menu_buf, sizeof(s_gb_menu_buf), "%s", menu ? menu : "");
if (reset_scroll) s_gb_scroll_home = true; // new passage → back to top
s_gamebook_open = true;
s_dirty = true;
if (s_mutex) xSemaphoreGive(s_mutex);
}
extern "C" void display_ui_gamebook_scroll(int dir) {
// dir > 0 : read further down ; dir < 0 : back up. Accumulated and applied
// on the display task (this runs on the buttons task).
s_gb_scroll_req += (dir > 0) ? 1 : -1;
s_dirty = true;
}
extern "C" void display_ui_gamebook_hide(void) {
s_gamebook_open = false;
s_gb_scroll_req = 0;
s_dirty = true;
}
extern "C" void display_ui_library_show(const char *const *titles, int count,
int sel) {
if (count > LIB_TILES) count = LIB_TILES;
if (count < 0) count = 0;
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
s_lib_count_buf = count;
s_lib_sel_buf = (count > 0) ? ((sel % count + count) % count) : 0;
for (int i = 0; i < LIB_TILES; i++) {
snprintf(s_lib_title_buf[i], sizeof(s_lib_title_buf[i]), "%s",
(i < count && titles && titles[i]) ? titles[i] : "");
}
s_library_open = true;
s_gamebook_open = false; // library and a running story are exclusive
s_dirty = true;
if (s_mutex) xSemaphoreGive(s_mutex);
}
extern "C" void display_ui_library_hide(void) {
s_library_open = false;
s_dirty = true;
}
extern "C" void display_ui_handle_key(uint8_t key) {
static uint8_t s_brightness = 100;
// Takeover mode (e.g. gamebook): if the hook consumes the key, stop here.
if (s_key_hook && s_key_hook(key)) {
return;
}
// Any key skips the boot intro.
if (!s_intro_done) {
s_intro_done = true;
@@ -78,6 +78,59 @@ void display_ui_camera_frame(const uint8_t *gray, int width, int height);
*/
void display_ui_handle_key(uint8_t key);
/**
* @brief Install an optional key interceptor.
*
* If a hook is set and returns true for a given key, the key is consumed and
* the normal shell/scene handling is skipped. Lets a takeover mode (e.g. the
* gamebook) own the 5-way pad without display_ui having to depend on it.
* Pass NULL to remove the hook. Thread-safe (single pointer write).
*/
typedef bool (*display_ui_key_hook_t)(uint8_t key);
void display_ui_set_key_hook(display_ui_key_hook_t hook);
/**
* @brief Show a full-screen gamebook page (takes over the display).
*
* Dedicated "livre dont vous êtes le héros" view: a centred title, the full
* passage text wrapped over multiple lines, and a choice menu at the bottom.
* Forces this view until display_ui_gamebook_hide(). Thread-safe (buffers
* copied under the mutex; the actual LVGL render runs on the display task).
* Strings should be ASCII (the embedded fonts have no accents).
*
* @param title short page title (top)
* @param body full passage text (wrapped); may be long
* @param menu choice lines (bottom), e.g. "[OK] ...\n[<>] ..."
*/
void display_ui_gamebook_show(const char *title, const char *body,
const char *menu, bool reset_scroll);
/**
* @brief Scroll the gamebook reading area (Up/Down buttons).
* @param dir >0 = read further down, <0 = back up. Half-page per call.
*/
void display_ui_gamebook_scroll(int dir);
/** @brief Leave the gamebook view and return to the normal scene/status flow. */
void display_ui_gamebook_hide(void);
/**
* @brief Show the gamebook library as a grid of up to 6 tiles.
*
* Each tile shows a story title; the tile at index `sel` is highlighted. The
* gamebook owns the pad and calls this on every cursor move. Forces the
* library view until a story is loaded or display_ui_library_hide(). Titles
* should be ASCII.
*
* @param titles array of `count` story titles
* @param count number of stories (clamped to 6)
* @param sel highlighted tile index
*/
void display_ui_library_show(const char *const *titles, int count, int sel);
/** @brief Leave the library view. */
void display_ui_library_hide(void);
/**
* @brief Pop the pending shell-app launch request, if any.
*
@@ -15,6 +15,13 @@ idf_component_register(
log
joltwallet__littlefs
puzzle_state
media_manager
sd_storage
gamebook
PRIV_REQUIRES
local_puzzles
p7_coffre
p5_morse
p6_nfc
npc_engine
)
@@ -18,6 +18,8 @@
#include "esp_http_server.h"
#include "esp_littlefs.h"
#include "esp_log.h"
#include "media_manager.h"
#include "sd_storage.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -25,9 +27,14 @@
#include "nvs_flash.h"
#include "hints_client.h"
#include "gamebook.h"
#include "npc_engine.h"
#include "scenario_mesh.h"
#include "puzzle_binding.h"
#include "local_puzzles.h"
#include "p7_coffre.h"
#include "p5_morse.h"
#include "p6_nfc.h"
static const char *TAG = "game_endpoint";
@@ -36,11 +43,40 @@ static const char *TAG = "game_endpoint";
static puzzle_state_t *s_pstate = NULL;
// Last step id armed via POST /game/step. Empty string = none.
static char s_current_step_id[64] = {0};
// Last armed puzzle type: "qr" | "sound" | "none" | "" (empty = nothing armed yet).
static char s_current_armed[8] = {0};
// Last armed puzzle type: "qr" | "sound" | "morse" | "nfc" | "none" | ""
static char s_current_armed[12] = {0}; // "qr"|"sound"|"morse"|"nfc"|"none"
// Display scene metadata of the current step (lenient parse; defaults safe).
static scene_binding_t s_current_scene = {0};
// ─── P5 / P6 solved-callback context ────────────────────────────────────────
// Mirrors the on_qr_solved / on_sound_solved pattern in local_puzzles.c:
// context is copied at arm time, callback is called on the puzzle task.
typedef struct {
uint8_t id;
uint8_t frag[PB_MAX_FRAG];
uint8_t frag_len;
} ep_puzzle_ctx_t;
static ep_puzzle_ctx_t s_morse_ctx = {0};
static ep_puzzle_ctx_t s_nfc_ctx = {0};
static void on_morse_solved(void)
{
if (s_pstate) {
puzzle_state_report(s_pstate, s_morse_ctx.id,
s_morse_ctx.frag, s_morse_ctx.frag_len);
}
}
static void on_nfc_solved(void)
{
if (s_pstate) {
puzzle_state_report(s_pstate, s_nfc_ctx.id,
s_nfc_ctx.frag, s_nfc_ctx.frag_len);
}
}
// Whitelist mirrored in the 4xx error message so the operator can
// recover without grepping the source. Keep aligned with
// hints_client_set_group_profile() validation.
@@ -330,8 +366,13 @@ static esp_err_t scenario_apply_buffer(const char *body, size_t len,
// New scenario supersedes any live puzzle — disarm and clear remembered step.
local_puzzles_disarm();
p5_morse_disarm(); // no-op when CONFIG_ZACUS_P5_MORSE_ENABLE=n
p6_nfc_disarm(); // no-op when CONFIG_ZACUS_P6_NFC_ENABLE=n
s_current_step_id[0] = '\0';
memset(&s_current_scene, 0, sizeof(s_current_scene));
// Re-arm the coffre for the new game session.
// p7_coffre_lock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n.
p7_coffre_lock();
// Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3).
schedule_restart();
@@ -721,7 +762,7 @@ static esp_err_t handle_espnow_cmd_post(httpd_req_t *req) {
// Core step-change logic, shared by the HTTP handler and internal callers
// (e.g. shell app launch on the local display). Error contract:
// ESP_OK armed (armed_out = "qr"|"sound"|"none")
// ESP_OK armed (armed_out = "qr"|"sound"|"morse"|"nfc"|"none")
// ESP_ERR_INVALID_STATE not ready (no puzzle_state wired)
// ESP_ERR_NOT_SUPPORTED no scenario stored / storage unavailable
// ESP_ERR_NOT_FOUND unknown step id
@@ -752,6 +793,9 @@ esp_err_t game_endpoint_apply_step(const char *step_id,
// Disarm current puzzle before re-arming.
local_puzzles_disarm();
// Disarm P5/P6 stubs (no-ops when their CONFIG flags are off).
p5_morse_disarm();
p6_nfc_disarm();
// Parse binding for this step (+ display scene metadata, lenient).
puzzle_binding_t binding;
@@ -765,6 +809,22 @@ esp_err_t game_endpoint_apply_step(const char *step_id,
strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1);
s_current_step_id[sizeof(s_current_step_id) - 1] = '\0';
// Sync the NPC/hints engine (and, through it, the voice gateway) with the
// scene this step enters. This is what makes the phone NPCs hint on — and
// scripted calls auto-ring for — the current scene, regardless of whether
// the step was driven by the local game loop or an external POST /game/step.
// Best-effort: notify is async + server-side idempotent and never blocks
// the step change. Steps with no scene_id (or an unknown one) are skipped.
if (s_current_scene.scene_id[0] != '\0') {
(void) npc_engine_set_scene_by_id(s_current_scene.scene_id, 0);
}
// P7 coffre: fire the actuator when the final win step is reached.
// p7_coffre_unlock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n.
if (strcmp(step_id, "STEP_FINAL_WIN") == 0) {
p7_coffre_unlock();
}
const char *armed = "none";
if (binding.type == PB_QR) {
const char *ptrs[PB_MAX_CODES];
@@ -792,6 +852,26 @@ esp_err_t game_endpoint_apply_step(const char *step_id,
if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT;
if (aerr != ESP_OK) return aerr;
armed = "sound";
} else if (binding.type == PB_MORSE) {
// P5 morse: copy context then arm.
// p5_morse_arm() is a no-op stub when CONFIG_ZACUS_P5_MORSE_ENABLE=n.
s_morse_ctx.id = binding.id;
s_morse_ctx.frag_len = binding.fragment_len;
if (binding.fragment_len > 0) {
memcpy(s_morse_ctx.frag, binding.fragment, binding.fragment_len);
}
p5_morse_arm(binding.morse_expected, on_morse_solved);
armed = "morse";
} else if (binding.type == PB_NFC) {
// P6 nfc: copy context then arm.
// p6_nfc_arm() is a no-op stub when CONFIG_ZACUS_P6_NFC_ENABLE=n.
s_nfc_ctx.id = binding.id;
s_nfc_ctx.frag_len = binding.fragment_len;
if (binding.fragment_len > 0) {
memcpy(s_nfc_ctx.frag, binding.fragment, binding.fragment_len);
}
p6_nfc_arm(binding.nfc_uid, on_nfc_solved);
armed = "nfc";
}
strncpy(s_current_armed, armed, sizeof(s_current_armed) - 1);
@@ -866,6 +946,78 @@ static esp_err_t handle_step_post(httpd_req_t *req) {
}
}
// ─── POST /game/media/play — trigger a media_manager WAV cue ─────────────────
// Body: { "path": "apps/<id>/cue.wav" | "/littlefs/music/foo.wav" }. Streams
// the file to the MAX98357A via media_manager (P4 real playback). Lets the
// gateway / atelier fire audio cues without an NPC decision.
static esp_err_t handle_media_play_post(httpd_req_t *req) {
char body[513] = {0};
if (req->content_len <= 0 || req->content_len > 512) {
return send_error(req, "400 Bad Request", "body must be 1..512 bytes");
}
int total = 0;
while (total < (int) req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
cJSON *root = cJSON_Parse(body);
if (!root) {
return send_error(req, "400 Bad Request", "malformed json");
}
const cJSON *path_j = cJSON_GetObjectItemCaseSensitive(root, "path");
if (!cJSON_IsString(path_j) || !path_j->valuestring || path_j->valuestring[0] == '\0') {
cJSON_Delete(root);
return send_error(req, "400 Bad Request", "missing or empty path");
}
char path[160];
strncpy(path, path_j->valuestring, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
cJSON_Delete(root);
esp_err_t err = media_manager_play(path);
if (err == ESP_OK) {
char buf[200];
snprintf(buf, sizeof(buf), "{\"status\":\"playing\",\"path\":\"%s\"}", path);
return send_json(req, "200 OK", buf);
}
if (err == ESP_ERR_NOT_FOUND) {
return send_error(req, "404 Not Found", "file_not_found");
}
if (err == ESP_ERR_INVALID_ARG) {
return send_error(req, "400 Bad Request", "invalid_path");
}
return send_error(req, "500 Internal Server Error", esp_err_to_name(err));
}
// ─── POST /game/gamebook[?action=stop] ───────────────────────────────────────
//
// Start the "livre dont vous êtes le héros" mode (loads /sdcard/gamebook/
// gamebook.json, plays the start passage, takes over the 5-way pad). With
// ?action=stop it leaves gamebook mode. No body required.
static esp_err_t handle_gamebook_post(httpd_req_t *req) {
char query[32] = {0}, action[16] = {0};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
httpd_query_key_value(query, "action", action, sizeof(action));
}
if (strcmp(action, "stop") == 0) {
gamebook_stop();
return send_json(req, "200 OK", "{\"gamebook\":\"stopped\"}");
}
esp_err_t err = gamebook_start();
if (err != ESP_OK) {
return send_error(req, "503 Service Unavailable",
err == ESP_ERR_NOT_FOUND ? "gamebook_not_on_sd"
: "gamebook_load_failed");
}
return send_json(req, "200 OK", "{\"gamebook\":\"started\"}");
}
// ─── GET /game/puzzle_state ──────────────────────────────────────────────────
//
// Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}.
@@ -951,22 +1103,36 @@ static esp_err_t handle_file_post(httpd_req_t *req) {
sizeof(path_param)) != ESP_OK) {
return send_error(req, "400 Bad Request", "missing path param");
}
if (strncmp(path_param, "apps/", 5) != 0 || strstr(path_param, "..") ||
// Route: apps/<…> → /littlefs/apps/<…> (display shell tiles, ≤256 KiB);
// sd/<…> → /sdcard/<…> (P4 large assets: audio, images).
const bool to_sd = (strncmp(path_param, "sd/", 3) == 0);
const bool to_apps = (strncmp(path_param, "apps/", 5) == 0);
if ((!to_sd && !to_apps) || strstr(path_param, "..") ||
path_param[strlen(path_param) - 1] == '/') {
return send_error(req, "403 Forbidden", "path must be under apps/");
return send_error(req, "403 Forbidden", "path must be under apps/ or sd/");
}
if (req->content_len <= 0 ||
req->content_len > GAME_ENDPOINT_MAX_FILE_BYTES) {
return send_error(req, "400 Bad Request", "size 1..262144 bytes");
}
if (mount_storage_lazy() != ESP_OK) {
return send_error(req, "503 Service Unavailable", "storage_unavailable");
const long max_bytes = to_sd ? (8L * 1024 * 1024) : GAME_ENDPOINT_MAX_FILE_BYTES;
if (req->content_len <= 0 || (long) req->content_len > max_bytes) {
return send_error(req, "400 Bad Request", "file too large");
}
char full[160];
snprintf(full, sizeof(full), "/littlefs/%s", path_param);
char full[192];
size_t root_len;
if (to_sd) {
if (!sd_storage_ready()) {
return send_error(req, "503 Service Unavailable", "sd_not_mounted");
}
snprintf(full, sizeof(full), "/sdcard/%s", path_param + 3); // strip "sd/"
root_len = strlen("/sdcard/");
} else {
if (mount_storage_lazy() != ESP_OK) {
return send_error(req, "503 Service Unavailable", "storage_unavailable");
}
snprintf(full, sizeof(full), "/littlefs/%s", path_param);
root_len = strlen("/littlefs/");
}
// mkdir -p for intermediate directories.
for (char *p = full + strlen("/littlefs/"); *p; p++) {
for (char *p = full + root_len; *p; p++) {
if (*p == '/') {
*p = '\0';
mkdir(full, 0775); // EEXIST is fine
@@ -1007,6 +1173,43 @@ static esp_err_t handle_file_post(httpd_req_t *req) {
return send_json(req, "200 OK", resp);
}
// ─── DELETE /game/file?path=sd/<…>|apps/<…> — remove a staged file ───────────
// Same path routing/whitelist as the POST handler. Lets the host clean stale
// assets (e.g. orphan gamebook WAVs) off the SD without reflashing.
static esp_err_t handle_file_delete(httpd_req_t *req) {
char query[160], path_param[96];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
httpd_query_key_value(query, "path", path_param,
sizeof(path_param)) != ESP_OK) {
return send_error(req, "400 Bad Request", "missing path param");
}
const bool to_sd = (strncmp(path_param, "sd/", 3) == 0);
const bool to_apps = (strncmp(path_param, "apps/", 5) == 0);
if ((!to_sd && !to_apps) || strstr(path_param, "..") ||
path_param[strlen(path_param) - 1] == '/') {
return send_error(req, "403 Forbidden", "path must be under apps/ or sd/");
}
char full[192];
if (to_sd) {
if (!sd_storage_ready()) {
return send_error(req, "503 Service Unavailable", "sd_not_mounted");
}
snprintf(full, sizeof(full), "/sdcard/%s", path_param + 3);
} else {
if (mount_storage_lazy() != ESP_OK) {
return send_error(req, "503 Service Unavailable", "storage_unavailable");
}
snprintf(full, sizeof(full), "/littlefs/%s", path_param);
}
if (unlink(full) != 0) {
return send_error(req, "404 Not Found", "remove failed");
}
ESP_LOGI(TAG, "file removed: %s", full);
char resp[192];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", path_param);
return send_json(req, "200 OK", resp);
}
void game_endpoint_get_scene(scene_binding_t *out) {
if (!out) return;
// Lock-free snapshot, same class as get_puzzle_status: written on the
@@ -1078,12 +1281,30 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
.handler = handle_file_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_file_delete = {
.uri = "/game/file",
.method = HTTP_DELETE,
.handler = handle_file_delete,
.user_ctx = NULL,
};
static const httpd_uri_t uri_espnow_cmd_post = {
.uri = "/game/espnow/cmd",
.method = HTTP_POST,
.handler = handle_espnow_cmd_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_media_play_post = {
.uri = "/game/media/play",
.method = HTTP_POST,
.handler = handle_media_play_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_gamebook_post = {
.uri = "/game/gamebook",
.method = HTTP_POST,
.handler = handle_gamebook_post,
.user_ctx = NULL,
};
// Bring up the ESP-NOW mesh transport. The master is primarily a sender
// (the relay handler) but we also register the apply adapter so a peer
@@ -1148,6 +1369,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/step: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_file_delete);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register DELETE /game/file: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_file_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register /game/file: %s", esp_err_to_name(err));
@@ -1156,6 +1381,14 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
if (err != ESP_OK) {
ESP_LOGW(TAG, "register GET /game/puzzle_state: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_media_play_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/media/play: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_gamebook_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/gamebook: %s", esp_err_to_name(err));
}
ESP_LOGI(TAG, "game endpoint registered "
"(GET+POST /game/group_profile, POST /game/scenario%s, "
@@ -11,7 +11,7 @@
#define PB_MAX_FRAG 4 // = PUZZLE_MAX_FRAG
#define PB_MAX_PUZZLE_ID 8 // = PUZZLE_MAX_ID
typedef enum { PB_NONE = 0, PB_QR, PB_SOUND } puzzle_binding_type_t;
typedef enum { PB_NONE = 0, PB_QR, PB_SOUND, PB_MORSE, PB_NFC } puzzle_binding_type_t;
typedef struct {
puzzle_binding_type_t type; // PB_NONE if the step has no puzzle
@@ -23,6 +23,10 @@ typedef struct {
int melody[PB_MAX_NOTES];
size_t note_count;
int tolerance; // default 1
// morse (P5): expected word in uppercase, e.g. "ZACUS"
char morse_expected[32];
// nfc (P6): expected tag UID as colon-hex string, e.g. "A1:B2:C3:D4"
char nfc_uid[20];
// common
uint8_t fragment[PB_MAX_FRAG];
uint8_t fragment_len;
@@ -51,6 +55,7 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
#define SB_MAX_TITLE 48
#define SB_MAX_SUBTITLE 64
#define SB_MAX_SYMBOL 16
#define SB_MAX_SCENE_ID 40
typedef enum {
SB_FX_PULSE = 0, // default (original SceneEffect::kPulse)
@@ -61,6 +66,8 @@ typedef enum {
typedef struct {
bool present; // step has a scene object
char scene_id[SB_MAX_SCENE_ID]; // canonical SCENE_* id (sibling of step id;
// set even when no display scene object)
char title[SB_MAX_TITLE];
char subtitle[SB_MAX_SUBTITLE];
char symbol[SB_MAX_SYMBOL];
@@ -60,6 +60,10 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
ptype = PB_QR;
} else if (strcmp(jtype->valuestring, "sound") == 0) {
ptype = PB_SOUND;
} else if (strcmp(jtype->valuestring, "morse") == 0) {
ptype = PB_MORSE;
} else if (strcmp(jtype->valuestring, "nfc") == 0) {
ptype = PB_NFC;
} else {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
@@ -120,7 +124,7 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
out->codes[i][PB_MAX_LABEL - 1] = '\0';
}
out->code_count = (size_t)n;
} else { // PB_SOUND
} else if (ptype == PB_SOUND) {
const cJSON *jmelody = cJSON_GetObjectItemCaseSensitive(puzzle, "melody");
if (!cJSON_IsArray(jmelody)) {
cJSON_Delete(root);
@@ -156,6 +160,37 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
} else {
out->tolerance = 1;
}
} else if (ptype == PB_MORSE) {
// puzzle.expected: required string, uppercase word, e.g. "ZACUS"
const cJSON *jexp = cJSON_GetObjectItemCaseSensitive(puzzle, "expected");
if (!cJSON_IsString(jexp) || !jexp->valuestring || !jexp->valuestring[0]) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
if (strlen(jexp->valuestring) >= sizeof(out->morse_expected)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->morse_expected, jexp->valuestring,
sizeof(out->morse_expected) - 1);
out->morse_expected[sizeof(out->morse_expected) - 1] = '\0';
} else if (ptype == PB_NFC) {
// puzzle.uid: required string, colon-hex UID, e.g. "A1:B2:C3:D4"
const cJSON *juid = cJSON_GetObjectItemCaseSensitive(puzzle, "uid");
if (!cJSON_IsString(juid) || !juid->valuestring || !juid->valuestring[0]) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
if (strlen(juid->valuestring) >= sizeof(out->nfc_uid)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->nfc_uid, juid->valuestring, sizeof(out->nfc_uid) - 1);
out->nfc_uid[sizeof(out->nfc_uid) - 1] = '\0';
}
// id/type written last — all error exits above leave *out zeroed
@@ -204,9 +239,15 @@ esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
}
if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; }
// Canonical scene id (SCENE_*), sibling of the step "id". Captured
// unconditionally so npc_engine / the voice gateway can be synced even
// on steps that carry no display "scene" object.
copy_scene_str(out->scene_id, sizeof(out->scene_id),
cJSON_GetObjectItemCaseSensitive(step, "scene_id"));
const cJSON *scene = cJSON_GetObjectItemCaseSensitive(step, "scene");
if (!scene || !cJSON_IsObject(scene)) {
// No scene — present stays false, ESP_OK.
// No scene object — present stays false, ESP_OK (scene_id may be set).
cJSON_Delete(root);
return ESP_OK;
}
@@ -0,0 +1,11 @@
idf_component_register(
SRCS
"gamebook.c"
INCLUDE_DIRS
"include"
REQUIRES
json
display_ui
media_manager
log
)
+293
View File
@@ -0,0 +1,293 @@
// gamebook.c — see gamebook.h. Standalone gamebook player + library for the
// Freenove master. On boot it shows the library (a grid of story tiles);
// picking one loads and plays that book; finishing returns to the library.
#include "gamebook.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "display_ui.h"
#include "media_manager.h"
static const char *TAG = "gamebook";
#define GAMEBOOK_DIR "/sdcard/gamebook"
#define LIBRARY_JSON GAMEBOOK_DIR "/library.json"
#define JSON_MAX (64 * 1024) /* per-file JSON sanity cap */
#define LIB_MAX 6 /* matches the display tile grid */
typedef enum { GB_OFF, GB_LIBRARY, GB_STORY } gb_mode_t;
// Library state
static cJSON *s_lib_root = NULL; /* owns library.json while loaded */
static cJSON *s_lib = NULL; /* borrowed: root->"library" array */
static int s_lib_n = 0;
static int s_lib_sel = 0;
// Story state
static cJSON *s_book = NULL; /* owns the current <book>.json */
static cJSON *s_passages = NULL; /* borrowed: book->"passages" */
static char s_title[48] = {0};
static char s_current[48] = {0};
static int s_sel = 0; /* highlighted choice index */
static volatile gb_mode_t s_mode = GB_OFF;
bool gamebook_active(void) { return s_mode != GB_OFF; }
/* Read a whole JSON file from the SD into a cJSON tree (caller frees). */
static cJSON *load_json(const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "open %s failed", path); return NULL; }
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0 || sz > JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; }
char *buf = malloc((size_t)sz + 1);
if (!buf) { fclose(f); return NULL; }
size_t rd = fread(buf, 1, (size_t)sz, f);
fclose(f);
buf[rd] = '\0';
cJSON *root = cJSON_Parse(buf);
free(buf);
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
return root;
}
// ── Library ──────────────────────────────────────────────────────────────────
static void render_library(void)
{
const char *titles[LIB_MAX] = {0};
int n = (s_lib_n < LIB_MAX) ? s_lib_n : LIB_MAX;
for (int i = 0; i < n; i++) {
const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title");
titles[i] = cJSON_IsString(t) ? t->valuestring : "?";
}
display_ui_library_show(titles, n, s_lib_sel);
}
static void free_story(void)
{
media_manager_stop();
if (s_book) { cJSON_Delete(s_book); s_book = NULL; }
s_passages = NULL;
s_current[0] = '\0';
}
static esp_err_t open_library(void)
{
free_story();
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
s_lib_root = load_json(LIBRARY_JSON);
if (!s_lib_root) return ESP_ERR_NOT_FOUND;
s_lib = cJSON_GetObjectItem(s_lib_root, "library");
if (!cJSON_IsArray(s_lib) || cJSON_GetArraySize(s_lib) == 0) {
cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL;
ESP_LOGW(TAG, "library.json has no stories");
return ESP_ERR_INVALID_ARG;
}
s_lib_n = cJSON_GetArraySize(s_lib);
s_lib_sel = 0;
s_mode = GB_LIBRARY;
display_ui_gamebook_hide();
render_library();
ESP_LOGI(TAG, "library open (%d stories)", s_lib_n);
return ESP_OK;
}
// ── Story ─────────────────────────────────────────────────────────────────────
static const cJSON *cur_passage(void) { return cJSON_GetObjectItem(s_passages, s_current); }
static int cur_choice_count(void)
{
const cJSON *ch = cJSON_GetObjectItem(cur_passage(), "choices");
return cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
}
/* (Re)draw the current page. `home` true = new passage (reset scroll to top);
* false = same passage, only the choice cursor moved (keep scroll position).
* The bottom line shows ONE choice with < > arrows (Left/Right cycles). */
static void render_page(bool home)
{
const cJSON *p = cur_passage();
if (!cJSON_IsObject(p)) return;
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
const cJSON *text = cJSON_GetObjectItem(p, "text");
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
char menu[160];
if (n == 0) {
snprintf(menu, sizeof(menu), "~ Fin ~ (clic = bibliotheque)");
} else {
const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel),
"label");
const char *txt = cJSON_IsString(lbl) ? lbl->valuestring : "?";
if (n > 1) {
/* Down cycles the answer, Click validates. */
snprintf(menu, sizeof(menu), "%d/%d %s (bas / clic)",
s_sel + 1, n, txt);
} else {
snprintf(menu, sizeof(menu), "%s (clic)", txt);
}
}
display_ui_gamebook_show(
cJSON_IsString(screen) ? screen->valuestring : s_title,
cJSON_IsString(text) ? text->valuestring : "",
menu, home);
}
static void enter_passage(const char *pid)
{
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' not found", pid); return; }
snprintf(s_current, sizeof(s_current), "%s", pid);
s_sel = 0;
render_page(true);
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) {
char path[96];
snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_DIR, wav->valuestring);
media_manager_stop(); /* a choice skips the narration */
media_manager_play(path);
}
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, cur_choice_count());
}
/* Load story #idx from the library and play it. */
static void load_book(int idx)
{
const cJSON *e = cJSON_GetArrayItem(s_lib, idx);
const cJSON *file = cJSON_GetObjectItem(e, "book");
if (!cJSON_IsString(file)) { ESP_LOGW(TAG, "library[%d] has no book", idx); return; }
char path[128];
snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_DIR, file->valuestring);
cJSON *book = load_json(path);
if (!book) return;
const cJSON *passages = cJSON_GetObjectItem(book, "passages");
const cJSON *start = cJSON_GetObjectItem(book, "start");
const cJSON *title = cJSON_GetObjectItem(book, "title");
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
ESP_LOGW(TAG, "%s missing passages/start", path);
cJSON_Delete(book);
return;
}
free_story();
s_book = book;
s_passages = (cJSON *)passages;
snprintf(s_title, sizeof(s_title), "%s",
cJSON_IsString(title) ? title->valuestring : "");
s_mode = GB_STORY;
display_ui_library_hide();
ESP_LOGI(TAG, "load book \"%s\" @ '%s'", s_title, start->valuestring);
enter_passage(start->valuestring);
}
// ── Input ─────────────────────────────────────────────────────────────────────
/* MEASURED pad mapping on this hardware (ADC ladder, see calibration):
* key1 = CLICK (centre, ~0 mV)
* key2 = LEFT (gauche, ~700 mV)
* key4 = DOWN (bas, ~1350 mV)
* key5 = RIGHT (droite, ~1994 mV)
* key3 is never produced; the physical UP button is electrically dead
* (held = no voltage change), so nothing can depend on it. */
#define PAD_CLICK 1
#define PAD_LEFT 2
#define PAD_DOWN 4
#define PAD_RIGHT 5
/* Library nav: Down/Right → next tile, Left → previous, Click → open.
* Selection wraps, so the working buttons reach every story without Up. */
static bool library_key(uint8_t key)
{
if (s_lib_n <= 0) return true;
switch (key) {
case PAD_DOWN: case PAD_RIGHT:
s_lib_sel = (s_lib_sel + 1) % s_lib_n; render_library(); break;
case PAD_LEFT:
s_lib_sel = (s_lib_sel - 1 + s_lib_n) % s_lib_n; render_library(); break;
case PAD_CLICK:
load_book(s_lib_sel); break;
default: break;
}
return true;
}
/* Story nav (Up is dead, so it's never used):
* Left → scroll the reading text UP
* Right → scroll the reading text DOWN
* Down → next answer (cycles through all choices, wraps around)
* Click → validate the highlighted answer
* On an ending page (no choices): Left/Right still scroll, Click → library. */
static bool story_key(uint8_t key)
{
if (key == PAD_LEFT) { display_ui_gamebook_scroll(-1); return true; } /* scroll up */
if (key == PAD_RIGHT) { display_ui_gamebook_scroll(+1); return true; } /* scroll down */
int n = cur_choice_count();
if (n == 0) { /* ending → click returns to library */
if (key == PAD_CLICK) open_library();
return true;
}
switch (key) {
case PAD_DOWN: s_sel = (s_sel + 1) % n; render_page(false); break; /* next answer */
case PAD_CLICK: { /* validate */
const cJSON *choices = cJSON_GetObjectItem(cur_passage(), "choices");
const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel), "goto");
if (cJSON_IsString(g)) {
ESP_LOGI(TAG, "validate #%d -> '%s'", s_sel, g->valuestring);
enter_passage(g->valuestring);
}
break;
}
default: break;
}
return true;
}
static bool gamebook_key_hook(uint8_t key)
{
switch (s_mode) {
case GB_LIBRARY: return library_key(key);
case GB_STORY: return story_key(key);
default: return false; /* not active → let the shell have it */
}
}
// ── Public API ────────────────────────────────────────────────────────────────
esp_err_t gamebook_start(void)
{
return open_library();
}
void gamebook_stop(void)
{
if (s_mode == GB_OFF) return;
s_mode = GB_OFF;
free_story();
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
display_ui_gamebook_hide();
display_ui_library_hide();
ESP_LOGI(TAG, "stopped");
}
void gamebook_init(void)
{
/* Only register the pad hook here — the SD and media_manager aren't up yet
* at this point in boot. main.c calls gamebook_start() once they are, to
* boot into the library. */
display_ui_set_key_hook(gamebook_key_hook);
ESP_LOGI(TAG, "key hook installed");
}
@@ -0,0 +1,42 @@
#pragma once
// gamebook — "livre dont vous êtes le héros" mode for the Freenove master.
//
// Reads /sdcard/gamebook/gamebook.json (built by tools/gamebook/build_gamebook.py),
// plays each passage's WAV narration from the SD via media_manager, shows the
// passage title + choice menu on the display, and navigates with the 5-way pad
// (it installs a display_ui key hook so it owns the buttons while active).
// Fully local: no model, no gateway.
//
// JSON shape:
// { "title": "...", "start": "intro",
// "passages": { "intro": { "wav": "intro.wav", "screen": "...",
// "menu": "[OK] ... | [<>] ...",
// "choices": [ {"key": 1, "goto": "machine"}, ... ] },
// ... } }
// key codes match the 5-way pad: 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP.
// MENU (3) always quits the gamebook.
#include "esp_err.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Install the display_ui key hook. Call once at boot, after display_ui_init().
void gamebook_init(void);
// Load /sdcard/gamebook/gamebook.json and enter the start passage (show text +
// play its WAV). Returns ESP_ERR_* if the SD/JSON is missing or malformed.
esp_err_t gamebook_start(void);
// Leave gamebook mode: stop playback, release the JSON, hand the pad back to
// the shell. Idempotent.
void gamebook_stop(void);
// True while a gamebook is running (used by the key hook to claim the pad).
bool gamebook_active(void);
#ifdef __cplusplus
}
#endif
@@ -17,5 +17,7 @@ idf_component_register(
driver
esp_timer
esp_system
freertos
voice_pipeline
joltwallet__littlefs
)
@@ -0,0 +1,4 @@
## Managed dependencies for media_manager.
## Slice P4: helix MP3 decoder for the hotline_tts/*.mp3 cue pool.
dependencies:
chmorgan/esp-libhelix-mp3: "^1.0.3"
@@ -27,15 +27,25 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include "mp3dec.h" // P4: helix MP3 decoder (chmorgan/esp-libhelix-mp3)
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "esp_err.h"
#include "esp_log.h"
#include "esp_timer.h"
// Slice P4: real WAV playback streams 16-bit PCM to the MAX98357A over the
// I2S TX channel already owned by voice_pipeline (shared DAC, no codec init).
#include "voice_pipeline.h"
static const char *TAG = "media_manager";
#define MEDIA_DEFAULT_MUSIC_DIR "/littlefs/music"
@@ -59,6 +69,8 @@ static struct {
uint32_t playback_ends_ms;
FILE *recording_file;
uint32_t recording_data_bytes;
TaskHandle_t playback_task; // P4: real WAV streamer, NULL when idle
volatile bool playback_stop; // request the streamer to abort
} s_media;
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -149,10 +161,20 @@ static void resolve_play_path(char *out, size_t out_len, const char *path) {
return;
}
if (path[0] == '/') {
copy_text(out, out_len, path);
} else {
snprintf(out, out_len, "%s/%s", s_media.config.music_dir, path);
copy_text(out, out_len, path); // absolute: /sdcard/... or /littlefs/...
return;
}
// Relative cue: prefer the microSD copy when present (P4 — large asset
// store), else fall back to the LittleFS music_dir. file_exists() returns
// false when no card is mounted, so this is transparent without a hard
// dependency on sd_storage.
char sd_try[MEDIA_PATH_MAX];
snprintf(sd_try, sizeof(sd_try), "/sdcard/music/%s", path);
if (file_exists(sd_try)) {
copy_text(out, out_len, sd_try);
return;
}
snprintf(out, out_len, "%s/%s", s_media.config.music_dir, path);
}
static void sanitize_filename(char *out, size_t out_len,
@@ -332,6 +354,211 @@ void media_manager_note_step_change(void) {
}
}
// ─── P4: real WAV playback over the shared MAX98357A I2S TX ──────────────────
//
// Streams 16-bit PCM WAV from LittleFS to the DAC via voice_pipeline's TX
// channel. WAV (not MP3) keeps this decoder-free; MP3 stays a later slice.
// NOTE: shares the I2S TX with TTS — callers must not overlap a `speak_*`
// exchange with music playback (the NPC coordination layer already serialises
// cues). A TX mutex is the natural follow-up.
#define MEDIA_PLAY_CHUNK_BYTES 1024U
typedef struct {
uint32_t sample_rate;
uint16_t channels;
uint16_t bits;
uint32_t data_size; // PCM bytes in the data chunk
} wav_info_t;
static uint32_t rd_u32(const uint8_t *p) {
return (uint32_t) p[0] | ((uint32_t) p[1] << 8) |
((uint32_t) p[2] << 16) | ((uint32_t) p[3] << 24);
}
// Parse a RIFF/WAVE header, leaving `f` positioned at the PCM data. Accepts
// 16-bit PCM mono/stereo only; returns false otherwise.
static bool parse_wav_header(FILE *f, wav_info_t *out) {
uint8_t riff[12];
if (fread(riff, 1, 12, f) != 12) return false;
if (memcmp(riff, "RIFF", 4) != 0 || memcmp(riff + 8, "WAVE", 4) != 0) return false;
bool have_fmt = false;
uint8_t ck[8];
while (fread(ck, 1, 8, f) == 8) {
uint32_t csz = rd_u32(ck + 4);
if (memcmp(ck, "fmt ", 4) == 0) {
uint8_t fmt[16];
uint32_t want = csz < sizeof(fmt) ? csz : (uint32_t) sizeof(fmt);
if (fread(fmt, 1, want, f) != want) return false;
if (((uint16_t) fmt[0] | ((uint16_t) fmt[1] << 8)) != 1U) return false; // PCM
out->channels = (uint16_t) fmt[2] | ((uint16_t) fmt[3] << 8);
out->sample_rate = rd_u32(fmt + 4);
out->bits = (uint16_t) fmt[14] | ((uint16_t) fmt[15] << 8);
if (csz > want) fseek(f, (long) (csz - want), SEEK_CUR);
have_fmt = true;
} else if (memcmp(ck, "data", 4) == 0) {
out->data_size = csz;
return have_fmt && out->bits == 16U &&
(out->channels == 1U || out->channels == 2U);
} else {
fseek(f, (long) (csz + (csz & 1U)), SEEK_CUR); // skip + word-align pad
}
}
return false;
}
static void apply_volume(int16_t *s, size_t count, uint8_t volume) {
if (volume >= 100U) return;
for (size_t i = 0; i < count; ++i) {
s[i] = (int16_t) (((int32_t) s[i] * (int32_t) volume) / 100);
}
}
static void clear_playback_state(void) {
s_media.snapshot.playing = false;
s_media.snapshot.playing_path[0] = '\0';
s_media.playback_ends_ms = 0U;
s_media.playback_task = NULL;
}
// Stream a 16-bit PCM WAV to the DAC. play_start/play_end bracket the session.
static esp_err_t stream_wav(FILE *f, const char *path) {
wav_info_t wav = {0};
if (!parse_wav_header(f, &wav)) {
ESP_LOGE(TAG, "playback: %s is not a 16-bit PCM WAV", path);
return ESP_ERR_INVALID_ARG;
}
ESP_LOGI(TAG, "playback start: %s (wav %lu Hz, %uch, %lu B) vol=%u",
path, (unsigned long) wav.sample_rate, wav.channels,
(unsigned long) wav.data_size, s_media.volume);
esp_err_t err = voice_pipeline_play_start(wav.sample_rate, "wav");
if (err != ESP_OK) {
ESP_LOGW(TAG, "play_start failed: %s", esp_err_to_name(err));
return err;
}
uint8_t buf[MEDIA_PLAY_CHUNK_BYTES];
uint32_t remaining = wav.data_size, written = 0;
while (remaining > 0U && !s_media.playback_stop) {
size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf);
size_t n = fread(buf, 1, want, f);
if (n == 0U) break;
remaining -= (uint32_t) n;
size_t pcm = n;
if (wav.channels == 2U) { // downmix interleaved stereo16 → mono16
int16_t *s = (int16_t *) buf;
size_t frames = n / 4U;
for (size_t i = 0; i < frames; ++i)
s[i] = (int16_t) (((int32_t) s[2 * i] + s[2 * i + 1]) / 2);
pcm = frames * 2U;
}
apply_volume((int16_t *) buf, pcm / 2U, s_media.volume);
if (voice_pipeline_play_chunk(buf, pcm) != ESP_OK) break;
written += (uint32_t) pcm;
}
voice_pipeline_play_end();
ESP_LOGI(TAG, "playback done: %s (%lu B to DAC%s)", path,
(unsigned long) written, s_media.playback_stop ? ", stopped" : "");
return ESP_OK;
}
// Decode and stream an MP3 to the DAC via the helix decoder. Big work buffers
// live on the heap (the decoder also uses a few KB of stack — task is sized
// accordingly). Mono output is downmixed; volume is applied per frame.
static esp_err_t stream_mp3(FILE *f, const char *path) {
HMP3Decoder dec = MP3InitDecoder();
const size_t IN_SZ = 2048U;
uint8_t *inbuf = malloc(IN_SZ);
int16_t *pcm = malloc(2304 * sizeof(int16_t)); // MAX_NCHAN*MAX_NGRAN*MAX_NSAMP
if (dec == NULL || inbuf == NULL || pcm == NULL) {
if (dec) MP3FreeDecoder(dec);
free(inbuf); free(pcm);
ESP_LOGE(TAG, "mp3: alloc failed");
return ESP_ERR_NO_MEM;
}
int bytes_left = 0;
uint8_t *rp = inbuf;
bool started = false, eof = false;
uint32_t written = 0;
esp_err_t result = ESP_OK;
while (!s_media.playback_stop) {
if (bytes_left < 1024 && !eof) { // refill: keep tail, top up
memmove(inbuf, rp, (size_t) bytes_left);
size_t n = fread(inbuf + bytes_left, 1, IN_SZ - (size_t) bytes_left, f);
if (n == 0U) eof = true;
bytes_left += (int) n;
rp = inbuf;
}
if (bytes_left <= 0) break;
int off = MP3FindSyncWord(rp, bytes_left);
if (off < 0) { if (eof) break; bytes_left = 0; continue; }
rp += off; bytes_left -= off;
int derr = MP3Decode(dec, &rp, &bytes_left, pcm, 0);
if (derr == ERR_MP3_INDATA_UNDERFLOW) { if (eof) break; continue; }
if (derr != ERR_MP3_NONE) continue; // skip a bad frame
MP3FrameInfo info;
MP3GetLastFrameInfo(dec, &info);
if (info.outputSamps <= 0) continue;
if (!started) {
ESP_LOGI(TAG, "playback start: %s (mp3 %d Hz, %dch) vol=%u",
path, info.samprate, info.nChans, s_media.volume);
esp_err_t e = voice_pipeline_play_start((uint32_t) info.samprate, "mp3");
if (e != ESP_OK) { result = e; break; }
started = true;
}
size_t samps = (size_t) info.outputSamps, bytes;
if (info.nChans == 2) { // downmix stereo16 → mono16
size_t frames = samps / 2U;
for (size_t i = 0; i < frames; ++i)
pcm[i] = (int16_t) (((int32_t) pcm[2 * i] + pcm[2 * i + 1]) / 2);
bytes = frames * 2U;
} else {
bytes = samps * 2U;
}
apply_volume(pcm, bytes / 2U, s_media.volume);
if (voice_pipeline_play_chunk((uint8_t *) pcm, bytes) != ESP_OK) break;
written += (uint32_t) bytes;
}
if (started) voice_pipeline_play_end();
MP3FreeDecoder(dec);
free(inbuf); free(pcm);
ESP_LOGI(TAG, "playback done: %s (mp3, %lu B to DAC%s)", path,
(unsigned long) written, s_media.playback_stop ? ", stopped" : "");
if (!started && result == ESP_OK) result = ESP_ERR_NOT_SUPPORTED;
return result;
}
static void playback_task(void *arg) {
(void) arg;
char path[MEDIA_PATH_MAX];
copy_text(path, sizeof(path), s_media.snapshot.playing_path);
FILE *f = fopen(path, "rb");
if (f == NULL) {
ESP_LOGE(TAG, "playback: cannot open %s", path);
set_last_error("media_play_open");
clear_playback_state();
vTaskDelete(NULL);
return;
}
const char *ext = strrchr(path, '.');
esp_err_t err = (ext && strcasecmp(ext, ".mp3") == 0)
? stream_mp3(f, path)
: stream_wav(f, path);
fclose(f);
if (err != ESP_OK) {
set_last_error("media_play_failed");
}
clear_playback_state();
vTaskDelete(NULL);
}
esp_err_t media_manager_play(const char *path) {
if (!s_media.initialized) {
return ESP_ERR_INVALID_STATE;
@@ -354,15 +581,27 @@ esp_err_t media_manager_play(const char *path) {
return ESP_ERR_NOT_FOUND;
}
// TODO(slice-4+): hand off to the real I2S MP3 decoder here.
const uint32_t now_ms = (uint32_t) (esp_timer_get_time() / 1000LL);
// Stop any in-flight playback before starting the next cue.
if (s_media.playback_task != NULL) {
(void) media_manager_stop();
}
s_media.playback_stop = false;
s_media.snapshot.playing = true;
copy_text(s_media.snapshot.playing_path,
sizeof(s_media.snapshot.playing_path), resolved);
s_media.playback_ends_ms = now_ms + MEDIA_STUB_PLAYBACK_MS;
s_media.playback_ends_ms = 0U; // real playback: tick must not auto-clear
clear_last_error();
ESP_LOGI(TAG, "playing %s (simulated %ums) vol=%u",
resolved, MEDIA_STUB_PLAYBACK_MS, s_media.volume);
BaseType_t ok = xTaskCreate(playback_task, "media_play", 8192, NULL, 4,
&s_media.playback_task);
if (ok != pdPASS) {
s_media.playback_task = NULL;
s_media.snapshot.playing = false;
s_media.snapshot.playing_path[0] = '\0';
set_last_error("media_play_task");
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
@@ -370,6 +609,12 @@ esp_err_t media_manager_stop(void) {
if (!s_media.initialized) {
return ESP_ERR_INVALID_STATE;
}
if (s_media.playback_task != NULL) {
s_media.playback_stop = true;
for (int i = 0; i < 100 && s_media.playback_task != NULL; ++i) {
vTaskDelay(pdMS_TO_TICKS(10)); // let the streamer drain + self-delete
}
}
if (s_media.snapshot.playing) {
ESP_LOGI(TAG, "stop %s", s_media.snapshot.playing_path);
}
@@ -22,6 +22,7 @@ idf_component_register(
REQUIRES
media_manager
hints_client
esp_http_client
esp_timer
esp_system
nvs_flash
@@ -169,6 +169,16 @@ esp_err_t npc_engine_trigger_cue(const char *cue_id);
// and primes the stuck timer.
esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms);
// Same bridge as npc_engine_set_step(), but keyed by the canonical scene
// string id (e.g. "SCENE_WARNING") rather than its numeric index. Resolves
// the id against the engine's internal scene table and forwards to
// npc_engine_set_step(). Lets the game endpoint sync the NPC/hints engine
// (and the voice gateway) straight from the scenario IR's `scene_id`.
// Returns ESP_ERR_NOT_FOUND when the id matches no known scene (no notify
// is sent in that case), ESP_ERR_INVALID_ARG on a NULL/empty id.
esp_err_t npc_engine_set_scene_by_id(const char *scene_id,
uint32_t expected_duration_ms);
// Request a hint for `puzzle_id` at escalation `level` (0..3). The engine
// invokes `cb` with the resulting text. The current implementation is a
// LOCAL STUB: it returns a hardcoded French placeholder synchronously.
@@ -184,6 +194,12 @@ const npc_state_t *npc_engine_state(void);
// Thin wrapper kept here to give callers a single npc_engine_* surface.
esp_err_t npc_engine_set_group_profile(const char *profile);
// Point the engine at the voice gateway (tools/zacus-gateway). On each scene
// change the engine POSTs the active SCENE_* to {base_url}/game/step?scene=… so
// the phone NPCs disguise that scene's hint. Best-effort; pass NULL/"" to
// disable. `token` is sent as a Bearer header (gateway require_token).
void npc_engine_set_gateway(const char *base_url, const char *token);
// Write the active puzzle id (e.g. "SCENE_LA_DETECTOR") into `out`,
// truncated to `cap` bytes (NUL-terminated). Falls back to "SCENE_NPC"
// when no scene is active or the engine is not initialised so callers
@@ -17,16 +17,78 @@
#include "npc_engine.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "esp_err.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "media_manager.h"
#include "hints_client.h"
static const char *TAG = "npc_engine";
// Voice gateway (tools/zacus-gateway) — we push the active SCENE_* to it at each
// scene change so the phone NPCs disguise THIS scene's hint. Best-effort, set by
// npc_engine_set_gateway(); empty = disabled (no-op, e.g. CI / dry runs).
static char s_gw_url[128] = {0};
static char s_gw_token[80] = {0};
void npc_engine_set_gateway(const char *base_url, const char *token) {
if (base_url) snprintf(s_gw_url, sizeof(s_gw_url), "%s", base_url);
if (token) snprintf(s_gw_token, sizeof(s_gw_token), "%s", token);
}
// One-shot worker: POST {gateway}/game/step?scene=<scene> then self-delete.
// Runs on its own task so a slow/unreachable gateway NEVER stalls the scene
// transition (the game must not lag 2 s on every scene change if the gateway is
// offline). `arg` is a heap copy of the scene, freed here. Best-effort.
static void gw_notify_task(void *arg) {
char *scene = (char *) arg;
char url[256];
snprintf(url, sizeof(url), "%s/game/step?scene=%s", s_gw_url, scene);
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = 2000,
};
esp_http_client_handle_t cli = esp_http_client_init(&cfg);
if (cli) {
if (s_gw_token[0]) {
char auth[112];
snprintf(auth, sizeof(auth), "Bearer %s", s_gw_token);
esp_http_client_set_header(cli, "Authorization", auth);
}
esp_err_t err = esp_http_client_perform(cli);
if (err != ESP_OK) {
ESP_LOGW(TAG, "gateway /game/step notify failed: %s", esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "gateway scene -> %s (HTTP %d)", scene,
esp_http_client_get_status_code(cli));
}
esp_http_client_cleanup(cli);
}
free(scene);
vTaskDelete(NULL);
}
// Fire-and-forget the gateway scene notification on a detached task so a scene
// change is never blocked by the network. No-op if no gateway configured.
static void notify_gateway_scene(const char *scene) {
if (!s_gw_url[0] || !scene || !scene[0]) return;
size_t n = strlen(scene) + 1;
char *copy = malloc(n);
if (!copy) return;
memcpy(copy, scene, n);
if (xTaskCreate(gw_notify_task, "gw_notify", 4096, copy, 3, NULL) != pdPASS) {
ESP_LOGW(TAG, "gw_notify task spawn failed");
free(copy);
}
}
// ─── Core: scene/trigger/mood lookup tables (verbatim Arduino) ──────────────
static const char *const kSceneIds[] = {
@@ -372,10 +434,25 @@ esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) {
(unsigned) prev_scene, (unsigned) step_id);
}
(void) hints_client_puzzle_start(puzzle_id);
// Keep the voice gateway in sync so the phone NPCs hint on THIS scene.
notify_gateway_scene(puzzle_id);
}
return ESP_OK;
}
esp_err_t npc_engine_set_scene_by_id(const char *scene_id,
uint32_t expected_duration_ms) {
if (scene_id == NULL || scene_id[0] == '\0') return ESP_ERR_INVALID_ARG;
for (uint8_t i = 0; i < kSceneCount; i++) {
if (strcmp(scene_id, kSceneIds[i]) == 0) {
return npc_engine_set_step(i, expected_duration_ms);
}
}
ESP_LOGW(TAG, "set_scene_by_id: unknown scene \"%s\" (no kSceneIds match)",
scene_id);
return ESP_ERR_NOT_FOUND;
}
esp_err_t npc_engine_set_group_profile(const char *profile) {
// Thin pass-through. hints_client validates the value and logs the
// outcome. Kept on npc_engine so the rest of the firmware doesn't
+3 -2
View File
@@ -310,8 +310,9 @@ esp_err_t ota_server_init(void) {
// HTTP server config
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = OTA_SERVER_PORT;
config.max_uri_handlers = 20; // ota + voice_hook + game (incl. /game/step,
// /game/puzzle_state, /game/file, relay)
config.max_uri_handlers = 24; // ota + voice_hook + game (incl. /game/step,
// /game/puzzle_state, /game/file POST+DELETE,
// relay, /game/gamebook)
// + headroom
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
@@ -0,0 +1,10 @@
idf_component_register(
SRCS
"p5_morse.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_driver_gpio
log
freertos
)
@@ -0,0 +1,68 @@
// p5_morse.h — Morse / telegraph-key puzzle driver for Zacus master (P5).
//
// When CONFIG_ZACUS_P5_MORSE_ENABLE=n (default) all functions are empty stubs
// (init returns ESP_OK, arm/disarm are no-ops) so callers need no #ifdef guards.
//
// When enabled:
// 1. Call p5_morse_init() once at boot (after gpio driver is available).
// 2. Call p5_morse_arm(expected, puzzle_id, fragment, frag_len) when the
// gateway POSTs /game/step with a "morse" puzzle.
// 3. A FreeRTOS task decodes dot/dash timing on CONFIG_ZACUS_P5_GPIO and
// calls the solved_cb supplied to p5_morse_arm() when the decoded word
// matches `expected`.
// 4. Call p5_morse_disarm() on step change or game reset.
//
// Timing constants are set via Kconfig (DOT_MS, DASH_MS, GAP_MS).
// The key is active-LOW by default (CONFIG_ZACUS_P5_ACTIVE_LOW=y).
#pragma once
#include "esp_err.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Callback type invoked on the p5_morse task when the sequence is solved.
// Must be ISR-safe in the sense that it runs inside the morse task context;
// do NOT call p5_morse_arm/disarm from it — defer via a task notification.
typedef void (*p5_morse_solved_cb_t)(void);
/**
* @brief Initialise the P5 Morse puzzle driver.
*
* Configures CONFIG_ZACUS_P5_GPIO as input with pull-up and spawns the
* morse decoding task (blocked until p5_morse_arm() is called).
* Idempotent — safe to call more than once.
*
* When CONFIG_ZACUS_P5_MORSE_ENABLE=n returns ESP_OK immediately.
*
* @return ESP_OK on success, or a driver error code.
*/
esp_err_t p5_morse_init(void);
/**
* @brief Arm the morse puzzle for a specific expected sequence.
*
* @param expected NUL-terminated Morse code string to match,
* e.g. "...-" (upper-case letters via kMorseTable)
* OR a plain word like "ZACUS" (decoded internally).
* The string is copied — caller buffer may be transient.
* @param solved_cb Callback invoked exactly once when the sequence matches.
* Pass NULL to skip the notification (e.g. during tests).
*
* When CONFIG_ZACUS_P5_MORSE_ENABLE=n this is a no-op.
*/
void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb);
/**
* @brief Disarm the puzzle (stop decoding, reset state).
*
* When CONFIG_ZACUS_P5_MORSE_ENABLE=n this is a no-op.
*/
void p5_morse_disarm(void);
#ifdef __cplusplus
}
#endif
+265
View File
@@ -0,0 +1,265 @@
// p5_morse.c — P5 Morse / telegraph-key puzzle driver.
//
// All real implementation is inside #if CONFIG_ZACUS_P5_MORSE_ENABLE so the
// object compiles to pure stubs when the flag is off (default). No #ifdef
// leaks into callers — they include p5_morse.h and call unconditionally.
//
// Logic adapted from puzzles/p5_morse/main/main.c (standalone ESP-NOW
// firmware), repackaged as a library with an arm/disarm API and a solved
// callback instead of the espnow_slave_notify_solved path.
#include "p5_morse.h"
#include "sdkconfig.h"
#include "esp_log.h"
#include "esp_err.h"
static const char *TAG = "p5_morse";
#if CONFIG_ZACUS_P5_MORSE_ENABLE
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#include <stdbool.h>
// ─── Morse table A-Z ────────────────────────────────────────────────────────
static const char *kMorseTable[26] = {
".-", "-...", "-.-.", "-..", ".", "..-.", "--.",
"....", "..", ".---", "-.-", ".-..", "--", "-.",
"---", ".--.", "--.-", ".-.", "...", "-", "..-",
"...-", ".--", "-..-", "-.--", "--.."
};
// ─── State ──────────────────────────────────────────────────────────────────
// Expected word (decoded as letters, uppercase, e.g. "ZACUS").
static char s_expected[32] = {0};
// Accumulator for letters decoded so far.
static char s_received[32] = {0};
static uint8_t s_recv_pos = 0;
// Current symbol dot/dash buffer.
static char s_symbol[16] = {0};
static uint8_t s_sym_pos = 0;
static volatile bool s_armed = false;
static volatile bool s_solved = false;
static p5_morse_solved_cb_t s_solved_cb = NULL;
static bool s_initialised = false;
static TaskHandle_t s_task_handle = NULL;
// ─── Helpers ────────────────────────────────────────────────────────────────
static uint32_t now_ms(void)
{
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
static bool key_pressed(void)
{
#if CONFIG_ZACUS_P5_ACTIVE_LOW
return (gpio_get_level(CONFIG_ZACUS_P5_GPIO) == 0);
#else
return (gpio_get_level(CONFIG_ZACUS_P5_GPIO) != 0);
#endif
}
// Decode accumulated symbol (dot/dash string) to a letter and append it.
static void decode_symbol(void)
{
if (s_sym_pos == 0) return;
s_symbol[s_sym_pos] = '\0';
for (int i = 0; i < 26; i++) {
if (strcmp(kMorseTable[i], s_symbol) == 0) {
char letter = (char)('A' + i);
if (s_recv_pos < (uint8_t)(sizeof(s_received) - 1)) {
s_received[s_recv_pos++] = letter;
s_received[s_recv_pos] = '\0';
}
ESP_LOGD(TAG, "decoded: %s → %c (so far: %s)",
s_symbol, letter, s_received);
break;
}
}
s_sym_pos = 0;
memset(s_symbol, 0, sizeof(s_symbol));
}
// Check whether the received word matches; invoke callback on match.
static void check_word(void)
{
if (strcmp(s_received, s_expected) == 0) {
s_solved = true;
ESP_LOGI(TAG, "SOLVED: \"%s\" decoded correctly", s_expected);
if (s_solved_cb) {
s_solved_cb();
}
} else {
ESP_LOGW(TAG, "wrong word: got \"%s\", expected \"%s\" — resetting",
s_received, s_expected);
s_recv_pos = 0;
memset(s_received, 0, sizeof(s_received));
}
}
// ─── Morse decoding task ─────────────────────────────────────────────────────
static void morse_task(void *arg)
{
(void)arg;
uint32_t press_start = 0;
uint32_t last_release = 0;
bool key_down = false;
for (;;) {
if (!s_armed || s_solved) {
// Idle: yield and wait for the next arm cycle.
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
uint32_t t = now_ms();
bool pressed = key_pressed();
if (pressed && !key_down) {
// Key press start.
key_down = true;
press_start = t;
} else if (!pressed && key_down) {
// Key release — classify as dot or dash.
key_down = false;
uint32_t duration = t - press_start;
last_release = t;
if (duration <= (uint32_t)CONFIG_ZACUS_P5_DOT_MS) {
if (s_sym_pos < (uint8_t)(sizeof(s_symbol) - 1))
s_symbol[s_sym_pos++] = '.';
ESP_LOGD(TAG, "dot (%lu ms)", (unsigned long)duration);
} else if (duration >= (uint32_t)CONFIG_ZACUS_P5_DASH_MS) {
if (s_sym_pos < (uint8_t)(sizeof(s_symbol) - 1))
s_symbol[s_sym_pos++] = '-';
ESP_LOGD(TAG, "dash (%lu ms)", (unsigned long)duration);
} else {
ESP_LOGD(TAG, "ignored press %lu ms (between dot and dash thresholds)",
(unsigned long)duration);
}
} else if (!key_down && last_release > 0) {
uint32_t silence = t - last_release;
// Letter gap: decode accumulated symbol.
if (silence > (uint32_t)CONFIG_ZACUS_P5_GAP_MS && s_sym_pos > 0) {
decode_symbol();
last_release = t;
}
// Word gap (2x letter gap): check assembled word.
if (silence > (uint32_t)(CONFIG_ZACUS_P5_GAP_MS * 2u) && s_recv_pos > 0) {
check_word();
last_release = 0;
}
}
vTaskDelay(pdMS_TO_TICKS(10)); // 100 Hz sampling
}
}
// ─── Public API ─────────────────────────────────────────────────────────────
esp_err_t p5_morse_init(void)
{
if (s_initialised) return ESP_OK;
gpio_config_t cfg = {
.pin_bit_mask = (1ULL << CONFIG_ZACUS_P5_GPIO),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t err = gpio_config(&cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "gpio_config(GPIO %d): %s",
CONFIG_ZACUS_P5_GPIO, esp_err_to_name(err));
return err;
}
BaseType_t rc = xTaskCreate(morse_task, "p5_morse", 3072, NULL, 5,
&s_task_handle);
if (rc != pdPASS) {
ESP_LOGE(TAG, "xTaskCreate(p5_morse) failed");
return ESP_ERR_NO_MEM;
}
s_initialised = true;
ESP_LOGI(TAG, "init OK (GPIO %d, dot<=%d ms, dash>=%d ms, gap>%d ms)",
CONFIG_ZACUS_P5_GPIO,
CONFIG_ZACUS_P5_DOT_MS,
CONFIG_ZACUS_P5_DASH_MS,
CONFIG_ZACUS_P5_GAP_MS);
return ESP_OK;
}
void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb)
{
if (!s_initialised) {
ESP_LOGW(TAG, "arm called before init — ignored");
return;
}
// Reset state.
s_armed = false; // pause task briefly while we reset
s_solved = false;
s_recv_pos = 0;
s_sym_pos = 0;
memset(s_received, 0, sizeof(s_received));
memset(s_symbol, 0, sizeof(s_symbol));
if (expected && expected[0]) {
strncpy(s_expected, expected, sizeof(s_expected) - 1);
s_expected[sizeof(s_expected) - 1] = '\0';
} else {
s_expected[0] = '\0';
}
s_solved_cb = solved_cb;
s_armed = true;
ESP_LOGI(TAG, "armed, expected \"%s\"", s_expected);
}
void p5_morse_disarm(void)
{
s_armed = false;
s_solved = false;
s_recv_pos = 0;
s_sym_pos = 0;
memset(s_received, 0, sizeof(s_received));
memset(s_symbol, 0, sizeof(s_symbol));
ESP_LOGI(TAG, "disarmed");
}
#else // CONFIG_ZACUS_P5_MORSE_ENABLE not set — emit stubs only
esp_err_t p5_morse_init(void)
{
return ESP_OK;
}
void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb)
{
(void)expected;
(void)solved_cb;
/* stub — CONFIG_ZACUS_P5_MORSE_ENABLE=n */
}
void p5_morse_disarm(void)
{
/* stub — CONFIG_ZACUS_P5_MORSE_ENABLE=n */
}
#endif // CONFIG_ZACUS_P5_MORSE_ENABLE
@@ -0,0 +1,12 @@
idf_component_register(
SRCS
"p6_nfc.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_driver_gpio
esp_driver_i2c
esp_driver_spi
log
freertos
)
@@ -0,0 +1,72 @@
// p6_nfc.h — NFC / alchemical-symbols puzzle driver for Zacus master (P6).
//
// When CONFIG_ZACUS_P6_NFC_ENABLE=n (default) all functions are empty stubs
// (init returns ESP_OK, arm/disarm are no-ops) so callers need no #ifdef guards.
//
// When enabled:
// 1. Call p6_nfc_init() once at boot.
// 2. Call p6_nfc_arm(expected_uid, solved_cb) when the gateway POSTs
// /game/step with a "nfc" puzzle. `expected_uid` is a hex UID string
// like "A1:B2:C3:D4" (4-byte NTAG213) or a space-separated sequence
// of UIDs for ordered multi-tag placement.
// 3. A FreeRTOS task polls the NFC reader at ~5 Hz and calls solved_cb
// when the presented tag UID matches (or the full sequence is placed).
// 4. Call p6_nfc_disarm() on step change or game reset.
//
// Hardware backend is selected via Kconfig:
// PN532 over I2C or SPI → CONFIG_ZACUS_P6_READER_PN532
// PN7150 over I2C → CONFIG_ZACUS_P6_READER_PN7150
//
// The hardware HAL is STUBBED in this scaffold revision: the read function
// compiles, links, and logs "NFC read (stub)" but returns no UIDs until the
// real HAL is wired in. This is the same pattern as media_manager stubs.
#pragma once
#include "esp_err.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Callback type invoked on the p6_nfc task when the tag / sequence is solved.
// Must not call p6_nfc_arm/disarm from within — defer via task notification.
typedef void (*p6_nfc_solved_cb_t)(void);
/**
* @brief Initialise the P6 NFC puzzle driver.
*
* Initialises the configured bus (I2C or SPI) and the NFC reader IC.
* Spawns the NFC polling task (blocked until p6_nfc_arm() is called).
* Idempotent — safe to call more than once.
*
* When CONFIG_ZACUS_P6_NFC_ENABLE=n returns ESP_OK immediately.
*
* @return ESP_OK on success, or a driver error code.
*/
esp_err_t p6_nfc_init(void);
/**
* @brief Arm the NFC puzzle for a specific expected tag UID or sequence.
*
* @param expected_uid NUL-terminated UID string to match. Format:
* single tag → "A1:B2:C3:D4"
* The string is copied — caller buffer may be transient.
* @param solved_cb Callback invoked exactly once on a match.
* Pass NULL to skip (e.g. during tests).
*
* When CONFIG_ZACUS_P6_NFC_ENABLE=n this is a no-op.
*/
void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb);
/**
* @brief Disarm the puzzle (stop polling, reset state).
*
* When CONFIG_ZACUS_P6_NFC_ENABLE=n this is a no-op.
*/
void p6_nfc_disarm(void);
#ifdef __cplusplus
}
#endif
+290
View File
@@ -0,0 +1,290 @@
// p6_nfc.c — P6 NFC / alchemical-symbols puzzle driver.
//
// All real implementation is inside #if CONFIG_ZACUS_P6_NFC_ENABLE so the
// object compiles to pure stubs when the flag is off (default). No #ifdef
// leaks into callers.
//
// Hardware HAL for the NFC reader is STUBBED: nfc_hal_read_uid() returns
// false (no card present) and logs once per arm cycle. The real HAL will be
// implemented when the PN532/PN7150 module is wired (replace the stub block
// marked with "STUB" below).
//
// Logic adapted from puzzles/p6_symboles_nfc/main/main.c, repackaged as a
// library with arm/disarm API and a solved callback.
#include "p6_nfc.h"
#include "sdkconfig.h"
#include "esp_log.h"
#include "esp_err.h"
static const char *TAG = "p6_nfc";
#if CONFIG_ZACUS_P6_NFC_ENABLE
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#include <stdbool.h>
#include <stdio.h>
// ─── I2C / SPI bus init (HAL layer) ─────────────────────────────────────────
//
// The bus init and NFC read are separated into nfc_hal_init() /
// nfc_hal_read_uid() so the stub is easy to swap for real silicon code.
#if CONFIG_ZACUS_P6_BUS_I2C
#include "driver/i2c_master.h"
static i2c_master_bus_handle_t s_i2c_bus = NULL;
static i2c_master_dev_handle_t s_i2c_dev = NULL;
static esp_err_t nfc_hal_init(void)
{
i2c_master_bus_config_t bus_cfg = {
.i2c_port = I2C_NUM_0,
.sda_io_num = CONFIG_ZACUS_P6_I2C_SDA,
.scl_io_num = CONFIG_ZACUS_P6_I2C_SCL,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
esp_err_t err = i2c_new_master_bus(&bus_cfg, &s_i2c_bus);
if (err != ESP_OK) {
ESP_LOGE(TAG, "i2c_new_master_bus: %s", esp_err_to_name(err));
return err;
}
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = (uint16_t)CONFIG_ZACUS_P6_I2C_ADDR,
.scl_speed_hz = 100000,
};
err = i2c_master_bus_add_device(s_i2c_bus, &dev_cfg, &s_i2c_dev);
if (err != ESP_OK) {
ESP_LOGE(TAG, "i2c_master_bus_add_device(addr=0x%02X): %s",
CONFIG_ZACUS_P6_I2C_ADDR, esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "I2C bus init OK (SDA=%d SCL=%d addr=0x%02X)",
CONFIG_ZACUS_P6_I2C_SDA, CONFIG_ZACUS_P6_I2C_SCL,
CONFIG_ZACUS_P6_I2C_ADDR);
return ESP_OK;
}
// STUB: real I2C read of PN532/PN7150 comes here.
// Returns true and fills uid_out[4] when a card is present.
static bool nfc_hal_read_uid(uint8_t uid_out[4])
{
(void)uid_out;
// HAL stub — hardware not yet wired. Replace with PN532/PN7150 I2C
// read sequence once the module is connected.
ESP_LOGD(TAG, "NFC read (stub, I2C 0x%02X)", CONFIG_ZACUS_P6_I2C_ADDR);
return false;
}
#elif CONFIG_ZACUS_P6_BUS_SPI
#include "driver/spi_master.h"
static spi_device_handle_t s_spi = NULL;
static esp_err_t nfc_hal_init(void)
{
spi_bus_config_t buscfg = {
.miso_io_num = 13, // default SPI2 pins — override via sdkconfig if needed
.mosi_io_num = 11,
.sclk_io_num = 12,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 64,
};
// Use SPI3_HOST to avoid conflict with possible SD card on SPI2.
esp_err_t err = spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO);
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "spi_bus_initialize(SPI3): %s", esp_err_to_name(err));
return err;
}
spi_device_interface_config_t devcfg = {
.clock_speed_hz = 1000000, // 1 MHz (PN532 max SPI is 5 MHz)
.mode = 0,
.spics_io_num = 10, // CS pin — adjust via Kconfig when available
.queue_size = 4,
};
err = spi_bus_add_device(SPI3_HOST, &devcfg, &s_spi);
if (err != ESP_OK) {
ESP_LOGE(TAG, "spi_bus_add_device: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "SPI bus init OK (SPI3, CS=10)");
return ESP_OK;
}
// STUB: real SPI read of PN532 comes here.
static bool nfc_hal_read_uid(uint8_t uid_out[4])
{
(void)uid_out;
ESP_LOGD(TAG, "NFC read (stub, SPI)");
return false;
}
#endif // bus type
// ─── UID comparison helper ───────────────────────────────────────────────────
// Parse a colon-separated hex UID string "A1:B2:C3:D4" into uid[4].
// Returns true on success.
static bool parse_uid(const char *str, uint8_t uid[4])
{
unsigned int b[4];
if (sscanf(str, "%x:%x:%x:%x", &b[0], &b[1], &b[2], &b[3]) != 4)
return false;
for (int i = 0; i < 4; i++) uid[i] = (uint8_t)b[i];
return true;
}
// ─── State ──────────────────────────────────────────────────────────────────
static char s_expected_uid[20] = {0}; // "A1:B2:C3:D4\0"
static uint8_t s_expected_raw[4] = {0};
static bool s_expected_valid = false;
static volatile bool s_armed = false;
static volatile bool s_solved = false;
static p6_nfc_solved_cb_t s_solved_cb = NULL;
static bool s_initialised = false;
// ─── NFC polling task ────────────────────────────────────────────────────────
static void nfc_task(void *arg)
{
(void)arg;
uint8_t uid[4] = {0};
uint8_t last_uid[4] = {0};
uint32_t last_read_ms = 0;
for (;;) {
if (!s_armed || s_solved) {
vTaskDelay(pdMS_TO_TICKS(200));
continue;
}
uint32_t t = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
if (nfc_hal_read_uid(uid)) {
// Debounce: ignore same tag within 1 s.
bool same = (memcmp(uid, last_uid, 4) == 0);
if (!same || (t - last_read_ms) > 1000) {
memcpy(last_uid, uid, 4);
last_read_ms = t;
ESP_LOGI(TAG, "tag read: %02X:%02X:%02X:%02X",
uid[0], uid[1], uid[2], uid[3]);
if (s_expected_valid &&
memcmp(uid, s_expected_raw, 4) == 0) {
s_solved = true;
ESP_LOGI(TAG, "SOLVED: tag %s matched", s_expected_uid);
if (s_solved_cb) {
s_solved_cb();
}
} else {
ESP_LOGW(TAG, "tag %02X:%02X:%02X:%02X not the expected %s",
uid[0], uid[1], uid[2], uid[3], s_expected_uid);
}
}
}
vTaskDelay(pdMS_TO_TICKS(200)); // 5 Hz polling
}
}
// ─── Public API ─────────────────────────────────────────────────────────────
esp_err_t p6_nfc_init(void)
{
if (s_initialised) return ESP_OK;
esp_err_t err = nfc_hal_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "nfc_hal_init failed: %s", esp_err_to_name(err));
return err;
}
BaseType_t rc = xTaskCreate(nfc_task, "p6_nfc", 3072, NULL, 5, NULL);
if (rc != pdPASS) {
ESP_LOGE(TAG, "xTaskCreate(p6_nfc) failed");
return ESP_ERR_NO_MEM;
}
s_initialised = true;
#if CONFIG_ZACUS_P6_READER_PN532 && CONFIG_ZACUS_P6_BUS_I2C
ESP_LOGI(TAG, "init OK (PN532, I2C, stub HAL)");
#elif CONFIG_ZACUS_P6_READER_PN532
ESP_LOGI(TAG, "init OK (PN532, SPI, stub HAL)");
#else
ESP_LOGI(TAG, "init OK (PN7150, I2C, stub HAL)");
#endif
return ESP_OK;
}
void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb)
{
if (!s_initialised) {
ESP_LOGW(TAG, "arm called before init — ignored");
return;
}
s_armed = false;
s_solved = false;
s_expected_valid = false;
memset(s_expected_uid, 0, sizeof(s_expected_uid));
memset(s_expected_raw, 0, sizeof(s_expected_raw));
if (expected_uid && expected_uid[0]) {
strncpy(s_expected_uid, expected_uid, sizeof(s_expected_uid) - 1);
s_expected_uid[sizeof(s_expected_uid) - 1] = '\0';
s_expected_valid = parse_uid(s_expected_uid, s_expected_raw);
if (!s_expected_valid) {
ESP_LOGW(TAG, "arm: could not parse UID \"%s\" — tag match disabled",
expected_uid);
}
}
s_solved_cb = solved_cb;
s_armed = true;
ESP_LOGI(TAG, "armed, expected UID \"%s\"", s_expected_uid);
}
void p6_nfc_disarm(void)
{
s_armed = false;
s_solved = false;
ESP_LOGI(TAG, "disarmed");
}
#else // CONFIG_ZACUS_P6_NFC_ENABLE not set — emit stubs only
esp_err_t p6_nfc_init(void)
{
return ESP_OK;
}
void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb)
{
(void)expected_uid;
(void)solved_cb;
/* stub — CONFIG_ZACUS_P6_NFC_ENABLE=n */
}
void p6_nfc_disarm(void)
{
/* stub — CONFIG_ZACUS_P6_NFC_ENABLE=n */
}
#endif // CONFIG_ZACUS_P6_NFC_ENABLE
@@ -0,0 +1,11 @@
idf_component_register(
SRCS
"p7_coffre.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_driver_ledc
esp_driver_gpio
log
freertos
)
@@ -0,0 +1,60 @@
// p7_coffre — unlocking actuator for the Zacus final chest (P7).
//
// When CONFIG_ZACUS_P7_COFFRE_ENABLE=n (default) all three functions are
// empty stubs (init returns ESP_OK, unlock/lock are no-ops) so callers do
// not need #ifdef guards.
//
// When enabled, call p7_coffre_init() once at boot, then p7_coffre_unlock()
// when STEP_FINAL_WIN is reached. p7_coffre_lock() re-arms between games.
//
// Actuator selection and GPIO are configured via Kconfig (menuconfig or
// sdkconfig):
// SERVO LEDC TIMER_2 / CHANNEL_2, 50 Hz, 13-bit duty cycle.
// RELAY plain GPIO output with optional one-shot pulse.
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Initialise the P7 coffre actuator.
*
* Servo: configures LEDC timer + channel and moves to the locked position.
* Relay: configures the GPIO as output and asserts the rest (inactive) state.
* Idempotent — safe to call more than once.
*
* When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a stub that returns ESP_OK.
*
* @return ESP_OK on success, or a driver error code.
*/
esp_err_t p7_coffre_init(void);
/**
* @brief Unlock the coffre (STEP_FINAL_WIN reached).
*
* Servo: moves to CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG.
* Relay: energises the coil; if CONFIG_ZACUS_P7_RELAY_PULSE_MS > 0 the coil
* is de-energised after that many milliseconds (blocking call on the
* caller's task — keep short; 800 ms default).
*
* When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a no-op.
*/
void p7_coffre_unlock(void);
/**
* @brief Re-arm the coffre to the locked position (between games).
*
* Servo: moves back to CONFIG_ZACUS_P7_SERVO_LOCK_DEG.
* Relay: de-energises the coil (if not already de-energised by the pulse).
*
* When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a no-op.
*/
void p7_coffre_lock(void);
#ifdef __cplusplus
}
#endif
+232
View File
@@ -0,0 +1,232 @@
// p7_coffre — P7 coffre unlocking actuator driver.
//
// All real implementation is inside #if CONFIG_ZACUS_P7_COFFRE_ENABLE so the
// object compiles to pure stubs when the flag is off (default). No #ifdef
// leaks into callers — they include p7_coffre.h and call the three functions
// unconditionally.
#include "p7_coffre.h"
#include "sdkconfig.h"
#include "esp_log.h"
#include "esp_err.h"
static const char *TAG = "p7_coffre";
// ─── Servo duty helper ──────────────────────────────────────────────────────
//
// Maps an angle in degrees [0, 180] to a LEDC duty value.
//
// Timer parameters (compile-time constants):
// Period : 20 ms (50 Hz)
// Pulse range : 500 µs (0°) … 2500 µs (180°) — standard SG90 / MG90S
// Resolution : 13 bits → 8192 counts per 20 ms
//
// counts_per_us = 8192 / 20000 = 0.4096
// duty(0°) = 500 * 0.4096 = 205
// duty(90°) = 1500 * 0.4096 = 614
// duty(180°) = 2500 * 0.4096 = 1024
#if CONFIG_ZACUS_P7_COFFRE_ENABLE
#include "driver/ledc.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
// LEDC resources — chosen to avoid conflicts with existing assignments:
// TIMER_0 / CHANNEL_0 = camera XCLK (qr_puzzle)
// TIMER_1 / CHANNEL_1 = display backlight (display_ui)
// TIMER_2 / CHANNEL_2 = P7 servo (this file)
#define P7_LEDC_TIMER LEDC_TIMER_2
#define P7_LEDC_CHANNEL LEDC_CHANNEL_2
#define P7_LEDC_MODE LEDC_LOW_SPEED_MODE
#define P7_LEDC_RES LEDC_TIMER_13_BIT // 8192 counts per period
#define P7_LEDC_FREQ_HZ 50u // 20 ms period
// counts = pulse_us * (2^13 / 20000)
// Use integer arithmetic: counts = pulse_us * 8192 / 20000
#define P7_SERVO_COUNTS(pulse_us) ((uint32_t)(pulse_us) * 8192u / 20000u)
// Pulse width at each extreme (SG90-compatible).
#define P7_SERVO_PULSE_MIN_US 500u // 0°
#define P7_SERVO_PULSE_MAX_US 2500u // 180°
// Convert angle [0, 180] to duty count.
static uint32_t angle_to_duty(int deg) {
if (deg < 0) deg = 0;
if (deg > 180) deg = 180;
uint32_t pulse_us = P7_SERVO_PULSE_MIN_US +
(uint32_t) deg *
(P7_SERVO_PULSE_MAX_US - P7_SERVO_PULSE_MIN_US) / 180u;
return P7_SERVO_COUNTS(pulse_us);
}
static bool s_initialised = false;
// ─── Servo path ─────────────────────────────────────────────────────────────
#if CONFIG_ZACUS_P7_ACTUATOR_SERVO
static esp_err_t servo_set_angle(int deg) {
uint32_t duty = angle_to_duty(deg);
esp_err_t err = ledc_set_duty(P7_LEDC_MODE, P7_LEDC_CHANNEL, duty);
if (err != ESP_OK) return err;
return ledc_update_duty(P7_LEDC_MODE, P7_LEDC_CHANNEL);
}
esp_err_t p7_coffre_init(void) {
if (s_initialised) return ESP_OK;
// Configure the 50 Hz timer.
ledc_timer_config_t timer_cfg = {
.speed_mode = P7_LEDC_MODE,
.duty_resolution = P7_LEDC_RES,
.timer_num = P7_LEDC_TIMER,
.freq_hz = P7_LEDC_FREQ_HZ,
.clk_cfg = LEDC_AUTO_CLK,
};
esp_err_t err = ledc_timer_config(&timer_cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "ledc_timer_config: %s", esp_err_to_name(err));
return err;
}
// Bind the channel to the GPIO at the lock angle.
uint32_t lock_duty = angle_to_duty(CONFIG_ZACUS_P7_SERVO_LOCK_DEG);
ledc_channel_config_t ch_cfg = {
.gpio_num = CONFIG_ZACUS_P7_GPIO,
.speed_mode = P7_LEDC_MODE,
.channel = P7_LEDC_CHANNEL,
.intr_type = LEDC_INTR_DISABLE,
.timer_sel = P7_LEDC_TIMER,
.duty = lock_duty,
.hpoint = 0,
};
err = ledc_channel_config(&ch_cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "ledc_channel_config: %s", esp_err_to_name(err));
return err;
}
s_initialised = true;
ESP_LOGI(TAG, "servo init OK (GPIO %d, lock=%d°, unlock=%d°, "
"LEDC TIMER_%d/CH_%d 50 Hz 13-bit)",
CONFIG_ZACUS_P7_GPIO,
CONFIG_ZACUS_P7_SERVO_LOCK_DEG,
CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG,
(int) P7_LEDC_TIMER, (int) P7_LEDC_CHANNEL);
return ESP_OK;
}
void p7_coffre_unlock(void) {
if (!s_initialised) {
ESP_LOGW(TAG, "unlock called before init — ignored");
return;
}
esp_err_t err = servo_set_angle(CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG);
if (err != ESP_OK) {
ESP_LOGE(TAG, "servo_set_angle(%d): %s",
CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG, esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "coffre UNLOCKED (servo -> %d deg)",
CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG);
}
}
void p7_coffre_lock(void) {
if (!s_initialised) return;
esp_err_t err = servo_set_angle(CONFIG_ZACUS_P7_SERVO_LOCK_DEG);
if (err != ESP_OK) {
ESP_LOGE(TAG, "servo_set_angle(%d): %s",
CONFIG_ZACUS_P7_SERVO_LOCK_DEG, esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "coffre re-LOCKED (servo -> %d deg)",
CONFIG_ZACUS_P7_SERVO_LOCK_DEG);
}
}
#endif // CONFIG_ZACUS_P7_ACTUATOR_SERVO
// ─── Relay path ─────────────────────────────────────────────────────────────
#if CONFIG_ZACUS_P7_ACTUATOR_RELAY
// Helper: set the relay coil to active (energised) or rest.
// Handles active-high vs active-low inversion.
static void relay_set(bool active) {
int level;
#if CONFIG_ZACUS_P7_RELAY_ACTIVE_HIGH
level = active ? 1 : 0;
#else
level = active ? 0 : 1;
#endif
gpio_set_level(CONFIG_ZACUS_P7_GPIO, level);
}
esp_err_t p7_coffre_init(void) {
if (s_initialised) return ESP_OK;
gpio_config_t cfg = {
.pin_bit_mask = (1ULL << CONFIG_ZACUS_P7_GPIO),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t err = gpio_config(&cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "gpio_config(GPIO %d): %s",
CONFIG_ZACUS_P7_GPIO, esp_err_to_name(err));
return err;
}
// Start in rest (de-energised) state.
relay_set(false);
s_initialised = true;
ESP_LOGI(TAG, "relay init OK (GPIO %d, active-%s, pulse=%d ms)",
CONFIG_ZACUS_P7_GPIO,
CONFIG_ZACUS_P7_RELAY_ACTIVE_HIGH ? "HIGH" : "LOW",
CONFIG_ZACUS_P7_RELAY_PULSE_MS);
return ESP_OK;
}
void p7_coffre_unlock(void) {
if (!s_initialised) {
ESP_LOGW(TAG, "unlock called before init — ignored");
return;
}
relay_set(true);
ESP_LOGI(TAG, "coffre UNLOCKED (relay energised)");
#if CONFIG_ZACUS_P7_RELAY_PULSE_MS > 0
vTaskDelay(pdMS_TO_TICKS(CONFIG_ZACUS_P7_RELAY_PULSE_MS));
relay_set(false);
ESP_LOGI(TAG, "coffre relay de-energised after %d ms pulse",
CONFIG_ZACUS_P7_RELAY_PULSE_MS);
#endif
}
void p7_coffre_lock(void) {
if (!s_initialised) return;
relay_set(false);
ESP_LOGI(TAG, "coffre relay de-energised (lock/re-arm)");
}
#endif // CONFIG_ZACUS_P7_ACTUATOR_RELAY
#else // CONFIG_ZACUS_P7_COFFRE_ENABLE not set — emit stubs only
esp_err_t p7_coffre_init(void) {
return ESP_OK;
}
void p7_coffre_unlock(void) {
/* stub — CONFIG_ZACUS_P7_COFFRE_ENABLE=n */
}
void p7_coffre_lock(void) {
/* stub — CONFIG_ZACUS_P7_COFFRE_ENABLE=n */
}
#endif // CONFIG_ZACUS_P7_COFFRE_ENABLE
@@ -0,0 +1,11 @@
## Zacus sd_storage — microSD (SDMMC 1-bit) FAT mount at /sdcard (P4).
idf_component_register(
SRCS
"sd_storage.c"
INCLUDE_DIRS
"."
REQUIRES
fatfs
sdmmc
esp_driver_sdmmc
)
@@ -0,0 +1,74 @@
#include "sd_storage.h"
#include "esp_log.h"
#include "esp_vfs_fat.h"
#include "sdmmc_cmd.h"
#include "driver/sdmmc_host.h"
static const char *TAG = "sd_storage";
// Freenove ESP32-S3 WROOM N16R8 microSD (SDMMC 1-bit). Mirrors
// ui_freenove_allinone/include/ui_freenove_config.h FREENOVE_SDMMC_*.
#define SD_PIN_CMD 38
#define SD_PIN_CLK 39
#define SD_PIN_D0 40
static sdmmc_card_t *s_card = NULL;
esp_err_t sd_storage_mount(void) {
if (s_card != NULL) {
return ESP_OK;
}
const esp_vfs_fat_sdmmc_mount_config_t mount_cfg = {
.format_if_mount_failed = false, // never wipe a user's card
.max_files = 5,
.allocation_unit_size = 16 * 1024,
};
sdmmc_host_t host = SDMMC_HOST_DEFAULT();
host.flags = SDMMC_HOST_FLAG_1BIT; // Freenove wires D0 only
host.max_freq_khz = SDMMC_FREQ_DEFAULT;
sdmmc_slot_config_t slot = SDMMC_SLOT_CONFIG_DEFAULT();
slot.width = 1;
slot.clk = SD_PIN_CLK;
slot.cmd = SD_PIN_CMD;
slot.d0 = SD_PIN_D0;
slot.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP;
esp_err_t err = esp_vfs_fat_sdmmc_mount(SD_STORAGE_MOUNT_POINT, &host,
&slot, &mount_cfg, &s_card);
if (err != ESP_OK) {
ESP_LOGW(TAG, "mount failed: %s — no card inserted or wiring issue",
esp_err_to_name(err));
s_card = NULL;
return err;
}
ESP_LOGI(TAG, "microSD mounted at %s — %lu MiB (%s)",
SD_STORAGE_MOUNT_POINT, (unsigned long) sd_storage_capacity_mb(),
s_card->cid.name);
return ESP_OK;
}
esp_err_t sd_storage_unmount(void) {
if (s_card == NULL) {
return ESP_OK;
}
esp_err_t err = esp_vfs_fat_sdcard_unmount(SD_STORAGE_MOUNT_POINT, s_card);
s_card = NULL;
return err;
}
bool sd_storage_ready(void) {
return s_card != NULL;
}
uint32_t sd_storage_capacity_mb(void) {
if (s_card == NULL) {
return 0U;
}
uint64_t bytes = (uint64_t) s_card->csd.capacity * s_card->csd.sector_size;
return (uint32_t) (bytes / (1024ULL * 1024ULL));
}
@@ -0,0 +1,35 @@
// Zacus sd_storage — microSD mount for the Freenove ESP32-S3 master.
//
// The Freenove WROOM N16R8 exposes the card on the SDMMC peripheral in
// 1-bit mode (CMD=GPIO38, CLK=GPIO39, D0=GPIO40 — mirrors the Arduino
// `ui_freenove_allinone` FREENOVE_SDMMC_* pinout). Mounts FAT at /sdcard so
// media_manager and the asset endpoints can stage large assets off the 5 MB
// LittleFS partition (P4). Mount is best-effort: a missing card is not fatal.
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
#define SD_STORAGE_MOUNT_POINT "/sdcard"
// Mount the microSD card (SDMMC 1-bit) at /sdcard. Returns ESP_OK on success;
// ESP_ERR_NOT_FOUND / ESP_FAIL when no card is present or wiring is wrong.
esp_err_t sd_storage_mount(void);
// Unmount and release the card.
esp_err_t sd_storage_unmount(void);
// True once a card is mounted.
bool sd_storage_ready(void);
// Card capacity in MiB (0 when not mounted).
uint32_t sd_storage_capacity_mb(void);
#ifdef __cplusplus
}
#endif
@@ -21,6 +21,7 @@
#include "esp_mac.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_afe_config.h"
#include "esp_afe_sr_iface.h"
@@ -64,6 +65,7 @@ static struct {
i2s_chan_handle_t tx_chan;
bool tx_enabled; // channel currently enabled
uint32_t tx_sample_rate; // current configured rate
SemaphoreHandle_t play_lock; // serialises TTS vs media playback (one session at a time)
voice_state_t state;
// capture_task / capture_run removed: mic_broker's task delivers frames
// via on_npc_frame(); broker mode (MIC_NPC_LISTEN/MIC_IDLE) gates it.
@@ -241,6 +243,9 @@ static esp_err_t i2s_tx_setup(void) {
return err;
}
s_pipe.tx_enabled = false;
if (s_pipe.play_lock == NULL) {
s_pipe.play_lock = xSemaphoreCreateMutex();
}
return ESP_OK;
}
@@ -575,6 +580,13 @@ esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format) {
ESP_LOGW(TAG, "play_start: no TX channel (enable_tts_playback=false?)");
return ESP_ERR_INVALID_STATE;
}
// Serialise the shared TX channel: only one playback session (TTS or
// media_manager) may own the DAC at a time. Held until play_end().
if (s_pipe.play_lock &&
xSemaphoreTake(s_pipe.play_lock, pdMS_TO_TICKS(2000)) != pdTRUE) {
ESP_LOGW(TAG, "play_start: busy — another playback owns the I2S TX");
return ESP_ERR_TIMEOUT;
}
// Reconfigure the I2S clock if the bridge announces a different rate.
// F5-TTS produces 24 kHz; the mic side runs at 16 kHz by default.
if (sample_rate != 0 && sample_rate != s_pipe.tx_sample_rate) {
@@ -587,13 +599,17 @@ esp_err_t voice_pipeline_play_start(uint32_t sample_rate, const char *format) {
if (err != ESP_OK) {
ESP_LOGW(TAG, "play_start: clk reconfig %u Hz failed: %s",
(unsigned) sample_rate, esp_err_to_name(err));
if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
return err;
}
s_pipe.tx_sample_rate = sample_rate;
}
if (!s_pipe.tx_enabled) {
esp_err_t err = i2s_channel_enable(s_pipe.tx_chan);
if (err != ESP_OK) return err;
if (err != ESP_OK) {
if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
return err;
}
s_pipe.tx_enabled = true;
}
voice_pipeline_set_state(VOICE_STATE_SPEAKING);
@@ -625,5 +641,6 @@ esp_err_t voice_pipeline_play_end(void) {
}
voice_pipeline_set_state(VOICE_STATE_IDLE);
ESP_LOGI(TAG, "play_end");
if (s_pipe.play_lock) xSemaphoreGive(s_pipe.play_lock);
return ESP_OK;
}
+5
View File
@@ -6,6 +6,7 @@ idf_component_register(
REQUIRES
joltwallet__littlefs
ota_server
sd_storage
media_manager
npc_engine
hints_client
@@ -22,5 +23,9 @@ idf_component_register(
esp_event
espressif__mdns
display_ui
gamebook
puzzle_state
p7_coffre
p5_morse
p6_nfc
)
+212
View File
@@ -0,0 +1,212 @@
menu "Zacus Game Hardware"
config ZACUS_P7_COFFRE_ENABLE
bool "Enable P7 coffre actuator (servo or relay)"
default n
help
Drives the unlocking actuator for the final chest (P7) when
STEP_FINAL_WIN is reached. When disabled all p7_coffre_*()
functions are empty stubs so callers compile without #ifdef.
Enable once the actuator (9g servo or 5V relay) is wired up.
if ZACUS_P7_COFFRE_ENABLE
choice ZACUS_P7_ACTUATOR_TYPE
prompt "Actuator type"
default ZACUS_P7_ACTUATOR_SERVO
help
Select the hardware wired to CONFIG_ZACUS_P7_GPIO.
SERVO 50 Hz PWM via LEDC (TIMER_2 / CHANNEL_2).
RELAY logic-level GPIO output (active-high or active-low).
config ZACUS_P7_ACTUATOR_SERVO
bool "Servo PWM (9g, 50 Hz LEDC)"
config ZACUS_P7_ACTUATOR_RELAY
bool "Relay / solid-state switch (GPIO)"
endchoice
config ZACUS_P7_GPIO
int "GPIO pin for the actuator"
default 13
range 0 48
help
GPIO number for the servo signal wire or the relay control
input. Default 13 is free on the Freenove Media Kit board.
if ZACUS_P7_ACTUATOR_SERVO
config ZACUS_P7_SERVO_LOCK_DEG
int "Servo lock angle (degrees, 0-180)"
default 0
range 0 180
help
Angle at which the servo holds the coffre locked.
Corresponds to a 500-2500 µs pulse on a 20 ms period.
config ZACUS_P7_SERVO_UNLOCK_DEG
int "Servo unlock angle (degrees, 0-180)"
default 90
range 0 180
help
Angle driven on STEP_FINAL_WIN to open the coffre.
endif
if ZACUS_P7_ACTUATOR_RELAY
config ZACUS_P7_RELAY_ACTIVE_HIGH
bool "Relay active-HIGH (coil energised when GPIO=1)"
default y
help
Set y for most 5V relay modules with active-high inputs.
Set n for active-low modules (e.g. opto-coupled boards
where the relay fires when the pin is pulled LOW).
config ZACUS_P7_RELAY_PULSE_MS
int "Relay pulse duration (ms, 0 = hold permanently)"
default 800
range 0 10000
help
If > 0 the relay is energised for this many milliseconds
then returned to rest (one-shot electronic latch).
Set 0 to keep the relay energised until p7_coffre_lock()
is called (e.g. when a magnetic solenoid holds the bolt).
endif
endif # ZACUS_P7_COFFRE_ENABLE
# ─── P5 Morse / telegraph key ───────────────────────────────────────────────
config ZACUS_P5_MORSE_ENABLE
bool "Enable P5 Morse puzzle (telegraph key input)"
default n
help
Drives the P5 code-Morse puzzle: player taps a telegraph key and
the firmware decodes dot/dash sequences. When disabled all
p5_morse_*() functions are empty stubs so callers compile without
#ifdef guards.
Enable once the telegraph key (or push button) is wired up.
if ZACUS_P5_MORSE_ENABLE
config ZACUS_P5_GPIO
int "GPIO pin for the telegraph key / button"
default 14
range 0 48
help
Input GPIO. The key connects this pin to GND when pressed.
An internal pull-up is enabled by the driver.
config ZACUS_P5_ACTIVE_LOW
bool "Key active-LOW (pressed = GPIO reads 0)"
default y
help
Set y when using a standard normally-open button to GND
(internal pull-up enabled). Set n for active-high sensors.
config ZACUS_P5_DOT_MS
int "Maximum dot press duration (ms)"
default 200
range 10 2000
help
A key press shorter than or equal to this value is decoded
as a dot. Presses above this threshold become dashes.
config ZACUS_P5_DASH_MS
int "Minimum dash press duration (ms)"
default 600
range 50 5000
help
A key press must last at least this long to be decoded as a
dash. Presses between DOT_MS+1 and DASH_MS-1 are discarded
as noise or accidental touches.
config ZACUS_P5_GAP_MS
int "Letter-gap silence duration (ms)"
default 800
range 100 5000
help
Silence between key presses longer than this value triggers
decoding of the accumulated symbol (letter boundary).
endif # ZACUS_P5_MORSE_ENABLE
# ─── P6 NFC / alchemical symbols ────────────────────────────────────────────
config ZACUS_P6_NFC_ENABLE
bool "Enable P6 NFC puzzle (alchemical symbol tags)"
default n
help
Drives the P6 alchemical-symbols puzzle: the player places NFC
tags (NTAG213) on a wooden tablet; the firmware reads the UIDs
and checks the placement order. When disabled all p6_nfc_*()
functions are empty stubs so callers compile without #ifdef guards.
Enable once the NFC reader module is wired up.
if ZACUS_P6_NFC_ENABLE
choice ZACUS_P6_READER_TYPE
prompt "NFC reader IC"
default ZACUS_P6_READER_PN532
help
Select the NFC reader module on the board.
PN532 NXP PN532 (common breakout, SPI or I2C).
PN7150 NXP PN7150 (NCI-based, I2C only).
config ZACUS_P6_READER_PN532
bool "PN532 (NXP)"
config ZACUS_P6_READER_PN7150
bool "PN7150 (NXP NCI)"
endchoice
choice ZACUS_P6_BUS_TYPE
prompt "Communication bus"
default ZACUS_P6_BUS_I2C
help
Select the bus connecting the ESP32 to the NFC reader.
I2C two-wire, uses SDA/SCL. PN7150 only supports I2C.
SPI four-wire, faster. PN532 only.
config ZACUS_P6_BUS_I2C
bool "I2C"
config ZACUS_P6_BUS_SPI
bool "SPI (PN532 only)"
endchoice
config ZACUS_P6_I2C_SDA
int "I2C SDA GPIO"
default 8
range 0 48
depends on ZACUS_P6_BUS_I2C
help
GPIO number for the I2C data line (SDA).
Default 8 is free on the Freenove Media Kit board.
config ZACUS_P6_I2C_SCL
int "I2C SCL GPIO"
default 9
range 0 48
depends on ZACUS_P6_BUS_I2C
help
GPIO number for the I2C clock line (SCL).
Default 9 is free on the Freenove Media Kit board.
config ZACUS_P6_I2C_ADDR
hex "NFC reader I2C address"
default 0x24
range 0x08 0x77
depends on ZACUS_P6_BUS_I2C
help
7-bit I2C address of the NFC reader.
PN532 default: 0x24. PN7150 default: 0x28.
config ZACUS_P6_IRQ_GPIO
int "IRQ / data-ready GPIO (-1 = polling mode)"
default -1
range -1 48
help
Optional interrupt / data-ready pin from the NFC reader.
Set -1 to use polling mode (5 Hz by default).
When wired, the driver waits for a falling edge before
reading, which reduces SPI/I2C bus traffic.
endif # ZACUS_P6_NFC_ENABLE
endmenu
+53
View File
@@ -40,6 +40,7 @@
#include "ota_server.h"
#include "media_manager.h"
#include "sd_storage.h"
#include "npc_engine.h"
#include "hints_client.h"
#include "voice_pipeline.h"
@@ -54,11 +55,21 @@
#include "mic_broker.h"
#include "board_pins_mediakit.h"
#include "display_ui.h"
#include "gamebook.h"
#include "p7_coffre.h"
#include "p5_morse.h"
#include "p6_nfc.h"
// Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move
// this to NVS so the field operator can repoint the firmware without a flash.
#define ZACUS_HINTS_BASE_URL "http://192.168.0.150:8302"
// Voice gateway (tools/zacus-gateway) — the engine POSTs each scene change to
// {URL}/game/step?scene=SCENE_* so the phone NPCs disguise that scene's hint.
// Hardcoded for now like the URLs above — moves to NVS in the same follow-up.
#define ZACUS_GATEWAY_BASE_URL "http://192.168.0.175:8401"
#define ZACUS_GATEWAY_TOKEN "testtoken"
// Slice 7: voice-bridge WebSocket on the MacStudio (Tailscale address).
// Hardcoded here for the same reason as ZACUS_HINTS_BASE_URL — moves to
// NVS in a follow-up slice. The bridge endpoint is documented in
@@ -426,6 +437,8 @@ void app_main(void) {
if (btn_err != ESP_OK) {
ESP_LOGW(TAG, "buttons_input_init: %s", esp_err_to_name(btn_err));
}
// Gamebook mode owns the 5-way pad when started (POST /game/gamebook).
gamebook_init();
}
bool sta_ok = wifi_bring_up();
@@ -484,6 +497,13 @@ void app_main(void) {
}
}
// P4: best-effort microSD mount (SDMMC 1-bit on /sdcard). A missing card
// is non-fatal — assets fall back to LittleFS.
if (sd_storage_mount() == ESP_OK) {
ESP_LOGI(TAG, "microSD ready (%lu MiB) at %s",
(unsigned long) sd_storage_capacity_mb(), SD_STORAGE_MOUNT_POINT);
}
if (mount_littlefs() == ESP_OK) {
list_littlefs_root();
@@ -503,6 +523,13 @@ void app_main(void) {
ESP_LOGI(TAG, "media smoke play -> %s",
esp_err_to_name(play_err));
// Boot into the gamebook library (the 6-story tile picker) when the
// SD pack is present. Best-effort: no /sdcard/gamebook/library.json
// -> the normal shell stays in charge.
if (gamebook_start() == ESP_OK) {
ESP_LOGI(TAG, "gamebook library up (boot picker)");
}
// Slice 4: bring up the ported npc_engine. Cue table is empty
// at this stage — wiring the scenario IR-driven cue catalog is
// a follow-up slice. The engine still boots, accepts ticks
@@ -519,6 +546,9 @@ void app_main(void) {
ESP_LOGE(TAG, "npc_engine_init failed: %s",
esp_err_to_name(npc_err));
}
// Push each scene change to the voice gateway so the phone NPCs
// disguise the CURRENT scene's hint (best-effort, see main.c URL).
npc_engine_set_gateway(ZACUS_GATEWAY_BASE_URL, ZACUS_GATEWAY_TOKEN);
// Slice 5: bring up the hints HTTP client (so npc_engine can
// route hint requests through the real backend) and the voice
@@ -579,6 +609,29 @@ void app_main(void) {
local_puzzles_init(&s_pstate);
ESP_LOGI(TAG, "puzzle_state + local_puzzles initialised");
// P7 coffre actuator: configure GPIO/LEDC at boot so the
// first call to p7_coffre_unlock() (on STEP_FINAL_WIN) has
// zero warm-up latency. A no-op stub when
// CONFIG_ZACUS_P7_COFFRE_ENABLE=n (default).
esp_err_t coffre_err = p7_coffre_init();
if (coffre_err != ESP_OK) {
ESP_LOGW(TAG, "p7_coffre_init: %s", esp_err_to_name(coffre_err));
}
// P5 Morse puzzle: configure telegraph-key GPIO and start the
// decoding task. No-op stub when CONFIG_ZACUS_P5_MORSE_ENABLE=n.
esp_err_t morse_err = p5_morse_init();
if (morse_err != ESP_OK) {
ESP_LOGW(TAG, "p5_morse_init: %s", esp_err_to_name(morse_err));
}
// P6 NFC puzzle: initialise bus and NFC polling task.
// No-op stub when CONFIG_ZACUS_P6_NFC_ENABLE=n.
esp_err_t nfc_err = p6_nfc_init();
if (nfc_err != ESP_OK) {
ESP_LOGW(TAG, "p6_nfc_init: %s", esp_err_to_name(nfc_err));
}
// Task 7: mic_broker takes ownership of the Media Kit I2S IN
// pins (3/14/46 per board_pins_mediakit.h) BEFORE
// voice_pipeline_init — the pipeline's own init call then
+5
View File
@@ -70,3 +70,8 @@ CONFIG_LV_USE_FS_STDIO=y
CONFIG_LV_FS_STDIO_LETTER=76
CONFIG_LV_FS_STDIO_PATH="/littlefs"
CONFIG_LV_USE_PNG=y
# P4: FATFS Long File Names (heap) — assets sur SD ont des noms > 8.3
# (SCENE_WIN.mp3, sonar_hint.mp3, hotline_tts/…). Sans ça fopen échoue.
CONFIG_FATFS_LFN_HEAP=y
CONFIG_FATFS_MAX_LFN=255
+8
View File
@@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.16)
# scenario_mesh is shared with box3_voice — single copy in lib/ so the
# ESP-NOW protocol stays in sync with the master and other annexes.
set(EXTRA_COMPONENT_DIRS ../lib/scenario_mesh)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(zacus-plip-voice)
+33
View File
@@ -0,0 +1,33 @@
idf_component_register(
SRCS
"main.c"
"es8388.c"
"audio.c"
"net.c"
"cmd_exec.c"
"hook_client.c"
"phone.c"
"tones.c"
"dialer.c"
"conversation.c"
"dtmf.c"
"turn_client.c"
"slic.c"
"plip_gamebook.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
esp_driver_gpio
esp_event
esp_http_client
esp_http_server
esp_netif
esp_timer
esp_wifi
json
nvs_flash
spiffs
fatfs
sdmmc
scenario_mesh
)
+121
View File
@@ -0,0 +1,121 @@
menu "PLIP Voice Configuration"
config PLIP_WIFI_SSID
string "WiFi SSID"
default "zacus-net"
help
WiFi network SSID. Credentials can also be stored in NVS
(namespace "wifi", keys "ssid"/"pwd") — NVS takes precedence.
config PLIP_WIFI_PASSWORD
string "WiFi Password"
default ""
help
WiFi password. Leave empty for open networks.
config PLIP_WIFI_CHANNEL
int "WiFi channel hint (0 = auto)"
default 11
range 0 13
help
Bias the STA scan to start on this channel. Must match the
master ESP32's connected channel for ESP-NOW co-channel
operation. Lab network uses channel 11.
config PLIP_MASTER_URL
string "Zacus Master Base URL"
default "http://192.168.0.188"
help
Base URL of the Zacus master ESP32 (Freenove board). The PLIP
reports hook transitions to <url>/voice/hook.
config PLIP_SPEAKER_VOLUME
int "Default Speaker Volume (0-100)"
default 98
range 0 100
help
Default speaker output volume at boot.
config PLIP_HOOK_GPIO
int "Off-hook GPIO number"
default 23
help
GPIO that signals handset off-hook.
SLIC SHK line is GPIO23 (A1S board KEY4, re-assigned to SLIC).
Legacy dev kit stub used GPIO4 (BOOT button, active-LOW).
config PLIP_HOOK_ACTIVE_HIGH
bool "Hook GPIO active-HIGH means off-hook"
default y
help
When enabled, a HIGH level on PLIP_HOOK_GPIO means the handset is
off-hook (SLIC SHK polarity). When disabled, LOW means off-hook
(original dev-kit pull-up + BOOT button polarity).
config PLIP_DIAL_PULSE
bool "Enable rotary dial pulse decoding on SHK GPIO"
default y
help
When enabled, brief open/close pulses on the hook GPIO (from a
rotary dial) are decoded into digits and pushed to the dialer.
Each pulse train: ~60-100 ms open + ~40 ms closed; 10 pulses = digit 0.
A gap > PLIP_DIAL_PULSE_MAX_GAP_MS terminates the digit.
config PLIP_DIAL_PULSE_MAX_GAP_MS
int "Rotary pulse inter-digit gap (ms)"
default 200
depends on PLIP_DIAL_PULSE
range 100 500
help
If the hook GPIO stays closed for more than this duration after
a pulse train, the train is considered complete and the digit is
emitted. 200 ms is standard for French rotary dials.
config PLIP_DIAL_DTMF
bool "Enable DTMF (touch-tone) dialing via Goertzel"
default n
help
When enabled, a background task reads 20 ms microphone frames and
runs a Goertzel-based DTMF detector (8 frequencies: 697-1633 Hz).
Confirmed digits (≥ 40 ms tone, with twist and dominance guards)
are pushed to the dialer just like rotary pulses.
The detector is active only between off-hook and the start of the
NPC greeting; it is disarmed during voice capture (CONNECTED state).
Can be combined with PLIP_DIAL_PULSE: whichever source detects a
digit first wins. Default off — enable for touch-tone handsets.
config PLIP_GATEWAY_URL
string "NPC Gateway Base URL"
default "http://192.168.0.50:8401"
help
Base URL of the PLIP voice gateway (zacus-gateway FastAPI), as seen
from the PLIP on the local LAN. Override at build time or via
sdkconfig.defaults. The turn_client appends /v1/voice/turn.
Example: http://192.168.0.10:8401 (IP of the Mac running the gateway).
config PLIP_GATEWAY_TOKEN
string "NPC Gateway Bearer Token"
default ""
help
Bearer token sent as "Authorization: Bearer <token>" on every
/v1/voice/turn request. Leave empty to skip the header.
config PLIP_VOICE_REPLY
bool "Enable Stage-3 conversational LISTEN loop (capture -> /v1/voice/reply -> play)"
default n
help
When enabled, after the NPC greeting is played (STATE_CONNECTED),
the firmware enters a continuous listen loop:
1. Capture mic audio (up to 8 s, VAD-gated) via audio_capture_wav().
2. POST the captured WAV as multipart/form-data to
CONFIG_PLIP_GATEWAY_URL/v1/voice/reply (STT + NPC reply via Kyutai).
3. Play the NPC response WAV from /spiffs/reply.wav.
4. Repeat until the handset is hung up.
Requires the gateway (zacus-gateway FastAPI) to be reachable and
the /v1/voice/reply endpoint to be operational.
Capture buffer (~256 KB for 8 s) is allocated from PSRAM when
available; falls back to internal heap with reduced duration (4 s).
Leave OFF (default) to keep STATE_CONNECTED as a terminal state
(Stage 2 behaviour — greeting only, no further interaction).
endmenu
+896
View File
@@ -0,0 +1,896 @@
/*
* audio.c — I2S + ES8388 audio driver for the PLIP voice annex (Phase A/D).
*
* Init order:
* 1. es8388_init() — I2C master + codec register sequence
* 2. i2s_new_channel() — allocate TX (speaker) + RX (mic) channels
* 3. i2s_channel_init_std_mode() — configure Philips 16-bit stereo @ 16kHz
* 4. i2s_channel_enable()
* 5. gpio PA_ENABLE = 1 (already done by es8388_init)
*
* Threading:
* - audio_play_tone() is blocking — call from a task, not the I2S callback.
* - audio_play_async() posts to a FreeRTOS queue; a worker task drains it.
* - The I2S handle is published via audio_spk_handle() for cmd_exec.
*/
#include "audio.h"
#include "board_config.h"
#include "es8388.h"
#include "phone.h"
#include "slic.h"
#include <inttypes.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "driver/gpio.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_spiffs.h"
#include "esp_vfs_fat.h"
#include "driver/i2s_std.h"
#include "driver/sdspi_host.h"
#include "driver/spi_common.h"
#include "sdmmc_cmd.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#define TAG "audio"
/* ── Audio parameters ────────────────────────────────────────────────────── */
#define SAMPLE_RATE PLIP_SAMPLE_RATE /* 16000 */
#define BITS PLIP_BITS_PER_SAMPLE /* 16 */
#define CHANNELS PLIP_CHANNELS /* 2 = stereo (ES8388 I2S) */
/* ── WAV header ──────────────────────────────────────────────────────────── */
#define WAV_MIN_HEADER 44
typedef struct {
uint16_t audio_format;
uint16_t channels;
uint32_t sample_rate;
uint16_t bits_per_sample;
uint32_t data_offset;
uint32_t data_size;
} wav_info_t;
/* ── Async play queue ────────────────────────────────────────────────────── */
typedef enum { CMD_PLAY, CMD_STOP, CMD_RING } audio_cmd_kind_t;
typedef struct {
audio_cmd_kind_t kind;
char path[160];
} audio_cmd_t;
static QueueHandle_t s_queue;
static i2s_chan_handle_t s_spk_handle = NULL;
static i2s_chan_handle_t s_mic_handle = NULL;
static volatile bool s_stop_req = false;
static volatile bool s_playing = false; /* true while the worker plays a clip */
static bool s_sd_mounted = false;
static bool s_spiffs_mounted = false;
/* ── SPIFFS lazy mount ───────────────────────────────────────────────────── */
static void ensure_spiffs_audio(void)
{
if (s_spiffs_mounted) return;
esp_vfs_spiffs_conf_t conf = {
.base_path = "/spiffs",
.partition_label = "storage",
.max_files = 8,
.format_if_mount_failed = true, /* format a blank/corrupt partition at boot */
};
esp_err_t ret = esp_vfs_spiffs_register(&conf);
if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE /* already mounted */) {
s_spiffs_mounted = true;
ESP_LOGI(TAG, "SPIFFS mounted at /spiffs");
} else {
ESP_LOGW(TAG, "SPIFFS mount failed: %s", esp_err_to_name(ret));
}
}
/* ── SD card init (best-effort) ──────────────────────────────────────────── */
static void ensure_sd_mounted(void)
{
if (s_sd_mounted) return;
sdmmc_host_t host = SDSPI_HOST_DEFAULT();
host.slot = SPI3_HOST;
spi_bus_config_t bus_cfg = {
.mosi_io_num = PLIP_SD_MOSI,
.miso_io_num = PLIP_SD_MISO,
.sclk_io_num = PLIP_SD_SCK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 4000,
};
esp_err_t ret = spi_bus_initialize(SPI3_HOST, &bus_cfg, SDSPI_DEFAULT_DMA);
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "spi_bus_initialize: %s", esp_err_to_name(ret));
return;
}
sdspi_device_config_t slot = SDSPI_DEVICE_CONFIG_DEFAULT();
slot.gpio_cs = PLIP_SD_CS;
slot.host_id = SPI3_HOST;
esp_vfs_fat_sdmmc_mount_config_t mount_cfg = {
.format_if_mount_failed = false,
.max_files = 4,
.allocation_unit_size = 16 * 1024,
};
sdmmc_card_t *card = NULL;
ret = esp_vfs_fat_sdspi_mount(PLIP_SD_MOUNT, &host, &slot, &mount_cfg, &card);
if (ret == ESP_OK) {
s_sd_mounted = true;
ESP_LOGI(TAG, "SD card mounted at %s", PLIP_SD_MOUNT);
sdmmc_card_print_info(stdout, card);
} else {
ESP_LOGW(TAG, "SD card mount failed: %s", esp_err_to_name(ret));
}
}
bool audio_ensure_sd(void)
{
ensure_sd_mounted();
return s_sd_mounted;
}
/* ── WAV parser ──────────────────────────────────────────────────────────── */
static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *out)
{
if (len < WAV_MIN_HEADER) return ESP_ERR_INVALID_SIZE;
if (memcmp(buf, "RIFF", 4) != 0 || memcmp(buf + 8, "WAVE", 4) != 0)
return ESP_ERR_INVALID_ARG;
size_t pos = 12;
bool got_fmt = false, got_data = false;
while (pos + 8 <= len && !(got_fmt && got_data)) {
uint32_t chunk_size;
memcpy(&chunk_size, buf + pos + 4, 4);
if (memcmp(buf + pos, "fmt ", 4) == 0 && chunk_size >= 16) {
memcpy(&out->audio_format, buf + pos + 8, 2);
memcpy(&out->channels, buf + pos + 10, 2);
memcpy(&out->sample_rate, buf + pos + 12, 4);
memcpy(&out->bits_per_sample,buf + pos + 22, 2);
got_fmt = true;
} else if (memcmp(buf + pos, "data", 4) == 0) {
out->data_offset = (uint32_t)(pos + 8);
out->data_size = chunk_size;
got_data = true;
}
pos += 8 + chunk_size;
if (chunk_size == 0) break;
}
if (!got_fmt || !got_data) return ESP_ERR_NOT_FOUND;
if (out->audio_format != 1) return ESP_ERR_NOT_SUPPORTED; /* not PCM */
return ESP_OK;
}
/* WAV streaming chunk size — keeps heap usage well under 8 KB. */
#define PLAY_CHUNK_BYTES 4096
/* Play WAV from filesystem (SPIFFS or SD) using streaming I/O.
* Only the WAV header (≤512 bytes) is buffered; PCM is piped directly
* to I2S in PLAY_CHUNK_BYTES slices, avoiding any large heap allocation.
* Paths starting with /spiffs/ are read from SPIFFS; all others use SD. */
static void play_wav_file(const char *path)
{
bool is_spiffs = (strncmp(path, "/spiffs/", 8) == 0 || strncmp(path, "/spiffs", 7) == 0);
if (is_spiffs) {
ensure_spiffs_audio();
if (!s_spiffs_mounted) {
ESP_LOGW(TAG, "SPIFFS not mounted — beep fallback");
audio_play_tone(880.0f, 200);
return;
}
} else {
ensure_sd_mounted();
if (!s_sd_mounted) {
ESP_LOGW(TAG, "SD not mounted — beep fallback");
audio_play_tone(880.0f, 200);
return;
}
}
char full[192];
if (path[0] == '/') {
snprintf(full, sizeof(full), "%s", path);
} else {
snprintf(full, sizeof(full), "%s/%s", PLIP_SD_MOUNT, path);
}
FILE *f = fopen(full, "rb");
if (!f) {
ESP_LOGW(TAG, "file not found: %s", full);
audio_play_tone(880.0f, 200);
return;
}
/* Read enough of the file to parse the WAV header (up to 512 bytes). */
uint8_t hdr_buf[512];
size_t hdr_read = fread(hdr_buf, 1, sizeof(hdr_buf), f);
if (hdr_read < WAV_MIN_HEADER) {
ESP_LOGW(TAG, "WAV too short: %zu bytes", hdr_read);
fclose(f);
audio_play_tone(880.0f, 200);
return;
}
wav_info_t wi = {0};
if (parse_wav_header(hdr_buf, hdr_read, &wi) != ESP_OK) {
ESP_LOGW(TAG, "WAV parse failed: %s", full);
fclose(f);
audio_play_tone(880.0f, 200);
return;
}
if (wi.bits_per_sample != 16) {
ESP_LOGW(TAG, "WAV: %d-bit not supported (need 16-bit)", wi.bits_per_sample);
fclose(f);
return;
}
ESP_LOGI(TAG, "WAV: %"PRIu32" Hz %d-bit %d ch, %"PRIu32" bytes PCM",
wi.sample_rate, wi.bits_per_sample, wi.channels, wi.data_size);
/* Seek to the PCM data section. */
if (fseek(f, (long)wi.data_offset, SEEK_SET) != 0) {
ESP_LOGW(TAG, "fseek to data failed");
fclose(f);
return;
}
/* The I2S TX slot is STEREO @ SAMPLE_RATE. A mono WAV must be expanded to
* L+R or it is consumed at 2x rate (the "chipmunk" fast/high-pitch bug).
* A WAV whose rate differs from SAMPLE_RATE would also play at the wrong
* speed — warn (the gateway TTS always returns 16 kHz, matching). */
if (wi.sample_rate != SAMPLE_RATE) {
ESP_LOGW(TAG, "WAV rate %"PRIu32" Hz != I2S %d Hz — playback speed will be off",
wi.sample_rate, SAMPLE_RATE);
}
const bool mono = (wi.channels == 1);
/* Load the ENTIRE PCM into PSRAM BEFORE playing. Streaming fread() from
* SPIFFS *between* I2S writes stalls the DMA → underrun → audible
* distortion ("saturation"). Diagnostic confirmed: the stored WAV is clean
* (0% clip) and RAM-generated tones play clean, but SPIFFS-streamed WAVs
* distorted. Reading it all up-front = zero file I/O during playback. */
uint8_t *pcm = heap_caps_malloc(wi.data_size, MALLOC_CAP_SPIRAM);
if (!pcm) pcm = malloc(wi.data_size);
if (!pcm) {
ESP_LOGE(TAG, "play: OOM for %"PRIu32"-byte PCM buffer", wi.data_size);
fclose(f);
return;
}
size_t pcm_len = fread(pcm, 1, wi.data_size, f);
fclose(f);
static int16_t s_stereo_chunk[PLAY_CHUNK_BYTES]; /* mono→stereo scratch */
size_t off = 0;
size_t total_written = 0;
const size_t step = mono ? (PLAY_CHUNK_BYTES / 2) : PLAY_CHUNK_BYTES;
while (!s_stop_req && off < pcm_len) {
size_t bytes = (pcm_len - off < step) ? (pcm_len - off) : step;
const uint8_t *out = pcm + off;
size_t out_len = bytes;
if (mono) {
/* Duplicate each 16-bit mono sample into L and R. */
size_t samples = bytes / 2;
const int16_t *src = (const int16_t *)(pcm + off);
for (size_t k = 0; k < samples; k++) {
s_stereo_chunk[2 * k] = src[k];
s_stereo_chunk[2 * k + 1] = src[k];
}
out = (const uint8_t *)s_stereo_chunk;
out_len = samples * 4; /* 2 channels × 2 bytes */
}
size_t w = 0;
esp_err_t ret = i2s_channel_write(s_spk_handle, out, out_len, &w, pdMS_TO_TICKS(500));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
break;
}
total_written += w;
off += bytes;
}
free(pcm);
float dur = (float)total_written / (float)(SAMPLE_RATE * 2 * 2); /* 16k stereo 16-bit */
ESP_LOGI(TAG, "play done: %zu bytes written, %.2fs", total_written, dur);
}
/* Generate a simple embedded 3-tone cue C5-E5-G5 as proof-of-life. */
static void play_embedded_cue(void)
{
/* Three 200ms tones: C5 523Hz, E5 659Hz, G5 784Hz */
const float freqs[] = {523.0f, 659.0f, 784.0f};
for (int i = 0; i < 3 && !s_stop_req; i++) {
audio_play_tone(freqs[i], 200);
vTaskDelay(pdMS_TO_TICKS(50));
}
}
/* ── Ring tone task ──────────────────────────────────────────────────────── */
static void ring_task(void *arg)
{
(void)arg;
/* French ring: 1.5s ON at ~440Hz, 3.5s OFF, repeat until stop */
while (!s_stop_req) {
/* ON: 1.5s at 440 Hz */
const int on_ms = 1500;
const int total_samples = SAMPLE_RATE * on_ms / 1000;
const float freq = 440.0f;
const float amp = 12000.0f;
#define RING_CHUNK 128
int16_t buf[RING_CHUNK * 2]; /* stereo buffer */
int sample_idx = 0;
while (!s_stop_req && sample_idx < total_samples) {
int chunk = (total_samples - sample_idx < RING_CHUNK)
? (total_samples - sample_idx) : RING_CHUNK;
for (int i = 0; i < chunk; i++) {
float t = (float)(sample_idx + i) / (float)SAMPLE_RATE;
int16_t v = (int16_t)(amp * sinf(2.0f * (float)M_PI * freq * t));
buf[i * 2] = v; /* L */
buf[i * 2 + 1] = v; /* R */
}
size_t written = 0;
i2s_channel_write(s_spk_handle, buf, (size_t)chunk * 2 * sizeof(int16_t),
&written, pdMS_TO_TICKS(500));
sample_idx += chunk;
}
#undef RING_CHUNK
if (s_stop_req) break;
/* OFF: 3.5s silence */
const int off_ms = 3500;
const int steps = off_ms / 50;
for (int i = 0; i < steps && !s_stop_req; i++) {
vTaskDelay(pdMS_TO_TICKS(50));
}
}
vTaskDelete(NULL);
}
/* ── Worker task ─────────────────────────────────────────────────────────── */
static void audio_worker_task(void *arg)
{
(void)arg;
ESP_LOGI(TAG, "audio worker ready");
audio_cmd_t cmd;
for (;;) {
if (xQueueReceive(s_queue, &cmd, portMAX_DELAY) != pdTRUE) continue;
s_stop_req = false;
switch (cmd.kind) {
case CMD_STOP:
s_stop_req = true;
ESP_LOGI(TAG, "stop");
break;
case CMD_RING:
/* Ring must sound on-hook (caller hears it before picking up).
* Force PA on for the duration of the ring regardless of hook state. */
ESP_LOGI(TAG, "ring start — forcing PA on");
audio_pa_set(true);
xTaskCreate(ring_task, "ring", 4096, NULL, 4, NULL);
break;
case CMD_PLAY: {
/* Gate playback on the REAL hook line (raw SLIC SHK), not phone.c's
* debounced flag. The debounced flag lags or misses the pickup while
* the bell is ringing, so an incoming-call greeting enqueued right
* after pickup would be dropped here ("on-hook") even though the
* handset is physically up. The raw SHK reads the pickup cleanly, and
* is the single source of truth for hook state. */
if (!slic_is_offhook()) {
ESP_LOGI(TAG, "play ignored: on-hook (handset down)");
break;
}
s_playing = true;
const char *p = cmd.path;
if (!p || !*p || strncmp(p, "embedded:", 9) == 0) {
ESP_LOGI(TAG, "play: embedded cue");
play_embedded_cue();
} else {
ESP_LOGI(TAG, "play: %s", p);
play_wav_file(p);
}
s_playing = false;
break;
}
default:
break;
}
}
}
/* ── Public API ──────────────────────────────────────────────────────────── */
void audio_pa_set(bool enable)
{
gpio_set_level(PLIP_PA_ENABLE, enable ? 1 : 0);
ESP_LOGI(TAG, "PA %s", enable ? "ON" : "OFF");
}
bool audio_is_playing(void)
{
return s_playing;
}
esp_err_t audio_init(void)
{
/* 1. ES8388 I2C init + register sequence. */
esp_err_t ret = es8388_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "es8388_init failed: %s", esp_err_to_name(ret));
return ret;
}
/* Apply the configured output volume. es8388_init() leaves OUT2 at 0 dB
* (max) — too loud for a handset earpiece — so set it explicitly here. */
es8388_set_volume(CONFIG_PLIP_SPEAKER_VOLUME);
/* 2. Create I2S channels (TX = speaker, RX = mic — full-duplex pair). */
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(PLIP_I2S_NUM,
I2S_ROLE_MASTER);
chan_cfg.auto_clear = true;
ret = i2s_new_channel(&chan_cfg, &s_spk_handle, &s_mic_handle);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "i2s_new_channel: %s", esp_err_to_name(ret));
return ret;
}
/* 3a+3b. Full-duplex I2S config: SAME gpio_cfg for TX and RX.
* IDF5 i2s_std constitutes full-duplex ONLY when TX and RX configs are
* identical (memcmp). If they differ, on ESP32 HW v1 it tries to move
* RX to I2S_NUM_1, breaking clock routing and yielding permanent zeros.
* Fix: set both dout=PLIP_I2S_DOUT and din=PLIP_I2S_DIN in the same
* config and apply it to both handles. The IDF GPIO driver handles
* direction internally (dout→output, din→input). */
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(
I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO),
.gpio_cfg = {
.mclk = PLIP_I2S_MCLK,
.bclk = PLIP_I2S_BCLK,
.ws = PLIP_I2S_WS,
.dout = PLIP_I2S_DOUT,
.din = PLIP_I2S_DIN,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
ret = i2s_channel_init_std_mode(s_spk_handle, &std_cfg);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "i2s_channel_init_std_mode (TX): %s", esp_err_to_name(ret));
return ret;
}
ret = i2s_channel_init_std_mode(s_mic_handle, &std_cfg);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "i2s_channel_init_std_mode (RX): %s", esp_err_to_name(ret));
return ret;
}
/* Enable both TX and RX at boot for full-duplex operation.
* Both must be enabled for the I2S hardware to route ASDOUT→DIN correctly.
* RX data will be read only during capture; TX stays active for clock/DAC. */
ret = i2s_channel_enable(s_spk_handle);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "i2s_channel_enable (TX): %s", esp_err_to_name(ret));
return ret;
}
ret = i2s_channel_enable(s_mic_handle);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "i2s_channel_enable (RX): %s", esp_err_to_name(ret));
/* Non-fatal: TX still works for playback; capture will fail gracefully. */
ESP_LOGW(TAG, "RX enable failed — capture disabled, playback unaffected");
s_mic_handle = NULL;
}
/* 3c. Quick RX sanity: read multiple frames to check DMA and signal quality.
* Do this before the 440Hz boot tone to see baseline ADC output. */
if (s_mic_handle) {
int16_t test_buf[320 * 2];
size_t test_read = 0;
/* Drain any startup garbage (first few frames may be codec settling). */
for (int flush = 0; flush < 5; flush++) {
i2s_channel_read(s_mic_handle, test_buf, sizeof(test_buf), &test_read, pdMS_TO_TICKS(50));
}
/* Read a real frame and report. */
esp_err_t rx_ret = i2s_channel_read(s_mic_handle, test_buf,
sizeof(test_buf), &test_read,
pdMS_TO_TICKS(200));
int32_t max_val = 0, rms_sq = 0;
int n = (int)(test_read / 2);
for (int i = 0; i < n; i++) {
int32_t v = test_buf[i] < 0 ? -test_buf[i] : test_buf[i];
if (v > max_val) max_val = v;
rms_sq += (int32_t)test_buf[i] * test_buf[i];
}
rms_sq /= (n > 0 ? n : 1);
ESP_LOGI(TAG, "RX sanity: ret=%s read=%zu max=%"PRId32" rms²=%"PRId32" s[0..3]=(%d,%d,%d,%d)",
esp_err_to_name(rx_ret), test_read, max_val, rms_sq,
n>0?test_buf[0]:0, n>0?test_buf[1]:0, n>1?test_buf[2]:0, n>1?test_buf[3]:0);
}
/* 4. Create async command queue + worker. */
s_queue = xQueueCreate(8, sizeof(audio_cmd_t));
if (!s_queue) return ESP_ERR_NO_MEM;
BaseType_t ok = xTaskCreatePinnedToCore(audio_worker_task, "audio_work",
8192, NULL, 5, NULL, 0);
if (ok != pdPASS) return ESP_ERR_NO_MEM;
/* Mount SPIFFS at init (not lazily) so turn_client can WRITE the NPC WAV to
* /spiffs before any playback has triggered the lazy mount. */
ensure_spiffs_audio();
ESP_LOGI(TAG, "audio init OK (I2S TX ready, RX handle allocated, ES8388 live)");
return ESP_OK;
}
/* ── Mic capture ─────────────────────────────────────────────────────────── */
/*
* audio_capture_wav() — capture microphone audio and return a WAV buffer.
*
* Strategy: half-duplex. Disable TX, enable RX, read until VAD silence timeout
* or hard cap, then disable RX, re-enable TX.
*
* ES8388 delivers stereo I2S frames (L+R int16) even though the mic is mono.
* We average L+R into a single mono sample for the output WAV.
*
* Returns number of bytes written to out (header + PCM), or -1 on error.
*/
int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms)
{
if (!s_mic_handle || !s_spk_handle) {
ESP_LOGE(TAG, "capture: I2S handles not ready");
return -1;
}
if (!out || out_max < 44 + 320) {
ESP_LOGE(TAG, "capture: output buffer too small");
return -1;
}
/* WAV parameters for output: 16 kHz, mono, S16-LE. */
const uint32_t cap_rate = SAMPLE_RATE; /* 16000 */
const uint16_t cap_ch = 1; /* mono output */
const uint16_t cap_bits = 16;
const size_t hdr_bytes = 44;
/* Per-frame: 20 ms at 16kHz = 320 mono samples = 640 bytes PCM out.
* ES8388 stereo frame = 640 bytes input (320 samples × 2 ch × 2 bytes). */
const int frame_samples = 320; /* mono samples per frame */
const size_t frame_in_bytes = (size_t)frame_samples * 2 * sizeof(int16_t); /* stereo */
const size_t frame_out_bytes = (size_t)frame_samples * sizeof(int16_t); /* mono */
/* Allocate scratch buffer in SPIRAM if available, else internal heap. */
int16_t *rx_buf = (int16_t *)heap_caps_malloc(frame_in_bytes,
MALLOC_CAP_SPIRAM);
if (!rx_buf) {
rx_buf = (int16_t *)malloc(frame_in_bytes);
}
if (!rx_buf) {
ESP_LOGE(TAG, "capture: OOM for rx_buf");
return -1;
}
/* RX is already enabled at boot (full-duplex). Calling enable again returns
* ESP_ERR_INVALID_STATE — that's fine, it just means RX is already running.
* Only a genuinely different error is fatal. */
esp_err_t ret = i2s_channel_enable(s_mic_handle);
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "capture: i2s_channel_enable(RX): %s", esp_err_to_name(ret));
free(rx_buf);
return -1;
}
/* Reserve space for WAV header at start of out buffer. */
uint8_t *pcm_out = out + hdr_bytes;
size_t pcm_max = out_max - hdr_bytes;
size_t pcm_written = 0;
const int max_frames = (max_ms / 20);
const int silence_frames = (silence_ms / 20);
/* Calibrated to the QUIET handset voice (DC-blocked: body 0.16-0.8 % FS RMS,
* loud syllables ~2-4 %, noise floor ~0.3 %). The old onset (1.4 %) NEVER
* triggered → "no sustained voice"; the old silence (0.6 %) sat ABOVE the
* body → cut mid-sentence. Now: onset catches a real syllable just above the
* floor (3-frame confirm guards clicks); silence sits just above the floor so
* the turn ends only on a true pause, holding the whole sentence. */
const int32_t vad_onset_rms_sq = 230 * 230; /* ~0.70% FS² — quiet-voice onset */
const int32_t vad_silence_rms_sq = 110 * 110; /* ~0.34% FS² — just above noise floor */
int silent_frames = 0;
int total_frames = 0;
/* One-pole DC blocker (high-pass ~80 Hz) applied to the captured mono.
* The SLIC/handset path carries a huge DC/sub-audio offset (~80 % FS,
* measured). Left in, it (a) swamps the faint voice and (b) keeps the VAD
* RMS permanently above the silence threshold, so the capture never ends
* and always runs the full max_ms — and the gateway then sees only a DC
* transient, transcribing empty. Removing it lets the VAD track the real
* speech envelope (onset/silence work) and hands the gateway a clean,
* voice-dominated signal. Telephone band (300-3400 Hz) is untouched.
* y[n] = x[n] - x[n-1] + R*y[n-1]; R=0.97 → cutoff ~80 Hz at 16 kHz. */
float dc_x1 = 0.0f, dc_y1 = 0.0f;
const float dc_R = 0.97f;
ESP_LOGI(TAG, "capture: RX enabled, max_ms=%d silence_ms=%d", max_ms, silence_ms);
/* ── Phase A: wait for SUSTAINED voice before committing to a capture ─────
* Only start recording when the caller actually speaks. We require the
* DC-blocked RMS to stay above the onset threshold for ONSET_CONFIRM
* consecutive frames (~60 ms) — a single loud frame is rejected as a
* transient (e.g. the click when the PA mutes just before capture). Audio
* is discarded during this phase. If no sustained voice arrives within
* max_ms we return an empty WAV so the caller posts nothing (the NPC must
* not "reply" to silence). The listen loop simply calls us again. */
const int ONSET_CONFIRM = 3;
int onset_run = 0;
bool got_onset = false;
for (int f = 0; f < max_frames; f++) {
size_t bytes_read = 0;
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
&bytes_read, pdMS_TO_TICKS(100));
if (ret != ESP_OK || bytes_read == 0) continue;
int64_t rms_sq = 0;
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
for (int i = 0; i < n_stereo; i++) {
float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
float yf = xf - dc_x1 + dc_R * dc_y1; /* DC-blocking high-pass */
dc_x1 = xf; dc_y1 = yf;
int32_t s = (int32_t)yf;
rms_sq += (int64_t)s * s;
}
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
if (rms_sq >= vad_onset_rms_sq) {
if (++onset_run >= ONSET_CONFIRM) {
got_onset = true;
ESP_LOGI(TAG, "capture: sustained voice onset at frame %d (rms²=%"PRId64")",
f, rms_sq);
break;
}
} else {
onset_run = 0;
}
}
if (got_onset) {
/* ── Phase B: record the utterance until silence_ms of silence ──────── */
for (int f = 0; f < max_frames && pcm_written + frame_out_bytes <= pcm_max; f++) {
size_t bytes_read = 0;
ret = i2s_channel_read(s_mic_handle, rx_buf, frame_in_bytes,
&bytes_read, pdMS_TO_TICKS(100));
if (ret != ESP_OK || bytes_read == 0) {
ESP_LOGW(TAG, "capture: read error f=%d: %s", f, esp_err_to_name(ret));
continue;
}
/* Downmix stereo → mono (DC-blocked), compute RMS². */
int16_t *out_frame = (int16_t *)(pcm_out + pcm_written);
int64_t rms_sq = 0;
int n_stereo = (int)(bytes_read / (2 * sizeof(int16_t)));
for (int i = 0; i < n_stereo; i++) {
float xf = (float)((rx_buf[i * 2] + rx_buf[i * 2 + 1]) / 2);
float yf = xf - dc_x1 + dc_R * dc_y1;
dc_x1 = xf; dc_y1 = yf;
if (yf > 32767.0f) yf = 32767.0f;
else if (yf < -32768.0f) yf = -32768.0f;
int16_t mono = (int16_t)yf;
out_frame[i] = mono;
rms_sq += (int64_t)mono * mono;
}
rms_sq /= (n_stereo > 0 ? n_stereo : 1);
pcm_written += (size_t)n_stereo * sizeof(int16_t);
total_frames = f + 1;
if (rms_sq < vad_silence_rms_sq) {
if (++silent_frames >= silence_frames) {
ESP_LOGI(TAG, "capture: VAD end (silence %d frames at f=%d)",
silent_frames, f);
break;
}
} else {
silent_frames = 0;
}
}
} else {
ESP_LOGI(TAG, "capture: no sustained voice in %d ms — nothing to send", max_ms);
/* pcm_written stays 0 → empty WAV → caller skips posting. */
}
/* Leave RX enabled (full-duplex, as at boot). Disabling it here would make
* the next capture's enable a no-op INVALID_STATE AND break other RX users
* (streaming capture / DTMF) that assume RX stays running. */
free(rx_buf);
if (pcm_written == 0) {
ESP_LOGW(TAG, "capture: no audio captured (silence or timeout)");
/* Return a valid but minimal WAV with 0 PCM bytes so caller can inspect. */
}
uint32_t data_size = (uint32_t)pcm_written;
uint32_t byte_rate = cap_rate * cap_ch * (cap_bits / 8);
uint16_t block_align = (uint16_t)(cap_ch * (cap_bits / 8));
uint32_t riff_size = 36 + data_size;
/* Write WAV header (44 bytes, little-endian). */
memcpy(out + 0, "RIFF", 4);
memcpy(out + 4, &riff_size, 4);
memcpy(out + 8, "WAVE", 4);
memcpy(out + 12, "fmt ", 4);
uint32_t fmt_size = 16;
uint16_t fmt_pcm = 1;
memcpy(out + 16, &fmt_size, 4);
memcpy(out + 20, &fmt_pcm, 2);
memcpy(out + 22, &cap_ch, 2);
memcpy(out + 24, &cap_rate, 4);
memcpy(out + 28, &byte_rate, 4);
memcpy(out + 32, &block_align, 2);
memcpy(out + 34, &cap_bits, 2);
memcpy(out + 36, "data", 4);
memcpy(out + 40, &data_size, 4);
int total_bytes = (int)(hdr_bytes + pcm_written);
float duration_s = (float)pcm_written / (float)(cap_rate * (cap_bits / 8) * cap_ch);
ESP_LOGI(TAG, "capture done: %d frames, %.2fs, %zu PCM bytes, %d total bytes",
total_frames, duration_s, pcm_written, total_bytes);
return total_bytes;
}
/* ── Streaming capture API ───────────────────────────────────────────────── */
/* Scratch buffer reused across frames (lives for the duration of a capture). */
static int16_t *s_rx_scratch = NULL; /* stereo frame: 320*2 int16_t = 1280 bytes */
static int s_frame_debug_count = 0;
#define CAP_FRAME_SAMPLES 320 /* 20 ms @ 16kHz */
#define CAP_FRAME_IN_BYTES (CAP_FRAME_SAMPLES * 2 * sizeof(int16_t))
int audio_capture_begin(int max_ms, int silence_ms)
{
(void)max_ms; (void)silence_ms; /* caller drives VAD/timeout */
if (!s_mic_handle) {
ESP_LOGE(TAG, "capture_begin: RX handle not available");
return -1;
}
s_rx_scratch = (int16_t *)heap_caps_malloc(CAP_FRAME_IN_BYTES, MALLOC_CAP_SPIRAM);
if (!s_rx_scratch) s_rx_scratch = (int16_t *)malloc(CAP_FRAME_IN_BYTES);
if (!s_rx_scratch) {
ESP_LOGE(TAG, "capture_begin: OOM for scratch buf");
return -1;
}
s_frame_debug_count = 0;
/* Full-duplex: keep TX running (provides MCLK/BCLK/WS clocks to codec).
* RX is already enabled at boot. */
ESP_LOGI(TAG, "capture_begin: ready (full-duplex, TX running)");
return 0;
}
/*
* Read one 20 ms frame from the mic, downmix stereo→mono.
* mono_out must hold n_samples (320) int16_t values.
* Sets *rms_sq_out to mean-squared energy of the frame.
* Returns number of mono samples written, 0 on read timeout, -1 on error.
*/
int audio_capture_read_frame(int16_t *mono_out, int n_samples, int64_t *rms_sq_out)
{
if (!s_rx_scratch) return -1;
size_t bytes_read = 0;
esp_err_t ret = i2s_channel_read(s_mic_handle, s_rx_scratch,
CAP_FRAME_IN_BYTES, &bytes_read,
pdMS_TO_TICKS(100));
if (ret != ESP_OK || bytes_read == 0) return 0;
int n = (int)(bytes_read / (2 * sizeof(int16_t)));
if (n > n_samples) n = n_samples;
/* Debug: log first 3 frames raw stereo values to diagnose zero data. */
if (s_frame_debug_count < 3) {
int16_t r0 = (n > 0) ? s_rx_scratch[0] : 0;
int16_t r1 = (n > 0) ? s_rx_scratch[1] : 0;
int16_t r2 = (n > 1) ? s_rx_scratch[2] : 0;
int16_t r3 = (n > 1) ? s_rx_scratch[3] : 0;
ESP_LOGI(TAG, "RX frame %d: bytes_read=%zu n=%d raw[0..3]=(%d,%d,%d,%d)",
s_frame_debug_count, bytes_read, n, r0, r1, r2, r3);
s_frame_debug_count++;
}
int64_t rms_sq = 0;
for (int i = 0; i < n; i++) {
int32_t l = s_rx_scratch[i * 2];
int32_t r = s_rx_scratch[i * 2 + 1];
int16_t mono = (int16_t)((l + r) / 2);
mono_out[i] = mono;
rms_sq += (int64_t)mono * mono;
}
if (rms_sq_out) *rms_sq_out = (n > 0) ? rms_sq / n : 0;
return n;
}
void audio_capture_end(void)
{
if (s_rx_scratch) { free(s_rx_scratch); s_rx_scratch = NULL; }
ESP_LOGI(TAG, "capture_end: done");
}
i2s_chan_handle_t audio_spk_handle(void)
{
return s_spk_handle;
}
void audio_play_tone(float frequency_hz, int duration_ms)
{
if (!s_spk_handle || duration_ms <= 0) return;
const int total_samples = SAMPLE_RATE * duration_ms / 1000;
const float amp = 12000.0f;
/* Stereo: each sample = L + R = 2 int16_t values. */
/* Process 128 mono samples per iteration = 256 stereo int16_t = 512 bytes. */
#define TONE_CHUNK 128
int16_t buf[TONE_CHUNK * 2]; /* stereo buffer: 2 samples per frame */
size_t written = 0;
int idx = 0;
while (idx < total_samples) {
int chunk = (total_samples - idx < TONE_CHUNK)
? (total_samples - idx) : TONE_CHUNK;
for (int i = 0; i < chunk; i++) {
float t = (float)(idx + i) / (float)SAMPLE_RATE;
int16_t v = (int16_t)(amp * sinf(2.0f * (float)M_PI * frequency_hz * t));
buf[i * 2] = v; /* L */
buf[i * 2 + 1] = v; /* R */
}
i2s_channel_write(s_spk_handle, buf, (size_t)chunk * 2 * sizeof(int16_t),
&written, pdMS_TO_TICKS(500));
idx += chunk;
}
#undef TONE_CHUNK
}
esp_err_t audio_play_async(const char *path)
{
if (!s_queue) return ESP_ERR_INVALID_STATE;
audio_cmd_t cmd = { .kind = CMD_PLAY };
if (path && *path) {
strncpy(cmd.path, path, sizeof(cmd.path) - 1);
}
if (xQueueSend(s_queue, &cmd, 0) != pdTRUE) {
ESP_LOGW(TAG, "play queue full");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t audio_stop(void)
{
s_stop_req = true;
if (!s_queue) return ESP_ERR_INVALID_STATE;
audio_cmd_t cmd = { .kind = CMD_STOP };
xQueueSend(s_queue, &cmd, 0);
return ESP_OK;
}
esp_err_t audio_ring_start(void)
{
s_stop_req = false;
if (!s_queue) return ESP_ERR_INVALID_STATE;
audio_cmd_t cmd = { .kind = CMD_RING };
if (xQueueSend(s_queue, &cmd, 0) != pdTRUE) {
return ESP_FAIL;
}
return ESP_OK;
}
+99
View File
@@ -0,0 +1,99 @@
#pragma once
/*
* audio.h — I2S + ES8388 audio interface for the PLIP voice annex.
*
* Provides:
* - es8388 + I2S initialisation (called once from app_main)
* - Blocking 440 Hz tone (Phase A proof)
* - WAV file playback from SD or embedded asset
* - Ring tone (400/450 Hz cadence) for Phase D
* - Async playback command queue (used by cmd_exec / ESP-NOW)
*/
#include <stdint.h>
#include <stddef.h>
#include "esp_err.h"
#include "driver/i2s_std.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Initialise ES8388 and I2S channels. Must be called before any other
* audio_* function. Returns ESP_OK on success. */
esp_err_t audio_init(void);
/* Return the speaker I2S TX channel handle (used by cmd_exec for WAV play). */
i2s_chan_handle_t audio_spk_handle(void);
/* Play a pure sine tone for duration_ms (blocking). */
void audio_play_tone(float frequency_hz, int duration_ms);
/* Enqueue an async play command. path may be:
* - "/sdcard/<file>.wav" — read from SD
* - "embedded://" — built-in C5-E5-G5 cue
* - "" or NULL — same as embedded://
* Returns ESP_OK if the command was enqueued (non-blocking from any task). */
esp_err_t audio_play_async(const char *path);
/* Stop current playback immediately. */
esp_err_t audio_stop(void);
/* Mount the microSD card at /sdcard on demand (best-effort, idempotent) and
* report whether it is mounted. Unlike playback, this does NOT depend on the
* hook state — it lets the REST layer stage a voice pack onto the SD while the
* handset is down. Returns true if /sdcard is usable. */
bool audio_ensure_sd(void);
/* True while the audio worker is playing a clip (tone/WAV). The conversation
* LISTEN loop polls this to stay half-duplex: never capture while playing
* (avoids the earpiece→mic feedback that saturated the line). */
bool audio_is_playing(void);
/* Start ring tone cadence (ON 1s / OFF 2s) at ~440 Hz. Continues until
* audio_stop() is called. Non-blocking — spawns an internal task. */
esp_err_t audio_ring_start(void);
/* Enable or disable the power amplifier (GPIO21 PA_ENABLE).
* Call audio_pa_set(true) on off-hook, audio_pa_set(false) on on-hook.
* ring_start forces PA on internally; ring_stop calls audio_pa_set(false)
* only if the handset is on-hook. */
void audio_pa_set(bool enable);
/*
* Capture microphone audio and encode as WAV (16 kHz, mono, S16-LE).
*
* out : caller-provided buffer (must be >= 44 + PCM bytes)
* out_max : size of out in bytes
* max_ms : hard cap on capture duration in milliseconds
* silence_ms: milliseconds of silence after voice onset to trigger stop
*
* Returns total bytes written (44-byte WAV header + PCM), or -1 on error.
* Uses half-duplex: disables TX speaker during capture and re-enables it
* on return. Safe to call from any task; not reentrant.
*/
int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms);
/*
* Streaming capture API — avoids a large output buffer by letting the caller
* write chunks incrementally (e.g. directly into a TCP socket).
*
* Usage:
* audio_capture_begin(max_ms, silence_ms) — enable RX, returns 0 on OK
* audio_capture_read_frame(mono_out, n_samples, rms_sq_out)
* — read one 20 ms frame (mono S16)
* fills n_samples mono samples,
* sets *rms_sq_out (energy metric)
* returns actual samples read, 0=timeout, -1=error
* audio_capture_end() — disable RX, re-enable TX
*
* n_samples must be 320 (= SAMPLE_RATE/1000*20, one 20 ms frame).
* The caller is responsible for VAD and stop logic.
*/
int audio_capture_begin(int max_ms, int silence_ms);
int audio_capture_read_frame(int16_t *mono_out, int n_samples, int64_t *rms_sq_out);
void audio_capture_end(void);
#ifdef __cplusplus
}
#endif
+49
View File
@@ -0,0 +1,49 @@
#pragma once
/*
* AI-Thinker ESP32-A1S Audio Kit V2.2 — Hardware Pin Configuration
* ES8388 codec (I2C control + I2S data), SD card (SPI), power amp.
* DIP switch: 1=OFF, 2=ON, 3=ON, 4=OFF, 5=OFF (SD active, KEY2 busy)
*/
/* ---------- I2C Bus (ES8388 control) ---------- */
#define PLIP_I2C_PORT I2C_NUM_0
#define PLIP_I2C_SCL 32
#define PLIP_I2C_SDA 33
#define PLIP_I2C_FREQ_HZ 400000
/* ---------- ES8388 Audio Codec (I2C address) ---------- */
#define PLIP_ES8388_ADDR 0x10 /* 7-bit: 0x10 with ADDR pin low */
/* ---------- I2S Audio Data ---------- */
#define PLIP_I2S_NUM I2S_NUM_0
#define PLIP_I2S_MCLK 0 /* GPIO0 — must be 0/1/3 on original ESP32 */
#define PLIP_I2S_BCLK 27
#define PLIP_I2S_WS 25 /* LRCK */
#define PLIP_I2S_DOUT 26 /* DAC → speaker */
#define PLIP_I2S_DIN 35 /* ADC ← mic (input-only, ES8388 ASDOUT) */
/* ---------- Power Amplifier ---------- */
#define PLIP_PA_ENABLE 21 /* Active HIGH, drives NS4150 amp */
/* ---------- SD Card (SPI / HSPI) ---------- */
#define PLIP_SD_CS 13
#define PLIP_SD_MOSI 15
#define PLIP_SD_MISO 2
#define PLIP_SD_SCK 14
#define PLIP_SD_MOUNT "/sdcard"
/* ---------- Audio Parameters ---------- */
#define PLIP_SAMPLE_RATE 16000
#define PLIP_BITS_PER_SAMPLE 16
#define PLIP_CHANNELS 2 /* ES8388 I2S always stereo; output averaged */
/* ---------- Off-hook GPIO (dev kit uses BOOT/KEY1 GPIO4 as stand-in) ---------- */
/* Actual value comes from CONFIG_PLIP_HOOK_GPIO (Kconfig) */
/* ---------- SLIC K50835F / AG1171-class front-end (A1S board wiring) ---------- */
/* KEY3=GPIO19, KEY4=GPIO23, KEY5=GPIO18, KEY6=GPIO5 share these pins — reassigned to SLIC */
#define PLIP_SLIC_RM 18 /* Ring Mode output — HIGH = ring burst active */
#define PLIP_SLIC_FR 5 /* Forward/Reverse output — toggled at 25 Hz for bell */
#define PLIP_SLIC_SHK 23 /* Switch Hook input — active-LOW (K50835F open-collector) */
#define PLIP_SLIC_PD 19 /* Power Down (open-drain) — HIGH = SLIC active */
+87
View File
@@ -0,0 +1,87 @@
/*
* cmd_exec.c — PLIP voice CMD executor (D5 contract, adapted from box3_voice).
*
* The PLIP has no display; screen/evt/led ops are logged and ignored.
* play → audio_play_async(path).
*/
#include "cmd_exec.h"
#include "audio.h"
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#define TAG "cmd_exec"
static esp_err_t exec_play(const cJSON *a)
{
const cJSON *path_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
const char *path = cJSON_IsString(path_j) ? path_j->valuestring : "embedded://";
ESP_LOGI(TAG, "CMD op=play path=\"%s\"", path);
return audio_play_async(path);
}
static esp_err_t exec_screen(const cJSON *a)
{
const cJSON *t_j = a ? cJSON_GetObjectItemCaseSensitive(a, "t") : NULL;
ESP_LOGI(TAG, "CMD op=screen t=\"%s\" (no display on PLIP — ignored)",
cJSON_IsString(t_j) ? t_j->valuestring : "");
return ESP_OK;
}
static esp_err_t exec_evt(const cJSON *a)
{
const cJSON *n_j = a ? cJSON_GetObjectItemCaseSensitive(a, "n") : NULL;
ESP_LOGI(TAG, "CMD op=evt n=\"%s\"",
cJSON_IsString(n_j) ? n_j->valuestring : "<none>");
return ESP_OK;
}
static esp_err_t exec_led(const cJSON *a)
{
const cJSON *p_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
ESP_LOGI(TAG, "CMD op=led pattern=\"%s\" (no LED — ignored)",
cJSON_IsString(p_j) ? p_j->valuestring : "<none>");
return ESP_OK;
}
esp_err_t cmd_exec_handle(const char *data, size_t len)
{
if (!data || len == 0) return ESP_ERR_INVALID_ARG;
cJSON *root = cJSON_ParseWithLength(data, len);
if (!root) {
ESP_LOGW(TAG, "CMD parse error: not valid JSON (%.40s)", data);
return ESP_ERR_INVALID_ARG;
}
const cJSON *op_j = cJSON_GetObjectItemCaseSensitive(root, "op");
if (!cJSON_IsString(op_j) || !op_j->valuestring || !op_j->valuestring[0]) {
ESP_LOGW(TAG, "CMD missing/invalid 'op' field");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
const char *op = op_j->valuestring;
const cJSON *a = cJSON_GetObjectItemCaseSensitive(root, "a");
esp_err_t err;
if (strcmp(op, "play") == 0) {
err = exec_play(a);
} else if (strcmp(op, "screen") == 0) {
err = exec_screen(a);
} else if (strcmp(op, "evt") == 0) {
err = exec_evt(a);
} else if (strcmp(op, "led") == 0) {
err = exec_led(a);
} else {
ESP_LOGW(TAG, "CMD op=\"%s\" unknown — ignored", op);
err = ESP_ERR_NOT_SUPPORTED;
}
cJSON_Delete(root);
return err;
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
/*
* cmd_exec.h — ESP-NOW CMD executor for the PLIP voice annex (D5 contract).
*
* Handles JSON CMD frames: { "op": "play"|"screen"|"evt"|"led", "a": {...} }
* Routing:
* play → audio_play_async(a.p)
* screen → log only (no display on PLIP)
* evt → log only
* led → log only
*/
#include "esp_err.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Parse and dispatch a CMD JSON payload received from the master via ESP-NOW.
* `data` is a NUL-terminated JSON string of `len` bytes.
* Returns ESP_OK on success, ESP_ERR_INVALID_ARG on parse error,
* ESP_ERR_NOT_SUPPORTED for unknown ops. */
esp_err_t cmd_exec_handle(const char *data, size_t len);
#ifdef __cplusplus
}
#endif
+506
View File
@@ -0,0 +1,506 @@
/*
* conversation.c — PLIP telephone conversation state machine.
*
* States: IDLE → DIALTONE → DIALING → RINGBACK | BUSY
* RINGBACK (after ~2 s) → GREET → CONNECTED
*
* Transitions:
* IDLE + off-hook → DIALTONE (tones_dialtone_start)
* DIALTONE + first digit → DIALING (tones_stop)
* DIALING + ms_since_last > 3000 → RINGBACK or BUSY (route decision)
* RINGBACK + 2 s elapsed → GREET (tones_stop, turn_client_greeting, play WAV)
* GREET + play enqueued → CONNECTED (Stage 3: listen loop)
* any state + on-hook → IDLE (tones_stop, audio_stop, PA off)
*
* Routing table (known numbers → ringback; unknown → busy after 3 s silence):
* "12", "3615", "15", "17", "18", "0142738200"
*/
#include "conversation.h"
#include "dialer.h"
#include "tones.h"
#include "audio.h"
#include "phone.h"
#include "slic.h"
#include "turn_client.h"
#include "plip_gamebook.h"
#if CONFIG_PLIP_DIAL_DTMF
#include "dtmf.h"
#endif
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include <stdio.h>
#include <string.h>
#if CONFIG_PLIP_VOICE_REPLY
/* Capture buffer sizing.
* PSRAM target: 8 s of 16kHz mono S16 + 44-byte WAV header.
* 8 s × 16000 samples/s × 2 bytes = 256000 bytes + 44 = 256044 → round to 256 KB.
* Fallback (internal heap, 4 s max):
* 4 s × 16000 × 2 + 44 = 128044 → round to 128 KB.
*/
#define CAPTURE_MAX_PSRAM (256 * 1024)
#define CAPTURE_MAX_IRAM (128 * 1024)
#define CAPTURE_MAX_MS_PSRAM 8000
#define CAPTURE_MAX_MS_IRAM 4000
#define CAPTURE_SILENCE_MS 900 /* end-of-speech: needs to ride through brief
* mid-sentence pauses so a whole phrase is
* captured (quiet handset voice). 900 ms. */
#define REPLY_POLL_MS 200 /* interval for checking hook during playback */
#define REPLY_PLAYBACK_EXTRA_MS 500 /* safety margin added to computed WAV duration */
#define BETWEEN_TURNS_MS 300 /* short pause between capture rounds */
#endif /* CONFIG_PLIP_VOICE_REPLY */
#define TAG "conversation"
/* Duration of ringback before picking up and fetching the greeting */
#define RINGBACK_GREET_MS 2000
typedef enum {
STATE_IDLE,
STATE_DIALTONE,
STATE_DIALING,
STATE_RINGBACK,
STATE_BUSY,
STATE_GREET, /* fetching + playing NPC greeting (Stage 2) */
STATE_CONNECTED, /* in-call — Stage 3 will add listen loop */
STATE_GAMEBOOK, /* audio "livre dont vous êtes le héros" */
} conv_state_t;
static volatile conv_state_t s_state = STATE_IDLE;
static volatile bool s_offhook = false;
static volatile bool s_hook_changed = false;
/* Ringback start timestamp (µs, from esp_timer_get_time) */
static int64_t s_ringback_start_us = 0;
/* Session ID for the current call (generated at ringback → greet transition) */
static char s_sid[32] = {0};
/* Dialed number LOCKED at routing time. The dialer can keep accumulating
* spurious rotary pulses (marginal hook contact) during the call, so we must
* NOT re-read dialer_current() for the greeting/reply — that polluted number
* would 404 at the gateway. Capture the clean routed number here once. */
static char s_number[16] = {0};
/* Scene this call hints on (e.g. "SCENE_WARNING"), locked at pickup like the
* number. Empty for player-dialled calls. Drives the local SD hint clip. */
static char s_scene[40] = {0};
/* Incoming-call mode: an NPC "calls" the phone. conversation_arm_incoming()
* stores the caller's persona number (and optional scene) and rings; when the
* handset is then picked up from IDLE, we skip the outgoing dialtone/dial/
* ringback and go straight to GREET with this number (the NPC speaks first). */
static char s_incoming_number[16] = {0};
static char s_incoming_scene[40] = {0};
static volatile bool s_incoming_armed = false;
/* Known numbers: ringback when dialed */
static const char *KNOWN[] = {
"12", "3615", "15", "17", "18", "0142738200", NULL
};
static bool is_known(const char *num)
{
for (int i = 0; KNOWN[i] != NULL; i++) {
if (strcmp(num, KNOWN[i]) == 0) return true;
}
return false;
}
/* "SCENE_WARNING" → "warning": strip the SCENE_ prefix and lowercase, matching
* the SD pack filenames from tools/tts/generate_plip_sd_pack.py. */
static void scene_slug(const char *scene, char *out, size_t cap)
{
const char *p = scene;
if (strncmp(p, "SCENE_", 6) == 0) p += 6;
size_t i = 0;
for (; p[i] && i + 1 < cap; i++) {
char c = p[i];
out[i] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
}
out[i] = '\0';
}
static void go_idle(void)
{
tones_stop();
audio_stop();
audio_pa_set(false);
dialer_reset();
plip_gamebook_end(); /* no-op if the gamebook wasn't running */
#if CONFIG_PLIP_DIAL_DTMF
dtmf_stop();
#endif
s_scene[0] = '\0'; /* clear scripted-call scene so a later dial-out call
* doesn't inherit it (CONNECTED would skip listening). */
s_state = STATE_IDLE;
ESP_LOGI(TAG, "-> IDLE");
}
/* Begin an answered INCOMING call: stop ringing, lock the persona number, and
* jump straight to GREET (the NPC speaks first). Drives the conversation's own
* s_offhook directly because phone.c's debounced GPIO detection is unreliable
* during ringing — we trust the SLIC's raw off-hook reading instead. */
static void enter_incoming_greet(void)
{
s_incoming_armed = false;
phone_ring_stop();
slic_ring_stop(); /* belt-and-suspenders: kill the physical bell directly in
* case phone.c's s_ringing flag desynced from slic.c's
* (otherwise phone_ring_stop early-returns and the bell
* keeps ringing through the answered call). */
tones_stop();
dialer_reset();
s_offhook = true;
snprintf(s_number, sizeof(s_number), "%s", s_incoming_number);
snprintf(s_scene, sizeof(s_scene), "%s", s_incoming_scene);
snprintf(s_sid, sizeof(s_sid), "%lld", (long long)esp_timer_get_time());
audio_pa_set(true);
s_state = STATE_GREET;
ESP_LOGI(TAG, "INCOMING pickup -> GREET num=%s sid=%s", s_number, s_sid);
}
static void conv_task(void *arg)
{
(void)arg;
ESP_LOGI(TAG, "conversation task ready");
for (;;) {
vTaskDelay(pdMS_TO_TICKS(50));
/* Handle hook change events */
if (s_hook_changed) {
s_hook_changed = false;
if (!s_offhook) {
/* On-hook: always go idle regardless of current state */
if (s_state != STATE_IDLE) {
ESP_LOGI(TAG, "on-hook -> IDLE");
go_idle();
}
continue;
} else {
/* Off-hook from idle */
if (s_state == STATE_IDLE) {
if (s_incoming_armed) {
/* INCOMING call answered (phone.c detected the edge). */
enter_incoming_greet();
} else if (plip_gamebook_available()) {
/* Gamebook phone: a story pack is on the SD → pick up
* launches the audio "livre dont vous êtes le héros".
* Digits dialed choose the story then the answers. */
dialer_reset();
#if CONFIG_PLIP_DIAL_DTMF
dtmf_start();
#endif
plip_gamebook_begin();
s_state = STATE_GAMEBOOK;
ESP_LOGI(TAG, "off-hook -> GAMEBOOK");
} else {
/* Outgoing call: dial tone, wait for digits. */
dialer_reset();
tones_dialtone_start();
#if CONFIG_PLIP_DIAL_DTMF
dtmf_start();
#endif
s_state = STATE_DIALTONE;
ESP_LOGI(TAG, "off-hook -> DIALTONE");
}
}
continue;
}
}
/* State machine polling */
switch (s_state) {
case STATE_IDLE:
/* Incoming-call pickup: phone.c's debounced edge detection is
* unreliable while the bell rings, so poll the SLIC's raw off-hook
* line directly — it reads the pickup cleanly mid-ring. */
if (s_incoming_armed && slic_is_offhook()) {
enter_incoming_greet();
}
break;
case STATE_DIALTONE:
if (!s_offhook) {
go_idle();
break;
}
/* First digit received → stop dialtone, enter dialing */
if (!dialer_idle()) {
tones_stop();
s_state = STATE_DIALING;
ESP_LOGI(TAG, "first digit -> DIALING");
}
break;
case STATE_GAMEBOOK:
if (!s_offhook) {
go_idle();
break;
}
/* Each dialed digit drives one choice. Consume a single digit at a
* time (take the first, reset) so the player can dial again right
* away — even over the narration, which gets interrupted. */
if (!dialer_idle()) {
int d = dialer_current()[0] - '0';
dialer_reset();
plip_gamebook_feed_digit(d);
}
break;
case STATE_DIALING:
if (!s_offhook) {
go_idle();
break;
}
/* Wait for 3 s of silence after last digit, then route */
if (dialer_ms_since_last() > 3000) {
const char *num = dialer_current();
if (is_known(num)) {
ESP_LOGI(TAG, "route %s -> known (ringback)", num);
/* Lock the routed number now — the dialer may pick up
* spurious pulses later and we must keep posting "17". */
snprintf(s_number, sizeof(s_number), "%s", num);
tones_ringback_start();
s_ringback_start_us = esp_timer_get_time();
s_state = STATE_RINGBACK;
} else {
ESP_LOGI(TAG, "route %s -> unknown (busy)", num);
tones_busy_start();
s_state = STATE_BUSY;
}
}
break;
case STATE_RINGBACK:
if (!s_offhook) {
go_idle();
break;
}
{
int64_t elapsed_ms =
(esp_timer_get_time() - s_ringback_start_us) / 1000;
if (elapsed_ms >= RINGBACK_GREET_MS) {
/* Stop ringback tone synchronously before fetching */
tones_stop();
#if CONFIG_PLIP_DIAL_DTMF
/* Disarm DTMF before entering voice-capture phase */
dtmf_stop();
#endif
/* Generate a session ID from timer ticks */
snprintf(s_sid, sizeof(s_sid), "%lld",
(long long)esp_timer_get_time());
ESP_LOGI(TAG, "ringback done -> GREET (sid=%s num=%s)",
s_sid, dialer_current());
s_state = STATE_GREET;
}
}
break;
case STATE_GREET:
if (!s_offhook) {
go_idle();
break;
}
/* Prefer the local SD voice pack (no gateway, no model in RAM):
* play /sdcard/voice/greet_<number>.wav when present. Fall back to
* the live gateway greeting only when the pack has no clip for this
* NPC number (see tools/tts/generate_plip_sd_pack.py). */
{
char sd_greet[64];
snprintf(sd_greet, sizeof(sd_greet),
"/sdcard/voice/greet_%s.wav", s_number);
FILE *tf = NULL;
if (audio_ensure_sd() && (tf = fopen(sd_greet, "rb")) != NULL) {
fclose(tf);
audio_play_async(sd_greet);
ESP_LOGI(TAG, "GREET: local SD pack %s", sd_greet);
} else if (turn_client_greeting(s_sid, s_number,
"/spiffs/turn.wav")) {
audio_play_async("/spiffs/turn.wav");
} else {
ESP_LOGW(TAG, "no SD clip + gateway greeting failed — silent");
}
}
/* Scripted offline call: chain this scene's hint from the SD pack
* right after the greeting (the audio queue plays them back-to-back).
* Fully local — no gateway, no model. */
if (s_scene[0] != '\0' && audio_ensure_sd()) {
char slug[40], hint[80];
scene_slug(s_scene, slug, sizeof(slug));
snprintf(hint, sizeof(hint),
"/sdcard/voice/hint_%s_l1_0.wav", slug);
FILE *hf = fopen(hint, "rb");
if (hf != NULL) {
fclose(hf);
audio_play_async(hint);
ESP_LOGI(TAG, "GREET: chained SD hint %s", hint);
} else {
ESP_LOGI(TAG, "GREET: no SD hint for scene %s (%s)",
s_scene, hint);
}
}
s_state = STATE_CONNECTED;
ESP_LOGI(TAG, "-> CONNECTED");
break;
case STATE_CONNECTED:
/* Scripted offline call (greeting + SD hint already queued): there's
* no live two-way conversation to run — just let the clips play out
* and wait for the player to hang up. Skips the gateway/model listen
* loop entirely. The global on-hook check returns us to IDLE. */
if (s_scene[0] != '\0') {
vTaskDelay(pdMS_TO_TICKS(REPLY_POLL_MS));
break;
}
#if CONFIG_PLIP_VOICE_REPLY
/*
* Stage 3 — LISTEN loop.
*
* Allocate capture buffer once from PSRAM (preferred) or internal
* heap. Then loop: capture → POST reply → play → wait → repeat.
* Exit on any on-hook event. Buffer freed before leaving.
*/
{
/* --- Allocate capture buffer -------------------------------- */
uint8_t *cap_buf = NULL;
size_t cap_max = 0;
int cap_ms = 0;
cap_buf = heap_caps_malloc(CAPTURE_MAX_PSRAM, MALLOC_CAP_SPIRAM);
if (cap_buf) {
cap_max = CAPTURE_MAX_PSRAM;
cap_ms = CAPTURE_MAX_MS_PSRAM;
ESP_LOGI(TAG, "listen: cap_buf %zu B from PSRAM", cap_max);
} else {
cap_buf = malloc(CAPTURE_MAX_IRAM);
if (cap_buf) {
cap_max = CAPTURE_MAX_IRAM;
cap_ms = CAPTURE_MAX_MS_IRAM;
ESP_LOGW(TAG, "listen: PSRAM unavail, cap_buf %zu B from heap (max %d s)",
cap_max, cap_ms / 1000);
} else {
ESP_LOGE(TAG, "listen: cap_buf alloc failed — staying silent");
/* Remain in CONNECTED without looping */
if (!s_offhook) go_idle();
break;
}
}
/* --- LISTEN loop ------------------------------------------- */
ESP_LOGI(TAG, "listen: entering loop (max %d s / silence %d ms)",
cap_ms, CAPTURE_SILENCE_MS);
while (s_offhook) {
/* HALF-DUPLEX: a telephone handset couples the earpiece into
* the mic. Never capture while anything is playing, or the
* playback feeds back and the line saturates. Wait for the
* greeting/filler/reply to finish, then let the line settle. */
vTaskDelay(pdMS_TO_TICKS(120)); /* let a just-queued clip start (was 250) */
while (s_offhook && audio_is_playing()) {
vTaskDelay(pdMS_TO_TICKS(50));
}
if (!s_offhook) break;
vTaskDelay(pdMS_TO_TICKS(200)); /* let the I2S DMA tail drain */
/* Capture the caller utterance with the PA LEFT ON. The loop
* already waits for any playback to finish above, so nothing
* is driving the earpiece during capture — there is no echo to
* mute. Cutting the PA here was found to collapse the mic level
* (captures came back near-silent / DC only), so keep it on. */
int n = audio_capture_wav(cap_buf, cap_max,
cap_ms, CAPTURE_SILENCE_MS);
if (!s_offhook) break; /* hung up during capture */
if (n <= 44) {
ESP_LOGD(TAG, "listen: no voice (n=%d)", n);
continue;
}
ESP_LOGI(TAG, "listen: captured %d bytes, posting to gateway", n);
/* Filler "un instant, je traite votre demande" plays while the
* reply is synthesised (the POST blocks for several seconds). */
audio_play_async("/spiffs/wait.wav");
esp_err_t ret = turn_client_reply(s_sid, s_number,
cap_buf, (size_t)n,
"/spiffs/reply.wav");
if (!s_offhook) break; /* hung up during HTTP round-trip */
if (ret != ESP_OK) {
ESP_LOGW(TAG, "listen: turn_client_reply failed (%s) — skipping",
esp_err_to_name(ret));
continue;
}
/* Let the filler finish before the reply (no overlap), then play
* the reply. The loop top waits for it to end before re-capturing. */
while (s_offhook && audio_is_playing()) {
vTaskDelay(pdMS_TO_TICKS(50));
}
if (!s_offhook) break;
audio_play_async("/spiffs/reply.wav");
}
/* --- Cleanup ----------------------------------------------- */
free(cap_buf);
ESP_LOGI(TAG, "listen: loop exited (offhook=%d)", (int)s_offhook);
if (!s_offhook) go_idle();
}
#else
/* Stage 3 disabled — STATE_CONNECTED is terminal */
if (!s_offhook) {
go_idle();
}
#endif /* CONFIG_PLIP_VOICE_REPLY */
break;
case STATE_BUSY:
if (!s_offhook) {
go_idle();
}
break;
}
}
}
void conversation_init(void)
{
s_state = STATE_IDLE;
s_offhook = false;
s_hook_changed = false;
/* Stack: STATE_GREET needs 6144 (esp_http_client + file I/O).
* STATE_CONNECTED listen loop (Stage 3) adds turn_client_reply (~1100 B
* locals) + stat() call → bump to 8192 when Stage 3 is compiled in. */
#if CONFIG_PLIP_VOICE_REPLY
xTaskCreate(conv_task, "conv", 8192, NULL, 4, NULL);
#else
xTaskCreate(conv_task, "conv", 6144, NULL, 4, NULL);
#endif
ESP_LOGI(TAG, "conversation init");
}
void conversation_on_hook_change(bool offhook)
{
s_offhook = offhook;
s_hook_changed = true;
}
void conversation_arm_incoming(const char *number, const char *scene)
{
snprintf(s_incoming_number, sizeof(s_incoming_number), "%s",
(number && number[0]) ? number : "17");
snprintf(s_incoming_scene, sizeof(s_incoming_scene), "%s",
(scene && scene[0]) ? scene : "");
s_incoming_armed = true;
phone_ring_start();
ESP_LOGI(TAG, "incoming call armed (num=%s scene=%s) — ringing until pickup",
s_incoming_number, s_incoming_scene[0] ? s_incoming_scene : "-");
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <stdbool.h>
void conversation_init(void);
void conversation_on_hook_change(bool offhook);
/* Arm an INCOMING call from NPC persona `number` and start ringing. When the
* handset is picked up from idle, the conversation jumps straight to the
* greeting (persona speaks first) instead of the outgoing dial-tone path.
* `scene` (optional, e.g. "SCENE_WARNING") makes the phone play that scene's
* hint from its local SD pack right after the greeting; pass NULL/"" for a
* greeting-only call. */
void conversation_arm_incoming(const char *number, const char *scene);
+73
View File
@@ -0,0 +1,73 @@
/*
* dialer.c — Digit accumulator for the PLIP telephone state machine.
*
* Digits are pushed from two sources:
* 1. HTTP /debug/dial?number=NNNN (net.c handler)
* 2. Rotary pulse detection (phone.c, CONFIG_PLIP_DIAL_PULSE)
*
* Thread-safety: all state is protected by atomic operations on s_last_us
* and simple writes to s_len / s_num (called from a single phone task or
* HTTP task at a time — no concurrent push expected).
*/
#include "dialer.h"
#include "esp_log.h"
#include "esp_timer.h"
#include <string.h>
#include <stdint.h>
#define TAG "dialer"
#define MAX_DIGITS 12
static char s_num[MAX_DIGITS + 1];
static int s_len;
static int64_t s_last_us; /* esp_timer_get_time() at last push */
void dialer_init(void)
{
memset(s_num, 0, sizeof(s_num));
s_len = 0;
s_last_us = 0;
ESP_LOGI(TAG, "dialer init");
}
void dialer_push_digit(int d)
{
if (d < 0 || d > 9) return;
if (s_len >= MAX_DIGITS) return; /* silently discard overflow */
s_num[s_len++] = (char)('0' + d);
s_num[s_len] = '\0';
s_last_us = esp_timer_get_time();
ESP_LOGI(TAG, "digit %d -> number so far: \"%s\"", d, s_num);
}
void dialer_reset(void)
{
memset(s_num, 0, sizeof(s_num));
s_len = 0;
s_last_us = 0;
ESP_LOGI(TAG, "dialer reset");
}
const char *dialer_current(void)
{
return s_num;
}
bool dialer_idle(void)
{
return s_len == 0;
}
int dialer_ms_since_last(void)
{
if (s_last_us == 0) return 0;
int64_t elapsed_us = esp_timer_get_time() - s_last_us;
int ms = (int)(elapsed_us / 1000);
return (ms < 0) ? 0 : ms;
}
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <stdbool.h>
void dialer_init(void);
void dialer_push_digit(int d);
void dialer_reset(void);
const char *dialer_current(void);
bool dialer_idle(void);
int dialer_ms_since_last(void);
+323
View File
@@ -0,0 +1,323 @@
/*
* dtmf.c — DTMF (touch-tone) detector using the Goertzel algorithm.
*
* Detection pipeline per 20 ms frame (320 samples @ 16 kHz):
* 1. Compute Goertzel power for 8 DTMF frequencies (4 low + 4 high groups).
* 2. Find the strongest frequency in each group (best_low, best_high).
* 3. Apply three guards:
* a) Absolute energy threshold — both must exceed DTMF_ENERGY_THRESH.
* b) Group dominance ratio — winner must be > DTMF_DOMINANT_RATIO×
* times the second-best in the same group.
* c) Twist guard — energy ratio (low/high) must be within
* [1/DTMF_TWIST_MAX, DTMF_TWIST_MAX].
* 4. Debounce:
* - Require DTMF_CONFIRM_FRAMES consecutive matching frames to emit.
* - Require DTMF_RELEASE_FRAMES of silence/mismatch before re-arming.
*
* Threshold rationale:
* DTMF_ENERGY_THRESH = 4000000
* Goertzel power is mean-squared × N². A -30 dBFS sine at 16-bit PCM
* (amplitude ≈ 1000 LSB) gives power ≈ (1000²/2) × 320² / 320 ≈ 1.6e8.
* -50 dBFS (amplitude ≈ 100) gives ≈ 1.6e6. We set the floor at 4e6
* (≈ -47 dBFS) to reject noise while allowing quiet handset microphones.
*
* DTMF_DOMINANT_RATIO = 4.0f (≈ 6 dB separation within a group)
* A real DTMF tone drives exactly one row and one column. If two
* frequencies in the same group are within 6 dB of each other, it is
* more likely noise or voice than a keypad press.
*
* DTMF_TWIST_MAX = 8.0f (≈ 9 dB)
* ITU-T Q.24 allows up to ±8 dB twist between low/high group. We use
* 8× power ratio which corresponds to ≈ 9 dB — slightly relaxed to
* accommodate the varied mic responses of vintage telephone handsets.
*
* DTMF_CONFIRM_FRAMES = 2 (2 × 20 ms = 40 ms minimum tone duration)
* ITU-T Q.24 specifies ≥ 40 ms tone duration for valid DTMF.
*
* DTMF_RELEASE_FRAMES = 1 (≥ 20 ms inter-digit silence required)
* Prevents a single sustained keypress from re-triggering.
*/
/* sdkconfig.h must be included before any CONFIG_* test so the preprocessor
* has the symbol defined when the #if guard below is evaluated. */
#include "sdkconfig.h"
#if CONFIG_PLIP_DIAL_DTMF
#include "dtmf.h"
#include "audio.h"
#include "dialer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <math.h>
#include <string.h>
#include <stdbool.h>
#include <stdint.h>
#define TAG "dtmf"
/* -------------------------------------------------------------------------
* Detection thresholds (see rationale in file header)
* ---------------------------------------------------------------------- */
/* Minimum Goertzel power (mean-squared × N) for a frequency to be considered
* "present". Both the row and column candidate must exceed this. */
#define DTMF_ENERGY_THRESH 4000000LL
/* Minimum ratio of best/second-best power within a group. Below this the
* group is ambiguous (noise / voice) and the frame is rejected. */
#define DTMF_DOMINANT_RATIO 4.0f
/* Maximum ratio of low-group / high-group power (and its inverse).
* Exceeding this means one group is far stronger than expected for DTMF. */
#define DTMF_TWIST_MAX 8.0f
/* Consecutive frames required before a digit is reported. */
#define DTMF_CONFIRM_FRAMES 2
/* Frames of "no valid tone" required between two reports. */
#define DTMF_RELEASE_FRAMES 1
/* -------------------------------------------------------------------------
* DTMF frequency table
* ---------------------------------------------------------------------- */
#define NUM_LOW 4
#define NUM_HIGH 4
static const float LOW_FREQS[NUM_LOW] = { 697.0f, 770.0f, 852.0f, 941.0f };
static const float HIGH_FREQS[NUM_HIGH] = { 1209.0f, 1336.0f, 1477.0f, 1633.0f };
/* DTMF matrix: [low_idx][high_idx] → character.
* Column 3 (1633 Hz) maps to ABCD which are unused on standard phones → '\0'. */
static const char DTMF_MATRIX[NUM_LOW][NUM_HIGH] = {
{ '1', '2', '3', '\0' }, /* 697 Hz row */
{ '4', '5', '6', '\0' }, /* 770 Hz row */
{ '7', '8', '9', '\0' }, /* 852 Hz row */
{ '*', '0', '#', '\0' }, /* 941 Hz row */
};
/* -------------------------------------------------------------------------
* Goertzel coefficient cache (precomputed at first call)
* ---------------------------------------------------------------------- */
#define FS 16000 /* sample rate */
#define N_SAMP 320 /* frame size */
static bool s_coeff_ready = false;
static float s_low_coeff[NUM_LOW];
static float s_high_coeff[NUM_HIGH];
static void precompute_coeffs(void)
{
for (int i = 0; i < NUM_LOW; i++) {
float k = (float)N_SAMP * LOW_FREQS[i] / (float)FS;
s_low_coeff[i] = 2.0f * cosf(2.0f * (float)M_PI * k / (float)N_SAMP);
}
for (int i = 0; i < NUM_HIGH; i++) {
float k = (float)N_SAMP * HIGH_FREQS[i] / (float)FS;
s_high_coeff[i] = 2.0f * cosf(2.0f * (float)M_PI * k / (float)N_SAMP);
}
s_coeff_ready = true;
}
/* -------------------------------------------------------------------------
* Goertzel power for a single frequency
* Power = Q1² + Q2² Q1·Q2·coeff (unnormalised, proportional to amplitude²)
* ---------------------------------------------------------------------- */
static float goertzel_power(const int16_t *samples, int n, float coeff)
{
float q1 = 0.0f, q2 = 0.0f;
for (int i = 0; i < n; i++) {
float q0 = coeff * q1 - q2 + (float)samples[i];
q2 = q1;
q1 = q0;
}
return q1 * q1 + q2 * q2 - q1 * q2 * coeff;
}
/* -------------------------------------------------------------------------
* Public API: dtmf_detect_frame
* ---------------------------------------------------------------------- */
char dtmf_detect_frame(const int16_t *mono, int n)
{
if (n <= 0 || !mono) return '\0';
if (!s_coeff_ready) precompute_coeffs();
/* Compute Goertzel power for all 8 frequencies */
float low_pow[NUM_LOW], high_pow[NUM_HIGH];
for (int i = 0; i < NUM_LOW; i++)
low_pow[i] = goertzel_power(mono, n, s_low_coeff[i]);
for (int i = 0; i < NUM_HIGH; i++)
high_pow[i] = goertzel_power(mono, n, s_high_coeff[i]);
/* Find strongest in each group */
int best_low = 0, best_high = 0;
float max_low = low_pow[0], max_high = high_pow[0];
for (int i = 1; i < NUM_LOW; i++) {
if (low_pow[i] > max_low) { max_low = low_pow[i]; best_low = i; }
}
for (int i = 1; i < NUM_HIGH; i++) {
if (high_pow[i] > max_high) { max_high = high_pow[i]; best_high = i; }
}
/* Guard (a): absolute energy threshold */
if ((int64_t)max_low < DTMF_ENERGY_THRESH || (int64_t)max_high < DTMF_ENERGY_THRESH)
goto no_tone;
/* Guard (b): group dominance — find second-best in each group */
{
float second_low = 0.0f, second_high = 0.0f;
for (int i = 0; i < NUM_LOW; i++) {
if (i != best_low && low_pow[i] > second_low)
second_low = low_pow[i];
}
for (int i = 0; i < NUM_HIGH; i++) {
if (i != best_high && high_pow[i] > second_high)
second_high = high_pow[i];
}
/* If second-best is within DTMF_DOMINANT_RATIO of best, ambiguous */
if (second_low > 0.0f && max_low < DTMF_DOMINANT_RATIO * second_low)
goto no_tone;
if (second_high > 0.0f && max_high < DTMF_DOMINANT_RATIO * second_high)
goto no_tone;
}
/* Guard (c): twist — power ratio must be within [1/TWIST_MAX, TWIST_MAX] */
{
float ratio = max_low / max_high;
if (ratio > DTMF_TWIST_MAX || ratio < (1.0f / DTMF_TWIST_MAX))
goto no_tone;
}
/* --- Debounce state (static) --- */
{
static char s_candidate = '\0';
static int s_confirm_count = 0;
static int s_release_count = 0;
static bool s_armed = true; /* true = ready to report */
char sym = DTMF_MATRIX[best_low][best_high];
if (sym == '\0') goto no_tone; /* 1633 Hz column — ignored */
/* Reset release counter: we have a tone */
s_release_count = 0;
if (!s_armed) {
/* Waiting for silence/release before accepting next press */
return '\0';
}
if (sym == s_candidate) {
s_confirm_count++;
} else {
s_candidate = sym;
s_confirm_count = 1;
}
if (s_confirm_count >= DTMF_CONFIRM_FRAMES) {
/* Confirmed — report and disarm until release */
s_confirm_count = 0;
s_candidate = '\0';
s_armed = false;
return sym;
}
return '\0';
no_tone:
/* No valid tone detected: advance release counter */
; /* label must precede a statement */
s_confirm_count = 0;
s_candidate = '\0';
if (!s_armed) {
s_release_count++;
if (s_release_count >= DTMF_RELEASE_FRAMES) {
s_armed = true;
s_release_count = 0;
}
}
return '\0';
}
}
/* -------------------------------------------------------------------------
* Background capture task
* ---------------------------------------------------------------------- */
#define DTMF_TASK_STACK 4096
#define DTMF_TASK_PRIO 3
#define DTMF_FRAME_SIZE 320
static volatile bool s_armed_flag = false; /* true = task should process frames */
static TaskHandle_t s_task_handle = NULL;
static void dtmf_task(void *arg)
{
(void)arg;
int16_t frame[DTMF_FRAME_SIZE];
int64_t rms_sq;
ESP_LOGI(TAG, "dtmf_task started");
/* Open the capture stream once and keep it open.
* Full-duplex: TX (speaker) stays active; RX is already enabled at boot.
* We pass generous max_ms / silence_ms since we never call capture_end
* while the call is in progress — dtmf_stop() just clears the armed flag. */
if (audio_capture_begin(3600000, 3600000) != 0) {
ESP_LOGE(TAG, "dtmf_task: capture_begin failed — task exits");
s_task_handle = NULL;
vTaskDelete(NULL);
return;
}
for (;;) {
if (!s_armed_flag) {
/* Disarmed: drain frames slowly so the RX FIFO doesn't overflow */
audio_capture_read_frame(frame, DTMF_FRAME_SIZE, &rms_sq);
vTaskDelay(pdMS_TO_TICKS(20));
continue;
}
int got = audio_capture_read_frame(frame, DTMF_FRAME_SIZE, &rms_sq);
if (got <= 0) continue;
char sym = dtmf_detect_frame(frame, got);
if (sym == '\0') continue;
ESP_LOGI(TAG, "DTMF detected: '%c'", sym);
if (sym >= '0' && sym <= '9') {
dialer_push_digit(sym - '0');
}
/* '*' and '#' are logged only — no dialer push for now */
}
}
void dtmf_start(void)
{
if (!s_task_handle) {
/* Create the task once */
BaseType_t ok = xTaskCreatePinnedToCore(
dtmf_task, "dtmf", DTMF_TASK_STACK, NULL, DTMF_TASK_PRIO,
&s_task_handle, 1);
if (ok != pdPASS) {
ESP_LOGE(TAG, "dtmf_start: xTaskCreate failed");
return;
}
}
s_armed_flag = true;
ESP_LOGI(TAG, "dtmf_start: DTMF detection armed");
}
void dtmf_stop(void)
{
s_armed_flag = false;
ESP_LOGI(TAG, "dtmf_stop: DTMF detection disarmed");
}
#endif /* CONFIG_PLIP_DIAL_DTMF */
+57
View File
@@ -0,0 +1,57 @@
#pragma once
/*
* dtmf.h — DTMF (touch-tone) detector via Goertzel algorithm.
*
* API:
* dtmf_detect_frame() — pure signal processing, no I/O, testable standalone
* dtmf_start() / dtmf_stop() — arm/disarm the background capture task
*
* The capture task reads 20 ms frames from audio_capture_read_frame() and calls
* dialer_push_digit() for confirmed digit presses (digits 0-9 only).
*
* Guard: all declarations and the task body are compiled only when
* CONFIG_PLIP_DIAL_DTMF is set (see Kconfig.projbuild).
*/
#include "sdkconfig.h"
#if CONFIG_PLIP_DIAL_DTMF
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Analyse one 20 ms frame (320 samples at 16 kHz) for a DTMF tone.
*
* Returns the detected character ('0'-'9', '*', '#') once per confirmed press,
* or '\0' when nothing is detected or debounce is still pending.
*
* Debounce rules (internal static state):
* - A symbol must appear in ≥ DTMF_CONFIRM_FRAMES consecutive frames to be
* reported.
* - After a report, at least DTMF_RELEASE_FRAMES of "no tone" must be seen
* before the same (or another) symbol can be reported again.
*/
char dtmf_detect_frame(const int16_t *mono, int n);
/*
* Start the DTMF background task (created once; idempotent re-arm).
* The task reads microphone frames and pushes confirmed 0-9 digits to the
* dialer. Must be called after audio_init().
*/
void dtmf_start(void);
/*
* Disarm the DTMF task. The task suspends itself; the RX stream continues
* for the benefit of the voice capture path (full-duplex architecture).
*/
void dtmf_stop(void);
#ifdef __cplusplus
}
#endif
#endif /* CONFIG_PLIP_DIAL_DTMF */
+286
View File
@@ -0,0 +1,286 @@
/*
* es8388.c — ES8388 codec driver (IDF 5.x, legacy I2C driver API).
*
* Register map reference: ES8388 datasheet rev 1.6 (Everest Semiconductor).
* Init sequence derived from:
* - Espressif esp-adf es8388.c (Apache 2.0)
* - schreibfaul1/ESP32-audioI2S ES8388 init (MIT)
* - AI-Thinker SDK AudioKit driver
*
* The sequence configures the ES8388 for:
* - Master clock: MCLK from ESP32 GPIO0 at 12.288 MHz (or 256*Fs at 16kHz)
* - I2S format: I2S Philips, 16-bit, stereo
* - DAC: LOUT1/ROUT1 → headphone amp (GPIO21 PA_ENABLE must be HIGH)
* - ADC: LINPUT1/RINPUT1 mic (differential), gain 24 dB
* - Sample rate: 16 kHz (MCLKDIV = 256Fs)
*/
#include "es8388.h"
#include "board_config.h"
#include <string.h>
#include "driver/i2c.h"
#include "driver/gpio.h"
#include "esp_log.h"
#define TAG "es8388"
/* ── ES8388 register addresses ──────────────────────────────────────────── */
#define ES8388_CHIP_CTL1 0x00 /* CHIP_CTL1 */
#define ES8388_CHIP_CTL2 0x01 /* CHIP_CTL2 */
#define ES8388_CHIP_POWER 0x02 /* CHIP_POWER (ADCPD, DACPD, etc.) */
#define ES8388_ADC_POWER 0x03 /* ADCPOWER */
#define ES8388_DAC_POWER 0x04 /* DACPOWER */
#define ES8388_CHIP_LP 0x05 /* CHIP_LP */
#define ES8388_CHIP_CTL3 0x06 /* CHIP_CTL3 */
#define ES8388_ADC_CTL1 0x09 /* ADCCONTROL1 — PGA gain */
#define ES8388_ADC_CTL2 0x0A /* ADCCONTROL2 — input select (LINSEL/RINSEL) */
#define ES8388_ADC_CTL3 0x0B /* ADCCONTROL3 — DS/DS filter select */
#define ES8388_ADC_CTL4 0x0C /* ADCCONTROL4 — I2S format / word length */
#define ES8388_ADC_CTL5 0x0D /* ADCCONTROL5 — MCLK divider (RATIO=256) */
#define ES8388_ADC_CTL7 0x0F /* ADCCONTROL7 — HPF enable/config */
#define ES8388_ADC_CTL8 0x10 /* ADCCONTROL8 — ADC L volume */
#define ES8388_ADC_CTL9 0x11 /* ADCCONTROL9 — ADC R volume */
#define ES8388_DAC_CTL1 0x17 /* DACCONTROL1 — I2S word len / format */
#define ES8388_DAC_CTL2 0x18 /* DACCONTROL2 — MCLK divider */
#define ES8388_DAC_CTL3 0x19 /* DACCONTROL3 — mute */
#define ES8388_DAC_CTL4 0x1A /* DACCONTROL4 — LDACVOL */
#define ES8388_DAC_CTL5 0x1B /* DACCONTROL5 — RDACVOL */
#define ES8388_DAC_CTL16 0x26 /* DACCONTROL16 — L/R mixer config */
#define ES8388_DAC_CTL17 0x27 /* DACCONTROL17 — L mixer gain */
#define ES8388_DAC_CTL20 0x2A /* DACCONTROL20 — R mixer gain */
#define ES8388_DAC_CTL21 0x2B /* DACCONTROL21 — ADC/DAC LRCK sync (bit7=1 for full-duplex) */
#define ES8388_DAC_CTL22 0x2C /* DACCONTROL22 — (unused in this config) */
#define ES8388_DAC_CTL23 0x2D /* DACCONTROL23 — (unused in this config) */
#define ES8388_DAC_CTL24 0x2E /* DACCONTROL24 — LOUT1VOL (OUT1 left volume) */
#define ES8388_DAC_CTL25 0x2F /* DACCONTROL25 — ROUT1VOL (OUT1 right volume) */
#define ES8388_DAC_CTL26 0x30 /* DACCONTROL26 — LOUT2VOL (OUT2 left volume) */
/* Volume register: 0x00=0dB (max), 0x21=-33dB step per 1.5dB, 0x24=mute. */
#define ES8388_VOL_MAX 0x00
#define ES8388_VOL_0DB 0x00
#define ES8388_VOL_MUTE 0x24
/* ── I2C helpers ─────────────────────────────────────────────────────────── */
static esp_err_t i2c_write_reg(uint8_t reg, uint8_t value)
{
uint8_t buf[2] = { reg, value };
esp_err_t ret = i2c_master_write_to_device(PLIP_I2C_PORT,
PLIP_ES8388_ADDR,
buf, sizeof(buf),
pdMS_TO_TICKS(100));
if (ret != ESP_OK) {
ESP_LOGE(TAG, "write reg 0x%02X=0x%02X failed: %s",
reg, value, esp_err_to_name(ret));
}
return ret;
}
esp_err_t es8388_read_reg(uint8_t reg, uint8_t *value)
{
return i2c_master_write_read_device(PLIP_I2C_PORT,
PLIP_ES8388_ADDR,
&reg, 1,
value, 1,
pdMS_TO_TICKS(100));
}
esp_err_t es8388_write_reg(uint8_t reg, uint8_t value)
{
return i2c_write_reg(reg, value);
}
/* ── Public API ──────────────────────────────────────────────────────────── */
esp_err_t es8388_init(void)
{
/* Initialise I2C master on the A1S bus. */
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = PLIP_I2C_SDA,
.scl_io_num = PLIP_I2C_SCL,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = PLIP_I2C_FREQ_HZ,
};
esp_err_t ret = i2c_param_config(PLIP_I2C_PORT, &conf);
if (ret != ESP_OK) return ret;
ret = i2c_driver_install(PLIP_I2C_PORT, conf.mode, 0, 0, 0);
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
/* ESP_ERR_INVALID_STATE means the driver is already installed — ok. */
ESP_LOGE(TAG, "i2c_driver_install: %s", esp_err_to_name(ret));
return ret;
}
/* Quick device presence check. */
uint8_t chip_id = 0;
ret = es8388_read_reg(ES8388_CHIP_CTL1, &chip_id);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "ES8388 not found on I2C bus (addr=0x%02X): %s",
PLIP_ES8388_ADDR, esp_err_to_name(ret));
return ret;
}
ESP_LOGI(TAG, "ES8388 detected: CHIP_CTL1=0x%02X", chip_id);
/* ── Full power-up + init sequence ─────────────────────────────────── */
/* 1. Reset. */
if (i2c_write_reg(ES8388_CHIP_CTL1, 0x80) != ESP_OK) return ESP_FAIL;
vTaskDelay(pdMS_TO_TICKS(20));
if (i2c_write_reg(ES8388_CHIP_CTL1, 0x00) != ESP_OK) return ESP_FAIL;
/* 2. Power down all blocks first (prevents pop on line-up). */
if (i2c_write_reg(ES8388_CHIP_POWER, 0xFF) != ESP_OK) return ESP_FAIL;
/* 3. CONTROL1: WORK_MODE = record+playback (0x10), VMIDSEL=10 (0x02).
* 0x12 = 0b00010010 — enables both ADC and DAC paths.
* Previous value 0x05 only enabled playback (WORK_MODE=00). */
if (i2c_write_reg(ES8388_CHIP_CTL1, 0x12) != ESP_OK) return ESP_FAIL;
/* 3b. CONTROL2 (0x01): VROI=0, LPVrefBuf=0, normal operation. */
if (i2c_write_reg(ES8388_CHIP_CTL2, 0x50) != ESP_OK) return ESP_FAIL;
/* 3c. Internal DLL stabilisation for LOW sample rates (16 kHz). UNDOCUMENTED
* registers 0x35/0x37/0x39 — the proven A252 driver (hardware/projects/
* slic-phone Es8388Driver.cpp) sets these to "disable internal DLL for low
* sample-rate stability". WITHOUT them the ADC clock domain is unstable at
* 16 kHz and the ADC outputs a frozen DC value (capture = flat, no AC) even
* though analog signal is present on the LIN pin — exactly our symptom. The
* DAC tolerates it, which is why playback worked but capture didn't. They
* must be set here in the boot sequence (a live poke can't re-lock the DLL). */
if (i2c_write_reg(0x35, 0xA0) != ESP_OK) return ESP_FAIL;
if (i2c_write_reg(0x37, 0xD0) != ESP_OK) return ESP_FAIL;
if (i2c_write_reg(0x39, 0xD0) != ESP_OK) return ESP_FAIL;
/* 4. Clock: MCLK divider = 256*Fs, DAC SRC = MCLK. */
if (i2c_write_reg(0x08, 0x00) != ESP_OK) return ESP_FAIL; /* MASTERMODE = slave */
/* 5. ADC power: power down to allow clean register writes. */
if (i2c_write_reg(ES8388_ADC_POWER, 0xFF) != ESP_OK) return ESP_FAIL;
/* 6. ADC config (follows Espressif esp-codec-dev reference sequence):
* - ADCCONTROL1 (0x09) = 0xBB: MIC PGA +24dB L and R
* - ADCCONTROL2 (0x0A) = 0x50: LINSEL=10 LIN2/RIN2 (telephone handset mic)
* Value 0x00 = LIN1 differential, 0x50 = LIN2 single-ended (A1S combiné)
* - ADCCONTROL3 (0x0B) = 0x02: DS filter select (required by reference)
* - ADCCONTROL4 (0x0C) = 0x0C: I2S Philips 16-bit word length
* - ADCCONTROL5 (0x0D) = 0x02: ADCFsMode SINGLE SPEED RATIO=256 (16kHz@MCLK 4.096MHz)
* - ADCCONTROL8/9 (0x10/0x11) = 0x00: ADC digital volume 0dB */
if (i2c_write_reg(ES8388_ADC_CTL1, 0x88) != ESP_OK) return ESP_FAIL; /* MIC PGA +24dB L+R — the SLIC handset mic is QUIET (~2-5% FS at +12dB); +24dB lifts speech above the capture VAD onset. The old "+24dB too hot" note was an artifact of the broken ADC (pre-DLL-fix); measured clean at ~5% peak now. */
/* ADCCONTROL2 (0x0A): input select. The K50835F SLIC handset transmit audio is
* wired to LIN2/RIN2 on this bench — PROVEN: speech captured (ACrms 196, crest 10.3)
* on 0x50, vs DC-only floating offset on 0x00 (LIN1). */
if (i2c_write_reg(ES8388_ADC_CTL2, 0x50) != ESP_OK) return ESP_FAIL; /* LIN2/RIN2 — SLIC handset mic */
if (i2c_write_reg(ES8388_ADC_CTL3, 0x02) != ESP_OK) return ESP_FAIL; /* DS filter sel */
if (i2c_write_reg(ES8388_ADC_CTL4, 0x0C) != ESP_OK) return ESP_FAIL; /* I2S 16-bit */
if (i2c_write_reg(ES8388_ADC_CTL5, 0x02) != ESP_OK) return ESP_FAIL; /* RATIO=256 */
if (i2c_write_reg(0x0E, 0x00) != ESP_OK) return ESP_FAIL; /* ADCCONTROL6: clear ADCSMUTE bit5 (reset default 0x30 = ADC output muted) */
if (i2c_write_reg(ES8388_ADC_CTL8, 0x00) != ESP_OK) return ESP_FAIL; /* ADC vol L 0dB */
if (i2c_write_reg(ES8388_ADC_CTL9, 0x00) != ESP_OK) return ESP_FAIL; /* ADC vol R 0dB */
/* 8. DAC I2S: 16-bit I2S Philips, MCLK/256, no softmute. */
if (i2c_write_reg(ES8388_DAC_CTL1, 0x18) != ESP_OK) return ESP_FAIL;
if (i2c_write_reg(ES8388_DAC_CTL2, 0x02) != ESP_OK) return ESP_FAIL; /* DACLRCKDIV=256 */
if (i2c_write_reg(ES8388_DAC_CTL3, 0x00) != ESP_OK) return ESP_FAIL; /* mute off */
/* 9. DAC volume: 0 dB on both channels. */
if (i2c_write_reg(ES8388_DAC_CTL4, ES8388_VOL_0DB) != ESP_OK) return ESP_FAIL;
if (i2c_write_reg(ES8388_DAC_CTL5, ES8388_VOL_0DB) != ESP_OK) return ESP_FAIL;
/* 10. Mixer: L→LOUT, R→ROUT (straight through, no cross-mix). */
if (i2c_write_reg(ES8388_DAC_CTL16, 0x1B) != ESP_OK) return ESP_FAIL;
if (i2c_write_reg(ES8388_DAC_CTL17, 0x90) != ESP_OK) return ESP_FAIL;
if (i2c_write_reg(ES8388_DAC_CTL20, 0x90) != ESP_OK) return ESP_FAIL;
/* 11. DACCONTROL21 (0x2B): ADC+DAC LRCK sync.
* Espressif reference es8388_start():
* 0xC0 = LINE mode (analog bypass, not ADC digital path)
* 0x80 = DAC+ADC digital record+playback mode (bit7 only)
* We want digital ADC recording + DAC playback → use 0x80. */
if (i2c_write_reg(ES8388_DAC_CTL21, 0x80) != ESP_OK) return ESP_FAIL;
/* 11b. Restart internal state machine (required after DACCONTROL21 change).
* Without this pulse, the ADC clock domain may not synchronise properly.
* Espressif reference sequence: CHIPPOWER=0xF0 then 0x00 to restart FSM. */
if (i2c_write_reg(ES8388_CHIP_POWER, 0xF0) != ESP_OK) return ESP_FAIL;
vTaskDelay(pdMS_TO_TICKS(5));
if (i2c_write_reg(ES8388_CHIP_POWER, 0x00) != ESP_OK) return ESP_FAIL;
vTaskDelay(pdMS_TO_TICKS(10));
/* DACCONTROL23 (0x2D): VROI=0, normal output. */
if (i2c_write_reg(ES8388_DAC_CTL23, 0x00) != ESP_OK) return ESP_FAIL;
/* 12. Volume: OUT1L/R = 0x1E (0dB), OUT2L = 0 (speaker vol set by PA separately). */
if (i2c_write_reg(ES8388_DAC_CTL24, 0x1E) != ESP_OK) return ESP_FAIL; /* LOUT1VOL */
if (i2c_write_reg(ES8388_DAC_CTL25, 0x1E) != ESP_OK) return ESP_FAIL; /* ROUT1VOL */
if (i2c_write_reg(ES8388_DAC_CTL26, 0x00) != ESP_OK) return ESP_FAIL; /* LOUT2VOL */
/* 13. DAC power: power up DAC L+R. */
if (i2c_write_reg(ES8388_DAC_POWER, 0x3C) != ESP_OK) return ESP_FAIL;
/* 14. ADC power: full power-up (all Pdn bits cleared = 0x00).
* 0x09 is the intermediate state (Espressif es8388_open end-state), but
* the full ADC + analog input power-up requires 0x00 (Espressif es8388_start(ADC)).
* Must come AFTER DACCONTROL21 state machine restart and ADCCONTROL6 unmute. */
if (i2c_write_reg(ES8388_ADC_POWER, 0x00) != ESP_OK) return ESP_FAIL;
vTaskDelay(pdMS_TO_TICKS(10)); /* let ADC analog settle after full power-up */
/* 15. Enable PA (power amplifier for speaker). */
gpio_set_direction(PLIP_PA_ENABLE, GPIO_MODE_OUTPUT);
gpio_set_level(PLIP_PA_ENABLE, 1);
/* Verify key register values to confirm ADC path is configured correctly. */
uint8_t ctl1=0, adcpwr=0, adcinsel=0, dacctl21=0, chippower=0, adcctl3=0;
es8388_read_reg(ES8388_CHIP_CTL1, &ctl1);
es8388_read_reg(ES8388_ADC_POWER, &adcpwr);
es8388_read_reg(ES8388_ADC_CTL2, &adcinsel); /* ADCCONTROL2 = input sel (0x50=LIN2) */
es8388_read_reg(ES8388_ADC_CTL3, &adcctl3); /* ADCCONTROL3 = DS filter (0x02) */
es8388_read_reg(ES8388_DAC_CTL21, &dacctl21); /* DACCONTROL21 = ADC+DAC LRCK sync (0xC0) */
es8388_read_reg(ES8388_CHIP_POWER, &chippower);
ESP_LOGI(TAG, "ES8388 regs: CTL1=0x%02X ADCPWR=0x%02X ADCINSEL=0x%02X ADCCTL3=0x%02X DACCTL21=0x%02X CHIPPOWER=0x%02X",
ctl1, adcpwr, adcinsel, adcctl3, dacctl21, chippower);
ESP_LOGI(TAG, "ES8388 init OK — PA enabled, DAC @ 0dB, ADC PGA +12dB, input=LIN2/RIN2 (SLIC handset), DACCTL21=0x80");
return ESP_OK;
}
esp_err_t es8388_set_volume(uint8_t vol)
{
/* ES8388 OUTx volume registers are GAIN, not attenuation: 0x00 = -45 dB
* (min) .. 0x21 = 0 dB (max); higher value = louder (>0x21 = mute/reserved).
* Map 0..100 → 0x00..0x21. (The previous code inverted this, so vol=100
* produced 0x00 = quietest — confirmed at the bench.)
* DACCONTROL24 (0x2E)=OUT1L, 25 (0x2F)=OUT1R, 26 (0x30)=OUT2L, 27 (0x31)=OUT2R.
* DACCONTROL21 (0x2B) is the ADC/DAC LRCK sync register — DO NOT touch here. */
if (vol > 100) vol = 100;
uint8_t reg_val = (uint8_t)((int)vol * 0x21 / 100);
if (reg_val > 0x21) reg_val = 0x21;
ESP_LOGI(TAG, "set_volume: %d%% -> reg=0x%02X (0x21=max,0dB)", vol, reg_val);
esp_err_t r = ESP_OK;
r |= i2c_write_reg(ES8388_DAC_CTL24, reg_val); /* OUT1 L volume */
r |= i2c_write_reg(ES8388_DAC_CTL25, reg_val); /* OUT1 R volume */
r |= i2c_write_reg(ES8388_DAC_CTL26, reg_val); /* OUT2 L volume */
r |= i2c_write_reg(0x31, reg_val); /* OUT2 R volume (DACCONTROL27) */
return r;
}
esp_err_t es8388_set_dac_volume(uint8_t atten)
{
/* DACCONTROL4 (0x04) = LDACVOL, DACCONTROL5 (0x05) = RDACVOL: DIGITAL DAC
* volume, applied BEFORE the analog output stages. 0x00 = 0 dB, each step
* = -0.5 dB, up to 0xC0 = -96 dB (mute). Lowering this gives analog
* headroom while keeping the output-stage volume (es8388_set_volume) high. */
if (atten > 0xC0) atten = 0xC0;
ESP_LOGI(TAG, "set_dac_volume: atten=0x%02X (-%.1f dB)", atten, atten * 0.5f);
esp_err_t r = i2c_write_reg(ES8388_DAC_CTL4, atten);
r |= i2c_write_reg(ES8388_DAC_CTL5, atten);
return r;
}
esp_err_t es8388_mute(bool mute)
{
uint8_t val = mute ? 0x04 : 0x00; /* bit2 = DACMUTE */
ESP_LOGI(TAG, "mute: %s", mute ? "on" : "off");
return i2c_write_reg(ES8388_DAC_CTL3, val);
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
/*
* es8388.h — Minimal ES8388 codec driver for AI-Thinker ESP32-A1S (IDF 5.x).
*
* Controls the ES8388 via I2C (addr 0x10) for playback (DAC) and capture (ADC).
* I2S is configured separately via the standard IDF driver/i2s_std.h API.
*
* Thread safety: all public functions are non-reentrant; call from a single task.
*/
#include <stdint.h>
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Initialise the I2C master bus and configure the ES8388 registers for
* stereo playback + mic capture at 16 kHz. Must be called once before any
* other es8388_* function and after driver_i2c is available.
*
* Returns ESP_OK on success. On I2C error the register that failed is logged
* and ESP_FAIL is returned — the codec is in an undefined state.
*/
esp_err_t es8388_init(void);
/* Set output volume. vol: 0 (mute) … 100 (full). Mapped to ES8388
* OUT1 (headphone) + OUT2 (speaker) attenuation registers. */
esp_err_t es8388_set_volume(uint8_t vol);
/* Set the DIGITAL DAC volume (DACCONTROL4/5), applied before the analog output
* stages. atten: 0 = 0 dB (full), each step -0.5 dB, 0xC0 = mute. Lowering it
* gives analog headroom while keeping es8388_set_volume() high. */
esp_err_t es8388_set_dac_volume(uint8_t atten);
/* Mute / unmute DAC output. */
esp_err_t es8388_mute(bool mute);
/* Read a single ES8388 register for diagnostic purposes. */
esp_err_t es8388_read_reg(uint8_t reg, uint8_t *value);
/* Write a single ES8388 register for diagnostic purposes (bench input/gain
* sweeps via /debug/es8388?reg=&val= — find which input the SLIC feeds). */
esp_err_t es8388_write_reg(uint8_t reg, uint8_t value);
#ifdef __cplusplus
}
#endif
+159
View File
@@ -0,0 +1,159 @@
/*
* hook_client.c — Ported from PLIP_FIRMWARE/src/zacus_hook_client.cpp.
*
* Uses esp_http_client (IDF) instead of Arduino HTTPClient.
* Failure policy: 3 s timeout, one retry at +250 ms, then drop.
*/
#include "hook_client.h"
#include <string.h>
#include <stdio.h>
#include "esp_http_client.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#define TAG "hook_client"
#define HOOK_PATH "/voice/hook"
#define TIMEOUT_MS 3000
#define RETRY_DELAY_MS 250
#define QUEUE_DEPTH 4
#define WIFI_WAIT_MS 1500
#define WIFI_WAIT_STEP_MS 50
typedef struct {
char state[8]; /* "off" | "on" */
char reason[32]; /* free-form */
} hook_event_t;
static QueueHandle_t s_queue = NULL;
static char s_url[192] = {0}; /* full URL: <base>/voice/hook */
/* ── Helpers ─────────────────────────────────────────────────────────────── */
static bool wifi_ready_within(uint32_t budget_ms)
{
uint32_t waited = 0;
wifi_ap_record_t ap;
while (waited < budget_ms) {
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) return true;
vTaskDelay(pdMS_TO_TICKS(WIFI_WAIT_STEP_MS));
waited += WIFI_WAIT_STEP_MS;
}
return false;
}
static bool post_hook(const hook_event_t *ev)
{
char body[96];
snprintf(body, sizeof(body),
"{\"state\":\"%s\",\"reason\":\"%s\"}",
ev->state, ev->reason);
esp_http_client_config_t cfg = {
.url = s_url,
.method = HTTP_METHOD_POST,
.timeout_ms = TIMEOUT_MS,
.buffer_size = 512,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) {
ESP_LOGW(TAG, "http_client_init failed");
return false;
}
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, body, (int)strlen(body));
esp_err_t ret = esp_http_client_perform(client);
int code = 0;
if (ret == ESP_OK) {
code = esp_http_client_get_status_code(client);
}
esp_http_client_cleanup(client);
if (ret == ESP_OK && code >= 200 && code < 300) {
ESP_LOGI(TAG, "POST %s -> %d (state=%s reason=%s)",
s_url, code, ev->state, ev->reason);
return true;
}
ESP_LOGW(TAG, "POST %s failed: ret=%s code=%d (state=%s reason=%s)",
s_url, esp_err_to_name(ret), code, ev->state, ev->reason);
return false;
}
/* ── Worker task ─────────────────────────────────────────────────────────── */
static void worker_task(void *arg)
{
(void)arg;
ESP_LOGI(TAG, "worker ready, target=%s", s_url);
hook_event_t ev;
for (;;) {
if (xQueueReceive(s_queue, &ev, portMAX_DELAY) != pdTRUE) continue;
if (!wifi_ready_within(WIFI_WAIT_MS)) {
ESP_LOGW(TAG, "WiFi down, dropping hook event (state=%s)", ev.state);
continue;
}
if (post_hook(&ev)) continue;
vTaskDelay(pdMS_TO_TICKS(RETRY_DELAY_MS));
if (!post_hook(&ev)) {
ESP_LOGW(TAG, "giving up after retry (state=%s)", ev.state);
}
}
}
/* ── Public API ──────────────────────────────────────────────────────────── */
esp_err_t hook_client_init(const char *master_url)
{
if (s_queue) return ESP_OK; /* idempotent */
const char *base = (master_url && *master_url)
? master_url : CONFIG_PLIP_MASTER_URL;
size_t blen = strnlen(base, sizeof(s_url) - sizeof(HOOK_PATH) - 1);
if (blen == 0 || blen >= sizeof(s_url) - sizeof(HOOK_PATH)) {
ESP_LOGE(TAG, "invalid master_url");
return ESP_ERR_INVALID_ARG;
}
memcpy(s_url, base, blen);
if (s_url[blen - 1] == '/') blen--;
s_url[blen] = '\0';
strncat(s_url, HOOK_PATH, sizeof(s_url) - strlen(s_url) - 1);
s_queue = xQueueCreate(QUEUE_DEPTH, sizeof(hook_event_t));
if (!s_queue) return ESP_ERR_NO_MEM;
if (xTaskCreate(worker_task, "hook_client", 4096, NULL, 5, NULL) != pdPASS) {
vQueueDelete(s_queue);
s_queue = NULL;
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
bool hook_client_report(const char *state, const char *reason)
{
if (!s_queue) {
ESP_LOGW(TAG, "report() before init() — dropping");
return false;
}
hook_event_t ev = {0};
strncpy(ev.state, state ? state : "", sizeof(ev.state) - 1);
strncpy(ev.reason, reason ? reason : "", sizeof(ev.reason) - 1);
if (xQueueSend(s_queue, &ev, 0) != pdTRUE) {
ESP_LOGW(TAG, "queue full, dropping (state=%s)", ev.state);
return false;
}
return true;
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
/*
* hook_client.h — POST hook transitions to the Zacus master (Phase D).
*
* Ported from PLIP_FIRMWARE/src/zacus_hook_client.cpp to plain C / IDF.
* Endpoint: POST <master_url>/voice/hook { "state": "off"|"on", "reason": "..." }
*/
#include "esp_err.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Initialise (spawns worker task). master_url: base URL e.g.
* "http://192.168.0.188". Pass NULL to use CONFIG_PLIP_MASTER_URL.
* Idempotent — safe to call once from app_main. */
esp_err_t hook_client_init(const char *master_url);
/* Enqueue a state report (non-blocking). state = "off"|"on",
* reason = short free-form tag ("pickup", "hangup", "boot", ...).
* Returns true if enqueued, false if queue was full. */
bool hook_client_report(const char *state, const char *reason);
#ifdef __cplusplus
}
#endif
+6
View File
@@ -0,0 +1,6 @@
## IDF Component Manager manifest for plip_voice main component.
## Managed components are fetched during `idf.py build` if not already present.
dependencies:
## cJSON for CMD/EVT JSON parsing
idf:
version: ">=5.1.0"
+162
View File
@@ -0,0 +1,162 @@
/*
* main.c — PLIP voice annex application entry point.
*
* Board: AI-Thinker ESP32-A1S Audio Kit V2.2 (ESP32, ES8388, SD, mic, HP)
* Target: ESP-IDF 5.4, esp32 target (NOT esp32s3)
*
* Boot sequence:
* 1. NVS init
* 2. audio_init() — ES8388 + I2S (Phase A)
* 3. audio_play_tone(440Hz, 1s) — Phase A proof (sound before WiFi)
* 4. net_init() — WiFi STA + HTTP server (Phase B)
* 5. scenario_mesh_init() — ESP-NOW CMD receiver (Phase C)
* 6. hook_client_init() — hook event forwarder (Phase D)
* 7. phone_init() — off-hook GPIO monitor (Phase D)
*/
#include <stdio.h>
#include <string.h>
#include "esp_log.h"
#include "esp_err.h"
#include "esp_system.h"
#include "nvs_flash.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "board_config.h"
#include "audio.h"
#include "net.h"
#include "cmd_exec.h"
#include "hook_client.h"
#include "phone.h"
#include "slic.h"
#include "scenario_mesh.h"
#include "dialer.h"
#include "conversation.h"
static const char *TAG = "plip-main";
/* ── ESP-NOW CMD/EVT text callback ─────────────────────────────────────────── */
static void on_mesh_text(uint8_t kind, const uint8_t src_mac[6], const char *text)
{
if (kind == SCENARIO_MESH_TEXT_CMD) {
ESP_LOGI(TAG, "ESP-NOW CMD from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
src_mac[0], src_mac[1], src_mac[2],
src_mac[3], src_mac[4], src_mac[5], text);
esp_err_t err = cmd_exec_handle(text, strlen(text));
if (err != ESP_OK && err != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(TAG, "cmd_exec_handle: %s", esp_err_to_name(err));
}
} else {
ESP_LOGI(TAG, "ESP-NOW EVT from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
src_mac[0], src_mac[1], src_mac[2],
src_mac[3], src_mac[4], src_mac[5], text);
}
}
/* ── Boot task — runs all phases with sufficient stack ────────────────────── */
static void boot_task(void *arg)
{
(void)arg;
/* ── 1b. SLIC power-up (before audio — PD must be released early) ── */
esp_err_t ret = slic_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "slic_init FAILED: %s — SLIC remains in power-down (no audio path, no hook sense)",
esp_err_to_name(ret));
} else {
ESP_LOGI(TAG, "SLIC powered up: PD=GPIO%d OD-HIGH, SHK=GPIO%d active-HIGH, RM=GPIO%d, FR=GPIO%d",
PLIP_SLIC_PD, PLIP_SLIC_SHK, PLIP_SLIC_RM, PLIP_SLIC_FR);
}
/* ── 2. Audio init (Phase A) ── */
ret = audio_init();
if (ret != ESP_OK) {
ESP_LOGE(TAG, "audio_init FAILED: %s", esp_err_to_name(ret));
ESP_LOGE(TAG, "PHASE A BLOCKED — check ES8388 I2C wiring (SDA=33, SCL=32)");
/* Continue anyway — WiFi + hook still work. */
} else {
/* ── 3. Phase A proof: 440 Hz test tone ── */
ESP_LOGI(TAG, "Phase A: playing 440 Hz test tone (1s) — LISTEN FOR SOUND");
audio_play_tone(440.0f, 1000);
vTaskDelay(pdMS_TO_TICKS(200));
ESP_LOGI(TAG, "Phase A: tone done. If you heard it, Phase A is VERIFIED.");
}
/* ── 4. Network + HTTP server (Phase B) ── */
ret = net_init();
if (ret == ESP_OK) {
ESP_LOGI(TAG, "Phase B: WiFi + httpd up. Verify: curl http://<PLIP_IP>/status");
} else if (ret == ESP_ERR_TIMEOUT) {
ESP_LOGW(TAG, "Phase B: WiFi timeout — running offline (ESP-NOW still active)");
} else {
ESP_LOGW(TAG, "Phase B: net_init error %s", esp_err_to_name(ret));
}
/* ── 5. ESP-NOW CMD receiver + scenario mesh (Phase C) ── */
/* scenario_mesh_init() requires WiFi to be started (done in net_init). */
ret = scenario_mesh_init(NULL); /* receive CMD only — no scenario apply */
if (ret != ESP_OK) {
ESP_LOGW(TAG, "scenario_mesh_init: %s — ESP-NOW unavailable",
esp_err_to_name(ret));
} else {
ret = scenario_mesh_set_text_cb(on_mesh_text);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "Phase C: ESP-NOW CMD receiver up");
}
}
/* ── 6. Hook client (Phase D) ── */
ret = hook_client_init(NULL); /* uses CONFIG_PLIP_MASTER_URL */
if (ret != ESP_OK) {
ESP_LOGW(TAG, "hook_client_init: %s", esp_err_to_name(ret));
}
/* ── Stage 1: dialer + conversation state machine ── */
dialer_init();
conversation_init();
ESP_LOGI(TAG, "Stage 1: dialer + conversation state machine up");
/* ── 7. Phone task (Phase D) ── */
ret = phone_init();
if (ret != ESP_OK) {
ESP_LOGW(TAG, "phone_init: %s", esp_err_to_name(ret));
} else {
ESP_LOGI(TAG, "Phase D: off-hook GPIO monitor up (GPIO=%d)",
CONFIG_PLIP_HOOK_GPIO);
}
ESP_LOGI(TAG, "Boot complete. Free heap: %lu bytes", esp_get_free_heap_size());
ESP_LOGI(TAG, "Press BOOT button (GPIO%d) to simulate hook pickup.",
CONFIG_PLIP_HOOK_GPIO);
vTaskDelete(NULL);
}
/* ── Application entry point ─────────────────────────────────────────────── */
void app_main(void)
{
ESP_LOGI(TAG, "============================================");
ESP_LOGI(TAG, " Zacus PLIP Voice Annex");
ESP_LOGI(TAG, " Board: AI-Thinker ESP32-A1S (ES8388)");
ESP_LOGI(TAG, " IDF: %s", esp_get_idf_version());
ESP_LOGI(TAG, " Free heap: %lu bytes", esp_get_free_heap_size());
ESP_LOGI(TAG, "============================================");
/* ── 1. NVS ── */
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
/* Delegate all subsequent phases to a task with adequate stack.
* app_main's default stack (8192) is too tight for I2S + WiFi init
* when called from the main task (IDF internal stack usage adds up). */
xTaskCreatePinnedToCore(boot_task, "boot", 12288, NULL, 5, NULL, 0);
}
+981
View File
@@ -0,0 +1,981 @@
/*
* net.c — WiFi STA + minimal HTTP server for the PLIP voice annex.
*
* WiFi credentials: read from NVS namespace "wifi" (keys "ssid"/"pwd")
* first; fall back to Kconfig compile-time defaults if NVS is empty.
* This mirrors the pattern used in the idf_zacus master (main.c:load_wifi_creds).
*
* HTTP endpoints (Phase B):
* GET /status → { "ip":"...", "playing":false, "off_hook":false }
* POST /game/scenario → store Runtime 3 IR to SPIFFS (same as box3_voice)
* POST /game/file?path=... → write an arbitrary file to SPIFFS (Phase B proof)
*
* The httpd handle is exposed via net_httpd_handle() so other modules
* (hook_client, cmd_exec) can register additional routes.
*/
#include "net.h"
#include "dialer.h"
#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <sys/stat.h>
#include "audio.h"
#include "tones.h"
#include "cmd_exec.h"
#include "phone.h"
#include "conversation.h"
#include "es8388.h"
#include "slic.h"
#include "board_config.h"
#include "driver/gpio.h"
#include "esp_err.h"
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_spiffs.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "cJSON.h"
#define TAG "net"
#define WIFI_CONNECT_TIMEOUT_MS 30000
#define WIFI_POLL_MS 250
#define SPIFFS_LABEL "storage"
#define SPIFFS_BASE "/spiffs"
#define MAX_BODY (3 * 1024 * 1024) /* streamed in 2 KB chunks → RAM-safe;
raised to fit full-length neural-TTS
(Kyutai) gamebook narration WAVs */
static volatile bool s_connected = false;
static httpd_handle_t s_httpd = NULL;
static bool s_spiffs_ok = false;
/* ── NVS WiFi cred loader (mirrors idf_zacus main.c) ─────────────────────── */
static esp_err_t load_wifi_creds(char *ssid, size_t ssid_len,
char *pwd, size_t pwd_len)
{
nvs_handle_t h;
esp_err_t err = nvs_open("wifi", NVS_READONLY, &h);
if (err != ESP_OK) return err;
size_t sl = ssid_len, pl = pwd_len;
err = nvs_get_str(h, "ssid", ssid, &sl);
err |= nvs_get_str(h, "pwd", pwd, &pl);
nvs_close(h);
if (err != ESP_OK || ssid[0] == '\0') return ESP_ERR_NOT_FOUND;
return ESP_OK;
}
/* ── WiFi event handler ───────────────────────────────────────────────────── */
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t event_id, void *data)
{
if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
/* Retry on EVERY disconnect, including failures during the initial boot
* attempt: on a mesh the first association frequently fails (wrong node
* picked / channel roam) and must be retried, otherwise the boot connect
* just times out. Previously the initial loop gave up on first failure. */
s_connected = false;
ESP_LOGW(TAG, "WiFi disconnected/assoc failed, reconnecting...");
vTaskDelay(pdMS_TO_TICKS(1500));
esp_wifi_connect();
} else if (base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *ev = (ip_event_got_ip_t *)data;
ESP_LOGI(TAG, "WiFi connected — IP: " IPSTR, IP2STR(&ev->ip_info.ip));
s_connected = true;
}
}
/* ── SPIFFS lazy mount ────────────────────────────────────────────────────── */
static void ensure_spiffs(void)
{
if (s_spiffs_ok) return;
esp_vfs_spiffs_conf_t conf = {
.base_path = SPIFFS_BASE,
.partition_label = SPIFFS_LABEL,
.max_files = 8,
.format_if_mount_failed = true,
};
esp_err_t ret = esp_vfs_spiffs_register(&conf);
if (ret == ESP_OK || ret == ESP_ERR_INVALID_STATE) {
s_spiffs_ok = true;
ESP_LOGI(TAG, "SPIFFS mounted at %s", SPIFFS_BASE);
} else {
ESP_LOGE(TAG, "SPIFFS mount failed: %s", esp_err_to_name(ret));
}
}
/* ── HTTP helpers ─────────────────────────────────────────────────────────── */
static esp_err_t send_json(httpd_req_t *req, const char *status,
const char *body)
{
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
return httpd_resp_sendstr(req, body);
}
/* ── GET /status ──────────────────────────────────────────────────────────── */
static esp_err_t handle_status(httpd_req_t *req)
{
char buf[128];
esp_netif_ip_info_t ip_info = {0};
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta) esp_netif_get_ip_info(sta, &ip_info);
/* Report REAL state (this used to hardcode playing/off_hook to false, which
* was badly misleading during bring-up). off_hook = firmware debounced view
* (phone_is_offhook); shk = raw SLIC line, the single source of truth that
* gates audio playback — sound routes to the handset only when shk is true. */
snprintf(buf, sizeof(buf),
"{\"board\":\"plip\",\"ip\":\"" IPSTR "\",\"playing\":%s,"
"\"off_hook\":%s,\"shk\":%s}",
IP2STR(&ip_info.ip),
audio_is_playing() ? "true" : "false",
phone_is_offhook() ? "true" : "false",
slic_is_offhook() ? "true" : "false");
return send_json(req, "200 OK", buf);
}
/* ── POST /game/scenario ──────────────────────────────────────────────────── */
static esp_err_t handle_scenario_post(httpd_req_t *req)
{
if (req->content_len <= 0 || req->content_len > MAX_BODY)
return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");
ensure_spiffs();
char *body = malloc((size_t)req->content_len + 1);
if (!body) return send_json(req, "500 Internal Server Error", "{\"error\":\"oom\"}");
int total = 0;
while (total < (int)req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) { free(body); return ESP_FAIL; }
total += got;
}
body[total] = '\0';
const char *path = SPIFFS_BASE "/scenario.json";
FILE *f = fopen(path, "wb");
if (!f) {
free(body);
return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
}
fwrite(body, 1, (size_t)total, f);
fclose(f);
free(body);
char resp[128];
snprintf(resp, sizeof(resp),
"{\"status\":\"ok\",\"board\":\"plip\",\"bytes\":%d}", total);
ESP_LOGI(TAG, "scenario stored: %d bytes", total);
return send_json(req, "200 OK", resp);
}
/* ── POST /game/file?path=... (Phase B proof: write file to SPIFFS) ─────── */
/* Streaming chunk size for /game/file — keeps heap usage under 4 KB. */
#define FILE_RECV_CHUNK 2048
static esp_err_t handle_file_post(httpd_req_t *req)
{
/* Extract ?path= query parameter */
char query[256] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char fpath[128] = {0};
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
}
if (req->content_len <= 0 || req->content_len > MAX_BODY)
return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");
/* Route to the microSD when the path is sd-prefixed ("sd/…", "sdcard/…"
* or "/sdcard/…"); otherwise stage in SPIFFS (default). The SD is mounted
* on demand here so a voice pack can be staged without an off-hook play. */
const char *base;
const char *rel = fpath;
bool to_sd = false;
if (strncmp(fpath, "sd/", 3) == 0) { rel = fpath + 3; to_sd = true; }
else if (strncmp(fpath, "sdcard/", 7) == 0) { rel = fpath + 7; to_sd = true; }
else if (strncmp(fpath, "/sdcard/", 8) == 0) { rel = fpath + 8; to_sd = true; }
if (to_sd) {
if (!audio_ensure_sd())
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
base = PLIP_SD_MOUNT; /* "/sdcard" */
} else {
ensure_spiffs();
base = SPIFFS_BASE;
}
while (*rel == '/') rel++; /* avoid a double slash when joining */
char full[192];
snprintf(full, sizeof(full), "%s/%s", base, rel);
/* Create the immediate parent dir (one level) so a pack can live in a
* subfolder like /sdcard/voice/<file>.wav — fopen won't create it. FAT
* supports mkdir; SPIFFS is flat so we only do this for the SD path. */
if (to_sd) {
char *slash = strrchr(full, '/');
if (slash && slash != full) {
*slash = '\0';
mkdir(full, 0775); /* best-effort; ignore EEXIST */
*slash = '/';
}
}
FILE *f = fopen(full, "wb");
if (!f) {
ESP_LOGW(TAG, "fopen %s failed: errno=%d", full, errno);
return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
}
/* Stream body to file using a small stack buffer — no large malloc needed. */
char chunk[FILE_RECV_CHUNK];
int remaining = (int)req->content_len;
size_t written = 0;
while (remaining > 0) {
int to_recv = (remaining < FILE_RECV_CHUNK) ? remaining : FILE_RECV_CHUNK;
int got = httpd_req_recv(req, chunk, to_recv);
if (got <= 0) {
fclose(f);
return ESP_FAIL;
}
size_t fw = fwrite(chunk, 1, (size_t)got, f);
if ((int)fw != got) {
ESP_LOGW(TAG, "fwrite short: %zu/%d (disk full?)", fw, got);
fclose(f);
return send_json(req, "500 Internal Server Error", "{\"error\":\"write failed\"}");
}
written += fw;
remaining -= got;
}
fclose(f);
char resp[256];
snprintf(resp, sizeof(resp),
"{\"status\":\"ok\",\"path\":\"%s\",\"bytes\":%zu}", full, written);
ESP_LOGI(TAG, "file stored: %s (%zu bytes)", full, written);
return send_json(req, "200 OK", resp);
}
/* ── DELETE /game/file?path=... (remove a staged file; SD or SPIFFS) ─────── */
/* Same path routing as the POST handler (sd/ prefix → /sdcard, else /spiffs).
* Lets the host clean stale clips when regenerating the SD voice pack. */
static esp_err_t handle_file_delete(httpd_req_t *req)
{
char query[128] = {0}, fpath[128] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK)
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
const char *base, *rel = fpath;
bool to_sd = false;
if (strncmp(fpath, "sd/", 3) == 0) { rel = fpath + 3; to_sd = true; }
else if (strncmp(fpath, "sdcard/", 7) == 0) { rel = fpath + 7; to_sd = true; }
else if (strncmp(fpath, "/sdcard/", 8) == 0) { rel = fpath + 8; to_sd = true; }
if (to_sd) {
if (!audio_ensure_sd())
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
base = PLIP_SD_MOUNT;
} else {
ensure_spiffs();
base = SPIFFS_BASE;
}
while (*rel == '/') rel++;
char full[192];
snprintf(full, sizeof(full), "%s/%s", base, rel);
if (remove(full) != 0) {
ESP_LOGW(TAG, "remove %s failed: errno=%d", full, errno);
return send_json(req, "404 Not Found", "{\"error\":\"remove failed\"}");
}
ESP_LOGI(TAG, "file removed: %s", full);
char resp[256];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", full);
return send_json(req, "200 OK", resp);
}
/* ── POST /voice/capture ──────────────────────────────────────────────────── */
#define CAP_MAX_MS_DEFAULT 5000
#define CAP_SILENCE_MS_DEFAULT 800
/* Max capture duration: 10 s (no large buffer needed — streaming approach). */
#define CAP_MAX_ALLOWED_MS 10000
/*
* Streaming WAV capture handler.
*
* Memory budget: only two small buffers on stack:
* - WAV header : 44 bytes
* - mono frame : 320 × 2 = 640 bytes
* The audio driver uses an additional 1280-byte scratch buf for the stereo I2S read.
* Total heap usage during capture: ~2 KB (vs 128+ KB for buffer-at-once approach).
*
* The response uses chunked transfer encoding via httpd_resp_send_chunk().
* We send the WAV header first (with data_size = 0xFFFFFFFF as placeholder since
* length is unknown), then PCM frames, then finalize with httpd_resp_send_chunk(NULL,0).
* Most WAV players / ffmpeg handle the placeholder length gracefully.
*/
static esp_err_t handle_voice_capture(httpd_req_t *req)
{
/* Parse optional query params: max_ms, silence_ms */
char query[128] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char val[16] = {0};
int max_ms = CAP_MAX_MS_DEFAULT;
int silence_ms = CAP_SILENCE_MS_DEFAULT;
if (httpd_query_key_value(query, "max_ms", val, sizeof(val)) == ESP_OK) {
int v = atoi(val);
if (v > 0 && v <= CAP_MAX_ALLOWED_MS) max_ms = v;
else if (v > CAP_MAX_ALLOWED_MS) max_ms = CAP_MAX_ALLOWED_MS;
}
memset(val, 0, sizeof(val));
if (httpd_query_key_value(query, "silence_ms", val, sizeof(val)) == ESP_OK) {
int v = atoi(val);
if (v > 0 && v <= 5000) silence_ms = v;
}
ESP_LOGI(TAG, "POST /voice/capture max_ms=%d silence_ms=%d (streaming)", max_ms, silence_ms);
/* Start capture (enables I2S RX, disables TX). */
if (audio_capture_begin(max_ms, silence_ms) != 0) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "capture init failed");
return ESP_FAIL;
}
/* Send HTTP headers. */
httpd_resp_set_type(req, "audio/wav");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
/* WAV header with placeholder data_size (0xFFFFFFFF = unknown length). */
const uint32_t cap_rate = 16000;
const uint16_t cap_ch = 1;
const uint16_t cap_bits = 16;
const uint32_t byte_rate = cap_rate * cap_ch * (cap_bits / 8);
const uint16_t block_align = (uint16_t)(cap_ch * (cap_bits / 8));
const uint32_t data_size = 0xFFFFFFFFu; /* placeholder, updated below if possible */
const uint32_t riff_size = 0xFFFFFFFFu;
uint16_t fmt_pcm = 1;
uint32_t fmt_size = 16;
uint8_t hdr[44];
memcpy(hdr + 0, "RIFF", 4); memcpy(hdr + 4, &riff_size, 4);
memcpy(hdr + 8, "WAVE", 4); memcpy(hdr + 12, "fmt ", 4);
memcpy(hdr + 16, &fmt_size, 4); memcpy(hdr + 20, &fmt_pcm, 2);
memcpy(hdr + 22, &cap_ch, 2); memcpy(hdr + 24, &cap_rate, 4);
memcpy(hdr + 28, &byte_rate, 4); memcpy(hdr + 32, &block_align, 2);
memcpy(hdr + 34, &cap_bits, 2);
memcpy(hdr + 36, "data", 4); memcpy(hdr + 40, &data_size, 4);
esp_err_t ret = httpd_resp_send_chunk(req, (const char *)hdr, sizeof(hdr));
if (ret != ESP_OK) { audio_capture_end(); return ESP_FAIL; }
/* VAD state (mirrors audio_capture_wav logic). */
const int64_t vad_onset_sq = (int64_t)328 * 328;
const int64_t vad_silence_sq = (int64_t)100 * 100;
const int max_frames = max_ms / 20;
const int silence_frames = silence_ms / 20;
bool voice_started = false;
int silent_count = 0;
int total_frames = 0;
size_t total_pcm_bytes = 0;
/* Per-frame mono buffer: 320 × int16_t = 640 bytes on stack. */
int16_t mono_frame[320];
for (int f = 0; f < max_frames; f++) {
int64_t rms_sq = 0;
int n = audio_capture_read_frame(mono_frame, 320, &rms_sq);
if (n < 0) { ESP_LOGW(TAG, "capture: read error at frame %d", f); break; }
if (n == 0) continue; /* timeout, retry */
/* Send PCM chunk. */
size_t frame_bytes = (size_t)n * sizeof(int16_t);
ret = httpd_resp_send_chunk(req, (const char *)mono_frame, (ssize_t)frame_bytes);
if (ret != ESP_OK) { ESP_LOGW(TAG, "capture: send_chunk failed"); break; }
total_pcm_bytes += frame_bytes;
total_frames = f + 1;
/* VAD. */
if (!voice_started) {
if (rms_sq >= vad_onset_sq) {
voice_started = true;
silent_count = 0;
ESP_LOGI(TAG, "capture: voice onset frame %d rms²=%"PRId64, f, rms_sq);
}
} else {
if (rms_sq < vad_silence_sq) {
if (++silent_count >= silence_frames) {
ESP_LOGI(TAG, "capture: VAD end frame %d", f);
break;
}
} else {
silent_count = 0;
}
}
}
audio_capture_end();
/* Finalize chunked transfer. */
httpd_resp_send_chunk(req, NULL, 0);
float dur = (float)total_pcm_bytes / (float)(cap_rate * (cap_bits / 8) * cap_ch);
ESP_LOGI(TAG, "capture done: %d frames %.2fs %zu PCM bytes sent", total_frames, dur, total_pcm_bytes);
return ESP_OK;
}
/* ── GET /debug/regs (ES8388 register dump for ADC diagnosis) ─────────────── */
static esp_err_t handle_debug_regs(httpd_req_t *req)
{
/* Read key ES8388 registers to diagnose ADC path. */
uint8_t regs[0x40] = {0};
for (int i = 0; i < 0x40; i++) {
es8388_read_reg((uint8_t)i, &regs[i]);
}
char buf[1024];
/* All register names use Espressif official numbering (ADCCONTROL1=reg0x09, etc.) */
int n = snprintf(buf, sizeof(buf),
"{\"CTL1\":\"0x%02X\",\"CTL2\":\"0x%02X\",\"CHIPPOWER\":\"0x%02X\","
"\"ADCPOWER\":\"0x%02X\",\"DACPOWER\":\"0x%02X\","
"\"ADCCONTROL1\":\"0x%02X\",\"ADCCONTROL2\":\"0x%02X\",\"ADCCONTROL3\":\"0x%02X\","
"\"ADCCONTROL4\":\"0x%02X\",\"ADCCONTROL5\":\"0x%02X\",\"ADCCONTROL6\":\"0x%02X\","
"\"ADCCONTROL7\":\"0x%02X\",\"ADCCONTROL8\":\"0x%02X\",\"ADCCONTROL9\":\"0x%02X\","
"\"DACCTL1\":\"0x%02X\",\"DACCTL2\":\"0x%02X\",\"DACCTL3\":\"0x%02X\","
"\"DACCTL21\":\"0x%02X\",\"DACCTL24\":\"0x%02X\",\"DACCTL25\":\"0x%02X\","
"\"MASTERMODE\":\"0x%02X\",\"CHIPCTL3\":\"0x%02X\",\"CHIPLP\":\"0x%02X\"}",
regs[0x00], regs[0x01], regs[0x02],
regs[0x03], regs[0x04],
regs[0x09], regs[0x0A], regs[0x0B],
regs[0x0C], regs[0x0D], regs[0x0E],
regs[0x0F], regs[0x10], regs[0x11],
regs[0x17], regs[0x18], regs[0x19],
regs[0x2B], regs[0x2E], regs[0x2F],
regs[0x08], regs[0x06], regs[0x05]);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
return httpd_resp_send(req, buf, n);
}
/* ── GET /debug/es8388?reg=0x0A&val=0x00 (poke a codec register) ──────────────
* Bench tool to find which ADC input the SLIC handset mic feeds: sweep the
* input-select (ADCCONTROL2 = reg 0x0A) and PGA gain (ADCCONTROL1 = reg 0x09)
* live, then re-capture, without reflashing per experiment. reg/val accept
* decimal or 0x-hex. Returns the written value read back. */
static esp_err_t handle_debug_es8388(httpd_req_t *req)
{
char query[64] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char rs[12] = {0}, vs[12] = {0};
if (httpd_query_key_value(query, "reg", rs, sizeof(rs)) != ESP_OK)
return send_json(req, "400 Bad Request", "{\"error\":\"missing reg param\"}");
uint8_t reg = (uint8_t)strtol(rs, NULL, 0);
char resp[96];
if (httpd_query_key_value(query, "val", vs, sizeof(vs)) == ESP_OK) {
uint8_t val = (uint8_t)strtol(vs, NULL, 0);
esp_err_t err = es8388_write_reg(reg, val);
uint8_t rb = 0;
es8388_read_reg(reg, &rb);
ESP_LOGI(TAG, "debug/es8388: reg 0x%02X <- 0x%02X (readback 0x%02X, %s)",
reg, val, rb, esp_err_to_name(err));
snprintf(resp, sizeof(resp),
"{\"reg\":\"0x%02X\",\"wrote\":\"0x%02X\",\"readback\":\"0x%02X\",\"ok\":%s}",
reg, val, rb, err == ESP_OK ? "true" : "false");
} else {
uint8_t rb = 0;
es8388_read_reg(reg, &rb);
snprintf(resp, sizeof(resp), "{\"reg\":\"0x%02X\",\"value\":\"0x%02X\"}", reg, rb);
}
return send_json(req, "200 OK", resp);
}
/* ── POST /game/cmd (WiFi-direct CMD endpoint) ───────────────────────────── */
#define MAX_CMD_BYTES 512
static esp_err_t handle_cmd_post(httpd_req_t *req)
{
if (req->content_len <= 0 || req->content_len > MAX_CMD_BYTES)
return send_json(req, "400 Bad Request", "{\"error\":\"body must be 1..512 bytes\"}");
char body[MAX_CMD_BYTES + 1];
int total = 0;
while (total < (int)req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return send_json(req, "400 Bad Request", "{\"error\":\"recv failed\"}");
}
total += got;
}
body[total] = '\0';
ESP_LOGI(TAG, "POST /game/cmd (%d B): %.*s", total, total < 80 ? total : 80, body);
esp_err_t err = cmd_exec_handle(body, (size_t)total);
if (err == ESP_ERR_INVALID_ARG)
return send_json(req, "400 Bad Request", "{\"error\":\"malformed cmd json or missing op\"}");
return send_json(req, "200 OK", "{\"ok\":true}");
}
/* ── GET /debug/ring (trigger ring tone over HTTP) ──────────────────────── */
static esp_err_t handle_debug_ring(httpd_req_t *req)
{
/* /debug/ring[?number=NN] — arm an INCOMING call from persona NN (default
* 17) and ring. On pickup the conversation jumps straight to the greeting. */
char query[96] = {0}, number[16] = {0}, scene[40] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
httpd_query_key_value(query, "number", number, sizeof(number));
httpd_query_key_value(query, "scene", scene, sizeof(scene));
/* scene (optional) lets the phone play that scene's hint from its SD pack
* after the greeting — fully offline, no model. */
conversation_arm_incoming(number, scene[0] ? scene : NULL);
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"ringing\":true,\"incoming\":\"%s\"}",
number[0] ? number : "17");
ESP_LOGI(TAG, "debug/ring: incoming armed (num=%s), ringing", number[0] ? number : "17");
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/ringstop (stop ring tone over HTTP) ─────────────────────── */
static esp_err_t handle_debug_ringstop(httpd_req_t *req)
{
phone_ring_stop();
ESP_LOGI(TAG, "debug/ringstop: ring stopped");
return send_json(req, "200 OK", "{\"ok\":true,\"ringing\":false}");
}
/* ── GET /debug/slic (read GPIO levels of all 4 SLIC pins) ──────────────── */
static esp_err_t handle_debug_slic(httpd_req_t *req)
{
char buf[192];
/* gpio_get_level reads the actual pad level even for output pins. */
snprintf(buf, sizeof(buf),
"{\"rm\":%d,\"fr\":%d,\"shk\":%d,\"pd\":%d,\"offhook\":%s,\"ringing\":%s}",
gpio_get_level(PLIP_SLIC_RM), gpio_get_level(PLIP_SLIC_FR),
gpio_get_level(PLIP_SLIC_SHK), gpio_get_level(PLIP_SLIC_PD),
slic_is_offhook() ? "true" : "false",
slic_is_ringing() ? "true" : "false");
ESP_LOGI(TAG, "debug/slic: %s", buf);
return send_json(req, "200 OK", buf);
}
/* ── GET /debug/hookmon (fast-sample the hook GPIO, log transitions) ─────────
* Samples CONFIG_PLIP_HOOK_GPIO every 2 ms for ~6 s and records every level
* transition with a timestamp. Catches both slow hook toggles AND fast rotary
* pulses (~60-100 ms) that the 0.7 s HTTP poll of /debug/slic cannot see.
* The user performs the physical action (lift/hang/dial) during the window. */
#define HOOKMON_SAMPLE_MS 2
#define HOOKMON_WINDOW_MS 6000
#define HOOKMON_MAX_EDGES 64
static esp_err_t handle_debug_hookmon(httpd_req_t *req)
{
const int gpio = CONFIG_PLIP_HOOK_GPIO;
int last = gpio_get_level(gpio);
const int initial = last;
int64_t t0 = esp_timer_get_time();
int edges_t[HOOKMON_MAX_EDGES];
int edges_l[HOOKMON_MAX_EDGES];
int n_edges = 0;
int lo = last, hi = last; /* track min/max seen */
ESP_LOGI(TAG, "hookmon: start, gpio=%d initial=%d (sample %dms / window %dms)",
gpio, initial, HOOKMON_SAMPLE_MS, HOOKMON_WINDOW_MS);
for (;;) {
int64_t now = esp_timer_get_time();
int dt = (int)((now - t0) / 1000);
if (dt >= HOOKMON_WINDOW_MS) break;
int lvl = gpio_get_level(gpio);
if (lvl < lo) lo = lvl;
if (lvl > hi) hi = lvl;
if (lvl != last) {
if (n_edges < HOOKMON_MAX_EDGES) {
edges_t[n_edges] = dt;
edges_l[n_edges] = lvl;
n_edges++;
}
last = lvl;
}
vTaskDelay(pdMS_TO_TICKS(HOOKMON_SAMPLE_MS));
}
/* Build JSON: { gpio, initial, final, lo, hi, edges:[{t,l},...], count } */
char buf[1024];
int off = 0;
off += snprintf(buf + off, sizeof(buf) - off,
"{\"gpio\":%d,\"initial\":%d,\"final\":%d,\"lo\":%d,\"hi\":%d,"
"\"count\":%d,\"edges\":[",
gpio, initial, last, lo, hi, n_edges);
for (int i = 0; i < n_edges && off < (int)sizeof(buf) - 32; i++) {
off += snprintf(buf + off, sizeof(buf) - off, "%s{\"t\":%d,\"l\":%d}",
i ? "," : "", edges_t[i], edges_l[i]);
}
off += snprintf(buf + off, sizeof(buf) - off, "]}");
ESP_LOGI(TAG, "hookmon: done, %d edges, lo=%d hi=%d", n_edges, lo, hi);
return send_json(req, "200 OK", buf);
}
/* ── GET /debug/dacvol?a=N (set ES8388 DIGITAL DAC volume, live tuning) ─────── */
static esp_err_t handle_debug_dacvol(httpd_req_t *req)
{
char query[32] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char astr[8] = {0};
if (httpd_query_key_value(query, "a", astr, sizeof(astr)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing a param (atten steps, 0=0dB)\"}");
}
int a = atoi(astr);
if (a < 0) a = 0;
if (a > 192) a = 192;
esp_err_t r = es8388_set_dac_volume((uint8_t)a);
char resp[80];
snprintf(resp, sizeof(resp), "{\"ok\":%s,\"atten_steps\":%d,\"db\":-%.1f}",
(r == ESP_OK) ? "true" : "false", a, a * 0.5);
ESP_LOGI(TAG, "debug/dacvol: atten=%d (-%.1f dB)", a, a * 0.5);
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/miccap?ms=4000 (TEMP DIAG: raw fixed-duration mic capture) ───
* No VAD, DC-blocked, PA on, tones stopped. Lets us measure the true handset
* mic level while the caller speaks continuously, independent of VAD timing. */
static esp_err_t handle_debug_miccap(httpd_req_t *req)
{
char query[64] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char val[16] = {0};
int ms = 4000;
if (httpd_query_key_value(query, "ms", val, sizeof(val)) == ESP_OK) {
int v = atoi(val);
if (v > 0 && v <= 8000) ms = v;
}
tones_stop();
audio_pa_set(true);
if (audio_capture_begin(ms, ms) != 0) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "capture init failed");
return ESP_FAIL;
}
httpd_resp_set_type(req, "audio/wav");
const uint32_t cap_rate = 16000; const uint16_t cap_ch = 1, cap_bits = 16;
const uint32_t byte_rate = cap_rate * cap_ch * (cap_bits / 8);
const uint16_t block_align = (uint16_t)(cap_ch * (cap_bits / 8));
const uint32_t big = 0xFFFFFFFFu; uint16_t fmt_pcm = 1; uint32_t fmt_size = 16;
uint8_t hdr[44];
memcpy(hdr, "RIFF", 4); memcpy(hdr + 4, &big, 4);
memcpy(hdr + 8, "WAVE", 4); memcpy(hdr + 12, "fmt ", 4);
memcpy(hdr + 16, &fmt_size, 4); memcpy(hdr + 20, &fmt_pcm, 2);
memcpy(hdr + 22, &cap_ch, 2); memcpy(hdr + 24, &cap_rate, 4);
memcpy(hdr + 28, &byte_rate, 4); memcpy(hdr + 32, &block_align, 2);
memcpy(hdr + 34, &cap_bits, 2); memcpy(hdr + 36, "data", 4); memcpy(hdr + 40, &big, 4);
httpd_resp_send_chunk(req, (const char *)hdr, sizeof(hdr));
const int max_frames = ms / 20;
int16_t mono[320];
float dc_x1 = 0.0f, dc_y1 = 0.0f; const float dc_R = 0.97f;
for (int f = 0; f < max_frames; f++) {
int64_t rms_sq = 0;
int n = audio_capture_read_frame(mono, 320, &rms_sq);
if (n < 0) break;
if (n == 0) continue;
for (int i = 0; i < n; i++) {
float xf = (float)mono[i];
float yf = xf - dc_x1 + dc_R * dc_y1;
dc_x1 = xf; dc_y1 = yf;
if (yf > 32767.0f) yf = 32767.0f; else if (yf < -32768.0f) yf = -32768.0f;
mono[i] = (int16_t)yf;
}
if (httpd_resp_send_chunk(req, (const char *)mono, (ssize_t)(n * sizeof(int16_t))) != ESP_OK) break;
}
audio_capture_end();
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
/* ── GET /debug/getfile?path=/x.wav (read a SPIFFS file back for diagnosis) ── */
static esp_err_t handle_debug_getfile(httpd_req_t *req)
{
char query[160] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char fpath[96] = {0};
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
}
ensure_spiffs();
char full[160];
if (fpath[0] == '/') snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
else snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
FILE *f = fopen(full, "rb");
if (!f) return send_json(req, "404 Not Found", "{\"error\":\"not found\"}");
httpd_resp_set_type(req, "application/octet-stream");
char chunk[1024];
size_t r;
while ((r = fread(chunk, 1, sizeof(chunk), f)) > 0) {
if (httpd_resp_send_chunk(req, chunk, r) != ESP_OK) { fclose(f); return ESP_FAIL; }
}
fclose(f);
httpd_resp_send_chunk(req, NULL, 0); /* terminate */
return ESP_OK;
}
/* ── GET /debug/vol?v=N (set ES8388 output volume 0..100, live tuning) ─────── */
static esp_err_t handle_debug_vol(httpd_req_t *req)
{
char query[32] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char vstr[8] = {0};
if (httpd_query_key_value(query, "v", vstr, sizeof(vstr)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing v param (0..100)\"}");
}
int v = atoi(vstr);
if (v < 0) v = 0;
if (v > 100) v = 100;
esp_err_t r = es8388_set_volume((uint8_t)v);
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":%s,\"volume\":%d}", (r == ESP_OK) ? "true" : "false", v);
ESP_LOGI(TAG, "debug/vol: set volume=%d (%s)", v, (r == ESP_OK) ? "ok" : "err");
return send_json(req, "200 OK", resp);
}
/* ── GET /debug/dial?number=NNNN (push digits into the dialer) ───────────── */
static esp_err_t handle_debug_dial(httpd_req_t *req)
{
char query[64] = {0};
httpd_req_get_url_query_str(req, query, sizeof(query));
char number[16] = {0};
if (httpd_query_key_value(query, "number", number, sizeof(number)) != ESP_OK) {
return send_json(req, "400 Bad Request", "{\"error\":\"missing number param\"}");
}
int pushed = 0;
for (int i = 0; number[i] != '\0' && i < 12; i++) {
if (number[i] >= '0' && number[i] <= '9') {
dialer_push_digit(number[i] - '0');
pushed++;
}
}
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"pushed\":%d,\"number\":\"%s\"}",
pushed, dialer_current());
ESP_LOGI(TAG, "debug/dial: pushed %d digits -> \"%s\"", pushed, dialer_current());
return send_json(req, "200 OK", resp);
}
/* ── Public API ───────────────────────────────────────────────────────────── */
bool net_is_connected(void)
{
return s_connected;
}
httpd_handle_t net_httpd_handle(void)
{
return s_httpd;
}
esp_err_t net_init(void)
{
/* WiFi credentials: NVS first, then Kconfig compile-time defaults. */
char ssid[64] = CONFIG_PLIP_WIFI_SSID;
char pwd[64] = CONFIG_PLIP_WIFI_PASSWORD;
char nvs_ssid[64] = {0}, nvs_pwd[64] = {0};
if (load_wifi_creds(nvs_ssid, sizeof(nvs_ssid),
nvs_pwd, sizeof(nvs_pwd)) == ESP_OK) {
strncpy(ssid, nvs_ssid, sizeof(ssid) - 1);
strncpy(pwd, nvs_pwd, sizeof(pwd) - 1);
ESP_LOGI(TAG, "WiFi creds from NVS: SSID=\"%s\"", ssid);
} else {
ESP_LOGI(TAG, "WiFi creds from Kconfig: SSID=\"%s\"", ssid);
}
/* Network stack init. */
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, NULL));
wifi_config_t wcfg = {
.sta = {
.threshold.authmode = (strlen(pwd) == 0)
? WIFI_AUTH_OPEN : WIFI_AUTH_WPA2_PSK,
/* Mesh-friendly association. A fixed .channel breaks on mesh APs
* (Freebox/Orange "Les cils") that roam channels via band-steering
* and DFS: if the node isn't on that channel at boot, association
* silently fails. So scan ALL channels (channel=0) and connect to
* the STRONGEST node broadcasting our SSID. The PLIP talks HTTP to
* the gateway, not ESP-NOW, so there is no co-channel constraint. */
.channel = 0,
.scan_method = WIFI_ALL_CHANNEL_SCAN,
.sort_method = WIFI_CONNECT_AP_BY_SIGNAL,
.failure_retry_cnt = 5,
},
};
strncpy((char *)wcfg.sta.ssid, ssid, sizeof(wcfg.sta.ssid) - 1);
strncpy((char *)wcfg.sta.password, pwd, sizeof(wcfg.sta.password) - 1);
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wcfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "WiFi STA started, connecting to '%s' (ch=%d)...",
ssid, CONFIG_PLIP_WIFI_CHANNEL);
/* Wait for IP with timeout. */
const uint32_t deadline = xTaskGetTickCount() +
pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS);
while (!s_connected && xTaskGetTickCount() < deadline) {
vTaskDelay(pdMS_TO_TICKS(WIFI_POLL_MS));
}
if (!s_connected) {
ESP_LOGW(TAG, "WiFi connect timeout — continuing offline");
return ESP_ERR_TIMEOUT;
}
/* Start HTTP server. */
httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG();
hcfg.server_port = 80;
hcfg.max_uri_handlers = 18;
hcfg.stack_size = 8192;
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "httpd_start: %s", esp_err_to_name(ret));
return ret;
}
static const httpd_uri_t uri_status = {
.uri = "/status", .method = HTTP_GET,
.handler = handle_status, .user_ctx = NULL,
};
static const httpd_uri_t uri_scenario = {
.uri = "/game/scenario", .method = HTTP_POST,
.handler = handle_scenario_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_file = {
.uri = "/game/file", .method = HTTP_POST,
.handler = handle_file_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_file_delete = {
.uri = "/game/file", .method = HTTP_DELETE,
.handler = handle_file_delete, .user_ctx = NULL,
};
static const httpd_uri_t uri_cmd = {
.uri = "/game/cmd", .method = HTTP_POST,
.handler = handle_cmd_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_capture = {
.uri = "/voice/capture", .method = HTTP_POST,
.handler = handle_voice_capture, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_regs = {
.uri = "/debug/regs", .method = HTTP_GET,
.handler = handle_debug_regs, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_dial = {
.uri = "/debug/dial", .method = HTTP_GET,
.handler = handle_debug_dial, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_vol = {
.uri = "/debug/vol", .method = HTTP_GET,
.handler = handle_debug_vol, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_getfile = {
.uri = "/debug/getfile", .method = HTTP_GET,
.handler = handle_debug_getfile, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_miccap = {
.uri = "/debug/miccap", .method = HTTP_GET,
.handler = handle_debug_miccap, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_dacvol = {
.uri = "/debug/dacvol", .method = HTTP_GET,
.handler = handle_debug_dacvol, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_ring = {
.uri = "/debug/ring", .method = HTTP_GET,
.handler = handle_debug_ring, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_ringstop = {
.uri = "/debug/ringstop", .method = HTTP_GET,
.handler = handle_debug_ringstop, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_slic = {
.uri = "/debug/slic", .method = HTTP_GET,
.handler = handle_debug_slic, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_hookmon = {
.uri = "/debug/hookmon", .method = HTTP_GET,
.handler = handle_debug_hookmon, .user_ctx = NULL,
};
static const httpd_uri_t uri_debug_es8388 = {
.uri = "/debug/es8388", .method = HTTP_GET,
.handler = handle_debug_es8388, .user_ctx = NULL,
};
httpd_register_uri_handler(s_httpd, &uri_status);
httpd_register_uri_handler(s_httpd, &uri_scenario);
httpd_register_uri_handler(s_httpd, &uri_file);
httpd_register_uri_handler(s_httpd, &uri_file_delete);
httpd_register_uri_handler(s_httpd, &uri_cmd);
httpd_register_uri_handler(s_httpd, &uri_capture);
httpd_register_uri_handler(s_httpd, &uri_debug_regs);
httpd_register_uri_handler(s_httpd, &uri_debug_dial);
httpd_register_uri_handler(s_httpd, &uri_debug_vol);
httpd_register_uri_handler(s_httpd, &uri_debug_getfile);
httpd_register_uri_handler(s_httpd, &uri_debug_miccap);
httpd_register_uri_handler(s_httpd, &uri_debug_dacvol);
httpd_register_uri_handler(s_httpd, &uri_debug_ring);
httpd_register_uri_handler(s_httpd, &uri_debug_ringstop);
httpd_register_uri_handler(s_httpd, &uri_debug_slic);
httpd_register_uri_handler(s_httpd, &uri_debug_hookmon);
httpd_register_uri_handler(s_httpd, &uri_debug_es8388);
ESP_LOGI(TAG, "httpd up on :80 (GET /status, GET /debug/regs, GET /debug/dial, GET /debug/ring, GET /debug/ringstop, GET /debug/slic, POST /game/scenario, POST /game/file, POST /game/cmd, POST /voice/capture)");
return ESP_OK;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
/* net.h — WiFi STA + HTTP server for PLIP voice annex. */
#include "esp_err.h"
#include "esp_http_server.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Start WiFi STA (creds from NVS or Kconfig fallback) + httpd on port 80.
* Blocks until connected or 30 s elapses.
* Returns ESP_OK on success; ESP_ERR_TIMEOUT if WiFi failed (board continues
* offline — httpd is NOT started in that case). */
esp_err_t net_init(void);
/* Returns true once the WiFi STA has obtained an IP address. */
bool net_is_connected(void);
/* Expose the httpd handle so other modules can register additional URI
* handlers after net_init() succeeds. Returns NULL if not yet started. */
httpd_handle_t net_httpd_handle(void);
#ifdef __cplusplus
}
#endif
+355
View File
@@ -0,0 +1,355 @@
/*
* phone.c — Off-hook GPIO debounce + rotary pulse decoding + ring control.
*
* ISR sets a flag; the task reads the GPIO level after a short debounce
* and reports the transition via hook_client_report() and
* conversation_on_hook_change().
*
* Rotary pulse decoding (CONFIG_PLIP_DIAL_PULSE):
* While off-hook, a rotary dial produces brief open/close pulses on the
* SHK line (~60-100 ms open per pulse). A real hangup holds the line
* open for >500 ms. The task polls the GPIO at 5 ms intervals to catch
* pulses that would be missed by the 30 ms debounce.
*
* Pulse train end: if the line stays closed for > PLIP_DIAL_PULSE_MAX_GAP_MS
* after at least one pulse, the count is emitted as a digit (10 pulses = 0).
*/
#include "phone.h"
#include "audio.h"
#include "hook_client.h"
#include "conversation.h"
#include "slic.h"
#if CONFIG_PLIP_DIAL_PULSE
#include "dialer.h"
#endif
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define TAG "phone"
#define DEBOUNCE_MS 30
#define HANGUP_THRESHOLD_MS 2500 /* open > this → real hangup, not a pulse/flicker.
* HOOK DEBOUNCE: the A1S cradle contact is
* marginal and flickers open for up to ~2 s
* mid-call; treating those as a hangup dropped
* the live conversation. Requiring the line to
* stay open ≥ 2.5 s before hanging up rides
* through the flickers. A real hangup holds the
* line open indefinitely so it still fires
* (~2.5 s later). Rotary pulses (~60 ms) and a
* closed line are unaffected. */
#define TASK_STACK 4096
#define TASK_PRIO 5
#define RESYNC_PICKUP_MS 600 /* off-hook (pickup) self-correct: fast, so an
* incoming call answers promptly. */
#define RESYNC_HANGUP_MS HANGUP_THRESHOLD_MS /* on-hook (hangup) self-correct:
* same long debounce as the prolonged-open path
* so a flickering contact never drops the call. */
/* Rotary pulse hardening (CONFIG_PLIP_DIAL_PULSE) */
#define PULSE_MIN_WIDTH_MS 20 /* ignore opens shorter than this (glitch filter) */
/* real rotary pulses are ≥ 40 ms open */
#define HANGUP_VERIFY_COUNT 5 /* re-read GPIO this many times before declaring */
/* hangup (guards against marginal cradle contact) */
#define HANGUP_VERIFY_STEP_MS 20 /* delay between verification reads (ms); */
/* total span = COUNT × STEP = 100 ms */
static volatile bool s_edge_pending = false;
static volatile bool s_ringing = false;
static volatile bool s_offhook = false; /* true = handset picked up */
/* IRAM_ATTR: ISR must live in IRAM on original ESP32. */
static void IRAM_ATTR on_hook_isr(void *arg)
{
(void)arg;
s_edge_pending = true;
}
void phone_ring_start(void)
{
if (s_ringing) return;
s_ringing = true;
ESP_LOGI(TAG, "ring start: SLIC RM/FR + audio tone");
/* Physical bell via SLIC RM/FR (main ringer) */
slic_ring_start();
/* Optional in-earpiece audio tone (audible feedback when handset is up) */
audio_ring_start();
}
void phone_ring_stop(void)
{
if (!s_ringing) return;
s_ringing = false;
ESP_LOGI(TAG, "ring stop: SLIC RM/FR off + audio stop");
slic_ring_stop();
audio_stop();
}
static void report_offhook(bool offhook)
{
conversation_on_hook_change(offhook);
hook_client_report(offhook ? "off" : "on", offhook ? "pickup" : "hangup");
}
/*
* Hook polarity:
* CONFIG_PLIP_HOOK_ACTIVE_HIGH=y (default, SLIC SHK GPIO23): HIGH = off-hook
* CONFIG_PLIP_HOOK_ACTIVE_HIGH=n (legacy BOOT button GPIO4): LOW = off-hook
*
* Rotary pulse notes (SLIC SHK, active-HIGH):
* Off-hook base: SHK HIGH. A rotary pulse briefly opens the loop → SHK drops LOW
* for ~60-100 ms, then returns HIGH. Hangup: SHK stays LOW for >500 ms.
* So "open" (pulse event) = level drops LOW when active-HIGH polarity.
*/
#if CONFIG_PLIP_HOOK_ACTIVE_HIGH
#define HOOK_OFFHOOK_LEVEL 1 /* HIGH = off-hook */
#define HOOK_PULSE_OPEN 0 /* LOW = rotary pulse open (loop broken) */
#define HOOK_PULSE_CLOSED 1 /* HIGH = rotary pulse closed (loop restored) */
#else
#define HOOK_OFFHOOK_LEVEL 0 /* LOW = off-hook (legacy pull-up + BOOT button) */
#define HOOK_PULSE_OPEN 1 /* HIGH = rotary pulse open */
#define HOOK_PULSE_CLOSED 0 /* LOW = rotary pulse closed */
#endif
static void phone_task(void *arg)
{
(void)arg;
const int hook_gpio = CONFIG_PLIP_HOOK_GPIO;
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << hook_gpio),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_ANYEDGE,
};
ESP_ERROR_CHECK(gpio_config(&io_conf));
ESP_ERROR_CHECK(gpio_install_isr_service(0));
ESP_ERROR_CHECK(gpio_isr_handler_add(hook_gpio, on_hook_isr, NULL));
/* Read and report initial level so master state machine is in sync. */
int last_level = gpio_get_level(hook_gpio);
s_offhook = (last_level == HOOK_OFFHOOK_LEVEL);
int resync_ms = 0; /* accumulates while raw level stably disagrees with s_offhook */
ESP_LOGI(TAG, "phone task ready, hook GPIO=%d level=%d active_%s (%s)",
hook_gpio, last_level,
(HOOK_OFFHOOK_LEVEL == 1) ? "high" : "low",
s_offhook ? "off-hook" : "on-hook");
if (!s_offhook) {
ESP_LOGI(TAG, "boot: on-hook -> PA off (mute)");
audio_pa_set(false);
} else {
ESP_LOGI(TAG, "boot: off-hook -> PA on (unmute)");
audio_pa_set(true);
}
hook_client_report(s_offhook ? "off" : "on", "boot");
conversation_on_hook_change(s_offhook);
#if CONFIG_PLIP_DIAL_PULSE
/* Rotary pulse state.
* With SLIC SHK (active-HIGH): off-hook = HIGH, pulse = brief LOW dip. */
int pulse_count = 0;
bool in_pulse = false; /* true while SHK is in pulse-open state */
int64_t pulse_open_us = 0; /* timestamp when pulse open started */
int64_t last_close_us = 0; /* timestamp when pulse closed (SHK back to OFFHOOK) */
#endif
for (;;) {
if (s_edge_pending) {
s_edge_pending = false;
#if CONFIG_PLIP_DIAL_PULSE
/* While off-hook, use fast polling instead of 30ms debounce
* so rotary pulses (~60-100ms) are not missed. */
if (s_offhook) {
/* The ISR fired — re-read immediately to catch the edge. */
vTaskDelay(pdMS_TO_TICKS(5)); /* minimal settle */
int level = gpio_get_level(hook_gpio);
if (level == HOOK_PULSE_OPEN && last_level == HOOK_PULSE_CLOSED) {
/* Pulse open: loop broken (SHK dropped from off-hook level) */
last_level = HOOK_PULSE_OPEN;
in_pulse = true;
pulse_open_us = esp_timer_get_time();
ESP_LOGD(TAG, "pulse: open");
} else if (level == HOOK_PULSE_CLOSED && last_level == HOOK_PULSE_OPEN) {
/* Pulse closed: loop restored */
last_level = HOOK_PULSE_CLOSED;
last_close_us = esp_timer_get_time();
int64_t open_dur_ms = (last_close_us - pulse_open_us) / 1000;
if (in_pulse && open_dur_ms < PULSE_MIN_WIDTH_MS) {
/* Glitch filter: open was too brief to be a real pulse.
* Ignore — do not count as pulse and do not declare hangup. */
in_pulse = false;
ESP_LOGD(TAG, "glitch ignored (%"PRId64"ms < %dms)",
open_dur_ms, PULSE_MIN_WIDTH_MS);
} else if (in_pulse && open_dur_ms < HANGUP_THRESHOLD_MS) {
/* Valid rotary pulse duration: count it */
pulse_count++;
in_pulse = false;
ESP_LOGD(TAG, "pulse %d (open %"PRId64"ms)", pulse_count, open_dur_ms);
} else if (open_dur_ms >= HANGUP_THRESHOLD_MS) {
/* Was open too long: treat as hangup */
ESP_LOGI(TAG, "on-hook (hangup) after %"PRId64"ms open", open_dur_ms);
pulse_count = 0;
in_pulse = false;
s_offhook = false;
audio_stop();
audio_pa_set(false);
report_offhook(false);
}
}
/* Don't fall through to the standard debounce below */
goto poll_sleep;
}
#endif
/* Standard 30 ms debounce for on-hook/off-hook transitions */
vTaskDelay(pdMS_TO_TICKS(DEBOUNCE_MS));
int level = gpio_get_level(hook_gpio);
if (level != last_level) {
last_level = level;
if (level == HOOK_OFFHOOK_LEVEL) {
/* Off-hook: handset picked up. */
s_offhook = true;
#if CONFIG_PLIP_DIAL_PULSE
pulse_count = 0;
in_pulse = false;
last_close_us = esp_timer_get_time();
#endif
ESP_LOGI(TAG, "off-hook (pickup) detected — PA on, SLIC offhook=%d",
slic_is_offhook());
audio_pa_set(true);
phone_ring_stop();
report_offhook(true);
} else {
/* On-hook: handset hung up. */
s_offhook = false;
ESP_LOGI(TAG, "on-hook (hangup) detected — PA off, stopping audio");
audio_stop();
audio_pa_set(false);
report_offhook(false);
}
}
}
#if CONFIG_PLIP_DIAL_PULSE
/* Check for inter-digit gap: if we accumulated pulses and the line
* has been at closed (off-hook) state for > MAX_GAP_MS, emit the digit. */
if (s_offhook && pulse_count > 0 && !in_pulse && last_close_us > 0) {
int64_t gap_ms = (esp_timer_get_time() - last_close_us) / 1000;
if (gap_ms > CONFIG_PLIP_DIAL_PULSE_MAX_GAP_MS) {
if (pulse_count > 10) {
ESP_LOGW(TAG, "bad pulse count %d, ignored", pulse_count);
pulse_count = 0;
} else {
int digit = (pulse_count == 10) ? 0 : pulse_count;
ESP_LOGI(TAG, "rotary digit: %d pulses -> %d", pulse_count, digit);
dialer_push_digit(digit);
pulse_count = 0;
}
}
}
/* Also detect prolonged open (hangup) even if no more edges arrive.
* Active-HIGH: hangup = level stays at HOOK_PULSE_OPEN (LOW) > 500 ms.
*
* Hardening: re-sample the GPIO HANGUP_VERIFY_COUNT times with
* HANGUP_VERIFY_STEP_MS gaps. Only declare hangup if every sample
* confirms the open state — guards against a marginal cradle contact
* that briefly dips below HANGUP_THRESHOLD_MS and then bounces back. */
if (s_offhook && in_pulse && pulse_open_us > 0) {
int64_t open_ms = (esp_timer_get_time() - pulse_open_us) / 1000;
if (open_ms >= HANGUP_THRESHOLD_MS) {
/* Multi-sample verification */
bool confirmed = true;
for (int v = 0; v < HANGUP_VERIFY_COUNT; v++) {
vTaskDelay(pdMS_TO_TICKS(HANGUP_VERIFY_STEP_MS));
if (gpio_get_level(hook_gpio) != HOOK_PULSE_OPEN) {
/* Line recovered during verification — not a real hangup */
confirmed = false;
ESP_LOGD(TAG, "hangup verify failed at sample %d — bounce", v);
/* Treat the level as if the line just closed */
last_level = HOOK_PULSE_CLOSED;
last_close_us = esp_timer_get_time();
/* The open was shorter than HANGUP_THRESHOLD_MS in effect,
* but we already passed the threshold — do not count as
* a pulse (it's the same ambiguous situation we reject
* elsewhere). Simply clear in_pulse. */
in_pulse = false;
break;
}
}
if (confirmed) {
ESP_LOGI(TAG, "on-hook (prolonged open %"PRId64"ms, verified ×%d) detected",
open_ms, HANGUP_VERIFY_COUNT);
last_level = HOOK_PULSE_OPEN;
pulse_count = 0;
in_pulse = false;
s_offhook = false;
audio_stop();
audio_pa_set(false);
report_offhook(false);
}
}
}
poll_sleep:
#endif
/* ── Self-healing resync ──────────────────────────────────────────────
* The marginal A1S cradle contact occasionally makes the edge detection
* miss/flap a transition, leaving s_offhook stuck (e.g. firmware thinks
* on-hook while the handset is physically off-hook → the call never
* starts). If the raw level stably disagrees with s_offhook for
* RESYNC_STABLE_MS — and we are NOT mid rotary-pulse — trust the
* physical level and correct s_offhook, firing the transition. */
{
bool pulse_active = false;
#if CONFIG_PLIP_DIAL_PULSE
pulse_active = in_pulse;
#endif
bool phys_off = (gpio_get_level(hook_gpio) == HOOK_OFFHOOK_LEVEL);
if (!pulse_active && phys_off != s_offhook) {
resync_ms += 10;
/* Hook debounce: a PICKUP (→off-hook) is confirmed fast so calls
* answer promptly; a HANGUP (→on-hook) needs the long debounce so
* a flickering cradle contact can't drop a live call. */
int need = phys_off ? RESYNC_PICKUP_MS : RESYNC_HANGUP_MS;
if (resync_ms >= need) {
ESP_LOGW(TAG, "hook resync: physical=%s but s_offhook=%d — correcting",
phys_off ? "off-hook" : "on-hook", (int)s_offhook);
s_offhook = phys_off;
last_level = phys_off ? HOOK_OFFHOOK_LEVEL : !HOOK_OFFHOOK_LEVEL;
audio_pa_set(phys_off);
if (!phys_off) audio_stop();
report_offhook(phys_off);
resync_ms = 0;
}
} else {
resync_ms = 0;
}
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
esp_err_t phone_init(void)
{
BaseType_t ok = xTaskCreatePinnedToCore(phone_task, "phone",
TASK_STACK, NULL,
TASK_PRIO, NULL, 1);
return (ok == pdPASS) ? ESP_OK : ESP_ERR_NO_MEM;
}
bool phone_is_offhook(void)
{
return s_offhook;
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
/*
* phone.h — Off-hook GPIO monitor + ring control for PLIP (Phase D).
*
* On the ESP32-A1S dev kit: BOOT button (GPIO4 = KEY1) acts as the
* off-hook stand-in (active LOW via INPUT_PULLUP, CONFIG_PLIP_HOOK_GPIO).
* On the Si3210 PCB target: replace with the SLIC interrupt GPIO.
*
* Ring: drives audio_ring_start() / audio_stop().
* Hook events: forwarded to hook_client_report().
*/
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Initialise the off-hook GPIO and start the monitoring task.
* hook_client_init() and audio_init() must have been called first. */
esp_err_t phone_init(void);
/* Signal the phone to start ringing (called externally if needed). */
void phone_ring_start(void);
/* Stop ringing. */
void phone_ring_stop(void);
/* Returns true if the handset is currently off-hook (picked up).
* Safe to call from any task; backed by a volatile flag updated in phone_task. */
bool phone_is_offhook(void);
#ifdef __cplusplus
}
#endif
+171
View File
@@ -0,0 +1,171 @@
// plip_gamebook.c — see plip_gamebook.h. Audio CYOA for the PLIP phone.
#include "plip_gamebook.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "audio.h"
static const char *TAG = "plip_gb";
#define GB_DIR "/sdcard/gamebook"
#define GB_LIBRARY GB_DIR "/library.json"
#define GB_MENU_WAV GB_DIR "/menu.wav"
#define GB_JSON_MAX (64 * 1024)
typedef enum { GB_OFF, GB_MENU, GB_STORY } gb_mode_t;
static cJSON *s_lib_root = NULL; // owns library.json
static cJSON *s_lib = NULL; // borrowed: root->"library"
static int s_lib_n = 0;
static cJSON *s_book = NULL; // owns the current <id>.json
static cJSON *s_passages = NULL; // borrowed: book->"passages"
static char s_current[48] = {0};
static gb_mode_t s_mode = GB_OFF;
bool plip_gamebook_active(void) { return s_mode != GB_OFF; }
static cJSON *load_json(const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "open %s failed", path); return NULL; }
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0 || sz > GB_JSON_MAX) { fclose(f); return NULL; }
char *buf = malloc((size_t)sz + 1);
if (!buf) { fclose(f); return NULL; }
size_t rd = fread(buf, 1, (size_t)sz, f);
fclose(f);
buf[rd] = '\0';
cJSON *root = cJSON_Parse(buf);
free(buf);
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
return root;
}
static void play_wav(const char *file) // file = bare name under GB_DIR
{
char path[96];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, file);
audio_stop(); // interrupt any current narration
audio_play_async(path);
}
bool plip_gamebook_available(void)
{
if (!audio_ensure_sd()) return false;
FILE *f = fopen(GB_LIBRARY, "rb");
if (!f) return false;
fclose(f);
return true;
}
static void free_story(void)
{
if (s_book) { cJSON_Delete(s_book); s_book = NULL; }
s_passages = NULL;
s_current[0] = '\0';
}
static void enter_menu(void)
{
free_story();
s_mode = GB_MENU;
audio_stop();
audio_play_async(GB_MENU_WAV); // absolute path
ESP_LOGI(TAG, "menu (%d stories)", s_lib_n);
}
// Play a passage by id (its WAV narrates the text + numbered choices).
static void enter_passage(const char *pid)
{
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' missing", pid); return; }
snprintf(s_current, sizeof(s_current), "%s", pid);
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) play_wav(wav->valuestring);
const cJSON *ch = cJSON_GetObjectItem(p, "choices");
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid,
cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0);
}
static void load_book(int idx)
{
const cJSON *e = cJSON_GetArrayItem(s_lib, idx);
const cJSON *file = cJSON_GetObjectItem(e, "book");
if (!cJSON_IsString(file)) return;
char path[128];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, file->valuestring);
cJSON *book = load_json(path);
if (!book) return;
const cJSON *passages = cJSON_GetObjectItem(book, "passages");
const cJSON *start = cJSON_GetObjectItem(book, "start");
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
cJSON_Delete(book); return;
}
free_story();
s_book = book;
s_passages = (cJSON *)passages;
s_mode = GB_STORY;
ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring);
enter_passage(start->valuestring);
}
void plip_gamebook_begin(void)
{
plip_gamebook_end(); // clean any previous run
s_lib_root = load_json(GB_LIBRARY);
if (!s_lib_root) return;
s_lib = cJSON_GetObjectItem(s_lib_root, "library");
if (!cJSON_IsArray(s_lib) || cJSON_GetArraySize(s_lib) == 0) {
cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL;
return;
}
s_lib_n = cJSON_GetArraySize(s_lib);
audio_pa_set(true); // earpiece amplifier on
enter_menu();
}
void plip_gamebook_feed_digit(int d)
{
if (s_mode == GB_MENU) {
if (d >= 1 && d <= s_lib_n) load_book(d - 1);
return;
}
if (s_mode == GB_STORY) {
const cJSON *p = cJSON_GetObjectItem(s_passages, s_current);
const cJSON *ch = cJSON_GetObjectItem(p, "choices");
int n = cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
if (n == 0) { // ending: 0 (or any) → back to menu
enter_menu();
return;
}
if (d >= 1 && d <= n) {
const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(ch, d - 1),
"goto");
if (cJSON_IsString(g)) {
ESP_LOGI(TAG, "choice %d -> '%s'", d, g->valuestring);
enter_passage(g->valuestring);
}
}
// out-of-range digit: ignore (player can dial again)
}
}
void plip_gamebook_end(void)
{
if (s_mode == GB_OFF && !s_lib_root && !s_book) return;
s_mode = GB_OFF;
audio_stop();
free_story();
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
ESP_LOGI(TAG, "ended");
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
// plip_gamebook — "livre dont vous êtes le héros" AUDIO pour le téléphone PLIP.
//
// Pas d'écran : tout passe par l'écouteur. On décroche, le narrateur lit le
// menu des histoires ("pour le dragon, faites le 1..."), on compose un chiffre
// au cadran pour choisir une histoire, puis à chaque passage le narrateur lit
// le texte ET les choix numérotés ("pour examiner la machine, faites le 1..."),
// et on compose le chiffre du choix. Raccrocher arrête tout.
//
// Lit /sdcard/gamebook/{library.json, menu.wav, <id>.json, <id>_<passage>.wav}
// (pack audio "téléphone" produit par tools/gamebook/build_phone_gamebook.py,
// où la narration des WAV inclut déjà les invites "faites le N"). 100% local,
// aucun modèle, aucun réseau.
//
// Intégration : conversation.c lance plip_gamebook_begin() au décroché (si un
// pack est présent sur la SD), pousse chaque chiffre composé via
// plip_gamebook_feed_digit(), et appelle plip_gamebook_end() au raccroché.
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// La SD est-elle montée et un pack gamebook présent ? (à tester au décroché)
bool plip_gamebook_available(void);
// Démarre le mode livre-jeu : charge la bibliothèque et joue le menu audio.
void plip_gamebook_begin(void);
// Traite un chiffre composé (0..9). Menu → choisit l'histoire ; en histoire →
// choisit la réponse ; sur une fin → 0 revient au menu. Interrompt la
// narration en cours pour enchaîner.
void plip_gamebook_feed_digit(int digit);
// Quitte le mode livre-jeu (raccroché) : stoppe l'audio, libère la mémoire.
void plip_gamebook_end(void);
// Vrai tant qu'une partie est en cours.
bool plip_gamebook_active(void);
#ifdef __cplusplus
}
#endif
+184
View File
@@ -0,0 +1,184 @@
/*
* slic.c — K50835F / AG1171-class SLIC control (ESP-IDF port).
*
* Ported from hardware/projects/slic-phone/src/slic/Ks0835SlicController.cpp
* (Arduino) to bare ESP-IDF 5.x GPIO driver.
*
* Key differences from Arduino original:
* - Open-drain on PD achieved via GPIO_MODE_OUTPUT_OD + gpio_set_level(PD, 1)
* - Ring FR toggle runs as a FreeRTOS task instead of a cooperative tick()
* - hook_active_high is hard-coded TRUE (matches A252ConfigStore default)
*/
#include "slic.h"
#include "board_config.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define TAG "slic"
/* Empirically on THIS A1S+SLIC unit, SHK is ACTIVE-HIGH: off-hook (loop closed)
* drives SHK HIGH, on-hook drives it LOW. Confirmed at the bench via
* /debug/hookmon: on-hook→0, pickup-during-ring→0→1 transition, off-hook→1.
* (The A252 reference was active-low; the polarity is inverted here, so the
* raw straight-wired GPIO reads off-hook = HIGH.) */
#define SLIC_SHK_OFFHOOK_LEVEL 1
static volatile bool s_ringing = false;
static TaskHandle_t s_ring_task = NULL;
/* France Télécom ring cadence: 1.5 s burst ON, 3.5 s silent pause, repeating.
* (Single-ring cadence — distinct from the UK double-ring or US 2 s/4 s.)
* The bell is driven during the burst by toggling FR at ~25 Hz with RM HIGH;
* during the pause RM and FR are held low so the bell is silent. */
#define RING_FR_TOGGLE_MS 20 /* 20 ms half-period → ~25 Hz bell drive */
#define RING_BURST_MS 1500 /* FT: 1.5 s of ringing */
#define RING_PAUSE_MS 3500 /* FT: 3.5 s of silence */
/* ── FR toggle + cadence task ─────────────────────────────────────────────── */
static void slic_ring_task(void *arg)
{
(void)arg;
bool fr_state = false;
bool in_burst = true; /* start each ring with a burst */
int phase_ms = 0; /* elapsed ms in the current burst/pause phase */
for (;;) {
if (s_ringing) {
if (in_burst) {
/* Ring burst: RM HIGH, FR toggling at ~25 Hz to swing the bell */
gpio_set_level(PLIP_SLIC_RM, 1);
fr_state = !fr_state;
gpio_set_level(PLIP_SLIC_FR, fr_state ? 1 : 0);
phase_ms += RING_FR_TOGGLE_MS;
if (phase_ms >= RING_BURST_MS) {
/* End of burst → enter silent pause */
in_burst = false;
phase_ms = 0;
fr_state = false;
gpio_set_level(PLIP_SLIC_RM, 0);
gpio_set_level(PLIP_SLIC_FR, 0);
ESP_LOGV(TAG, "ring: burst end -> pause");
}
} else {
/* Silent pause: RM/FR stay low */
phase_ms += RING_FR_TOGGLE_MS;
if (phase_ms >= RING_PAUSE_MS) {
in_burst = true;
phase_ms = 0;
ESP_LOGV(TAG, "ring: pause end -> burst");
}
}
} else {
/* Idle — keep RM/FR low and reset the cadence for the next ring */
gpio_set_level(PLIP_SLIC_RM, 0);
gpio_set_level(PLIP_SLIC_FR, 0);
fr_state = false;
in_burst = true;
phase_ms = 0;
}
vTaskDelay(pdMS_TO_TICKS(RING_FR_TOGGLE_MS));
}
}
/* ── Public API ──────────────────────────────────────────────────────────── */
esp_err_t slic_init(void)
{
/* RM: Ring Mode output, init LOW.
* INPUT_OUTPUT (not plain OUTPUT) so gpio_get_level() reads back the real
* pad level — plain OUTPUT disables the input buffer and always reads 0. */
gpio_config_t rm_cfg = {
.pin_bit_mask = (1ULL << PLIP_SLIC_RM),
.mode = GPIO_MODE_INPUT_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t ret = gpio_config(&rm_cfg);
if (ret != ESP_OK) return ret;
gpio_set_level(PLIP_SLIC_RM, 0);
/* FR: Forward/Reverse output, init LOW (INPUT_OUTPUT for readback, see RM). */
gpio_config_t fr_cfg = {
.pin_bit_mask = (1ULL << PLIP_SLIC_FR),
.mode = GPIO_MODE_INPUT_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
ret = gpio_config(&fr_cfg);
if (ret != ESP_OK) return ret;
gpio_set_level(PLIP_SLIC_FR, 0);
/* SHK: Switch Hook input with pull-up (physical line has no external pull) */
gpio_config_t shk_cfg = {
.pin_bit_mask = (1ULL << PLIP_SLIC_SHK),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
ret = gpio_config(&shk_cfg);
if (ret != ESP_OK) return ret;
/* PD: Power Down — EXACT A252-proven sequence: open-drain output, HIGH = released
* = SLIC active (setPowerDown(false) in Ks0835SlicController). This is the config
* the working Arduino slic-phone project uses on the same chip. */
/* INPUT_OUTPUT_OD (not plain OUTPUT_OD) so gpio_get_level() reads the real
* pad level — OUTPUT_OD also disables the input buffer and would read 0. */
ret = gpio_set_direction(PLIP_SLIC_PD, GPIO_MODE_INPUT_OUTPUT_OD);
if (ret != ESP_OK) return ret;
ret = gpio_set_level(PLIP_SLIC_PD, 1); /* open-drain released HIGH = active (A252-proven) */
if (ret != ESP_OK) return ret;
ESP_LOGI(TAG, "slic: PD GPIO%d open-drain HIGH (A252-proven active) — testing with fixed mic", PLIP_SLIC_PD);
ESP_LOGI(TAG, "slic: pins RM=%d FR=%d SHK=%d PD=%d hook_active_high=1",
PLIP_SLIC_RM, PLIP_SLIC_FR, PLIP_SLIC_SHK, PLIP_SLIC_PD);
/* Spawn the FR-toggle task (runs indefinitely, toggles only when s_ringing=true) */
BaseType_t ok = xTaskCreatePinnedToCore(slic_ring_task, "slic_ring",
2048, NULL, 4, &s_ring_task, 1);
if (ok != pdPASS) {
ESP_LOGE(TAG, "Failed to create slic_ring task");
return ESP_ERR_NO_MEM;
}
int shk = gpio_get_level(PLIP_SLIC_SHK);
ESP_LOGI(TAG, "SLIC init OK — SHK level=%d (%s)",
shk, (shk == SLIC_SHK_OFFHOOK_LEVEL) ? "off-hook" : "on-hook");
return ESP_OK;
}
bool slic_is_offhook(void)
{
return gpio_get_level(PLIP_SLIC_SHK) == SLIC_SHK_OFFHOOK_LEVEL;
}
void slic_ring_start(void)
{
if (s_ringing) return;
ESP_LOGI(TAG, "ring start: France Télécom cadence %d ms ON / %d ms OFF",
RING_BURST_MS, RING_PAUSE_MS);
/* The cadence task drives RM/FR; it starts on a burst (in_burst reset in
* the idle branch). Just arm it here. */
s_ringing = true;
}
void slic_ring_stop(void)
{
if (!s_ringing) return;
ESP_LOGI(TAG, "ring stop: RM=LOW, FR=LOW");
s_ringing = false;
gpio_set_level(PLIP_SLIC_RM, 0);
gpio_set_level(PLIP_SLIC_FR, 0);
}
bool slic_is_ringing(void)
{
return s_ringing;
}
+59
View File
@@ -0,0 +1,59 @@
/*
* slic.h — K50835F / AG1171-class SLIC control (ESP-IDF port of Ks0835SlicController).
*
* Pins (A1S board, from board_config.h):
* RM GPIO18 Ring Mode output — HIGH = ring burst active
* FR GPIO5 Forward/Reverse out — toggled at ~25 Hz during ring to drive bell
* SHK GPIO23 Switch Hook input — HIGH = off-hook (active-high)
* PD GPIO19 Power Down (OD) — HIGH = SLIC powered (released open-drain)
*
* Power-up: PD is open-drain, written HIGH → high-impedance → SLIC active.
* Power-down: PD driven LOW → SLIC off (no audio, no hook sense).
*
* Ring: slic_ring_start() sets RM=1 and spawns/enables a 20 ms FR-toggle task.
* slic_ring_stop() clears RM=0, FR=0, pauses the toggle task.
*
* Hook: slic_is_offhook() reads SHK; HIGH = off-hook (matches A252ConfigStore default).
*/
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* Initialise SLIC GPIO and power up the SLIC.
* Must be called early in boot, before audio init.
* Returns ESP_OK on success.
*/
esp_err_t slic_init(void);
/**
* Returns true when the handset is off-hook (SHK GPIO23 HIGH).
*/
bool slic_is_offhook(void);
/**
* Activate ringing: RM=HIGH + FR toggles at ~25 Hz (20 ms period).
* No-op if already ringing.
*/
void slic_ring_start(void);
/**
* Stop ringing: RM=LOW, FR=LOW, FR toggle suspended.
* No-op if not ringing.
*/
void slic_ring_stop(void);
/**
* Returns true if slic_ring_start() was called and not yet stopped.
*/
bool slic_is_ringing(void);
#ifdef __cplusplus
}
#endif
+139
View File
@@ -0,0 +1,139 @@
/*
* tones.c — France Télécom telephone tones (dial / busy / ringback).
*
* Generates 440 Hz sinusoid with on/off cadence written directly to the
* I2S speaker handle (stereo — ES8388 requires L+R pairs).
*
* Cadences (all at 440 Hz):
* DIAL : continuous
* BUSY : 0.5 s on / 0.5 s off (1 s cycle)
* RINGBACK : 1.5 s on / 3.5 s off (5 s cycle)
*/
#include "tones.h"
#include "audio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2s_std.h"
#include "esp_log.h"
#include <math.h>
#include <string.h>
#include <stdbool.h>
#define TAG "tones"
typedef enum { TONE_NONE, TONE_DIAL, TONE_BUSY, TONE_RINGBACK } tone_mode_t;
static volatile tone_mode_t s_mode = TONE_NONE;
static volatile bool s_tone_idle = true; /* true when tone_task is NOT in i2s_channel_write */
static TaskHandle_t s_task = NULL;
#define SR 16000 /* sample rate (must match audio_init) */
#define FRAME 320 /* 20 ms @ 16 kHz */
/*
* Fill `n` stereo frames (L=R = 440 Hz sine) into buf.
* buf must hold n * 2 int16_t (stereo interleaved).
* ph: running phase accumulator (mono sample index).
*/
static void fill_440_stereo(int16_t *buf, int n, int *ph)
{
for (int i = 0; i < n; i++) {
int16_t v = (int16_t)(8000.0f * sinf(2.0f * (float)M_PI * 440.0f * (*ph) / SR));
buf[i * 2] = v; /* L */
buf[i * 2 + 1] = v; /* R */
(*ph)++;
}
}
static void tone_task(void *a)
{
(void)a;
/* Stereo buffer: FRAME mono samples × 2 channels × 2 bytes = FRAME*4 bytes */
int16_t buf[FRAME * 2];
int ph = 0;
int64_t t = 0; /* ms elapsed in current tone state */
for (;;) {
tone_mode_t m = s_mode;
if (m == TONE_NONE) {
s_tone_idle = true;
vTaskDelay(pdMS_TO_TICKS(20));
t = 0;
ph = 0;
continue;
}
bool on = true;
if (m == TONE_BUSY) {
on = (t % 1000) < 500; /* 500ms on / 500ms off */
} else if (m == TONE_RINGBACK) {
on = (t % 5000) < 1500; /* 1.5s on / 3.5s off */
}
/* TONE_DIAL: always on */
if (on) {
fill_440_stereo(buf, FRAME, &ph);
} else {
memset(buf, 0, sizeof(buf));
/* Keep phase accumulator advancing so pitch is smooth on resumption */
ph += FRAME;
}
size_t written = 0;
s_tone_idle = false;
i2s_channel_write(audio_spk_handle(), buf, sizeof(buf), &written, pdMS_TO_TICKS(50));
t += 20; /* each frame = 20 ms */
}
}
static void ensure_task(void)
{
if (!s_task) {
xTaskCreate(tone_task, "tones", 3072, NULL, 5, &s_task);
ESP_LOGI(TAG, "tone_task created");
}
}
void tones_dialtone_start(void)
{
ESP_LOGI(TAG, "dial tone start");
audio_pa_set(true);
ensure_task();
s_mode = TONE_DIAL;
}
void tones_busy_start(void)
{
ESP_LOGI(TAG, "busy tone start");
audio_pa_set(true);
ensure_task();
s_mode = TONE_BUSY;
}
void tones_ringback_start(void)
{
ESP_LOGI(TAG, "ringback tone start");
audio_pa_set(true);
ensure_task();
s_mode = TONE_RINGBACK;
}
void tones_stop(void)
{
ESP_LOGI(TAG, "tones stop");
s_mode = TONE_NONE;
/* Wait until tone_task is no longer inside i2s_channel_write (I2S arbitration).
* Guard: max 200 ms so we never block indefinitely. */
const int max_wait_ms = 200;
int waited_ms = 0;
while (!s_tone_idle && waited_ms < max_wait_ms) {
vTaskDelay(pdMS_TO_TICKS(5));
waited_ms += 5;
}
if (waited_ms >= max_wait_ms) {
ESP_LOGW(TAG, "tones_stop: timeout waiting for tone_task idle");
}
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include "esp_err.h"
void tones_dialtone_start(void); /* 440 Hz continuous */
void tones_busy_start(void); /* 440 Hz 0.5s on/off */
void tones_ringback_start(void); /* 440 Hz 1.5s/3.5s */
void tones_stop(void);
+340
View File
@@ -0,0 +1,340 @@
/*
* turn_client.c — NPC gateway client for /v1/voice/turn and /v1/voice/reply.
*
* Uses esp_http_client open/write/fetch/read streaming so the binary WAV
* body can be written directly to SPIFFS without a large heap buffer.
*
* Pattern mirrors hook_client.c but synchronous (called from conv_task).
* Timeout is generous (30 s) because TTS synthesis adds latency.
*
* Stage 3 adds turn_client_reply() which POSTs captured mic audio as
* multipart/form-data to /v1/voice/reply and streams the NPC response WAV.
*/
#include "turn_client.h"
#include "sdkconfig.h"
#include <stdio.h>
#include <string.h>
#include "esp_http_client.h"
#include "esp_log.h"
#define TAG "turn_client"
/* WAV header is 44 bytes; anything shorter is certainly not a valid response. */
#define MIN_WAV_BYTES 44
/* Read chunks when streaming the response body. */
#define CHUNK_SIZE 1024
/* Write chunks when uploading the captured WAV body (4 KB). */
#define UPLOAD_CHUNK 4096
/* Timeout for the full TTS round-trip (connect + synthesis + transfer). */
#define TIMEOUT_MS 90000 /* reply TTS (Kyutai MLX ~0.3x realtime) can take
* tens of seconds; was 30s → reply POST timed out
* (HTTP -1). 90s covers worst-case generation. */
/* Multipart boundary (must not appear in the WAV payload — 16kHz PCM is binary
* so any fixed ASCII boundary is safe). */
#define BOUNDARY "----ZacusPlipBoundary7MA4YWxkTrZu0gW"
bool turn_client_greeting(const char *session_id,
const char *number,
const char *out_path)
{
/* --- Build URL -------------------------------------------------------- */
char url[256];
snprintf(url, sizeof(url), "%s/v1/voice/turn",
CONFIG_PLIP_GATEWAY_URL);
/* --- Build JSON body -------------------------------------------------- */
char body[256];
int body_len = snprintf(body, sizeof(body),
"{\"session_id\":\"%s\",\"number\":\"%s\",\"kind\":\"greeting\"}",
session_id ? session_id : "",
number ? number : "");
/* --- Configure client ------------------------------------------------- */
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = TIMEOUT_MS,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) {
ESP_LOGE(TAG, "esp_http_client_init failed");
return false;
}
esp_http_client_set_header(client, "Content-Type", "application/json");
/* Authorization header — skip if token is empty */
const char *token = CONFIG_PLIP_GATEWAY_TOKEN;
if (token && token[0] != '\0') {
char auth[128];
snprintf(auth, sizeof(auth), "Bearer %s", token);
esp_http_client_set_header(client, "Authorization", auth);
}
/* --- Open connection and write body (streaming POST) ------------------ */
esp_err_t err = esp_http_client_open(client, body_len);
if (err != ESP_OK) {
ESP_LOGW(TAG, "open %s failed: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return false;
}
int written = esp_http_client_write(client, body, body_len);
if (written < 0) {
ESP_LOGW(TAG, "write body failed (written=%d)", written);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return false;
}
/* --- Fetch response headers ------------------------------------------ */
int content_len = esp_http_client_fetch_headers(client);
int status_code = esp_http_client_get_status_code(client);
if (status_code != 200) {
ESP_LOGW(TAG, "gateway returned HTTP %d (url=%s)", status_code, url);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return false;
}
ESP_LOGI(TAG, "HTTP 200 from %s (content_length=%d)", url, content_len);
/* --- Stream binary body into SPIFFS file ------------------------------ */
FILE *fp = fopen(out_path, "wb");
if (!fp) {
ESP_LOGE(TAG, "fopen(%s, wb) failed", out_path);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return false;
}
static uint8_t s_chunk[CHUNK_SIZE]; /* static: avoids stack pressure */
int total = 0;
int rd;
while ((rd = esp_http_client_read(client, (char *)s_chunk, sizeof(s_chunk))) > 0) {
size_t fw = fwrite(s_chunk, 1, (size_t)rd, fp);
if ((int)fw != rd) {
ESP_LOGW(TAG, "fwrite short: wrote %d of %d bytes", (int)fw, rd);
break;
}
total += rd;
}
fclose(fp);
esp_http_client_close(client);
esp_http_client_cleanup(client);
if (total <= MIN_WAV_BYTES) {
ESP_LOGW(TAG, "greeting WAV too short (%d bytes) — ignoring", total);
return false;
}
ESP_LOGI(TAG, "greeting WAV %d bytes written to %s", total, out_path);
return true;
}
/* ── turn_client_reply ─────────────────────────────────────────────────────
*
* POST multipart/form-data to /v1/voice/reply with the captured mic WAV.
*
* Multipart layout (each part is CRLF-delimited per RFC 2046):
*
* --<boundary>\r\n
* Content-Disposition: form-data; name="session_id"\r\n\r\n
* <session_id>\r\n
* --<boundary>\r\n
* Content-Disposition: form-data; name="number"\r\n\r\n
* <number>\r\n
* --<boundary>\r\n
* Content-Disposition: form-data; name="audio"; filename="rec.wav"\r\n
* Content-Type: audio/wav\r\n\r\n
* <wav bytes>
* \r\n--<boundary>--\r\n
*
* Content-Length = len(preamble) + wav_len + len(epilogue)
* (known exactly before writing — allows non-chunked POST).
*/
esp_err_t turn_client_reply(const char *session_id,
const char *number,
const uint8_t *wav,
size_t wav_len,
const char *out_path)
{
if (!session_id || !number || !wav || wav_len == 0 || !out_path) {
return ESP_ERR_INVALID_ARG;
}
/* --- Build URL -------------------------------------------------------- */
char url[256];
snprintf(url, sizeof(url), "%s/v1/voice/reply",
CONFIG_PLIP_GATEWAY_URL);
/* --- Build multipart preamble ---------------------------------------- */
/* preamble = 3 parts before the raw wav bytes:
* part 1 — session_id (text field)
* part 2 — number (text field)
* part 3 — audio file header (up to but NOT including the file body)
*/
char preamble[512];
int preamble_len = snprintf(preamble, sizeof(preamble),
"--%s\r\n"
"Content-Disposition: form-data; name=\"session_id\"\r\n\r\n"
"%s\r\n"
"--%s\r\n"
"Content-Disposition: form-data; name=\"number\"\r\n\r\n"
"%s\r\n"
"--%s\r\n"
"Content-Disposition: form-data; name=\"audio\"; filename=\"rec.wav\"\r\n"
"Content-Type: audio/wav\r\n\r\n",
BOUNDARY,
session_id,
BOUNDARY,
number,
BOUNDARY);
if (preamble_len <= 0 || preamble_len >= (int)sizeof(preamble)) {
ESP_LOGE(TAG, "preamble buffer overflow (len=%d)", preamble_len);
return ESP_ERR_INVALID_SIZE;
}
/* --- Build multipart epilogue ---------------------------------------- */
/* epilogue = CRLF after the wav data + closing boundary */
char epilogue[64];
int epilogue_len = snprintf(epilogue, sizeof(epilogue),
"\r\n--%s--\r\n",
BOUNDARY);
if (epilogue_len <= 0 || epilogue_len >= (int)sizeof(epilogue)) {
ESP_LOGE(TAG, "epilogue buffer overflow");
return ESP_ERR_INVALID_SIZE;
}
int content_length = preamble_len + (int)wav_len + epilogue_len;
ESP_LOGI(TAG, "reply POST %s preamble=%d wav=%zu epilogue=%d total=%d",
url, preamble_len, wav_len, epilogue_len, content_length);
/* --- Configure client ------------------------------------------------- */
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = TIMEOUT_MS,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) {
ESP_LOGE(TAG, "esp_http_client_init failed");
return ESP_ERR_NO_MEM;
}
/* Content-Type with boundary */
char ct_header[128];
snprintf(ct_header, sizeof(ct_header),
"multipart/form-data; boundary=%s", BOUNDARY);
esp_http_client_set_header(client, "Content-Type", ct_header);
/* Authorization header — skip if token is empty */
const char *token = CONFIG_PLIP_GATEWAY_TOKEN;
if (token && token[0] != '\0') {
char auth[128];
snprintf(auth, sizeof(auth), "Bearer %s", token);
esp_http_client_set_header(client, "Authorization", auth);
}
/* --- Open connection and stream body ---------------------------------- */
esp_err_t err = esp_http_client_open(client, content_length);
if (err != ESP_OK) {
ESP_LOGW(TAG, "open %s failed: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return err;
}
/* Write preamble */
int written = esp_http_client_write(client, preamble, preamble_len);
if (written < 0) {
ESP_LOGW(TAG, "write preamble failed (ret=%d)", written);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_FAIL;
}
/* Write WAV data in 4 KB chunks */
size_t remaining = wav_len;
const uint8_t *ptr = wav;
while (remaining > 0) {
size_t to_send = (remaining < UPLOAD_CHUNK) ? remaining : UPLOAD_CHUNK;
int wr = esp_http_client_write(client, (const char *)ptr, (int)to_send);
if (wr < 0) {
ESP_LOGW(TAG, "write wav chunk failed (ret=%d)", wr);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_FAIL;
}
ptr += (size_t)wr;
remaining -= (size_t)wr;
}
/* Write epilogue */
written = esp_http_client_write(client, epilogue, epilogue_len);
if (written < 0) {
ESP_LOGW(TAG, "write epilogue failed (ret=%d)", written);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_FAIL;
}
/* --- Fetch response headers ------------------------------------------ */
int content_len = esp_http_client_fetch_headers(client);
int status_code = esp_http_client_get_status_code(client);
ESP_LOGI(TAG, "reply HTTP %d content_length=%d", status_code, content_len);
if (status_code != 200) {
ESP_LOGW(TAG, "gateway returned HTTP %d for reply", status_code);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_ERR_INVALID_RESPONSE;
}
/* --- Stream binary WAV response into SPIFFS file --------------------- */
FILE *fp = fopen(out_path, "wb");
if (!fp) {
ESP_LOGE(TAG, "fopen(%s, wb) failed", out_path);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_ERR_NOT_FOUND;
}
static uint8_t s_reply_chunk[CHUNK_SIZE]; /* static: avoids stack pressure */
int total = 0;
int rd;
while ((rd = esp_http_client_read(client, (char *)s_reply_chunk,
sizeof(s_reply_chunk))) > 0) {
size_t fw = fwrite(s_reply_chunk, 1, (size_t)rd, fp);
if ((int)fw != rd) {
ESP_LOGW(TAG, "fwrite short: wrote %d of %d bytes", (int)fw, rd);
break;
}
total += rd;
}
fclose(fp);
esp_http_client_close(client);
esp_http_client_cleanup(client);
if (total <= MIN_WAV_BYTES) {
ESP_LOGW(TAG, "reply WAV too short (%d bytes) — ignoring", total);
return ESP_ERR_INVALID_SIZE;
}
ESP_LOGI(TAG, "reply WAV %d bytes written to %s", total, out_path);
return ESP_OK;
}
+66
View File
@@ -0,0 +1,66 @@
#pragma once
/*
* turn_client.h — POST /v1/voice/turn to the NPC gateway and retrieve a WAV response.
*
* Stage 2: greeting fetch (kind="greeting").
* Stage 3: reply fetch via /v1/voice/reply (multipart/form-data with captured WAV).
*/
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* turn_client_greeting — POST /v1/voice/turn with kind="greeting".
*
* Sends: { "session_id": session_id, "number": number, "kind": "greeting" }
* Bearer: CONFIG_PLIP_GATEWAY_TOKEN (skipped if empty)
* URL: CONFIG_PLIP_GATEWAY_URL/v1/voice/turn
*
* On success (HTTP 200, body > 44 bytes written to out_path):
* - Streams the binary WAV response into fopen(out_path, "wb").
* - Returns true.
*
* On failure (network error, non-200, short body):
* - Logs a warning and returns false.
* - Does not crash; caller may proceed silently.
*/
bool turn_client_greeting(const char *session_id,
const char *number,
const char *out_path);
/*
* turn_client_reply — POST /v1/voice/reply as multipart/form-data.
*
* Endpoint: CONFIG_PLIP_GATEWAY_URL/v1/voice/reply
* Method: POST multipart/form-data
* Fields: session_id (text), number (text), audio (file, "rec.wav", audio/wav)
* Bearer: CONFIG_PLIP_GATEWAY_TOKEN (skipped if empty)
*
* The function builds the multipart body in three segments:
* preamble = boundary + session_id part + boundary + number part +
* boundary + audio file header
* wav data = wav bytes (wav_len bytes, sent in 4 KB chunks)
* epilogue = CRLF + closing boundary
*
* Content-Length = len(preamble) + wav_len + len(epilogue)
* The response body (WAV 16 kHz) is streamed into out_path.
*
* Response headers X-Zacus-Heard and X-Zacus-Said are logged at INFO level.
*
* Returns ESP_OK if HTTP 200 and a valid WAV (> 44 bytes) was written.
* On any failure: logs a warning and returns an ESP error code.
*/
esp_err_t turn_client_reply(const char *session_id,
const char *number,
const uint8_t *wav,
size_t wav_len,
const char *out_path);
#ifdef __cplusplus
}
#endif
+8
View File
@@ -0,0 +1,8 @@
# Partitions for PLIP voice firmware — AI-Thinker ESP32-A1S (4 MB flash)
# Matches PLIP_FIRMWARE/partitions/plip_4mb.csv (OTA dual-bank + SPIFFS 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 voice firmware — AI-Thinker ESP32-A1S (4 MB flash)
2 # Matches PLIP_FIRMWARE/partitions/plip_4mb.csv (OTA dual-bank + SPIFFS 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,
+58
View File
@@ -0,0 +1,58 @@
# Target: ESP32 (AI-Thinker ESP32-A1S, 4MB flash, 8MB PSRAM N4R8)
CONFIG_IDF_TARGET="esp32"
# Flash: 4MB DIO (A1S default)
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
CONFIG_ESPTOOLPY_FLASHFREQ_40M=y
# Partition table
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
# WiFi
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32
CONFIG_ESP_WIFI_NVS_ENABLED=y
CONFIG_LWIP_LOCAL_HOSTNAME="plip"
# FreeRTOS
CONFIG_FREERTOS_HZ=1000
# Logging
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_COLORS=y
# HTTPD
CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024
CONFIG_HTTPD_MAX_URI_LEN=512
# Watchdog — allow for codec init time
CONFIG_ESP_TASK_WDT_TIMEOUT_S=15
# FreeRTOS IDLE task stack — increase from default 1536 to avoid overflow
# when I2S/WiFi interrupts spill into idle stack on original ESP32.
CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=2048
# SLIC hook GPIO — SHK on GPIO23 (A1S KEY4, active-HIGH: HIGH = off-hook)
CONFIG_PLIP_HOOK_GPIO=23
CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
# WiFi credentials — set via `idf.py menuconfig` (PLIP Voice Configuration)
# or override locally in sdkconfig (NOT committed).
# CONFIG_PLIP_WIFI_SSID="..."
# CONFIG_PLIP_WIFI_PASSWORD="..."
# CONFIG_PLIP_WIFI_CHANNEL=11
# Zacus master URL — default matches the lab IP
# CONFIG_PLIP_MASTER_URL="http://192.168.0.188"
# NPC gateway (Stage 2) — override in sdkconfig (NOT committed) for local testing.
# Example: CONFIG_PLIP_GATEWAY_URL="http://192.168.0.175:8401"
# CONFIG_PLIP_GATEWAY_TOKEN="testtoken"
# Default (Kconfig): http://192.168.0.50:8401 with empty token.
# Long file names on the SD (voice sample pack uses >8.3 names)
CONFIG_FATFS_LFN_HEAP=y
CONFIG_FATFS_MAX_LFN=255