feat(ui): ST7796 status screen via LovyanGFX

This commit is contained in:
Claude Worker claude2
2026-06-10 21:06:16 +02:00
parent 0aa9271038
commit 9ac287c843
9 changed files with 499 additions and 13 deletions
@@ -0,0 +1,8 @@
idf_component_register(
SRCS "display_ui.cpp"
INCLUDE_DIRS "include"
PRIV_REQUIRES driver local_puzzles
)
# LovyanGFX requires C++17.
target_compile_options(${COMPONENT_LIB} PRIVATE -std=gnu++17)
@@ -0,0 +1,291 @@
// display_ui.cpp — ST7796 status screen via LovyanGFX, Phase 1.
//
// 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)
//
// 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.
//
// 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.
#include "display_ui.h"
#include "board_pins_mediakit.h"
#include <string.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
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 (plain GPIO, 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) ────────────────────────────────────────────
// 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
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) ────────────────────────────────
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
// 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");
// Background for content area
s_lcd.fillRect(0, 28, W, H - 28, COL_BG);
// Helper lambda: label + value pair on one line
int y = 36;
const int LINE_H = 28;
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 : "---");
}
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");
}
// ─── Display task ─────────────────────────────────────────────────────────────
static void display_task(void *arg) {
(void) arg;
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) {
memcpy(&local_status, &s_status, sizeof(local_status));
s_dirty = false;
xSemaphoreGive(s_mutex);
} else {
continue;
}
render_status(&local_status);
}
}
// ─── 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);
// 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();
// State
s_mutex = xSemaphoreCreateMutex();
if (!s_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 rendering lives here)
BaseType_t rc = xTaskCreate(display_task, "display_ui", 4096, 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");
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);
}
}
@@ -0,0 +1,16 @@
## display_ui managed-component manifest.
##
## LovyanGFX is not published on the Espressif component registry; it is
## distributed as a standard ESP-IDF component on GitHub. The IDF component
## manager supports git: sources — on the first `idf.py build` it clones the
## repo at the requested tag into managed_components/.
##
## Trust note: lovyan03/LovyanGFX is the SAME upstream the original Arduino
## firmware already depends on (platformio.ini line 34: lovyan03/[email protected]).
## Pinned below to the exact commit SHA of release tag 1.2.21 so the dependency
## is reproducible and tamper-evident (a moved tag cannot change what we build).
dependencies:
lovyan03/LovyanGFX:
git: https://github.com/lovyan03/LovyanGFX.git
version: "4e689dba65135c2d91b180dc0a27a3cedebcfb5e" # tag 1.2.21
@@ -0,0 +1,44 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
char step_id[64]; // current scenario step ("" = none)
char armed[8]; // "qr" | "sound" | "none"
char code[16]; // assembled puzzle code
uint8_t solved_count;
char ip[16]; // STA IP ("" = none)
bool wake_active; // wake-word detector up
} display_status_t;
/**
* @brief Initialise the ST7796 panel via LovyanGFX and show a splash screen.
*
* Must be called once from app_main after NVS init but before the idle loop.
* Non-fatal: a failure is logged and the rest of the firmware continues.
* The internal refresh task is spawned here; it polls for status changes
* every 250 ms.
*
* @return ESP_OK on success, or a driver error code.
*/
esp_err_t display_ui_init(void);
/**
* @brief Copy a new status snapshot and request an async redraw.
*
* Thread-safe. The copy is protected by an internal mutex; the actual
* rendering happens on the display task (LovyanGFX is not thread-safe
* across tasks).
*
* @param s Pointer to the caller's status struct. May be NULL (no-op).
*/
void display_ui_set_status(const display_status_t *s);
#ifdef __cplusplus
}
#endif
@@ -36,6 +36,8 @@ static const char *TAG = "game_endpoint";
static puzzle_state_t *s_pstate = NULL;
// Last step id armed via POST /game/step. Empty string = none.
static char s_current_step_id[64] = {0};
// Last armed puzzle type: "qr" | "sound" | "none" | "" (empty = nothing armed yet).
static char s_current_armed[8] = {0};
// Whitelist mirrored in the 4xx error message so the operator can
// recover without grepping the source. Keep aligned with
@@ -724,6 +726,8 @@ static esp_err_t handle_step_post(httpd_req_t *req) {
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);
@@ -750,6 +754,8 @@ static esp_err_t handle_step_post(httpd_req_t *req) {
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);
@@ -772,6 +778,8 @@ static esp_err_t handle_step_post(httpd_req_t *req) {
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);
@@ -836,6 +844,24 @@ void game_endpoint_set_puzzle_state(puzzle_state_t *state) {
ESP_LOGI(TAG, "puzzle_state registered (GET /game/puzzle_state + POST /game/step active)");
}
// ─── public getter for display_ui (Phase 1) ──────────────────────────────────
//
// Copies the statics written by the step handler. Reading without a mutex is
// acceptable here — the game master arms one step at a time and the display is
// informational; a torn read produces at worst a one-cycle stale value.
void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap,
char *armed, size_t armed_cap) {
if (step_id && step_cap > 0) {
strncpy(step_id, s_current_step_id, step_cap - 1);
step_id[step_cap - 1] = '\0';
}
if (armed && armed_cap > 0) {
strncpy(armed, s_current_armed, armed_cap - 1);
armed[armed_cap - 1] = '\0';
}
}
// ─── public init ────────────────────────────────────────────────────────────
esp_err_t game_endpoint_init(httpd_handle_t server) {
@@ -74,6 +74,23 @@ esp_err_t game_endpoint_init(httpd_handle_t server);
*/
void game_endpoint_set_puzzle_state(puzzle_state_t *state);
/**
* @brief Thread-safe snapshot of the current step id and armed puzzle type.
*
* Copies the last step armed via POST /game/step into caller-supplied buffers.
* Both buffers are NUL-terminated on return. Either pointer may be NULL if
* the caller does not need that field.
*
* Returns "" / "" when no step has been armed yet.
*
* @param step_id Output buffer for the current step id.
* @param step_cap Capacity of step_id buffer (recommend 64).
* @param armed Output buffer: "qr" | "sound" | "none" (or "" = nothing yet).
* @param armed_cap Capacity of armed buffer (recommend 8).
*/
void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap,
char *armed, size_t armed_cap);
#ifdef __cplusplus
}
#endif
@@ -91,3 +91,35 @@
*
* Note: GPIO -1 (PWDN, RESET) indicates "not wired" and is not allocated.
*/
/* ── TFT display (FNK0102H ST7796 320×480) ─────────────────────────────────
*
* Source: ui_freenove_allinone/include/ui_freenove_config.h (validated firmware)
* docs/FNK0102H_SOURCE_OF_TRUTH.md (canonical pin tables)
*
* SPI host: SPI2_HOST (FSPI) — FREENOVE_LCD_USE_HSPI == 0 in the reference
* firmware, so the default FSPI host is used (not HSPI/SPI3_HOST).
* Write frequency: 80 MHz. Read frequency: 20 MHz.
* CS pin: -1 (not wired — the panel is the only device on this SPI bus).
*
* Coexistence with camera/mic/speaker:
* GPIO 20 (TFT_PIN_RST): not present in the camera/mic/speaker tables above.
* GPIO 21 (TFT_PIN_MOSI): not present in the camera/mic/speaker tables above.
* GPIO 45 (TFT_PIN_DC): not present in the camera/mic/speaker tables above.
* GPIO 47 (TFT_PIN_SCK): not present in the camera/mic/speaker tables above.
* GPIO 2 (TFT_PIN_BL): not present in the camera/mic/speaker tables above.
* RESULT: ZERO collision with any already-mapped GPIO.
*
* BACKLIGHT / LEDC NOTE (Phase 1):
* The reference firmware uses Light_PWM (LEDC) on GPIO 2. In Phase 1 we
* drive the backlight as plain GPIO (full-on) because qr_puzzle already
* claims LEDC_TIMER_0 / LEDC_CHANNEL_0 for the camera XCLK. Phase 2 will
* switch to Light_PWM on a free LEDC channel/timer once the XCLK is moved
* to the esp32-camera driver's own LEDC allocation.
*/
#define TFT_PIN_SCK 47 /* SPI clock */
#define TFT_PIN_MOSI 21 /* SPI MOSI */
#define TFT_PIN_MISO -1 /* not wired */
#define TFT_PIN_DC 45 /* D/C (register select) */
#define TFT_PIN_RST 20 /* hardware reset */
#define TFT_PIN_BL 2 /* backlight enable (Phase 1: plain GPIO, full-on) */
+2
View File
@@ -21,4 +21,6 @@ idf_component_register(
esp_netif
esp_event
espressif__mdns
display_ui
puzzle_state
)
+63 -13
View File
@@ -51,6 +51,7 @@
#include "local_puzzles.h"
#include "mic_broker.h"
#include "board_pins_mediakit.h"
#include "display_ui.h"
// Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move
// this to NVS so the field operator can repoint the firmware without a flash.
@@ -409,6 +410,14 @@ void app_main(void) {
ESP_ERROR_CHECK(nvs_err);
ESP_LOGI(TAG, "NVS initialized");
// Phase 1 display: bring up the ST7796 TFT early so the splash is visible
// while the rest of the subsystems initialise. Non-fatal on failure.
esp_err_t disp_err = display_ui_init();
if (disp_err != ESP_OK) {
ESP_LOGW(TAG, "display_ui_init: %s (continuing without display)",
esp_err_to_name(disp_err));
}
bool sta_ok = wifi_bring_up();
ESP_LOGI(TAG, "Wi-Fi up (mode=%s)", sta_ok ? "STA" : "AP-fallback");
@@ -631,19 +640,60 @@ void app_main(void) {
ota_server_mark_valid();
ESP_LOGI(TAG, "entering idle loop (heartbeat every 60 s)");
uint32_t tick = 0;
uint32_t tick = 0;
uint32_t disp_sub = 0; // sub-counter: display refresh every 2 s
for (;;) {
vTaskDelay(pdMS_TO_TICKS(60000));
tick++;
const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();
ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s",
(unsigned) tick,
(unsigned long long) (uptime_ms / 1000));
// Drive the slice-3/4 subsystems from the heartbeat. Once we have
// a real game loop these will move to a dedicated FreeRTOS task
// running at ~5 Hz; for now 60 s is enough to keep mood + media
// simulation state coherent without spamming the log.
media_manager_update(uptime_ms);
npc_engine_update(uptime_ms);
// Sleep in 2 s increments so the display stays responsive.
// Every 30 sub-ticks (~60 s) we do the full heartbeat log.
vTaskDelay(pdMS_TO_TICKS(2000));
disp_sub++;
// ── Display status refresh (every 2 s) ──────────────────────────
if (disp_err == ESP_OK) {
display_status_t ds;
memset(&ds, 0, sizeof(ds));
// IP address (STA only; empty when in AP mode)
esp_netif_t *sta_if = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta_if) {
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(sta_if, &ip_info) == ESP_OK &&
ip_info.ip.addr != 0) {
snprintf(ds.ip, sizeof(ds.ip), IPSTR,
IP2STR(&ip_info.ip));
}
}
// Wake-word detector state
ds.wake_active = voice_pipeline_wake_word_active();
// Step + armed type from game_endpoint
game_endpoint_get_puzzle_status(ds.step_id, sizeof(ds.step_id),
ds.armed, sizeof(ds.armed));
// Solved count + assembled code from puzzle_state
ds.solved_count = 0;
for (int i = 1; i <= PUZZLE_MAX_ID; i++) {
if (s_pstate.solved[i]) {
ds.solved_count++;
}
}
puzzle_state_code(&s_pstate, ds.code, sizeof(ds.code));
display_ui_set_status(&ds);
}
// ── 60 s heartbeat (every 30 × 2 s sub-ticks) ───────────────────
if (disp_sub >= 30) {
disp_sub = 0;
tick++;
const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();
ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s",
(unsigned) tick,
(unsigned long long) (uptime_ms / 1000));
// Drive the slice-3/4 subsystems from the heartbeat.
media_manager_update(uptime_ms);
npc_engine_update(uptime_ms);
}
}
}