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) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 21:12:08 +02:00
co-authored by Claude Opus 4.6
parent 52dddfd74f
commit 77c9a77645
12 changed files with 589 additions and 22 deletions
+30 -4
View File
@@ -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> +<epaper/>
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> +<lcd/>
upload_speed = 921600
@@ -3,9 +3,9 @@
#include <HTTPClient.h>
#include <ArduinoJson.h>
// GxEPD2 for Waveshare 7.5" V2 (800x480, GxEPD2_750_T7)
// GxEPD2 for Waveshare 7.5" V2 (800x480)
#include <GxEPD2_BW.h>
#include <GxEPD2_750_T7.h>
#include <epd/GxEPD2_750_T7.h>
#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;
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
// ── 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);
}
};
+205
View File
@@ -0,0 +1,205 @@
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#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<JsonArray>();
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
}
+153
View File
@@ -0,0 +1,153 @@
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
#include <LovyanGFX.hpp>
// ── 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 <typename LGFX_Type>
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);
}
}
}
}