From 21b06665e8d5568c211a47a59f3d25a576cc9c20 Mon Sep 17 00:00:00 2001 From: Claude Worker claude2 Date: Wed, 10 Jun 2026 21:20:45 +0200 Subject: [PATCH] feat(ui): LVGL layer + PWM backlight --- .../components/display_ui/CMakeLists.txt | 2 +- .../components/display_ui/display_ui.cpp | 327 +++++++++++++----- .../components/display_ui/idf_component.yml | 5 + .../display_ui/include/display_ui.h | 7 + idf_zacus/sdkconfig.defaults | 6 + 5 files changed, 252 insertions(+), 95 deletions(-) diff --git a/idf_zacus/components/display_ui/CMakeLists.txt b/idf_zacus/components/display_ui/CMakeLists.txt index d078e49..c5a83de 100644 --- a/idf_zacus/components/display_ui/CMakeLists.txt +++ b/idf_zacus/components/display_ui/CMakeLists.txt @@ -1,7 +1,7 @@ idf_component_register( SRCS "display_ui.cpp" INCLUDE_DIRS "include" - PRIV_REQUIRES driver local_puzzles + PRIV_REQUIRES driver local_puzzles esp_timer ) # LovyanGFX requires C++17. diff --git a/idf_zacus/components/display_ui/display_ui.cpp b/idf_zacus/components/display_ui/display_ui.cpp index 800d753..dc5f299 100644 --- a/idf_zacus/components/display_ui/display_ui.cpp +++ b/idf_zacus/components/display_ui/display_ui.cpp @@ -1,4 +1,4 @@ -// display_ui.cpp — ST7796 status screen via LovyanGFX, Phase 1. +// 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 @@ -15,33 +15,49 @@ // dlen_16bit false, bus_shared true // - Rotation: 1 (landscape) // -// BACKLIGHT (Phase 1): -// The reference firmware uses Light_PWM (LEDC channel 7, 44100 Hz) on -// GPIO 2. Phase 1 uses plain gpio_set_level(TFT_PIN_BL, 1) instead, -// because qr_puzzle already claims LEDC_TIMER_0 / LEDC_CHANNEL_0 for the -// camera XCLK — calling Light_PWM::init() on any channel would attempt to -// reconfigure the LEDC timer and may conflict. Phase 2 will switch to -// Light_PWM on a free timer/channel pair once the XCLK LEDC allocation is -// moved to the esp32-camera driver's own management. +// 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 LovyanGFX rendering runs exclusively on the display_task (prio 3, -// stack 4096). display_ui_set_status() copies the status under a mutex -// and sets a dirty flag; the task picks it up within 250 ms. +// 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 +#include #include "driver/gpio.h" +#include "driver/ledc.h" +#include "esp_heap_caps.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" #define LGFX_USE_V1 #include +#include "lvgl.h" static const char *TAG = "display_ui"; @@ -93,7 +109,7 @@ class ZacusDisplay final : public lgfx::LGFX_Device { _panel_instance.config(cfg); } - // Backlight handled externally (plain GPIO, see file header). + // Backlight handled externally (LEDC PWM, see file header). setPanel(&_panel_instance); } @@ -113,7 +129,6 @@ static volatile bool s_dirty = false; static bool s_running = false; // ─── Palette (Amiga Workbench-ish) ──────────────────────────────────────────── -// Keep tasteful — this is a status screen, not a demo. 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 @@ -121,143 +136,267 @@ 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 -// ─── Rendering (called only from display_task) ──────────────────────────────── +// ─── LVGL plumbing ──────────────────────────────────────────────────────────── -static void render_status(const display_status_t *s) { - const int W = s_lcd.width(); // 480 in rotation 1 - const int H = s_lcd.height(); // 320 in rotation 1 +#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_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; + +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; +} + +static void build_status_screen(void) { + lv_obj_t *scr = lv_scr_act(); + 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 - s_lcd.fillRect(0, 0, W, 28, COL_TITLEBAR); - s_lcd.setTextColor(COL_TITLE_TXT); - s_lcd.setFont(&fonts::Font4); - s_lcd.setCursor(8, 5); - s_lcd.print("ZACUS MASTER v1.0"); + 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); - // Background for content area - s_lcd.fillRect(0, 28, W, H - 28, COL_BG); + 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); - // Helper lambda: label + value pair on one line - int y = 36; - const int LINE_H = 28; + // 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; - auto draw_row = [&](const char *label, const char *value, uint32_t vcol) { - s_lcd.setFont(&fonts::Font2); - s_lcd.setTextColor(COL_LABEL); - s_lcd.setCursor(8, y); - s_lcd.print(label); - - s_lcd.setTextColor(vcol); - s_lcd.setCursor(160, y); - s_lcd.print(value); - - y += LINE_H; - }; - - // IP - draw_row("IP :", s[0].ip[0] ? s[0].ip : "(aucune)", COL_VALUE); - - // Wake detector - draw_row("Wake :", s[0].wake_active ? "oui" : "non", COL_VALUE); - - // Current step - draw_row("Etape :", s[0].step_id[0] ? s[0].step_id : "(aucune)", COL_VALUE); - - // Armed puzzle type - draw_row("Enigme :", s[0].armed[0] ? s[0].armed : "none", COL_VALUE); - - // Solved count - char solved_str[8]; - snprintf(solved_str, sizeof(solved_str), "%u", (unsigned) s[0].solved_count); - draw_row("Resolues :", solved_str, COL_VALUE); - - // Assembled code — big font, orange - s_lcd.setFont(&fonts::Font6); - s_lcd.setTextColor(COL_CODE); - s_lcd.setCursor(8, y + 4); - s_lcd.print("Code: "); - s_lcd.print(s[0].code[0] ? s[0].code : "---"); + // 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) { + 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[24]; + 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); +} + +// ─── 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); - - // Title bar 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"); - // Sub-title s_lcd.setFont(&fonts::Font2); s_lcd.setTextColor(COL_VALUE); s_lcd.setCursor(8, 50); s_lcd.print("Demarrage..."); - // Version line s_lcd.setTextColor(COL_LABEL); s_lcd.setCursor(8, H - 20); - s_lcd.print("FNK0102H ESP-IDF 5.4 Phase 1"); + 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. + build_status_screen(); + display_status_t local_status; memset(&local_status, 0, sizeof(local_status)); for (;;) { - vTaskDelay(pdMS_TO_TICKS(250)); - - if (!s_dirty) { - continue; - } - - // Snapshot under mutex - if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) { + 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); - } else { - continue; + apply_status(&local_status); } - render_status(&local_status); + lv_timer_handler(); + vTaskDelay(pdMS_TO_TICKS(10)); } } // ─── Public API ────────────────────────────────────────────────────────────── extern "C" esp_err_t display_ui_init(void) { - // Backlight on (plain GPIO — Phase 1; see header for LEDC Phase 2 plan). - gpio_config_t bl_cfg = {}; - bl_cfg.pin_bit_mask = (1ULL << TFT_PIN_BL); - bl_cfg.mode = GPIO_MODE_OUTPUT; - bl_cfg.pull_up_en = GPIO_PULLUP_DISABLE; - bl_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; - bl_cfg.intr_type = GPIO_INTR_DISABLE; - gpio_config(&bl_cfg); - gpio_set_level((gpio_num_t) TFT_PIN_BL, 1); + // 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 - s_lcd.setBrightness(255); 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, INTERNAL ram (like the original). + 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_INTERNAL | MALLOC_CAP_8BIT); + 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, internal ram)", + lv_version_major(), lv_version_minor(), (unsigned) DUI_BUF_LINES); + // State s_mutex = xSemaphoreCreateMutex(); if (!s_mutex) { @@ -268,14 +407,14 @@ extern "C" esp_err_t display_ui_init(void) { s_dirty = false; s_running = true; - // Spawn refresh task (all rendering lives here) - BaseType_t rc = xTaskCreate(display_task, "display_ui", 4096, NULL, 3, NULL); + // 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 — splash visible"); + ESP_LOGI(TAG, "display_ui_init OK — LVGL status screen active"); return ESP_OK; } diff --git a/idf_zacus/components/display_ui/idf_component.yml b/idf_zacus/components/display_ui/idf_component.yml index 580f3f3..3777f55 100644 --- a/idf_zacus/components/display_ui/idf_component.yml +++ b/idf_zacus/components/display_ui/idf_component.yml @@ -14,3 +14,8 @@ dependencies: lovyan03/LovyanGFX: git: https://github.com/lovyan03/LovyanGFX.git version: "4e689dba65135c2d91b180dc0a27a3cedebcfb5e" # tag 1.2.21 + # LVGL from the Espressif registry (checksummed). Pinned to the same + # 8.4.x line as the original firmware (platformio.ini:35 lvgl/lvgl@8.4.0) + # so the original screens port unchanged in later phases. + lvgl/lvgl: + version: "~8.4.0" diff --git a/idf_zacus/components/display_ui/include/display_ui.h b/idf_zacus/components/display_ui/include/display_ui.h index 5cc92b3..b417741 100644 --- a/idf_zacus/components/display_ui/include/display_ui.h +++ b/idf_zacus/components/display_ui/include/display_ui.h @@ -39,6 +39,13 @@ esp_err_t display_ui_init(void); */ void display_ui_set_status(const display_status_t *s); +/** + * @brief Set the backlight brightness (LEDC PWM on LEDC_TIMER_1/CHANNEL_1). + * + * @param pct 0..100 (values above 100 are clamped). Default after init: 100. + */ +void display_ui_set_brightness(uint8_t pct); + #ifdef __cplusplus } #endif diff --git a/idf_zacus/sdkconfig.defaults b/idf_zacus/sdkconfig.defaults index dc30aeb..70b8e8d 100644 --- a/idf_zacus/sdkconfig.defaults +++ b/idf_zacus/sdkconfig.defaults @@ -58,3 +58,9 @@ CONFIG_SR_MN_CN_NONE=y CONFIG_SR_MN_EN_NONE=y CONFIG_MODEL_IN_FLASH=y CONFIG_AFE_INTERFACE_V1=y + +# LVGL (display_ui phase 2a) — mirrors original ui_freenove_allinone lv_conf.h: +# depth 16 + swap 0 are lvgl defaults; mem pool 160KB; Montserrat 24/28 enabled. +CONFIG_LV_MEM_SIZE_KILOBYTES=160 +CONFIG_LV_FONT_MONTSERRAT_24=y +CONFIG_LV_FONT_MONTSERRAT_28=y