chore(box): commit firmware base + feature docs

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.
This commit is contained in:
Clément Saillant
2026-06-15 00:20:47 +02:00
parent e2f0010688
commit e35058f53d
25 changed files with 2695 additions and 318 deletions
+12 -2
View File
@@ -14,8 +14,18 @@ cmake_minimum_required(VERSION 3.16)
if(DEFINED ENV{ADF_PATH})
set(ADF_COMPONENTS "$ENV{ADF_PATH}/components")
if(EXISTS "${ADF_COMPONENTS}")
list(APPEND EXTRA_COMPONENT_DIRS "${ADF_COMPONENTS}")
message(STATUS "Lisael Box: ESP-ADF components from ${ADF_COMPONENTS}")
# On n'ajoute QUE les composants audio nécessaires (pas tout l'arbre ADF) :
# `audio_board` épingle esp_lcd_ili9341 ^1 et entre en conflit avec le BSP
# esp-box-3 (qui veut ^2.0.1). On exclut donc audio_board / display_service
# et on laisse le BSP gérer écran/tactile/SD/codec.
set(ADF_AUDIO_COMPONENTS
esp-adf-libs audio_pipeline audio_stream audio_sal esp_dispatcher)
foreach(_adf_c IN LISTS ADF_AUDIO_COMPONENTS)
if(EXISTS "${ADF_COMPONENTS}/${_adf_c}")
list(APPEND EXTRA_COMPONENT_DIRS "${ADF_COMPONENTS}/${_adf_c}")
endif()
endforeach()
message(STATUS "Lisael Box: ESP-ADF audio components from ${ADF_COMPONENTS}")
else()
message(WARNING "Lisael Box: ADF_PATH set but ${ADF_COMPONENTS} not found")
endif()
@@ -0,0 +1,867 @@
# É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 build` ré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` :
```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` :
```c
// 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 :
```bash
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` :
```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) :
```bash
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)**
```bash
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 de `podcast_manifest.c` aux SRCS)
- [ ] **Step 1 : Ajouter `podcast_manifest.c` aux 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 :
```bash
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)**
```bash
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`**
```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.c` ne 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 :
```bash
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)**
```bash
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 :
```c
lv_obj_t *lisael_screen_soundbox(void);
```
et
```c
lv_obj_t *lisael_screen_podcasts(void); // "Histoires" — Radio France podcasts
```
**Ajouter** à la place (près des autres déclarations d'écran) :
```c
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();`
par `case 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
```c
{ &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**
```bash
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érifier `grep -n podcast main/CMakeLists.txt`. Ne PAS supprimer `audio/podcast_manifest.c`.
- [ ] **Step 6 : Build complet (la vraie vérification de l'écran)**
```bash
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)**
```bash
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`**
```python
#!/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) :
> ```bash
> [ -f /tmp/lisael_episodes/manifest.json ] && cp /tmp/lisael_episodes/manifest.json /tmp/lisael_episodes/manifest.json.v1
> ```
- [ ] **Step 2 : Lancer l'outil**
```bash
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)**
```bash
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 :
```bash
# (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**
```bash
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) :
```bash
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)**
1. Accueil : 5 tuiles, « Histoires » ouvre la grille.
2. Grille 2×2 : les **vraies pochettes** des 4 podcasts (pagination/points si > 4).
3. Taper une pochette → liste des épisodes du bon show.
4. Taper un épisode → lecture plein écran avec pochette ; **son OK en .m4a ET .mp3** ; ⏮/⏭ changent d'épisode ; ▶/⏹ ; retour à chaque niveau.
5. (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 `.bin` SD 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`.
@@ -0,0 +1,133 @@
# Lisael Box — Écran « Histoires » à vignettes (design)
**Date:** 2026-06-14
**Statut:** validé (brainstorming), prêt pour le plan d'implémentation.
**Projet:** firmware Lisael Box (ESP32-S3-BOX-3), ESP-IDF v5.4, `/tmp/lisael-box`,
repo Gitea `electron-rare/lisael-box`.
## But
Remplacer les menus « Histoires » (download on-device) et « Sons » (liste) par
**un seul écran « Histoires » à vignettes**, où Lisael (6 ans) choisit une histoire
**par sa pochette** (image), façon Fabrique à histoires *Merlin*. Le contenu (audio
+ pochettes + manifeste) vit sur la carte SD ; la box lit et affiche.
## Périmètre
- **DANS** : l'écran à vignettes (3 niveaux), le chargement des pochettes depuis la
SD, le manifeste enrichi v2, la fusion des tuiles d'accueil, le chargement initial
du contenu sur la SD côté Mac (via le flux USB-MSC déjà rodé).
- **HORS** (specs séparées) :
- **Spec A** — webapp de gestion de contenu. **Direction figée** : *Tower héberge
l'app + télécharge les épisodes + convertit les pochettes, puis pousse les fichiers
à la box en HTTP simple* (ou la box tire depuis Tower sur le tailnet). La box ne
fait **jamais** de HTTPS lourd (évite le reset de download connu). B produit
exactement le format SD que A alimentera ensuite.
- Carrousel « grande pochette », bruitages/sons courts, écran réglages.
## Navigation (3 niveaux)
1. **Accueil** → la tuile **« Histoires »** ouvre la grille. Les anciennes tuiles
« Histoires » + « Sons » fusionnent en cette unique tuile → **5 tuiles** à l'accueil
(6ᵉ emplacement laissé libre, futur « Réglages/Web »).
2. **Niveau 1 — grille 2×2 paginée** : 4 pochettes par page (grosses cibles tactiles),
**balayage horizontal + points indicateurs** (`●○○`) entre pages dès qu'il y a plus
de 4 podcasts. Taper une pochette → niveau 2.
3. **Niveau 2 — épisodes** : liste verticale des épisodes du podcast choisi (petite
vignette du show + titre). Bouton retour vers la grille. Taper un épisode → niveau 3.
4. **Niveau 3 — lecture plein écran** : grande pochette + titre + transport
`⏮ ⏸/▶ ⏭` + barre de progression + bouton retour. Le « Stop » global reste
disponible (`lisael_audio_stop_all`).
## Pochettes — pipeline image (approche retenue)
**Approche 1 : pochettes pré-converties au format binaire LVGL (RGB565) sur la SD,
lues via un driver de système de fichiers LVGL.** Choisie pour la RAM déterministe
(pas de décodeur JPEG à l'exécution — la RAM interne est tendue) et parce qu'elle
correspond au « vrai Merlin » (pochettes sur la carte) et prépare la spec A.
- **Format** : `.bin` LVGL (`LVGLImage.py --ofmt BIN`, `cf RGB565` ou `RGB565A8`),
produit côté Mac (pipeline déjà maîtrisé pour les icônes Twemoji). **Une seule taille
stockée par pochette : 160×160** ; LVGL la **met à l'échelle** à l'affichage (≈88 px
en grille, ≈150 px en lecture, ≈48 px en liste) via `lv_image_set_scale`.
- **Driver FS LVGL** : activer `CONFIG_LV_USE_FS_STDIO=y` avec la lettre `'S'`
(mappe `S:``fopen`/`fread`, donc la SD FATFS). Chargement :
`lv_image_set_src(img, "S:/sdcard/podcasts/<cover>.bin")`. Le décodeur `.bin` LVGL
est toujours présent (aucun décodeur JPEG/PNG requis).
- **Cache image** : les ~4 pochettes décodées tiennent en **PSRAM** (160×160×2 ≈ 51 Ko
chacune, ~205 Ko pour 4). Acceptable.
- **Source** : artwork des flux Radio France (`<itunes:image>`), redimensionné 160×160
côté Mac. Repli si pochette absente : une vignette générée (emoji + couleur du show).
## Données — manifeste enrichi v2 (par show)
`/sdcard/podcasts/manifest.json` passe d'une liste plate d'épisodes à **une liste de
shows**, chacun portant sa pochette et ses épisodes :
```json
[
{
"show": "Une histoire et Oli",
"cover": "oli.bin",
"episodes": [
{ "file": "oli_01.m4a", "title": "OLI en concert, p.1" },
{ "file": "oli_02.m4a", "title": "OLI en concert, p.2" }
]
},
{
"show": "Bestioles",
"cover": "bestioles.bin",
"episodes": [ { "file": "bestioles_01.mp3", "title": "La gerboise…" } ]
}
]
```
- `cover` = nom de fichier `.bin` sous `/sdcard/podcasts/`. `file` = nom audio ASCII
sous `/sdcard/podcasts/`. `show`/`title` = UTF-8 (accents ; rendus par Fredoka).
- Les noms de **fichiers** restent **ASCII** (FAT) ; le « joli » texte vit dans le JSON.
## Composants firmware
- **`main/modes/histoires.c`** (neuf) — l'écran à 3 niveaux (grille paginée + liste +
player). **Remplace** `main/modes/podcasts.c` *et* `main/modes/soundbox.c` (supprimés).
- **Chargeur manifeste v2** — dans `main/audio/sd_player.c` (ou un petit
`main/net/podcast.c` réutilisé) : parse le tableau de shows en structures
`lisael_show_t { char show[48]; char cover[32]; lisael_ep_t eps[N]; int n; }`.
Remplace/complète `lisael_sd_load_manifest_dir` (v1 plate, devenue inutile).
- **Driver FS LVGL** — `CONFIG_LV_USE_FS_STDIO=y` (lettre `S`), pour `lv_image` depuis SD.
- **Lecture** — inchangée : dispatch par extension `.m4a → lisael_aac_play_file`,
sinon `lisael_sd_play_file` ; `lisael_audio_stop_all` au changement/Stop. (déjà en place)
- **Accueil** — `main/modes/home.c` : tableau de tuiles 6 → 5 (retirer « Sons », garder
« Histoires » → `LISAEL_MODE_HISTOIRES`). `main/ui/ui.h` : retirer `LISAEL_MODE_SOUNDBOX`,
garder/retituler `lisael_screen_histoires`. `main/ui/ui.c` : router `LISAEL_MODE_HISTOIRES`
`lisael_screen_histoires`. Mettre à jour `main/CMakeLists.txt` (retrait podcasts.c/
soundbox.c, ajout histoires.c).
## Contenu initial (pour B, maintenant)
Stand-in manuel de ce que Tower fera plus tard (spec A) :
1. Côté Mac : pour chaque show, récupérer l'`<itunes:image>` du flux RF →
redimensionner 160×160 → convertir en `.bin` LVGL (`LVGLImage.py`).
2. Écrire le `manifest.json` v2 (shows + pochettes + épisodes déjà téléchargés).
3. Déposer pochettes `.bin` + manifeste sur `/sdcard/podcasts/` via le **firmware
USB-MSC** (`/tmp/lisael-msc`) déjà construit, puis éjecter proprement.
4. Reflasher le firmware normal (BOOT+RESET).
## Gestion d'erreurs
- **Pas de SD / pas de manifeste** → écran placeholder amical (« Branche la box en USB
pour ajouter des histoires »), pas d'erreur.
- **Pochette manquante / `.bin` illisible** → vignette de repli (emoji + couleur du show).
- **Épisode introuvable à la lecture** → toast/log, on reste sur la liste.
- **0 épisode pour un show** → le show n'apparaît pas dans la grille.
## Tests (acceptation)
Build + flash, puis sur la vraie carte :
1. Accueil affiche 5 tuiles, « Histoires » ouvre la grille.
2. Grille 2×2 affiche les **vraies pochettes** des 4 podcasts ; pagination/points si > 4.
3. Taper une pochette → liste des épisodes du bon show.
4. Taper un épisode → lecture plein écran avec pochette ; **son OK en `.m4a` ET `.mp3`**.
5. Retour à chaque niveau + Stop fonctionnels.
6. Carte sans pochette → repli affiché, pas de crash.
```
+26
View File
@@ -0,0 +1,26 @@
// Streaming AAC/M4A file player for Lisael Box (Radio France podcasts are AAC).
//
// Decodes an .m4a file from the microSD card chunk-by-chunk via
// esp_audio_codec's simple decoder and writes PCM to the BSP ES8311 codec, on a
// background task. One playback at a time; starting stops any other audio.
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// Start playing an .m4a file (absolute SD path). Stops any current audio, then
// streams+decodes on a background task. Returns ESP_OK once the task is started.
esp_err_t lisael_aac_play_file(const char *abs_path);
// Stop playback (blocks briefly until the decode task has stopped).
void lisael_aac_stop(void);
bool lisael_aac_is_playing(void);
#ifdef __cplusplus
}
#endif
+87
View File
@@ -3,17 +3,23 @@
#include "audio/audio_player.h"
#include "audio/radio_pipeline.h"
#include "audio/sd_player.h"
#include "audio/aac_player.h"
#include "lisael_config.h"
#include "esp_log.h"
#include "nvs.h"
#include "bsp/esp-box-3.h"
#include "esp_codec_dev.h"
#include "driver/i2s_std.h"
#include <audio_player.h> // chmorgan/esp-audio-player (angle brackets target
// its include dir, NOT our audio/audio_player.h)
static const char *TAG = "audio";
static esp_codec_dev_handle_t s_codec;
static lisael_audio_source_t s_source = LISAEL_AUDIO_IDLE;
static int s_volume = LISAEL_VOLUME_DEFAULT;
static bool s_player_ready;
static int clamp_volume(int v)
{
@@ -44,6 +50,84 @@ static void persist_volume(void)
}
}
// --- esp-audio-player <-> BSP codec glue ------------------------------------
// The player decodes MP3 to PCM and pushes it through these callbacks, which we
// back with the BSP's ES8311 codec device (esp_codec_dev). We do NOT touch I2S
// directly — the BSP owns it; we only (re)open the codec at the stream's rate.
static esp_err_t ap_mute(AUDIO_PLAYER_MUTE_SETTING setting)
{
if (s_codec) {
esp_codec_dev_set_out_mute(s_codec, setting == AUDIO_PLAYER_MUTE);
}
return ESP_OK;
}
static esp_err_t ap_clk_set(uint32_t rate, uint32_t bits, i2s_slot_mode_t ch)
{
if (!s_codec) {
return ESP_FAIL;
}
esp_codec_dev_close(s_codec); // harmless if not open
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = (uint8_t)bits,
.channel = (ch == I2S_SLOT_MODE_STEREO) ? 2 : 1,
.sample_rate = rate,
};
esp_err_t err = esp_codec_dev_open(s_codec, &fs);
esp_codec_dev_set_out_vol(s_codec, (float)s_volume); // re-apply volume
return err;
}
static esp_err_t ap_write(void *buf, size_t len, size_t *written, uint32_t timeout_ms)
{
(void)timeout_ms;
int r = esp_codec_dev_write(s_codec, buf, (int)len);
*written = (r == ESP_CODEC_DEV_OK) ? len : 0;
return (r == ESP_CODEC_DEV_OK) ? ESP_OK : ESP_FAIL;
}
static void ap_event_cb(audio_player_cb_ctx_t *ctx)
{
if (ctx->audio_event == AUDIO_PLAYER_CALLBACK_EVENT_IDLE) {
// Playback finished (or was stopped): close the codec and mark idle.
if (s_codec) {
esp_codec_dev_close(s_codec);
}
if (s_source == LISAEL_AUDIO_SD) {
s_source = LISAEL_AUDIO_IDLE;
}
}
}
esp_err_t lisael_audio_player_ensure(void)
{
if (s_player_ready) {
return ESP_OK;
}
if (!s_codec) {
ESP_LOGE(TAG, "codec not ready — call lisael_audio_init first");
return ESP_FAIL;
}
audio_player_config_t cfg = {
.mute_fn = ap_mute,
.clk_set_fn = ap_clk_set,
.write_fn = ap_write,
.priority = 5,
.coreID = 1,
.force_stereo = false,
};
esp_err_t err = audio_player_new(cfg);
if (err != ESP_OK) {
ESP_LOGE(TAG, "audio_player_new failed: %s", esp_err_to_name(err));
return err;
}
audio_player_callback_register(ap_event_cb, NULL);
s_player_ready = true;
ESP_LOGI(TAG, "esp-audio-player ready");
return ESP_OK;
}
esp_err_t lisael_audio_init(void)
{
// The BSP returns a ready-to-use ES8311 speaker codec device.
@@ -54,6 +138,8 @@ esp_err_t lisael_audio_init(void)
return ESP_FAIL;
}
load_volume();
s_volume = LISAEL_VOLUME_MAX; // user request: start at maximum volume
persist_volume();
esp_codec_dev_set_out_vol(s_codec, (float)s_volume);
ESP_LOGI(TAG, "codec ready, volume=%d%% (cap %d%%)", s_volume, LISAEL_VOLUME_MAX);
return ESP_OK;
@@ -69,6 +155,7 @@ void lisael_audio_stop_all(void)
switch (s_source) {
case LISAEL_AUDIO_RADIO: lisael_radio_stop(); break;
case LISAEL_AUDIO_SD: lisael_sd_stop(); break;
case LISAEL_AUDIO_PODCAST: lisael_aac_stop(); break;
default: break;
}
s_source = LISAEL_AUDIO_IDLE;
+7 -1
View File
@@ -20,13 +20,19 @@ typedef enum {
LISAEL_AUDIO_IDLE = 0,
LISAEL_AUDIO_RADIO,
LISAEL_AUDIO_SD,
LISAEL_AUDIO_PODCAST, // streaming AAC/M4A file (aac_player.c)
} lisael_audio_source_t;
// Initialise the BSP audio codec (ES8311 speaker). Call once after bsp_i2c_init.
// Loads the persisted volume from NVS (default LISAEL_VOLUME_DEFAULT).
esp_err_t lisael_audio_init(void);
// The shared codec handle, for the ESP-ADF i2s_stream / direct writes.
// Lazily create the shared esp-audio-player instance (MP3 -> PCM -> ES8311
// codec via callbacks). Safe to call repeatedly; only the first call inits.
// Returns ESP_FAIL if the codec isn't ready (lisael_audio_init not done).
esp_err_t lisael_audio_player_ensure(void);
// The shared codec handle, for direct esp_codec_dev writes.
// Returns NULL before lisael_audio_init().
esp_codec_dev_handle_t lisael_audio_codec(void);
+145
View File
@@ -0,0 +1,145 @@
// Calm-corner tone — see calm_tone.h.
//
// Real-time synthesis: a 432 Hz sine with two soft harmonics (864, 1296 Hz),
// amplitude-shaped by a breathing envelope that matches the bubble (4 s rise
// "Inspire", 6 s fall "Souffle"). Written to the BSP codec on a task.
#include "audio/calm_tone.h"
#include "audio/audio_player.h"
#include <math.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/idf_additions.h"
#include "esp_heap_caps.h"
#include "esp_codec_dev.h"
#include "esp_log.h"
static const char *TAG = "calm_tone";
#define SR 32000 // sample rate
#define INHALE_S 4.0f
#define EXHALE_S 6.0f
#define CYCLE_S (INHALE_S + EXHALE_S)
static volatile bool s_stop;
static volatile bool s_running;
static inline float smoothstep(float x)
{
if (x < 0) x = 0;
if (x > 1) x = 1;
return x * x * (3.0f - 2.0f * x);
}
// Breathing amplitude envelope in [0.32 .. 1.0] over the 10 s cycle.
static float breath_env(float t)
{
const float lo = 0.32f, hi = 1.0f;
if (t < INHALE_S) {
return lo + (hi - lo) * smoothstep(t / INHALE_S); // inhale: rise
}
return hi - (hi - lo) * smoothstep((t - INHALE_S) / EXHALE_S); // exhale: fall
}
static void tone_task(void *arg)
{
(void)arg;
s_running = true;
esp_codec_dev_handle_t codec = lisael_audio_codec();
if (!codec) {
s_running = false;
vTaskDeleteWithCaps(NULL);
return;
}
esp_codec_dev_close(codec);
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16,
.channel = 1,
.sample_rate = SR,
};
esp_codec_dev_open(codec, &fs);
esp_codec_dev_set_out_vol(codec, (float)lisael_audio_get_volume());
const int N = 512;
int16_t *buf = malloc(N * sizeof(int16_t));
const float TAU = 6.2831853f;
float ph1 = 0, ph2 = 0, ph3 = 0; // fundamental + 2 harmonic phases
float t = 0; // position in the breathing cycle (s)
float gt = 0; // global time (s) for slow LFOs
while (!s_stop && buf) {
// Slow evolving parameters — computed once per buffer (~16 ms) so the
// drone never sits on one steady pitch/timbre (less fatiguing). A gentle
// pitch drift around 432 Hz, a soft tremolo, and harmonics that swell.
float drift = 1.0f + 0.013f * sinf(TAU * 0.05f * gt); // +/-1.3% pitch wander
float trem = 0.86f + 0.14f * sinf(TAU * 0.17f * gt); // gentle tremolo
float a432 = 0.30f + 0.06f * sinf(TAU * 0.030f * gt + 1.0f); // 432 Hz swell
float h3amp = 0.07f + 0.05f * sinf(TAU * 0.021f * gt + 2.2f); // 576 Hz shimmer
// Centred on 144 Hz (deep, twice as loud) + 432 Hz (= 3x144), with a
// soft 576 Hz shimmer. ph1=144, ph2=432, ph3=576.
float f1 = 144.0f * drift;
float d1 = TAU * f1 / SR, d2 = TAU * 3.0f * f1 / SR, d3 = TAU * 4.0f * f1 / SR;
for (int i = 0; i < N; i++) {
float env = breath_env(t) * trem;
float s = sinf(ph1) * 0.66f + sinf(ph2) * a432 + sinf(ph3) * h3amp;
int v = (int)(s * env * 6600.0f);
if (v > 32767) v = 32767;
if (v < -32768) v = -32768;
buf[i] = (int16_t)v;
ph1 += d1; if (ph1 > TAU) ph1 -= TAU;
ph2 += d2; if (ph2 > TAU) ph2 -= TAU;
ph3 += d3; if (ph3 > TAU) ph3 -= TAU;
t += 1.0f / SR; if (t > CYCLE_S) t -= CYCLE_S;
gt += 1.0f / SR; if (gt > 600.0f) gt -= 600.0f;
}
int w = esp_codec_dev_write(codec, buf, N * sizeof(int16_t));
if (w != ESP_CODEC_DEV_OK) {
vTaskDelay(pdMS_TO_TICKS(5)); // never busy-spin if the codec balks
}
}
free(buf);
esp_codec_dev_close(codec);
ESP_LOGI(TAG, "stopped");
s_running = false;
vTaskDeleteWithCaps(NULL);
}
esp_err_t lisael_calm_tone_start(void)
{
if (s_running) {
return ESP_OK;
}
lisael_audio_stop_all(); // exclusive with radio/SD/podcast
s_stop = false;
// Priority 3 — BELOW the LVGL task (4) so the breathing animation never
// starves: the tone must never preempt the UI. The blocking codec write
// paces this task and yields to IDLE, so the watchdog stays happy.
if (xTaskCreatePinnedToCoreWithCaps(tone_task, "calm_tone", 4096, NULL, 3, NULL,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM) != pdPASS) {
return ESP_FAIL;
}
ESP_LOGI(TAG, "432 Hz breathing tone started");
return ESP_OK;
}
void lisael_calm_tone_stop(void)
{
if (!s_running) {
return;
}
s_stop = true;
for (int i = 0; i < 100 && s_running; i++) {
vTaskDelay(pdMS_TO_TICKS(20));
}
}
bool lisael_calm_tone_is_playing(void)
{
return s_running;
}
+19
View File
@@ -0,0 +1,19 @@
// Calm-corner tone generator: a soft 432 Hz sine plus gentle harmonics with a
// slow "breathing" amplitude envelope, synthesised in real time and played
// through the BSP codec. Used by the Coin calme screen.
#pragma once
#include "esp_err.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t lisael_calm_tone_start(void);
void lisael_calm_tone_stop(void);
bool lisael_calm_tone_is_playing(void);
#ifdef __cplusplus
}
#endif
+319 -120
View File
@@ -1,165 +1,364 @@
// Webradio streaming pipeline (ESP-ADF) for Lisael Box.
// Webradio live-streaming pipeline for Lisael Box — NO ESP-ADF.
//
// Pipeline: http_stream -> esp_decoder (auto MP3/AAC) -> i2s_stream
// Plays a live HTTP(S) MP3 webradio (Shoutcast/Icecast) straight onto the
// shared ES8311 codec, reusing the exact streaming-decode pattern of
// aac_player.c (esp_audio_simple_dec -> esp_codec_dev_write) but fed from the
// network instead of a FILE.
//
// IMPORTANT (verify on hardware):
// The esp-box BSP also configures I2S/ES8311 (bsp_audio_init /
// bsp_audio_codec_speaker_init). ESP-ADF's i2s_stream historically owns the
// I2S peripheral itself. On a real BOX-3 you must make these agree on ONE
// owner of the I2S port. Two known-good approaches:
// (A) Let ADF own I2S: configure i2s_stream with the BOX-3 pins/port and
// do NOT call bsp_audio_init(); keep using the BSP codec handle only
// for volume.
// (B) Let the BSP own I2S (bsp_audio_init) and write decoded PCM to the
// codec via esp_codec_dev_write() from a raw_stream sink instead of
// i2s_stream.
// This file implements (A) — ADF i2s_stream as the output — which is the
// most common ADF idiom. The i2s_std config (pins, port) MUST be matched to
// the BOX-3 BSP values; left as TODOs below because they depend on the exact
// BSP version and are not safe to guess. Marked clearly.
// Data flow (two FreeRTOS tasks + one StreamBuffer, no busy-spin anywhere):
//
// Because ADF is only present when ADF_PATH is set, the whole body is guarded
// by __has_include so a no-ADF inspection build still compiles (the functions
// then log "audio disabled").
// [HTTP task] esp_http_client GET url
// -> HTTP_EVENT_ON_DATA callback
// -> xStreamBufferSend(net_buf) (blocks if buffer full)
//
// [decode task] xStreamBufferReceive(net_buf) (blocks if buffer empty)
// -> esp_audio_simple_dec_process (MP3 -> PCM)
// -> esp_codec_dev_write() (blocks / paces playback)
//
// The codec is the rate limiter: esp_codec_dev_write() blocks until the I2S DMA
// drains, which naturally throttles the whole chain. If the network is slower
// than playback, the StreamBuffer empties and the decode task simply blocks on
// receive (no spinning). If the network is faster, the HTTP callback blocks on
// send until the decoder has consumed room (back-pressure). A single volatile
// s_stop flag unwinds both tasks cleanly; lisael_radio_stop() waits (bounded)
// for them to exit and restores the IDLE source.
#include "audio/radio_pipeline.h"
#include "audio/audio_player.h"
#include "lisael_config.h"
#include <string.h>
#include <stdlib.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/idf_additions.h"
#include "freertos/stream_buffer.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_codec_dev.h"
#include "esp_audio_simple_dec.h"
#include "esp_audio_simple_dec_default.h"
#include "esp_audio_dec_default.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h" // HTTPS CA bundle (RadioKing streams are https)
#include "net/udp_log.h" // temporary network debug
static const char *TAG = "radio";
#if defined(__has_include)
# if __has_include("audio_pipeline.h")
# define LISAEL_HAVE_ADF 1
# endif
#endif
// --- Tuning ------------------------------------------------------------------
#define NET_STREAM_BUF_BYTES (32 * 1024) // ~32 KB of buffered compressed MP3
#define NET_CHUNK_TIMEOUT_MS 2000 // max wait pushing a network chunk
#define DEC_READ_TIMEOUT_MS 2000 // max wait pulling MP3 from buffer
#define DEC_READ_CHUNK 4096 // bytes pulled per decode iteration
#define DEC_OUT_CAP_INIT 8192 // initial PCM output buffer
#define HTTP_RX_BUFFER 2048 // esp_http_client receive buffer
#ifdef LISAEL_HAVE_ADF
// --- State (single radio instance; sources are mutually exclusive) ----------
static volatile bool s_stop; // request both tasks to unwind
static volatile bool s_http_running; // HTTP task alive
static volatile bool s_dec_running; // decode task alive
static volatile bool s_playing; // public "is playing" state
#include "audio_pipeline.h"
#include "audio_element.h"
#include "http_stream.h"
#include "i2s_stream.h"
#include "esp_decoder.h"
#include "audio_mem.h"
static StreamBufferHandle_t s_net_buf; // compressed MP3 bytes: HTTP -> decoder
static TaskHandle_t s_http_task;
static TaskHandle_t s_dec_task;
static char s_url[512];
static audio_pipeline_handle_t s_pipe;
static audio_element_handle_t s_http, s_decoder, s_i2s;
static bool s_playing;
static esp_err_t build_pipeline(void)
// Register the MP3 frame decoder + simple-decoder container parsers once.
static void register_decoders_once(void)
{
if (s_pipe) {
return ESP_OK; // already built
static bool done;
if (!done) {
esp_audio_dec_register_default(); // MP3/AAC frame decoders
esp_audio_simple_dec_register_default(); // simple-dec wrappers
done = true;
}
}
audio_pipeline_cfg_t pcfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
s_pipe = audio_pipeline_init(&pcfg);
if (!s_pipe) {
return ESP_ERR_NO_MEM;
// ----------------------------------------------------------------------------
// HTTP task: pull bytes off the network and push them into the StreamBuffer.
// ----------------------------------------------------------------------------
// Called by esp_http_client from inside esp_http_client_perform() for every
// received body chunk. We forward the bytes into the StreamBuffer; the send
// blocks (bounded) when the buffer is full, applying back-pressure to the TCP
// read without ever busy-spinning. Returning ESP_FAIL aborts the transfer,
// which is how a stop request unwinds the (otherwise endless) live stream.
static esp_err_t http_event(esp_http_client_event_t *evt)
{
if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data_len > 0) {
static bool first = false;
if (!first) { first = true; lisael_udp_send("RADIO: first net data, len=%d", evt->data_len); }
const uint8_t *p = (const uint8_t *)evt->data;
size_t remaining = (size_t)evt->data_len;
while (remaining > 0) {
if (s_stop) {
return ESP_FAIL; // abort perform() -> HTTP task returns
}
size_t sent = xStreamBufferSend(s_net_buf, p, remaining,
pdMS_TO_TICKS(NET_CHUNK_TIMEOUT_MS));
p += sent;
remaining -= sent;
// If sent < remaining we timed out with the buffer full (decoder
// stalled / paused) — loop again, re-checking s_stop. No spin: the
// send call itself blocks for up to NET_CHUNK_TIMEOUT_MS.
}
}
// --- HTTP source ---------------------------------------------------------
http_stream_cfg_t http_cfg = HTTP_STREAM_CFG_DEFAULT();
http_cfg.type = AUDIO_STREAM_READER;
s_http = http_stream_init(&http_cfg);
// --- Auto decoder (MP3 / AAC / others) ----------------------------------
// esp_decoder auto-detects the container/codec from the stream — perfect
// for webradios that may serve MP3 or AAC.
audio_decoder_t decoders[] = {
DEFAULT_ESP_MP3_DECODER_CONFIG(),
DEFAULT_ESP_AAC_DECODER_CONFIG(),
};
esp_decoder_cfg_t dec_cfg = DEFAULT_ESP_DECODER_CONFIG();
s_decoder = esp_decoder_init(&dec_cfg, decoders,
sizeof(decoders) / sizeof(decoders[0]));
// --- I2S sink to ES8311 --------------------------------------------------
// TODO(hardware): confirm the I2S port number and that these pins match the
// BOX-3 BSP. With ADF >= 2.7 the simplest path is i2s_stream_init with the
// default config and then i2s_stream_set_clk() once the decoder reports the
// sample rate. The codec itself (gain/mute) stays driven by the BSP
// esp_codec_dev handle (lisael_audio_codec()).
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT();
i2s_cfg.type = AUDIO_STREAM_WRITER;
s_i2s = i2s_stream_init(&i2s_cfg);
audio_pipeline_register(s_pipe, s_http, "http");
audio_pipeline_register(s_pipe, s_decoder, "dec");
audio_pipeline_register(s_pipe, s_i2s, "i2s");
const char *link[] = { "http", "dec", "i2s" };
audio_pipeline_link(s_pipe, link, 3);
ESP_LOGI(TAG, "pipeline built: http -> dec -> i2s");
return ESP_OK;
}
static void http_task(void *arg)
{
(void)arg;
s_http_running = true;
ESP_LOGI(TAG, "http: GET %s", s_url);
esp_http_client_config_t cfg = {
.url = s_url,
.event_handler = http_event,
.timeout_ms = 15000,
.buffer_size = HTTP_RX_BUFFER,
// Follow 3xx automatically: RadioKing /play/<slug> endpoints redirect to
// the live listen.radioking.com mount, and the stream itself is one long
// 200 response we want to keep reading. .disable_auto_redirect = false.
.disable_auto_redirect = false,
.max_redirection_count = 5,
// HTTPS support via the certificate bundle (harmless for plain http://).
.crt_bundle_attach = esp_crt_bundle_attach,
.keep_alive_enable = true,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (client) {
lisael_udp_send("RADIO: http perform start");
esp_err_t err = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
lisael_udp_send("RADIO: http ended err=%s status=%d",
esp_err_to_name(err), status);
if (err != ESP_OK && !s_stop) {
ESP_LOGW(TAG, "http perform ended: %s", esp_err_to_name(err));
}
esp_http_client_cleanup(client);
} else {
lisael_udp_send("RADIO: http client init FAILED");
ESP_LOGE(TAG, "http client init failed");
}
// The network is gone: tell the decoder to wind down too.
s_stop = true;
s_http_running = false;
s_http_task = NULL;
ESP_LOGI(TAG, "http task exit");
vTaskDeleteWithCaps(NULL);
}
// ----------------------------------------------------------------------------
// Decode task: pull MP3 from the StreamBuffer, decode to PCM, write to codec.
// ----------------------------------------------------------------------------
static void decode_task(void *arg)
{
(void)arg;
s_dec_running = true;
register_decoders_once();
esp_audio_simple_dec_cfg_t dcfg = {
.dec_type = ESP_AUDIO_SIMPLE_DEC_TYPE_MP3, // FR kids webradios are MP3
.dec_cfg = NULL,
.cfg_size = 0,
};
esp_audio_simple_dec_handle_t dec = NULL;
if (esp_audio_simple_dec_open(&dcfg, &dec) != ESP_AUDIO_ERR_OK) {
ESP_LOGE(TAG, "decoder open failed");
goto done;
}
esp_codec_dev_handle_t codec = lisael_audio_codec();
uint8_t *inbuf = malloc(DEC_READ_CHUNK);
uint32_t out_cap = DEC_OUT_CAP_INIT;
uint8_t *out = malloc(out_cap);
bool codec_open = false;
if (!inbuf || !out || !codec) {
ESP_LOGE(TAG, "decode buffers/codec unavailable");
free(inbuf);
free(out);
if (dec) esp_audio_simple_dec_close(dec);
goto done;
}
ESP_LOGI(TAG, "decode task started");
while (!s_stop) {
// Block until at least 1 byte is available (or timeout). When the HTTP
// task has exited and drained the buffer this returns 0 -> we leave.
size_t n = xStreamBufferReceive(s_net_buf, inbuf, DEC_READ_CHUNK,
pdMS_TO_TICKS(DEC_READ_TIMEOUT_MS));
if (n == 0) {
// No data within the timeout. If the producer is gone, stop;
// otherwise loop and wait again (the receive blocked, no spin).
if (!s_http_running) {
break;
}
continue;
}
esp_audio_simple_dec_raw_t raw = {
.buffer = inbuf,
.len = (uint32_t)n,
.eos = false,
};
while (raw.len > 0 && !s_stop) {
esp_audio_simple_dec_out_t frame = { .buffer = out, .len = out_cap };
esp_audio_err_t e = esp_audio_simple_dec_process(dec, &raw, &frame);
if (e == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) {
uint8_t *bigger = realloc(out, frame.needed_size);
if (!bigger) { s_stop = true; break; }
out = bigger; out_cap = frame.needed_size;
continue; // retry with a larger PCM buffer
}
if (e != ESP_AUDIO_ERR_OK) {
// A corrupt MP3 frame at a stream boundary is normal; skip this
// input chunk and resync on the next network read.
ESP_LOGD(TAG, "decode err %d (resync)", (int)e);
break;
}
if (frame.decoded_size > 0) {
if (!codec_open) {
// Open the codec at the rate/channels reported by the stream
// on the first decoded PCM, exactly like aac_player.c.
esp_audio_simple_dec_info_t info = {0};
esp_audio_simple_dec_get_info(dec, &info);
ESP_LOGI(TAG, "MP3 %" PRIu32 " Hz %u ch %u bit",
info.sample_rate, info.channel,
info.bits_per_sample);
lisael_udp_send("RADIO: decoded MP3 %" PRIu32 " Hz %u ch %u bit -> opening codec",
info.sample_rate, info.channel, info.bits_per_sample);
esp_codec_dev_close(codec);
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = info.bits_per_sample,
.channel = info.channel,
.sample_rate = info.sample_rate,
};
esp_codec_dev_open(codec, &fs);
esp_codec_dev_set_out_vol(codec,
(float)lisael_audio_get_volume());
codec_open = true;
}
// Blocking write — this is what paces the whole pipeline.
esp_codec_dev_write(codec, frame.buffer, frame.decoded_size);
}
if (raw.consumed == 0 && frame.decoded_size == 0) {
break; // no progress on this chunk; fetch more from the network
}
raw.buffer += raw.consumed;
raw.len -= raw.consumed;
}
}
free(inbuf);
free(out);
if (codec_open) {
esp_codec_dev_close(codec);
}
esp_audio_simple_dec_close(dec);
done:
// Make sure the HTTP task also stops (e.g. when we exited on a decode error).
s_stop = true;
if (lisael_audio_get_source() == LISAEL_AUDIO_RADIO) {
lisael_audio_set_source(LISAEL_AUDIO_IDLE);
}
s_playing = false;
s_dec_running = false;
s_dec_task = NULL;
ESP_LOGI(TAG, "decode task exit");
vTaskDeleteWithCaps(NULL);
}
// ----------------------------------------------------------------------------
// Public API
// ----------------------------------------------------------------------------
esp_err_t lisael_radio_play(const char *url)
{
if (!url) {
return ESP_ERR_INVALID_ARG;
}
// Take over the shared codec from any other source.
// Stop whatever else is playing (and any previous radio session) first.
lisael_radio_stop();
lisael_audio_stop_all();
esp_err_t err = build_pipeline();
if (err != ESP_OK) {
return err;
// Lazily create the network->decoder StreamBuffer IN PSRAM: it is ~32 KB
// and internal RAM is nearly exhausted (xStreamBufferCreate would fail with
// NO_MEM, which is exactly why the radio never started).
if (!s_net_buf) {
s_net_buf = xStreamBufferCreateWithCaps(NET_STREAM_BUF_BYTES, 1,
MALLOC_CAP_SPIRAM);
if (!s_net_buf) {
ESP_LOGE(TAG, "stream buffer alloc failed");
lisael_udp_send("RADIO: stream buffer alloc FAILED");
return ESP_ERR_NO_MEM;
}
} else {
xStreamBufferReset(s_net_buf);
}
if (s_playing) {
audio_pipeline_stop(s_pipe);
audio_pipeline_wait_for_stop(s_pipe);
audio_pipeline_reset_ringbuffer(s_pipe);
audio_pipeline_reset_elements(s_pipe);
s_playing = false;
}
audio_element_set_uri(s_http, url);
err = audio_pipeline_run(s_pipe);
if (err == ESP_OK) {
strlcpy(s_url, url, sizeof(s_url));
s_stop = false;
s_playing = true;
lisael_audio_set_source(LISAEL_AUDIO_RADIO);
// Re-assert the child-safe volume on the shared codec.
lisael_audio_set_volume(lisael_audio_get_volume());
ESP_LOGI(TAG, "playing %s", url);
} else {
ESP_LOGE(TAG, "pipeline run failed: %s", esp_err_to_name(err));
// Priority 4 == LVGL task priority. We MUST NOT run above the UI task or a
// saturated audio path could starve the display. The codec write in the
// decode task blocks, so it yields the CPU regularly; priority 4 keeps it
// responsive without preempting LVGL's own time slice. pin to no core so
// the scheduler can balance with Wi-Fi/LVGL.
BaseType_t ok1 = xTaskCreatePinnedToCoreWithCaps(decode_task, "radio_dec", 8192,
NULL, 4, &s_dec_task,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM);
BaseType_t ok2 = xTaskCreatePinnedToCoreWithCaps(http_task, "radio_http", 8192,
NULL, 4, &s_http_task,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM);
if (ok1 != pdPASS || ok2 != pdPASS) {
ESP_LOGE(TAG, "task create failed");
s_stop = true;
// Let whichever task did start unwind, then report failure.
lisael_radio_stop();
return ESP_FAIL;
}
return err;
ESP_LOGI(TAG, "playing %s", url);
return ESP_OK;
}
void lisael_radio_stop(void)
{
if (s_pipe && s_playing) {
audio_pipeline_stop(s_pipe);
audio_pipeline_wait_for_stop(s_pipe);
audio_pipeline_reset_ringbuffer(s_pipe);
audio_pipeline_reset_elements(s_pipe);
s_playing = false;
ESP_LOGI(TAG, "stopped");
if (!s_http_running && !s_dec_running && !s_playing) {
return; // nothing to do
}
s_stop = true;
// Nudge the decode task in case it is blocked on an empty StreamBuffer.
if (s_net_buf) {
xStreamBufferReset(s_net_buf);
}
// Wait (bounded, ~2 s) for both tasks to release the codec and exit. The
// HTTP perform may take up to its timeout to notice the abort, and the
// decode task may be mid codec write; ~100 * 20 ms covers both.
for (int i = 0; i < 100 && (s_http_running || s_dec_running); i++) {
vTaskDelay(pdMS_TO_TICKS(20));
}
s_playing = false;
if (lisael_audio_get_source() == LISAEL_AUDIO_RADIO) {
lisael_audio_set_source(LISAEL_AUDIO_IDLE);
}
ESP_LOGI(TAG, "stopped");
}
bool lisael_radio_is_playing(void)
{
return s_playing;
}
#else // !LISAEL_HAVE_ADF — inspection build without ESP-ADF
esp_err_t lisael_radio_play(const char *url)
{
(void)url;
ESP_LOGW(TAG, "ESP-ADF not present (ADF_PATH unset) — radio disabled");
return ESP_ERR_NOT_SUPPORTED;
}
void lisael_radio_stop(void) {}
bool lisael_radio_is_playing(void) { return false; }
#endif
+1
View File
@@ -2,6 +2,7 @@
#pragma once
#include "esp_err.h"
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
+42 -94
View File
@@ -1,5 +1,8 @@
// microSD sound-clip player (ESP-ADF fatfs pipeline) for Lisael Box.
// See sd_player.h. Same BSP<->ADF I2S ownership caveat as radio_pipeline.c.
// microSD sound-clip / story player for Lisael Box. See sd_player.h.
//
// Playback uses chmorgan/esp-audio-player (MP3 -> PCM) pushed to the BSP's
// ES8311 codec through the callbacks installed in audio_player.c. No ESP-ADF,
// so no audio_board <-> esp_lcd_ili9341 version conflict with the BOX-3 BSP.
#include "audio/sd_player.h"
#include "audio/audio_player.h"
@@ -12,6 +15,8 @@
#include "esp_log.h"
#include "bsp/esp-box-3.h"
#include "cJSON.h"
#include <audio_player.h> // chmorgan (angle brackets => its include dir, not
// our same-directory audio/audio_player.h)
static const char *TAG = "sd";
static bool s_mounted;
@@ -33,14 +38,20 @@ esp_err_t lisael_sd_mount(void)
int lisael_sd_load_manifest(lisael_sound_t *out, int max)
{
if (!out || max <= 0) {
return lisael_sd_load_manifest_dir("sounds", out, max);
}
int lisael_sd_load_manifest_dir(const char *subdir, lisael_sound_t *out, int max)
{
if (!out || max <= 0 || !subdir) {
return -1;
}
if (!s_mounted && lisael_sd_mount() != ESP_OK) {
return -1;
}
const char *path = BSP_SD_MOUNT_POINT "/sounds/manifest.json";
char path[96];
snprintf(path, sizeof(path), "%s/%s/manifest.json", BSP_SD_MOUNT_POINT, subdir);
FILE *f = fopen(path, "r");
if (!f) {
ESP_LOGW(TAG, "no manifest at %s", path);
@@ -78,15 +89,22 @@ int lisael_sd_load_manifest(lisael_sound_t *out, int max)
}
cJSON *file = cJSON_GetObjectItem(item, "file");
cJSON *title = cJSON_GetObjectItem(item, "title");
cJSON *show = cJSON_GetObjectItem(item, "show");
cJSON *emoji = cJSON_GetObjectItem(item, "emoji");
if (!cJSON_IsString(file)) {
continue;
}
memset(&out[n], 0, sizeof(out[n]));
strlcpy(out[n].file, file->valuestring, sizeof(out[n].file));
strlcpy(out[n].title,
cJSON_IsString(title) ? title->valuestring : file->valuestring,
sizeof(out[n].title));
// Absolute path so callers can play it directly regardless of subdir.
snprintf(out[n].file, sizeof(out[n].file), "%s/%s/%s",
BSP_SD_MOUNT_POINT, subdir, file->valuestring);
const char *t = cJSON_IsString(title) ? title->valuestring : file->valuestring;
if (cJSON_IsString(show) && show->valuestring[0]) {
snprintf(out[n].title, sizeof(out[n].title), "%s - %s",
show->valuestring, t);
} else {
strlcpy(out[n].title, t, sizeof(out[n].title));
}
strlcpy(out[n].emoji,
cJSON_IsString(emoji) ? emoji->valuestring : "\U0001F50A", // speaker
sizeof(out[n].emoji));
@@ -104,57 +122,7 @@ esp_err_t lisael_sd_play_sound(const char *basename)
return lisael_sd_play_file(abs);
}
// --- ADF-guarded pipeline ---------------------------------------------------
#if defined(__has_include)
# if __has_include("audio_pipeline.h")
# define LISAEL_HAVE_ADF 1
# endif
#endif
#ifdef LISAEL_HAVE_ADF
#include "audio_pipeline.h"
#include "audio_element.h"
#include "fatfs_stream.h"
#include "mp3_decoder.h"
#include "i2s_stream.h"
static audio_pipeline_handle_t s_pipe;
static audio_element_handle_t s_fatfs, s_mp3, s_i2s;
static bool s_playing;
static esp_err_t build_pipeline(void)
{
if (s_pipe) {
return ESP_OK;
}
audio_pipeline_cfg_t pcfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
s_pipe = audio_pipeline_init(&pcfg);
if (!s_pipe) {
return ESP_ERR_NO_MEM;
}
fatfs_stream_cfg_t fs_cfg = FATFS_STREAM_CFG_DEFAULT();
fs_cfg.type = AUDIO_STREAM_READER;
s_fatfs = fatfs_stream_init(&fs_cfg);
mp3_decoder_cfg_t mp3_cfg = DEFAULT_MP3_DECODER_CONFIG();
s_mp3 = mp3_decoder_init(&mp3_cfg);
// TODO(hardware): match i2s port/pins to the BOX-3 BSP — same caveat as
// radio_pipeline.c (ADF vs BSP I2S ownership).
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT();
i2s_cfg.type = AUDIO_STREAM_WRITER;
s_i2s = i2s_stream_init(&i2s_cfg);
audio_pipeline_register(s_pipe, s_fatfs, "file");
audio_pipeline_register(s_pipe, s_mp3, "mp3");
audio_pipeline_register(s_pipe, s_i2s, "i2s");
const char *link[] = { "file", "mp3", "i2s" };
audio_pipeline_link(s_pipe, link, 3);
return ESP_OK;
}
// --- playback via esp-audio-player ------------------------------------------
esp_err_t lisael_sd_play_file(const char *abs_path)
{
@@ -164,59 +132,39 @@ esp_err_t lisael_sd_play_file(const char *abs_path)
if (!s_mounted && lisael_sd_mount() != ESP_OK) {
return ESP_ERR_NOT_FOUND;
}
lisael_audio_stop_all();
esp_err_t err = build_pipeline();
esp_err_t err = lisael_audio_player_ensure();
if (err != ESP_OK) {
return err;
}
if (s_playing) {
audio_pipeline_stop(s_pipe);
audio_pipeline_wait_for_stop(s_pipe);
audio_pipeline_reset_ringbuffer(s_pipe);
audio_pipeline_reset_elements(s_pipe);
s_playing = false;
}
lisael_audio_stop_all(); // mutually exclusive sources
audio_element_set_uri(s_fatfs, abs_path);
err = audio_pipeline_run(s_pipe);
FILE *fp = fopen(abs_path, "rb");
if (!fp) {
ESP_LOGW(TAG, "cannot open %s", abs_path);
return ESP_ERR_NOT_FOUND;
}
// audio_player_play() takes ownership of fp and fclose()s it on success
// (and on playback error). Only close it ourselves if queuing failed.
err = audio_player_play(fp);
if (err == ESP_OK) {
s_playing = true;
lisael_audio_set_source(LISAEL_AUDIO_SD);
lisael_audio_set_volume(lisael_audio_get_volume());
ESP_LOGI(TAG, "playing %s", abs_path);
} else {
ESP_LOGE(TAG, "run failed: %s", esp_err_to_name(err));
fclose(fp);
ESP_LOGE(TAG, "play failed: %s", esp_err_to_name(err));
}
return err;
}
void lisael_sd_stop(void)
{
if (s_pipe && s_playing) {
audio_pipeline_stop(s_pipe);
audio_pipeline_wait_for_stop(s_pipe);
audio_pipeline_reset_ringbuffer(s_pipe);
audio_pipeline_reset_elements(s_pipe);
s_playing = false;
if (audio_player_get_state() != AUDIO_PLAYER_STATE_IDLE) {
audio_player_stop();
ESP_LOGI(TAG, "stopped");
}
}
bool lisael_sd_is_playing(void)
{
return s_playing;
return audio_player_get_state() == AUDIO_PLAYER_STATE_PLAYING;
}
#else // !LISAEL_HAVE_ADF
esp_err_t lisael_sd_play_file(const char *abs_path)
{
(void)abs_path;
ESP_LOGW(TAG, "ESP-ADF not present — SD playback disabled");
return ESP_ERR_NOT_SUPPORTED;
}
void lisael_sd_stop(void) {}
bool lisael_sd_is_playing(void) { return false; }
#endif
+7
View File
@@ -29,6 +29,13 @@ esp_err_t lisael_sd_mount(void);
// LISAEL_SD_MAX_SOUNDS). Returns -1 on error (no card / no manifest).
int lisael_sd_load_manifest(lisael_sound_t *out, int max);
// Generic: parse <MOUNT>/<subdir>/manifest.json (a JSON array of
// {file, title, show?, emoji?}). out[i].file is filled with the ABSOLUTE path
// "<MOUNT>/<subdir>/<file>"; when "show" is present, out[i].title is composed as
// "show — title". Returns count (>=0), or -1 on error. Used for both the sound
// clips ("sounds") and the downloaded podcast episodes ("podcasts").
int lisael_sd_load_manifest_dir(const char *subdir, lisael_sound_t *out, int max);
// Play an absolute SD path (e.g. "/sdcard/sounds/miaou.mp3"). Stops other audio.
esp_err_t lisael_sd_play_file(const char *abs_path);
+12
View File
@@ -19,6 +19,18 @@ dependencies:
# esp_lcd_ili9341, button, icm42670, aht30 transitively.
espressif/esp-box-3: "^3.2.0"
# Audio playback (MP3 from SD, later HTTP streams) WITHOUT ESP-ADF: a small
# high-level player that decodes to PCM and hands it to a write callback we
# back with the BSP's ES8311 codec (esp_codec_dev). Avoids the ESP-ADF
# audio_board <-> esp_lcd_ili9341 version conflict with the BOX-3 BSP.
chmorgan/esp-audio-player: "^1.0.7"
chmorgan/esp-libhelix-mp3: "^1.0.3"
# AAC / M4A decoding (Radio France podcasts are AAC in an MP4/.m4a container).
# Espressif's audio codec lib provides a "simple decoder" that demuxes M4A and
# decodes AAC/MP3/etc -> PCM. Used to test on-device AAC playback.
espressif/esp_audio_codec: "^2.0.0"
# JSON parsing (manifest.json, open-meteo replies) uses the cJSON shipped with
# ESP-IDF as the built-in "json" component — no managed dependency needed.
# (REQUIRES json in main/CMakeLists.txt.)
+383
View File
@@ -0,0 +1,383 @@
// "Calme ta colère" — balloon-popping game, see balloons.h.
//
// Colourful balloons (a rounded lv_obj with a vertical gradient for a 3D sheen,
// plus a thin "string" below) rise from y=240 to off-screen at y=-80 with a
// per-balloon random duration. A repeating lv_timer spawns a new balloon every
// ~0.9-1.4 s, capped at BALLOON_MAX on screen at once. Tapping a balloon makes
// it pop: the rise animation is removed, the balloon scales up while fading out
// (~200 ms), then it is deleted; a short synthesised "pop" is played.
//
// Lifetime / threading:
// * The spawn timer, rise animations and pop animations all run on the LVGL
// task, so they touch LVGL freely.
// * The pop sound runs on its own detached FreeRTOS task and touches ONLY the
// shared codec (never LVGL). A single volatile flag serialises pops so two
// tasks never fight over the codec.
// * On LV_EVENT_SCREEN_UNLOADED everything is torn down: the timer is deleted,
// every live balloon (and its rise anim) is removed, and audio is stopped.
#include "ui/ui.h"
#include "ui/fonts/lisael_fonts.h"
#include "audio/audio_player.h"
#include <math.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/idf_additions.h"
#include "esp_heap_caps.h"
#include "esp_codec_dev.h"
#include "esp_log.h"
static const char *TAG = "ui.balloons";
// --- Geometry / timing tunables ---------------------------------------------
#define SCREEN_W 320
#define SCREEN_H 240
#define BALLOON_W 56 // balloon body width (px)
#define BALLOON_H 66 // balloon body height (px)
#define STRING_H 22 // little string under the balloon (px)
#define Y_START SCREEN_H // spawn just below the screen
#define Y_END (-(BALLOON_H + STRING_H + 14)) // fully off the top
#define RISE_MIN_MS 4000
#define RISE_MAX_MS 8000
#define SPAWN_MIN_MS 900
#define SPAWN_MAX_MS 1400
#define BALLOON_MAX 8 // max balloons alive at once
#define POP_MS 200 // pop grow/fade duration
// --- Pop sound (detached task) ----------------------------------------------
// A satisfying, cathartic "pop": a short explosion (noise burst + descending
// boom) immediately followed by a bright melodic note that rings out. Each pop
// steps UP a C-major-pentatonic scale (cycling), so popping balloons in a row
// plays a cheerful rising melody — the "défouloir" effect.
#define POP_SR 32000
#define POP_MS_SOUND 520 // longer, rewarding
#define POP_SAMPLES ((POP_SR * POP_MS_SOUND) / 1000)
static volatile bool s_pop_running; // serialise codec access
static uint32_t s_rng = 0x1234567u; // tiny noise generator
static int s_note = 0; // ascending melody index
// C5 D5 E5 G5 A5 C6 D6 E6 — major pentatonic, ascending.
static const float PENTA[] = {
523.25f, 587.33f, 659.25f, 783.99f, 880.00f, 1046.50f, 1174.66f, 1318.51f,
};
static inline float frand(void)
{
s_rng = s_rng * 1664525u + 1013904223u;
return ((float)((s_rng >> 9) & 0xFFFF) / 32768.0f) - 1.0f; // -1..1
}
static void pop_task(void *arg)
{
int note = (int)(intptr_t)arg;
esp_codec_dev_handle_t codec = lisael_audio_codec();
if (!codec) {
s_pop_running = false;
vTaskDeleteWithCaps(NULL);
return;
}
esp_codec_dev_close(codec);
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16, .channel = 1, .sample_rate = POP_SR,
};
esp_codec_dev_open(codec, &fs);
esp_codec_dev_set_out_vol(codec, (float)lisael_audio_get_volume());
const int N = 256;
int16_t *buf = malloc(N * sizeof(int16_t));
if (buf) {
const float TAU = 6.2831853f;
const float fnote = PENTA[note % (int)(sizeof(PENTA) / sizeof(PENTA[0]))];
float ph = 0, ph2 = 0, phb = 0; // note, octave, boom phases
int written = 0;
while (written < POP_SAMPLES) {
int n = POP_SAMPLES - written;
if (n > N) n = N;
for (int i = 0; i < n; i++) {
float t = (float)(written + i) / (float)POP_SR; // seconds
// 1) Explosion: noise + descending boom, very fast decay (~60 ms).
float burst_env = expf(-26.0f * t);
float boom_f = 60.0f + 260.0f * expf(-12.0f * t);
float burst = (frand() * 0.5f + sinf(phb) * 0.55f) * burst_env;
// 2) Melodic note: soft attack, rings out over ~400 ms.
float atk = t < 0.012f ? t / 0.012f : 1.0f;
float note_env = atk * expf(-3.4f * t);
float tone = (sinf(ph) * 0.7f + sinf(ph2) * 0.3f) * note_env;
float s = burst * 0.55f + tone * 0.55f;
int v = (int)(s * 9000.0f);
if (v > 32767) v = 32767;
if (v < -32768) v = -32768;
buf[i] = (int16_t)v;
ph += TAU * fnote / POP_SR; if (ph > TAU) ph -= TAU;
ph2 += TAU * fnote * 2.0f / POP_SR; if (ph2 > TAU) ph2 -= TAU;
phb += TAU * boom_f / POP_SR; if (phb > TAU) phb -= TAU;
}
esp_codec_dev_write(codec, buf, n * sizeof(int16_t));
written += n;
}
free(buf);
}
esp_codec_dev_close(codec);
s_pop_running = false;
vTaskDeleteWithCaps(NULL);
}
static void play_pop(void)
{
if (s_pop_running) {
return; // one already ringing — don't fight over the codec
}
s_pop_running = true;
int note = s_note++; // ascend the scale on each successful pop
// Priority 3 (below the LVGL task at 4) so the pop animation stays smooth.
if (xTaskCreatePinnedToCoreWithCaps(pop_task, "balloon_pop", 4096,
(void *)(intptr_t)note, 3, NULL,
tskNO_AFFINITY, MALLOC_CAP_SPIRAM) != pdPASS) {
s_pop_running = false;
}
}
// --- Balloon bookkeeping (all on the LVGL task) -----------------------------
static lv_obj_t *s_root;
static lv_obj_t *s_play_area; // balloons are children of this
static lv_timer_t *s_spawn_timer; // periodic spawner, NULL when idle
static lv_obj_t *s_balloons[BALLOON_MAX]; // live balloons (NULL = free slot)
// Vivid balloon colours (light top -> vivid bottom for a 3D look).
typedef struct { uint32_t top; uint32_t bottom; } balloon_pal_t;
static const balloon_pal_t PALETTE[] = {
{ 0xFFC9C9, 0xE03131 }, // red
{ 0xA5D8FF, 0x1971C2 }, // blue
{ 0xB2F2BB, 0x2F9E44 }, // green
{ 0xFFEC99, 0xF59F00 }, // yellow
{ 0xD0BFFF, 0x7048E8 }, // purple
{ 0xFFD8A8, 0xE8590C }, // orange
};
#define PALETTE_N (sizeof(PALETTE) / sizeof(PALETTE[0]))
static int balloon_slot(lv_obj_t *b)
{
for (int i = 0; i < BALLOON_MAX; i++) {
if (s_balloons[i] == b) return i;
}
return -1;
}
static void balloon_release_slot(lv_obj_t *b)
{
int i = balloon_slot(b);
if (i >= 0) s_balloons[i] = NULL;
}
// Rise animation: move the balloon's y; when it reaches the top, delete it.
static void rise_anim_cb(void *obj, int32_t y)
{
lv_obj_set_y((lv_obj_t *)obj, y);
}
static void rise_done_cb(lv_anim_t *a)
{
lv_obj_t *b = (lv_obj_t *)lv_anim_get_user_data(a);
balloon_release_slot(b);
lv_obj_delete(b); // floated off the top of the screen
}
// Pop: scale up + fade out, then delete asynchronously.
static void pop_scale_cb(void *obj, int32_t scale)
{
lv_obj_set_style_transform_scale_x((lv_obj_t *)obj, scale, 0);
lv_obj_set_style_transform_scale_y((lv_obj_t *)obj, scale, 0);
}
static void pop_opa_cb(void *obj, int32_t opa)
{
lv_obj_set_style_opa((lv_obj_t *)obj, (lv_opa_t)opa, 0);
}
static void pop_done_cb(lv_anim_t *a)
{
lv_obj_t *b = (lv_obj_t *)lv_anim_get_user_data(a);
balloon_release_slot(b);
lv_obj_delete_async(b); // safe-delete after callbacks unwind
}
static void balloon_clicked_cb(lv_event_t *e)
{
lv_obj_t *b = lv_event_get_target_obj(e);
play_pop();
// Stop the rise so the two animations don't fight over the same object.
lv_anim_delete(b, rise_anim_cb);
// Free the slot now so the spawner can reuse it during the short pop anim.
balloon_release_slot(b);
// No more taps while it pops.
lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE);
// Scale around the centre so it grows in place.
lv_obj_set_style_transform_pivot_x(b, BALLOON_W / 2, 0);
lv_obj_set_style_transform_pivot_y(b, BALLOON_H / 2, 0);
// Grow.
lv_anim_t grow;
lv_anim_init(&grow);
lv_anim_set_var(&grow, b);
lv_anim_set_exec_cb(&grow, pop_scale_cb);
lv_anim_set_values(&grow, LV_SCALE_NONE, LV_SCALE_NONE * 7 / 4); // 1.0 -> 1.75x
lv_anim_set_duration(&grow, POP_MS);
lv_anim_set_path_cb(&grow, lv_anim_path_ease_out);
lv_anim_start(&grow);
// Fade out in parallel; this animation owns the delete.
lv_anim_t fade;
lv_anim_init(&fade);
lv_anim_set_var(&fade, b);
lv_anim_set_exec_cb(&fade, pop_opa_cb);
lv_anim_set_user_data(&fade, b);
lv_anim_set_values(&fade, LV_OPA_COVER, LV_OPA_TRANSP);
lv_anim_set_duration(&fade, POP_MS);
lv_anim_set_completed_cb(&fade, pop_done_cb);
lv_anim_start(&fade);
}
// Build one balloon (body + string) at a random x with a random colour, and
// start its rise animation. Returns the balloon object, or NULL if full.
static lv_obj_t *spawn_balloon(void)
{
int slot = -1;
for (int i = 0; i < BALLOON_MAX; i++) {
if (!s_balloons[i]) { slot = i; break; }
}
if (slot < 0) {
return NULL; // already at capacity
}
const balloon_pal_t *pal = &PALETTE[lv_rand(0, PALETTE_N - 1)];
int x = (int)lv_rand(6, SCREEN_W - BALLOON_W - 6);
// Balloon body: rounded rectangle with a vertical gradient (light -> vivid).
lv_obj_t *b = lv_obj_create(s_play_area);
lv_obj_set_size(b, BALLOON_W, BALLOON_H);
lv_obj_set_pos(b, x, Y_START);
lv_obj_set_style_radius(b, BALLOON_W / 2, 0); // egg-ish rounded balloon
lv_obj_set_style_bg_color(b, lv_color_hex(pal->top), 0);
lv_obj_set_style_bg_grad_color(b, lv_color_hex(pal->bottom), 0);
lv_obj_set_style_bg_grad_dir(b, LV_GRAD_DIR_VER, 0);
lv_obj_set_style_border_width(b, 0, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_pad_all(b, 0, 0);
lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(b, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(b, balloon_clicked_cb, LV_EVENT_CLICKED, NULL);
// Little string hanging from the bottom of the balloon.
lv_obj_t *string = lv_obj_create(b);
lv_obj_set_size(string, 2, STRING_H);
lv_obj_align(string, LV_ALIGN_BOTTOM_MID, 0, STRING_H);
lv_obj_set_style_radius(string, 1, 0);
lv_obj_set_style_bg_color(string, lv_color_hex(0x868E96), 0);
lv_obj_set_style_border_width(string, 0, 0);
lv_obj_remove_flag(string, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(string, LV_OBJ_FLAG_SCROLLABLE);
s_balloons[slot] = b;
// Rise from the bottom to off the top, then self-delete.
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, b);
lv_anim_set_exec_cb(&a, rise_anim_cb);
lv_anim_set_user_data(&a, b);
lv_anim_set_values(&a, Y_START, Y_END);
lv_anim_set_duration(&a, (uint32_t)lv_rand(RISE_MIN_MS, RISE_MAX_MS));
lv_anim_set_path_cb(&a, lv_anim_path_linear);
lv_anim_set_completed_cb(&a, rise_done_cb);
lv_anim_start(&a);
return b;
}
// Periodic spawner: add a balloon and reschedule itself with a new random gap.
static void spawn_timer_cb(lv_timer_t *t)
{
spawn_balloon();
lv_timer_set_period(t, lv_rand(SPAWN_MIN_MS, SPAWN_MAX_MS));
}
// Remove every live balloon (deleting its rise anim first) and stop spawning.
static void teardown(void)
{
if (s_spawn_timer) {
lv_timer_delete(s_spawn_timer);
s_spawn_timer = NULL;
}
for (int i = 0; i < BALLOON_MAX; i++) {
if (s_balloons[i]) {
lv_anim_delete(s_balloons[i], rise_anim_cb);
lv_anim_delete(s_balloons[i], pop_scale_cb);
lv_anim_delete(s_balloons[i], pop_opa_cb);
lv_obj_delete(s_balloons[i]);
s_balloons[i] = NULL;
}
}
}
static void on_screen_event(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_SCREEN_LOADED) {
ESP_LOGI(TAG, "balloons started");
lisael_audio_stop_all(); // silence radio / podcast / calme
// A couple of balloons already on screen feels livelier than an empty
// start, then let the timer keep them coming.
spawn_balloon();
spawn_balloon();
if (!s_spawn_timer) {
s_spawn_timer = lv_timer_create(spawn_timer_cb,
lv_rand(SPAWN_MIN_MS, SPAWN_MAX_MS),
NULL);
}
} else if (code == LV_EVENT_SCREEN_UNLOADED) {
ESP_LOGI(TAG, "balloons stopped");
teardown();
lisael_audio_stop_all(); // make sure any pop tail is cut
}
}
lv_obj_t *lisael_screen_balloons(void)
{
if (s_root) {
return s_root;
}
s_root = lv_obj_create(NULL);
// Soft sky background so the vivid balloons stand out.
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xE3FAFC), 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);
lv_obj_remove_flag(s_root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_LOADED, NULL);
lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_UNLOADED, NULL);
lisael_ui_add_back_button(s_root);
lv_obj_t *title = lv_label_create(s_root);
lv_label_set_text(title, "Éclate les ballons !");
lv_obj_set_style_text_font(title, &lisael_font_24, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
// Full-screen transparent play area that hosts (and clips nothing of) the
// balloons. Keeping balloons in their own container makes teardown simple
// and keeps the title/back-button untouched.
s_play_area = lv_obj_create(s_root);
lv_obj_remove_style_all(s_play_area);
lv_obj_set_size(s_play_area, SCREEN_W, SCREEN_H);
lv_obj_align(s_play_area, LV_ALIGN_TOP_LEFT, 0, 0);
lv_obj_remove_flag(s_play_area, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(s_play_area, LV_OBJ_FLAG_CLICKABLE);
lv_obj_move_to_index(s_play_area, 0); // behind the title + back button
return s_root;
}
+21
View File
@@ -0,0 +1,21 @@
// "Calme ta colère" balloon-popping game for Lisael Box.
//
// Colourful balloons drift up from the bottom of the screen; tapping one makes
// it pop (grow + fade) with a short "pop" sound. A soothing way for a child to
// let off steam. LVGL-only graphics (no images); the pop sound is synthesised
// on a detached FreeRTOS task so the LVGL task is never blocked.
#pragma once
#include "lvgl.h"
#ifdef __cplusplus
extern "C" {
#endif
// Build (lazily, cached) the balloon game screen and return its root object.
// Call while holding the display lock, like the other mode builders.
lv_obj_t *lisael_screen_balloons(void);
#ifdef __cplusplus
}
#endif
+31 -7
View File
@@ -7,7 +7,8 @@
#include "ui/ui.h"
#include "ui/icons.h"
#include "audio/sd_player.h"
#include "ui/fonts/lisael_fonts.h"
#include "audio/calm_tone.h"
#include "esp_log.h"
@@ -82,10 +83,12 @@ static void on_screen_event(lv_event_t *e)
if (code == LV_EVENT_SCREEN_LOADED) {
ESP_LOGI(TAG, "breathing started");
start_inhale();
// Best-effort soft ambience (optional file; ignored if absent).
lisael_sd_play_sound("calme.mp3");
// Soft 432 Hz tone with harmonics, breathing-synced (rises on inhale,
// falls on exhale) — synthesised in real time, no SD file needed.
lisael_calm_tone_start();
} else if (code == LV_EVENT_SCREEN_UNLOADED) {
lv_anim_delete(s_bubble, anim_size_cb);
lisael_calm_tone_stop();
}
}
@@ -95,24 +98,45 @@ lv_obj_t *lisael_screen_calm(void)
return s_root;
}
s_root = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xE7F5FF), 0); // calm blue
// Calm vertical gradient (soft blue at the top fading to near-white).
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xBFE3FF), 0);
lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xF2FAFF), 0);
lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0);
lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_LOADED, NULL);
lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_UNLOADED, NULL);
lisael_ui_add_back_button(s_root);
// 3D glassy bubble: vertical gradient (light top -> blue bottom), a soft
// cyan glow (shadow), and a small white specular highlight near the top.
s_bubble = lv_obj_create(s_root);
lv_obj_set_size(s_bubble, BUBBLE_MIN, BUBBLE_MIN);
lv_obj_center(s_bubble);
lv_obj_set_style_radius(s_bubble, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(s_bubble, lv_color_hex(0x74C0FC), 0);
lv_obj_set_style_bg_opa(s_bubble, LV_OPA_70, 0);
lv_obj_set_style_bg_color(s_bubble, lv_color_hex(0xD6F0FF), 0);
lv_obj_set_style_bg_grad_color(s_bubble, lv_color_hex(0x4DA9F0), 0);
lv_obj_set_style_bg_grad_dir(s_bubble, LV_GRAD_DIR_VER, 0);
lv_obj_set_style_bg_opa(s_bubble, LV_OPA_90, 0);
lv_obj_set_style_border_width(s_bubble, 0, 0);
// NO blurred shadow here: it is recomputed every animation frame as the
// bubble breathes (resizes), and the soft-shadow blur over a large circle
// with the small draw buffer monopolised the LVGL task -> watchdog/freeze.
// The vertical gradient + the white gloss highlight give enough depth.
lv_obj_clear_flag(s_bubble, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *gloss = lv_obj_create(s_bubble);
lv_obj_set_size(gloss, 26, 26);
lv_obj_align(gloss, LV_ALIGN_TOP_LEFT, 14, 12);
lv_obj_set_style_radius(gloss, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(gloss, lv_color_white(), 0);
lv_obj_set_style_bg_opa(gloss, LV_OPA_60, 0);
lv_obj_set_style_border_width(gloss, 0, 0);
lv_obj_clear_flag(gloss, LV_OBJ_FLAG_SCROLLABLE);
s_phase_lbl = lv_label_create(s_root);
lv_label_set_text(s_phase_lbl, "Inspire…");
lv_obj_set_style_text_font(s_phase_lbl, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_font(s_phase_lbl, &lisael_font_30, 0);
lv_obj_set_style_text_color(s_phase_lbl, lv_color_hex(0x2A5B86), 0);
lv_obj_align(s_phase_lbl, LV_ALIGN_BOTTOM_MID, 0, -16);
return s_root;
+49 -26
View File
@@ -13,6 +13,8 @@
#include "ui/ui.h"
#include "ui/icons.h"
#include "ui/assets/lisael_icons.h"
#include "ui/fonts/lisael_fonts.h"
#include <stdlib.h>
#include <string.h>
@@ -34,10 +36,15 @@ static void show_bravo(lv_obj_t *parent)
lv_obj_center(toast);
lv_obj_set_style_bg_color(toast, lv_color_hex(0xFFF3BF), 0);
lv_obj_set_style_radius(toast, 16, 0);
lv_obj_set_flex_flow(toast, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(toast, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(toast, 8, 0);
lv_obj_t *lbl = lv_label_create(toast);
lv_label_set_text(lbl, "Bravo ! " LISAEL_EMOJI_STAR);
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_28, 0);
lv_obj_center(lbl);
lv_label_set_text(lbl, "Bravo !");
lv_obj_set_style_text_font(lbl, &lisael_font_24, 0);
lv_obj_t *star = lv_image_create(toast);
lv_image_set_src(star, &lisael_ic_star);
// Auto-dismiss after 1.5 s.
lv_obj_delete_delayed(toast, 1500);
}
@@ -48,10 +55,10 @@ static void show_bravo(lv_obj_t *parent)
#define MEM_PAIRS 3
#define MEM_CARDS (MEM_PAIRS * 2)
static const char *const MEM_FACES[MEM_PAIRS] = {
"\U0001F431", // 🐱
"\U0001F436", // 🐶
"\U0001F438", // 🐸
static const lv_image_dsc_t *const MEM_FACES[MEM_PAIRS] = {
&lisael_ic_cat, // 🐱
&lisael_ic_dog, // 🐶
&lisael_ic_frog, // 🐸
};
typedef struct {
@@ -65,8 +72,13 @@ typedef struct {
static void mem_set_revealed(memory_state_t *st, int i, bool revealed)
{
lv_obj_t *lbl = lv_obj_get_child(st->cards[i], 0);
lv_label_set_text(lbl, revealed ? MEM_FACES[st->face[i]] : "?");
lv_obj_t *img = lv_obj_get_child(st->cards[i], 0);
if (revealed) {
lv_image_set_src(img, MEM_FACES[st->face[i]]);
lv_obj_set_style_image_opa(img, LV_OPA_COVER, 0);
} else {
lv_obj_set_style_image_opa(img, LV_OPA_TRANSP, 0); // face down
}
lv_obj_set_style_bg_color(st->cards[i],
revealed ? lv_color_hex(0xB2F2BB) : lv_color_hex(0x99C8FF), 0);
}
@@ -147,10 +159,8 @@ static void mem_build(lv_obj_t *parent)
lv_obj_set_size(card, 90, 80);
lv_obj_set_style_radius(card, 12, 0);
lv_obj_add_event_cb(card, mem_card_cb, LV_EVENT_CLICKED, st);
lv_obj_t *lbl = lv_label_create(card);
lv_label_set_text(lbl, "?");
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_28, 0);
lv_obj_center(lbl);
lv_obj_t *img = lv_image_create(card);
lv_obj_center(img);
st->cards[i] = card;
mem_set_revealed(st, i, false);
}
@@ -192,9 +202,8 @@ static void count_new_round(count_state_t *st)
// Render the objects.
lv_obj_clean(st->objects_box);
for (int i = 0; i < st->answer; i++) {
lv_obj_t *o = lv_label_create(st->objects_box);
lv_label_set_text(o, "\U0001F34E"); // 🍎
lv_obj_set_style_text_font(o, &lv_font_montserrat_28, 0);
lv_obj_t *o = lv_image_create(st->objects_box); // 🍎 apple icon
lv_image_set_src(o, &lisael_ic_apple);
}
// Offer the right answer + two distractors in a shuffled set.
@@ -294,9 +303,16 @@ static void open_game(game_builder_t builder, const char *title)
ESP_LOGI(TAG, "game open: %s", title);
}
static void memory_cb(lv_event_t *e) { (void)e; open_game(mem_build, LISAEL_EMOJI_GAMES " Memory"); }
static void count_cb(lv_event_t *e) { (void)e; open_game(count_build, "🔢 Compter"); }
static void color_cb(lv_event_t *e) { (void)e; open_game(build_coloring_stub, "🎨 Coloriage"); }
static void memory_cb(lv_event_t *e) { (void)e; open_game(mem_build, "Memory"); }
static void count_cb(lv_event_t *e) { (void)e; open_game(count_build, "Compter"); }
static void color_cb(lv_event_t *e) { (void)e; open_game(build_coloring_stub, "Coloriage"); }
static void balloons_cb(lv_event_t *e)
{
(void)e;
// The balloons screen is self-contained (own back button to HOME).
lv_screen_load_anim(lisael_screen_balloons(), LV_SCR_LOAD_ANIM_MOVE_LEFT,
250, 0, false);
}
lv_obj_t *lisael_screen_games(void)
{
@@ -309,7 +325,8 @@ lv_obj_t *lisael_screen_games(void)
lisael_ui_add_back_button(s_menu_root);
lv_obj_t *title = lv_label_create(s_menu_root);
lv_label_set_text(title, LISAEL_EMOJI_GAMES " Jeux");
lv_label_set_text(title, "Jeux");
lv_obj_set_style_text_font(title, &lisael_font_24, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
lv_obj_t *list = lv_obj_create(s_menu_root);
@@ -321,19 +338,25 @@ lv_obj_t *lisael_screen_games(void)
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(list, 10, 0);
struct { const char *txt; lv_event_cb_t cb; } games[] = {
{ LISAEL_EMOJI_GAMES " Memory", memory_cb },
{ "🔢 Compter", count_cb },
{ "🎨 Coloriage (bientôt)", color_cb },
struct { const char *txt; const lv_image_dsc_t *icon; lv_event_cb_t cb; } games[] = {
{ "Memory", &lisael_ic_cat, memory_cb },
{ "Compter", &lisael_ic_apple, count_cb },
{ "Calme ta colère", &lisael_ic_balloon, balloons_cb },
{ "Coloriage", &lisael_ic_palette, color_cb },
};
for (size_t i = 0; i < sizeof(games) / sizeof(games[0]); i++) {
lv_obj_t *b = lv_button_create(list);
lv_obj_add_style(b, lisael_ui_style_button(), 0);
lv_obj_set_width(b, LV_PCT(80));
lv_obj_set_width(b, LV_PCT(82));
lv_obj_set_flex_flow(b, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(b, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(b, 12, 0);
lv_obj_add_event_cb(b, games[i].cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *ic = lv_image_create(b);
lv_image_set_src(ic, games[i].icon);
lv_obj_t *l = lv_label_create(b);
lv_label_set_text(l, games[i].txt);
lv_obj_center(l);
}
return s_menu_root;
}
+75 -58
View File
@@ -1,122 +1,139 @@
// Webradio mode screen for Lisael Box.
//
// A scrollable column of big station buttons + a bottom bar with volume down /
// stop / volume up. Tapping a station starts the ESP-ADF http_stream pipeline.
// A soft-gradient screen with a "← Accueil" back button, a "Radio" title, a
// column of big station buttons (one tap = lisael_radio_play(url)), and a Stop
// button (lisael_radio_stop()). A small status label echoes the current action.
//
// The station table below is local on purpose (radio_pipeline.h is the only
// audio contract we depend on). Each entry is a live HTTP/HTTPS MP3 stream.
//
// ⚠️ STREAM URLs — VERIFY ON HARDWARE BEFORE SHIPPING ⚠️
// French kids' webradios are mostly hosted on RadioKing / Zeno.FM, whose direct
// stream endpoints are loaded dynamically by their JS players and are not
// publicly documented in a stable text form. The URLs below use each platform's
// documented direct-play form:
// - RadioKing: https://www.radioking.com/play/<slug> 302-redirects to the
// live listen.radioking.com/<id> MP3 mount (our HTTP client follows 3xx).
// - Zeno.FM: https://stream.zeno.fm/<mount> is a direct MP3 stream.
// Confirm with `curl -IL <url>` that the final response is audio/mpeg. If a
// station's slug/mount changed, update it here. All are HTTPS (handled by the
// certificate bundle in radio_pipeline.c).
#include "ui/ui.h"
#include "ui/icons.h"
#include "modes/radios.h"
#include "ui/fonts/lisael_fonts.h"
#include "audio/radio_pipeline.h"
#include "audio/audio_player.h"
#include "lisael_config.h"
#include <stdio.h>
#include <stddef.h>
#include "esp_log.h"
static const char *TAG = "ui.radio";
// --- Station table (name + live MP3 stream URL) -----------------------------
typedef struct {
const char *name;
const char *url;
} radio_station_t;
// DIRECT MP3 stream mounts (content-type audio/mpeg), verified 2026-06-14 from
// the RadioKing players. The /play/<slug> pages are HTML — these are the real
// listen.radioking.com mounts. If a stream changes, re-read it from
// play.radioking.io/<slug> (grep listen.radioking.com/radio/<id>/stream/<id>).
static const radio_station_t k_stations[] = {
// Radio Doudou — comptines & berceuses pour les tout-petits.
{ "Radio Doudou", "https://listen.radioking.com/radio/11565/stream/22961" },
// Radio Pomme d'Api (Bayard) — la radio des 3-7 ans.
{ "Pomme d'Api", "https://listen.radioking.com/radio/361753/stream/411352" },
};
#define K_STATIONS_COUNT (sizeof(k_stations) / sizeof(k_stations[0]))
static lv_obj_t *s_root;
static lv_obj_t *s_status_lbl;
static lv_obj_t *s_vol_lbl;
static void refresh_volume_label(void)
static void set_status(const char *txt)
{
if (s_vol_lbl) {
char buf[16];
snprintf(buf, sizeof(buf), "%d%%", lisael_audio_get_volume());
lv_label_set_text(s_vol_lbl, buf);
if (s_status_lbl) {
lv_label_set_text(s_status_lbl, txt);
}
}
static void station_cb(lv_event_t *e)
{
const lisael_radio_station_t *st = lv_event_get_user_data(e);
const radio_station_t *st = (const radio_station_t *)lv_event_get_user_data(e);
ESP_LOGI(TAG, "station: %s", st->name);
esp_err_t err = lisael_radio_play(st->url);
if (s_status_lbl) {
lv_label_set_text_fmt(s_status_lbl, "%s %s",
err == ESP_OK ? "Écoute :" : "Erreur :", st->name);
err == ESP_OK ? "Écoute :" : "Oups :", st->name);
}
}
static void stop_cb(lv_event_t *e)
{
(void)e;
lisael_audio_stop_all();
if (s_status_lbl) {
lv_label_set_text(s_status_lbl, "Arrêté");
}
lisael_radio_stop();
set_status("Silence");
}
static void vol_up_cb(lv_event_t *e) { (void)e; lisael_audio_volume_step(+5); refresh_volume_label(); }
static void vol_down_cb(lv_event_t *e) { (void)e; lisael_audio_volume_step(-5); refresh_volume_label(); }
lv_obj_t *lisael_screen_radio(void)
{
if (s_root) {
refresh_volume_label();
return s_root;
}
s_root = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xFFF4E6), 0);
// Soft warm vertical gradient (peach at the top fading to near-white), in
// the same gentle spirit as the calm screen.
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xFFE3C2), 0);
lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xFFF7EE), 0);
lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0);
lisael_ui_add_back_button(s_root);
// Title in the kid-friendly Fredoka 24 px font.
lv_obj_t *title = lv_label_create(s_root);
lv_label_set_text(title, LISAEL_EMOJI_RADIO " Radios");
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
lv_label_set_text(title, "Radio");
lv_obj_set_style_text_font(title, &lisael_font_24, 0);
lv_obj_set_style_text_color(title, lv_color_hex(0x8A4B16), 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14);
// Station list (scrollable column).
// Scrollable column of big station buttons.
lv_obj_t *list = lv_obj_create(s_root);
lv_obj_remove_style_all(list);
lv_obj_set_size(list, LV_PCT(100), 130);
lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 66);
lv_obj_set_size(list, LV_PCT(100), 132);
lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 58);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(list, LV_FLEX_ALIGN_START,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(list, 8, 0);
for (size_t i = 0; i < LISAEL_RADIOS_COUNT; i++) {
for (size_t i = 0; i < K_STATIONS_COUNT; i++) {
lv_obj_t *btn = lv_button_create(list);
lv_obj_add_style(btn, lisael_ui_style_button(), 0);
lv_obj_set_width(btn, LV_PCT(92));
lv_obj_add_event_cb(btn, station_cb, LV_EVENT_CLICKED,
(void *)&LISAEL_RADIOS[i]);
(void *)&k_stations[i]);
lv_obj_t *lbl = lv_label_create(btn);
lv_label_set_text_fmt(lbl, "%s %s",
LISAEL_RADIOS[i].emoji, LISAEL_RADIOS[i].name);
lv_label_set_text(lbl, k_stations[i].name);
lv_obj_center(lbl);
}
// Bottom control bar: vol-, stop, vol+, and a volume readout.
lv_obj_t *bar = lv_obj_create(s_root);
lv_obj_remove_style_all(bar);
lv_obj_set_size(bar, LV_PCT(100), 60);
lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -2);
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);
struct { const char *txt; lv_event_cb_t cb; } ctrls[] = {
{ LV_SYMBOL_MINUS, vol_down_cb },
{ LV_SYMBOL_STOP, stop_cb },
{ LV_SYMBOL_PLUS, vol_up_cb },
};
for (size_t i = 0; i < 3; i++) {
lv_obj_t *b = lv_button_create(bar);
lv_obj_add_style(b, lisael_ui_style_button(), 0);
lv_obj_set_size(b, 64, 50);
lv_obj_add_event_cb(b, ctrls[i].cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *l = lv_label_create(b);
lv_label_set_text(l, ctrls[i].txt);
lv_obj_center(l);
}
s_vol_lbl = lv_label_create(bar);
refresh_volume_label();
// Bottom Stop button.
lv_obj_t *stop = lv_button_create(s_root);
lv_obj_add_style(stop, lisael_ui_style_button(), 0);
lv_obj_set_size(stop, 120, 50);
lv_obj_align(stop, LV_ALIGN_BOTTOM_MID, 0, -8);
lv_obj_add_event_cb(stop, stop_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *stop_lbl = lv_label_create(stop);
lv_label_set_text(stop_lbl, LV_SYMBOL_STOP " Stop");
lv_obj_center(stop_lbl);
// Status line just above the Stop button.
s_status_lbl = lv_label_create(s_root);
lv_obj_set_style_text_color(s_status_lbl, lv_color_hex(0x8A4B16), 0);
lv_label_set_text(s_status_lbl, "");
lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -66);
lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -64);
return s_root;
}
+331
View File
@@ -0,0 +1,331 @@
// Radio France youth podcasts — see podcast.h.
#include "net/podcast.h"
#include "audio/sd_player.h" // BSP_SD_MOUNT_POINT via sd mount; reuse mount
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <dirent.h>
#include "esp_log.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h"
static const char *TAG = "podcast";
#define PODCAST_DIR "/sdcard/podcasts"
typedef struct {
const char *id;
const char *title;
const char *rss_url;
} podcast_show_t;
// Verified 2026-06-14: first <enclosure> in each feed is the latest episode,
// audio/x-m4a (AAC in MP4).
static const podcast_show_t s_shows[] = {
{ "oli", "Une histoire et Oli",
"https://radiofrance-podcast.net/podcast09/rss_19721.xml" },
{ "bestioles", "Bestioles",
"https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/"
"podcast_a80ecbd5-df3d-4c9d-bee7-4e3d9efc1974.xml" },
};
int lisael_podcast_count(void)
{
return (int)(sizeof(s_shows) / sizeof(s_shows[0]));
}
const char *lisael_podcast_title(int idx)
{
return (idx >= 0 && idx < lisael_podcast_count()) ? s_shows[idx].title : "";
}
const char *lisael_podcast_id(int idx)
{
return (idx >= 0 && idx < lisael_podcast_count()) ? s_shows[idx].id : "";
}
void lisael_podcast_local_path(int idx, char *buf, size_t n)
{
snprintf(buf, n, "%s/%s.m4a", PODCAST_DIR, lisael_podcast_id(idx));
}
bool lisael_podcast_have_local(int idx)
{
char path[96];
lisael_podcast_local_path(idx, path, sizeof(path));
struct stat st;
return (stat(path, &st) == 0 && st.st_size > 0);
}
// --- RSS: stream the feed and grab the first <enclosure url="..."> ----------
typedef struct {
char buf[49152]; // accumulator (heap/PSRAM): some feeds (Bestioles) have a
// large channel header before the first <enclosure>
int len;
bool found;
char url[512];
} rss_accum_t;
static void rss_try_extract(rss_accum_t *a)
{
char *enc = strstr(a->buf, "<enclosure");
if (!enc) {
return;
}
char *u = strstr(enc, "url=\"");
if (!u) {
return;
}
u += 5;
char *end = strchr(u, '"');
if (!end) {
return; // URL not fully received yet
}
size_t n = (size_t)(end - u);
if (n >= sizeof(a->url)) {
n = sizeof(a->url) - 1;
}
memcpy(a->url, u, n);
a->url[n] = '\0';
a->found = true;
}
static esp_err_t rss_event(esp_http_client_event_t *evt)
{
if (evt->event_id != HTTP_EVENT_ON_DATA) {
return ESP_OK;
}
rss_accum_t *a = (rss_accum_t *)evt->user_data;
if (a->found) {
return ESP_FAIL; // abort the transfer early — we have what we need
}
int space = (int)sizeof(a->buf) - 1 - a->len;
if (space > 0) {
int cp = evt->data_len < space ? evt->data_len : space;
memcpy(a->buf + a->len, evt->data, cp);
a->len += cp;
a->buf[a->len] = '\0';
rss_try_extract(a);
}
if (a->found || space <= 0) {
return ESP_FAIL; // stop: found, or our window is full
}
return ESP_OK;
}
esp_err_t lisael_podcast_latest_url(int idx, char *url_out, size_t url_sz)
{
if (idx < 0 || idx >= lisael_podcast_count() || !url_out) {
return ESP_ERR_INVALID_ARG;
}
// Heap (PSRAM) — 8 KB, must not sit in internal BSS (would starve Wi-Fi).
rss_accum_t *acc = calloc(1, sizeof(*acc));
if (!acc) {
return ESP_ERR_NO_MEM;
}
esp_http_client_config_t cfg = {
.url = s_shows[idx].rss_url,
.event_handler = rss_event,
.user_data = acc,
.timeout_ms = 15000,
.crt_bundle_attach = esp_crt_bundle_attach,
.buffer_size = 2048,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (!client) {
free(acc);
return ESP_FAIL;
}
esp_http_client_perform(client); // may "fail" because we abort early
esp_http_client_cleanup(client);
esp_err_t ret = ESP_FAIL;
if (acc->found) {
strlcpy(url_out, acc->url, url_sz);
ESP_LOGI(TAG, "[%s] latest: %s", s_shows[idx].id, url_out);
ret = ESP_OK;
} else {
ESP_LOGW(TAG, "[%s] no enclosure found in RSS", s_shows[idx].id);
}
free(acc);
return ret;
}
// --- download the enclosure to SD -------------------------------------------
typedef struct {
FILE *f;
int written;
char redirect[600]; // Location header captured from the 3xx response
} dl_accum_t;
static esp_err_t dl_event(esp_http_client_event_t *evt)
{
dl_accum_t *d = (dl_accum_t *)evt->user_data;
if (evt->event_id == HTTP_EVENT_ON_HEADER) {
// Response headers arrive here (get_header() only reads *request*
// headers). Capture Location so we can follow the redirect manually.
if (evt->header_key && strcasecmp(evt->header_key, "Location") == 0) {
strlcpy(d->redirect, evt->header_value, sizeof(d->redirect));
}
} else if (evt->event_id == HTTP_EVENT_ON_DATA) {
if (d->f && evt->data_len > 0) {
d->written += (int)fwrite(evt->data, 1, evt->data_len, d->f);
}
}
return ESP_OK;
}
static void read_sidecar(const char *path, char *out, size_t n)
{
out[0] = '\0';
char side[160];
snprintf(side, sizeof(side), "%s.url", path);
FILE *f = fopen(side, "r");
if (f) {
size_t r = fread(out, 1, n - 1, f);
out[r] = '\0';
// trim trailing newline
char *nl = strchr(out, '\n');
if (nl) *nl = '\0';
fclose(f);
}
}
static void write_sidecar(const char *path, const char *url)
{
char side[160];
snprintf(side, sizeof(side), "%s.url", path);
FILE *f = fopen(side, "w");
if (f) {
fputs(url, f);
fclose(f);
}
}
esp_err_t lisael_podcast_sync(int idx)
{
if (idx < 0 || idx >= lisael_podcast_count()) {
return ESP_ERR_INVALID_ARG;
}
if (lisael_sd_mount() != ESP_OK) {
return ESP_ERR_NOT_FOUND;
}
char url[512];
if (lisael_podcast_latest_url(idx, url, sizeof(url)) != ESP_OK) {
return ESP_FAIL;
}
char path[96];
lisael_podcast_local_path(idx, path, sizeof(path));
// Already have this exact episode? Skip the (large) re-download.
char stored[512];
read_sidecar(path, stored, sizeof(stored));
if (lisael_podcast_have_local(idx) && strcmp(stored, url) == 0) {
ESP_LOGI(TAG, "[%s] up to date", s_shows[idx].id);
return ESP_OK;
}
mkdir(PODCAST_DIR, 0777);
FILE *f = fopen(path, "wb");
if (!f) {
ESP_LOGE(TAG, "[%s] cannot open %s for write", s_shows[idx].id, path);
return ESP_FAIL;
}
ESP_LOGI(TAG, "[%s] downloading...", s_shows[idx].id);
// proxycast.radiofrance.fr issues a 302 to media.radiofrance-podcast.net.
// Follow redirects with a FRESH client per hop (reusing one handle across a
// host change leaves it in a bad state); 3xx bodies are empty so the file
// only fills on the final 200.
dl_accum_t d = { .f = f, .written = 0 };
char cur[600];
strlcpy(cur, url, sizeof(cur));
esp_err_t err = ESP_FAIL;
int status = 0;
for (int hop = 0; hop < 5; hop++) {
d.redirect[0] = '\0';
esp_http_client_config_t cfg = {
.url = cur,
.event_handler = dl_event,
.user_data = &d,
.timeout_ms = 30000,
.crt_bundle_attach = esp_crt_bundle_attach,
.buffer_size = 8192, // media.radiofrance sends large CDN
.buffer_size_tx = 2048, // headers + long signed URLs
.disable_auto_redirect = true,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
err = esp_http_client_perform(client);
status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
if ((status == 301 || status == 302 || status == 303 ||
status == 307 || status == 308) && d.redirect[0]) {
ESP_LOGI(TAG, "[%s] -> redirect to %.60s...", s_shows[idx].id, d.redirect);
strlcpy(cur, d.redirect, sizeof(cur));
continue;
}
break;
}
fclose(f);
if (err == ESP_OK && status == 200 && d.written > 0) {
write_sidecar(path, url);
ESP_LOGI(TAG, "[%s] downloaded %d bytes -> %s", s_shows[idx].id, d.written, path);
return ESP_OK;
}
ESP_LOGE(TAG, "[%s] download failed err=%s status=%d written=%d",
s_shows[idx].id, esp_err_to_name(err), status, d.written);
remove(path);
return ESP_FAIL;
}
// --- list downloaded episodes on the SD (for the "Sons" browser) ------------
int lisael_podcast_list_local(char paths[][128], char labels[][48], int max)
{
DIR *d = opendir(PODCAST_DIR);
if (!d) {
return 0;
}
int n = 0;
struct dirent *e;
while ((e = readdir(d)) != NULL && n < max) {
const char *name = e->d_name;
size_t len = strlen(name);
if (len < 5 || strcmp(name + len - 4, ".m4a") != 0) {
continue; // only .m4a episode files
}
snprintf(paths[n], 128, "%s/%s", PODCAST_DIR, name);
// Base name without extension; map a show id to its nice title.
char base[48];
size_t bl = len - 4;
if (bl >= sizeof(base)) {
bl = sizeof(base) - 1;
}
memcpy(base, name, bl);
base[bl] = '\0';
const char *label = base;
for (int i = 0; i < lisael_podcast_count(); i++) {
if (strcmp(base, lisael_podcast_id(i)) == 0) {
label = lisael_podcast_title(i);
break;
}
}
strlcpy(labels[n], label, 48);
n++;
}
closedir(d);
ESP_LOGI(TAG, "local episodes: %d", n);
return n;
}
+43
View File
@@ -0,0 +1,43 @@
// Radio France youth podcasts for Lisael Box.
//
// Fetches a show's RSS feed over HTTPS, extracts the latest episode's audio
// enclosure (AAC/.m4a) and downloads it to the microSD card, where the AAC
// player (aac_player.c) plays it offline. Hybrid model: a background prefetch
// keeps the latest episode local; the Podcasts screen plays the local file.
#pragma once
#include <stddef.h>
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// Number of configured shows, and per-show accessors.
int lisael_podcast_count(void);
const char *lisael_podcast_title(int idx);
const char *lisael_podcast_id(int idx);
// Absolute SD path of a show's downloaded episode ("/sdcard/podcasts/<id>.m4a").
void lisael_podcast_local_path(int idx, char *buf, size_t n);
// True if a downloaded episode exists locally for this show.
bool lisael_podcast_have_local(int idx);
// Fetch the RSS feed and write the latest episode's enclosure URL into url_out.
esp_err_t lisael_podcast_latest_url(int idx, char *url_out, size_t url_sz);
// Ensure the latest episode is downloaded to SD. Fetches the RSS, and if the
// latest enclosure differs from what's stored (or no local file), downloads it.
// Returns ESP_OK when a local file is ready to play.
esp_err_t lisael_podcast_sync(int idx);
// List the downloaded episode files on the SD card (/sdcard/podcasts/*.m4a).
// Fills paths[i] (absolute) and labels[i] (human title). Returns the count
// (<= max). Used by the "Sons" screen to browse/play downloaded episodes.
int lisael_podcast_list_local(char paths[][128], char labels[][48], int max);
#ifdef __cplusplus
}
#endif
+22
View File
@@ -0,0 +1,22 @@
// Lightweight UDP reporter: send debug lines over the LAN (the BOX-3
// USB-Serial-JTAG console stops flushing after boot). Called directly from
// tasks (NOT a log vprintf hook — that deadlocks: the log mutex is held while
// sendto -> lwip might re-enter the logger). Capture on a host on the same LAN
// with `nc -ulk 9999` or a UDP listener.
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Open the broadcast socket. Call once after Wi-Fi has an IP.
void lisael_udp_log_start(uint16_t port);
// Send one line (printf-style) as a UDP broadcast datagram. Non-blocking.
void lisael_udp_send(const char *fmt, ...);
#ifdef __cplusplus
}
#endif
+5 -2
View File
@@ -110,8 +110,12 @@ static esp_err_t http_event(esp_http_client_event_t *evt)
static bool fetch_once(const char *lat, const char *lon, lisael_weather_t *out)
{
char url[256];
// Plain HTTP on purpose: weather is public, non-sensitive data, and the TLS
// handshake's transient RAM otherwise starves the Wi-Fi RX DMA buffers
// ("wifi:mem fail") now that the LVGL draw buffer lives in internal RAM.
// HTTP avoids mbedtls entirely so the fetch is reliable.
snprintf(url, sizeof(url),
"https://api.open-meteo.com/v1/forecast"
"http://api.open-meteo.com/v1/forecast"
"?latitude=%s&longitude=%s&current=temperature_2m,weather_code",
lat, lon);
@@ -126,7 +130,6 @@ static bool fetch_once(const char *lat, const char *lon, lisael_weather_t *out)
.event_handler = http_event,
.user_data = &acc,
.timeout_ms = 8000,
.crt_bundle_attach = esp_crt_bundle_attach, // uses the CA bundle
};
esp_http_client_handle_t client = esp_http_client_init(&config);
bool ok = false;
+18
View File
@@ -0,0 +1,18 @@
// Fredoka (OFL) converted to LVGL fonts with French accents (Latin-1 range
// 0xA0-0xFF: é è à ç ù â ê î ô û ë ï ü ...). Rounded, kid-friendly. Generated
// with lv_font_conv from tools — see ui/fonts/CREDITS.md.
#pragma once
#include "lvgl.h"
#ifdef __cplusplus
extern "C" {
#endif
extern const lv_font_t lisael_font_18; // body / tiles
extern const lv_font_t lisael_font_24; // sub-headers
extern const lv_font_t lisael_font_30; // clock / big
#ifdef __cplusplus
}
#endif
+23 -3
View File
@@ -1,5 +1,25 @@
// Icon helpers translation unit — currently header-only (macros in icons.h).
// Kept as a compiled unit so future icon image descriptors / converted emoji
// fonts have a home and the build file list stays stable.
// Icon helpers for the Lisael Box UI.
//
// The home tiles and weather use real colour icons (Twemoji PNGs converted to
// LVGL RGB565A8 image descriptors, in ui/assets/). This file maps open-meteo
// WMO weather codes to the matching icon.
#include "ui/icons.h"
#include "ui/assets/lisael_icons.h"
const lv_image_dsc_t *lisael_weather_code_icon(int code)
{
switch (code) {
case 0: return &lisael_ic_wsun; // clear
case 1: case 2: case 3: return &lisael_ic_wpartly; // partly cloudy
case 45: case 48: return &lisael_ic_wfog; // fog
case 51: case 53: case 55: return &lisael_ic_wdrizzle; // drizzle
case 61: case 63: case 65: return &lisael_ic_wrain; // rain
case 66: case 67: return &lisael_ic_wsleet; // freezing rain
case 71: case 73: case 75:
case 77: case 85: case 86: return &lisael_ic_wsnow; // snow
case 80: case 81: case 82: return &lisael_ic_wrain; // showers
case 95: case 96: case 99: return &lisael_ic_wthunder; // thunderstorm
default: return &lisael_ic_wunknown;
}
}
+12
View File
@@ -26,3 +26,15 @@ CONFIG_ESP_TLS_INSECURE=n
# --- esp-box BSP -------------------------------------------------------------
# SD card mount point used throughout the firmware (see audio/sd_player.c).
CONFIG_BSP_SD_MOUNT_POINT="/sdcard"
# Polices LVGL utilisées par l'UI Lisael Box (titres date/heure, gros boutons)
CONFIG_LV_FONT_MONTSERRAT_28=y
CONFIG_LV_FONT_MONTSERRAT_48=y
# GCC 14 (esp 14.2) ajoute des warnings -Werror agressifs (array-bounds faux positifs)
CONFIG_COMPILER_DISABLE_GCC14_WARNINGS=y
# Diagnostic : console primaire sur USB-Serial-JTAG (logs boot+app sur l'USB-C),
# car l'UART0 par défaut n'est pas exposé sur le port USB de la carte.
CONFIG_ESP_CONSOLE_UART_DEFAULT=n
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y