feat(ui): cracktro intro + file browser + apps

This commit is contained in:
Claude Worker claude2
2026-06-10 22:38:44 +02:00
parent 563dfea368
commit fd1a517989
6 changed files with 545 additions and 169 deletions
+382 -60
View File
@@ -45,6 +45,9 @@
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <dirent.h>
#include <sys/stat.h>
#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/<id>/ (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/<id>/ (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;
@@ -1,5 +1,6 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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/<id>/) 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
+104 -109
View File
@@ -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 ──────────────────────────────────────────────────
@@ -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
+27
View File
@@ -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/<id>/):
// 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) ───────────────
+6
View File
@@ -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