From 77c9a77645e159fa72aab91ad86af05a9ed58d6c Mon Sep 17 00:00:00 2001 From: Christian Mueller Date: Tue, 7 Apr 2026 21:12:08 +0200 Subject: [PATCH] feat: add WT32-SC01 LCD support + dynamic display resolution - Add WT32-SC01 V1 (ESP32-WROVER, 480x320 LCD) as second board option - Split ESP32 code into epaper/ and lcd/ subdirectories - LCD version: LovyanGFX, color display, touch-to-refresh, no deep sleep - Add display_type, width, height to displays table (migration 003) - Frontend: display type selector with predefined resolutions - Preview and layout editor now use dynamic resolution from display Co-Authored-By: Claude Opus 4.6 (1M context) --- display/esp32/platformio.ini | 34 ++- display/esp32/src/{ => epaper}/main.cpp | 12 +- display/esp32/src/lcd/display_config.h | 86 ++++++++ display/esp32/src/lcd/main.cpp | 205 ++++++++++++++++++ display/esp32/src/lcd/renderer_lcd.h | 153 +++++++++++++ .../frontend/src/components/EpaperPreview.tsx | 2 +- display/frontend/src/pages/Displays.tsx | 56 ++++- display/frontend/src/pages/Layouts.tsx | 2 +- display/middleware/src/api/routes/displays.ts | 35 ++- display/middleware/src/db/index.ts | 2 + .../db/migrations/003-display-resolution.ts | 13 ++ .../src/modules/display/display-builder.ts | 11 +- 12 files changed, 589 insertions(+), 22 deletions(-) rename display/esp32/src/{ => epaper}/main.cpp (92%) create mode 100644 display/esp32/src/lcd/display_config.h create mode 100644 display/esp32/src/lcd/main.cpp create mode 100644 display/esp32/src/lcd/renderer_lcd.h create mode 100644 display/middleware/src/db/migrations/003-display-resolution.ts diff --git a/display/esp32/platformio.ini b/display/esp32/platformio.ini index 16c3e01..68faf54 100644 --- a/display/esp32/platformio.ini +++ b/display/esp32/platformio.ini @@ -1,12 +1,38 @@ -[env:esp32] +[platformio] +default_envs = wt32-sc01 + +; ── Shared settings ────────────────────────────────────────────────────────── +[env] platform = espressif32 -board = esp32dev framework = arduino monitor_speed = 115200 lib_deps = - zinggjm/GxEPD2@^1.5.8 bblanchon/ArduinoJson@^7.2.0 +build_flags = -DBOARD_HAS_PSRAM + +; ── Waveshare 7.5" E-Paper + ESP32 Driver Board ───────────────────────────── +[env:epaper] +board = esp32dev +lib_deps = + ${env.lib_deps} + zinggjm/GxEPD2@^1.5.8 adafruit/Adafruit GFX Library@^1.11.10 adafruit/Adafruit BusIO@^1.16.1 +build_flags = + ${env.build_flags} + -DUSE_EPAPER +build_src_filter = +<*.cpp> + board_build.partitions = default.csv -build_flags = -DBOARD_HAS_PSRAM + +; ── WT32-SC01 V1 (ESP32-WROVER, 3.5" LCD 480x320, Touch) ───────────────────── +[env:wt32-sc01] +board = esp-wrover-kit +board_build.partitions = default.csv +lib_deps = + ${env.lib_deps} + lovyan03/LovyanGFX@^1.1.16 +build_flags = + ${env.build_flags} + -DUSE_LCD +build_src_filter = +<*.cpp> + +upload_speed = 921600 diff --git a/display/esp32/src/main.cpp b/display/esp32/src/epaper/main.cpp similarity index 92% rename from display/esp32/src/main.cpp rename to display/esp32/src/epaper/main.cpp index c83f79d..12882a9 100644 --- a/display/esp32/src/main.cpp +++ b/display/esp32/src/epaper/main.cpp @@ -3,9 +3,9 @@ #include #include -// GxEPD2 for Waveshare 7.5" V2 (800x480, GxEPD2_750_T7) +// GxEPD2 for Waveshare 7.5" V2 (800x480) #include -#include +#include #include "config.h" #include "renderer.h" @@ -59,11 +59,15 @@ static void showError(const char* message) { // ── setup ───────────────────────────────────────────────────────────────────── void setup() { Serial.begin(115200); - Serial.println("[Boot] EPaper Display starting..."); + delay(3000); // Wait for serial monitor to connect + Serial.println("\n\n[Boot] EPaper Display starting..."); // 1. Init display - display.init(115200); + display.init(115200, true, 20, false); // baud, initial, reset_duration_ms*10, pulldown_rst display.setRotation(0); + display.setTextWrap(false); + // 7.5" V2 needs up to 25s for full refresh — increase busy timeout + display.epd2.setBusyCallback(nullptr); // use default busy wait // 2. Load config from NVS Config config; diff --git a/display/esp32/src/lcd/display_config.h b/display/esp32/src/lcd/display_config.h new file mode 100644 index 0000000..83c0a98 --- /dev/null +++ b/display/esp32/src/lcd/display_config.h @@ -0,0 +1,86 @@ +#pragma once + +#define LGFX_USE_V1 +#include + +// ── WT32-SC01 V1 (ESP32-WROVER, ST7796, 480x320, capacitive touch FT5x06) ── +class LGFX_WT32_SC01 : public lgfx::LGFX_Device { + lgfx::Panel_ST7796 _panel; + lgfx::Bus_SPI _bus; + lgfx::Light_PWM _light; + lgfx::Touch_FT5x06 _touch; + +public: + LGFX_WT32_SC01() { + // ── Bus (SPI) ─────────────────────────────────────────────────────── + { + auto cfg = _bus.config(); + cfg.spi_host = VSPI_HOST; + cfg.spi_mode = 0; + cfg.freq_write = 40000000; + cfg.freq_read = 16000000; + cfg.spi_3wire = false; + cfg.use_lock = true; + cfg.dma_channel = SPI_DMA_CH_AUTO; + cfg.pin_sclk = 14; + cfg.pin_mosi = 13; + cfg.pin_miso = -1; + cfg.pin_dc = 21; + _bus.config(cfg); + _panel.setBus(&_bus); + } + + // ── Panel ─────────────────────────────────────────────────────────── + { + auto cfg = _panel.config(); + cfg.pin_cs = 15; + cfg.pin_rst = 22; + 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.readable = false; + cfg.invert = false; + cfg.rgb_order = false; + cfg.dlen_16bit = false; + cfg.bus_shared = true; + _panel.config(cfg); + } + + // ── Backlight ─────────────────────────────────────────────────────── + { + auto cfg = _light.config(); + cfg.pin_bl = 23; + cfg.invert = false; + cfg.freq = 44100; + cfg.pwm_channel = 7; + _light.config(cfg); + _panel.setLight(&_light); + } + + // ── Touch (FT5x06, I2C) ──────────────────────────────────────────── + { + auto cfg = _touch.config(); + cfg.x_min = 0; + cfg.x_max = 319; + cfg.y_min = 0; + cfg.y_max = 479; + cfg.pin_int = 39; + cfg.bus_shared = false; + cfg.offset_rotation = 0; + cfg.i2c_port = 1; + cfg.i2c_addr = 0x38; + cfg.pin_sda = 18; + cfg.pin_scl = 19; + cfg.freq = 400000; + _touch.config(cfg); + _panel.setTouch(&_touch); + } + + setPanel(&_panel); + } +}; diff --git a/display/esp32/src/lcd/main.cpp b/display/esp32/src/lcd/main.cpp new file mode 100644 index 0000000..4cde1d9 --- /dev/null +++ b/display/esp32/src/lcd/main.cpp @@ -0,0 +1,205 @@ +#include +#include +#include +#include + +#include "display_config.h" +#include "renderer_lcd.h" +#include "config.h" +#include "captive_portal.h" + +// ── Display instance ──────────────────────────────────────────────────────── +static LGFX_WT32_SC01 lcd; + +// ── Status bar at bottom ──────────────────────────────────────────────────── +static void drawStatusBar(const char* status, uint32_t color = LCD_LABEL) { + lcd.fillRect(0, 300, 480, 20, 0x0000); + lcd.setTextSize(1.0f); + lcd.setTextColor(color); + lcd.setTextDatum(lgfx::bottom_left); + lcd.drawString(status, 4, 318); +} + +// ── Show error full screen ────────────────────────────────────────────────── +static void showError(const char* message) { + Serial.printf("[Error] %s\n", message); + lcd.fillScreen(0x0000); + lcd.setTextSize(2.0f); + lcd.setTextColor(TFT_RED); + lcd.setTextDatum(lgfx::middle_center); + lcd.drawString("FEHLER", 240, 140); + lcd.setTextSize(1.2f); + lcd.setTextColor(LCD_TEXT); + lcd.drawString(message, 240, 170); +} + +// ── Show setup instructions on LCD ────────────────────────────────────────── +static void showSetupScreen() { + lcd.fillScreen(0x0000); + lcd.setTextSize(2.0f); + lcd.setTextColor(LCD_ACCENT); + lcd.setTextDatum(lgfx::middle_center); + lcd.drawString("EPaper Setup", 240, 80); + lcd.setTextSize(1.2f); + lcd.setTextColor(LCD_TEXT); + lcd.drawString("Mit 'EPaper-Setup' WLAN verbinden", 240, 140); + lcd.drawString("und http://192.168.4.1 oeffnen", 240, 165); +} + +// ── Fetch and render ──────────────────────────────────────────────────────── +static bool fetchAndRender(Config& config) { + String url = config.api_url + "/api/display/" + config.display_id; + Serial.printf("[HTTP] GET %s\n", url.c_str()); + drawStatusBar("Daten laden...", LCD_ACCENT); + + HTTPClient http; + http.begin(url); + http.setTimeout(10000); + if (config.api_token.length() > 0) { + http.addHeader("Authorization", "Bearer " + config.api_token); + } + + int httpCode = http.GET(); + if (httpCode <= 0 || httpCode >= 400) { + Serial.printf("[HTTP] Error: %d\n", httpCode); + http.end(); + String errMsg = "HTTP Fehler: " + String(httpCode); + drawStatusBar(errMsg.c_str(), TFT_RED); + return false; + } + + String payload = http.getString(); + http.end(); + Serial.printf("[HTTP] Payload length: %d\n", payload.length()); + + // Parse JSON + JsonDocument doc; + DeserializationError err = deserializeJson(doc, payload); + if (err) { + Serial.printf("[JSON] Parse error: %s\n", err.c_str()); + String errMsg = "JSON Fehler: " + String(err.c_str()); + drawStatusBar(errMsg.c_str(), TFT_RED); + return false; + } + + // Extract refresh_sec + int refreshSec = doc["refresh_sec"] | config.refresh_sec; + if (refreshSec < 5) refreshSec = 30; + config.refresh_sec = refreshSec; + + // Render + JsonArray elements = doc["elements"].as(); + + lcd.fillScreen(LCD_BG); + renderElementsLCD(lcd, elements); + + // Status bar with last update time + char timeBuf[40]; + unsigned long sec = millis() / 1000; + snprintf(timeBuf, sizeof(timeBuf), "Aktualisiert | Refresh: %ds", refreshSec); + drawStatusBar(timeBuf, LCD_LABEL); + + Serial.println("[Render] Done"); + return true; +} + +// ── Setup ─────────────────────────────────────────────────────────────────── +void setup() { + Serial.begin(115200); + delay(2000); + Serial.println("\n\n[Boot] WT32-SC01 Plus LCD Display starting..."); + + // 1. Init LCD + lcd.init(); + lcd.setRotation(1); // Landscape: 480x320 + lcd.setBrightness(200); + lcd.fillScreen(0x0000); + lcd.setTextSize(1.5f); + lcd.setTextColor(LCD_TEXT); + lcd.setTextDatum(lgfx::middle_center); + lcd.drawString("Starte...", 240, 160); + + // 2. Load config from NVS + Config config; + config.load(); + + // 3. If not configured → captive portal + if (!config.isConfigured()) { + Serial.println("[Boot] Not configured, starting captive portal"); + showSetupScreen(); + startCaptivePortal(config); // never returns + } + + // 4. Connect WiFi + lcd.fillScreen(0x0000); + lcd.drawString("WiFi verbinden...", 240, 160); + + Serial.printf("[WiFi] Connecting to %s\n", config.wifi_ssid.c_str()); + WiFi.mode(WIFI_STA); + WiFi.begin(config.wifi_ssid.c_str(), config.wifi_password.c_str()); + + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 30) { + delay(500); + Serial.print("."); + ++attempts; + } + Serial.println(); + + if (WiFi.status() != WL_CONNECTED) { + showError("WiFi Verbindung fehlgeschlagen"); + delay(10000); + ESP.restart(); + return; + } + Serial.printf("[WiFi] Connected, IP: %s\n", WiFi.localIP().toString().c_str()); + + // 5. Initial fetch + if (!fetchAndRender(config)) { + showError("Daten konnten nicht geladen werden"); + } +} + +// ── Loop: periodic refresh (no deep sleep for LCD) ────────────────────────── +static unsigned long lastUpdate = 0; +static Config loopConfig; +static bool configLoaded = false; + +void loop() { + if (!configLoaded) { + loopConfig.load(); + configLoaded = true; + lastUpdate = millis(); + } + + unsigned long now = millis(); + unsigned long intervalMs = (unsigned long)loopConfig.refresh_sec * 1000UL; + + if (now - lastUpdate >= intervalMs) { + lastUpdate = now; + if (WiFi.status() != WL_CONNECTED) { + Serial.println("[WiFi] Reconnecting..."); + WiFi.reconnect(); + delay(5000); + } + if (WiFi.status() == WL_CONNECTED) { + fetchAndRender(loopConfig); + } else { + drawStatusBar("WiFi getrennt!", TFT_RED); + } + } + + // Touch handling: tap to force refresh + lgfx::touch_point_t tp; + if (lcd.getTouch(&tp)) { + Serial.println("[Touch] Manual refresh triggered"); + drawStatusBar("Manuell aktualisieren...", LCD_ACCENT); + delay(300); // debounce + if (WiFi.status() == WL_CONNECTED) { + fetchAndRender(loopConfig); + } + lastUpdate = millis(); + } + + delay(50); // small delay for touch responsiveness +} diff --git a/display/esp32/src/lcd/renderer_lcd.h b/display/esp32/src/lcd/renderer_lcd.h new file mode 100644 index 0000000..d240981 --- /dev/null +++ b/display/esp32/src/lcd/renderer_lcd.h @@ -0,0 +1,153 @@ +#pragma once + +#include +#include +#include + +// ── Color constants ───────────────────────────────────────────────────────── +#define LCD_BG 0x1082 // Dark background (#1a1a2e) +#define LCD_CARD_BG 0x2124 // Card background (#222244) +#define LCD_TEXT 0xFFFF // White text +#define LCD_LABEL 0xAD55 // Light gray (#aaaaaa) +#define LCD_ACCENT 0x07FF // Cyan accent +#define LCD_LINE 0x39C7 // Subtle divider + +// ── Font size mapping ─────────────────────────────────────────────────────── +static inline float mapFontSizeLCD(int fontSize) { + // API font_size 8-40 → LovyanGFX textsize + if (fontSize <= 10) return 1.0f; + if (fontSize <= 14) return 1.2f; + if (fontSize <= 18) return 1.5f; + if (fontSize <= 24) return 2.0f; + if (fontSize <= 32) return 2.5f; + return 3.0f; +} + +// ── Scale coordinates from layout (800x480) to LCD (480x320) ──────────────── +static inline int scaleX(int x) { return (x * 480) / 800; } +static inline int scaleY(int y) { return (y * 320) / 480; } +static inline int scaleW(int w) { return (w * 480) / 800; } +static inline int scaleH(int h) { return (h * 320) / 480; } + +// ── Icon rendering (text-based for LCD, no bitmaps needed) ────────────────── +static const char* getIconChar(const char* name) { + if (strcmp(name, "thermometer") == 0) return "T"; + if (strcmp(name, "droplet") == 0) return "~"; + if (strcmp(name, "wind") == 0) return "W"; + if (strcmp(name, "pressure") == 0) return "P"; + if (strcmp(name, "sun") == 0) return "*"; + if (strcmp(name, "bolt") == 0) return "#"; + if (strcmp(name, "gauge") == 0) return "G"; + if (strcmp(name, "wifi") == 0) return "?"; + if (strcmp(name, "battery") == 0) return "B"; + if (strcmp(name, "check") == 0) return "V"; + if (strcmp(name, "warning") == 0) return "!"; + if (strcmp(name, "clock") == 0) return "@"; + return ""; +} + +// ── Render all elements from JSON array onto LCD ──────────────────────────── +template +void renderElementsLCD(LGFX_Type& lcd, JsonArray elements) { + for (JsonObject el : elements) { + const char* type = el["type"] | ""; + int x = scaleX(el["x"] | 0); + int y = scaleY(el["y"] | 0); + int w = scaleW(el["w"] | 0); + int h = scaleH(el["h"] | 0); + + // ── line ──────────────────────────────────────────────────────────── + if (strcmp(type, "line") == 0) { + int thickness = el["thickness"] | 1; + lcd.fillRect(x, y, w > 1 ? w : thickness, h > 1 ? h : thickness, LCD_LINE); + } + // ── rect ──────────────────────────────────────────────────────────── + else if (strcmp(type, "rect") == 0) { + lcd.drawRect(x, y, w, h, LCD_LINE); + } + // ── label ─────────────────────────────────────────────────────────── + else if (strcmp(type, "label") == 0) { + const char* text = el["text"] | ""; + int fontSize = el["font_size"] | 8; + const char* textAlign = el["text_align"] | "left"; + float sz = mapFontSizeLCD(fontSize); + + lcd.setTextSize(sz); + lcd.setTextColor(LCD_TEXT); + + if (strcmp(textAlign, "center") == 0) { + lcd.setTextDatum(lgfx::middle_center); + lcd.drawString(text, x, y); + } else if (strcmp(textAlign, "right") == 0) { + lcd.setTextDatum(lgfx::middle_right); + lcd.drawString(text, x, y); + } else { + lcd.setTextDatum(lgfx::top_left); + lcd.drawString(text, x, y); + } + } + // ── value ─────────────────────────────────────────────────────────── + else if (strcmp(type, "value") == 0) { + const char* style = el["style"] | "plain"; + const char* label = el["label"] | ""; + const char* value = el["value"] | ""; + const char* unit = el["unit"] | ""; + const char* iconName = el["icon"] | ""; + int fontSize = el["font_size"] | 16; + + if (strcmp(style, "card") == 0) { + // ── card style ────────────────────────────────────────────── + // Card background with rounded corners + lcd.fillRoundRect(x, y, w, h, 4, LCD_CARD_BG); + lcd.drawRoundRect(x, y, w, h, 4, LCD_ACCENT); + + // Icon (text-based) + if (strlen(iconName) > 0) { + const char* ic = getIconChar(iconName); + lcd.setTextSize(2.0f); + lcd.setTextColor(LCD_ACCENT); + lcd.setTextDatum(lgfx::top_left); + lcd.drawString(ic, x + 6, y + 4); + } + + // Label + lcd.setTextSize(1.0f); + lcd.setTextColor(LCD_LABEL); + lcd.setTextDatum(lgfx::top_left); + lcd.drawString(label, x + 6, y + 24); + + // Value + unit (large, right-aligned at bottom) + float valSize = mapFontSizeLCD(fontSize); + lcd.setTextSize(valSize); + lcd.setTextColor(LCD_TEXT); + + String valStr = String(value); + if (strlen(unit) > 0) { + valStr += " "; + valStr += unit; + } + lcd.setTextDatum(lgfx::bottom_right); + lcd.drawString(valStr.c_str(), x + w - 6, y + h - 4); + + } else { + // ── plain style ───────────────────────────────────────────── + float sz = mapFontSizeLCD(fontSize); + lcd.setTextSize(sz); + lcd.setTextColor(LCD_TEXT); + lcd.setTextDatum(lgfx::top_left); + + String line; + if (strlen(label) > 0) { + line += label; + line += ": "; + } + line += value; + if (strlen(unit) > 0) { + line += " "; + line += unit; + } + lcd.drawString(line.c_str(), x, y); + } + } + } +} diff --git a/display/frontend/src/components/EpaperPreview.tsx b/display/frontend/src/components/EpaperPreview.tsx index efc5840..7624f6a 100644 --- a/display/frontend/src/components/EpaperPreview.tsx +++ b/display/frontend/src/components/EpaperPreview.tsx @@ -143,7 +143,7 @@ export default function EpaperPreview({ layoutId, elements, layoutWidth = 800, l if (el.type === 'line') { return
; diff --git a/display/frontend/src/pages/Displays.tsx b/display/frontend/src/pages/Displays.tsx index 16928e5..885d7ed 100644 --- a/display/frontend/src/pages/Displays.tsx +++ b/display/frontend/src/pages/Displays.tsx @@ -5,6 +5,9 @@ import { api } from '../api/client'; interface Display { id: number; name: string; + display_type: string; + width: number; + height: number; layout_id: number | null; api_token: string | null; last_seen: string | null; @@ -12,23 +15,27 @@ interface Display { } interface Layout { id: number; name: string } +interface DisplayType { width: number; height: number; label: string } -const emptyForm = { name: '', layout_id: '', refresh_sec: '300' }; +const emptyForm = { name: '', display_type: 'epaper-7.5', layout_id: '', refresh_sec: '300' }; export default function Displays() { const [displays, setDisplays] = useState([]); const [layouts, setLayouts] = useState([]); + const [types, setTypes] = useState>({}); const [form, setForm] = useState(emptyForm); const [error, setError] = useState(''); async function load() { try { - const [d, l] = await Promise.all([ + const [d, l, t] = await Promise.all([ api.get('/api/displays'), api.get('/api/layouts'), + api.get>('/api/displays/types'), ]); setDisplays(d); setLayouts(l); + setTypes(t); } catch (e) { setError((e as Error).message); } @@ -40,8 +47,12 @@ export default function Displays() { e.preventDefault(); setError(''); try { + const dtype = types[form.display_type]; await api.post('/api/displays', { name: form.name, + display_type: form.display_type, + width: dtype?.width ?? 800, + height: dtype?.height ?? 480, layout_id: form.layout_id ? parseInt(form.layout_id) : null, refresh_sec: parseInt(form.refresh_sec), }); @@ -61,6 +72,20 @@ export default function Displays() { } } + async function handleTypeChange(id: number, display_type: string) { + try { + const dtype = types[display_type]; + await api.put(`/api/displays/${id}`, { + display_type, + width: dtype?.width ?? 800, + height: dtype?.height ?? 480, + }); + load(); + } catch (e) { + setError((e as Error).message); + } + } + async function handleDelete(id: number) { if (!confirm('Display wirklich loeschen?')) return; try { @@ -81,6 +106,7 @@ export default function Displays() { Name + Display-Typ Layout Refresh (s) API URL @@ -90,11 +116,23 @@ export default function Displays() { {displays.length === 0 && ( - Keine Displays konfiguriert + Keine Displays konfiguriert )} {displays.map((d) => ( {d.name} + + + {d.width}x{d.height} + setForm({ ...form, display_type: e.target.value })} + > + {Object.entries(types).map(([key, t]) => ( + + ))} + +