diff --git a/idf_zacus/components/display_ui/display_ui.cpp b/idf_zacus/components/display_ui/display_ui.cpp index b6fb41c..43bb86f 100644 --- a/idf_zacus/components/display_ui/display_ui.cpp +++ b/idf_zacus/components/display_ui/display_ui.cpp @@ -45,6 +45,9 @@ #include #include +#include +#include +#include #include "driver/gpio.h" #include "driver/ledc.h" @@ -207,27 +210,69 @@ static bool s_cam_visible = false; #define SHELL_ICON_SPACING 16 // = ICON_SPACING #define SHELL_GRID_X 16 // = GRID_START_X #define SHELL_GRID_Y 48 // GRID_START_Y(32) + our taller title bar -#define SHELL_APP_COUNT 4 +#define SHELL_BUILTINS 5 +#define SHELL_MAX_TILES 12 static lv_obj_t *s_scr_shell; -static lv_obj_t *s_shell_tiles[SHELL_APP_COUNT]; +static lv_obj_t *s_shell_tiles[SHELL_MAX_TILES]; // Written by the buttons task, applied by the display task. static volatile uint8_t s_shell_sel = 0; static volatile bool s_shell_open = false; // Accent colors from the original shell palette (ui_amiga_shell.cpp:280-287). static const struct { const char *name; uint32_t color; } -kShellApps[SHELL_APP_COUNT] = { - {"Statut", 0x00FFFF}, // cyan - {"Scene", 0xFF8800}, // orange - {"Auto", 0x00FF88}, // green - {"Lumiere", 0xFFFF00}, // yellow +kShellBuiltins[SHELL_BUILTINS] = { + {"Statut", 0x00FFFF}, // cyan + {"Scene", 0xFF8800}, // orange + {"Auto", 0x00FF88}, // green + {"Lumiere", 0xFFFF00}, // yellow + {"Fichiers", 0x88FF00}, // lime — opens the file browser }; +// Dynamic apps from /littlefs/apps// (icon.png optional, step.txt = the +// step to arm on launch — handled by main via the pending-launch poll). +typedef struct { char id[32]; } shell_dyn_app_t; +static shell_dyn_app_t s_shell_dyn[SHELL_MAX_TILES - SHELL_BUILTINS]; +static uint8_t s_shell_count = SHELL_BUILTINS; // builtins + dynamic +static bool s_shell_loaded = false; // lazy (littlefs mounts late) -// ─── Intro (zoom/fade evocation of the original 3D intro sequence) ─────────── -// The original ui_manager_intro.cpp (2475 lines, parallax planes) is NOT -// ported; this is the boot-zoom essence on LVGL anims. +// Pending app launch (buttons task → main poll). +static char s_pending_launch[32]; +static volatile bool s_pending_launch_set = false; + +// ─── File browser (original shell's drawer, master edition) ───────────────── +#define BROWSER_MAX_ENTRIES 24 +static lv_obj_t *s_scr_browser; +static lv_obj_t *s_browser_list; // plain container, rebuilt on reload +static lv_obj_t *s_browser_title; +static char s_browser_path[96] = "/littlefs"; +static char s_browser_items[BROWSER_MAX_ENTRIES][40]; +static bool s_browser_isdir[BROWSER_MAX_ENTRIES]; +static uint8_t s_browser_count = 0; +static volatile uint8_t s_browser_sel = 0; +static volatile bool s_browser_open = false; +static volatile bool s_browser_reload = false; + +// ─── Intro — faithful port of the original cracktro phase A ────────────────── +// Algorithms from ui_manager_intro.cpp: 3-layer parallax starfield in Q8.8 +// fixed point (layer split 50/30/20%, speeds {54,116,198} px/s, sizes 1-3 px, +// opacities 30/60/100%, 1-in-8 twinkle), logo + drop shadow (offset 1,23), +// bottom sine-wave scrolltext, copper bars. The 3D FX modes of phases B/C +// (dot sphere, ray corridor, rotozoom) are NOT ported yet. +#define INTRO_STARS 48 +#define INTRO_BARS 4 +#define INTRO_DURATION_MS 8000 +typedef struct { int32_t x_q8, y_q8; uint8_t layer; int16_t speed; } intro_star_t; static lv_obj_t *s_scr_intro; -static lv_obj_t *s_intro_title; +static lv_obj_t *s_intro_logo, *s_intro_logo_shadow; +static lv_obj_t *s_intro_scroll; +static lv_obj_t *s_star_objs[INTRO_STARS]; +static intro_star_t s_stars[INTRO_STARS]; +static lv_obj_t *s_intro_bars[INTRO_BARS]; +static int32_t s_scroll_x_q8; // scrolltext x in Q8.8 +static uint32_t s_intro_t_ms; // elapsed intro time static volatile bool s_intro_done = false; +// Original kIntro defaults: scroll 90 px/s, sine amp 14 px, period 120 px. +#define INTRO_SCROLL_PXS 90 +#define INTRO_SINE_AMP 14 +#define INTRO_SINE_PERIOD 120.0f static lv_obj_t *make_row(lv_obj_t *parent, int y, const char *label_txt, dui_row_t *out) { @@ -403,43 +448,149 @@ static void build_shell_screen(void) { lv_obj_set_style_text_font(t, &lv_font_montserrat_24, 0); lv_label_set_text(t, "Zacus Workbench"); lv_obj_align(t, LV_ALIGN_LEFT_MID, 8, 0); + // Tiles are created lazily by shell_populate() — littlefs mounts after + // display_ui_init, so dynamic apps can't be scanned here. +} - for (int i = 0; i < SHELL_APP_COUNT; i++) { - const int col = i % SHELL_GRID_COLS; - const int row = i / SHELL_GRID_COLS; - lv_obj_t *tile = lv_obj_create(s_scr_shell); - lv_obj_remove_style_all(tile); - lv_obj_set_size(tile, SHELL_ICON_SIZE, SHELL_ICON_SIZE); - lv_obj_set_pos(tile, - SHELL_GRID_X + col * (SHELL_ICON_SIZE + SHELL_ICON_SPACING), - SHELL_GRID_Y + row * (SHELL_ICON_SIZE + SHELL_ICON_SPACING)); - lv_obj_set_style_bg_color(tile, lv_color_hex(kShellApps[i].color), 0); - lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); - lv_obj_set_style_radius(tile, 4, 0); // like the original - lv_obj_set_style_border_color(tile, lv_color_white(), 0); - lv_obj_set_style_border_width(tile, 1, 0); +static lv_obj_t *shell_make_tile(int index, const char *name, uint32_t color, + const char *icon_path) { + const int col = index % SHELL_GRID_COLS; + const int row = index / SHELL_GRID_COLS; + lv_obj_t *tile = lv_obj_create(s_scr_shell); + lv_obj_remove_style_all(tile); + lv_obj_set_size(tile, SHELL_ICON_SIZE, SHELL_ICON_SIZE); + lv_obj_set_pos(tile, + SHELL_GRID_X + col * (SHELL_ICON_SIZE + SHELL_ICON_SPACING), + SHELL_GRID_Y + row * (SHELL_ICON_SIZE + SHELL_ICON_SPACING)); + lv_obj_set_style_bg_color(tile, lv_color_hex(color), 0); + lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); + lv_obj_set_style_radius(tile, 4, 0); // like the original + lv_obj_set_style_border_color(tile, lv_color_white(), 0); + lv_obj_set_style_border_width(tile, 1, 0); - lv_obj_t *lbl = lv_label_create(tile); - lv_obj_set_style_text_color(lbl, lv_color_hex(0x000000), 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); - lv_label_set_text(lbl, kShellApps[i].name); - lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, -4); - - s_shell_tiles[i] = tile; + if (icon_path) { + // PNG icon via LVGL FS drive 'L' (→ /littlefs), like the original's + // "L:" + icon.png path. + lv_obj_t *img = lv_img_create(tile); + lv_img_set_src(img, icon_path); + lv_obj_align(img, LV_ALIGN_TOP_MID, 0, 2); } + + lv_obj_t *lbl = lv_label_create(tile); + lv_obj_set_style_text_color(lbl, lv_color_hex(0x000000), 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_label_set_text(lbl, name); + lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, -4); + return tile; +} + +// Lazy tile creation: builtins + /littlefs/apps// (display task only). +static void shell_populate(void) { + if (s_shell_loaded) return; + s_shell_loaded = true; + + for (int i = 0; i < SHELL_BUILTINS; i++) { + s_shell_tiles[i] = shell_make_tile(i, kShellBuiltins[i].name, + kShellBuiltins[i].color, NULL); + } + s_shell_count = SHELL_BUILTINS; + + DIR *dir = opendir("/littlefs/apps"); + if (!dir) return; // no apps dir — builtins only + struct dirent *ent; + while ((ent = readdir(dir)) != NULL && + s_shell_count < SHELL_MAX_TILES) { + if (ent->d_type != DT_DIR || ent->d_name[0] == '.') continue; + shell_dyn_app_t *app = &s_shell_dyn[s_shell_count - SHELL_BUILTINS]; + strncpy(app->id, ent->d_name, sizeof(app->id) - 1); + app->id[sizeof(app->id) - 1] = '\0'; + + // Optional PNG icon (LVGL path with the L: drive). + char fs_path[96], lv_path[96]; + snprintf(fs_path, sizeof(fs_path), "/littlefs/apps/%s/icon.png", app->id); + struct stat st; + const bool has_icon = (stat(fs_path, &st) == 0); + snprintf(lv_path, sizeof(lv_path), "L:apps/%s/icon.png", app->id); + + s_shell_tiles[s_shell_count] = shell_make_tile( + s_shell_count, app->id, 0x335577, + has_icon ? lv_path : NULL); + s_shell_count++; + } + closedir(dir); + ESP_LOGI(TAG, "shell: %d tiles (%d apps from /littlefs/apps)", + s_shell_count, s_shell_count - SHELL_BUILTINS); } // Highlight the selected tile (display task only). static void apply_shell_selection(void) { - for (int i = 0; i < SHELL_APP_COUNT; i++) { - lv_obj_set_style_border_width(s_shell_tiles[i], - (i == s_shell_sel) ? 4 : 1, 0); + for (int i = 0; i < s_shell_count; i++) { + if (s_shell_tiles[i]) + lv_obj_set_style_border_width(s_shell_tiles[i], + (i == s_shell_sel) ? 4 : 1, 0); } } -static void intro_zoom_cb(void *obj, int32_t v) { - lv_obj_set_style_transform_zoom((lv_obj_t *) obj, (uint16_t) v, 0); - lv_obj_set_style_opa((lv_obj_t *) obj, (lv_opa_t) ((v * 255) / 256), 0); +// ─── File browser (display task only for LVGL parts) ──────────────────────── + +static void build_browser_screen(void) { + s_scr_browser = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_scr_browser, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(s_scr_browser, LV_OPA_COVER, 0); + + lv_obj_t *bar = lv_obj_create(s_scr_browser); + lv_obj_remove_style_all(bar); + lv_obj_set_size(bar, DUI_HOR_RES, 36); + lv_obj_set_style_bg_color(bar, lv_color_hex(COL_TITLEBAR), 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + s_browser_title = lv_label_create(bar); + lv_obj_set_style_text_color(s_browser_title, lv_color_hex(COL_TITLE_TXT), 0); + lv_obj_set_style_text_font(s_browser_title, &lv_font_ibmplexmono_18, 0); + lv_label_set_text(s_browser_title, s_browser_path); + lv_obj_align(s_browser_title, LV_ALIGN_LEFT_MID, 8, 0); + + s_browser_list = lv_obj_create(s_scr_browser); + lv_obj_remove_style_all(s_browser_list); + lv_obj_set_size(s_browser_list, DUI_HOR_RES, DUI_VER_RES - 36); + lv_obj_set_pos(s_browser_list, 0, 36); +} + +static void browser_reload(void) { + s_browser_count = 0; + DIR *dir = opendir(s_browser_path); + if (dir) { + struct dirent *ent; + while ((ent = readdir(dir)) != NULL && + s_browser_count < BROWSER_MAX_ENTRIES) { + strncpy(s_browser_items[s_browser_count], ent->d_name, + sizeof(s_browser_items[0]) - 1); + s_browser_items[s_browser_count][sizeof(s_browser_items[0]) - 1] = '\0'; + s_browser_isdir[s_browser_count] = (ent->d_type == DT_DIR); + s_browser_count++; + } + closedir(dir); + } + + lv_obj_clean(s_browser_list); + lv_label_set_text(s_browser_title, s_browser_path); + for (int i = 0; i < s_browser_count; i++) { + lv_obj_t *row = lv_label_create(s_browser_list); + lv_obj_set_style_text_font(row, &lv_font_ibmplexmono_18, 0); + lv_obj_set_pos(row, 12, 6 + i * 22); + char line[56]; + snprintf(line, sizeof(line), "%s%s", + s_browser_isdir[i] ? "[D] " : " ", s_browser_items[i]); + lv_label_set_text(row, line); + lv_obj_set_style_text_color(row, + (i == s_browser_sel) ? lv_color_hex(COL_CODE) : lv_color_white(), 0); + } + if (s_browser_count == 0) { + lv_obj_t *row = lv_label_create(s_browser_list); + lv_obj_set_style_text_font(row, &lv_font_ibmplexmono_18, 0); + lv_obj_set_pos(row, 12, 6); + lv_obj_set_style_text_color(row, lv_color_hex(COL_LABEL), 0); + lv_label_set_text(row, "(vide)"); + } } static void build_intro_screen(void) { @@ -447,20 +598,103 @@ static void build_intro_screen(void) { lv_obj_set_style_bg_color(s_scr_intro, lv_color_hex(0x000000), 0); lv_obj_set_style_bg_opa(s_scr_intro, LV_OPA_COVER, 0); - s_intro_title = lv_label_create(s_scr_intro); - lv_obj_set_style_text_color(s_intro_title, lv_color_hex(COL_CODE), 0); - lv_obj_set_style_text_font(s_intro_title, &lv_font_orbitron_40, 0); - lv_label_set_text(s_intro_title, "ZACUS"); - lv_obj_center(s_intro_title); + // Starfield — original layer split: 50% layer0, 30% layer1, 20% layer2. + static const int16_t kSpeeds[3] = {54, 116, 198}; // speeds_fast + const int l0_end = (INTRO_STARS * 50) / 100; + const int l1_end = (INTRO_STARS * 80) / 100; + for (int i = 0; i < INTRO_STARS; i++) { + const uint8_t layer = (i < l0_end) ? 0 : (i < l1_end) ? 1 : 2; + intro_star_t *st = &s_stars[i]; + st->layer = layer; + st->speed = kSpeeds[layer]; + st->x_q8 = (int32_t) (esp_random() % DUI_HOR_RES) << 8; + st->y_q8 = (int32_t) (esp_random() % DUI_VER_RES) << 8; - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, s_intro_title); - lv_anim_set_exec_cb(&a, intro_zoom_cb); - lv_anim_set_values(&a, 32, 256); // 12.5% → 100% zoom-in - lv_anim_set_time(&a, 1400); - lv_anim_set_path_cb(&a, lv_anim_path_ease_out); - lv_anim_start(&a); + lv_obj_t *star = lv_obj_create(s_scr_intro); + lv_obj_remove_style_all(star); + lv_obj_set_size(star, 1 + layer, 1 + layer); + lv_obj_set_style_radius(star, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(star, + (layer == 2) ? lv_color_hex(0x00FFFF) : lv_color_white(), 0); + lv_obj_set_style_bg_opa(star, + (layer == 0) ? LV_OPA_30 : (layer == 1) ? LV_OPA_60 : LV_OPA_COVER, 0); + lv_obj_set_pos(star, st->x_q8 >> 8, st->y_q8 >> 8); + s_star_objs[i] = star; + } + + // Copper bars (4 horizontal gradient bars bobbing on sines). + static const uint32_t kBarCols[INTRO_BARS] = + {0xFF0088, 0x0088FF, 0x00FF88, 0xFFFF00}; + for (int i = 0; i < INTRO_BARS; i++) { + lv_obj_t *bar = lv_obj_create(s_scr_intro); + lv_obj_remove_style_all(bar); + lv_obj_set_size(bar, DUI_HOR_RES, 6); + lv_obj_set_style_bg_color(bar, lv_color_hex(kBarCols[i]), 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_70, 0); + lv_obj_set_pos(bar, 0, 60 + i * 14); + s_intro_bars[i] = bar; + } + + // Logo + drop shadow (original offsets: shadow at TOP_MID +1,+23; + // logo at TOP_MID 0,22 — shadow drawn first, behind). + s_intro_logo_shadow = lv_label_create(s_scr_intro); + lv_obj_set_style_text_color(s_intro_logo_shadow, lv_color_hex(0x222222), 0); + lv_obj_set_style_text_font(s_intro_logo_shadow, &lv_font_orbitron_40, 0); + lv_label_set_text(s_intro_logo_shadow, "Professeur ZACUS"); + lv_obj_align(s_intro_logo_shadow, LV_ALIGN_TOP_MID, 1, 23); + + s_intro_logo = lv_label_create(s_scr_intro); + lv_obj_set_style_text_color(s_intro_logo, lv_color_hex(COL_CODE), 0); + lv_obj_set_style_text_font(s_intro_logo, &lv_font_orbitron_40, 0); + lv_label_set_text(s_intro_logo, "Professeur ZACUS"); + lv_obj_align(s_intro_logo, LV_ALIGN_TOP_MID, 0, 22); + + // Bottom sine scroller. + s_intro_scroll = lv_label_create(s_scr_intro); + lv_obj_set_style_text_color(s_intro_scroll, lv_color_hex(0x00FFFF), 0); + lv_obj_set_style_text_font(s_intro_scroll, &lv_font_ibmplexmono_18, 0); + lv_label_set_text(s_intro_scroll, + "*** ZACUS MASTER CRACKTRO *** LE MYSTERE CONTINUE ... " + "GREETINGS TO THE GAME MASTER ... "); + s_scroll_x_q8 = (int32_t) DUI_HOR_RES << 8; + lv_obj_set_pos(s_intro_scroll, DUI_HOR_RES, DUI_VER_RES - 60); +} + +// Per-tick intro animation (display task only; dt in ms). Faithful Q8.8 math. +static void update_intro(uint32_t dt_ms) { + s_intro_t_ms += dt_ms; + + // Starfield: fall + wrap + twinkle (original update loop). + for (int i = 0; i < INTRO_STARS; i++) { + intro_star_t *st = &s_stars[i]; + st->y_q8 += (int32_t) (((uint32_t) st->speed * dt_ms * 256u) / 1000u); + if (st->y_q8 > ((int32_t) (DUI_VER_RES + 4) << 8)) { + st->y_q8 = -(int32_t) ((esp_random() % 36u) << 8); + st->x_q8 = (int32_t) (esp_random() % DUI_HOR_RES) << 8; + } + if ((esp_random() & 0x7u) == 0u) { + lv_obj_set_style_bg_opa(s_star_objs[i], + (lv_opa_t) (96u + (esp_random() % 160u)), 0); + } + lv_obj_set_pos(s_star_objs[i], st->x_q8 >> 8, st->y_q8 >> 8); + } + + // Copper bars: vertical sine bob, phase-shifted per bar. + for (int i = 0; i < INTRO_BARS; i++) { + const float ph = (float) s_intro_t_ms * 0.004f + (float) i * 0.9f; + lv_obj_set_y(s_intro_bars[i], 60 + i * 14 + (int) (sinf(ph) * 18.0f)); + } + + // Sine scroller: leftward at INTRO_SCROLL_PXS, y rides a sine of the + // x position (original sine_amp/sine_period defaults). + s_scroll_x_q8 -= (int32_t) ((INTRO_SCROLL_PXS * dt_ms * 256u) / 1000u); + const int sx = s_scroll_x_q8 >> 8; + const int w = lv_obj_get_width(s_intro_scroll); + if (sx < -w) s_scroll_x_q8 = (int32_t) DUI_HOR_RES << 8; + const int sy = DUI_VER_RES - 60 + + (int) (sinf((float) sx / INTRO_SINE_PERIOD * 6.2832f) * + INTRO_SINE_AMP); + lv_obj_set_pos(s_intro_scroll, sx, sy); } static void build_status_screen(void) { @@ -559,6 +793,8 @@ static void apply_status(const display_status_t *s) { lv_obj_t *want; if (!s_intro_done) { want = s_scr_intro; // boot intro plays out first + } else if (s_browser_open) { + want = s_scr_browser; // file browser on top of everything } else if (s_shell_open) { want = s_scr_shell; // Workbench shell on top } else switch (g_dui_view_override) { @@ -664,11 +900,14 @@ static void display_task(void *arg) { build_status_screen(); build_scene_screen(); build_shell_screen(); + build_browser_screen(); build_intro_screen(); - // Boot intro: shown immediately, hands over after ~1.8 s. + // Boot intro: shown immediately, hands over after INTRO_DURATION_MS + // (any 5-way key skips it — see display_ui_handle_key). lv_scr_load(s_scr_intro); - TickType_t intro_until = xTaskGetTickCount() + pdMS_TO_TICKS(1800); + TickType_t intro_until = xTaskGetTickCount() + + pdMS_TO_TICKS(INTRO_DURATION_MS); display_status_t local_status; memset(&local_status, 0, sizeof(local_status)); @@ -678,10 +917,13 @@ static void display_task(void *arg) { bool shell_open_applied = false; for (;;) { - // Intro handover. - if (!s_intro_done && xTaskGetTickCount() >= intro_until) { - s_intro_done = true; - s_dirty = true; // re-evaluate the wanted screen + // Intro: animate while active, hand over at the deadline. + if (!s_intro_done) { + update_intro(10); + if (xTaskGetTickCount() >= intro_until) { + s_intro_done = true; + s_dirty = true; // re-evaluate the wanted screen + } } // Shell state (selection highlight / open-close) from the buttons task. @@ -689,10 +931,18 @@ static void display_task(void *arg) { s_shell_open != shell_open_applied) { shell_sel_applied = s_shell_sel; shell_open_applied = s_shell_open; + if (s_shell_open) shell_populate(); // lazy: littlefs mounts late apply_shell_selection(); s_dirty = true; } + // Browser reload requested (path change / open / selection move). + if (s_browser_reload) { + s_browser_reload = false; + browser_reload(); + s_dirty = true; + } + if (s_dirty && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(20)) == pdTRUE) { memcpy(&local_status, &s_status, sizeof(local_status)); @@ -825,6 +1075,56 @@ extern "C" void display_ui_set_status(const display_status_t *s) { extern "C" void display_ui_handle_key(uint8_t key) { static uint8_t s_brightness = 100; + // Any key skips the boot intro. + if (!s_intro_done) { + s_intro_done = true; + s_dirty = true; + return; + } + + // ── Browser open: list navigation (SELECT enters dirs, MENU goes up + // then closes at the littlefs root). + if (s_browser_open) { + switch (key) { + case 2: // DOWN + if (s_browser_sel + 1 < s_browser_count) s_browser_sel++; + s_browser_reload = true; + break; + case 5: // UP + if (s_browser_sel > 0) s_browser_sel--; + s_browser_reload = true; + break; + case 1: { // SELECT — enter directory + const uint8_t sel = s_browser_sel; + if (sel < s_browser_count && s_browser_isdir[sel]) { + const size_t len = strlen(s_browser_path); + snprintf(s_browser_path + len, sizeof(s_browser_path) - len, + "/%s", s_browser_items[sel]); + s_browser_sel = 0; + s_browser_reload = true; + } + break; + } + case 3: { // MENU — up one level, close at root + char *slash = strrchr(s_browser_path, '/'); + if (slash && slash != s_browser_path && + strcmp(s_browser_path, "/littlefs") != 0) { + *slash = '\0'; + s_browser_sel = 0; + s_browser_reload = true; + } else { + s_browser_open = false; + s_shell_open = true; // back to the shell + } + break; + } + default: + break; + } + s_dirty = true; + return; + } + // ── Shell open: navigate the Workbench grid (original key semantics: // 1=SELECT launch, 2=DOWN row, 3=MENU close, 4=LEFT/RIGHT, 5=UP row). if (s_shell_open) { @@ -842,12 +1142,26 @@ extern "C" void display_ui_handle_key(uint8_t key) { display_ui_set_brightness(s_brightness); s_shell_open = true; // stay in the shell for feedback break; - default: break; + case 4: // Fichiers — browser + strncpy(s_browser_path, "/littlefs", sizeof(s_browser_path)); + s_browser_sel = 0; + s_browser_open = true; + s_browser_reload = true; + break; + default: // dynamic app + if (sel < s_shell_count && !s_pending_launch_set) { + strncpy(s_pending_launch, + s_shell_dyn[sel - SHELL_BUILTINS].id, + sizeof(s_pending_launch) - 1); + s_pending_launch[sizeof(s_pending_launch) - 1] = '\0'; + s_pending_launch_set = true; + } + break; } break; } case 2: // DOWN — next row - if (s_shell_sel + SHELL_GRID_COLS < SHELL_APP_COUNT) + if (s_shell_sel + SHELL_GRID_COLS < s_shell_count) s_shell_sel = (uint8_t)(s_shell_sel + SHELL_GRID_COLS); break; case 5: // UP — previous row @@ -855,7 +1169,7 @@ extern "C" void display_ui_handle_key(uint8_t key) { s_shell_sel = (uint8_t)(s_shell_sel - SHELL_GRID_COLS); break; case 4: // LEFT/RIGHT — next column (wraps) - s_shell_sel = (uint8_t)((s_shell_sel + 1) % SHELL_APP_COUNT); + s_shell_sel = (uint8_t)((s_shell_sel + 1) % s_shell_count); break; case 3: // MENU — close shell s_shell_open = false; @@ -893,6 +1207,14 @@ extern "C" void display_ui_handle_key(uint8_t key) { } } +extern "C" bool display_ui_take_pending_launch(char *id_out, size_t cap) { + if (!s_pending_launch_set || !id_out || cap == 0) return false; + strncpy(id_out, s_pending_launch, cap - 1); + id_out[cap - 1] = '\0'; + s_pending_launch_set = false; + return true; +} + extern "C" void display_ui_camera_frame(const uint8_t *gray, int width, int height) { // Runs in the qr_puzzle scan task — copy fast, never block scanning. if (!gray || width != CAM_W || height != CAM_H) return; diff --git a/idf_zacus/components/display_ui/include/display_ui.h b/idf_zacus/components/display_ui/include/display_ui.h index 62c5c7c..2a64173 100644 --- a/idf_zacus/components/display_ui/include/display_ui.h +++ b/idf_zacus/components/display_ui/include/display_ui.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include "esp_err.h" @@ -77,6 +78,16 @@ void display_ui_camera_frame(const uint8_t *gray, int width, int height); */ void display_ui_handle_key(uint8_t key); +/** + * @brief Pop the pending shell-app launch request, if any. + * + * Dynamic shell tiles (from /littlefs/apps//) set a pending launch on + * SELECT; the main loop polls this and performs the app action (e.g. read + * the app's step.txt and call game_endpoint_apply_step). One-shot: returns + * true at most once per launch. + */ +bool display_ui_take_pending_launch(char *id_out, size_t cap); + #ifdef __cplusplus } #endif diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 764fb1f..e5a5f28 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -653,6 +653,90 @@ static esp_err_t handle_peers_get(httpd_req_t *req) { // the arm/disarm calls here are the only writers to local_puzzles state; puzzle // tasks only call puzzle_state_report. No mutex needed for this slice. +// Core step-change logic, shared by the HTTP handler and internal callers +// (e.g. shell app launch on the local display). Error contract: +// ESP_OK armed (armed_out = "qr"|"sound"|"none") +// ESP_ERR_INVALID_STATE not ready (no puzzle_state wired) +// ESP_ERR_NOT_SUPPORTED no scenario stored / storage unavailable +// ESP_ERR_NOT_FOUND unknown step id +// ESP_ERR_INVALID_ARG invalid puzzle object in the IR +// ESP_ERR_TIMEOUT puzzle busy (QR teardown window, after one retry) +// others internal failure +esp_err_t game_endpoint_apply_step(const char *step_id, + char *armed_out, size_t armed_cap) { + if (armed_out && armed_cap) armed_out[0] = '\0'; + if (!step_id || !step_id[0]) return ESP_ERR_INVALID_ARG; + if (s_pstate == NULL) return ESP_ERR_INVALID_STATE; + + if (mount_storage_lazy() != ESP_OK) return ESP_ERR_NOT_SUPPORTED; + FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "rb"); + if (!f) return ESP_ERR_NOT_SUPPORTED; + fseek(f, 0, SEEK_END); + long fsize = ftell(f); + rewind(f); + if (fsize <= 0 || fsize > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { + fclose(f); + return ESP_ERR_NOT_SUPPORTED; + } + char *ir_json = (char *) malloc((size_t) fsize + 1); + if (!ir_json) { fclose(f); return ESP_ERR_NO_MEM; } + size_t nread = fread(ir_json, 1, (size_t) fsize, f); + fclose(f); + ir_json[nread] = '\0'; + + // Disarm current puzzle before re-arming. + local_puzzles_disarm(); + + // Parse binding for this step (+ display scene metadata, lenient). + puzzle_binding_t binding; + esp_err_t berr = puzzle_binding_from_ir(ir_json, step_id, &binding); + if (berr == ESP_OK) { + (void) scene_binding_from_ir(ir_json, step_id, &s_current_scene); + } + free(ir_json); + if (berr != ESP_OK) return berr; // NOT_FOUND / INVALID_ARG / other + + strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1); + s_current_step_id[sizeof(s_current_step_id) - 1] = '\0'; + + const char *armed = "none"; + if (binding.type == PB_QR) { + const char *ptrs[PB_MAX_CODES]; + for (size_t i = 0; i < binding.code_count; i++) ptrs[i] = binding.codes[i]; + esp_err_t aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count, + binding.fragment, binding.fragment_len); + if (aerr == ESP_ERR_INVALID_STATE) { + vTaskDelay(pdMS_TO_TICKS(250)); // async QR teardown — retry once + aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count, + binding.fragment, binding.fragment_len); + } + if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT; + if (aerr != ESP_OK) return aerr; + armed = "qr"; + } else if (binding.type == PB_SOUND) { + esp_err_t aerr = local_puzzles_arm_sound(binding.id, binding.melody, + binding.note_count, binding.tolerance, + binding.fragment, binding.fragment_len); + if (aerr == ESP_ERR_INVALID_STATE) { + vTaskDelay(pdMS_TO_TICKS(250)); + aerr = local_puzzles_arm_sound(binding.id, binding.melody, + binding.note_count, binding.tolerance, + binding.fragment, binding.fragment_len); + } + if (aerr == ESP_ERR_INVALID_STATE) return ESP_ERR_TIMEOUT; + if (aerr != ESP_OK) return aerr; + armed = "sound"; + } + + strncpy(s_current_armed, armed, sizeof(s_current_armed) - 1); + s_current_armed[sizeof(s_current_armed) - 1] = '\0'; + if (armed_out && armed_cap) { + strncpy(armed_out, armed, armed_cap - 1); + armed_out[armed_cap - 1] = '\0'; + } + return ESP_OK; +} + static esp_err_t handle_step_post(httpd_req_t *req) { if (s_pstate == NULL) { return send_error(req, "503 Service Unavailable", "not_ready"); @@ -689,120 +773,31 @@ static esp_err_t handle_step_post(httpd_req_t *req) { step_id[sizeof(step_id) - 1] = '\0'; cJSON_Delete(root); - // Load scenario from LittleFS. - if (mount_storage_lazy() != ESP_OK) { - return send_error(req, "503 Service Unavailable", "storage_unavailable"); + // Delegate to the shared core; map its error contract onto the SAME + // HTTP statuses/messages as before (REST contract unchanged). + char armed[8]; + esp_err_t aerr = game_endpoint_apply_step(step_id, armed, sizeof(armed)); + switch (aerr) { + case ESP_OK: { + char buf[128]; + snprintf(buf, sizeof(buf), + "{\"step_id\":\"%s\",\"armed\":\"%s\"}", step_id, armed); + return send_json(req, "200 OK", buf); } - FILE *f = fopen(GAME_ENDPOINT_SCENARIO_PATH, "rb"); - if (!f) { + case ESP_ERR_NOT_SUPPORTED: return send_error(req, "409 Conflict", "no_scenario"); - } - fseek(f, 0, SEEK_END); - long fsize = ftell(f); - rewind(f); - if (fsize <= 0 || fsize > GAME_ENDPOINT_MAX_SCENARIO_BYTES) { - fclose(f); - return send_error(req, "409 Conflict", "no_scenario"); - } - char *ir_json = (char *) malloc((size_t) fsize + 1); - if (!ir_json) { - fclose(f); - return send_error(req, "500 Internal Server Error", "out of memory"); - } - size_t nread = fread(ir_json, 1, (size_t) fsize, f); - fclose(f); - ir_json[nread] = '\0'; - - // Disarm current puzzle before re-arming. - local_puzzles_disarm(); - - // Parse binding for this step (+ display scene metadata, lenient). - puzzle_binding_t binding; - esp_err_t berr = puzzle_binding_from_ir(ir_json, step_id, &binding); - if (berr == ESP_OK) { - // Scene parse can only fail on malformed JSON, which binding parse - // already screened — ignore its return; defaults are safe. - (void) scene_binding_from_ir(ir_json, step_id, &s_current_scene); - } - free(ir_json); - - if (berr == ESP_ERR_NOT_FOUND) { + case ESP_ERR_NOT_FOUND: return send_error(req, "404 Not Found", "unknown_step"); - } - if (berr == ESP_ERR_INVALID_ARG) { + case ESP_ERR_INVALID_ARG: return send_error(req, "422 Unprocessable Entity", "invalid_puzzle"); + case ESP_ERR_TIMEOUT: + return send_error(req, "503 Service Unavailable", "puzzle_busy"); + case ESP_ERR_INVALID_STATE: + return send_error(req, "503 Service Unavailable", "not_ready"); + default: + return send_error(req, "500 Internal Server Error", + esp_err_to_name(aerr)); } - if (berr != ESP_OK) { - return send_error(req, "500 Internal Server Error", esp_err_to_name(berr)); - } - - // Remember step id. - strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1); - s_current_step_id[sizeof(s_current_step_id) - 1] = '\0'; - - if (binding.type == PB_NONE) { - strncpy(s_current_armed, "none", sizeof(s_current_armed) - 1); - s_current_armed[sizeof(s_current_armed) - 1] = '\0'; - char buf[128]; - snprintf(buf, sizeof(buf), - "{\"step_id\":\"%s\",\"armed\":\"none\"}", step_id); - return send_json(req, "200 OK", buf); - } - - if (binding.type == PB_QR) { - // Build pointer array from the [16][32] codes matrix. - const char *ptrs[PB_MAX_CODES]; - for (size_t i = 0; i < binding.code_count; i++) { - ptrs[i] = binding.codes[i]; - } - esp_err_t aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count, - binding.fragment, binding.fragment_len); - if (aerr == ESP_ERR_INVALID_STATE) { - // Async QR teardown window — retry once after 250 ms. - vTaskDelay(pdMS_TO_TICKS(250)); - aerr = local_puzzles_arm_qr(binding.id, ptrs, binding.code_count, - binding.fragment, binding.fragment_len); - } - if (aerr == ESP_ERR_INVALID_STATE) { - return send_error(req, "503 Service Unavailable", "puzzle_busy"); - } - if (aerr != ESP_OK) { - return send_error(req, "500 Internal Server Error", esp_err_to_name(aerr)); - } - strncpy(s_current_armed, "qr", sizeof(s_current_armed) - 1); - s_current_armed[sizeof(s_current_armed) - 1] = '\0'; - char buf[128]; - snprintf(buf, sizeof(buf), - "{\"step_id\":\"%s\",\"armed\":\"qr\"}", step_id); - return send_json(req, "200 OK", buf); - } - - if (binding.type == PB_SOUND) { - esp_err_t aerr = local_puzzles_arm_sound(binding.id, binding.melody, - binding.note_count, binding.tolerance, - binding.fragment, binding.fragment_len); - if (aerr == ESP_ERR_INVALID_STATE) { - vTaskDelay(pdMS_TO_TICKS(250)); - aerr = local_puzzles_arm_sound(binding.id, binding.melody, - binding.note_count, binding.tolerance, - binding.fragment, binding.fragment_len); - } - if (aerr == ESP_ERR_INVALID_STATE) { - return send_error(req, "503 Service Unavailable", "puzzle_busy"); - } - if (aerr != ESP_OK) { - return send_error(req, "500 Internal Server Error", esp_err_to_name(aerr)); - } - strncpy(s_current_armed, "sound", sizeof(s_current_armed) - 1); - s_current_armed[sizeof(s_current_armed) - 1] = '\0'; - char buf[128]; - snprintf(buf, sizeof(buf), - "{\"step_id\":\"%s\",\"armed\":\"sound\"}", step_id); - return send_json(req, "200 OK", buf); - } - - // Unreachable — unknown binding type. - return send_error(req, "500 Internal Server Error", "unknown_binding_type"); } // ─── GET /game/puzzle_state ────────────────────────────────────────────────── diff --git a/idf_zacus/components/game_endpoint/include/game_endpoint.h b/idf_zacus/components/game_endpoint/include/game_endpoint.h index 9df190e..da2ccba 100644 --- a/idf_zacus/components/game_endpoint/include/game_endpoint.h +++ b/idf_zacus/components/game_endpoint/include/game_endpoint.h @@ -101,6 +101,21 @@ void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap, */ void game_endpoint_get_scene(scene_binding_t *out); +/** + * @brief Apply a scenario step internally (same logic as POST /game/step). + * + * Used by local triggers (e.g. shell app launch on the device display). + * May block up to ~250 ms (QR re-arm retry). Error contract documented at + * the definition (NOT_SUPPORTED=no scenario, NOT_FOUND=unknown step, + * TIMEOUT=puzzle busy, INVALID_STATE=not ready, INVALID_ARG=bad IR puzzle). + * + * @param step_id Step to arm (non-empty). + * @param armed_out Optional out: "qr"|"sound"|"none". + * @param armed_cap Capacity of armed_out. + */ +esp_err_t game_endpoint_apply_step(const char *step_id, + char *armed_out, size_t armed_cap); + #ifdef __cplusplus } #endif diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index a0553bf..b9a5819 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -702,6 +702,33 @@ void app_main(void) { ds.scene_effect = (uint8_t) sb.effect; display_ui_set_status(&ds); + + // Shell app launch (dynamic tiles from /littlefs/apps//): + // the app's step.txt names the scenario step to arm. + char app_id[32]; + if (display_ui_take_pending_launch(app_id, sizeof(app_id))) { + char path[80], step[64] = {0}; + snprintf(path, sizeof(path), "/littlefs/apps/%s/step.txt", + app_id); + FILE *af = fopen(path, "r"); + if (af) { + if (fgets(step, sizeof(step), af)) { + step[strcspn(step, "\r\n")] = '\0'; + } + fclose(af); + } + if (step[0]) { + char armed[8]; + esp_err_t lerr = game_endpoint_apply_step(step, armed, + sizeof(armed)); + ESP_LOGI(TAG, "app '%s' -> step '%s': %s (armed=%s)", + app_id, step, esp_err_to_name(lerr), + (lerr == ESP_OK) ? armed : "-"); + } else { + ESP_LOGW(TAG, "app '%s': no step.txt — nothing to do", + app_id); + } + } } // ── 60 s heartbeat (every 120 × 500 ms sub-ticks) ─────────────── diff --git a/idf_zacus/sdkconfig.defaults b/idf_zacus/sdkconfig.defaults index 510458a..c4d92ae 100644 --- a/idf_zacus/sdkconfig.defaults +++ b/idf_zacus/sdkconfig.defaults @@ -64,3 +64,9 @@ CONFIG_AFE_INTERFACE_V1=y CONFIG_LV_MEM_SIZE_KILOBYTES=48 CONFIG_LV_FONT_MONTSERRAT_24=y CONFIG_LV_FONT_MONTSERRAT_28=y + +# LVGL file access + PNG icons (shell apps from littlefs) +CONFIG_LV_USE_FS_STDIO=y +CONFIG_LV_FS_STDIO_LETTER=76 +CONFIG_LV_FS_STDIO_PATH="/littlefs" +CONFIG_LV_USE_PNG=y