Two implementation plans for the routine mode (firmware) and Merlin Studio v2 (Tower/web: routines tab + transfers viz).
55 KiB
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.c → icon_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.py → LVGLImage.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
.binest en PSRAM viaheap_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ésenterlv_image_dsc_ten RAM), jamais le driver LVGL « S: » (relit la SD à chaque frame = lag). Gardesz <= sizeof(lv_image_header_t)→ rejeter. LISAEL_MODE_COUNTreste toujours dernier dans l'enum (il dimensionnes_screens[]).- Le builder
lisael_screen_routine()s'exécute sous le lock LVGL déjà tenu par l'appelant → ne pas reprendrebsp_display_lockdedans. 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 caches_screens[]estauto_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 =
ccdirect 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
sdkconfigneuf reproductible viaidf.py set-target esp32s3 && idf.py reconfigure. -
Consumes: rien (creds WiFi laissés au
sdkconfiglocal 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 desdkconfig.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 desdkconfig.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' sdkconfigExpected: 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.json → lisael_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 IDFjson). -
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.cpas 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_parseindé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_manifestExpected: échec à la compilation/link (le
.cn'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_manifestExpected:
OK: 2 routines(exit 0). -
Step 6: Ajouter
routine_manifest.cà la liste explicite dessrcsdansmain/CMakeLists.txt. Dansset(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>]}vershttp://192.168.0.120:9555/register. -
Consumes: dirs
/sdcard/podcastset/sdcard/routines;routines.jsonn'est PAS annoncé (Tower le produit, il ne le reçoit pas comme asset à pousser). -
Step 1: Remplacer le corps de
box_register.cpar une version factorisée à deux scans. Remplacer toutvoid lisael_box_register(void) { ... }(lignes 14–55) 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 debool.) -
Step 2: Vérifier l'include
<stdbool.h>. En tête demain/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 buildExpected: build OK (pas d'erreur,
box_register.ccompile). -
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 pourmodes/routine.c— fait ici pour que ui.c link ; l'asset.cest pris par GLOB)
Interfaces:
-
Produces:
lv_obj_t *lisael_screen_routine(void);(proto ; corps en Task 4) ; entrée enumLISAEL_MODE_ROUTINE; tuile « Routine » à l'accueil ;extern const lv_image_dsc_t lisael_ic_routine;. -
Consumes:
lisael_ui_goto(LISAEL_MODE_ROUTINE)viatile_cb. -
Step 1: Ajouter l'entrée enum + le proto dans
main/ui/ui.h. Dans l'enumlisael_mode_t, AVANTLISAEL_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()demain/ui/ui.c. Dans leswitch (mode), aprèscase 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.pyet déclarer l'extern. Dans le dictSPECS, après la ligne"reglages": ("2699", 50),:"reglages": ("2699", 50), # "Réglages WiFi" home tile (gear) "routine": ("1f9f9", 50), # "Routine" home tile (broom 🧹) — chores/stepsPuis dans
main/ui/assets/lisael_icons.h, aprèsextern 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.cExpected:
main/ui/assets/lisael_ic_routine.cexiste (la sortie indiqueconverted N/…etroutinen'est PAS dansbad:). L'asset est pris par lefile(GLOB …)du CMakeLists — rien à ajouter pour lui. -
Step 5: Ajouter la tuile « Routine » à l'accueil dans
main/modes/home.c. Dansstatic 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 ...)dansmain/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 buildExpected: 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 internesload_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 buildExpected: 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 declstatic 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 dansroutine.c, juste avant leopen_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.mp3est 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 (avantopen_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 buildExpected: 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à danslisael_icons.h),lv_anim_*(patternballoons.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 buildExpected: 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.jsonde 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
.binRGB565A8 (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/*.binExpected: 5 fichiers
.binproduits (matin.bin, soir.bin, matin_01.bin, matin_02.bin, soir_01.bin). -
Step 3: Générer les voix
.mp3mono 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/*.mp3Expected: 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_resetExpected: 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" doneExpected: chaque
putrenvoie 200 (le file-server accepte le sous-dossierroutines/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" 25Expected dans la capture :
routine: loaded 2 routine(s) from /sdcard/routines/routines.jsonaprès ouverture du mode, etmode -> N(N = index deLISAEL_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.binnimatin_01.mp3, puis recharger le mode et vérifier : picto manquant → case colorée avec initiale (make_routine_iconfallback) ; audio manquant → étape affichée sans voix (non bloquant). Puis retirer/renommerroutines.jsonsur 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 durcissementunlinkdu file-server existant ; ne PAS pousser un fichier vide via:8080/put, il serait rejeté par la gardesz<=12et 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-dossierroutines/poussé via:8080/put?name=routines/<f>sans changement file_server ;.mp3joués paraac_player(route par extension). Le firmware ne reçoit jamaisroutines.jsoncomme asset poussé (Tower le produit) — confirmé :box_registerne 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_audiodéclarés en fwd avant usage ;s_root/s_step_screen/s_ridx/s_sidx/s_routines/s_iconspartagés dans le mêmeroutine.c.lisael_ic_routine(Task 3) etlisael_ic_star(déjà présent) cohérents aveclisael_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.mp3non 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 debsp_display_lockdans les builders (sous lock appelant) ; gardesz <= sizeof(lv_image_header_t);LISAEL_MODE_COUNTdernier ;s_adots[8]= exactement 8 tuiles ; LFN activé en Task 0 ; pas de driver « S: » (cache PSRAM) ;LV_LVGL_H_INCLUDE_SIMPLE=1déjà global.