docs: plan impl enigmes locales Freenove
Repo State / repo-state (push) Failing after 13m46s

This commit is contained in:
Claude Worker claude2
2026-06-10 13:42:56 +02:00
parent 7ee051827f
commit a4838dccf0
@@ -0,0 +1,816 @@
# Énigmes locales Freenove (P1 son, P3 QR) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Faire de la Freenove ESP32-S3 Media Kit v1.2 (N16R8) un master portable tout-en-un qui scanne les QR (P3) via sa caméra et valide une mélodie (P1) via son micro, en réinjectant les résultats dans le master comme une énigme ESP-NOW.
**Architecture:** Nouveau composant `idf_zacus/components/local_puzzles/` (mic_broker, qr_puzzle, sound_puzzle, local_puzzle_report). Les validateurs de séquence sont de la logique C pure testée sur hôte (Unity). L'intégration matérielle (esp32-camera + quirc, refacto micro du voice_pipeline) vient ensuite. Une fondation d'agrégation « puzzle résolu » est ajoutée au master car elle n'existe pas encore.
**Tech Stack:** ESP-IDF 5.4, `espressif/esp32-camera`, `quirc` (décodeur QR C), `espressif/esp-sr` (existant), Unity (tests hôte IDF).
**Spec:** `docs/superpowers/specs/2026-06-10-freenove-local-puzzles-design.md`
---
## File Structure
| Fichier | Responsabilité |
|---|---|
| `idf_zacus/components/local_puzzles/include/local_puzzles.h` | API publique (init, report, arm/disarm par énigme) |
| `idf_zacus/components/local_puzzles/seq_validator.c/.h` | Validateur de séquence QR (ordre) — **logique pure** |
| `idf_zacus/components/local_puzzles/melody_validator.c/.h` | Validateur de mélodie (notes + tolérance) — **logique pure** |
| `idf_zacus/components/local_puzzles/mic_broker.c/.h` | Possède l'I2S RX, aiguille les frames par mode |
| `idf_zacus/components/local_puzzles/qr_puzzle.c` | Caméra + quirc + seq_validator |
| `idf_zacus/components/local_puzzles/sound_puzzle.c` | Frames micro + détection notes + melody_validator |
| `idf_zacus/components/local_puzzles/local_puzzles.c` | Câblage, `local_puzzle_report`, arm/disarm |
| `idf_zacus/components/local_puzzles/CMakeLists.txt` | Déclaration composant + REQUIRES |
| `idf_zacus/components/local_puzzles/idf_component.yml` | Deps managées (esp32-camera, quirc) |
| `idf_zacus/components/local_puzzles/test/test_validators.c` | Tests Unity hôte des deux validateurs |
| `idf_zacus/components/puzzle_state/…` | **Fondation** : agrégation des codes d'énigme + `POST /game/puzzle_solved` |
| `idf_zacus/components/voice_pipeline/voice_pipeline.c` | **Modifié** : tire le micro du broker |
| `idf_zacus/main/main.c` | **Modifié** : init puzzle_state + local_puzzles |
---
## Task 0: Réconcilier le pin-map Media Kit v1.2 (BLOQUANT, pas de code)
**Pourquoi bloquant :** le `voice_pipeline` occupe déjà `GPIO14` (BCLK), `GPIO15` (WS), `GPIO22` (DIN micro) et `GPIO11/12/13` (haut-parleur MAX98357A). Le pin-map caméra Freenove générique réutilise `GPIO15` (XCLK), `GPIO13` (PCLK), `GPIO11/12` (data) → **collision directe**. Sans le vrai brochage du Media Kit v1.2, le code GPIO de la caméra serait faux.
- [ ] **Step 1: Extraire le brochage réel**
Récupérer, depuis le schéma officiel **Freenove ESP32-S3 Media Kit v1.2** (PDF fourni avec le kit / dépôt Freenove `Freenove_Media_Kit_for_ESP32_S3`), les GPIO de : caméra (PWDN, RESET, XCLK, SIOD, SIOC, VSYNC, HREF, PCLK, D0-D7), micro I2S (BCLK, WS, DIN), haut-parleur (BCLK, LRC, DIN).
- [ ] **Step 2: Vérifier la coexistence**
Construire un tableau GPIO unique. Confirmer **zéro collision** entre caméra, micro et haut-parleur. Si collision réelle sur le Media Kit (caméra et micro partagent un bus), documenter la conséquence : P3 (caméra) et P1/NPC (micro) deviennent **mutuellement exclusifs dans le temps** — acceptable car déjà séquentiels par phase de jeu, mais à acter.
- [ ] **Step 3: Geler les constantes**
Écrire le tableau final dans `idf_zacus/components/local_puzzles/include/board_pins_mediakit.h` (macros `CAM_PIN_*`, et confirmer que `voice_pipeline` garde ses pins micro actuels). Commit :
```bash
git add idf_zacus/components/local_puzzles/include/board_pins_mediakit.h
git commit -m "docs(local): freeze Media Kit v1.2 pin map"
```
> **Sortie de Task 0** : `board_pins_mediakit.h` avec les `CAM_PIN_*` confirmés. Les tâches caméra en dépendent.
---
## Task 1: Validateur de séquence QR (logique pure, testable hôte)
**Files:**
- Create: `idf_zacus/components/local_puzzles/seq_validator.h`
- Create: `idf_zacus/components/local_puzzles/seq_validator.c`
- Test: `idf_zacus/components/local_puzzles/test/test_validators.c`
- [ ] **Step 1: Écrire le test qui échoue**
```c
#include "unity.h"
#include "seq_validator.h"
TEST_CASE("seq accepts codes scanned in the expected order", "[seq]")
{
const char *expected[] = {"qr3", "qr1", "qr4"};
seq_validator_t v;
seq_validator_init(&v, expected, 3);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "qr3")); // 1/3
TEST_ASSERT_FALSE(seq_validator_feed(&v, "qr1")); // 2/3
TEST_ASSERT_TRUE (seq_validator_feed(&v, "qr4")); // complete
}
TEST_CASE("seq resets on a wrong scan", "[seq]")
{
const char *expected[] = {"a", "b"};
seq_validator_t v;
seq_validator_init(&v, expected, 2);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // 1/2
TEST_ASSERT_FALSE(seq_validator_feed(&v, "x")); // wrong -> reset
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // 1/2 again
TEST_ASSERT_TRUE (seq_validator_feed(&v, "b")); // complete
}
TEST_CASE("seq ignores a duplicate of the last correct scan", "[seq]")
{
const char *expected[] = {"a", "b"};
seq_validator_t v;
seq_validator_init(&v, expected, 2);
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a"));
TEST_ASSERT_FALSE(seq_validator_feed(&v, "a")); // duplicate, no progress, no reset
TEST_ASSERT_TRUE (seq_validator_feed(&v, "b"));
}
```
- [ ] **Step 2: Lancer le test (échec attendu)**
Run: cible de test Unity du composant (`idf.py -C idf_zacus/components/local_puzzles/test build` via un projet de test, ou la cible `unity` du composant).
Expected: FAIL — `seq_validator.h` introuvable.
- [ ] **Step 3: Écrire le header**
```c
// seq_validator.h — ordered-scan validator (QR P3). Pure logic, no I/O.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#define SEQ_MAX_STEPS 16
#define SEQ_MAX_LABEL 32
typedef struct {
char expected[SEQ_MAX_STEPS][SEQ_MAX_LABEL];
size_t count;
size_t pos; // next index to match
} seq_validator_t;
// expected: array of `count` C-strings (<= SEQ_MAX_STEPS, each < SEQ_MAX_LABEL).
void seq_validator_init(seq_validator_t *v, const char *const *expected, size_t count);
// Feed one scanned code. Returns true exactly when the full sequence is done.
// A wrong code resets progress; a duplicate of the last matched code is ignored.
bool seq_validator_feed(seq_validator_t *v, const char *code);
```
- [ ] **Step 4: Écrire l'implémentation minimale**
```c
// seq_validator.c
#include "seq_validator.h"
#include <string.h>
void seq_validator_init(seq_validator_t *v, const char *const *expected, size_t count)
{
memset(v, 0, sizeof(*v));
if (count > SEQ_MAX_STEPS) count = SEQ_MAX_STEPS;
v->count = count;
for (size_t i = 0; i < count; i++) {
strncpy(v->expected[i], expected[i], SEQ_MAX_LABEL - 1);
}
}
bool seq_validator_feed(seq_validator_t *v, const char *code)
{
if (v->count == 0 || v->pos >= v->count) return false;
// duplicate of the last matched code: ignore (no progress, no reset)
if (v->pos > 0 && strncmp(code, v->expected[v->pos - 1], SEQ_MAX_LABEL) == 0)
return false;
if (strncmp(code, v->expected[v->pos], SEQ_MAX_LABEL) == 0) {
v->pos++;
return v->pos == v->count;
}
v->pos = 0; // wrong scan -> reset
// allow the wrong scan to itself start a new run at index 0
if (strncmp(code, v->expected[0], SEQ_MAX_LABEL) == 0) v->pos = 1;
return false;
}
```
- [ ] **Step 5: Lancer le test (succès attendu)** — 3 cas `[seq]` verts.
- [ ] **Step 6: Commit**
```bash
git add idf_zacus/components/local_puzzles/seq_validator.h \
idf_zacus/components/local_puzzles/seq_validator.c \
idf_zacus/components/local_puzzles/test/test_validators.c
git commit -m "feat(local): QR sequence validator + tests"
```
---
## Task 2: Validateur de mélodie (logique pure, testable hôte)
**Files:**
- Create: `idf_zacus/components/local_puzzles/melody_validator.h`
- Create: `idf_zacus/components/local_puzzles/melody_validator.c`
- Modify: `idf_zacus/components/local_puzzles/test/test_validators.c` (ajouter des cas)
- [ ] **Step 1: Écrire le test qui échoue**
```c
#include "melody_validator.h"
// Notes as MIDI numbers; tolerance in semitones. The player reproduces a melody;
// the detector hands us one note at a time, we accept the sequence within tolerance.
TEST_CASE("melody accepts the exact expected note sequence", "[melody]")
{
const int expected[] = {60, 62, 64, 65}; // do re mi fa
melody_validator_t m;
melody_validator_init(&m, expected, 4, /*tolerance=*/1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 62));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 64));
TEST_ASSERT_TRUE (melody_validator_feed(&m, 65));
}
TEST_CASE("melody accepts notes within tolerance", "[melody]")
{
const int expected[] = {60, 62};
melody_validator_t m;
melody_validator_init(&m, expected, 2, 1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 61)); // 60 +/-1 ok
TEST_ASSERT_TRUE (melody_validator_feed(&m, 62));
}
TEST_CASE("melody resets on an out-of-tolerance note", "[melody]")
{
const int expected[] = {60, 62};
melody_validator_t m;
melody_validator_init(&m, expected, 2, 1);
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_FALSE(melody_validator_feed(&m, 70)); // way off -> reset
TEST_ASSERT_FALSE(melody_validator_feed(&m, 60));
TEST_ASSERT_TRUE (melody_validator_feed(&m, 62));
}
```
- [ ] **Step 2: Lancer le test (échec attendu)**`melody_validator.h` introuvable.
- [ ] **Step 3: Écrire le header**
```c
// melody_validator.h — note-sequence validator (P1). Pure logic, no I/O.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#define MELODY_MAX_NOTES 32
typedef struct {
int expected[MELODY_MAX_NOTES]; // MIDI note numbers
size_t count;
int tolerance; // accepted deviation in semitones
size_t pos;
} melody_validator_t;
void melody_validator_init(melody_validator_t *m, const int *expected,
size_t count, int tolerance);
// Feed one detected note (MIDI). True when the full melody is matched.
bool melody_validator_feed(melody_validator_t *m, int note);
```
- [ ] **Step 4: Écrire l'implémentation**
```c
// melody_validator.c
#include "melody_validator.h"
#include <string.h>
#include <stdlib.h>
void melody_validator_init(melody_validator_t *m, const int *expected,
size_t count, int tolerance)
{
memset(m, 0, sizeof(*m));
if (count > MELODY_MAX_NOTES) count = MELODY_MAX_NOTES;
m->count = count;
m->tolerance = tolerance;
for (size_t i = 0; i < count; i++) m->expected[i] = expected[i];
}
bool melody_validator_feed(melody_validator_t *m, int note)
{
if (m->count == 0 || m->pos >= m->count) return false;
if (abs(note - m->expected[m->pos]) <= m->tolerance) {
m->pos++;
return m->pos == m->count;
}
// out of tolerance: reset, but let this note seed index 0 if it fits
m->pos = 0;
if (abs(note - m->expected[0]) <= m->tolerance) m->pos = 1;
return false;
}
```
- [ ] **Step 5: Lancer le test (succès attendu)** — cas `[melody]` verts.
- [ ] **Step 6: Commit**
```bash
git add idf_zacus/components/local_puzzles/melody_validator.h \
idf_zacus/components/local_puzzles/melody_validator.c \
idf_zacus/components/local_puzzles/test/test_validators.c
git commit -m "feat(local): melody validator + tests"
```
---
## Task 3: Fondation — agrégation « puzzle résolu » dans le master
**Pourquoi :** `idf_zacus` n'a aujourd'hui **aucun** chemin de réception d'énigme résolue (ni ESP-NOW, ni HTTP). `local_puzzle_report` n'a donc rien à mirrorer — on crée la fondation, que les énigmes locales **et** futures distantes utiliseront.
**Files:**
- Create: `idf_zacus/components/puzzle_state/include/puzzle_state.h`
- Create: `idf_zacus/components/puzzle_state/puzzle_state.c`
- Create: `idf_zacus/components/puzzle_state/CMakeLists.txt`
- Test: `idf_zacus/components/puzzle_state/test/test_puzzle_state.c`
- [ ] **Step 1: Écrire le test qui échoue**
```c
#include "unity.h"
#include "puzzle_state.h"
TEST_CASE("assembles the code from reported fragments", "[pstate]")
{
puzzle_state_t s;
puzzle_state_init(&s);
puzzle_state_report(&s, /*id=*/1, (const uint8_t[]){1,2,0,0}, 2);
puzzle_state_report(&s, /*id=*/3, (const uint8_t[]){3,4,0,0}, 2);
char code[16];
TEST_ASSERT_EQUAL_INT(4, puzzle_state_code(&s, code, sizeof(code)));
TEST_ASSERT_EQUAL_STRING("1234", code);
}
TEST_CASE("a puzzle reported twice does not duplicate its digits", "[pstate]")
{
puzzle_state_t s;
puzzle_state_init(&s);
puzzle_state_report(&s, 1, (const uint8_t[]){1,2,0,0}, 2);
puzzle_state_report(&s, 1, (const uint8_t[]){1,2,0,0}, 2);
char code[16];
TEST_ASSERT_EQUAL_INT(2, puzzle_state_code(&s, code, sizeof(code)));
TEST_ASSERT_EQUAL_STRING("12", code);
}
```
- [ ] **Step 2: Lancer le test (échec attendu)** — header introuvable.
- [ ] **Step 3: Écrire le header**
```c
// puzzle_state.h — master-side aggregation of solved puzzles + assembled code.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define PUZZLE_MAX_ID 8
#define PUZZLE_MAX_FRAG 4
typedef struct {
bool solved[PUZZLE_MAX_ID + 1];
uint8_t frag[PUZZLE_MAX_ID + 1][PUZZLE_MAX_FRAG];
uint8_t frag_len[PUZZLE_MAX_ID + 1];
} puzzle_state_t;
void puzzle_state_init(puzzle_state_t *s);
// Record a solved puzzle. Idempotent per id. `len` <= PUZZLE_MAX_FRAG.
void puzzle_state_report(puzzle_state_t *s, uint8_t id,
const uint8_t *fragment, uint8_t len);
// Write the assembled code (digits of all solved puzzles, by ascending id)
// as a NUL-terminated string. Returns the number of digits written.
int puzzle_state_code(const puzzle_state_t *s, char *out, size_t cap);
```
- [ ] **Step 4: Écrire l'implémentation**
```c
// puzzle_state.c
#include "puzzle_state.h"
#include <string.h>
void puzzle_state_init(puzzle_state_t *s) { memset(s, 0, sizeof(*s)); }
void puzzle_state_report(puzzle_state_t *s, uint8_t id,
const uint8_t *fragment, uint8_t len)
{
if (id == 0 || id > PUZZLE_MAX_ID) return;
if (len > PUZZLE_MAX_FRAG) len = PUZZLE_MAX_FRAG;
if (s->solved[id]) return; // idempotent
s->solved[id] = true;
s->frag_len[id] = len;
memcpy(s->frag[id], fragment, len);
}
int puzzle_state_code(const puzzle_state_t *s, char *out, size_t cap)
{
int n = 0;
for (uint8_t id = 1; id <= PUZZLE_MAX_ID; id++) {
if (!s->solved[id]) continue;
for (uint8_t k = 0; k < s->frag_len[id] && (size_t)(n + 1) < cap; k++)
out[n++] = (char)('0' + (s->frag[id][k] % 10));
}
if ((size_t)n < cap) out[n] = '\0';
return n;
}
```
- [ ] **Step 5: Écrire le CMakeLists du composant**
```cmake
idf_component_register(
SRCS "puzzle_state.c"
INCLUDE_DIRS "include"
)
```
- [ ] **Step 6: Lancer le test (succès attendu)** — cas `[pstate]` verts.
- [ ] **Step 7: Commit**
```bash
git add idf_zacus/components/puzzle_state/
git commit -m "feat(master): puzzle-solved aggregation + code assembly"
```
---
## Task 4: `mic_broker` + refacto `voice_pipeline`
**Files:**
- Create: `idf_zacus/components/local_puzzles/mic_broker.h`
- Create: `idf_zacus/components/local_puzzles/mic_broker.c`
- Modify: `idf_zacus/components/voice_pipeline/voice_pipeline.c` (i2s_setup + capture_task)
- [ ] **Step 1: Écrire le header du broker**
```c
// mic_broker.h — single owner of the I2S RX mic; routes frames to one
// active consumer chosen by mode. No frame is delivered in MIC_IDLE.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
typedef enum { MIC_IDLE, MIC_NPC_LISTEN, MIC_P1_SOUND } mic_mode_t;
// cb receives PCM16 mono frames (count = samples) when its mode is active.
typedef void (*mic_frame_cb_t)(const int16_t *pcm, size_t samples, void *ctx);
esp_err_t mic_broker_init(int bclk_pin, int ws_pin, int din_pin, int sample_rate_hz);
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx);
void mic_broker_set_mode(mic_mode_t mode);
mic_mode_t mic_broker_mode(void);
```
- [ ] **Step 2: Écrire l'implémentation du broker**
```c
// mic_broker.c
#include "mic_broker.h"
#include "driver/i2s_std.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define MB_FRAME 320 // 20 ms @ 16 kHz
static i2s_chan_handle_t s_rx;
static volatile mic_mode_t s_mode = MIC_IDLE;
static struct { mic_frame_cb_t cb; void *ctx; } s_consumers[3];
static void capture_task(void *arg) {
int16_t buf[MB_FRAME];
size_t got;
for (;;) {
if (i2s_channel_read(s_rx, buf, sizeof(buf), &got, portMAX_DELAY) != ESP_OK) continue;
mic_mode_t m = s_mode;
if (m != MIC_IDLE && s_consumers[m].cb)
s_consumers[m].cb(buf, got / sizeof(int16_t), s_consumers[m].ctx);
}
}
esp_err_t mic_broker_init(int bclk, int ws, int din, int sr) {
i2s_chan_config_t cc = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
esp_err_t e = i2s_new_channel(&cc, NULL, &s_rx);
if (e != ESP_OK) return e;
i2s_std_config_t std = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sr),
.slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = { .bclk = bclk, .ws = ws, .din = din, .mclk = I2S_GPIO_UNUSED,
.dout = I2S_GPIO_UNUSED, .invert_flags = {0} },
};
e = i2s_channel_init_std_mode(s_rx, &std);
if (e != ESP_OK) return e;
e = i2s_channel_enable(s_rx);
if (e != ESP_OK) return e;
xTaskCreate(capture_task, "mic_broker", 4096, NULL, 5, NULL);
return ESP_OK;
}
void mic_broker_register(mic_mode_t mode, mic_frame_cb_t cb, void *ctx) {
if (mode == MIC_IDLE) return;
s_consumers[mode].cb = cb; s_consumers[mode].ctx = ctx;
}
void mic_broker_set_mode(mic_mode_t mode) { s_mode = mode; }
mic_mode_t mic_broker_mode(void) { return s_mode; }
```
- [ ] **Step 3: Refacto `voice_pipeline` pour consommer le broker**
Dans `voice_pipeline.c`, remplacer l'init I2S RX locale (`i2s_setup`, `rx_chan`, lecture dans `capture_task`) par : `mic_broker_init(cfg.i2s_bclk_pin, cfg.i2s_ws_pin, cfg.i2s_din_pin, 16000)` au démarrage, puis `mic_broker_register(MIC_NPC_LISTEN, on_npc_frame, &s_pipe)``on_npc_frame` contient le corps actuel de la boucle de capture (feed AFE). Conserver l'I2S TX (haut-parleur) inchangé. Au passage en écoute NPC : `mic_broker_set_mode(MIC_NPC_LISTEN)`.
- [ ] **Step 4: Build + smoke régression**
Run: `idf.py -C idf_zacus build && idf.py -C idf_zacus -p /dev/cu.usbmodem5AB90753301 flash monitor`
Expected: NPC vocal toujours fonctionnel (wakeword + stream WS), aucun log d'erreur I2S.
- [ ] **Step 5: Commit**
```bash
git add idf_zacus/components/local_puzzles/mic_broker.h \
idf_zacus/components/local_puzzles/mic_broker.c \
idf_zacus/components/voice_pipeline/voice_pipeline.c
git commit -m "feat(local): mic broker; voice_pipeline pulls from it"
```
---
## Task 5: `qr_puzzle` (caméra OV2640 + quirc + seq_validator)
**Files:**
- Create: `idf_zacus/components/local_puzzles/qr_puzzle.c`
- Create: `idf_zacus/components/local_puzzles/include/qr_puzzle.h`
- Create/Modify: `idf_zacus/components/local_puzzles/idf_component.yml`
- [ ] **Step 1: Déclarer les deps managées**
```yaml
dependencies:
espressif/esp32-camera:
version: ">=2.0.0"
espressif/quirc: # si absent du registre, vendorer quirc (note ci-dessous)
version: "*"
```
> Si `espressif/quirc` n'existe pas au registre : vendorer `quirc` (lib C, ~5 fichiers) sous `idf_zacus/components/quirc/` avec un `CMakeLists.txt` minimal `idf_component_register(SRCS quirc.c decode.c identify.c version_db.c INCLUDE_DIRS .)`.
- [ ] **Step 2: Écrire `qr_puzzle.h`**
```c
// qr_puzzle.h
#pragma once
#include <stddef.h>
#include "esp_err.h"
typedef void (*qr_solved_cb_t)(void);
esp_err_t qr_puzzle_start(const char *const *expected, size_t count, qr_solved_cb_t cb);
void qr_puzzle_stop(void);
```
- [ ] **Step 3: Écrire `qr_puzzle.c`**
```c
// qr_puzzle.c — camera capture + quirc decode; validates QR order.
#include "qr_puzzle.h"
#include "seq_validator.h"
#include "board_pins_mediakit.h"
#include "esp_camera.h"
#include "quirc.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#define TAG "qr_puzzle"
static seq_validator_t s_seq;
static qr_solved_cb_t s_cb;
static TaskHandle_t s_task;
static volatile bool s_run;
static camera_config_t cam_cfg(void) {
camera_config_t c = {
.pin_pwdn = CAM_PIN_PWDN, .pin_reset = CAM_PIN_RESET, .pin_xclk = CAM_PIN_XCLK,
.pin_sccb_sda = CAM_PIN_SIOD, .pin_sccb_scl = CAM_PIN_SIOC,
.pin_d7 = CAM_PIN_D7, .pin_d6 = CAM_PIN_D6, .pin_d5 = CAM_PIN_D5, .pin_d4 = CAM_PIN_D4,
.pin_d3 = CAM_PIN_D3, .pin_d2 = CAM_PIN_D2, .pin_d1 = CAM_PIN_D1, .pin_d0 = CAM_PIN_D0,
.pin_vsync = CAM_PIN_VSYNC, .pin_href = CAM_PIN_HREF, .pin_pclk = CAM_PIN_PCLK,
.xclk_freq_hz = 20000000, .ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0,
.pixel_format = PIXFORMAT_GRAYSCALE, .frame_size = FRAMESIZE_QVGA,
.fb_count = 1, .fb_location = CAMERA_FB_IN_PSRAM, .grab_mode = CAMERA_GRAB_LATEST,
};
return c;
}
static void scan_task(void *arg) {
struct quirc *q = quirc_new();
quirc_resize(q, 320, 240);
while (s_run) {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) { vTaskDelay(pdMS_TO_TICKS(50)); continue; }
uint8_t *img = quirc_begin(q, NULL, NULL);
memcpy(img, fb->buf, 320 * 240);
quirc_end(q);
int n = quirc_count(q);
for (int i = 0; i < n; i++) {
struct quirc_code code; struct quirc_data data;
quirc_extract(q, i, &code);
if (quirc_decode(&code, &data) == 0) {
if (seq_validator_feed(&s_seq, (const char *)data.payload)) {
if (s_cb) s_cb();
s_run = false;
}
}
}
esp_camera_fb_return(fb);
vTaskDelay(pdMS_TO_TICKS(30));
}
quirc_destroy(q);
esp_camera_deinit(); // free PSRAM + power down (battery)
s_task = NULL;
vTaskDelete(NULL);
}
esp_err_t qr_puzzle_start(const char *const *expected, size_t count, qr_solved_cb_t cb) {
if (s_task) return ESP_ERR_INVALID_STATE;
camera_config_t cfg = cam_cfg();
esp_err_t e = esp_camera_init(&cfg);
if (e != ESP_OK) { ESP_LOGE(TAG, "camera init: 0x%x", e); return e; }
seq_validator_init(&s_seq, expected, count);
s_cb = cb; s_run = true;
xTaskCreate(scan_task, "qr_scan", 8192, NULL, 5, &s_task);
return ESP_OK;
}
void qr_puzzle_stop(void) { s_run = false; }
```
- [ ] **Step 4: Smoke hardware**
Armer P3 (Task 7) avec un set de QR connus, présenter les QR dans l'ordre devant la caméra.
Expected: log `qr_puzzle` reconnaît chaque code ; au dernier, le callback se déclenche.
- [ ] **Step 5: Commit**
```bash
git add idf_zacus/components/local_puzzles/qr_puzzle.c \
idf_zacus/components/local_puzzles/include/qr_puzzle.h \
idf_zacus/components/local_puzzles/idf_component.yml
git commit -m "feat(local): QR puzzle via camera + quirc"
```
---
## Task 6: `sound_puzzle` (frames micro + détection notes + melody_validator)
**Files:**
- Create: `idf_zacus/components/local_puzzles/sound_puzzle.c`
- Create: `idf_zacus/components/local_puzzles/include/sound_puzzle.h`
- [ ] **Step 1: Écrire `sound_puzzle.h`**
```c
// sound_puzzle.h
#pragma once
#include <stddef.h>
typedef void (*sound_solved_cb_t)(void);
void sound_puzzle_start(const int *expected, size_t count, int tol, sound_solved_cb_t cb);
void sound_puzzle_stop(void);
```
- [ ] **Step 2: Écrire `sound_puzzle.c`**
```c
// sound_puzzle.c — consumes MIC_P1_SOUND frames, estimates note, validates melody.
#include "sound_puzzle.h"
#include "melody_validator.h"
#include "mic_broker.h"
#include <math.h>
static melody_validator_t s_mel;
static sound_solved_cb_t s_cb;
static int s_last_note = -1000;
// Crude pitch: zero-crossing rate -> fundamental -> MIDI. Replaced by an
// autocorrelation estimator if accuracy is insufficient (documented limit).
static int frame_to_midi(const int16_t *pcm, size_t n, int sr) {
size_t zc = 0;
for (size_t i = 1; i < n; i++) if ((pcm[i-1] < 0) != (pcm[i] < 0)) zc++;
if (zc < 2) return -1000; // silence
double freq = (double)zc * sr / (2.0 * n);
if (freq < 80 || freq > 2000) return -1000;
return (int)lround(69 + 12 * log2(freq / 440.0));
}
static void on_frame(const int16_t *pcm, size_t n, void *ctx) {
int note = frame_to_midi(pcm, n, 16000);
if (note <= -1000) { s_last_note = -1000; return; }
if (note == s_last_note) return; // debounce sustained note
s_last_note = note;
if (melody_validator_feed(&s_mel, note)) { if (s_cb) s_cb(); }
}
void sound_puzzle_start(const int *expected, size_t count, int tol, sound_solved_cb_t cb) {
melody_validator_init(&s_mel, expected, count, tol);
s_cb = cb; s_last_note = -1000;
mic_broker_register(MIC_P1_SOUND, on_frame, NULL);
mic_broker_set_mode(MIC_P1_SOUND);
}
void sound_puzzle_stop(void) { mic_broker_set_mode(MIC_IDLE); }
```
- [ ] **Step 3: Smoke hardware**
Armer P1, jouer la mélodie attendue (instrument/voix) devant le micro.
Expected: les notes détectées progressent ; à la dernière, le callback se déclenche. Documenter dans le commit la limite connue de l'estimateur (zero-crossing) si la robustesse est insuffisante (piste : autocorrélation / `esp-dsp` FFT).
- [ ] **Step 4: Commit**
```bash
git add idf_zacus/components/local_puzzles/sound_puzzle.c \
idf_zacus/components/local_puzzles/include/sound_puzzle.h
git commit -m "feat(local): sound puzzle via mic broker"
```
---
## Task 7: Câblage `local_puzzles` + flag config local|remote + main.c
**Files:**
- Create: `idf_zacus/components/local_puzzles/local_puzzles.c`
- Create: `idf_zacus/components/local_puzzles/include/local_puzzles.h`
- Create: `idf_zacus/components/local_puzzles/CMakeLists.txt`
- Modify: `idf_zacus/main/main.c`
- [ ] **Step 1: Écrire `local_puzzles.h`**
```c
// local_puzzles.h
#pragma once
#include <stddef.h>
#include "esp_err.h"
#include "puzzle_state.h"
void local_puzzles_init(puzzle_state_t *state);
esp_err_t local_puzzles_arm_qr(const char *const *expected, size_t count);
void local_puzzles_arm_sound(const int *expected, size_t count, int tol);
```
- [ ] **Step 2: Écrire `local_puzzles.c`**
```c
// local_puzzles.c — wires QR/sound to puzzle_state and arms by puzzle id.
#include "local_puzzles.h"
#include "qr_puzzle.h"
#include "sound_puzzle.h"
static puzzle_state_t *s_state;
// Code-fragment slots per scenario (placeholder values; the scenario engine
// will supply real fragments in a later iteration).
static void on_qr_solved(void) { puzzle_state_report(s_state, 3, (const uint8_t[]){5,0,0,0}, 1); }
static void on_sound_solved(void) { puzzle_state_report(s_state, 1, (const uint8_t[]){1,2,0,0}, 2); }
void local_puzzles_init(puzzle_state_t *state) { s_state = state; }
esp_err_t local_puzzles_arm_qr(const char *const *expected, size_t count) {
return qr_puzzle_start(expected, count, on_qr_solved);
}
void local_puzzles_arm_sound(const int *expected, size_t count, int tol) {
sound_puzzle_start(expected, count, tol, on_sound_solved);
}
```
- [ ] **Step 3: Écrire le `CMakeLists.txt` du composant**
```cmake
idf_component_register(
SRCS "local_puzzles.c" "mic_broker.c" "qr_puzzle.c" "sound_puzzle.c"
"seq_validator.c" "melody_validator.c"
INCLUDE_DIRS "include" "."
PRIV_REQUIRES driver esp_timer puzzle_state
REQUIRES esp_camera
)
```
- [ ] **Step 4: Initialiser dans `main.c`**
Après l'init Wi-Fi / `game_endpoint`, ajouter : `static puzzle_state_t s_pstate;` global, `puzzle_state_init(&s_pstate);`, `local_puzzles_init(&s_pstate);`, et `mic_broker_init(14, 15, 22, 16000);` (pins micro du `voice_pipeline` — à aligner avec Task 0). L'armement de P1/P3 est déclenché par l'étape de scénario active (gating via le moteur de scénario existant) ; en V1, appel direct `local_puzzles_arm_qr(...)` / `local_puzzles_arm_sound(...)` lors de la phase correspondante avec les valeurs du scénario chargé.
- [ ] **Step 5: Build + flash + smoke complet**
Run: `idf.py -C idf_zacus -p /dev/cu.usbmodem5AB90753301 flash monitor`
Expected: build vert, P3 puis P1 résolus localement déclenchent `puzzle_state_report` et le code s'assemble (`puzzle_state_code`).
- [ ] **Step 6: Commit**
```bash
git add idf_zacus/components/local_puzzles/local_puzzles.c \
idf_zacus/components/local_puzzles/include/local_puzzles.h \
idf_zacus/components/local_puzzles/CMakeLists.txt \
idf_zacus/main/main.c
git commit -m "feat(local): wire local puzzles into the master"
```
---
## Self-review (couverture spec)
- mic_broker + contention → Task 4 ✓
- qr_puzzle (caméra+quirc+ordre) → Task 5 ✓
- sound_puzzle (mélodie) → Task 6 ✓
- local_puzzle_report → fondu dans `puzzle_state` (Task 3) + callbacks `local_puzzles` (Task 7) ✓
- flag local|remote → Task 7 Step 4 (gating scénario) ✓ ; config persistante `boards.yaml` = extension future, notée
- validateurs testables hôte → Tasks 1, 2 ✓
- pin-map / conflit GPIO → Task 0 (bloquant) ✓
- **Écart spec assumé** : le spec supposait un chemin `MSG_PUZZLE_SOLVED` existant ; il n'existe pas dans `idf_zacus` → fondation `puzzle_state` ajoutée (Task 3). Le repli ESP-NOW « si le nœud existe » reste théorique tant que le master ne reçoit pas d'ESP-NOW puzzle — à traiter dans un plan ultérieur si P5/P6/P7 doivent aussi remonter au master.
## Inconnues à lever en exécution
1. Pins réelles caméra/micro/HP du Media Kit v1.2 (Task 0, bloquant).
2. Présence de `espressif/quirc` au registre, sinon vendoring (Task 5 Step 1).
3. Robustesse de l'estimateur de hauteur zero-crossing vs autocorrélation/FFT `esp-dsp` (Task 6 Step 3).
4. Fragments de code réels par énigme (actuellement placeholders dans `local_puzzles.c`) — fournis par le moteur de scénario.