fix(box3): SPIFFS mount + canal WiFi configurable (relais ESP-NOW) #8
@@ -62,3 +62,4 @@ dependencies.lock
|
||||
sdkconfig
|
||||
.cache/
|
||||
sdkconfig.old
|
||||
.worktrees/
|
||||
|
||||
@@ -8,10 +8,18 @@ Workspace firmware centré sur une seule cible: Freenove FNK0102H avec `ui_freen
|
||||
- `docs/FNK0102H_SOURCE_OF_TRUTH.md`: matrice board/config/pins/support.
|
||||
- `README_ESP32_ZACUS.md`: notes runtime et signatures série utiles.
|
||||
|
||||
## Deux firmwares
|
||||
|
||||
- `ui_freenove_allinone/` — firmware **Arduino/PlatformIO** historique (UI/audio/caméra/réseau), chemin release stabilisé.
|
||||
- `idf_zacus/` — firmware **master ESP-IDF 5.4** (migration en cours, branche `feat/idf-migration`) : voix NPC (esp-sr wakeword + bridge WS), **énigmes locales** P1 son / P3 QR (caméra OV3660 + micro), **écran LVGL** (vue scène, viewfinder, shell Workbench, intro cracktro), et une **surface REST de jeu** (`/game/step`, `/game/puzzle_state`, `/game/file`). Voir `idf_zacus/README.md`.
|
||||
- `box3_voice/` — firmware annexe **ESP32-S3-BOX-3** : pipeline voix + **générateur de stimulus** (`/stim/qr`, `/stim/melody`) pour exercer les énigmes du master. Voir `box3_voice/README.md`.
|
||||
|
||||
## Arborescence utile
|
||||
|
||||
- `platformio.ini`: env PlatformIO canonique `freenove_esp32s3_full_with_ui`.
|
||||
- `ui_freenove_allinone/`: firmware UI/audio/caméra/réseau.
|
||||
- `ui_freenove_allinone/`: firmware Arduino UI/audio/caméra/réseau.
|
||||
- `idf_zacus/`: firmware master ESP-IDF (énigmes locales, écran LVGL, REST de jeu).
|
||||
- `box3_voice/`: firmware BOX-3 (voix + générateur de stimulus QR/mélodie).
|
||||
- `data/`: contenu LittleFS.
|
||||
- `lib/`: bibliothèques runtime.
|
||||
- `scripts/`: bootstrap et scripts repo.
|
||||
|
||||
@@ -54,3 +54,67 @@ Use `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**:
|
||||
|
|
||||
hints engine <--+
|
||||
```
|
||||
|
||||
## Générateur de stimulus (QR + mélodie)
|
||||
|
||||
En plus du pipeline vocal, la BOX-3 sert de **source de stimulus** pour le
|
||||
master Freenove. Elle affiche un QR code (lu par la caméra du master) et joue
|
||||
une mélodie (entendue par le micro du master). Cela permet de tester les
|
||||
énigmes locales P1 (son) et P3 (QR) du master de bout en bout, sans QR imprimé
|
||||
ni instrument de musique.
|
||||
|
||||
Les routes sont enregistrées sur le serveur HTTP existant (`scenario_server`)
|
||||
au démarrage, juste après l'init de l'écran et de l'UI (`stimulus_init()` puis
|
||||
`stimulus_register_routes()` dans `app_main`).
|
||||
|
||||
### `POST /stim/qr`
|
||||
|
||||
```json
|
||||
{"text": "zacus-qr-1"}
|
||||
```
|
||||
|
||||
Affiche le texte en QR plein écran : un widget `lv_qrcode` de **160 px** sur une
|
||||
page LVGL dédiée, chargée au premier plan. Le rétroéclairage est **atténué à
|
||||
35 %** pour réduire le halo de l'écran émissif et préserver le contraste pour la
|
||||
caméra du master.
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.1.50/stim/qr \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"text":"zacus-qr-1"}'
|
||||
# {"status":"ok","text":"zacus-qr-1"}
|
||||
```
|
||||
|
||||
### `POST /stim/melody`
|
||||
|
||||
```json
|
||||
{"notes": [60, 62, 64, 65], "ms": 400}
|
||||
```
|
||||
|
||||
Joue la séquence de notes **MIDI** au haut-parleur. `notes` est un tableau
|
||||
d'entiers `0-127` (60 = do central), `ms` est la durée par note en
|
||||
millisecondes, bornée à `1-4000` (défaut 400). La séquence est jouée sur une
|
||||
**tâche worker** dédiée, donc la réponse HTTP revient immédiatement. Maximum
|
||||
32 notes ; les notes hors plage sont ignorées.
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.1.50/stim/melody \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"notes":[60,62,64,65,67],"ms":300}'
|
||||
# {"status":"ok","notes":5}
|
||||
```
|
||||
|
||||
### Note de terrain
|
||||
|
||||
Le décodage par la caméra du master du **QR affiché sur l'écran LCD n'est pas
|
||||
fiable** : le contraste émissif est insuffisant pour quirc, qui ne retrouve pas
|
||||
les motifs de repérage (finder patterns) quelle que soit la résolution, même
|
||||
écran atténué. **La mélodie est le chemin de stimulus le plus robuste.** Pour
|
||||
P3, le **QR imprimé reste recommandé** ; le `/stim/qr` sert surtout au cadrage
|
||||
et au débogage de la caméra.
|
||||
|
||||
### Configuration Wi-Fi
|
||||
|
||||
Le générateur est joignable sur l'IP de la BOX-3 en mode station. Configurez le
|
||||
réseau via `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**, ou
|
||||
directement par `CONFIG_ZACUS_WIFI_SSID` / `CONFIG_ZACUS_WIFI_PASSWORD`.
|
||||
|
||||
@@ -1,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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// stimulus.c — QR display + melody playback, driven over REST.
|
||||
#include "stimulus.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "bsp/esp-box-3.h"
|
||||
#include "lvgl.h"
|
||||
#include "libs/qrcode/lv_qrcode.h"
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "stimulus";
|
||||
#define LOCK_MS 1000
|
||||
|
||||
static lv_obj_t *s_qr_screen; // dedicated fullscreen QR screen
|
||||
static lv_obj_t *s_qr; // lv_qrcode widget
|
||||
static lv_obj_t *s_qr_caption;
|
||||
|
||||
esp_err_t stimulus_init(void) {
|
||||
if (!bsp_display_lock(LOCK_MS)) return ESP_FAIL;
|
||||
|
||||
s_qr_screen = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_qr_screen, lv_color_white(), 0);
|
||||
|
||||
// 160 px QR on the 320x240 LCD — leaves ~40 px white top/bottom and
|
||||
// ~80 px sides as the quiet zone quirc needs to lock the finder patterns
|
||||
// (220 px starved the vertical quiet zone and blocked detection).
|
||||
s_qr = lv_qrcode_create(s_qr_screen);
|
||||
lv_qrcode_set_size(s_qr, 160);
|
||||
lv_qrcode_set_dark_color(s_qr, lv_color_black());
|
||||
lv_qrcode_set_light_color(s_qr, lv_color_white());
|
||||
lv_qrcode_update(s_qr, "zacus", 5);
|
||||
lv_obj_align(s_qr, LV_ALIGN_CENTER, 0, -10);
|
||||
|
||||
s_qr_caption = lv_label_create(s_qr_screen);
|
||||
lv_obj_set_style_text_color(s_qr_caption, lv_color_black(), 0);
|
||||
lv_label_set_text(s_qr_caption, "");
|
||||
lv_obj_align(s_qr_caption, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
|
||||
bsp_display_unlock();
|
||||
ESP_LOGI(TAG, "stimulus QR screen ready");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void show_qr(const char *text) {
|
||||
if (!s_qr || !bsp_display_lock(LOCK_MS)) return;
|
||||
lv_qrcode_update(s_qr, text, strlen(text));
|
||||
lv_label_set_text(s_qr_caption, text);
|
||||
lv_screen_load(s_qr_screen); // bring the QR to the front
|
||||
bsp_display_unlock();
|
||||
// Dim the backlight hard: an emissive LCD at full brightness blooms and
|
||||
// crushes QR contrast for the master's camera. ~35% keeps the modules
|
||||
// readable while killing the glare halo.
|
||||
bsp_display_brightness_set(35);
|
||||
ESP_LOGI(TAG, "QR shown (dimmed): %s", text);
|
||||
}
|
||||
|
||||
// MIDI note -> frequency (A4=69=440 Hz).
|
||||
static float midi_to_hz(int note) {
|
||||
return 440.0f * powf(2.0f, (float)(note - 69) / 12.0f);
|
||||
}
|
||||
|
||||
// ─── REST ────────────────────────────────────────────────────────────────────
|
||||
|
||||
static esp_err_t read_body(httpd_req_t *req, char *buf, size_t cap) {
|
||||
if (req->content_len <= 0 || (size_t)req->content_len >= cap) return ESP_FAIL;
|
||||
int total = 0;
|
||||
while (total < (int)req->content_len) {
|
||||
int got = httpd_req_recv(req, buf + total, req->content_len - total);
|
||||
if (got <= 0) {
|
||||
if (got == HTTPD_SOCK_ERR_TIMEOUT) continue;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
total += got;
|
||||
}
|
||||
buf[total] = '\0';
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t reply(httpd_req_t *req, const char *status, const char *json) {
|
||||
httpd_resp_set_status(req, status);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_sendstr(req, json);
|
||||
}
|
||||
|
||||
static esp_err_t handle_qr_post(httpd_req_t *req) {
|
||||
char body[256];
|
||||
if (read_body(req, body, sizeof(body)) != ESP_OK) {
|
||||
return reply(req, "400 Bad Request", "{\"error\":\"body\"}");
|
||||
}
|
||||
cJSON *root = cJSON_Parse(body);
|
||||
const cJSON *t = root ? cJSON_GetObjectItemCaseSensitive(root, "text") : NULL;
|
||||
if (!cJSON_IsString(t) || !t->valuestring[0]) {
|
||||
cJSON_Delete(root);
|
||||
return reply(req, "400 Bad Request", "{\"error\":\"text required\"}");
|
||||
}
|
||||
show_qr(t->valuestring);
|
||||
char resp[280];
|
||||
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"text\":\"%s\"}",
|
||||
t->valuestring);
|
||||
cJSON_Delete(root);
|
||||
return reply(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
// Melody played on a worker task so the HTTP response returns immediately.
|
||||
typedef struct { float hz[32]; int count; int note_ms; } melody_job_t;
|
||||
|
||||
static void melody_task(void *arg) {
|
||||
melody_job_t *job = (melody_job_t *)arg;
|
||||
for (int i = 0; i < job->count; i++) {
|
||||
audio_play_tone(job->hz[i], job->note_ms);
|
||||
vTaskDelay(pdMS_TO_TICKS(40)); // brief gap = note onset for the mic
|
||||
}
|
||||
free(job);
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static esp_err_t handle_melody_post(httpd_req_t *req) {
|
||||
char body[512];
|
||||
if (read_body(req, body, sizeof(body)) != ESP_OK) {
|
||||
return reply(req, "400 Bad Request", "{\"error\":\"body\"}");
|
||||
}
|
||||
cJSON *root = cJSON_Parse(body);
|
||||
const cJSON *notes = root ? cJSON_GetObjectItemCaseSensitive(root, "notes") : NULL;
|
||||
if (!cJSON_IsArray(notes) || cJSON_GetArraySize(notes) == 0) {
|
||||
cJSON_Delete(root);
|
||||
return reply(req, "400 Bad Request", "{\"error\":\"notes required\"}");
|
||||
}
|
||||
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(root, "ms");
|
||||
const int note_ms = cJSON_IsNumber(ms) ? ms->valueint : 400;
|
||||
|
||||
melody_job_t *job = calloc(1, sizeof(*job));
|
||||
if (!job) { cJSON_Delete(root); return reply(req, "500 Internal Server Error", "{\"error\":\"oom\"}"); }
|
||||
job->note_ms = (note_ms > 0 && note_ms <= 4000) ? note_ms : 400;
|
||||
const cJSON *n;
|
||||
cJSON_ArrayForEach(n, notes) {
|
||||
if (job->count >= 32) break;
|
||||
if (cJSON_IsNumber(n) && n->valueint >= 0 && n->valueint <= 127) {
|
||||
job->hz[job->count++] = midi_to_hz(n->valueint);
|
||||
}
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
if (job->count == 0) { free(job); return reply(req, "400 Bad Request", "{\"error\":\"no valid notes\"}"); }
|
||||
|
||||
if (xTaskCreate(melody_task, "melody", 4096, job, 5, NULL) != pdPASS) {
|
||||
free(job);
|
||||
return reply(req, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
}
|
||||
char resp[64];
|
||||
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"notes\":%d}", job->count);
|
||||
return reply(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
esp_err_t stimulus_register_routes(httpd_handle_t server) {
|
||||
static const httpd_uri_t uri_qr = {
|
||||
.uri = "/stim/qr", .method = HTTP_POST,
|
||||
.handler = handle_qr_post, .user_ctx = NULL,
|
||||
};
|
||||
static const httpd_uri_t uri_melody = {
|
||||
.uri = "/stim/melody", .method = HTTP_POST,
|
||||
.handler = handle_melody_post, .user_ctx = NULL,
|
||||
};
|
||||
esp_err_t e = httpd_register_uri_handler(server, &uri_qr);
|
||||
if (e == ESP_OK) e = httpd_register_uri_handler(server, &uri_melody);
|
||||
if (e == ESP_OK) ESP_LOGI(TAG, "routes up: POST /stim/qr, POST /stim/melody");
|
||||
return e;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// stimulus.h — BOX-3 as a stimulus generator for the Freenove master's
|
||||
// camera (QR) and microphone (melody). Lets the master's local puzzles be
|
||||
// exercised end-to-end without printed cards or an instrument.
|
||||
//
|
||||
// REST (registered on the existing scenario_server httpd):
|
||||
// POST /stim/qr {"text":"zacus-qr-1"} -> show QR fullscreen
|
||||
// POST /stim/melody {"notes":[60,62,64,65],"ms":400} -> play melody
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Provided by main.c — play one tone (blocking) on the BOX-3 speaker.
|
||||
void audio_play_tone(float frequency, int duration_ms);
|
||||
|
||||
// Build the QR screen (under bsp_display_lock). Call once after the display
|
||||
// and UI are up.
|
||||
esp_err_t stimulus_init(void);
|
||||
|
||||
// Register /stim/* routes on an existing server.
|
||||
esp_err_t stimulus_register_routes(httpd_handle_t server);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -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
@@ -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, D0–D7 = 11/9/8/10/12/18/17/16.
|
||||
- **Micro I2S IN** — BCLK 3, WS 14, DIN 46.
|
||||
- **HP I2S OUT** (MAX98357A) — BCLK 42, LRC 41, DOUT 1.
|
||||
- **Écran ST7796 (SPI2/FSPI, 80 MHz)** — SCK 47, MOSI 21, DC 45, RST 20, BL 2.
|
||||
|
||||
> Les défauts du composant `voice_pipeline` (mic 14/15/22, HP 11/12/13)
|
||||
> **entrent en collision** avec les pins caméra ; `main.c` les écrase par les
|
||||
> valeurs Media Kit ci-dessus. Ne pas réintroduire les défauts.
|
||||
|
||||
### Validateurs testables sur hôte
|
||||
|
||||
`seq_validator` et `melody_validator` sont de la logique pure, sans I/O,
|
||||
compilables et testables sur la machine de dev :
|
||||
|
||||
```bash
|
||||
make -C idf_zacus/components/local_puzzles/test/host test
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 ~5–15 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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user