Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd7af95bcf | |||
| 5519ed2f72 | |||
| a4efce4c20 | |||
| a8af29068b | |||
| c2527a0a66 | |||
| 0027970907 | |||
| a70963f64a | |||
| f58b090a34 | |||
| f3d03c637a | |||
| 39e0498221 | |||
| da7b6ace9c | |||
| be369fa260 | |||
| ace740a629 | |||
| 4ac3981845 | |||
| 653a299ea4 | |||
| 54022ed6cc | |||
| cfe429d885 | |||
| 82759ee536 | |||
| 8c076d81d6 | |||
| 99d79efcdd | |||
| 8dffe14972 | |||
| d71453d32e | |||
| ccc84da785 | |||
| 1561e613e1 | |||
| 4a6fc435a7 | |||
| ebcfb011d3 | |||
| 3c0eb75465 | |||
| aa7ae277ed | |||
| 37db47ad7b | |||
| f03156b65e | |||
| b36b703f34 | |||
| a209b3b86c | |||
| 8b0bd2c262 | |||
| 54d1a1ec6e | |||
| ccbe5720b0 | |||
| 47fbc09fea | |||
| 0f7047215b | |||
| f531bf3e55 | |||
| 87075ba92b | |||
| 75d6aaf6d4 | |||
| c740bfe20a | |||
| e68e9f5f66 | |||
| 2710965741 | |||
| b92f138f41 | |||
| 4621f451be | |||
| e819eae5a5 | |||
| ba248e92fe | |||
| 39f4cd2313 | |||
| b8ba1457fb | |||
| 7718f4653d | |||
| f7104b4180 | |||
| b748c2d75f | |||
| dd3d239784 | |||
| b31388dd86 | |||
| d5b9297cee | |||
| 038e0a980a | |||
| fc4b58f785 | |||
| 022e343750 | |||
| 875333b6f0 | |||
| e9413fa898 | |||
| 39903c376d | |||
| 309005360b | |||
| bbe79deb07 | |||
| 935f86d844 | |||
| 29fa8eed34 | |||
| 3e702f6fd8 | |||
| 0b530e70e0 | |||
| 735aaed648 | |||
| 7f52d3d759 | |||
| c7485f272f | |||
| fcb872c226 | |||
| 8a58c7d92b | |||
| a8f0210567 | |||
| c35c9673f4 | |||
| 2517cef7db | |||
| 7893c32bf6 | |||
| 31dcb28c62 | |||
| f5d8134cee | |||
| 9c133842a4 | |||
| 1faba591d5 | |||
| ea0e07acbe | |||
| 898808c396 | |||
| a7463464c2 | |||
| 080581f39f | |||
| 6d7b670256 | |||
| fac94d5885 | |||
| ed336967a1 | |||
| 132b88337b | |||
| 859c975654 | |||
| 087dbd3c03 | |||
| e8125a20e5 | |||
| 0e4cea9c39 | |||
| fd1a517989 | |||
| 563dfea368 | |||
| a552e1976e | |||
| d0f217f5d9 | |||
| 29c0eecfb9 | |||
| ff2ab2abb5 | |||
| 2de7b20cd8 | |||
| 21b06665e8 | |||
| 9ac287c843 | |||
| 0aa9271038 | |||
| b66932c800 | |||
| e216490bf3 | |||
| 8eab308185 | |||
| d715d45b34 | |||
| b6a9d62d9a | |||
| 0f0d8405f7 | |||
| 275197139c | |||
| 96f3a9ad65 | |||
| 31c739fd82 | |||
| d9ba124d16 | |||
| 10af7b28b2 | |||
| 585af362f7 | |||
| 87046eadf0 | |||
| 43c2651777 | |||
| b812e059da | |||
| c0ccd8f114 | |||
| 5c97013f8c | |||
| ef1741bd09 | |||
| 185d1186fe | |||
| 593b920354 | |||
| 7fe0bd2af6 | |||
| 13bbb56244 | |||
| c077d2950f | |||
| 7be1961122 | |||
| 17e575ee0e | |||
| b2267f2261 | |||
| d220d94607 | |||
| 2a596b9fcb | |||
| a743cfc87f | |||
| 71bc9a5e37 | |||
| ec6f363606 | |||
| de22adc08c | |||
| d47c23068a | |||
| 3585a65c8f | |||
| 6d3e989ca3 | |||
| c3a771ca8b | |||
| 479e92e952 | |||
| 44a987d8b4 | |||
| 010f7f3d81 | |||
| 64892aa6a3 | |||
| 90a0bd66b2 | |||
| c285e04d07 | |||
| 4bb2c79e37 | |||
| 8a6f4e32d7 |
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
managed_components/
|
||||
dependencies.lock
|
||||
sdkconfig
|
||||
sdkconfig.old
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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, D0–D7 = 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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user