# É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)` où `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.
