Files
lisael-box/docs/superpowers/plans/2026-06-21-lisael-box-routine-mode.md
T
L'électron rare 4bca57e514 docs(plans): firmware routine + merlin studio v2
Two implementation plans for the routine mode (firmware) and Merlin Studio v2 (Tower/web: routines tab + transfers viz).
2026-06-21 09:00:21 +02:00

55 KiB
Raw Blame History

Firmware Mode Routine — 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: Ajouter au firmware de la Lisael Box (ESP32-S3-BOX-3, ESP-IDF v5.4, LVGL 9) un mode « Routine illustrée » : tuile d'accueil → choix Matin/Soir → enchaînement d'étapes plein écran (gros picto + nom + progression X/N + bouton « C'est fait ✓ » + voix d'annonce + son de validation) → écran « Bravo ! » final. Le contenu vient de /sdcard/routines/routines.json + assets .bin/.mp3 poussés par Tower en WiFi — aucun reflash pour changer une routine. Ce plan ne couvre QUE le firmware ; la génération de contenu et l'onglet web vivent dans le plan « Merlin Studio v2 ».

Architecture: Réutilise trois briques en prod : (1) le parser de manifeste pur+testable du jeu podcasts (audio/podcast_manifest.{h,c}), (2) le cache picto PSRAM du jeu Lire (modes/lire.cicon_dsc() : lit chaque .bin une fois en PSRAM, jamais le driver LVGL « S: »), (3) le câblage de mode du shell UI (ui/ui.{h,c} enum + builder mémoïsé, modes/home.c carrousel de tuiles). Nouveau : audio/routine_manifest.{c,h} (parse routines.json), modes/routine.c (les 3 écrans : choix / étape / bravo), une icône de tuile lisael_ic_routine, et l'extension de net/box_register.c pour annoncer routines/* à Tower.

Tech Stack: C (ESP-IDF v5.4, cible esp32s3), LVGL 9, cJSON (composant IDF json), esp_heap_caps (PSRAM MALLOC_CAP_SPIRAM), aac_player (lecture .mp3/.m4a), test hôte C nu (cc -DLISAEL_HOST_TEST, pas de framework), Python venv IDF pour générer l'icône Twemoji (tools/gen_icons.pyLVGLImage.py).

Global Constraints

  • ESP-IDF v5.4, cible esp32s3 ; activer l'environnement : export IDF_PATH=$HOME/esp/esp-idf; unset ADF_PATH; . $IDF_PATH/export.sh (venv idf5.4).
  • Noms de fichiers SD ASCII (matin_01) ; libellés accentués dans le JSON (UTF-8, rendus par Fredoka). LFN FAT doit être activé (Task 0).
  • Toute structure routine + tout buffer .bin est en PSRAM via heap_caps_*(…, MALLOC_CAP_SPIRAM) — jamais sur la pile.
  • Picto .bin = lv_image_header_t (12 octets) + données RGB565A8 ; cache PSRAM (lire le fichier une fois, présenter lv_image_dsc_t en RAM), jamais le driver LVGL « S: » (relit la SD à chaque frame = lag). Garde sz <= sizeof(lv_image_header_t) → rejeter.
  • LISAEL_MODE_COUNT reste toujours dernier dans l'enum (il dimensionne s_screens[]).
  • Le builder lisael_screen_routine() s'exécute sous le lock LVGL déjà tenu par l'appelant → ne pas reprendre bsp_display_lock dedans. Une tâche de fond qui touche l'UI prend le lock elle-même.
  • Builder mémoïsé : static lv_obj_t *s_root; if (s_root) return s_root; (le cache s_screens[] est auto_del=false).
  • Caps : LISAEL_MAX_ROUTINES 2, LISAEL_MAX_STEPS 12.
  • Commits : sujet ≤ 50 caractères, scope sans underscore, pas d'attribution AI, jamais --no-verify. Un commit par tâche.
  • Test hôte = cc direct avec -DLISAEL_HOST_TEST (pas de CMake pour les tests hôte).

Task 0: sdkconfig — remonter les réglages critiques dans les defaults

Le sdkconfig (gitigné) a été perdu. sdkconfig.defaults* ne contient PAS tous les réglages nécessaires (LFN FATFS, FS stdio LVGL pour les covers podcasts, mbedTLS dynamique, WiFi TX dynamique, stack en PSRAM). Sans eux : manifeste illisible, pictos invisibles, reset TLS. C'est la première tâche, sinon rien ne tourne.

Files:

  • Modify: sdkconfig.defaults (réglages target-indépendants)
  • Modify: sdkconfig.defaults.esp32s3 (réglages spécifiques s3)

Interfaces:

  • Produces: un sdkconfig neuf reproductible via idf.py set-target esp32s3 && idf.py reconfigure.

  • Consumes: rien (creds WiFi laissés au sdkconfig local non commité ou override NVS — ne pas committer de secrets).

  • Step 1: Ajouter les réglages FATFS LFN + FS stdio LVGL à sdkconfig.defaults. Ajouter à la fin de sdkconfig.defaults :

    # --- FATFS long file names (covers podcasts/routines, manifestes lus par fopen) ---
    CONFIG_FATFS_LFN_HEAP=y
    CONFIG_FATFS_MAX_LFN=255
    
    # --- LVGL stdio FS driver (covers podcasts via "S:" ; lettre 'S' = ASCII 83) ---
    CONFIG_LV_USE_FS_STDIO=y
    CONFIG_LV_FS_STDIO_LETTER=83
    
  • Step 2: Ajouter les réglages mbedTLS dynamique + WiFi TX dynamique à sdkconfig.defaults. Ajouter ensuite :

    # --- mbedTLS : allocation externe + buffers dynamiques (évite le reset TLS PSRAM) ---
    CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
    CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
    
    # --- WiFi : buffers TX dynamiques (libère la RAM interne) ---
    CONFIG_ESP_WIFI_STATIC_TX_BUFFER=n
    CONFIG_ESP_WIFI_TX_BUFFER_TYPE=1
    
  • Step 3: Ajouter l'autorisation de pile en PSRAM à sdkconfig.defaults.esp32s3. Ajouter à la fin de sdkconfig.defaults.esp32s3 (sous la section PSRAM existante) :

    # --- Autoriser les piles de tâches en PSRAM (gros buffers UI/routines) ---
    CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y
    
  • Step 4: Régénérer le sdkconfig et vérifier que les clés sont présentes.

    export IDF_PATH=$HOME/esp/esp-idf; unset ADF_PATH; . $IDF_PATH/export.sh
    idf.py set-target esp32s3 && idf.py reconfigure
    grep -E 'CONFIG_FATFS_LFN_HEAP=y|CONFIG_FATFS_MAX_LFN=255|CONFIG_LV_USE_FS_STDIO=y|CONFIG_LV_FS_STDIO_LETTER=83|CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y|CONFIG_MBEDTLS_DYNAMIC_BUFFER=y|CONFIG_ESP_WIFI_TX_BUFFER_TYPE=1|CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y' sdkconfig
    

    Expected: les 8 lignes ressortent du grep (reconfigure OK, sans erreur).

  • Step 5: Commit. git add sdkconfig.defaults sdkconfig.defaults.esp32s3 && git commit -m "build: restore sdkconfig defaults for routines"


Task 1: Parser routine_manifest (TDD hôte)

Parser pur de routines.jsonlisael_routine_t[], modelé sur audio/podcast_manifest.{h,c}. Différence clé : la racine JSON est un OBJET avec une clé "routines" (tableau), pas un tableau racine.

Files:

  • Create: main/audio/routine_manifest.h
  • Create: main/audio/routine_manifest.c
  • Create: test/host/test_routine_manifest.c

Interfaces:

  • Produces:

    • int lisael_routine_parse(const char *json, lisael_routine_t *routines, int max_routines); (pur, testable hôte ; renvoie le nombre de routines ≥ 0, ou -1 si JSON invalide / pas d'objet {"routines":[...]})
    • int lisael_routine_load(lisael_routine_t *routines, int max_routines); (lit /sdcard/routines/routines.json, gardé #ifndef LISAEL_HOST_TEST)
    • types lisael_step_t { char text[80]; char icon[40]; char audio[40]; }, lisael_routine_t { char id[16]; char title[48]; char icon[40]; lisael_step_t steps[LISAEL_MAX_STEPS]; int n_steps; }
    • macros LISAEL_MAX_ROUTINES 2, LISAEL_MAX_STEPS 12
  • Consumes: cJSON.h (composant IDF json).

  • Step 1: Écrire le header main/audio/routine_manifest.h.

    // Routine manifest for the Lisael Box "Routine" mode.
    // /sdcard/routines/routines.json = JSON object { "routines": [ ... ] }, each
    // routine has an id, a title, a tile icon, and an ordered list of steps. ASCII
    // file names; UTF-8 title/text strings. The pure parser is host-testable.
    #pragma once
    
    #ifdef __cplusplus
    extern "C" {
    #endif
    
    // Caps kept small: lisael_routine_t is ~2 KB; mode keeps a static array of
    // these in PSRAM, never a task stack. Two fixed routines (matin/soir), <=12
    // steps each.
    #define LISAEL_MAX_ROUTINES 2
    #define LISAEL_MAX_STEPS    12
    
    typedef struct {
        char text[80];   // UTF-8 step label (e.g. "Habille-toi")
        char icon[40];   // .bin basename under /sdcard/routines/ ("matin_01.bin")
        char audio[40];  // .mp3 basename under /sdcard/routines/ ("matin_01.mp3")
    } lisael_step_t;
    
    typedef struct {
        char id[16];                            // ASCII id ("matin", "soir")
        char title[48];                         // UTF-8 display title ("Le matin")
        char icon[40];                          // tile icon basename ("matin.bin"), "" if none
        lisael_step_t steps[LISAEL_MAX_STEPS];
        int  n_steps;
    } lisael_routine_t;
    
    // Pure parser: JSON string -> routines[]. Expects a root OBJECT with a
    // "routines" array. Skips routines with zero steps. Returns routine count
    // (>=0), or -1 if the JSON is malformed or has no "routines" array.
    int lisael_routine_parse(const char *json, lisael_routine_t *routines, int max_routines);
    
    // Read /sdcard/routines/routines.json and parse it. Returns count or -1.
    int lisael_routine_load(lisael_routine_t *routines, int max_routines);
    
    #ifdef __cplusplus
    }
    #endif
    
  • Step 2: Écrire le test hôte test/host/test_routine_manifest.c (qui échoue car .c pas encore écrit).

    // Host unit test for the routine manifest parser.
    #define LISAEL_HOST_TEST
    #include "audio/routine_manifest.h"
    #include <assert.h>
    #include <stdio.h>
    #include <string.h>
    
    static const char *SAMPLE =
    "{\"routines\":["
     "{\"id\":\"matin\",\"title\":\"Le matin\",\"icon\":\"matin.bin\",\"steps\":["
       "{\"text\":\"Habille-toi\",\"icon\":\"matin_01.bin\",\"audio\":\"matin_01.mp3\"},"
       "{\"text\":\"Petit-déjeuner\",\"icon\":\"matin_02.bin\",\"audio\":\"matin_02.mp3\"}]},"
     "{\"id\":\"soir\",\"title\":\"Le soir\",\"icon\":\"soir.bin\",\"steps\":["
       "{\"text\":\"Brosse tes dents\",\"icon\":\"soir_01.bin\",\"audio\":\"soir_01.mp3\"}]},"
     "{\"id\":\"vide\",\"title\":\"Vide\",\"icon\":\"vide.bin\",\"steps\":[]}"
    "]}";
    
    int main(void) {
        lisael_routine_t r[LISAEL_MAX_ROUTINES];
        int n = lisael_routine_parse(SAMPLE, r, LISAEL_MAX_ROUTINES);
        assert(n == 2);                                    // empty routine skipped
        assert(strcmp(r[0].id, "matin") == 0);
        assert(strcmp(r[0].title, "Le matin") == 0);
        assert(strcmp(r[0].icon, "matin.bin") == 0);
        assert(r[0].n_steps == 2);
        assert(strcmp(r[0].steps[0].text, "Habille-toi") == 0);
        assert(strcmp(r[0].steps[1].icon, "matin_02.bin") == 0);
        assert(strcmp(r[0].steps[1].audio, "matin_02.mp3") == 0);
        assert(strcmp(r[1].id, "soir") == 0);
        assert(r[1].n_steps == 1);
    
        // malformed / wrong-shape inputs -> -1
        assert(lisael_routine_parse("not json", r, LISAEL_MAX_ROUTINES) == -1);
        assert(lisael_routine_parse("[]", r, LISAEL_MAX_ROUTINES) == -1);     // array root, not object
        assert(lisael_routine_parse("{\"foo\":1}", r, LISAEL_MAX_ROUTINES) == -1); // no "routines"
    
        // empty routines array -> 0 (valid, just no content)
        lisael_routine_t r2[LISAEL_MAX_ROUTINES];
        assert(lisael_routine_parse("{\"routines\":[]}", r2, LISAEL_MAX_ROUTINES) == 0);
    
        // a step missing "text" is skipped; icon/audio optional (stay "")
        int m = lisael_routine_parse(
            "{\"routines\":[{\"id\":\"x\",\"title\":\"X\",\"steps\":["
              "{\"icon\":\"a.bin\"},"                       // no text -> skipped
              "{\"text\":\"Range\"}]}]}",                   // no icon/audio -> ""
            r2, LISAEL_MAX_ROUTINES);
        assert(m == 1 && r2[0].n_steps == 1);
        assert(strcmp(r2[0].steps[0].text, "Range") == 0);
        assert(r2[0].steps[0].icon[0] == '\0');
        assert(r2[0].steps[0].audio[0] == '\0');
    
        // guard clauses
        assert(lisael_routine_parse(NULL, r2, LISAEL_MAX_ROUTINES) == -1);
        assert(lisael_routine_parse("{\"routines\":[]}", r2, 0) == -1);
    
        // caps: never exceed LISAEL_MAX_STEPS even if JSON has more
        char big[2048];
        int o = snprintf(big, sizeof(big), "{\"routines\":[{\"id\":\"b\",\"title\":\"B\",\"steps\":[");
        for (int i = 0; i < 20; i++) {                      // 20 > LISAEL_MAX_STEPS (12)
            o += snprintf(big + o, sizeof(big) - o,
                          "%s{\"text\":\"s%d\"}", i ? "," : "", i);
        }
        snprintf(big + o, sizeof(big) - o, "]}]}");
        lisael_routine_t r3[LISAEL_MAX_ROUTINES];
        int k = lisael_routine_parse(big, r3, LISAEL_MAX_ROUTINES);
        assert(k == 1 && r3[0].n_steps == LISAEL_MAX_STEPS);
    
        printf("OK: %d routines\n", n);
        return 0;
    }
    
  • Step 3: Lancer le test → FAIL (linker error : lisael_routine_parse indéfini).

    export IDF_PATH=$HOME/esp/esp-idf
    REPO=/Users/electron/Documents/Projets_Techniques/GitHub/electron-rare/lisael-box
    cc -DLISAEL_HOST_TEST -I$REPO/main -I$IDF_PATH/components/json/cJSON \
       $REPO/test/host/test_routine_manifest.c $REPO/main/audio/routine_manifest.c \
       $IDF_PATH/components/json/cJSON/cJSON.c -o /tmp/test_routine_manifest && /tmp/test_routine_manifest
    

    Expected: échec à la compilation/link (le .c n'existe pas encore) — c'est le RED attendu.

  • Step 4: Écrire l'implémentation main/audio/routine_manifest.c.

    // See routine_manifest.h. The pure parser is host-testable (no ESP-IDF deps);
    // the SD loader is compiled only on-target (#ifndef LISAEL_HOST_TEST).
    #include "audio/routine_manifest.h"
    #include "cJSON.h"
    #include <string.h>
    #include <stdio.h>
    #include <stdlib.h>
    
    int lisael_routine_parse(const char *json, lisael_routine_t *routines, int max_routines)
    {
        if (!json || !routines || max_routines <= 0) {
            return -1;
        }
        cJSON *root = cJSON_Parse(json);
        if (!cJSON_IsObject(root)) {
            cJSON_Delete(root);
            return -1;
        }
        cJSON *arr = cJSON_GetObjectItem(root, "routines");
        if (!cJSON_IsArray(arr)) {
            cJSON_Delete(root);
            return -1;
        }
    
        int n_routines = 0;
        cJSON *jr;
        cJSON_ArrayForEach(jr, arr) {
            if (n_routines >= max_routines) {
                break;
            }
            cJSON *jid    = cJSON_GetObjectItem(jr, "id");
            cJSON *jtitle = cJSON_GetObjectItem(jr, "title");
            cJSON *jicon  = cJSON_GetObjectItem(jr, "icon");
            cJSON *jsteps = cJSON_GetObjectItem(jr, "steps");
            if (!cJSON_IsString(jid) || !cJSON_IsArray(jsteps)) {
                continue;
            }
            lisael_routine_t *r = &routines[n_routines];
            memset(r, 0, sizeof(*r));
            snprintf(r->id, sizeof(r->id), "%s", jid->valuestring);
            if (cJSON_IsString(jtitle)) {
                snprintf(r->title, sizeof(r->title), "%s", jtitle->valuestring);
            } else {
                snprintf(r->title, sizeof(r->title), "%s", jid->valuestring);
            }
            if (cJSON_IsString(jicon)) {
                snprintf(r->icon, sizeof(r->icon), "%s", jicon->valuestring);
            }
            cJSON *jstep;
            cJSON_ArrayForEach(jstep, jsteps) {
                if (r->n_steps >= LISAEL_MAX_STEPS) {
                    break;
                }
                cJSON *jtext  = cJSON_GetObjectItem(jstep, "text");
                cJSON *jsicon = cJSON_GetObjectItem(jstep, "icon");
                cJSON *jaudio = cJSON_GetObjectItem(jstep, "audio");
                if (!cJSON_IsString(jtext)) {
                    continue;  // a step with no label is useless; skip it
                }
                lisael_step_t *s = &r->steps[r->n_steps];
                snprintf(s->text, sizeof(s->text), "%s", jtext->valuestring);
                if (cJSON_IsString(jsicon)) {
                    snprintf(s->icon, sizeof(s->icon), "%s", jsicon->valuestring);
                }
                if (cJSON_IsString(jaudio)) {
                    snprintf(s->audio, sizeof(s->audio), "%s", jaudio->valuestring);
                }
                r->n_steps++;
            }
            if (r->n_steps > 0) {
                n_routines++;  // skip routines with no usable step
            }
        }
        cJSON_Delete(root);
        return n_routines;
    }
    
    #ifndef LISAEL_HOST_TEST
    #include "esp_log.h"
    int lisael_routine_load(lisael_routine_t *routines, int max_routines)
    {
        const char *path = "/sdcard/routines/routines.json";
        FILE *f = fopen(path, "r");
        if (!f) {
            return -1;
        }
        fseek(f, 0, SEEK_END);
        long sz = ftell(f);
        fseek(f, 0, SEEK_SET);
        if (sz <= 0 || sz > 64 * 1024) {
            fclose(f);
            return -1;
        }
        char *buf = malloc((size_t)sz + 1);
        if (!buf) {
            fclose(f);
            return -1;
        }
        size_t rd = fread(buf, 1, (size_t)sz, f);
        buf[rd] = '\0';
        fclose(f);
        int n = lisael_routine_parse(buf, routines, max_routines);
        free(buf);
        ESP_LOGI("routine", "loaded %d routine(s) from %s", n, path);
        return n;
    }
    #endif
    
  • Step 5: Relancer le test → PASS.

    export IDF_PATH=$HOME/esp/esp-idf
    REPO=/Users/electron/Documents/Projets_Techniques/GitHub/electron-rare/lisael-box
    cc -DLISAEL_HOST_TEST -I$REPO/main -I$IDF_PATH/components/json/cJSON \
       $REPO/test/host/test_routine_manifest.c $REPO/main/audio/routine_manifest.c \
       $IDF_PATH/components/json/cJSON/cJSON.c -o /tmp/test_routine_manifest && /tmp/test_routine_manifest
    

    Expected: OK: 2 routines (exit 0).

  • Step 6: Ajouter routine_manifest.c à la liste explicite des srcs dans main/CMakeLists.txt. Dans set(srcs ...), à la suite de "audio/podcast_manifest.c" :

        "audio/podcast_manifest.c"
        "audio/routine_manifest.c"
    
  • Step 7: Commit. git add main/audio/routine_manifest.h main/audio/routine_manifest.c test/host/test_routine_manifest.c main/CMakeLists.txt && git commit -m "feat: routine manifest parser + host test"


Task 2: box_register — annoncer aussi /sdcard/routines/*

Tower décide quoi pousser à partir du tableau have du register. Aujourd'hui box_register.c ne scanne que /sdcard/podcasts (.m4a/.mp3). Il faut factoriser le scan en helper et scanner AUSSI /sdcard/routines (filtre .mp3 ET .bin). Le body passe de 2048 à 4096.

Files:

  • Modify: main/net/box_register.c

Interfaces:

  • Produces: POST {"id":"lisael-box","have":[<basenames podcasts .m4a/.mp3>, <basenames routines .mp3/.bin>]} vers http://192.168.0.120:9555/register.

  • Consumes: dirs /sdcard/podcasts et /sdcard/routines ; routines.json n'est PAS annoncé (Tower le produit, il ne le reçoit pas comme asset à pousser).

  • Step 1: Remplacer le corps de box_register.c par une version factorisée à deux scans. Remplacer tout void lisael_box_register(void) { ... } (lignes 1455) par :

    // Append the basenames of files in `dir` whose extension matches one of `exts`
    // (NULL-terminated list, e.g. {".mp3",".bin",NULL}) as JSON strings into body.
    // *first tracks whether a comma is needed; updates *o (write offset).
    static void scan_dir_into_have(const char *dir, const char *const *exts,
                                   char *body, size_t cap, int *o, bool *first)
    {
        DIR *d = opendir(dir);
        if (!d) {
            return;
        }
        struct dirent *e;
        while ((e = readdir(d)) != NULL) {
            const char *n = e->d_name;
            size_t L = strlen(n);
            if (L < 5) {
                continue;
            }
            const char *ext = n + L - 4;
            bool match = false;
            for (int i = 0; exts[i]; i++) {
                if (strcasecmp(ext, exts[i]) == 0) {
                    match = true;
                    break;
                }
            }
            if (!match) {
                continue;
            }
            int w = snprintf(body + *o, cap - *o, "%s\"%s\"", *first ? "" : ",", n);
            if (w < 0 || w >= (int)(cap - *o)) {
                break;  // list full; send what we have
            }
            *o += w;
            *first = false;
        }
        closedir(d);
    }
    
    void lisael_box_register(void)
    {
        char body[4096];
        int o = snprintf(body, sizeof(body), "{\"id\":\"lisael-box\",\"have\":[");
        bool first = true;
        static const char *const podcast_exts[] = { ".m4a", ".mp3", NULL };
        static const char *const routine_exts[] = { ".mp3", ".bin", NULL };
        scan_dir_into_have("/sdcard/podcasts", podcast_exts, body, sizeof(body), &o, &first);
        scan_dir_into_have("/sdcard/routines", routine_exts, body, sizeof(body), &o, &first);
        o += snprintf(body + o, sizeof(body) - o, "]}");
    
        esp_http_client_config_t cfg = {
            .url        = TOWER_URL,
            .method     = HTTP_METHOD_POST,
            .timeout_ms = 8000,
        };
        esp_http_client_handle_t c = esp_http_client_init(&cfg);
        esp_http_client_set_header(c, "Content-Type", "application/json");
        esp_http_client_set_post_field(c, body, strlen(body));
        esp_err_t err = esp_http_client_perform(c);
        ESP_LOGI(TAG, "register -> %s status=%d", esp_err_to_name(err),
                 esp_http_client_get_status_code(c));
        esp_http_client_cleanup(c);
    }
    

    (Les includes existants <stdbool.h> n'est pas listé — ajouter #include <stdbool.h> en tête près des autres includes si la compilation se plaint de bool.)

  • Step 2: Vérifier l'include <stdbool.h>. En tête de main/net/box_register.c, après #include <dirent.h>, ajouter si absent :

    #include <stdbool.h>
    
  • Step 3: Build.

    export IDF_PATH=$HOME/esp/esp-idf; unset ADF_PATH; . $IDF_PATH/export.sh
    idf.py build
    

    Expected: build OK (pas d'erreur, box_register.c compile).

  • Step 4: Commit. git add main/net/box_register.c && git commit -m "feat: announce routines assets in box register"


Task 3: Icône de tuile + câblage du mode (enum / ui.c / home.c / gen_icons / CMakeLists)

Câbler LISAEL_MODE_ROUTINE dans le shell UI et ajouter la tuile d'accueil avec son icône Twemoji. Attention : s_adots[8] (home.c) → il y a déjà 7 tuiles, la 8e (routine) passe juste, pas plus.

Files:

  • Modify: main/ui/ui.h
  • Modify: main/ui/ui.c
  • Modify: main/modes/home.c
  • Modify: tools/gen_icons.py
  • Modify: main/ui/assets/lisael_icons.h
  • Create (généré): main/ui/assets/lisael_ic_routine.c
  • Modify: main/CMakeLists.txt (uniquement pour modes/routine.c — fait ici pour que ui.c link ; l'asset .c est pris par GLOB)

Interfaces:

  • Produces: lv_obj_t *lisael_screen_routine(void); (proto ; corps en Task 4) ; entrée enum LISAEL_MODE_ROUTINE ; tuile « Routine » à l'accueil ; extern const lv_image_dsc_t lisael_ic_routine;.

  • Consumes: lisael_ui_goto(LISAEL_MODE_ROUTINE) via tile_cb.

  • Step 1: Ajouter l'entrée enum + le proto dans main/ui/ui.h. Dans l'enum lisael_mode_t, AVANT LISAEL_MODE_COUNT :

        LISAEL_MODE_WIFI,       // réglages WiFi (force le SoftAP + portail captif)
        LISAEL_MODE_ROUTINE,    // routine illustrée à étapes (matin / soir)
        LISAEL_MODE_COUNT,
    

    Et dans la liste des builders (après lv_obj_t *lisael_screen_wifi(void);) :

    lv_obj_t *lisael_screen_routine(void);    // "Routine" — étapes illustrées matin/soir
    
  • Step 2: Router le mode dans build_screen() de main/ui/ui.c. Dans le switch (mode), après case LISAEL_MODE_WIFI: :

            case LISAEL_MODE_WIFI:     return lisael_screen_wifi();
            case LISAEL_MODE_ROUTINE:  return lisael_screen_routine();
            default:                   return lisael_screen_home();
    
  • Step 3: Ajouter le codepoint Twemoji dans tools/gen_icons.py et déclarer l'extern. Dans le dict SPECS, après la ligne "reglages": ("2699", 50), :

        "reglages": ("2699", 50),  # "Réglages WiFi" home tile (gear)
        "routine": ("1f9f9", 50),  # "Routine" home tile (broom 🧹) — chores/steps
    

    Puis dans main/ui/assets/lisael_icons.h, après extern const lv_image_dsc_t lisael_ic_reglages; :

    extern const lv_image_dsc_t lisael_ic_reglages; // "Réglages WiFi" home tile (gear)
    extern const lv_image_dsc_t lisael_ic_routine;  // "Routine" home tile (broom)
    
  • Step 4: Générer l'asto icône.

    cd /Users/electron/Documents/Projets_Techniques/GitHub/electron-rare/lisael-box
    ~/.espressif/python_env/idf5.4_py3.14_env/bin/python tools/gen_icons.py
    ls -l main/ui/assets/lisael_ic_routine.c
    

    Expected: main/ui/assets/lisael_ic_routine.c existe (la sortie indique converted N/… et routine n'est PAS dans bad:). L'asset est pris par le file(GLOB …) du CMakeLists — rien à ajouter pour lui.

  • Step 5: Ajouter la tuile « Routine » à l'accueil dans main/modes/home.c. Dans static const tile_def_t tiles[], après la ligne Réglages :

            { &lisael_ic_reglages,  "Réglages",  LISAEL_MODE_WIFI,      {0} },
            { &lisael_ic_routine,   "Routine",   LISAEL_MODE_ROUTINE,   {0} },
        };
    

    Et dans le tableau parallèle colors[], ajouter une couleur en dernier (8 entrées au total, alignées sur les 8 tuiles) :

        static const uint32_t colors[] = {
            0xFFE08A, 0xFFD8A8, 0xB2F2BB, 0xA5D8FF, 0xD0BFFF, 0xFFC9C9, 0xCED4DA, 0xBAC8FF,
        };
    

    (s_adots[8] accepte exactement 8 tuiles — on est à la limite, ne pas en ajouter d'autre sans agrandir le tableau.)

  • Step 6: Créer un stub minimal main/modes/routine.c (pour que ça linke avant la Task 4).

    // "Routine" — illustrated step-by-step routine (matin/soir) for Lisael Box.
    // Full implementation lands in Task 4; this stub keeps the link green.
    #include "ui/ui.h"
    
    lv_obj_t *lisael_screen_routine(void)
    {
        static lv_obj_t *s_root;
        if (s_root) {
            return s_root;
        }
        s_root = lv_obj_create(NULL);
        lisael_ui_add_back_button(s_root);
        return s_root;
    }
    
  • Step 7: Ajouter modes/routine.c à set(srcs ...) dans main/CMakeLists.txt. Après "modes/lire.c" :

        "modes/lire.c"
        "modes/routine.c"
    
  • Step 8: Build.

    export IDF_PATH=$HOME/esp/esp-idf; unset ADF_PATH; . $IDF_PATH/export.sh
    idf.py build
    

    Expected: build OK ; la tuile « Routine » sera visible à l'accueil au prochain flash (vérifié en Task 5).

  • Step 9: Commit. git add main/ui/ui.h main/ui/ui.c main/modes/home.c main/modes/routine.c tools/gen_icons.py main/ui/assets/lisael_icons.h main/ui/assets/lisael_ic_routine.c main/CMakeLists.txt && git commit -m "feat: wire routine mode tile and screen stub"


Task 4a: Écran routine — chargement manifeste + écran de choix Matin/Soir

Remplacer le stub routine.c par : le chargement du manifeste en PSRAM, le cache picto PSRAM (copié de lire.c), et l'écran de choix (deux grandes cartes Matin/Soir avec leur picto, ou un message « demande à un parent » si pas de manifeste). Les écrans étape/bravo arrivent en 4b/4c.

Files:

  • Modify: main/modes/routine.c

Interfaces:

  • Produces: lisael_screen_routine() complet pour l'écran de choix ; helpers internes load_routines(), routine_icon_dsc(const char *basename), make_routine_icon(parent, basename, px), static void open_routine(int ridx) (fwd decl, défini en 4b).

  • Consumes: lisael_routine_load() (Task 1), heap_caps_* PSRAM, lisael_routine_t/lisael_step_t.

  • Step 1: Écrire l'en-tête, les statics et le chargement du manifeste dans main/modes/routine.c. Remplacer tout le contenu du stub par (début ; la suite est ajoutée aux steps suivants — écrire le fichier entier en une fois à la fin du step 4) :

    // "Routine" — illustrated step-by-step routine (matin/soir) for Lisael Box.
    //
    // Content lives on the SD at /sdcard/routines/: routines.json (manifest) +
    // per-step Twemoji .bin pictos + .mp3 voice announcements, all pushed by Tower
    // over Wi-Fi (no reflash to change a routine). Three screens:
    //   1. choice : two big cards (Matin / Soir), tap to open a routine
    //   2. step   : big picto + label + N/total dots + "C'est fait" + voice
    //   3. bravo  : star + congratulation, back to Home
    //
    // Pictos are read ONCE into PSRAM and handed to LVGL as in-RAM image
    // descriptors (same trick as lire.c) — never the "S:" FS driver, which re-reads
    // the slow microSD on every draw band. Structures live in PSRAM, never a stack.
    // The builder runs under the LVGL lock held by the caller: it never re-takes it.
    
    #include "ui/ui.h"
    #include "ui/assets/lisael_icons.h"
    #include "ui/fonts/lisael_fonts.h"
    #include "audio/routine_manifest.h"
    #include "audio/aac_player.h"
    
    #include <stdint.h>
    #include <stdio.h>
    #include <string.h>
    
    #include "esp_heap_caps.h"
    #include "esp_log.h"
    
    static const char *TAG = "ui.routine";
    
    static lisael_routine_t *s_routines;   // PSRAM
    static int               s_n;          // number of routines loaded (<=2)
    
    static lv_obj_t *s_root;               // cached choice screen (owned by ui.c)
    static lv_obj_t *s_step_screen;        // rebuilt per routine run
    static int       s_ridx;               // current routine index
    static int       s_sidx;               // current step index
    
    static const uint32_t s_card_colors[2] = { 0xFFE08A, 0x748FFC }; // matin / soir
    
    static void open_routine(int ridx);    // fwd decl (Task 4b)
    
    // --- content load (PSRAM) ----------------------------------------------------
    static int load_routines(void)
    {
        if (!s_routines) {
            s_routines = heap_caps_calloc(LISAEL_MAX_ROUTINES, sizeof(lisael_routine_t),
                                          MALLOC_CAP_SPIRAM);
        }
        if (!s_routines) {
            return -1;
        }
        int n = lisael_routine_load(s_routines, LISAEL_MAX_ROUTINES);
        ESP_LOGI(TAG, "loaded %d routine(s)", n);
        return n;
    }
    
  • Step 2: Ajouter le cache picto PSRAM (copie fidèle de lire.c, indexé par basename .bin). À la suite, dans le même fichier :

    // --- PSRAM picto cache (keyed by .bin basename, e.g. "matin_01.bin") --------
    typedef struct {
        char           name[40];
        void          *buf;      // whole .bin in PSRAM (NULL = known-missing)
        lv_image_dsc_t dsc;
    } routine_icon_t;
    
    #define ROUTINE_ICON_CACHE (LISAEL_MAX_ROUTINES * (LISAEL_MAX_STEPS + 1))
    static routine_icon_t *s_icons;   // PSRAM
    static int             s_icons_n;
    
    static const lv_image_dsc_t *routine_icon_dsc(const char *name)
    {
        if (!name || !name[0]) {
            return NULL;
        }
        for (int i = 0; i < s_icons_n; i++) {
            if (!strcmp(s_icons[i].name, name)) {
                return s_icons[i].buf ? &s_icons[i].dsc : NULL;
            }
        }
        if (!s_icons) {
            s_icons = heap_caps_calloc(ROUTINE_ICON_CACHE, sizeof(routine_icon_t),
                                       MALLOC_CAP_SPIRAM);
            if (!s_icons) return NULL;
        }
        if (s_icons_n >= ROUTINE_ICON_CACHE) return NULL;
        routine_icon_t *c = &s_icons[s_icons_n++];   // negatively caches misses too
        snprintf(c->name, sizeof(c->name), "%s", name);
        c->buf = NULL;
    
        char p[80];
        snprintf(p, sizeof(p), "/sdcard/routines/%s", name);
        FILE *f = fopen(p, "rb");
        if (!f) return NULL;
        fseek(f, 0, SEEK_END);
        long sz = ftell(f);
        fseek(f, 0, SEEK_SET);
        if (sz <= (long)sizeof(lv_image_header_t) || sz > 256 * 1024) { fclose(f); return NULL; }
        uint8_t *buf = heap_caps_malloc((size_t)sz, MALLOC_CAP_SPIRAM);
        if (!buf) { fclose(f); return NULL; }
        size_t rd = fread(buf, 1, (size_t)sz, f);
        fclose(f);
        if (rd != (size_t)sz) { heap_caps_free(buf); return NULL; }
    
        lv_memcpy(&c->dsc.header, buf, sizeof(lv_image_header_t));
        c->dsc.data      = buf + sizeof(lv_image_header_t);
        c->dsc.data_size = (uint32_t)(sz - (long)sizeof(lv_image_header_t));
        c->buf           = buf;
        return &c->dsc;
    }
    
    // A picto from the PSRAM cache, or a coloured fallback box with an initial.
    static lv_obj_t *make_routine_icon(lv_obj_t *parent, const char *name,
                                       char fallback_initial, uint32_t fallback_color, int px)
    {
        const lv_image_dsc_t *d = routine_icon_dsc(name);
        if (d) {
            lv_obj_t *img = lv_image_create(parent);
            lv_image_set_src(img, d);
            lv_obj_set_size(img, px, px);
            lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN);
            return img;
        }
        lv_obj_t *box = lv_obj_create(parent);
        lv_obj_remove_style_all(box);
        lv_obj_set_size(box, px, px);
        lv_obj_set_style_radius(box, 14, 0);
        lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0);
        lv_obj_set_style_bg_color(box, lv_color_hex(fallback_color), 0);
        lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE);
        lv_obj_t *l = lv_label_create(box);
        char ini[2] = { fallback_initial ? fallback_initial : '?', '\0' };
        lv_label_set_text(l, ini);
        lv_obj_set_style_text_color(l, lv_color_white(), 0);
        lv_obj_center(l);
        return box;
    }
    
  • Step 3: Ajouter le callback de carte et le builder de l'écran de choix lisael_screen_routine(). À la suite, dans le même fichier :

    // --- choice screen -----------------------------------------------------------
    static void card_cb(lv_event_t *e)
    {
        int ridx = (int)(intptr_t)lv_event_get_user_data(e);
        open_routine(ridx);
    }
    
    static void add_choice_card(lv_obj_t *parent, int ridx)
    {
        lisael_routine_t *r = &s_routines[ridx];
        uint32_t col = s_card_colors[ridx % 2];
    
        lv_obj_t *card = lv_button_create(parent);
        lv_obj_add_style(card, lisael_ui_style_tile(), 0);
        lv_obj_set_style_bg_color(card, lv_color_lighten(lv_color_hex(col), 90), 0);
        lv_obj_set_style_bg_grad_color(card, lv_color_hex(col), 0);
        lv_obj_set_size(card, 138, 150);
        lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
        lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER,
                              LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
        lv_obj_set_style_pad_row(card, 8, 0);
        lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
        lv_obj_add_event_cb(card, card_cb, LV_EVENT_CLICKED, (void *)(intptr_t)ridx);
    
        make_routine_icon(card, r->icon, r->title[0], col, 88);
    
        lv_obj_t *l = lv_label_create(card);
        lv_label_set_text(l, r->title);
        lv_obj_set_style_text_font(l, &lisael_font_24, 0);
    }
    
    lv_obj_t *lisael_screen_routine(void)
    {
        if (s_root) {
            return s_root;
        }
        s_root = lv_obj_create(NULL);
        lv_obj_set_style_bg_color(s_root, lv_color_hex(0xEAF3FF), 0);
        lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xFFFFFF), 0);
        lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0);
    
        lisael_ui_add_back_button(s_root);
    
        lv_obj_t *title = lv_label_create(s_root);
        lv_label_set_text(title, "Ma routine");
        lv_obj_set_style_text_font(title, &lisael_font_24, 0);
        lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14);
    
        s_n = load_routines();
        if (s_n <= 0) {
            lv_obj_t *info = lv_label_create(s_root);
            lv_label_set_text(info,
                "Demande à un parent\nde préparer tes routines.");
            lv_obj_set_style_text_align(info, LV_TEXT_ALIGN_CENTER, 0);
            lv_obj_center(info);
            return s_root;
        }
    
        lv_obj_t *row = lv_obj_create(s_root);
        lv_obj_remove_style_all(row);
        lv_obj_set_size(row, LV_PCT(100), 170);
        lv_obj_align(row, LV_ALIGN_TOP_MID, 0, 56);
        lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
        lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY,
                              LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
        lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
        for (int i = 0; i < s_n; i++) {
            add_choice_card(row, i);
        }
        return s_root;
    }
    
  • Step 4: Ajouter un open_routine() provisoire (corps réel en 4b) pour garder le link vert. À la suite, dans le même fichier (sera remplacé en 4b) :

    // Provisional: real body lands in Task 4b (builds the step screen).
    static void open_routine(int ridx)
    {
        s_ridx = ridx;
        s_sidx = 0;
        (void)s_step_screen;
    }
    
  • Step 5: Build.

    export IDF_PATH=$HOME/esp/esp-idf; unset ADF_PATH; . $IDF_PATH/export.sh
    idf.py build
    

    Expected: build OK (l'écran de choix compile ; le tap ne fait encore rien d'autre que mémoriser l'index — normal).

  • Step 6: Commit. git add main/modes/routine.c && git commit -m "feat: routine choice screen + PSRAM picto cache"


Task 4b: Écran routine — écran d'étape (picto + nom + progression + « C'est fait » + voix)

Remplacer le open_routine() provisoire par le vrai écran d'étape : gros picto plein écran, nom (Fredoka grand), pastilles de progression X/N, bouton géant « C'est fait ✓ », bouton retour Accueil, et annonce vocale du .mp3 à l'arrivée. Le tap « C'est fait » joue un son et passe à l'étape suivante (ou au Bravo en 4c).

Files:

  • Modify: main/modes/routine.c

Interfaces:

  • Produces: static void open_routine(int ridx) réel (construit et charge l'écran d'étape), static void build_step_screen(void), static void play_step_audio(void), static void done_cb(lv_event_t *e), static void step_home_cb(lv_event_t *e), et fwd decl static void show_bravo(void) (corps en 4c).

  • Consumes: lisael_aac_play_file(), routine_icon_dsc()/make_routine_icon() (4a), lv_screen_load_anim().

  • Step 1: Ajouter la lecture audio d'étape et les callbacks au-dessus de open_routine(). Insérer dans routine.c, juste avant le open_routine() provisoire (qui sera remplacé au step suivant) :

    // --- step screen -------------------------------------------------------------
    static void show_bravo(void);   // fwd decl (Task 4c)
    
    static void play_step_audio(void)
    {
        lisael_routine_t *r = &s_routines[s_ridx];
        if (s_sidx >= r->n_steps) {
            return;
        }
        const char *a = r->steps[s_sidx].audio;
        if (!a[0]) {
            return;   // step with no voice -> silent, non-blocking
        }
        char p[80];
        snprintf(p, sizeof(p), "/sdcard/routines/%s", a);
        lisael_aac_play_file(p);   // routes .mp3 by extension internally
    }
    
    static void build_step_screen(void);   // fwd decl
    
    static void step_home_cb(lv_event_t *e)
    {
        (void)e;
        lisael_aac_stop();
        lisael_ui_goto(LISAEL_MODE_HOME);
        // The step screen is rebuilt fresh on next open_routine(); drop the ref so
        // the next build replaces it. (It is auto-deleted on the next load_anim.)
        s_step_screen = NULL;
    }
    
    static void done_cb(lv_event_t *e)
    {
        (void)e;
        lisael_routine_t *r = &s_routines[s_ridx];
        // short validation sound (reuse the reading game's spoken-word path is
        // overkill; a dedicated cue lives on the SD as a routine asset "ok.mp3").
        lisael_aac_play_file("/sdcard/routines/ok.mp3");
        if (s_sidx + 1 >= r->n_steps) {
            show_bravo();
            return;
        }
        s_sidx++;
        build_step_screen();   // rebuild the step screen content in place
    }
    

    (NB : ok.mp3 est un asset commun de routine poussé par Tower ; s'il manque, lisael_aac_play_file échoue silencieusement — non bloquant, conforme au spec « son manquant = pas de son ».)

  • Step 2: Écrire build_step_screen() (construit / reconstruit l'écran d'étape). À la suite (avant open_routine()), ajouter :

    static void build_step_screen(void)
    {
        lisael_routine_t *r = &s_routines[s_ridx];
    
        // Build a fresh screen object each call (forward nav). Loading it with an
        // animation; the previous step screen is auto-deleted by the load_anim.
        lv_obj_t *scr = lv_obj_create(NULL);
        lv_obj_set_style_bg_color(scr, lv_color_hex(0xFFF8E8), 0);
        lv_obj_set_style_bg_grad_color(scr, lv_color_hex(0xFFFFFF), 0);
        lv_obj_set_style_bg_grad_dir(scr, LV_GRAD_DIR_VER, 0);
    
        // back-to-Home button (top-left), separate from "C'est fait"
        lv_obj_t *home = lv_button_create(scr);
        lv_obj_add_style(home, lisael_ui_style_button(), 0);
        lv_obj_set_size(home, 64, 56);
        lv_obj_align(home, LV_ALIGN_TOP_LEFT, 6, 6);
        lv_obj_add_event_cb(home, step_home_cb, LV_EVENT_CLICKED, NULL);
        lv_obj_t *hl = lv_label_create(home);
        lv_label_set_text(hl, LV_SYMBOL_HOME);
        lv_obj_center(hl);
    
        // progression dots N/total (top-right area)
        lv_obj_t *prog = lv_obj_create(scr);
        lv_obj_remove_style_all(prog);
        lv_obj_set_size(prog, 150, 18);
        lv_obj_align(prog, LV_ALIGN_TOP_RIGHT, -10, 22);
        lv_obj_set_flex_flow(prog, LV_FLEX_FLOW_ROW);
        lv_obj_set_flex_align(prog, LV_FLEX_ALIGN_END,
                              LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
        lv_obj_clear_flag(prog, LV_OBJ_FLAG_SCROLLABLE);
        for (int i = 0; i < r->n_steps; i++) {
            lv_obj_t *dot = lv_obj_create(prog);
            lv_obj_remove_style_all(dot);
            lv_obj_set_size(dot, 11, 11);
            lv_obj_set_style_radius(dot, 6, 0);
            lv_obj_set_style_margin_left(dot, 3, 0);
            lv_obj_set_style_bg_color(dot, lv_color_hex(0x2F9E44), 0);
            lv_obj_set_style_bg_opa(dot, i <= s_sidx ? LV_OPA_COVER : LV_OPA_30, 0);
        }
    
        // big picto (centered, upper half)
        lv_obj_t *pic = make_routine_icon(scr, r->steps[s_sidx].icon,
                                          r->steps[s_sidx].text[0],
                                          s_card_colors[s_ridx % 2], 150);
        lv_obj_align(pic, LV_ALIGN_TOP_MID, 0, 50);
    
        // step label (Fredoka, large)
        lv_obj_t *lbl = lv_label_create(scr);
        lv_label_set_text(lbl, r->steps[s_sidx].text);
        lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP);
        lv_obj_set_width(lbl, 300);
        lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
        lv_obj_set_style_text_font(lbl, &lisael_font_30, 0);
        lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 56);
    
        // giant "C'est fait ✓" button (bottom)
        lv_obj_t *done = lv_button_create(scr);
        lv_obj_add_style(done, lisael_ui_style_button(), 0);
        lv_obj_set_style_bg_color(done, lv_color_hex(0x2F9E44), 0);
        lv_obj_set_size(done, 240, 64);
        lv_obj_align(done, LV_ALIGN_BOTTOM_MID, 0, -10);
        lv_obj_add_event_cb(done, done_cb, LV_EVENT_CLICKED, NULL);
        lv_obj_t *dl = lv_label_create(done);
        lv_label_set_text(dl, LV_SYMBOL_OK "  C'est fait");
        lv_obj_set_style_text_color(dl, lv_color_white(), 0);
        lv_obj_set_style_text_font(dl, &lisael_font_24, 0);
        lv_obj_center(dl);
    
        lv_screen_load_anim(scr, LV_SCR_LOAD_ANIM_MOVE_LEFT, 200, 0, true);
        s_step_screen = scr;
        play_step_audio();
    }
    
  • Step 3: Remplacer le open_routine() provisoire par le vrai. Remplacer le bloc provisoire de la Task 4a par :

    static void open_routine(int ridx)
    {
        s_ridx = ridx;
        s_sidx = 0;
        build_step_screen();
    }
    
  • Step 4: Ajouter un show_bravo() provisoire (corps réel en 4c) pour garder le link vert. À la suite, ajouter (sera remplacé en 4c) :

    // Provisional: real body lands in Task 4c (the Bravo screen).
    static void show_bravo(void)
    {
        lisael_aac_stop();
        lisael_ui_goto(LISAEL_MODE_HOME);
        s_step_screen = NULL;
    }
    
  • Step 5: Build.

    export IDF_PATH=$HOME/esp/esp-idf; unset ADF_PATH; . $IDF_PATH/export.sh
    idf.py build
    

    Expected: build OK (l'écran d'étape s'enchaîne ; la dernière étape ramène à l'accueil — le Bravo arrive en 4c).

  • Step 6: Commit. git add main/modes/routine.c && git commit -m "feat: routine step screen with voice and progress"


Task 4c: Écran routine — écran « Bravo ! » final (étoile + son + retour)

Remplacer le show_bravo() provisoire par le vrai écran de félicitations : étoile lisael_ic_star (animation de scale/pop), texte « Bravo ! », son de fête, et un bouton « Accueil » (plus un retour auto optionnel non requis — décision spec : retour Accueil au tap).

Files:

  • Modify: main/modes/routine.c

Interfaces:

  • Produces: static void show_bravo(void) réel, static void bravo_home_cb(lv_event_t *e).

  • Consumes: lisael_ic_star (déjà dans lisael_icons.h), lv_anim_* (pattern balloons.c), lisael_aac_play_file().

  • Step 1: Ajouter le callback retour + l'écran Bravo, en remplaçant le show_bravo() provisoire. Remplacer le bloc provisoire de la Task 4b par :

    static void bravo_home_cb(lv_event_t *e)
    {
        (void)e;
        lisael_aac_stop();
        lisael_ui_goto(LISAEL_MODE_HOME);
        s_step_screen = NULL;   // bravo screen auto-deleted on the load_anim
    }
    
    // simple "pop in" scale animation for the star. Use the style transform
    // (pattern from balloons.c pop_scale_cb) — NOT lv_image_set_scale, which
    // leaves the layout box at full size (see the warning in histoires.c).
    static void bravo_scale_cb(void *var, int32_t scale)
    {
        lv_obj_set_style_transform_scale_x((lv_obj_t *)var, scale, 0);
        lv_obj_set_style_transform_scale_y((lv_obj_t *)var, scale, 0);
    }
    
    static void show_bravo(void)
    {
        lv_obj_t *scr = lv_obj_create(NULL);
        lv_obj_set_style_bg_color(scr, lv_color_hex(0xFFF4C2), 0);
        lv_obj_set_style_bg_grad_color(scr, lv_color_hex(0xFFFFFF), 0);
        lv_obj_set_style_bg_grad_dir(scr, LV_GRAD_DIR_VER, 0);
    
        lv_obj_t *star = lv_image_create(scr);
        lv_image_set_src(star, &lisael_ic_star);
        lv_obj_set_size(star, 140, 140);
        lv_image_set_inner_align(star, LV_IMAGE_ALIGN_CONTAIN);
        lv_obj_align(star, LV_ALIGN_CENTER, 0, -40);
    
        lv_anim_t a;
        lv_anim_init(&a);
        lv_anim_set_var(&a, star);
        lv_anim_set_exec_cb(&a, bravo_scale_cb);
        lv_anim_set_values(&a, LV_SCALE_NONE / 3, LV_SCALE_NONE);  // pop from 0.33x to 1x
        lv_anim_set_duration(&a, 400);
        lv_anim_set_path_cb(&a, lv_anim_path_overshoot);
        lv_anim_start(&a);
    
        lv_obj_t *txt = lv_label_create(scr);
        lv_label_set_text(txt, "Bravo !");
        lv_obj_set_style_text_font(txt, &lisael_font_30, 0);
        lv_obj_align(txt, LV_ALIGN_CENTER, 0, 50);
    
        lv_obj_t *home = lv_button_create(scr);
        lv_obj_add_style(home, lisael_ui_style_button(), 0);
        lv_obj_set_style_bg_color(home, lv_color_hex(0x1971C2), 0);
        lv_obj_set_size(home, 200, 60);
        lv_obj_align(home, LV_ALIGN_BOTTOM_MID, 0, -16);
        lv_obj_add_event_cb(home, bravo_home_cb, LV_EVENT_CLICKED, NULL);
        lv_obj_t *hl = lv_label_create(home);
        lv_label_set_text(hl, LV_SYMBOL_HOME "  Accueil");
        lv_obj_set_style_text_color(hl, lv_color_white(), 0);
        lv_obj_set_style_text_font(hl, &lisael_font_24, 0);
        lv_obj_center(hl);
    
        // celebratory sound (routine asset; silent fallback if absent)
        lisael_aac_play_file("/sdcard/routines/bravo.mp3");
    
        lv_screen_load_anim(scr, LV_SCR_LOAD_ANIM_FADE_IN, 250, 0, true);
        s_step_screen = scr;
    }
    
  • Step 2: Build.

    export IDF_PATH=$HOME/esp/esp-idf; unset ADF_PATH; . $IDF_PATH/export.sh
    idf.py build
    

    Expected: build OK (l'enchaînement complet choix → étapes → Bravo → Accueil compile).

  • Step 3: Commit. git add main/modes/routine.c && git commit -m "feat: routine bravo screen with star animation"


Task 5: Vérification E2E sur carte (seed SD + flash + boot-burst)

Vérifier le mode complet sur la vraie carte : générer un routines.json + assets de test localement (pictos .bin Twemoji + .mp3 TTS via la gateway), les pousser sur la SD via le file-server WiFi (ou copie SD directe), flasher, puis capturer le boot et observer manuellement l'enchaînement.

Files:

  • Create (jetable, hors repo): /tmp/seed_routines/routines.json + /tmp/seed_routines/*.bin + /tmp/seed_routines/*.mp3
  • (Pas de modif repo — tâche de vérif.)

Interfaces:

  • Consumes: tools/push_to_box.sh / file-server :8080/put?name=routines/<f>, idf.py flash, capture USB-JTAG.

  • Produces: preuve E2E (logs routine: loaded N routine(s), écrans observés).

  • Step 1: Composer un routines.json de test minimal. Écrire /tmp/seed_routines/routines.json :

    { "routines": [
      { "id":"matin", "title":"Le matin", "icon":"matin.bin", "steps":[
          {"text":"Habille-toi",      "icon":"matin_01.bin", "audio":"matin_01.mp3"},
          {"text":"Brosse tes dents", "icon":"matin_02.bin", "audio":"matin_02.mp3"} ]},
      { "id":"soir",  "title":"Le soir",  "icon":"soir.bin",  "steps":[
          {"text":"Mets ton pyjama",  "icon":"soir_01.bin",  "audio":"soir_01.mp3"} ]}
    ]}
    
  • Step 2: Générer les pictos .bin RGB565A8 (160x160) avec le pipeline LVGLImage existant.

    cd /Users/electron/Documents/Projets_Techniques/GitHub/electron-rare/lisael-box
    PY=~/.espressif/python_env/idf5.4_py3.14_env/bin/python
    CONV=managed_components/lvgl__lvgl/scripts/LVGLImage.py
    mkdir -p /tmp/seed_png
    # matin.bin=sun 2600, soir.bin=moon 1f319, matin_01=tshirt 1f455,
    # matin_02=tooth 1f9b7, soir_01=pyjama->bed 1f6cf
    $PY - <<'PY'
    import io,ssl,urllib.request
    from PIL import Image
    M={"matin":"2600","soir":"1f319","matin_01":"1f455","matin_02":"1f9b7","soir_01":"1f6cf"}
    B="https://raw.githubusercontent.com/jdecked/twemoji/main/assets/72x72"
    ctx=ssl.create_default_context()
    for name,cp in M.items():
        raw=urllib.request.urlopen(urllib.request.Request(f"{B}/{cp}.png",headers={"User-Agent":"curl/8"}),timeout=15,context=ctx).read()
        Image.open(io.BytesIO(raw)).convert("RGBA").resize((160,160),Image.LANCZOS).save(f"/tmp/seed_png/{name}.png")
    print("png ok")
    PY
    $PY $CONV --ofmt BIN --cf RGB565A8 -o /tmp/seed_routines /tmp/seed_png
    ls -l /tmp/seed_routines/*.bin
    

    Expected: 5 fichiers .bin produits (matin.bin, soir.bin, matin_01.bin, matin_02.bin, soir_01.bin).

  • Step 3: Générer les voix .mp3 mono via la gateway (voix "fr"), + le son de validation.

    PY=~/.espressif/python_env/idf5.4_py3.14_env/bin/python
    $PY - <<'PY'
    import json,urllib.request
    GW="http://100.78.6.122:9300/v1/audio/speech"
    T={"matin_01":"Habille-toi","matin_02":"Brosse tes dents","soir_01":"Mets ton pyjama",
       "ok":"Bravo","bravo":"Bravo, tu as fini ta routine"}
    for wid,txt in T.items():
        body=json.dumps({"model":"tts-1","input":txt,"voice":"fr","response_format":"mp3"}).encode()
        au=urllib.request.urlopen(urllib.request.Request(GW,data=body,headers={"Content-Type":"application/json"}),timeout=30).read()
        open(f"/tmp/seed_routines/{wid}.mp3","wb").write(au)
        print("tts", wid, len(au))
    PY
    ls -l /tmp/seed_routines/*.mp3
    

    Expected: 5 .mp3 (matin_01, matin_02, soir_01, ok, bravo). (Si la gateway n'est pas joignable, le firmware reste non bloquant : étapes muettes — le test visuel reste valide.)

  • Step 4: Flasher le firmware sur la carte sans la rebooter en boucle (USB-JTAG).

    export IDF_PATH=$HOME/esp/esp-idf; unset ADF_PATH; . $IDF_PATH/export.sh
    PORT=$(ls /dev/cu.usbmodem* | head -1)
    idf.py -p "$PORT" flash --after no_reset
    

    Expected: flash OK. (--after no_reset évite le crash-loop USB-JTAG connu au reboot ; on capture le boot dans le step suivant.)

  • Step 5: Pousser les assets routines sur la SD via le file-server WiFi (carte connectée au WiFi).

    BOX=lisael-box.local   # ou l'IP affichée dans les logs WiFi
    for f in /tmp/seed_routines/*; do
      n=$(basename "$f")
      curl -fsS --data-binary @"$f" "http://$BOX:8080/put?name=routines/$n" && echo "put $n"
    done
    

    Expected: chaque put renvoie 200 (le file-server accepte le sous-dossier routines/ d'un niveau — aucun changement requis côté firmware). Alternative si pas de WiFi : copier /tmp/seed_routines/* dans /sdcard/routines/ directement sur la microSD montée.

  • Step 6: Capturer le boot et observer.

    PORT=$(ls /dev/cu.usbmodem* | head -1)
    python3 /tmp/lisael_cap.py "$PORT" 25
    

    Expected dans la capture : routine: loaded 2 routine(s) from /sdcard/routines/routines.json après ouverture du mode, et mode -> N (N = index de LISAEL_MODE_ROUTINE). Sur l'écran : tuile « Routine » à l'accueil → tap → deux cartes Matin/Soir → tap Matin → étape 1 (picto t-shirt + « Habille-toi » + 2 pastilles + bouton vert « C'est fait ») + voix → tap → étape 2 → tap → écran « Bravo ! » avec étoile + son → bouton « Accueil ».

  • Step 7: Vérifier les dégradés gracieux (manifeste / picto / audio manquants). Faire un 2e essai en omettant volontairement deux assets : au step 5, ne PAS pousser matin_02.bin ni matin_01.mp3, puis recharger le mode et vérifier : picto manquant → case colorée avec initiale (make_routine_icon fallback) ; audio manquant → étape affichée sans voix (non bloquant). Puis retirer/renommer routines.json sur la SD et rouvrir le mode → écran « Demande à un parent de préparer tes routines. ». (Pour effacer un fichier sur SD : le faire via la microSD montée, ou via le durcissement unlink du file-server existant ; ne PAS pousser un fichier vide via :8080/put, il serait rejeté par la garde sz<=12 et lirait comme « manquant » — comportement correct mais piège de test.) Expected: les trois dégradés se comportent comme spécifié, sans crash ni reboot.

  • Step 8: (Pas de commit de code — tâche de vérif.) Consigner le résultat E2E dans le message de fin de tâche ; si un défaut est trouvé, ouvrir une sous-tâche de correction et boucler sur la Task concernée.


Notes de cohérence (self-review)

  • Couverture spec : Task 0 = §11 pré-requis build ; Task 1 = §6 données + §10 test hôte parser ; Task 2 = §7 push (annonce have) côté firmware ; Task 3 = §6 câblage (enum/ui.c/home.c/icône) ; Task 4a/4b/4c = §6 écrans (choix / étape voix+progression+« C'est fait »+son / Bravo) + §9 dégradés (manifeste absent, picto/audio manquant) ; Task 5 = §10 test sur carte + §12 critères d'acceptation 1-5. Le critère §12.6 (tests Tower delta) relève du plan Merlin Studio v2, hors périmètre ici ; le parser hôte (§12.6 partie firmware) est couvert par Task 1.
  • Contrat d'interface : layout /sdcard/routines/ + format JSON objet {"routines":[...]} respecté ; have = union podcasts (.m4a/.mp3) + routines (.mp3/.bin) ; sous-dossier routines/ poussé via :8080/put?name=routines/<f> sans changement file_server ; .mp3 joués par aac_player (route par extension). Le firmware ne reçoit jamais routines.json comme asset poussé (Tower le produit) — confirmé : box_register ne l'annonce pas comme "have" à régénérer, il scanne juste ce qui est sur SD ; Tower compare au manifeste qu'il détient.
  • Cohérence des types/noms entre tâches : lisael_routine_t/lisael_step_t + LISAEL_MAX_ROUTINES/LISAEL_MAX_STEPS (Task 1) réutilisés tels quels en Task 4 ; routine_icon_dsc/make_routine_icon/load_routines/open_routine/build_step_screen/show_bravo/play_step_audio déclarés en fwd avant usage ; s_root/s_step_screen/s_ridx/s_sidx/s_routines/s_icons partagés dans le même routine.c. lisael_ic_routine (Task 3) et lisael_ic_star (déjà présent) cohérents avec lisael_icons.h.
  • Pas de placeholder : tout step porte du code complet (test + implémentation) ou une commande de vérif exacte. Le seul asset ok.mp3/bravo.mp3 non généré par Tower dans le contrat est un asset de routine que Tower poussera (ou absent → son silencieux, non bloquant) ; Task 5 le seede pour le test.
  • Pièges adressés : PSRAM partout (structs + buffers .bin) ; pas de bsp_display_lock dans les builders (sous lock appelant) ; garde sz <= sizeof(lv_image_header_t) ; LISAEL_MODE_COUNT dernier ; s_adots[8] = exactement 8 tuiles ; LFN activé en Task 0 ; pas de driver « S: » (cache PSRAM) ; LV_LVGL_H_INCLUDE_SIMPLE=1 déjà global.