# `idf_zacus/` — firmware master Zacus (ESP-IDF)

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).

Ce n'est plus une *slice* de migration : c'est le master complet. Le firmware
réunit aujourd'hui :

- **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.

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.

## 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
idf.py set-target esp32s3
idf.py build
```

Flash + monitor (le port de cette machine est fixe) :

```bash
idf.py -p /dev/cu.usbmodem5AB90753301 flash monitor
```

Sortie du monitor : `Ctrl-]`.

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`.

## Carte des composants

Tous les composants vivent sous `components/` et sont des composants ESP-IDF
standard (chacun avec son `include/`).

| 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`). |

## Séquence de boot (`main/main.c`)

`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
```

## Écran (`display_ui`)

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.

Vues et fonctions :

- **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).

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.
