fix(box3): SPIFFS mount + canal WiFi configurable (relais ESP-NOW) #8

Merged
electron merged 39 commits from fix/box3-spiffs-channel into main 2026-06-13 14:44:15 +00:00
71 changed files with 12349 additions and 279 deletions
+1
View File
@@ -62,3 +62,4 @@ dependencies.lock
sdkconfig
.cache/
sdkconfig.old
.worktrees/
+9 -1
View File
@@ -8,10 +8,18 @@ Workspace firmware centré sur une seule cible: Freenove FNK0102H avec `ui_freen
- `docs/FNK0102H_SOURCE_OF_TRUTH.md`: matrice board/config/pins/support.
- `README_ESP32_ZACUS.md`: notes runtime et signatures série utiles.
## Deux firmwares
- `ui_freenove_allinone/` — firmware **Arduino/PlatformIO** historique (UI/audio/caméra/réseau), chemin release stabilisé.
- `idf_zacus/` — firmware **master ESP-IDF 5.4** (migration en cours, branche `feat/idf-migration`) : voix NPC (esp-sr wakeword + bridge WS), **énigmes locales** P1 son / P3 QR (caméra OV3660 + micro), **écran LVGL** (vue scène, viewfinder, shell Workbench, intro cracktro), et une **surface REST de jeu** (`/game/step`, `/game/puzzle_state`, `/game/file`). Voir `idf_zacus/README.md`.
- `box3_voice/` — firmware annexe **ESP32-S3-BOX-3** : pipeline voix + **générateur de stimulus** (`/stim/qr`, `/stim/melody`) pour exercer les énigmes du master. Voir `box3_voice/README.md`.
## Arborescence utile
- `platformio.ini`: env PlatformIO canonique `freenove_esp32s3_full_with_ui`.
- `ui_freenove_allinone/`: firmware UI/audio/caméra/réseau.
- `ui_freenove_allinone/`: firmware Arduino UI/audio/caméra/réseau.
- `idf_zacus/`: firmware master ESP-IDF (énigmes locales, écran LVGL, REST de jeu).
- `box3_voice/`: firmware BOX-3 (voix + générateur de stimulus QR/mélodie).
- `data/`: contenu LittleFS.
- `lib/`: bibliothèques runtime.
- `scripts/`: bootstrap et scripts repo.
+64
View File
@@ -54,3 +54,67 @@ Use `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**:
|
hints engine <--+
```
## Générateur de stimulus (QR + mélodie)
En plus du pipeline vocal, la BOX-3 sert de **source de stimulus** pour le
master Freenove. Elle affiche un QR code (lu par la caméra du master) et joue
une mélodie (entendue par le micro du master). Cela permet de tester les
énigmes locales P1 (son) et P3 (QR) du master de bout en bout, sans QR imprimé
ni instrument de musique.
Les routes sont enregistrées sur le serveur HTTP existant (`scenario_server`)
au démarrage, juste après l'init de l'écran et de l'UI (`stimulus_init()` puis
`stimulus_register_routes()` dans `app_main`).
### `POST /stim/qr`
```json
{"text": "zacus-qr-1"}
```
Affiche le texte en QR plein écran : un widget `lv_qrcode` de **160 px** sur une
page LVGL dédiée, chargée au premier plan. Le rétroéclairage est **atténué à
35 %** pour réduire le halo de l'écran émissif et préserver le contraste pour la
caméra du master.
```bash
curl -X POST http://192.168.1.50/stim/qr \
-H 'Content-Type: application/json' \
-d '{"text":"zacus-qr-1"}'
# {"status":"ok","text":"zacus-qr-1"}
```
### `POST /stim/melody`
```json
{"notes": [60, 62, 64, 65], "ms": 400}
```
Joue la séquence de notes **MIDI** au haut-parleur. `notes` est un tableau
d'entiers `0-127` (60 = do central), `ms` est la durée par note en
millisecondes, bornée à `1-4000` (défaut 400). La séquence est jouée sur une
**tâche worker** dédiée, donc la réponse HTTP revient immédiatement. Maximum
32 notes ; les notes hors plage sont ignorées.
```bash
curl -X POST http://192.168.1.50/stim/melody \
-H 'Content-Type: application/json' \
-d '{"notes":[60,62,64,65,67],"ms":300}'
# {"status":"ok","notes":5}
```
### Note de terrain
Le décodage par la caméra du master du **QR affiché sur l'écran LCD n'est pas
fiable** : le contraste émissif est insuffisant pour quirc, qui ne retrouve pas
les motifs de repérage (finder patterns) quelle que soit la résolution, même
écran atténué. **La mélodie est le chemin de stimulus le plus robuste.** Pour
P3, le **QR imprimé reste recommandé** ; le `/stim/qr` sert surtout au cadrage
et au débogage de la caméra.
### Configuration Wi-Fi
Le générateur est joignable sur l'IP de la BOX-3 en mode station. Configurez le
réseau via `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**, ou
directement par `CONFIG_ZACUS_WIFI_SSID` / `CONFIG_ZACUS_WIFI_PASSWORD`.
+3 -1
View File
@@ -1,5 +1,5 @@
idf_component_register(
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c"
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c"
INCLUDE_DIRS "."
PRIV_REQUIRES
driver
@@ -12,4 +12,6 @@ idf_component_register(
nvs_flash
spiffs
scenario_mesh
espressif__esp-box-3
lvgl__lvgl
)
+10
View File
@@ -12,6 +12,16 @@ menu "Zacus BOX-3 Voice Configuration"
help
WiFi password. Leave empty for open networks.
config ZACUS_WIFI_CHANNEL
int "WiFi channel hint (0 = auto)"
default 0
range 0 13
help
Bias the STA scan to start on this channel (1-13). Set this to the
master's WiFi channel so ESP-NOW peers land co-channel — required
for the scenario relay on multi-AP / mesh networks where the same
SSID is broadcast on several channels. 0 = auto (scan all).
config ZACUS_VOICE_BRIDGE_URL
string "Voice Bridge WebSocket URL"
default "ws://192.168.0.119:8200/voice/ws"
+33
View File
@@ -28,6 +28,7 @@
#include "scenario_server.h"
#include "plip_virtual.h"
#include "plip_ui.h"
#include "stimulus.h"
#include "scenario_mesh.h"
/* BSP header — provided by espressif/esp-box component */
@@ -88,6 +89,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) */
},
};
@@ -106,6 +108,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) {
@@ -404,6 +429,14 @@ void app_main(void)
/* 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");
}
}
/* Start the ESP-NOW receiver so the master can relay scenarios to us even
+1 -1
View File
@@ -25,7 +25,7 @@
#define TAG "scenario_srv"
#define MAX_SCENARIO_BYTES (64 * 1024)
#define SPIFFS_LABEL "storage"
#define SPIFFS_LABEL "spiffs"
#define SPIFFS_BASE "/spiffs"
#define SCENARIO_PATH SPIFFS_BASE "/scenario.json"
#define SCENARIO_BAK SPIFFS_BASE "/scenario.bak"
+170
View File
@@ -0,0 +1,170 @@
// stimulus.c — QR display + melody playback, driven over REST.
#include "stimulus.h"
#include <math.h>
#include <string.h>
#include "bsp/esp-box-3.h"
#include "lvgl.h"
#include "libs/qrcode/lv_qrcode.h"
#include "cJSON.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "stimulus";
#define LOCK_MS 1000
static lv_obj_t *s_qr_screen; // dedicated fullscreen QR screen
static lv_obj_t *s_qr; // lv_qrcode widget
static lv_obj_t *s_qr_caption;
esp_err_t stimulus_init(void) {
if (!bsp_display_lock(LOCK_MS)) return ESP_FAIL;
s_qr_screen = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_qr_screen, lv_color_white(), 0);
// 160 px QR on the 320x240 LCD — leaves ~40 px white top/bottom and
// ~80 px sides as the quiet zone quirc needs to lock the finder patterns
// (220 px starved the vertical quiet zone and blocked detection).
s_qr = lv_qrcode_create(s_qr_screen);
lv_qrcode_set_size(s_qr, 160);
lv_qrcode_set_dark_color(s_qr, lv_color_black());
lv_qrcode_set_light_color(s_qr, lv_color_white());
lv_qrcode_update(s_qr, "zacus", 5);
lv_obj_align(s_qr, LV_ALIGN_CENTER, 0, -10);
s_qr_caption = lv_label_create(s_qr_screen);
lv_obj_set_style_text_color(s_qr_caption, lv_color_black(), 0);
lv_label_set_text(s_qr_caption, "");
lv_obj_align(s_qr_caption, LV_ALIGN_BOTTOM_MID, 0, -10);
bsp_display_unlock();
ESP_LOGI(TAG, "stimulus QR screen ready");
return ESP_OK;
}
static void show_qr(const char *text) {
if (!s_qr || !bsp_display_lock(LOCK_MS)) return;
lv_qrcode_update(s_qr, text, strlen(text));
lv_label_set_text(s_qr_caption, text);
lv_screen_load(s_qr_screen); // bring the QR to the front
bsp_display_unlock();
// Dim the backlight hard: an emissive LCD at full brightness blooms and
// crushes QR contrast for the master's camera. ~35% keeps the modules
// readable while killing the glare halo.
bsp_display_brightness_set(35);
ESP_LOGI(TAG, "QR shown (dimmed): %s", text);
}
// MIDI note -> frequency (A4=69=440 Hz).
static float midi_to_hz(int note) {
return 440.0f * powf(2.0f, (float)(note - 69) / 12.0f);
}
// ─── REST ────────────────────────────────────────────────────────────────────
static esp_err_t read_body(httpd_req_t *req, char *buf, size_t cap) {
if (req->content_len <= 0 || (size_t)req->content_len >= cap) return ESP_FAIL;
int total = 0;
while (total < (int)req->content_len) {
int got = httpd_req_recv(req, buf + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return ESP_FAIL;
}
total += got;
}
buf[total] = '\0';
return ESP_OK;
}
static esp_err_t reply(httpd_req_t *req, const char *status, const char *json) {
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "application/json");
return httpd_resp_sendstr(req, json);
}
static esp_err_t handle_qr_post(httpd_req_t *req) {
char body[256];
if (read_body(req, body, sizeof(body)) != ESP_OK) {
return reply(req, "400 Bad Request", "{\"error\":\"body\"}");
}
cJSON *root = cJSON_Parse(body);
const cJSON *t = root ? cJSON_GetObjectItemCaseSensitive(root, "text") : NULL;
if (!cJSON_IsString(t) || !t->valuestring[0]) {
cJSON_Delete(root);
return reply(req, "400 Bad Request", "{\"error\":\"text required\"}");
}
show_qr(t->valuestring);
char resp[280];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"text\":\"%s\"}",
t->valuestring);
cJSON_Delete(root);
return reply(req, "200 OK", resp);
}
// Melody played on a worker task so the HTTP response returns immediately.
typedef struct { float hz[32]; int count; int note_ms; } melody_job_t;
static void melody_task(void *arg) {
melody_job_t *job = (melody_job_t *)arg;
for (int i = 0; i < job->count; i++) {
audio_play_tone(job->hz[i], job->note_ms);
vTaskDelay(pdMS_TO_TICKS(40)); // brief gap = note onset for the mic
}
free(job);
vTaskDelete(NULL);
}
static esp_err_t handle_melody_post(httpd_req_t *req) {
char body[512];
if (read_body(req, body, sizeof(body)) != ESP_OK) {
return reply(req, "400 Bad Request", "{\"error\":\"body\"}");
}
cJSON *root = cJSON_Parse(body);
const cJSON *notes = root ? cJSON_GetObjectItemCaseSensitive(root, "notes") : NULL;
if (!cJSON_IsArray(notes) || cJSON_GetArraySize(notes) == 0) {
cJSON_Delete(root);
return reply(req, "400 Bad Request", "{\"error\":\"notes required\"}");
}
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(root, "ms");
const int note_ms = cJSON_IsNumber(ms) ? ms->valueint : 400;
melody_job_t *job = calloc(1, sizeof(*job));
if (!job) { cJSON_Delete(root); return reply(req, "500 Internal Server Error", "{\"error\":\"oom\"}"); }
job->note_ms = (note_ms > 0 && note_ms <= 4000) ? note_ms : 400;
const cJSON *n;
cJSON_ArrayForEach(n, notes) {
if (job->count >= 32) break;
if (cJSON_IsNumber(n) && n->valueint >= 0 && n->valueint <= 127) {
job->hz[job->count++] = midi_to_hz(n->valueint);
}
}
cJSON_Delete(root);
if (job->count == 0) { free(job); return reply(req, "400 Bad Request", "{\"error\":\"no valid notes\"}"); }
if (xTaskCreate(melody_task, "melody", 4096, job, 5, NULL) != pdPASS) {
free(job);
return reply(req, "503 Service Unavailable", "{\"error\":\"busy\"}");
}
char resp[64];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"notes\":%d}", job->count);
return reply(req, "200 OK", resp);
}
esp_err_t stimulus_register_routes(httpd_handle_t server) {
static const httpd_uri_t uri_qr = {
.uri = "/stim/qr", .method = HTTP_POST,
.handler = handle_qr_post, .user_ctx = NULL,
};
static const httpd_uri_t uri_melody = {
.uri = "/stim/melody", .method = HTTP_POST,
.handler = handle_melody_post, .user_ctx = NULL,
};
esp_err_t e = httpd_register_uri_handler(server, &uri_qr);
if (e == ESP_OK) e = httpd_register_uri_handler(server, &uri_melody);
if (e == ESP_OK) ESP_LOGI(TAG, "routes up: POST /stim/qr, POST /stim/melody");
return e;
}
+28
View File
@@ -0,0 +1,28 @@
// stimulus.h — BOX-3 as a stimulus generator for the Freenove master's
// camera (QR) and microphone (melody). Lets the master's local puzzles be
// exercised end-to-end without printed cards or an instrument.
//
// REST (registered on the existing scenario_server httpd):
// POST /stim/qr {"text":"zacus-qr-1"} -> show QR fullscreen
// POST /stim/melody {"notes":[60,62,64,65],"ms":400} -> play melody
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
// Provided by main.c — play one tone (blocking) on the BOX-3 speaker.
void audio_play_tone(float frequency, int duration_ms);
// Build the QR screen (under bsp_display_lock). Call once after the display
// and UI are up.
esp_err_t stimulus_init(void);
// Register /stim/* routes on an existing server.
esp_err_t stimulus_register_routes(httpd_handle_t server);
#ifdef __cplusplus
}
#endif
+3
View File
@@ -59,3 +59,6 @@ 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
+201 -52
View File
@@ -1,79 +1,228 @@
# `idf_zacus/` — Zacus master ESP-IDF scaffold
# `idf_zacus/` — firmware master Zacus (ESP-IDF)
This tree is the future home of the Zacus master firmware. It is the **P1
first slice** of the voice pipeline migration described in
`docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md`.
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).
The Arduino firmware in `../ui_freenove_allinone/` keeps running unchanged
during the transition; this scaffold lives side-by-side until feature parity
is reached.
Ce n'est plus une *slice* de migration : c'est le master complet. Le firmware
réunit aujourd'hui :
## Prerequisites
- **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.
- ESP-IDF v5.4 or v5.5 installed under `~/esp/esp-idf/`.
- Source the IDF environment in each shell:
```bash
. $HOME/esp/esp-idf/export.sh
```
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.
## Build
## 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
cd idf_zacus
idf.py set-target esp32s3
idf.py build
```
## Flash & monitor
Flash + monitor (le port de cette machine est fixe) :
```bash
idf.py -p /dev/cu.usbmodem* flash monitor
idf.py -p /dev/cu.usbmodem5AB90753301 flash monitor
```
Exit the monitor with `Ctrl-]`.
Sortie du monitor : `Ctrl-]`.
## What the first slice does
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`.
`main/main.c` boots the device, initializes NVS, mounts the LittleFS
`storage` partition on `/littlefs`, lists its contents, logs heap stats
(internal + PSRAM), and enters an idle heartbeat loop (no deep sleep — the
inherited `ota_server` listening loop will be wired in slice 2).
## Carte des composants
Inherited components:
Tous les composants vivent sous `components/` et sont des composants ESP-IDF
standard (chacun avec son `include/`).
- `components/ota_server/` — HTTP server on :80 with rate-limited OTA upload
and 30 s watchdog auto-rollback (`POST /ota`, `POST /ota/rollback`,
`GET /version`, `GET /status`, `GET /ota/status`). Not yet started by
`main.c`; that comes next.
| 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`). |
## Layout
## Séquence de boot (`main/main.c`)
```
idf_zacus/
├── CMakeLists.txt # project entry, points EXTRA_COMPONENT_DIRS at components/
├── sdkconfig.defaults # ESP32-S3, octal PSRAM 80 MHz, custom partitions
├── partitions.csv # OTA layout + 2 MB LittleFS "storage"
├── main/
│ ├── CMakeLists.txt
│ ├── idf_component.yml # joltwallet/littlefs ^1.14
│ └── main.c # app_main + LittleFS mount + heartbeat
└── components/
└── ota_server/ # inherited from 2026-04-03 IDF bootstrap
`app_main` exécute, dans l'ordre :
1. **NVS** — init (efface + réinit si pages pleines / nouvelle version).
2. **Écran**`display_ui_init()` (splash tôt, non fatal). Si OK : enregistre
`display_ui_camera_frame` comme callback de preview QR, puis
`buttons_input_init()` (5 directions).
3. **Wi-Fi** — lit les creds NVS (namespace `wifi`, clés `ssid`/`pwd`). Si
présents : **STA** (attente `GOT_IP`, 8 retries, timeout 20 s). Sinon, ou
échec : **AP** ouvert de secours `zacus-setup` (IP `192.168.4.1`).
4. **mDNS** — uniquement en STA : publie `zacus-master.local` et le service
`_zacus._tcp:80` (TXT `path=/voice/hook`). Sauté en AP-fallback.
5. **OTA server**`ota_server_init()` (`:80`). Sur succès, greffe sur le même
httpd : `voice_hook_endpoint_init()` puis `game_endpoint_init()` (qui monte
aussi `scenario_mesh` / ESP-NOW), seed du registre de peers relais depuis
NVS (namespace `peers`), et `game_endpoint_set_puzzle_state(&s_pstate)`.
6. **LittleFS** — monte la partition `storage` sur `/littlefs` (reformat si
échec) et liste la racine. Puis, sous ce montage :
- `media_manager_init()` (catalogues sur LittleFS) + smoke-test de lecture ;
- `npc_engine_init()` (table de cues vide à ce stade) ;
- `hints_client_init()` vers le backend hints, puis chargement du
`group_profile` depuis NVS (namespace `zacus`, défaut `MIXED`) ;
- `voice_dispatcher_init()` ;
- `puzzle_state_init()` + `local_puzzles_init()` ;
- `mic_broker_init()` sur les pins micro Media Kit (3/14/46, 16 kHz) **avant**
`voice_pipeline_init()` (le pipeline no-op alors avec `ESP_ERR_INVALID_STATE`) ;
- `voice_pipeline_init()` : wake-word + auto-start capture activés, URL du
voice-bridge, lecture TTS activée, **pins forcés** aux valeurs Media Kit
(mic 3/14/46, HP 42/41/1) pour éviter la collision avec les pins caméra.
7. **`ota_server_mark_valid()`** — valide l'image (désamorce l'auto-rollback)
une fois les sous-systèmes montés.
8. **Boucle de statut** — réveil toutes les 500 ms : rafraîchit l'écran
(IP, état wake-word, étape/armé, code assemblé, métadonnées de scène),
traite les lancements d'apps du shell (`/littlefs/apps/<id>/step.txt`
`game_endpoint_apply_step`). Toutes les 60 s : heartbeat + `media_manager_update`
+ `npc_engine_update`.
URLs hardcodées (déplacées en NVS dans un suivi) : hints
`http://192.168.0.150:8302`, voice-bridge WS `ws://100.116.92.12:8200/voice/ws`.
## Énigmes locales (P1 son / P3 QR)
`local_puzzles` arme deux types d'énigmes et reporte leur fragment dans
`puzzle_state` à la résolution :
- **P1 — son/mélodie** (`local_puzzles_arm_sound`) : `mic_broker` (propriétaire
unique du micro I2S RX) passe en mode `MIC_P1_SOUND` et route les trames PCM16
vers `sound_puzzle`, qui détecte les notes et les valide via
`melody_validator` (notes MIDI + tolérance en demi-tons). Le **même broker**
est partagé avec la voix (`MIC_NPC_LISTEN`) : un seul propriétaire des pins,
un consommateur actif à la fois.
- **P3 — QR séquentiel** (`local_puzzles_arm_qr`) : `qr_puzzle` initialise la
caméra (QVGA 320×240 niveaux de gris), décode avec **quirc**, et valide
l'ordre des codes via `seq_validator`. Les trames sont mirrorées au
viewfinder de l'écran via le callback de preview. Teardown caméra
asynchrone (réarmement à retenter après ~100 ms).
### Pin-map Media Kit (`board_pins_mediakit.h`)
Freenove FNK0102H Media Kit v1.2, **zéro collision** entre caméra, micro,
HP et écran :
- **Caméra** (capteur réel : **OV3660**) — XCLK 15, SIOD/SIOC 4/5, VSYNC 6,
HREF 7, PCLK 13, D0D7 = 11/9/8/10/12/18/17/16.
- **Micro I2S IN** — BCLK 3, WS 14, DIN 46.
- **HP I2S OUT** (MAX98357A) — BCLK 42, LRC 41, DOUT 1.
- **Écran ST7796 (SPI2/FSPI, 80 MHz)** — SCK 47, MOSI 21, DC 45, RST 20, BL 2.
> Les défauts du composant `voice_pipeline` (mic 14/15/22, HP 11/12/13)
> **entrent en collision** avec les pins caméra ; `main.c` les écrase par les
> valeurs Media Kit ci-dessus. Ne pas réintroduire les défauts.
### Validateurs testables sur hôte
`seq_validator` et `melody_validator` sont de la logique pure, sans I/O,
compilables et testables sur la machine de dev :
```bash
make -C idf_zacus/components/local_puzzles/test/host test
```
## Coexistence with Arduino
## Écran (`display_ui`)
`../ui_freenove_allinone/` (Arduino, PlatformIO) remains the production
firmware until the IDF port reaches feature parity. The two trees do **not**
share build artifacts. To work on the Arduino tree:
`cd ui_freenove_allinone && pio run`. To work on the IDF tree, source the
ESP-IDF env first.
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.
## Roadmap (next P1 slices)
Vues et fonctions :
1. Boot `ota_server_init()` from `app_main` after a small Wi-Fi STA bring-up.
2. Port the NPC engine and media manager skeleton.
3. Scaffold the voice pipeline (I2S RX task, no esp-sr yet).
4. Bring up esp-sr AFE + wakenet ("hi_esp" placeholder) — start of P3.
- **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).
See the design spec for the full plan.
Navigation **5 directions** (`buttons_input`, échelle analogique sur GPIO19,
ADC2) : 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP. Shell fermé : SELECT /
LEFT-RIGHT bascule scène↔statut, MENU ouvre le shell, UP/DOWN règlent la
luminosité. Shell ouvert : navigation grille, SELECT lance la tuile.
## Surface REST de jeu
Tous les endpoints de jeu partagent l'instance `esp_http_server` d'`ota_server`
(port 80, pas de second socket). Détail des routes (`/game/group_profile`,
`/game/step`, `/game/puzzle_state`, `/game/scenario`, `/game/scenario/relay`…)
et de la persistance NVS dans **`components/game_endpoint/README.md`**.
## Tests hôte
Trois suites de logique pure tournent sur la machine de dev (Unity), sans
matériel ni IDF flashé :
| Suite | Cas | Commande |
|---|---|---|
| `local_puzzles` (validateurs séquence + mélodie) | 6 | `make -C idf_zacus/components/local_puzzles/test/host test` |
| `puzzle_state` (agrégation + assemblage du code) | 2 | `make -C idf_zacus/components/puzzle_state/test/host test` |
| `game_endpoint` (binding puzzle/scène) | 10 | `make -C idf_zacus/components/game_endpoint/test/host test` |
## Layout flash (`partitions.csv`)
Cible 16 MB (N16R8) :
| Partition | Type | Taille |
|---|---|---|
| `nvs` | data/nvs | 24 KB |
| `otadata` | data/ota | 8 KB |
| `phy_init` | data/phy | 4 KB |
| `factory` / `ota_0` / `ota_1` | app | **3 MB chacune** |
| `model` | data/spiffs | 1 MB (modèles esp-sr) |
| `storage` | data/littlefs | 5 MB (assets, scénario, apps) |
La partition app est passée de 2 MB à **3 MB** (2026-06-10) pour loger
LVGL + LovyanGFX + polices + effets/preview. NVS/otadata/phy gardent leurs
offsets (les creds Wi-Fi survivent au reflash) ; `ota_0`/`ota_1`/`model`/
`storage` se décalent, donc **le contenu LittleFS est perdu au reflash**
(reformatage auto ; repousser le scénario via `POST /game/scenario`).
## Limites connues / terrain
- **Décodage QR sur écran LCD émissif** : lire un QR **affiché à l'écran** via
la caméra **ne fonctionne pas de façon fiable** — quirc ne retrouve pas les
motifs (contraste insuffisant sur un panneau émissif). Le **QR imprimé** reste
la méthode recommandée pour P3.
- **Note repérée dans le code** : le commentaire d'en-tête de `qr_puzzle.h`
mentionne encore « OV2640 » ; le capteur réel de la carte est **OV3660**
(commentaire à corriger, sans impact fonctionnel sur le chemin grayscale/quirc).
- **Provisioning** : creds Wi-Fi, URL hints et URL voice-bridge passent encore
partiellement par des constantes / NVS pré-flashé ; le passage tout-NVS au
runtime est un suivi.
@@ -0,0 +1,12 @@
idf_component_register(
SRCS "display_ui.cpp"
"intro_fx3d.cpp"
"buttons_input.c"
"fonts/lv_font_orbitron_40.c"
"fonts/lv_font_ibmplexmono_18.c"
INCLUDE_DIRS "include"
PRIV_REQUIRES driver local_puzzles esp_timer esp_adc
)
# LovyanGFX requires C++17.
target_compile_options(${COMPONENT_LIB} PRIVATE -std=gnu++17)
+47
View File
@@ -0,0 +1,47 @@
# display_ui
Écran ST7796 (320×480, paysage) du master Freenove, piloté via **LovyanGFX** +
**LVGL 8.4**. Port fidèle de l'UI Arduino d'origine (`ui_freenove_allinone`).
Tous les appels LVGL et LovyanGFX s'exécutent exclusivement sur la
`display_task` ; `display_ui_set_status` copie l'état sous mutex et lève un flag
dirty que la tâche replie dans les labels.
## Pile
- **HAL panneau** — `Panel_ST7796` sur `Bus_SPI` (SPI2_HOST/FSPI, 80 MHz
écriture / 20 MHz lecture). Configuration **identique** à l'UI Arduino
d'origine (`FreenoveLgfxDevice` dans `ui_freenove_allinone`) : pins
SCK=47/MOSI=21/DC=45/RST=20/BL=2, rotation 1, inversion on, ordre RGB.
- **Rétroéclairage PWM** — LEDC sur `LEDC_TIMER_1` / `LEDC_CHANNEL_1`. Le
`LEDC_TIMER_0` / `LEDC_CHANNEL_0` est réservé au **XCLK de la caméra**
(`qr_puzzle`). Duty exposé 0-100 % via `display_ui_set_brightness`.
- **Vue statut** + **vue scène** — la vue scène affiche le step courant et le
code assemblé, avec un effet (**pulse** = respiration d'opacité, **glitch** =
jitter + scintillement du titre, **gyro** = anneau-gyrophare rotatif) et un
**viewfinder caméra** : `lv_canvas` PSRAM nourri par les trames grayscale du
scan QR, converties en **RGB565** (~5 fps), visible seulement quand l'énigme
QR est armée.
- **Boutons 5 directions** — échelle résistive sur ADC (GPIO19 → ADC2_CH8),
seuils en mV repris de l'original (`{0, 447, 730, 1008, 1307, 1659}`, plancher
« relâché » 2800 mV, décodage par milieu entre seuils adjacents).
- **Shell Workbench** — tuiles builtin **Statut / Scene / Auto / Lumiere /
Fichiers**, plus des **apps dynamiques** chargées depuis
`/littlefs/apps/<id>/` (`icon.png` optionnel + `step.txt` = action de l'app).
- **Browser de fichiers** — drawer parcourant `/littlefs`.
- **Intro cracktro** — port fidèle de la phase A d'origine : starfield parallaxe
3 couches en **Q8.8**, copper bars, scrolltext en onde sinus, logo avec ombre
portée.
## API publique (`display_ui.h`)
- `display_ui_init()` — init panneau + splash, spawn la tâche de refresh.
- `display_ui_set_status(s)` — copie un snapshot d'état et demande un redraw
async (thread-safe ; NULL = no-op).
- `display_ui_set_brightness(pct)` — duty 0-100 % (défaut 100).
- `display_ui_camera_frame(gray, w, h)` — pousse une trame caméra pour le
viewfinder (câblé comme preview callback de `qr_puzzle`) ; non-QVGA ignorée.
- `display_ui_handle_key(key)` — traite une touche 5 directions (1=SELECT
2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP).
- `display_ui_take_pending_launch(id_out, cap)` — récupère (one-shot) une
demande de lancement d'app du shell dynamique.
@@ -0,0 +1,133 @@
// buttons_input.c — 5-way analog ladder scan (IDF port of ButtonManager).
//
// Faithful to ui_freenove_allinone/src/drivers/input/button_manager.cpp:
// - ladder thresholds (mV): {0, 447, 730, 1008, 1307, 1659} for keys 1..5
// - "no button" floor: 2800 mV
// - decode: midpoint splits between adjacent thresholds
// - debounce: 30 ms of stable raw key before an event fires
// - scan period: 20 ms (~50 Hz)
// Differences vs the original: long-press detection is not ported yet (no
// consumer needs it); events go straight to display_ui_handle_key().
#include "buttons_input.h"
#include "display_ui.h"
#include "board_pins_mediakit.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "buttons";
// GPIO19 → ADC2_CHANNEL_8 on the ESP32-S3.
#define BTN_ADC_UNIT ADC_UNIT_2
#define BTN_ADC_CHANNEL ADC_CHANNEL_8
#define BTN_DEBOUNCE_MS 30
#define BTN_SCAN_MS 20
static const int kThresholdsMv[6] = {0, 447, 730, 1008, 1307, 1659};
static const int kNoButtonMv = 2800;
static adc_oneshot_unit_handle_t s_adc;
static adc_cali_handle_t s_cali;
static bool s_cali_ok;
static uint8_t decode_key(int mv) {
if (mv >= (kThresholdsMv[5] + kNoButtonMv) / 2) return 0; // released
if (mv < (kThresholdsMv[1] + kThresholdsMv[2]) / 2) return 1;
if (mv < (kThresholdsMv[2] + kThresholdsMv[3]) / 2) return 2;
if (mv < (kThresholdsMv[3] + kThresholdsMv[4]) / 2) return 3;
if (mv < (kThresholdsMv[4] + kThresholdsMv[5]) / 2) return 4;
return 5;
}
static void scan_task(void *arg) {
(void) arg;
uint8_t raw_key = 0;
TickType_t raw_changed = xTaskGetTickCount();
uint8_t reported_key = 0;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(BTN_SCAN_MS));
int raw = 0;
// ADC2 + Wi-Fi arbitration can time out — skip the sample.
if (adc_oneshot_read(s_adc, BTN_ADC_CHANNEL, &raw) != ESP_OK) continue;
int mv;
if (s_cali_ok) {
if (adc_cali_raw_to_voltage(s_cali, raw, &mv) != ESP_OK) continue;
} else {
// crude conversion without calibration: 12-bit, ~3100 mV span
mv = (raw * 3100) / 4095;
}
const uint8_t key = decode_key(mv);
const TickType_t now = xTaskGetTickCount();
if (key != raw_key) {
raw_key = key;
raw_changed = now;
continue;
}
if ((now - raw_changed) < pdMS_TO_TICKS(BTN_DEBOUNCE_MS)) continue;
// Stable. Fire on press transitions only (release → key).
if (key != reported_key) {
reported_key = key;
if (key != 0) {
display_ui_handle_key(key);
}
}
}
}
esp_err_t buttons_input_init(void) {
adc_oneshot_unit_init_cfg_t ucfg = {
.unit_id = BTN_ADC_UNIT,
.ulp_mode = ADC_ULP_MODE_DISABLE,
};
esp_err_t err = adc_oneshot_new_unit(&ucfg, &s_adc);
if (err != ESP_OK) {
ESP_LOGE(TAG, "adc_oneshot_new_unit: %s", esp_err_to_name(err));
return err;
}
adc_oneshot_chan_cfg_t ccfg = {
.atten = ADC_ATTEN_DB_12, // full-range, like the original 11dB
.bitwidth = ADC_BITWIDTH_12,
};
err = adc_oneshot_config_channel(s_adc, BTN_ADC_CHANNEL, &ccfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "adc channel cfg: %s", esp_err_to_name(err));
adc_oneshot_del_unit(s_adc);
s_adc = NULL;
return err;
}
adc_cali_curve_fitting_config_t cal = {
.unit_id = BTN_ADC_UNIT,
.chan = BTN_ADC_CHANNEL,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_12,
};
s_cali_ok = (adc_cali_create_scheme_curve_fitting(&cal, &s_cali) == ESP_OK);
if (!s_cali_ok) {
ESP_LOGW(TAG, "no ADC calibration — using crude raw→mV conversion");
}
if (xTaskCreate(scan_task, "btn_scan", 3072, NULL, 4, NULL) != pdPASS) {
ESP_LOGE(TAG, "scan task create failed");
adc_oneshot_del_unit(s_adc);
s_adc = NULL;
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "5-way ladder up (GPIO19/ADC2_CH8, debounce %d ms)",
BTN_DEBOUNCE_MS);
return ESP_OK;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
# Fonts — copied from ui_freenove_allinone/src/ui/fonts/ (original Arduino UI).
# Generated LVGL 8 bitmap fonts (see each file header for lv_font_conv options).
# Orbitron 40: scene titles/symbol. IBM Plex Mono 18: scene body text.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
## display_ui managed-component manifest.
##
## LovyanGFX is not published on the Espressif component registry; it is
## distributed as a standard ESP-IDF component on GitHub. The IDF component
## manager supports git: sources — on the first `idf.py build` it clones the
## repo at the requested tag into managed_components/.
##
## Trust note: lovyan03/LovyanGFX is the SAME upstream the original Arduino
## firmware already depends on (platformio.ini line 34: lovyan03/LovyanGFX@1.2.7).
## Pinned below to the exact commit SHA of release tag 1.2.21 so the dependency
## is reproducible and tamper-evident (a moved tag cannot change what we build).
dependencies:
lovyan03/LovyanGFX:
git: https://github.com/lovyan03/LovyanGFX.git
version: "4e689dba65135c2d91b180dc0a27a3cedebcfb5e" # tag 1.2.21
# LVGL from the Espressif registry (checksummed). Pinned to the same
# 8.4.x line as the original firmware (platformio.ini:35 lvgl/lvgl@8.4.0)
# so the original screens port unchanged in later phases.
lvgl/lvgl:
version: "~8.4.0"
@@ -0,0 +1,22 @@
// buttons_input.h — 5-way analog ladder on GPIO19 (FNK0102H), IDF port of
// ui_freenove_allinone's ButtonManager (same thresholds, debounce, semantics).
//
// Key numbers follow the original firmware: 1=SELECT 2=DOWN 3=MENU
// 4=LEFT/RIGHT 5=UP. Events are delivered to display_ui_handle_key() from a
// dedicated scan task (50 Hz, 30 ms debounce).
//
// GPIO19 is ADC2 on the ESP32-S3 — concurrent Wi-Fi can make individual
// reads fail with ESP_ERR_TIMEOUT; those samples are silently skipped (the
// 50 Hz scan re-samples immediately after).
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t buttons_input_init(void);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,93 @@
#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 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;
}
}
}
@@ -1,6 +1,7 @@
idf_component_register(
SRCS
"game_endpoint.c"
"puzzle_binding.c"
INCLUDE_DIRS
"include"
REQUIRES
@@ -13,4 +14,7 @@ idf_component_register(
freertos
log
joltwallet__littlefs
puzzle_state
PRIV_REQUIRES
local_puzzles
)
+406 -56
View File
@@ -1,30 +1,57 @@
# game_endpoint
REST surface for **runtime game configuration**. Slice 12 of the IDF
migration. Currently exposes a single resource — the hints-engine
group profile — but the component is the natural home for additional
runtime tunables (cooldowns, voice persona, etc.) as scenarios grow.
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()`.
## Why
Né en slice 12 pour le seul profil de groupe des indices, le composant a
beaucoup grandi. Il couvre aujourd'hui quatre domaines :
The hints engine adapts its policy to the audience: `TECH`,
`NON_TECH`, `MIXED`, or `BOTH`. Until this slice the value was baked
into NVS via `idf.py nvs-partition-gen` or the dashboard's flash
helper — both require a power cycle. The game master needs to be able
to change profile mid-session if the actual room composition differs
from the booking, so we expose a REST endpoint that updates both the
in-RAM hints client and the persistent NVS slot.
- **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.
## Routes
## Table des routes
Listener: existing `esp_http_server` instance on port **80** (shared
with `ota_server` and `voice_hook_endpoint` — same TCP socket, no
second httpd).
| 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`
Returns the live profile (whatever `hints_client_group_profile()`
reports — defaults to `MIXED` after init).
Renvoie le profil vivant rapporté par `hints_client_group_profile()`
(`MIXED` par défaut après init).
```json
{ "group_profile": "MIXED" }
@@ -32,65 +59,387 @@ reports — defaults to `MIXED` after init).
### `POST /game/group_profile`
Body (JSON, max 256 bytes):
Corps JSON, **1..256 octets** :
```json
{ "group_profile": "NON_TECH" }
```
| Status | Body | Meaning |
|--------|------|---------|
| 200 | `{"status":"ok","group_profile":"NON_TECH"}` | accepted, NVS persisted |
| 400 | `{"error":"missing 'group_profile'"}` | empty / non-string field |
| 400 | `{"error":"invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]"}` | rejected by hints client whitelist |
| 400 | `{"error":"malformed json"}` | body did not parse |
| 413 | `{"error":"body must be 1..256 bytes"}` | body too large |
| 500 | `{"status":"runtime_only","group_profile":"NON_TECH","warning":"nvs write failed: …"}` | hints client updated but NVS commit failed (will not survive reboot) |
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.
## curl examples
| 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) |
```bash
# Set the profile (uses mDNS — see main.c slice 12 wire-up)
curl -X POST http://zacus-master.local/game/group_profile \
-H "Content-Type: application/json" \
-d '{"group_profile":"NON_TECH"}'
---
# Probe current state
curl http://zacus-master.local/game/group_profile
## Scénario
# Static-IP fallback
curl -X POST http://192.168.0.<master-ip>/game/group_profile \
-H "Content-Type: application/json" \
-d '{"group_profile":"MIXED"}'
### `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" }
```
## Persistence
| 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 |
Successful POSTs write to NVS namespace `zacus`, key `group_profile`.
This is the same slot `main.c` reads at boot to seed the hints client,
so a successful POST survives reboot without a flash step. The
write/commit pair runs on the httpd worker task; expect ~515 ms of
flash latency.
```bash
curl -X POST http://zacus-master.local/game/scenario \
-H "Content-Type: application/json" \
--data-binary @scenario.runtime3.json
```
If the hints client validation passes but `nvs_set_str` /
`nvs_commit` fails (e.g. NVS partition full), the response is `500`
with `status: runtime_only` so the operator can decide whether to
keep going or force a reboot.
### `POST /game/scenario/relay`
## Component dependencies
(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 # the shared httpd_handle_t
json # cJSON for body parsing
nvs_flash # nvs_open / nvs_set_str / nvs_commit
hints_client # validation + push to in-RAM state
ota_server # supplies httpd_handle via ota_server_get_handle()
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
```
Init pattern in `main.c` (slice 12):
Câblage dans `main.c` :
```c
esp_err_t ota_err = ota_server_init();
@@ -98,5 +447,6 @@ 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
}
```
@@ -26,9 +26,21 @@
#include "hints_client.h"
#include "scenario_mesh.h"
#include "puzzle_binding.h"
#include "local_puzzles.h"
static const char *TAG = "game_endpoint";
// ─── puzzle state + step tracking (Task C) ──────────────────────────────────
// Pointer supplied by game_endpoint_set_puzzle_state(); NULL until then.
static puzzle_state_t *s_pstate = NULL;
// Last step id armed via POST /game/step. Empty string = none.
static char s_current_step_id[64] = {0};
// Last armed puzzle type: "qr" | "sound" | "none" | "" (empty = nothing armed yet).
static char s_current_armed[8] = {0};
// Display scene metadata of the current step (lenient parse; defaults safe).
static scene_binding_t s_current_scene = {0};
// Whitelist mirrored in the 4xx error message so the operator can
// recover without grepping the source. Keep aligned with
// hints_client_set_group_profile() validation.
@@ -175,6 +187,13 @@ static bool s_storage_mounted = false;
static esp_err_t mount_storage_lazy(void) {
if (s_storage_mounted) return ESP_OK;
// Already mounted by main.c (or media_manager)? Checking first avoids
// esp_littlefs's own "Partition already used" ERROR logs — the register
// call below would still succeed-as-INVALID_STATE, but noisily.
if (esp_littlefs_mounted(GAME_ENDPOINT_STORAGE_LABEL)) {
s_storage_mounted = true;
return ESP_OK;
}
esp_vfs_littlefs_conf_t conf = {
.base_path = GAME_ENDPOINT_STORAGE_BASE,
.partition_label = GAME_ENDPOINT_STORAGE_LABEL,
@@ -309,6 +328,11 @@ static esp_err_t scenario_apply_buffer(const char *body, size_t len,
ESP_LOGI(TAG, "scenario hot-load OK: %zu bytes, %d steps, entry=%s",
len, steps_count, (entry_out && entry_cap) ? entry_out : "");
// New scenario supersedes any live puzzle — disarm and clear remembered step.
local_puzzles_disarm();
s_current_step_id[0] = '\0';
memset(&s_current_scene, 0, sizeof(s_current_scene));
// Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3).
schedule_restart();
return ESP_OK;
@@ -571,12 +595,13 @@ static esp_err_t handle_peers_post(httpd_req_t *req) {
ESP_LOGI(TAG, "peers: \"%s\" -> %02X:%02X:%02X:%02X:%02X:%02X (%s)",
alias, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
reg == ESP_OK ? "live" : "reboot needed");
cJSON_Delete(root);
// Build the response BEFORE deleting root: `alias` points into the cJSON
// tree (use-after-free observed on-device as a garbled alias echo).
char resp[128];
snprintf(resp, sizeof(resp),
"{\"ok\":true,\"alias\":\"%s\",\"live\":%s}",
alias, reg == ESP_OK ? "true" : "false");
cJSON_Delete(root);
return send_json(req, "200 OK", resp);
}
@@ -620,6 +645,376 @@ static esp_err_t handle_peers_get(httpd_req_t *req) {
return ret;
}
// ─── POST /game/espnow/cmd — debug/manual CMD injection ─────────────────────
//
// Body: {"peer":"alias","command":"ping"} or {"broadcast":true,"command":"x"}.
// Sends one CMD text frame (spec 2026-06-11) and reports the radio status.
// The scenario-driven executor will share this exact send path.
static const uint8_t kEspnowBroadcastMac[6] =
{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
// Receive side: log every CMD/EVT so two-board bring-up is observable on the
// serial console. The step executor will hook waits in here later.
static void master_text_cb(uint8_t kind, const uint8_t src[6],
const char *text) {
ESP_LOGI(TAG, "espnow %s \"%s\" from %02x:%02x:%02x:%02x:%02x:%02x",
kind == SCENARIO_MESH_TEXT_CMD ? "CMD" : "EVT", text,
src[0], src[1], src[2], src[3], src[4], src[5]);
}
static esp_err_t handle_espnow_cmd_post(httpd_req_t *req) {
char buf[256];
int total = req->content_len;
if (total <= 0 || total >= (int) sizeof(buf)) {
return send_error(req, "400 Bad Request", "bad body length");
}
int got = httpd_req_recv(req, buf, total);
if (got <= 0) {
return send_error(req, "400 Bad Request", "body read failed");
}
buf[got] = '\0';
cJSON *root = cJSON_Parse(buf);
if (!root) {
return send_error(req, "400 Bad Request", "invalid JSON");
}
const cJSON *cmd_j = cJSON_GetObjectItemCaseSensitive(root, "command");
const cJSON *peer_j = cJSON_GetObjectItemCaseSensitive(root, "peer");
const cJSON *bc_j = cJSON_GetObjectItemCaseSensitive(root, "broadcast");
if (!cJSON_IsString(cmd_j) || !cmd_j->valuestring[0]) {
cJSON_Delete(root);
return send_error(req, "400 Bad Request", "'command' required");
}
uint8_t mac[6];
if (cJSON_IsTrue(bc_j)) {
memcpy(mac, kEspnowBroadcastMac, 6);
} else if (cJSON_IsString(peer_j) && peer_j->valuestring[0]) {
if (scenario_mesh_mac_for_alias(peer_j->valuestring, mac) != ESP_OK) {
cJSON_Delete(root);
return send_error(req, "404 Not Found", "unknown peer alias");
}
} else {
cJSON_Delete(root);
return send_error(req, "400 Bad Request",
"'peer' or 'broadcast':true required");
}
esp_err_t serr = scenario_mesh_send_text(mac, SCENARIO_MESH_TEXT_CMD,
cmd_j->valuestring);
char resp[96];
snprintf(resp, sizeof(resp), "{\"ok\":%s,\"status\":\"%s\"}",
serr == ESP_OK ? "true" : "false", esp_err_to_name(serr));
cJSON_Delete(root);
return send_json(req, serr == ESP_OK ? "200 OK" : "502 Bad Gateway", resp);
}
// ─── POST /game/step — arm a puzzle for the given step id ───────────────────
//
// Body: {"step_id":"STEP_X"}
// Reads /littlefs/scenario.json, calls puzzle_binding_from_ir, then
// disarms whatever is running and arms the new puzzle (if any).
// Concurrency: httpd task vs. puzzle solved-callbacks writing puzzle_state —
// the arm/disarm calls here are the only writers to local_puzzles state; puzzle
// tasks only call puzzle_state_report. No mutex needed for this slice.
// Core step-change logic, shared by the HTTP handler and internal callers
// (e.g. shell app launch on the local display). Error contract:
// ESP_OK armed (armed_out = "qr"|"sound"|"none")
// ESP_ERR_INVALID_STATE not ready (no puzzle_state wired)
// ESP_ERR_NOT_SUPPORTED no scenario stored / storage unavailable
// ESP_ERR_NOT_FOUND unknown step id
// ESP_ERR_INVALID_ARG invalid puzzle object in the IR
// ESP_ERR_TIMEOUT puzzle busy (QR teardown window, after one retry)
// others internal failure
esp_err_t game_endpoint_apply_step(const char *step_id,
char *armed_out, size_t armed_cap) {
if (armed_out && armed_cap) armed_out[0] = '\0';
if (!step_id || !step_id[0]) return ESP_ERR_INVALID_ARG;
if (s_pstate == NULL) return ESP_ERR_INVALID_STATE;
if (mount_storage_lazy() != ESP_OK) return ESP_ERR_NOT_SUPPORTED;
FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "rb");
if (!f) return ESP_ERR_NOT_SUPPORTED;
fseek(f, 0, SEEK_END);
long fsize = ftell(f);
rewind(f);
if (fsize <= 0 || fsize > GAME_ENDPOINT_MAX_SCENARIO_BYTES) {
fclose(f);
return ESP_ERR_NOT_SUPPORTED;
}
char *ir_json = (char *) malloc((size_t) fsize + 1);
if (!ir_json) { fclose(f); return ESP_ERR_NO_MEM; }
size_t nread = fread(ir_json, 1, (size_t) fsize, f);
fclose(f);
ir_json[nread] = '\0';
// Disarm current puzzle before re-arming.
local_puzzles_disarm();
// Parse binding for this step (+ display scene metadata, lenient).
puzzle_binding_t binding;
esp_err_t berr = puzzle_binding_from_ir(ir_json, step_id, &binding);
if (berr == ESP_OK) {
(void) scene_binding_from_ir(ir_json, step_id, &s_current_scene);
}
free(ir_json);
if (berr != ESP_OK) return berr; // NOT_FOUND / INVALID_ARG / other
strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1);
s_current_step_id[sizeof(s_current_step_id) - 1] = '\0';
const char *armed = "none";
if (binding.type == PB_QR) {
const char *ptrs[PB_MAX_CODES];
for (size_t i = 0; i < binding.code_count; i++) ptrs[i] = binding.codes[i];
esp_err_t aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count,
binding.fragment, binding.fragment_len);
if (aerr == ESP_ERR_INVALID_STATE) {
vTaskDelay(pdMS_TO_TICKS(250)); // async QR teardown — retry once
aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count,
binding.fragment, binding.fragment_len);
}
if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT;
if (aerr != ESP_OK) return aerr;
armed = "qr";
} else if (binding.type == PB_SOUND) {
esp_err_t aerr = local_puzzles_arm_sound(binding.id, binding.melody,
binding.note_count, binding.tolerance,
binding.fragment, binding.fragment_len);
if (aerr == ESP_ERR_INVALID_STATE) {
vTaskDelay(pdMS_TO_TICKS(250));
aerr = local_puzzles_arm_sound(binding.id, binding.melody,
binding.note_count, binding.tolerance,
binding.fragment, binding.fragment_len);
}
if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT;
if (aerr != ESP_OK) return aerr;
armed = "sound";
}
strncpy(s_current_armed, armed, sizeof(s_current_armed) - 1);
s_current_armed[sizeof(s_current_armed) - 1] = '\0';
if (armed_out && armed_cap) {
strncpy(armed_out, armed, armed_cap - 1);
armed_out[armed_cap - 1] = '\0';
}
return ESP_OK;
}
static esp_err_t handle_step_post(httpd_req_t *req) {
if (s_pstate == NULL) {
return send_error(req, "503 Service Unavailable", "not_ready");
}
// Read body (≤512 bytes is sufficient for {"step_id":"..."}).
char body[513] = {0};
if (req->content_len <= 0 || req->content_len > 512) {
return send_error(req, "400 Bad Request", "body must be 1..512 bytes");
}
int total = 0;
while (total < (int) req->content_len) {
int got = httpd_req_recv(req, body + total, req->content_len - total);
if (got <= 0) {
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
return send_error(req, "400 Bad Request", "recv failed");
}
total += got;
}
body[total] = '\0';
cJSON *root = cJSON_Parse(body);
if (!root) {
return send_error(req, "400 Bad Request", "malformed json");
}
const cJSON *step_id_j = cJSON_GetObjectItemCaseSensitive(root, "step_id");
if (!cJSON_IsString(step_id_j) || !step_id_j->valuestring ||
step_id_j->valuestring[0] == '\0') {
cJSON_Delete(root);
return send_error(req, "400 Bad Request", "missing or empty step_id");
}
char step_id[64];
strncpy(step_id, step_id_j->valuestring, sizeof(step_id) - 1);
step_id[sizeof(step_id) - 1] = '\0';
cJSON_Delete(root);
// Delegate to the shared core; map its error contract onto the SAME
// HTTP statuses/messages as before (REST contract unchanged).
char armed[8];
esp_err_t aerr = game_endpoint_apply_step(step_id, armed, sizeof(armed));
switch (aerr) {
case ESP_OK: {
char buf[128];
snprintf(buf, sizeof(buf),
"{\"step_id\":\"%s\",\"armed\":\"%s\"}", step_id, armed);
return send_json(req, "200 OK", buf);
}
case ESP_ERR_NOT_SUPPORTED:
return send_error(req, "409 Conflict", "no_scenario");
case ESP_ERR_NOT_FOUND:
return send_error(req, "404 Not Found", "unknown_step");
case ESP_ERR_INVALID_ARG:
return send_error(req, "422 Unprocessable Entity", "invalid_puzzle");
case ESP_ERR_TIMEOUT:
return send_error(req, "503 Service Unavailable", "puzzle_busy");
case ESP_ERR_INVALID_STATE:
return send_error(req, "503 Service Unavailable", "not_ready");
default:
return send_error(req, "500 Internal Server Error",
esp_err_to_name(aerr));
}
}
// ─── GET /game/puzzle_state ──────────────────────────────────────────────────
//
// Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}.
// NOTE: reads puzzle_state fields without a mutex — acceptable for this game
// (GM dashboard polling). solved[] flags are written monotonically by puzzle
// tasks; a concurrent read may see solved=true with fragment mid-write.
static esp_err_t handle_puzzle_state_get(httpd_req_t *req) {
if (s_pstate == NULL) {
return send_error(req, "503 Service Unavailable", "not_ready");
}
// Build "solved" array of ids where solved[id] == true (ids 1..PUZZLE_MAX_ID).
char solved_buf[64] = "[";
int solved_len = 1;
bool first = true;
for (int id = 1; id <= PUZZLE_MAX_ID; id++) {
if (s_pstate->solved[id]) {
int n = snprintf(solved_buf + solved_len,
sizeof(solved_buf) - (size_t) solved_len - 1,
"%s%d", first ? "" : ",", id);
if (n > 0) solved_len += n;
first = false;
}
}
if (solved_len < (int) sizeof(solved_buf) - 1) {
solved_buf[solved_len++] = ']';
solved_buf[solved_len] = '\0';
}
char code[PUZZLE_MAX_ID * PUZZLE_MAX_FRAG + 1] = {0};
puzzle_state_code(s_pstate, code, sizeof(code));
// Build response. step_id is null when no step has been armed yet.
char resp[256];
if (s_current_step_id[0] != '\0') {
snprintf(resp, sizeof(resp),
"{\"step_id\":\"%s\",\"solved\":%s,\"code\":\"%s\"}",
s_current_step_id, solved_buf, code);
} else {
snprintf(resp, sizeof(resp),
"{\"step_id\":null,\"solved\":%s,\"code\":\"%s\"}",
solved_buf, code);
}
return send_json(req, "200 OK", resp);
}
// ─── public setter (Task C) ──────────────────────────────────────────────────
void game_endpoint_set_puzzle_state(puzzle_state_t *state) {
s_pstate = state;
ESP_LOGI(TAG, "puzzle_state registered (GET /game/puzzle_state + POST /game/step active)");
}
// ─── public getter for display_ui (Phase 1) ──────────────────────────────────
//
// Copies the statics written by the step handler. Reading without a mutex is
// acceptable here — the game master arms one step at a time and the display is
// informational; a torn read produces at worst a one-cycle stale value.
void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap,
char *armed, size_t armed_cap) {
if (step_id && step_cap > 0) {
strncpy(step_id, s_current_step_id, step_cap - 1);
step_id[step_cap - 1] = '\0';
}
if (armed && armed_cap > 0) {
strncpy(armed, s_current_armed, armed_cap - 1);
armed[armed_cap - 1] = '\0';
}
}
// ─── POST /game/file?path=apps/<id>/<file> — provision shell apps ────────────
// Raw body → file under /littlefs/apps/ ONLY (whitelist + no traversal).
// Lets the GM push icon.png / step.txt for the display shell's dynamic tiles
// without reflashing a littlefs image.
#define GAME_ENDPOINT_MAX_FILE_BYTES (256 * 1024)
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");
}
if (strncmp(path_param, "apps/", 5) != 0 || strstr(path_param, "..") ||
path_param[strlen(path_param) - 1] == '/') {
return send_error(req, "403 Forbidden", "path must be under apps/");
}
if (req->content_len <= 0 ||
req->content_len > GAME_ENDPOINT_MAX_FILE_BYTES) {
return send_error(req, "400 Bad Request", "size 1..262144 bytes");
}
if (mount_storage_lazy() != ESP_OK) {
return send_error(req, "503 Service Unavailable", "storage_unavailable");
}
char full[160];
snprintf(full, sizeof(full), "/littlefs/%s", path_param);
// mkdir -p for intermediate directories.
for (char *p = full + strlen("/littlefs/"); *p; p++) {
if (*p == '/') {
*p = '\0';
mkdir(full, 0775); // EEXIST is fine
*p = '/';
}
}
FILE *f = fopen(full, "wb");
if (!f) {
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);
}
void game_endpoint_get_scene(scene_binding_t *out) {
if (!out) return;
// Lock-free snapshot, same class as get_puzzle_status: written on the
// httpd task per step change, polled by main's status loop. A torn read
// of display text during the copy is cosmetic and self-heals next poll.
memcpy(out, &s_current_scene, sizeof(*out));
}
// ─── public init ────────────────────────────────────────────────────────────
esp_err_t game_endpoint_init(httpd_handle_t server) {
@@ -665,6 +1060,30 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
.handler = handle_peers_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_step_post = {
.uri = "/game/step",
.method = HTTP_POST,
.handler = handle_step_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_puzzle_state_get = {
.uri = "/game/puzzle_state",
.method = HTTP_GET,
.handler = handle_puzzle_state_get,
.user_ctx = NULL,
};
static const httpd_uri_t uri_file_post = {
.uri = "/game/file",
.method = HTTP_POST,
.handler = handle_file_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_espnow_cmd_post = {
.uri = "/game/espnow/cmd",
.method = HTTP_POST,
.handler = handle_espnow_cmd_post,
.user_ctx = NULL,
};
// Bring up the ESP-NOW mesh transport. The master is primarily a sender
// (the relay handler) but we also register the apply adapter so a peer
@@ -702,6 +1121,17 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
ESP_LOGW(TAG, "register POST /game/scenario/relay: %s",
esp_err_to_name(err));
}
// CMD/EVT text channel: log inbound traffic + manual CMD injection.
esp_err_t terr = scenario_mesh_set_text_cb(master_text_cb);
if (terr != ESP_OK) {
ESP_LOGW(TAG, "scenario_mesh_set_text_cb: %s",
esp_err_to_name(terr));
}
err = httpd_register_uri_handler(server, &uri_espnow_cmd_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/espnow/cmd: %s",
esp_err_to_name(err));
}
}
// Peer registry management — non-fatal if registration fails.
err = httpd_register_uri_handler(server, &uri_peers_get);
@@ -713,9 +1143,23 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
ESP_LOGW(TAG, "register POST /game/peers: %s", esp_err_to_name(err));
}
// Task C: puzzle arming + state readout — non-fatal.
err = httpd_register_uri_handler(server, &uri_step_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/step: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_file_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register /game/file: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_puzzle_state_get);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register GET /game/puzzle_state: %s", esp_err_to_name(err));
}
ESP_LOGI(TAG, "game endpoint registered "
"(GET+POST /game/group_profile, POST /game/scenario%s, "
"GET+POST /game/peers)",
"GET+POST /game/peers, POST /game/step, GET /game/puzzle_state)",
mesh_err == ESP_OK ? ", POST /game/scenario/relay" : "");
return ESP_OK;
}
@@ -24,6 +24,8 @@
#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" {
@@ -57,6 +59,8 @@ extern "C" {
* 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
@@ -64,6 +68,54 @@ extern "C" {
*/
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,73 @@
// 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 } 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
// 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
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 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,232 @@
// 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 {
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 { // 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;
}
}
// 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; }
const cJSON *scene = cJSON_GetObjectItemCaseSensitive(step, "scene");
if (!scene || !cJSON_IsObject(scene)) {
// No scene — present stays false, ESP_OK.
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,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));
}
+3 -1
View File
@@ -310,7 +310,9 @@ esp_err_t ota_server_init(void) {
// HTTP server config
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = OTA_SERVER_PORT;
config.max_uri_handlers = 16; // ota (3) + voice_hook (2) + game (3) + headroom
config.max_uri_handlers = 20; // ota + voice_hook + game (incl. /game/step,
// /game/puzzle_state, /game/file, relay)
// + headroom
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
@@ -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);
@@ -0,0 +1,29 @@
// puzzle_state.c
#include "puzzle_state.h"
#include <string.h>
void puzzle_state_init(puzzle_state_t *s) { memset(s, 0, sizeof(*s)); }
void puzzle_state_report(puzzle_state_t *s, uint8_t id,
const uint8_t *fragment, uint8_t len)
{
if (id == 0 || id > PUZZLE_MAX_ID) return;
if (len > PUZZLE_MAX_FRAG) len = PUZZLE_MAX_FRAG;
if (s->solved[id]) return; // idempotent
s->solved[id] = true;
s->frag_len[id] = len;
memcpy(s->frag[id], fragment, len);
}
int puzzle_state_code(const puzzle_state_t *s, char *out, size_t cap)
{
if (cap == 0) return 0;
int n = 0;
for (uint8_t id = 1; id <= PUZZLE_MAX_ID; id++) {
if (!s->solved[id]) continue;
for (uint8_t k = 0; k < s->frag_len[id] && (size_t)(n + 1) < cap; k++)
out[n++] = (char)('0' + (s->frag[id][k] % 10));
}
if ((size_t)n < cap) out[n] = '\0';
return n;
}
@@ -0,0 +1 @@
build/
@@ -0,0 +1,63 @@
# Host test harness Makefile — covers puzzle_state aggregation logic
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)/puzzle_state.c \
$(UNITY_DIR)/unity.c \
../../../local_puzzles/test/host/host_runner.c
# Canonical IDF-style test file (single source of truth for assertions)
TEST_SRCS := \
$(TEST_DIR)/test_puzzle_state.c
ALL_SRCS := $(SRCS) $(TEST_SRCS)
# Include directories
INCS := \
-I$(COMPONENT_DIR)/include \
-I$(COMPONENT_DIR) \
-I$(TEST_DIR)/host \
-I../../../local_puzzles/test/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 ../../../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=...))
# 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_puzzle_state.o $(TEST_DIR)/test_puzzle_state.c
$(CC) $(CFLAGS) -o $@ $(SRCS) $(BUILD_DIR)/test_puzzle_state.o
test: $(TEST_BIN)
./$(TEST_BIN)
clean:
rm -rf $(BUILD_DIR)
.PRECIOUS: $(TEST_BIN)
@@ -0,0 +1,24 @@
#include "unity.h"
#include "puzzle_state.h"
TEST_CASE("assembles the code from reported fragments", "[pstate]")
{
puzzle_state_t s;
puzzle_state_init(&s);
puzzle_state_report(&s, /*id=*/1, (const uint8_t[]){1,2,0,0}, 2);
puzzle_state_report(&s, /*id=*/3, (const uint8_t[]){3,4,0,0}, 2);
char code[16];
TEST_ASSERT_EQUAL_INT(4, puzzle_state_code(&s, code, sizeof(code)));
TEST_ASSERT_EQUAL_STRING("1234", code);
}
TEST_CASE("a puzzle reported twice does not duplicate its digits", "[pstate]")
{
puzzle_state_t s;
puzzle_state_init(&s);
puzzle_state_report(&s, 1, (const uint8_t[]){1,2,0,0}, 2);
puzzle_state_report(&s, 1, (const uint8_t[]){1,2,0,0}, 2);
char code[16];
TEST_ASSERT_EQUAL_INT(2, puzzle_state_code(&s, code, sizeof(code)));
TEST_ASSERT_EQUAL_STRING("12", code);
}
@@ -8,6 +8,7 @@ idf_component_register(
PRIV_INCLUDE_DIRS
"."
REQUIRES
local_puzzles
esp_driver_i2s
esp_timer
esp_system
@@ -3,6 +3,12 @@
// `cfg.enable_wake_word`; if init fails (PSRAM exhausted, model
// partition absent, etc.) we log + degrade silently to the slice-5
// I2S-only capture path so the rest of the firmware still boots.
//
// Slice (task 4): I2S RX ownership transferred to mic_broker. The
// voice_pipeline registers on_npc_frame() for MIC_NPC_LISTEN and the broker
// delivers PCM16 mono 320-sample frames; voice_pipeline accumulates them
// into the AFE feed buffer (or counts heartbeat bytes in stub mode).
// The I2S TX path (speaker / MAX98357A on I2S_NUM_1) is unchanged.
#include "voice_pipeline.h"
#include "voice_pipeline_ws.h"
@@ -23,6 +29,8 @@
#include "esp_wn_models.h"
#include "model_path.h"
#include "mic_broker.h"
static const char *TAG = "voice_pipeline";
// Slice 6 placeholder wake word. Standard Espressif WakeNet9 model
@@ -50,15 +58,15 @@ static const char *TAG = "voice_pipeline";
static struct {
bool ready;
voice_pipeline_config_t cfg;
i2s_chan_handle_t rx_chan;
// rx_chan removed: I2S RX is now owned by mic_broker (task 4).
// Slice 9: I2S TX channel for TTS playback (MAX98357A DAC). NULL
// if `enable_tts_playback == false` or alloc/init failed.
i2s_chan_handle_t tx_chan;
bool tx_enabled; // channel currently enabled
uint32_t tx_sample_rate; // current configured rate
voice_state_t state;
TaskHandle_t capture_task;
bool capture_run;
// capture_task / capture_run removed: mic_broker's task delivers frames
// via on_npc_frame(); broker mode (MIC_NPC_LISTEN/MIC_IDLE) gates it.
// Wake-word callback (set independently of init, may be NULL).
voice_wake_callback_t wake_cb;
@@ -75,13 +83,18 @@ static struct {
int afe_fetch_chunk_samples; // post-AFE, what we stream
// Slice 7: streaming state. `stream_active` mirrors voice_ws_is_streaming
// but is owned by the capture task so we don't race with the WS event
// loop on transitions. `silence_chunks` counts sustained AFE_VAD_SILENCE
// fetches; reaching VAD_SILENCE_CHUNKS_TO_END closes the upload.
// but is owned by the on_npc_frame callback so we don't race with the WS
// event loop on transitions. `silence_chunks` counts sustained
// AFE_VAD_SILENCE fetches; reaching END_SILENCE_CHUNKS closes the upload.
bool stream_active;
uint32_t silence_chunks;
uint32_t streamed_chunks;
char session_id[32];
// AFE feed accumulator: broker delivers 320-sample chunks; the AFE may
// require a larger feed_chunk_samples. We accumulate here before feeding.
int16_t *afe_accum; // PSRAM alloc; size = feed_bytes
size_t afe_accum_fill; // samples already buffered
} s_pipe = {
.state = VOICE_STATE_IDLE,
};
@@ -186,36 +199,8 @@ esp_err_t voice_pipeline_stop_streaming(void) {
return ESP_OK;
}
static esp_err_t i2s_setup(void) {
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
esp_err_t err = i2s_new_channel(&chan_cfg, NULL, &s_pipe.rx_chan);
if (err != ESP_OK) return err;
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(s_pipe.cfg.sample_rate_hz),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT,
I2S_SLOT_MODE_MONO),
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = s_pipe.cfg.i2s_bclk_pin,
.ws = s_pipe.cfg.i2s_ws_pin,
.dout = I2S_GPIO_UNUSED,
.din = s_pipe.cfg.i2s_din_pin,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
err = i2s_channel_init_std_mode(s_pipe.rx_chan, &std_cfg);
if (err != ESP_OK) {
i2s_del_channel(s_pipe.rx_chan);
s_pipe.rx_chan = NULL;
return err;
}
return ESP_OK;
}
// i2s_setup() removed: I2S RX is now owned by mic_broker (task 4).
// mic_broker_init() is called from voice_pipeline_init() instead.
// Slice 9: bring up the I2S TX channel for TTS playback on a separate
// I2S port (I2S_NUM_1) so the mic capture on I2S_NUM_0 keeps running
@@ -343,59 +328,49 @@ static void wake_word_teardown(void) {
}
}
// Capture task. Two modes:
// * AFE active (esp-sr loaded) : feed I2S into AFE, fetch results,
// detect wake → fire callback,
// stream post-AFE PCM until VAD silence.
// * AFE inactive (slice-5 stub) : log a heartbeat every ~1.6 s.
static void capture_task(void *pv) {
if (voice_pipeline_wake_word_active()) {
const int chunk_samples = s_pipe.afe_feed_chunk_samples;
const int chunk_channels = s_pipe.afe_feed_channel_num;
const size_t feed_bytes = (size_t) chunk_samples * chunk_channels * sizeof(int16_t);
// NPC mic callback — invoked by mic_broker's capture task for every
// MB_FRAME-sample (320 samples = 20 ms) frame while MIC_NPC_LISTEN is active.
//
// Two paths:
// * AFE active : accumulate frames into s_pipe.afe_accum until we have a
// full afe_feed_chunk_samples worth, then feed+fetch+react.
// * Stub (no esp-sr): count bytes for heartbeat logging.
//
// Called from mic_broker's task context (stack 4096); must not block long.
static void on_npc_frame(const int16_t *pcm, size_t samples, void *ctx) {
(void)ctx;
int16_t *feed_buf = heap_caps_malloc(feed_bytes,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!feed_buf) {
ESP_LOGE(TAG, "PSRAM alloc for AFE feed buffer failed (%u B) — "
"stopping capture", (unsigned) feed_bytes);
s_pipe.capture_task = NULL;
vTaskDelete(NULL);
if (voice_pipeline_wake_word_active()) {
// ── AFE path ─────────────────────────────────────────────────────
const int need = s_pipe.afe_feed_chunk_samples * s_pipe.afe_feed_channel_num;
if (!s_pipe.afe_accum) return; // alloc failed at init; silently skip
// Slice 9: mute-during-TTS gate — drain the mic but do not feed AFE.
if (s_pipe.state == VOICE_STATE_SPEAKING) {
s_pipe.afe_accum_fill = 0; // also reset accumulator on mute
return;
}
ESP_LOGI(TAG, "capture_task: AFE mode — chunk=%d samples × %d ch (%u B)",
chunk_samples, chunk_channels, (unsigned) feed_bytes);
size_t copied = 0;
while (copied < samples) {
size_t space = (size_t)need - s_pipe.afe_accum_fill;
size_t take = samples - copied;
if (take > space) take = space;
memcpy(s_pipe.afe_accum + s_pipe.afe_accum_fill, pcm + copied,
take * sizeof(int16_t));
s_pipe.afe_accum_fill += take;
copied += take;
size_t bytes_read = 0;
uint32_t feeds = 0;
while (s_pipe.capture_run) {
// INMP441 mono I2S: read directly into the feed buffer (1 ch).
// If chunk_channels > 1 (e.g. with reference), this would need
// interleaving — slice-6 single-mic path keeps it simple.
esp_err_t err = i2s_channel_read(s_pipe.rx_chan, feed_buf,
feed_bytes, &bytes_read,
pdMS_TO_TICKS(200));
if (err == ESP_ERR_TIMEOUT) {
continue;
}
if (err != ESP_OK) {
ESP_LOGW(TAG, "i2s read err: %s", esp_err_to_name(err));
vTaskDelay(pdMS_TO_TICKS(200));
continue;
}
if (bytes_read == 0) continue;
if ((int)s_pipe.afe_accum_fill < need) break; // not full yet
// Slice 9: mute-during-TTS gate. While the pipeline is in
// SPEAKING state we keep draining I2S so the DMA buffers
// don't overflow, but we do NOT feed AFE — this prevents
// the speaker output (which leaks back into the mic) from
// re-triggering the wake word during a TTS reply.
if (s_pipe.state == VOICE_STATE_SPEAKING) continue;
// Buffer full — feed AFE.
// INMP441 single-mic: afe_feed_channel_num == 1, so accum IS
// the feed buffer directly (no interleaving needed).
s_pipe.afe_iface->feed(s_pipe.afe_data, s_pipe.afe_accum);
s_pipe.afe_accum_fill = 0;
s_pipe.afe_iface->feed(s_pipe.afe_data, feed_buf);
// Drain anything available without blocking the feed cadence.
// Fetch result (non-blocking).
static uint32_t s_feeds = 0;
afe_fetch_result_t *res = s_pipe.afe_iface->fetch_with_delay(
s_pipe.afe_data, 0);
if (res && res->ret_value == ESP_OK) {
@@ -407,8 +382,8 @@ static void capture_task(void *pv) {
if (s_pipe.wake_cb) {
s_pipe.wake_cb(word, s_pipe.wake_cb_ctx);
}
// Slice 7: open the WS stream as soon as the wake
// fires so the player's first words make it across.
// Slice 7: open WS stream immediately on wake so
// the player's first words are captured.
if (voice_ws_is_configured() && !s_pipe.stream_active) {
if (streaming_begin() != ESP_OK) {
ESP_LOGW(TAG, "streaming_begin failed at wake — "
@@ -418,10 +393,7 @@ static void capture_task(void *pv) {
}
}
// Slice 7: while streaming, push the post-AFE PCM out
// and watch the VAD for end-of-speech. `res->data` is
// the cleaned, single-channel int16 buffer of length
// `afe_fetch_chunk_samples`.
// Slice 7: push post-AFE PCM and watch VAD.
if (s_pipe.stream_active && res->data && res->data_size > 0) {
esp_err_t serr = voice_ws_send_chunk(
res->data, res->data_size / sizeof(int16_t));
@@ -431,16 +403,12 @@ static void capture_task(void *pv) {
streaming_end("send_error");
} else {
s_pipe.streamed_chunks++;
// res->vad_state is a `vad_state_t` (VAD_SILENCE = 0,
// VAD_SPEECH = 1). The legacy `AFE_VAD_*` enum is
// marked deprecated in esp_afe_sr_iface.h.
// res->vad_state: VAD_SILENCE=0, VAD_SPEECH=1
if (res->vad_state == VAD_SILENCE) {
s_pipe.silence_chunks++;
} else {
s_pipe.silence_chunks = 0;
}
if (s_pipe.silence_chunks >= END_SILENCE_CHUNKS) {
streaming_end("vad_silence");
} else if (s_pipe.streamed_chunks >= STREAMING_MAX_CHUNKS) {
@@ -450,39 +418,19 @@ static void capture_task(void *pv) {
}
}
if (++feeds % 100 == 0) {
ESP_LOGD(TAG, "AFE feed heartbeat: %u chunks", (unsigned) feeds);
if (++s_feeds % 100 == 0) {
ESP_LOGD(TAG, "AFE feed heartbeat: %u chunks", (unsigned)s_feeds);
}
}
// Make sure we don't leak an open WS if capture is being torn down.
if (s_pipe.stream_active) {
streaming_end("capture_stop");
}
free(feed_buf);
} else {
// Slice-5 fallback: dumb capture, no detection.
static uint8_t buf[CAPTURE_CHUNK_BYTES];
size_t bytes_read = 0;
uint32_t total = 0;
uint32_t ticks = 0;
ESP_LOGI(TAG, "capture_task: stub mode (no esp-sr)");
while (s_pipe.capture_run) {
esp_err_t err = i2s_channel_read(s_pipe.rx_chan, buf, sizeof(buf),
&bytes_read, pdMS_TO_TICKS(100));
if (err == ESP_OK) {
total += bytes_read;
if (++ticks % 50 == 0) {
ESP_LOGI(TAG, "capture heartbeat: %u bytes total",
(unsigned) total);
}
} else if (err != ESP_ERR_TIMEOUT) {
ESP_LOGW(TAG, "i2s read err: %s", esp_err_to_name(err));
vTaskDelay(pdMS_TO_TICKS(200));
}
// ── Stub path (no esp-sr) ─────────────────────────────────────────
static uint32_t s_total = 0;
static uint32_t s_ticks = 0;
s_total += (uint32_t)(samples * sizeof(int16_t));
if (++s_ticks % 80 == 0) { // ~80 × 20 ms ≈ 1.6 s
ESP_LOGI(TAG, "capture heartbeat: %u bytes total", (unsigned)s_total);
}
}
s_pipe.capture_task = NULL;
vTaskDelete(NULL);
}
esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) {
@@ -497,16 +445,20 @@ esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) {
session_id_init();
esp_err_t err = i2s_setup();
if (err != ESP_OK) {
ESP_LOGW(TAG, "i2s setup failed: %s — staying idle without capture",
// Bring up mic_broker (single I2S RX owner).
// ESP_ERR_INVALID_STATE means the broker is already running (another
// caller initialised it first) — treat as success.
esp_err_t err = mic_broker_init(cfg.i2s_bclk_pin, cfg.i2s_ws_pin,
cfg.i2s_din_pin, 16000);
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "mic_broker_init failed: %s — staying idle without capture",
esp_err_to_name(err));
// Don't fail init: we still want the state machine to be usable so
// the rest of the system (npc_engine, REST surface) can wire calls.
// Don't fail init: state machine stays usable.
s_pipe.ready = true;
s_pipe.state = VOICE_STATE_IDLE;
return ESP_OK;
}
mic_broker_register(MIC_NPC_LISTEN, on_npc_frame, &s_pipe);
// Slice 9: optional I2S TX bring-up for TTS playback. Failure is
// non-fatal — the rest of the voice loop still works.
@@ -528,8 +480,29 @@ esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) {
ESP_LOGW(TAG, "wake_word_setup failed: %s — degrading to stub capture",
esp_err_to_name(we));
// Don't fail init — the rest of the firmware should still
// come up. The capture task will run in slice-5 stub mode.
// come up. The callback will run in slice-5 stub mode.
wake_word_teardown();
} else {
// Broker delivers linear mono; AFE must be configured for 1 channel.
if (s_pipe.afe_feed_channel_num != 1) {
ESP_LOGE(TAG, "AFE feed_channel_num=%d but broker delivers mono (1) — "
"configuration mismatch", s_pipe.afe_feed_channel_num);
wake_word_teardown();
return ESP_ERR_NOT_SUPPORTED;
}
// Allocate the AFE accumulator now that we know chunk sizes.
size_t feed_bytes = (size_t)s_pipe.afe_feed_chunk_samples
* s_pipe.afe_feed_channel_num * sizeof(int16_t);
s_pipe.afe_accum = heap_caps_malloc(feed_bytes,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!s_pipe.afe_accum) {
ESP_LOGW(TAG, "PSRAM alloc for AFE accum failed (%u B) — "
"degrading to stub capture", (unsigned)feed_bytes);
wake_word_teardown();
} else {
s_pipe.afe_accum_fill = 0;
ESP_LOGI(TAG, "AFE accum alloc OK (%u B)", (unsigned)feed_bytes);
}
}
}
@@ -565,28 +538,21 @@ esp_err_t voice_pipeline_init(const voice_pipeline_config_t *config) {
esp_err_t voice_pipeline_start_capture(void) {
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
if (!s_pipe.rx_chan) return ESP_ERR_INVALID_STATE;
if (s_pipe.capture_task) return ESP_OK; // already running
esp_err_t err = i2s_channel_enable(s_pipe.rx_chan);
if (err != ESP_OK) return err;
s_pipe.capture_run = true;
if (xTaskCreate(capture_task, "voice_capture", CAPTURE_TASK_STACK, NULL,
CAPTURE_TASK_PRIO, &s_pipe.capture_task) != pdPASS) {
s_pipe.capture_run = false;
i2s_channel_disable(s_pipe.rx_chan);
return ESP_ERR_NO_MEM;
}
// Reset stale partial accumulator from a previous stop before new session.
s_pipe.afe_accum_fill = 0;
// Route mic frames to the NPC callback via the broker.
mic_broker_set_mode(MIC_NPC_LISTEN);
voice_pipeline_set_state(VOICE_STATE_LISTENING);
return ESP_OK;
}
esp_err_t voice_pipeline_stop_capture(void) {
if (!s_pipe.ready) return ESP_ERR_INVALID_STATE;
if (!s_pipe.capture_task) return ESP_OK;
s_pipe.capture_run = false;
// Task observes the flag and self-deletes on its next tick.
if (s_pipe.rx_chan) {
i2s_channel_disable(s_pipe.rx_chan);
// Stop delivery to the NPC callback.
mic_broker_set_mode(MIC_IDLE);
// Close any in-flight stream so we don't leak a WS connection.
if (s_pipe.stream_active) {
streaming_end("capture_stop");
}
voice_pipeline_set_state(VOICE_STATE_IDLE);
return ESP_OK;
+3
View File
@@ -13,6 +13,7 @@ idf_component_register(
voice_hook_endpoint
game_endpoint
scenario_mesh
local_puzzles
nvs_flash
esp_timer
esp_system
@@ -20,4 +21,6 @@ idf_component_register(
esp_netif
esp_event
espressif__mdns
display_ui
puzzle_state
)
+163 -14
View File
@@ -47,6 +47,13 @@
#include "voice_hook_endpoint.h"
#include "game_endpoint.h"
#include "scenario_mesh.h"
#include "puzzle_state.h"
#include "local_puzzles.h"
#include "qr_puzzle.h"
#include "buttons_input.h"
#include "mic_broker.h"
#include "board_pins_mediakit.h"
#include "display_ui.h"
// Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move
// this to NVS so the field operator can repoint the firmware without a flash.
@@ -60,6 +67,9 @@
static const char *TAG = "zacus_main";
// ─── Puzzle aggregation state ─────────────────────────────────────────────────
static puzzle_state_t s_pstate;
// Soft-AP fallback when no creds in NVS yet.
#define ZACUS_FALLBACK_AP_SSID "zacus-setup"
#define ZACUS_FALLBACK_AP_CHAN 6
@@ -79,7 +89,7 @@ int puzzle_get_battery_pct(void) {
}
int puzzle_get_espnow_peer_count(void) {
return 0;
return scenario_mesh_peer_count();
}
// Slice 6: wake-word callback. Runs on the voice_pipeline capture task,
@@ -402,6 +412,22 @@ void app_main(void) {
ESP_ERROR_CHECK(nvs_err);
ESP_LOGI(TAG, "NVS initialized");
// Phase 1 display: bring up the ST7796 TFT early so the splash is visible
// while the rest of the subsystems initialise. Non-fatal on failure.
esp_err_t disp_err = display_ui_init();
if (disp_err != ESP_OK) {
ESP_LOGW(TAG, "display_ui_init: %s (continuing without display)",
esp_err_to_name(disp_err));
} else {
// Phase 3: mirror QR camera frames to the scene-view viewfinder.
qr_puzzle_set_preview_cb(display_ui_camera_frame);
// 5-way buttons (view toggle + brightness). Non-fatal.
esp_err_t btn_err = buttons_input_init();
if (btn_err != ESP_OK) {
ESP_LOGW(TAG, "buttons_input_init: %s", esp_err_to_name(btn_err));
}
}
bool sta_ok = wifi_bring_up();
ESP_LOGI(TAG, "Wi-Fi up (mode=%s)", sta_ok ? "STA" : "AP-fallback");
@@ -448,6 +474,13 @@ void app_main(void) {
// relay peer registry from NVS so /game/scenario/relay can resolve
// aliases to MACs without a reflash.
seed_relay_peers_from_nvs();
// Task C: give the endpoint access to the master puzzle state so
// POST /game/step and GET /game/puzzle_state become live.
// Called here so s_pstate is valid (puzzle_state_init runs below).
// game_endpoint_set_puzzle_state tolerates being called before
// puzzle_state_init — it only stores the pointer; actual reads
// happen on-demand in the httpd task after boot completes.
game_endpoint_set_puzzle_state(&s_pstate);
}
}
@@ -541,6 +574,30 @@ void app_main(void) {
esp_err_to_name(disp_err));
}
// Task 7: init puzzle aggregation + local puzzle wiring.
puzzle_state_init(&s_pstate);
local_puzzles_init(&s_pstate);
ESP_LOGI(TAG, "puzzle_state + local_puzzles initialised");
// Task 7: mic_broker takes ownership of the Media Kit I2S IN
// pins (3/14/46 per board_pins_mediakit.h) BEFORE
// voice_pipeline_init — the pipeline's own init call then
// no-ops with ESP_ERR_INVALID_STATE, which it tolerates.
// Note (a): a HARD failure here (anything other than
// ESP_ERR_INVALID_STATE) will surface again inside
// voice_pipeline_init — same root cause, two log warnings.
// Note (b): the readout path is also deferred — nothing
// consumes puzzle_state_code yet; that lands with the
// scenario hook slice.
esp_err_t mic_err = mic_broker_init(MIC_PIN_BCLK, MIC_PIN_WS,
MIC_PIN_DIN, 16000);
if (mic_err != ESP_OK && mic_err != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "mic_broker_init: %s", esp_err_to_name(mic_err));
} else {
ESP_LOGI(TAG, "mic_broker initialised (bclk=%d ws=%d din=%d 16kHz)",
MIC_PIN_BCLK, MIC_PIN_WS, MIC_PIN_DIN);
}
voice_pipeline_config_t voice_cfg;
voice_pipeline_default_config(&voice_cfg);
// Slice 6: bring up esp-sr AFE + WakeNet (placeholder
@@ -558,6 +615,16 @@ void app_main(void) {
// sessions, but "hi esp" remains a backup if the phone is
// unplugged or its hook switch fails.
voice_cfg.enable_tts_playback = true;
// Task 7 / Task 0 pin alignment: override voice_pipeline
// defaults (mic 14/15/22, spk 11/12/13) which collide with
// the FNK0102H camera pins. Use Media Kit values from
// board_pins_mediakit.h (mic: 3/14/46, spk: 42/41/1).
voice_cfg.i2s_bclk_pin = MIC_PIN_BCLK; // 3
voice_cfg.i2s_ws_pin = MIC_PIN_WS; // 14
voice_cfg.i2s_din_pin = MIC_PIN_DIN; // 46
voice_cfg.i2s_out_bclk_pin = SPK_PIN_BCLK; // 42
voice_cfg.i2s_out_lrc_pin = SPK_PIN_LRC; // 41
voice_cfg.i2s_out_din_pin = SPK_PIN_DIN; // 1
voice_pipeline_set_wake_callback(on_voice_wake, NULL);
voice_pipeline_set_stt_callback(on_voice_stt, NULL);
esp_err_t voice_err = voice_pipeline_init(&voice_cfg);
@@ -571,6 +638,9 @@ void app_main(void) {
ESP_LOGW(TAG, "voice: wake-word inactive — running "
"in slice-5 stub mode");
}
// Arming is driven by POST /game/step (game_endpoint);
// readout via GET /game/puzzle_state.
}
}
@@ -580,19 +650,98 @@ void app_main(void) {
ota_server_mark_valid();
ESP_LOGI(TAG, "entering idle loop (heartbeat every 60 s)");
uint32_t tick = 0;
uint32_t tick = 0;
uint32_t disp_sub = 0; // sub-counter: display refresh every 2 s
for (;;) {
vTaskDelay(pdMS_TO_TICKS(60000));
tick++;
const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();
ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s",
(unsigned) tick,
(unsigned long long) (uptime_ms / 1000));
// Drive the slice-3/4 subsystems from the heartbeat. Once we have
// a real game loop these will move to a dedicated FreeRTOS task
// running at ~5 Hz; for now 60 s is enough to keep mood + media
// simulation state coherent without spamming the log.
media_manager_update(uptime_ms);
npc_engine_update(uptime_ms);
// Sleep in 2 s increments so the display stays responsive.
// Every 30 sub-ticks (~60 s) we do the full heartbeat log.
// 500 ms so scene changes (POST /game/step) reach the display fast.
vTaskDelay(pdMS_TO_TICKS(500));
disp_sub++;
// ── Display status refresh (every 500 ms) ───────────────────────
if (disp_err == ESP_OK) {
display_status_t ds;
memset(&ds, 0, sizeof(ds));
// IP address (STA only; empty when in AP mode)
esp_netif_t *sta_if = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta_if) {
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(sta_if, &ip_info) == ESP_OK &&
ip_info.ip.addr != 0) {
snprintf(ds.ip, sizeof(ds.ip), IPSTR,
IP2STR(&ip_info.ip));
}
}
// Wake-word detector state
ds.wake_active = voice_pipeline_wake_word_active();
// Step + armed type from game_endpoint
game_endpoint_get_puzzle_status(ds.step_id, sizeof(ds.step_id),
ds.armed, sizeof(ds.armed));
// Solved count + assembled code from puzzle_state
ds.solved_count = 0;
for (int i = 1; i <= PUZZLE_MAX_ID; i++) {
if (s_pstate.solved[i]) {
ds.solved_count++;
}
}
puzzle_state_code(&s_pstate, ds.code, sizeof(ds.code));
// Scene metadata (title/subtitle/symbol/effect) of the step.
scene_binding_t sb;
game_endpoint_get_scene(&sb);
if (sb.present) {
memcpy(ds.scene_title, sb.title, sizeof(ds.scene_title));
memcpy(ds.scene_subtitle, sb.subtitle, sizeof(ds.scene_subtitle));
memcpy(ds.scene_symbol, sb.symbol, sizeof(ds.scene_symbol));
}
ds.scene_effect = (uint8_t) sb.effect;
display_ui_set_status(&ds);
// Shell app launch (dynamic tiles from /littlefs/apps/<id>/):
// the app's step.txt names the scenario step to arm.
char app_id[32];
if (display_ui_take_pending_launch(app_id, sizeof(app_id))) {
char path[80], step[64] = {0};
snprintf(path, sizeof(path), "/littlefs/apps/%s/step.txt",
app_id);
FILE *af = fopen(path, "r");
if (af) {
if (fgets(step, sizeof(step), af)) {
step[strcspn(step, "\r\n")] = '\0';
}
fclose(af);
}
if (step[0]) {
char armed[8];
esp_err_t lerr = game_endpoint_apply_step(step, armed,
sizeof(armed));
ESP_LOGI(TAG, "app '%s' -> step '%s': %s (armed=%s)",
app_id, step, esp_err_to_name(lerr),
(lerr == ESP_OK) ? armed : "-");
} else {
ESP_LOGW(TAG, "app '%s': no step.txt — nothing to do",
app_id);
}
}
}
// ── 60 s heartbeat (every 120 × 500 ms sub-ticks) ───────────────
if (disp_sub >= 120) {
disp_sub = 0;
tick++;
const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();
ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s",
(unsigned) tick,
(unsigned long long) (uptime_ms / 1000));
// Drive the slice-3/4 subsystems from the heartbeat.
media_manager_update(uptime_ms);
npc_engine_update(uptime_ms);
}
}
}
+13 -7
View File
@@ -5,21 +5,27 @@
# esp-sr component CMake glue).
#
# Total target: 16 MB flash (Freenove ESP32-S3 N16R8).
# Layout (rough): 0x000000-0x020000 system, 0x020000-0x620000 apps (3×2 MB),
# 0x620000-0x720000 model (SPIFFS 1 MB, esp-sr models),
# 0x720000-0xC20000 storage (LittleFS 5 MB),
# 0xC20000-0x1000000 unused (~3.87 MB headroom).
# Layout (rough): 0x000000-0x020000 system, 0x020000-0x920000 apps (3×3 MB),
# 0x920000-0xA20000 model (SPIFFS 1 MB, esp-sr models),
# 0xA20000-0xF20000 storage (LittleFS 5 MB),
# 0xF20000-0x1000000 unused (~0.87 MB headroom).
#
# App partition bumped 1.5 MB → 2 MB (2026-05-03) to give voice/STT/hints
# slices and esp-sr integration ~25 % free space margin. OTA dual-bank
# preserved (factory + ota_0 + ota_1 all 2 MB → rollback symmetry intact).
#
# Bumped 2 MB → 3 MB (2026-06-10, display_ui phase 2b): LVGL + LovyanGFX +
# the original UI fonts left only 5 % app headroom; later UI phases (camera
# preview, more fonts, effects) need room. NVS/otadata/phy offsets unchanged
# (Wi-Fi creds survive); ota_0/ota_1/model/storage shift — LittleFS content
# is lost on reflash (auto-reformats; re-push scenario via POST /game/scenario).
#
# 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, 0x200000,
ota_0, app, ota_0, , 0x200000,
ota_1, app, ota_1, , 0x200000,
factory, app, factory, 0x20000, 0x300000,
ota_0, app, ota_0, , 0x300000,
ota_1, app, ota_1, , 0x300000,
model, data, spiffs, , 0x100000,
storage, data, littlefs,, 0x500000,
1 # Zacus master partition table — merges ota_server's OTA layout (factory +
5 # esp-sr component CMake glue).
6 #
7 # Total target: 16 MB flash (Freenove ESP32-S3 N16R8).
8 # Layout (rough): 0x000000-0x020000 system, 0x020000-0x620000 apps (3×2 MB), # Layout (rough): 0x000000-0x020000 system, 0x020000-0x920000 apps (3×3 MB),
9 # 0x620000-0x720000 model (SPIFFS 1 MB, esp-sr models), # 0x920000-0xA20000 model (SPIFFS 1 MB, esp-sr models),
10 # 0x720000-0xC20000 storage (LittleFS 5 MB), # 0xA20000-0xF20000 storage (LittleFS 5 MB),
11 # 0xC20000-0x1000000 unused (~3.87 MB headroom). # 0xF20000-0x1000000 unused (~0.87 MB headroom).
12 #
13 # App partition bumped 1.5 MB → 2 MB (2026-05-03) to give voice/STT/hints
14 # slices and esp-sr integration ~25 % free space margin. OTA dual-bank
15 # preserved (factory + ota_0 + ota_1 all 2 MB → rollback symmetry intact).
16 #
17 # Bumped 2 MB → 3 MB (2026-06-10, display_ui phase 2b): LVGL + LovyanGFX +
18 # the original UI fonts left only 5 % app headroom; later UI phases (camera
19 # preview, more fonts, effects) need room. NVS/otadata/phy offsets unchanged
20 # (Wi-Fi creds survive); ota_0/ota_1/model/storage shift — LittleFS content
21 # is lost on reflash (auto-reformats; re-push scenario via POST /game/scenario).
22 #
23 # Name, Type, SubType, Offset, Size, Flags
24 nvs, data, nvs, 0x9000, 0x6000,
25 otadata, data, ota, 0xf000, 0x2000,
26 phy_init, data, phy, 0x11000, 0x1000,
27 factory, app, factory, 0x20000, 0x200000, factory, app, factory, 0x20000, 0x300000,
28 ota_0, app, ota_0, , 0x200000, ota_0, app, ota_0, , 0x300000,
29 ota_1, app, ota_1, , 0x200000, ota_1, app, ota_1, , 0x300000,
30 model, data, spiffs, , 0x100000,
31 storage, data, littlefs,, 0x500000,
+12
View File
@@ -58,3 +58,15 @@ CONFIG_SR_MN_CN_NONE=y
CONFIG_SR_MN_EN_NONE=y
CONFIG_MODEL_IN_FLASH=y
CONFIG_AFE_INTERFACE_V1=y
# LVGL (display_ui phase 2a) — mirrors original ui_freenove_allinone lv_conf.h:
# depth 16 + swap 0 are lvgl defaults; Montserrat 24/28 enabled. Mem pool 48KB
CONFIG_LV_MEM_SIZE_KILOBYTES=48
CONFIG_LV_FONT_MONTSERRAT_24=y
CONFIG_LV_FONT_MONTSERRAT_28=y
# LVGL file access + PNG icons (shell apps from littlefs)
CONFIG_LV_USE_FS_STDIO=y
CONFIG_LV_FS_STDIO_LETTER=76
CONFIG_LV_FS_STDIO_PATH="/littlefs"
CONFIG_LV_USE_PNG=y
+31
View File
@@ -73,6 +73,10 @@ esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]);
// ESP_ERR_NOT_FOUND otherwise.
esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]);
// Number of peers currently registered in the live table (broadcast peer
// excluded). Backs the espnow_peers health counter.
int scenario_mesh_peer_count(void);
// Chunk `data` (len bytes) into frames and send them all sequentially to
// `dest_mac`, awaiting the per-frame ack. Returns ESP_OK only if every frame
// was acked; ESP_ERR_TIMEOUT if any frame ack timed out, or the underlying
@@ -81,6 +85,33 @@ esp_err_t scenario_mesh_mac_for_alias(const char *alias, uint8_t mac_out[6]);
esp_err_t scenario_mesh_send(const uint8_t dest_mac[6],
const char *data, size_t len);
// ─── short text frames: commands & events (spec 2026-06-11) ────────────────
//
// Single-frame messages riding the same ESP-NOW link as IR transfers. Wire
// compat: header seq=0xFFFF (sentinel) + total=kind — old receivers drop
// them via their `seq >= total` malformed check. Payload: UTF-8 text,
// NUL excluded, <= SCENARIO_MESH_TEXT_MAX bytes.
#define SCENARIO_MESH_TEXT_CMD 1 // master -> annex: do something
#define SCENARIO_MESH_TEXT_EVT 2 // annex -> master: something happened
#define SCENARIO_MESH_TEXT_MAX 200
// kind, sender MAC, NUL-terminated text. Runs on a dedicated worker task
// (never the Wi-Fi callback); keep it reasonably short anyway.
typedef void (*scenario_mesh_text_cb_t)(uint8_t kind,
const uint8_t src_mac[6],
const char *text);
// Send one CMD/EVT frame (kind = SCENARIO_MESH_TEXT_*). Unicast awaits the
// radio ack (ESP_ERR_TIMEOUT if the peer is silent); broadcast returns once
// transmitted. dest_mac may be the broadcast address FF:FF:FF:FF:FF:FF.
esp_err_t scenario_mesh_send_text(const uint8_t dest_mac[6], uint8_t kind,
const char *text);
// Install (or replace, NULL to clear) the receive handler for CMD/EVT
// frames. Spawns the dispatch worker on first use.
esp_err_t scenario_mesh_set_text_cb(scenario_mesh_text_cb_t cb);
#ifdef __cplusplus
}
#endif
+112
View File
@@ -72,6 +72,30 @@ typedef struct {
static QueueHandle_t s_apply_queue;
// ─── CMD/EVT text frames (header sentinel seq=0xFFFF, total=kind) ───────────
#define MESH_TEXT_SEQ_SENTINEL 0xFFFFu
typedef struct {
uint8_t kind;
uint8_t src[6];
char text[SCENARIO_MESH_TEXT_MAX + 1];
} mesh_text_job_t;
static scenario_mesh_text_cb_t s_text_cb;
static QueueHandle_t s_text_queue;
static void text_worker_task(void *arg) {
(void) arg;
mesh_text_job_t job;
for (;;) {
if (xQueueReceive(s_text_queue, &job, portMAX_DELAY) == pdTRUE) {
scenario_mesh_text_cb_t cb = s_text_cb;
if (cb) cb(job.kind, job.src, job.text);
}
}
}
static void apply_worker_task(void *arg) {
(void) arg;
mesh_apply_job_t job;
@@ -93,6 +117,14 @@ static void apply_worker_task(void *arg) {
// ─── peer table helpers ─────────────────────────────────────────────────────
int scenario_mesh_peer_count(void) {
int n = 0;
for (int i = 0; i < MESH_PEERS_MAX; i++) {
if (s_peers[i].used) n++;
}
return n;
}
esp_err_t scenario_mesh_register_peer(const char *alias, const uint8_t mac[6]) {
if (!alias || !mac) return ESP_ERR_INVALID_ARG;
@@ -212,6 +244,22 @@ static void on_recv(const esp_now_recv_info_t *info,
uint16_t total = (uint16_t) (data[2] | (data[3] << 8));
const uint8_t *payload = data + SCENARIO_MESH_HEADER_BYTES;
int payload_len = len - SCENARIO_MESH_HEADER_BYTES;
// CMD/EVT text frame: sentinel seq + kind in `total`. Old receivers fall
// through to the malformed check below and drop it silently.
if (seq == MESH_TEXT_SEQ_SENTINEL &&
(total == SCENARIO_MESH_TEXT_CMD || total == SCENARIO_MESH_TEXT_EVT)) {
if (!s_text_queue || payload_len > SCENARIO_MESH_TEXT_MAX) return;
mesh_text_job_t job;
job.kind = (uint8_t) total;
memcpy(job.src, info->src_addr, 6);
memcpy(job.text, payload, payload_len);
job.text[payload_len] = '\0';
BaseType_t hp = pdFALSE;
xQueueSendFromISR(s_text_queue, &job, &hp);
if (hp) portYIELD_FROM_ISR();
return;
}
if (seq >= total) return; // malformed
if (!s_reasm_lock) return;
@@ -387,3 +435,67 @@ esp_err_t scenario_mesh_send(const uint8_t dest_mac[6],
}
return result;
}
// ─── CMD/EVT text API ───────────────────────────────────────────────────────
esp_err_t scenario_mesh_set_text_cb(scenario_mesh_text_cb_t cb) {
if (!s_text_queue) {
s_text_queue = xQueueCreate(8, sizeof(mesh_text_job_t));
if (!s_text_queue) return ESP_ERR_NO_MEM;
BaseType_t ok = xTaskCreate(text_worker_task, "scn_mesh_text",
3072, NULL, tskIDLE_PRIORITY + 2, NULL);
if (ok != pdPASS) {
vQueueDelete(s_text_queue);
s_text_queue = NULL;
return ESP_ERR_NO_MEM;
}
}
s_text_cb = cb;
return ESP_OK;
}
esp_err_t scenario_mesh_send_text(const uint8_t dest_mac[6], uint8_t kind,
const char *text) {
if (!dest_mac || !text ||
(kind != SCENARIO_MESH_TEXT_CMD && kind != SCENARIO_MESH_TEXT_EVT)) {
return ESP_ERR_INVALID_ARG;
}
size_t len = strlen(text);
if (len == 0 || len > SCENARIO_MESH_TEXT_MAX) return ESP_ERR_INVALID_SIZE;
const bool broadcast = (memcmp(dest_mac, kBroadcast, 6) == 0);
if (xSemaphoreTake(s_send_lock, portMAX_DELAY) != pdTRUE) return ESP_FAIL;
uint8_t frame[SCENARIO_MESH_FRAME_MAX];
frame[0] = (uint8_t) (MESH_TEXT_SEQ_SENTINEL & 0xFF);
frame[1] = (uint8_t) ((MESH_TEXT_SEQ_SENTINEL >> 8) & 0xFF);
frame[2] = kind;
frame[3] = 0;
memcpy(frame + SCENARIO_MESH_HEADER_BYTES, text, len);
xSemaphoreTake(s_send_done, 0); // drain stale ack
s_last_send_status = ESP_NOW_SEND_FAIL;
esp_err_t result = esp_now_send(dest_mac, frame,
SCENARIO_MESH_HEADER_BYTES + len);
if (result == ESP_OK) {
if (xSemaphoreTake(s_send_done,
pdMS_TO_TICKS(SCENARIO_MESH_ACK_TIMEOUT_MS))
!= pdTRUE) {
result = ESP_ERR_TIMEOUT;
} else if (!broadcast &&
s_last_send_status != ESP_NOW_SEND_SUCCESS) {
result = ESP_ERR_TIMEOUT; // unicast: peer silent
}
}
xSemaphoreGive(s_send_lock);
ESP_LOGI(TAG, "text %s \"%s\" -> %02x:%02x:%02x:%02x:%02x:%02x: %s",
kind == SCENARIO_MESH_TEXT_CMD ? "CMD" : "EVT", text,
dest_mac[0], dest_mac[1], dest_mac[2],
dest_mac[3], dest_mac[4], dest_mac[5],
esp_err_to_name(result));
return result;
}