feat(ui): add LVGL UI shell, modes and app_main

Context:
  The box is a touch device for a young child: big icon buttons, no
  reading required, gentle visuals, no game with a losing state.

Approach:
  An LVGL 9 shell (ui/) with a mode router that lazily builds and caches
  each screen and slides between them. Shared pastel styles, an
  icon-first home with French date in letters (custom fr_date, no
  setlocale), live time/weather/chip-temp hooks that take the display
  lock. Six modes: home, radio, soundbox, calm (breathing bubble),
  clock/night-light, games (Memory + Compter implemented, Coloriage a
  documented stub). app_main sequences NVS -> BSP -> codec -> SD -> UI
  and spawns the net and tick tasks.

Changes:
  - ui/ui, ui/fr_date, ui/icons
  - modes/{home,radio,soundbox,calm,clock,games}, modes/radios.h
  - app_main.c boot sequence + tasks

Impact:
  Full feature surface present; emoji glyphs need an embedded font and
  several BSP/LVGL assumptions need a real board (see README).
This commit is contained in:
Clément Saillant
2026-06-14 00:40:32 +02:00
parent a53d7e273b
commit bc2d1ecb1c
14 changed files with 1468 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
// Lisael Box — application entry point.
//
// Boot sequence:
// 1. NVS (required before Wi-Fi)
// 2. BSP I2C + display + touch + LVGL (esp-box BSP)
// 3. Audio codec (ES8311 via BSP)
// 4. microSD mount (best-effort)
// 5. Build the LVGL UI (home screen + mode router)
// 6. Wi-Fi station start, then SNTP + weather once connected (background)
// 7. A light "clock tick" task refreshes the home/clock once per minute.
//
// app_main returns; the LVGL task (created by the BSP) and our FreeRTOS tasks
// keep the system alive.
#include "lisael_config.h"
#include "ui/ui.h"
#include "net/wifi.h"
#include "net/sntp_time.h"
#include "net/weather.h"
#include "audio/audio_player.h"
#include "audio/sd_player.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "bsp/esp-box-3.h"
static const char *TAG = "app";
// Called by the weather task whenever a fetch completes — push to the home UI.
static void on_weather_update(const lisael_weather_t *w)
{
(void)w;
lisael_home_update_weather();
}
// Light periodic task: refresh the clock label every minute, plus the internal
// sensor readout. Cheap; keeps the home screen current without a busy loop.
static void tick_task(void *arg)
{
(void)arg;
for (;;) {
lisael_home_update_clock();
lisael_home_update_sensors();
vTaskDelay(pdMS_TO_TICKS(60 * 1000));
}
}
// Networking bring-up runs off the main path so the UI is responsive even with
// no Wi-Fi. Blocks (in its own task) on association, then starts SNTP+weather.
static void net_task(void *arg)
{
(void)arg;
ESP_ERROR_CHECK(lisael_wifi_start());
if (lisael_wifi_wait_connected(30000)) {
ESP_LOGI(TAG, "Wi-Fi up — starting SNTP + weather");
lisael_sntp_start();
lisael_weather_start(on_weather_update);
// Nudge the clock once time is likely valid.
vTaskDelay(pdMS_TO_TICKS(3000));
lisael_home_update_clock();
} else {
ESP_LOGW(TAG, "Wi-Fi not connected after 30s — offline mode");
// UI still works (radio/weather show "—"); Wi-Fi keeps retrying.
}
vTaskDelete(NULL);
}
void app_main(void)
{
ESP_LOGI(TAG, "Lisael Box starting");
// --- 1. NVS --------------------------------------------------------------
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
// --- 2. BSP: I2C, display + touch + LVGL --------------------------------
ESP_ERROR_CHECK(bsp_i2c_init());
bsp_display_start(); // brings up LCD, touch and the LVGL task
bsp_display_backlight_on();
bsp_display_brightness_set(80); // comfortable default brightness
// --- 3. Audio codec ------------------------------------------------------
if (lisael_audio_init() != ESP_OK) {
ESP_LOGE(TAG, "audio init failed — continuing without sound");
}
// --- 4. microSD (best-effort) -------------------------------------------
lisael_sd_mount(); // logs a warning if no card; modes handle absence
// --- 5. Build the UI (must hold the display lock) -----------------------
if (bsp_display_lock(0)) {
lisael_ui_init();
bsp_display_unlock();
}
// --- 6 & 7. Networking + periodic refresh tasks -------------------------
xTaskCreatePinnedToCore(net_task, "net", 8192, NULL, 4, NULL, tskNO_AFFINITY);
xTaskCreatePinnedToCore(tick_task, "tick", 4096, NULL, 3, NULL, tskNO_AFFINITY);
ESP_LOGI(TAG, "Lisael Box ready");
}
+119
View File
@@ -0,0 +1,119 @@
// "Coin calme" / breathing mode for Lisael Box.
//
// A circle (the "bubble") inflates over ~4 s ("Inspire…") then deflates over
// ~6 s ("Souffle…"), looping. Driven by two chained LVGL animations on the
// bubble's width/height. An optional soft sound can be played from the SD card
// if present (best-effort, ignored on failure).
#include "ui/ui.h"
#include "ui/icons.h"
#include "audio/sd_player.h"
#include "esp_log.h"
static const char *TAG = "ui.calm";
#define BUBBLE_MIN 70
#define BUBBLE_MAX 200
#define INHALE_MS 4000
#define EXHALE_MS 6000
static lv_obj_t *s_root;
static lv_obj_t *s_bubble;
static lv_obj_t *s_phase_lbl;
static void anim_size_cb(void *obj, int32_t v)
{
lv_obj_set_size((lv_obj_t *)obj, v, v);
}
// Forward decls for the inhale<->exhale chain.
static void start_inhale(void);
static void start_exhale(void);
static void on_inhale_done(lv_anim_t *a)
{
(void)a;
start_exhale();
}
static void on_exhale_done(lv_anim_t *a)
{
(void)a;
start_inhale();
}
static void start_inhale(void)
{
if (s_phase_lbl) {
lv_label_set_text(s_phase_lbl, "Inspire…");
}
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, s_bubble);
lv_anim_set_exec_cb(&a, anim_size_cb);
lv_anim_set_values(&a, BUBBLE_MIN, BUBBLE_MAX);
lv_anim_set_duration(&a, INHALE_MS);
lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out);
lv_anim_set_completed_cb(&a, on_inhale_done);
lv_anim_start(&a);
}
static void start_exhale(void)
{
if (s_phase_lbl) {
lv_label_set_text(s_phase_lbl, "Souffle…");
}
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, s_bubble);
lv_anim_set_exec_cb(&a, anim_size_cb);
lv_anim_set_values(&a, BUBBLE_MAX, BUBBLE_MIN);
lv_anim_set_duration(&a, EXHALE_MS);
lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out);
lv_anim_set_completed_cb(&a, on_exhale_done);
lv_anim_start(&a);
}
// Stop the animation when we leave the screen so it doesn't run forever.
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, "breathing started");
start_inhale();
// Best-effort soft ambience (optional file; ignored if absent).
lisael_sd_play_sound("calme.mp3");
} else if (code == LV_EVENT_SCREEN_UNLOADED) {
lv_anim_delete(s_bubble, anim_size_cb);
}
}
lv_obj_t *lisael_screen_calm(void)
{
if (s_root) {
return s_root;
}
s_root = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xE7F5FF), 0); // calm blue
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);
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_border_width(s_bubble, 0, 0);
lv_obj_clear_flag(s_bubble, 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_align(s_phase_lbl, LV_ALIGN_BOTTOM_MID, 0, -16);
return s_root;
}
+104
View File
@@ -0,0 +1,104 @@
// Horloge / veilleuse mode for Lisael Box.
//
// A large centred time + French date on a soft, dimmed background (gentle for a
// dark bedroom). A "Histoire du soir" button plays a bedtime story from the SD
// card. A periodic LVGL timer refreshes the time once a minute.
#include "ui/ui.h"
#include "ui/icons.h"
#include "ui/fr_date.h"
#include "audio/sd_player.h"
#include "net/sntp_time.h"
#include <time.h>
#include "esp_log.h"
#include "bsp/esp-box-3.h"
static const char *TAG = "ui.clock";
static lv_obj_t *s_root;
static lv_obj_t *s_time_lbl;
static lv_obj_t *s_date_lbl;
static lv_timer_t *s_timer;
static void refresh(void)
{
time_t now = time(NULL);
struct tm tm;
localtime_r(&now, &tm);
char time_buf[8], date_buf[48];
lisael_fr_time(&tm, time_buf, sizeof(time_buf));
lisael_fr_date(&tm, date_buf, sizeof(date_buf));
lv_label_set_text(s_time_lbl, lisael_time_is_valid() ? time_buf : "--:--");
lv_label_set_text(s_date_lbl, date_buf);
}
static void timer_cb(lv_timer_t *t)
{
(void)t;
refresh(); // already on the LVGL task -> no extra lock needed
}
static void story_cb(lv_event_t *e)
{
(void)e;
ESP_LOGI(TAG, "histoire du soir");
// Plays /sdcard/sounds/histoire.mp3 if present (best-effort).
lisael_sd_play_sound("histoire.mp3");
}
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) {
// Dim the backlight for a soft night-light feel.
bsp_display_brightness_set(30);
if (!s_timer) {
s_timer = lv_timer_create(timer_cb, 30000, NULL); // every 30 s
}
lv_timer_resume(s_timer);
refresh();
} else if (code == LV_EVENT_SCREEN_UNLOADED) {
if (s_timer) {
lv_timer_pause(s_timer);
}
bsp_display_brightness_set(80); // restore normal brightness
}
}
lv_obj_t *lisael_screen_clock(void)
{
if (s_root) {
return s_root;
}
s_root = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_root, lv_color_hex(0x1E2233), 0); // soft night
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);
s_time_lbl = lv_label_create(s_root);
lv_obj_set_style_text_font(s_time_lbl, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(s_time_lbl, lv_color_hex(0xFFE8B0), 0); // warm
lv_label_set_text(s_time_lbl, "--:--");
lv_obj_align(s_time_lbl, LV_ALIGN_CENTER, 0, -20);
s_date_lbl = lv_label_create(s_root);
lv_obj_set_style_text_color(s_date_lbl, lv_color_hex(0xC0C8E0), 0);
lv_label_set_text(s_date_lbl, "...");
lv_obj_align(s_date_lbl, LV_ALIGN_CENTER, 0, 24);
lv_obj_t *story = lv_button_create(s_root);
lv_obj_add_style(story, lisael_ui_style_button(), 0);
lv_obj_set_style_bg_color(story, lv_color_hex(0x5C7CFA), 0);
lv_obj_align(story, LV_ALIGN_BOTTOM_MID, 0, -12);
lv_obj_add_event_cb(story, story_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *lbl = lv_label_create(story);
lv_label_set_text(lbl, LISAEL_EMOJI_CALM " Histoire du soir");
lv_obj_center(lbl);
return s_root;
}
+339
View File
@@ -0,0 +1,339 @@
// Jeux mode for Lisael Box: a small menu -> Memory, Compter, (Coloriage stub).
//
// Design rule: no losing, no scores that shame — only positive feedback (a star
// / "Bravo !"). All games are LVGL-only and fit the 320x240 screen.
//
// * Memory : 3x2 grid of 6 cards (3 emoji pairs). Flip two; if they match
// they stay revealed; otherwise they flip back. Win = all matched.
// * Compter : show N emoji objects, offer 3 number buttons, tap the right
// count -> "Bravo !". New round on success.
// * Coloriage: documented STUB (free-draw on a canvas is heavier on 320x240
// and needs an lv_canvas + touch drawing loop; left as a labelled
// placeholder screen). See note in build_coloring_stub().
#include "ui/ui.h"
#include "ui/icons.h"
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include "esp_log.h"
#include "esp_random.h"
static const char *TAG = "ui.games";
static lv_obj_t *s_menu_root;
// ---------------------------------------------------------------------------
// Shared: a positive-feedback toast ("Bravo ! ⭐").
// ---------------------------------------------------------------------------
static void show_bravo(lv_obj_t *parent)
{
lv_obj_t *toast = lv_obj_create(parent);
lv_obj_set_size(toast, 200, 70);
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_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);
// Auto-dismiss after 1.5 s.
lv_obj_delete_delayed(toast, 1500);
}
// ===========================================================================
// MEMORY
// ===========================================================================
#define MEM_PAIRS 3
#define MEM_CARDS (MEM_PAIRS * 2)
static const char *const MEM_FACES[MEM_PAIRS] = {
"\U0001F431", // 🐱
"\U0001F436", // 🐶
"\U0001F438", // 🐸
};
typedef struct {
lv_obj_t *cards[MEM_CARDS];
int face[MEM_CARDS]; // index into MEM_FACES
bool matched[MEM_CARDS];
int first; // currently revealed card, or -1
int matched_count;
lv_obj_t *root;
} memory_state_t;
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_set_style_bg_color(st->cards[i],
revealed ? lv_color_hex(0xB2F2BB) : lv_color_hex(0x99C8FF), 0);
}
static void mem_flip_back_cb(lv_timer_t *t)
{
memory_state_t *st = lv_timer_get_user_data(t);
for (int i = 0; i < MEM_CARDS; i++) {
if (!st->matched[i]) {
mem_set_revealed(st, i, false);
}
}
st->first = -1;
lv_timer_delete(t);
}
static void mem_card_cb(lv_event_t *e)
{
memory_state_t *st = lv_event_get_user_data(e);
lv_obj_t *card = lv_event_get_target_obj(e);
int idx = -1;
for (int i = 0; i < MEM_CARDS; i++) {
if (st->cards[i] == card) { idx = i; break; }
}
if (idx < 0 || st->matched[idx] || idx == st->first) {
return; // ignore taps on matched / already-open card
}
mem_set_revealed(st, idx, true);
if (st->first < 0) {
st->first = idx; // first of the pair
return;
}
// Second card: check match.
if (st->face[idx] == st->face[st->first]) {
st->matched[idx] = st->matched[st->first] = true;
st->matched_count += 2;
st->first = -1;
if (st->matched_count == MEM_CARDS) {
show_bravo(st->root);
}
} else {
// Flip both back after a short delay.
lv_timer_t *t = lv_timer_create(mem_flip_back_cb, 800, st);
lv_timer_set_repeat_count(t, 1);
}
}
static void mem_build(lv_obj_t *parent)
{
memory_state_t *st = lv_malloc(sizeof(memory_state_t));
memset(st, 0, sizeof(*st));
st->first = -1;
st->root = parent;
// Build and shuffle the deck.
for (int i = 0; i < MEM_CARDS; i++) {
st->face[i] = i % MEM_PAIRS;
}
for (int i = MEM_CARDS - 1; i > 0; i--) {
int j = (int)(esp_random() % (uint32_t)(i + 1));
int tmp = st->face[i]; st->face[i] = st->face[j]; st->face[j] = tmp;
}
lv_obj_t *grid = lv_obj_create(parent);
lv_obj_remove_style_all(grid);
lv_obj_set_size(grid, LV_PCT(100), 180);
lv_obj_align(grid, LV_ALIGN_BOTTOM_MID, 0, -4);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_SPACE_EVENLY,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
for (int i = 0; i < MEM_CARDS; i++) {
lv_obj_t *card = lv_button_create(grid);
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);
st->cards[i] = card;
mem_set_revealed(st, i, false);
}
// NOTE: `st` intentionally lives for the screen's lifetime (freed when the
// screen object is deleted). For a long-lived single-screen app this is
// fine; if screens are recreated, attach a delete handler to free `st`.
}
// ===========================================================================
// COMPTER ("combien d'objets ?")
// ===========================================================================
typedef struct {
lv_obj_t *objects_box;
lv_obj_t *buttons[3];
int answer;
lv_obj_t *root;
} count_state_t;
static void count_new_round(count_state_t *st);
static void count_btn_cb(lv_event_t *e)
{
count_state_t *st = lv_event_get_user_data(e);
lv_obj_t *btn = lv_event_get_target_obj(e);
lv_obj_t *lbl = lv_obj_get_child(btn, 0);
int chosen = atoi(lv_label_get_text(lbl));
if (chosen == st->answer) {
show_bravo(st->root);
count_new_round(st);
}
// Wrong answer: no penalty, no message — child just tries again.
}
static void count_new_round(count_state_t *st)
{
// 1..5 objects.
st->answer = 1 + (int)(esp_random() % 5);
// 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);
}
// Offer the right answer + two distractors in a shuffled set.
int opts[3];
opts[0] = st->answer;
for (int i = 1; i < 3; i++) {
int v;
do { v = 1 + (int)(esp_random() % 5); }
while (v == opts[0] || (i == 2 && v == opts[1]));
opts[i] = v;
}
for (int i = 2; i > 0; i--) {
int j = (int)(esp_random() % (uint32_t)(i + 1));
int tmp = opts[i]; opts[i] = opts[j]; opts[j] = tmp;
}
for (int i = 0; i < 3; i++) {
lv_obj_t *lbl = lv_obj_get_child(st->buttons[i], 0);
lv_label_set_text_fmt(lbl, "%d", opts[i]);
}
}
static void count_build(lv_obj_t *parent)
{
count_state_t *st = lv_malloc(sizeof(count_state_t));
memset(st, 0, sizeof(*st));
st->root = parent;
lv_obj_t *q = lv_label_create(parent);
lv_label_set_text(q, "Combien ?");
lv_obj_align(q, LV_ALIGN_TOP_MID, 0, 50);
st->objects_box = lv_obj_create(parent);
lv_obj_remove_style_all(st->objects_box);
lv_obj_set_size(st->objects_box, LV_PCT(90), 80);
lv_obj_align(st->objects_box, LV_ALIGN_CENTER, 0, -10);
lv_obj_set_flex_flow(st->objects_box, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(st->objects_box, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_t *bar = lv_obj_create(parent);
lv_obj_remove_style_all(bar);
lv_obj_set_size(bar, LV_PCT(100), 64);
lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -6);
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);
for (int 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, 72, 56);
lv_obj_add_event_cb(b, count_btn_cb, LV_EVENT_CLICKED, st);
lv_obj_t *l = lv_label_create(b);
lv_obj_set_style_text_font(l, &lv_font_montserrat_28, 0);
lv_label_set_text(l, "0");
lv_obj_center(l);
st->buttons[i] = b;
}
count_new_round(st);
}
// ===========================================================================
// COLORIAGE (documented stub)
// ===========================================================================
static void build_coloring_stub(lv_obj_t *parent)
{
// STUB: a real coloring game would use an lv_canvas (e.g. 280x180 in PSRAM)
// plus an LV_EVENT_PRESSING handler reading the touch point and painting a
// brush of the selected color via lv_canvas_set_px / lv_draw on the canvas
// buffer. That is doable but heavier on a 320x240 screen and needs a tuned
// brush/undo UX for small children, so it is intentionally deferred.
lv_obj_t *lbl = lv_label_create(parent);
lv_label_set_text(lbl,
"Coloriage\n(bientôt)\n\n🎨");
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(lbl);
}
// ===========================================================================
// Game sub-screen wrapper + games menu
// ===========================================================================
typedef void (*game_builder_t)(lv_obj_t *parent);
static void open_game(game_builder_t builder, const char *title)
{
lv_obj_t *scr = lv_obj_create(NULL);
lv_obj_set_style_bg_color(scr, lv_color_hex(0xF3F0FF), 0);
lisael_ui_add_back_button(scr); // returns HOME
lv_obj_t *t = lv_label_create(scr);
lv_label_set_text(t, title);
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 16);
builder(scr);
lv_screen_load_anim(scr, LV_SCR_LOAD_ANIM_MOVE_LEFT, 250, 0, false);
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"); }
lv_obj_t *lisael_screen_games(void)
{
if (s_menu_root) {
return s_menu_root;
}
s_menu_root = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_menu_root, lv_color_hex(0xF3F0FF), 0);
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_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
lv_obj_t *list = lv_obj_create(s_menu_root);
lv_obj_remove_style_all(list);
lv_obj_set_size(list, LV_PCT(100), 150);
lv_obj_align(list, LV_ALIGN_CENTER, 0, 20);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(list, LV_FLEX_ALIGN_CENTER,
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 },
};
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_add_event_cb(b, games[i].cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *l = lv_label_create(b);
lv_label_set_text(l, games[i].txt);
lv_obj_center(l);
}
return s_menu_root;
}
+224
View File
@@ -0,0 +1,224 @@
// Home screen for Lisael Box: French date + time, weather, sensors, mode tiles.
//
// Live-update hooks (lisael_home_update_*) are called from background tasks and
// take the display lock themselves. The build function runs on the LVGL task
// (lock already held by the caller in app_main / lisael_ui_goto).
#include "ui/ui.h"
#include "ui/icons.h"
#include "ui/fr_date.h"
#include "net/weather.h"
#include "net/sntp_time.h"
#include "lisael_config.h"
#include <time.h>
#include <stdio.h>
#include <stdint.h>
#include "esp_log.h"
#include "bsp/esp-box-3.h"
// Optional internal temperature sensor (ESP32-S3 has a built-in one). The
// BOX-3 has no external temp/humidity sensor on the base board (the BSP exposes
// an aht30 driver for the optional sensor add-on; not assumed present here).
#include "driver/temperature_sensor.h"
static const char *TAG = "home";
static lv_obj_t *s_date_lbl;
static lv_obj_t *s_time_lbl;
static lv_obj_t *s_weather_lbl;
static lv_obj_t *s_weather_icon;
static lv_obj_t *s_sensor_lbl;
static lv_obj_t *s_root;
static temperature_sensor_handle_t s_tsens; // NULL if unavailable
// --- one mode tile ----------------------------------------------------------
typedef struct {
const char *emoji;
const char *label;
lisael_mode_t mode;
lv_color_t color;
} tile_def_t;
static void tile_cb(lv_event_t *e)
{
lisael_mode_t mode = (lisael_mode_t)(intptr_t)lv_event_get_user_data(e);
lisael_ui_goto(mode);
}
static lv_obj_t *make_tile(lv_obj_t *parent, const tile_def_t *def)
{
lv_obj_t *tile = lv_button_create(parent);
lv_obj_add_style(tile, lisael_ui_style_tile(), 0);
lv_obj_set_style_bg_color(tile, def->color, 0);
lv_obj_set_size(tile, 92, 78);
lv_obj_add_event_cb(tile, tile_cb, LV_EVENT_CLICKED,
(void *)(intptr_t)def->mode);
lv_obj_t *col = lv_obj_create(tile);
lv_obj_remove_style_all(col);
lv_obj_set_size(col, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(col, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t *emoji = lv_label_create(col);
lv_label_set_text(emoji, def->emoji);
lv_obj_t *txt = lv_label_create(col);
lv_label_set_text(txt, def->label);
return tile;
}
// --- internal temperature sensor (graceful fallback) ------------------------
static void sensors_init(void)
{
temperature_sensor_config_t cfg =
TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80);
if (temperature_sensor_install(&cfg, &s_tsens) == ESP_OK &&
temperature_sensor_enable(s_tsens) == ESP_OK) {
ESP_LOGI(TAG, "internal temperature sensor ready");
} else {
s_tsens = NULL;
ESP_LOGW(TAG, "internal temperature sensor unavailable");
}
}
lv_obj_t *lisael_screen_home(void)
{
if (s_root) {
return s_root;
}
sensors_init();
s_root = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xEAF4FF), 0); // soft sky
// --- header: date + time + weather --------------------------------------
lv_obj_t *header = lv_obj_create(s_root);
lv_obj_remove_style_all(header);
lv_obj_set_size(header, LV_PCT(100), 86);
lv_obj_align(header, LV_ALIGN_TOP_MID, 0, 4);
lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE);
s_time_lbl = lv_label_create(header);
lv_obj_set_style_text_font(s_time_lbl, &lv_font_montserrat_28, 0);
lv_label_set_text(s_time_lbl, "--:--");
lv_obj_align(s_time_lbl, LV_ALIGN_TOP_LEFT, 12, 2);
s_date_lbl = lv_label_create(header);
lv_label_set_text(s_date_lbl, "...");
lv_obj_align(s_date_lbl, LV_ALIGN_TOP_LEFT, 12, 38);
s_weather_icon = lv_label_create(header);
lv_label_set_text(s_weather_icon, "");
lv_obj_align(s_weather_icon, LV_ALIGN_TOP_RIGHT, -12, 2);
s_weather_lbl = lv_label_create(header);
lv_label_set_text(s_weather_lbl, "—");
lv_obj_align(s_weather_lbl, LV_ALIGN_TOP_RIGHT, -12, 34);
s_sensor_lbl = lv_label_create(header);
lv_label_set_text(s_sensor_lbl, "");
lv_obj_align(s_sensor_lbl, LV_ALIGN_TOP_RIGHT, -12, 58);
// --- mode tiles ----------------------------------------------------------
lv_obj_t *grid = lv_obj_create(s_root);
lv_obj_remove_style_all(grid);
lv_obj_set_size(grid, LV_PCT(100), 150);
lv_obj_align(grid, LV_ALIGN_BOTTOM_MID, 0, -4);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_SPACE_EVENLY,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
static const tile_def_t tiles[] = {
{ LISAEL_EMOJI_RADIO, "Radio", LISAEL_MODE_RADIO, {0} },
{ LISAEL_EMOJI_SOUND, "Sons", LISAEL_MODE_SOUNDBOX, {0} },
{ LISAEL_EMOJI_CALM, "Calme", LISAEL_MODE_CALM, {0} },
{ LISAEL_EMOJI_CLOCK, "Horloge", LISAEL_MODE_CLOCK, {0} },
{ LISAEL_EMOJI_GAMES, "Jeux", LISAEL_MODE_GAMES, {0} },
};
static const uint32_t colors[] = {
0xFFD8A8, 0xB2F2BB, 0xA5D8FF, 0xD0BFFF, 0xFFC9C9,
};
for (size_t i = 0; i < sizeof(tiles) / sizeof(tiles[0]); i++) {
tile_def_t t = tiles[i];
t.color = lv_color_hex(colors[i]);
make_tile(grid, &t);
}
// NB: the live values are filled in shortly by the tick/net tasks via
// lisael_home_update_*(). We do NOT call them here: this builder runs while
// the caller already holds the display lock, and those helpers take the
// lock themselves (the esp_lvgl_port mutex is recursive so it would be
// safe, but keeping init lock-clean is clearer). The labels above already
// show sensible placeholders ("--:--", "...", "—").
return s_root;
}
// --- live updates (called from background tasks; take the lock here) --------
void lisael_home_update_clock(void)
{
if (!s_date_lbl || !s_time_lbl) {
return;
}
time_t now = time(NULL);
struct tm tm;
localtime_r(&now, &tm);
char date_buf[48];
char time_buf[8];
lisael_fr_date(&tm, date_buf, sizeof(date_buf));
lisael_fr_time(&tm, time_buf, sizeof(time_buf));
if (bsp_display_lock(50)) {
lv_label_set_text(s_time_lbl, lisael_time_is_valid() ? time_buf : "--:--");
lv_label_set_text(s_date_lbl, date_buf);
bsp_display_unlock();
}
}
void lisael_home_update_weather(void)
{
if (!s_weather_lbl) {
return;
}
lisael_weather_t w;
lisael_weather_get(&w);
char buf[32];
if (w.valid) {
snprintf(buf, sizeof(buf), "%.0f°C", w.temperature_c);
} else {
snprintf(buf, sizeof(buf), "—");
}
if (bsp_display_lock(50)) {
lv_label_set_text(s_weather_lbl, buf);
lv_label_set_text(s_weather_icon,
w.valid ? lisael_weather_code_emoji(w.weather_code) : "");
bsp_display_unlock();
}
}
void lisael_home_update_sensors(void)
{
if (!s_sensor_lbl) {
return;
}
char buf[32] = "";
if (s_tsens) {
float c = 0;
if (temperature_sensor_get_celsius(s_tsens, &c) == ESP_OK) {
// Internal die temperature — informational only, not room temp.
snprintf(buf, sizeof(buf), "puce %.0f°C", c);
}
}
if (bsp_display_lock(50)) {
lv_label_set_text(s_sensor_lbl, buf);
bsp_display_unlock();
}
}
+122
View File
@@ -0,0 +1,122 @@
// 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.
#include "ui/ui.h"
#include "ui/icons.h"
#include "modes/radios.h"
#include "audio/radio_pipeline.h"
#include "audio/audio_player.h"
#include "lisael_config.h"
#include <stdio.h>
#include "esp_log.h"
static const char *TAG = "ui.radio";
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)
{
if (s_vol_lbl) {
char buf[16];
snprintf(buf, sizeof(buf), "%d%%", lisael_audio_get_volume());
lv_label_set_text(s_vol_lbl, buf);
}
}
static void station_cb(lv_event_t *e)
{
const lisael_radio_station_t *st = 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);
}
}
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é");
}
}
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);
lisael_ui_add_back_button(s_root);
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);
// Station list (scrollable column).
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_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++) {
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]);
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_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();
s_status_lbl = lv_label_create(s_root);
lv_label_set_text(s_status_lbl, "");
lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -66);
return s_root;
}
+41
View File
@@ -0,0 +1,41 @@
// Webradio station list for Lisael Box.
//
// ⚠️ À VÉRIFIER / COMPLÉTER: the stream URLs below are PLAUSIBLE placeholders.
// French kids' webradio stream endpoints change and are not all publicly
// documented. Before shipping, confirm each URL actually returns an MP3/AAC
// stream (e.g. `curl -I <url>` should show audio/mpeg or audio/aac, or use the
// station's official "écouter en direct" / TuneIn stream link). Prefer plain
// HTTP MP3 streams for the lightest decode path on the ESP32-S3.
#pragma once
typedef struct {
const char *name; // display name
const char *emoji; // small icon
const char *url; // stream URL (MP3 or AAC)
} lisael_radio_station_t;
// NOTE: keep this list short — each station is one big touch button.
static const lisael_radio_station_t LISAEL_RADIOS[] = {
// Radio Pomme d'Api (Bayard) — kids' radio.
{ "Pomme d'Api", "\U0001F34E", // 🍎
"https://radio.pommedapi.example/stream.mp3" }, // TODO verify
// Radio Doudou — lullabies / very young children.
{ "Radio Doudou", "\U0001F9F8", // 🧸
"http://stream.radiodoudou.example/doudou.mp3" }, // TODO verify
// Mmuz Kids / Mouv'Kids style FR kids music.
{ "Muz Kids", "\U0001F3B5", // 🎵
"http://stream.muzkids.example/kids.mp3" }, // TODO verify
// Une histoire et Oli (France Inter) — stories podcast stream proxy.
{ "Histoires", "\U0001F4D6", // 📖
"http://stream.histoires.example/oli.mp3" }, // TODO verify
// Comptines FR.
{ "Comptines", "\U0001F3B6", // 🎶
"http://stream.comptines.example/fr.mp3" }, // TODO verify
};
#define LISAEL_RADIOS_COUNT \
(sizeof(LISAEL_RADIOS) / sizeof(LISAEL_RADIOS[0]))
+91
View File
@@ -0,0 +1,91 @@
// "Boîte à sons" mode: grid of buttons reading /sdcard/sounds/*.mp3.
//
// Buttons come from /sdcard/sounds/manifest.json (file/title/emoji). If the
// card is missing or has no manifest, a friendly placeholder is shown instead
// of an error.
#include "ui/ui.h"
#include "ui/icons.h"
#include "audio/sd_player.h"
#include "audio/audio_player.h"
#include <stdint.h>
#include "esp_log.h"
static const char *TAG = "ui.soundbox";
static lv_obj_t *s_root;
static lisael_sound_t s_sounds[LISAEL_SD_MAX_SOUNDS];
static int s_count;
static void sound_cb(lv_event_t *e)
{
int idx = (int)(intptr_t)lv_event_get_user_data(e);
if (idx >= 0 && idx < s_count) {
ESP_LOGI(TAG, "play %s", s_sounds[idx].file);
lisael_sd_play_sound(s_sounds[idx].file);
}
}
static void build_grid(lv_obj_t *parent)
{
s_count = lisael_sd_load_manifest(s_sounds, LISAEL_SD_MAX_SOUNDS);
if (s_count <= 0) {
lv_obj_t *info = lv_label_create(parent);
lv_label_set_text(info,
"Insère la carte SD\navec des sons\n(/sounds/manifest.json)");
lv_obj_set_style_text_align(info, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(info);
return;
}
lv_obj_t *grid = lv_obj_create(parent);
lv_obj_remove_style_all(grid);
lv_obj_set_size(grid, LV_PCT(100), 170);
lv_obj_align(grid, LV_ALIGN_BOTTOM_MID, 0, -4);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_SPACE_EVENLY,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(grid, 6, 0);
for (int i = 0; i < s_count; i++) {
lv_obj_t *btn = lv_button_create(grid);
lv_obj_add_style(btn, lisael_ui_style_tile(), 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(0xD3F9D8), 0);
lv_obj_set_size(btn, 92, 76);
lv_obj_add_event_cb(btn, sound_cb, LV_EVENT_CLICKED,
(void *)(intptr_t)i);
lv_obj_t *col = lv_obj_create(btn);
lv_obj_remove_style_all(col);
lv_obj_set_size(col, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_clear_flag(col, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t *emoji = lv_label_create(col);
lv_label_set_text(emoji, s_sounds[i].emoji);
lv_obj_t *title = lv_label_create(col);
lv_label_set_text(title, s_sounds[i].title);
}
}
lv_obj_t *lisael_screen_soundbox(void)
{
if (s_root) {
return s_root;
}
s_root = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xEBFBEE), 0);
lisael_ui_add_back_button(s_root);
lv_obj_t *title = lv_label_create(s_root);
lv_label_set_text(title, LISAEL_EMOJI_SOUND " Boîte à sons");
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
build_grid(s_root);
return s_root;
}
+75
View File
@@ -0,0 +1,75 @@
// French date/time formatting for Lisael Box.
//
// We deliberately do NOT rely on setlocale(LC_TIME, "fr_FR") because the
// newlib build shipped with ESP-IDF has no locale data — strftime("%A") would
// return English. Instead we keep small FR tables and format by hand.
//
// Strings use UTF-8 with proper diacritics ("décembre", "février"). The LVGL
// font in use must include the accented glyphs (the BSP default Montserrat
// covers Latin-1; verify on hardware — see README "À vérifier").
#include "ui/fr_date.h"
#include <stdio.h>
#include <string.h>
// tm_wday: 0 = Sunday .. 6 = Saturday
static const char *const k_days[7] = {
"dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi",
};
// tm_mon: 0 = January .. 11 = December
static const char *const k_months[12] = {
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
};
const char *lisael_fr_weekday(int tm_wday)
{
if (tm_wday < 0 || tm_wday > 6) {
return "";
}
return k_days[tm_wday];
}
const char *lisael_fr_month(int tm_mon)
{
if (tm_mon < 0 || tm_mon > 11) {
return "";
}
return k_months[tm_mon];
}
char *lisael_fr_date(const struct tm *tm, char *out, size_t out_size)
{
if (!out || out_size == 0) {
return out;
}
snprintf(out, out_size, "%s %d %s",
lisael_fr_weekday(tm->tm_wday),
tm->tm_mday,
lisael_fr_month(tm->tm_mon));
return out;
}
char *lisael_fr_date_full(const struct tm *tm, char *out, size_t out_size)
{
if (!out || out_size == 0) {
return out;
}
snprintf(out, out_size, "%s %d %s %d",
lisael_fr_weekday(tm->tm_wday),
tm->tm_mday,
lisael_fr_month(tm->tm_mon),
tm->tm_year + 1900);
return out;
}
char *lisael_fr_time(const struct tm *tm, char *out, size_t out_size)
{
if (!out || out_size == 0) {
return out;
}
snprintf(out, out_size, "%02d:%02d", tm->tm_hour, tm->tm_min);
return out;
}
+30
View File
@@ -0,0 +1,30 @@
// French date/time formatting for Lisael Box (no setlocale dependency).
#pragma once
#include <time.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// Format a struct tm as a French date string, e.g. "samedi 14 juin".
// Writes into `out` (NUL-terminated, truncated to `out_size`). The year is
// omitted by default (kid-friendly). Returns `out`.
char *lisael_fr_date(const struct tm *tm, char *out, size_t out_size);
// Format "samedi 14 juin 2026" (with year).
char *lisael_fr_date_full(const struct tm *tm, char *out, size_t out_size);
// Format the time as "HH:MM", e.g. "07:05". Returns `out`.
char *lisael_fr_time(const struct tm *tm, char *out, size_t out_size);
// Day-of-week name in French, 0=dimanche .. 6=samedi (matches tm_wday).
const char *lisael_fr_weekday(int tm_wday);
// Month name in French, 0=janvier .. 11=decembre (matches tm_mon).
const char *lisael_fr_month(int tm_mon);
#ifdef __cplusplus
}
#endif
+5
View File
@@ -0,0 +1,5 @@
// 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.
#include "ui/icons.h"
+32
View File
@@ -0,0 +1,32 @@
// Icon / emoji helpers for the Lisael Box UI.
//
// The home tiles and many buttons are icon-first (no reading required). We use
// emoji glyphs as the "icons". IMPORTANT (verify on hardware): the default
// LVGL Montserrat fonts do NOT contain emoji glyphs. To actually render these
// you must add an emoji-capable font (e.g. a subset of Noto Color Emoji built
// with lv_font_conv, or LVGL's built-in symbol set LV_SYMBOL_*). Until then
// the emoji may show as tofu boxes. The text labels below always render.
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
// Mode tile glyphs (UTF-8 emoji). Swap for a bundled emoji font or replace with
// LV_SYMBOL_* if you prefer the built-in monochrome icon set.
#define LISAEL_EMOJI_RADIO "\U0001F4FB" // 📻
#define LISAEL_EMOJI_SOUND "\U0001F50A" // 🔊
#define LISAEL_EMOJI_CALM "\U0001FAE7" // 🫧 bubbles
#define LISAEL_EMOJI_CLOCK "\U0001F550" // 🕐
#define LISAEL_EMOJI_GAMES "\U0001F3AE" // 🎮
#define LISAEL_EMOJI_HOME "\U0001F3E0" // 🏠
#define LISAEL_EMOJI_PLAY "▶" // ▶
#define LISAEL_EMOJI_STOP "⏹" // ⏹
#define LISAEL_EMOJI_VOL_UP "\U0001F509" // 🔉 (up)
#define LISAEL_EMOJI_VOL_DOWN "\U0001F508" // 🔈 (down)
#define LISAEL_EMOJI_STAR "⭐" // ⭐ (positive feedback)
#define LISAEL_EMOJI_HEART "❤️" // ❤️
#ifdef __cplusplus
}
#endif
+118
View File
@@ -0,0 +1,118 @@
// LVGL UI shell + mode router for Lisael Box (LVGL 9 API).
//
// Screens are built lazily and cached so switching back is instant. Mode
// transitions use lv_screen_load_anim() for a gentle slide. All LVGL access
// from background tasks goes through bsp_display_lock()/unlock(); the functions
// here are called either from app_main during init (lock held by caller) or
// from LVGL event callbacks (already on the LVGL task).
#include "ui/ui.h"
#include "ui/icons.h"
#include "lisael_config.h"
#include "esp_log.h"
#include "bsp/esp-box-3.h"
static const char *TAG = "ui";
static lv_obj_t *s_screens[LISAEL_MODE_COUNT];
static lisael_mode_t s_current = LISAEL_MODE_HOME;
static lv_style_t s_style_tile;
static lv_style_t s_style_button;
static bool s_styles_ready;
static void init_styles(void)
{
if (s_styles_ready) {
return;
}
// Big rounded pastel tiles for the home menu.
lv_style_init(&s_style_tile);
lv_style_set_radius(&s_style_tile, 18);
lv_style_set_bg_opa(&s_style_tile, LV_OPA_COVER);
lv_style_set_border_width(&s_style_tile, 0);
lv_style_set_pad_all(&s_style_tile, 6);
lv_style_set_shadow_width(&s_style_tile, 8);
lv_style_set_shadow_ofs_y(&s_style_tile, 3);
lv_style_set_shadow_opa(&s_style_tile, LV_OPA_20);
// Generic large touch button.
lv_style_init(&s_style_button);
lv_style_set_radius(&s_style_button, 14);
lv_style_set_pad_all(&s_style_button, 10);
lv_style_set_min_height(&s_style_button, 56); // comfortable for small hands
s_styles_ready = true;
}
const lv_style_t *lisael_ui_style_tile(void)
{
init_styles();
return &s_style_tile;
}
const lv_style_t *lisael_ui_style_button(void)
{
init_styles();
return &s_style_button;
}
static lv_obj_t *build_screen(lisael_mode_t mode)
{
switch (mode) {
case LISAEL_MODE_HOME: return lisael_screen_home();
case LISAEL_MODE_RADIO: return lisael_screen_radio();
case LISAEL_MODE_SOUNDBOX: return lisael_screen_soundbox();
case LISAEL_MODE_CALM: return lisael_screen_calm();
case LISAEL_MODE_CLOCK: return lisael_screen_clock();
case LISAEL_MODE_GAMES: return lisael_screen_games();
default: return lisael_screen_home();
}
}
void lisael_ui_goto(lisael_mode_t mode)
{
if (mode < 0 || mode >= LISAEL_MODE_COUNT) {
return;
}
if (!s_screens[mode]) {
s_screens[mode] = build_screen(mode);
}
// Slide direction: going "deeper" (home->mode) slides left, back slides right.
lv_screen_load_anim_t anim = (mode == LISAEL_MODE_HOME)
? LV_SCR_LOAD_ANIM_MOVE_RIGHT
: LV_SCR_LOAD_ANIM_MOVE_LEFT;
lv_screen_load_anim(s_screens[mode], anim, 250, 0, false);
s_current = mode;
ESP_LOGI(TAG, "mode -> %d", mode);
}
static void back_cb(lv_event_t *e)
{
(void)e;
lisael_ui_goto(LISAEL_MODE_HOME);
}
lv_obj_t *lisael_ui_add_back_button(lv_obj_t *parent)
{
lv_obj_t *btn = lv_button_create(parent);
lv_obj_add_style(btn, lisael_ui_style_button(), 0);
lv_obj_set_size(btn, 64, 56);
lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 6, 6);
lv_obj_add_event_cb(btn, back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *lbl = lv_label_create(btn);
lv_label_set_text(lbl, LV_SYMBOL_LEFT); // monochrome arrow — always renders
lv_obj_center(lbl);
return btn;
}
void lisael_ui_init(void)
{
init_styles();
s_screens[LISAEL_MODE_HOME] = lisael_screen_home();
lv_screen_load(s_screens[LISAEL_MODE_HOME]);
s_current = LISAEL_MODE_HOME;
ESP_LOGI(TAG, "UI initialised");
}
+60
View File
@@ -0,0 +1,60 @@
// LVGL UI shell + mode router for Lisael Box.
//
// The UI runs entirely on the LVGL task created by the esp-box BSP
// (bsp_display_start / esp_lvgl_port). ALL LVGL calls from other tasks (e.g.
// the weather task pushing an update) MUST be wrapped in
// bsp_display_lock()/bsp_display_unlock(). Helpers here assume they are called
// while holding that lock unless noted otherwise.
#pragma once
#include "lvgl.h"
#ifdef __cplusplus
extern "C" {
#endif
// The available screens / modes.
typedef enum {
LISAEL_MODE_HOME = 0, // accueil: date FR + meteo + capteurs + tuiles
LISAEL_MODE_RADIO, // webradios enfants
LISAEL_MODE_SOUNDBOX, // boite a sons (SD)
LISAEL_MODE_CALM, // coin calme / respiration
LISAEL_MODE_CLOCK, // horloge / veilleuse
LISAEL_MODE_GAMES, // jeux (memory, compter)
LISAEL_MODE_COUNT,
} lisael_mode_t;
// Build the UI (call once, while holding the display lock). Loads HOME.
void lisael_ui_init(void);
// Switch to a mode. Each mode lazily (re-)builds its screen and is loaded with
// a gentle slide animation. Safe to call from LVGL callbacks.
void lisael_ui_goto(lisael_mode_t mode);
// Convenience used by every mode screen to add a top-left "← Accueil" button
// (big, icon-only) that returns HOME. Returns the button object.
lv_obj_t *lisael_ui_add_back_button(lv_obj_t *parent);
// Shared style accessors (lazily initialised). Big rounded buttons, soft
// pastel palette suited to a young child.
const lv_style_t *lisael_ui_style_tile(void); // home menu tiles
const lv_style_t *lisael_ui_style_button(void); // generic big buttons
// --- Each mode exposes a builder that returns its root screen object. --------
// (Implemented in modes/*.c.)
lv_obj_t *lisael_screen_home(void);
lv_obj_t *lisael_screen_radio(void);
lv_obj_t *lisael_screen_soundbox(void);
lv_obj_t *lisael_screen_calm(void);
lv_obj_t *lisael_screen_clock(void);
lv_obj_t *lisael_screen_games(void);
// Home screen live-update hooks (called from background tasks; they take the
// display lock internally — do NOT hold it when calling these).
void lisael_home_update_clock(void); // refresh date/time labels
void lisael_home_update_weather(void); // pull latest weather snapshot
void lisael_home_update_sensors(void); // pull latest sensor readings
#ifdef __cplusplus
}
#endif