feat(ui): LVGL layer + PWM backlight

This commit is contained in:
Claude Worker claude2
2026-06-10 21:20:45 +02:00
parent 9ac287c843
commit 21b06665e8
5 changed files with 252 additions and 95 deletions
@@ -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.
+233 -94
View File
@@ -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 <string.h>
#include <stdio.h>
#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 <LovyanGFX.hpp>
#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;
}
@@ -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"
@@ -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
+6
View File
@@ -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