Compare commits

...

146 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Gate playback on the real SHK line (slic_is_offhook) instead — the
single source of truth. Remove all hook forcing (s_hook_override,
phone_force_offhook, /debug/offhook): the SHK contact is reliable in
hardware, so the firmware must trust it, never override it. Builds
clean (ESP-IDF v5.4.4).
2026-06-17 09:01:48 +02:00
clement 99d79efcdd tune(plip): +24dB mic PGA + VAD thresholds
CI / platformio (push) Failing after 11m27s
2026-06-16 19:18:52 +02:00
clement 8dffe14972 fix(plip): mute earpiece during capture 2026-06-16 19:18:45 +02:00
clement d71453d32e fix(plip): report real state in /status 2026-06-16 19:18:38 +02:00
clement ccc84da785 tune(plip): default speaker volume 98%
CI / platformio (push) Failing after 15m51s
2026-06-16 10:32:11 +02:00
clement 1561e613e1 feat(plip): incoming call + fast half-duplex
- /debug/ring?number=NN arms incoming call, starts ring
- conversation_arm_incoming() + enter_incoming_greet()
- offhook with armed call skips dial/ringback, goes to GREET
- slic_is_offhook() used during ring (GPIO debounce unreliable)
- CAPTURE_SILENCE_MS 800->600 ms, settles 250->120, 200->100 ms
2026-06-16 10:32:01 +02:00
clement 4a6fc435a7 fix(plip): ES8388 undoc DLL regs for 16kHz ADC
CI / platformio (push) Failing after 13m6s
2026-06-16 09:32:36 +02:00
clement ebcfb011d3 feat(plip): /debug/es8388 register poke endpoint
CI / platformio (push) Failing after 11m53s
Add es8388_write_reg() public API and GET /debug/es8388?reg=&val=
HTTP handler to read/write codec registers at runtime without
reflashing. Bumps max_uri_handlers 16→17.
2026-06-16 08:40:59 +02:00
clement 3c0eb75465 fix(plip): robust WiFi on mesh/DFS network
STA config: channel=0 (all-channel scan), WIFI_ALL_CHANNEL_SCAN,
WIFI_CONNECT_AP_BY_SIGNAL, failure_retry_cnt=5.
Disconnect handler: retry on every disconnect event, including during
initial association — previously the first failure at boot caused an
immediate abort and a ~30s timeout before IP was acquired.
Validated on hardware: connects reliably on ch1, RSSI -32, IP in ~2.5s.
2026-06-15 22:27:01 +02:00
clement aa7ae277ed feat(plip): voice loop + DTMF + ring cadence
CI / platformio (pull_request) Failing after 4m0s
CI / platformio (push) Failing after 14m58s
- hook polarity active-HIGH + auto-resync (was LOW)
- ring cadence FT 1.5s ON / 3.5s OFF
- DTMF Goertzel decoder (dtmf.c/h) + rotary debounce
- LISTEN half-duplex: capture → /v1/voice/reply → play
- WAV playback buffered PSRAM + mono→stereo upmix
- SPIFFS mount at boot for pre-loaded greetings
- ES8388: DAC digital vol + mic PGA + GPIO INPUT_OUTPUT
- turn_client multipart + 90s timeout + fixed routing
- debug endpoints: vol/dacvol/offhook/getfile/hookmon
2026-06-15 21:12:33 +02:00
electron 37db47ad7b Merge pull request 'fix(plip): ES8388 ADC input LIN2/RIN2 — SLIC handset mic works' (#24) from fix/plip-adc-lin2 into main
CI / platformio (push) Failing after 13m12s
2026-06-15 07:51:27 +00:00
clemsail f03156b65e fix(plip): ES8388 ADC input LIN2/RIN2 — SLIC handset mic (voice captured, was reading floating LIN1)
CI / platformio (pull_request) Failing after 3m54s
2026-06-15 09:51:15 +02:00
electron b36b703f34 Merge pull request 'fix(plip): SLIC hook polarity active-LOW + faithful PD open-drain' (#23) from feat/plip-hook-polarity into main
CI / platformio (push) Has been cancelled
2026-06-15 07:43:54 +00:00
clemsail a209b3b86c fix(plip): SLIC hook polarity active-LOW (K50835F open-collector) + faithful PD open-drain
CI / platformio (pull_request) Failing after 11m29s
2026-06-15 09:43:44 +02:00
electron 8b0bd2c262 Merge pull request 'feat(plip): SLIC integration + I2S RX mic fix + /debug/ring,slic' (#22) from feat/plip-slic-integration into main
CI / platformio (push) Failing after 5m56s
2026-06-14 23:01:39 +00:00
clemsail 54d1a1ec6e fix(plip): I2S full-duplex silently broken by mismatched TX/RX gpio_cfg — ES8388 ADC/I2S RX capture path
CI / platformio (pull_request) Failing after 4m10s
IDF5 i2s_channel_init_std_mode() constitutes full-duplex ONLY when TX and
RX std_cfg are byte-for-byte identical (memcmp). When din/dout differ
between the two calls, the driver silently moves RX to I2S_NUM_1 which has
no BCLK/WS routing, producing permanent zeros on i2s_channel_read().

Fix: use the same i2s_std_config_t for both TX and RX init calls, with
dout=GPIO26 and din=GPIO35 both set. IDF handles GPIO direction internally.

Also clean up ES8388 register sequence:
- ADCCONTROL2 = 0x00 (LIN1/RIN1 differential, LINE IN header)
- ADCCONTROL6 = 0x00 (clear ADCSMUTE, reset default was 0x30)
- ADCPOWER = 0x00 (full ADC power-up, was 0x09)
- DACCONTROL21 = 0x80 (DAC+ADC normal mode, not line bypass 0xC0)

Verified: peak=2593, rms=2482 over 48000 samples (3s @ 16kHz).
2026-06-15 00:57:02 +02:00
clemsail ccbe5720b0 fix(plip): SLIC PD push-pull HIGH (definite power-up) + /debug/slic gpio readback 2026-06-15 00:49:30 +02:00
clemsail 47fbc09fea fix(plip): ES8388 ADC unmute (ADCCONTROL6=0x00) + full ADC power-up (ADCPOWER=0x00)
ADCCONTROL6 (reg 0x0E) reset default = 0x30 which has ADCSMUTE=1 (bit5) — ADC output
muted by default. Writing 0x00 unmutes. Without this, ADCINSEL=0x50 (LIN2) is selected
but the signal is suppressed at the ADC output stage → peak=0.

ADCPOWER (reg 0x03) changed from 0x09 (intermediate Espressif open state) to 0x00 (full
power-up: all power-down bits cleared). Value 0x09 = bits 0+3 set (ADCPD_L + MICBIAS_PD)
— MICBIAS_PD in particular means the internal microphone bias is powered off, which can
starve the SLIC line-in path. 0x00 is the correct end state for recording mode per
Espressif esp8388_start(ES_MODULE_ADC) reference.

sdkconfig.defaults: add CONFIG_PLIP_HOOK_GPIO=23 / CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
as explicit defaults so clean builds use the SLIC SHK pin without menuconfig.
2026-06-15 00:36:43 +02:00
clemsail 0f7047215b feat(plip): ES8388 ADC line-in (LINPUT2/RINPUT2) for SLIC audio
ADCCONTROL2 (reg 0x0A): was 0x00 (LINSEL=00 LINPUT1/RINPUT1 — onboard PCB mic),
now 0x50 (LINSEL=01 LINPUT2 / RINSEL=01 RINPUT2 — telephone handset mic via SLIC).

The SLIC K50835F routes the handset mic signal to the ES8388 LINPUT2/RINPUT2 pins.
Writing 0x00 meant the ADC was listening to the empty onboard mic, producing 0/0 RMS.
Writing 0x50 connects the SLIC audio path, enabling voice capture from the handset.

PGA stays at +24 dB (ADCCONTROL1 = 0xBB). ADC power-up sequence unchanged.
2026-06-15 00:26:49 +02:00
clemsail f531bf3e55 feat(plip): SLIC control — power-up (PD OD-HIGH), hook on SHK GPIO23, ring RM/FR
- board_config.h: add PLIP_SLIC_RM=18, FR=5, SHK=23, PD=19 (A1S KEY3-6 repurposed)
- slic.c/slic.h: new ESP-IDF module porting Ks0835SlicController:
    * slic_init(): RM/FR output LOW, SHK input+pullup, PD open-drain HIGH (power-up)
    * slic_is_offhook(): reads SHK GPIO23, HIGH = off-hook (active-high, matches A252ConfigStore default)
    * slic_ring_start/stop(): RM HIGH + FreeRTOS task toggles FR at 25 Hz (20 ms period)
- CMakeLists.txt: add slic.c to SRCS, esp_driver_gpio to PRIV_REQUIRES
- Kconfig: PLIP_HOOK_GPIO default 4→23, add PLIP_HOOK_ACTIVE_HIGH (default y)
- phone.c: hook reads SHK GPIO23 via HOOK_OFFHOOK_LEVEL/HOOK_PULSE_OPEN macros (active-HIGH);
    phone_ring_start/stop() now drives slic_ring_start/stop() for physical bell + audio tone
- main.c: slic_init() called early in boot_task before audio_init

Root cause fixed: SLIC was never powered (PD never released from reset state).
Hook was read on wrong GPIO (4) with wrong polarity. Ring drove only audio, not bell.
2026-06-15 00:26:35 +02:00
clemsail 87075ba92b feat(plip): /debug/ring + /debug/ringstop — trigger ringer over HTTP 2026-06-14 23:24:36 +02:00
electron 75d6aaf6d4 Merge pull request 'feat(plip): téléphone stage 2 — turn_client + GREET (accueil PNJ depuis gateway)' (#21) from feat/plip-phone-stage2 into main
CI / platformio (push) Failing after 3m51s
2026-06-14 20:45:00 +00:00
clemsail c740bfe20a feat(plip): turn_client + GREET state — fetch & play NPC greeting from gateway /v1/voice/turn (stage 2)
CI / platformio (pull_request) Failing after 11m23s
2026-06-14 22:43:46 +02:00
electron e68e9f5f66 Merge pull request 'feat(plip): téléphone France Télécom — stage 1 (tonalités + dialer + machine à états)' (#20) from feat/plip-phone-stage1 into main
CI / platformio (push) Failing after 4m23s
2026-06-14 20:17:25 +00:00
clemsail 2710965741 fix(plip): synchronous tones_stop (I2S arbitration), MAX_DIGITS 12, drop s_state cross-task write, pulse guard
CI / platformio (pull_request) Failing after 4m21s
2026-06-14 22:15:53 +02:00
clemsail b92f138f41 feat(plip): conversation state machine — dialtone/dialing/routing/ringback/busy (stage 1) 2026-06-14 22:01:33 +02:00
clemsail 4621f451be feat(plip): rotary pulse dialing on SHK GPIO 2026-06-14 21:57:14 +02:00
clemsail e819eae5a5 feat(plip): dialer module + /debug/dial (digit accumulator) 2026-06-14 21:55:02 +02:00
clemsail ba248e92fe feat(plip): tones module — France Télécom dial/busy/ringback 2026-06-14 21:53:50 +02:00
electron 39f4cd2313 Merge pull request 'feat(plip): mute audio output when handset on-hook' (#19) from feat/plip-mute-onhook into main
CI / platformio (push) Failing after 3m57s
2026-06-14 18:45:03 +00:00
clemsail b8ba1457fb feat(plip): mute audio output when handset on-hook (gate playback on hook state)
CI / platformio (pull_request) Failing after 3m41s
2026-06-14 20:43:33 +02:00
electron 7718f4653d Merge pull request 'feat(plip): streaming file upload + playback (MAX_BODY 256K, full TTS phrases)' (#18) from feat/plip-maxbody-256k into main
CI / platformio (push) Failing after 11m35s
2026-06-14 18:30:47 +00:00
clemsail f7104b4180 feat(plip): bump /game/file MAX_BODY 64K->256K for full TTS phrases
CI / platformio (pull_request) Failing after 3m46s
2026-06-14 20:29:25 +02:00
electron b748c2d75f Merge pull request 'feat(plip): I2S RX mic capture + POST /voice/capture (STT input leg) + ES8388 ADC register fix' (#17) from feat/plip-mic-capture into main
CI / platformio (push) Failing after 4m20s
2026-06-14 16:53:01 +00:00
clemsail dd3d239784 feat(plip): I2S RX mic capture + POST /voice/capture (WAV) — STT input leg
CI / platformio (pull_request) Failing after 4m5s
- audio.c: add full-duplex I2S RX channel (s_mic_handle, GPIO35/ASDOUT)
  enabled at boot alongside TX; streaming capture API with VAD
  (audio_capture_begin/read_frame/end) avoids large buffer allocation
- audio.h: export streaming capture API
- es8388.c: fix ADC register addresses (ADCCONTROL2=0x0A, 3=0x0B, 4=0x0C,
  5=0x0D, 8=0x10, 9=0x11 — were off by 1 from Espressif reference);
  CHIP_CTL1=0x12 (record+play), DACCONTROL21=0xC0 (ADC+DAC LRCK sync),
  ADCPOWER=0x09, FSM restart pulse (CHIPPOWER 0xF0→0x00) after CTL21 write;
  fix set_volume() to use OUT1 volume regs (0x2E/0x2F) not clock-sync reg 0x2B
- net.c: POST /voice/capture streaming WAV handler (chunked, 44-byte header
  + PCM frames, VAD onset/silence, query params max_ms/silence_ms);
  GET /debug/regs for live ES8388 register dump (diagnostic aid)
- board_config.h: PLIP_I2S_DIN=GPIO35 (ES8388 ASDOUT)

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

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

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

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

Build: IDF 5.4.4, target esp32, clean.
Flash: /dev/cu.usbserial-0001, 4MB flash verified.
2026-06-14 13:29:33 +02:00
electron bbe79deb07 Merge pull request 'feat(box3): POST /game/file over HTTP' (#12) from feat/box3-game-file into main
CI / platformio (push) Failing after 17m50s
2026-06-14 11:09:43 +00:00
clemsail 935f86d844 feat(box3): POST /game/file — write assets to SD over HTTP (WiFi-direct)
CI / platformio (pull_request) Failing after 4m26s
2026-06-14 12:58:45 +02:00
electron 29fa8eed34 Merge pull request 'feat(box3): real screen render + real WAV/I2S play in cmd_exec' (#11) from feat/box3-screen-play-real into main
CI / platformio (push) Failing after 6m5s
2026-06-14 10:02:50 +00:00
clemsail 3e702f6fd8 feat(box3): real screen rendering + WAV I2S playback for CMD executor
CI / platformio (pull_request) Failing after 9m25s
Objective 1 — screen: full-scene faithful rendering on 320x240 LVGL display.
Palette matches idf_zacus display_ui reference: bg #0055AA (Workbench blue),
symbol #FF8800 (orange / font-48), title #FFFFFF (font-24 top), subtitle
#AAAAAA (font-14 bottom). All four effects implemented:
  - pulse: LVGL anim opacity COVER→50 breathing on symbol (600ms half-period)
  - glitch: lv_timer 120ms flicker + X-jitter on title
  - gyro: lv_arc rotating ring around symbol (1200ms full rotation)
  - none: static

Objective 2 — play: real WAV PCM-16 decode + I2S streaming.
  - SD card mounted best-effort via bsp_sdcard_mount() on first play CMD
  - WAV header parser (chunk-walker, tolerant of non-standard ordering)
  - PCM samples streamed via i2s_channel_write (same s_spk_handle as TTS)
  - Embedded test cue (C5-E5-G5, 570ms, 16kHz mono, ~18 KB) baked into
    embedded_wav.h — proves real WAV decode + I2S path without SD card
  - Graceful fallback chain: SD file → embedded cue → 880 Hz beep

main.c: cmd_exec_set_spk_handle() called after speaker_init() to pass the
I2S TX channel handle to the CMD executor.

Tested on ESP32-S3-BOX-3 (/dev/cu.usbmodem11301):
  - All four screen effects confirmed via serial log + visual observation
  - play embedded://cue streams 18240 bytes PCM at 16kHz confirmed
  - SD mount succeeds (card present), file not found → embedded fallback OK
  - No crashes, existing voice pipeline and scenario server unaffected
2026-06-14 12:01:31 +02:00
electron 0b530e70e0 Merge pull request 'feat(box3): ESP-NOW CMD executor (D5 contract)' (#10) from feat/box3-cmd-executor into main
CI / platformio (push) Failing after 9m39s
2026-06-14 09:33:59 +00:00
clemsail 735aaed648 feat(box3): implement ESP-NOW CMD executor (D5 contract)
CI / platformio (pull_request) Failing after 4m36s
Add cmd_exec.c / cmd_exec.h providing cmd_exec_handle() — a tolerant
cJSON-based dispatcher for structured ESP-NOW CMD frames per the D5
wire contract ({"op":"<verb>","a":{<args>}}, ≤200 bytes).

Supported ops (all logged):
  screen  — renders title/subtitle/symbol on an LVGL full-screen
             overlay via bsp_display_lock; "pulse"/"flash" effect
             tints the background blue.
  play    — logs the path + loop flag, emits an 880 Hz beep via
             audio_play_tone() (no media_manager on box3_voice).
  evt     — logs event name (card→master direction, unusual here).
  led     — logs pattern (stub, no LED ring on box3_voice).
  unknown — ESP_LOGW + ESP_ERR_NOT_SUPPORTED, no crash.

Wire-up in main.c:
  on_mesh_text() callback installed via scenario_mesh_set_text_cb()
  immediately after scenario_mesh_init(). Every SCENARIO_MESH_TEXT_CMD
  frame is decoded and dispatched; EVT frames are logged and ignored.
  cmd_exec.c added to CMakeLists.txt SRCS.

Channel fix in sdkconfig.defaults:
  Document that CONFIG_ZACUS_WIFI_CHANNEL must match the master's
  connected WiFi channel (lab: ch11) for ESP-NOW co-channel operation.

Tested on hardware (BOX-3 MAC 90:e5:b1:cc:05:f8, /dev/cu.usbmodem11301):
  - Build: 2104/2104 targets, 0 errors, 0 warnings on new files.
  - Flash: idf.py -p /dev/cu.usbmodem11301 flash — Done.
  - Serial proof:
      I (10115) zacus-voice: ESP-NOW CMD executor registered (op: screen/play/evt/led)
      I (10115) cmd_exec: CMD op=screen t="ZACUS" u="" y="LA" e="pulse"
      I (13327) cmd_exec: CMD op=play path="/spiffs/ambiance.wav" loop=0 (beep fallback)
      W (16497) cmd_exec: CMD op="explode" unknown — ignored
    All three via POST /game/espnow/cmd on master ({"ok":true,"status":"ESP_OK"}).
2026-06-14 11:20:45 +02:00
electron 7f52d3d759 Merge pull request 'feat(P4): media_manager vrai playback WAV + /game/media/play' (#9) from feat/p4-media-wav-playback into main
CI / platformio (push) Failing after 3m27s
2026-06-13 14:44:29 +00:00
electron c7485f272f Merge pull request 'fix(box3): SPIFFS mount + canal WiFi configurable (relais ESP-NOW)' (#8) from fix/box3-spiffs-channel into main
CI / platformio (push) Has been cancelled
2026-06-13 14:44:13 +00:00
clement fcb872c226 feat(p4): décodeur MP3 (helix) + mutex I2S TTS↔musique + FATFS LFN
CI / platformio (pull_request) Failing after 10m4s
- voice_pipeline : mutex play_lock pris dans play_start / rendu dans play_end
  (+ tous chemins d'erreur). Sérialise TTS et musique sur le canal I2S TX
  partagé ; un play_start concurrent attend 2 s puis renvoie BUSY.
- media_manager : décodage MP3 via helix (chmorgan/esp-libhelix-mp3). Refactor
  playback_task en dispatcher d'extension → stream_wav / stream_mp3. stream_mp3
  décode frame par frame (buffers heap), downmix stéréo→mono, volume, feed I2S.
  Stack tâche 5120→8192 (le décodeur consomme de la pile).
- sdkconfig.defaults : CONFIG_FATFS_LFN_HEAP — les assets SD ont des noms > 8.3
  (SCENE_WIN.mp3, sonar_hint.mp3…) ; sans LFN fopen échoue ("open failed").

Validé sur Freenove : upload SCENE_WIN.mp3 (510 Ko, stéréo) sur /sdcard →
play → helix décode 22050 Hz 2ch → downmix → I2S MAX98357A. win.mp3 22050 mono
idem. Mutex : TTS et musique ne peuvent plus se chevaucher sur le DAC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:42:13 +02:00
clement 8a58c7d92b feat(p4): brancher media_manager + /game/file sur /sdcard
CI / platformio (pull_request) Failing after 3m39s
- media_manager resolve_play_path : un chemin relatif préfère désormais
  /sdcard/music/<f> quand il existe (asset store SD), fallback LittleFS.
  Les chemins absolus (/sdcard/... | /littlefs/...) passent inchangés.
- /game/file : route le préfixe `sd/<…>` → /sdcard/<…> (≤8 Mio, exige la
  carte montée) en plus de `apps/<…>` → /littlefs/apps/ (≤256 Kio).
- sd_storage.h : include <stdint.h> (uint32_t).

Validé sur Freenove : POST /game/file?path=sd/music/cue440.wav → écrit sur
la microSD ; POST /game/media/play {path:"cue440.wav"} → résolu en
/sdcard/music/cue440.wav, 32000 B streamés en ~1005 ms au MAX98357A.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:28:16 +02:00
clement a8f0210567 feat(p4): montage microSD (SDMMC 1-bit) sur /sdcard
CI / platformio (pull_request) Failing after 6m0s
Nouveau composant sd_storage : monte la carte microSD du Freenove en SDMMC
1-bit (CMD=38, CLK=39, D0=40 — pinout FREENOVE_SDMMC_* du firmware Arduino)
sur /sdcard via esp_vfs_fat_sdmmc_mount. Best-effort au boot : carte absente
= non fatal, fallback LittleFS. Lève le plafond LittleFS 5 Mo pour les assets.

Validé sur Freenove : carte ~3839 MiB montée à /sdcard au boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:49:44 +02:00
clement c35c9673f4 feat(p4): media_manager — vrai playback WAV + endpoint /game/media/play
CI / platformio (pull_request) Failing after 3m32s
Remplace le stub de media_manager_play() (qui simulait 2 s) par un vrai
streaming audio :
- parse_wav_header : RIFF/WAVE 16-bit PCM mono/stéréo, positionne sur data.
- playback_task : lit le WAV par chunks et le pousse au MAX98357A via
  l'I2S TX de voice_pipeline (play_start/chunk/end), avec downmix stéréo→mono
  et volume logiciel. Tâche dédiée (non bloquante), stop coopératif.
- media_manager_stop() attend la fin de la tâche.

Ajoute POST /game/media/play {path} dans game_endpoint pour déclencher une
lecture (gateway/atelier → cue audio), sans passer par une décision NPC.

Validé sur Freenove : upload WAV via /game/file → POST /game/media/play →
play_start sr=16000 → 32000 B streamés en ~1006 ms (cadence DMA temps réel,
= durée du fichier) → play_end. MP3 + montage SD = slices P4 suivantes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:31:54 +02:00
clement 2517cef7db fix(box3): SPIFFS mount + canal WiFi configurable pour relais ESP-NOW
CI / platformio (pull_request) Failing after 3m54s
scenario_server.c : SPIFFS_LABEL valait "storage" alors que la partition
s'appelle "spiffs" (partitions.csv) → esp_vfs_spiffs_register échouait en
ESP_ERR_NOT_FOUND, la BOX-3 ne pouvait persister aucun scénario reçu (HTTP
500 + relais ESP-NOW sans effet). Corrigé en "spiffs".

main.c + Kconfig.projbuild : ajout de CONFIG_ZACUS_WIFI_CHANNEL (défaut
0 = auto) câblé sur wifi_config.sta.channel. Sur réseau multi-AP / mesh
(même SSID sur plusieurs canaux), permet de forcer la BOX-3 co-canal avec
le master — prérequis ESP-NOW.

Validé sur matériel : master (ch6) → POST /game/scenario/relay → la BOX-3
reçoit + réassemble (679 B) via scenario_mesh, monte SPIFFS, hot-load OK,
reboot pour appliquer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:04:05 +02:00
Claude Worker claude2 7893c32bf6 feat(espnow): canal texte CMD/EVT + endpoint debug
Frames courtes commandes/evenements sur la pile scenario_mesh
(sentinel seq=0xFFFF + kind, retrocompatible : les anciens
recepteurs les jettent via le check malformed). API additive
send_text/set_text_cb (dispatch hors callback WiFi via tache
dediee). Master : POST /game/espnow/cmd {peer|broadcast,command}
+ log des CMD/EVT recus. Teste sur FNK0102H : broadcast ESP_OK,
unicast sans recepteur ESP_ERR_TIMEOUT, alias inconnu 404.
Spec : docs/superpowers/specs/2026-06-11-espnow-cmd-evt-design.md
2026-06-11 05:47:20 +02:00
Claude Worker claude2 31dcb28c62 fix(espnow): compteur peers reel + alias UAF
scenario_mesh_peer_count() cable sur le stub sante (espnow_peers
restait a 0) ; reponse POST /game/peers construite avant
cJSON_Delete (use-after-free observe sur carte : alias illisible).
Teste sur FNK0102H : register live + NVS recharge au boot
(espnow_peers=2), relay -> timeout/unknown_peer attendus.
2026-06-11 05:18:55 +02:00
Claude Worker claude2 f5d8134cee fix(intro): horloge intro en temps reel
Sur carte, la boucle display (vTaskDelay 10 ms + rendu LVGL)
tourne sous 100 Hz : le dt fixe de 10 ms faisait trainer
l'horloge virtuelle derriere la deadline murale et la phase FX
ne demarrait jamais. Mesure du dt reel par delta de ticks
(borne 100 ms) + log de l'ordre des FX tires. Verifie sur
FNK0102H : intro fx order=0,2,3 a t=9.6s, 35 s sans panic.
2026-06-11 05:03:03 +02:00
Claude Worker claude2 9c133842a4 feat(intro): pool FX 3D complet + tirage par boot
Ajout des 3 dernieres scenes 3D de l'original : starfield 3D
(vol en z + trainees), voxel landscape (heightfield raycast) et
wirecube v9 (cube filaire Bresenham). L'intro tire 3 effets
distincts au hasard parmi les 6 a chaque boot (Fisher-Yates),
sans rallonger les 16 s. Build vert.
2026-06-11 04:54:17 +02:00
Claude Worker claude2 1faba591d5 feat(p1): log notes detectees + progression
Trace serie du bout-en-bout melodie : note MIDI entendue,
progression pos/count du validateur, marqueur (reset) sur fausse
note, ligne dediee a la completion. Prerequis documente au test
physique P1.
2026-06-11 04:47:53 +02:00
Claude Worker claude2 ea0e07acbe feat(intro): port 3D FX phases B/C/D
Rotozoom, dot sphere et ray corridor portes depuis le FxEngine
Arduino (boucles de rendu seules, sans CapsAllocator ni timelines).
Rendu 240x160 RGB565 en PSRAM double en pixels vers un lv_canvas
plein ecran derriere logo+scroller ; phases A 7s puis 3x3s ;
fallback starfield si l'alloc PSRAM echoue. Build vert (35% libre).
2026-06-11 04:45:56 +02:00
Claude Worker claude2 898808c396 docs: top README points to idf_zacus + box3 2026-06-11 02:58:10 +02:00
Claude Worker claude2 a7463464c2 docs: correct sensor comment to OV3660 2026-06-11 02:57:05 +02:00
Claude Worker claude2 080581f39f Merge qr fixes: adaptive geometry + mirror + QVGA 2026-06-11 02:56:47 +02:00
Claude Worker claude2 6d7b670256 docs: refresh firmware READMEs (puzzles+UI+REST) 2026-06-11 02:56:47 +02:00
Claude Worker claude2 fac94d5885 fix(qr): QVGA viewfinder, mirror, sensor tuning 2026-06-11 02:43:51 +02:00
Claude Worker claude2 ed336967a1 feat(box3): QR size + dim screen for camera 2026-06-11 02:43:51 +02:00
Claude Worker claude2 132b88337b fix(qr): adaptive frame size + OV3660 tuning 2026-06-11 02:03:13 +02:00
Claude Worker claude2 859c975654 feat(box3): larger QR for camera decode 2026-06-11 02:03:13 +02:00
Claude Worker claude2 087dbd3c03 feat(box3): QR + melody stimulus generator 2026-06-10 23:15:33 +02:00
Claude Worker claude2 e8125a20e5 Merge feat/freenove-local-puzzles: local puzzles + scenario hook + UI port 2026-06-10 23:06:04 +02:00
Claude Worker claude2 0e4cea9c39 feat(game): POST /game/file provisions apps 2026-06-10 22:46:05 +02:00
Claude Worker claude2 fd1a517989 feat(ui): cracktro intro + file browser + apps 2026-06-10 22:38:44 +02:00
Claude Worker claude2 563dfea368 feat(ui): Workbench shell + boot zoom intro 2026-06-10 22:20:28 +02:00
Claude Worker claude2 a552e1976e feat(ui): 5-way buttons (view + brightness) 2026-06-10 22:09:47 +02:00
Claude Worker claude2 d0f217f5d9 feat(ui): scene IR metadata + fx glitch/gyro 2026-06-10 22:01:17 +02:00
Claude Worker claude2 29c0eecfb9 fix(game): avoid littlefs double-register noise 2026-06-10 21:45:13 +02:00
Claude Worker claude2 ff2ab2abb5 feat(ui): live QR viewfinder in scene view 2026-06-10 21:40:58 +02:00
Claude Worker claude2 2de7b20cd8 feat(ui): scene view + original fonts, 3MB apps 2026-06-10 21:28:31 +02:00
Claude Worker claude2 21b06665e8 feat(ui): LVGL layer + PWM backlight 2026-06-10 21:20:45 +02:00
Claude Worker claude2 9ac287c843 feat(ui): ST7796 status screen via LovyanGFX 2026-06-10 21:06:16 +02:00
Claude Worker claude2 0aa9271038 feat(game): step arming + puzzle state REST 2026-06-10 19:44:10 +02:00
Claude Worker claude2 b66932c800 feat(game): parse step puzzle binding from IR 2026-06-10 19:33:53 +02:00
Claude Worker claude2 e216490bf3 feat(local): arm API carries id + fragment 2026-06-10 19:23:24 +02:00
Claude Worker claude2 8eab308185 feat(local): wire local puzzles into master 2026-06-10 16:13:19 +02:00
Claude Worker claude2 d715d45b34 feat(local): sound puzzle via mic broker 2026-06-10 15:53:27 +02:00
Claude Worker claude2 b6a9d62d9a feat(local): QR puzzle via camera + quirc 2026-06-10 15:42:48 +02:00
Claude Worker claude2 0f0d8405f7 feat(local): mic broker; voice_pipeline uses it 2026-06-10 15:29:10 +02:00
Claude Worker claude2 275197139c feat(master): puzzle state aggregation logic 2026-06-10 14:33:33 +02:00
Claude Worker claude2 96f3a9ad65 feat(local): melody validator + tests 2026-06-10 14:24:15 +02:00
Claude Worker claude2 31c739fd82 feat(local): QR sequence validator + tests
Ordered-scan validator for QR puzzle sequences with host-based
Unity test harness. Tests cover: correct order acceptance, reset on
wrong scan, and duplicate-of-last handling.
2026-06-10 14:17:58 +02:00
Claude Worker claude2 d9ba124d16 docs(local): freeze Media Kit v1.2 pin map 2026-06-10 13:56:59 +02:00
Claude Worker claude2 10af7b28b2 chore: ignore .worktrees/ 2026-06-10 13:49:10 +02:00
electron 585af362f7 Merge pull request 'fix(box3): BSP esp-box-3 + rétroéclairage' (#7) from feat/idf-migration into main
CI / platformio (push) Failing after 11m6s
2026-06-10 11:21:59 +00:00
Claude Worker claude2 87046eadf0 fix(box3): BOX-3 BSP + backlight
CI / platformio (pull_request) Failing after 9m37s
The project depended on espressif/esp-box (first-gen BOX: ST7789 +
TT21100 touch), which mis-initialises a real ESP32-S3-BOX-3 (ILI9341 +
GT911) and leaves the screen black. Switch the dependency to
espressif/esp-box-3 and use the generic bsp/esp-bsp.h header.

Also: the voice app started the display but never lit the backlight
("LCD backlight must be enabled separately" per the BSP). plip_ui_init
now calls bsp_display_brightness_init()/set(100) and uses a dark-blue
background with white text so the UI is actually visible.

Resolves clean (esp-box-3 3.2.0, esp_lcd_ili9341 2.0.2,
esp_lcd_touch_gt911 1.2.0); build green.
2026-06-10 13:20:59 +02: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
Claude Worker claude2 b812e059da feat(box3): on-screen phone with touch keypad
CI / platformio (pull_request) Failing after 4m27s
plip_ui.{h,c}: LVGL phone on the BOX-3 LCD driven by the plip_virtual
hook state. Idle/ringing shows a phone glyph + status ("Appel
entrant…"); off-hook shows a 3x4 dial keypad (1-9,*,0,#) with a live
number field. A fixed bottom touch button toggles the hook —
"Décrocher" (green) while ringing, "Raccrocher" (red) off-hook,
disabled when idle. Dialed digits are reported to the master
(reason "dial:<n>") and surfaced in GET /status.

plip_virtual gains the public pickup/hangup/dial API, a state
callback for the UI, and a state getter; enables Montserrat 24/48.

Build green; flashed and verified on a real ESP32-S3-BOX-3.
2026-06-10 12:43:20 +02: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
Claude Worker claude2 5c97013f8c feat(box3): virtual PLIP annex (phone-less)
CI / platformio (pull_request) Failing after 14m53s
New main/plip_virtual.{h,c}: implements the PLIP REST contract on the
existing port-80 httpd (POST /ring {duration_ms} with French cadence
1.5s/3.5s 440 Hz on the speaker, POST /stop, GET /status, POST /play
501 — audio flows through the WS TTS path). The BOOT button becomes
the virtual hook switch when ringing/off-hook (falls back to the
streaming toggle otherwise) and every transition is reported to the
master POST <ZACUS_MASTER_URL>/voice/hook {state,reason}, the exact
zacus_hook_client contract — the master cannot tell the annexes apart.
New Kconfig ZACUS_MASTER_URL (default 10.2.5.42). scenario_server
exposes its httpd handle for co-registration.

Build green (zacus-box3-voice.bin).
2026-06-10 12:28:36 +02: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
Claude Worker claude2 185d1186fe feat(master): GET/POST /game/peers endpoint
CI / platformio (pull_request) Failing after 14m56s
HTTP management of the ESP-NOW relay peer registry — replaces the
desktop NvsConfigurator round-trip. POST {alias, mac} persists to the
NVS "peers" namespace (the exact format main.c seeds at boot) and
registers the peer live in the scenario_mesh table (no reboot). GET
lists the registry. Provisioning PLIP becomes:
  curl -X POST master/game/peers -d '{"alias":"plip","mac":"..."}'

Build green (zacus_master.bin).
2026-06-10 11:55:23 +02: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
Claude Worker claude2 7fe0bd2af6 chore: drop sdkconfig.old from tracking
CI / platformio (pull_request) Failing after 4m11s
idf.py set-target regenerates these backups on every target switch;
they slipped into the previous commit. Ignore them like sdkconfig.
2026-06-10 10:51:31 +02:00
Claude Worker claude2 13bbb56244 refactor: single scenario_mesh in shared lib/
The component was vendored byte-identical in idf_zacus/components/ and
box3_voice/components/ (documented drift risk). Hoist the single copy
to lib/scenario_mesh, referenced from both projects via
EXTRA_COMPONENT_DIRS in their root CMakeLists. The two deliberate
frame-format reimplementations (puzzle demux in espnow_common, PLIP's
Arduino scenario_now) are now called out in the receiver-patch doc.

All 3 firmwares rebuilt green (p7_coffre, idf_zacus, box3_voice).
2026-06-10 10:50:15 +02: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
Claude Worker claude2 7be1961122 docs: PLIP re-scoped into scenario hot-load
CI / platformio (pull_request) Failing after 5m2s
PLIP_FIRMWARE now has its own ESP-NOW receiver
(src/scenario_now.{h,cpp}); update the receiver-patch status and the
PLIP section accordingly. HTTP stays the recommended push path once
PLIP's REST server lands.
2026-06-10 10:24:12 +02: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
Claude Worker claude2 b2267f2261 feat(scenario-mesh): ESP-NOW scenario hot-load receivers + green builds
CI / platformio (pull_request) Failing after 6m18s
- scenario_mesh: ESP-NOW frame protocol component (master + box3_voice):
  chunking, per-source reassembly, deferred apply off the Wi-Fi callback.
- game_endpoint: POST /game/scenario/relay (master) + shared scenario_apply.
- box3_voice: scenario receiver wiring (scenario_mesh_init).
- espnow_slave (shared by all puzzles): demux scenario frames inside the single
  recv callback so a misrouted relay can't corrupt the MSG_* stream; reassemble
  per source MAC; optional consumer hook (logs+drops by default, puzzles have no
  scenario engine). Add missing esp_mac.h include for MACSTR/MAC2STR.
- CMakeLists (scenario_mesh + 4 puzzle mains): drop invalid `esp_now` REQUIRES;
  the ESP-NOW API lives in esp_wifi. Fixes "Failed to resolve component esp_now".
- docs: scenario-mesh receiver patch note (puzzles done via defensive demux;
  PLIP out of scope — Wi-Fi/HTTP-only, no ESP-NOW stack).

Builds green under ESP-IDF 5.4.4: idf_zacus, box3_voice, p7_coffre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:11:28 +02:00
L'électron rare d220d94607 feat(box3): scenario hot-load HTTP server
Mirrors the master's hot-load endpoint on the BOX-3 with a
self-contained httpd (no shared component) since box3_voice is
a separate IDF project. Storage uses the existing SPIFFS
partition; behaviour matches the master (validate, .bak rotate,
write, deferred restart).

main.c hooks scenario_server_start() after wifi_init_sta(); the
http server binds once the STA gets an IP.

Move idf_component.yml to main/ so the IDF Component Manager
actually fetches the managed dependencies. Pin button to 2.5.x
and esp_codec_dev to 1.2.0 to stay compatible with esp-box 1.x
legacy i2c. Set CONFIG_I2C_ENABLE_LEGACY_DRIVERS=y for the same
reason; a residual upstream BSP conflict still aborts boot on
hardware (esp-box 1.x vs driver_ng) - tracked separately.

sdkconfig.defaults documents that WiFi credentials must be set
via menuconfig and never committed.
2026-05-24 10:47:08 +02:00
L'électron rare 2a596b9fcb feat(idf): POST /game/scenario hot-load
game_endpoint gains a /game/scenario handler that accepts a Runtime 3
IR JSON (<=64 KiB), validates schema_version + non-empty steps array,
rotates the existing scenario to .bak, writes to LittleFS at
/littlefs/scenario.json on partition 'storage', and reboots after
the HTTP response flushes so the engine picks up the new IR.

ota_server bumps max_uri_handlers 8 -> 16 to fit the extra handlers
(/game/scenario plus headroom).

sdkconfig.qemu disables PSRAM + esp-sr so the firmware boots inside
qemu-system-xtensa without crashing on the unemulated octal PSRAM.
QEMU_HOWTO.md documents the workflow and the WiFi/Ethernet gap that
still blocks full HTTP smoke under emulation.
2026-05-24 10:46:29 +02:00
L'électron rare a743cfc87f feat(idf): voice_dispatcher fuzzy aliases 2026-05-04 00:55:23 +02:00
L'électron rare 71bc9a5e37 feat(idf): mDNS + /game/group_profile endpoint 2026-05-04 00:33:49 +02:00
L'électron rare ec6f363606 feat(idf): hints lifecycle + group_profile NVS
hints_client gains puzzle_start, attempt_failed, set_group_profile (whitelist TECH/NON_TECH/MIXED/BOTH default MIXED). npc_engine.set_step now auto-fires hints_client_puzzle_start; new helpers current_puzzle_id and report_failed_attempt. voice_dispatcher detects failure keywords (non/faux/mauvais/rate/marche pas) and bumps the failed-attempt counter before the hint fast-path. main.c reads NVS zacus/group_profile at boot. Build green: 1.40 MB / 2 MB partition (30% free).
2026-05-04 00:25:18 +02:00
L'électron rare de22adc08c chore(idf): app partition 1.5MB -> 2MB (OTA dual)
Bump factory + ota_0 + ota_1 from 0x180000 to 0x200000 (2 MB each)
to give voice_pipeline / STT / hints_client / esp-sr slices ~25% free
margin (was 7%). OTA dual-bank rollback symmetry preserved (all three
app slots same size). Storage (LittleFS) grows from 2 MB to 5 MB to
host MP3 cue pool; model (esp-sr SPIFFS) stays at 1 MB. Layout uses
12.1 MB of the 16 MB flash, leaves 3.87 MB unused for future growth.

idf.py build reports zacus_master.bin = 0x1639a0 (1.39 MB), 31% free
in 0x200000 partition. Reordered model before storage in CSV so auto
offsets cascade as planned (model at 0x620000, storage at 0x720000).

No sdkconfig change. Migration only requires erasing flash on first
deploy because partition table changed.
2026-05-04 00:20:25 +02:00
L'électron rare d47c23068a feat(idf): voice_pipeline_ws speak_* parsing
Slice 9b: parse {type:speak_start,sample_rate,format} and {type:speak_end,duration_ms,backend,latency_ms} from voice-bridge, route binary frames to voice_pipeline_play_chunk during the speak window. State machine transitions LISTENING -> SPEAKING -> IDLE drive the mute gate added in slice 9. End-to-end TTS playback now works: ESP32 wake -> stream -> /voice/ws -> whisper -> intent -> F5-TTS PCM stream -> I2S out.
2026-05-04 00:16:36 +02:00
L'électron rare 3585a65c8f feat(idf): PLIP /voice/hook endpoint + TTS enable
Context:
The PLIP retro-telephone annex (Si3210 SLIC + RJ9 combine) needs a
way to drive the master ESP32 voice pipeline directly from the hook
switch, so the escape-room narrative "le telephone sonne, decroche
pour parler a Zacus" works without relying on the wake-word.

Approach:
- Expose the existing ota_server httpd_handle_t via a new getter so a
  second component can register routes on the same listener (port 80)
  instead of standing up a parallel httpd.
- New voice_hook_endpoint component attaches POST /voice/hook +
  GET /voice/hook/state to that shared handle. Off-hook arms the
  pipeline (LISTENING + capture + WS streaming, bypassing wake-word);
  on-hook tears it down (stop streaming + stop capture + IDLE).
- Enable enable_tts_playback so TTS frames received over the bridge
  WS land on the MAX98357A DAC. Wake-word stays enabled (mixed mode:
  hook is primary, "hi esp" is fallback).

Changes:
- ota_server: add ota_server_get_handle() returning the static
  httpd_handle_t for late URI handler registration.
- voice_hook_endpoint: new component (CMakeLists, header, .c, README).
  POST handler validates body (<=256 B), parses cJSON, dispatches
  state on/off (idempotent), returns JSON status. GET state handler
  exposes voice_state + wake_word_active + streaming for debugging.
  Explicit GET on /voice/hook returns 405 with Allow: POST so PLIP
  integrators see an actionable error instead of a 404.
- main.c: include header, init voice_hook_endpoint after ota_server
  succeeds, flip voice_cfg.enable_tts_playback = true.
- main/CMakeLists.txt: add voice_hook_endpoint to REQUIRES.
- README.md: document the wire protocol PLIP must call (state values,
  response codes, curl examples, mixed-mode behaviour).

Impact:
- PLIP firmware can now drive the Zacus voice loop over Wi-Fi via
  REST without any code change on the master side beyond this slice.
- TTS replies from the MacStudio voice-bridge are audible on the
  master speaker (slice 9 shipped the API; slice 10 turns it on).
- App size 0x1639a0 (1.39 MB) in 0x180000 partition — 7% headroom,
  no partitions.csv resize needed.
2026-05-04 00:13:54 +02:00
L'électron rare 6d3e989ca3 feat(idf): TTS playback I2S TX + mute gate
voice_pipeline slice 9: I2S_NUM_1 TX channel for TTS playback, voice_pipeline_play_start/chunk/end APIs (clock reconfig 16k mic vs 24k F5), mute-during-TTS gate in capture_task (state==SPEAKING skips AFE feed to prevent echo retriggering wake). enable_tts_playback opt-in default false. Build green: 1.45 MB app, 8% free.
2026-05-04 00:05:02 +02:00
L'électron rare c3a771ca8b feat(idf): voice_dispatcher (STT->hints fast-path) 2026-05-03 22:07:03 +02:00
L'électron rare 479e92e952 feat(idf): voice_pipeline STT websocket streaming 2026-05-03 21:56:30 +02:00
L'électron rare 44a987d8b4 feat(idf): voice_pipeline + esp-sr wakenet (placeholder)
Slice 6 of the IDF migration: bring up the Espressif AFE + WakeNet9
pipeline on top of the existing slice-5 I2S capture stub. The active
wake word is the standard wn9_hiesp ("Hi ESP") model — custom
"Professeur Zacus" needs an Espressif training round-trip and is
tracked under P4 of the voice spec. The capture task feeds AFE,
fetches results, fires a user callback on WAKENET_DETECTED and
auto-transitions to VOICE_STATE_LISTENING. If esp-sr fails to init
(model partition missing, PSRAM exhausted, AFE alloc OOM) the
pipeline falls back to the slice-5 stub so the rest of the firmware
still boots.

- main/idf_component.yml: managed dep on espressif/esp-sr ~2.0
- partitions.csv: new 1 MB SPIFFS "model" partition for srmodels.bin
- sdkconfig.defaults: enable wn9_hiesp; disable MultiNet (off-device)
- voice_pipeline.h: new wake callback API + enable_wake_word config
- main.c: enable wake word, log + restart capture from callback

Built green on ESP-IDF 5.4 with esp-sr 2.0.5. App size 1.27 MB
(18% free in 1.5 MB factory partition); srmodels.bin 284 KB
(72% free in 1 MB model partition). No partition table grow needed
this slice.
2026-05-03 21:24:23 +02:00
L'électron rare 010f7f3d81 feat(idf): voice_pipeline + hints_client (HTTP)
voice_pipeline: I2S capture stub (i2s_std driver), state machine. hints_client: separate component (avoids cycle), POST /hints/ask via esp_http_client + cJSON, sync + async wrappers. npc_engine.request_hint now routes through hints_client when init'd, falls back to local stub otherwise. Build green: 876 KB app, 44% free.
2026-05-03 19:29:51 +02:00
L'électron rare 64892aa6a3 feat(idf): port npc_engine + wire heartbeat 2026-05-03 19:01:37 +02:00
L'électron rare 90a0bd66b2 feat(idf): port media_manager (stub MP3) 2026-05-03 17:57:08 +02:00
L'électron rare c285e04d07 chore(idf): gitignore build, managed_components, sdkconfig 2026-05-03 17:41:19 +02:00
L'électron rare 4bb2c79e37 feat(idf): wifi STA + ota_init wiring + IDF 5.4 fixes
- main.c: NVS-driven Wi-Fi STA bring-up with AP fallback, ota_server_init after IP

- main/CMakeLists: REQUIRES esp_wifi/esp_netif/esp_event, fix littlefs name (joltwallet__littlefs)

- sdkconfig.defaults: Wi-Fi RX/TX buffers, AMPDU, NVS, hostname

- ota_server: rename esp_ota_ops -> app_update, replace HTTPD_429/409 (absent in IDF 5.4) by HTTPD_400
2026-05-03 17:41:03 +02:00
L'électron rare 8a6f4e32d7 feat(idf): scaffold project with LittleFS + OTA
P1 first slice of the voice pipeline migration (spec
docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md).

Adds idf_zacus/ alongside the Arduino tree (ui_freenove_allinone/
keeps building unchanged). The scaffold targets ESP32-S3, octal
PSRAM 80 MHz, custom partition table with OTA + 2 MB LittleFS.

main/main.c boots, initializes NVS, mounts LittleFS on /littlefs,
logs heap stats (internal + PSRAM), and idles with a 60 s
heartbeat. The orphaned 2026-04-03 ota_server component
(HTTP :80, rate-limited OTA upload, 30 s rollback watchdog) is
folded in under components/ota_server/ and its header is fixed
to include esp_http_server.h (matches the .c, port 80 = HTTP).

Not yet wired: ota_server_init() boot, Wi-Fi STA, voice/NPC
modules. Stubs for puzzle_get_battery_pct() and
puzzle_get_espnow_peer_count() let the OTA component link.
2026-05-03 17:23:37 +02:00
168 changed files with 30549 additions and 6 deletions
+8
View File
@@ -55,3 +55,11 @@ data/hotline_tts/**/*.mp3
# Local venvs
.venv*/
# IDF managed components + lock + per-build sdkconfig
managed_components/
dependencies.lock
sdkconfig
.cache/
sdkconfig.old
.worktrees/
+9 -1
View File
@@ -8,10 +8,18 @@ Workspace firmware centré sur une seule cible: Freenove FNK0102H avec `ui_freen
- `docs/FNK0102H_SOURCE_OF_TRUTH.md`: matrice board/config/pins/support.
- `README_ESP32_ZACUS.md`: notes runtime et signatures série utiles.
## Deux firmwares
- `ui_freenove_allinone/` — firmware **Arduino/PlatformIO** historique (UI/audio/caméra/réseau), chemin release stabilisé.
- `idf_zacus/` — firmware **master ESP-IDF 5.4** (migration en cours, branche `feat/idf-migration`) : voix NPC (esp-sr wakeword + bridge WS), **énigmes locales** P1 son / P3 QR (caméra OV3660 + micro), **écran LVGL** (vue scène, viewfinder, shell Workbench, intro cracktro), et une **surface REST de jeu** (`/game/step`, `/game/puzzle_state`, `/game/file`). Voir `idf_zacus/README.md`.
- `box3_voice/` — firmware annexe **ESP32-S3-BOX-3** : pipeline voix + **générateur de stimulus** (`/stim/qr`, `/stim/melody`) pour exercer les énigmes du master. Voir `box3_voice/README.md`.
## Arborescence utile
- `platformio.ini`: env PlatformIO canonique `freenove_esp32s3_full_with_ui`.
- `ui_freenove_allinone/`: firmware UI/audio/caméra/réseau.
- `ui_freenove_allinone/`: firmware Arduino UI/audio/caméra/réseau.
- `idf_zacus/`: firmware master ESP-IDF (énigmes locales, écran LVGL, REST de jeu).
- `box3_voice/`: firmware BOX-3 (voix + générateur de stimulus QR/mélodie).
- `data/`: contenu LittleFS.
- `lib/`: bibliothèques runtime.
- `scripts/`: bootstrap et scripts repo.
+5
View File
@@ -1,4 +1,9 @@
cmake_minimum_required(VERSION 3.16)
# scenario_mesh is shared with idf_zacus and lives in the repo-level lib/
# (single copy, no protocol drift). Project-local components under
# box3_voice/components/ are picked up automatically by ESP-IDF.
set(EXTRA_COMPONENT_DIRS ../lib/scenario_mesh)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(zacus-box3-voice)
+64
View File
@@ -54,3 +54,67 @@ Use `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**:
|
hints engine <--+
```
## Générateur de stimulus (QR + mélodie)
En plus du pipeline vocal, la BOX-3 sert de **source de stimulus** pour le
master Freenove. Elle affiche un QR code (lu par la caméra du master) et joue
une mélodie (entendue par le micro du master). Cela permet de tester les
énigmes locales P1 (son) et P3 (QR) du master de bout en bout, sans QR imprimé
ni instrument de musique.
Les routes sont enregistrées sur le serveur HTTP existant (`scenario_server`)
au démarrage, juste après l'init de l'écran et de l'UI (`stimulus_init()` puis
`stimulus_register_routes()` dans `app_main`).
### `POST /stim/qr`
```json
{"text": "zacus-qr-1"}
```
Affiche le texte en QR plein écran : un widget `lv_qrcode` de **160 px** sur une
page LVGL dédiée, chargée au premier plan. Le rétroéclairage est **atténué à
35 %** pour réduire le halo de l'écran émissif et préserver le contraste pour la
caméra du master.
```bash
curl -X POST http://192.168.1.50/stim/qr \
-H 'Content-Type: application/json' \
-d '{"text":"zacus-qr-1"}'
# {"status":"ok","text":"zacus-qr-1"}
```
### `POST /stim/melody`
```json
{"notes": [60, 62, 64, 65], "ms": 400}
```
Joue la séquence de notes **MIDI** au haut-parleur. `notes` est un tableau
d'entiers `0-127` (60 = do central), `ms` est la durée par note en
millisecondes, bornée à `1-4000` (défaut 400). La séquence est jouée sur une
**tâche worker** dédiée, donc la réponse HTTP revient immédiatement. Maximum
32 notes ; les notes hors plage sont ignorées.
```bash
curl -X POST http://192.168.1.50/stim/melody \
-H 'Content-Type: application/json' \
-d '{"notes":[60,62,64,65,67],"ms":300}'
# {"status":"ok","notes":5}
```
### Note de terrain
Le décodage par la caméra du master du **QR affiché sur l'écran LCD n'est pas
fiable** : le contraste émissif est insuffisant pour quirc, qui ne retrouve pas
les motifs de repérage (finder patterns) quelle que soit la résolution, même
écran atténué. **La mélodie est le chemin de stimulus le plus robuste.** Pour
P3, le **QR imprimé reste recommandé** ; le `/stim/qr` sert surtout au cadrage
et au débogage de la caméra.
### Configuration Wi-Fi
Le générateur est joignable sur l'IP de la BOX-3 en mode station. Configurez le
réseau via `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**, ou
directement par `CONFIG_ZACUS_WIFI_SSID` / `CONFIG_ZACUS_WIFI_PASSWORD`.
+14 -1
View File
@@ -1,4 +1,17 @@
idf_component_register(
SRCS "main.c" "voice_ws_client.c"
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c" "gamebook.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
esp_event
esp_http_client
esp_http_server
esp_netif
esp_wifi
json
nvs_flash
spiffs
scenario_mesh
espressif__esp-box-3
lvgl__lvgl
)
+18
View File
@@ -12,6 +12,16 @@ menu "Zacus BOX-3 Voice Configuration"
help
WiFi password. Leave empty for open networks.
config ZACUS_WIFI_CHANNEL
int "WiFi channel hint (0 = auto)"
default 0
range 0 13
help
Bias the STA scan to start on this channel (1-13). Set this to the
master's WiFi channel so ESP-NOW peers land co-channel — required
for the scenario relay on multi-AP / mesh networks where the same
SSID is broadcast on several channels. 0 = auto (scan all).
config ZACUS_VOICE_BRIDGE_URL
string "Voice Bridge WebSocket URL"
default "ws://192.168.0.119:8200/voice/ws"
@@ -19,6 +29,14 @@ menu "Zacus BOX-3 Voice Configuration"
WebSocket endpoint for the mascarade voice bridge.
The bridge routes voice commands to the hints engine.
config ZACUS_MASTER_URL
string "Zacus Master Base URL"
default "http://10.2.5.42"
help
Base URL of the Zacus master ESP32. The virtual PLIP annex
reports hook transitions to <url>/voice/hook. Prefer a plain
IP (mDNS .local names need the mdns component).
config ZACUS_VOICE_TOKEN
string "Voice Bridge Auth Token"
default ""
+650
View File
@@ -0,0 +1,650 @@
// cmd_exec.c — BOX-3 executor for structured ESP-NOW CMD frames (D5 contract).
// See cmd_exec.h for the wire format spec.
//
// Objective 1 — screen: full-scene faithful rendering
// Palette: bg #0055AA / symbol #FF8800 / title #FFFFFF / subtitle #AAAAAA
// Effects: pulse (opacity breathing on symbol via LVGL anim)
// glitch (rapid opacity flicker on title via lv_timer)
// gyro (rotating arc ring around symbol)
// none (static)
//
// Objective 2 — play: real WAV streaming to I2S
// 1. Mount SD card (best-effort, /sdcard). Parse WAV header (PCM-16).
// 2. Stream PCM samples via s_spk_handle (extern, same I2S channel as TTS).
// 3. If SD absent / file not found → fallback bip + log.
// 4. Embedded test cue always available at virtual path "embedded://" or
// when the requested path starts with "embedded:" — proves WAV decode + I2S.
#include "cmd_exec.h"
#include <string.h>
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "bsp/esp-bsp.h"
#include "cJSON.h"
#include "esp_log.h"
#include "esp_vfs_fat.h"
#include "lvgl.h"
#include "embedded_wav.h" // s_embedded_wav[], EMBEDDED_WAV_SIZE
// audio_play_tone and s_spk_handle are defined in main.c
extern void audio_play_tone(float frequency, int duration_ms);
// Speaker I2S handle — obtained from main.c via a weak accessor.
// We declare a weak symbol here; main.c must define cmd_exec_set_spk_handle()
// and call it after speaker_init(). Alternatively we use the driver directly.
#include "driver/i2s_std.h"
static i2s_chan_handle_t s_spk_handle_local = NULL;
void cmd_exec_set_spk_handle(i2s_chan_handle_t h)
{
s_spk_handle_local = h;
}
#define TAG "cmd_exec"
// ─── Palette (matches idf_zacus/components/display_ui reference) ─────────────
#define COL_BG 0x0055AA // Workbench blue
#define COL_SYMBOL 0xFF8800 // Workbench orange
#define COL_TITLE 0xFFFFFF // white
#define COL_SUB 0xAAAAAA // mid grey
#define SCREEN_LOCK_MS 1000
// ─── Screen overlay state ─────────────────────────────────────────────────────
static lv_obj_t *s_overlay = NULL;
static lv_obj_t *s_title_lbl = NULL;
static lv_obj_t *s_sub_lbl = NULL;
static lv_obj_t *s_sym_lbl = NULL;
static lv_obj_t *s_gyro_arc = NULL;
// Active animations / timers (so we can stop them on next call)
static lv_timer_t *s_glitch_timer = NULL;
// ─── Effect callbacks ─────────────────────────────────────────────────────────
static void pulse_anim_cb(void *obj, int32_t v)
{
lv_obj_set_style_opa((lv_obj_t *)obj, (lv_opa_t)v, 0);
}
static void gyro_anim_cb(void *obj, int32_t v)
{
lv_arc_set_end_angle((lv_obj_t *)obj, (uint16_t)(v % 360));
}
static void glitch_timer_cb(lv_timer_t *t)
{
(void)t;
static bool flip = false;
flip = !flip;
lv_obj_set_style_opa(s_title_lbl,
flip ? LV_OPA_20 : LV_OPA_COVER, 0);
// Slight X jitter: move label 0 or 3 pixels right alternately
lv_obj_set_x(s_title_lbl, flip ? 3 : 0);
}
// ─── Stop all active effects ──────────────────────────────────────────────────
static void stop_effects_locked(void)
{
// Stop pulse animation on symbol
lv_anim_del(s_sym_lbl, pulse_anim_cb);
if (s_sym_lbl) {
lv_obj_set_style_opa(s_sym_lbl, LV_OPA_COVER, 0);
}
// Stop glitch timer
if (s_glitch_timer) {
lv_timer_pause(s_glitch_timer);
}
if (s_title_lbl) {
lv_obj_set_style_opa(s_title_lbl, LV_OPA_COVER, 0);
lv_obj_set_x(s_title_lbl, 0);
}
// Stop gyro arc
if (s_gyro_arc) {
lv_anim_del(s_gyro_arc, gyro_anim_cb);
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
}
}
// ─── Build overlay (lazy, first CMD only) ────────────────────────────────────
static void build_overlay_locked(void)
{
if (s_overlay) return;
lv_obj_t *scr = lv_screen_active();
// Full-screen panel with Workbench blue background
s_overlay = lv_obj_create(scr);
lv_obj_set_size(s_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(s_overlay, 0, 0);
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(s_overlay, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_overlay, 0, 0);
lv_obj_set_style_radius(s_overlay, 0, 0);
lv_obj_set_style_pad_all(s_overlay, 10, 0);
// Title — top, white, font 24
s_title_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_title_lbl, "");
lv_obj_set_style_text_font(s_title_lbl, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(s_title_lbl, lv_color_hex(COL_TITLE), 0);
lv_label_set_long_mode(s_title_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_title_lbl, LV_PCT(90));
lv_obj_set_style_text_align(s_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 14);
// Symbol — center, orange, font 48
s_sym_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_sym_lbl, "");
lv_obj_set_style_text_font(s_sym_lbl, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(s_sym_lbl, lv_color_hex(COL_SYMBOL), 0);
lv_obj_align(s_sym_lbl, LV_ALIGN_CENTER, 0, -10);
// Gyro arc: hidden rotating ring around symbol (gyro effect)
s_gyro_arc = lv_arc_create(s_overlay);
lv_obj_set_size(s_gyro_arc, 90, 90);
lv_obj_align(s_gyro_arc, LV_ALIGN_CENTER, 0, -10);
lv_arc_set_rotation(s_gyro_arc, 270);
lv_arc_set_bg_angles(s_gyro_arc, 0, 360);
lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(COL_SYMBOL), LV_PART_INDICATOR);
lv_obj_set_style_arc_width(s_gyro_arc, 4, LV_PART_INDICATOR);
lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_gyro_arc, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(s_gyro_arc, 0, 0);
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
// Subtitle — bottom, grey, font 14
s_sub_lbl = lv_label_create(s_overlay);
lv_label_set_text(s_sub_lbl, "");
lv_obj_set_style_text_font(s_sub_lbl, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(s_sub_lbl, lv_color_hex(COL_SUB), 0);
lv_label_set_long_mode(s_sub_lbl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_sub_lbl, LV_PCT(90));
lv_obj_set_style_text_align(s_sub_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(s_sub_lbl, LV_ALIGN_BOTTOM_MID, 0, -14);
// Glitch timer: created paused, resumed by glitch effect
s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL);
lv_timer_pause(s_glitch_timer);
}
// ─── op=screen ────────────────────────────────────────────────────────────────
static esp_err_t exec_screen(const cJSON *a)
{
const cJSON *title = a ? cJSON_GetObjectItemCaseSensitive(a, "t") : NULL;
const cJSON *sub = a ? cJSON_GetObjectItemCaseSensitive(a, "u") : NULL;
const cJSON *sym = a ? cJSON_GetObjectItemCaseSensitive(a, "y") : NULL;
const cJSON *eff = a ? cJSON_GetObjectItemCaseSensitive(a, "e") : NULL;
const char *t_str = cJSON_IsString(title) ? title->valuestring : "";
const char *u_str = cJSON_IsString(sub) ? sub->valuestring : "";
const char *y_str = cJSON_IsString(sym) ? sym->valuestring : "";
const char *e_str = cJSON_IsString(eff) ? eff->valuestring : "none";
ESP_LOGI(TAG, "CMD op=screen t=\"%s\" u=\"%s\" y=\"%s\" e=\"%s\"",
t_str, u_str, y_str, e_str);
if (!bsp_display_lock(SCREEN_LOCK_MS)) {
ESP_LOGW(TAG, "screen: display lock timeout");
return ESP_FAIL;
}
build_overlay_locked();
stop_effects_locked();
// Update labels
lv_label_set_text(s_title_lbl, t_str);
lv_label_set_text(s_sym_lbl, y_str);
lv_label_set_text(s_sub_lbl, u_str);
// Re-align after text update
lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 14);
lv_obj_align(s_sym_lbl, LV_ALIGN_CENTER, 0, -10);
lv_obj_align(s_sub_lbl, LV_ALIGN_BOTTOM_MID, 0, -14);
// Bring overlay to front
lv_obj_move_foreground(s_overlay);
// Apply effect
if (strcmp(e_str, "pulse") == 0) {
// Opacity breathing: COVER → 50 → COVER, 600ms per half
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, s_sym_lbl);
lv_anim_set_exec_cb(&a, pulse_anim_cb);
lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
lv_anim_set_time(&a, 600);
lv_anim_set_playback_time(&a, 600);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&a);
ESP_LOGI(TAG, "screen: pulse effect started");
} else if (strcmp(e_str, "glitch") == 0) {
// Rapid opacity + X-jitter on title
if (s_glitch_timer) {
lv_timer_resume(s_glitch_timer);
}
ESP_LOGI(TAG, "screen: glitch effect started");
} else if (strcmp(e_str, "gyro") == 0) {
// Rotating arc ring around the symbol
if (s_gyro_arc) {
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, s_gyro_arc);
lv_anim_set_exec_cb(&a, gyro_anim_cb);
lv_anim_set_values(&a, 0, 360);
lv_anim_set_time(&a, 1200);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&a);
}
ESP_LOGI(TAG, "screen: gyro effect started");
} else {
// "none" or unrecognised → static
ESP_LOGI(TAG, "screen: static (no effect)");
}
bsp_display_unlock();
return ESP_OK;
}
// ─── WAV player ───────────────────────────────────────────────────────────────
//
// WAV header layout (canonical PCM, 44 bytes):
// RIFF header : 12 bytes (RIFF, chunk size, WAVE)
// fmt chunk : 24 bytes (fmt , 16, pcm=1, channels, rate, byte_rate, align, bits)
// data chunk : 8 bytes (data, data_size)
// PCM samples : data_size bytes
#define WAV_HDR_RIFF_OFS 0
#define WAV_HDR_FMT_OFS 12
#define WAV_HDR_DATA_OFS 36
#define WAV_MIN_HEADER 44
typedef struct {
uint16_t audio_format;
uint16_t channels;
uint32_t sample_rate;
uint32_t byte_rate;
uint16_t block_align;
uint16_t bits_per_sample;
uint32_t data_offset; // offset of PCM data in the buffer/file
uint32_t data_size;
} wav_info_t;
static esp_err_t parse_wav_header(const uint8_t *buf, size_t buf_len, wav_info_t *out)
{
if (buf_len < WAV_MIN_HEADER) return ESP_ERR_INVALID_SIZE;
// RIFF
if (memcmp(buf, "RIFF", 4) != 0) return ESP_ERR_INVALID_ARG;
if (memcmp(buf + 8, "WAVE", 4) != 0) return ESP_ERR_INVALID_ARG;
// Walk chunks to find fmt and data (handles non-standard ordering / extra chunks)
size_t pos = 12;
bool got_fmt = false, got_data = false;
while (pos + 8 <= buf_len && !(got_fmt && got_data)) {
uint32_t chunk_size;
memcpy(&chunk_size, buf + pos + 4, 4);
if (memcmp(buf + pos, "fmt ", 4) == 0 && chunk_size >= 16) {
memcpy(&out->audio_format, buf + pos + 8, 2);
memcpy(&out->channels, buf + pos + 10, 2);
memcpy(&out->sample_rate, buf + pos + 12, 4);
memcpy(&out->byte_rate, buf + pos + 16, 4);
memcpy(&out->block_align, buf + pos + 20, 2);
memcpy(&out->bits_per_sample,buf + pos + 22, 2);
got_fmt = true;
} else if (memcmp(buf + pos, "data", 4) == 0) {
out->data_offset = (uint32_t)(pos + 8);
out->data_size = chunk_size;
got_data = true;
}
pos += 8 + chunk_size;
}
if (!got_fmt || !got_data) return ESP_ERR_NOT_FOUND;
if (out->audio_format != 1) {
ESP_LOGW(TAG, "WAV format %d is not PCM (1) — unsupported", out->audio_format);
return ESP_ERR_NOT_SUPPORTED;
}
return ESP_OK;
}
// Stream raw PCM-16 samples to the speaker I2S channel.
// Handles any sample rate (simple rate-adaptation via repeat/skip is NOT done here;
// the test WAV and typical cues should be 16 kHz mono = native rate).
static esp_err_t stream_pcm16(const uint8_t *pcm, size_t byte_len,
bool loop, int max_loops)
{
if (!s_spk_handle_local) {
ESP_LOGW(TAG, "play: no speaker handle — skip stream");
return ESP_ERR_INVALID_STATE;
}
int loops = 0;
do {
size_t offset = 0;
while (offset < byte_len) {
size_t chunk = (byte_len - offset < 1024) ? (byte_len - offset) : 1024;
size_t written = 0;
esp_err_t ret = i2s_channel_write(s_spk_handle_local,
pcm + offset, chunk,
&written, pdMS_TO_TICKS(500));
if (ret != ESP_OK) {
ESP_LOGW(TAG, "I2S write error: %s", esp_err_to_name(ret));
return ret;
}
offset += chunk;
}
loops++;
} while (loop && (max_loops == 0 || loops < max_loops));
return ESP_OK;
}
// ─── SD card mount (best-effort, guarded) ────────────────────────────────────
static bool s_sd_mounted = false;
static void ensure_sd_mounted(void)
{
if (s_sd_mounted) return;
ESP_LOGI(TAG, "play: attempting SD card mount...");
esp_err_t ret = bsp_sdcard_mount();
if (ret == ESP_OK) {
s_sd_mounted = true;
ESP_LOGI(TAG, "play: SD card mounted at %s", BSP_SD_MOUNT_POINT);
} else {
ESP_LOGW(TAG, "play: SD card mount failed: %s (will use fallback)", esp_err_to_name(ret));
}
}
// ─── play task (off main task to allow loop without blocking CMD recv) ────────
typedef struct {
char path[128];
bool loop;
} play_args_t;
static void play_task(void *arg)
{
play_args_t *pargs = (play_args_t *)arg;
char path[128];
bool loop = pargs->loop;
strncpy(path, pargs->path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
free(pargs);
ESP_LOGI(TAG, "play_task: path=\"%s\" loop=%d", path, loop);
// Check for embedded asset path
bool use_embedded = (strncmp(path, "embedded:", 9) == 0 ||
strcmp(path, "<none>") == 0 ||
strlen(path) == 0);
if (!use_embedded) {
// Try SD card
ensure_sd_mounted();
if (!s_sd_mounted) {
ESP_LOGW(TAG, "play: SD not mounted, falling back to embedded asset");
use_embedded = true;
}
}
if (!use_embedded) {
// Build full path if not absolute
char full_path[160];
if (path[0] == '/') {
snprintf(full_path, sizeof(full_path), "%s", path);
} else {
snprintf(full_path, sizeof(full_path), "%s/%s", BSP_SD_MOUNT_POINT, path);
}
FILE *f = fopen(full_path, "rb");
if (!f) {
ESP_LOGW(TAG, "play: file not found: %s — falling back to embedded", full_path);
use_embedded = true;
} else {
// Read WAV from file
fseek(f, 0, SEEK_END);
long fsize = ftell(f);
fseek(f, 0, SEEK_SET);
if (fsize < WAV_MIN_HEADER || fsize > 512 * 1024) {
ESP_LOGW(TAG, "play: file size %ld bytes — out of range, fallback", fsize);
fclose(f);
use_embedded = true;
} else {
uint8_t *fbuf = malloc((size_t)fsize);
if (!fbuf) {
ESP_LOGW(TAG, "play: alloc failed for %ld bytes — fallback", fsize);
fclose(f);
use_embedded = true;
} else {
size_t nread = fread(fbuf, 1, (size_t)fsize, f);
fclose(f);
wav_info_t wi;
esp_err_t ret = parse_wav_header(fbuf, nread, &wi);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "play: WAV parse error %s — fallback", esp_err_to_name(ret));
free(fbuf);
use_embedded = true;
} else {
ESP_LOGI(TAG, "play: WAV ok — %" PRIu32 " Hz %" PRIu16 "-bit %" PRIu16 " ch, %" PRIu32 " bytes PCM",
wi.sample_rate, wi.bits_per_sample,
wi.channels, wi.data_size);
if (wi.bits_per_sample != 16) {
ESP_LOGW(TAG, "play: %" PRIu16 "-bit WAV — only 16-bit supported, fallback",
wi.bits_per_sample);
free(fbuf);
use_embedded = true;
} else {
// Real streaming from file
stream_pcm16(fbuf + wi.data_offset, wi.data_size, loop, 3);
free(fbuf);
use_embedded = false;
}
}
}
}
}
}
if (use_embedded) {
// Play embedded C5-E5-G5 cue (proves WAV decode + I2S path)
wav_info_t wi;
esp_err_t ret = parse_wav_header(s_embedded_wav, EMBEDDED_WAV_SIZE, &wi);
if (ret == ESP_OK && wi.bits_per_sample == 16) {
ESP_LOGI(TAG, "play: streaming embedded cue (%" PRIu32 " Hz %" PRIu16 "-bit, %" PRIu32 " bytes PCM)",
wi.sample_rate, wi.bits_per_sample, wi.data_size);
stream_pcm16(s_embedded_wav + wi.data_offset, wi.data_size, false, 1);
} else {
// Last-resort: beep
ESP_LOGW(TAG, "play: embedded WAV parse error %s — beep fallback",
esp_err_to_name(ret));
audio_play_tone(880.0f, 200);
}
}
vTaskDelete(NULL);
}
// ─── Gamebook narration player (async, streamed, interruptible) ──────────────
// One persistent task plays one WAV at a time, streaming straight from the SD
// in small chunks (no full-file malloc → any length, RAM-safe). A new request
// or a stop flag breaks the stream so tapping a choice cuts the narration.
static TaskHandle_t s_gb_task = NULL;
static char s_gb_path[160];
static volatile bool s_gb_new = false;
static volatile bool s_gb_stop = false;
static void gb_play_task(void *arg)
{
(void) arg;
uint8_t hdr[64];
uint8_t buf[2048];
for (;;) {
if (!s_gb_new) { vTaskDelay(pdMS_TO_TICKS(20)); continue; }
char path[160];
strncpy(path, s_gb_path, sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
s_gb_new = false;
s_gb_stop = false;
ensure_sd_mounted();
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "gb: open %s failed", path); continue; }
size_t hn = fread(hdr, 1, sizeof(hdr), f);
wav_info_t wi;
if (parse_wav_header(hdr, hn, &wi) != ESP_OK || wi.bits_per_sample != 16) {
ESP_LOGW(TAG, "gb: bad/usupported WAV %s", path);
fclose(f);
continue;
}
fseek(f, wi.data_offset, SEEK_SET);
size_t remaining = wi.data_size;
while (remaining > 0 && !s_gb_stop && !s_gb_new) {
size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf);
size_t got = fread(buf, 1, want, f);
if (got == 0) break;
size_t written = 0;
if (s_spk_handle_local) {
i2s_channel_write(s_spk_handle_local, buf, got, &written,
pdMS_TO_TICKS(500));
}
remaining -= got;
}
fclose(f);
}
}
void cmd_exec_play_file_async(const char *path)
{
if (!path || !path[0]) return;
strncpy(s_gb_path, path, sizeof(s_gb_path) - 1);
s_gb_path[sizeof(s_gb_path) - 1] = '\0';
s_gb_stop = true; // interrupt whatever is playing
s_gb_new = true; // … and pick up the new clip
if (!s_gb_task) {
xTaskCreate(gb_play_task, "gb_play", 4096, NULL, 4, &s_gb_task);
}
}
void cmd_exec_stop_play(void)
{
s_gb_stop = true;
s_gb_new = false;
}
// ─── op=play ──────────────────────────────────────────────────────────────────
static esp_err_t exec_play(const cJSON *a)
{
const cJSON *path_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
const cJSON *loop_j = a ? cJSON_GetObjectItemCaseSensitive(a, "l") : NULL;
const char *path = cJSON_IsString(path_j) ? path_j->valuestring : "embedded://cue";
bool loop = cJSON_IsNumber(loop_j) ? ((int)loop_j->valuedouble != 0) : false;
ESP_LOGI(TAG, "CMD op=play path=\"%s\" loop=%d", path, loop);
play_args_t *pargs = malloc(sizeof(play_args_t));
if (!pargs) {
ESP_LOGW(TAG, "play: alloc failed — beep fallback");
audio_play_tone(880.0f, 200);
return ESP_ERR_NO_MEM;
}
strncpy(pargs->path, path, sizeof(pargs->path) - 1);
pargs->path[sizeof(pargs->path) - 1] = '\0';
pargs->loop = loop;
// Spawn a dedicated task (4 KB stack) so we don't block the CMD receive path.
// Priority 4 = same as mic_capture.
if (xTaskCreate(play_task, "cmd_play", 4096, pargs, 4, NULL) != pdPASS) {
ESP_LOGW(TAG, "play: task create failed — beep fallback");
free(pargs);
audio_play_tone(880.0f, 200);
return ESP_FAIL;
}
return ESP_OK;
}
// ─── op=evt ───────────────────────────────────────────────────────────────────
static esp_err_t exec_evt(const cJSON *a)
{
const cJSON *name_j = a ? cJSON_GetObjectItemCaseSensitive(a, "n") : NULL;
const char *name = cJSON_IsString(name_j) ? name_j->valuestring : "<none>";
ESP_LOGI(TAG, "CMD op=evt n=\"%s\" (evt received on BOX-3 — logging only)", name);
return ESP_OK;
}
// ─── op=led ───────────────────────────────────────────────────────────────────
static esp_err_t exec_led(const cJSON *a)
{
const cJSON *pat_j = a ? cJSON_GetObjectItemCaseSensitive(a, "p") : NULL;
const char *pat = cJSON_IsString(pat_j) ? pat_j->valuestring : "<none>";
ESP_LOGI(TAG, "CMD op=led pattern=\"%s\" (stub — no LED driver in box3_voice)", pat);
return ESP_OK;
}
// ─── public entry point ───────────────────────────────────────────────────────
esp_err_t cmd_exec_handle(const char *data, size_t len)
{
if (!data || len == 0) return ESP_ERR_INVALID_ARG;
cJSON *root = cJSON_ParseWithLength(data, len);
if (!root) {
ESP_LOGW(TAG, "CMD parse error: not valid JSON (%.40s...)", data);
return ESP_ERR_INVALID_ARG;
}
const cJSON *op_j = cJSON_GetObjectItemCaseSensitive(root, "op");
if (!cJSON_IsString(op_j) || !op_j->valuestring || !op_j->valuestring[0]) {
ESP_LOGW(TAG, "CMD missing or invalid 'op' field");
cJSON_Delete(root);
return ESP_ERR_INVALID_ARG;
}
const char *op = op_j->valuestring;
const cJSON *a = cJSON_GetObjectItemCaseSensitive(root, "a");
esp_err_t err;
if (strcmp(op, "screen") == 0) {
err = exec_screen(a);
} else if (strcmp(op, "play") == 0) {
err = exec_play(a);
} else if (strcmp(op, "evt") == 0) {
err = exec_evt(a);
} else if (strcmp(op, "led") == 0) {
err = exec_led(a);
} else {
ESP_LOGW(TAG, "CMD op=\"%s\" unknown — ignored", op);
err = ESP_ERR_NOT_SUPPORTED;
}
cJSON_Delete(root);
return err;
}
+47
View File
@@ -0,0 +1,47 @@
// cmd_exec.h — BOX-3 executor for structured ESP-NOW CMD frames (D5 contract).
//
// Wire format (SCENARIO_MESH_TEXT_CMD, ≤200 bytes):
// {"op":"<verb>","a":{<args>}}
//
// Supported ops:
// screen {"t":"title","u":"subtitle","y":"symbol","e":"effect"} — display on LCD
// effect: "pulse"|"glitch"|"gyro"|"none"
// play {"p":"<path>","l":0|1} — stream WAV via I2S; embedded cue if no file
// evt {"n":"<name>"} — log the event (future: relay to master)
// led {"p":"<pattern>"} — log the pattern (stub)
// <any> unknown op — log warn, ignore
//
// Tolerant by design: malformed JSON, missing keys, or unknown ops log a warning
// and return without crashing. Never asserts on network input.
#pragma once
#include <stddef.h>
#include "esp_err.h"
#include "driver/i2s_std.h"
#ifdef __cplusplus
extern "C" {
#endif
// Parse and execute one CMD frame. `data` is the raw UTF-8 payload received via
// scenario_mesh text callback (NUL-terminated). Returns ESP_OK if the op was
// recognised and dispatched, ESP_ERR_INVALID_ARG on bad JSON or missing `op`,
// ESP_ERR_NOT_SUPPORTED for unknown ops (all logged, no crash).
esp_err_t cmd_exec_handle(const char *data, size_t len);
// Provide the speaker I2S channel handle so exec_play() can stream audio.
// Must be called from main.c after speaker_init(), before any CMD arrives.
void cmd_exec_set_spk_handle(i2s_chan_handle_t h);
// Play a 16 kHz mono 16-bit WAV from the SD asynchronously, streamed in chunks
// (RAM-safe for long narration) and INTERRUPTIBLE: a new call stops the current
// clip and starts the new one. Used by the touch gamebook for passage audio.
void cmd_exec_play_file_async(const char *path);
// Stop the async narration (no-op if nothing is playing).
void cmd_exec_stop_play(void);
#ifdef __cplusplus
}
#endif
File diff suppressed because it is too large Load Diff
+234
View File
@@ -0,0 +1,234 @@
// gamebook.c — see gamebook.h. Touch CYOA for the ESP32-S3-BOX-3.
#include "gamebook.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "bsp/esp-bsp.h"
#include "lvgl.h"
#include "cmd_exec.h" // async WAV narration player
static const char *TAG = "gamebook";
#define GB_DIR "/sdcard/gamebook"
#define GB_LIBRARY GB_DIR "/library.json"
#define GB_JSON_MAX (256 * 1024) // expanded books are ~80 KB of JSON
// Colours (Workbench-ish palette).
#define COL_BG 0x101820
#define COL_TITLE 0xFFCC55
#define COL_TEXT 0xF0F0F0
#define COL_BTN 0x224466
static cJSON *s_lib_root = NULL; // owns library.json
static cJSON *s_lib = NULL; // borrowed: root->"library"
static int s_lib_n = 0;
static cJSON *s_book = NULL; // owns the current <id>.json
static cJSON *s_passages = NULL; // borrowed: book->"passages"
static lv_obj_t *s_root = NULL; // scrollable full-screen column
// ── PSRAM allocators for cJSON (book trees are large) ───────────────────────
static void *gb_malloc(size_t sz) { return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); }
static void gb_free(void *p) { heap_caps_free(p); }
static cJSON *load_json(const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "open %s failed", path); return NULL; }
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0 || sz > GB_JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; }
char *buf = heap_caps_malloc((size_t)sz + 1, MALLOC_CAP_SPIRAM);
if (!buf) { fclose(f); return NULL; }
size_t rd = fread(buf, 1, (size_t)sz, f);
fclose(f);
buf[rd] = '\0';
cJSON *root = cJSON_Parse(buf);
heap_caps_free(buf);
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
return root;
}
// ── Root container (created once, cleared per view) ─────────────────────────
// All UI functions below assume the LVGL lock is held (gamebook_init takes it;
// button callbacks already run inside the LVGL task / lock).
static void ensure_root(void)
{
if (s_root) { lv_obj_clean(s_root); return; }
lv_obj_t *scr = lv_screen_active();
s_root = lv_obj_create(scr);
lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(s_root, 0, 0);
lv_obj_set_style_bg_color(s_root, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_root, 0, 0);
lv_obj_set_style_radius(s_root, 0, 0);
lv_obj_set_style_pad_all(s_root, 8, 0);
lv_obj_set_style_pad_row(s_root, 8, 0);
lv_obj_set_flex_flow(s_root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_scroll_dir(s_root, LV_DIR_VER);
}
static lv_obj_t *add_title(const char *txt)
{
lv_obj_t *l = lv_label_create(s_root);
lv_obj_set_style_text_font(l, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(l, lv_color_hex(COL_TITLE), 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return l;
}
static lv_obj_t *add_text(const char *txt)
{
lv_obj_t *l = lv_label_create(s_root);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(l, lv_color_hex(COL_TEXT), 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return l;
}
static lv_obj_t *add_button(const char *txt, lv_event_cb_t cb, void *user)
{
lv_obj_t *b = lv_button_create(s_root);
lv_obj_set_width(b, LV_PCT(100));
lv_obj_set_style_bg_color(b, lv_color_hex(COL_BTN), 0);
lv_obj_set_style_pad_all(b, 10, 0);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, user);
lv_obj_t *l = lv_label_create(b);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_label_set_text(l, txt);
return b;
}
// Forward decls
static void show_library(void);
static void enter_passage(const char *pid);
static void tile_cb(lv_event_t *e);
static void choice_cb(lv_event_t *e);
static void menu_cb(lv_event_t *e);
// ── Library menu ────────────────────────────────────────────────────────────
static void show_library(void)
{
cmd_exec_stop_play(); // silence any passage narration
if (s_book) { cJSON_Delete(s_book); s_book = NULL; s_passages = NULL; }
ensure_root();
add_title("Choisis ton histoire");
for (int i = 0; i < s_lib_n; i++) {
const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title");
add_button(cJSON_IsString(t) ? t->valuestring : "?",
tile_cb, (void *)(intptr_t)i);
}
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
}
// ── Passage ─────────────────────────────────────────────────────────────────
static void enter_passage(const char *pid)
{
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' missing", pid); return; }
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
const cJSON *text = cJSON_GetObjectItem(p, "text");
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
ensure_root();
add_title(cJSON_IsString(screen) ? screen->valuestring : "");
add_text(cJSON_IsString(text) ? text->valuestring : "");
// Narration: play this passage's WAV from the SD if the pack has audio.
// Interruptible — tapping a choice cuts it and plays the next passage.
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) {
char path[160];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, wav->valuestring);
cmd_exec_play_file_async(path);
}
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
if (n == 0) {
add_button("Revenir au menu", menu_cb, NULL);
} else {
for (int i = 0; i < n; i++) {
const cJSON *c = cJSON_GetArrayItem(choices, i);
const cJSON *lbl = cJSON_GetObjectItem(c, "label");
const cJSON *g = cJSON_GetObjectItem(c, "goto");
// goto valuestring stays valid while s_book is alive → use as user_data
add_button(cJSON_IsString(lbl) ? lbl->valuestring : "?",
choice_cb, cJSON_IsString(g) ? g->valuestring : NULL);
}
}
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, n);
}
static void load_book(int idx)
{
const cJSON *e = cJSON_GetArrayItem(s_lib, idx);
const cJSON *file = cJSON_GetObjectItem(e, "book");
if (!cJSON_IsString(file)) return;
char path[128];
snprintf(path, sizeof(path), "%s/%s", GB_DIR, file->valuestring);
cJSON *book = load_json(path);
if (!book) return;
const cJSON *passages = cJSON_GetObjectItem(book, "passages");
const cJSON *start = cJSON_GetObjectItem(book, "start");
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
cJSON_Delete(book); return;
}
if (s_book) cJSON_Delete(s_book);
s_book = book;
s_passages = (cJSON *)passages;
ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring);
enter_passage(start->valuestring);
}
// ── Touch callbacks (run inside the LVGL task → lock already held) ───────────
static void tile_cb(lv_event_t *e) { load_book((int)(intptr_t)lv_event_get_user_data(e)); }
static void menu_cb(lv_event_t *e) { (void)e; show_library(); }
static void choice_cb(lv_event_t *e)
{
const char *goto_id = (const char *)lv_event_get_user_data(e);
if (goto_id) enter_passage(goto_id);
}
// ── Public init ─────────────────────────────────────────────────────────────
void gamebook_init(void)
{
cJSON_Hooks hooks = { .malloc_fn = gb_malloc, .free_fn = gb_free };
cJSON_InitHooks(&hooks); // keep the big book trees in PSRAM
if (bsp_sdcard_mount() != ESP_OK) {
ESP_LOGW(TAG, "no SD card — gamebook disabled");
return;
}
s_lib_root = load_json(GB_LIBRARY);
if (!s_lib_root) { ESP_LOGW(TAG, "no library.json — gamebook disabled"); return; }
s_lib = cJSON_GetObjectItem(s_lib_root, "library");
if (!cJSON_IsArray(s_lib) || cJSON_GetArraySize(s_lib) == 0) {
cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL;
ESP_LOGW(TAG, "empty library");
return;
}
s_lib_n = cJSON_GetArraySize(s_lib);
bsp_display_brightness_set(80);
if (bsp_display_lock(1000)) {
show_library();
bsp_display_unlock();
}
ESP_LOGI(TAG, "touch gamebook up (%d stories)", s_lib_n);
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
// gamebook — "livre dont tu es le héros" TACTILE pour l'ESP32-S3-BOX-3.
//
// Écran 320x240 + tactile : on lit le passage à l'écran et on TOUCHE un bouton
// pour choisir. Réutilise le pack gamebook du master sur la SD :
// /sdcard/gamebook/{library.json, <id>.json}
// où chaque passage porte {screen, text, choices:[{label, goto}]} (le champ
// "wav" éventuel est ignoré — version texte/tactile, pas d'audio requis).
//
// S'appuie sur la stack LVGL déjà démarrée par bsp_display_start() : on
// construit une UUI (menu d'histoires en liste, puis pages tactiles) et la
// navigation se fait dans les callbacks de boutons LVGL. 100% local, hors-ligne.
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Monte la SD, charge la bibliothèque et affiche le menu tactile des histoires.
// À appeler une fois depuis app_main, après bsp_display_start() et le serveur
// de fichiers (pour que la SD soit accessible). No-op si aucun pack présent.
void gamebook_init(void);
#ifdef __cplusplus
}
#endif
+10
View File
@@ -0,0 +1,10 @@
dependencies:
# ESP32-S3-BOX-3 board support (ILI9341 LCD + GT911 touch). The original
# `espressif/esp-box` BSP targets the first-gen BOX (ST7789/TT21100) and
# leaves a BOX-3 screen black — this is the matching BSP.
espressif/esp-box-3:
version: ">=1.2.0"
espressif/esp-sr:
version: ">=1.4.0"
espressif/esp_websocket_client:
version: ">=1.0.0"
+116
View File
@@ -25,6 +25,13 @@
#include "board_config.h"
#include "voice_ws_client.h"
#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"
@@ -84,6 +91,7 @@ static esp_err_t wifi_init_sta(void)
.ssid = CONFIG_ZACUS_WIFI_SSID,
.password = CONFIG_ZACUS_WIFI_PASSWORD,
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
.channel = CONFIG_ZACUS_WIFI_CHANNEL, /* co-channel master for ESP-NOW relay (0 = auto) */
},
};
@@ -102,6 +110,29 @@ static esp_err_t wifi_init_sta(void)
/* --------------- Test tone (440 Hz sine) --------------- */
// Play a single tone on the speaker (blocking). Public so the stimulus
// 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;
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)
? (total_samples - sample_idx) : 256;
for (int i = 0; i < chunk; i++) {
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);
sample_idx += chunk;
}
}
static void audio_test_tone(void)
{
if (!s_spk_handle) {
@@ -317,6 +348,15 @@ static void button_task(void *arg)
/* Detect falling edge (button press) */
if (last_state && !current) {
/* Virtual PLIP hook first: while ringing or off-hook the BOOT
* button is the hook switch (pickup/hangup), not the streaming
* toggle. */
if (plip_virtual_button_press()) {
vTaskDelay(pdMS_TO_TICKS(300));
last_state = current;
continue;
}
s_voice_streaming = !s_voice_streaming;
ESP_LOGI(TAG, "BOOT button pressed — streaming %s",
s_voice_streaming ? "ON" : "OFF");
@@ -334,6 +374,28 @@ static void button_task(void *arg)
}
}
/* --------------- ESP-NOW CMD text callback --------------- */
// Called from the scenario_mesh text worker task for every CMD/EVT frame
// received via ESP-NOW. On the BOX-3 we only care about CMD (master→us).
static void on_mesh_text(uint8_t kind, const uint8_t src_mac[6], const char *text)
{
if (kind == SCENARIO_MESH_TEXT_CMD) {
ESP_LOGI(TAG, "ESP-NOW CMD from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
src_mac[0], src_mac[1], src_mac[2],
src_mac[3], src_mac[4], src_mac[5], text);
esp_err_t err = cmd_exec_handle(text, strlen(text));
if (err != ESP_OK && err != ESP_ERR_NOT_SUPPORTED) {
ESP_LOGW(TAG, "cmd_exec_handle: %s", esp_err_to_name(err));
}
} else {
// EVT arriving at the BOX-3 (unusual — master echoing back).
ESP_LOGI(TAG, "ESP-NOW EVT from %02x:%02x:%02x:%02x:%02x:%02x: %.80s",
src_mac[0], src_mac[1], src_mac[2],
src_mac[3], src_mac[4], src_mac[5], text);
}
}
/* --------------- Application entry point --------------- */
void app_main(void)
@@ -361,6 +423,9 @@ void app_main(void)
/* Initialize persistent speaker output (used for test tone + TTS playback) */
speaker_init();
/* Pass speaker handle to CMD executor for real WAV playback */
cmd_exec_set_spk_handle(s_spk_handle);
/* Play test tone to verify audio output */
audio_test_tone();
@@ -377,6 +442,57 @@ void app_main(void)
/* Start voice bridge connection task (waits for WiFi, then connects WS) */
xTaskCreate(voice_bridge_task, "voice_bridge", 6144, NULL, 5, NULL);
/* 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. */
if (scenario_server_start() != ESP_OK) {
ESP_LOGW(TAG, "scenario_server_start failed — IR hot-load unavailable");
} else {
/* 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) {
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. */
ESP_LOGW(TAG, "plip_ui_init failed — on-screen phone unavailable");
}
/* Stimulus generator: BOX-3 shows QR / plays melody for the Freenove
* master's camera + mic (POST /stim/qr, POST /stim/melody). */
if (stimulus_init() == ESP_OK) {
stimulus_register_routes(scenario_server_handle());
} 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
* when WiFi is unreachable (battery / RF-noise fallback per the spec). The
* reassembled IR is funnelled through the exact same scenario_apply_buffer()
* path the HTTP handler uses. esp_wifi_start() already ran in
* wifi_init_sta(), so esp_now_init() inside has its prerequisite. */
esp_err_t mesh_err = scenario_mesh_init(scenario_apply_buffer);
if (mesh_err != ESP_OK) {
ESP_LOGW(TAG, "scenario_mesh_init failed: %s — ESP-NOW IR relay unavailable",
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
* - Load WakeNet9 model ("hi esp" or custom)
* - Feed audio frames from mic to WakeNet
+210
View File
@@ -0,0 +1,210 @@
// plip_ui — implementation. See plip_ui.h for the design.
//
// LVGL 9 widget tree, built once under bsp_display_lock(). The plip_virtual
// state callback can fire from any task, so the callback re-takes the lock
// before touching widgets. Touch events (keypad, bottom button) already run
// in the LVGL task context, so they call plip_virtual_* directly.
#include "plip_ui.h"
#include <string.h>
#include "bsp/esp-bsp.h"
#include "esp_log.h"
#include "lvgl.h"
#include "plip_virtual.h"
#define TAG "plip_ui"
#define LOCK_MS 1000
static bool s_built = false;
static lv_obj_t *s_phone_view; // idle / ringing illustration
static lv_obj_t *s_keypad_view; // off-hook dial pad
static lv_obj_t *s_status_lbl; // line under the phone glyph
static lv_obj_t *s_number_lbl; // dialed digits
static lv_obj_t *s_hook_btn; // fixed bottom button
static lv_obj_t *s_hook_lbl; // its label
static char s_number[21] = "";
static const char *KEYS[] = {
"1", "2", "3",
"4", "5", "6",
"7", "8", "9",
"*", "0", "#",
};
// ---------- touch event handlers (LVGL task context) ----------
static void key_event_cb(lv_event_t *e)
{
const char *key = (const char *) lv_event_get_user_data(e);
size_t len = strlen(s_number);
if (len < sizeof(s_number) - 1) {
s_number[len] = key[0];
s_number[len + 1] = '\0';
lv_label_set_text(s_number_lbl, s_number);
// Report the running number to the master; "#" finalises (drop it
// from the buffer first). The master decides what a valid number is.
if (key[0] == '#' && len > 0) {
s_number[len] = '\0';
lv_label_set_text(s_number_lbl, s_number);
}
plip_virtual_dial(s_number);
}
}
static void hook_btn_event_cb(lv_event_t *e)
{
(void) e;
switch (plip_virtual_state()) {
case PLIP_HOOK_RINGING: plip_virtual_pickup(); break;
case PLIP_HOOK_OFF: plip_virtual_hangup(); break;
default: break; // idle: button is disabled, nothing to do
}
}
// ---------- view switching (must hold the display lock) ----------
static void apply_state_locked(plip_hook_state_t st)
{
switch (st) {
case PLIP_HOOK_OFF:
s_number[0] = '\0';
lv_label_set_text(s_number_lbl, "");
lv_obj_add_flag(s_phone_view, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(s_keypad_view, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(s_hook_lbl, "Raccrocher");
lv_obj_set_style_bg_color(s_hook_btn, lv_palette_main(LV_PALETTE_RED), 0);
lv_obj_clear_state(s_hook_btn, LV_STATE_DISABLED);
break;
case PLIP_HOOK_RINGING:
lv_obj_clear_flag(s_phone_view, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(s_keypad_view, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(s_status_lbl, "Appel entrant…");
lv_label_set_text(s_hook_lbl, "Décrocher");
lv_obj_set_style_bg_color(s_hook_btn, lv_palette_main(LV_PALETTE_GREEN), 0);
lv_obj_clear_state(s_hook_btn, LV_STATE_DISABLED);
break;
case PLIP_HOOK_ON:
default:
lv_obj_clear_flag(s_phone_view, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(s_keypad_view, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(s_status_lbl, "PLIP — en attente");
lv_label_set_text(s_hook_lbl, "");
lv_obj_set_style_bg_color(s_hook_btn, lv_palette_darken(LV_PALETTE_GREY, 2), 0);
lv_obj_add_state(s_hook_btn, LV_STATE_DISABLED);
break;
}
}
// plip_virtual callback — arbitrary task context, so take the lock here.
static void on_hook_state(plip_hook_state_t st)
{
if (bsp_display_lock(LOCK_MS)) {
apply_state_locked(st);
bsp_display_unlock();
}
}
// ---------- widget tree ----------
static void build_phone_view(lv_obj_t *parent)
{
s_phone_view = lv_obj_create(parent);
lv_obj_remove_style_all(s_phone_view);
lv_obj_set_size(s_phone_view, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_flow(s_phone_view, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(s_phone_view, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_t *glyph = lv_label_create(s_phone_view);
lv_label_set_text(glyph, LV_SYMBOL_CALL);
lv_obj_set_style_text_font(glyph, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(glyph, lv_palette_main(LV_PALETTE_BLUE), 0);
s_status_lbl = lv_label_create(s_phone_view);
lv_label_set_text(s_status_lbl, "PLIP — en attente");
lv_obj_set_style_pad_top(s_status_lbl, 12, 0);
}
static void build_keypad_view(lv_obj_t *parent)
{
s_keypad_view = lv_obj_create(parent);
lv_obj_remove_style_all(s_keypad_view);
lv_obj_set_size(s_keypad_view, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_flow(s_keypad_view, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(s_keypad_view, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_add_flag(s_keypad_view, LV_OBJ_FLAG_HIDDEN);
s_number_lbl = lv_label_create(s_keypad_view);
lv_label_set_text(s_number_lbl, "");
lv_obj_set_style_text_font(s_number_lbl, &lv_font_montserrat_24, 0);
lv_obj_set_style_pad_bottom(s_number_lbl, 6, 0);
lv_obj_t *grid = lv_obj_create(s_keypad_view);
lv_obj_remove_style_all(grid);
lv_obj_set_size(grid, 240, 150);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
for (size_t i = 0; i < sizeof(KEYS) / sizeof(KEYS[0]); i++) {
lv_obj_t *btn = lv_button_create(grid);
lv_obj_set_size(btn, 64, 40);
lv_obj_add_event_cb(btn, key_event_cb, LV_EVENT_CLICKED,
(void *) KEYS[i]);
lv_obj_t *lbl = lv_label_create(btn);
lv_label_set_text(lbl, KEYS[i]);
lv_obj_center(lbl);
}
}
static void build_hook_button(lv_obj_t *parent)
{
s_hook_btn = lv_button_create(parent);
lv_obj_set_size(s_hook_btn, LV_PCT(96), 44);
lv_obj_align(s_hook_btn, LV_ALIGN_BOTTOM_MID, 0, -4);
lv_obj_add_event_cb(s_hook_btn, hook_btn_event_cb, LV_EVENT_CLICKED, NULL);
s_hook_lbl = lv_label_create(s_hook_btn);
lv_label_set_text(s_hook_lbl, "");
lv_obj_center(s_hook_lbl);
}
esp_err_t plip_ui_init(void)
{
if (s_built) return ESP_OK;
if (!bsp_display_lock(LOCK_MS)) {
ESP_LOGE(TAG, "display lock failed");
return ESP_FAIL;
}
// The voice app starts the display but never lights the backlight — turn
// it on (and to full brightness) or the whole UI is invisible.
bsp_display_brightness_init();
bsp_display_brightness_set(100);
lv_obj_t *scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, lv_color_hex(0x101020), 0);
lv_obj_set_style_text_color(scr, lv_color_white(), 0);
// Content area above the fixed bottom button.
lv_obj_t *content = lv_obj_create(scr);
lv_obj_remove_style_all(content);
lv_obj_set_size(content, LV_PCT(100), 188);
lv_obj_align(content, LV_ALIGN_TOP_MID, 0, 0);
build_phone_view(content);
build_keypad_view(content);
build_hook_button(scr);
apply_state_locked(plip_virtual_state());
bsp_display_unlock();
plip_virtual_register_state_cb(on_hook_state);
s_built = true;
ESP_LOGI(TAG, "phone UI ready");
return ESP_OK;
}
+31
View File
@@ -0,0 +1,31 @@
// plip_ui — on-screen telephone for the phone-less PLIP annex (BOX-3 LCD).
//
// Two views on the 320x240 display, switched by the hook state of
// plip_virtual:
// - idle / ringing : a telephone illustration + status line; during a ring
// the screen shows "Appel entrant…".
// - off-hook : a 3x4 dial keypad (1-9, *, 0, #) with a number field.
//
// A fixed bottom button toggles the hook: "Décrocher" (green) while ringing,
// "Raccrocher" (red) while off-hook. It is disabled when idle.
//
// All LVGL access is guarded with bsp_display_lock/unlock. The widget tree is
// built once; hook-state changes only flip visibility + the bottom button,
// driven by the plip_virtual state callback.
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// Build the phone UI on the active LVGL display and subscribe to
// plip_virtual hook transitions. Call after bsp_display_start() and
// plip_virtual_init(). Idempotent.
esp_err_t plip_ui_init(void);
#ifdef __cplusplus
}
#endif
+319
View File
@@ -0,0 +1,319 @@
// plip_virtual — implementation. See plip_virtual.h for the contract.
//
// Threading: the ring tone runs in its own task (synthesised sine bursts on
// the shared speaker I2S channel, French cadence 1.5 s on / 3.5 s off); hook
// reports run in a tiny worker so the button/touch paths never block on
// HTTP. The UI callback fires from whichever context transitions the state
// (httpd, button task, LVGL touch) — the UI does its own locking.
#include "plip_virtual.h"
#include <math.h>
#include <string.h>
#include "cJSON.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "board_config.h"
#ifndef CONFIG_ZACUS_MASTER_URL
#define CONFIG_ZACUS_MASTER_URL "http://10.2.5.42"
#endif
#define TAG "plip_virtual"
#define RING_FREQ_HZ 440.0f
#define RING_ON_MS 1500 /* French cadence */
#define RING_OFF_MS 3500
#define RING_AMPLITUDE 14000.0f
#define HOOK_QUEUE_DEPTH 4
#define DIAL_MAX 20
typedef struct {
char state[8]; /* "off" | "on" */
char reason[32]; /* "pickup" | "hangup" | "ring_timeout" | "dial:<n>" */
} hook_event_t;
static i2s_chan_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;
static QueueHandle_t s_hook_queue = NULL;
static plip_state_cb_t s_state_cb = NULL;
static char s_dialed[DIAL_MAX + 1] = "";
static void set_state(plip_hook_state_t st)
{
s_state = st;
if (s_state_cb) s_state_cb(st);
}
/* ---------- hook reporting (mirrors PLIP's zacus_hook_client) ---------- */
static void hook_report(const char *state, const char *reason)
{
if (!s_hook_queue) return;
hook_event_t ev;
strlcpy(ev.state, state, sizeof(ev.state));
strlcpy(ev.reason, reason, sizeof(ev.reason));
if (xQueueSend(s_hook_queue, &ev, 0) != pdTRUE) {
ESP_LOGW(TAG, "hook queue full, dropping (%s/%s)", state, reason);
}
}
static void hook_worker_task(void *arg)
{
(void) arg;
hook_event_t ev;
char url[160];
snprintf(url, sizeof(url), "%s/voice/hook", CONFIG_ZACUS_MASTER_URL);
for (;;) {
if (xQueueReceive(s_hook_queue, &ev, portMAX_DELAY) != pdTRUE) continue;
char body[96];
snprintf(body, sizeof(body), "{\"state\":\"%s\",\"reason\":\"%s\"}",
ev.state, ev.reason);
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = 3000,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) continue;
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, body, strlen(body));
esp_err_t err = esp_http_client_perform(client);
if (err == ESP_OK) {
ESP_LOGI(TAG, "hook POST %s -> %d (%s/%s)", url,
esp_http_client_get_status_code(client),
ev.state, ev.reason);
} else {
ESP_LOGW(TAG, "hook POST failed: %s (%s/%s)",
esp_err_to_name(err), ev.state, ev.reason);
}
esp_http_client_cleanup(client);
}
}
/* ---------- ring tone ---------- */
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;
for (int i = 0; i < chunk; i++) {
float t = (float) (idx + i) / (float) AUDIO_SAMPLE_RATE;
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));
idx += chunk;
}
}
static void ring_task(void *arg)
{
int duration_ms = (int) (intptr_t) arg;
int elapsed = 0;
ESP_LOGI(TAG, "ring start (%d ms)", duration_ms);
while (elapsed < duration_ms && !s_ring_stop) {
ring_burst(RING_ON_MS);
elapsed += RING_ON_MS;
if (elapsed >= duration_ms || s_ring_stop) break;
for (int w = 0; w < RING_OFF_MS && !s_ring_stop; w += 100) {
vTaskDelay(pdMS_TO_TICKS(100));
}
elapsed += RING_OFF_MS;
}
if (s_state == PLIP_HOOK_RINGING) {
set_state(PLIP_HOOK_ON); /* nobody picked up */
if (!s_ring_stop) hook_report("on", "ring_timeout");
}
ESP_LOGI(TAG, "ring end (state=%d)", (int) s_state);
s_ring_task = NULL;
vTaskDelete(NULL);
}
static void ring_stop(void)
{
s_ring_stop = true;
/* The task observes the flag within one 100 ms slice and self-deletes. */
for (int i = 0; i < 20 && s_ring_task; i++) vTaskDelay(pdMS_TO_TICKS(50));
s_ring_stop = false;
}
/* ---------- REST handlers ---------- */
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");
return httpd_resp_sendstr(req, body);
}
static esp_err_t handle_ring_post(httpd_req_t *req)
{
int duration_ms = 4000;
if (req->content_len > 0 && req->content_len < 128) {
char body[128];
int got = httpd_req_recv(req, body, req->content_len);
if (got > 0) {
body[got] = '\0';
cJSON *root = cJSON_Parse(body);
const cJSON *d = root ? cJSON_GetObjectItem(root, "duration_ms") : NULL;
if (cJSON_IsNumber(d) && d->valueint > 0 && d->valueint <= 60000) {
duration_ms = d->valueint;
}
cJSON_Delete(root);
}
}
if (s_state == PLIP_HOOK_OFF) {
return send_json(req, "409 Conflict", "{\"error\":\"off-hook\"}");
}
if (s_ring_task) ring_stop();
set_state(PLIP_HOOK_RINGING);
if (xTaskCreate(ring_task, "plip_ring", 4096,
(void *) (intptr_t) duration_ms, 4, &s_ring_task) != pdPASS) {
set_state(PLIP_HOOK_ON);
return send_json(req, "500 Internal Server Error",
"{\"error\":\"ring task failed\"}");
}
char resp[64];
snprintf(resp, sizeof(resp), "{\"ok\":true,\"duration_ms\":%d}", duration_ms);
return send_json(req, "200 OK", resp);
}
static esp_err_t handle_stop_post(httpd_req_t *req)
{
if (s_ring_task) ring_stop();
if (s_state == PLIP_HOOK_RINGING) set_state(PLIP_HOOK_ON);
return send_json(req, "200 OK", "{\"ok\":true}");
}
static esp_err_t handle_play_post(httpd_req_t *req)
{
/* Audio on the BOX-3 flows through the voice-bridge WS TTS path; a file
* player duplicating that would lie about capabilities. */
return send_json(req, "501 Not Implemented",
"{\"error\":\"use the voice bridge TTS path\"}");
}
static esp_err_t handle_status_get(httpd_req_t *req)
{
char resp[128];
snprintf(resp, sizeof(resp),
"{\"off_hook\":%s,\"ringing\":%s,\"playing\":false,"
"\"dialed\":\"%s\"}",
s_state == PLIP_HOOK_OFF ? "true" : "false",
s_state == PLIP_HOOK_RINGING ? "true" : "false",
s_dialed);
return send_json(req, "200 OK", resp);
}
/* ---------- public API ---------- */
void plip_virtual_pickup(void)
{
if (s_state != PLIP_HOOK_RINGING) return;
ring_stop();
s_dialed[0] = '\0';
set_state(PLIP_HOOK_OFF);
ESP_LOGI(TAG, "virtual pickup");
hook_report("off", "pickup");
}
void plip_virtual_hangup(void)
{
if (s_state != PLIP_HOOK_OFF) return;
set_state(PLIP_HOOK_ON);
ESP_LOGI(TAG, "virtual hangup");
hook_report("on", "hangup");
}
void plip_virtual_dial(const char *number)
{
if (s_state != PLIP_HOOK_OFF || !number || !*number) return;
strlcpy(s_dialed, number, sizeof(s_dialed));
char reason[32];
snprintf(reason, sizeof(reason), "dial:%s", s_dialed);
ESP_LOGI(TAG, "dialed \"%s\"", s_dialed);
hook_report("off", reason);
}
bool plip_virtual_button_press(void)
{
switch (s_state) {
case PLIP_HOOK_RINGING:
plip_virtual_pickup();
return true;
case PLIP_HOOK_OFF:
plip_virtual_hangup();
return true;
default:
return false; /* on-hook, not ringing: not ours */
}
}
void plip_virtual_register_state_cb(plip_state_cb_t cb)
{
s_state_cb = cb;
}
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)
{
if (!server || !spk) return ESP_ERR_INVALID_ARG;
s_spk = spk;
s_hook_queue = xQueueCreate(HOOK_QUEUE_DEPTH, sizeof(hook_event_t));
if (!s_hook_queue) return ESP_ERR_NO_MEM;
if (xTaskCreate(hook_worker_task, "plip_hook", 4096, NULL, 3, NULL) != pdPASS) {
vQueueDelete(s_hook_queue);
s_hook_queue = NULL;
return ESP_FAIL;
}
static const httpd_uri_t uri_ring = {
.uri = "/ring", .method = HTTP_POST,
.handler = handle_ring_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_stop = {
.uri = "/stop", .method = HTTP_POST,
.handler = handle_stop_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_play = {
.uri = "/play", .method = HTTP_POST,
.handler = handle_play_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_status = {
.uri = "/status", .method = HTTP_GET,
.handler = handle_status_get, .user_ctx = NULL,
};
httpd_register_uri_handler(server, &uri_ring);
httpd_register_uri_handler(server, &uri_stop);
httpd_register_uri_handler(server, &uri_play);
httpd_register_uri_handler(server, &uri_status);
ESP_LOGI(TAG, "virtual PLIP up (POST /ring /stop /play, GET /status; "
"BOOT button = hook switch; master=%s)", CONFIG_ZACUS_MASTER_URL);
return ESP_OK;
}
+63
View File
@@ -0,0 +1,63 @@
// plip_virtual — phone-less PLIP annex running on the ESP32-S3-BOX-3.
//
// Implements the PLIP REST contract (PLIP_FIRMWARE/src/network_task.cpp) on
// the box's existing HTTP server:
// POST /ring { "duration_ms": 4000 } → ring cadence on the BOX-3 speaker
// POST /stop → stop ringing
// POST /play → 501 (audio path is the WS TTS)
// GET /status → { off_hook, ringing, playing }
//
// The BOOT button is the virtual hook switch: a press during the ring picks
// up, a press while off-hook hangs up. Each transition is reported to the
// Zacus master (POST CONFIG_ZACUS_MASTER_URL/voice/hook {state, reason})
// exactly like the real PLIP's zacus_hook_client — the master cannot tell
// the two annexes apart.
#pragma once
#include <stdbool.h>
#include "driver/i2s_std.h"
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
// Hook state, exposed for the on-screen phone UI (plip_ui).
typedef enum {
PLIP_HOOK_ON = 0, // idle, handset down
PLIP_HOOK_RINGING,
PLIP_HOOK_OFF, // picked up
} plip_hook_state_t;
// 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);
// 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
// handling (voice-streaming toggle).
bool plip_virtual_button_press(void);
// Programmatic hook transitions (used by the touch UI). Both are no-ops
// when the state does not allow the transition.
void plip_virtual_pickup(void); // RINGING -> OFF (stops the ring)
void plip_virtual_hangup(void); // OFF -> ON
// Report a dialed number to the master (reason "dial:<number>" on the
// existing /voice/hook contract) and remember it for GET /status.
// Only meaningful while off-hook; ignored otherwise.
void plip_virtual_dial(const char *number);
// UI notification: called on every hook state change (from HTTP, button or
// touch context — the callback must do its own LVGL locking).
typedef void (*plip_state_cb_t)(plip_hook_state_t state);
void plip_virtual_register_state_cb(plip_state_cb_t cb);
plip_hook_state_t plip_virtual_state(void);
#ifdef __cplusplus
}
#endif
+394
View File
@@ -0,0 +1,394 @@
// scenario_server.c — minimal HTTP server for receiving Runtime 3 IR scenarios
// on the ESP32-S3-BOX-3. Mirrors the master's game_endpoint handler but is
// self-contained (no shared component) since box3_voice is a separate IDF
// project. Storage uses the existing SPIFFS partition declared in
// partitions.csv (master uses LittleFS — both work, we match the local table).
#include "scenario_server.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "cJSON.h"
#include "cmd_exec.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_spiffs.h"
#include "esp_system.h"
#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)
#define SPIFFS_LABEL "spiffs"
#define SPIFFS_BASE "/spiffs"
#define SCENARIO_PATH SPIFFS_BASE "/scenario.json"
#define SCENARIO_BAK SPIFFS_BASE "/scenario.bak"
static httpd_handle_t s_server = NULL;
static bool s_spiffs_mounted = false;
// ---------- helpers ----------
static esp_err_t send_json(httpd_req_t *req, const char *status_line, const char *body) {
httpd_resp_set_status(req, status_line);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
return httpd_resp_sendstr(req, body);
}
static esp_err_t send_error(httpd_req_t *req, const char *status_line, const char *message) {
char buf[192];
snprintf(buf, sizeof(buf), "{\"error\":\"%s\"}", message ? message : "");
return send_json(req, status_line, buf);
}
static esp_err_t mount_spiffs_lazy(void) {
if (s_spiffs_mounted) return ESP_OK;
esp_vfs_spiffs_conf_t conf = {
.base_path = SPIFFS_BASE,
.partition_label = SPIFFS_LABEL,
.max_files = 6,
.format_if_mount_failed = true,
};
esp_err_t err = esp_vfs_spiffs_register(&conf);
if (err == ESP_OK || err == ESP_ERR_INVALID_STATE) {
s_spiffs_mounted = true;
ESP_LOGI(TAG, "spiffs '%s' mounted at %s", conf.partition_label, conf.base_path);
return ESP_OK;
}
ESP_LOGE(TAG, "spiffs mount failed: %s", esp_err_to_name(err));
return err;
}
static void deferred_restart_task(void *arg) {
(void) arg;
vTaskDelay(pdMS_TO_TICKS(800));
ESP_LOGW(TAG, "scenario hot-load: rebooting to apply new IR");
esp_restart();
}
static void schedule_restart(void) {
xTaskCreate(deferred_restart_task, "scenario_restart",
4096, NULL, tskIDLE_PRIORITY + 1, NULL);
}
// ---------- handlers ----------
static esp_err_t handle_healthz_get(httpd_req_t *req) {
httpd_resp_set_type(req, "text/plain");
return httpd_resp_sendstr(req, "ok");
}
// Shared internal apply path. Returns ESP_OK on success and fills the optional
// out-params; on failure returns a specific esp_err_t and (if non-NULL) sets a
// static reason string. The HTTP handler and the ESP-NOW receiver both call
// this — the single `_scenario_apply` the spec mandates.
static esp_err_t scenario_apply_internal(const char *body, size_t len,
int *steps_count_out,
char *entry_out, size_t entry_cap,
const char **err_msg_out) {
if (err_msg_out) *err_msg_out = NULL;
if (steps_count_out) *steps_count_out = 0;
if (entry_out && entry_cap) entry_out[0] = '\0';
if (!body || len == 0 || len > MAX_SCENARIO_BYTES) {
if (err_msg_out) *err_msg_out = "body must be 1..65536 bytes";
return ESP_ERR_INVALID_SIZE;
}
if (mount_spiffs_lazy() != ESP_OK) {
if (err_msg_out) *err_msg_out = "spiffs mount failed";
return ESP_FAIL;
}
cJSON *root = cJSON_Parse(body);
if (!root) {
if (err_msg_out) *err_msg_out = "malformed json";
return ESP_ERR_INVALID_ARG;
}
const cJSON *schema = cJSON_GetObjectItemCaseSensitive(root, "schema_version");
if (!cJSON_IsString(schema) || strcmp(schema->valuestring, "zacus.runtime3.v1") != 0) {
cJSON_Delete(root);
if (err_msg_out) *err_msg_out = "schema_version must be zacus.runtime3.v1";
return ESP_ERR_INVALID_ARG;
}
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
cJSON_Delete(root);
if (err_msg_out) *err_msg_out = "steps must be a non-empty array";
return ESP_ERR_INVALID_ARG;
}
const cJSON *scenario_obj = cJSON_GetObjectItemCaseSensitive(root, "scenario");
const cJSON *entry = scenario_obj
? cJSON_GetObjectItemCaseSensitive(scenario_obj, "entry_step_id") : NULL;
if (entry_out && entry_cap && cJSON_IsString(entry) && entry->valuestring) {
strncpy(entry_out, entry->valuestring, entry_cap - 1);
entry_out[entry_cap - 1] = '\0';
}
int steps_count = cJSON_GetArraySize(steps);
cJSON_Delete(root);
if (steps_count_out) *steps_count_out = steps_count;
// Rotate current -> .bak
struct stat st;
if (stat(SCENARIO_PATH, &st) == 0) {
unlink(SCENARIO_BAK);
if (rename(SCENARIO_PATH, SCENARIO_BAK) != 0) {
ESP_LOGW(TAG, "rename .json -> .bak failed (errno=%d)", errno);
}
}
FILE *f = fopen(SCENARIO_PATH, "wb");
if (!f) {
ESP_LOGE(TAG, "fopen %s for write failed (errno=%d)", SCENARIO_PATH, errno);
if (err_msg_out) *err_msg_out = "scenario write open failed";
return ESP_FAIL;
}
size_t written = fwrite(body, 1, len, f);
fclose(f);
if (written != len) {
unlink(SCENARIO_PATH);
if (stat(SCENARIO_BAK, &st) == 0) rename(SCENARIO_BAK, SCENARIO_PATH);
if (err_msg_out) *err_msg_out = "scenario write short";
return ESP_FAIL;
}
ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s",
len, steps_count, (entry_out && entry_cap) ? entry_out : "");
schedule_restart();
return ESP_OK;
}
// Public thin wrapper used by the ESP-NOW receiver (matches
// scenario_mesh_apply_cb_t: esp_err_t (*)(const char *, size_t)).
esp_err_t scenario_apply_buffer(const char *data, size_t len) {
const char *emsg = NULL;
esp_err_t err = scenario_apply_internal(data, len, NULL, NULL, 0, &emsg);
if (err != ESP_OK) {
ESP_LOGW(TAG, "ESP-NOW scenario rejected: %s",
emsg ? emsg : esp_err_to_name(err));
}
return err;
}
static esp_err_t handle_scenario_post(httpd_req_t *req) {
if (req->content_len <= 0 || req->content_len > MAX_SCENARIO_BYTES) {
ESP_LOGW(TAG, "POST /game/scenario: bad body length %d", (int) req->content_len);
return send_error(req, "413 Payload Too Large", "body must be 1..65536 bytes");
}
char *body = (char *) malloc((size_t) req->content_len + 1);
if (!body) return send_error(req, "500 Internal Server Error", "out of memory");
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;
free(body);
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
int steps_count = 0;
char entry_str[64] = {0};
const char *emsg = NULL;
esp_err_t aerr = scenario_apply_internal(body, (size_t) total, &steps_count,
entry_str, sizeof(entry_str), &emsg);
free(body);
if (aerr != ESP_OK) {
const char *status = (aerr == ESP_ERR_INVALID_ARG ||
aerr == ESP_ERR_INVALID_SIZE)
? "400 Bad Request" : "500 Internal Server Error";
return send_error(req, status, emsg ? emsg : esp_err_to_name(aerr));
}
char buf[256];
snprintf(buf, sizeof(buf),
"{\"status\":\"ok\",\"board\":\"box3_voice\",\"steps_count\":%d,"
"\"entry_step_id\":\"%s\",\"bytes\":%d,\"reload\":\"reboot_pending\"}",
steps_count, entry_str, total);
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) {
return s_server;
}
esp_err_t scenario_server_start(void) {
if (s_server) {
ESP_LOGW(TAG, "scenario_server already running");
return ESP_OK;
}
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 80;
cfg.max_uri_handlers = 12;
cfg.stack_size = 8192;
esp_err_t err = httpd_start(&s_server, &cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "httpd_start: %s", esp_err_to_name(err));
return err;
}
static const httpd_uri_t uri_healthz = {
.uri = "/healthz", .method = HTTP_GET,
.handler = handle_healthz_get, .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_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, POST /game/file, POST /game/cmd)");
return ESP_OK;
}
+30
View File
@@ -0,0 +1,30 @@
// scenario_server.h — start the BOX-3 minimal HTTP server that accepts
// POST /game/scenario (Runtime 3 IR hot-load via reboot).
#pragma once
#include <stddef.h>
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t scenario_server_start(void);
// Handle of the running server (NULL before scenario_server_start succeeds).
// Lets other modules (plip_virtual) register their URIs on the same port 80.
httpd_handle_t scenario_server_handle(void);
// Shared internal apply path (the spec's `_scenario_apply`): validate a
// Runtime 3 IR blob, atomically write it to SPIFFS, and schedule the hot-reload
// reboot. Used both by the HTTP POST /game/scenario handler and by the ESP-NOW
// receiver (scenario_mesh). `data` need not be NUL-terminated beyond `len`.
// Returns ESP_OK on success; ESP_ERR_INVALID_ARG / _INVALID_SIZE on bad input,
// ESP_FAIL on storage errors.
esp_err_t scenario_apply_buffer(const char *data, size_t len);
#ifdef __cplusplus
}
#endif
+170
View File
@@ -0,0 +1,170 @@
// stimulus.c — QR display + melody playback, driven over REST.
#include "stimulus.h"
#include <math.h>
#include <string.h>
#include "bsp/esp-box-3.h"
#include "lvgl.h"
#include "libs/qrcode/lv_qrcode.h"
#include "cJSON.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "stimulus";
#define LOCK_MS 1000
static lv_obj_t *s_qr_screen; // dedicated fullscreen QR screen
static lv_obj_t *s_qr; // lv_qrcode widget
static lv_obj_t *s_qr_caption;
esp_err_t stimulus_init(void) {
if (!bsp_display_lock(LOCK_MS)) return ESP_FAIL;
s_qr_screen = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_qr_screen, lv_color_white(), 0);
// 160 px QR on the 320x240 LCD — leaves ~40 px white top/bottom and
// ~80 px sides as the quiet zone quirc needs to lock the finder patterns
// (220 px starved the vertical quiet zone and blocked detection).
s_qr = lv_qrcode_create(s_qr_screen);
lv_qrcode_set_size(s_qr, 160);
lv_qrcode_set_dark_color(s_qr, lv_color_black());
lv_qrcode_set_light_color(s_qr, lv_color_white());
lv_qrcode_update(s_qr, "zacus", 5);
lv_obj_align(s_qr, LV_ALIGN_CENTER, 0, -10);
s_qr_caption = lv_label_create(s_qr_screen);
lv_obj_set_style_text_color(s_qr_caption, lv_color_black(), 0);
lv_label_set_text(s_qr_caption, "");
lv_obj_align(s_qr_caption, LV_ALIGN_BOTTOM_MID, 0, -10);
bsp_display_unlock();
ESP_LOGI(TAG, "stimulus QR screen ready");
return ESP_OK;
}
static void show_qr(const char *text) {
if (!s_qr || !bsp_display_lock(LOCK_MS)) return;
lv_qrcode_update(s_qr, text, strlen(text));
lv_label_set_text(s_qr_caption, text);
lv_screen_load(s_qr_screen); // bring the QR to the front
bsp_display_unlock();
// Dim the backlight hard: an emissive LCD at full brightness blooms and
// crushes QR contrast for the master's camera. ~35% keeps the modules
// readable while killing the glare halo.
bsp_display_brightness_set(35);
ESP_LOGI(TAG, "QR shown (dimmed): %s", text);
}
// MIDI note -> frequency (A4=69=440 Hz).
static float midi_to_hz(int note) {
return 440.0f * powf(2.0f, (float)(note - 69) / 12.0f);
}
// ─── REST ────────────────────────────────────────────────────────────────────
static esp_err_t read_body(httpd_req_t *req, char *buf, size_t cap) {
if (req->content_len <= 0 || (size_t)req->content_len >= cap) return ESP_FAIL;
int total = 0;
while (total < (int)req->content_len) {
int got = httpd_req_recv(req, buf + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return ESP_FAIL;
}
total += got;
}
buf[total] = '\0';
return ESP_OK;
}
static esp_err_t reply(httpd_req_t *req, const char *status, const char *json) {
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "application/json");
return httpd_resp_sendstr(req, json);
}
static esp_err_t handle_qr_post(httpd_req_t *req) {
char body[256];
if (read_body(req, body, sizeof(body)) != ESP_OK) {
return reply(req, "400 Bad Request", "{\"error\":\"body\"}");
}
cJSON *root = cJSON_Parse(body);
const cJSON *t = root ? cJSON_GetObjectItemCaseSensitive(root, "text") : NULL;
if (!cJSON_IsString(t) || !t->valuestring[0]) {
cJSON_Delete(root);
return reply(req, "400 Bad Request", "{\"error\":\"text required\"}");
}
show_qr(t->valuestring);
char resp[280];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"text\":\"%s\"}",
t->valuestring);
cJSON_Delete(root);
return reply(req, "200 OK", resp);
}
// Melody played on a worker task so the HTTP response returns immediately.
typedef struct { float hz[32]; int count; int note_ms; } melody_job_t;
static void melody_task(void *arg) {
melody_job_t *job = (melody_job_t *)arg;
for (int i = 0; i < job->count; i++) {
audio_play_tone(job->hz[i], job->note_ms);
vTaskDelay(pdMS_TO_TICKS(40)); // brief gap = note onset for the mic
}
free(job);
vTaskDelete(NULL);
}
static esp_err_t handle_melody_post(httpd_req_t *req) {
char body[512];
if (read_body(req, body, sizeof(body)) != ESP_OK) {
return reply(req, "400 Bad Request", "{\"error\":\"body\"}");
}
cJSON *root = cJSON_Parse(body);
const cJSON *notes = root ? cJSON_GetObjectItemCaseSensitive(root, "notes") : NULL;
if (!cJSON_IsArray(notes) || cJSON_GetArraySize(notes) == 0) {
cJSON_Delete(root);
return reply(req, "400 Bad Request", "{\"error\":\"notes required\"}");
}
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(root, "ms");
const int note_ms = cJSON_IsNumber(ms) ? ms->valueint : 400;
melody_job_t *job = calloc(1, sizeof(*job));
if (!job) { cJSON_Delete(root); return reply(req, "500 Internal Server Error", "{\"error\":\"oom\"}"); }
job->note_ms = (note_ms > 0 && note_ms <= 4000) ? note_ms : 400;
const cJSON *n;
cJSON_ArrayForEach(n, notes) {
if (job->count >= 32) break;
if (cJSON_IsNumber(n) && n->valueint >= 0 && n->valueint <= 127) {
job->hz[job->count++] = midi_to_hz(n->valueint);
}
}
cJSON_Delete(root);
if (job->count == 0) { free(job); return reply(req, "400 Bad Request", "{\"error\":\"no valid notes\"}"); }
if (xTaskCreate(melody_task, "melody", 4096, job, 5, NULL) != pdPASS) {
free(job);
return reply(req, "503 Service Unavailable", "{\"error\":\"busy\"}");
}
char resp[64];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"notes\":%d}", job->count);
return reply(req, "200 OK", resp);
}
esp_err_t stimulus_register_routes(httpd_handle_t server) {
static const httpd_uri_t uri_qr = {
.uri = "/stim/qr", .method = HTTP_POST,
.handler = handle_qr_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_melody = {
.uri = "/stim/melody", .method = HTTP_POST,
.handler = handle_melody_post, .user_ctx = NULL,
};
esp_err_t e = httpd_register_uri_handler(server, &uri_qr);
if (e == ESP_OK) e = httpd_register_uri_handler(server, &uri_melody);
if (e == ESP_OK) ESP_LOGI(TAG, "routes up: POST /stim/qr, POST /stim/melody");
return e;
}
+28
View File
@@ -0,0 +1,28 @@
// stimulus.h — BOX-3 as a stimulus generator for the Freenove master's
// camera (QR) and microphone (melody). Lets the master's local puzzles be
// exercised end-to-end without printed cards or an instrument.
//
// REST (registered on the existing scenario_server httpd):
// POST /stim/qr {"text":"zacus-qr-1"} -> show QR fullscreen
// POST /stim/melody {"notes":[60,62,64,65],"ms":400} -> play melody
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
// Provided by main.c — play one tone (blocking) on the BOX-3 speaker.
void audio_play_tone(float frequency, int duration_ms);
// Build the QR screen (under bsp_display_lock). Call once after the display
// and UI are up.
esp_err_t stimulus_init(void);
// Register /stim/* routes on an existing server.
esp_err_t stimulus_register_routes(httpd_handle_t server);
#ifdef __cplusplus
}
#endif
+24
View File
@@ -44,3 +44,27 @@ CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
# Heap: place large buffers in PSRAM
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=4096
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768
# WiFi credentials — set via `idf.py menuconfig` (Zacus BOX-3 Voice
# Configuration) or via a local sdkconfig override that is NOT committed.
# Defaults stay empty so credentials never land in git.
# 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).
CONFIG_I2C_ENABLE_LEGACY_DRIVERS=y
# PLIP phone UI fonts (plip_ui.c)
CONFIG_LV_FONT_MONTSERRAT_24=y
CONFIG_LV_FONT_MONTSERRAT_48=y
# Stimulus generator (QR display for the master camera, melody for its mic)
CONFIG_LV_USE_QRCODE=y
+149
View File
@@ -0,0 +1,149 @@
# ESP-NOW scenario receiver — patch to report onto PLIP + puzzles
Status: **box3_voice done**; **puzzle nodes done** (defensive demux in the
shared `espnow_slave.c`, see "Resolution" below); **PLIP done** (brought back
into scope on 2026-06-10 — dedicated receiver `src/scenario_now.{h,cpp}` in
`PLIP_FIRMWARE`, see "PLIP" below).
Spec: `docs/specs/2026-05-24-firmware-scenario-hotload.md`, task 6
("Receiver side on each peer").
## Resolution (2026-06-09)
Investigation of the real firmware changed the plan from "vendor a reassembler
into each node" to "**demux defensively, treat as no-op consumer**", because:
- **No puzzle node runs a Runtime 3 scenario.** `p7_coffre` (the final lock)
receives its 8-digit code via `MSG_PUZZLE_CONFIG` (8 bytes), not an IR; the
others are driven entirely by `MSG_*` commands. None has a LittleFS/SPIFFS
scenario store. Pushing a full scenario to them is genuinely a no-op.
- The real risk is **stream corruption**, not a missing feature: a multi-frame
scenario relay misrouted to a puzzle MAC would inject frames whose `data[0]`
equals the frame `seq` low byte — e.g. `seq==1 → 0x01 == MSG_PUZZLE_SOLVED`.
So the receiver was added **once** to the shared `lib/espnow_common/espnow_slave.c`
(compiled into all four puzzles): frames are demultiplexed in
`espnow_slave_process()` (task context, not the ISR), reassembled per source MAC,
and handed to an **optional** `espnow_scenario_callback_t`. Puzzle nodes register
no callback, so a reassembled scenario is logged and dropped — and, critically,
never reaches the `MSG_*` path. A future node that does consume scenarios opts in
via `espnow_slave_register_scenario_callback()`. No per-puzzle code changed; no
new component; bounded heap reassembly (≤64 KiB), 5 s sender-silence timeout.
The original per-node inline-reassembler sketch below is kept for historical
context; the shared-file approach above supersedes it.
## What box3_voice got (the reference implementation)
box3_voice is a standalone IDF project that does **not** use the legacy
ESP-NOW slave. It received:
1. `components/scenario_mesh/` — a vendored copy of the master's component
(frame protocol + reassembly + alias→MAC table). Identical bytes to
`idf_zacus/components/scenario_mesh/`.
2. `scenario_server.c` refactored so the validate+write path is a reusable
`scenario_apply_buffer(const char *data, size_t len)` (declared in
`scenario_server.h`). Both the HTTP `POST /game/scenario` handler and the
ESP-NOW receiver call it — the single `_scenario_apply` the spec mandates.
3. `main.c` calls `scenario_mesh_init(scenario_apply_buffer)` after Wi-Fi /
`scenario_server_start()`.
box3 could take the component wholesale because it owns its ESP-NOW stack — it
does **not** register any other `esp_now_register_recv_cb`.
## Why PLIP + puzzles can't just drop the component in
The puzzle nodes already own the single ESP-NOW receive callback via
`lib/espnow_common/espnow_slave.c` (`esp_now_register_recv_cb(on_recv)`).
ESP-IDF allows **one** recv callback per process. `scenario_mesh_init()` calls
`esp_now_register_recv_cb()` too, so calling it after `espnow_slave_init()`
would silently steal the puzzle command stream (or vice-versa). The two
protocols must be **demultiplexed inside the one existing callback**.
Frame discriminator (no wire-format change needed):
- Legacy puzzle frames: `data[0]` is a `MSG_*` type in `0x01..0x08`
(see `espnow_slave.h`).
- scenario_mesh frames: `data[0..1]` = `seq` (u16 LE), `data[2..3]` = `total`
(u16 LE), then payload. For the first frame `seq==0` so `data[0]==0x00`,
which never collides with a `MSG_*` type. A scenario frame is also always
`>= 4` bytes with `total >= 1` and `seq < total`.
So: **`data[0] == 0x00` (and `len >= 4`) ⇒ scenario frame; otherwise legacy.**
## Patch for each puzzle node (`p1`, `p5`, `p6`, `p7_coffre`)
These share `lib/espnow_common/espnow_slave.c`, so patch it **once** there
(it is compiled into each puzzle via the `../../../lib/espnow_common/espnow_slave.c`
SRC entry already present in every puzzle `main/CMakeLists.txt`).
1. Add the reassembler. Either:
- vendor `scenario_mesh` as a component **but do not let it register the
recv cb** (add a `scenario_mesh_feed_frame(const uint8_t *src, const
uint8_t *data, int len)` entry point and a `scenario_mesh_init_passive()`
that skips `esp_now_register_recv_cb`), or
- inline a ~60-LOC reassembler keyed by `(src_mac, total)` straight into
`espnow_slave.c` (simplest; no new component).
2. In `espnow_slave.c::on_recv`, branch before the queue push:
```c
static void on_recv(const esp_now_recv_info_t *info,
const uint8_t *data, int len) {
if (len >= 4 && data[0] == 0x00) { // scenario frame
scenario_mesh_feed_frame(info->src_addr, data, len);
return;
}
/* …existing puzzle-command path (queue push)… */
}
```
On full reassembly the reassembler writes the JSON to the node's local
filesystem and calls `scenario_engine_reload()` (or, until that symbol
lands, the puzzle's equivalent of `scenario_apply_buffer()` +
deferred `esp_restart()`, mirroring box3).
3. Each puzzle needs a `scenario_apply_buffer()` equivalent. The puzzle nodes
currently have no LittleFS/SPIFFS scenario store — if a node is purely
driven by ESP-NOW puzzle commands and has no IR of its own, task 6 may be a
no-op for it. Confirm per node before adding storage: `p7_coffre` (the
final-code lock) is the most likely to actually consume a scenario.
## PLIP (`PLIP_FIRMWARE`) — done (re-scoped 2026-06-10)
Originally resolved out of scope on 2026-06-09 (Wi-Fi/HTTP-only client, no
ESP-NOW stack). Re-scoped in on 2026-06-10 by explicit request: PLIP now has a
dedicated receiver, `PLIP_FIRMWARE/src/scenario_now.{h,cpp}` (Arduino C++,
matching the `zacus_hook_client` worker-task pattern).
- Same wire format as `scenario_mesh` / the shared `espnow_slave.c`:
4-byte header `{ seq:u16 LE, total:u16 LE }`, <=236 payload bytes, bounded
heap reassembly (<=64 KiB), 5 s sender-silence timeout.
- PLIP registers no other `esp_now_register_recv_cb`, so the module owns the
single callback — no MSG_* demux needed (unlike the puzzle nodes).
- A completed scenario is persisted to LittleFS at `/scenario.json`
(temp-then-rename); an optional `scenario_now_register_callback()` hook lets
a future Runtime 3 engine consume it. With no consumer registered the
scenario is stored and logged.
- `network_task.cpp` calls `scenario_now_init()` once the station is up
(ESP-NOW rides the AP's channel); the call is idempotent and repeated on
reconnect.
The HTTP path (`POST /game/scenario` on PLIP's future REST server) remains the
recommended push channel once that server lands; the ESP-NOW receiver covers
the relay/fallback case in the meantime.
## Shared-protocol drift risk — resolved 2026-06-10
`scenario_mesh` was vendored byte-identical in two places
(`idf_zacus/components/` and `box3_voice/components/`). It now lives **once**
at `lib/scenario_mesh`, referenced by both projects via
`EXTRA_COMPONENT_DIRS` in their root CMakeLists — the follow-up suggested
below is done; both firmwares rebuilt green after the hoist.
Two independent reimplementations of the *frame format* remain by design
(different runtimes, not copies): the puzzle nodes' demux inside the shared
`lib/espnow_common/espnow_slave.c`, and PLIP's Arduino-side
`PLIP_FIRMWARE/src/scenario_now.cpp`. If the 4-byte header
`{ seq:u16 LE, total:u16 LE }` or the 236-byte payload cap ever changes,
update those two alongside `lib/scenario_mesh`.
+5
View File
@@ -0,0 +1,5 @@
build/
managed_components/
dependencies.lock
sdkconfig
sdkconfig.old
+14
View File
@@ -0,0 +1,14 @@
# Zacus master ESP-IDF project
# Coexists with the Arduino tree under ui_freenove_allinone/ — this scaffold
# is the future home of the master firmware (P1 of the voice pipeline spec
# 2026-05-03-voice-pipeline-esp-sr-design.md).
cmake_minimum_required(VERSION 3.16)
# Local components live under idf_zacus/components/ (e.g. ota_server inherited
# from the 2026-04-03 IDF bootstrap). scenario_mesh is shared with box3_voice
# and lives in the repo-level lib/ (single copy, no protocol drift).
set(EXTRA_COMPONENT_DIRS components ../lib/scenario_mesh)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(zacus_master)
+77
View File
@@ -0,0 +1,77 @@
# QEMU smoke test for `idf_zacus`
## What QEMU can do today
- Boot the firmware end-to-end (NVS init, partition table, app_main).
- Validate that new components do not break boot.
- Surface any link-time / runtime init crashes that escape the build.
Tested 2026-05-24: firmware with the new `POST /game/scenario` handler boots
cleanly to `app_main()` in QEMU 9.0.0 (esp_develop build).
## What QEMU can NOT do (yet)
- **WiFi radio**: stubbed. The board comes up in AP fallback but no station
ever associates, so the IP netif never gets an address and the HTTP server
(which waits for `IP_EVENT_STA_GOT_IP`) doesn't bind.
- **PSRAM**: QEMU's esp32s3 machine does not emulate the Octal PSRAM the
Freenove N16R8 ships with. Use `sdkconfig.qemu` to disable.
- **esp-sr / WakeNet**: depends on PSRAM, also disabled in `sdkconfig.qemu`.
- **WiFi-driven HTTP smoke**: see the "future work" section below.
## Run
```bash
. $HOME/esp/esp-idf/export.sh
export PATH=$HOME/.espressif/tools/qemu-xtensa/esp_develop_9.0.0_20240606/qemu/bin:$PATH
# clean reconfigure with the QEMU overrides
rm -rf build sdkconfig
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.qemu" set-target esp32s3
idf.py build
# launch with port forward for the future ethernet integration
idf.py qemu --qemu-extra-args="-nic user,model=open_eth,hostfwd=tcp::8580-:80"
# in another terminal — when HTTP arrives, this is the smoke test
curl -sS http://127.0.0.1:8580/healthz # → "ok"
curl -sS -X POST -H "Content-Type: application/json" \
--data @../../../game/scenarios/zacus_cond_demo.ir.json \
http://127.0.0.1:8580/game/scenario
```
Press `Ctrl-A x` to exit the QEMU console.
## Restore the production build
The `sdkconfig.qemu` overrides break the real board (no PSRAM = no esp-sr =
no voice pipeline). To return to the canonical config:
```bash
rm -rf build sdkconfig
idf.py set-target esp32s3 # picks sdkconfig.defaults only
idf.py build flash monitor # real board path
```
`sdkconfig.qemu` is committed but never used by the default build — only when
explicitly listed in `SDKCONFIG_DEFAULTS`.
## Future work — HTTP smoke in QEMU
The main blocker is `main.c`: it gates `ota_server_init()` / `game_endpoint_init()`
on a WiFi `IP_EVENT_STA_GOT_IP` callback. To unblock HTTP testing under QEMU
without WiFi:
1. Add a `CONFIG_ZACUS_QEMU_ETHERNET=y` Kconfig option in `main/Kconfig.projbuild`
(default `n`).
2. In `app_main()`, if the option is set, initialise the `esp_eth` driver against
the `open_eth` NIC and use its `IP_EVENT_ETH_GOT_IP` event to start the HTTP
stack — same lifecycle as the WiFi path, different transport.
3. Add `CONFIG_ZACUS_QEMU_ETHERNET=y` to `sdkconfig.qemu`.
Once that lands, `curl http://127.0.0.1:8580/game/scenario` from the host hits
the real handler inside QEMU and we get a true integration test of the
hot-load path (scenario validation, LittleFS write, deferred reboot).
Estimated effort: ~80 LOC + Kconfig + one `esp_eth_open_eth_new()` glue —
half a day of work.
+228
View File
@@ -0,0 +1,228 @@
# `idf_zacus/` — firmware master Zacus (ESP-IDF)
Firmware **master** du jeu *Le Mystère du Professeur Zacus*, pour la carte
**Freenove FNK0102H — ESP32-S3 WROOM N16R8** (16 MB flash, 8 MB PSRAM octale).
Ce n'est plus une *slice* de migration : c'est le master complet. Le firmware
réunit aujourd'hui :
- **Voix NPC** — capture I2S + esp-sr (AFE + WakeNet9, mot-clé placeholder
`wn9_hiesp` / « Hi ESP »), pont WebSocket vers le voice-bridge MacStudio pour
la STT et le retour TTS, plus un *hook* REST PLIP (téléphone rétro).
- **Énigmes locales** caméra et micro résolues sur l'appareil (P1 son /
P3 QR), agrégées en un code de sortie.
- **Écran LVGL** (ST7796 320×480) : vue statut, vue scène, viewfinder caméra,
shell Workbench, navigation 5 directions.
- **Surface REST de jeu** (pilotage scénario, étapes, profil de groupe,
relais ESP-NOW) et **serveur OTA** double-banque.
Le firmware Arduino historique (`../ui_freenove_allinone/`, PlatformIO) reste la
source de vérité matérielle (pin-maps, drivers de référence), mais l'IDF est
désormais le firmware master complet.
## Prérequis, build, flash
ESP-IDF v5.4 installé sous `~/esp/esp-idf/`.
> **QUIRK de cette machine** — l'environnement Python par défaut d'IDF échoue
> ici. Il faut forcer le venv py3.11 **avant** de sourcer `export.sh` :
>
> ```bash
> export IDF_PYTHON_ENV_PATH=$HOME/.espressif/python_env/idf5.4_py3.11_env
> source ~/esp/esp-idf/export.sh
> ```
Build :
```bash
idf.py set-target esp32s3
idf.py build
```
Flash + monitor (le port de cette machine est fixe) :
```bash
idf.py -p /dev/cu.usbmodem5AB90753301 flash monitor
```
Sortie du monitor : `Ctrl-]`.
Au premier `idf.py build`, le component manager clone LovyanGFX (tag 1.2.21,
épinglé par SHA) depuis GitHub et récupère LVGL `~8.4.0`, esp-sr, esp-dsp,
mdns, esp_websocket_client et littlefs dans `managed_components/`. Les modèles
esp-sr sont flashés automatiquement (`srmodels.bin`) dans la partition `model`.
## Carte des composants
Tous les composants vivent sous `components/` et sont des composants ESP-IDF
standard (chacun avec son `include/`).
| Composant | Rôle |
|---|---|
| `ota_server` | Serveur HTTP `:80` : OTA double-banque (`POST /ota`, `POST /ota/rollback`), watchdog d'auto-rollback, `GET /version` / `/status`. Détient l'instance `esp_http_server` partagée par les autres endpoints. |
| `game_endpoint` | Surface REST de jeu greffée sur le httpd d'`ota_server` : profil de groupe hints, pilotage scénario, étapes (`/game/step`), `puzzle_state`, relais ESP-NOW. Voir son `README.md`. |
| `voice_pipeline` | Capture I2S + esp-sr (AFE `AFE_TYPE_SR`, WakeNet9), machine d'états idle/listening/speaking/muted, pont WebSocket vers le voice-bridge (STT/TTS), lecture TTS sur DAC. Inclut `voice_dispatcher` (routage STT → npc_engine, fast-path mots-clés). |
| `voice_hook_endpoint` | Pont REST `POST /voice/hook` depuis le PLIP (téléphone Si3210) : off-hook arme la voix directement (bypass wake-word), on-hook ferme la session. Greffé sur le httpd d'`ota_server`. |
| `npc_engine` | Port C du moteur de décision NPC Arduino : table de cues, humeur, déclenchement de cues via `media_manager`. |
| `hints_client` | Client HTTP du moteur de hints (`POST /hints/ask`, `/hints/puzzle_start`, `/hints/attempt_failed`), profil de groupe (`TECH` / `NON_TECH` / `MIXED` / `BOTH`). |
| `media_manager` | Port C du MediaManager Arduino : catalogue, play/stop, enregistrement WAV. Décodage MP3 et capture micro stubés ici (le micro réel passe par `mic_broker`). |
| `local_puzzles` | Énigmes locales caméra/micro et leur câblage vers `puzzle_state` : `qr_puzzle`, `sound_puzzle`, `mic_broker`, validateurs `seq_validator` / `melody_validator`, pin-map `board_pins_mediakit.h`. |
| `puzzle_state` | Agrégation master des énigmes résolues et assemblage du **code** de sortie (jusqu'à 8 énigmes, fragments → chiffres décimaux). |
| `display_ui` | Écran LVGL 8.4 sur LovyanGFX : vues statut/scène, viewfinder caméra, effets, shell Workbench, browser de fichiers, intro cracktro ; boutons 5 directions (`buttons_input`). |
## Séquence de boot (`main/main.c`)
`app_main` exécute, dans l'ordre :
1. **NVS** — init (efface + réinit si pages pleines / nouvelle version).
2. **Écran**`display_ui_init()` (splash tôt, non fatal). Si OK : enregistre
`display_ui_camera_frame` comme callback de preview QR, puis
`buttons_input_init()` (5 directions).
3. **Wi-Fi** — lit les creds NVS (namespace `wifi`, clés `ssid`/`pwd`). Si
présents : **STA** (attente `GOT_IP`, 8 retries, timeout 20 s). Sinon, ou
échec : **AP** ouvert de secours `zacus-setup` (IP `192.168.4.1`).
4. **mDNS** — uniquement en STA : publie `zacus-master.local` et le service
`_zacus._tcp:80` (TXT `path=/voice/hook`). Sauté en AP-fallback.
5. **OTA server**`ota_server_init()` (`:80`). Sur succès, greffe sur le même
httpd : `voice_hook_endpoint_init()` puis `game_endpoint_init()` (qui monte
aussi `scenario_mesh` / ESP-NOW), seed du registre de peers relais depuis
NVS (namespace `peers`), et `game_endpoint_set_puzzle_state(&s_pstate)`.
6. **LittleFS** — monte la partition `storage` sur `/littlefs` (reformat si
échec) et liste la racine. Puis, sous ce montage :
- `media_manager_init()` (catalogues sur LittleFS) + smoke-test de lecture ;
- `npc_engine_init()` (table de cues vide à ce stade) ;
- `hints_client_init()` vers le backend hints, puis chargement du
`group_profile` depuis NVS (namespace `zacus`, défaut `MIXED`) ;
- `voice_dispatcher_init()` ;
- `puzzle_state_init()` + `local_puzzles_init()` ;
- `mic_broker_init()` sur les pins micro Media Kit (3/14/46, 16 kHz) **avant**
`voice_pipeline_init()` (le pipeline no-op alors avec `ESP_ERR_INVALID_STATE`) ;
- `voice_pipeline_init()` : wake-word + auto-start capture activés, URL du
voice-bridge, lecture TTS activée, **pins forcés** aux valeurs Media Kit
(mic 3/14/46, HP 42/41/1) pour éviter la collision avec les pins caméra.
7. **`ota_server_mark_valid()`** — valide l'image (désamorce l'auto-rollback)
une fois les sous-systèmes montés.
8. **Boucle de statut** — réveil toutes les 500 ms : rafraîchit l'écran
(IP, état wake-word, étape/armé, code assemblé, métadonnées de scène),
traite les lancements d'apps du shell (`/littlefs/apps/<id>/step.txt`
`game_endpoint_apply_step`). Toutes les 60 s : heartbeat + `media_manager_update`
+ `npc_engine_update`.
URLs hardcodées (déplacées en NVS dans un suivi) : hints
`http://192.168.0.150:8302`, voice-bridge WS `ws://100.116.92.12:8200/voice/ws`.
## Énigmes locales (P1 son / P3 QR)
`local_puzzles` arme deux types d'énigmes et reporte leur fragment dans
`puzzle_state` à la résolution :
- **P1 — son/mélodie** (`local_puzzles_arm_sound`) : `mic_broker` (propriétaire
unique du micro I2S RX) passe en mode `MIC_P1_SOUND` et route les trames PCM16
vers `sound_puzzle`, qui détecte les notes et les valide via
`melody_validator` (notes MIDI + tolérance en demi-tons). Le **même broker**
est partagé avec la voix (`MIC_NPC_LISTEN`) : un seul propriétaire des pins,
un consommateur actif à la fois.
- **P3 — QR séquentiel** (`local_puzzles_arm_qr`) : `qr_puzzle` initialise la
caméra (QVGA 320×240 niveaux de gris), décode avec **quirc**, et valide
l'ordre des codes via `seq_validator`. Les trames sont mirrorées au
viewfinder de l'écran via le callback de preview. Teardown caméra
asynchrone (réarmement à retenter après ~100 ms).
### Pin-map Media Kit (`board_pins_mediakit.h`)
Freenove FNK0102H Media Kit v1.2, **zéro collision** entre caméra, micro,
HP et écran :
- **Caméra** (capteur réel : **OV3660**) — XCLK 15, SIOD/SIOC 4/5, VSYNC 6,
HREF 7, PCLK 13, D0D7 = 11/9/8/10/12/18/17/16.
- **Micro I2S IN** — BCLK 3, WS 14, DIN 46.
- **HP I2S OUT** (MAX98357A) — BCLK 42, LRC 41, DOUT 1.
- **Écran ST7796 (SPI2/FSPI, 80 MHz)** — SCK 47, MOSI 21, DC 45, RST 20, BL 2.
> Les défauts du composant `voice_pipeline` (mic 14/15/22, HP 11/12/13)
> **entrent en collision** avec les pins caméra ; `main.c` les écrase par les
> valeurs Media Kit ci-dessus. Ne pas réintroduire les défauts.
### Validateurs testables sur hôte
`seq_validator` et `melody_validator` sont de la logique pure, sans I/O,
compilables et testables sur la machine de dev :
```bash
make -C idf_zacus/components/local_puzzles/test/host test
```
## Écran (`display_ui`)
Pile **LVGL 8.4 + LovyanGFX** (driver ST7796, `setAddrWindow` / `writePixels`
RGB565). Tâche de rendu dédiée (LovyanGFX n'est pas thread-safe inter-tâches) ;
`display_ui_set_status` copie un snapshot sous mutex.
Vues et fonctions :
- **Vue statut** — IP, wake-word, étape/armé, compte d'énigmes résolues, code.
- **Vue scène** — titre/sous-titre/code de l'étape, avec effets
(`pulse` / `glitch` / `gyro` / `none`) issus de l'IR de scène.
- **Viewfinder caméra** — `lv_canvas` en PSRAM, alimenté par les trames QR
(grayscale → RGB565, ~5 fps) quand la vue scène est active et le QR armé.
- **Shell Workbench** — port d'`ui_amiga_shell` : tuiles statiques (Statut,
Scene, Auto, Lumiere, Fichiers) + tuiles dynamiques d'apps lues dans
`/littlefs/apps/<id>/` (`icon.png` optionnel, `step.txt` = l'étape à armer).
- **Browser de fichiers** — drawer LittleFS navigable.
- **Intro cracktro** — port fidèle de `ui_manager_intro` (starfield parallaxe
3 couches, copper bars).
Navigation **5 directions** (`buttons_input`, échelle analogique sur GPIO19,
ADC2) : 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP. Shell fermé : SELECT /
LEFT-RIGHT bascule scène↔statut, MENU ouvre le shell, UP/DOWN règlent la
luminosité. Shell ouvert : navigation grille, SELECT lance la tuile.
## Surface REST de jeu
Tous les endpoints de jeu partagent l'instance `esp_http_server` d'`ota_server`
(port 80, pas de second socket). Détail des routes (`/game/group_profile`,
`/game/step`, `/game/puzzle_state`, `/game/scenario`, `/game/scenario/relay`…)
et de la persistance NVS dans **`components/game_endpoint/README.md`**.
## Tests hôte
Trois suites de logique pure tournent sur la machine de dev (Unity), sans
matériel ni IDF flashé :
| Suite | Cas | Commande |
|---|---|---|
| `local_puzzles` (validateurs séquence + mélodie) | 6 | `make -C idf_zacus/components/local_puzzles/test/host test` |
| `puzzle_state` (agrégation + assemblage du code) | 2 | `make -C idf_zacus/components/puzzle_state/test/host test` |
| `game_endpoint` (binding puzzle/scène) | 10 | `make -C idf_zacus/components/game_endpoint/test/host test` |
## Layout flash (`partitions.csv`)
Cible 16 MB (N16R8) :
| Partition | Type | Taille |
|---|---|---|
| `nvs` | data/nvs | 24 KB |
| `otadata` | data/ota | 8 KB |
| `phy_init` | data/phy | 4 KB |
| `factory` / `ota_0` / `ota_1` | app | **3 MB chacune** |
| `model` | data/spiffs | 1 MB (modèles esp-sr) |
| `storage` | data/littlefs | 5 MB (assets, scénario, apps) |
La partition app est passée de 2 MB à **3 MB** (2026-06-10) pour loger
LVGL + LovyanGFX + polices + effets/preview. NVS/otadata/phy gardent leurs
offsets (les creds Wi-Fi survivent au reflash) ; `ota_0`/`ota_1`/`model`/
`storage` se décalent, donc **le contenu LittleFS est perdu au reflash**
(reformatage auto ; repousser le scénario via `POST /game/scenario`).
## Limites connues / terrain
- **Décodage QR sur écran LCD émissif** : lire un QR **affiché à l'écran** via
la caméra **ne fonctionne pas de façon fiable** — quirc ne retrouve pas les
motifs (contraste insuffisant sur un panneau émissif). Le **QR imprimé** reste
la méthode recommandée pour P3.
- **Note repérée dans le code** : le commentaire d'en-tête de `qr_puzzle.h`
mentionne encore « OV2640 » ; le capteur réel de la carte est **OV3660**
(commentaire à corriger, sans impact fonctionnel sur le chemin grayscale/quirc).
- **Provisioning** : creds Wi-Fi, URL hints et URL voice-bridge passent encore
partiellement par des constantes / NVS pré-flashé ; le passage tout-NVS au
runtime est un suivi.
@@ -0,0 +1,12 @@
idf_component_register(
SRCS "display_ui.cpp"
"intro_fx3d.cpp"
"buttons_input.c"
"fonts/lv_font_orbitron_40.c"
"fonts/lv_font_ibmplexmono_18.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES driver local_puzzles esp_timer esp_adc
)
# LovyanGFX requires C++17.
target_compile_options(${COMPONENT_LIB} PRIVATE -std=gnu++17)
+47
View File
@@ -0,0 +1,47 @@
# display_ui
Écran ST7796 (320×480, paysage) du master Freenove, piloté via **LovyanGFX** +
**LVGL 8.4**. Port fidèle de l'UI Arduino d'origine (`ui_freenove_allinone`).
Tous les appels LVGL et LovyanGFX s'exécutent exclusivement sur la
`display_task` ; `display_ui_set_status` copie l'état sous mutex et lève un flag
dirty que la tâche replie dans les labels.
## Pile
- **HAL panneau** — `Panel_ST7796` sur `Bus_SPI` (SPI2_HOST/FSPI, 80 MHz
écriture / 20 MHz lecture). Configuration **identique** à l'UI Arduino
d'origine (`FreenoveLgfxDevice` dans `ui_freenove_allinone`) : pins
SCK=47/MOSI=21/DC=45/RST=20/BL=2, rotation 1, inversion on, ordre RGB.
- **Rétroéclairage PWM** — LEDC sur `LEDC_TIMER_1` / `LEDC_CHANNEL_1`. Le
`LEDC_TIMER_0` / `LEDC_CHANNEL_0` est réservé au **XCLK de la caméra**
(`qr_puzzle`). Duty exposé 0-100 % via `display_ui_set_brightness`.
- **Vue statut** + **vue scène** — la vue scène affiche le step courant et le
code assemblé, avec un effet (**pulse** = respiration d'opacité, **glitch** =
jitter + scintillement du titre, **gyro** = anneau-gyrophare rotatif) et un
**viewfinder caméra** : `lv_canvas` PSRAM nourri par les trames grayscale du
scan QR, converties en **RGB565** (~5 fps), visible seulement quand l'énigme
QR est armée.
- **Boutons 5 directions** — échelle résistive sur ADC (GPIO19 → ADC2_CH8),
seuils en mV repris de l'original (`{0, 447, 730, 1008, 1307, 1659}`, plancher
« relâché » 2800 mV, décodage par milieu entre seuils adjacents).
- **Shell Workbench** — tuiles builtin **Statut / Scene / Auto / Lumiere /
Fichiers**, plus des **apps dynamiques** chargées depuis
`/littlefs/apps/<id>/` (`icon.png` optionnel + `step.txt` = action de l'app).
- **Browser de fichiers** — drawer parcourant `/littlefs`.
- **Intro cracktro** — port fidèle de la phase A d'origine : starfield parallaxe
3 couches en **Q8.8**, copper bars, scrolltext en onde sinus, logo avec ombre
portée.
## API publique (`display_ui.h`)
- `display_ui_init()` — init panneau + splash, spawn la tâche de refresh.
- `display_ui_set_status(s)` — copie un snapshot d'état et demande un redraw
async (thread-safe ; NULL = no-op).
- `display_ui_set_brightness(pct)` — duty 0-100 % (défaut 100).
- `display_ui_camera_frame(gray, w, h)` — pousse une trame caméra pour le
viewfinder (câblé comme preview callback de `qr_puzzle`) ; non-QVGA ignorée.
- `display_ui_handle_key(key)` — traite une touche 5 directions (1=SELECT
2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP).
- `display_ui_take_pending_launch(id_out, cap)` — récupère (one-shot) une
demande de lancement d'app du shell dynamique.
@@ -0,0 +1,133 @@
// buttons_input.c — 5-way analog ladder scan (IDF port of ButtonManager).
//
// Faithful to ui_freenove_allinone/src/drivers/input/button_manager.cpp:
// - ladder thresholds (mV): {0, 447, 730, 1008, 1307, 1659} for keys 1..5
// - "no button" floor: 2800 mV
// - decode: midpoint splits between adjacent thresholds
// - debounce: 30 ms of stable raw key before an event fires
// - scan period: 20 ms (~50 Hz)
// Differences vs the original: long-press detection is not ported yet (no
// consumer needs it); events go straight to display_ui_handle_key().
#include "buttons_input.h"
#include "display_ui.h"
#include "board_pins_mediakit.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "buttons";
// GPIO19 → ADC2_CHANNEL_8 on the ESP32-S3.
#define BTN_ADC_UNIT ADC_UNIT_2
#define BTN_ADC_CHANNEL ADC_CHANNEL_8
#define BTN_DEBOUNCE_MS 30
#define BTN_SCAN_MS 20
static const int kThresholdsMv[6] = {0, 447, 730, 1008, 1307, 1659};
static const int kNoButtonMv = 2800;
static adc_oneshot_unit_handle_t s_adc;
static adc_cali_handle_t s_cali;
static bool s_cali_ok;
static uint8_t decode_key(int mv) {
if (mv >= (kThresholdsMv[5] + kNoButtonMv) / 2) return 0; // released
if (mv < (kThresholdsMv[1] + kThresholdsMv[2]) / 2) return 1;
if (mv < (kThresholdsMv[2] + kThresholdsMv[3]) / 2) return 2;
if (mv < (kThresholdsMv[3] + kThresholdsMv[4]) / 2) return 3;
if (mv < (kThresholdsMv[4] + kThresholdsMv[5]) / 2) return 4;
return 5;
}
static void scan_task(void *arg) {
(void) arg;
uint8_t raw_key = 0;
TickType_t raw_changed = xTaskGetTickCount();
uint8_t reported_key = 0;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(BTN_SCAN_MS));
int raw = 0;
// ADC2 + Wi-Fi arbitration can time out — skip the sample.
if (adc_oneshot_read(s_adc, BTN_ADC_CHANNEL, &raw) != ESP_OK) continue;
int mv;
if (s_cali_ok) {
if (adc_cali_raw_to_voltage(s_cali, raw, &mv) != ESP_OK) continue;
} else {
// crude conversion without calibration: 12-bit, ~3100 mV span
mv = (raw * 3100) / 4095;
}
const uint8_t key = decode_key(mv);
const TickType_t now = xTaskGetTickCount();
if (key != raw_key) {
raw_key = key;
raw_changed = now;
continue;
}
if ((now - raw_changed) < pdMS_TO_TICKS(BTN_DEBOUNCE_MS)) continue;
// Stable. Fire on press transitions only (release → key).
if (key != reported_key) {
reported_key = key;
if (key != 0) {
display_ui_handle_key(key);
}
}
}
}
esp_err_t buttons_input_init(void) {
adc_oneshot_unit_init_cfg_t ucfg = {
.unit_id = BTN_ADC_UNIT,
.ulp_mode = ADC_ULP_MODE_DISABLE,
};
esp_err_t err = adc_oneshot_new_unit(&ucfg, &s_adc);
if (err != ESP_OK) {
ESP_LOGE(TAG, "adc_oneshot_new_unit: %s", esp_err_to_name(err));
return err;
}
adc_oneshot_chan_cfg_t ccfg = {
.atten = ADC_ATTEN_DB_12, // full-range, like the original 11dB
.bitwidth = ADC_BITWIDTH_12,
};
err = adc_oneshot_config_channel(s_adc, BTN_ADC_CHANNEL, &ccfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "adc channel cfg: %s", esp_err_to_name(err));
adc_oneshot_del_unit(s_adc);
s_adc = NULL;
return err;
}
adc_cali_curve_fitting_config_t cal = {
.unit_id = BTN_ADC_UNIT,
.chan = BTN_ADC_CHANNEL,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_12,
};
s_cali_ok = (adc_cali_create_scheme_curve_fitting(&cal, &s_cali) == ESP_OK);
if (!s_cali_ok) {
ESP_LOGW(TAG, "no ADC calibration — using crude raw→mV conversion");
}
if (xTaskCreate(scan_task, "btn_scan", 3072, NULL, 4, NULL) != pdPASS) {
ESP_LOGE(TAG, "scan task create failed");
adc_oneshot_del_unit(s_adc);
s_adc = NULL;
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "5-way ladder up (GPIO19/ADC2_CH8, debounce %d ms)",
BTN_DEBOUNCE_MS);
return ESP_OK;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
# Fonts — copied from ui_freenove_allinone/src/ui/fonts/ (original Arduino UI).
# Generated LVGL 8 bitmap fonts (see each file header for lv_font_conv options).
# Orbitron 40: scene titles/symbol. IBM Plex Mono 18: scene body text.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
## display_ui managed-component manifest.
##
## LovyanGFX is not published on the Espressif component registry; it is
## distributed as a standard ESP-IDF component on GitHub. The IDF component
## manager supports git: sources — on the first `idf.py build` it clones the
## repo at the requested tag into managed_components/.
##
## Trust note: lovyan03/LovyanGFX is the SAME upstream the original Arduino
## firmware already depends on (platformio.ini line 34: lovyan03/LovyanGFX@1.2.7).
## Pinned below to the exact commit SHA of release tag 1.2.21 so the dependency
## is reproducible and tamper-evident (a moved tag cannot change what we build).
dependencies:
lovyan03/LovyanGFX:
git: https://github.com/lovyan03/LovyanGFX.git
version: "4e689dba65135c2d91b180dc0a27a3cedebcfb5e" # tag 1.2.21
# LVGL from the Espressif registry (checksummed). Pinned to the same
# 8.4.x line as the original firmware (platformio.ini:35 lvgl/lvgl@8.4.0)
# so the original screens port unchanged in later phases.
lvgl/lvgl:
version: "~8.4.0"
@@ -0,0 +1,22 @@
// buttons_input.h — 5-way analog ladder on GPIO19 (FNK0102H), IDF port of
// ui_freenove_allinone's ButtonManager (same thresholds, debounce, semantics).
//
// Key numbers follow the original firmware: 1=SELECT 2=DOWN 3=MENU
// 4=LEFT/RIGHT 5=UP. Events are delivered to display_ui_handle_key() from a
// dedicated scan task (50 Hz, 30 ms debounce).
//
// GPIO19 is ADC2 on the ESP32-S3 — concurrent Wi-Fi can make individual
// reads fail with ESP_ERR_TIMEOUT; those samples are silently skipped (the
// 50 Hz scan re-samples immediately after).
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t buttons_input_init(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,146 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
char step_id[64]; // current scenario step ("" = none)
char armed[8]; // "qr" | "sound" | "none"
char code[16]; // assembled puzzle code
uint8_t solved_count;
char ip[16]; // STA IP ("" = none)
bool wake_active; // wake-word detector up
// Scene metadata (from the step's optional `scene` IR object; empty
// strings = fall back to step_id / armed / code). Sizes mirror
// puzzle_binding.h SB_MAX_*.
char scene_title[48];
char scene_subtitle[64];
char scene_symbol[16];
uint8_t scene_effect; // scene_effect_t value (0=pulse 1=glitch 2=gyro 3=none)
} display_status_t;
/**
* @brief Initialise the ST7796 panel via LovyanGFX and show a splash screen.
*
* Must be called once from app_main after NVS init but before the idle loop.
* Non-fatal: a failure is logged and the rest of the firmware continues.
* The internal refresh task is spawned here; it polls for status changes
* every 250 ms.
*
* @return ESP_OK on success, or a driver error code.
*/
esp_err_t display_ui_init(void);
/**
* @brief Copy a new status snapshot and request an async redraw.
*
* Thread-safe. The copy is protected by an internal mutex; the actual
* rendering happens on the display task (LovyanGFX is not thread-safe
* across tasks).
*
* @param s Pointer to the caller's status struct. May be NULL (no-op).
*/
void display_ui_set_status(const display_status_t *s);
/**
* @brief Set the backlight brightness (LEDC PWM on LEDC_TIMER_1/CHANNEL_1).
*
* @param pct 0..100 (values above 100 are clamped). Default after init: 100.
*/
void display_ui_set_brightness(uint8_t pct);
/**
* @brief Push a camera frame for the scene-view viewfinder (QR aiming aid).
*
* Thread-safe; intended as the qr_puzzle preview callback (wired in main.c).
* Copies the grayscale QVGA buffer; the display task converts it to RGB565
* and refreshes the canvas at ~5 fps. Frames are shown only while the scene
* view is active with the QR puzzle armed. Non-QVGA frames are ignored.
*/
void display_ui_camera_frame(const uint8_t *gray, int width, int height);
/**
* @brief Handle a 5-way key press (called from the buttons scan task).
*
* Original key numbering: 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP.
* Shell closed: SELECT / LEFT-RIGHT toggle scene↔status; MENU opens the
* Workbench shell; UP/DOWN step the backlight brightness.
* Shell open: grid navigation per the original ui_amiga_shell semantics
* (SELECT launches the tile — Statut / Scene / Auto / Lumiere; MENU closes).
* Thread-safe (atomic request flags consumed by the display task; the
* brightness path drives LEDC directly, which has its own locking).
*/
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.
*
* Dynamic shell tiles (from /littlefs/apps/<id>/) set a pending launch on
* SELECT; the main loop polls this and performs the app action (e.g. read
* the app's step.txt and call game_endpoint_apply_step). One-shot: returns
* true at most once per launch.
*/
bool display_ui_take_pending_launch(char *id_out, size_t cap);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,37 @@
// Intro cracktro — 3D FX phases B/C/D (rotozoom, dot sphere, ray corridor).
// Faithful per-pixel ports of ui_freenove_allinone/src/ui/fx/fx_engine.cpp
// (renderMidRotoZoom, renderDotSphere3D, renderRayCorridor) rendered at
// 240x160 in a PSRAM buffer then pixel-doubled to the 480x320 LVGL canvas.
#pragma once
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define FX3D_W 240
#define FX3D_H 160
typedef enum {
FX3D_ROTOZOOM = 0,
FX3D_DOTSPHERE = 1,
FX3D_CORRIDOR = 2,
FX3D_STARFIELD = 3, // renderStarfield3D — z-flight + rotation + trails
FX3D_VOXEL = 4, // renderVoxelLandscape — raycast heightfield
FX3D_WIRECUBE = 5, // v9 WireCubeFx — Bresenham wireframe cube
FX3D_MODE_COUNT = 6,
} fx3d_mode_t;
// Allocate the low-res buffer + LUTs/textures (PSRAM). Idempotent.
bool fx3d_init(void);
// Render `mode` at time t_ms into dst (RGB565, dst_w x dst_h) with 2x pixel
// doubling. dst must be exactly FX3D_W*2 x FX3D_H*2; anything else is a no-op.
void fx3d_render(fx3d_mode_t mode, uint32_t t_ms, uint16_t *dst,
int dst_w, int dst_h);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,528 @@
// Intro cracktro 3D FX — ports of the original Arduino FxEngine renderers
// (ui_freenove_allinone/src/ui/fx/fx_engine.cpp): renderMidRotoZoom,
// renderDotSphere3D (+ kDotSphere3D init), renderRayCorridor (+ kRayCorridor
// init). Only the render loops are ported — no CapsAllocator, timelines or
// DMA line buffers. Everything renders into a 240x160 RGB565 low-res buffer
// (PSRAM) then gets pixel-doubled into the caller's 480x320 canvas.
#include "intro_fx3d.h"
#include <cmath>
#include <cstring>
#include "esp_heap_caps.h"
namespace {
constexpr int kRotoTexSize = 128; // FxEngine kRotoTexSize
constexpr int kRayTexSize = 64; // FxEngine kRayTexSize
constexpr int kDotCount = 360; // ~W*H/75 clamped (original formula)
constexpr int kDotRadius = 72; // min_dim/2 - 8 clamped to [24,72]
constexpr int kDotBlobR = 2;
constexpr int kStar3DCount = 400; // ~W*H/50, capped for the 10 ms tick
constexpr int kVoxelMaxDist = 96; // FxEngine voxel_max_dist_
struct DotPt { int16_t x, y, z; };
struct Star3D { int16_t x, y; uint16_t z; };
uint16_t *s_lowres; // FX3D_W * FX3D_H
uint16_t *s_roto_tex; // 128 * 128
uint16_t *s_ray_tex; // 64 * 64
DotPt *s_dots;
Star3D *s_stars3d;
uint16_t s_dot_shade[256];
int8_t s_ray_col_off[FX3D_W];
uint16_t s_ray_floor_q12[FX3D_H];
int16_t s_sin_q15[256];
uint8_t s_voxel_height[256];
uint16_t s_voxel_pal[256];
uint16_t s_voxel_proj_q8[kVoxelMaxDist + 1];
uint32_t s_rng = 0x5EED1234u;
bool s_ready = false;
uint32_t next_rand(void) {
s_rng ^= s_rng << 13; s_rng ^= s_rng >> 17; s_rng ^= s_rng << 5;
return s_rng;
}
// ---- helpers (FxEngine::rgb565 / mul565_u8 / addSat565 / sin8) ----
uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) {
return (uint16_t) (((r & 0xF8u) << 8) | ((g & 0xFCu) << 3) | (b >> 3));
}
uint16_t mul565_u8(uint16_t c, uint8_t v) {
uint16_t r = (uint16_t) ((c >> 11) & 31u);
uint16_t g = (uint16_t) ((c >> 5) & 63u);
uint16_t b = (uint16_t) (c & 31u);
r = (uint16_t) ((r * v + 128u) >> 8);
g = (uint16_t) ((g * v + 128u) >> 8);
b = (uint16_t) ((b * v + 128u) >> 8);
return (uint16_t) ((r << 11) | (g << 5) | b);
}
uint16_t add_sat565(uint16_t a, uint16_t b) {
uint16_t ar = (uint16_t) ((a >> 11) & 31u), ag = (uint16_t) ((a >> 5) & 63u), ab = (uint16_t) (a & 31u);
uint16_t br = (uint16_t) ((b >> 11) & 31u), bg = (uint16_t) ((b >> 5) & 63u), bb = (uint16_t) (b & 31u);
const uint16_t rr = (uint16_t) ((ar + br > 31u) ? 31u : (ar + br));
const uint16_t gg = (uint16_t) ((ag + bg > 63u) ? 63u : (ag + bg));
const uint16_t b2 = (uint16_t) ((ab + bb > 31u) ? 31u : (ab + bb));
return (uint16_t) ((rr << 11) | (gg << 5) | b2);
}
int16_t sin_q15(uint8_t a) { return s_sin_q15[a]; }
int16_t cos_q15(uint8_t a) { return s_sin_q15[(uint8_t) (a + 64u)]; }
// fx_sin8/fx_cos8 equivalents: amplitude -128..127.
int16_t sin8(uint8_t a) { return (int16_t) (s_sin_q15[a] >> 8); }
int16_t cos8(uint8_t a) { return (int16_t) (s_sin_q15[(uint8_t) (a + 64u)] >> 8); }
template <typename T>
T clampv(T v, T lo, T hi) { return (v < lo) ? lo : (v > hi) ? hi : v; }
void fill_lowres(uint16_t color) {
// 32-bit fill (buffer is 4-byte aligned, FX3D_W*FX3D_H even)
const uint32_t packed = (uint32_t) color | ((uint32_t) color << 16);
uint32_t *dst32 = (uint32_t *) s_lowres;
for (size_t i = 0; i < (size_t) FX3D_W * FX3D_H / 2; i++) dst32[i] = packed;
}
void add_pixel(int x, int y, uint16_t color) {
if (x < 0 || y < 0 || x >= FX3D_W || y >= FX3D_H) return;
const size_t idx = (size_t) y * FX3D_W + (size_t) x;
s_lowres[idx] = add_sat565(s_lowres[idx], color);
}
void set_pixel(int x, int y, uint16_t color) {
if (x < 0 || y < 0 || x >= FX3D_W || y >= FX3D_H) return;
s_lowres[(size_t) y * FX3D_W + (size_t) x] = color;
}
// Bresenham, additive (v9 WireCubeFx::line_, max-blend approximated by add).
void add_line(int x0, int y0, int x1, int y1, uint16_t color) {
int dx = (x1 > x0) ? (x1 - x0) : (x0 - x1);
const int sx = (x0 < x1) ? 1 : -1;
int dy = (y1 > y0) ? (y0 - y1) : (y1 - y0); // -abs
const int sy = (y0 < y1) ? 1 : -1;
int err = dx + dy;
for (;;) {
add_pixel(x0, y0, color);
if (x0 == x1 && y0 == y1) break;
const int e2 = err << 1;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
void *psram_alloc(size_t bytes) {
void *p = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM);
if (!p) p = heap_caps_malloc(bytes, MALLOC_CAP_DEFAULT);
return p;
}
// ---- renderers (faithful ports) ----
// FxEngine::renderMidRotoZoom — additive rotozoom of the radial-checker
// texture generated in FxEngine::begin().
void render_rotozoom(uint32_t now_ms) {
fill_lowres(rgb565(4u, 8u, 16u));
const int cx = FX3D_W / 2, cy = FX3D_H / 2;
const uint8_t phase = (uint8_t) ((now_ms / 10u) & 0xFFu);
const int16_t s = sin8(phase);
const int16_t c = cos8(phase);
const int16_t pulse = (int16_t) (sin8((uint8_t) (phase * 2u)) >> 1);
const int16_t zoom_q8 = (int16_t) (256 + pulse);
for (int y = 0; y < FX3D_H; y++) {
const int16_t dy = (int16_t) (y - cy);
const size_t row = (size_t) y * FX3D_W;
for (int x = 0; x < FX3D_W; x++) {
const int16_t dx = (int16_t) (x - cx);
int32_t u = (c * dx - s * dy);
int32_t v = (s * dx + c * dy);
u = (u * zoom_q8) >> 8;
v = (v * zoom_q8) >> 8;
const int tx = (int) ((u + kRotoTexSize / 2) & (kRotoTexSize - 1));
const int ty = (int) ((v + kRotoTexSize / 2) & (kRotoTexSize - 1));
const uint16_t tex = s_roto_tex[(size_t) ty * kRotoTexSize + (size_t) tx];
s_lowres[row + x] = add_sat565(s_lowres[row + x], mul565_u8(tex, 180u));
}
}
}
// FxEngine::renderDotSphere3D — lit point sphere, Q15 LUT rotations.
void render_dotsphere(uint32_t now_ms) {
fill_lowres(0x0000u);
const uint8_t ax = (uint8_t) (now_ms >> 4);
const uint8_t ay = (uint8_t) (now_ms >> 5);
const uint8_t az = (uint8_t) (now_ms >> 6);
const int16_t cx_r = cos_q15(ax), sx_r = sin_q15(ax);
const int16_t cy_r = cos_q15(ay), sy_r = sin_q15(ay);
const int16_t cz_r = cos_q15(az), sz_r = sin_q15(az);
const int16_t lx = (int16_t) (0.30f * 32767.0f);
const int16_t ly = (int16_t) (-0.20f * 32767.0f);
const int16_t lz = (int16_t) (0.93f * 32767.0f);
const int center_x = FX3D_W / 2, center_y = FX3D_H / 2;
const int fov = (FX3D_W < FX3D_H) ? FX3D_W : FX3D_H;
for (int i = 0; i < kDotCount; i++) {
const DotPt &dot = s_dots[i];
const int32_t x = ((int32_t) dot.x * kDotRadius) >> 7;
const int32_t y = ((int32_t) dot.y * kDotRadius) >> 7;
const int32_t z = ((int32_t) dot.z * kDotRadius) >> 7;
const int32_t y1 = (y * cx_r - z * sx_r) >> 15;
const int32_t z1 = (y * sx_r + z * cx_r) >> 15;
const int32_t x2 = (x * cy_r + z1 * sy_r) >> 15;
const int32_t z2 = (-x * sy_r + z1 * cy_r) >> 15;
const int32_t x3 = (x2 * cz_r - y1 * sz_r) >> 15;
const int32_t y3 = (x2 * sz_r + y1 * cz_r) >> 15;
const int32_t depth = z2 + (kDotRadius * 3);
if (depth <= 1) continue;
const int sxp = center_x + (int) ((x3 * fov) / depth);
const int syp = center_y + (int) ((y3 * fov) / depth);
if (sxp < 0 || syp < 0 || sxp >= FX3D_W || syp >= FX3D_H) continue;
const int32_t nd = (x3 * lx + y3 * ly + z2 * lz) >> 15;
const int32_t ndotl = clampv<int32_t>((nd * 128) / kDotRadius + 128, 0, 255);
const uint16_t base = s_dot_shade[ndotl];
for (int yy = -kDotBlobR; yy <= kDotBlobR; yy++) {
for (int xx = -kDotBlobR; xx <= kDotBlobR; xx++) {
const int d2 = xx * xx + yy * yy;
if (d2 > kDotBlobR * kDotBlobR) continue;
const int atten = clampv<int>(255 - d2 * 28, 0, 255);
add_pixel(sxp + xx, syp + yy, mul565_u8(base, (uint8_t) atten));
}
}
}
}
// FxEngine::renderRayCorridor — textured tunnel walls + scrolling floor.
void render_corridor(uint32_t now_ms) {
fill_lowres(0x0000u);
const int horizon = FX3D_H / 2;
const uint32_t zscroll = now_ms >> 3;
const uint8_t camera_angle = (uint8_t) (now_ms >> 6);
for (int x = 0; x < FX3D_W; x++) {
const int8_t off = s_ray_col_off[x];
const uint8_t ray_angle = (uint8_t) (camera_angle + (uint8_t) off);
const int16_t dir_x = sin_q15(ray_angle);
const int16_t dir_z = cos_q15(ray_angle);
const int16_t abs_dir_x = (int16_t) ((dir_x < 0) ? -dir_x : dir_x);
if (abs_dir_x < 64) {
for (int y = horizon; y < FX3D_H; y++) {
const int dy = y - horizon;
const uint8_t shade = (uint8_t) (120 + dy * 2);
s_lowres[(size_t) y * FX3D_W + x] = mul565_u8(rgb565(6u, 5u, 2u), shade);
}
continue;
}
const uint32_t t_q15 = (1ul << 30) / (uint32_t) abs_dir_x;
const uint16_t corr = (uint16_t) cos_q15((uint8_t) off);
uint32_t dist_q15 = (uint32_t) (((uint64_t) t_q15 * corr) >> 15);
if (dist_q15 == 0u) dist_q15 = 1u;
int slice = (int) (((uint32_t) FX3D_H << 15) / dist_q15);
slice = clampv<int>(slice, 1, FX3D_H);
int y0 = horizon - slice / 2;
int y1 = y0 + slice - 1;
y0 = clampv<int>(y0, 0, FX3D_H - 1);
y1 = clampv<int>(y1, 0, FX3D_H - 1);
const int32_t zhit_q15 = (int32_t) (((int64_t) dir_z * (int64_t) t_q15) >> 15);
int u = (int) (((zhit_q15 >> 9) + (int32_t) zscroll) & 63);
if (dir_x < 0) u ^= 63;
const int shade = clampv<int>(255 - (int) (dist_q15 >> 9), 0, 255);
for (int y = y0; y <= y1; y++) {
const int v = ((y - y0) * 64) / ((slice == 0) ? 1 : slice);
uint16_t color = s_ray_tex[(size_t) (v & 63) * kRayTexSize + (size_t) (u & 63)];
s_lowres[(size_t) y * FX3D_W + x] = mul565_u8(color, (uint8_t) shade);
}
for (int y = y1 + 1; y < FX3D_H; y++) {
const uint16_t k = s_ray_floor_q12[y];
if (k == 0u) continue;
const int32_t uu_q12 = (int32_t) (((int64_t) dir_x * k) >> 15);
const int32_t vv_q12 = (int32_t) (((int64_t) dir_z * k) >> 15);
const int uf = (int) (((uu_q12 >> 6) + (int32_t) zscroll) & 63);
const int vf = (int) (((vv_q12 >> 6) + (int32_t) (zscroll >> 1)) & 63);
uint16_t color = s_ray_tex[(size_t) (vf & 63) * kRayTexSize + (size_t) (uf & 63)];
const int dy = y - horizon;
const int fade = clampv<int>(255 - dy * 2, 0, 255);
s_lowres[(size_t) y * FX3D_W + x] = mul565_u8(color, (uint8_t) fade);
}
}
}
// FxEngine::renderStarfield3D — z-flight starfield, slow roll, motion trails.
void render_starfield3d(uint32_t now_ms) {
fill_lowres(rgb565(2u, 4u, 10u));
const uint8_t angle = (uint8_t) (now_ms >> 4);
const int16_t cs = cos_q15(angle);
const int16_t sn = sin_q15(angle);
const int fov = ((FX3D_W < FX3D_H) ? FX3D_W : FX3D_H) + 24;
const uint16_t z_min = 32u;
const int dz = (int) (10u + ((now_ms >> 6) & 7u));
const int cx = FX3D_W / 2, cy = FX3D_H / 2;
const uint16_t base = rgb565(240u, 248u, 255u);
for (int i = 0; i < kStar3DCount; i++) {
Star3D &star = s_stars3d[i];
const uint16_t z_prev = star.z;
const int z_next = (int) star.z - dz;
if (z_next < (int) z_min) {
star.x = (int16_t) ((int32_t) (next_rand() & 511u) - 256);
star.y = (int16_t) ((int32_t) ((next_rand() >> 9) & 511u) - 256);
star.z = (uint16_t) (256u + (next_rand() % 768u));
continue;
}
star.z = (uint16_t) z_next;
const int xr = (int) (((int32_t) star.x * cs - (int32_t) star.y * sn) >> 15);
const int yr = (int) (((int32_t) star.x * sn + (int32_t) star.y * cs) >> 15);
const int sx = cx + (xr * fov) / (int) star.z;
const int sy = cy + (yr * fov) / (int) star.z;
const int sx0 = cx + (xr * fov) / (int) z_prev;
const int sy0 = cy + (yr * fov) / (int) z_prev;
if (sx < 0 || sy < 0 || sx >= FX3D_W || sy >= FX3D_H) continue;
uint8_t brightness = (uint8_t) (255u - (star.z >> 2));
if (brightness < 40u) brightness = 40u;
set_pixel(sx, sy, mul565_u8(base, brightness));
const int dx = sx - sx0, dy = sy - sy0;
int steps = ((dx < 0 ? -dx : dx) > (dy < 0 ? -dy : dy))
? (dx < 0 ? -dx : dx) : (dy < 0 ? -dy : dy);
steps = clampv<int>(steps, 0, 10);
for (int s = 1; s <= steps; s++) {
const int x = sx0 + (dx * s) / steps;
const int y = sy0 + (dy * s) / steps;
const uint8_t fade = (uint8_t) ((brightness * (steps - s)) / (steps + 1));
add_pixel(x, y, mul565_u8(base, fade));
}
}
}
// FxEngine::renderVoxelLandscape — per-column raycast heightfield over a
// vertical sky gradient, camera drifting with time.
void render_voxel(uint32_t now_ms) {
for (int y = 0; y < FX3D_H; y++) {
const uint8_t t = (uint8_t) ((y * 255) / FX3D_H);
const uint16_t color = rgb565((uint8_t) ((8 * (255 - t)) >> 8),
(uint8_t) ((12 * (255 - t)) >> 8),
(uint8_t) ((32 * (255 - t)) >> 8));
const size_t row = (size_t) y * FX3D_W;
for (int x = 0; x < FX3D_W; x++) s_lowres[row + x] = color;
}
const int horizon = FX3D_H / 2;
const uint8_t angle = (uint8_t) (now_ms >> 6);
const uint16_t cam_x = (uint16_t) ((now_ms >> 5) & 255u);
const uint16_t cam_y = (uint16_t) ((now_ms >> 6) & 255u);
const int half = FX3D_W / 2;
for (int x = 0; x < FX3D_W; x++) {
const int dx = x - half;
const uint8_t ray_angle = (uint8_t) (angle + (uint8_t) ((dx * 24) / half));
const int16_t dir_x = cos_q15(ray_angle);
const int16_t dir_y = sin_q15(ray_angle);
int max_y = FX3D_H - 1;
for (int z = 1; z <= kVoxelMaxDist; z++) {
const int map_x = ((int) cam_x + ((dir_x * z) >> 15)) & 255;
const int map_y = ((int) cam_y + ((dir_y * z) >> 15)) & 255;
const uint8_t hh = s_voxel_height[(uint8_t) ((map_x + map_y * 3) & 255)];
const uint16_t proj = s_voxel_proj_q8[z];
int y = horizon - (int) (((unsigned) hh * proj) >> 8);
if (y < 0) y = 0;
if (y > max_y) continue;
const uint8_t shade = (uint8_t) ((z * 3 < 255) ? (255 - z * 3) : 0);
const uint16_t color = s_voxel_pal[shade];
for (int yy = y; yy <= max_y; yy++)
s_lowres[(size_t) yy * FX3D_W + x] = color;
max_y = y - 1;
if (max_y < 0) break;
}
}
}
// v9 WireCubeFx — wireframe cube, float math (8 vertices/frame, FPU is fine),
// rotation speeds and projection from the v9 defaults feel.
void render_wirecube(uint32_t now_ms) {
fill_lowres(rgb565(2u, 3u, 8u));
static const float V[8][3] = {
{-1, -1, -1}, {+1, -1, -1}, {+1, +1, -1}, {-1, +1, -1},
{-1, -1, +1}, {+1, -1, +1}, {+1, +1, +1}, {-1, +1, +1},
};
static const uint8_t E[12][2] = {
{0, 1}, {1, 2}, {2, 3}, {3, 0},
{4, 5}, {5, 6}, {6, 7}, {7, 4},
{0, 4}, {1, 5}, {2, 6}, {3, 7},
};
const float t = (float) now_ms * 0.001f;
const float ax = t * 0.9f, ay = t * 1.3f, az = t * 0.5f;
const float sx = sinf(ax), cxr = cosf(ax);
const float sy = sinf(ay), cyr = cosf(ay);
const float sz = sinf(az), czr = cosf(az);
const int cx = FX3D_W / 2, cy = FX3D_H / 2;
const float pulse = 0.5f + 0.5f * sinf(t * 2.4f);
const float scale = (float) FX3D_H * 0.30f * (1.0f + 0.20f * pulse);
const float fov = 2.2f, z_offset = 3.0f;
int px[8], py[8];
for (int i = 0; i < 8; i++) {
const float x = V[i][0], y = V[i][1], z = V[i][2];
const float x1 = x * cyr + z * sy;
const float z1 = -x * sy + z * cyr;
const float y2 = y * cxr - z1 * sx;
const float z2 = y * sx + z1 * cxr;
const float x3 = x1 * czr - y2 * sz;
const float y3 = x1 * sz + y2 * czr;
float zz = z2 + z_offset;
if (zz < 0.3f) zz = 0.3f;
const float inv = fov / zz;
px[i] = cx + (int) lroundf(x3 * inv * scale);
py[i] = cy + (int) lroundf(y3 * inv * scale);
}
const uint16_t edge = mul565_u8(rgb565(120u, 255u, 220u),
(uint8_t) (200 + (int) (55.0f * pulse)));
for (int e = 0; e < 12; e++)
add_line(px[E[e][0]], py[E[e][0]], px[E[e][1]], py[E[e][1]], edge);
}
} // namespace
extern "C" bool fx3d_init(void) {
if (s_ready) return true;
s_lowres = (uint16_t *) psram_alloc((size_t) FX3D_W * FX3D_H * sizeof(uint16_t));
s_roto_tex = (uint16_t *) psram_alloc((size_t) kRotoTexSize * kRotoTexSize * sizeof(uint16_t));
s_ray_tex = (uint16_t *) psram_alloc((size_t) kRayTexSize * kRayTexSize * sizeof(uint16_t));
s_dots = (DotPt *) psram_alloc((size_t) kDotCount * sizeof(DotPt));
s_stars3d = (Star3D *) psram_alloc((size_t) kStar3DCount * sizeof(Star3D));
if (!s_lowres || !s_roto_tex || !s_ray_tex || !s_dots || !s_stars3d) {
heap_caps_free(s_lowres); s_lowres = nullptr;
heap_caps_free(s_roto_tex); s_roto_tex = nullptr;
heap_caps_free(s_ray_tex); s_ray_tex = nullptr;
heap_caps_free(s_dots); s_dots = nullptr;
heap_caps_free(s_stars3d); s_stars3d = nullptr;
return false;
}
for (int i = 0; i < 256; i++) {
s_sin_q15[i] = (int16_t) (sinf((float) i * (6.28318530f / 256.0f)) * 32767.0f);
}
// Rotozoom texture — radial-shaded checker (FxEngine::begin).
for (int y = 0; y < kRotoTexSize; y++) {
for (int x = 0; x < kRotoTexSize; x++) {
const float cx = (float) x - kRotoTexSize * 0.5f;
const float cy = (float) y - kRotoTexSize * 0.5f;
float rr = sqrtf(cx * cx + cy * cy) / (kRotoTexSize * 0.5f);
if (rr > 1.0f) rr = 1.0f;
const bool checker = (((x >> 4) ^ (y >> 4)) & 1) != 0;
const uint8_t r = checker ? (uint8_t) (40 + (uint8_t) (200.0f * (1.0f - rr))) : 200u;
const uint8_t g = checker ? (uint8_t) (60 + (uint8_t) (160.0f * (1.0f - rr)))
: (uint8_t) (50 + (uint8_t) (120.0f * (1.0f - rr)));
const uint8_t b = checker ? 200u : (uint8_t) (60 + (uint8_t) (120.0f * (1.0f - rr)));
s_roto_tex[(size_t) y * kRotoTexSize + x] = rgb565(r, g, b);
}
}
// Dot sphere — shade LUT + random unit sphere (initModeState, same seed).
{
const uint16_t base = rgb565(40u, 80u, 240u);
const uint16_t high = rgb565(255u, 255u, 255u);
for (int i = 0; i < 256; i++) {
const uint8_t diffuse = (uint8_t) i;
const int spec_i = (i > 220) ? (i - 220) * 7 : 0;
const uint8_t spec = (uint8_t) clampv<int>(spec_i, 0, 255);
s_dot_shade[i] = add_sat565(mul565_u8(base, diffuse), mul565_u8(high, spec));
}
uint32_t rng = 0xBADC0FFEul ^ (uint32_t) FX3D_W ^ ((uint32_t) FX3D_H << 16);
for (int i = 0; i < kDotCount; i++) {
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5;
const uint8_t a = (uint8_t) (rng & 0xFFu);
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5;
const uint8_t b = (uint8_t) ((rng >> 8) & 0xFFu);
const int16_t ca = cos_q15(a), sa = sin_q15(a);
const int16_t cb = cos_q15(b), sb = sin_q15(b);
s_dots[i].x = (int16_t) ((((int32_t) ca * cb) >> 15) >> 8);
s_dots[i].y = (int16_t) ((int32_t) sb >> 8);
s_dots[i].z = (int16_t) ((((int32_t) sa * cb) >> 15) >> 8);
}
}
// Ray corridor — wall/floor texture, per-column angle offsets, floor LUT
// (initModeState kRayCorridor).
for (int y = 0; y < kRayTexSize; y++) {
for (int x = 0; x < kRayTexSize; x++) {
const bool checker = (((x >> 3) ^ (y >> 3)) & 1) != 0;
int c = checker ? 190 : 70;
if ((y & 7) == 0) c = 40;
s_ray_tex[(size_t) y * kRayTexSize + x] =
rgb565((uint8_t) c, (uint8_t) (c / 2), (uint8_t) (c / 3));
}
}
for (int x = 0; x < FX3D_W; x++) {
const int dx = x - FX3D_W / 2;
s_ray_col_off[x] = (int8_t) clampv<int>((dx * 24) / (FX3D_W / 2), -64, 64);
}
for (int y = 0; y < FX3D_H; y++) {
const int dy = y - FX3D_H / 2;
if (dy <= 0) { s_ray_floor_q12[y] = 0u; continue; }
uint32_t value = (64ul << 12) / (uint32_t) dy;
if (value > 65535ul) value = 65535ul;
s_ray_floor_q12[y] = (uint16_t) value;
}
// Starfield 3D — initModeState kStarfield3D.
for (int i = 0; i < kStar3DCount; i++) {
s_stars3d[i].x = (int16_t) ((int32_t) (next_rand() & 511u) - 256);
s_stars3d[i].y = (int16_t) ((int32_t) ((next_rand() >> 9) & 511u) - 256);
s_stars3d[i].z = (uint16_t) (128u + (next_rand() % 896u));
}
// Voxel landscape — initModeState kVoxelLandscape (sine heightfield,
// shaded green palette, perspective projection table).
for (int i = 0; i < 256; i++) {
const int16_t s1 = sin_q15((uint8_t) i);
const int16_t s2 = sin_q15((uint8_t) (i * 3));
s_voxel_height[i] = (uint8_t) clampv<int>(s1 / 512 + s2 / 1024 + 128, 0, 255);
s_voxel_pal[i] = mul565_u8(rgb565(30u, 220u, 80u), (uint8_t) i);
}
for (int z = 1; z <= kVoxelMaxDist; z++) {
s_voxel_proj_q8[z] = (uint16_t) clampv<int>((70 * 256) / (z + 8), 0, 65535);
}
s_voxel_proj_q8[0] = 0u;
s_ready = true;
return true;
}
extern "C" void fx3d_render(fx3d_mode_t mode, uint32_t t_ms, uint16_t *dst,
int dst_w, int dst_h) {
if (!s_ready || dst == nullptr || dst_w != FX3D_W * 2 || dst_h != FX3D_H * 2) {
return;
}
switch (mode) {
case FX3D_ROTOZOOM: render_rotozoom(t_ms); break;
case FX3D_DOTSPHERE: render_dotsphere(t_ms); break;
case FX3D_CORRIDOR: render_corridor(t_ms); break;
case FX3D_STARFIELD: render_starfield3d(t_ms); break;
case FX3D_VOXEL: render_voxel(t_ms); break;
case FX3D_WIRECUBE: render_wirecube(t_ms); break;
default: return;
}
// 2x pixel doubling, two 32-bit writes per source pixel, row duplicated.
for (int y = 0; y < FX3D_H; y++) {
const uint16_t *src = &s_lowres[(size_t) y * FX3D_W];
uint32_t *out0 = (uint32_t *) (dst + (size_t) (y * 2) * dst_w);
uint32_t *out1 = (uint32_t *) (dst + (size_t) (y * 2 + 1) * dst_w);
for (int x = 0; x < FX3D_W; x++) {
const uint32_t px = (uint32_t) src[x] | ((uint32_t) src[x] << 16);
out0[x] = px;
out1[x] = px;
}
}
}
@@ -0,0 +1,27 @@
idf_component_register(
SRCS
"game_endpoint.c"
"puzzle_binding.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_http_server
json
nvs_flash
hints_client
ota_server
scenario_mesh
freertos
log
joltwallet__littlefs
puzzle_state
media_manager
sd_storage
gamebook
PRIV_REQUIRES
local_puzzles
p7_coffre
p5_morse
p6_nfc
npc_engine
)
@@ -0,0 +1,452 @@
# game_endpoint
Surface **REST de jeu** du firmware. Le composant n'ouvre **aucun socket
propre** : il greffe ses handlers sur l'instance `esp_http_server`
(port **80**) détenue par `ota_server` — même pattern que
`voice_hook_endpoint`, même pool de workers. `game_endpoint_init(httpd)`
est appelé après `ota_server_init()`, avec le handle rendu par
`ota_server_get_handle()`.
Né en slice 12 pour le seul profil de groupe des indices, le composant a
beaucoup grandi. Il couvre aujourd'hui quatre domaines :
- **configuration runtime** — profil de groupe du moteur d'indices ;
- **scénario** — chargement à chaud de l'IR Runtime 3 (LittleFS + reboot),
et relais du même IR vers d'autres boîtiers en ESP-NOW ;
- **provisioning mesh** — registre des pairs ESP-NOW (alias → MAC) ;
- **énigmes locales** — armement d'une énigme par step, lecture de l'état
agrégé, et dépôt de fichiers d'apps pour le shell d'affichage.
## Table des routes
| Méthode | Chemin | Corps attendu | Réponse (succès) |
|---|---|---|---|
| GET | `/game/group_profile` | — | `200 {group_profile}` |
| POST | `/game/group_profile` | `{group_profile}` | `200 {status,group_profile}` |
| POST | `/game/scenario` | IR Runtime 3 (JSON brut, ≤64 KiB) | `200 {status,steps_count,entry_step_id,bytes,reload}` + reboot |
| POST | `/game/scenario/relay` | `{peers:[…], ir:{…}}` | `200 {relayed:[…],skipped:[…]}` |
| GET | `/game/peers` | — | `200 {peers:[{alias,mac}]}` |
| POST | `/game/peers` | `{alias, mac}` | `200 {ok,alias,live}` |
| POST | `/game/step` | `{step_id}` | `200 {step_id,armed}` |
| GET | `/game/puzzle_state` | — | `200 {step_id,solved,code}` |
| POST | `/game/file?path=apps/<id>/<f>` | corps brut du fichier (≤256 KiB) | `200 {status,path,bytes}` |
Toutes les réponses sont `application/json` avec l'en-tête
`Access-Control-Allow-Origin: *`. Les corps d'erreur ont la forme
`{"error":"<message>"}` (sauf le cas `500 runtime_only` de
`POST /game/group_profile`, qui renvoie un objet structuré).
`POST /game/scenario/relay` n'est enregistrée **que** si
`scenario_mesh_init()` a réussi au boot ; sinon elle est absente et le log
le signale. `/game/peers`, `/game/step`, `/game/puzzle_state` et
`/game/file` sont enregistrées en best-effort (un échec d'enregistrement
est loggué mais non fatal). `GET /game/puzzle_state` et `POST /game/step`
ne fonctionnent qu'après l'appel à `game_endpoint_set_puzzle_state()` ;
avant cela elles répondent `503 not_ready`.
---
## Configuration runtime
### `GET /game/group_profile`
Renvoie le profil vivant rapporté par `hints_client_group_profile()`
(`MIXED` par défaut après init).
```json
{ "group_profile": "MIXED" }
```
### `POST /game/group_profile`
Corps JSON, **1..256 octets** :
```json
{ "group_profile": "NON_TECH" }
```
La validation est déléguée à `hints_client_set_group_profile()`
(whitelist `TECH` / `NON_TECH` / `MIXED` / `BOTH`). En cas d'acceptation
la valeur est ensuite persistée en NVS (namespace `zacus`, clé
`group_profile`) — le même slot que `main.c` relit au boot, donc le
changement survit au reboot sans flash.
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"status":"ok","group_profile":"NON_TECH"}` | accepté, NVS persisté |
| 400 | `{"error":"missing 'group_profile'"}` | champ vide / non-string |
| 400 | `{"error":"invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]"}` | rejeté par la whitelist |
| 400 | `{"error":"malformed json"}` | corps non parsable |
| 413 | `{"error":"body must be 1..256 bytes"}` | corps hors bornes |
| 500 | `{"status":"runtime_only","group_profile":"NON_TECH","warning":"nvs write failed: …"}` | hints client mis à jour mais commit NVS échoué (ne survivra pas au reboot) |
---
## Scénario
### `POST /game/scenario`
Charge à chaud un IR **Runtime 3** complet. Corps = JSON brut,
**1..65536 octets**. Validation firmware **minimale** (le validateur
strict est `runtime3_common.py`, côté gateway) :
- JSON parsable ;
- `schema_version == "zacus.runtime3.v1"` ;
- `steps` = tableau **non vide**.
Sur succès : l'ancien `scenario.json` est tourné en `scenario.bak`, le
nouveau blob est écrit atomiquement sur LittleFS
(`/littlefs/scenario.json`), toute énigme en cours est désarmée, puis un
**reboot différé de ~800 ms** est planifié (le temps que la réponse HTTP
soit flushée). Un write court est rollback depuis `.bak`.
```json
{ "status": "ok", "steps_count": 12, "entry_step_id": "STEP_INTRO",
"bytes": 4096, "reload": "reboot_pending" }
```
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{…,"reload":"reboot_pending"}` | accepté, écrit, reboot planifié |
| 400 | `{"error":"malformed json"}` | JSON invalide |
| 400 | `{"error":"schema_version must be zacus.runtime3.v1"}` | mauvais schéma |
| 400 | `{"error":"steps must be a non-empty array"}` | `steps` absent/vide |
| 400 | `{"error":"recv failed"}` | erreur de réception socket |
| 413 | `{"error":"body must be 1..65536 bytes"}` | corps hors bornes |
| 500 | `{"error":"littlefs mount failed"}` / `"scenario write open failed"` / `"scenario write short"` / `"out of memory"` | échec stockage |
```bash
curl -X POST http://zacus-master.local/game/scenario \
-H "Content-Type: application/json" \
--data-binary @scenario.runtime3.json
```
### `POST /game/scenario/relay`
(Présente uniquement si le mesh ESP-NOW est monté.) Diffuse un IR vers
d'autres boîtiers. Corps **1..65536 octets** :
```json
{ "peers": ["box3", "plip"], "ir": { /* IR Runtime 3 */ } }
```
L'objet `ir` est re-sérialisé compact puis chunké/envoyé en ESP-NOW à
chaque alias résolu via le registre des pairs. Un échec sur un pair
(alias inconnu, timeout) n'interrompt pas les autres : il atterrit dans
`skipped` avec sa raison. Côté récepteur, l'IR repasse par exactement le
même chemin que `POST /game/scenario` (validation + write + reboot).
```json
{ "relayed": ["box3"],
"skipped": [ { "name": "plip", "reason": "unknown_peer" } ] }
```
Raisons de `skipped` : `unknown_peer` (alias absent du registre),
`timeout` (pas d'ack ESP-NOW), ou tout autre `esp_err_to_name`.
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"relayed":[…],"skipped":[…]}` | traité (succès partiel possible) |
| 400 | `{"error":"malformed json"}` | JSON invalide |
| 400 | `{"error":"'peers' must be a non-empty array"}` | `peers` absent/vide |
| 400 | `{"error":"'ir' must be an object"}` | `ir` non objet |
| 413 | `{"error":"body must be 1..65536 bytes"}` | corps hors bornes |
| 500 | `{"error":"ir serialize failed"}` / `"response serialize failed"` | échec interne cJSON |
---
## Provisioning mesh
Remplace le round-trip desktop `NvsConfigurator` pour le provisioning
ESP-NOW.
### `POST /game/peers`
Corps **1..256 octets** :
```json
{ "alias": "plip", "mac": "AA:BB:CC:DD:EE:FF" }
```
- `alias` : **1..15 caractères** (limite de clé NVS) ;
- `mac` : format `AA:BB:CC:DD:EE:FF` (6 octets hex).
Persiste en NVS (namespace `peers`, clé = alias, blob = MAC 6 octets — le
format exact que `main.c` reseed au boot) **et** enregistre le pair en
direct dans la table `scenario_mesh` (sans reboot). Si l'enregistrement
live échoue, le pair est tout de même en NVS (`live:false`, effectif au
prochain reboot).
```json
{ "ok": true, "alias": "plip", "live": true }
```
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"ok":true,"alias":"plip","live":true|false}` | persisté (`live` = enregistré à chaud) |
| 400 | `{"error":"invalid JSON"}` | corps non parsable |
| 400 | `{"error":"alias and mac (string) required"}` | champ manquant/non-string |
| 400 | `{"error":"alias must be 1..15 chars (NVS key)"}` | alias hors bornes |
| 400 | `{"error":"mac must be AA:BB:CC:DD:EE:FF"}` | MAC mal formé |
| 413 | `{"error":"body must be 1..256 bytes"}` | corps hors bornes |
| 500 | `{"error":"NVS write failed"}` | échec NVS |
### `GET /game/peers`
Itère le namespace NVS `peers` et liste les pairs persistés :
```json
{ "peers": [ { "alias": "plip", "mac": "AA:BB:CC:DD:EE:FF" } ] }
```
```bash
curl -X POST http://zacus-master.local/game/peers \
-H "Content-Type: application/json" \
-d '{"alias":"plip","mac":"AA:BB:CC:DD:EE:FF"}'
curl http://zacus-master.local/game/peers
```
---
## Énigmes locales
### `POST /game/step`
Arme l'énigme locale attachée à un step de l'IR stocké. Corps
**1..512 octets** :
```json
{ "step_id": "STEP_3" }
```
Le handler relit `/littlefs/scenario.json`, en extrait le binding du step
via `puzzle_binding_from_ir` (+ la `scene` d'affichage, parse lenient),
désarme l'énigme courante, puis arme la nouvelle. Même logique exposée en
interne via `game_endpoint_apply_step()` (lancement d'app sur l'écran du
boîtier).
`armed` indique ce qui a été armé : `"qr"`, `"sound"`, ou `"none"` (step
trouvé mais sans objet `puzzle`).
```json
{ "step_id": "STEP_3", "armed": "qr" }
```
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"step_id":"STEP_3","armed":"qr|sound|none"}` | armé |
| 400 | `{"error":"missing or empty step_id"}` | champ absent/vide |
| 400 | `{"error":"malformed json"}` | corps non parsable |
| 400 | `{"error":"body must be 1..512 bytes"}` | corps hors bornes |
| 404 | `{"error":"unknown_step"}` | aucun step de cet id dans l'IR |
| 409 | `{"error":"no_scenario"}` | aucun scénario stocké / stockage indispo |
| 422 | `{"error":"invalid_puzzle"}` | objet `puzzle` invalide dans l'IR |
| 503 | `{"error":"puzzle_busy"}` | énigme occupée (fenêtre de teardown QR, après un retry) |
| 503 | `{"error":"not_ready"}` | `puzzle_state` pas encore câblé |
```bash
curl -X POST http://zacus-master.local/game/step \
-H "Content-Type: application/json" \
-d '{"step_id":"STEP_3"}'
```
### `GET /game/puzzle_state`
État agrégé des énigmes résolues, pour le polling du dashboard GM.
```json
{ "step_id": "STEP_3", "solved": [1, 3], "code": "125" }
```
- `step_id` : dernier step armé, ou `null` si rien n'a encore été armé ;
- `solved` : ids (1..8) des énigmes résolues ;
- `code` : concaténation des fragments des énigmes résolues.
`503 {"error":"not_ready"}` tant que `puzzle_state` n'est pas câblé.
```bash
curl http://zacus-master.local/game/puzzle_state
```
### `POST /game/file?path=apps/<id>/<file>`
Provisionne un fichier d'app du shell d'affichage (icône, `step.txt`…)
sans reflasher l'image LittleFS. Le chemin est passé en **query param**,
le corps est le **contenu brut** du fichier (**1..262144 octets**, soit
256 KiB).
Contraintes de sécurité sur `path` :
- doit commencer par `apps/` (whitelist) ;
- ne doit contenir aucun `..` (anti-traversal) ;
- ne doit pas finir par `/`.
Toute violation → `403 {"error":"path must be under apps/"}`. Les
répertoires intermédiaires sont créés (`mkdir -p`). Le fichier final est
écrit sous `/littlefs/apps/…` ; un échec d'écriture supprime le fichier
partiel.
```json
{ "status": "ok", "path": "apps/clock/icon.png", "bytes": 1873 }
```
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"status":"ok","path":"…","bytes":N}` | écrit |
| 400 | `{"error":"missing path param"}` | query `path` absent |
| 400 | `{"error":"size 1..262144 bytes"}` | corps hors bornes |
| 400 | `{"error":"recv failed"}` | erreur réception socket |
| 403 | `{"error":"path must be under apps/"}` | hors whitelist / traversal |
| 500 | `{"error":"open failed"}` / `"write failed"` | échec stockage |
| 503 | `{"error":"storage_unavailable"}` | mount LittleFS échoué |
```bash
curl -X POST "http://zacus-master.local/game/file?path=apps/clock/icon.png" \
--data-binary @icon.png
```
---
## Format IR : objets `puzzle` et `scene`
Les deux objets sont **optionnels** par step de l'IR Runtime 3, lus à
`POST /game/step` (et `game_endpoint_apply_step`). Les contraintes
ci-dessous sont celles de `puzzle_binding.c` / `puzzle_binding.h`.
### `puzzle` (validation **stricte**)
Un objet `puzzle` invalide bloque le step (`422 invalid_puzzle`).
Absent ou `null``armed:"none"`, pas d'erreur.
| Champ | Type | Contrainte |
|---|---|---|
| `id` | nombre | **1..8** (entier) |
| `type` | string | `"qr"` ou `"sound"` (rien d'autre) |
| `fragment` | tableau d'entiers | **1..4** valeurs, chacune **0..9** (chiffres) |
| `codes` | tableau de strings | *(qr)* **1..16** labels, chacun < 32 chars |
| `melody` | tableau d'entiers | *(sound)* **1..32** notes |
| `tolerance` | nombre | *(sound, optionnel)* entier **≥ 0**, défaut **1** |
Énigme QR :
```json
{
"id": 1,
"type": "qr",
"codes": ["BADGE_ROUGE", "BADGE_BLEU"],
"fragment": [1, 2]
}
```
Énigme sonore (mélodie + tolérance) :
```json
{
"id": 4,
"type": "sound",
"melody": [262, 294, 330, 349],
"tolerance": 2,
"fragment": [5]
}
```
### `scene` (parse **lenient**)
Décoration d'affichage : ne bloque **jamais** un changement de step. Les
strings trop longues sont **silencieusement tronquées**, un `effect`
inconnu retombe sur `pulse`. Seul un JSON globalement non parsable échoue.
| Champ | Type | Contrainte |
|---|---|---|
| `title` | string | tronqué à 48 chars |
| `subtitle` | string | tronqué à 64 chars |
| `symbol` | string | tronqué à 16 chars |
| `effect` | string | `pulse` (défaut) \| `glitch` \| `gyro` \| `none` ; inconnu → `pulse` |
```json
{
"title": "Salle des machines",
"subtitle": "Trouvez la séquence",
"symbol": "⚙",
"effect": "glitch"
}
```
Un step complet combinant les deux :
```json
{
"id": "STEP_3",
"puzzle": { "id": 1, "type": "qr", "codes": ["BADGE_ROUGE"], "fragment": [1, 2] },
"scene": { "title": "Le coffre", "effect": "pulse" }
}
```
---
## Modèle de concurrence
La lecture de `GET /game/puzzle_state` (et les snapshots
`game_endpoint_get_puzzle_status` / `game_endpoint_get_scene`) est
**lock-free**, par choix assumé pour ce jeu :
- l'armement d'un step (writer) se fait uniquement sur la tâche httpd ;
les tâches d'énigmes n'écrivent que via `puzzle_state_report` (flags
`solved[]` montones) ;
- le dashboard GM ne fait que poller ;
- un read concurrent peut donc voir un `solved[id]` à `true` alors que le
fragment correspondant est en cours d'écriture (lecture torn). De même
un torn read du texte de `scene` pendant la copie est cosmétique et
s'auto-corrige au poll suivant.
**Note honnête** : c'est acceptable ici (un seul step armé à la fois,
affichage informatif, polling), mais ce n'est pas thread-safe au sens
strict — pas de mutex sur `puzzle_state` ni sur les statiques
`s_current_*`. À ne pas réutiliser tel quel dans un contexte
multi-writer.
---
## Tests hôte du parser
Le parser `puzzle_binding` a une suite de **10 tests `[pbind]`** qui
tourne sur l'hôte (pas de cible ESP requise), via Unity + cJSON :
```bash
make -C idf_zacus/components/game_endpoint/test/host test
```
Le `Makefile` attend Unity et cJSON dans l'arbre ESP-IDF
(`UNITY_DIR` / `CJSON_DIR`, par défaut sous `~/esp/esp-idf/components/`,
surchargeables en variables d'environnement).
---
## Dépendances du composant
```cmake
REQUIRES
esp_http_server # le httpd_handle_t partagé
json # cJSON pour le parsing
nvs_flash # group_profile + registre des pairs
hints_client # validation + push du profil en RAM
ota_server # fournit le handle via ota_server_get_handle()
scenario_mesh # transport ESP-NOW (relay + provisioning)
local_puzzles # armement QR / sonore
esp_littlefs # stockage scénario + fichiers d'apps
freertos
log
```
Câblage dans `main.c` :
```c
esp_err_t ota_err = ota_server_init();
if (ota_err == ESP_OK) {
httpd_handle_t httpd = ota_server_get_handle();
voice_hook_endpoint_init(httpd);
game_endpoint_init(httpd);
game_endpoint_set_puzzle_state(&g_puzzle_state); // active /game/step + /game/puzzle_state
}
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
// game_endpoint — REST surface for runtime game configuration.
//
// Slice 12 of the IDF migration. Today this exposes a single resource:
//
// GET /game/group_profile — read the active hints group profile.
// POST /game/group_profile — set a new profile, persist to NVS, and
// push it to the hints_client so the
// next /hints/ask body carries it.
//
// The handlers are attached to the existing esp_http_server instance
// owned by the ota_server component (port 80) — same pattern as
// voice_hook_endpoint. No second TCP socket, no second worker pool.
//
// NVS persistence: namespace "zacus", key "group_profile". This is the
// same key main.c reads at boot to seed the hints client, so a
// successful POST survives reboot without any flash step.
//
// Validation is delegated to hints_client_set_group_profile() which
// already enforces the "TECH" / "NON_TECH" / "MIXED" / "BOTH" whitelist.
// On invalid input the NVS write is skipped and the client keeps its
// previous value.
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
#include "puzzle_state.h"
#include "puzzle_binding.h" // scene_binding_t (display scene metadata)
#ifdef __cplusplus
extern "C" {
#endif
// Cap the request body so a malformed PLIP / dashboard client cannot
// blow up the worker stack. 256 bytes is plenty for {"group_profile":
// "NON_TECH"} (~30 bytes) plus future additive fields.
#define GAME_ENDPOINT_MAX_BODY_BYTES 256
// Larger cap for the Runtime 3 IR scenario blob. 64 KiB lets a
// reasonable escape-room scenario (~50 steps, dialogues + actions)
// fit comfortably. Scenarios that exceed this should be split
// across multiple boards or trimmed.
#define GAME_ENDPOINT_MAX_SCENARIO_BYTES (64 * 1024)
// LittleFS partition label declared in partitions.csv. game_endpoint
// mounts lazily on first scenario POST. media_manager may also mount
// the same label — esp_vfs_littlefs_register is idempotent per label.
#define GAME_ENDPOINT_STORAGE_LABEL "storage"
// main.c mounts the storage partition at /littlefs at boot — we reuse the
// same mount point instead of registering a second base path for the same
// partition (which fails silently with INVALID_STATE).
#define GAME_ENDPOINT_STORAGE_BASE "/littlefs"
#define GAME_ENDPOINT_SCENARIO_PATH GAME_ENDPOINT_STORAGE_BASE "/scenario.json"
#define GAME_ENDPOINT_SCENARIO_BAK GAME_ENDPOINT_STORAGE_BASE "/scenario.bak"
/**
* @brief Attach all game endpoint handlers to an existing esp_http_server.
*
* Registers:
* - GET/POST /game/group_profile (slice 12, runtime hints profile)
* - POST /game/scenario (slice 13, Runtime 3 IR hot-load)
* - POST /game/step (Task C, REST-driven puzzle arming)
* - GET /game/puzzle_state (Task C, puzzle state readout)
*
* Pass the handle returned by `ota_server_get_handle()`. Returns
* ESP_ERR_INVALID_ARG if `server` is NULL, or any error propagated
* from `httpd_register_uri_handler()`.
*/
esp_err_t game_endpoint_init(httpd_handle_t server);
/**
* @brief Give the endpoint read access to the master's puzzle aggregation
* state and enable GET /game/puzzle_state + POST /game/step.
* Call once at boot, right after game_endpoint_init succeeds.
*/
void game_endpoint_set_puzzle_state(puzzle_state_t *state);
/**
* @brief Thread-safe snapshot of the current step id and armed puzzle type.
*
* Copies the last step armed via POST /game/step into caller-supplied buffers.
* Both buffers are NUL-terminated on return. Either pointer may be NULL if
* the caller does not need that field.
*
* Returns "" / "" when no step has been armed yet.
*
* @param step_id Output buffer for the current step id.
* @param step_cap Capacity of step_id buffer (recommend 64).
* @param armed Output buffer: "qr" | "sound" | "none" (or "" = nothing yet).
* @param armed_cap Capacity of armed buffer (recommend 8).
*/
void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap,
char *armed, size_t armed_cap);
/**
* @brief Snapshot the display scene metadata of the current step.
*
* Filled from the step's optional `scene` IR object at POST /game/step time
* (lenient parse — see puzzle_binding.h). Zeroed when no step is armed or a
* new scenario is pushed. Lock-free copy; cosmetic torn reads possible.
*/
void game_endpoint_get_scene(scene_binding_t *out);
/**
* @brief Apply a scenario step internally (same logic as POST /game/step).
*
* Used by local triggers (e.g. shell app launch on the device display).
* May block up to ~250 ms (QR re-arm retry). Error contract documented at
* the definition (NOT_SUPPORTED=no scenario, NOT_FOUND=unknown step,
* TIMEOUT=puzzle busy, INVALID_STATE=not ready, INVALID_ARG=bad IR puzzle).
*
* @param step_id Step to arm (non-empty).
* @param armed_out Optional out: "qr"|"sound"|"none".
* @param armed_cap Capacity of armed_out.
*/
esp_err_t game_endpoint_apply_step(const char *step_id,
char *armed_out, size_t armed_cap);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,80 @@
// puzzle_binding.h — extract a step's optional puzzle binding from Runtime 3 IR.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#define PB_MAX_CODES 16 // = SEQ_MAX_STEPS
#define PB_MAX_LABEL 32 // = SEQ_MAX_LABEL
#define PB_MAX_NOTES 32 // = MELODY_MAX_NOTES
#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, PB_MORSE, PB_NFC } puzzle_binding_type_t;
typedef struct {
puzzle_binding_type_t type; // PB_NONE if the step has no puzzle
uint8_t id; // 1..8
// qr
char codes[PB_MAX_CODES][PB_MAX_LABEL];
size_t code_count;
// sound
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;
} puzzle_binding_t;
// Parse `ir_json` (full Runtime 3 IR), locate step `step_id`, fill `out`.
// ESP_OK: step found ('out->type' PB_NONE if it has no/empty puzzle object).
// ESP_ERR_NOT_FOUND: no step with that id (or no steps array).
// ESP_ERR_INVALID_ARG: malformed JSON, or puzzle object violating constraints
// (bad type string, id out of range, too many/zero codes|notes, label too
// long, fragment missing/empty/too long/non-digit values, negative tolerance).
// NOTE: Parsing a full 64KB IR allocates transient heap via cJSON — call
// sparingly (e.g. per step change, not per frame).
// Tolerance defaults to 1 when absent (sound only).
esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
puzzle_binding_t *out);
// ─── Scene metadata (display) ────────────────────────────────────────────────
// Optional per-step `scene` object mirroring the original ui_manager scene
// frame: {"title": "...", "subtitle": "...", "symbol": "...",
// "effect": "pulse"|"glitch"|"gyro"|"none"}.
// LENIENT parsing (unlike the strict puzzle constraints): scene fields are
// display decoration and must never block a step change — over-long strings
// are silently TRUNCATED, an unknown effect falls back to pulse.
#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)
SB_FX_GLITCH,
SB_FX_GYRO,
SB_FX_NONE,
} scene_effect_t;
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];
scene_effect_t effect;
} scene_binding_t;
// Same return contract as puzzle_binding_from_ir for ESP_OK/NOT_FOUND/
// INVALID_ARG(malformed JSON only — scene content itself never fails).
esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
scene_binding_t *out);
@@ -0,0 +1,273 @@
// puzzle_binding.c — parse a step's optional puzzle binding from Runtime 3 IR.
#include "puzzle_binding.h"
#include "cJSON.h"
#include <string.h>
esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
puzzle_binding_t *out)
{
if (!ir_json || !step_id || !out) return ESP_ERR_INVALID_ARG;
memset(out, 0, sizeof(*out));
cJSON *root = cJSON_Parse(ir_json);
if (!root) return ESP_ERR_INVALID_ARG;
if (!cJSON_IsObject(root)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; }
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
cJSON_Delete(root);
return ESP_ERR_NOT_FOUND;
}
// Find the step with matching id.
const cJSON *step = NULL;
const cJSON *item = NULL;
cJSON_ArrayForEach(item, steps) {
const cJSON *sid = cJSON_GetObjectItemCaseSensitive(item, "id");
if (cJSON_IsString(sid) && strcmp(sid->valuestring, step_id) == 0) {
step = item;
break;
}
}
if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; }
// Step found. Check for puzzle member.
const cJSON *puzzle = cJSON_GetObjectItemCaseSensitive(step, "puzzle");
if (!puzzle || cJSON_IsNull(puzzle)) {
// No puzzle — out->type stays PB_NONE, ESP_OK.
cJSON_Delete(root);
return ESP_OK;
}
// --- puzzle.id ---
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(puzzle, "id");
if (!cJSON_IsNumber(jid) || jid->valueint < 1 || jid->valueint > PB_MAX_PUZZLE_ID) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
// --- puzzle.type ---
const cJSON *jtype = cJSON_GetObjectItemCaseSensitive(puzzle, "type");
if (!cJSON_IsString(jtype)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
puzzle_binding_type_t ptype;
if (strcmp(jtype->valuestring, "qr") == 0) {
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));
return ESP_ERR_INVALID_ARG;
}
// --- fragment (required) ---
const cJSON *jfrag = cJSON_GetObjectItemCaseSensitive(puzzle, "fragment");
if (!cJSON_IsArray(jfrag)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
int frag_len = cJSON_GetArraySize(jfrag);
if (frag_len < 1 || frag_len > PB_MAX_FRAG) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
for (int i = 0; i < frag_len; i++) {
const cJSON *fv = cJSON_GetArrayItem(jfrag, i);
if (!cJSON_IsNumber(fv) || fv->valueint < 0 || fv->valueint > 9) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->fragment[i] = (uint8_t)fv->valueint;
}
out->fragment_len = (uint8_t)frag_len;
// --- type-specific fields ---
if (ptype == PB_QR) {
const cJSON *jcodes = cJSON_GetObjectItemCaseSensitive(puzzle, "codes");
if (!cJSON_IsArray(jcodes)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
int n = cJSON_GetArraySize(jcodes);
if (n < 1 || n > PB_MAX_CODES) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
for (int i = 0; i < n; i++) {
const cJSON *cv = cJSON_GetArrayItem(jcodes, i);
if (!cJSON_IsString(cv)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
if (strlen(cv->valuestring) >= PB_MAX_LABEL) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
strncpy(out->codes[i], cv->valuestring, PB_MAX_LABEL - 1);
out->codes[i][PB_MAX_LABEL - 1] = '\0';
}
out->code_count = (size_t)n;
} else if (ptype == PB_SOUND) {
const cJSON *jmelody = cJSON_GetObjectItemCaseSensitive(puzzle, "melody");
if (!cJSON_IsArray(jmelody)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
int n = cJSON_GetArraySize(jmelody);
if (n < 1 || n > PB_MAX_NOTES) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
for (int i = 0; i < n; i++) {
const cJSON *nv = cJSON_GetArrayItem(jmelody, i);
if (!cJSON_IsNumber(nv)) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->melody[i] = nv->valueint;
}
out->note_count = (size_t)n;
// tolerance: optional, default 1, must be >= 0
const cJSON *jtol = cJSON_GetObjectItemCaseSensitive(puzzle, "tolerance");
if (jtol) {
if (!cJSON_IsNumber(jtol) || jtol->valueint < 0) {
cJSON_Delete(root);
memset(out, 0, sizeof(*out));
return ESP_ERR_INVALID_ARG;
}
out->tolerance = jtol->valueint;
} 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
out->id = (uint8_t)jid->valueint;
out->type = ptype;
cJSON_Delete(root);
return ESP_OK;
}
// ─── Scene metadata (lenient — see header) ───────────────────────────────────
static void copy_scene_str(char *dst, size_t cap, const cJSON *node) {
if (cJSON_IsString(node) && node->valuestring) {
strncpy(dst, node->valuestring, cap - 1); // silent truncation OK
dst[cap - 1] = '\0';
}
}
esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
scene_binding_t *out)
{
if (!ir_json || !step_id || !out) return ESP_ERR_INVALID_ARG;
memset(out, 0, sizeof(*out));
out->effect = SB_FX_PULSE;
cJSON *root = cJSON_Parse(ir_json);
if (!root) return ESP_ERR_INVALID_ARG;
if (!cJSON_IsObject(root)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; }
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
cJSON_Delete(root);
return ESP_ERR_NOT_FOUND;
}
const cJSON *step = NULL;
const cJSON *item = NULL;
cJSON_ArrayForEach(item, steps) {
const cJSON *sid = cJSON_GetObjectItemCaseSensitive(item, "id");
if (cJSON_IsString(sid) && strcmp(sid->valuestring, step_id) == 0) {
step = item;
break;
}
}
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 object — present stays false, ESP_OK (scene_id may be set).
cJSON_Delete(root);
return ESP_OK;
}
out->present = true;
copy_scene_str(out->title, sizeof(out->title),
cJSON_GetObjectItemCaseSensitive(scene, "title"));
copy_scene_str(out->subtitle, sizeof(out->subtitle),
cJSON_GetObjectItemCaseSensitive(scene, "subtitle"));
copy_scene_str(out->symbol, sizeof(out->symbol),
cJSON_GetObjectItemCaseSensitive(scene, "symbol"));
const cJSON *fx = cJSON_GetObjectItemCaseSensitive(scene, "effect");
if (cJSON_IsString(fx) && fx->valuestring) {
if (strcmp(fx->valuestring, "glitch") == 0) out->effect = SB_FX_GLITCH;
else if (strcmp(fx->valuestring, "gyro") == 0) out->effect = SB_FX_GYRO;
else if (strcmp(fx->valuestring, "none") == 0) out->effect = SB_FX_NONE;
else out->effect = SB_FX_PULSE; // unknown → default, never an error
}
cJSON_Delete(root);
return ESP_OK;
}
@@ -0,0 +1 @@
build/
@@ -0,0 +1,57 @@
# Host test harness Makefile — puzzle_binding parser
UNITY_DIR ?= $(HOME)/esp/esp-idf/components/unity/unity/src
CJSON_DIR ?= $(HOME)/esp/esp-idf/components/json/cJSON
CC ?= cc
COMPONENT_DIR := ../..
TEST_DIR := ..
BUILD_DIR := build
TEST_BIN := $(BUILD_DIR)/test_runner
SRCS := \
$(COMPONENT_DIR)/puzzle_binding.c \
$(CJSON_DIR)/cJSON.c \
$(UNITY_DIR)/unity.c \
../../../local_puzzles/test/host/host_runner.c
TEST_SRCS := \
$(TEST_DIR)/test_puzzle_binding.c
ALL_SRCS := $(SRCS) $(TEST_SRCS)
INCS := \
-I$(COMPONENT_DIR)/include \
-I$(COMPONENT_DIR) \
-Iesp_err_shim \
-I$(TEST_DIR)/host \
-I../../../local_puzzles/test/host \
-I$(CJSON_DIR) \
-I$(UNITY_DIR)
CFLAGS := -std=c11 -Wall -Wextra -Werror $(INCS) -DUNITY_INCLUDE_CONFIG_H
TEST_CFLAGS := $(CFLAGS) -include ../../../local_puzzles/test/host/unity_test_case.h
.PHONY: all test clean
all: $(TEST_BIN)
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
$(if $(wildcard $(UNITY_DIR)/unity.c),,$(error UNITY_DIR not found: $(UNITY_DIR)set UNITY_DIR=...))
$(if $(wildcard $(CJSON_DIR)/cJSON.c),,$(error CJSON_DIR not found: $(CJSON_DIR)set CJSON_DIR=...))
$(TEST_BIN): $(ALL_SRCS) | $(BUILD_DIR)
$(if $(wildcard $(UNITY_DIR)/unity.c),,$(error UNITY_DIR not found: $(UNITY_DIR)set UNITY_DIR=...))
$(if $(wildcard $(CJSON_DIR)/cJSON.c),,$(error CJSON_DIR not found: $(CJSON_DIR)set CJSON_DIR=...))
$(CC) $(TEST_CFLAGS) -c -o $(BUILD_DIR)/test_puzzle_binding.o $(TEST_DIR)/test_puzzle_binding.c
$(CC) $(CFLAGS) -o $@ $(SRCS) $(BUILD_DIR)/test_puzzle_binding.o
test: $(TEST_BIN)
./$(TEST_BIN)
clean:
rm -rf $(BUILD_DIR)
.PRECIOUS: $(TEST_BIN)
@@ -0,0 +1,9 @@
// esp_err.h — HOST-ONLY shim. Mirrors IDF values for unit tests outside ESP-IDF.
// Do NOT ship this to firmware; the real esp_err.h is used there.
#pragma once
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL (-1)
#define ESP_ERR_INVALID_ARG 0x102
#define ESP_ERR_INVALID_STATE 0x103
#define ESP_ERR_NOT_FOUND 0x105
@@ -0,0 +1,154 @@
// test_puzzle_binding.c — IDF-style host tests for puzzle_binding_from_ir.
#include "unity.h"
#include "puzzle_binding.h"
#include <string.h>
// ── 1. QR step parses fully ──────────────────────────────────────────────────
TEST_CASE("pbind: qr step parses fully", "[pbind]")
{
const char *ir =
"{\"steps\":["
"{\"id\":\"STEP_QR_DETECTOR\","
"\"puzzle\":{\"id\":3,\"type\":\"qr\","
"\"codes\":[\"zacus-qr-1\",\"zacus-qr-2\",\"zacus-qr-3\"],"
"\"fragment\":[5]}}"
"]}";
puzzle_binding_t b;
TEST_ASSERT_EQUAL(ESP_OK, puzzle_binding_from_ir(ir, "STEP_QR_DETECTOR", &b));
TEST_ASSERT_EQUAL(PB_QR, b.type);
TEST_ASSERT_EQUAL(3, b.id);
TEST_ASSERT_EQUAL(3, b.code_count);
TEST_ASSERT_EQUAL_STRING("zacus-qr-1", b.codes[0]);
TEST_ASSERT_EQUAL_STRING("zacus-qr-2", b.codes[1]);
TEST_ASSERT_EQUAL_STRING("zacus-qr-3", b.codes[2]);
TEST_ASSERT_EQUAL(1, b.fragment_len);
TEST_ASSERT_EQUAL(5, b.fragment[0]);
}
// ── 2. Sound step parses; tolerance defaults to 1 when absent ────────────────
TEST_CASE("pbind: sound step parses with default tolerance", "[pbind]")
{
const char *ir =
"{\"steps\":["
"{\"id\":\"STEP_LA_DETECTOR\","
"\"puzzle\":{\"id\":1,\"type\":\"sound\","
"\"melody\":[60,62,64,65],"
"\"fragment\":[1,2]}}"
"]}";
puzzle_binding_t b;
TEST_ASSERT_EQUAL(ESP_OK, puzzle_binding_from_ir(ir, "STEP_LA_DETECTOR", &b));
TEST_ASSERT_EQUAL(PB_SOUND, b.type);
TEST_ASSERT_EQUAL(1, b.id);
TEST_ASSERT_EQUAL(4, b.note_count);
TEST_ASSERT_EQUAL(60, b.melody[0]);
TEST_ASSERT_EQUAL(65, b.melody[3]);
TEST_ASSERT_EQUAL(1, b.tolerance); // default
TEST_ASSERT_EQUAL(2, b.fragment_len);
TEST_ASSERT_EQUAL(1, b.fragment[0]);
TEST_ASSERT_EQUAL(2, b.fragment[1]);
}
// ── 3. Step without puzzle → ESP_OK + PB_NONE ────────────────────────────────
TEST_CASE("pbind: step without puzzle yields PB_NONE", "[pbind]")
{
const char *ir =
"{\"steps\":["
"{\"id\":\"STEP_PLAIN\"}"
"]}";
puzzle_binding_t b;
TEST_ASSERT_EQUAL(ESP_OK, puzzle_binding_from_ir(ir, "STEP_PLAIN", &b));
TEST_ASSERT_EQUAL(PB_NONE, b.type);
}
// ── 4. Unknown step id → ESP_ERR_NOT_FOUND ───────────────────────────────────
TEST_CASE("pbind: unknown step id yields NOT_FOUND", "[pbind]")
{
const char *ir =
"{\"steps\":["
"{\"id\":\"STEP_A\"}"
"]}";
puzzle_binding_t b;
TEST_ASSERT_EQUAL(ESP_ERR_NOT_FOUND,
puzzle_binding_from_ir(ir, "STEP_MISSING", &b));
TEST_ASSERT_EQUAL(PB_NONE, b.type);
}
// ── 5. Malformed JSON → ESP_ERR_INVALID_ARG ──────────────────────────────────
TEST_CASE("pbind: malformed JSON yields INVALID_ARG", "[pbind]")
{
puzzle_binding_t b;
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
puzzle_binding_from_ir("{not valid json", "STEP_A", &b));
TEST_ASSERT_EQUAL(PB_NONE, b.type);
}
// ── 6a. Puzzle id out of range (0) → ESP_ERR_INVALID_ARG ─────────────────────
TEST_CASE("pbind: puzzle id 0 yields INVALID_ARG", "[pbind]")
{
const char *ir =
"{\"steps\":["
"{\"id\":\"STEP_X\","
"\"puzzle\":{\"id\":0,\"type\":\"qr\","
"\"codes\":[\"c1\"],\"fragment\":[1]}}"
"]}";
puzzle_binding_t b;
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
puzzle_binding_from_ir(ir, "STEP_X", &b));
TEST_ASSERT_EQUAL(PB_NONE, b.type);
}
// ── 6b. Fragment value 12 (non-single-digit) → ESP_ERR_INVALID_ARG ───────────
TEST_CASE("pbind: fragment value 12 yields INVALID_ARG", "[pbind]")
{
const char *ir =
"{\"steps\":["
"{\"id\":\"STEP_X\","
"\"puzzle\":{\"id\":2,\"type\":\"qr\","
"\"codes\":[\"c1\"],\"fragment\":[12]}}"
"]}";
puzzle_binding_t b;
TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG,
puzzle_binding_from_ir(ir, "STEP_X", &b));
TEST_ASSERT_EQUAL(PB_NONE, b.type);
}
// ── 8-10. Scene metadata (lenient parser) ────────────────────────────────────
TEST_CASE("scene parses title/subtitle/symbol/effect", "[pbind]")
{
const char *ir =
"{\"steps\":["
"{\"id\":\"STEP_X\",\"scene\":{\"title\":\"MISSION\","
"\"subtitle\":\"trouvez les QR\",\"symbol\":\"RUN\","
"\"effect\":\"gyro\"}}"
"]}";
scene_binding_t s;
TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s));
TEST_ASSERT_TRUE(s.present);
TEST_ASSERT_EQUAL_STRING("MISSION", s.title);
TEST_ASSERT_EQUAL_STRING("trouvez les QR", s.subtitle);
TEST_ASSERT_EQUAL_STRING("RUN", s.symbol);
TEST_ASSERT_EQUAL(SB_FX_GYRO, s.effect);
}
TEST_CASE("scene absent leaves present=false, defaults intact", "[pbind]")
{
const char *ir = "{\"steps\":[{\"id\":\"STEP_X\"}]}";
scene_binding_t s;
TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s));
TEST_ASSERT_FALSE(s.present);
TEST_ASSERT_EQUAL(SB_FX_PULSE, s.effect);
}
TEST_CASE("scene unknown effect -> pulse; long title truncated", "[pbind]")
{
const char *ir =
"{\"steps\":["
"{\"id\":\"STEP_X\",\"scene\":{\"effect\":\"discoball\","
"\"title\":\"0123456789012345678901234567890123456789012345678901234\"}}"
"]}";
scene_binding_t s;
TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s));
TEST_ASSERT_TRUE(s.present);
TEST_ASSERT_EQUAL(SB_FX_PULSE, s.effect);
TEST_ASSERT_EQUAL(SB_MAX_TITLE - 1, (int) strlen(s.title));
}
@@ -0,0 +1,11 @@
idf_component_register(
SRCS
"gamebook.c"
INCLUDE_DIRS
"include"
REQUIRES
json
display_ui
media_manager
log
)
+293
View File
@@ -0,0 +1,293 @@
// gamebook.c — see gamebook.h. Standalone gamebook player + library for the
// Freenove master. On boot it shows the library (a grid of story tiles);
// picking one loads and plays that book; finishing returns to the library.
#include "gamebook.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "display_ui.h"
#include "media_manager.h"
static const char *TAG = "gamebook";
#define GAMEBOOK_DIR "/sdcard/gamebook"
#define LIBRARY_JSON GAMEBOOK_DIR "/library.json"
#define JSON_MAX (64 * 1024) /* per-file JSON sanity cap */
#define LIB_MAX 6 /* matches the display tile grid */
typedef enum { GB_OFF, GB_LIBRARY, GB_STORY } gb_mode_t;
// Library state
static cJSON *s_lib_root = NULL; /* owns library.json while loaded */
static cJSON *s_lib = NULL; /* borrowed: root->"library" array */
static int s_lib_n = 0;
static int s_lib_sel = 0;
// Story state
static cJSON *s_book = NULL; /* owns the current <book>.json */
static cJSON *s_passages = NULL; /* borrowed: book->"passages" */
static char s_title[48] = {0};
static char s_current[48] = {0};
static int s_sel = 0; /* highlighted choice index */
static volatile gb_mode_t s_mode = GB_OFF;
bool gamebook_active(void) { return s_mode != GB_OFF; }
/* Read a whole JSON file from the SD into a cJSON tree (caller frees). */
static cJSON *load_json(const char *path)
{
FILE *f = fopen(path, "rb");
if (!f) { ESP_LOGW(TAG, "open %s failed", path); return NULL; }
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0 || sz > JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; }
char *buf = malloc((size_t)sz + 1);
if (!buf) { fclose(f); return NULL; }
size_t rd = fread(buf, 1, (size_t)sz, f);
fclose(f);
buf[rd] = '\0';
cJSON *root = cJSON_Parse(buf);
free(buf);
if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path);
return root;
}
// ── Library ──────────────────────────────────────────────────────────────────
static void render_library(void)
{
const char *titles[LIB_MAX] = {0};
int n = (s_lib_n < LIB_MAX) ? s_lib_n : LIB_MAX;
for (int i = 0; i < n; i++) {
const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title");
titles[i] = cJSON_IsString(t) ? t->valuestring : "?";
}
display_ui_library_show(titles, n, s_lib_sel);
}
static void free_story(void)
{
media_manager_stop();
if (s_book) { cJSON_Delete(s_book); s_book = NULL; }
s_passages = NULL;
s_current[0] = '\0';
}
static esp_err_t open_library(void)
{
free_story();
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
s_lib_root = load_json(LIBRARY_JSON);
if (!s_lib_root) return ESP_ERR_NOT_FOUND;
s_lib = cJSON_GetObjectItem(s_lib_root, "library");
if (!cJSON_IsArray(s_lib) || cJSON_GetArraySize(s_lib) == 0) {
cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL;
ESP_LOGW(TAG, "library.json has no stories");
return ESP_ERR_INVALID_ARG;
}
s_lib_n = cJSON_GetArraySize(s_lib);
s_lib_sel = 0;
s_mode = GB_LIBRARY;
display_ui_gamebook_hide();
render_library();
ESP_LOGI(TAG, "library open (%d stories)", s_lib_n);
return ESP_OK;
}
// ── Story ─────────────────────────────────────────────────────────────────────
static const cJSON *cur_passage(void) { return cJSON_GetObjectItem(s_passages, s_current); }
static int cur_choice_count(void)
{
const cJSON *ch = cJSON_GetObjectItem(cur_passage(), "choices");
return cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
}
/* (Re)draw the current page. `home` true = new passage (reset scroll to top);
* false = same passage, only the choice cursor moved (keep scroll position).
* The bottom line shows ONE choice with < > arrows (Left/Right cycles). */
static void render_page(bool home)
{
const cJSON *p = cur_passage();
if (!cJSON_IsObject(p)) return;
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
const cJSON *text = cJSON_GetObjectItem(p, "text");
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
char menu[160];
if (n == 0) {
snprintf(menu, sizeof(menu), "~ Fin ~ (clic = bibliotheque)");
} else {
const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel),
"label");
const char *txt = cJSON_IsString(lbl) ? lbl->valuestring : "?";
if (n > 1) {
/* Down cycles the answer, Click validates. */
snprintf(menu, sizeof(menu), "%d/%d %s (bas / clic)",
s_sel + 1, n, txt);
} else {
snprintf(menu, sizeof(menu), "%s (clic)", txt);
}
}
display_ui_gamebook_show(
cJSON_IsString(screen) ? screen->valuestring : s_title,
cJSON_IsString(text) ? text->valuestring : "",
menu, home);
}
static void enter_passage(const char *pid)
{
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' not found", pid); return; }
snprintf(s_current, sizeof(s_current), "%s", pid);
s_sel = 0;
render_page(true);
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) {
char path[96];
snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_DIR, wav->valuestring);
media_manager_stop(); /* a choice skips the narration */
media_manager_play(path);
}
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, cur_choice_count());
}
/* Load story #idx from the library and play it. */
static void load_book(int idx)
{
const cJSON *e = cJSON_GetArrayItem(s_lib, idx);
const cJSON *file = cJSON_GetObjectItem(e, "book");
if (!cJSON_IsString(file)) { ESP_LOGW(TAG, "library[%d] has no book", idx); return; }
char path[128];
snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_DIR, file->valuestring);
cJSON *book = load_json(path);
if (!book) return;
const cJSON *passages = cJSON_GetObjectItem(book, "passages");
const cJSON *start = cJSON_GetObjectItem(book, "start");
const cJSON *title = cJSON_GetObjectItem(book, "title");
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
ESP_LOGW(TAG, "%s missing passages/start", path);
cJSON_Delete(book);
return;
}
free_story();
s_book = book;
s_passages = (cJSON *)passages;
snprintf(s_title, sizeof(s_title), "%s",
cJSON_IsString(title) ? title->valuestring : "");
s_mode = GB_STORY;
display_ui_library_hide();
ESP_LOGI(TAG, "load book \"%s\" @ '%s'", s_title, start->valuestring);
enter_passage(start->valuestring);
}
// ── Input ─────────────────────────────────────────────────────────────────────
/* MEASURED pad mapping on this hardware (ADC ladder, see calibration):
* key1 = CLICK (centre, ~0 mV)
* key2 = LEFT (gauche, ~700 mV)
* key4 = DOWN (bas, ~1350 mV)
* key5 = RIGHT (droite, ~1994 mV)
* key3 is never produced; the physical UP button is electrically dead
* (held = no voltage change), so nothing can depend on it. */
#define PAD_CLICK 1
#define PAD_LEFT 2
#define PAD_DOWN 4
#define PAD_RIGHT 5
/* Library nav: Down/Right → next tile, Left → previous, Click → open.
* Selection wraps, so the working buttons reach every story without Up. */
static bool library_key(uint8_t key)
{
if (s_lib_n <= 0) return true;
switch (key) {
case PAD_DOWN: case PAD_RIGHT:
s_lib_sel = (s_lib_sel + 1) % s_lib_n; render_library(); break;
case PAD_LEFT:
s_lib_sel = (s_lib_sel - 1 + s_lib_n) % s_lib_n; render_library(); break;
case PAD_CLICK:
load_book(s_lib_sel); break;
default: break;
}
return true;
}
/* Story nav (Up is dead, so it's never used):
* Left → scroll the reading text UP
* Right → scroll the reading text DOWN
* Down → next answer (cycles through all choices, wraps around)
* Click → validate the highlighted answer
* On an ending page (no choices): Left/Right still scroll, Click → library. */
static bool story_key(uint8_t key)
{
if (key == PAD_LEFT) { display_ui_gamebook_scroll(-1); return true; } /* scroll up */
if (key == PAD_RIGHT) { display_ui_gamebook_scroll(+1); return true; } /* scroll down */
int n = cur_choice_count();
if (n == 0) { /* ending → click returns to library */
if (key == PAD_CLICK) open_library();
return true;
}
switch (key) {
case PAD_DOWN: s_sel = (s_sel + 1) % n; render_page(false); break; /* next answer */
case PAD_CLICK: { /* validate */
const cJSON *choices = cJSON_GetObjectItem(cur_passage(), "choices");
const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel), "goto");
if (cJSON_IsString(g)) {
ESP_LOGI(TAG, "validate #%d -> '%s'", s_sel, g->valuestring);
enter_passage(g->valuestring);
}
break;
}
default: break;
}
return true;
}
static bool gamebook_key_hook(uint8_t key)
{
switch (s_mode) {
case GB_LIBRARY: return library_key(key);
case GB_STORY: return story_key(key);
default: return false; /* not active → let the shell have it */
}
}
// ── Public API ────────────────────────────────────────────────────────────────
esp_err_t gamebook_start(void)
{
return open_library();
}
void gamebook_stop(void)
{
if (s_mode == GB_OFF) return;
s_mode = GB_OFF;
free_story();
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
display_ui_gamebook_hide();
display_ui_library_hide();
ESP_LOGI(TAG, "stopped");
}
void gamebook_init(void)
{
/* Only register the pad hook here — the SD and media_manager aren't up yet
* at this point in boot. main.c calls gamebook_start() once they are, to
* boot into the library. */
display_ui_set_key_hook(gamebook_key_hook);
ESP_LOGI(TAG, "key hook installed");
}
@@ -0,0 +1,42 @@
#pragma once
// gamebook — "livre dont vous êtes le héros" mode for the Freenove master.
//
// Reads /sdcard/gamebook/gamebook.json (built by tools/gamebook/build_gamebook.py),
// plays each passage's WAV narration from the SD via media_manager, shows the
// passage title + choice menu on the display, and navigates with the 5-way pad
// (it installs a display_ui key hook so it owns the buttons while active).
// Fully local: no model, no gateway.
//
// JSON shape:
// { "title": "...", "start": "intro",
// "passages": { "intro": { "wav": "intro.wav", "screen": "...",
// "menu": "[OK] ... | [<>] ...",
// "choices": [ {"key": 1, "goto": "machine"}, ... ] },
// ... } }
// key codes match the 5-way pad: 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP.
// MENU (3) always quits the gamebook.
#include "esp_err.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
// Install the display_ui key hook. Call once at boot, after display_ui_init().
void gamebook_init(void);
// Load /sdcard/gamebook/gamebook.json and enter the start passage (show text +
// play its WAV). Returns ESP_ERR_* if the SD/JSON is missing or malformed.
esp_err_t gamebook_start(void);
// Leave gamebook mode: stop playback, release the JSON, hand the pad back to
// the shell. Idempotent.
void gamebook_stop(void);
// True while a gamebook is running (used by the key hook to claim the pad).
bool gamebook_active(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,16 @@
idf_component_register(
SRCS
"hints_client.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_http_client
esp_timer
esp_system
esp_wifi
esp_netif
nvs_flash
json
freertos
log
)
@@ -0,0 +1,327 @@
#include "hints_client.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "esp_event.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "hints_client";
#define WORKER_STACK_DEFAULT 6144
#define WORKER_PRIO_DEFAULT 5
#define ASK_PATH "/hints/ask"
#define PUZZLE_START_PATH "/hints/puzzle_start"
#define ATTEMPT_FAILED_PATH "/hints/attempt_failed"
#define SESSION_ID_LEN 13 // 12 hex + NUL
static struct {
bool ready;
char base_url[HINTS_CLIENT_BASE_URL_MAX];
char session_id[SESSION_ID_LEN];
char group_profile[HINTS_CLIENT_GROUP_PROFILE_MAX];
} s_client = {0};
// Whitelist of accepted group profile values. Keep in sync with the
// `group_profile` enum in the hints engine (game/hints/* server-side).
static const char *const kAllowedProfiles[] = {
"TECH", "NON_TECH", "MIXED", "BOTH",
};
static const size_t kAllowedProfilesCount =
sizeof(kAllowedProfiles) / sizeof(kAllowedProfiles[0]);
static bool profile_is_allowed(const char *p) {
if (!p || !*p) return false;
for (size_t i = 0; i < kAllowedProfilesCount; ++i) {
if (strcmp(p, kAllowedProfiles[i]) == 0) return true;
}
return false;
}
// Receive buffer used by hints_client_ask. Sized to one HINTS_CLIENT_HINT_MAX
// hint plus generous JSON envelope.
typedef struct {
char *buf;
size_t cap;
size_t len;
} recv_buf_t;
static void session_id_init(void) {
uint8_t mac[6] = {0};
if (esp_efuse_mac_get_default(mac) == ESP_OK) {
snprintf(s_client.session_id, SESSION_ID_LEN,
"%02x%02x%02x%02x%02x%02x",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
} else {
strncpy(s_client.session_id, "unknown-mac", SESSION_ID_LEN - 1);
}
s_client.session_id[SESSION_ID_LEN - 1] = '\0';
}
esp_err_t hints_client_init(const char *base_url) {
if (!base_url || !*base_url) return ESP_ERR_INVALID_ARG;
if (strlen(base_url) >= sizeof(s_client.base_url)) return ESP_ERR_INVALID_SIZE;
strncpy(s_client.base_url, base_url, sizeof(s_client.base_url) - 1);
s_client.base_url[sizeof(s_client.base_url) - 1] = '\0';
// Default group profile until main reads NVS or the dashboard pushes
// a new one. Validated through hints_client_set_group_profile.
strncpy(s_client.group_profile, "MIXED",
sizeof(s_client.group_profile) - 1);
s_client.group_profile[sizeof(s_client.group_profile) - 1] = '\0';
session_id_init();
s_client.ready = true;
ESP_LOGI(TAG, "ready, base_url=%s session_id=%s group_profile=%s",
s_client.base_url, s_client.session_id, s_client.group_profile);
return ESP_OK;
}
bool hints_client_is_ready(void) {
return s_client.ready;
}
esp_err_t hints_client_set_group_profile(const char *profile) {
if (!profile_is_allowed(profile)) {
ESP_LOGW(TAG, "set_group_profile: invalid value \"%s\" — keeping \"%s\"",
profile ? profile : "(null)", s_client.group_profile);
return ESP_ERR_INVALID_ARG;
}
strncpy(s_client.group_profile, profile,
sizeof(s_client.group_profile) - 1);
s_client.group_profile[sizeof(s_client.group_profile) - 1] = '\0';
ESP_LOGI(TAG, "group_profile set to \"%s\"", s_client.group_profile);
return ESP_OK;
}
const char *hints_client_group_profile(void) {
// Always non-NULL after init(); falls back to empty string before
// init so callers don't need a separate ready check.
return s_client.group_profile[0] ? s_client.group_profile : "";
}
static esp_err_t http_event_cb(esp_http_client_event_t *evt) {
if (evt->event_id != HTTP_EVENT_ON_DATA) return ESP_OK;
recv_buf_t *r = (recv_buf_t *) evt->user_data;
if (!r || !r->buf) return ESP_OK;
int chunk = evt->data_len;
if (r->len + chunk >= r->cap) {
chunk = (int) (r->cap - r->len - 1);
}
if (chunk > 0) {
memcpy(r->buf + r->len, evt->data, (size_t) chunk);
r->len += (size_t) chunk;
r->buf[r->len] = '\0';
}
return ESP_OK;
}
// ── Shared HTTP helper ───────────────────────────────────────────────────
//
// Performs `POST {base_url}{path}` with `body_str` as the request body
// and writes the response into `recv` (NUL-terminated, truncated to
// recv->cap-1). Returns:
// ESP_OK any 2xx (including 204 No Content)
// ESP_FAIL transport ok, non-2xx response
// <esp_err_t> transport-level error from esp_http_client_perform()
//
// The caller owns `body_str` and `recv->buf`. `timeout_ms` is per-request.
static esp_err_t post_json(const char *path, const char *body_str,
int timeout_ms, recv_buf_t *recv) {
char url[HINTS_CLIENT_BASE_URL_MAX + 64];
snprintf(url, sizeof(url), "%s%s", s_client.base_url, path);
esp_http_client_config_t cfg = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = timeout_ms,
.event_handler = recv ? http_event_cb : NULL,
.user_data = recv,
.disable_auto_redirect = true,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) return ESP_FAIL;
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, body_str, (int) strlen(body_str));
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
if (err != ESP_OK) {
ESP_LOGW(TAG, "POST %s perform failed: %s",
path, esp_err_to_name(err));
return err;
}
if (status < 200 || status >= 300) {
ESP_LOGW(TAG, "POST %s non-2xx %d body=%.*s",
path, status,
recv ? (int) recv->len : 0,
recv ? recv->buf : "");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level,
char *out_hint, size_t out_size) {
if (!s_client.ready) return ESP_ERR_INVALID_STATE;
if (!puzzle_id || !*puzzle_id || !out_hint || out_size == 0) {
return ESP_ERR_INVALID_ARG;
}
cJSON *body = cJSON_CreateObject();
if (!body) return ESP_ERR_NO_MEM;
cJSON_AddStringToObject(body, "puzzle_id", puzzle_id);
cJSON_AddNumberToObject(body, "level", level);
cJSON_AddStringToObject(body, "session_id", s_client.session_id);
if (s_client.group_profile[0] != '\0') {
cJSON_AddStringToObject(body, "group_profile", s_client.group_profile);
}
char *body_str = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
if (!body_str) return ESP_ERR_NO_MEM;
char recv_storage[1024];
recv_buf_t recv = {.buf = recv_storage, .cap = sizeof(recv_storage), .len = 0};
recv_storage[0] = '\0';
esp_err_t err = post_json(ASK_PATH, body_str,
HINTS_CLIENT_TIMEOUT_MS, &recv);
free(body_str);
if (err != ESP_OK) return err;
cJSON *root = cJSON_Parse(recv.buf);
if (!root) {
ESP_LOGW(TAG, "JSON parse failed: %.*s", (int) recv.len, recv.buf);
return ESP_FAIL;
}
cJSON *refused = cJSON_GetObjectItemCaseSensitive(root, "refused");
if (cJSON_IsTrue(refused)) {
cJSON *reason = cJSON_GetObjectItemCaseSensitive(root, "reason");
const char *reason_str = (reason && cJSON_IsString(reason))
? reason->valuestring : "unknown";
snprintf(out_hint, out_size,
"Le Professeur Zacus reste muet pour l'instant.");
ESP_LOGI(TAG, "refused: %s", reason_str);
cJSON_Delete(root);
return ESP_OK; // refusal is a valid response, not an error
}
cJSON *hint = cJSON_GetObjectItemCaseSensitive(root, "hint");
if (!hint || !cJSON_IsString(hint)) {
ESP_LOGW(TAG, "no hint field in response");
cJSON_Delete(root);
return ESP_FAIL;
}
strncpy(out_hint, hint->valuestring, out_size - 1);
out_hint[out_size - 1] = '\0';
cJSON_Delete(root);
return ESP_OK;
}
// ── Lifecycle endpoints (slice 11 / P5) ────────────────────────────────────
// Shared body builder for /puzzle_start and /attempt_failed.
// Both endpoints take the same minimal payload {session_id, puzzle_id}.
static esp_err_t lifecycle_post(const char *path, const char *puzzle_id) {
if (!s_client.ready) return ESP_ERR_INVALID_STATE;
if (!puzzle_id || !*puzzle_id) return ESP_ERR_INVALID_ARG;
cJSON *body = cJSON_CreateObject();
if (!body) return ESP_ERR_NO_MEM;
cJSON_AddStringToObject(body, "session_id", s_client.session_id);
cJSON_AddStringToObject(body, "puzzle_id", puzzle_id);
char *body_str = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
if (!body_str) return ESP_ERR_NO_MEM;
char recv_storage[256];
recv_buf_t recv = {.buf = recv_storage, .cap = sizeof(recv_storage), .len = 0};
recv_storage[0] = '\0';
esp_err_t err = post_json(path, body_str,
HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS, &recv);
free(body_str);
return err;
}
esp_err_t hints_client_puzzle_start(const char *puzzle_id) {
esp_err_t err = lifecycle_post(PUZZLE_START_PATH, puzzle_id);
if (err == ESP_OK) {
ESP_LOGI(TAG, "puzzle_start ok puzzle=\"%s\"", puzzle_id);
} else {
ESP_LOGW(TAG, "puzzle_start best-effort failed (puzzle=\"%s\"): %s",
puzzle_id ? puzzle_id : "(null)", esp_err_to_name(err));
}
return err;
}
esp_err_t hints_client_attempt_failed(const char *puzzle_id) {
esp_err_t err = lifecycle_post(ATTEMPT_FAILED_PATH, puzzle_id);
if (err == ESP_OK) {
ESP_LOGI(TAG, "attempt_failed ok puzzle=\"%s\"", puzzle_id);
} else {
ESP_LOGW(TAG, "attempt_failed best-effort failed (puzzle=\"%s\"): %s",
puzzle_id ? puzzle_id : "(null)", esp_err_to_name(err));
}
return err;
}
// ── Async wrapper ──────────────────────────────────────────────────────────
typedef struct {
char puzzle_id_str[64];
uint8_t puzzle_id_num;
uint8_t level;
hints_client_callback_t cb;
void *user_ctx;
} async_arg_t;
static void async_worker(void *pv) {
async_arg_t *arg = (async_arg_t *) pv;
char hint[HINTS_CLIENT_HINT_MAX];
hint[0] = '\0';
esp_err_t err = hints_client_ask(arg->puzzle_id_str, arg->level,
hint, sizeof(hint));
if (arg->cb) {
arg->cb(arg->puzzle_id_num, arg->level, err,
err == ESP_OK ? hint : NULL, arg->user_ctx);
}
free(arg);
vTaskDelete(NULL);
}
esp_err_t hints_client_ask_async(const char *puzzle_id_str,
uint8_t puzzle_id_num,
uint8_t level,
hints_client_callback_t cb,
void *user_ctx,
uint32_t stack,
uint8_t prio) {
if (!s_client.ready) return ESP_ERR_INVALID_STATE;
if (!puzzle_id_str || !*puzzle_id_str || !cb) return ESP_ERR_INVALID_ARG;
async_arg_t *arg = (async_arg_t *) calloc(1, sizeof(async_arg_t));
if (!arg) return ESP_ERR_NO_MEM;
strncpy(arg->puzzle_id_str, puzzle_id_str, sizeof(arg->puzzle_id_str) - 1);
arg->puzzle_id_num = puzzle_id_num;
arg->level = level;
arg->cb = cb;
arg->user_ctx = user_ctx;
BaseType_t ok = xTaskCreate(async_worker, "hints_async",
stack ? stack : WORKER_STACK_DEFAULT,
arg,
prio ? prio : WORKER_PRIO_DEFAULT,
NULL);
if (ok != pdPASS) {
free(arg);
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
@@ -0,0 +1,110 @@
// hints_client — HTTP client to the Zacus hints engine.
//
// POSTs JSON {"puzzle_id":..,"level":..,"session_id":..,"group_profile":..}
// to {base_url}/hints/ask and parses the "hint" / "source" / "refused" /
// "cooldown_until_ms" fields.
//
// Slice 11 (P5) adds two best-effort lifecycle endpoints used by the
// scenario engine to give the hints backend richer context for its
// adaptive policy:
// * /hints/puzzle_start — entered a new pivot
// * /hints/attempt_failed — operator reported an invalid input
// Both POST a minimal `{session_id, puzzle_id}` body. Failures are logged
// but never fatal: the engine works without these signals, they only
// improve the hint quality.
//
// Surfaces:
// * hints_client_ask() — synchronous, blocks the calling task up
// to HINTS_CLIENT_TIMEOUT_MS. Returns
// ESP_OK on success and writes the hint
// into out_hint.
// * hints_client_ask_async() — spawns a one-shot worker task that
// performs the request and invokes `cb`
// when done.
// * hints_client_puzzle_start() — synchronous best-effort POST.
// * hints_client_attempt_failed()— synchronous best-effort POST.
// * hints_client_set_group_profile() — global profile attached to every
// /hints/ask payload (default "MIXED").
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HINTS_CLIENT_BASE_URL_MAX 128
#define HINTS_CLIENT_HINT_MAX 512
#define HINTS_CLIENT_TIMEOUT_MS 10000 // 10 s — covers MLX 32B latency
#define HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS 5000 // /puzzle_start + /attempt_failed
#define HINTS_CLIENT_GROUP_PROFILE_MAX 32 // "TECH","NON_TECH","MIXED","BOTH"
// Callback signature mirrors npc_engine.h's npc_hint_callback_t so the
// engine can forward `cb` directly without a trampoline.
typedef void (*hints_client_callback_t)(uint8_t puzzle_id, uint8_t level,
esp_err_t status, const char *text,
void *user_ctx);
// Initialise the client with the base URL of the hints engine
// (e.g. "http://192.168.0.150:8302"). Caller retains ownership of the
// string — it is copied internally.
esp_err_t hints_client_init(const char *base_url);
// Returns true once hints_client_init() succeeded.
bool hints_client_is_ready(void);
// Synchronous request. `out_hint` receives a NUL-terminated UTF-8 string,
// up to `out_size` bytes. Errors:
// ESP_ERR_INVALID_STATE not initialised
// ESP_ERR_INVALID_ARG bad params
// ESP_ERR_TIMEOUT server did not respond in time
// ESP_FAIL HTTP non-2xx or JSON parse error
esp_err_t hints_client_ask(const char *puzzle_id, uint8_t level,
char *out_hint, size_t out_size);
// Async wrapper. Spawns a worker FreeRTOS task with `stack` bytes of stack
// (default 6144 if 0 passed) and priority `prio` (default 5 if 0). The worker
// calls hints_client_ask() and then `cb` from its own context.
//
// `puzzle_id_str` and the user_ctx pointer are copied into the worker arg
// block, so the caller does not need to keep them alive.
esp_err_t hints_client_ask_async(const char *puzzle_id_str,
uint8_t puzzle_id_num,
uint8_t level,
hints_client_callback_t cb,
void *user_ctx,
uint32_t stack,
uint8_t prio);
// Slice 11 (P5): notify the hints engine that the operator just entered
// the pivot identified by `puzzle_id`. Synchronous POST, timeout
// HINTS_CLIENT_LIFECYCLE_TIMEOUT_MS. Returns ESP_OK on any 2xx
// (the engine treats this as idempotent), ESP_FAIL otherwise. The call
// is best-effort — callers should log failures and continue.
esp_err_t hints_client_puzzle_start(const char *puzzle_id);
// Same shape as hints_client_puzzle_start, but for the
// /hints/attempt_failed endpoint. Bumps the engine's failure counter
// for the current pivot, which feeds the adaptive escalation policy.
esp_err_t hints_client_attempt_failed(const char *puzzle_id);
// Replace the global group profile attached to every /hints/ask body.
// `profile` must be one of "TECH", "NON_TECH", "MIXED", "BOTH". Any
// other value (including NULL/empty) is rejected with
// ESP_ERR_INVALID_ARG and the previous profile is preserved. Default
// after init() is "MIXED".
esp_err_t hints_client_set_group_profile(const char *profile);
// Returns the currently configured group profile (always non-NULL).
// The pointer is owned by the client; copy if you need it past the
// next set_group_profile() call.
const char *hints_client_group_profile(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,7 @@
idf_component_register(
SRCS "mic_broker.c" "seq_validator.c" "melody_validator.c" "qr_puzzle.c" "sound_puzzle.c"
"local_puzzles.c"
INCLUDE_DIRS "include" "."
REQUIRES puzzle_state
PRIV_REQUIRES driver
)
@@ -0,0 +1,55 @@
# local_puzzles
Énigmes locales du master Freenove (FNK0102H Media Kit) : la séquence QR (P3)
et la mélodie au son (P1). Le composant câble la capture matérielle (caméra,
micro) à une logique de validation pure, et reporte les fragments de code
résolus dans `puzzle_state`.
## Sous-modules
- **`board_pins_mediakit.h`** — pin-map de la carte Freenove FNK0102H (gelée le
2026-06-10, source de vérité). Caméra, micro I2S IN, haut-parleur I2S OUT et
écran TFT ST7796, sans collision GPIO. À noter : les défauts de
`voice_pipeline` ciblent un autre câblage et **entrent en collision** avec les
pins caméra — ils doivent être surchargés par les valeurs FNK0102H (micro
3/14/46, HP 42/41/1) à l'init de l'application.
- **`seq_validator`** / **`melody_validator`** — logique pure, sans I/O, testée
sur hôte. `seq_validator` valide une séquence de codes QR dans l'ordre (labels
distincts ; un mauvais scan réinitialise, un doublon du dernier code correct
est ignoré). `melody_validator` valide une séquence de notes MIDI avec
tolérance en demi-tons (notes répétées permises).
- **`mic_broker`** — propriétaire unique du micro I2S RX, partagé avec
`voice_pipeline`. Route les trames PCM16 mono vers **un seul** consommateur
actif selon le mode : `MIC_IDLE` (aucune trame), `MIC_NPC_LISTEN` (dialogue
PNJ), `MIC_P1_SOUND` (énigme son). Init unique ; un second `mic_broker_init()`
renvoie `ESP_ERR_INVALID_STATE`.
- **`qr_puzzle`** — caméra OV3660 en QVGA 320×240 grayscale (esp32-camera) +
décodage **quirc**. La tâche de scan ne passe la trame à quirc que si sa
géométrie correspond à la taille configurée (garde adaptatif à la géométrie du
capteur) ; hook viewfinder optionnel avant décodage. Teardown caméra
**asynchrone** après `stop``start` renvoie `ESP_ERR_INVALID_STATE` jusqu'à
la fin du teardown (réessayer après ~100 ms).
- **`sound_puzzle`** — consomme les trames `MIC_P1_SOUND` et détecte les notes
par taux de **passages par zéro** (zero-crossing → fréquence fondamentale →
note MIDI), avant validation par `melody_validator`.
- **`local_puzzles`** — couche de câblage : `local_puzzles_init` (cible
d'agrégation), `arm_qr` / `arm_sound` (armer une énigme avec sa séquence
attendue et le fragment à reporter), `disarm` (arrêter les deux). Les callbacks
de résolution ne doivent pas ré-armer ni désarmer directement — différer à une
autre tâche.
## Tests hôte
```bash
make -C idf_zacus/components/local_puzzles/test/host test
```
6 tests (3 `seq_validator`, 3 `melody_validator`) compilés et exécutés sur hôte,
sans matériel. Nécessite Unity (`UNITY_DIR`, défaut `~/esp/esp-idf/...`).
## Notes / incohérences
- **Capteur** : le matériel est un **OV3660**. Le code et certains commentaires
(ex. en-tête `qr_puzzle.h`) disent parfois OV2640 à tort.
- **QR sur LCD** : le décodage par la caméra d'un QR affiché sur un écran LCD
(émissif) n'est pas fiable — préférer un QR imprimé pour P3.
@@ -0,0 +1,7 @@
## Managed dependencies for local_puzzles component.
## Both libs exist on the ESP Component Registry — no vendoring needed.
dependencies:
espressif/esp32-camera:
version: ">=2.0.0"
espressif/quirc:
version: ">=1.2.0"
@@ -0,0 +1,125 @@
#pragma once
/**
* @file board_pins_mediakit.h
*
* Freenove FNK0102H Media Kit v1.2 pin map.
*
* Board: Freenove ESP32-S3 WROOM N16R8 (FNK0102H)
* Sources:
* - docs/FNK0102H_SOURCE_OF_TRUTH.md (canonical pin tables)
* - ui_freenove_allinone/include/ui_freenove_config.h (validated firmware)
*
* Date frozen: 2026-06-10
*
* CRITICAL NOTE (voice_pipeline defaults):
* The voice_pipeline component defaults (in idf_zacus/components/voice_pipeline/voice_pipeline.c)
* are configured for a different wiring:
* - Mic: 14 (BCLK), 15 (WS), 22 (DIN)
* - Speaker: 11 (BCLK), 12 (LRC), 13 (DOUT)
*
* These defaults COLLIDE with the FNK0102H camera pins:
* - 15 = CAM_PIN_XCLK
* - 13 = CAM_PIN_PCLK
* - 11 = CAM_PIN_D0 (Y2)
* - 12 = CAM_PIN_D4 (Y6)
*
* When initializing voice_pipeline on the Media Kit, the defaults MUST be overridden
* with the FNK0102H values defined below (mic: 3/14/46, speaker: 42/41/1).
* This override is performed in the main application initialization (separate task).
*/
/* Camera pins (8-bit parallel interface + control) */
#define CAM_PIN_PWDN -1 /* Power down: not wired */
#define CAM_PIN_RESET -1 /* Reset: not wired */
#define CAM_PIN_XCLK 15 /* Master clock */
#define CAM_PIN_SIOD 4 /* I2C SDA (camera config) */
#define CAM_PIN_SIOC 5 /* I2C SCL (camera config) */
#define CAM_PIN_VSYNC 6 /* Vertical sync */
#define CAM_PIN_HREF 7 /* Horizontal ref / data enable */
#define CAM_PIN_PCLK 13 /* Pixel clock */
/* Camera data pins (D0..D7 = Y2..Y9 per esp32-camera convention) */
#define CAM_PIN_D0 11 /* Y2 */
#define CAM_PIN_D1 9 /* Y3 */
#define CAM_PIN_D2 8 /* Y4 */
#define CAM_PIN_D3 10 /* Y5 */
#define CAM_PIN_D4 12 /* Y6 */
#define CAM_PIN_D5 18 /* Y7 */
#define CAM_PIN_D6 17 /* Y8 */
#define CAM_PIN_D7 16 /* Y9 */
/* Microphone I2S IN (PDM or PCM, input path) */
#define MIC_PIN_BCLK 3 /* Bit clock (I2S_IN_SCK) */
#define MIC_PIN_WS 14 /* Word select (I2S_IN_WS) */
#define MIC_PIN_DIN 46 /* Data in (I2S_IN_DIN) */
/* Speaker I2S OUT (output path) */
#define SPK_PIN_BCLK 42 /* Bit clock (I2S_BCK) */
#define SPK_PIN_LRC 41 /* Left-right clock / word select (I2S_WS) */
#define SPK_PIN_DIN 1 /* Data out (I2S_DOUT) */
/**
* GPIO Coexistence Table
*
* All GPIO pins used across camera, microphone, and speaker:
*
* GPIO │ Device │ Function
* ──────┼──────────┼────────────────────
* 1 │ Speaker │ DOUT (I2S OUT)
* 3 │ Mic │ BCLK (I2S IN)
* 4 │ Camera │ SIOD (I2C SDA)
* 5 │ Camera │ SIOC (I2C SCL)
* 6 │ Camera │ VSYNC
* 7 │ Camera │ HREF
* 8 │ Camera │ D2 (Y4)
* 9 │ Camera │ D1 (Y3)
* 10 │ Camera │ D3 (Y5)
* 11 │ Camera │ D0 (Y2)
* 12 │ Camera │ D4 (Y6)
* 13 │ Camera │ PCLK
* 14 │ Mic │ WS (I2S IN)
* 15 │ Camera │ XCLK
* 16 │ Camera │ D7 (Y9)
* 17 │ Camera │ D6 (Y8)
* 18 │ Camera │ D5 (Y7)
* 41 │ Speaker │ LRC (I2S OUT)
* 42 │ Speaker │ BCLK (I2S OUT)
* 46 │ Mic │ DIN (I2S IN)
*
* Result: ZERO collision. Each GPIO assigned to exactly one device.
*
* Note: GPIO -1 (PWDN, RESET) indicates "not wired" and is not allocated.
*/
/* ── TFT display (FNK0102H ST7796 320×480) ─────────────────────────────────
*
* Source: ui_freenove_allinone/include/ui_freenove_config.h (validated firmware)
* docs/FNK0102H_SOURCE_OF_TRUTH.md (canonical pin tables)
*
* SPI host: SPI2_HOST (FSPI) — FREENOVE_LCD_USE_HSPI == 0 in the reference
* firmware, so the default FSPI host is used (not HSPI/SPI3_HOST).
* Write frequency: 80 MHz. Read frequency: 20 MHz.
* CS pin: -1 (not wired — the panel is the only device on this SPI bus).
*
* Coexistence with camera/mic/speaker:
* GPIO 20 (TFT_PIN_RST): not present in the camera/mic/speaker tables above.
* GPIO 21 (TFT_PIN_MOSI): not present in the camera/mic/speaker tables above.
* GPIO 45 (TFT_PIN_DC): not present in the camera/mic/speaker tables above.
* GPIO 47 (TFT_PIN_SCK): not present in the camera/mic/speaker tables above.
* GPIO 2 (TFT_PIN_BL): not present in the camera/mic/speaker tables above.
* RESULT: ZERO collision with any already-mapped GPIO.
*
* BACKLIGHT / LEDC NOTE (Phase 1):
* The reference firmware uses Light_PWM (LEDC) on GPIO 2. In Phase 1 we
* drive the backlight as plain GPIO (full-on) because qr_puzzle already
* claims LEDC_TIMER_0 / LEDC_CHANNEL_0 for the camera XCLK. Phase 2 will
* switch to Light_PWM on a free LEDC channel/timer once the XCLK is moved
* to the esp32-camera driver's own LEDC allocation.
*/
#define TFT_PIN_SCK 47 /* SPI clock */
#define TFT_PIN_MOSI 21 /* SPI MOSI */
#define TFT_PIN_MISO -1 /* not wired */
#define TFT_PIN_DC 45 /* D/C (register select) */
#define TFT_PIN_RST 20 /* hardware reset */
#define TFT_PIN_BL 2 /* backlight enable (Phase 1: plain GPIO, full-on) */
@@ -0,0 +1,40 @@
// local_puzzles.h — wiring layer: QR and sound puzzles → puzzle_state.
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "puzzle_state.h"
// Store the aggregation target. Must be called before arm_*.
void local_puzzles_init(puzzle_state_t *state);
// Arm the QR sequence puzzle. expected[0..count-1] are the QR strings to
// match in order. On solve, reports fragment[0..frag_len) for puzzle_id into
// puzzle_state. Caller buffers may be transient — contents are copied.
// Returns ESP_ERR_INVALID_STATE if init not called OR if the QR puzzle is
// still running (including the async teardown window after disarm) — retry
// after ~100 ms.
// Returns ESP_ERR_INVALID_ARG if puzzle_id out of range [1,PUZZLE_MAX_ID],
// frag_len > PUZZLE_MAX_FRAG, or fragment is NULL when frag_len > 0.
// CALLBACK SAFETY: do not call arm_qr or disarm from the solved callback;
// defer to another task (e.g. via task notification or event group).
esp_err_t local_puzzles_arm_qr(uint8_t puzzle_id,
const char *const *expected, size_t count,
const uint8_t *fragment, uint8_t frag_len);
// Arm the sound/melody puzzle. expected[0..count-1] are MIDI note numbers;
// tol is the accepted semitone deviation. On solve, reports fragment[0..frag_len)
// for puzzle_id into puzzle_state. Caller buffers may be transient — copied.
// mic_broker_init() must have been called first.
// Returns ESP_ERR_INVALID_STATE if init not called OR if the sound puzzle is
// still running (broker is in MIC_P1_SOUND mode).
// Returns ESP_ERR_INVALID_ARG if puzzle_id out of range [1,PUZZLE_MAX_ID],
// frag_len > PUZZLE_MAX_FRAG, or fragment is NULL when frag_len > 0.
// CALLBACK SAFETY: do not call arm_sound or disarm from the solved callback;
// defer to another task.
esp_err_t local_puzzles_arm_sound(uint8_t puzzle_id,
const int *expected, size_t count, int tol,
const uint8_t *fragment, uint8_t frag_len);
// Stop both puzzles. QR teardown is asynchronous (see qr_puzzle.h).
void local_puzzles_disarm(void);
@@ -0,0 +1,34 @@
// qr_puzzle.h — camera-based QR sequence puzzle for the OV3660 on the
// Freenove ESP32-S3 Media Kit. Uses esp32-camera (QVGA grayscale) + quirc.
//
// qr_puzzle_start: initialise the camera, spawn the scan task, and register
// the sequence to match. Returns ESP_ERR_INVALID_STATE if already running.
// NOTE: after qr_puzzle_stop() the scan task deinits the camera
// asynchronously — qr_puzzle_start returns ESP_ERR_INVALID_STATE until
// teardown completes; callers should retry after a short delay (e.g. 100 ms).
// qr_puzzle_stop: request shutdown; the camera is deinitialized by the task
// itself before it exits (frees PSRAM and powers down the sensor).
//
// CALLBACK SAFETY: the solved callback runs in the scan task context and MUST
// NOT call qr_puzzle_start/qr_puzzle_stop directly — defer re-arming to
// another task (e.g. via task notification or event group).
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
typedef void (*qr_solved_cb_t)(void);
// Optional frame-mirror hook (e.g. live viewfinder on the local display).
// Called from the scan task for every captured frame BEFORE decode, with the
// raw grayscale QVGA buffer. The callee must COPY the data and return fast
// (it blocks scanning); it runs in the scan task context — same restrictions
// as the solved callback. Pass NULL to disable. Survives start/stop cycles.
typedef void (*qr_preview_cb_t)(const uint8_t *gray, int width, int height);
void qr_puzzle_set_preview_cb(qr_preview_cb_t cb);
esp_err_t qr_puzzle_start(const char *const *expected, size_t count, qr_solved_cb_t cb);
void qr_puzzle_stop(void);
// True while the scan task exists, including the async teardown window after stop.
bool qr_puzzle_is_running(void);
@@ -0,0 +1,37 @@
// sound_puzzle.h — melody-recognition puzzle consuming MIC_P1_SOUND frames
// from the mic broker.
//
// Prerequisites: mic_broker_init() must have been called before
// sound_puzzle_start().
//
// Callback contract: the solved callback runs on the mic_broker task
// (8 KiB stack). It must not block nor allocate large buffers. It must not
// call sound_puzzle_start(). sound_puzzle_stop() is safe to call from the
// callback (it is a thin alias for mic_broker_set_mode(MIC_IDLE), which is
// a safe volatile write from the broker task). Note: the puzzle self-stops
// on solve (MIC_IDLE is set before the callback fires), so calling
// sound_puzzle_stop() from the callback is redundant but harmless.
//
// Note on repeated notes: melodies with immediately repeated notes require
// an intervening silence between them (the per-frame debounce suppresses
// consecutive identical notes).
//
// Concurrency: sound_puzzle_start() must be called only while the broker is
// MIC_IDLE; calling it while MIC_P1_SOUND is active is undefined behaviour.
#pragma once
#include <stdbool.h>
#include <stddef.h>
typedef void (*sound_solved_cb_t)(void);
// Register on the mic broker (MIC_P1_SOUND mode) and start routing frames to
// the melody validator. expected[0..count-1] are MIDI note numbers; tol is the
// accepted semitone deviation. cb is invoked once the full melody is matched.
void sound_puzzle_start(const int *expected, size_t count, int tol,
sound_solved_cb_t cb);
// Return the broker to MIC_IDLE, stopping frame delivery. Safe to call from
// the solved callback; the puzzle self-stops on solve so this is redundant then.
void sound_puzzle_stop(void);
// True while the broker is in MIC_P1_SOUND mode.
bool sound_puzzle_is_running(void);
@@ -0,0 +1,73 @@
// local_puzzles.c — wires QR/sound to puzzle_state and arms by puzzle id.
#include "local_puzzles.h"
#include "qr_puzzle.h"
#include "sound_puzzle.h"
#include <string.h>
static puzzle_state_t *s_state;
// Per-type static contexts — copied at arm time so caller buffers can be
// transient (e.g. a REST handler's stack frame).
typedef struct {
uint8_t id;
uint8_t frag[PUZZLE_MAX_FRAG];
uint8_t frag_len;
} puzzle_ctx_t;
static puzzle_ctx_t s_qr_ctx;
static puzzle_ctx_t s_sound_ctx;
static void on_qr_solved(void) {
puzzle_state_report(s_state, s_qr_ctx.id, s_qr_ctx.frag, s_qr_ctx.frag_len);
}
static void on_sound_solved(void) {
puzzle_state_report(s_state, s_sound_ctx.id, s_sound_ctx.frag, s_sound_ctx.frag_len);
}
void local_puzzles_init(puzzle_state_t *state) { s_state = state; }
esp_err_t local_puzzles_arm_qr(uint8_t puzzle_id,
const char *const *expected, size_t count,
const uint8_t *fragment, uint8_t frag_len) {
if (!s_state) return ESP_ERR_INVALID_STATE;
if (puzzle_id < 1 || puzzle_id > PUZZLE_MAX_ID) return ESP_ERR_INVALID_ARG;
if (frag_len > PUZZLE_MAX_FRAG) return ESP_ERR_INVALID_ARG;
if (frag_len > 0 && fragment == NULL) return ESP_ERR_INVALID_ARG;
if (qr_puzzle_is_running()) return ESP_ERR_INVALID_STATE;
puzzle_ctx_t prev_qr = s_qr_ctx;
s_qr_ctx.id = puzzle_id;
s_qr_ctx.frag_len = frag_len;
if (frag_len > 0) {
memcpy(s_qr_ctx.frag, fragment, frag_len);
}
esp_err_t e = qr_puzzle_start(expected, count, on_qr_solved);
if (e != ESP_OK) { s_qr_ctx = prev_qr; }
return e;
}
esp_err_t local_puzzles_arm_sound(uint8_t puzzle_id,
const int *expected, size_t count, int tol,
const uint8_t *fragment, uint8_t frag_len) {
if (!s_state) return ESP_ERR_INVALID_STATE;
if (puzzle_id < 1 || puzzle_id > PUZZLE_MAX_ID) return ESP_ERR_INVALID_ARG;
if (frag_len > PUZZLE_MAX_FRAG) return ESP_ERR_INVALID_ARG;
if (frag_len > 0 && fragment == NULL) return ESP_ERR_INVALID_ARG;
if (sound_puzzle_is_running()) return ESP_ERR_INVALID_STATE;
s_sound_ctx.id = puzzle_id;
s_sound_ctx.frag_len = frag_len;
if (frag_len > 0) {
memcpy(s_sound_ctx.frag, fragment, frag_len);
}
sound_puzzle_start(expected, count, tol, on_sound_solved);
return ESP_OK;
}
void local_puzzles_disarm(void) {
qr_puzzle_stop();
sound_puzzle_stop();
}
@@ -0,0 +1,28 @@
// melody_validator.c
#include "melody_validator.h"
#include <string.h>
#include <stdlib.h>
void melody_validator_init(melody_validator_t *m, const int *expected,
size_t count, int tolerance)
{
memset(m, 0, sizeof(*m));
if (count > MELODY_MAX_NOTES) count = MELODY_MAX_NOTES;
if (tolerance < 0) tolerance = 0;
m->count = count;
m->tolerance = tolerance;
for (size_t i = 0; i < count; i++) m->expected[i] = expected[i];
}
bool melody_validator_feed(melody_validator_t *m, int note)
{
if (m->count == 0 || m->pos >= m->count) return false;
if (abs(note - m->expected[m->pos]) <= m->tolerance) {
m->pos++;
return m->pos == m->count;
}
// out of tolerance: reset, but let this note seed index 0 if it fits
m->pos = 0;
if (abs(note - m->expected[0]) <= m->tolerance) m->pos = 1;
return false;
}
@@ -0,0 +1,24 @@
// melody_validator.h — note-sequence validator (P1). Pure logic, no I/O.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#define MELODY_MAX_NOTES 32
typedef struct {
int expected[MELODY_MAX_NOTES]; // MIDI note numbers (0-127)
size_t count;
int tolerance; // accepted deviation in semitones (>=0; negative clamped to 0)
size_t pos;
} melody_validator_t;
// expected: array of `count` MIDI note numbers (0-127, caller-bounded).
// tolerance: maximum deviation in semitones from an expected note (>=0; negative clamped to 0).
// Repeated notes ARE permitted in the expected sequence (unlike seq_validator).
// m and expected must be non-NULL; no internal NULL checks are performed.
void melody_validator_init(melody_validator_t *m, const int *expected,
size_t count, int tolerance);
// Feed one detected note (MIDI number 0-127). True when the full melody is matched.
// On mismatch, resets and allows the current note to seed index 0 if it fits.
bool melody_validator_feed(melody_validator_t *m, int note);
@@ -0,0 +1,114 @@
// mic_broker.c — single I2S RX owner; delivers PCM16 mono frames to one
// active consumer at a time.
//
// I2S configuration mirrors the working voice_pipeline setup exactly:
// port : I2S_NUM_0 (RX only; voice_pipeline TX uses I2S_NUM_1 — no clash)
// mode : Philips std, 16-bit, mono
// rate : caller-supplied (voice_pipeline uses 16 000 Hz)
// The broker's capture_task reads MB_FRAME-sample chunks (20 ms @ 16 kHz)
// and invokes the registered callback for the current mode on every frame.
// MIC_IDLE suppresses delivery without stopping the I2S clock, keeping DMA
// buffers drained.
#include "mic_broker.h"
#include "driver/i2s_std.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "mic_broker";
// 20 ms @ 16 kHz mono 16-bit
#define MB_FRAME 320
// NPC callback runs AFE feed/fetch + WS send on this task;
// sized like the old voice_pipeline capture task (CAPTURE_TASK_STACK = 8192).
#define MB_TASK_STACK 8192
static i2s_chan_handle_t s_rx;
static volatile mic_mode_t s_mode = MIC_IDLE;
static struct {
mic_frame_cb_t cb;
void *ctx;
} s_consumers[3]; // indexed by mic_mode_t value (0=IDLE unused, 1, 2)
static void capture_task(void *arg) {
int16_t buf[MB_FRAME];
size_t got;
for (;;) {
esp_err_t err = i2s_channel_read(s_rx, buf, sizeof(buf), &got,
portMAX_DELAY);
if (err != ESP_OK) {
ESP_LOGW(TAG, "i2s read err: %s", esp_err_to_name(err));
continue;
}
mic_mode_t m = s_mode;
if (m != MIC_IDLE && s_consumers[m].cb) {
s_consumers[m].cb(buf, got / sizeof(int16_t), s_consumers[m].ctx);
}
}
}
esp_err_t mic_broker_init(int bclk, int ws, int din, int sr) {
// Guard double init: return a distinctive error so callers can treat
// it as benign ("already up") rather than a hard failure.
if (s_rx) return ESP_ERR_INVALID_STATE;
i2s_chan_config_t cc = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
esp_err_t e = i2s_new_channel(&cc, NULL, &s_rx);
if (e != ESP_OK) return e;
// Philips mono 16-bit — matches voice_pipeline's working i2s_setup().
i2s_std_config_t std = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sr),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = bclk,
.ws = ws,
.din = din,
.dout = I2S_GPIO_UNUSED,
.invert_flags = { .mclk_inv = false,
.bclk_inv = false,
.ws_inv = false },
},
};
e = i2s_channel_init_std_mode(s_rx, &std);
if (e != ESP_OK) {
i2s_del_channel(s_rx);
s_rx = NULL;
return e;
}
e = i2s_channel_enable(s_rx);
if (e != ESP_OK) {
i2s_del_channel(s_rx);
s_rx = NULL;
return e;
}
if (xTaskCreate(capture_task, "mic_broker", MB_TASK_STACK, NULL, 5, NULL) != pdPASS) {
i2s_channel_disable(s_rx);
i2s_del_channel(s_rx);
s_rx = NULL;
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "init OK (BCLK=%d WS=%d DIN=%d @%d Hz)", bclk, ws, din, sr);
return ESP_OK;
}
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx) {
if (mode == MIC_IDLE) return; // IDLE slot is never called
s_consumers[mode].cb = cb;
s_consumers[mode].ctx = ctx;
}
void mic_broker_set_mode(mic_mode_t mode) {
s_mode = mode;
}
mic_mode_t mic_broker_mode(void) {
return s_mode;
}
@@ -0,0 +1,20 @@
// mic_broker.h — single owner of the I2S RX mic; routes frames to one
// active consumer chosen by mode. No frame is delivered in MIC_IDLE.
//
// A second mic_broker_init() call returns ESP_ERR_INVALID_STATE (single
// init; callers may treat that as benign if the broker is already up).
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
typedef enum { MIC_IDLE, MIC_NPC_LISTEN, MIC_P1_SOUND } mic_mode_t;
// cb receives PCM16 mono frames (count = samples) when its mode is active.
typedef void (*mic_frame_cb_t)(const int16_t *pcm, size_t samples, void *ctx);
esp_err_t mic_broker_init(int bclk_pin, int ws_pin, int din_pin, int sample_rate_hz);
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx);
void mic_broker_set_mode(mic_mode_t mode);
mic_mode_t mic_broker_mode(void);
@@ -0,0 +1,181 @@
// qr_puzzle.c — camera capture + quirc decode; validates QR order via seq_validator.
// Pin map: board_pins_mediakit.h (frozen source of truth for CAM_PIN_* macros).
#include "qr_puzzle.h"
#include "seq_validator.h"
#include "board_pins_mediakit.h"
#include "esp_camera.h"
#include "quirc.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#define TAG "qr_puzzle"
// QVGA dimensions — must match frame_size = FRAMESIZE_QVGA in cam_cfg().
#define QR_WIDTH 320
#define QR_HEIGHT 240
static seq_validator_t s_seq;
static qr_solved_cb_t s_cb;
static TaskHandle_t s_task;
static volatile bool s_run;
static qr_preview_cb_t s_preview_cb; // optional viewfinder mirror
void qr_puzzle_set_preview_cb(qr_preview_cb_t cb) { s_preview_cb = cb; }
static camera_config_t cam_cfg(void) {
camera_config_t c = {
.pin_pwdn = CAM_PIN_PWDN,
.pin_reset = CAM_PIN_RESET,
.pin_xclk = CAM_PIN_XCLK,
.pin_sccb_sda = CAM_PIN_SIOD,
.pin_sccb_scl = CAM_PIN_SIOC,
.pin_d7 = CAM_PIN_D7,
.pin_d6 = CAM_PIN_D6,
.pin_d5 = CAM_PIN_D5,
.pin_d4 = CAM_PIN_D4,
.pin_d3 = CAM_PIN_D3,
.pin_d2 = CAM_PIN_D2,
.pin_d1 = CAM_PIN_D1,
.pin_d0 = CAM_PIN_D0,
.pin_vsync = CAM_PIN_VSYNC,
.pin_href = CAM_PIN_HREF,
.pin_pclk = CAM_PIN_PCLK,
.xclk_freq_hz = 20000000,
.ledc_timer = LEDC_TIMER_0,
.ledc_channel = LEDC_CHANNEL_0,
.pixel_format = PIXFORMAT_GRAYSCALE,
// QVGA: matches the on-device viewfinder canvas (320x240) so the live
// preview keeps working. VGA was tried for QR decode but quirc still
// found zero finder patterns (the LCD-emissive contrast is the real
// blocker, not resolution), and it broke the viewfinder — so revert.
.frame_size = FRAMESIZE_QVGA, // 320x240
.fb_count = 1,
.fb_location = CAMERA_FB_IN_PSRAM,
.grab_mode = CAMERA_GRAB_LATEST,
};
return c;
}
static void scan_task(void *arg) {
// CONFIG_SPIRAM_USE_MALLOC=y: quirc_new's ~76 KB internal buffer lands in PSRAM via malloc.
struct quirc *q = quirc_new();
if (!q) {
ESP_LOGE(TAG, "quirc_new failed (out of memory)");
esp_camera_deinit();
s_task = NULL;
vTaskDelete(NULL);
return;
}
// quirc is (re)sized to the camera's ACTUAL frame geometry. The OV3660
// on this board does not always deliver exactly QVGA — adapt at runtime
// instead of dropping every off-size frame (the old fixed 320x240 guard
// silently starved quirc when the sensor returned another size).
int q_w = 0, q_h = 0;
while (s_run) {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) { vTaskDelay(pdMS_TO_TICKS(50)); continue; }
if (fb->width != q_w || fb->height != q_h) {
if (quirc_resize(q, fb->width, fb->height) < 0) {
ESP_LOGE(TAG, "quirc_resize(%d,%d) failed", fb->width, fb->height);
esp_camera_fb_return(fb);
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
q_w = fb->width;
q_h = fb->height;
ESP_LOGI(TAG, "scanning at sensor geometry %dx%d", q_w, q_h);
}
if (s_preview_cb) s_preview_cb(fb->buf, q_w, q_h);
uint8_t *img = quirc_begin(q, NULL, NULL);
memcpy(img, fb->buf, (size_t)q_w * q_h);
quirc_end(q);
int n = quirc_count(q);
// Diagnostic: a code IDENTIFIED but not decoded points at quiet-zone /
// image-quality issues rather than framing. Throttled to ~1/sec.
static int s_diag_ticks;
if (n > 0 && (s_diag_ticks++ % 33) == 0) {
ESP_LOGI(TAG, "quirc identified %d candidate(s)", n);
}
for (int i = 0; i < n; i++) {
// static: ~12.5 KB total — too large for the task stack; single-instance task.
static struct quirc_code code;
static struct quirc_data data;
quirc_extract(q, i, &code);
if (quirc_decode(&code, &data) == 0) {
// data.payload is a NUL-terminated uint8_t string for text QRs.
ESP_LOGI(TAG, "QR decoded: %s", (const char *)data.payload);
if (seq_validator_feed(&s_seq, (const char *)data.payload)) {
if (s_cb) s_cb();
s_run = false;
}
}
}
esp_camera_fb_return(fb); // always return the frame
vTaskDelay(pdMS_TO_TICKS(30));
}
quirc_destroy(q);
esp_camera_deinit(); // free PSRAM + power down sensor
s_task = NULL;
vTaskDelete(NULL);
}
esp_err_t qr_puzzle_start(const char *const *expected, size_t count, qr_solved_cb_t cb) {
if (s_task) {
ESP_LOGW(TAG, "busy (running or still stopping)");
return ESP_ERR_INVALID_STATE;
}
camera_config_t cfg = cam_cfg();
esp_err_t e = esp_camera_init(&cfg);
if (e != ESP_OK) {
ESP_LOGE(TAG, "camera init failed: 0x%x (%s)", e, esp_err_to_name(e));
return e;
}
// Tune the OV3660 for QR decoding off a backlit LCD: max contrast +
// sharpness, and cap the gain/exposure so the bright screen does not
// bloom and wash out the QR modules.
sensor_t *s = esp_camera_sensor_get();
if (s) {
if (s->set_contrast) s->set_contrast(s, 2);
if (s->set_sharpness) s->set_sharpness(s, 2);
if (s->set_gainceiling) s->set_gainceiling(s, GAINCEILING_2X);
if (s->set_whitebal) s->set_whitebal(s, 1);
if (s->set_aec2) s->set_aec2(s, 1);
// The OV3660 mounts mirrored on this board: un-mirror so the captured
// image matches reality (the viewfinder showed a mirror image, and a
// flipped frame defeats QR finder-pattern detection).
if (s->set_hmirror) s->set_hmirror(s, 1);
if (s->set_vflip) s->set_vflip(s, 1);
}
seq_validator_init(&s_seq, expected, count);
s_cb = cb;
s_run = true;
if (xTaskCreate(scan_task, "qr_scan", 8192, NULL, 5, &s_task) != pdPASS) {
ESP_LOGE(TAG, "xTaskCreate failed");
esp_camera_deinit();
s_task = NULL;
s_run = false;
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
void qr_puzzle_stop(void) {
if (!s_task) return; // no-op when idle
s_run = false;
}
bool qr_puzzle_is_running(void) { return s_task != NULL; }
@@ -0,0 +1,29 @@
// seq_validator.c
#include "seq_validator.h"
#include <string.h>
void seq_validator_init(seq_validator_t *v, const char *const *expected, size_t count)
{
memset(v, 0, sizeof(*v));
if (count > SEQ_MAX_STEPS) count = SEQ_MAX_STEPS;
v->count = count;
for (size_t i = 0; i < count; i++) {
strncpy(v->expected[i], expected[i], SEQ_MAX_LABEL - 1);
}
}
bool seq_validator_feed(seq_validator_t *v, const char *code)
{
if (v->count == 0 || v->pos >= v->count) return false;
// duplicate of the last matched code: ignore (no progress, no reset)
if (v->pos > 0 && strncmp(code, v->expected[v->pos - 1], SEQ_MAX_LABEL) == 0)
return false;
if (strncmp(code, v->expected[v->pos], SEQ_MAX_LABEL) == 0) {
v->pos++;
return v->pos == v->count;
}
v->pos = 0; // wrong scan -> reset
// allow the wrong scan to itself start a new run at index 0
if (strncmp(code, v->expected[0], SEQ_MAX_LABEL) == 0) v->pos = 1;
return false;
}
@@ -0,0 +1,22 @@
// seq_validator.h — ordered-scan validator (QR P3). Pure logic, no I/O.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#define SEQ_MAX_STEPS 16
#define SEQ_MAX_LABEL 32
typedef struct {
char expected[SEQ_MAX_STEPS][SEQ_MAX_LABEL];
size_t count;
size_t pos; // next index to match
} seq_validator_t;
// expected: array of `count` C-strings (<= SEQ_MAX_STEPS, each < SEQ_MAX_LABEL).
// All expected labels must be distinct: duplicates make the sequence uncompletable.
// v and expected must be non-NULL; no internal NULL checks are performed.
void seq_validator_init(seq_validator_t *v, const char *const *expected, size_t count);
// Feed one scanned code. Returns true exactly when the full sequence is done.
// A wrong code resets progress; a duplicate of the last matched code is ignored.
bool seq_validator_feed(seq_validator_t *v, const char *code);
@@ -0,0 +1,60 @@
// sound_puzzle.c — consumes MIC_P1_SOUND frames, estimates note, validates melody.
//
// Pitch estimation: zero-crossing rate -> fundamental -> MIDI note number.
// Known limit: ZCR conflates harmonics with the fundamental; accuracy is
// acceptable for well-separated notes in a quiet room. Future upgrade path:
// autocorrelation or esp-dsp FFT for more reliable fundamental detection.
#include "sound_puzzle.h"
#include "melody_validator.h"
#include "mic_broker.h"
#include "esp_log.h"
#include <math.h>
#include <stdint.h>
#include <stddef.h>
static const char *TAG = "sound_puzzle";
static melody_validator_t s_mel;
static sound_solved_cb_t s_cb;
static int s_last_note = -1000;
// Crude pitch: zero-crossing rate -> fundamental -> MIDI. Replaced by an
// autocorrelation estimator if accuracy is insufficient (documented limit).
static int frame_to_midi(const int16_t *pcm, size_t n, int sr) {
size_t zc = 0;
for (size_t i = 1; i < n; i++) if ((pcm[i-1] < 0) != (pcm[i] < 0)) zc++;
if (zc < 2) return -1000; // silence
float freq = (float)zc * sr / (2.0f * n);
if (freq < 80.0f || freq > 2000.0f) return -1000;
return (int)lroundf(69.0f + 12.0f * log2f(freq / 440.0f));
}
static void on_frame(const int16_t *pcm, size_t n, void *ctx) {
int note = frame_to_midi(pcm, n, 16000);
if (note <= -1000) { s_last_note = -1000; return; }
if (note == s_last_note) return; // debounce sustained note
s_last_note = note;
const size_t before = s_mel.pos;
if (melody_validator_feed(&s_mel, note)) {
ESP_LOGI(TAG, "note=%d -> melodie complete (%u notes)",
note, (unsigned) s_mel.count);
mic_broker_set_mode(MIC_IDLE); // self-stop: safe volatile mode write from broker task
if (s_cb) s_cb();
} else {
// Trace bout-en-bout du test physique : note entendue + progression
// (un retour a 0/N signale un reset du validateur sur fausse note).
ESP_LOGI(TAG, "note=%d progression=%u/%u%s", note,
(unsigned) s_mel.pos, (unsigned) s_mel.count,
(s_mel.pos > before) ? "" : " (reset)");
}
}
void sound_puzzle_start(const int *expected, size_t count, int tol, sound_solved_cb_t cb) {
melody_validator_init(&s_mel, expected, count, tol);
s_cb = cb; s_last_note = -1000;
mic_broker_register(MIC_P1_SOUND, on_frame, NULL);
mic_broker_set_mode(MIC_P1_SOUND);
}
void sound_puzzle_stop(void) { mic_broker_set_mode(MIC_IDLE); }
bool sound_puzzle_is_running(void) { return mic_broker_mode() == MIC_P1_SOUND; }
@@ -0,0 +1 @@
build/
@@ -0,0 +1,63 @@
# Host test harness Makefile — covers the component's pure-logic units (seq_validator, melody_validator)
UNITY_DIR ?= $(HOME)/esp/esp-idf/components/unity/unity/src
CC ?= cc
# Source paths
COMPONENT_DIR := ../..
TEST_DIR := ..
# Build output
BUILD_DIR := build
# Test executable
TEST_BIN := $(BUILD_DIR)/test_runner
# Component + harness sources
SRCS := \
$(COMPONENT_DIR)/seq_validator.c \
$(COMPONENT_DIR)/melody_validator.c \
$(UNITY_DIR)/unity.c \
host_runner.c
# Canonical IDF-style test file (single source of truth for assertions)
TEST_SRCS := \
$(TEST_DIR)/test_validators.c
ALL_SRCS := $(SRCS) $(TEST_SRCS)
# Include directories
INCS := \
-I$(COMPONENT_DIR)/include \
-I$(COMPONENT_DIR) \
-I$(TEST_DIR)/host \
-I$(UNITY_DIR)
# Compiler flags
CFLAGS := -std=c11 -Wall -Wextra -Werror
CFLAGS += $(INCS)
CFLAGS += -DUNITY_INCLUDE_CONFIG_H
# Inject the TEST_CASE shim before each IDF-style test source
TEST_CFLAGS := $(CFLAGS) -include unity_test_case.h
.PHONY: all test clean
all: $(TEST_BIN)
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
$(if $(wildcard $(UNITY_DIR)/unity.c),,$(error UNITY_DIR not found: $(UNITY_DIR)set UNITY_DIR=...))
# Compile test sources with the shim injected, then link everything in one shot
$(TEST_BIN): $(ALL_SRCS) | $(BUILD_DIR)
$(if $(wildcard $(UNITY_DIR)/unity.c),,$(error UNITY_DIR not found: $(UNITY_DIR)set UNITY_DIR=...))
$(CC) $(TEST_CFLAGS) -c -o $(BUILD_DIR)/test_validators.o $(TEST_DIR)/test_validators.c
$(CC) $(CFLAGS) -o $@ $(SRCS) $(BUILD_DIR)/test_validators.o
test: $(TEST_BIN)
./$(TEST_BIN)
clean:
rm -rf $(BUILD_DIR)
.PRECIOUS: $(TEST_BIN)
@@ -0,0 +1,36 @@
// host_runner.c — dynamic registration runner for Unity on host.
// Tests self-register via constructor attributes defined in unity_test_case.h.
#include "unity.h"
#include "unity_test_case.h"
#include <stdio.h>
#include <stdlib.h>
#define HOST_TEST_TABLE_CAPACITY 64
static host_test_fn_t s_fns[HOST_TEST_TABLE_CAPACITY];
static const char *s_names[HOST_TEST_TABLE_CAPACITY];
static int s_count = 0;
void host_register_test(host_test_fn_t fn, const char *name)
{
if (s_count >= HOST_TEST_TABLE_CAPACITY) {
fprintf(stderr, "host_runner: test table full (capacity %d) — cannot register \"%s\"\n",
HOST_TEST_TABLE_CAPACITY, name);
exit(1);
}
s_fns[s_count] = fn;
s_names[s_count] = name;
s_count++;
}
void setUp(void) {}
void tearDown(void) {}
int main(void)
{
UNITY_BEGIN();
for (int i = 0; i < s_count; i++) {
UnityDefaultTestRun(s_fns[i], s_names[i], 0);
}
return UNITY_END();
}
@@ -0,0 +1,6 @@
// unity_config.h — minimal Unity configuration for host builds
#pragma once
// For host build, we use standard libc, not platform-specific memory layouts
#define UNITY_INT_WIDTH 32
#define UNITY_LONG_WIDTH 64
@@ -0,0 +1,20 @@
// unity_test_case.h — host shim for ESP-IDF's TEST_CASE macro.
// Injected via `-include` before each IDF-style test file; the file's own
// `#include "unity.h"` is harmless after this.
#pragma once
#include "unity.h"
typedef void (*host_test_fn_t)(void);
void host_register_test(host_test_fn_t fn, const char *name);
#define HOST_CAT2(a, b) a##b
#define HOST_CAT(a, b) HOST_CAT2(a, b)
#define HOST_TEST_CASE_IMPL(fn, name_str) \
static void fn(void); \
__attribute__((constructor)) static void HOST_CAT(fn, _register)(void) \
{ host_register_test(fn, name_str); } \
static void fn(void)
#define TEST_CASE(name_str, tags) \
HOST_TEST_CASE_IMPL(HOST_CAT(host_test_line_, __LINE__), name_str)
@@ -0,0 +1,65 @@
#include "unity.h"
#include "seq_validator.h"
#include "melody_validator.h"
TEST_CASE("seq accepts codes scanned in the expected order", "[seq]")
{
const char *expected[] = {"qr3", "qr1", "qr4"};
seq_validator_t v;
seq_validator_init(&v, expected, 3);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "qr3")); // 1/3
TEST_ASSERT_FALSE(seq_validator_feed(&v, "qr1")); // 2/3
TEST_ASSERT_TRUE (seq_validator_feed(&v, "qr4")); // complete
}
TEST_CASE("seq resets on a wrong scan", "[seq]")
{
const char *expected[] = {"a", "b"};
seq_validator_t v;
seq_validator_init(&v, expected, 2);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // 1/2
TEST_ASSERT_FALSE(seq_validator_feed(&v, "x")); // wrong -> reset
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // 1/2 again
TEST_ASSERT_TRUE (seq_validator_feed(&v, "b")); // complete
}
TEST_CASE("seq ignores a duplicate of the last correct scan", "[seq]")
{
const char *expected[] = {"a", "b"};
seq_validator_t v;
seq_validator_init(&v, expected, 2);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a"));
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // duplicate, no progress, no reset
TEST_ASSERT_TRUE (seq_validator_feed(&v, "b"));
}
TEST_CASE("melody accepts the exact expected note sequence", "[melody]")
{
const int expected[] = {60, 62, 64, 65}; // do re mi fa
melody_validator_t m;
melody_validator_init(&m, expected, 4, /*tolerance=*/1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 62));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 64));
TEST_ASSERT_TRUE (melody_validator_feed(&m, 65));
}
TEST_CASE("melody accepts notes within tolerance", "[melody]")
{
const int expected[] = {60, 62};
melody_validator_t m;
melody_validator_init(&m, expected, 2, 1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 61)); // 60 +/-1 ok
TEST_ASSERT_TRUE (melody_validator_feed(&m, 62));
}
TEST_CASE("melody resets on an out-of-tolerance note", "[melody]")
{
const int expected[] = {60, 62};
melody_validator_t m;
melody_validator_init(&m, expected, 2, 1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 70)); // way off -> reset
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_TRUE (melody_validator_feed(&m, 62));
}
@@ -0,0 +1,23 @@
## Zacus media_manager — ESP-IDF port (slice 3, P1).
##
## Ports the Arduino `MediaManager` C++ class
## (ui_freenove_allinone/src/system/media/media_manager.cpp, 416 LOC) into a
## C-only ESP-IDF component. Public API exposes catalog browsing + play/stop
## + recording control. Real MP3 decoding is intentionally deferred to a
## later slice (see TODO in media_manager.c). The recorder code path is
## stubbed in the same way to keep the dependency surface minimal until the
## ES8388 / I2S microphone wiring is brought up under IDF.
idf_component_register(
SRCS
"media_manager.c"
INCLUDE_DIRS
"include"
REQUIRES
driver
esp_timer
esp_system
freertos
voice_pipeline
joltwallet__littlefs
)
@@ -0,0 +1,4 @@
## Managed dependencies for media_manager.
## Slice P4: helix MP3 decoder for the hotline_tts/*.mp3 cue pool.
dependencies:
chmorgan/esp-libhelix-mp3: "^1.0.3"
@@ -0,0 +1,125 @@
// Zacus media_manager — ESP-IDF C port of the Arduino MediaManager class.
//
// Source of truth for the Arduino implementation:
// ESP32_ZACUS/ui_freenove_allinone/src/system/media/media_manager.cpp
//
// The Arduino version is a C++ class with stateful catalog + I2S recorder
// helpers driven from the Freenove UI loop. The IDF port keeps the same
// runtime contract (catalog browsing, play/stop, fixed-duration WAV
// recording, snapshot read-out) but exposes it as a pure-C singleton
// because every consumer in the new firmware (NPC engine, voice pipeline,
// HTTP services) speaks C and we want to avoid a C++ runtime dependency
// on this layer.
//
// MP3 decoding and the I2S microphone capture path are deliberately stubbed
// in this slice — they pull in heavy managed components (esp-adf or
// audio_pipeline + helix-mp3, plus ES8388 codec bringup) that belong in
// their own dedicated slices. The stub still mounts LittleFS, opens the
// requested file to validate it exists, simulates a 2 s playback window
// (so callers can sequence cues end-to-end), and returns ESP_OK so the
// surrounding NPC coordination logic can be exercised today.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
#define MEDIA_PATH_MAX 128
#define MEDIA_DIR_MAX 32
#define MEDIA_ERROR_MAX 64
#define MEDIA_DEFAULT_RECORD_MAX_S 30U
// Configuration mirrors `MediaManager::Config` from the Arduino sources but
// with C strings instead of fixed char arrays embedded in a struct method.
typedef struct {
char music_dir[MEDIA_DIR_MAX]; // default "/littlefs/music"
char picture_dir[MEDIA_DIR_MAX]; // default "/littlefs/picture"
char record_dir[MEDIA_DIR_MAX]; // default "/littlefs/recorder"
uint16_t record_max_seconds; // default 30
bool auto_stop_record_on_step_change; // default true
} media_manager_config_t;
// Snapshot mirrors `MediaManager::Snapshot`. Returned by value (cheap, ~400B).
typedef struct {
bool ready;
bool playing;
bool recording;
bool last_ok;
bool record_simulated; // true while recorder remains stubbed
uint16_t record_limit_seconds;
uint16_t record_elapsed_seconds;
uint32_t record_started_ms;
char playing_path[MEDIA_PATH_MAX];
char record_file[MEDIA_PATH_MAX];
char last_error[MEDIA_ERROR_MAX];
char music_dir[MEDIA_DIR_MAX];
char picture_dir[MEDIA_DIR_MAX];
char record_dir[MEDIA_DIR_MAX];
} media_manager_snapshot_t;
// Fill `cfg` with the defaults used by the Arduino firmware.
void media_manager_default_config(media_manager_config_t *cfg);
// Initialize the singleton media manager. Idempotent — re-initialization
// updates the configuration without losing the recorder state.
//
// Pre-conditions:
// * LittleFS partition mounted at `cfg->music_dir` root (the manager will
// create `music_dir`, `picture_dir`, `record_dir` if missing, but the
// parent FS must exist first).
//
// Returns ESP_OK on success, ESP_ERR_INVALID_ARG on null config.
esp_err_t media_manager_init(const media_manager_config_t *cfg);
// Periodic tick — call from the main loop (Arduino did this from `loop()`).
// `now_ms` is a monotonic millisecond counter (use esp_timer_get_time/1000).
// Updates the simulated playback completion + recorder timeout.
void media_manager_update(uint32_t now_ms);
// Inform the manager that the active scenario step changed. When
// `auto_stop_record_on_step_change` is enabled this stops any recording
// in flight (matches Arduino behaviour).
void media_manager_note_step_change(void);
// Begin (simulated) playback of `path`. The path can be absolute (resolved
// as-is, e.g. "/littlefs/music/foo.mp3") or relative (resolved against
// `music_dir`). Returns ESP_OK if the file exists and ESP_ERR_NOT_FOUND
// otherwise; ESP_ERR_INVALID_ARG if `path` is null/empty.
//
// TODO(slice-4+): replace simulation with real I2S MP3 playback.
esp_err_t media_manager_play(const char *path);
// Stop any active (simulated) playback. Always succeeds.
esp_err_t media_manager_stop(void);
// Set the playback gain (0..100). Stored in the snapshot only — the stub
// playback path does not yet drive a codec. Returns ESP_OK or
// ESP_ERR_INVALID_ARG when value > 100.
esp_err_t media_manager_set_volume(uint8_t volume);
// Start recording up to `seconds` (clamped to `record_max_seconds`). The
// stubbed recorder allocates an empty WAV file at `record_dir/<filename>`
// so the file plumbing is exercised; future slices will plug the real I2S
// capture loop into `media_manager_update`.
//
// `filename_hint` may be null — a `record_<ms>.wav` name is generated.
esp_err_t media_manager_start_recording(uint16_t seconds,
const char *filename_hint);
// Stop the active recording. Safe to call when not recording (returns OK).
esp_err_t media_manager_stop_recording(void);
// Copy the current snapshot into `out` (caller-owned). Useful for
// status endpoints.
void media_manager_snapshot(media_manager_snapshot_t *out);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,730 @@
// Zacus media_manager — IDF C port (slice 3, P1 voice pipeline migration).
//
// Mirrors the Arduino MediaManager class in
// ui_freenove_allinone/src/system/media/media_manager.cpp (~416 LOC C++).
//
// What is real here:
// * Catalog directory bookkeeping (music / picture / record).
// * `media_manager_play()` validates the file exists on LittleFS and
// records the simulated-playback state into the snapshot.
// * Recorder writes an empty WAV header so the file plumbing is real
// and downstream consumers (NPC engine, voice bridge) can list /
// fetch the recorder output.
// * Step-change auto-stop hook matches Arduino behaviour.
//
// TODO(slice-4+): replace the stub with real I2S MP3 playback. The
// candidate paths are (a) ESP-ADF audio_pipeline + helix-mp3 decoder, or
// (b) a custom mini decoder reusing the helix-mp3 source already vendored
// in the Arduino tree. The decision belongs to the next slice that ports
// the AudioManager wrapper. Likewise, the I2S microphone capture path
// needs the ES8388 codec bringup before the recorder can deliver real PCM.
#include "media_manager.h"
#include <ctype.h>
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include "mp3dec.h" // P4: helix MP3 decoder (chmorgan/esp-libhelix-mp3)
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_check.h"
#include "esp_err.h"
#include "esp_log.h"
#include "esp_timer.h"
// Slice P4: real WAV playback streams 16-bit PCM to the MAX98357A over the
// I2S TX channel already owned by voice_pipeline (shared DAC, no codec init).
#include "voice_pipeline.h"
static const char *TAG = "media_manager";
#define MEDIA_DEFAULT_MUSIC_DIR "/littlefs/music"
#define MEDIA_DEFAULT_PICTURE_DIR "/littlefs/picture"
#define MEDIA_DEFAULT_RECORD_DIR "/littlefs/recorder"
#define MEDIA_RECORDER_SAMPLE_RATE 16000UL
#define MEDIA_RECORDER_BITS 16U
#define MEDIA_RECORDER_CHANNELS 1U
// Mirror Arduino's 2-second simulated playback window so callers can
// sequence cues without a real decoder behind us.
#define MEDIA_STUB_PLAYBACK_MS 2000U
// ─── Singleton state ─────────────────────────────────────────────────────────
static struct {
bool initialized;
media_manager_config_t config;
media_manager_snapshot_t snapshot;
uint8_t volume; // 0..100
uint32_t playback_ends_ms;
FILE *recording_file;
uint32_t recording_data_bytes;
TaskHandle_t playback_task; // P4: real WAV streamer, NULL when idle
volatile bool playback_stop; // request the streamer to abort
} s_media;
// ─── Helpers ─────────────────────────────────────────────────────────────────
static void copy_text(char *dst, size_t dst_len, const char *src) {
if (dst == NULL || dst_len == 0U) {
return;
}
if (src == NULL) {
dst[0] = '\0';
return;
}
strncpy(dst, src, dst_len - 1U);
dst[dst_len - 1U] = '\0';
}
static void normalize_dir(char *out, size_t out_len, const char *src) {
if (out == NULL || out_len == 0U) {
return;
}
if (src == NULL || src[0] == '\0') {
copy_text(out, out_len, "/");
return;
}
// Skip leading whitespace.
while (*src == ' ' || *src == '\t') {
++src;
}
if (src[0] == '\0') {
copy_text(out, out_len, "/");
return;
}
if (src[0] != '/') {
snprintf(out, out_len, "/%s", src);
} else {
copy_text(out, out_len, src);
}
// Trim trailing slash unless root.
size_t len = strlen(out);
while (len > 1U && out[len - 1U] == '/') {
out[len - 1U] = '\0';
--len;
}
}
static bool file_exists(const char *path) {
if (path == NULL || path[0] == '\0') {
return false;
}
struct stat st;
return stat(path, &st) == 0;
}
static esp_err_t ensure_dir(const char *path) {
if (path == NULL || path[0] == '\0') {
return ESP_ERR_INVALID_ARG;
}
struct stat st;
if (stat(path, &st) == 0) {
return ESP_OK;
}
if (mkdir(path, 0777) == 0) {
return ESP_OK;
}
ESP_LOGW(TAG, "mkdir(%s) failed: errno=%d", path, errno);
return ESP_FAIL;
}
static void set_last_error(const char *msg) {
s_media.snapshot.last_ok = false;
copy_text(s_media.snapshot.last_error,
sizeof(s_media.snapshot.last_error),
msg != NULL ? msg : "media_unknown_error");
}
static void clear_last_error(void) {
s_media.snapshot.last_ok = true;
s_media.snapshot.last_error[0] = '\0';
}
// Resolve `path` against `music_dir` if relative; otherwise copy as-is.
static void resolve_play_path(char *out, size_t out_len, const char *path) {
if (out == NULL || out_len == 0U) {
return;
}
if (path == NULL || path[0] == '\0') {
out[0] = '\0';
return;
}
if (path[0] == '/') {
copy_text(out, out_len, path); // absolute: /sdcard/... or /littlefs/...
return;
}
// Relative cue: prefer the microSD copy when present (P4 — large asset
// store), else fall back to the LittleFS music_dir. file_exists() returns
// false when no card is mounted, so this is transparent without a hard
// dependency on sd_storage.
char sd_try[MEDIA_PATH_MAX];
snprintf(sd_try, sizeof(sd_try), "/sdcard/music/%s", path);
if (file_exists(sd_try)) {
copy_text(out, out_len, sd_try);
return;
}
snprintf(out, out_len, "%s/%s", s_media.config.music_dir, path);
}
static void sanitize_filename(char *out, size_t out_len,
const char *hint, const char *default_prefix,
const char *extension) {
if (out == NULL || out_len == 0U) {
return;
}
if (hint == NULL || hint[0] == '\0') {
const uint32_t now_ms =
(uint32_t) (esp_timer_get_time() / 1000LL);
snprintf(out, out_len, "%s_%lu", default_prefix,
(unsigned long) now_ms);
} else {
copy_text(out, out_len, hint);
}
// Replace anything not [A-Za-z0-9_.-] with '_'.
for (size_t i = 0U; out[i] != '\0'; ++i) {
const unsigned char ch = (unsigned char) out[i];
const bool keep = isalnum(ch) || ch == '_' || ch == '-' || ch == '.';
if (!keep) {
out[i] = '_';
}
}
if (extension != NULL && extension[0] != '\0') {
const size_t cur_len = strlen(out);
const size_t ext_len = strlen(extension);
if (cur_len < ext_len ||
strcmp(out + cur_len - ext_len, extension) != 0) {
if (cur_len + ext_len < out_len) {
strcat(out, extension);
}
}
}
}
// Write a minimal RIFF/WAVE header with `data_size` bytes (0 means "open"
// header, will be patched by stop_recording).
static bool write_wav_header(FILE *f, uint32_t data_size) {
if (f == NULL) {
return false;
}
const uint32_t byte_rate =
MEDIA_RECORDER_SAMPLE_RATE * MEDIA_RECORDER_CHANNELS *
(MEDIA_RECORDER_BITS / 8U);
const uint16_t block_align =
(uint16_t) (MEDIA_RECORDER_CHANNELS * (MEDIA_RECORDER_BITS / 8U));
const uint32_t chunk_size = 36U + data_size;
const uint32_t fmt_size = 16U;
const uint16_t audio_format = 1U; // PCM
const uint16_t channels = MEDIA_RECORDER_CHANNELS;
const uint32_t sample_rate = MEDIA_RECORDER_SAMPLE_RATE;
const uint16_t bits = MEDIA_RECORDER_BITS;
if (fseek(f, 0, SEEK_SET) != 0) {
return false;
}
if (fwrite("RIFF", 1, 4, f) != 4) return false;
if (fwrite(&chunk_size, sizeof(chunk_size), 1, f) != 1) return false;
if (fwrite("WAVE", 1, 4, f) != 4) return false;
if (fwrite("fmt ", 1, 4, f) != 4) return false;
if (fwrite(&fmt_size, sizeof(fmt_size), 1, f) != 1) return false;
if (fwrite(&audio_format, sizeof(audio_format), 1, f) != 1) return false;
if (fwrite(&channels, sizeof(channels), 1, f) != 1) return false;
if (fwrite(&sample_rate, sizeof(sample_rate), 1, f) != 1) return false;
if (fwrite(&byte_rate, sizeof(byte_rate), 1, f) != 1) return false;
if (fwrite(&block_align, sizeof(block_align), 1, f) != 1) return false;
if (fwrite(&bits, sizeof(bits), 1, f) != 1) return false;
if (fwrite("data", 1, 4, f) != 4) return false;
if (fwrite(&data_size, sizeof(data_size), 1, f) != 1) return false;
return true;
}
// ─── Public API ──────────────────────────────────────────────────────────────
void media_manager_default_config(media_manager_config_t *cfg) {
if (cfg == NULL) {
return;
}
memset(cfg, 0, sizeof(*cfg));
copy_text(cfg->music_dir, sizeof(cfg->music_dir), MEDIA_DEFAULT_MUSIC_DIR);
copy_text(cfg->picture_dir, sizeof(cfg->picture_dir), MEDIA_DEFAULT_PICTURE_DIR);
copy_text(cfg->record_dir, sizeof(cfg->record_dir), MEDIA_DEFAULT_RECORD_DIR);
cfg->record_max_seconds = MEDIA_DEFAULT_RECORD_MAX_S;
cfg->auto_stop_record_on_step_change = true;
}
esp_err_t media_manager_init(const media_manager_config_t *cfg) {
if (cfg == NULL) {
return ESP_ERR_INVALID_ARG;
}
// Apply config + normalize dirs.
media_manager_config_t normalized = *cfg;
char tmp[MEDIA_DIR_MAX];
normalize_dir(tmp, sizeof(tmp), cfg->music_dir);
copy_text(normalized.music_dir, sizeof(normalized.music_dir), tmp);
normalize_dir(tmp, sizeof(tmp), cfg->picture_dir);
copy_text(normalized.picture_dir, sizeof(normalized.picture_dir), tmp);
normalize_dir(tmp, sizeof(tmp), cfg->record_dir);
copy_text(normalized.record_dir, sizeof(normalized.record_dir), tmp);
if (normalized.record_max_seconds == 0U) {
normalized.record_max_seconds = MEDIA_DEFAULT_RECORD_MAX_S;
}
if (normalized.record_max_seconds > 1800U) {
normalized.record_max_seconds = 1800U;
}
s_media.config = normalized;
if (!s_media.initialized) {
s_media.volume = 80U;
s_media.playback_ends_ms = 0U;
s_media.recording_file = NULL;
s_media.recording_data_bytes = 0U;
}
memset(&s_media.snapshot, 0, sizeof(s_media.snapshot));
s_media.snapshot.ready = true;
s_media.snapshot.last_ok = true;
s_media.snapshot.record_simulated = true; // recorder is stubbed
s_media.snapshot.record_limit_seconds = normalized.record_max_seconds;
copy_text(s_media.snapshot.music_dir, sizeof(s_media.snapshot.music_dir),
normalized.music_dir);
copy_text(s_media.snapshot.picture_dir, sizeof(s_media.snapshot.picture_dir),
normalized.picture_dir);
copy_text(s_media.snapshot.record_dir, sizeof(s_media.snapshot.record_dir),
normalized.record_dir);
(void) ensure_dir(normalized.music_dir);
(void) ensure_dir(normalized.picture_dir);
(void) ensure_dir(normalized.record_dir);
s_media.initialized = true;
ESP_LOGI(TAG, "init music=%s picture=%s record=%s rec_max=%us",
normalized.music_dir, normalized.picture_dir,
normalized.record_dir, normalized.record_max_seconds);
ESP_LOGW(TAG,
"playback + capture are STUBBED — see TODO in media_manager.c");
return ESP_OK;
}
void media_manager_update(uint32_t now_ms) {
if (!s_media.initialized) {
return;
}
// Simulated playback: clear `playing` after the stub window elapses.
if (s_media.snapshot.playing &&
s_media.playback_ends_ms != 0U &&
(int32_t) (now_ms - s_media.playback_ends_ms) >= 0) {
ESP_LOGI(TAG, "simulated playback finished: %s",
s_media.snapshot.playing_path);
s_media.snapshot.playing = false;
s_media.snapshot.playing_path[0] = '\0';
s_media.playback_ends_ms = 0U;
}
// Recorder timeout (stub: data bytes never grow, but the timer mirrors
// Arduino behaviour so callers can rely on auto-stop).
if (s_media.snapshot.recording) {
const uint32_t elapsed_ms = now_ms - s_media.snapshot.record_started_ms;
s_media.snapshot.record_elapsed_seconds =
(uint16_t) (elapsed_ms / 1000U);
if (s_media.snapshot.record_limit_seconds > 0U &&
s_media.snapshot.record_elapsed_seconds >=
s_media.snapshot.record_limit_seconds) {
(void) media_manager_stop_recording();
}
}
}
void media_manager_note_step_change(void) {
if (!s_media.initialized) {
return;
}
if (s_media.config.auto_stop_record_on_step_change &&
s_media.snapshot.recording) {
(void) media_manager_stop_recording();
}
}
// ─── P4: real WAV playback over the shared MAX98357A I2S TX ──────────────────
//
// Streams 16-bit PCM WAV from LittleFS to the DAC via voice_pipeline's TX
// channel. WAV (not MP3) keeps this decoder-free; MP3 stays a later slice.
// NOTE: shares the I2S TX with TTS — callers must not overlap a `speak_*`
// exchange with music playback (the NPC coordination layer already serialises
// cues). A TX mutex is the natural follow-up.
#define MEDIA_PLAY_CHUNK_BYTES 1024U
typedef struct {
uint32_t sample_rate;
uint16_t channels;
uint16_t bits;
uint32_t data_size; // PCM bytes in the data chunk
} wav_info_t;
static uint32_t rd_u32(const uint8_t *p) {
return (uint32_t) p[0] | ((uint32_t) p[1] << 8) |
((uint32_t) p[2] << 16) | ((uint32_t) p[3] << 24);
}
// Parse a RIFF/WAVE header, leaving `f` positioned at the PCM data. Accepts
// 16-bit PCM mono/stereo only; returns false otherwise.
static bool parse_wav_header(FILE *f, wav_info_t *out) {
uint8_t riff[12];
if (fread(riff, 1, 12, f) != 12) return false;
if (memcmp(riff, "RIFF", 4) != 0 || memcmp(riff + 8, "WAVE", 4) != 0) return false;
bool have_fmt = false;
uint8_t ck[8];
while (fread(ck, 1, 8, f) == 8) {
uint32_t csz = rd_u32(ck + 4);
if (memcmp(ck, "fmt ", 4) == 0) {
uint8_t fmt[16];
uint32_t want = csz < sizeof(fmt) ? csz : (uint32_t) sizeof(fmt);
if (fread(fmt, 1, want, f) != want) return false;
if (((uint16_t) fmt[0] | ((uint16_t) fmt[1] << 8)) != 1U) return false; // PCM
out->channels = (uint16_t) fmt[2] | ((uint16_t) fmt[3] << 8);
out->sample_rate = rd_u32(fmt + 4);
out->bits = (uint16_t) fmt[14] | ((uint16_t) fmt[15] << 8);
if (csz > want) fseek(f, (long) (csz - want), SEEK_CUR);
have_fmt = true;
} else if (memcmp(ck, "data", 4) == 0) {
out->data_size = csz;
return have_fmt && out->bits == 16U &&
(out->channels == 1U || out->channels == 2U);
} else {
fseek(f, (long) (csz + (csz & 1U)), SEEK_CUR); // skip + word-align pad
}
}
return false;
}
static void apply_volume(int16_t *s, size_t count, uint8_t volume) {
if (volume >= 100U) return;
for (size_t i = 0; i < count; ++i) {
s[i] = (int16_t) (((int32_t) s[i] * (int32_t) volume) / 100);
}
}
static void clear_playback_state(void) {
s_media.snapshot.playing = false;
s_media.snapshot.playing_path[0] = '\0';
s_media.playback_ends_ms = 0U;
s_media.playback_task = NULL;
}
// Stream a 16-bit PCM WAV to the DAC. play_start/play_end bracket the session.
static esp_err_t stream_wav(FILE *f, const char *path) {
wav_info_t wav = {0};
if (!parse_wav_header(f, &wav)) {
ESP_LOGE(TAG, "playback: %s is not a 16-bit PCM WAV", path);
return ESP_ERR_INVALID_ARG;
}
ESP_LOGI(TAG, "playback start: %s (wav %lu Hz, %uch, %lu B) vol=%u",
path, (unsigned long) wav.sample_rate, wav.channels,
(unsigned long) wav.data_size, s_media.volume);
esp_err_t err = voice_pipeline_play_start(wav.sample_rate, "wav");
if (err != ESP_OK) {
ESP_LOGW(TAG, "play_start failed: %s", esp_err_to_name(err));
return err;
}
uint8_t buf[MEDIA_PLAY_CHUNK_BYTES];
uint32_t remaining = wav.data_size, written = 0;
while (remaining > 0U && !s_media.playback_stop) {
size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf);
size_t n = fread(buf, 1, want, f);
if (n == 0U) break;
remaining -= (uint32_t) n;
size_t pcm = n;
if (wav.channels == 2U) { // downmix interleaved stereo16 → mono16
int16_t *s = (int16_t *) buf;
size_t frames = n / 4U;
for (size_t i = 0; i < frames; ++i)
s[i] = (int16_t) (((int32_t) s[2 * i] + s[2 * i + 1]) / 2);
pcm = frames * 2U;
}
apply_volume((int16_t *) buf, pcm / 2U, s_media.volume);
if (voice_pipeline_play_chunk(buf, pcm) != ESP_OK) break;
written += (uint32_t) pcm;
}
voice_pipeline_play_end();
ESP_LOGI(TAG, "playback done: %s (%lu B to DAC%s)", path,
(unsigned long) written, s_media.playback_stop ? ", stopped" : "");
return ESP_OK;
}
// Decode and stream an MP3 to the DAC via the helix decoder. Big work buffers
// live on the heap (the decoder also uses a few KB of stack — task is sized
// accordingly). Mono output is downmixed; volume is applied per frame.
static esp_err_t stream_mp3(FILE *f, const char *path) {
HMP3Decoder dec = MP3InitDecoder();
const size_t IN_SZ = 2048U;
uint8_t *inbuf = malloc(IN_SZ);
int16_t *pcm = malloc(2304 * sizeof(int16_t)); // MAX_NCHAN*MAX_NGRAN*MAX_NSAMP
if (dec == NULL || inbuf == NULL || pcm == NULL) {
if (dec) MP3FreeDecoder(dec);
free(inbuf); free(pcm);
ESP_LOGE(TAG, "mp3: alloc failed");
return ESP_ERR_NO_MEM;
}
int bytes_left = 0;
uint8_t *rp = inbuf;
bool started = false, eof = false;
uint32_t written = 0;
esp_err_t result = ESP_OK;
while (!s_media.playback_stop) {
if (bytes_left < 1024 && !eof) { // refill: keep tail, top up
memmove(inbuf, rp, (size_t) bytes_left);
size_t n = fread(inbuf + bytes_left, 1, IN_SZ - (size_t) bytes_left, f);
if (n == 0U) eof = true;
bytes_left += (int) n;
rp = inbuf;
}
if (bytes_left <= 0) break;
int off = MP3FindSyncWord(rp, bytes_left);
if (off < 0) { if (eof) break; bytes_left = 0; continue; }
rp += off; bytes_left -= off;
int derr = MP3Decode(dec, &rp, &bytes_left, pcm, 0);
if (derr == ERR_MP3_INDATA_UNDERFLOW) { if (eof) break; continue; }
if (derr != ERR_MP3_NONE) continue; // skip a bad frame
MP3FrameInfo info;
MP3GetLastFrameInfo(dec, &info);
if (info.outputSamps <= 0) continue;
if (!started) {
ESP_LOGI(TAG, "playback start: %s (mp3 %d Hz, %dch) vol=%u",
path, info.samprate, info.nChans, s_media.volume);
esp_err_t e = voice_pipeline_play_start((uint32_t) info.samprate, "mp3");
if (e != ESP_OK) { result = e; break; }
started = true;
}
size_t samps = (size_t) info.outputSamps, bytes;
if (info.nChans == 2) { // downmix stereo16 → mono16
size_t frames = samps / 2U;
for (size_t i = 0; i < frames; ++i)
pcm[i] = (int16_t) (((int32_t) pcm[2 * i] + pcm[2 * i + 1]) / 2);
bytes = frames * 2U;
} else {
bytes = samps * 2U;
}
apply_volume(pcm, bytes / 2U, s_media.volume);
if (voice_pipeline_play_chunk((uint8_t *) pcm, bytes) != ESP_OK) break;
written += (uint32_t) bytes;
}
if (started) voice_pipeline_play_end();
MP3FreeDecoder(dec);
free(inbuf); free(pcm);
ESP_LOGI(TAG, "playback done: %s (mp3, %lu B to DAC%s)", path,
(unsigned long) written, s_media.playback_stop ? ", stopped" : "");
if (!started && result == ESP_OK) result = ESP_ERR_NOT_SUPPORTED;
return result;
}
static void playback_task(void *arg) {
(void) arg;
char path[MEDIA_PATH_MAX];
copy_text(path, sizeof(path), s_media.snapshot.playing_path);
FILE *f = fopen(path, "rb");
if (f == NULL) {
ESP_LOGE(TAG, "playback: cannot open %s", path);
set_last_error("media_play_open");
clear_playback_state();
vTaskDelete(NULL);
return;
}
const char *ext = strrchr(path, '.');
esp_err_t err = (ext && strcasecmp(ext, ".mp3") == 0)
? stream_mp3(f, path)
: stream_wav(f, path);
fclose(f);
if (err != ESP_OK) {
set_last_error("media_play_failed");
}
clear_playback_state();
vTaskDelete(NULL);
}
esp_err_t media_manager_play(const char *path) {
if (!s_media.initialized) {
return ESP_ERR_INVALID_STATE;
}
if (path == NULL || path[0] == '\0') {
set_last_error("media_play_invalid_args");
return ESP_ERR_INVALID_ARG;
}
char resolved[MEDIA_PATH_MAX];
resolve_play_path(resolved, sizeof(resolved), path);
if (resolved[0] == '\0') {
set_last_error("media_play_empty_path");
return ESP_ERR_INVALID_ARG;
}
if (!file_exists(resolved)) {
ESP_LOGW(TAG, "play(%s): file not found", resolved);
set_last_error("media_play_not_found");
return ESP_ERR_NOT_FOUND;
}
// Stop any in-flight playback before starting the next cue.
if (s_media.playback_task != NULL) {
(void) media_manager_stop();
}
s_media.playback_stop = false;
s_media.snapshot.playing = true;
copy_text(s_media.snapshot.playing_path,
sizeof(s_media.snapshot.playing_path), resolved);
s_media.playback_ends_ms = 0U; // real playback: tick must not auto-clear
clear_last_error();
BaseType_t ok = xTaskCreate(playback_task, "media_play", 8192, NULL, 4,
&s_media.playback_task);
if (ok != pdPASS) {
s_media.playback_task = NULL;
s_media.snapshot.playing = false;
s_media.snapshot.playing_path[0] = '\0';
set_last_error("media_play_task");
return ESP_ERR_NO_MEM;
}
return ESP_OK;
}
esp_err_t media_manager_stop(void) {
if (!s_media.initialized) {
return ESP_ERR_INVALID_STATE;
}
if (s_media.playback_task != NULL) {
s_media.playback_stop = true;
for (int i = 0; i < 100 && s_media.playback_task != NULL; ++i) {
vTaskDelay(pdMS_TO_TICKS(10)); // let the streamer drain + self-delete
}
}
if (s_media.snapshot.playing) {
ESP_LOGI(TAG, "stop %s", s_media.snapshot.playing_path);
}
s_media.snapshot.playing = false;
s_media.snapshot.playing_path[0] = '\0';
s_media.playback_ends_ms = 0U;
clear_last_error();
return ESP_OK;
}
esp_err_t media_manager_set_volume(uint8_t volume) {
if (volume > 100U) {
return ESP_ERR_INVALID_ARG;
}
s_media.volume = volume;
ESP_LOGI(TAG, "volume=%u", volume);
return ESP_OK;
}
esp_err_t media_manager_start_recording(uint16_t seconds,
const char *filename_hint) {
if (!s_media.initialized) {
return ESP_ERR_INVALID_STATE;
}
if (s_media.snapshot.recording) {
set_last_error("recorder_already_running");
return ESP_ERR_INVALID_STATE;
}
if (seconds == 0U) {
seconds = s_media.config.record_max_seconds;
}
if (seconds > s_media.config.record_max_seconds) {
seconds = s_media.config.record_max_seconds;
}
if (seconds == 0U) {
seconds = 1U;
}
if (ensure_dir(s_media.config.record_dir) != ESP_OK) {
set_last_error("recorder_dir_missing");
return ESP_FAIL;
}
// Filename bounded so the concatenation below cannot overflow `path`
// (record_dir <= MEDIA_DIR_MAX, plus '/' separator, plus filename).
char filename[MEDIA_PATH_MAX - MEDIA_DIR_MAX - 1];
sanitize_filename(filename, sizeof(filename),
filename_hint, "record", ".wav");
char path[MEDIA_PATH_MAX];
snprintf(path, sizeof(path), "%s/%s",
s_media.config.record_dir, filename);
if (s_media.recording_file != NULL) {
fclose(s_media.recording_file);
s_media.recording_file = NULL;
}
s_media.recording_file = fopen(path, "wb+");
if (s_media.recording_file == NULL) {
ESP_LOGW(TAG, "fopen(%s) failed: errno=%d", path, errno);
set_last_error("recorder_create_failed");
return ESP_FAIL;
}
if (!write_wav_header(s_media.recording_file, 0U)) {
fclose(s_media.recording_file);
s_media.recording_file = NULL;
set_last_error("recorder_header_failed");
return ESP_FAIL;
}
s_media.recording_data_bytes = 0U;
const uint32_t now_ms = (uint32_t) (esp_timer_get_time() / 1000LL);
s_media.snapshot.recording = true;
s_media.snapshot.record_limit_seconds = seconds;
s_media.snapshot.record_started_ms = now_ms;
s_media.snapshot.record_elapsed_seconds = 0U;
copy_text(s_media.snapshot.record_file,
sizeof(s_media.snapshot.record_file), path);
clear_last_error();
ESP_LOGI(TAG, "recording started -> %s (limit=%us, simulated PCM)",
path, seconds);
return ESP_OK;
}
esp_err_t media_manager_stop_recording(void) {
if (!s_media.initialized) {
return ESP_ERR_INVALID_STATE;
}
if (!s_media.snapshot.recording) {
return ESP_OK;
}
if (s_media.recording_file != NULL) {
// Patch header with the real (zero, in stub mode) data length.
(void) write_wav_header(s_media.recording_file,
s_media.recording_data_bytes);
fclose(s_media.recording_file);
s_media.recording_file = NULL;
}
const uint32_t now_ms = (uint32_t) (esp_timer_get_time() / 1000LL);
const uint32_t elapsed_ms = now_ms - s_media.snapshot.record_started_ms;
s_media.snapshot.record_elapsed_seconds = (uint16_t) (elapsed_ms / 1000U);
s_media.snapshot.recording = false;
clear_last_error();
ESP_LOGI(TAG, "recording stopped (%us elapsed, file=%s)",
s_media.snapshot.record_elapsed_seconds,
s_media.snapshot.record_file);
return ESP_OK;
}
void media_manager_snapshot(media_manager_snapshot_t *out) {
if (out == NULL) {
return;
}
*out = s_media.snapshot;
}
@@ -0,0 +1,31 @@
## Zacus npc_engine — ESP-IDF port (slice 4, P1).
##
## Ports the Arduino `npc_engine` decision module
## (ui_freenove_allinone/src/npc/npc_engine.cpp, 198 LOC) into an IDF
## component. The Arduino sources were already pure C with `extern "C"`
## guards and zero Arduino-runtime calls, so the port reuses the same
## state machine verbatim and only adapts the wrapper layer (init/update/
## trigger entry points) to the IDF idioms (esp_err_t returns, esp_log,
## media_manager integration). The hint-request HTTP path remains a stub
## (callback invoked synchronously with a hardcoded text) until the
## hints-engine HTTP client lands in slice 6.
##
## Persistence of "cues already played" lives in RAM only at this slice;
## an NVS-backed log can be added once a real trigger source feeds the
## engine.
idf_component_register(
SRCS
"npc_engine.c"
INCLUDE_DIRS
"include"
REQUIRES
media_manager
hints_client
esp_http_client
esp_timer
esp_system
nvs_flash
freertos
log
)
@@ -0,0 +1,219 @@
// Zacus npc_engine — ESP-IDF C port of the Arduino NPC decision engine.
//
// Source of truth for the Arduino implementation:
// ESP32_ZACUS/ui_freenove_allinone/src/npc/npc_engine.cpp
// ESP32_ZACUS/ui_freenove_allinone/include/npc/npc_engine.h
//
// The IDF port keeps the Arduino "core" state-machine API verbatim
// (npc_init / npc_evaluate / npc_on_*) because that code was already
// pure-C, side-effect free and free of Arduino-runtime dependencies.
//
// On top of that core the port adds an IDF-idiomatic "engine" wrapper
// layer with:
// * npc_engine_init(config) — boot the singleton, log readiness
// * npc_engine_update(now_ms) — periodic tick (mood + auto-evaluate)
// * npc_engine_trigger_cue(cue_id) — best-effort cue dispatch through
// media_manager_play()
// * npc_engine_set_step(step_id) — bridge to the scenario engine
// * npc_engine_request_hint(...) — async hint request (stubbed locally
// until the hints-engine HTTP client
// lands in a later slice)
//
// All wrapper entry points return `esp_err_t`. Callbacks are plain C
// function pointers — no C++ classes, no lambdas, RTOS-friendly.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// ── Core (ported verbatim from the Arduino sources) ─────────────────────────
#define NPC_MAX_SCENES 12
#define NPC_MAX_HINT_LEVEL 3
#define NPC_PHRASE_MAX_LEN 200
#define NPC_STUCK_TIMEOUT_MS (3UL * 60UL * 1000UL)
#define NPC_FAST_THRESHOLD_PCT 50
#define NPC_SLOW_THRESHOLD_PCT 150
#define NPC_QR_DEBOUNCE_MS 30000
typedef enum {
NPC_MOOD_NEUTRAL = 0,
NPC_MOOD_IMPRESSED,
NPC_MOOD_WORRIED,
NPC_MOOD_AMUSED,
NPC_MOOD_COUNT
} npc_mood_t;
typedef enum {
NPC_TRIGGER_NONE = 0,
NPC_TRIGGER_HINT_REQUEST,
NPC_TRIGGER_STUCK_TIMER,
NPC_TRIGGER_QR_SCANNED,
NPC_TRIGGER_WRONG_ACTION,
NPC_TRIGGER_FAST_PROGRESS,
NPC_TRIGGER_SLOW_PROGRESS,
NPC_TRIGGER_SCENE_TRANSITION,
NPC_TRIGGER_GAME_START,
NPC_TRIGGER_GAME_END,
NPC_TRIGGER_COUNT
} npc_trigger_t;
typedef enum {
NPC_AUDIO_NONE = 0,
NPC_AUDIO_LIVE_TTS,
NPC_AUDIO_SD_CONTEXTUAL,
NPC_AUDIO_SD_GENERIC
} npc_audio_source_t;
typedef struct {
uint8_t current_scene;
uint8_t current_step;
uint32_t scene_start_ms;
uint32_t total_elapsed_ms;
uint8_t hints_given[NPC_MAX_SCENES];
uint8_t qr_scanned_count;
uint8_t failed_attempts;
bool phone_off_hook;
bool tower_reachable;
npc_mood_t mood;
uint32_t last_qr_scan_ms;
uint32_t expected_scene_duration_ms;
} npc_state_t;
typedef struct {
npc_trigger_t trigger;
npc_audio_source_t audio_source;
char phrase_text[NPC_PHRASE_MAX_LEN];
char sd_path[128];
npc_mood_t resulting_mood;
} npc_decision_t;
void npc_init(npc_state_t *state);
void npc_reset(npc_state_t *state);
bool npc_evaluate(const npc_state_t *state, uint32_t now_ms,
npc_decision_t *out);
void npc_on_scene_change(npc_state_t *state, uint8_t new_scene,
uint32_t expected_duration_ms, uint32_t now_ms);
void npc_on_qr_scan(npc_state_t *state, bool valid, uint32_t now_ms);
void npc_on_phone_hook(npc_state_t *state, bool off_hook);
void npc_on_hint_request(npc_state_t *state, uint32_t now_ms);
void npc_on_tower_status(npc_state_t *state, bool reachable);
void npc_update_mood(npc_state_t *state, uint32_t now_ms);
uint8_t npc_hint_level(const npc_state_t *state, uint8_t scene);
bool npc_build_sd_path(char *out_path, size_t capacity,
uint8_t scene, npc_trigger_t trigger,
npc_mood_t mood, uint8_t variant);
// ── Engine wrapper (IDF idioms) ─────────────────────────────────────────────
#define NPC_ENGINE_MAX_CUES 32
#define NPC_ENGINE_CUE_PATH_MAX 128
#define NPC_ENGINE_CUE_ID_MAX 32
// Static cue table entry. Authored cues live in flash; runtime state
// (already-played flag, cooldown) is tracked separately in RAM.
typedef struct {
char id[NPC_ENGINE_CUE_ID_MAX];
char audio_path[NPC_ENGINE_CUE_PATH_MAX];
uint8_t scene; // associated scene index (0xFF = global cue)
npc_mood_t mood;
} npc_cue_t;
// Configuration for the wrapper. `cues` may be NULL/0 — the engine still
// boots and accepts triggers (each `trigger_cue` call simply tries
// media_manager_play() with the supplied cue identifier as a path).
typedef struct {
const npc_cue_t *cues;
size_t cue_count;
bool auto_evaluate; // run npc_evaluate() each tick
bool auto_play_decisions; // dispatch decisions through media_manager_play
} npc_engine_config_t;
// Hint request callback. Invoked when the hints-engine produced a result
// (today: synchronously with a hardcoded stub text). `text` is owned by
// the engine and only valid for the duration of the call — copy if needed.
typedef void (*npc_hint_callback_t)(uint8_t puzzle_id, uint8_t level,
esp_err_t status, const char *text,
void *user_ctx);
// Initialize the engine singleton. `config` may be NULL — defaults are
// applied (no cue table, auto_evaluate=false, auto_play_decisions=false).
esp_err_t npc_engine_init(const npc_engine_config_t *config);
// Periodic tick. `now_ms` is the same monotonic millisecond counter as
// `media_manager_update`. Updates mood, optionally runs npc_evaluate and
// dispatches the resulting cue when `auto_*` flags are enabled.
esp_err_t npc_engine_update(uint32_t now_ms);
// Manually trigger a cue by id. Looks up the cue table and dispatches the
// associated audio path through media_manager_play(). When the cue is not
// in the table the engine treats `cue_id` itself as the path and forwards
// it as-is — useful for ad-hoc tests from the REST surface.
//
// Returns:
// ESP_OK cue dispatched (media_manager_play returned OK)
// ESP_ERR_NOT_FOUND cue id not in table AND raw path not playable
// ESP_ERR_INVALID_* propagated from media_manager
esp_err_t npc_engine_trigger_cue(const char *cue_id);
// Bridge from the scenario runtime — informs the engine of the active
// step (and implicitly the active scene). Resets failed-attempts counter
// 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.
// TODO(slice-6): replace with HTTP POST to /hints/ask on the hints engine.
esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level,
npc_hint_callback_t cb, void *user_ctx);
// Read-only access to the underlying core state — handy for diagnostics.
const npc_state_t *npc_engine_state(void);
// Slice 11 (P5): forward the global hints group profile to hints_client.
// `profile` must be one of "TECH", "NON_TECH", "MIXED", "BOTH".
// 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
// always have a non-empty id to send to the hints engine. Returns the
// number of bytes written excluding the trailing NUL.
size_t npc_engine_current_puzzle_id(char *out, size_t cap);
// Slice 11 (P5): notify the hints engine that the operator just made an
// invalid attempt on `scene`. Resolves the scene to the same string id
// returned by npc_engine_current_puzzle_id() and forwards through
// hints_client_attempt_failed(). Best-effort: returns ESP_OK even if the
// hints engine is unreachable, the failure is logged.
esp_err_t npc_engine_report_failed_attempt(uint8_t scene);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,541 @@
// Zacus npc_engine — ESP-IDF C port (slice 4, P1).
//
// Portage strategy: the Arduino sources were already pure C (no classes,
// no Arduino runtime calls), so the "core" half of this file is a verbatim
// copy of ui_freenove_allinone/src/npc/npc_engine.cpp with `cstring`/`cstdio`
// replaced by their C headers. The "engine wrapper" half is new and follows
// the same pattern as the media_manager component:
//
// * single static singleton, idempotent init
// * esp_err_t returns + ESP_LOG instrumentation
// * media_manager_play() integration for cue dispatch
//
// Hint requests are stubbed locally (synchronous callback with a hardcoded
// French placeholder). The real HTTP call to the hints engine lands in a
// later slice (see TODO below).
#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[] = {
"SCENE_U_SON_PROTO",
"SCENE_LA_DETECTOR",
"SCENE_WIN_ETAPE1",
"SCENE_WARNING",
"SCENE_LEFOU_DETECTOR",
"SCENE_WIN_ETAPE2",
"SCENE_QR_DETECTOR",
"SCENE_FINAL_WIN",
};
static const uint8_t kSceneCount = sizeof(kSceneIds) / sizeof(kSceneIds[0]);
static const char *const kTriggerDirs[] = {
[NPC_TRIGGER_NONE] = "generic",
[NPC_TRIGGER_HINT_REQUEST] = "indice",
[NPC_TRIGGER_STUCK_TIMER] = "indice",
[NPC_TRIGGER_QR_SCANNED] = "felicitations",
[NPC_TRIGGER_WRONG_ACTION] = "attention",
[NPC_TRIGGER_FAST_PROGRESS] = "fausse_piste",
[NPC_TRIGGER_SLOW_PROGRESS] = "adaptation",
[NPC_TRIGGER_SCENE_TRANSITION] = "transition",
[NPC_TRIGGER_GAME_START] = "ambiance",
[NPC_TRIGGER_GAME_END] = "ambiance",
};
static const char *const kMoodSuffixes[] = {
[NPC_MOOD_NEUTRAL] = "neutral",
[NPC_MOOD_IMPRESSED] = "impressed",
[NPC_MOOD_WORRIED] = "worried",
[NPC_MOOD_AMUSED] = "amused",
};
// ─── Core: state-machine API (verbatim Arduino, NULL guards preserved) ──────
void npc_init(npc_state_t *state) {
if (state == NULL) return;
memset(state, 0, sizeof(*state));
state->mood = NPC_MOOD_NEUTRAL;
}
void npc_reset(npc_state_t *state) {
npc_init(state);
}
void npc_on_scene_change(npc_state_t *state, uint8_t new_scene,
uint32_t expected_duration_ms, uint32_t now_ms) {
if (state == NULL) return;
state->current_scene = new_scene;
state->scene_start_ms = now_ms;
state->expected_scene_duration_ms = expected_duration_ms;
state->failed_attempts = 0;
}
void npc_on_qr_scan(npc_state_t *state, bool valid, uint32_t now_ms) {
if (state == NULL) return;
if (valid) {
state->qr_scanned_count++;
} else {
state->failed_attempts++;
}
state->last_qr_scan_ms = now_ms;
}
void npc_on_phone_hook(npc_state_t *state, bool off_hook) {
if (state == NULL) return;
state->phone_off_hook = off_hook;
}
void npc_on_hint_request(npc_state_t *state, uint32_t now_ms) {
if (state == NULL) return;
uint8_t scene = state->current_scene;
if (scene < NPC_MAX_SCENES && state->hints_given[scene] < NPC_MAX_HINT_LEVEL) {
state->hints_given[scene]++;
}
(void) now_ms;
}
void npc_on_tower_status(npc_state_t *state, bool reachable) {
if (state == NULL) return;
state->tower_reachable = reachable;
}
void npc_update_mood(npc_state_t *state, uint32_t now_ms) {
if (state == NULL || state->expected_scene_duration_ms == 0) return;
uint32_t elapsed = now_ms - state->scene_start_ms;
uint32_t expected = state->expected_scene_duration_ms;
uint32_t pct = (elapsed * 100U) / expected;
if (state->failed_attempts >= 3) {
state->mood = NPC_MOOD_AMUSED;
} else if (pct < NPC_FAST_THRESHOLD_PCT) {
state->mood = NPC_MOOD_IMPRESSED;
} else if (pct > NPC_SLOW_THRESHOLD_PCT) {
state->mood = NPC_MOOD_WORRIED;
} else {
state->mood = NPC_MOOD_NEUTRAL;
}
}
uint8_t npc_hint_level(const npc_state_t *state, uint8_t scene) {
if (state == NULL || scene >= NPC_MAX_SCENES) return 0;
return state->hints_given[scene];
}
bool npc_build_sd_path(char *out_path, size_t capacity,
uint8_t scene, npc_trigger_t trigger,
npc_mood_t mood, uint8_t variant) {
if (out_path == NULL || capacity < 16) return false;
const char *scene_id = (scene < kSceneCount) ? kSceneIds[scene] : "npc";
const char *trigger_dir = (trigger < NPC_TRIGGER_COUNT)
? kTriggerDirs[trigger] : "generic";
const char *mood_str = (mood < NPC_MOOD_COUNT)
? kMoodSuffixes[mood] : "neutral";
bool is_scene_specific = (trigger != NPC_TRIGGER_GAME_START
&& trigger != NPC_TRIGGER_GAME_END
&& trigger != NPC_TRIGGER_NONE);
int written;
if (is_scene_specific && scene < kSceneCount) {
written = snprintf(out_path, capacity,
"/hotline_tts/%s/%s_%s_%u.mp3",
scene_id, trigger_dir, mood_str, (unsigned) variant);
} else {
written = snprintf(out_path, capacity,
"/hotline_tts/npc/%s_%s_%u.mp3",
trigger_dir, mood_str, (unsigned) variant);
}
return (written > 0 && (size_t) written < capacity);
}
bool npc_evaluate(const npc_state_t *state, uint32_t now_ms,
npc_decision_t *out) {
if (state == NULL || out == NULL) return false;
memset(out, 0, sizeof(*out));
uint32_t scene_elapsed = now_ms - state->scene_start_ms;
uint32_t expected = state->expected_scene_duration_ms;
// Priority 1: Hint request (phone off hook while stuck)
if (state->phone_off_hook && scene_elapsed > NPC_STUCK_TIMEOUT_MS) {
uint8_t level = npc_hint_level(state, state->current_scene);
out->trigger = NPC_TRIGGER_HINT_REQUEST;
out->resulting_mood = state->mood;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_HINT_REQUEST,
state->mood, level);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 2: Stuck timer (proactive, no phone needed)
if (scene_elapsed > NPC_STUCK_TIMEOUT_MS
&& npc_hint_level(state, state->current_scene) == 0) {
out->trigger = NPC_TRIGGER_STUCK_TIMER;
out->resulting_mood = NPC_MOOD_WORRIED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_STUCK_TIMER,
NPC_MOOD_WORRIED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 3: Fast progress detection
if (expected > 0 && scene_elapsed > 0
&& (scene_elapsed * 100U / expected) < NPC_FAST_THRESHOLD_PCT) {
out->trigger = NPC_TRIGGER_FAST_PROGRESS;
out->resulting_mood = NPC_MOOD_IMPRESSED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_FAST_PROGRESS,
NPC_MOOD_IMPRESSED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
// Priority 4: Slow progress detection
if (expected > 0 && (scene_elapsed * 100U / expected) > NPC_SLOW_THRESHOLD_PCT) {
out->trigger = NPC_TRIGGER_SLOW_PROGRESS;
out->resulting_mood = NPC_MOOD_WORRIED;
npc_build_sd_path(out->sd_path, sizeof(out->sd_path),
state->current_scene, NPC_TRIGGER_SLOW_PROGRESS,
NPC_MOOD_WORRIED, 0);
out->audio_source = state->tower_reachable
? NPC_AUDIO_LIVE_TTS : NPC_AUDIO_SD_CONTEXTUAL;
return true;
}
return false;
}
// ─── Wrapper singleton (slice-4 IDF surface) ────────────────────────────────
typedef struct {
bool ready;
npc_state_t core;
const npc_cue_t *cues;
size_t cue_count;
bool played[NPC_ENGINE_MAX_CUES]; // already-played log
bool auto_evaluate;
bool auto_play_decisions;
} engine_t;
static engine_t s_engine;
static const npc_cue_t *find_cue(const char *cue_id) {
if (cue_id == NULL || s_engine.cues == NULL) return NULL;
for (size_t i = 0; i < s_engine.cue_count && i < NPC_ENGINE_MAX_CUES; ++i) {
if (strncmp(s_engine.cues[i].id, cue_id,
NPC_ENGINE_CUE_ID_MAX) == 0) {
return &s_engine.cues[i];
}
}
return NULL;
}
static size_t cue_index(const npc_cue_t *cue) {
if (cue == NULL || s_engine.cues == NULL) return SIZE_MAX;
return (size_t) (cue - s_engine.cues);
}
esp_err_t npc_engine_init(const npc_engine_config_t *config) {
memset(&s_engine, 0, sizeof(s_engine));
npc_init(&s_engine.core);
if (config != NULL) {
s_engine.cues = config->cues;
s_engine.cue_count = config->cue_count;
s_engine.auto_evaluate = config->auto_evaluate;
s_engine.auto_play_decisions = config->auto_play_decisions;
if (s_engine.cue_count > NPC_ENGINE_MAX_CUES) {
ESP_LOGW(TAG, "cue table truncated: %u > NPC_ENGINE_MAX_CUES (%u)",
(unsigned) s_engine.cue_count,
(unsigned) NPC_ENGINE_MAX_CUES);
s_engine.cue_count = NPC_ENGINE_MAX_CUES;
}
}
s_engine.ready = true;
ESP_LOGI(TAG, "npc_engine ready, %u cues registered "
"(auto_evaluate=%d, auto_play=%d)",
(unsigned) s_engine.cue_count,
(int) s_engine.auto_evaluate,
(int) s_engine.auto_play_decisions);
return ESP_OK;
}
esp_err_t npc_engine_update(uint32_t now_ms) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
s_engine.core.total_elapsed_ms = now_ms;
npc_update_mood(&s_engine.core, now_ms);
if (!s_engine.auto_evaluate) return ESP_OK;
npc_decision_t decision;
if (!npc_evaluate(&s_engine.core, now_ms, &decision)) return ESP_OK;
ESP_LOGI(TAG, "decision: trigger=%d mood=%d audio=%d path=\"%s\"",
(int) decision.trigger,
(int) decision.resulting_mood,
(int) decision.audio_source,
decision.sd_path);
if (s_engine.auto_play_decisions && decision.sd_path[0] != '\0') {
esp_err_t err = media_manager_play(decision.sd_path);
if (err != ESP_OK) {
ESP_LOGW(TAG, "auto-play \"%s\" failed: %s",
decision.sd_path, esp_err_to_name(err));
}
}
return ESP_OK;
}
esp_err_t npc_engine_trigger_cue(const char *cue_id) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (cue_id == NULL || cue_id[0] == '\0') return ESP_ERR_INVALID_ARG;
const npc_cue_t *cue = find_cue(cue_id);
const char *path = NULL;
size_t idx = SIZE_MAX;
if (cue != NULL) {
path = cue->audio_path;
idx = cue_index(cue);
ESP_LOGI(TAG, "trigger_cue id=\"%s\" -> path=\"%s\" (scene=%u, mood=%d)",
cue_id, path, (unsigned) cue->scene, (int) cue->mood);
} else {
// Fallback: treat the id itself as a path. Useful for ad-hoc REST tests.
path = cue_id;
ESP_LOGI(TAG, "trigger_cue id=\"%s\" not in table — playing raw path",
cue_id);
}
esp_err_t err = media_manager_play(path);
if (err == ESP_OK && idx < NPC_ENGINE_MAX_CUES) {
s_engine.played[idx] = true;
}
return err;
}
// Resolve a scene index to the canonical puzzle id used by the hints
// engine (matches game/scenarios/npc_phrases.yaml). Returns the count
// of bytes written (excluding NUL). Always writes at least the fallback
// "SCENE_NPC" so callers can rely on a non-empty string.
static size_t scene_to_puzzle_id(uint8_t scene, char *out, size_t cap) {
if (out == NULL || cap == 0) return 0;
const char *id = (scene < kSceneCount) ? kSceneIds[scene] : "SCENE_NPC";
int written = snprintf(out, cap, "%s", id);
if (written < 0) {
out[0] = '\0';
return 0;
}
return ((size_t) written < cap) ? (size_t) written : (cap - 1);
}
esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
const uint8_t prev_scene = s_engine.core.current_scene;
s_engine.core.current_step = step_id;
npc_on_scene_change(&s_engine.core, step_id, expected_duration_ms,
s_engine.core.total_elapsed_ms);
ESP_LOGI(TAG, "step set to %u (expected_duration=%u ms)",
(unsigned) step_id, (unsigned) expected_duration_ms);
// Slice 11 (P5): notify the hints engine that the operator just
// entered a new pivot. Idempotent on the server, so we still post
// even if step_id hasn't moved (defensive: the scenario may rebind
// the same step after a recovery). hints_client logs the outcome
// internally — we don't propagate the failure (best-effort).
if (hints_client_is_ready()) {
char puzzle_id[NPC_ENGINE_CUE_ID_MAX];
scene_to_puzzle_id(step_id, puzzle_id, sizeof(puzzle_id));
if (step_id != prev_scene) {
ESP_LOGI(TAG, "scene changed %u → %u, signalling /puzzle_start",
(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
// need to depend directly on hints_client just for this knob.
if (!hints_client_is_ready()) {
ESP_LOGW(TAG, "set_group_profile(\"%s\") before hints_client_init",
profile ? profile : "(null)");
return ESP_ERR_INVALID_STATE;
}
return hints_client_set_group_profile(profile);
}
size_t npc_engine_current_puzzle_id(char *out, size_t cap) {
if (out == NULL || cap == 0) return 0;
uint8_t scene = s_engine.ready ? s_engine.core.current_scene : 0xFF;
return scene_to_puzzle_id(scene, out, cap);
}
esp_err_t npc_engine_report_failed_attempt(uint8_t scene) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (!hints_client_is_ready()) {
// Track locally only — keeps failed_attempts coherent so the
// stuck timer / mood updater still react.
s_engine.core.failed_attempts++;
ESP_LOGD(TAG, "failed_attempt scene=%u (hints offline, local only)",
(unsigned) scene);
return ESP_OK;
}
char puzzle_id[NPC_ENGINE_CUE_ID_MAX];
scene_to_puzzle_id(scene, puzzle_id, sizeof(puzzle_id));
s_engine.core.failed_attempts++;
(void) hints_client_attempt_failed(puzzle_id);
return ESP_OK;
}
esp_err_t npc_engine_request_hint(uint8_t puzzle_id, uint8_t level,
npc_hint_callback_t cb, void *user_ctx) {
if (!s_engine.ready) return ESP_ERR_INVALID_STATE;
if (cb == NULL) return ESP_ERR_INVALID_ARG;
const uint8_t clamped = (level > NPC_MAX_HINT_LEVEL)
? NPC_MAX_HINT_LEVEL : level;
npc_on_hint_request(&s_engine.core, s_engine.core.total_elapsed_ms);
// Slice 5: when the hints_client component has been initialised, route
// the request through the real HTTP backend asynchronously. Otherwise
// fall back to a hardcoded French placeholder so the surrounding NPC
// orchestration can still be exercised end-to-end (CI smoke, dry runs).
if (hints_client_is_ready()) {
// Slice 11 (P5): map the numeric puzzle hint id to the same
// SCENE_* string id used by /hints/puzzle_start. When the
// dispatcher passes id=0 (placeholder), fall back to the active
// scene so the hints engine can still pick a contextual answer.
char puzzle_str[NPC_ENGINE_CUE_ID_MAX];
const uint8_t scene_for_id = (puzzle_id == 0)
? s_engine.core.current_scene : puzzle_id;
scene_to_puzzle_id(scene_for_id, puzzle_str, sizeof(puzzle_str));
esp_err_t err = hints_client_ask_async(puzzle_str, puzzle_id, clamped,
(hints_client_callback_t) cb,
user_ctx, 0, 0);
if (err == ESP_OK) {
ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> hints_client async",
(unsigned) puzzle_id, (unsigned) clamped);
return ESP_OK;
}
ESP_LOGW(TAG, "hints_client_ask_async failed (%s) — using stub",
esp_err_to_name(err));
}
static const char *const kStubHints[NPC_MAX_HINT_LEVEL + 1] = {
"Regarde autour de toi, la solution est plus proche que tu ne crois.",
"As-tu pensé à observer chaque indice plus attentivement ?",
"Concentre-toi sur l'objet le plus inhabituel de la pièce.",
"Le code se trouve dans la séquence des couleurs, dans l'ordre.",
};
const char *text = kStubHints[clamped];
ESP_LOGI(TAG, "hint request puzzle=%u level=%u -> stub \"%s\"",
(unsigned) puzzle_id, (unsigned) level, text);
cb(puzzle_id, clamped, ESP_OK, text, user_ctx);
return ESP_OK;
}
const npc_state_t *npc_engine_state(void) {
return s_engine.ready ? &s_engine.core : NULL;
}
@@ -0,0 +1,29 @@
idf_component_register(
SRCS
"ota_server.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_http_server
app_update
esp_timer
esp_system
nvs_flash
mbedtls
freertos
)
# Firmware metadata injected at compile time
# Override these in the puzzle's CMakeLists.txt before adding the component
if(NOT DEFINED OTA_FIRMWARE_NAME)
set(OTA_FIRMWARE_NAME "zacus_puzzle")
endif()
if(NOT DEFINED OTA_FIRMWARE_VERSION)
set(OTA_FIRMWARE_VERSION "1.0.0")
endif()
target_compile_definitions(${COMPONENT_LIB} PRIVATE
OTA_FIRMWARE_NAME="${OTA_FIRMWARE_NAME}"
OTA_FIRMWARE_VERSION="${OTA_FIRMWARE_VERSION}"
)
@@ -0,0 +1,95 @@
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
// ─── Version info (override in each puzzle's CMakeLists.txt) ─────────────────
#ifndef OTA_FIRMWARE_NAME
#define OTA_FIRMWARE_NAME "zacus_puzzle"
#endif
#ifndef OTA_FIRMWARE_VERSION
#define OTA_FIRMWARE_VERSION "1.0.0"
#endif
// ─── Configuration ────────────────────────────────────────────────────────────
#define OTA_SERVER_PORT 80
#define OTA_RATE_LIMIT_SECS 60 // Minimum seconds between OTA updates
#define OTA_WATCHDOG_SECS 30 // Auto-rollback if new firmware crashes within this
#define OTA_MAX_UPLOAD_SIZE (4 * 1024 * 1024) // 4 MB max firmware size
#define OTA_CHUNK_SIZE 4096
// ─── State ────────────────────────────────────────────────────────────────────
typedef enum {
OTA_STATE_IDLE = 0,
OTA_STATE_DOWNLOADING = 1,
OTA_STATE_VERIFYING = 2,
OTA_STATE_REBOOTING = 3,
OTA_STATE_ERROR = 4,
} ota_state_t;
typedef struct {
ota_state_t state;
int progress; // 0-100
char error[128];
uint32_t bytes_received;
uint32_t total_bytes;
int64_t last_ota_time; // Unix timestamp of last OTA attempt
} ota_status_t;
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* @brief Initialize the OTA HTTP server on port 80.
*
* Registers 5 endpoints:
* GET /version -> firmware name, version, IDF version
* GET /status -> battery, heap, uptime, ESP-NOW peers
* POST /ota -> receive .bin, write to OTA partition, reboot
* GET /ota/status -> current OTA state and progress
* POST /ota/rollback -> revert to previous firmware partition
*
* @return ESP_OK on success, error code otherwise.
*/
esp_err_t ota_server_init(void);
/**
* @brief Mark current firmware as valid (call after successful startup).
*
* Cancels the rollback watchdog. Call this after all subsystems have
* initialized successfully, typically 5-10 seconds after boot.
*/
void ota_server_mark_valid(void);
/**
* @brief Get the current OTA status.
*/
const ota_status_t* ota_server_get_status(void);
/**
* @brief Register a callback invoked when an OTA update completes.
*
* Called before the device reboots. Use to flush pending data to NVS.
*/
void ota_server_set_complete_cb(void (*cb)(bool success));
/**
* @brief Get the underlying esp_http_server handle so other components
* can register additional URI handlers on the same listener
* (port 80) instead of standing up a second httpd instance.
*
* Returns NULL if ota_server_init() has not been called or failed.
*
* Used by the voice_hook_endpoint component (PLIP /voice/hook bridge,
* slice 10) to attach POST /voice/hook + GET /voice/hook/state without
* burning a second TCP socket / second httpd worker.
*/
httpd_handle_t ota_server_get_handle(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,369 @@
#include "ota_server.h"
#include <string.h>
#include <time.h>
#include <sys/param.h>
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_app_format.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "esp_http_server.h"
#include "esp_mac.h"
#include "nvs_flash.h"
#include "mbedtls/sha256.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
// ─── External symbols (provided by each puzzle's main component) ──────────────
extern int puzzle_get_battery_pct(void);
extern int puzzle_get_espnow_peer_count(void);
static const char* TAG = "ota_server";
// ─── Module state ─────────────────────────────────────────────────────────────
static httpd_handle_t s_server = NULL;
static ota_status_t s_status = { .state = OTA_STATE_IDLE };
static void (*s_complete_cb)(bool) = NULL;
static esp_timer_handle_t s_watchdog = NULL;
// ─── JSON helpers ─────────────────────────────────────────────────────────────
static void json_str(char* buf, size_t size, const char* key, const char* val, bool comma) {
snprintf(buf + strlen(buf), size - strlen(buf),
"\"%s\":\"%s\"%s", key, val, comma ? "," : "");
}
static void json_int(char* buf, size_t size, const char* key, int val, bool comma) {
snprintf(buf + strlen(buf), size - strlen(buf),
"\"%s\":%d%s", key, val, comma ? "," : "");
}
// ─── GET /version ─────────────────────────────────────────────────────────────
static esp_err_t handle_version(httpd_req_t* req) {
char buf[256] = "{";
json_str(buf, sizeof(buf), "firmware", OTA_FIRMWARE_NAME, true);
json_str(buf, sizeof(buf), "version", OTA_FIRMWARE_VERSION, true);
json_str(buf, sizeof(buf), "idf", IDF_VER, false);
strncat(buf, "}", sizeof(buf) - strlen(buf) - 1);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, buf);
return ESP_OK;
}
// ─── GET /status ──────────────────────────────────────────────────────────────
static esp_err_t handle_status(httpd_req_t* req) {
char buf[256] = "{";
json_int(buf, sizeof(buf), "battery_pct", puzzle_get_battery_pct(), true);
json_int(buf, sizeof(buf), "uptime_s", (int)(esp_timer_get_time() / 1000000), true);
json_int(buf, sizeof(buf), "espnow_peers", puzzle_get_espnow_peer_count(), true);
json_int(buf, sizeof(buf), "heap_free", (int)esp_get_free_heap_size(), false);
strncat(buf, "}", sizeof(buf) - strlen(buf) - 1);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, buf);
return ESP_OK;
}
// ─── GET /ota/status ──────────────────────────────────────────────────────────
static const char* state_to_str(ota_state_t state) {
switch (state) {
case OTA_STATE_IDLE: return "idle";
case OTA_STATE_DOWNLOADING: return "downloading";
case OTA_STATE_VERIFYING: return "verifying";
case OTA_STATE_REBOOTING: return "rebooting";
case OTA_STATE_ERROR: return "error";
default: return "unknown";
}
}
static esp_err_t handle_ota_status(httpd_req_t* req) {
char buf[256] = "{";
json_str(buf, sizeof(buf), "state", state_to_str(s_status.state), true);
json_int(buf, sizeof(buf), "progress", s_status.progress, false);
strncat(buf, "}", sizeof(buf) - strlen(buf) - 1);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, buf);
return ESP_OK;
}
// ─── POST /ota ────────────────────────────────────────────────────────────────
static void do_ota_task(void* arg) {
esp_ota_handle_t ota_handle = 0;
const esp_partition_t* ota_part = NULL;
httpd_req_t* req = (httpd_req_t*)arg;
esp_err_t err = ESP_OK;
mbedtls_sha256_context sha_ctx;
mbedtls_sha256_init(&sha_ctx);
uint8_t* buf = malloc(OTA_CHUNK_SIZE);
if (!buf) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM"); goto cleanup; }
// Check content length
int total = req->content_len;
if (total <= 0 || total > OTA_MAX_UPLOAD_SIZE) {
ESP_LOGE(TAG, "Invalid content length: %d", total);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid size");
goto cleanup;
}
// Get OTA partition
ota_part = esp_ota_get_next_update_partition(NULL);
if (!ota_part) {
ESP_LOGE(TAG, "No OTA partition available");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "No OTA partition");
goto cleanup;
}
err = esp_ota_begin(ota_part, OTA_SIZE_UNKNOWN, &ota_handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed");
goto cleanup;
}
s_status.state = OTA_STATE_DOWNLOADING;
s_status.bytes_received = 0;
s_status.total_bytes = total;
s_status.progress = 0;
// SHA256 context for integrity check
mbedtls_sha256_starts(&sha_ctx, 0);
// Receive and write firmware chunks
int received = 0;
while (received < total) {
int chunk_size = MIN(OTA_CHUNK_SIZE, total - received);
int r = httpd_req_recv(req, (char*)buf, chunk_size);
if (r <= 0) {
if (r == HTTPD_SOCK_ERR_TIMEOUT) continue;
ESP_LOGE(TAG, "Recv error: %d", r);
err = ESP_FAIL;
break;
}
err = esp_ota_write(ota_handle, buf, r);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_write failed at offset %d: %s", received, esp_err_to_name(err));
break;
}
mbedtls_sha256_update(&sha_ctx, buf, r);
received += r;
s_status.bytes_received = received;
s_status.progress = (received * 100) / total;
}
if (err != ESP_OK) {
snprintf(s_status.error, sizeof(s_status.error), "Write failed: %s", esp_err_to_name(err));
s_status.state = OTA_STATE_ERROR;
esp_ota_abort(ota_handle);
ota_handle = 0;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error);
goto cleanup;
}
// Compute SHA256 of received data
uint8_t sha256[32];
mbedtls_sha256_finish(&sha_ctx, sha256);
mbedtls_sha256_free(&sha_ctx);
s_status.state = OTA_STATE_VERIFYING;
s_status.progress = 95;
// Finalize OTA
err = esp_ota_end(ota_handle);
ota_handle = 0;
if (err != ESP_OK) {
snprintf(s_status.error, sizeof(s_status.error), "OTA end failed: %s", esp_err_to_name(err));
s_status.state = OTA_STATE_ERROR;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error);
goto cleanup;
}
// Set boot partition
err = esp_ota_set_boot_partition(ota_part);
if (err != ESP_OK) {
snprintf(s_status.error, sizeof(s_status.error), "Set boot failed: %s", esp_err_to_name(err));
s_status.state = OTA_STATE_ERROR;
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, s_status.error);
goto cleanup;
}
s_status.progress = 100;
s_status.state = OTA_STATE_REBOOTING;
// Respond before reboot
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"Firmware accepted, rebooting\"}");
if (s_complete_cb) s_complete_cb(true);
ESP_LOGI(TAG, "OTA success, rebooting in 1s");
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
cleanup:
if (ota_handle) esp_ota_abort(ota_handle);
free(buf);
mbedtls_sha256_free(&sha_ctx);
vTaskDelete(NULL);
}
static esp_err_t handle_ota_upload(httpd_req_t* req) {
// Rate limiting
int64_t now = esp_timer_get_time() / 1000000;
if (s_status.last_ota_time > 0 && (now - s_status.last_ota_time) < OTA_RATE_LIMIT_SECS) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Rate limited: wait 60s");
return ESP_FAIL;
}
if (s_status.state != OTA_STATE_IDLE) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA already in progress");
return ESP_FAIL;
}
s_status.last_ota_time = now;
// Run OTA in a separate task to not block the HTTP server
if (xTaskCreate(do_ota_task, "ota_task", 8192, req, 5, NULL) != pdPASS) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Task create failed");
return ESP_FAIL;
}
// Task will send the HTTP response
return ESP_OK;
}
// ─── POST /ota/rollback ───────────────────────────────────────────────────────
static esp_err_t handle_ota_rollback(httpd_req_t* req) {
const esp_partition_t* prev = esp_ota_get_last_invalid_partition();
if (!prev) {
// Try running partition as fallback
prev = esp_ota_get_running_partition();
}
if (!prev) {
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "No previous partition to roll back to");
return ESP_FAIL;
}
esp_err_t err = esp_ota_set_boot_partition(prev);
if (err != ESP_OK) {
char msg[64];
snprintf(msg, sizeof(msg), "Rollback failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, msg);
return ESP_FAIL;
}
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"status\":\"ok\",\"message\":\"Rolling back, rebooting\"}");
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
return ESP_OK;
}
// ─── Watchdog (auto-rollback) ──────────────────────────────────────────────────
static void watchdog_cb(void* arg) {
ESP_LOGE(TAG, "Watchdog expired -- new firmware did not call ota_server_mark_valid(), rolling back");
esp_ota_mark_app_invalid_rollback_and_reboot();
}
static void start_watchdog(void) {
const esp_timer_create_args_t args = {
.callback = watchdog_cb,
.name = "ota_watchdog",
};
esp_timer_create(&args, &s_watchdog);
esp_timer_start_once(s_watchdog, (int64_t)OTA_WATCHDOG_SECS * 1000000);
ESP_LOGI(TAG, "OTA watchdog started (%ds to mark valid)", OTA_WATCHDOG_SECS);
}
// ─── Public API ───────────────────────────────────────────────────────────────
esp_err_t ota_server_init(void) {
// Check if we booted from an OTA partition that needs validation
const esp_partition_t* running = esp_ota_get_running_partition();
esp_ota_img_states_t ota_state;
if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) {
if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
ESP_LOGW(TAG, "Running unvalidated OTA firmware -- starting watchdog");
start_watchdog();
}
}
// HTTP server config
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = OTA_SERVER_PORT;
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;
esp_err_t err = httpd_start(&s_server, &config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server: %s", esp_err_to_name(err));
return err;
}
// Register URI handlers
static const httpd_uri_t uris[] = {
{ .uri = "/version", .method = HTTP_GET, .handler = handle_version },
{ .uri = "/status", .method = HTTP_GET, .handler = handle_status },
{ .uri = "/ota", .method = HTTP_POST, .handler = handle_ota_upload },
{ .uri = "/ota/status", .method = HTTP_GET, .handler = handle_ota_status },
{ .uri = "/ota/rollback", .method = HTTP_POST, .handler = handle_ota_rollback },
};
for (int i = 0; i < (int)(sizeof(uris) / sizeof(uris[0])); i++) {
err = httpd_register_uri_handler(s_server, &uris[i]);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to register URI %s: %s", uris[i].uri, esp_err_to_name(err));
return err;
}
}
ESP_LOGI(TAG, "OTA server started on port %d (%s v%s)",
OTA_SERVER_PORT, OTA_FIRMWARE_NAME, OTA_FIRMWARE_VERSION);
return ESP_OK;
}
void ota_server_mark_valid(void) {
if (s_watchdog) {
esp_timer_stop(s_watchdog);
esp_timer_delete(s_watchdog);
s_watchdog = NULL;
ESP_LOGI(TAG, "OTA watchdog cancelled -- firmware marked valid");
}
esp_ota_mark_app_valid_cancel_rollback();
}
const ota_status_t* ota_server_get_status(void) {
return &s_status;
}
void ota_server_set_complete_cb(void (*cb)(bool success)) {
s_complete_cb = cb;
}
httpd_handle_t ota_server_get_handle(void) {
return s_server;
}
@@ -0,0 +1,8 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xf000, 0x2000,
phy_init, data, phy, 0x11000, 0x1000,
factory, app, factory, 0x20000, 1500K,
ota_0, app, ota_0, , 1500K,
ota_1, app, ota_1, , 1500K,
spiffs, data, spiffs, , 512K,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 otadata data ota 0xf000 0x2000
4 phy_init data phy 0x11000 0x1000
5 factory app factory 0x20000 1500K
6 ota_0 app ota_0 1500K
7 ota_1 app ota_1 1500K
8 spiffs data spiffs 512K
@@ -0,0 +1,10 @@
idf_component_register(
SRCS
"p5_morse.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_driver_gpio
log
freertos
)
@@ -0,0 +1,68 @@
// p5_morse.h — Morse / telegraph-key puzzle driver for Zacus master (P5).
//
// When CONFIG_ZACUS_P5_MORSE_ENABLE=n (default) all functions are empty stubs
// (init returns ESP_OK, arm/disarm are no-ops) so callers need no #ifdef guards.
//
// When enabled:
// 1. Call p5_morse_init() once at boot (after gpio driver is available).
// 2. Call p5_morse_arm(expected, puzzle_id, fragment, frag_len) when the
// gateway POSTs /game/step with a "morse" puzzle.
// 3. A FreeRTOS task decodes dot/dash timing on CONFIG_ZACUS_P5_GPIO and
// calls the solved_cb supplied to p5_morse_arm() when the decoded word
// matches `expected`.
// 4. Call p5_morse_disarm() on step change or game reset.
//
// Timing constants are set via Kconfig (DOT_MS, DASH_MS, GAP_MS).
// The key is active-LOW by default (CONFIG_ZACUS_P5_ACTIVE_LOW=y).
#pragma once
#include "esp_err.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Callback type invoked on the p5_morse task when the sequence is solved.
// Must be ISR-safe in the sense that it runs inside the morse task context;
// do NOT call p5_morse_arm/disarm from it — defer via a task notification.
typedef void (*p5_morse_solved_cb_t)(void);
/**
* @brief Initialise the P5 Morse puzzle driver.
*
* Configures CONFIG_ZACUS_P5_GPIO as input with pull-up and spawns the
* morse decoding task (blocked until p5_morse_arm() is called).
* Idempotent — safe to call more than once.
*
* When CONFIG_ZACUS_P5_MORSE_ENABLE=n returns ESP_OK immediately.
*
* @return ESP_OK on success, or a driver error code.
*/
esp_err_t p5_morse_init(void);
/**
* @brief Arm the morse puzzle for a specific expected sequence.
*
* @param expected NUL-terminated Morse code string to match,
* e.g. "...-" (upper-case letters via kMorseTable)
* OR a plain word like "ZACUS" (decoded internally).
* The string is copied — caller buffer may be transient.
* @param solved_cb Callback invoked exactly once when the sequence matches.
* Pass NULL to skip the notification (e.g. during tests).
*
* When CONFIG_ZACUS_P5_MORSE_ENABLE=n this is a no-op.
*/
void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb);
/**
* @brief Disarm the puzzle (stop decoding, reset state).
*
* When CONFIG_ZACUS_P5_MORSE_ENABLE=n this is a no-op.
*/
void p5_morse_disarm(void);
#ifdef __cplusplus
}
#endif
+265
View File
@@ -0,0 +1,265 @@
// p5_morse.c — P5 Morse / telegraph-key puzzle driver.
//
// All real implementation is inside #if CONFIG_ZACUS_P5_MORSE_ENABLE so the
// object compiles to pure stubs when the flag is off (default). No #ifdef
// leaks into callers — they include p5_morse.h and call unconditionally.
//
// Logic adapted from puzzles/p5_morse/main/main.c (standalone ESP-NOW
// firmware), repackaged as a library with an arm/disarm API and a solved
// callback instead of the espnow_slave_notify_solved path.
#include "p5_morse.h"
#include "sdkconfig.h"
#include "esp_log.h"
#include "esp_err.h"
static const char *TAG = "p5_morse";
#if CONFIG_ZACUS_P5_MORSE_ENABLE
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#include <stdbool.h>
// ─── Morse table A-Z ────────────────────────────────────────────────────────
static const char *kMorseTable[26] = {
".-", "-...", "-.-.", "-..", ".", "..-.", "--.",
"....", "..", ".---", "-.-", ".-..", "--", "-.",
"---", ".--.", "--.-", ".-.", "...", "-", "..-",
"...-", ".--", "-..-", "-.--", "--.."
};
// ─── State ──────────────────────────────────────────────────────────────────
// Expected word (decoded as letters, uppercase, e.g. "ZACUS").
static char s_expected[32] = {0};
// Accumulator for letters decoded so far.
static char s_received[32] = {0};
static uint8_t s_recv_pos = 0;
// Current symbol dot/dash buffer.
static char s_symbol[16] = {0};
static uint8_t s_sym_pos = 0;
static volatile bool s_armed = false;
static volatile bool s_solved = false;
static p5_morse_solved_cb_t s_solved_cb = NULL;
static bool s_initialised = false;
static TaskHandle_t s_task_handle = NULL;
// ─── Helpers ────────────────────────────────────────────────────────────────
static uint32_t now_ms(void)
{
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
static bool key_pressed(void)
{
#if CONFIG_ZACUS_P5_ACTIVE_LOW
return (gpio_get_level(CONFIG_ZACUS_P5_GPIO) == 0);
#else
return (gpio_get_level(CONFIG_ZACUS_P5_GPIO) != 0);
#endif
}
// Decode accumulated symbol (dot/dash string) to a letter and append it.
static void decode_symbol(void)
{
if (s_sym_pos == 0) return;
s_symbol[s_sym_pos] = '\0';
for (int i = 0; i < 26; i++) {
if (strcmp(kMorseTable[i], s_symbol) == 0) {
char letter = (char)('A' + i);
if (s_recv_pos < (uint8_t)(sizeof(s_received) - 1)) {
s_received[s_recv_pos++] = letter;
s_received[s_recv_pos] = '\0';
}
ESP_LOGD(TAG, "decoded: %s → %c (so far: %s)",
s_symbol, letter, s_received);
break;
}
}
s_sym_pos = 0;
memset(s_symbol, 0, sizeof(s_symbol));
}
// Check whether the received word matches; invoke callback on match.
static void check_word(void)
{
if (strcmp(s_received, s_expected) == 0) {
s_solved = true;
ESP_LOGI(TAG, "SOLVED: \"%s\" decoded correctly", s_expected);
if (s_solved_cb) {
s_solved_cb();
}
} else {
ESP_LOGW(TAG, "wrong word: got \"%s\", expected \"%s\" — resetting",
s_received, s_expected);
s_recv_pos = 0;
memset(s_received, 0, sizeof(s_received));
}
}
// ─── Morse decoding task ─────────────────────────────────────────────────────
static void morse_task(void *arg)
{
(void)arg;
uint32_t press_start = 0;
uint32_t last_release = 0;
bool key_down = false;
for (;;) {
if (!s_armed || s_solved) {
// Idle: yield and wait for the next arm cycle.
vTaskDelay(pdMS_TO_TICKS(50));
continue;
}
uint32_t t = now_ms();
bool pressed = key_pressed();
if (pressed && !key_down) {
// Key press start.
key_down = true;
press_start = t;
} else if (!pressed && key_down) {
// Key release — classify as dot or dash.
key_down = false;
uint32_t duration = t - press_start;
last_release = t;
if (duration <= (uint32_t)CONFIG_ZACUS_P5_DOT_MS) {
if (s_sym_pos < (uint8_t)(sizeof(s_symbol) - 1))
s_symbol[s_sym_pos++] = '.';
ESP_LOGD(TAG, "dot (%lu ms)", (unsigned long)duration);
} else if (duration >= (uint32_t)CONFIG_ZACUS_P5_DASH_MS) {
if (s_sym_pos < (uint8_t)(sizeof(s_symbol) - 1))
s_symbol[s_sym_pos++] = '-';
ESP_LOGD(TAG, "dash (%lu ms)", (unsigned long)duration);
} else {
ESP_LOGD(TAG, "ignored press %lu ms (between dot and dash thresholds)",
(unsigned long)duration);
}
} else if (!key_down && last_release > 0) {
uint32_t silence = t - last_release;
// Letter gap: decode accumulated symbol.
if (silence > (uint32_t)CONFIG_ZACUS_P5_GAP_MS && s_sym_pos > 0) {
decode_symbol();
last_release = t;
}
// Word gap (2x letter gap): check assembled word.
if (silence > (uint32_t)(CONFIG_ZACUS_P5_GAP_MS * 2u) && s_recv_pos > 0) {
check_word();
last_release = 0;
}
}
vTaskDelay(pdMS_TO_TICKS(10)); // 100 Hz sampling
}
}
// ─── Public API ─────────────────────────────────────────────────────────────
esp_err_t p5_morse_init(void)
{
if (s_initialised) return ESP_OK;
gpio_config_t cfg = {
.pin_bit_mask = (1ULL << CONFIG_ZACUS_P5_GPIO),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t err = gpio_config(&cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "gpio_config(GPIO %d): %s",
CONFIG_ZACUS_P5_GPIO, esp_err_to_name(err));
return err;
}
BaseType_t rc = xTaskCreate(morse_task, "p5_morse", 3072, NULL, 5,
&s_task_handle);
if (rc != pdPASS) {
ESP_LOGE(TAG, "xTaskCreate(p5_morse) failed");
return ESP_ERR_NO_MEM;
}
s_initialised = true;
ESP_LOGI(TAG, "init OK (GPIO %d, dot<=%d ms, dash>=%d ms, gap>%d ms)",
CONFIG_ZACUS_P5_GPIO,
CONFIG_ZACUS_P5_DOT_MS,
CONFIG_ZACUS_P5_DASH_MS,
CONFIG_ZACUS_P5_GAP_MS);
return ESP_OK;
}
void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb)
{
if (!s_initialised) {
ESP_LOGW(TAG, "arm called before init — ignored");
return;
}
// Reset state.
s_armed = false; // pause task briefly while we reset
s_solved = false;
s_recv_pos = 0;
s_sym_pos = 0;
memset(s_received, 0, sizeof(s_received));
memset(s_symbol, 0, sizeof(s_symbol));
if (expected && expected[0]) {
strncpy(s_expected, expected, sizeof(s_expected) - 1);
s_expected[sizeof(s_expected) - 1] = '\0';
} else {
s_expected[0] = '\0';
}
s_solved_cb = solved_cb;
s_armed = true;
ESP_LOGI(TAG, "armed, expected \"%s\"", s_expected);
}
void p5_morse_disarm(void)
{
s_armed = false;
s_solved = false;
s_recv_pos = 0;
s_sym_pos = 0;
memset(s_received, 0, sizeof(s_received));
memset(s_symbol, 0, sizeof(s_symbol));
ESP_LOGI(TAG, "disarmed");
}
#else // CONFIG_ZACUS_P5_MORSE_ENABLE not set — emit stubs only
esp_err_t p5_morse_init(void)
{
return ESP_OK;
}
void p5_morse_arm(const char *expected, p5_morse_solved_cb_t solved_cb)
{
(void)expected;
(void)solved_cb;
/* stub — CONFIG_ZACUS_P5_MORSE_ENABLE=n */
}
void p5_morse_disarm(void)
{
/* stub — CONFIG_ZACUS_P5_MORSE_ENABLE=n */
}
#endif // CONFIG_ZACUS_P5_MORSE_ENABLE
@@ -0,0 +1,12 @@
idf_component_register(
SRCS
"p6_nfc.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_driver_gpio
esp_driver_i2c
esp_driver_spi
log
freertos
)
@@ -0,0 +1,72 @@
// p6_nfc.h — NFC / alchemical-symbols puzzle driver for Zacus master (P6).
//
// When CONFIG_ZACUS_P6_NFC_ENABLE=n (default) all functions are empty stubs
// (init returns ESP_OK, arm/disarm are no-ops) so callers need no #ifdef guards.
//
// When enabled:
// 1. Call p6_nfc_init() once at boot.
// 2. Call p6_nfc_arm(expected_uid, solved_cb) when the gateway POSTs
// /game/step with a "nfc" puzzle. `expected_uid` is a hex UID string
// like "A1:B2:C3:D4" (4-byte NTAG213) or a space-separated sequence
// of UIDs for ordered multi-tag placement.
// 3. A FreeRTOS task polls the NFC reader at ~5 Hz and calls solved_cb
// when the presented tag UID matches (or the full sequence is placed).
// 4. Call p6_nfc_disarm() on step change or game reset.
//
// Hardware backend is selected via Kconfig:
// PN532 over I2C or SPI → CONFIG_ZACUS_P6_READER_PN532
// PN7150 over I2C → CONFIG_ZACUS_P6_READER_PN7150
//
// The hardware HAL is STUBBED in this scaffold revision: the read function
// compiles, links, and logs "NFC read (stub)" but returns no UIDs until the
// real HAL is wired in. This is the same pattern as media_manager stubs.
#pragma once
#include "esp_err.h"
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Callback type invoked on the p6_nfc task when the tag / sequence is solved.
// Must not call p6_nfc_arm/disarm from within — defer via task notification.
typedef void (*p6_nfc_solved_cb_t)(void);
/**
* @brief Initialise the P6 NFC puzzle driver.
*
* Initialises the configured bus (I2C or SPI) and the NFC reader IC.
* Spawns the NFC polling task (blocked until p6_nfc_arm() is called).
* Idempotent — safe to call more than once.
*
* When CONFIG_ZACUS_P6_NFC_ENABLE=n returns ESP_OK immediately.
*
* @return ESP_OK on success, or a driver error code.
*/
esp_err_t p6_nfc_init(void);
/**
* @brief Arm the NFC puzzle for a specific expected tag UID or sequence.
*
* @param expected_uid NUL-terminated UID string to match. Format:
* single tag → "A1:B2:C3:D4"
* The string is copied — caller buffer may be transient.
* @param solved_cb Callback invoked exactly once on a match.
* Pass NULL to skip (e.g. during tests).
*
* When CONFIG_ZACUS_P6_NFC_ENABLE=n this is a no-op.
*/
void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb);
/**
* @brief Disarm the puzzle (stop polling, reset state).
*
* When CONFIG_ZACUS_P6_NFC_ENABLE=n this is a no-op.
*/
void p6_nfc_disarm(void);
#ifdef __cplusplus
}
#endif
+290
View File
@@ -0,0 +1,290 @@
// p6_nfc.c — P6 NFC / alchemical-symbols puzzle driver.
//
// All real implementation is inside #if CONFIG_ZACUS_P6_NFC_ENABLE so the
// object compiles to pure stubs when the flag is off (default). No #ifdef
// leaks into callers.
//
// Hardware HAL for the NFC reader is STUBBED: nfc_hal_read_uid() returns
// false (no card present) and logs once per arm cycle. The real HAL will be
// implemented when the PN532/PN7150 module is wired (replace the stub block
// marked with "STUB" below).
//
// Logic adapted from puzzles/p6_symboles_nfc/main/main.c, repackaged as a
// library with arm/disarm API and a solved callback.
#include "p6_nfc.h"
#include "sdkconfig.h"
#include "esp_log.h"
#include "esp_err.h"
static const char *TAG = "p6_nfc";
#if CONFIG_ZACUS_P6_NFC_ENABLE
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#include <stdbool.h>
#include <stdio.h>
// ─── I2C / SPI bus init (HAL layer) ─────────────────────────────────────────
//
// The bus init and NFC read are separated into nfc_hal_init() /
// nfc_hal_read_uid() so the stub is easy to swap for real silicon code.
#if CONFIG_ZACUS_P6_BUS_I2C
#include "driver/i2c_master.h"
static i2c_master_bus_handle_t s_i2c_bus = NULL;
static i2c_master_dev_handle_t s_i2c_dev = NULL;
static esp_err_t nfc_hal_init(void)
{
i2c_master_bus_config_t bus_cfg = {
.i2c_port = I2C_NUM_0,
.sda_io_num = CONFIG_ZACUS_P6_I2C_SDA,
.scl_io_num = CONFIG_ZACUS_P6_I2C_SCL,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
esp_err_t err = i2c_new_master_bus(&bus_cfg, &s_i2c_bus);
if (err != ESP_OK) {
ESP_LOGE(TAG, "i2c_new_master_bus: %s", esp_err_to_name(err));
return err;
}
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = (uint16_t)CONFIG_ZACUS_P6_I2C_ADDR,
.scl_speed_hz = 100000,
};
err = i2c_master_bus_add_device(s_i2c_bus, &dev_cfg, &s_i2c_dev);
if (err != ESP_OK) {
ESP_LOGE(TAG, "i2c_master_bus_add_device(addr=0x%02X): %s",
CONFIG_ZACUS_P6_I2C_ADDR, esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "I2C bus init OK (SDA=%d SCL=%d addr=0x%02X)",
CONFIG_ZACUS_P6_I2C_SDA, CONFIG_ZACUS_P6_I2C_SCL,
CONFIG_ZACUS_P6_I2C_ADDR);
return ESP_OK;
}
// STUB: real I2C read of PN532/PN7150 comes here.
// Returns true and fills uid_out[4] when a card is present.
static bool nfc_hal_read_uid(uint8_t uid_out[4])
{
(void)uid_out;
// HAL stub — hardware not yet wired. Replace with PN532/PN7150 I2C
// read sequence once the module is connected.
ESP_LOGD(TAG, "NFC read (stub, I2C 0x%02X)", CONFIG_ZACUS_P6_I2C_ADDR);
return false;
}
#elif CONFIG_ZACUS_P6_BUS_SPI
#include "driver/spi_master.h"
static spi_device_handle_t s_spi = NULL;
static esp_err_t nfc_hal_init(void)
{
spi_bus_config_t buscfg = {
.miso_io_num = 13, // default SPI2 pins — override via sdkconfig if needed
.mosi_io_num = 11,
.sclk_io_num = 12,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 64,
};
// Use SPI3_HOST to avoid conflict with possible SD card on SPI2.
esp_err_t err = spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO);
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "spi_bus_initialize(SPI3): %s", esp_err_to_name(err));
return err;
}
spi_device_interface_config_t devcfg = {
.clock_speed_hz = 1000000, // 1 MHz (PN532 max SPI is 5 MHz)
.mode = 0,
.spics_io_num = 10, // CS pin — adjust via Kconfig when available
.queue_size = 4,
};
err = spi_bus_add_device(SPI3_HOST, &devcfg, &s_spi);
if (err != ESP_OK) {
ESP_LOGE(TAG, "spi_bus_add_device: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "SPI bus init OK (SPI3, CS=10)");
return ESP_OK;
}
// STUB: real SPI read of PN532 comes here.
static bool nfc_hal_read_uid(uint8_t uid_out[4])
{
(void)uid_out;
ESP_LOGD(TAG, "NFC read (stub, SPI)");
return false;
}
#endif // bus type
// ─── UID comparison helper ───────────────────────────────────────────────────
// Parse a colon-separated hex UID string "A1:B2:C3:D4" into uid[4].
// Returns true on success.
static bool parse_uid(const char *str, uint8_t uid[4])
{
unsigned int b[4];
if (sscanf(str, "%x:%x:%x:%x", &b[0], &b[1], &b[2], &b[3]) != 4)
return false;
for (int i = 0; i < 4; i++) uid[i] = (uint8_t)b[i];
return true;
}
// ─── State ──────────────────────────────────────────────────────────────────
static char s_expected_uid[20] = {0}; // "A1:B2:C3:D4\0"
static uint8_t s_expected_raw[4] = {0};
static bool s_expected_valid = false;
static volatile bool s_armed = false;
static volatile bool s_solved = false;
static p6_nfc_solved_cb_t s_solved_cb = NULL;
static bool s_initialised = false;
// ─── NFC polling task ────────────────────────────────────────────────────────
static void nfc_task(void *arg)
{
(void)arg;
uint8_t uid[4] = {0};
uint8_t last_uid[4] = {0};
uint32_t last_read_ms = 0;
for (;;) {
if (!s_armed || s_solved) {
vTaskDelay(pdMS_TO_TICKS(200));
continue;
}
uint32_t t = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
if (nfc_hal_read_uid(uid)) {
// Debounce: ignore same tag within 1 s.
bool same = (memcmp(uid, last_uid, 4) == 0);
if (!same || (t - last_read_ms) > 1000) {
memcpy(last_uid, uid, 4);
last_read_ms = t;
ESP_LOGI(TAG, "tag read: %02X:%02X:%02X:%02X",
uid[0], uid[1], uid[2], uid[3]);
if (s_expected_valid &&
memcmp(uid, s_expected_raw, 4) == 0) {
s_solved = true;
ESP_LOGI(TAG, "SOLVED: tag %s matched", s_expected_uid);
if (s_solved_cb) {
s_solved_cb();
}
} else {
ESP_LOGW(TAG, "tag %02X:%02X:%02X:%02X not the expected %s",
uid[0], uid[1], uid[2], uid[3], s_expected_uid);
}
}
}
vTaskDelay(pdMS_TO_TICKS(200)); // 5 Hz polling
}
}
// ─── Public API ─────────────────────────────────────────────────────────────
esp_err_t p6_nfc_init(void)
{
if (s_initialised) return ESP_OK;
esp_err_t err = nfc_hal_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "nfc_hal_init failed: %s", esp_err_to_name(err));
return err;
}
BaseType_t rc = xTaskCreate(nfc_task, "p6_nfc", 3072, NULL, 5, NULL);
if (rc != pdPASS) {
ESP_LOGE(TAG, "xTaskCreate(p6_nfc) failed");
return ESP_ERR_NO_MEM;
}
s_initialised = true;
#if CONFIG_ZACUS_P6_READER_PN532 && CONFIG_ZACUS_P6_BUS_I2C
ESP_LOGI(TAG, "init OK (PN532, I2C, stub HAL)");
#elif CONFIG_ZACUS_P6_READER_PN532
ESP_LOGI(TAG, "init OK (PN532, SPI, stub HAL)");
#else
ESP_LOGI(TAG, "init OK (PN7150, I2C, stub HAL)");
#endif
return ESP_OK;
}
void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb)
{
if (!s_initialised) {
ESP_LOGW(TAG, "arm called before init — ignored");
return;
}
s_armed = false;
s_solved = false;
s_expected_valid = false;
memset(s_expected_uid, 0, sizeof(s_expected_uid));
memset(s_expected_raw, 0, sizeof(s_expected_raw));
if (expected_uid && expected_uid[0]) {
strncpy(s_expected_uid, expected_uid, sizeof(s_expected_uid) - 1);
s_expected_uid[sizeof(s_expected_uid) - 1] = '\0';
s_expected_valid = parse_uid(s_expected_uid, s_expected_raw);
if (!s_expected_valid) {
ESP_LOGW(TAG, "arm: could not parse UID \"%s\" — tag match disabled",
expected_uid);
}
}
s_solved_cb = solved_cb;
s_armed = true;
ESP_LOGI(TAG, "armed, expected UID \"%s\"", s_expected_uid);
}
void p6_nfc_disarm(void)
{
s_armed = false;
s_solved = false;
ESP_LOGI(TAG, "disarmed");
}
#else // CONFIG_ZACUS_P6_NFC_ENABLE not set — emit stubs only
esp_err_t p6_nfc_init(void)
{
return ESP_OK;
}
void p6_nfc_arm(const char *expected_uid, p6_nfc_solved_cb_t solved_cb)
{
(void)expected_uid;
(void)solved_cb;
/* stub — CONFIG_ZACUS_P6_NFC_ENABLE=n */
}
void p6_nfc_disarm(void)
{
/* stub — CONFIG_ZACUS_P6_NFC_ENABLE=n */
}
#endif // CONFIG_ZACUS_P6_NFC_ENABLE
@@ -0,0 +1,11 @@
idf_component_register(
SRCS
"p7_coffre.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_driver_ledc
esp_driver_gpio
log
freertos
)
@@ -0,0 +1,60 @@
// p7_coffre — unlocking actuator for the Zacus final chest (P7).
//
// When CONFIG_ZACUS_P7_COFFRE_ENABLE=n (default) all three functions are
// empty stubs (init returns ESP_OK, unlock/lock are no-ops) so callers do
// not need #ifdef guards.
//
// When enabled, call p7_coffre_init() once at boot, then p7_coffre_unlock()
// when STEP_FINAL_WIN is reached. p7_coffre_lock() re-arms between games.
//
// Actuator selection and GPIO are configured via Kconfig (menuconfig or
// sdkconfig):
// SERVO LEDC TIMER_2 / CHANNEL_2, 50 Hz, 13-bit duty cycle.
// RELAY plain GPIO output with optional one-shot pulse.
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Initialise the P7 coffre actuator.
*
* Servo: configures LEDC timer + channel and moves to the locked position.
* Relay: configures the GPIO as output and asserts the rest (inactive) state.
* Idempotent — safe to call more than once.
*
* When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a stub that returns ESP_OK.
*
* @return ESP_OK on success, or a driver error code.
*/
esp_err_t p7_coffre_init(void);
/**
* @brief Unlock the coffre (STEP_FINAL_WIN reached).
*
* Servo: moves to CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG.
* Relay: energises the coil; if CONFIG_ZACUS_P7_RELAY_PULSE_MS > 0 the coil
* is de-energised after that many milliseconds (blocking call on the
* caller's task — keep short; 800 ms default).
*
* When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a no-op.
*/
void p7_coffre_unlock(void);
/**
* @brief Re-arm the coffre to the locked position (between games).
*
* Servo: moves back to CONFIG_ZACUS_P7_SERVO_LOCK_DEG.
* Relay: de-energises the coil (if not already de-energised by the pulse).
*
* When CONFIG_ZACUS_P7_COFFRE_ENABLE=n this is a no-op.
*/
void p7_coffre_lock(void);
#ifdef __cplusplus
}
#endif
+232
View File
@@ -0,0 +1,232 @@
// p7_coffre — P7 coffre unlocking actuator driver.
//
// All real implementation is inside #if CONFIG_ZACUS_P7_COFFRE_ENABLE so the
// object compiles to pure stubs when the flag is off (default). No #ifdef
// leaks into callers — they include p7_coffre.h and call the three functions
// unconditionally.
#include "p7_coffre.h"
#include "sdkconfig.h"
#include "esp_log.h"
#include "esp_err.h"
static const char *TAG = "p7_coffre";
// ─── Servo duty helper ──────────────────────────────────────────────────────
//
// Maps an angle in degrees [0, 180] to a LEDC duty value.
//
// Timer parameters (compile-time constants):
// Period : 20 ms (50 Hz)
// Pulse range : 500 µs (0°) … 2500 µs (180°) — standard SG90 / MG90S
// Resolution : 13 bits → 8192 counts per 20 ms
//
// counts_per_us = 8192 / 20000 = 0.4096
// duty(0°) = 500 * 0.4096 = 205
// duty(90°) = 1500 * 0.4096 = 614
// duty(180°) = 2500 * 0.4096 = 1024
#if CONFIG_ZACUS_P7_COFFRE_ENABLE
#include "driver/ledc.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
// LEDC resources — chosen to avoid conflicts with existing assignments:
// TIMER_0 / CHANNEL_0 = camera XCLK (qr_puzzle)
// TIMER_1 / CHANNEL_1 = display backlight (display_ui)
// TIMER_2 / CHANNEL_2 = P7 servo (this file)
#define P7_LEDC_TIMER LEDC_TIMER_2
#define P7_LEDC_CHANNEL LEDC_CHANNEL_2
#define P7_LEDC_MODE LEDC_LOW_SPEED_MODE
#define P7_LEDC_RES LEDC_TIMER_13_BIT // 8192 counts per period
#define P7_LEDC_FREQ_HZ 50u // 20 ms period
// counts = pulse_us * (2^13 / 20000)
// Use integer arithmetic: counts = pulse_us * 8192 / 20000
#define P7_SERVO_COUNTS(pulse_us) ((uint32_t)(pulse_us) * 8192u / 20000u)
// Pulse width at each extreme (SG90-compatible).
#define P7_SERVO_PULSE_MIN_US 500u // 0°
#define P7_SERVO_PULSE_MAX_US 2500u // 180°
// Convert angle [0, 180] to duty count.
static uint32_t angle_to_duty(int deg) {
if (deg < 0) deg = 0;
if (deg > 180) deg = 180;
uint32_t pulse_us = P7_SERVO_PULSE_MIN_US +
(uint32_t) deg *
(P7_SERVO_PULSE_MAX_US - P7_SERVO_PULSE_MIN_US) / 180u;
return P7_SERVO_COUNTS(pulse_us);
}
static bool s_initialised = false;
// ─── Servo path ─────────────────────────────────────────────────────────────
#if CONFIG_ZACUS_P7_ACTUATOR_SERVO
static esp_err_t servo_set_angle(int deg) {
uint32_t duty = angle_to_duty(deg);
esp_err_t err = ledc_set_duty(P7_LEDC_MODE, P7_LEDC_CHANNEL, duty);
if (err != ESP_OK) return err;
return ledc_update_duty(P7_LEDC_MODE, P7_LEDC_CHANNEL);
}
esp_err_t p7_coffre_init(void) {
if (s_initialised) return ESP_OK;
// Configure the 50 Hz timer.
ledc_timer_config_t timer_cfg = {
.speed_mode = P7_LEDC_MODE,
.duty_resolution = P7_LEDC_RES,
.timer_num = P7_LEDC_TIMER,
.freq_hz = P7_LEDC_FREQ_HZ,
.clk_cfg = LEDC_AUTO_CLK,
};
esp_err_t err = ledc_timer_config(&timer_cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "ledc_timer_config: %s", esp_err_to_name(err));
return err;
}
// Bind the channel to the GPIO at the lock angle.
uint32_t lock_duty = angle_to_duty(CONFIG_ZACUS_P7_SERVO_LOCK_DEG);
ledc_channel_config_t ch_cfg = {
.gpio_num = CONFIG_ZACUS_P7_GPIO,
.speed_mode = P7_LEDC_MODE,
.channel = P7_LEDC_CHANNEL,
.intr_type = LEDC_INTR_DISABLE,
.timer_sel = P7_LEDC_TIMER,
.duty = lock_duty,
.hpoint = 0,
};
err = ledc_channel_config(&ch_cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "ledc_channel_config: %s", esp_err_to_name(err));
return err;
}
s_initialised = true;
ESP_LOGI(TAG, "servo init OK (GPIO %d, lock=%d°, unlock=%d°, "
"LEDC TIMER_%d/CH_%d 50 Hz 13-bit)",
CONFIG_ZACUS_P7_GPIO,
CONFIG_ZACUS_P7_SERVO_LOCK_DEG,
CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG,
(int) P7_LEDC_TIMER, (int) P7_LEDC_CHANNEL);
return ESP_OK;
}
void p7_coffre_unlock(void) {
if (!s_initialised) {
ESP_LOGW(TAG, "unlock called before init — ignored");
return;
}
esp_err_t err = servo_set_angle(CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG);
if (err != ESP_OK) {
ESP_LOGE(TAG, "servo_set_angle(%d): %s",
CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG, esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "coffre UNLOCKED (servo -> %d deg)",
CONFIG_ZACUS_P7_SERVO_UNLOCK_DEG);
}
}
void p7_coffre_lock(void) {
if (!s_initialised) return;
esp_err_t err = servo_set_angle(CONFIG_ZACUS_P7_SERVO_LOCK_DEG);
if (err != ESP_OK) {
ESP_LOGE(TAG, "servo_set_angle(%d): %s",
CONFIG_ZACUS_P7_SERVO_LOCK_DEG, esp_err_to_name(err));
} else {
ESP_LOGI(TAG, "coffre re-LOCKED (servo -> %d deg)",
CONFIG_ZACUS_P7_SERVO_LOCK_DEG);
}
}
#endif // CONFIG_ZACUS_P7_ACTUATOR_SERVO
// ─── Relay path ─────────────────────────────────────────────────────────────
#if CONFIG_ZACUS_P7_ACTUATOR_RELAY
// Helper: set the relay coil to active (energised) or rest.
// Handles active-high vs active-low inversion.
static void relay_set(bool active) {
int level;
#if CONFIG_ZACUS_P7_RELAY_ACTIVE_HIGH
level = active ? 1 : 0;
#else
level = active ? 0 : 1;
#endif
gpio_set_level(CONFIG_ZACUS_P7_GPIO, level);
}
esp_err_t p7_coffre_init(void) {
if (s_initialised) return ESP_OK;
gpio_config_t cfg = {
.pin_bit_mask = (1ULL << CONFIG_ZACUS_P7_GPIO),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t err = gpio_config(&cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "gpio_config(GPIO %d): %s",
CONFIG_ZACUS_P7_GPIO, esp_err_to_name(err));
return err;
}
// Start in rest (de-energised) state.
relay_set(false);
s_initialised = true;
ESP_LOGI(TAG, "relay init OK (GPIO %d, active-%s, pulse=%d ms)",
CONFIG_ZACUS_P7_GPIO,
CONFIG_ZACUS_P7_RELAY_ACTIVE_HIGH ? "HIGH" : "LOW",
CONFIG_ZACUS_P7_RELAY_PULSE_MS);
return ESP_OK;
}
void p7_coffre_unlock(void) {
if (!s_initialised) {
ESP_LOGW(TAG, "unlock called before init — ignored");
return;
}
relay_set(true);
ESP_LOGI(TAG, "coffre UNLOCKED (relay energised)");
#if CONFIG_ZACUS_P7_RELAY_PULSE_MS > 0
vTaskDelay(pdMS_TO_TICKS(CONFIG_ZACUS_P7_RELAY_PULSE_MS));
relay_set(false);
ESP_LOGI(TAG, "coffre relay de-energised after %d ms pulse",
CONFIG_ZACUS_P7_RELAY_PULSE_MS);
#endif
}
void p7_coffre_lock(void) {
if (!s_initialised) return;
relay_set(false);
ESP_LOGI(TAG, "coffre relay de-energised (lock/re-arm)");
}
#endif // CONFIG_ZACUS_P7_ACTUATOR_RELAY
#else // CONFIG_ZACUS_P7_COFFRE_ENABLE not set — emit stubs only
esp_err_t p7_coffre_init(void) {
return ESP_OK;
}
void p7_coffre_unlock(void) {
/* stub — CONFIG_ZACUS_P7_COFFRE_ENABLE=n */
}
void p7_coffre_lock(void) {
/* stub — CONFIG_ZACUS_P7_COFFRE_ENABLE=n */
}
#endif // CONFIG_ZACUS_P7_COFFRE_ENABLE
@@ -0,0 +1,4 @@
idf_component_register(
SRCS "puzzle_state.c"
INCLUDE_DIRS "include"
)
@@ -0,0 +1,30 @@
# puzzle_state
Agrégation côté master des énigmes résolues. Chaque énigme reporte un fragment
de code ; `puzzle_state` assemble le **code final** en concaténant les fragments
de toutes les énigmes résolues, **par id croissant**.
Logique pure, sans I/O — testée sur hôte.
## API (`puzzle_state.h`)
- **`puzzle_state_init(s)`** — remet l'état à zéro.
- **`puzzle_state_report(s, id, fragment, len)`** — enregistre une énigme
résolue. **Idempotent par id** : reporter deux fois le même id ne duplique pas
ses chiffres. `len <= PUZZLE_MAX_FRAG` ; `fragment` non-NULL si `len > 0`. Un
fragment de `len == 0` est valide (énigme marquée résolue, aucun chiffre).
- **`puzzle_state_code(s, out, cap)`** — écrit le code assemblé (chiffres de
toutes les énigmes résolues, id croissant) en chaîne NUL-terminée ; renvoie le
nombre de chiffres écrits. Chaque octet de fragment est pris **modulo 10** pour
donner un chiffre décimal. `cap == 0` n'écrit rien et renvoie 0.
Constantes : `PUZZLE_MAX_ID` = 8, `PUZZLE_MAX_FRAG` = 4.
## Tests hôte
```bash
make -C idf_zacus/components/puzzle_state/test/host test
```
2 tests : assemblage du code à partir des fragments reportés, et idempotence
(une énigme reportée deux fois ne duplique pas ses chiffres). Nécessite Unity.
@@ -0,0 +1,28 @@
// puzzle_state.h — master-side aggregation of solved puzzles + assembled code.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define PUZZLE_MAX_ID 8
#define PUZZLE_MAX_FRAG 4
typedef struct {
bool solved[PUZZLE_MAX_ID + 1];
uint8_t frag[PUZZLE_MAX_ID + 1][PUZZLE_MAX_FRAG];
uint8_t frag_len[PUZZLE_MAX_ID + 1];
} puzzle_state_t;
void puzzle_state_init(puzzle_state_t *s);
// Record a solved puzzle. Idempotent per id. `len` <= PUZZLE_MAX_FRAG.
// `fragment` pointer must be non-NULL when len > 0. No internal NULL checks.
// A len == 0 fragment is valid — the puzzle is marked solved and contributes no digits.
void puzzle_state_report(puzzle_state_t *s, uint8_t id,
const uint8_t *fragment, uint8_t len);
// Write the assembled code (digits of all solved puzzles, by ascending id)
// as a NUL-terminated string. Returns the number of digits written.
// Each fragment byte is taken modulo 10 to produce a decimal digit.
// If cap == 0, nothing is written and 0 is returned.
int puzzle_state_code(const puzzle_state_t *s, char *out, size_t cap);

Some files were not shown because too many files have changed in this diff Show More