From 6d7b67025643692f67d78bb0331d469d96efd0a9 Mon Sep 17 00:00:00 2001 From: Claude Worker claude2 Date: Thu, 11 Jun 2026 02:56:47 +0200 Subject: [PATCH] docs: refresh firmware READMEs (puzzles+UI+REST) --- box3_voice/README.md | 64 +++ idf_zacus/README.md | 253 +++++++--- idf_zacus/components/display_ui/README.md | 47 ++ idf_zacus/components/game_endpoint/README.md | 462 ++++++++++++++++--- idf_zacus/components/local_puzzles/README.md | 55 +++ idf_zacus/components/puzzle_state/README.md | 30 ++ 6 files changed, 803 insertions(+), 108 deletions(-) create mode 100644 idf_zacus/components/display_ui/README.md create mode 100644 idf_zacus/components/local_puzzles/README.md create mode 100644 idf_zacus/components/puzzle_state/README.md diff --git a/box3_voice/README.md b/box3_voice/README.md index 09c652c..6a49290 100644 --- a/box3_voice/README.md +++ b/box3_voice/README.md @@ -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`. diff --git a/idf_zacus/README.md b/idf_zacus/README.md index fcf2cb6..2eb6e4e 100644 --- a/idf_zacus/README.md +++ b/idf_zacus/README.md @@ -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//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//` (`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. diff --git a/idf_zacus/components/display_ui/README.md b/idf_zacus/components/display_ui/README.md new file mode 100644 index 0000000..688166a --- /dev/null +++ b/idf_zacus/components/display_ui/README.md @@ -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//` (`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. diff --git a/idf_zacus/components/game_endpoint/README.md b/idf_zacus/components/game_endpoint/README.md index 83dba56..59722b5 100644 --- a/idf_zacus/components/game_endpoint/README.md +++ b/idf_zacus/components/game_endpoint/README.md @@ -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//` | 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":""}` (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./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//` + +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 } ``` diff --git a/idf_zacus/components/local_puzzles/README.md b/idf_zacus/components/local_puzzles/README.md new file mode 100644 index 0000000..8fd022b --- /dev/null +++ b/idf_zacus/components/local_puzzles/README.md @@ -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. diff --git a/idf_zacus/components/puzzle_state/README.md b/idf_zacus/components/puzzle_state/README.md new file mode 100644 index 0000000..c254cd9 --- /dev/null +++ b/idf_zacus/components/puzzle_state/README.md @@ -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.