docs: refresh firmware READMEs (puzzles+UI+REST)
This commit is contained in:
@@ -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
@@ -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,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.
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user