Compare commits

...

81 Commits

Author SHA1 Message Date
clement 43dc1478b1 box3: fix gamebook audio, accents, and crashes
CI / platformio (pull_request) Failing after 3m44s
CI / platformio (push) Failing after 3m34s
The BOX-3 touch gamebook had several blocking bugs that made the
voiced+foley story packs unusable. This fixes them end to end.

- Stack overflow on story start: gb_play_task kept a 2 KB buffer on
  its 4 KB stack and rebooted the board. Buffers are now static (the
  task is a singleton) and the stack is 6 KB.
- No sound at all: the ES8311 codec was never initialised — main.c
  wrote raw I2S to a muted DAC. Output (and the mic) now go through
  the BSP esp_codec_dev (bsp_audio_codec_speaker/microphone_init,
  esp_codec_dev_write/read). The power amplifier (GPIO46) is also
  forced on and volume set to 100, as the es8311 pa_pin handling did
  not drive it in practice.
- First passage silent: gb_play re-mounted the SD that the gamebook
  had already mounted; the failed second bsp_sdcard_mount tore down
  the VFS so the first fopen failed. Now we open the file first and
  only mount if that fails. A short silence lead-in also primes the
  codec/PA ramp.
- No accents: the gamebook used ASCII-only lv_font_montserrat_14/24.
  Added custom Montserrat fonts (font_fr_14/24, Latin-1 + œŒŸ « » ’ …,
  uncompressed so they render without LV_USE_FONT_COMPRESSED).
- Mic/voice streaming gated behind CONFIG_BOX3_VOICE_STREAMING
  (default off): as a gamebook the BOX-3 needs no mic, and the
  constant codec reads + bridge reconnects interfered with playback
  and flooded the log.

plip_virtual + cmd_exec handle types switched from i2s_chan_handle_t
to esp_codec_dev_handle_t accordingly.
2026-06-21 11:07:34 +02:00
clement b203f0e4de plip: optional auto story-ring (off by default)
CI / platformio (pull_request) Failing after 3m46s
CI / platformio (push) Failing after 4m1s
Escape-room mechanic: the retro phone can call the players on its own
and tell them a story when they pick up.

Approach: a story_ring_task rings at a random 15-30 min interval (only
when the line is idle and a story pack is on the SD). Picking up routes
through enter_story_from_ring(), which silences the bell and launches a
random story via plip_gamebook_begin_random() straight into the gamebook
state — no menu. If nobody answers within 1 min the bell stops and the
next ring is rescheduled; hanging up mid-ring also silences it.

The whole feature is gated behind CONFIG_PLIP_AUTO_RING (default n), so
the phone stays silent unless explicitly enabled in menuconfig. The hook
pickup paths (raw SLIC poll + debounced edge) mirror the existing NPC
incoming-call handling, which is unreliable while the bell rings.
2026-06-20 21:08:56 +02:00
clement 42b5d6375a plip: default speaker volume to maximum
CI / platformio (push) Failing after 6m1s
CI / platformio (pull_request) Failing after 25m39s
2026-06-20 09:31:05 +02:00
clement e37fa9ded2 gamebook: load expanded books from PSRAM 2026-06-20 09:31:05 +02:00
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
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
73 changed files with 16828 additions and 88 deletions
+2 -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" "font_fr_14.c" "font_fr_24.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
@@ -13,5 +13,6 @@ idf_component_register(
spiffs
scenario_mesh
espressif__esp-box-3
espressif__esp_codec_dev
lvgl__lvgl
)
+10
View File
@@ -1,5 +1,15 @@
menu "Zacus BOX-3 Voice Configuration"
config BOX3_VOICE_STREAMING
bool "Enable microphone + voice-bridge streaming"
default n
help
Start the mic capture task and the WebSocket voice-bridge client.
Off by default: as a touch gamebook the BOX-3 needs no microphone,
and the continuous codec reads + bridge reconnect attempts interfere
with speaker playback (the first narration clip came out silent) and
flood the log. Enable only when using the BOX-3 as a voice device.
config ZACUS_WIFI_SSID
string "WiFi SSID"
default "zacus-net"
+667
View File
@@ -0,0 +1,667 @@
// 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 the ES8311 codec (esp_codec_dev), same path 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 is defined in main.c (writes through the ES8311 codec).
extern void audio_play_tone(float frequency, int duration_ms);
// Speaker codec (ES8311) handle — obtained from main.c after speaker_init().
// Audio is streamed through esp_codec_dev so the ES8311 DAC is actually driven
// (a raw I2S write leaves the codec muted).
#include "esp_codec_dev.h"
static esp_codec_dev_handle_t s_spk_handle_local = NULL;
void cmd_exec_set_spk_handle(esp_codec_dev_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;
int ret = esp_codec_dev_write(s_spk_handle_local,
(void *)(pcm + offset), (int)chunk);
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGW(TAG, "codec write error: %d", ret);
return ESP_FAIL;
}
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;
/* The task is a singleton (guarded by s_gb_task), so these large buffers
* are kept static — on the stack they ate ~2.1 KB and, together with the
* FATFS fread internals + i2s + logging, overflowed the 4 KB stack and
* rebooted the board when a story started. */
static uint8_t hdr[64];
static 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;
/* The SD is normally already mounted (the gamebook mounts it at init).
* Try the file directly first — re-mounting an already-mounted card via
* bsp_sdcard_mount() FAILS and tears down the existing VFS, which made
* the very first narration clip silent. Only mount if the open fails. */
FILE *f = fopen(path, "rb");
if (!f) {
ensure_sd_mounted();
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);
/* Wake the codec/PA before the real audio: after idle the ES8311 mutes
* and ramps up on the first write, swallowing the start of the clip.
* A short silent lead-in lets the unmute settle so the narration plays
* from the very first word. */
memset(buf, 0, sizeof(buf));
for (int i = 0; i < 4 && s_spk_handle_local && !s_gb_stop && !s_gb_new; i++) {
esp_codec_dev_write(s_spk_handle_local, buf, (int)sizeof(buf));
}
size_t remaining = wi.data_size;
ESP_LOGI(TAG, "gb: play %s (%u B, %u Hz)", path,
(unsigned)wi.data_size, (unsigned)wi.sample_rate);
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;
if (s_spk_handle_local) {
esp_codec_dev_write(s_spk_handle_local, buf, (int)got);
}
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", 6144, 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 "esp_codec_dev.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 codec (ES8311) 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(esp_codec_dev_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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
// 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";
// Custom fonts with French glyphs (ASCII + Latin-1 + + …). See main/font_fr_*.c.
LV_FONT_DECLARE(font_fr_14);
LV_FONT_DECLARE(font_fr_24);
#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, &font_fr_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, &font_fr_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, &font_fr_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
+127 -70
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"
@@ -38,8 +40,12 @@ static const char *TAG = "zacus-voice";
/* --------------- Shared state --------------- */
static i2s_chan_handle_t s_spk_handle = NULL; /* Speaker TX channel */
static i2s_chan_handle_t s_mic_handle = NULL; /* Microphone RX channel */
/* Audio now goes through the ES8311 (speaker) / ES7210 (mic) codecs via the
* BSP + esp_codec_dev. bsp_audio_init() owns the single shared I2S port; we
* must NOT create our own raw I2S channels (that left the ES8311 DAC unpowered
* and the speaker silent). */
static esp_codec_dev_handle_t s_spk_codec = NULL; /* ES8311 output (DAC) */
static esp_codec_dev_handle_t s_mic_codec = NULL; /* ES7210 input (ADC) */
static volatile bool s_wifi_connected = false;
static volatile bool s_voice_streaming = false;
@@ -112,11 +118,10 @@ static esp_err_t wifi_init_sta(void)
// generator can sequence a melody for the master's microphone.
void audio_play_tone(float frequency, int duration_ms)
{
if (!s_spk_handle || duration_ms <= 0) return;
if (!s_spk_codec || duration_ms <= 0) return;
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
const float amplitude = 16000.0f;
int16_t buffer[256];
size_t bytes_written = 0;
int sample_idx = 0;
while (sample_idx < total_samples) {
int chunk = (total_samples - sample_idx < 256)
@@ -125,29 +130,27 @@ void audio_play_tone(float frequency, int duration_ms)
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
}
i2s_channel_write(s_spk_handle, buffer, chunk * sizeof(int16_t),
&bytes_written, portMAX_DELAY);
esp_codec_dev_write(s_spk_codec, buffer, chunk * sizeof(int16_t));
sample_idx += chunk;
}
}
static void audio_test_tone(void)
{
if (!s_spk_handle) {
if (!s_spk_codec) {
ESP_LOGW(TAG, "Speaker not initialized, skipping test tone");
return;
}
ESP_LOGI(TAG, "Playing 440 Hz test tone (1 second)...");
/* Generate 1 second of 440 Hz sine wave using persistent speaker handle */
/* Generate 1 second of 440 Hz sine wave through the ES8311 codec */
const int duration_ms = 1000;
const int total_samples = AUDIO_SAMPLE_RATE * duration_ms / 1000;
const float frequency = 440.0f;
const float amplitude = 16000.0f;
int16_t buffer[256];
size_t bytes_written = 0;
int sample_idx = 0;
while (sample_idx < total_samples) {
@@ -156,7 +159,7 @@ static void audio_test_tone(void)
float t = (float)(sample_idx + i) / (float)AUDIO_SAMPLE_RATE;
buffer[i] = (int16_t)(amplitude * sinf(2.0f * M_PI * frequency * t));
}
i2s_channel_write(s_spk_handle, buffer, chunk * sizeof(int16_t), &bytes_written, portMAX_DELAY);
esp_codec_dev_write(s_spk_codec, buffer, chunk * sizeof(int16_t));
sample_idx += chunk;
}
@@ -172,15 +175,13 @@ static void tts_audio_callback(const uint8_t *data, size_t len)
* Called from WebSocket event context with incoming TTS PCM16 data.
* Write directly to the speaker I2S channel.
*/
if (!s_spk_handle || len == 0) {
if (!s_spk_codec || len == 0) {
return;
}
size_t bytes_written = 0;
esp_err_t ret = i2s_channel_write(s_spk_handle, data, len,
&bytes_written, pdMS_TO_TICKS(200));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "TTS write to speaker failed: %s", esp_err_to_name(ret));
int ret = esp_codec_dev_write(s_spk_codec, (void *)data, (int)len);
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGW(TAG, "TTS write to speaker failed: %d", ret);
}
}
@@ -188,34 +189,42 @@ static void tts_audio_callback(const uint8_t *data, size_t len)
static esp_err_t speaker_init(void)
{
/* Enable power amplifier */
/* Init the ES8311 output codec via the BSP. This also runs bsp_audio_init()
* (shared duplex I2S port) + bsp_i2c_init() and configures the power
* amplifier (BSP_POWER_AMP_IO = GPIO46). The ES8311 DAC is configured over
* I2C here — without this the speaker stays muted no matter what we push on
* I2S. */
s_spk_codec = bsp_audio_codec_speaker_init();
if (!s_spk_codec) {
ESP_LOGE(TAG, "bsp_audio_codec_speaker_init failed — speaker unavailable");
return ESP_FAIL;
}
/* All speaker audio in this app is 16 kHz / 16-bit / mono PCM. */
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = AUDIO_BITS,
.channel = AUDIO_CHANNELS,
.channel_mask = 0,
.sample_rate = AUDIO_SAMPLE_RATE,
.mclk_multiple = 0,
};
int ret = esp_codec_dev_open(s_spk_codec, &fs);
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGE(TAG, "esp_codec_dev_open (speaker) failed: %d", ret);
return ESP_FAIL;
}
/* Output volume 0..100. */
esp_codec_dev_set_out_vol(s_spk_codec, 100);
/* Belt-and-suspenders: the es8311 driver is configured with pa_pin=GPIO46
* (pa_reverted=false) and should raise it on open, but the speaker stays
* silent in practice — force the power amplifier on explicitly. */
gpio_set_direction(BOX3_PA_ENABLE, GPIO_MODE_OUTPUT);
gpio_set_level(BOX3_PA_ENABLE, 1);
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &s_spk_handle, NULL));
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = BOX3_I2S_MCLK,
.bclk = BOX3_I2S_BCLK,
.ws = BOX3_I2S_WS,
.dout = BOX3_I2S_DOUT,
.din = I2S_GPIO_UNUSED,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_spk_handle, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(s_spk_handle));
ESP_LOGI(TAG, "Speaker I2S initialized (I2S_NUM_0)");
ESP_LOGI(TAG, "Speaker codec (ES8311) initialized @ %d Hz, vol=100, PA on",
AUDIO_SAMPLE_RATE);
return ESP_OK;
}
@@ -225,43 +234,45 @@ static void mic_monitor_task(void *arg)
{
ESP_LOGI(TAG, "Starting mic capture task...");
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, NULL, &s_mic_handle));
/* Init the ES7210 input codec via the BSP. It shares the same I2S port
* (already created by speaker_init -> bsp_audio_init); creating a separate
* raw I2S channel here would conflict with that shared port. */
s_mic_codec = bsp_audio_codec_microphone_init();
if (!s_mic_codec) {
ESP_LOGE(TAG, "bsp_audio_codec_microphone_init failed — mic unavailable");
vTaskDelete(NULL);
return;
}
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(AUDIO_SAMPLE_RATE),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = BOX3_I2S_BCLK,
.ws = BOX3_I2S_WS,
.dout = I2S_GPIO_UNUSED,
.din = BOX3_I2S_DIN,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = AUDIO_BITS,
.channel = AUDIO_CHANNELS,
.channel_mask = 0,
.sample_rate = AUDIO_SAMPLE_RATE,
.mclk_multiple = 0,
};
ESP_ERROR_CHECK(i2s_channel_init_std_mode(s_mic_handle, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(s_mic_handle));
int oret = esp_codec_dev_open(s_mic_codec, &fs);
if (oret != ESP_CODEC_DEV_OK) {
ESP_LOGE(TAG, "esp_codec_dev_open (mic) failed: %d", oret);
vTaskDelete(NULL);
return;
}
/* ES7210 input gain (dB). 30 dB is a sane default for the BOX-3 mic array. */
esp_codec_dev_set_in_gain(s_mic_codec, 30.0f);
int16_t buffer[AUDIO_FRAME_SAMPLES];
size_t bytes_read = 0;
int rms_log_counter = 0;
while (1) {
esp_err_t ret = i2s_channel_read(s_mic_handle, buffer,
AUDIO_FRAME_SAMPLES * sizeof(int16_t),
&bytes_read, pdMS_TO_TICKS(1000));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "Mic read error: %s", esp_err_to_name(ret));
int ret = esp_codec_dev_read(s_mic_codec, buffer,
AUDIO_FRAME_SAMPLES * sizeof(int16_t));
if (ret != ESP_CODEC_DEV_OK) {
ESP_LOGW(TAG, "Mic read error: %d", ret);
vTaskDelay(pdMS_TO_TICKS(20));
continue;
}
int samples = bytes_read / sizeof(int16_t);
int samples = AUDIO_FRAME_SAMPLES;
/* If voice streaming is active, send PCM to bridge */
if (s_voice_streaming) {
@@ -372,6 +383,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,11 +432,19 @@ void app_main(void)
/* Initialize persistent speaker output (used for test tone + TTS playback) */
speaker_init();
/* Pass speaker codec handle to CMD executor for real WAV playback */
cmd_exec_set_spk_handle(s_spk_codec);
/* Play test tone to verify audio output */
audio_test_tone();
/* Start mic capture task (reads I2S mic, streams to bridge when active) */
#if CONFIG_BOX3_VOICE_STREAMING
/* Start mic capture task (reads I2S mic, streams to bridge when active).
* Off by default: as a touch gamebook the BOX-3 needs no mic, and the
* continuous codec reads + bridge reconnects interfered with the speaker
* (the first narration clip came out silent) and spammed the log. */
xTaskCreatePinnedToCore(mic_monitor_task, "mic_capture", 4096, NULL, 5, NULL, 1);
#endif
/* Start button handler (BOOT button toggles voice streaming) */
xTaskCreate(button_task, "button", 2048, NULL, 4, NULL);
@@ -412,8 +453,10 @@ void app_main(void)
ESP_LOGI(TAG, "Connecting to WiFi...");
wifi_init_sta();
#if CONFIG_BOX3_VOICE_STREAMING
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
#endif
/* Start the scenario hot-load HTTP server (POST /game/scenario).
* httpd_start binds to all netifs — works as soon as the WiFi STA has an IP. */
@@ -423,7 +466,7 @@ void app_main(void)
/* Phone-less PLIP annex: same REST contract as PLIP_FIRMWARE
* (POST /ring /stop /play, GET /status), ring on the speaker,
* BOOT button as the virtual hook switch. */
if (plip_virtual_init(scenario_server_handle(), s_spk_handle) != ESP_OK) {
if (plip_virtual_init(scenario_server_handle(), s_spk_codec) != ESP_OK) {
ESP_LOGW(TAG, "plip_virtual_init failed — virtual phone unavailable");
} else if (plip_ui_init() != ESP_OK) {
/* REST/ESP-NOW phone still works headless if the UI fails. */
@@ -437,6 +480,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 +498,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
+5 -5
View File
@@ -38,7 +38,7 @@ typedef struct {
char reason[32]; /* "pickup" | "hangup" | "ring_timeout" | "dial:<n>" */
} hook_event_t;
static i2s_chan_handle_t s_spk;
static esp_codec_dev_handle_t s_spk;
static volatile plip_hook_state_t s_state = PLIP_HOOK_ON;
static volatile bool s_ring_stop = false;
static TaskHandle_t s_ring_task = NULL;
@@ -107,7 +107,6 @@ static void ring_burst(int duration_ms)
{
const int total = AUDIO_SAMPLE_RATE * duration_ms / 1000;
int16_t buffer[256];
size_t written = 0;
int idx = 0;
while (idx < total && !s_ring_stop) {
int chunk = (total - idx < 256) ? (total - idx) : 256;
@@ -116,8 +115,9 @@ static void ring_burst(int duration_ms)
buffer[i] = (int16_t) (RING_AMPLITUDE *
sinf(2.0f * (float) M_PI * RING_FREQ_HZ * t));
}
i2s_channel_write(s_spk, buffer, chunk * sizeof(int16_t), &written,
pdMS_TO_TICKS(500));
if (s_spk) {
esp_codec_dev_write(s_spk, buffer, chunk * sizeof(int16_t));
}
idx += chunk;
}
}
@@ -279,7 +279,7 @@ plip_hook_state_t plip_virtual_state(void)
return s_state;
}
esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk)
esp_err_t plip_virtual_init(httpd_handle_t server, esp_codec_dev_handle_t spk)
{
if (!server || !spk) return ESP_ERR_INVALID_ARG;
s_spk = spk;
+2 -2
View File
@@ -16,7 +16,7 @@
#pragma once
#include <stdbool.h>
#include "driver/i2s_std.h"
#include "esp_codec_dev.h"
#include "esp_err.h"
#include "esp_http_server.h"
@@ -34,7 +34,7 @@ typedef enum {
// Register the REST handlers on `server` and keep `spk` for the ring tone.
// Call once after scenario_server_start(); the speaker channel must be
// enabled (speaker_init() in main.c).
esp_err_t plip_virtual_init(httpd_handle_t server, i2s_chan_handle_t spk);
esp_err_t plip_virtual_init(httpd_handle_t server, esp_codec_dev_handle_t spk);
// Forward a BOOT-button press. Returns true when the press was consumed as
// a hook transition (pickup/hangup) — the caller should then skip its own
+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.
*
@@ -17,6 +17,11 @@ idf_component_register(
puzzle_state
media_manager
sd_storage
gamebook
PRIV_REQUIRES
local_puzzles
p7_coffre
p5_morse
p6_nfc
npc_engine
)
@@ -27,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";
@@ -38,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.
@@ -332,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();
@@ -723,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
@@ -754,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;
@@ -767,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];
@@ -794,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);
@@ -917,6 +995,29 @@ static esp_err_t handle_media_play_post(httpd_req_t *req) {
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"}.
@@ -1072,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
@@ -1143,6 +1281,12 @@ 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,
@@ -1155,6 +1299,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
.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
@@ -1219,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));
@@ -1231,6 +1385,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
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
)
+306
View File
@@ -0,0 +1,306 @@
// 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 "esp_heap_caps.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 (256 * 1024) /* expanded books are ~80 KB of JSON */
#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; }
/* PSRAM allocators for cJSON — book trees are large (see gamebook_init). */
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); }
/* 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 = 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;
}
// ── 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)
{
/* Keep the large book trees in PSRAM — an expanded book is ~80 KB of JSON,
* whose parsed node tree would exhaust the internal heap on this firmware. */
static cJSON_Hooks hooks = {
.malloc_fn = gb_malloc,
.free_fn = gb_free,
};
cJSON_InitHooks(&hooks);
/* 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
@@ -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
+4
View File
@@ -23,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
+45
View File
@@ -55,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
@@ -427,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();
@@ -511,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
@@ -527,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
@@ -587,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
+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
)
+131
View File
@@ -0,0 +1,131 @@
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_AUTO_RING
bool "Auto story-ring (rings by itself every 15-30 min)"
default n
help
When enabled, the phone rings on its own at a random 15-30 minute
interval; picking up launches a random audio story straight away
(no menu). If nobody answers within 1 minute the bell stops and the
next ring is rescheduled. Default off — enable for the escape-room
ambience where the phone calls the players.
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
+588
View File
@@ -0,0 +1,588 @@
/*
* 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 "esp_random.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;
/* Auto story-ring: the phone rings on its own at a random interval; picking up
* launches a random audio story straight away. If nobody answers, the bell
* stops after STORY_RING_TIMEOUT_MS and the next ring is rescheduled. */
#define STORY_RING_MIN_MS (15 * 60 * 1000) /* 15 min */
#define STORY_RING_SPAN_MS (15 * 60 * 1000) /* + up to 15 min → 30 max */
#define STORY_RING_TIMEOUT_MS (60 * 1000) /* give up after 1 min */
static volatile bool s_story_ring_armed = false;
static int64_t s_story_ring_start_us = 0;
/* 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)
{
if (s_story_ring_armed) { /* hung up mid auto-ring → silence the bell */
phone_ring_stop();
slic_ring_stop();
s_story_ring_armed = false;
}
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);
}
/* Answered an auto story-ring: stop the bell and launch a random story right
* into STATE_GAMEBOOK (no menu). Drives s_offhook directly because the
* debounced GPIO hook detection is unreliable while the bell rings. */
static void enter_story_from_ring(void)
{
s_story_ring_armed = false;
phone_ring_stop();
slic_ring_stop();
tones_stop();
dialer_reset();
s_offhook = true;
#if CONFIG_PLIP_DIAL_DTMF
dtmf_start();
#endif
audio_pa_set(true);
plip_gamebook_begin_random();
s_state = STATE_GAMEBOOK;
ESP_LOGI(TAG, "story-ring pickup -> GAMEBOOK (random story)");
}
#if CONFIG_PLIP_AUTO_RING
/* Rings the phone at a random 1530 min interval. Only rings when the line is
* truly idle (on-hook, no other call/ring armed) and a story pack is present.
* The 1-minute no-answer timeout is handled in conv_task. */
static void story_ring_task(void *arg)
{
(void)arg;
for (;;) {
uint32_t wait_ms = STORY_RING_MIN_MS + (esp_random() % STORY_RING_SPAN_MS);
ESP_LOGI(TAG, "auto story-ring: next attempt in %u min",
(unsigned)(wait_ms / 60000));
vTaskDelay(pdMS_TO_TICKS(wait_ms));
if (s_state == STATE_IDLE && !s_offhook && !s_incoming_armed
&& !s_story_ring_armed && plip_gamebook_available()) {
s_story_ring_armed = true;
s_story_ring_start_us = esp_timer_get_time();
phone_ring_start();
ESP_LOGI(TAG, "auto story-ring: ringing (up to %d s)",
STORY_RING_TIMEOUT_MS / 1000);
} else {
ESP_LOGI(TAG, "auto story-ring: line busy/unavailable — skipped");
}
}
}
#endif /* CONFIG_PLIP_AUTO_RING */
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_story_ring_armed) {
/* Answered an auto story-ring → straight into a story. */
enter_story_from_ring();
} else 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();
} else if (s_story_ring_armed) {
/* Same raw-SLIC poll for the auto story-ring, plus the
* no-answer timeout: stop the bell after 1 min unanswered. */
if (slic_is_offhook()) {
enter_story_from_ring();
} else if ((esp_timer_get_time() - s_story_ring_start_us) / 1000
>= STORY_RING_TIMEOUT_MS) {
phone_ring_stop();
slic_ring_stop();
s_story_ring_armed = false;
ESP_LOGI(TAG, "auto story-ring: no answer after %d s — stop",
STORY_RING_TIMEOUT_MS / 1000);
}
}
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
#if CONFIG_PLIP_AUTO_RING
/* Auto story-ring scheduler: rings every 1530 min when idle. */
xTaskCreate(story_ring_task, "storyring", 3072, NULL, 3, 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
+190
View File
@@ -0,0 +1,190 @@
// 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 "esp_random.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_begin_random(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
int idx = (int)(esp_random() % (uint32_t)s_lib_n);
ESP_LOGI(TAG, "auto-start random story #%d/%d", idx, s_lib_n);
load_book(idx); // straight into the story, no 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");
}
+48
View File
@@ -0,0 +1,48 @@
#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);
// Démarre directement UNE histoire tirée au hasard (sans passer par le menu) —
// utilisé quand le téléphone sonne tout seul et qu'on décroche.
void plip_gamebook_begin_random(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,
+61
View File
@@ -0,0 +1,61 @@
# 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
# Speaker at max (earpiece)
CONFIG_PLIP_SPEAKER_VOLUME=100