diff --git a/docs/superpowers/plans/2026-06-21-lisael-box-routine-mode.md b/docs/superpowers/plans/2026-06-21-lisael-box-routine-mode.md new file mode 100644 index 0000000..c776361 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-lisael-box-routine-mode.md @@ -0,0 +1,1141 @@ +# 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 `.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.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 IDF `json`). + +- [ ] **Step 1: Écrire le header `main/audio/routine_manifest.h`.** + ```c + // 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).** + ```c + // Host unit test for the routine manifest parser. + #define LISAEL_HOST_TEST + #include "audio/routine_manifest.h" + #include + #include + #include + + 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`.** + ```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 + #include + #include + + 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"` : + ```cmake + "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":[, ]}` 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 14–55) par : + ```c + // 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 `` n'est pas listé — ajouter `#include ` en tête près des autres includes si la compilation se plaint de `bool`.) +- [ ] **Step 2: Vérifier l'include ``.** + En tête de `main/net/box_register.c`, après `#include `, ajouter si absent : + ```c + #include + ``` +- [ ] **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` : + ```c + 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);`) : + ```c + 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:` : + ```c + 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),` : + ```python + "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;` : + ```c + 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 : + ```c + { &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) : + ```c + 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).** + ```c + // "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"` : + ```cmake + "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) : + ```c + // "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 + #include + #include + + #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 : + ```c + // --- 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 : + ```c + // --- 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) : + ```c + // 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) : + ```c + // --- 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 : + ```c + 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 : + ```c + 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) : + ```c + // 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 : + ```c + 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/`, `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` : + ```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/` 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. diff --git a/docs/superpowers/plans/2026-06-21-merlin-studio-v2.md b/docs/superpowers/plans/2026-06-21-merlin-studio-v2.md new file mode 100644 index 0000000..4ac8127 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-merlin-studio-v2.md @@ -0,0 +1,1066 @@ +# Merlin Studio v2 — 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:** Côté Tower (`lisael-content`) + UI web (`merlin.saillant.cc`), livrer (A) le pipeline de génération des routines illustrées (TTS voix `fr` + Twemoji→`.bin` RGB565A8 + `routines.json` SD + push delta), (B) l'onglet web « 🗓️ Routines » + son API, et (C) le journal des transferts (durée/débit/retry) + l'onglet web « 📤 Transferts » + son API. Le firmware est traité dans un plan séparé ; ce plan respecte le contrat d'interface SD/push partagé. + +**Architecture :** `tower/lisael_content.py` (module partagé, Python stdlib + Pillow) gère feeds/podcasts + désormais routines + journal transferts. `tower/lisael_server.py` (`:9555`, `ThreadingHTTPServer`, classe `H`) expose `/register` (machine) et l'API web sous Basic Auth. `tower/merlin_ui.html` (vanilla JS, onglets) est l'UI. La box annonce dans `have` (POST `/register`) l'union des basenames de `/sdcard/podcasts` ET `/sdcard/routines` ; Tower calcule le delta routines = `(basenames staging routines) − have` et pousse vers `?name=routines/`. Vérité éditoriale = `routines.json` côté Tower (`/home/clems/lisael-content/routines.json`), régénéré en assets par `build_routine_manifest`. + +**Tech Stack :** Python 3 stdlib (`http.server`, `urllib`, `json`, `subprocess`, `tempfile`, `threading`, `hashlib`, `time`) + Pillow (`PIL.Image`) + `LVGLImage.py` (script LVGL, `--ofmt BIN`) + `ffmpeg`. UI = HTML/CSS/JS vanilla, pas de framework. Tests hôte = asserts purs `if __name__ == "__main__"` (pattern `tower/test_delta.py` / `tower/test_content.py`), pas de pytest. + +## Global Constraints +- Python stdlib only (+ Pillow déjà utilisé pour les covers) ; aucune nouvelle dépendance pip. +- TTS via la gateway origine tailnet `http://100.78.6.122:9300/v1/audio/speech` (`model=tts-1`, `voice=fr`, `response_format=mp3`) — JAMAIS le domaine public (Cloudflare bloque `Python-urllib`). +- Twemoji raw via `https://raw.githubusercontent.com/jdecked/twemoji/main/assets/72x72/.png` avec `User-Agent: curl/8` ; `cp()` strip le VS16 (`U+FE0F`) sinon 404. +- Vérif des URLs web publiques (`merlin.saillant.cc`) toujours avec `User-Agent: Mozilla/5.0` (Cloudflare 403 sinon). +- Pictos étape = `LVGLImage.py --ofmt BIN --cf RGB565A8` sur PNG 160×160 `.convert("RGBA")` (garde l'alpha de l'emoji) — PAS `RGB565`/`.convert("RGB")` (réservé aux covers podcasts). +- Audio = TTS mono mp3 ; si re-transcode nécessaire, `ffmpeg -ac 1 -b:a 48k -c:a libmp3lame` (la sortie gateway est déjà mono mp3, transcode optionnel). +- Noms de fichiers SD strictement ASCII, préfixés par id de routine (`matin_`/`soir_`) ; les libellés accentués vivent dans le JSON UTF-8. Pas de collision avec les podcasts (dossier SD distinct `routines/`). +- Push delta : le manifest (`routines.json`) part TOUJOURS en dernier (réutiliser la sémantique `compute_delta`). +- Toute écriture de fichier d'état (`routines.json`, `transfers.json`) via `_atomic_write_json`. +- API web sous Basic Auth `MERLIN_AUTH` (court-circuit `/register` machine sans auth, restreint IP privée via `_is_private`). +- Déploiement manuel : `rsync` vers `/home/clems/lisael-content` sur Tower (`clems@192.168.0.120`) puis `sudo systemctl restart lisael-content`. +- Commits : sujet ≤ 50 chars, scope sans underscore, PAS d'attribution AI, pas de `--no-verify`. + +--- + +### Task 1: Helper TTS `tts_speak()` dans `lisael_content.py` + +**Files:** +- `tower/lisael_content.py` (ajout) + +**Interfaces:** +- Produces : `def tts_speak(text: str) -> bytes` — POST gateway `/v1/audio/speech`, renvoie les octets mp3 (lève en cas d'échec réseau/HTTP). +- Consumes : env `LISAEL_TTS_URL` (défaut `http://100.78.6.122:9300/v1/audio/speech`), env `LISAEL_TTS_VOICE` (défaut `fr`). + +- [ ] **Step 1:** Ajouter en tête de `tower/lisael_content.py` (près des autres constantes, après `BOX_PORT`) : + ```python + TTS_URL = os.environ.get("LISAEL_TTS_URL", "http://100.78.6.122:9300/v1/audio/speech") + TTS_VOICE = os.environ.get("LISAEL_TTS_VOICE", "fr") + ``` +- [ ] **Step 2:** Ajouter la fonction (après `transcode`, avant `make_cover`) : + ```python + def tts_speak(text): + """Generate a mono mp3 announcement via the tailnet gateway. Returns bytes. + Raises on network/HTTP failure (caller decides degraded behaviour).""" + body = json.dumps({"model": "tts-1", "input": text, "voice": TTS_VOICE, + "response_format": "mp3"}).encode() + req = urllib.request.Request(TTS_URL, data=body, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as r: + return r.read() + ``` +- [ ] **Step 3:** Vérif manuelle (réseau, non unit-testable). Depuis grosmac via Tower : `ssh clems@192.168.0.120 'cd /home/clems/lisael-content && python3 -c "import lisael_content as C; b=C.tts_speak(\"Habille-toi\"); print(len(b)); open(\"/tmp/tts_test.mp3\",\"wb\").write(b)"'` puis vérifier que le fichier fait > 1 Ko et est lisible (`file /tmp/tts_test.mp3` → MPEG ADTS / MP3). Si la gateway n'est pas joignable depuis grosmac, exécuter directement sur Tower. Commit : `feat(tower): add gateway TTS helper`. + +--- + +### Task 2: Helper Twemoji→`.bin` `emoji_cp()` + `emoji_bin()` (TDD pour `emoji_cp`) + +**Files:** +- `tower/lisael_content.py` (ajout) +- `tower/test_routines.py` (nouveau fichier de test) + +**Interfaces:** +- Produces : `def emoji_cp(emoji: str) -> str` — codepoints Twemoji joints par `-`, VS16 stripé (pure, testable). +- Produces : `def emoji_bin(emoji: str, key: str) -> str` — télécharge le Twemoji, resize 160×160 RGBA, génère `.bin` RGB565A8 dans `STAGING_ROUTINES`, renvoie `".bin"` ou `""` (réseau, non unit-testable). +- Consumes : constante `TWEMOJI`, `LVGLIMG`, `STAGING_ROUTINES` (Task 5 ; pour cette task on peut écrire dans un dossier passé/par défaut — voir Step 4). + +- [ ] **Step 1 (RED):** Créer `tower/test_routines.py` avec en tête le pattern d'isolation de `test_content.py` : + ```python + import os, tempfile + os.environ["LISAEL_BASE"] = tempfile.mkdtemp(prefix="routines_test_") + os.environ["LISAEL_STAGING"] = os.path.join(os.environ["LISAEL_BASE"], "podcasts") + os.environ["LISAEL_STAGING_ROUTINES"] = os.path.join(os.environ["LISAEL_BASE"], "routines") + import lisael_content as C + + def test_emoji_cp_strips_vs16(): + # plain emoji: single codepoint + assert C.emoji_cp("\U0001F600") == "1f600" # 😀 + # emoji with VS16 (U+FE0F) must be stripped (twemoji 404s otherwise) + assert C.emoji_cp("☀️") == "2600" # ☀️ -> 2600 + # multi-codepoint (no VS16): joined by '-' + assert C.emoji_cp("\U0001F468‍\U0001F4BB").split("-")[0] == "1f468" + ``` + Et le runner en bas (calqué sur `test_content.py`) : + ```python + if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_"): + fn(); print("ok", name) + print("ALL OK") + ``` +- [ ] **Step 2 (RED run):** `cd tower && python3 test_routines.py` → DOIT échouer (`AttributeError: module ... has no attribute 'emoji_cp'`). +- [ ] **Step 3 (GREEN):** Ajouter dans `lisael_content.py` (la constante près des autres, la fonction après `transcode`/`tts_speak`) : + ```python + TWEMOJI = os.environ.get("LISAEL_TWEMOJI", + "https://raw.githubusercontent.com/jdecked/twemoji/main/assets/72x72") + + def emoji_cp(emoji): + """Twemoji filename codepoints, VS16 (U+FE0F) stripped or twemoji 404s.""" + return "-".join(f"{ord(c):x}" for c in emoji if c != "️") + ``` +- [ ] **Step 4 (GREEN run):** `cd tower && python3 test_routines.py` → `ok test_emoji_cp_strips_vs16` puis `ALL OK`. +- [ ] **Step 5:** Ajouter `emoji_bin` (après `emoji_cp`). Écrit dans `STAGING_ROUTINES` (défini en Task 5 ; si Task 5 pas encore mergée, ce step suppose la constante présente — l'ordre du plan place Task 5 avant l'usage réel ; ici on référence la constante) : + ```python + def emoji_bin(emoji, key): + """Download the Twemoji for `emoji`, resize 160x160 RGBA, write `.bin` + (RGB565A8, keeps alpha) into STAGING_ROUTINES. Returns '.bin' or ''.""" + try: + url = f"{TWEMOJI}/{emoji_cp(emoji)}.png" + raw = urllib.request.urlopen( + urllib.request.Request(url, headers={"User-Agent": "curl/8"}), + timeout=15, context=ssl.create_default_context()).read() + im = Image.open(io.BytesIO(raw)).convert("RGBA").resize((160, 160), Image.LANCZOS) + os.makedirs(STAGING_ROUTINES, exist_ok=True) + with tempfile.TemporaryDirectory() as td: + png = os.path.join(td, f"{key}.png"); im.save(png) + subprocess.run([sys.executable, LVGLIMG, "--ofmt", "BIN", "--cf", "RGB565A8", + "-o", td, png], check=True, capture_output=True) + binp = os.path.join(td, f"{key}.bin") + if os.path.exists(binp): + os.replace(binp, os.path.join(STAGING_ROUTINES, f"{key}.bin")) + return f"{key}.bin" + except Exception as e: + print("emoji_bin fail", key, emoji, e, flush=True) + return "" + ``` +- [ ] **Step 6:** Vérif manuelle (réseau + LVGLImage). Sur Tower (vérifier d'abord que `LVGLImage.py` existe : `ls -l $LISAEL_LVGLIMG` ou `ls -l /home/clems/lisael-content/LVGLImage.py`) : + `cd /home/clems/lisael-content && python3 -c "import lisael_content as C; print(C.emoji_bin('☀️','matin'))"` → doit imprimer `matin.bin` et le fichier doit exister dans le staging routines, taille > 1 Ko. Commit : `feat(tower): twemoji to RGB565A8 bin helper`. + +--- + +### Task 3: Modèle routines — `routines_load()` / `routines_save()` + seed défaut (TDD) + +**Files:** +- `tower/lisael_content.py` (ajout) +- `tower/test_routines.py` (ajout de tests) + +**Interfaces:** +- Produces : `def routines_load() -> list` — lit `ROUTINES_JSON` (vérité éditoriale Tower), seed défaut Matin/Soir si absent. +- Produces : `def routines_save(routines: list) -> None` — `_atomic_write_json` vers `ROUTINES_JSON`. +- Consumes : env `LISAEL_BASE` (chemin `ROUTINES_JSON = BASE/routines.json`). + +Format de la vérité éditoriale Tower (`routines.json` à la racine `BASE`, distinct du manifest SD `STAGING_ROUTINES/routines.json`) — porte l'emoji source par étape, qui n'apparaît PAS dans le manifest SD : +```json +{"routines":[ + {"id":"matin","title":"Le matin","emoji":"☀️","steps":[ + {"text":"Habille-toi","emoji":"\U0001F455"}, + {"text":"Petit-déjeuner","emoji":"\U0001F950"}]}, + {"id":"soir","title":"Le soir","emoji":"\U0001F319","steps":[ + {"text":"Brosse tes dents","emoji":"\U0001FAA5"}]}]} +``` + +- [ ] **Step 1 (RED):** Ajouter dans `tower/test_routines.py` : + ```python + def test_routines_seed_and_roundtrip(): + r = C.routines_load() # seeds default if missing + ids = [x["id"] for x in r] + assert ids == ["matin", "soir"] + assert os.path.exists(C.ROUTINES_JSON) + assert all("emoji" in x and "title" in x and "steps" in x for x in r) + r[0]["steps"].append({"text": "Cartable", "emoji": "\U0001F392"}) + C.routines_save(r) + r2 = C.routines_load() + assert r2[0]["steps"][-1]["text"] == "Cartable" + ``` +- [ ] **Step 2 (RED run):** `cd tower && python3 test_routines.py` → échoue (`ROUTINES_JSON` / `routines_load` absents). +- [ ] **Step 3 (GREEN):** Ajouter dans `lisael_content.py` (constante près de `FEEDS_JSON`, fonctions dans une section `# ---- routines (truth) ----`) : + ```python + ROUTINES_JSON = os.path.join(BASE, "routines.json") + + DEFAULT_ROUTINES = [ + {"id": "matin", "title": "Le matin", "emoji": "☀️", "steps": [ + {"text": "Habille-toi", "emoji": "\U0001F455"}, + {"text": "Petit-déjeuner", "emoji": "\U0001F950"}, + {"text": "Brosse tes dents", "emoji": "\U0001FAA5"}, + {"text": "Prépare ton cartable", "emoji": "\U0001F392"}]}, + {"id": "soir", "title": "Le soir", "emoji": "\U0001F319", "steps": [ + {"text": "Mets ton pyjama", "emoji": "\U0001F476"}, + {"text": "Brosse tes dents", "emoji": "\U0001FAA5"}, + {"text": "Lis une histoire", "emoji": "\U0001F4D6"}]}, + ] + + def routines_load(): + if os.path.exists(ROUTINES_JSON): + try: + return json.load(open(ROUTINES_JSON, encoding="utf-8")).get("routines", []) + except Exception: + pass + routines_save([dict(r) for r in DEFAULT_ROUTINES]) + return [dict(r) for r in DEFAULT_ROUTINES] + + def routines_save(routines): + _atomic_write_json(ROUTINES_JSON, {"routines": routines}) + ``` +- [ ] **Step 4 (GREEN run):** `cd tower && python3 test_routines.py` → `ok test_emoji_cp_strips_vs16`, `ok test_routines_seed_and_roundtrip`, `ALL OK`. Commit : `feat(tower): routines truth model load and save`. + +--- + +### Task 4: `build_routine_manifest()` — assets en cache + manifest SD (TDD avec assets factices) + +**Files:** +- `tower/lisael_content.py` (ajout) +- `tower/test_routines.py` (ajout de tests) + +**Interfaces:** +- Produces : `def asset_key(rid: str, idx: int) -> str` — base ASCII d'une étape : `f"{rid}_{idx:02d}"` (idx 1-based). Pure, testable. +- Produces : `def build_routine_manifest(gen_bin=None, gen_audio=None) -> dict` — pour chaque routine/étape régénère picto `.bin` (si emoji changé) + audio `.mp3` (si texte changé), écrit le manifest SD `STAGING_ROUTINES/routines.json` au format firmware, renvoie `{"generated": n_assets, "skipped": n_cached, "routines": }`. `gen_bin`/`gen_audio` injectables (TDD : factices ; défaut = `emoji_bin` / TTS). Cache via index `STAGING_ROUTINES/.cache.json` (clé asset → `{"emoji":..., "text":...}`). +- Consumes : `routines_load()`, `emoji_bin`, `tts_speak`, `STAGING_ROUTINES`, `_atomic_write_json`. + +Le manifest SD produit (format que le firmware parse — chemins `.bin`/`.mp3` relatifs, PAS d'emoji) : +```json +{"routines":[{"id":"matin","title":"Le matin","icon":"matin.bin","steps":[ + {"text":"Habille-toi","icon":"matin_01.bin","audio":"matin_01.mp3"}, ...]}, ...]} +``` + +- [ ] **Step 1 (RED):** Ajouter dans `tower/test_routines.py` : + ```python + def test_asset_key(): + assert C.asset_key("matin", 1) == "matin_01" + assert C.asset_key("soir", 12) == "soir_12" + + def test_build_routine_manifest_caches(): + # deterministic content, fake generators that count calls and "write" files + C.routines_save([{"id": "matin", "title": "Le matin", "emoji": "☀️", + "steps": [{"text": "Habille-toi", "emoji": "\U0001F455"}]}]) + os.makedirs(C.STAGING_ROUTINES, exist_ok=True) + calls = {"bin": 0, "audio": 0} + def fake_bin(emoji, key): + calls["bin"] += 1 + open(os.path.join(C.STAGING_ROUTINES, key + ".bin"), "wb").close() + return key + ".bin" + def fake_audio(text, key): + calls["audio"] += 1 + open(os.path.join(C.STAGING_ROUTINES, key + ".mp3"), "wb").close() + return key + ".mp3" + r1 = C.build_routine_manifest(gen_bin=fake_bin, gen_audio=fake_audio) + # 1 step => 1 step bin + 1 audio + 1 routine icon bin = 2 bin, 1 audio + assert calls == {"bin": 2, "audio": 1} + man = r1["routines"] + assert man[0]["icon"] == "matin.bin" + assert man[0]["steps"][0] == {"text": "Habille-toi", "icon": "matin_01.bin", "audio": "matin_01.mp3"} + assert os.path.exists(os.path.join(C.STAGING_ROUTINES, "routines.json")) + # second build, unchanged content => everything cached, no regeneration + calls2 = {"bin": 0, "audio": 0} + def fb(e, k): calls2["bin"] += 1; return k + ".bin" + def fa(t, k): calls2["audio"] += 1; return k + ".mp3" + C.build_routine_manifest(gen_bin=fb, gen_audio=fa) + assert calls2 == {"bin": 0, "audio": 0} + ``` +- [ ] **Step 2 (RED run):** `cd tower && python3 test_routines.py` → échoue (`asset_key` / `build_routine_manifest` absents). +- [ ] **Step 3 (GREEN):** Ajouter dans `lisael_content.py` : + ```python + ROUTINES_CACHE = lambda: os.path.join(STAGING_ROUTINES, ".cache.json") + + def asset_key(rid, idx): + return f"{rid}_{idx:02d}" + + def _routine_cache_load(): + p = ROUTINES_CACHE() + if os.path.exists(p): + try: + return json.load(open(p, encoding="utf-8")) + except Exception: + pass + return {} + + def build_routine_manifest(gen_bin=None, gen_audio=None): + """Regenerate per-step .bin (emoji) + .mp3 (TTS) into STAGING_ROUTINES, + skipping assets whose (emoji|text) is unchanged, then write the SD manifest. + gen_bin(emoji, key)->bin_name ; gen_audio(text, key)->mp3_name (injectable).""" + gen_bin = gen_bin or emoji_bin + gen_audio = gen_audio or _gen_step_audio + os.makedirs(STAGING_ROUTINES, exist_ok=True) + cache = _routine_cache_load() + new_cache = {} + generated = skipped = 0 + manifest = [] + for r in routines_load(): + rid = r["id"] + # routine icon (emoji -> .bin), key = rid + icon_key = rid + c = cache.get(icon_key) + if c and c.get("emoji") == r.get("emoji") and os.path.exists( + os.path.join(STAGING_ROUTINES, icon_key + ".bin")): + icon = icon_key + ".bin"; skipped += 1 + else: + icon = gen_bin(r.get("emoji", ""), icon_key) or (icon_key + ".bin"); generated += 1 + new_cache[icon_key] = {"emoji": r.get("emoji")} + steps = [] + for i, st in enumerate(r.get("steps", []), start=1): + k = asset_key(rid, i) + prev = cache.get(k) or {} + bin_name = k + ".bin"; mp3_name = k + ".mp3" + # picto + if prev.get("emoji") == st.get("emoji") and os.path.exists( + os.path.join(STAGING_ROUTINES, bin_name)): + skipped += 1 + else: + gen_bin(st.get("emoji", ""), k); generated += 1 + # audio + if prev.get("text") == st.get("text") and os.path.exists( + os.path.join(STAGING_ROUTINES, mp3_name)): + skipped += 1 + else: + gen_audio(st.get("text", ""), k); generated += 1 + new_cache[k] = {"emoji": st.get("emoji"), "text": st.get("text")} + steps.append({"text": st.get("text", ""), "icon": bin_name, "audio": mp3_name}) + manifest.append({"id": rid, "title": r.get("title", rid), + "icon": icon, "steps": steps}) + _atomic_write_json(os.path.join(STAGING_ROUTINES, "routines.json"), + {"routines": manifest}) + _atomic_write_json(ROUTINES_CACHE(), new_cache) + return {"generated": generated, "skipped": skipped, "routines": manifest} + + def _gen_step_audio(text, key): + """Default audio generator: TTS -> mono mp3 in STAGING_ROUTINES. '' on failure.""" + try: + au = tts_speak(text) + _atomic_write_bytes(os.path.join(STAGING_ROUTINES, key + ".mp3"), au) + return key + ".mp3" + except Exception as e: + print("step audio fail", key, e, flush=True) + return "" + ``` + Note : la sortie gateway est déjà mono mp3 ; pas de re-transcode `ffmpeg` nécessaire ici (le contrat « mono 48k » est satisfait par la voix `fr` de la gateway). Si un jour la gateway renvoie du stéréo, brancher `transcode` dans `_gen_step_audio`. +- [ ] **Step 4 (GREEN run):** `cd tower && python3 test_routines.py` → tous les `ok ...` + `ALL OK`. Commit : `feat(tower): build routine manifest with asset cache`. + +--- + +### Task 5: Staging routines + `push_file(subdir)` généralisé + delta routines (TDD) + +**Files:** +- `tower/lisael_content.py` (modif + ajout) +- `tower/test_routines.py` (ajout de tests) + +**Interfaces:** +- Produces : constante `STAGING_ROUTINES` (env `LISAEL_STAGING_ROUTINES`, défaut `BASE/routines`). +- Produces : `def routines_staging_files() -> list` — basenames triés de `STAGING_ROUTINES` (hors cachés, hors `.cache.json`). +- Modifie : `def push_file(ip, name, subdir="podcasts") -> int` — POST vers `?name=/`, lit depuis le bon staging (`podcasts` → `STAGING`, `routines` → `STAGING_ROUTINES`). Défaut `"podcasts"` = compat totale. +- Produces : `def compute_routines_delta(have) -> list` — `compute_delta(routines_staging_files(), have)` (le manifest SD `routines.json` part en dernier). +- Consumes : `compute_delta` (réutilisé tel quel : il traite `manifest.json` comme nom de manifest — voir Step 2 pour l'adaptation au nom `routines.json`). + +- [ ] **Step 1 (RED):** Ajouter dans `tower/test_routines.py` : + ```python + def test_routines_delta_manifest_last(): + os.makedirs(C.STAGING_ROUTINES, exist_ok=True) + for f in ("matin.bin", "matin_01.bin", "matin_01.mp3", "routines.json"): + open(os.path.join(C.STAGING_ROUTINES, f), "wb").close() + d = C.compute_routines_delta([]) + assert d[-1] == "routines.json" # manifest always last + assert set(d[:-1]) == {"matin.bin", "matin_01.bin", "matin_01.mp3"} + # box already has assets -> only manifest moves if assets changed; here all present + assert C.compute_routines_delta(["matin.bin", "matin_01.bin", "matin_01.mp3"]) == [] + ``` +- [ ] **Step 2 (RED run):** `cd tower && python3 test_routines.py` → échoue (`STAGING_ROUTINES`/`compute_routines_delta` absents). +- [ ] **Step 3 (GREEN — constante + staging):** Ajouter la constante près de `STAGING` : + ```python + STAGING_ROUTINES = os.environ.get("LISAEL_STAGING_ROUTINES", os.path.join(BASE, "routines")) + ``` + Et la fonction (section routines) : + ```python + def routines_staging_files(): + if not os.path.isdir(STAGING_ROUTINES): + return [] + return sorted(f for f in os.listdir(STAGING_ROUTINES) + if not f.startswith(".")) + ``` +- [ ] **Step 4 (GREEN — delta):** Le `compute_delta` existant traite littéralement `"manifest.json"` comme manifest. Pour réutiliser la logique « manifest en dernier » avec un nom paramétrable, ajouter : + ```python + def compute_delta_named(staging, have, manifest_name): + haveset = set(have) + files = [f for f in staging if f != manifest_name and f not in haveset] + if files and manifest_name in staging: + files.append(manifest_name) + return files + + def compute_routines_delta(have): + return compute_delta_named(routines_staging_files(), have, "routines.json") + ``` + (Ne PAS toucher `compute_delta` — il reste la version podcasts `manifest.json` pour la compat et `test_delta.py`.) +- [ ] **Step 5 (GREEN — push_file subdir):** Modifier `push_file` : + ```python + def push_file(ip, name, subdir="podcasts"): + src_dir = STAGING_ROUTINES if subdir == "routines" else STAGING + data = open(os.path.join(src_dir, name), "rb").read() + url = f"http://{ip}:{BOX_PORT}/put?name={subdir}/{name}" + with urllib.request.urlopen(urllib.request.Request(url, data=data, method="POST"), + timeout=300) as r: + return r.status + ``` +- [ ] **Step 6 (GREEN run):** `cd tower && python3 test_routines.py` (delta) ET `cd tower && python3 test_delta.py` (régression podcasts → doit toujours afficher `OK`). Commit : `feat(tower): routines staging push and delta`. + +--- + +### Task 6: Journal des transferts — `transfers_begin/mark/end` + persistance (TDD) + +**Files:** +- `tower/lisael_content.py` (ajout) +- `tower/test_transfers.py` (nouveau fichier de test) + +**Interfaces:** +- Produces : `transfers_begin(box_ip, files)` — `files = [(name, subdir, size)]` ou `[(name, subdir)]` (taille via `os.path.getsize` si absente) ; initialise le lot actif (tous `pending`). +- Produces : `transfers_mark(name, status, error=None)` — `status ∈ {"sending","done","failed"}` ; au passage `done|failed` calcule `duration_s` (monotone, depuis le passage `sending`) et `rate_bps = size/duration_s`. +- Produces : `transfers_end()` — vide le lot actif, flush les enregistrements `done|failed` dans `TRANSFERS_JSON` (borné aux ~200 derniers, `_atomic_write_json`). +- Produces : `transfers_active() -> dict|None`, `transfers_history(limit=200) -> list`. +- Consumes : env `LISAEL_TRANSFERS` (défaut `BASE/transfers.json`), `_atomic_write_json`, `time.monotonic`. + +Enregistrement par fichier : +```json +{"name":"matin_01.mp3","dir":"routines","size":428111,"status":"done", + "started":"2026-06-21T09:12:03","ended":"2026-06-21T09:12:07", + "duration_s":4.1,"rate_bps":104417,"error":null,"box_ip":"192.168.0.250"} +``` + +- [ ] **Step 1 (RED):** Créer `tower/test_transfers.py` : + ```python + import os, tempfile, time, json + os.environ["LISAEL_BASE"] = tempfile.mkdtemp(prefix="transfers_test_") + os.environ["LISAEL_STAGING"] = os.path.join(os.environ["LISAEL_BASE"], "podcasts") + os.environ["LISAEL_STAGING_ROUTINES"] = os.path.join(os.environ["LISAEL_BASE"], "routines") + os.environ["LISAEL_TRANSFERS"] = os.path.join(os.environ["LISAEL_BASE"], "transfers.json") + import lisael_content as C + + def test_begin_mark_end_cycle(): + C.transfers_begin("192.168.0.250", [("a.mp3", "routines", 1000), + ("routines.json", "routines", 50)]) + a = C.transfers_active() + assert a["box_ip"] == "192.168.0.250" and a["total"] == 2 and a["current"] == 0 + assert all(f["status"] == "pending" for f in a["files"]) + C.transfers_mark("a.mp3", "sending") + assert C.transfers_active()["current"] == 1 + time.sleep(0.05) + C.transfers_mark("a.mp3", "done") + f = next(x for x in C.transfers_active()["files"] if x["name"] == "a.mp3") + assert f["status"] == "done" and f["duration_s"] > 0 and f["rate_bps"] > 0 + C.transfers_mark("routines.json", "sending") + C.transfers_mark("routines.json", "failed", "boom") + C.transfers_end() + assert C.transfers_active() is None + hist = C.transfers_history() + names = {h["name"]: h for h in hist} + assert names["a.mp3"]["status"] == "done" + assert names["routines.json"]["status"] == "failed" and names["routines.json"]["error"] == "boom" + + def test_history_truncates_200(): + for i in range(250): + C.transfers_begin("1.2.3.4", [(f"f{i}.mp3", "routines", 10)]) + C.transfers_mark(f"f{i}.mp3", "sending"); C.transfers_mark(f"f{i}.mp3", "done") + C.transfers_end() + assert len(C.transfers_history()) == 200 + # the most recent survive + assert any(h["name"] == "f249.mp3" for h in C.transfers_history()) + + if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_"): + fn(); print("ok", name) + print("ALL OK") + ``` +- [ ] **Step 2 (RED run):** `cd tower && python3 test_transfers.py` → échoue (`transfers_*` absents). +- [ ] **Step 3 (GREEN):** Ajouter `import time` en tête de `lisael_content.py` (sur la ligne d'imports stdlib existante) puis une section `# ---- transfers journal ----` : + ```python + TRANSFERS_JSON = os.environ.get("LISAEL_TRANSFERS", os.path.join(BASE, "transfers.json")) + TRANSFERS_MAX = 200 + + _active = None # active batch dict or None + _active_lock = threading.Lock() + + def _now_iso(): + return time.strftime("%Y-%m-%dT%H:%M:%S") + + def transfers_begin(box_ip, files): + """files: list of (name, subdir[, size]); size resolved via getsize if missing.""" + global _active + recs = [] + for it in files: + name, subdir = it[0], it[1] + size = it[2] if len(it) > 2 else None + if size is None: + src = STAGING_ROUTINES if subdir == "routines" else STAGING + try: + size = os.path.getsize(os.path.join(src, name)) + except OSError: + size = 0 + recs.append({"name": name, "dir": subdir, "size": size, "status": "pending", + "started": None, "ended": None, "duration_s": None, + "rate_bps": None, "error": None, "box_ip": box_ip, + "_t0": None}) + with _active_lock: + _active = {"box_ip": box_ip, "started": _now_iso(), + "total": len(recs), "current": 0, "files": recs} + + def transfers_mark(name, status, error=None): + with _active_lock: + if not _active: + return + for f in _active["files"]: + if f["name"] != name: + continue + if status == "sending": + f["status"] = "sending"; f["started"] = _now_iso(); f["_t0"] = time.monotonic() + _active["current"] += 1 + elif status in ("done", "failed"): + f["status"] = status; f["ended"] = _now_iso(); f["error"] = error + if f.get("_t0") is not None: + dur = max(time.monotonic() - f["_t0"], 0.001) + f["duration_s"] = round(dur, 3) + f["rate_bps"] = int((f["size"] or 0) / dur) + return + + def transfers_active(): + with _active_lock: + if not _active: + return None + # strip internal _t0 for the API view + files = [{k: v for k, v in f.items() if k != "_t0"} for f in _active["files"]] + return {**_active, "files": files} + + def _transfers_history_load(): + if os.path.exists(TRANSFERS_JSON): + try: + return json.load(open(TRANSFERS_JSON, encoding="utf-8")) + except Exception: + pass + return [] + + def transfers_history(limit=TRANSFERS_MAX): + return _transfers_history_load()[-limit:] + + def transfers_end(): + global _active + with _active_lock: + if not _active: + return + done = [{k: v for k, v in f.items() if k != "_t0"} + for f in _active["files"] if f["status"] in ("done", "failed")] + _active = None + hist = _transfers_history_load() + hist.extend(done) + hist = hist[-TRANSFERS_MAX:] + _atomic_write_json(TRANSFERS_JSON, hist) + ``` +- [ ] **Step 4 (GREEN run):** `cd tower && python3 test_transfers.py` → `ok test_begin_mark_end_cycle`, `ok test_history_truncates_200`, `ALL OK`. Commit : `feat(tower): transfers journal begin mark end`. + +--- + +### Task 7: Instrumenter `push_batch` + `try_push(subdir)` + `retry_failed` (TDD pour la sélection) + +**Files:** +- `tower/lisael_content.py` (modif + ajout) +- `tower/test_transfers.py` (ajout de tests) + +**Interfaces:** +- Modifie : `def push_batch(ip, delta, subdir="podcasts")` — `transfers_begin` au début (chaque fichier `(f, subdir)`), `transfers_mark(f,"sending")` avant `push_file`, `transfers_mark(f,"done"/"failed",err)` après, `transfers_end` à la fin ; passe `subdir` à `push_file`. +- Modifie : `def try_push(ip, delta, subdir="podcasts")` — passe `subdir` au thread `push_batch`. +- Produces : `def failed_names(box_ip) -> list` — derniers fichiers `failed` connus pour `box_ip` (lot actif sinon historique récent), dédupliqués, en gardant le `dir`. Pure-ish (lit l'état), testable. +- Produces : `def retry_failed(box_ip) -> dict` — collecte `failed_names`, relance `try_push` par sous-dossier ; renvoie `{"started": bool, "count": n}`. `409`-able côté API si `count == 0`. + +- [ ] **Step 1 (RED):** Ajouter dans `tower/test_transfers.py` (test de sélection des échecs sans réseau) : + ```python + def test_failed_names_from_history(): + # seed a finished batch with one failed file for a box + C.transfers_begin("9.9.9.9", [("ok.mp3", "routines", 10), ("bad.mp3", "routines", 10)]) + C.transfers_mark("ok.mp3", "sending"); C.transfers_mark("ok.mp3", "done") + C.transfers_mark("bad.mp3", "sending"); C.transfers_mark("bad.mp3", "failed", "net") + C.transfers_end() + fn = C.failed_names("9.9.9.9") + assert ("bad.mp3", "routines") in fn + assert ("ok.mp3", "routines") not in fn + # unknown box -> nothing + assert C.failed_names("0.0.0.0") == [] + ``` +- [ ] **Step 2 (RED run):** `cd tower && python3 test_transfers.py` → échoue (`failed_names` absent). +- [ ] **Step 3 (GREEN — failed_names + retry_failed):** Ajouter (section transfers) : + ```python + def failed_names(box_ip): + """Failed (name, dir) for box_ip: active batch first, else recent history. + Skips names already re-sent successfully later in history.""" + seen, failed = {}, [] + a = transfers_active() + pool = (a["files"] if a and a.get("box_ip") == box_ip else []) \ + + [h for h in transfers_history() if h.get("box_ip") == box_ip] + # later success overrides earlier failure: iterate chronological, keep last status + status_by = {} + for h in pool: + status_by[h["name"]] = (h["status"], h.get("dir", "podcasts")) + out = [] + for name, (st, d) in status_by.items(): + if st == "failed": + out.append((name, d)) + return out + + def retry_failed(box_ip): + fails = failed_names(box_ip) + if not fails: + return {"started": False, "count": 0} + by_dir = {} + for name, d in fails: + by_dir.setdefault(d, []).append(name) + started = False + for d, names in by_dir.items(): + if try_push(box_ip, names, subdir=d): + started = True + return {"started": started, "count": len(fails)} + ``` + Note : `transfers_active()["files"]` est ordonné, et l'historique est chronologique (append), donc `status_by` garde bien le dernier statut connu par nom. +- [ ] **Step 4 (GREEN — instrument push):** Modifier `push_batch` + `try_push` : + ```python + def push_batch(ip, delta, subdir="podcasts"): + transfers_begin(ip, [(f, subdir) for f in delta]) + ok = 0 + for f in delta: + transfers_mark(f, "sending") + try: + push_file(ip, f, subdir=subdir); ok += 1 + transfers_mark(f, "done") + except Exception as e: + transfers_mark(f, "failed", str(e)[:200]) + print("push fail", f, e, flush=True) + transfers_end() + print(f"push done {ip} {subdir} {ok}/{len(delta)}", flush=True) + with _busy_lock: + _busy.discard(ip) + + def try_push(ip, delta, subdir="podcasts"): + """Start a background push unless one already runs for ip. Returns started?""" + if not delta: + return False + with _busy_lock: + if ip in _busy: + return False + _busy.add(ip) + threading.Thread(target=push_batch, args=(ip, delta, subdir), daemon=True).start() + return True + ``` +- [ ] **Step 5 (GREEN run):** `cd tower && python3 test_transfers.py` → tous `ok` + `ALL OK`. Régression : `cd tower && python3 test_delta.py` → `OK`. Commit : `feat(tower): instrument push and retry failed`. + +--- + +### Task 8: API `/api/routines` (GET/POST) + `/api/routines/push` dans `lisael_server.py` + +**Files:** +- `tower/lisael_server.py` (modif `do_GET` / `do_POST`) + +**Interfaces:** +- Produces : `GET /api/routines` → `routines_load()` (vérité éditoriale, avec emojis). +- Produces : `POST /api/routines` body `{"routines":[...]}` → `routines_save` + `build_routine_manifest` en thread daemon → `202 {"queued": n_steps}`. +- Produces : `POST /api/routines/push` → `compute_routines_delta(have)` + `try_push(ip, delta, subdir="routines")` → `{"ip","delta","started"}` ou `409` si pas d'IP. +- Consumes : `box_state_load()`, `routines_load/save`, `build_routine_manifest`, `compute_routines_delta`, `try_push`. + +- [ ] **Step 1:** Dans `do_GET`, après la branche `if path == "/api/box":` (avant `self.send_error(404)`), ajouter : + ```python + if path == "/api/routines": + self._json({"routines": C.routines_load()}); return + ``` +- [ ] **Step 2:** Dans `do_POST`, après la branche `/api/push` (avant `self.send_error(404)`), ajouter la sauvegarde + régénération en thread (pattern `/api/select`) : + ```python + if path == "/api/routines": + try: + b = json.loads(self._body() or b"{}") + routines = b["routines"] + assert isinstance(routines, list) + except Exception as e: + self._json({"error": str(e)}, 400); return + C.routines_save(routines) + n_steps = sum(len(r.get("steps", [])) for r in routines) + def work(): + try: + res = C.build_routine_manifest() + print("routines built", res["generated"], "gen", + res["skipped"], "cached", flush=True) + except Exception as e: + print("routines build fail", e, flush=True) + threading.Thread(target=work, daemon=True).start() + self._json({"queued": n_steps}, 202); return + if path == "/api/routines/push": + st = C.box_state_load() + ip = st.get("ip") + if not ip: + self._json({"error": "box never registered"}, 409); return + delta = C.compute_routines_delta(st.get("have", [])) + started = C.try_push(ip, delta, subdir="routines") + self._json({"ip": ip, "delta": len(delta), "started": started}); return + ``` +- [ ] **Step 3:** Mettre à jour le handler `/register` pour que le delta routines soit aussi poussé au boot. Dans `do_POST` branche `/register`, après `started = C.try_push(ip, delta)` ajouter le push routines (la box annonce déjà l'union des basenames dans `have`) : + ```python + r_delta = C.compute_routines_delta(have) + if r_delta: + C.try_push(ip, r_delta, subdir="routines") + self._json({"queued": len(delta) + len(r_delta), "started": started}); return + ``` + (Remplace la ligne `self._json({"queued": len(delta), "started": started}); return` existante.) +- [ ] **Step 4:** Vérif manuelle locale (pas de box requise). Lancer le serveur en local avec auth désactivée et staging temporaire : + ```bash + cd tower && LISAEL_BASE=/tmp/merlin_v2 LISAEL_STAGING=/tmp/merlin_v2/podcasts \ + LISAEL_STAGING_ROUTINES=/tmp/merlin_v2/routines LISAEL_PORT=9556 \ + python3 lisael_server.py & + sleep 1 + curl -s localhost:9556/api/routines | head -c 400 # -> seed matin/soir + curl -s -X POST localhost:9556/api/routines -H 'Content-Type: application/json' \ + -d '{"routines":[{"id":"matin","title":"Le matin","emoji":"☀️","steps":[{"text":"Test","emoji":"👕"}]}]}' -i | head -1 # -> 202 + curl -s -X POST localhost:9556/api/routines/push -i | head -1 # -> 409 (no box) JSON + kill %1 + ``` + (Le build manifest tentera un vrai TTS/Twemoji ; sur grosmac sans accès gateway il échouera proprement — vérifier que l'API répond bien `202` quoi qu'il en soit, le build est en thread.) Commit : `feat(tower): routines api save build push`. + +--- + +### Task 9: API `/api/transfers` (GET) + `/api/transfers/retry` (POST) + +**Files:** +- `tower/lisael_server.py` (modif `do_GET` / `do_POST`) + +**Interfaces:** +- Produces : `GET /api/transfers` → `{"active": transfers_active(), "history": transfers_history()}`. +- Produces : `POST /api/transfers/retry` → `retry_failed(box_ip)` ; `202 {"started","count"}` si `count>0`, sinon `409 {"error": "..."}` (pas d'IP ou rien à relancer). +- Consumes : `box_state_load()`, `transfers_active/history`, `retry_failed`. + +- [ ] **Step 1:** Dans `do_GET`, après la branche `/api/routines` (Task 8), ajouter : + ```python + if path == "/api/transfers": + self._json({"active": C.transfers_active(), + "history": C.transfers_history()}); return + ``` +- [ ] **Step 2:** Dans `do_POST`, après la branche `/api/routines/push` (Task 8), ajouter : + ```python + if path == "/api/transfers/retry": + st = C.box_state_load() + ip = st.get("ip") + if not ip: + self._json({"error": "box never registered"}, 409); return + res = C.retry_failed(ip) + if not res["count"]: + self._json({"error": "rien a relancer"}, 409); return + self._json(res, 202); return + ``` +- [ ] **Step 3:** Vérif manuelle locale (reprend le serveur de la Task 8) : + ```bash + cd tower && LISAEL_BASE=/tmp/merlin_v2 LISAEL_STAGING=/tmp/merlin_v2/podcasts \ + LISAEL_STAGING_ROUTINES=/tmp/merlin_v2/routines LISAEL_PORT=9556 \ + python3 lisael_server.py & + sleep 1 + curl -s localhost:9556/api/transfers # -> {"active":null,"history":[]} + curl -s -X POST localhost:9556/api/transfers/retry -i | head -1 # -> 409 JSON + kill %1 + ``` + Commit : `feat(tower): transfers api and retry endpoint`. + +--- + +### Task 10: UI onglet « 🗓️ Routines » dans `merlin_ui.html` + +**Files:** +- `tower/merlin_ui.html` (modif : tabs, panneau, JS) + +**Interfaces:** +- Consumes : `GET /api/routines`, `POST /api/routines`, `POST /api/routines/push` (via le helper `api()` existant). +- Produces : édition en mémoire de la liste routines + rendu (2 sections Matin/Soir, étapes texte+emoji, +/−, ↑↓, Enregistrer & pousser). + +- [ ] **Step 1:** Ajouter le bouton d'onglet dans `.tabs` (après le bouton `tab-box`) : + ```html + + ``` +- [ ] **Step 2:** Ajouter le panneau (après `
...
`, avant la fermeture `` de `.wrap`) : + ```html + +
+
chargement…
+
+ +
+
+ ``` +- [ ] **Step 3:** Étendre `TABS` et `showTab` : + ```javascript + const TABS = ["podcasts", "fichiers", "box", "routines", "transfers"]; + ``` + et dans `showTab`, ajouter : + ```javascript + if (name === "routines") loadRoutines(); + ``` + (la branche `transfers` sera câblée en Task 11 ; on déclare déjà l'entrée TABS ici pour éviter un second diff sur la même ligne.) +- [ ] **Step 4:** Ajouter une palette d'emojis enfant + l'état en mémoire (dans le `