Backfills the working firmware that had stayed uncommitted across the build sessions, so the branch is self-contained. - audio: audio_player, radio_pipeline, sd_player, calm_tone, aac_player - net: podcast (RSS), udp_log - modes: balloons, plus calm/games/radio tweaks - ui: fonts header, icons; build files (CMakeLists, idf_component.yml, sdkconfig.defaults) - docs/superpowers: Histoires design + plan Note: sdkconfig stays gitignored (Wi-Fi creds + tuned LFN/FS/mbedtls settings); a fresh checkout must reconfigure those.
34 KiB
Écran « Histoires » à vignettes — Plan d'implémentation
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: Remplacer les menus « Histoires » et « Sons » par un seul écran à vignettes où l'on choisit une histoire par sa pochette (grille 2×2 paginée → liste d'épisodes → lecture plein écran).
Architecture: Le contenu (audio + pochettes .bin LVGL + manifest.json v2 groupé par show) vit sur la carte SD. Un parseur pur (testable sur l'hôte) charge le manifeste ; un écran LVGL à 3 niveaux l'affiche ; les pochettes sont lues depuis la SD via le driver de fichiers LVGL stdio ; la lecture réutilise le dispatch existant .m4a → aac_player / .mp3 → sd_player.
Tech Stack: ESP-IDF v5.4, LVGL 9.5 (esp-box-3 BSP), cJSON, FATFS (LFN activé), LVGLImage.py (pochettes → .bin).
Notes d'exécution :
- Vérification : ce firmware n'a pas de harnais de test UI. Le seul test unitaire hôte est celui du parseur (Task 1). Pour le reste, la vérification par tâche =
idf.py buildréussit (attrape erreurs de type/compilation) ; la validation finale est l'acceptation sur la vraie carte (Task 8). - Commits : locaux uniquement, ne pas pousser (politique du repo : push sur demande explicite). Messages en anglais, sujet ≤ 50 car., pas d'attribution IA, pas de
_dans le scope. - Activation IDF (chaque shell) :
. /Users/electron/esp/esp-idf/export.sh. Port box :/dev/cu.usbmodem1101.
Task 1 : Parseur de manifeste v2 (pur, testé sur l'hôte)
Files:
-
Create:
main/audio/podcast_manifest.h -
Create:
main/audio/podcast_manifest.c -
Test:
test/host/test_podcast_manifest.c -
Step 1 : Écrire le test hôte (échoue d'abord)
test/host/test_podcast_manifest.c :
// Host unit test for the v2 podcast manifest parser.
#define LISAEL_HOST_TEST
#include "audio/podcast_manifest.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
static const char *SAMPLE =
"[{\"show\":\"Une histoire et Oli\",\"cover\":\"oli.bin\",\"episodes\":["
"{\"file\":\"oli_01.m4a\",\"title\":\"Concert p.1\"},"
"{\"file\":\"oli_02.m4a\",\"title\":\"Concert p.2\"}]},"
"{\"show\":\"Bestioles\",\"cover\":\"bestioles.bin\",\"episodes\":["
"{\"file\":\"bestioles_01.mp3\",\"title\":\"La gerboise\"}]},"
"{\"show\":\"Vide\",\"episodes\":[]}]";
int main(void) {
lisael_show_t shows[LISAEL_MAX_SHOWS];
int n = lisael_podcast_parse(SAMPLE, shows, LISAEL_MAX_SHOWS);
assert(n == 2); // empty show skipped
assert(strcmp(shows[0].show, "Une histoire et Oli") == 0);
assert(strcmp(shows[0].cover, "oli.bin") == 0);
assert(shows[0].n_eps == 2);
assert(strcmp(shows[0].eps[1].file, "oli_02.m4a") == 0);
assert(strcmp(shows[0].eps[0].title, "Concert p.1") == 0);
assert(strcmp(shows[1].show, "Bestioles") == 0);
assert(shows[1].n_eps == 1);
assert(lisael_podcast_parse("not json", shows, LISAEL_MAX_SHOWS) == -1);
printf("OK: %d shows\n", n);
return 0;
}
- Step 2 : Écrire le header
main/audio/podcast_manifest.h :
// Podcast manifest v2 (grouped by show) for the Lisael Box "Histoires" screen.
// /sdcard/podcasts/manifest.json = JSON array of shows, each with a cover and
// a list of episodes. ASCII file names; UTF-8 show/title strings.
#pragma once
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
#define LISAEL_MAX_SHOWS 12
#define LISAEL_MAX_EPISODES 24
typedef struct {
char file[64]; // audio basename under /sdcard/podcasts/ (e.g. "oli_01.m4a")
char title[80]; // UTF-8 display title
} lisael_episode_t;
typedef struct {
char show[48]; // UTF-8 show name
char cover[40]; // cover basename ("oli.bin"), "" if none
lisael_episode_t eps[LISAEL_MAX_EPISODES];
int n_eps;
} lisael_show_t;
// Pure parser: JSON string -> shows[]. Skips shows with zero episodes.
// Returns show count (>=0), or -1 if the JSON is not a valid array.
int lisael_podcast_parse(const char *json, lisael_show_t *shows, int max_shows);
// Read /sdcard/podcasts/manifest.json and parse it. Returns count or -1.
int lisael_podcast_load(lisael_show_t *shows, int max_shows);
#ifdef __cplusplus
}
#endif
- Step 3 : Vérifier que le test échoue (pas d'implémentation)
Run :
cc -DLISAEL_HOST_TEST -I/tmp/lisael-box/main \
-I/Users/electron/esp/esp-idf/components/json/cJSON \
/tmp/lisael-box/test/host/test_podcast_manifest.c \
/tmp/lisael-box/main/audio/podcast_manifest.c \
/Users/electron/esp/esp-idf/components/json/cJSON/cJSON.c \
-o /tmp/test_podcast_manifest 2>&1 | head
Expected : ÉCHEC de link/compile (podcast_manifest.c n'existe pas encore / lisael_podcast_parse indéfini).
- Step 4 : Implémenter le parseur
main/audio/podcast_manifest.c :
// See podcast_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/podcast_manifest.h"
#include "cJSON.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
int lisael_podcast_parse(const char *json, lisael_show_t *shows, int max_shows)
{
if (!json || !shows || max_shows <= 0) {
return -1;
}
cJSON *root = cJSON_Parse(json);
if (!cJSON_IsArray(root)) {
cJSON_Delete(root);
return -1;
}
int n = 0;
cJSON *jshow;
cJSON_ArrayForEach(jshow, root) {
if (n >= max_shows) {
break;
}
cJSON *jname = cJSON_GetObjectItem(jshow, "show");
cJSON *jcover = cJSON_GetObjectItem(jshow, "cover");
cJSON *jeps = cJSON_GetObjectItem(jshow, "episodes");
if (!cJSON_IsString(jname) || !cJSON_IsArray(jeps)) {
continue;
}
lisael_show_t *s = &shows[n];
memset(s, 0, sizeof(*s));
snprintf(s->show, sizeof(s->show), "%s", jname->valuestring);
if (cJSON_IsString(jcover)) {
snprintf(s->cover, sizeof(s->cover), "%s", jcover->valuestring);
}
cJSON *jep;
cJSON_ArrayForEach(jep, jeps) {
if (s->n_eps >= LISAEL_MAX_EPISODES) {
break;
}
cJSON *jf = cJSON_GetObjectItem(jep, "file");
cJSON *jt = cJSON_GetObjectItem(jep, "title");
if (!cJSON_IsString(jf)) {
continue;
}
lisael_episode_t *e = &s->eps[s->n_eps];
snprintf(e->file, sizeof(e->file), "%s", jf->valuestring);
snprintf(e->title, sizeof(e->title), "%s",
cJSON_IsString(jt) ? jt->valuestring : jf->valuestring);
s->n_eps++;
}
if (s->n_eps > 0) {
n++; // skip shows with no playable episode
}
}
cJSON_Delete(root);
return n;
}
#ifndef LISAEL_HOST_TEST
#include "esp_log.h"
int lisael_podcast_load(lisael_show_t *shows, int max_shows)
{
const char *path = "/sdcard/podcasts/manifest.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_podcast_parse(buf, shows, max_shows);
free(buf);
ESP_LOGI("podcast", "loaded %d show(s) from %s", n, path);
return n;
}
#endif
- Step 5 : Vérifier que le test passe
Run (même commande qu'au Step 3) :
cc -DLISAEL_HOST_TEST -I/tmp/lisael-box/main \
-I/Users/electron/esp/esp-idf/components/json/cJSON \
/tmp/lisael-box/test/host/test_podcast_manifest.c \
/tmp/lisael-box/main/audio/podcast_manifest.c \
/Users/electron/esp/esp-idf/components/json/cJSON/cJSON.c \
-o /tmp/test_podcast_manifest && /tmp/test_podcast_manifest
Expected : OK: 2 shows.
- Step 6 : Commit (local)
git add main/audio/podcast_manifest.h main/audio/podcast_manifest.c test/host/test_podcast_manifest.c
git commit -m "feat(histoires): podcast manifest v2 parser + host test"
Task 2 : Activer le driver de fichiers LVGL (stdio) pour lire les .bin de la SD
Files:
-
Modify:
sdkconfig(section LVGL FS) -
Modify:
main/CMakeLists.txt(ajout depodcast_manifest.caux SRCS) -
Step 1 : Ajouter
podcast_manifest.caux sources
Dans main/CMakeLists.txt, ajouter "audio/podcast_manifest.c" à la liste SRCS (à côté des autres audio/*.c).
- Step 2 : Activer le driver FS stdio dans
sdkconfig
Repérer la ligne # CONFIG_LV_USE_FS_STDIO is not set (ou son absence) et la remplacer / ajouter, dans la section LVGL :
CONFIG_LV_USE_FS_STDIO=y
CONFIG_LV_FS_STDIO_LETTER=83
CONFIG_LV_FS_STDIO_PATH=""
CONFIG_LV_FS_STDIO_CACHE_SIZE=0
(83 = ASCII 'S' → on adressera les pochettes par "S:/sdcard/podcasts/<x>.bin", que LVGL ouvre via fopen("/sdcard/podcasts/<x>.bin").)
- Step 3 : Build (vérifie la prise en compte + que rien ne casse)
Run :
cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 && idf.py build 2>&1 | tail -5
grep -E "LV_USE_FS_STDIO|LV_FS_STDIO_LETTER" sdkconfig
Expected : Project build complete. et CONFIG_LV_USE_FS_STDIO=y + CONFIG_LV_FS_STDIO_LETTER=83.
- Step 4 : Commit (local)
git add sdkconfig main/CMakeLists.txt
git commit -m "build(histoires): enable LVGL stdio FS for SD cover images"
Task 3 : Écran « Histoires » à vignettes (3 niveaux) — modes/histoires.c
Files:
- Create:
main/modes/histoires.c
Cet écran gère lui-même ses 3 sous-écrans LVGL (grille / liste / lecteur) et navigue entre eux avec lv_screen_load_anim. Seul le niveau grille utilise lisael_ui_add_back_button (→ Accueil) ; liste et lecteur ont leur propre bouton retour vers le niveau parent.
- Step 1 : Écrire
main/modes/histoires.c
// "Histoires" — Merlin-style vignette browser for the Lisael Box.
//
// 3 levels, each its own LVGL screen, navigated with lv_screen_load_anim:
// 1. paged 2x2 grid of podcast covers (lisael_screen_histoires())
// 2. episode list for the chosen show
// 3. full-screen player (cover + title + transport)
//
// Covers are RGB565 .bin images on the SD, loaded via the LVGL stdio FS driver
// ("S:" -> /sdcard). Playback dispatches by extension: .m4a -> aac_player,
// otherwise -> sd_player (MP3). One audio source at a time.
#include "ui/ui.h"
#include "audio/podcast_manifest.h"
#include "audio/aac_player.h"
#include "audio/sd_player.h"
#include "audio/audio_player.h"
#include <stdint.h>
#include <string.h>
#include <strings.h>
#include <stdio.h>
#include <sys/stat.h>
#include "esp_log.h"
static const char *TAG = "ui.histoires";
static lisael_show_t s_shows[LISAEL_MAX_SHOWS];
static int s_n_shows;
static int s_cur_show = -1; // show currently drilled into
static int s_cur_ep = -1; // episode currently in the player
static lv_obj_t *s_grid_screen; // cached level-1 screen (owned by ui.c cache)
static lv_obj_t *s_list_screen; // rebuilt per show
static lv_obj_t *s_player_screen; // rebuilt per episode
static const uint32_t s_fallback_colors[6] = {
0xE8590C, 0x1971C2, 0x2F9E44, 0x9C36B5, 0xE8A92C, 0xC2255C,
};
// --- a cover image (or a coloured fallback tile if no/unreadable .bin) -------
static lv_obj_t *make_cover(lv_obj_t *parent, int show_idx, int px)
{
lisael_show_t *s = &s_shows[show_idx];
if (s->cover[0]) {
char real[64];
snprintf(real, sizeof(real), "/sdcard/podcasts/%s", s->cover);
struct stat st;
if (stat(real, &st) == 0 && st.st_size > 0) {
char src[72];
snprintf(src, sizeof(src), "S:/sdcard/podcasts/%s", s->cover);
lv_obj_t *img = lv_image_create(parent);
lv_image_set_src(img, src);
lv_image_set_scale(img, (uint32_t)(px * 256 / 160)); // stored at 160px
return img;
}
}
// fallback: rounded coloured square with the show's first letter
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(s_fallback_colors[show_idx % 6]), 0);
lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *l = lv_label_create(box);
char init[2] = { s->show[0] ? s->show[0] : '?', '\0' };
lv_label_set_text(l, init);
lv_obj_set_style_text_color(l, lv_color_white(), 0);
lv_obj_center(l);
return box;
}
// --- level 3: player ---------------------------------------------------------
static void play_current(void)
{
lisael_show_t *s = &s_shows[s_cur_show];
char abs[96];
snprintf(abs, sizeof(abs), "/sdcard/podcasts/%s", s->eps[s_cur_ep].file);
size_t len = strlen(abs);
if (len > 4 && strcasecmp(abs + len - 4, ".m4a") == 0) {
lisael_aac_play_file(abs);
} else {
lisael_sd_play_file(abs);
}
ESP_LOGI(TAG, "play %s", abs);
}
static void player_back_cb(lv_event_t *e)
{
(void)e;
lisael_audio_stop_all();
lv_screen_load_anim(s_list_screen, LV_SCR_LOAD_ANIM_MOVE_RIGHT, 220, 0, false);
}
static void player_toggle_cb(lv_event_t *e)
{
lv_obj_t *lbl = lv_obj_get_child(lv_event_get_target(e), 0);
if (lisael_aac_is_playing() || lisael_sd_is_playing()) {
lisael_audio_stop_all();
lv_label_set_text(lbl, LV_SYMBOL_PLAY);
} else {
play_current();
lv_label_set_text(lbl, LV_SYMBOL_STOP);
}
}
static void player_step_cb(lv_event_t *e)
{
int dir = (int)(intptr_t)lv_event_get_user_data(e);
int n = s_shows[s_cur_show].n_eps;
s_cur_ep = (s_cur_ep + dir + n) % n;
lisael_audio_stop_all();
lv_obj_t *scr = lisael_screen_histoires(); // ensure shows loaded; no-op rebuild
(void)scr;
// rebuild the player for the new episode
extern lv_obj_t *lisael_histoires_open_episode(int ep); // fwd (same file)
lisael_histoires_open_episode(s_cur_ep);
}
lv_obj_t *lisael_histoires_open_episode(int ep)
{
s_cur_ep = ep;
s_player_screen = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_player_screen, lv_color_hex(0x0E1726), 0);
lv_obj_t *back = lv_button_create(s_player_screen);
lv_obj_add_style(back, lisael_ui_style_button(), 0);
lv_obj_set_size(back, 64, 52);
lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6);
lv_obj_add_event_cb(back, player_back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *bl = lv_label_create(back);
lv_label_set_text(bl, LV_SYMBOL_LEFT);
lv_obj_center(bl);
make_cover(s_player_screen, s_cur_show, 110);
lv_obj_t *cover_holder = lv_obj_get_child(s_player_screen, lv_obj_get_child_count(s_player_screen) - 1);
lv_obj_align(cover_holder, LV_ALIGN_TOP_MID, 0, 12);
lv_obj_t *title = lv_label_create(s_player_screen);
lv_label_set_text(title, s_shows[s_cur_show].eps[s_cur_ep].title);
lv_obj_set_style_text_color(title, lv_color_white(), 0);
lv_obj_set_width(title, 300);
lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(title, LV_LABEL_LONG_WRAP);
lv_obj_align(title, LV_ALIGN_CENTER, 0, 30);
// transport: ⏮ ▶/⏹ ⏭
lv_obj_t *bar = lv_obj_create(s_player_screen);
lv_obj_remove_style_all(bar);
lv_obj_set_size(bar, 240, 60);
lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *prev = lv_button_create(bar);
lv_obj_set_size(prev, 60, 52);
lv_obj_add_event_cb(prev, player_step_cb, LV_EVENT_CLICKED, (void *)(intptr_t)-1);
lv_obj_center(lv_label_create(prev));
lv_label_set_text(lv_obj_get_child(prev, 0), LV_SYMBOL_PREV);
lv_obj_t *toggle = lv_button_create(bar);
lv_obj_set_size(toggle, 72, 52);
lv_obj_add_event_cb(toggle, player_toggle_cb, LV_EVENT_CLICKED, NULL);
lv_obj_center(lv_label_create(toggle));
lv_label_set_text(lv_obj_get_child(toggle, 0), LV_SYMBOL_STOP);
lv_obj_t *next = lv_button_create(bar);
lv_obj_set_size(next, 60, 52);
lv_obj_add_event_cb(next, player_step_cb, LV_EVENT_CLICKED, (void *)(intptr_t)+1);
lv_obj_center(lv_label_create(next));
lv_label_set_text(lv_obj_get_child(next, 0), LV_SYMBOL_NEXT);
lv_screen_load_anim(s_player_screen, LV_SCR_LOAD_ANIM_MOVE_LEFT, 220, 0, true);
play_current();
return s_player_screen;
}
// --- level 2: episode list ---------------------------------------------------
static void ep_cb(lv_event_t *e)
{
int ep = (int)(intptr_t)lv_event_get_user_data(e);
lisael_histoires_open_episode(ep);
}
static void list_back_cb(lv_event_t *e)
{
(void)e;
lv_screen_load_anim(s_grid_screen, LV_SCR_LOAD_ANIM_MOVE_RIGHT, 220, 0, false);
}
static lv_obj_t *open_show(int show_idx)
{
s_cur_show = show_idx;
lisael_show_t *s = &s_shows[show_idx];
s_list_screen = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_list_screen, lv_color_hex(0xEBFBEE), 0);
lv_obj_t *back = lv_button_create(s_list_screen);
lv_obj_add_style(back, lisael_ui_style_button(), 0);
lv_obj_set_size(back, 64, 52);
lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6);
lv_obj_add_event_cb(back, list_back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_center(lv_label_create(back));
lv_label_set_text(lv_obj_get_child(back, 0), LV_SYMBOL_LEFT);
lv_obj_t *title = lv_label_create(s_list_screen);
lv_label_set_text(title, s->show);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18);
lv_obj_t *list = lv_list_create(s_list_screen);
lv_obj_set_size(list, LV_PCT(96), 168);
lv_obj_align(list, LV_ALIGN_BOTTOM_MID, 0, -4);
lv_obj_set_style_bg_color(list, lv_color_white(), 0);
lv_obj_set_style_radius(list, 12, 0);
for (int i = 0; i < s->n_eps; i++) {
lv_obj_t *btn = lv_list_add_button(list, NULL, s->eps[i].title);
lv_obj_add_event_cb(btn, ep_cb, LV_EVENT_CLICKED, (void *)(intptr_t)i);
}
lv_screen_load_anim(s_list_screen, LV_SCR_LOAD_ANIM_MOVE_LEFT, 220, 0, true);
return s_list_screen;
}
// --- level 1: paged 2x2 cover grid -------------------------------------------
static lv_obj_t *s_dots[ (LISAEL_MAX_SHOWS + 3) / 4 ];
static int s_n_pages;
static void cover_cb(lv_event_t *e)
{
int idx = (int)(intptr_t)lv_event_get_user_data(e);
open_show(idx);
}
static void tv_scroll_cb(lv_event_t *e)
{
lv_obj_t *tv = lv_event_get_target(e);
int w = lv_obj_get_width(tv);
int page = (w > 0) ? (lv_obj_get_scroll_x(tv) + w / 2) / w : 0;
for (int i = 0; i < s_n_pages; i++) {
lv_obj_set_style_bg_opa(s_dots[i], i == page ? LV_OPA_COVER : LV_OPA_30, 0);
}
}
lv_obj_t *lisael_screen_histoires(void)
{
if (s_grid_screen) {
return s_grid_screen;
}
s_n_shows = lisael_podcast_load(s_shows, LISAEL_MAX_SHOWS);
s_grid_screen = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_grid_screen, lv_color_hex(0xCDE8FF), 0);
lv_obj_set_style_bg_grad_color(s_grid_screen, lv_color_hex(0xF6FBFF), 0);
lv_obj_set_style_bg_grad_dir(s_grid_screen, LV_GRAD_DIR_VER, 0);
lisael_ui_add_back_button(s_grid_screen);
lv_obj_t *title = lv_label_create(s_grid_screen);
lv_label_set_text(title, "Histoires");
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18);
if (s_n_shows <= 0) {
lv_obj_t *info = lv_label_create(s_grid_screen);
lv_label_set_text(info,
"Aucune histoire pour l'instant.\nBranche la box en USB\npour en ajouter.");
lv_obj_set_style_text_align(info, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(info);
return s_grid_screen;
}
lv_obj_t *tv = lv_tileview_create(s_grid_screen);
lv_obj_set_size(tv, LV_PCT(100), 168);
lv_obj_align(tv, LV_ALIGN_TOP_MID, 0, 50);
lv_obj_set_style_bg_opa(tv, LV_OPA_TRANSP, 0);
lv_obj_add_event_cb(tv, tv_scroll_cb, LV_EVENT_SCROLL_END, NULL);
s_n_pages = (s_n_shows + 3) / 4;
for (int p = 0; p < s_n_pages; p++) {
lv_obj_t *page = lv_tileview_add_tile(tv, p, 0, LV_DIR_HOR);
lv_obj_set_flex_flow(page, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(page, LV_FLEX_ALIGN_SPACE_EVENLY,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(page, 6, 0);
for (int k = 0; k < 4; k++) {
int idx = p * 4 + k;
if (idx >= s_n_shows) {
break;
}
lv_obj_t *cell = lv_button_create(page);
lv_obj_remove_style_all(cell);
lv_obj_set_size(cell, 150, 96);
lv_obj_set_flex_flow(cell, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(cell, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_add_event_cb(cell, cover_cb, LV_EVENT_CLICKED, (void *)(intptr_t)idx);
make_cover(cell, idx, 64);
lv_obj_t *cap = lv_label_create(cell);
lv_label_set_text(cap, s_shows[idx].show);
lv_label_set_long_mode(cap, LV_LABEL_LONG_DOT);
lv_obj_set_width(cap, 144);
lv_obj_set_style_text_align(cap, LV_TEXT_ALIGN_CENTER, 0);
}
}
// page dots (drawn objects, no font dependency)
if (s_n_pages > 1) {
lv_obj_t *dotrow = lv_obj_create(s_grid_screen);
lv_obj_remove_style_all(dotrow);
lv_obj_set_size(dotrow, LV_PCT(60), 18);
lv_obj_align(dotrow, LV_ALIGN_BOTTOM_MID, 0, -6);
lv_obj_set_flex_flow(dotrow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(dotrow, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(dotrow, LV_OBJ_FLAG_SCROLLABLE);
for (int i = 0; i < s_n_pages; i++) {
lv_obj_t *d = lv_obj_create(dotrow);
lv_obj_remove_style_all(d);
lv_obj_set_size(d, 10, 10);
lv_obj_set_style_radius(d, 5, 0);
lv_obj_set_style_margin_left(d, 4, 0);
lv_obj_set_style_margin_right(d, 4, 0);
lv_obj_set_style_bg_opa(d, i == 0 ? LV_OPA_COVER : LV_OPA_30, 0);
lv_obj_set_style_bg_color(d, lv_color_hex(0x1971C2), 0);
s_dots[i] = d;
}
}
return s_grid_screen;
}
- Step 2 : Build (vérifie la compilation de l'écran)
⚠️
histoires.cne sera lié qu'après la Task 4 (ajout aux SRCS + retrait de podcasts.c/soundbox.c). Pour vérifier la compilation isolée maintenant :
cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1
xtensa-esp32s3-elf-gcc -fsyntax-only \
-I main -I managed_components/lvgl__lvgl/src -I managed_components/lvgl__lvgl \
-I build/config -DLV_LVGL_H_INCLUDE_SIMPLE=1 main/modes/histoires.c 2>&1 | head -20 || true
Expected : pas d'erreur de syntaxe (les warnings d'include LVGL résolus à la Task 4 via le composant ; si -fsyntax-only peine sur les includes, considérer le build complet de la Task 4 comme la vraie vérification).
- Step 3 : Commit (local)
git add main/modes/histoires.c
git commit -m "feat(histoires): vignette grid + episode list + player screen"
Task 4 : Câblage UI — router, fusion des tuiles, suppression des anciens écrans
Files:
-
Modify:
main/ui/ui.h -
Modify:
main/ui/ui.c:75(case du routeur) -
Modify:
main/modes/home.c:148-158(tuiles) -
Modify:
main/CMakeLists.txt(SRCS) -
Delete:
main/modes/podcasts.c,main/modes/soundbox.c -
Step 1 :
ui.h— remplacer les déclarations d'écran
Dans main/ui/ui.h, supprimer les lignes :
lv_obj_t *lisael_screen_soundbox(void);
et
lv_obj_t *lisael_screen_podcasts(void); // "Histoires" — Radio France podcasts
Ajouter à la place (près des autres déclarations d'écran) :
lv_obj_t *lisael_screen_histoires(void); // "Histoires" — vignettes podcasts
Et dans l'enum, supprimer la ligne LISAEL_MODE_SOUNDBOX, (les autres modes décalent, sans impact car ils sont utilisés symboliquement).
- Step 2 :
ui.c— router HISTOIRES vers le nouvel écran, retirer SOUNDBOX
Dans main/ui/ui.c, fonction build_screen :
-
Supprimer la ligne
case LISAEL_MODE_SOUNDBOX: return lisael_screen_soundbox(); -
Remplacer
case LISAEL_MODE_HISTOIRES:return lisael_screen_podcasts();parcase LISAEL_MODE_HISTOIRES:return lisael_screen_histoires(); -
Step 3 :
home.c— fusionner les tuiles (6 → 5)
Dans main/modes/home.c, le tableau tiles[] : supprimer la ligne
{ &lisael_ic_sound, "Sons", LISAEL_MODE_SOUNDBOX, {0} },
Le tableau passe à 5 entrées (Histoires, Radio, Calme, Horloge, Jeux). Le tableau colors[] a 6 couleurs — il en reste assez (la boucle n'en lit que 5). La grille flex se réagence seule.
- Step 4 :
CMakeLists.txt— sources
Dans main/CMakeLists.txt : retirer "modes/podcasts.c" et "modes/soundbox.c" de SRCS, ajouter "modes/histoires.c" (si pas déjà fait). podcast_manifest.c doit y être (Task 2).
- Step 5 : Supprimer les anciens écrans
cd /tmp/lisael-box && git rm main/modes/podcasts.c main/modes/soundbox.c
Note :
main/net/podcast.c(téléchargement RSS on-device) n'est PLUS référencé par l'UI. Le laisser tel quel (inoffensif) ou le retirer aussi des SRCS si présent — vérifiergrep -n podcast main/CMakeLists.txt. Ne PAS supprimeraudio/podcast_manifest.c.
- Step 6 : Build complet (la vraie vérification de l'écran)
cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 && idf.py build 2>&1 | tail -8
Expected : Project build complete. (résout aussi les includes LVGL de histoires.c).
- Step 7 : Commit (local)
git add -A
git commit -m "feat(histoires): route Histoires to vignette screen, drop old menus"
Task 5 : Outil Mac — pochettes .bin + manifeste v2
Files:
- Create:
/tmp/lisael_episodes/gen_covers.py
Réutilise les épisodes déjà téléchargés dans /tmp/lisael_episodes (oli_01.m4a, odyssees_*, salutinfo_*, bestioles_*). Récupère l'itunes:image de chaque flux, redimensionne en 160×160 PNG, convertit en .bin LVGL, et écrit manifest.json v2 groupé par show.
- Step 1 : Écrire
gen_covers.py
#!/usr/bin/env python3
# Build v2 manifest (grouped by show) + LVGL .bin covers for the Lisael Box.
import json, os, re, subprocess, sys, urllib.request, xml.etree.ElementTree as ET
from PIL import Image
import io
OUT = "/tmp/lisael_episodes"
LVGLIMG = "/tmp/lisael-box/managed_components/lvgl__lvgl/scripts/LVGLImage.py"
UA = {"User-Agent": "Mozilla/5.0"}
NS = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"}
# key -> (show title, RSS url)
FEEDS = {
"oli": ("Une histoire et Oli", "https://radiofrance-podcast.net/podcast09/rss_19721.xml"),
"odyssees": ("Les Odyssees", "https://radiofrance-podcast.net/podcast09/rss_20108.xml"),
"salutinfo": ("Salut l'info", "https://radiofrance-podcast.net/podcast09/rss_20689.xml"),
"bestioles": ("Bestioles",
"https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/"
"podcast_a80ecbd5-df3d-4c9d-bee7-4e3d9efc1974.xml"),
}
def fetch(url, t=60):
return urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=t).read()
def cover_url(root):
el = root.find(".//channel/itunes:image", NS)
if el is not None and el.get("href"):
return el.get("href")
el = root.find(".//channel/image/url")
return el.text if el is not None else None
manifest = []
for key, (show, rss) in FEEDS.items():
# episodes already downloaded for this show, from the existing flat manifest if present
eps = []
flat = os.path.join(OUT, "manifest.json.v1")
# derive episodes from files on disk + titles from a saved v1 manifest if available
titles = {}
if os.path.exists(flat):
for e in json.load(open(flat, encoding="utf-8")):
titles[e["file"]] = e["title"]
for f in sorted(os.listdir(OUT)):
if re.match(rf"^{key}_\d+\.(m4a|mp3)$", f):
eps.append({"file": f, "title": titles.get(f, f)})
if not eps:
print(f"[{key}] no episodes on disk, skip"); continue
cover_name = ""
try:
root = ET.fromstring(fetch(rss))
cu = cover_url(root)
if cu:
img = Image.open(io.BytesIO(fetch(cu))).convert("RGB").resize((160, 160))
png = os.path.join(OUT, f"{key}.png")
img.save(png)
subprocess.run([sys.executable, LVGLIMG, "--ofmt", "BIN", "--cf", "RGB565",
"-o", OUT, png], check=True)
cover_name = f"{key}.bin"
print(f"[{key}] cover -> {cover_name}")
except Exception as ex:
print(f"[{key}] cover failed: {ex}")
manifest.append({"show": show, "cover": cover_name, "episodes": eps})
json.dump(manifest, open(os.path.join(OUT, "manifest.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
print(f"manifest.json: {len(manifest)} shows")
Préparer les titres : si l'ancien manifeste plat existe, le sauver d'abord sous
manifest.json.v1(sinon les titres retombent sur le nom de fichier) :[ -f /tmp/lisael_episodes/manifest.json ] && cp /tmp/lisael_episodes/manifest.json /tmp/lisael_episodes/manifest.json.v1
- Step 2 : Lancer l'outil
cd /tmp/lisael_episodes
[ -f manifest.json ] && cp manifest.json manifest.json.v1
/Users/electron/.espressif/python_env/idf5.4_py3.14_env/bin/python gen_covers.py
echo "=== .bin produits ==="; ls -la *.bin
echo "=== manifest v2 ==="; cat manifest.json
Expected : un .bin par show (oli.bin, odyssees.bin, salutinfo.bin, bestioles.bin) + manifest.json v2 listant 4 shows avec leurs épisodes et cover.
- Step 3 : Commit (local — l'outil seulement ; pas de podcasts ni binaires)
cd /tmp/lisael-box
mkdir -p tools && cp /tmp/lisael_episodes/gen_covers.py tools/gen_covers.py
git add tools/gen_covers.py
git commit -m "tools: generate v2 manifest + LVGL cover images from RF feeds"
Task 6 : Charger la SD (USB-MSC) + flasher + recette sur la vraie carte
Files: (aucun — procédure)
- Step 1 : Passer la box en mode disque USB
Flasher le firmware MSC (/tmp/lisael-msc, déjà construit) après BOOT+RESET :
# (demander à l'utilisateur de faire BOOT+RESET, attendre le port)
cd /tmp/lisael-msc && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1
python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 -b 460800 \
--before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m \
0x0 build/bootloader/bootloader.bin 0x8000 build/partition_table/partition-table.bin 0x10000 build/lisael_msc.bin
Attendre que /Volumes/POT_04 monte (diskutil list external physical).
- Step 2 : Copier pochettes + manifeste v2 sur la SD
cp -v /tmp/lisael_episodes/*.bin /tmp/lisael_episodes/manifest.json /Volumes/POT_04/PODCASTS/
sync
rm -f /Volumes/POT_04/PODCASTS/._* /Volumes/POT_04/PODCASTS/.DS_Store
ls -la /Volumes/POT_04/PODCASTS/
diskutil eject /Volumes/POT_04
Expected : oli.bin…bestioles.bin + manifest.json (v2) présents ; éjection propre.
- Step 3 : Reflasher le firmware normal
Après BOOT+RESET (la box est en MSC, pas de port sinon) :
cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1
python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 -b 460800 \
--before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m \
0x0 build/bootloader/bootloader.bin 0x8000 build/partition_table/partition-table.bin 0x10000 build/lisael_box.bin
python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 --after hard_reset run
-
Step 4 : Recette sur la carte (demander à l'utilisateur de vérifier)
- Accueil : 5 tuiles, « Histoires » ouvre la grille.
- Grille 2×2 : les vraies pochettes des 4 podcasts (pagination/points si > 4).
- Taper une pochette → liste des épisodes du bon show.
- Taper un épisode → lecture plein écran avec pochette ; son OK en .m4a ET .mp3 ; ⏮/⏭ changent d'épisode ; ▶/⏹ ; retour à chaque niveau.
- (Optionnel) carte sans pochette → tuile de repli colorée, pas de crash.
-
Step 5 : Mettre à jour la mémoire projet
Ajouter au fichier project_lisael_box_hw_bringup_2026_06_14.md une note « Session 5 : écran Histoires à vignettes livré » (composants, format manifeste v2, driver FS LVGL, statut recette).
Auto-revue du plan
1. Couverture de la spec :
- Navigation 3 niveaux → Task 3 (grille/liste/lecteur). ✅
- Grille 2×2 paginée + carrousel/points → Task 3 (
lv_tileview+ dots objets). ✅ - Pochettes
.binSD via driver FS → Task 2 (config) + Task 3 (make_cover/S:). ✅ - Manifeste v2 par show → Task 1 (parseur) + Task 5 (générateur). ✅
- Fusion tuiles + remplacement podcasts.c/soundbox.c → Task 4. ✅
- Lecture m4a/mp3 → Task 3 (
play_current). ✅ - Contenu initial via MSC → Task 6. ✅
- Gestion d'erreurs (pas de SD/manifeste, pochette manquante) → Task 3 (placeholder + fallback). ✅
- Écart assumé vs spec : la barre de progression du lecteur est différée (les lecteurs audio n'exposent pas la position de lecture ; l'ajouter exigerait une API de position — propre follow-up). Le lecteur affiche pochette + titre + transport sans barre live.
2. Placeholders : aucun TODO/TBD ; tout le code est fourni.
3. Cohérence des types : lisael_show_t/lisael_episode_t (Task 1) utilisés tels quels en Task 3 ; lisael_screen_histoires() déclarée (Task 4 ui.h) / définie (Task 3) / routée (Task 4 ui.c) ; make_cover(parent, idx, px) cohérent ; pochettes stockées 160px, mises à l'échelle via px*256/160.