docs: refresh firmware READMEs (puzzles+UI+REST)

This commit is contained in:
Claude Worker claude2
2026-06-11 02:56:47 +02:00
parent ed336967a1
commit 6d7b670256
6 changed files with 803 additions and 108 deletions
+64
View File
@@ -54,3 +54,67 @@ Use `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**:
|
hints engine <--+
```
## Générateur de stimulus (QR + mélodie)
En plus du pipeline vocal, la BOX-3 sert de **source de stimulus** pour le
master Freenove. Elle affiche un QR code (lu par la caméra du master) et joue
une mélodie (entendue par le micro du master). Cela permet de tester les
énigmes locales P1 (son) et P3 (QR) du master de bout en bout, sans QR imprimé
ni instrument de musique.
Les routes sont enregistrées sur le serveur HTTP existant (`scenario_server`)
au démarrage, juste après l'init de l'écran et de l'UI (`stimulus_init()` puis
`stimulus_register_routes()` dans `app_main`).
### `POST /stim/qr`
```json
{"text": "zacus-qr-1"}
```
Affiche le texte en QR plein écran : un widget `lv_qrcode` de **160 px** sur une
page LVGL dédiée, chargée au premier plan. Le rétroéclairage est **atténué à
35 %** pour réduire le halo de l'écran émissif et préserver le contraste pour la
caméra du master.
```bash
curl -X POST http://192.168.1.50/stim/qr \
-H 'Content-Type: application/json' \
-d '{"text":"zacus-qr-1"}'
# {"status":"ok","text":"zacus-qr-1"}
```
### `POST /stim/melody`
```json
{"notes": [60, 62, 64, 65], "ms": 400}
```
Joue la séquence de notes **MIDI** au haut-parleur. `notes` est un tableau
d'entiers `0-127` (60 = do central), `ms` est la durée par note en
millisecondes, bornée à `1-4000` (défaut 400). La séquence est jouée sur une
**tâche worker** dédiée, donc la réponse HTTP revient immédiatement. Maximum
32 notes ; les notes hors plage sont ignorées.
```bash
curl -X POST http://192.168.1.50/stim/melody \
-H 'Content-Type: application/json' \
-d '{"notes":[60,62,64,65,67],"ms":300}'
# {"status":"ok","notes":5}
```
### Note de terrain
Le décodage par la caméra du master du **QR affiché sur l'écran LCD n'est pas
fiable** : le contraste émissif est insuffisant pour quirc, qui ne retrouve pas
les motifs de repérage (finder patterns) quelle que soit la résolution, même
écran atténué. **La mélodie est le chemin de stimulus le plus robuste.** Pour
P3, le **QR imprimé reste recommandé** ; le `/stim/qr` sert surtout au cadrage
et au débogage de la caméra.
### Configuration Wi-Fi
Le générateur est joignable sur l'IP de la BOX-3 en mode station. Configurez le
réseau via `idf.py menuconfig` > **Zacus BOX-3 Voice Configuration**, ou
directement par `CONFIG_ZACUS_WIFI_SSID` / `CONFIG_ZACUS_WIFI_PASSWORD`.
+201 -52
View File
@@ -1,79 +1,228 @@
# `idf_zacus/` — Zacus master ESP-IDF scaffold
# `idf_zacus/` — firmware master Zacus (ESP-IDF)
This tree is the future home of the Zacus master firmware. It is the **P1
first slice** of the voice pipeline migration described in
`docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md`.
Firmware **master** du jeu *Le Mystère du Professeur Zacus*, pour la carte
**Freenove FNK0102H — ESP32-S3 WROOM N16R8** (16 MB flash, 8 MB PSRAM octale).
The Arduino firmware in `../ui_freenove_allinone/` keeps running unchanged
during the transition; this scaffold lives side-by-side until feature parity
is reached.
Ce n'est plus une *slice* de migration : c'est le master complet. Le firmware
réunit aujourd'hui :
## Prerequisites
- **Voix NPC** — capture I2S + esp-sr (AFE + WakeNet9, mot-clé placeholder
`wn9_hiesp` / « Hi ESP »), pont WebSocket vers le voice-bridge MacStudio pour
la STT et le retour TTS, plus un *hook* REST PLIP (téléphone rétro).
- **Énigmes locales** caméra et micro résolues sur l'appareil (P1 son /
P3 QR), agrégées en un code de sortie.
- **Écran LVGL** (ST7796 320×480) : vue statut, vue scène, viewfinder caméra,
shell Workbench, navigation 5 directions.
- **Surface REST de jeu** (pilotage scénario, étapes, profil de groupe,
relais ESP-NOW) et **serveur OTA** double-banque.
- ESP-IDF v5.4 or v5.5 installed under `~/esp/esp-idf/`.
- Source the IDF environment in each shell:
```bash
. $HOME/esp/esp-idf/export.sh
```
Le firmware Arduino historique (`../ui_freenove_allinone/`, PlatformIO) reste la
source de vérité matérielle (pin-maps, drivers de référence), mais l'IDF est
désormais le firmware master complet.
## Build
## Prérequis, build, flash
ESP-IDF v5.4 installé sous `~/esp/esp-idf/`.
> **QUIRK de cette machine** — l'environnement Python par défaut d'IDF échoue
> ici. Il faut forcer le venv py3.11 **avant** de sourcer `export.sh` :
>
> ```bash
> export IDF_PYTHON_ENV_PATH=$HOME/.espressif/python_env/idf5.4_py3.11_env
> source ~/esp/esp-idf/export.sh
> ```
Build :
```bash
cd idf_zacus
idf.py set-target esp32s3
idf.py build
```
## Flash & monitor
Flash + monitor (le port de cette machine est fixe) :
```bash
idf.py -p /dev/cu.usbmodem* flash monitor
idf.py -p /dev/cu.usbmodem5AB90753301 flash monitor
```
Exit the monitor with `Ctrl-]`.
Sortie du monitor : `Ctrl-]`.
## What the first slice does
Au premier `idf.py build`, le component manager clone LovyanGFX (tag 1.2.21,
épinglé par SHA) depuis GitHub et récupère LVGL `~8.4.0`, esp-sr, esp-dsp,
mdns, esp_websocket_client et littlefs dans `managed_components/`. Les modèles
esp-sr sont flashés automatiquement (`srmodels.bin`) dans la partition `model`.
`main/main.c` boots the device, initializes NVS, mounts the LittleFS
`storage` partition on `/littlefs`, lists its contents, logs heap stats
(internal + PSRAM), and enters an idle heartbeat loop (no deep sleep — the
inherited `ota_server` listening loop will be wired in slice 2).
## Carte des composants
Inherited components:
Tous les composants vivent sous `components/` et sont des composants ESP-IDF
standard (chacun avec son `include/`).
- `components/ota_server/` — HTTP server on :80 with rate-limited OTA upload
and 30 s watchdog auto-rollback (`POST /ota`, `POST /ota/rollback`,
`GET /version`, `GET /status`, `GET /ota/status`). Not yet started by
`main.c`; that comes next.
| Composant | Rôle |
|---|---|
| `ota_server` | Serveur HTTP `:80` : OTA double-banque (`POST /ota`, `POST /ota/rollback`), watchdog d'auto-rollback, `GET /version` / `/status`. Détient l'instance `esp_http_server` partagée par les autres endpoints. |
| `game_endpoint` | Surface REST de jeu greffée sur le httpd d'`ota_server` : profil de groupe hints, pilotage scénario, étapes (`/game/step`), `puzzle_state`, relais ESP-NOW. Voir son `README.md`. |
| `voice_pipeline` | Capture I2S + esp-sr (AFE `AFE_TYPE_SR`, WakeNet9), machine d'états idle/listening/speaking/muted, pont WebSocket vers le voice-bridge (STT/TTS), lecture TTS sur DAC. Inclut `voice_dispatcher` (routage STT → npc_engine, fast-path mots-clés). |
| `voice_hook_endpoint` | Pont REST `POST /voice/hook` depuis le PLIP (téléphone Si3210) : off-hook arme la voix directement (bypass wake-word), on-hook ferme la session. Greffé sur le httpd d'`ota_server`. |
| `npc_engine` | Port C du moteur de décision NPC Arduino : table de cues, humeur, déclenchement de cues via `media_manager`. |
| `hints_client` | Client HTTP du moteur de hints (`POST /hints/ask`, `/hints/puzzle_start`, `/hints/attempt_failed`), profil de groupe (`TECH` / `NON_TECH` / `MIXED` / `BOTH`). |
| `media_manager` | Port C du MediaManager Arduino : catalogue, play/stop, enregistrement WAV. Décodage MP3 et capture micro stubés ici (le micro réel passe par `mic_broker`). |
| `local_puzzles` | Énigmes locales caméra/micro et leur câblage vers `puzzle_state` : `qr_puzzle`, `sound_puzzle`, `mic_broker`, validateurs `seq_validator` / `melody_validator`, pin-map `board_pins_mediakit.h`. |
| `puzzle_state` | Agrégation master des énigmes résolues et assemblage du **code** de sortie (jusqu'à 8 énigmes, fragments → chiffres décimaux). |
| `display_ui` | Écran LVGL 8.4 sur LovyanGFX : vues statut/scène, viewfinder caméra, effets, shell Workbench, browser de fichiers, intro cracktro ; boutons 5 directions (`buttons_input`). |
## Layout
## Séquence de boot (`main/main.c`)
```
idf_zacus/
├── CMakeLists.txt # project entry, points EXTRA_COMPONENT_DIRS at components/
├── sdkconfig.defaults # ESP32-S3, octal PSRAM 80 MHz, custom partitions
├── partitions.csv # OTA layout + 2 MB LittleFS "storage"
├── main/
│ ├── CMakeLists.txt
│ ├── idf_component.yml # joltwallet/littlefs ^1.14
│ └── main.c # app_main + LittleFS mount + heartbeat
└── components/
└── ota_server/ # inherited from 2026-04-03 IDF bootstrap
`app_main` exécute, dans l'ordre :
1. **NVS** — init (efface + réinit si pages pleines / nouvelle version).
2. **Écran**`display_ui_init()` (splash tôt, non fatal). Si OK : enregistre
`display_ui_camera_frame` comme callback de preview QR, puis
`buttons_input_init()` (5 directions).
3. **Wi-Fi** — lit les creds NVS (namespace `wifi`, clés `ssid`/`pwd`). Si
présents : **STA** (attente `GOT_IP`, 8 retries, timeout 20 s). Sinon, ou
échec : **AP** ouvert de secours `zacus-setup` (IP `192.168.4.1`).
4. **mDNS** — uniquement en STA : publie `zacus-master.local` et le service
`_zacus._tcp:80` (TXT `path=/voice/hook`). Sauté en AP-fallback.
5. **OTA server**`ota_server_init()` (`:80`). Sur succès, greffe sur le même
httpd : `voice_hook_endpoint_init()` puis `game_endpoint_init()` (qui monte
aussi `scenario_mesh` / ESP-NOW), seed du registre de peers relais depuis
NVS (namespace `peers`), et `game_endpoint_set_puzzle_state(&s_pstate)`.
6. **LittleFS** — monte la partition `storage` sur `/littlefs` (reformat si
échec) et liste la racine. Puis, sous ce montage :
- `media_manager_init()` (catalogues sur LittleFS) + smoke-test de lecture ;
- `npc_engine_init()` (table de cues vide à ce stade) ;
- `hints_client_init()` vers le backend hints, puis chargement du
`group_profile` depuis NVS (namespace `zacus`, défaut `MIXED`) ;
- `voice_dispatcher_init()` ;
- `puzzle_state_init()` + `local_puzzles_init()` ;
- `mic_broker_init()` sur les pins micro Media Kit (3/14/46, 16 kHz) **avant**
`voice_pipeline_init()` (le pipeline no-op alors avec `ESP_ERR_INVALID_STATE`) ;
- `voice_pipeline_init()` : wake-word + auto-start capture activés, URL du
voice-bridge, lecture TTS activée, **pins forcés** aux valeurs Media Kit
(mic 3/14/46, HP 42/41/1) pour éviter la collision avec les pins caméra.
7. **`ota_server_mark_valid()`** — valide l'image (désamorce l'auto-rollback)
une fois les sous-systèmes montés.
8. **Boucle de statut** — réveil toutes les 500 ms : rafraîchit l'écran
(IP, état wake-word, étape/armé, code assemblé, métadonnées de scène),
traite les lancements d'apps du shell (`/littlefs/apps/<id>/step.txt`
`game_endpoint_apply_step`). Toutes les 60 s : heartbeat + `media_manager_update`
+ `npc_engine_update`.
URLs hardcodées (déplacées en NVS dans un suivi) : hints
`http://192.168.0.150:8302`, voice-bridge WS `ws://100.116.92.12:8200/voice/ws`.
## Énigmes locales (P1 son / P3 QR)
`local_puzzles` arme deux types d'énigmes et reporte leur fragment dans
`puzzle_state` à la résolution :
- **P1 — son/mélodie** (`local_puzzles_arm_sound`) : `mic_broker` (propriétaire
unique du micro I2S RX) passe en mode `MIC_P1_SOUND` et route les trames PCM16
vers `sound_puzzle`, qui détecte les notes et les valide via
`melody_validator` (notes MIDI + tolérance en demi-tons). Le **même broker**
est partagé avec la voix (`MIC_NPC_LISTEN`) : un seul propriétaire des pins,
un consommateur actif à la fois.
- **P3 — QR séquentiel** (`local_puzzles_arm_qr`) : `qr_puzzle` initialise la
caméra (QVGA 320×240 niveaux de gris), décode avec **quirc**, et valide
l'ordre des codes via `seq_validator`. Les trames sont mirrorées au
viewfinder de l'écran via le callback de preview. Teardown caméra
asynchrone (réarmement à retenter après ~100 ms).
### Pin-map Media Kit (`board_pins_mediakit.h`)
Freenove FNK0102H Media Kit v1.2, **zéro collision** entre caméra, micro,
HP et écran :
- **Caméra** (capteur réel : **OV3660**) — XCLK 15, SIOD/SIOC 4/5, VSYNC 6,
HREF 7, PCLK 13, D0D7 = 11/9/8/10/12/18/17/16.
- **Micro I2S IN** — BCLK 3, WS 14, DIN 46.
- **HP I2S OUT** (MAX98357A) — BCLK 42, LRC 41, DOUT 1.
- **Écran ST7796 (SPI2/FSPI, 80 MHz)** — SCK 47, MOSI 21, DC 45, RST 20, BL 2.
> Les défauts du composant `voice_pipeline` (mic 14/15/22, HP 11/12/13)
> **entrent en collision** avec les pins caméra ; `main.c` les écrase par les
> valeurs Media Kit ci-dessus. Ne pas réintroduire les défauts.
### Validateurs testables sur hôte
`seq_validator` et `melody_validator` sont de la logique pure, sans I/O,
compilables et testables sur la machine de dev :
```bash
make -C idf_zacus/components/local_puzzles/test/host test
```
## Coexistence with Arduino
## Écran (`display_ui`)
`../ui_freenove_allinone/` (Arduino, PlatformIO) remains the production
firmware until the IDF port reaches feature parity. The two trees do **not**
share build artifacts. To work on the Arduino tree:
`cd ui_freenove_allinone && pio run`. To work on the IDF tree, source the
ESP-IDF env first.
Pile **LVGL 8.4 + LovyanGFX** (driver ST7796, `setAddrWindow` / `writePixels`
RGB565). Tâche de rendu dédiée (LovyanGFX n'est pas thread-safe inter-tâches) ;
`display_ui_set_status` copie un snapshot sous mutex.
## Roadmap (next P1 slices)
Vues et fonctions :
1. Boot `ota_server_init()` from `app_main` after a small Wi-Fi STA bring-up.
2. Port the NPC engine and media manager skeleton.
3. Scaffold the voice pipeline (I2S RX task, no esp-sr yet).
4. Bring up esp-sr AFE + wakenet ("hi_esp" placeholder) — start of P3.
- **Vue statut** — IP, wake-word, étape/armé, compte d'énigmes résolues, code.
- **Vue scène** — titre/sous-titre/code de l'étape, avec effets
(`pulse` / `glitch` / `gyro` / `none`) issus de l'IR de scène.
- **Viewfinder caméra** — `lv_canvas` en PSRAM, alimenté par les trames QR
(grayscale → RGB565, ~5 fps) quand la vue scène est active et le QR armé.
- **Shell Workbench** — port d'`ui_amiga_shell` : tuiles statiques (Statut,
Scene, Auto, Lumiere, Fichiers) + tuiles dynamiques d'apps lues dans
`/littlefs/apps/<id>/` (`icon.png` optionnel, `step.txt` = l'étape à armer).
- **Browser de fichiers** — drawer LittleFS navigable.
- **Intro cracktro** — port fidèle de `ui_manager_intro` (starfield parallaxe
3 couches, copper bars).
See the design spec for the full plan.
Navigation **5 directions** (`buttons_input`, échelle analogique sur GPIO19,
ADC2) : 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP. Shell fermé : SELECT /
LEFT-RIGHT bascule scène↔statut, MENU ouvre le shell, UP/DOWN règlent la
luminosité. Shell ouvert : navigation grille, SELECT lance la tuile.
## Surface REST de jeu
Tous les endpoints de jeu partagent l'instance `esp_http_server` d'`ota_server`
(port 80, pas de second socket). Détail des routes (`/game/group_profile`,
`/game/step`, `/game/puzzle_state`, `/game/scenario`, `/game/scenario/relay`…)
et de la persistance NVS dans **`components/game_endpoint/README.md`**.
## Tests hôte
Trois suites de logique pure tournent sur la machine de dev (Unity), sans
matériel ni IDF flashé :
| Suite | Cas | Commande |
|---|---|---|
| `local_puzzles` (validateurs séquence + mélodie) | 6 | `make -C idf_zacus/components/local_puzzles/test/host test` |
| `puzzle_state` (agrégation + assemblage du code) | 2 | `make -C idf_zacus/components/puzzle_state/test/host test` |
| `game_endpoint` (binding puzzle/scène) | 10 | `make -C idf_zacus/components/game_endpoint/test/host test` |
## Layout flash (`partitions.csv`)
Cible 16 MB (N16R8) :
| Partition | Type | Taille |
|---|---|---|
| `nvs` | data/nvs | 24 KB |
| `otadata` | data/ota | 8 KB |
| `phy_init` | data/phy | 4 KB |
| `factory` / `ota_0` / `ota_1` | app | **3 MB chacune** |
| `model` | data/spiffs | 1 MB (modèles esp-sr) |
| `storage` | data/littlefs | 5 MB (assets, scénario, apps) |
La partition app est passée de 2 MB à **3 MB** (2026-06-10) pour loger
LVGL + LovyanGFX + polices + effets/preview. NVS/otadata/phy gardent leurs
offsets (les creds Wi-Fi survivent au reflash) ; `ota_0`/`ota_1`/`model`/
`storage` se décalent, donc **le contenu LittleFS est perdu au reflash**
(reformatage auto ; repousser le scénario via `POST /game/scenario`).
## Limites connues / terrain
- **Décodage QR sur écran LCD émissif** : lire un QR **affiché à l'écran** via
la caméra **ne fonctionne pas de façon fiable** — quirc ne retrouve pas les
motifs (contraste insuffisant sur un panneau émissif). Le **QR imprimé** reste
la méthode recommandée pour P3.
- **Note repérée dans le code** : le commentaire d'en-tête de `qr_puzzle.h`
mentionne encore « OV2640 » ; le capteur réel de la carte est **OV3660**
(commentaire à corriger, sans impact fonctionnel sur le chemin grayscale/quirc).
- **Provisioning** : creds Wi-Fi, URL hints et URL voice-bridge passent encore
partiellement par des constantes / NVS pré-flashé ; le passage tout-NVS au
runtime est un suivi.
+47
View File
@@ -0,0 +1,47 @@
# display_ui
Écran ST7796 (320×480, paysage) du master Freenove, piloté via **LovyanGFX** +
**LVGL 8.4**. Port fidèle de l'UI Arduino d'origine (`ui_freenove_allinone`).
Tous les appels LVGL et LovyanGFX s'exécutent exclusivement sur la
`display_task` ; `display_ui_set_status` copie l'état sous mutex et lève un flag
dirty que la tâche replie dans les labels.
## Pile
- **HAL panneau** — `Panel_ST7796` sur `Bus_SPI` (SPI2_HOST/FSPI, 80 MHz
écriture / 20 MHz lecture). Configuration **identique** à l'UI Arduino
d'origine (`FreenoveLgfxDevice` dans `ui_freenove_allinone`) : pins
SCK=47/MOSI=21/DC=45/RST=20/BL=2, rotation 1, inversion on, ordre RGB.
- **Rétroéclairage PWM** — LEDC sur `LEDC_TIMER_1` / `LEDC_CHANNEL_1`. Le
`LEDC_TIMER_0` / `LEDC_CHANNEL_0` est réservé au **XCLK de la caméra**
(`qr_puzzle`). Duty exposé 0-100 % via `display_ui_set_brightness`.
- **Vue statut** + **vue scène** — la vue scène affiche le step courant et le
code assemblé, avec un effet (**pulse** = respiration d'opacité, **glitch** =
jitter + scintillement du titre, **gyro** = anneau-gyrophare rotatif) et un
**viewfinder caméra** : `lv_canvas` PSRAM nourri par les trames grayscale du
scan QR, converties en **RGB565** (~5 fps), visible seulement quand l'énigme
QR est armée.
- **Boutons 5 directions** — échelle résistive sur ADC (GPIO19 → ADC2_CH8),
seuils en mV repris de l'original (`{0, 447, 730, 1008, 1307, 1659}`, plancher
« relâché » 2800 mV, décodage par milieu entre seuils adjacents).
- **Shell Workbench** — tuiles builtin **Statut / Scene / Auto / Lumiere /
Fichiers**, plus des **apps dynamiques** chargées depuis
`/littlefs/apps/<id>/` (`icon.png` optionnel + `step.txt` = action de l'app).
- **Browser de fichiers** — drawer parcourant `/littlefs`.
- **Intro cracktro** — port fidèle de la phase A d'origine : starfield parallaxe
3 couches en **Q8.8**, copper bars, scrolltext en onde sinus, logo avec ombre
portée.
## API publique (`display_ui.h`)
- `display_ui_init()` — init panneau + splash, spawn la tâche de refresh.
- `display_ui_set_status(s)` — copie un snapshot d'état et demande un redraw
async (thread-safe ; NULL = no-op).
- `display_ui_set_brightness(pct)` — duty 0-100 % (défaut 100).
- `display_ui_camera_frame(gray, w, h)` — pousse une trame caméra pour le
viewfinder (câblé comme preview callback de `qr_puzzle`) ; non-QVGA ignorée.
- `display_ui_handle_key(key)` — traite une touche 5 directions (1=SELECT
2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP).
- `display_ui_take_pending_launch(id_out, cap)` — récupère (one-shot) une
demande de lancement d'app du shell dynamique.
+406 -56
View File
@@ -1,30 +1,57 @@
# game_endpoint
REST surface for **runtime game configuration**. Slice 12 of the IDF
migration. Currently exposes a single resource — the hints-engine
group profile — but the component is the natural home for additional
runtime tunables (cooldowns, voice persona, etc.) as scenarios grow.
Surface **REST de jeu** du firmware. Le composant n'ouvre **aucun socket
propre** : il greffe ses handlers sur l'instance `esp_http_server`
(port **80**) détenue par `ota_server` — même pattern que
`voice_hook_endpoint`, même pool de workers. `game_endpoint_init(httpd)`
est appelé après `ota_server_init()`, avec le handle rendu par
`ota_server_get_handle()`.
## Why
Né en slice 12 pour le seul profil de groupe des indices, le composant a
beaucoup grandi. Il couvre aujourd'hui quatre domaines :
The hints engine adapts its policy to the audience: `TECH`,
`NON_TECH`, `MIXED`, or `BOTH`. Until this slice the value was baked
into NVS via `idf.py nvs-partition-gen` or the dashboard's flash
helper — both require a power cycle. The game master needs to be able
to change profile mid-session if the actual room composition differs
from the booking, so we expose a REST endpoint that updates both the
in-RAM hints client and the persistent NVS slot.
- **configuration runtime** — profil de groupe du moteur d'indices ;
- **scénario** — chargement à chaud de l'IR Runtime 3 (LittleFS + reboot),
et relais du même IR vers d'autres boîtiers en ESP-NOW ;
- **provisioning mesh** — registre des pairs ESP-NOW (alias → MAC) ;
- **énigmes locales** — armement d'une énigme par step, lecture de l'état
agrégé, et dépôt de fichiers d'apps pour le shell d'affichage.
## Routes
## Table des routes
Listener: existing `esp_http_server` instance on port **80** (shared
with `ota_server` and `voice_hook_endpoint` — same TCP socket, no
second httpd).
| Méthode | Chemin | Corps attendu | Réponse (succès) |
|---|---|---|---|
| GET | `/game/group_profile` | — | `200 {group_profile}` |
| POST | `/game/group_profile` | `{group_profile}` | `200 {status,group_profile}` |
| POST | `/game/scenario` | IR Runtime 3 (JSON brut, ≤64 KiB) | `200 {status,steps_count,entry_step_id,bytes,reload}` + reboot |
| POST | `/game/scenario/relay` | `{peers:[…], ir:{…}}` | `200 {relayed:[…],skipped:[…]}` |
| GET | `/game/peers` | — | `200 {peers:[{alias,mac}]}` |
| POST | `/game/peers` | `{alias, mac}` | `200 {ok,alias,live}` |
| POST | `/game/step` | `{step_id}` | `200 {step_id,armed}` |
| GET | `/game/puzzle_state` | — | `200 {step_id,solved,code}` |
| POST | `/game/file?path=apps/<id>/<f>` | corps brut du fichier (≤256 KiB) | `200 {status,path,bytes}` |
Toutes les réponses sont `application/json` avec l'en-tête
`Access-Control-Allow-Origin: *`. Les corps d'erreur ont la forme
`{"error":"<message>"}` (sauf le cas `500 runtime_only` de
`POST /game/group_profile`, qui renvoie un objet structuré).
`POST /game/scenario/relay` n'est enregistrée **que** si
`scenario_mesh_init()` a réussi au boot ; sinon elle est absente et le log
le signale. `/game/peers`, `/game/step`, `/game/puzzle_state` et
`/game/file` sont enregistrées en best-effort (un échec d'enregistrement
est loggué mais non fatal). `GET /game/puzzle_state` et `POST /game/step`
ne fonctionnent qu'après l'appel à `game_endpoint_set_puzzle_state()` ;
avant cela elles répondent `503 not_ready`.
---
## Configuration runtime
### `GET /game/group_profile`
Returns the live profile (whatever `hints_client_group_profile()`
reports — defaults to `MIXED` after init).
Renvoie le profil vivant rapporté par `hints_client_group_profile()`
(`MIXED` par défaut après init).
```json
{ "group_profile": "MIXED" }
@@ -32,65 +59,387 @@ reports — defaults to `MIXED` after init).
### `POST /game/group_profile`
Body (JSON, max 256 bytes):
Corps JSON, **1..256 octets** :
```json
{ "group_profile": "NON_TECH" }
```
| Status | Body | Meaning |
|--------|------|---------|
| 200 | `{"status":"ok","group_profile":"NON_TECH"}` | accepted, NVS persisted |
| 400 | `{"error":"missing 'group_profile'"}` | empty / non-string field |
| 400 | `{"error":"invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]"}` | rejected by hints client whitelist |
| 400 | `{"error":"malformed json"}` | body did not parse |
| 413 | `{"error":"body must be 1..256 bytes"}` | body too large |
| 500 | `{"status":"runtime_only","group_profile":"NON_TECH","warning":"nvs write failed: …"}` | hints client updated but NVS commit failed (will not survive reboot) |
La validation est déléguée à `hints_client_set_group_profile()`
(whitelist `TECH` / `NON_TECH` / `MIXED` / `BOTH`). En cas d'acceptation
la valeur est ensuite persistée en NVS (namespace `zacus`, clé
`group_profile`) — le même slot que `main.c` relit au boot, donc le
changement survit au reboot sans flash.
## curl examples
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"status":"ok","group_profile":"NON_TECH"}` | accepté, NVS persisté |
| 400 | `{"error":"missing 'group_profile'"}` | champ vide / non-string |
| 400 | `{"error":"invalid group_profile, must be one of [TECH, NON_TECH, MIXED, BOTH]"}` | rejeté par la whitelist |
| 400 | `{"error":"malformed json"}` | corps non parsable |
| 413 | `{"error":"body must be 1..256 bytes"}` | corps hors bornes |
| 500 | `{"status":"runtime_only","group_profile":"NON_TECH","warning":"nvs write failed: …"}` | hints client mis à jour mais commit NVS échoué (ne survivra pas au reboot) |
```bash
# Set the profile (uses mDNS — see main.c slice 12 wire-up)
curl -X POST http://zacus-master.local/game/group_profile \
-H "Content-Type: application/json" \
-d '{"group_profile":"NON_TECH"}'
---
# Probe current state
curl http://zacus-master.local/game/group_profile
## Scénario
# Static-IP fallback
curl -X POST http://192.168.0.<master-ip>/game/group_profile \
-H "Content-Type: application/json" \
-d '{"group_profile":"MIXED"}'
### `POST /game/scenario`
Charge à chaud un IR **Runtime 3** complet. Corps = JSON brut,
**1..65536 octets**. Validation firmware **minimale** (le validateur
strict est `runtime3_common.py`, côté gateway) :
- JSON parsable ;
- `schema_version == "zacus.runtime3.v1"` ;
- `steps` = tableau **non vide**.
Sur succès : l'ancien `scenario.json` est tourné en `scenario.bak`, le
nouveau blob est écrit atomiquement sur LittleFS
(`/littlefs/scenario.json`), toute énigme en cours est désarmée, puis un
**reboot différé de ~800 ms** est planifié (le temps que la réponse HTTP
soit flushée). Un write court est rollback depuis `.bak`.
```json
{ "status": "ok", "steps_count": 12, "entry_step_id": "STEP_INTRO",
"bytes": 4096, "reload": "reboot_pending" }
```
## Persistence
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{…,"reload":"reboot_pending"}` | accepté, écrit, reboot planifié |
| 400 | `{"error":"malformed json"}` | JSON invalide |
| 400 | `{"error":"schema_version must be zacus.runtime3.v1"}` | mauvais schéma |
| 400 | `{"error":"steps must be a non-empty array"}` | `steps` absent/vide |
| 400 | `{"error":"recv failed"}` | erreur de réception socket |
| 413 | `{"error":"body must be 1..65536 bytes"}` | corps hors bornes |
| 500 | `{"error":"littlefs mount failed"}` / `"scenario write open failed"` / `"scenario write short"` / `"out of memory"` | échec stockage |
Successful POSTs write to NVS namespace `zacus`, key `group_profile`.
This is the same slot `main.c` reads at boot to seed the hints client,
so a successful POST survives reboot without a flash step. The
write/commit pair runs on the httpd worker task; expect ~515 ms of
flash latency.
```bash
curl -X POST http://zacus-master.local/game/scenario \
-H "Content-Type: application/json" \
--data-binary @scenario.runtime3.json
```
If the hints client validation passes but `nvs_set_str` /
`nvs_commit` fails (e.g. NVS partition full), the response is `500`
with `status: runtime_only` so the operator can decide whether to
keep going or force a reboot.
### `POST /game/scenario/relay`
## Component dependencies
(Présente uniquement si le mesh ESP-NOW est monté.) Diffuse un IR vers
d'autres boîtiers. Corps **1..65536 octets** :
```json
{ "peers": ["box3", "plip"], "ir": { /* IR Runtime 3 */ } }
```
L'objet `ir` est re-sérialisé compact puis chunké/envoyé en ESP-NOW à
chaque alias résolu via le registre des pairs. Un échec sur un pair
(alias inconnu, timeout) n'interrompt pas les autres : il atterrit dans
`skipped` avec sa raison. Côté récepteur, l'IR repasse par exactement le
même chemin que `POST /game/scenario` (validation + write + reboot).
```json
{ "relayed": ["box3"],
"skipped": [ { "name": "plip", "reason": "unknown_peer" } ] }
```
Raisons de `skipped` : `unknown_peer` (alias absent du registre),
`timeout` (pas d'ack ESP-NOW), ou tout autre `esp_err_to_name`.
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"relayed":[…],"skipped":[…]}` | traité (succès partiel possible) |
| 400 | `{"error":"malformed json"}` | JSON invalide |
| 400 | `{"error":"'peers' must be a non-empty array"}` | `peers` absent/vide |
| 400 | `{"error":"'ir' must be an object"}` | `ir` non objet |
| 413 | `{"error":"body must be 1..65536 bytes"}` | corps hors bornes |
| 500 | `{"error":"ir serialize failed"}` / `"response serialize failed"` | échec interne cJSON |
---
## Provisioning mesh
Remplace le round-trip desktop `NvsConfigurator` pour le provisioning
ESP-NOW.
### `POST /game/peers`
Corps **1..256 octets** :
```json
{ "alias": "plip", "mac": "AA:BB:CC:DD:EE:FF" }
```
- `alias` : **1..15 caractères** (limite de clé NVS) ;
- `mac` : format `AA:BB:CC:DD:EE:FF` (6 octets hex).
Persiste en NVS (namespace `peers`, clé = alias, blob = MAC 6 octets — le
format exact que `main.c` reseed au boot) **et** enregistre le pair en
direct dans la table `scenario_mesh` (sans reboot). Si l'enregistrement
live échoue, le pair est tout de même en NVS (`live:false`, effectif au
prochain reboot).
```json
{ "ok": true, "alias": "plip", "live": true }
```
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"ok":true,"alias":"plip","live":true|false}` | persisté (`live` = enregistré à chaud) |
| 400 | `{"error":"invalid JSON"}` | corps non parsable |
| 400 | `{"error":"alias and mac (string) required"}` | champ manquant/non-string |
| 400 | `{"error":"alias must be 1..15 chars (NVS key)"}` | alias hors bornes |
| 400 | `{"error":"mac must be AA:BB:CC:DD:EE:FF"}` | MAC mal formé |
| 413 | `{"error":"body must be 1..256 bytes"}` | corps hors bornes |
| 500 | `{"error":"NVS write failed"}` | échec NVS |
### `GET /game/peers`
Itère le namespace NVS `peers` et liste les pairs persistés :
```json
{ "peers": [ { "alias": "plip", "mac": "AA:BB:CC:DD:EE:FF" } ] }
```
```bash
curl -X POST http://zacus-master.local/game/peers \
-H "Content-Type: application/json" \
-d '{"alias":"plip","mac":"AA:BB:CC:DD:EE:FF"}'
curl http://zacus-master.local/game/peers
```
---
## Énigmes locales
### `POST /game/step`
Arme l'énigme locale attachée à un step de l'IR stocké. Corps
**1..512 octets** :
```json
{ "step_id": "STEP_3" }
```
Le handler relit `/littlefs/scenario.json`, en extrait le binding du step
via `puzzle_binding_from_ir` (+ la `scene` d'affichage, parse lenient),
désarme l'énigme courante, puis arme la nouvelle. Même logique exposée en
interne via `game_endpoint_apply_step()` (lancement d'app sur l'écran du
boîtier).
`armed` indique ce qui a été armé : `"qr"`, `"sound"`, ou `"none"` (step
trouvé mais sans objet `puzzle`).
```json
{ "step_id": "STEP_3", "armed": "qr" }
```
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"step_id":"STEP_3","armed":"qr|sound|none"}` | armé |
| 400 | `{"error":"missing or empty step_id"}` | champ absent/vide |
| 400 | `{"error":"malformed json"}` | corps non parsable |
| 400 | `{"error":"body must be 1..512 bytes"}` | corps hors bornes |
| 404 | `{"error":"unknown_step"}` | aucun step de cet id dans l'IR |
| 409 | `{"error":"no_scenario"}` | aucun scénario stocké / stockage indispo |
| 422 | `{"error":"invalid_puzzle"}` | objet `puzzle` invalide dans l'IR |
| 503 | `{"error":"puzzle_busy"}` | énigme occupée (fenêtre de teardown QR, après un retry) |
| 503 | `{"error":"not_ready"}` | `puzzle_state` pas encore câblé |
```bash
curl -X POST http://zacus-master.local/game/step \
-H "Content-Type: application/json" \
-d '{"step_id":"STEP_3"}'
```
### `GET /game/puzzle_state`
État agrégé des énigmes résolues, pour le polling du dashboard GM.
```json
{ "step_id": "STEP_3", "solved": [1, 3], "code": "125" }
```
- `step_id` : dernier step armé, ou `null` si rien n'a encore été armé ;
- `solved` : ids (1..8) des énigmes résolues ;
- `code` : concaténation des fragments des énigmes résolues.
`503 {"error":"not_ready"}` tant que `puzzle_state` n'est pas câblé.
```bash
curl http://zacus-master.local/game/puzzle_state
```
### `POST /game/file?path=apps/<id>/<file>`
Provisionne un fichier d'app du shell d'affichage (icône, `step.txt`…)
sans reflasher l'image LittleFS. Le chemin est passé en **query param**,
le corps est le **contenu brut** du fichier (**1..262144 octets**, soit
256 KiB).
Contraintes de sécurité sur `path` :
- doit commencer par `apps/` (whitelist) ;
- ne doit contenir aucun `..` (anti-traversal) ;
- ne doit pas finir par `/`.
Toute violation → `403 {"error":"path must be under apps/"}`. Les
répertoires intermédiaires sont créés (`mkdir -p`). Le fichier final est
écrit sous `/littlefs/apps/…` ; un échec d'écriture supprime le fichier
partiel.
```json
{ "status": "ok", "path": "apps/clock/icon.png", "bytes": 1873 }
```
| Statut | Corps | Sens |
|---|---|---|
| 200 | `{"status":"ok","path":"…","bytes":N}` | écrit |
| 400 | `{"error":"missing path param"}` | query `path` absent |
| 400 | `{"error":"size 1..262144 bytes"}` | corps hors bornes |
| 400 | `{"error":"recv failed"}` | erreur réception socket |
| 403 | `{"error":"path must be under apps/"}` | hors whitelist / traversal |
| 500 | `{"error":"open failed"}` / `"write failed"` | échec stockage |
| 503 | `{"error":"storage_unavailable"}` | mount LittleFS échoué |
```bash
curl -X POST "http://zacus-master.local/game/file?path=apps/clock/icon.png" \
--data-binary @icon.png
```
---
## Format IR : objets `puzzle` et `scene`
Les deux objets sont **optionnels** par step de l'IR Runtime 3, lus à
`POST /game/step` (et `game_endpoint_apply_step`). Les contraintes
ci-dessous sont celles de `puzzle_binding.c` / `puzzle_binding.h`.
### `puzzle` (validation **stricte**)
Un objet `puzzle` invalide bloque le step (`422 invalid_puzzle`).
Absent ou `null``armed:"none"`, pas d'erreur.
| Champ | Type | Contrainte |
|---|---|---|
| `id` | nombre | **1..8** (entier) |
| `type` | string | `"qr"` ou `"sound"` (rien d'autre) |
| `fragment` | tableau d'entiers | **1..4** valeurs, chacune **0..9** (chiffres) |
| `codes` | tableau de strings | *(qr)* **1..16** labels, chacun < 32 chars |
| `melody` | tableau d'entiers | *(sound)* **1..32** notes |
| `tolerance` | nombre | *(sound, optionnel)* entier **≥ 0**, défaut **1** |
Énigme QR :
```json
{
"id": 1,
"type": "qr",
"codes": ["BADGE_ROUGE", "BADGE_BLEU"],
"fragment": [1, 2]
}
```
Énigme sonore (mélodie + tolérance) :
```json
{
"id": 4,
"type": "sound",
"melody": [262, 294, 330, 349],
"tolerance": 2,
"fragment": [5]
}
```
### `scene` (parse **lenient**)
Décoration d'affichage : ne bloque **jamais** un changement de step. Les
strings trop longues sont **silencieusement tronquées**, un `effect`
inconnu retombe sur `pulse`. Seul un JSON globalement non parsable échoue.
| Champ | Type | Contrainte |
|---|---|---|
| `title` | string | tronqué à 48 chars |
| `subtitle` | string | tronqué à 64 chars |
| `symbol` | string | tronqué à 16 chars |
| `effect` | string | `pulse` (défaut) \| `glitch` \| `gyro` \| `none` ; inconnu → `pulse` |
```json
{
"title": "Salle des machines",
"subtitle": "Trouvez la séquence",
"symbol": "⚙",
"effect": "glitch"
}
```
Un step complet combinant les deux :
```json
{
"id": "STEP_3",
"puzzle": { "id": 1, "type": "qr", "codes": ["BADGE_ROUGE"], "fragment": [1, 2] },
"scene": { "title": "Le coffre", "effect": "pulse" }
}
```
---
## Modèle de concurrence
La lecture de `GET /game/puzzle_state` (et les snapshots
`game_endpoint_get_puzzle_status` / `game_endpoint_get_scene`) est
**lock-free**, par choix assumé pour ce jeu :
- l'armement d'un step (writer) se fait uniquement sur la tâche httpd ;
les tâches d'énigmes n'écrivent que via `puzzle_state_report` (flags
`solved[]` montones) ;
- le dashboard GM ne fait que poller ;
- un read concurrent peut donc voir un `solved[id]` à `true` alors que le
fragment correspondant est en cours d'écriture (lecture torn). De même
un torn read du texte de `scene` pendant la copie est cosmétique et
s'auto-corrige au poll suivant.
**Note honnête** : c'est acceptable ici (un seul step armé à la fois,
affichage informatif, polling), mais ce n'est pas thread-safe au sens
strict — pas de mutex sur `puzzle_state` ni sur les statiques
`s_current_*`. À ne pas réutiliser tel quel dans un contexte
multi-writer.
---
## Tests hôte du parser
Le parser `puzzle_binding` a une suite de **10 tests `[pbind]`** qui
tourne sur l'hôte (pas de cible ESP requise), via Unity + cJSON :
```bash
make -C idf_zacus/components/game_endpoint/test/host test
```
Le `Makefile` attend Unity et cJSON dans l'arbre ESP-IDF
(`UNITY_DIR` / `CJSON_DIR`, par défaut sous `~/esp/esp-idf/components/`,
surchargeables en variables d'environnement).
---
## Dépendances du composant
```cmake
REQUIRES
esp_http_server # the shared httpd_handle_t
json # cJSON for body parsing
nvs_flash # nvs_open / nvs_set_str / nvs_commit
hints_client # validation + push to in-RAM state
ota_server # supplies httpd_handle via ota_server_get_handle()
esp_http_server # le httpd_handle_t partagé
json # cJSON pour le parsing
nvs_flash # group_profile + registre des pairs
hints_client # validation + push du profil en RAM
ota_server # fournit le handle via ota_server_get_handle()
scenario_mesh # transport ESP-NOW (relay + provisioning)
local_puzzles # armement QR / sonore
esp_littlefs # stockage scénario + fichiers d'apps
freertos
log
```
Init pattern in `main.c` (slice 12):
Câblage dans `main.c` :
```c
esp_err_t ota_err = ota_server_init();
@@ -98,5 +447,6 @@ if (ota_err == ESP_OK) {
httpd_handle_t httpd = ota_server_get_handle();
voice_hook_endpoint_init(httpd);
game_endpoint_init(httpd);
game_endpoint_set_puzzle_state(&g_puzzle_state); // active /game/step + /game/puzzle_state
}
```
@@ -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,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.