Files
ESP32_ZACUS/idf_zacus/components/display_ui/display_ui.cpp
T
clement a8af29068b
CI / platformio (pull_request) Failing after 4m50s
CI / platformio (push) Failing after 10m23s
feat(gamebook): full-height scrolling text + real pad mapping
Context:
Two issues on the Freenove gamebook: (1) long passages were clipped —
the reading band was small and text overflowed; (2) the 5-way pad was
decoded wrong, so navigation felt random, and on this unit the physical
Up button is electrically dead (held = no voltage change).

Approach:
Give the reading text the whole screen height in a vertically
scrollable container, split selection from scrolling, and remap the
keys to the pad's MEASURED voltages.

Changes:
- display_ui: gamebook view reworked — compact title, a full-height
  scrollable reading container (the body wraps and scrolls, never
  clipped), and a one-line choice selector pinned at the bottom; new
  display_ui_gamebook_scroll() + reset-on-new-passage; show() gains a
  reset_scroll arg; body buffer 512 -> 1100.
- gamebook.c: controls matched to the measured ladder
  (key2=LEFT, key4=DOWN, key5=RIGHT, key1=CLICK; key3 unused; Up dead):
  Left/Right scroll the text up/down, Down cycles the answer, Click
  validates; library nav cycles tiles without needing Up.

Impact:
Long passages are fully readable (they scroll), and all four working
pad buttons do the right thing; nothing depends on the dead Up button.
Hardware-calibrated against the real pad voltages.
2026-06-19 23:56:45 +02:00

1510 lines
63 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// display_ui.cpp — ST7796 + LVGL status screen, Phase 2a.
//
// Panel / bus configuration faithfully replicates the original Freenove
// Arduino firmware (ui_freenove_allinone/src/drivers/display/display_hal_lgfx.cpp
// FreenoveLgfxDevice, lines ~132-208):
//
// - Panel: Panel_ST7796
// - Bus: Bus_SPI, SPI2_HOST (FSPI; FREENOVE_LCD_USE_HSPI == 0)
// - Freq: 80 MHz write / 20 MHz read
// - spi_mode 0, spi_3wire false, use_lock false, dma_channel 0 (CPU mode)
// - Pins: SCK=47, MOSI=21, MISO=-1, DC=45, CS=-1, RST=20, BL=2
// - Panel: memory/panel 320×480, offset_x/y 0, offset_rotation 0,
// dummy_read_pixel 8, dummy_read_bits 1, readable false (no MISO),
// invert true (TFT_INVERSION_ON), rgb_order true (not BGR),
// dlen_16bit false, bus_shared true
// - Rotation: 1 (landscape)
//
// LVGL (Phase 2a):
// LVGL 8.4 (same line as the original: platformio.ini:35 lvgl/lvgl@8.4.0),
// configured via the managed component's Kconfig (sdkconfig: depth 16,
// swap 0, mem 160 KB, Montserrat 24/28 — mirroring the original lv_conf.h).
// Flush callback is the canonical LovyanGFX↔LVGL8 glue (startWrite /
// setAddrWindow / writePixels rgb565 / endWrite), matching the effective
// behaviour of the original ui_manager_display.cpp glue (its DMA path is
// disabled at runtime in the original HAL, so the synchronous path is the
// faithful one). Draw buffer: width × 40 lines, single, INTERNAL ram —
// exactly like the original (buffer_cfg_.draw_in_psram = false, lines = 40).
// Tick: esp_timer periodic 2 ms → lv_tick_inc(2) (the original calls
// lv_tick_inc(elapsed) from its UI task; LV_TICK_CUSTOM is off both sides).
//
// BACKLIGHT (Phase 2a):
// LEDC PWM on LEDC_TIMER_1 / LEDC_CHANNEL_1 (low-speed), ~44.1 kHz like the
// original Light_PWM. LEDC_TIMER_0 / LEDC_CHANNEL_0 stay reserved for the
// camera XCLK (qr_puzzle). display_ui_set_brightness() exposes duty 0-100%.
//
// Thread model:
// ALL LVGL and LovyanGFX calls run exclusively on the display_task.
// display_ui_set_status() copies the status under a mutex and sets a dirty
// flag; the task folds it into LVGL labels within ~50 ms. lv_tick_inc is
// called from the esp_timer task (atomic counter increment — the standard
// espressif pattern).
#include "display_ui.h"
#include "board_pins_mediakit.h"
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <dirent.h>
#include <sys/stat.h>
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
#include "lvgl.h"
// Original UI fonts (fonts/ — copied from ui_freenove_allinone/src/ui/fonts/).
LV_FONT_DECLARE(lv_font_orbitron_40);
LV_FONT_DECLARE(lv_font_ibmplexmono_18);
static const char *TAG = "display_ui";
// ─── LovyanGFX device (faithful port of FreenoveLgfxDevice) ──────────────────
class ZacusDisplay final : public lgfx::LGFX_Device {
public:
ZacusDisplay() {
// ── SPI bus ──────────────────────────────────────────────────────────
{
auto cfg = _bus_instance.config();
cfg.spi_host = SPI2_HOST; // FSPI (FREENOVE_LCD_USE_HSPI == 0)
cfg.spi_mode = 0;
cfg.freq_write = 80000000; // 80 MHz
cfg.freq_read = 20000000; // 20 MHz
cfg.spi_3wire = false;
cfg.use_lock = false;
// CPU mode — DMA out-link init can be unavailable on this
// board/runtime mix (same note as original firmware, line ~145).
cfg.dma_channel = 0;
cfg.pin_sclk = TFT_PIN_SCK;
cfg.pin_mosi = TFT_PIN_MOSI;
cfg.pin_miso = TFT_PIN_MISO; // -1 (not wired)
cfg.pin_dc = TFT_PIN_DC;
_bus_instance.config(cfg);
_panel_instance.setBus(&_bus_instance);
}
// ── Panel ─────────────────────────────────────────────────────────────
{
auto cfg = _panel_instance.config();
cfg.pin_cs = -1; // CS not wired
cfg.pin_rst = TFT_PIN_RST;
cfg.pin_busy = -1;
cfg.memory_width = 320;
cfg.memory_height = 480;
cfg.panel_width = 320;
cfg.panel_height = 480;
cfg.offset_x = 0;
cfg.offset_y = 0;
cfg.offset_rotation = 0;
cfg.dummy_read_pixel = 8;
cfg.dummy_read_bits = 1;
cfg.readable = false; // MISO == -1
cfg.invert = true; // TFT_INVERSION_ON in original
cfg.rgb_order = true; // not BGR
cfg.dlen_16bit = false;
cfg.bus_shared = true;
_panel_instance.config(cfg);
}
// Backlight handled externally (LEDC PWM, see file header).
setPanel(&_panel_instance);
}
private:
lgfx::Bus_SPI _bus_instance;
lgfx::Panel_ST7796 _panel_instance;
};
static ZacusDisplay s_lcd;
// ─── Status state ─────────────────────────────────────────────────────────────
static display_status_t s_status;
static SemaphoreHandle_t s_mutex = NULL;
static volatile bool s_dirty = false;
static bool s_running = false;
// ─── Palette (Amiga Workbench-ish) ────────────────────────────────────────────
static const uint32_t COL_BG = 0x0055AA; // Workbench blue
static const uint32_t COL_TITLEBAR = 0x0000AA; // darker blue
static const uint32_t COL_TITLE_TXT = 0xFFFFFF; // white
static const uint32_t COL_LABEL = 0xAAAAAA; // mid grey
static const uint32_t COL_VALUE = 0xFFFFFF; // white
static const uint32_t COL_CODE = 0xFF8800; // Workbench orange
// ─── LVGL plumbing ────────────────────────────────────────────────────────────
#define DUI_HOR_RES 480 // rotation 1 (landscape)
#define DUI_VER_RES 320
#define DUI_BUF_LINES 40 // like the original (buffer_cfg_.lines)
static lv_disp_draw_buf_t s_draw_buf;
static lv_color_t *s_buf1;
static lv_disp_drv_t s_disp_drv;
static esp_timer_handle_t s_tick_timer;
// Canonical LovyanGFX↔LVGL8 glue (synchronous — matches the original's
// effective path; its DMA branch is disabled at runtime).
static void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area,
lv_color_t *color_p) {
const uint32_t w = (uint32_t) (area->x2 - area->x1 + 1);
const uint32_t h = (uint32_t) (area->y2 - area->y1 + 1);
s_lcd.startWrite();
s_lcd.setAddrWindow(area->x1, area->y1, w, h);
s_lcd.writePixels((lgfx::rgb565_t *) &color_p->full, w * h);
s_lcd.endWrite();
lv_disp_flush_ready(drv);
}
static void lvgl_tick_cb(void *arg) {
(void) arg;
lv_tick_inc(2);
}
// ─── LVGL status screen objects (display task only) ───────────────────────────
typedef struct {
lv_obj_t *value; // value label of a row
} dui_row_t;
static lv_obj_t *s_scr_status; // status screen (no active step)
static lv_obj_t *s_lbl_title;
static dui_row_t s_row_ip, s_row_wake, s_row_step, s_row_armed, s_row_solved;
static lv_obj_t *s_lbl_code;
// Scene view (active step) — structure mirrors the original ui_manager scene
// frame: title (Orbitron), subtitle (IBM Plex Mono), symbol slot (the
// assembled code, pulse effect = SceneEffect::kPulse default).
static lv_obj_t *s_scr_scene;
static lv_obj_t *s_scene_title; // step id, Orbitron 40, top-center
static lv_obj_t *s_scene_subtitle; // armed puzzle, IBM Plex 18, bottom
static lv_obj_t *s_scene_code; // assembled code, Orbitron 40, center
// Camera viewfinder (QR aiming aid). The qr_puzzle scan task pushes
// grayscale QVGA frames via display_ui_camera_frame(); the display task
// converts to RGB565 into the canvas buffer at ~5 fps.
#define CAM_W 320
#define CAM_H 240
static lv_obj_t *s_scene_cam; // lv_canvas, PSRAM-backed
static lv_color_t *s_cam_canvas_buf; // CAM_W*CAM_H rgb565 (PSRAM)
static uint8_t *s_cam_gray; // incoming copy (PSRAM)
static SemaphoreHandle_t s_cam_mutex;
static volatile bool s_cam_fresh = false;
static bool s_cam_visible = false;
// ─── Amiga Workbench shell (port of ui_amiga_shell, same grid metrics) ───────
#define SHELL_GRID_COLS 4 // = AmigaUIShell::GRID_COLS
#define SHELL_ICON_SIZE 64 // = ICON_SIZE
#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_BUILTINS 5
#define SHELL_MAX_TILES 12
static lv_obj_t *s_scr_shell;
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; }
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)
// 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;
// ── Gamebook view ("livre dont vous êtes le héros") ─────────────────────────
static lv_obj_t *s_scr_gamebook;
static lv_obj_t *s_gb_title; // page title, top
static lv_obj_t *s_gb_body; // full passage text, wrapped
static lv_obj_t *s_gb_menu; // choice lines, bottom
static volatile bool s_gamebook_open = false;
static lv_obj_t *s_gb_body_cont; // scrollable container holding s_gb_body
static char s_gb_title_buf[64];
static char s_gb_body_buf[1100];
static char s_gb_menu_buf[160];
static volatile int s_gb_scroll_req = 0; // pending scroll: +pages down / -pages up
static volatile bool s_gb_scroll_home = false; // reset scroll to top on new passage
// ── Gamebook library (tile grid) ────────────────────────────────────────────
#define LIB_TILES 6
static lv_obj_t *s_scr_library;
static lv_obj_t *s_lib_tile[LIB_TILES];
static lv_obj_t *s_lib_label[LIB_TILES];
static volatile bool s_library_open = false;
static char s_lib_title_buf[LIB_TILES][48];
static int s_lib_count_buf = 0;
static int s_lib_sel_buf = 0;
static volatile bool s_browser_reload = false;
// ─── Intro — faithful port of the original cracktro ──────────────────────────
// Phase A 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. Phases B/C/D are the original 3D
// FX (rotozoom, dot sphere, ray corridor) ported in intro_fx3d.cpp, rendered
// into a full-screen LVGL canvas behind the logo + scroller. If the canvas
// PSRAM allocation fails, the intro gracefully stays on phase A throughout.
#include "intro_fx3d.h"
#define INTRO_STARS 48
#define INTRO_BARS 4
#define INTRO_PHASE_A_MS 7000
#define INTRO_FX_MS 3000
#define INTRO_DURATION_MS (INTRO_PHASE_A_MS + 3 * INTRO_FX_MS)
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_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;
static lv_obj_t *s_fx_canvas; // phases B/C/D render target
static lv_color_t *s_fx_canvas_buf; // 480x320 RGB565, PSRAM
static bool s_fx_tried, s_fx_ok; // lazy one-shot setup at phase B
static fx3d_mode_t s_fx_order[3]; // 3 distinct FX drawn per boot
// 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) {
lv_obj_t *lbl = lv_label_create(parent);
lv_obj_set_style_text_color(lbl, lv_color_hex(COL_LABEL), 0);
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
lv_label_set_text(lbl, label_txt);
lv_obj_set_pos(lbl, 8, y);
lv_obj_t *val = lv_label_create(parent);
lv_obj_set_style_text_color(val, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0);
lv_label_set_text(val, "...");
lv_obj_set_pos(val, 160, y);
lv_label_set_long_mode(val, LV_LABEL_LONG_DOT);
lv_obj_set_width(val, DUI_HOR_RES - 168);
out->value = val;
return val;
}
// ─── Scene effects (ports of the original ui_manager SceneEffect set) ────────
// Pulse — SceneEffect::kPulse equivalent (opacity breathe on the symbol/code).
static void pulse_anim_cb(void *obj, int32_t v) {
lv_obj_set_style_opa((lv_obj_t *) obj, (lv_opa_t) v, 0);
}
// Glitch — jitter + flicker on the title (text-artifact evocation of the
// original glitch overlay; the full pixel-shred shader is a later phase).
static lv_timer_t *s_glitch_timer;
static void glitch_timer_cb(lv_timer_t *t) {
(void) t;
const int dx = (int) (esp_random() % 7) - 3;
lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, dx, 24);
lv_obj_set_style_opa(s_scene_title,
(esp_random() % 8) ? LV_OPA_COVER : LV_OPA_60, 0);
}
// Gyrophare — rotating beacon ring around the scene.
static lv_obj_t *s_gyro_arc;
static void gyro_anim_cb(void *obj, int32_t v) {
lv_arc_set_rotation((lv_obj_t *) obj, (uint16_t) v);
}
static uint8_t s_fx_applied = 0xFF; // force first apply
static void start_pulse(void) {
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, s_scene_code);
lv_anim_set_exec_cb(&a, pulse_anim_cb);
lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
lv_anim_set_time(&a, 600);
lv_anim_set_playback_time(&a, 600);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&a);
}
static void apply_effect(uint8_t fx) {
if (fx == s_fx_applied) return;
s_fx_applied = fx;
// Stop everything, restore neutral state.
lv_anim_del(s_scene_code, pulse_anim_cb);
lv_obj_set_style_opa(s_scene_code, LV_OPA_COVER, 0);
if (s_glitch_timer) lv_timer_pause(s_glitch_timer);
lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, 0, 24);
lv_obj_set_style_opa(s_scene_title, LV_OPA_COVER, 0);
if (s_gyro_arc) {
lv_anim_del(s_gyro_arc, gyro_anim_cb);
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
}
switch (fx) {
case 0: // pulse (default)
start_pulse();
break;
case 1: // glitch
if (s_glitch_timer) lv_timer_resume(s_glitch_timer);
break;
case 2: // gyro
if (s_gyro_arc) {
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
lv_anim_t a;
lv_anim_init(&a);
lv_anim_set_var(&a, s_gyro_arc);
lv_anim_set_exec_cb(&a, gyro_anim_cb);
lv_anim_set_values(&a, 0, 360);
lv_anim_set_time(&a, 1200);
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&a);
}
break;
default: // none
break;
}
}
static void build_scene_screen(void) {
s_scr_scene = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_scr_scene, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_scr_scene, LV_OPA_COVER, 0);
// Viewfinder canvas (hidden unless the QR puzzle is armed).
s_cam_canvas_buf = (lv_color_t *) heap_caps_malloc(
(size_t) CAM_W * CAM_H * sizeof(lv_color_t), MALLOC_CAP_SPIRAM);
s_cam_gray = (uint8_t *) heap_caps_malloc(
(size_t) CAM_W * CAM_H, MALLOC_CAP_SPIRAM);
if (s_cam_canvas_buf && s_cam_gray) {
s_scene_cam = lv_canvas_create(s_scr_scene);
lv_canvas_set_buffer(s_scene_cam, s_cam_canvas_buf,
CAM_W, CAM_H, LV_IMG_CF_TRUE_COLOR);
lv_canvas_fill_bg(s_scene_cam, lv_color_hex(0x101010), LV_OPA_COVER);
lv_obj_align(s_scene_cam, LV_ALIGN_CENTER, 0, 8);
lv_obj_add_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
} else {
ESP_LOGW(TAG, "viewfinder buffers alloc failed — preview disabled");
}
s_scene_title = lv_label_create(s_scr_scene);
lv_obj_set_style_text_color(s_scene_title, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(s_scene_title, &lv_font_orbitron_40, 0);
lv_label_set_text(s_scene_title, "");
lv_label_set_long_mode(s_scene_title, LV_LABEL_LONG_SCROLL_CIRCULAR);
lv_obj_set_width(s_scene_title, DUI_HOR_RES - 24);
lv_obj_set_style_text_align(s_scene_title, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, 0, 24);
s_scene_code = lv_label_create(s_scr_scene);
lv_obj_set_style_text_color(s_scene_code, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_text_font(s_scene_code, &lv_font_orbitron_40, 0);
lv_label_set_text(s_scene_code, "");
lv_obj_align(s_scene_code, LV_ALIGN_CENTER, 0, 16);
s_scene_subtitle = lv_label_create(s_scr_scene);
lv_obj_set_style_text_color(s_scene_subtitle, lv_color_hex(COL_LABEL), 0);
lv_obj_set_style_text_font(s_scene_subtitle, &lv_font_ibmplexmono_18, 0);
lv_label_set_text(s_scene_subtitle, "");
lv_obj_align(s_scene_subtitle, LV_ALIGN_BOTTOM_LEFT, 8, -10);
// Effect plumbing: glitch timer (paused until selected) + gyro ring
// (hidden until selected). Pulse starts via apply_effect on first status.
s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL);
lv_timer_pause(s_glitch_timer);
s_gyro_arc = lv_arc_create(s_scr_scene);
lv_obj_set_size(s_gyro_arc, 300, 300);
lv_obj_center(s_gyro_arc);
lv_arc_set_bg_angles(s_gyro_arc, 0, 360);
lv_arc_set_angles(s_gyro_arc, 0, 60); // 60° beacon segment
lv_obj_remove_style(s_gyro_arc, NULL, LV_PART_KNOB);
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(0xFF3300),
LV_PART_INDICATOR);
lv_obj_set_style_arc_width(s_gyro_arc, 8, LV_PART_INDICATOR);
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
}
static void build_shell_screen(void) {
s_scr_shell = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_scr_shell, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(s_scr_shell, LV_OPA_COVER, 0);
lv_obj_t *bar = lv_obj_create(s_scr_shell);
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);
lv_obj_t *t = lv_label_create(bar);
lv_obj_set_style_text_color(t, lv_color_hex(COL_TITLE_TXT), 0);
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.
}
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);
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 < 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);
}
}
// ─── 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) {
s_scr_intro = lv_obj_create(NULL);
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);
// 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_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);
}
// Phase B entry: allocate the FX module + a full-screen canvas behind the
// logo/scroller, hide the phase-A starfield + bars. One-shot; on failure the
// intro simply keeps the starfield (s_fx_ok stays false). Display task only.
static bool intro_fx_setup(void) {
if (!fx3d_init()) return false;
// Fisher-Yates over the 6-FX pool, keep the first 3: each boot shows a
// different random selection/order without lengthening the intro.
fx3d_mode_t pool[FX3D_MODE_COUNT];
for (int i = 0; i < FX3D_MODE_COUNT; i++) pool[i] = (fx3d_mode_t) i;
for (int i = FX3D_MODE_COUNT - 1; i > 0; i--) {
const int j = (int) (esp_random() % (uint32_t) (i + 1));
const fx3d_mode_t tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
}
for (int i = 0; i < 3; i++) s_fx_order[i] = pool[i];
ESP_LOGI(TAG, "intro fx: order=%d,%d,%d (0=roto 1=sphere 2=corridor "
"3=star3d 4=voxel 5=cube)",
(int) s_fx_order[0], (int) s_fx_order[1], (int) s_fx_order[2]);
s_fx_canvas_buf = (lv_color_t *) heap_caps_malloc(
(size_t) DUI_HOR_RES * DUI_VER_RES * sizeof(lv_color_t),
MALLOC_CAP_SPIRAM);
if (!s_fx_canvas_buf) return false;
s_fx_canvas = lv_canvas_create(s_scr_intro);
lv_canvas_set_buffer(s_fx_canvas, s_fx_canvas_buf,
DUI_HOR_RES, DUI_VER_RES, LV_IMG_CF_TRUE_COLOR);
lv_obj_set_pos(s_fx_canvas, 0, 0);
lv_obj_move_background(s_fx_canvas);
for (int i = 0; i < INTRO_STARS; i++)
lv_obj_add_flag(s_star_objs[i], LV_OBJ_FLAG_HIDDEN);
for (int i = 0; i < INTRO_BARS; i++)
lv_obj_add_flag(s_intro_bars[i], LV_OBJ_FLAG_HIDDEN);
return true;
}
// 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;
// Phase sequencing: A starfield, then B/C/D = rotozoom, dot sphere,
// ray corridor (original fx_mode order) on the FX canvas.
if (s_intro_t_ms >= INTRO_PHASE_A_MS && !s_fx_tried) {
s_fx_tried = true;
s_fx_ok = intro_fx_setup();
}
if (s_fx_ok && s_intro_t_ms >= INTRO_PHASE_A_MS) {
int fx = (int) ((s_intro_t_ms - INTRO_PHASE_A_MS) / INTRO_FX_MS);
if (fx > 2) fx = 2;
// Render every other tick (20 ms): the SPI flush of a full-screen
// canvas dominates anyway, no point racing it.
static bool skip;
skip = !skip;
if (!skip) {
fx3d_render(s_fx_order[fx], s_intro_t_ms,
(uint16_t *) s_fx_canvas_buf, DUI_HOR_RES, DUI_VER_RES);
lv_obj_invalidate(s_fx_canvas);
}
goto scroller; // stars/bars hidden — skip their updates
}
// 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));
}
scroller:
// 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_library_screen(void) {
s_scr_library = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_scr_library, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_scr_library, LV_OPA_COVER, 0);
lv_obj_clear_flag(s_scr_library, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *hdr = lv_label_create(s_scr_library);
lv_obj_set_style_text_color(hdr, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_text_font(hdr, &lv_font_montserrat_14, 0);
lv_label_set_text(hdr, "Choisis ton histoire");
lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 4);
for (int i = 0; i < LIB_TILES; i++) {
int col = i % 2, row = i / 2;
lv_obj_t *t = lv_obj_create(s_scr_library);
lv_obj_set_size(t, 228, 84);
lv_obj_set_pos(t, 8 + col * 236, 26 + row * 94);
lv_obj_set_style_radius(t, 8, 0);
lv_obj_set_style_bg_color(t, lv_color_hex(0x101820), 0);
lv_obj_set_style_bg_opa(t, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(t, 2, 0);
lv_obj_set_style_border_color(t, lv_color_hex(0x445566), 0);
lv_obj_set_style_pad_all(t, 6, 0);
lv_obj_clear_flag(t, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *l = lv_label_create(t);
lv_obj_set_style_text_color(l, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, 208);
lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(l, "");
lv_obj_center(l);
s_lib_tile[i] = t;
s_lib_label[i] = l;
}
}
static void build_gamebook_screen(void) {
s_scr_gamebook = lv_obj_create(NULL);
lv_obj_set_style_bg_color(s_scr_gamebook, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_scr_gamebook, LV_OPA_COVER, 0);
// Compact title line at the very top.
s_gb_title = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_title, LV_LABEL_LONG_DOT);
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 12);
lv_obj_set_style_text_align(s_gb_title, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s_gb_title, "");
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 4);
// Choice selector: a single line pinned at the very bottom. Left/Right cycle
// the selection, click confirms. Drawn last (foreground).
s_gb_menu = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_menu, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x182230), 0);
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(s_gb_menu, 5, 0);
lv_obj_set_style_text_font(s_gb_menu, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_DOT);
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
lv_obj_set_style_text_align(s_gb_menu, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s_gb_menu, "");
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_MID, 0, 0);
// Reading area: a vertically-scrollable container filling the whole height
// between the title and the choice line. Up/Down scroll it by half-pages.
s_gb_body_cont = lv_obj_create(s_scr_gamebook);
lv_obj_set_style_bg_color(s_gb_body_cont, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_gb_body_cont, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_gb_body_cont, 0, 0);
lv_obj_set_style_pad_all(s_gb_body_cont, 6, 0);
lv_obj_set_size(s_gb_body_cont, DUI_HOR_RES, 264); // 24 (title) .. 288 (menu)
lv_obj_set_pos(s_gb_body_cont, 0, 24);
lv_obj_set_scroll_dir(s_gb_body_cont, LV_DIR_VER);
lv_obj_set_scrollbar_mode(s_gb_body_cont, LV_SCROLLBAR_MODE_AUTO);
s_gb_body = lv_label_create(s_gb_body_cont);
lv_obj_set_style_text_color(s_gb_body, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(s_gb_body, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_gb_body, DUI_HOR_RES - 24); // container width minus padding
lv_label_set_text(s_gb_body, "");
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 0, 0);
}
static void build_status_screen(void) {
lv_obj_t *scr = s_scr_status;
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
// Title bar
lv_obj_t *bar = lv_obj_create(scr);
lv_obj_remove_style_all(bar);
lv_obj_set_size(bar, DUI_HOR_RES, 36);
lv_obj_set_pos(bar, 0, 0);
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_lbl_title = lv_label_create(bar);
lv_obj_set_style_text_color(s_lbl_title, lv_color_hex(COL_TITLE_TXT), 0);
lv_obj_set_style_text_font(s_lbl_title, &lv_font_montserrat_24, 0);
lv_label_set_text(s_lbl_title, "ZACUS MASTER");
lv_obj_align(s_lbl_title, LV_ALIGN_LEFT_MID, 8, 0);
// Rows
int y = 52;
const int LINE_H = 30;
make_row(scr, y, "IP", &s_row_ip); y += LINE_H;
make_row(scr, y, "Wake", &s_row_wake); y += LINE_H;
make_row(scr, y, "Etape", &s_row_step); y += LINE_H;
make_row(scr, y, "Enigme", &s_row_armed); y += LINE_H;
make_row(scr, y, "Resolues", &s_row_solved); y += LINE_H;
// Assembled code — big, orange
s_lbl_code = lv_label_create(scr);
lv_obj_set_style_text_color(s_lbl_code, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_text_font(s_lbl_code, &lv_font_montserrat_28, 0);
lv_label_set_text(s_lbl_code, "Code: ---");
lv_obj_set_pos(s_lbl_code, 8, y + 12);
}
static void apply_status(const display_status_t *s) {
// Status screen rows
lv_label_set_text(s_row_ip.value, s->ip[0] ? s->ip : "(aucune)");
lv_label_set_text(s_row_wake.value, s->wake_active ? "oui" : "non");
lv_label_set_text(s_row_step.value, s->step_id[0] ? s->step_id : "(aucune)");
lv_label_set_text(s_row_armed.value, s->armed[0] ? s->armed : "none");
char tmp[48];
snprintf(tmp, sizeof(tmp), "%u", (unsigned) s->solved_count);
lv_label_set_text(s_row_solved.value, tmp);
char code[32];
snprintf(code, sizeof(code), "Code: %s", s->code[0] ? s->code : "---");
lv_label_set_text(s_lbl_code, code);
// Scene screen — scene metadata wins, with sensible fallbacks.
lv_label_set_text(s_scene_title,
s->scene_title[0] ? s->scene_title : s->step_id);
if (s->scene_subtitle[0]) {
lv_label_set_text(s_scene_subtitle, s->scene_subtitle);
} else {
if (s->armed[0] && strcmp(s->armed, "none") != 0) {
snprintf(tmp, sizeof(tmp), "enigme: %s", s->armed);
} else {
snprintf(tmp, sizeof(tmp), "resolues: %u",
(unsigned) s->solved_count);
}
lv_label_set_text(s_scene_subtitle, tmp);
}
// Center slot: scenario symbol when provided, else the assembled code.
lv_label_set_text(s_scene_code,
s->scene_symbol[0] ? s->scene_symbol
: (s->code[0] ? s->code : ""));
apply_effect(s->scene_effect);
// Viewfinder: visible only while the QR puzzle is armed. When visible,
// the centered code label yields its slot to the canvas (code moves into
// the subtitle line).
s_cam_visible = (s_scene_cam != NULL) && (strcmp(s->armed, "qr") == 0);
if (s_scene_cam) {
if (s_cam_visible) {
lv_obj_clear_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(s_scene_code, LV_OBJ_FLAG_HIDDEN);
if (s->code[0]) {
snprintf(tmp, sizeof(tmp), "enigme: qr code: %s", s->code);
lv_label_set_text(s_scene_subtitle, tmp);
}
} else {
lv_obj_add_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(s_scene_code, LV_OBJ_FLAG_HIDDEN);
}
}
// Active step → scene view; idle → status view. The 5-way buttons can
// override (1=force status, 2=force scene; 0=auto). Fade 240 ms, the
// original ui_manager default transition (SceneTransition::kFade, 240).
// Gamebook page: refresh its labels from the latest show() buffers, then
// apply any pending manual scroll (Up/Down) on the reading container.
if (s_gamebook_open) {
lv_label_set_text(s_gb_title, s_gb_title_buf);
lv_label_set_text(s_gb_body, s_gb_body_buf);
lv_label_set_text(s_gb_menu, s_gb_menu_buf);
if (s_gb_scroll_home) {
lv_obj_scroll_to_y(s_gb_body_cont, 0, LV_ANIM_OFF);
s_gb_scroll_home = false;
}
int req = s_gb_scroll_req;
if (req != 0) {
s_gb_scroll_req = 0;
// +1 page = read further down (content moves up): negative dy.
lv_obj_scroll_by(s_gb_body_cont, 0, -req * 130, LV_ANIM_ON);
}
}
// Library grid: refresh tile titles + highlight the selected one.
if (s_library_open) {
for (int i = 0; i < LIB_TILES; i++) {
if (i < s_lib_count_buf) {
lv_label_set_text(s_lib_label[i], s_lib_title_buf[i]);
lv_obj_clear_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN);
}
bool on = (i == s_lib_sel_buf);
lv_obj_set_style_border_color(s_lib_tile[i],
lv_color_hex(on ? COL_CODE : 0x445566), 0);
lv_obj_set_style_border_width(s_lib_tile[i], on ? 4 : 2, 0);
lv_obj_set_style_bg_color(s_lib_tile[i],
lv_color_hex(on ? 0x223040 : 0x101820), 0);
}
}
extern volatile uint8_t g_dui_view_override;
lv_obj_t *want;
if (!s_intro_done) {
want = s_scr_intro; // boot intro plays out first
} else if (s_library_open) {
want = s_scr_library; // story picker owns the screen
} else if (s_gamebook_open) {
want = s_scr_gamebook; // gamebook owns the screen while running
} 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) {
case 1: want = s_scr_status; break;
case 2: want = s_scr_scene; break;
default: want = s->step_id[0] ? s_scr_scene : s_scr_status; break;
}
if (lv_scr_act() != want) {
lv_scr_load_anim(want, LV_SCR_LOAD_ANIM_FADE_ON, 240, 0, false);
}
extern volatile bool g_dui_on_scene;
g_dui_on_scene = (want == s_scr_scene);
}
// View override shared with display_ui_handle_key (byte writes are atomic).
volatile uint8_t g_dui_view_override = 0;
// Mirror of the active view, written ONLY by the display task — lets
// display_ui_handle_key decide without calling LVGL from the buttons task.
volatile bool g_dui_on_scene = false;
// Convert the freshest grayscale frame into the canvas (display task only).
static void refresh_viewfinder(void) {
if (!s_cam_visible || !s_cam_fresh || !s_cam_mutex) return;
if (xSemaphoreTake(s_cam_mutex, pdMS_TO_TICKS(10)) != pdTRUE) return;
// Convert in place from the shared gray buffer into the canvas buffer.
const uint8_t *g = s_cam_gray;
uint16_t *dst = (uint16_t *) s_cam_canvas_buf;
for (size_t i = 0; i < (size_t) CAM_W * CAM_H; i++) {
const uint8_t v = g[i];
dst[i] = (uint16_t) (((v >> 3) << 11) | ((v >> 2) << 5) | (v >> 3));
}
s_cam_fresh = false;
xSemaphoreGive(s_cam_mutex);
lv_obj_invalidate(s_scene_cam);
}
// ─── Raw splash (pre-LVGL, visible during the rest of boot) ───────────────────
static void render_splash(void) {
const int W = s_lcd.width();
const int H = s_lcd.height();
s_lcd.fillScreen(COL_BG);
s_lcd.fillRect(0, 0, W, 36, COL_TITLEBAR);
s_lcd.setTextColor(COL_TITLE_TXT);
s_lcd.setFont(&fonts::Font4);
s_lcd.setCursor(8, 8);
s_lcd.print("ZACUS MASTER");
s_lcd.setFont(&fonts::Font2);
s_lcd.setTextColor(COL_VALUE);
s_lcd.setCursor(8, 50);
s_lcd.print("Demarrage...");
s_lcd.setTextColor(COL_LABEL);
s_lcd.setCursor(8, H - 20);
s_lcd.print("FNK0102H ESP-IDF 5.4 Phase 2a");
}
// ─── Backlight (LEDC PWM — free pair; camera XCLK owns TIMER_0/CHANNEL_0) ─────
#define DUI_BL_TIMER LEDC_TIMER_1
#define DUI_BL_CHANNEL LEDC_CHANNEL_1
#define DUI_BL_FREQ_HZ 44100 // like the original Light_PWM
#define DUI_BL_RES LEDC_TIMER_8_BIT
static esp_err_t backlight_init(void) {
ledc_timer_config_t tcfg = {};
tcfg.speed_mode = LEDC_LOW_SPEED_MODE;
tcfg.timer_num = DUI_BL_TIMER;
tcfg.duty_resolution = DUI_BL_RES;
tcfg.freq_hz = DUI_BL_FREQ_HZ;
tcfg.clk_cfg = LEDC_AUTO_CLK;
esp_err_t err = ledc_timer_config(&tcfg);
if (err != ESP_OK) return err;
ledc_channel_config_t ccfg = {};
ccfg.gpio_num = TFT_PIN_BL;
ccfg.speed_mode = LEDC_LOW_SPEED_MODE;
ccfg.channel = DUI_BL_CHANNEL;
ccfg.timer_sel = DUI_BL_TIMER;
ccfg.duty = 255; // full on by default
ccfg.hpoint = 0;
return ledc_channel_config(&ccfg);
}
extern "C" void display_ui_set_brightness(uint8_t pct) {
if (pct > 100) pct = 100;
const uint32_t duty = (255u * pct) / 100u;
ledc_set_duty(LEDC_LOW_SPEED_MODE, DUI_BL_CHANNEL, duty);
ledc_update_duty(LEDC_LOW_SPEED_MODE, DUI_BL_CHANNEL);
}
// ─── Display task ─────────────────────────────────────────────────────────────
static void display_task(void *arg) {
(void) arg;
// LVGL lives entirely on this task.
s_scr_status = lv_scr_act();
build_status_screen();
build_scene_screen();
build_shell_screen();
build_gamebook_screen();
build_library_screen();
build_browser_screen();
build_intro_screen();
// 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(INTRO_DURATION_MS);
display_status_t local_status;
memset(&local_status, 0, sizeof(local_status));
TickType_t last_cam = 0;
uint8_t shell_sel_applied = 0xFF;
bool shell_open_applied = false;
for (;;) {
// Intro: animate while active, hand over at the deadline. dt is the
// REAL elapsed time: the loop period is vTaskDelay(10) + LVGL render
// (up to ~30 ms on a full-canvas frame), so a fixed dt=10 would make
// the intro clock lag wall time and starve the FX phases.
if (!s_intro_done) {
static TickType_t intro_prev_ticks;
const TickType_t now_ticks = xTaskGetTickCount();
if (intro_prev_ticks == 0) intro_prev_ticks = now_ticks;
uint32_t dt = (uint32_t) pdTICKS_TO_MS(now_ticks - intro_prev_ticks);
intro_prev_ticks = now_ticks;
if (dt > 100) dt = 100;
update_intro(dt);
if (now_ticks >= intro_until) {
s_intro_done = true;
s_dirty = true; // re-evaluate the wanted screen
}
}
// Shell state (selection highlight / open-close) from the buttons task.
if (s_shell_sel != shell_sel_applied ||
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));
s_dirty = false;
xSemaphoreGive(s_mutex);
apply_status(&local_status);
}
// Viewfinder refresh, throttled to ~5 fps (full-frame gray→rgb565
// conversion is ~77k pixels — keep the UI task responsive).
const TickType_t now = xTaskGetTickCount();
if ((now - last_cam) >= pdMS_TO_TICKS(200)) {
refresh_viewfinder();
last_cam = now;
}
lv_timer_handler();
vTaskDelay(pdMS_TO_TICKS(10));
}
}
// ─── Public API ──────────────────────────────────────────────────────────────
extern "C" esp_err_t display_ui_init(void) {
// Backlight PWM up first (panel garbage stays dark otherwise).
esp_err_t err = backlight_init();
if (err != ESP_OK) {
ESP_LOGW(TAG, "backlight LEDC init: %s — falling back to plain GPIO",
esp_err_to_name(err));
gpio_config_t bl_cfg = {};
bl_cfg.pin_bit_mask = (1ULL << TFT_PIN_BL);
bl_cfg.mode = GPIO_MODE_OUTPUT;
gpio_config(&bl_cfg);
gpio_set_level((gpio_num_t) TFT_PIN_BL, 1);
}
// Initialise panel
if (!s_lcd.init()) {
ESP_LOGE(TAG, "LovyanGFX panel init failed");
return ESP_FAIL;
}
s_lcd.setRotation(1); // landscape, matches FREENOVE_LCD_ROTATION 1
ESP_LOGI(TAG, "ST7796 panel up (%dx%d)",
(int) s_lcd.width(), (int) s_lcd.height());
render_splash();
// ── LVGL ──────────────────────────────────────────────────────────────
lv_init();
// Draw buffer: width × 40 lines, single. DEVIATION from the original
// (draw_in_psram = false): on this firmware the camera DMA needs a 30 KB
// contiguous INTERNAL block (cam_hal), and a 38.4 KB internal draw buffer
// starved it (largest free block dropped to 28 KB → esp_camera_init
// ESP_FAIL). PSRAM costs some blit speed in CPU mode but keeps internal
// RAM for camera/voice DMA. The original Arduino UI never ran esp-sr +
// WS streaming + camera concurrently, so it could afford internal.
const size_t buf_px = (size_t) DUI_HOR_RES * DUI_BUF_LINES;
s_buf1 = (lv_color_t *) heap_caps_malloc(buf_px * sizeof(lv_color_t),
MALLOC_CAP_SPIRAM);
if (!s_buf1) {
ESP_LOGE(TAG, "LVGL draw buffer alloc failed (%u B)",
(unsigned) (buf_px * sizeof(lv_color_t)));
return ESP_ERR_NO_MEM;
}
lv_disp_draw_buf_init(&s_draw_buf, s_buf1, NULL, buf_px);
lv_disp_drv_init(&s_disp_drv);
s_disp_drv.hor_res = DUI_HOR_RES;
s_disp_drv.ver_res = DUI_VER_RES;
s_disp_drv.flush_cb = lvgl_flush_cb;
s_disp_drv.draw_buf = &s_draw_buf;
if (!lv_disp_drv_register(&s_disp_drv)) {
ESP_LOGE(TAG, "lv_disp_drv_register failed");
return ESP_FAIL;
}
// Tick: 2 ms periodic (LV_TICK_CUSTOM off, like the original).
const esp_timer_create_args_t targs = {
.callback = lvgl_tick_cb,
.arg = NULL,
.dispatch_method = ESP_TIMER_TASK,
.name = "lv_tick",
.skip_unhandled_events = true,
};
err = esp_timer_create(&targs, &s_tick_timer);
if (err == ESP_OK) err = esp_timer_start_periodic(s_tick_timer, 2000);
if (err != ESP_OK) {
ESP_LOGE(TAG, "LVGL tick timer: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "LVGL %d.%d up (buf %u lines, PSRAM)",
lv_version_major(), lv_version_minor(), (unsigned) DUI_BUF_LINES);
// State
s_mutex = xSemaphoreCreateMutex();
s_cam_mutex = xSemaphoreCreateMutex();
if (!s_mutex || !s_cam_mutex) {
ESP_LOGE(TAG, "failed to create mutex");
return ESP_ERR_NO_MEM;
}
memset(&s_status, 0, sizeof(s_status));
s_dirty = false;
s_running = true;
// Spawn refresh task (ALL LVGL + panel access lives here).
BaseType_t rc = xTaskCreate(display_task, "display_ui", 8192, NULL, 3, NULL);
if (rc != pdPASS) {
ESP_LOGE(TAG, "xTaskCreate failed");
return ESP_ERR_NO_MEM;
}
ESP_LOGI(TAG, "display_ui_init OK — LVGL status screen active");
return ESP_OK;
}
extern "C" void display_ui_set_status(const display_status_t *s) {
if (!s || !s_running || !s_mutex) {
return;
}
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
memcpy(&s_status, s, sizeof(s_status));
s_dirty = true;
xSemaphoreGive(s_mutex);
}
}
static display_ui_key_hook_t s_key_hook = nullptr;
extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) {
s_key_hook = hook;
}
extern "C" void display_ui_gamebook_show(const char *title, const char *body,
const char *menu, bool reset_scroll) {
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
snprintf(s_gb_title_buf, sizeof(s_gb_title_buf), "%s", title ? title : "");
snprintf(s_gb_body_buf, sizeof(s_gb_body_buf), "%s", body ? body : "");
snprintf(s_gb_menu_buf, sizeof(s_gb_menu_buf), "%s", menu ? menu : "");
if (reset_scroll) s_gb_scroll_home = true; // new passage → back to top
s_gamebook_open = true;
s_dirty = true;
if (s_mutex) xSemaphoreGive(s_mutex);
}
extern "C" void display_ui_gamebook_scroll(int dir) {
// dir > 0 : read further down ; dir < 0 : back up. Accumulated and applied
// on the display task (this runs on the buttons task).
s_gb_scroll_req += (dir > 0) ? 1 : -1;
s_dirty = true;
}
extern "C" void display_ui_gamebook_hide(void) {
s_gamebook_open = false;
s_gb_scroll_req = 0;
s_dirty = true;
}
extern "C" void display_ui_library_show(const char *const *titles, int count,
int sel) {
if (count > LIB_TILES) count = LIB_TILES;
if (count < 0) count = 0;
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
s_lib_count_buf = count;
s_lib_sel_buf = (count > 0) ? ((sel % count + count) % count) : 0;
for (int i = 0; i < LIB_TILES; i++) {
snprintf(s_lib_title_buf[i], sizeof(s_lib_title_buf[i]), "%s",
(i < count && titles && titles[i]) ? titles[i] : "");
}
s_library_open = true;
s_gamebook_open = false; // library and a running story are exclusive
s_dirty = true;
if (s_mutex) xSemaphoreGive(s_mutex);
}
extern "C" void display_ui_library_hide(void) {
s_library_open = false;
s_dirty = true;
}
extern "C" void display_ui_handle_key(uint8_t key) {
static uint8_t s_brightness = 100;
// Takeover mode (e.g. gamebook): if the hook consumes the key, stop here.
if (s_key_hook && s_key_hook(key)) {
return;
}
// 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) {
switch (key) {
case 1: { // SELECT — launch tile
const uint8_t sel = s_shell_sel;
s_shell_open = false;
switch (sel) {
case 0: g_dui_view_override = 1; break; // Statut
case 1: g_dui_view_override = 2; break; // Scene
case 2: g_dui_view_override = 0; break; // Auto
case 3: // Lumiere — cycle
s_brightness = (s_brightness <= 20) ? 100
: (uint8_t)(s_brightness - 40);
display_ui_set_brightness(s_brightness);
s_shell_open = true; // stay in the shell for feedback
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 < s_shell_count)
s_shell_sel = (uint8_t)(s_shell_sel + SHELL_GRID_COLS);
break;
case 5: // UP — previous row
if (s_shell_sel >= SHELL_GRID_COLS)
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) % s_shell_count);
break;
case 3: // MENU — close shell
s_shell_open = false;
break;
default:
break;
}
s_dirty = true;
return;
}
// ── Shell closed: view shortcuts + brightness.
switch (key) {
case 1: // SELECT — toggle scene↔status manually
case 4: { // LEFT/RIGHT — same toggle
// g_dui_on_scene is the display task's mirror — no LVGL call here.
g_dui_view_override = g_dui_on_scene ? 1 : 2;
s_dirty = true; // wake the display task decision
break;
}
case 3: // MENU — open the Workbench shell
s_shell_open = true;
s_dirty = true;
break;
case 5: // UP — brightness +
s_brightness = (s_brightness >= 80) ? 100 : s_brightness + 20;
display_ui_set_brightness(s_brightness);
break;
case 2: // DOWN — brightness
s_brightness = (s_brightness <= 20) ? 10 : s_brightness - 20;
display_ui_set_brightness(s_brightness);
break;
default:
break;
}
}
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;
if (!s_running || !s_cam_mutex || !s_cam_gray || !s_cam_visible) return;
if (xSemaphoreTake(s_cam_mutex, 0) != pdTRUE) return; // skip frame if busy
memcpy(s_cam_gray, gray, (size_t) CAM_W * CAM_H);
s_cam_fresh = true;
xSemaphoreGive(s_cam_mutex);
}