diff --git a/main/app_main.c b/main/app_main.c new file mode 100644 index 0000000..3749302 --- /dev/null +++ b/main/app_main.c @@ -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"); +} diff --git a/main/modes/calm.c b/main/modes/calm.c new file mode 100644 index 0000000..43aa5e8 --- /dev/null +++ b/main/modes/calm.c @@ -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; +} diff --git a/main/modes/clock.c b/main/modes/clock.c new file mode 100644 index 0000000..202b93c --- /dev/null +++ b/main/modes/clock.c @@ -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 +#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; +} diff --git a/main/modes/games.c b/main/modes/games.c new file mode 100644 index 0000000..fa520b4 --- /dev/null +++ b/main/modes/games.c @@ -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 +#include +#include +#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; +} diff --git a/main/modes/home.c b/main/modes/home.c new file mode 100644 index 0000000..e47d0a8 --- /dev/null +++ b/main/modes/home.c @@ -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 +#include +#include + +#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(); + } +} diff --git a/main/modes/radio.c b/main/modes/radio.c new file mode 100644 index 0000000..f76e8d8 --- /dev/null +++ b/main/modes/radio.c @@ -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 +#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; +} diff --git a/main/modes/radios.h b/main/modes/radios.h new file mode 100644 index 0000000..4849209 --- /dev/null +++ b/main/modes/radios.h @@ -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 ` 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])) diff --git a/main/modes/soundbox.c b/main/modes/soundbox.c new file mode 100644 index 0000000..449207c --- /dev/null +++ b/main/modes/soundbox.c @@ -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 +#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; +} diff --git a/main/ui/fr_date.c b/main/ui/fr_date.c new file mode 100644 index 0000000..7d48707 --- /dev/null +++ b/main/ui/fr_date.c @@ -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 +#include + +// 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; +} diff --git a/main/ui/fr_date.h b/main/ui/fr_date.h new file mode 100644 index 0000000..73e00cf --- /dev/null +++ b/main/ui/fr_date.h @@ -0,0 +1,30 @@ +// French date/time formatting for Lisael Box (no setlocale dependency). +#pragma once + +#include +#include + +#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 diff --git a/main/ui/icons.c b/main/ui/icons.c new file mode 100644 index 0000000..b24a5cb --- /dev/null +++ b/main/ui/icons.c @@ -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" diff --git a/main/ui/icons.h b/main/ui/icons.h new file mode 100644 index 0000000..5433567 --- /dev/null +++ b/main/ui/icons.h @@ -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 diff --git a/main/ui/ui.c b/main/ui/ui.c new file mode 100644 index 0000000..baf1db3 --- /dev/null +++ b/main/ui/ui.c @@ -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"); +} diff --git a/main/ui/ui.h b/main/ui/ui.h new file mode 100644 index 0000000..b689aef --- /dev/null +++ b/main/ui/ui.h @@ -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