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:
parent
52dddfd74f
commit
77c9a77645
@ -1,12 +1,38 @@
|
|||||||
[env:esp32]
|
[platformio]
|
||||||
|
default_envs = wt32-sc01
|
||||||
|
|
||||||
|
; ── Shared settings ──────────────────────────────────────────────────────────
|
||||||
|
[env]
|
||||||
platform = espressif32
|
platform = espressif32
|
||||||
board = esp32dev
|
|
||||||
framework = arduino
|
framework = arduino
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
lib_deps =
|
lib_deps =
|
||||||
zinggjm/GxEPD2@^1.5.8
|
|
||||||
bblanchon/ArduinoJson@^7.2.0
|
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 GFX Library@^1.11.10
|
||||||
adafruit/Adafruit BusIO@^1.16.1
|
adafruit/Adafruit BusIO@^1.16.1
|
||||||
|
build_flags =
|
||||||
|
${env.build_flags}
|
||||||
|
-DUSE_EPAPER
|
||||||
|
build_src_filter = +<*.cpp> +<epaper/>
|
||||||
board_build.partitions = default.csv
|
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 <HTTPClient.h>
|
||||||
#include <ArduinoJson.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_BW.h>
|
||||||
#include <GxEPD2_750_T7.h>
|
#include <epd/GxEPD2_750_T7.h>
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "renderer.h"
|
#include "renderer.h"
|
||||||
@ -59,11 +59,15 @@ static void showError(const char* message) {
|
|||||||
// ── setup ─────────────────────────────────────────────────────────────────────
|
// ── setup ─────────────────────────────────────────────────────────────────────
|
||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
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
|
// 1. Init display
|
||||||
display.init(115200);
|
display.init(115200, true, 20, false); // baud, initial, reset_duration_ms*10, pulldown_rst
|
||||||
display.setRotation(0);
|
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
|
// 2. Load config from NVS
|
||||||
Config config;
|
Config config;
|
||||||
86
display/esp32/src/lcd/display_config.h
Normal file
86
display/esp32/src/lcd/display_config.h
Normal 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
display/esp32/src/lcd/main.cpp
Normal file
205
display/esp32/src/lcd/main.cpp
Normal 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
display/esp32/src/lcd/renderer_lcd.h
Normal file
153
display/esp32/src/lcd/renderer_lcd.h
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -143,7 +143,7 @@ export default function EpaperPreview({ layoutId, elements, layoutWidth = 800, l
|
|||||||
if (el.type === 'line') {
|
if (el.type === 'line') {
|
||||||
return <div key={idx} style={{
|
return <div key={idx} style={{
|
||||||
position: 'absolute', left, top,
|
position: 'absolute', left, top,
|
||||||
width: (el.width || 800) * SCALE,
|
width: (el.width || layoutWidth) * SCALE,
|
||||||
height: Math.max(1, (el.height || 2) * SCALE),
|
height: Math.max(1, (el.height || 2) * SCALE),
|
||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
}} />;
|
}} />;
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import { api } from '../api/client';
|
|||||||
interface Display {
|
interface Display {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
display_type: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
layout_id: number | null;
|
layout_id: number | null;
|
||||||
api_token: string | null;
|
api_token: string | null;
|
||||||
last_seen: string | null;
|
last_seen: string | null;
|
||||||
@ -12,23 +15,27 @@ interface Display {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Layout { id: number; name: string }
|
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() {
|
export default function Displays() {
|
||||||
const [displays, setDisplays] = useState<Display[]>([]);
|
const [displays, setDisplays] = useState<Display[]>([]);
|
||||||
const [layouts, setLayouts] = useState<Layout[]>([]);
|
const [layouts, setLayouts] = useState<Layout[]>([]);
|
||||||
|
const [types, setTypes] = useState<Record<string, DisplayType>>({});
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [d, l] = await Promise.all([
|
const [d, l, t] = await Promise.all([
|
||||||
api.get<Display[]>('/api/displays'),
|
api.get<Display[]>('/api/displays'),
|
||||||
api.get<Layout[]>('/api/layouts'),
|
api.get<Layout[]>('/api/layouts'),
|
||||||
|
api.get<Record<string, DisplayType>>('/api/displays/types'),
|
||||||
]);
|
]);
|
||||||
setDisplays(d);
|
setDisplays(d);
|
||||||
setLayouts(l);
|
setLayouts(l);
|
||||||
|
setTypes(t);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
@ -40,8 +47,12 @@ export default function Displays() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
const dtype = types[form.display_type];
|
||||||
await api.post('/api/displays', {
|
await api.post('/api/displays', {
|
||||||
name: form.name,
|
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,
|
layout_id: form.layout_id ? parseInt(form.layout_id) : null,
|
||||||
refresh_sec: parseInt(form.refresh_sec),
|
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) {
|
async function handleDelete(id: number) {
|
||||||
if (!confirm('Display wirklich loeschen?')) return;
|
if (!confirm('Display wirklich loeschen?')) return;
|
||||||
try {
|
try {
|
||||||
@ -81,6 +106,7 @@ export default function Displays() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Display-Typ</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Layout</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Layout</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Refresh (s)</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Refresh (s)</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">API URL</th>
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">API URL</th>
|
||||||
@ -90,11 +116,23 @@ export default function Displays() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{displays.length === 0 && (
|
{displays.length === 0 && (
|
||||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Displays konfiguriert</td></tr>
|
<tr><td colSpan={7} className="px-4 py-6 text-center text-gray-400">Keine Displays konfiguriert</td></tr>
|
||||||
)}
|
)}
|
||||||
{displays.map((d) => (
|
{displays.map((d) => (
|
||||||
<tr key={d.id} className="hover:bg-gray-50">
|
<tr key={d.id} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-2 font-medium text-gray-800">{d.name}</td>
|
<td className="px-4 py-2 font-medium text-gray-800">{d.name}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||||
|
value={d.display_type}
|
||||||
|
onChange={(e) => handleTypeChange(d.id, e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(types).map(([key, t]) => (
|
||||||
|
<option key={key} value={key}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="ml-2 text-xs text-gray-400">{d.width}x{d.height}</span>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<select
|
<select
|
||||||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||||
@ -133,6 +171,18 @@ export default function Displays() {
|
|||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Display-Typ</label>
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||||
|
value={form.display_type}
|
||||||
|
onChange={(e) => setForm({ ...form, display_type: e.target.value })}
|
||||||
|
>
|
||||||
|
{Object.entries(types).map(([key, t]) => (
|
||||||
|
<option key={key} value={key}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-600 mb-1">Layout</label>
|
<label className="block text-xs text-gray-600 mb-1">Layout</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@ -358,7 +358,7 @@ export default function Layouts() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* E-paper preview */}
|
{/* E-paper preview */}
|
||||||
<h4 className="text-sm font-medium text-gray-600 mb-2">Vorschau (800x480)</h4>
|
<h4 className="text-sm font-medium text-gray-600 mb-2">Vorschau ({selected.width}x{selected.height})</h4>
|
||||||
<EpaperPreview
|
<EpaperPreview
|
||||||
layoutId={selected.id}
|
layoutId={selected.id}
|
||||||
elements={selected.elements}
|
elements={selected.elements}
|
||||||
|
|||||||
@ -5,6 +5,9 @@ export interface Display {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
display_type: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
layout_id: number | null;
|
layout_id: number | null;
|
||||||
api_token: string | null;
|
api_token: string | null;
|
||||||
last_seen: string | null;
|
last_seen: string | null;
|
||||||
@ -12,8 +15,22 @@ export interface Display {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Predefined display types with resolutions
|
||||||
|
export const DISPLAY_TYPES: Record<string, { width: number; height: number; label: string }> = {
|
||||||
|
'epaper-7.5': { width: 800, height: 480, label: 'Waveshare 7.5" E-Paper (800x480)' },
|
||||||
|
'wt32-sc01': { width: 480, height: 320, label: 'WT32-SC01 LCD (480x320)' },
|
||||||
|
'epaper-4.2': { width: 400, height: 300, label: 'Waveshare 4.2" E-Paper (400x300)' },
|
||||||
|
'epaper-5.83': { width: 648, height: 480, label: 'Waveshare 5.83" E-Paper (648x480)' },
|
||||||
|
'custom': { width: 800, height: 480, label: 'Benutzerdefiniert' },
|
||||||
|
};
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/displays/types — available display types
|
||||||
|
router.get('/types', (_req, res): void => {
|
||||||
|
res.json(DISPLAY_TYPES);
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/displays
|
// GET /api/displays
|
||||||
router.get('/', (_req, res): void => {
|
router.get('/', (_req, res): void => {
|
||||||
try {
|
try {
|
||||||
@ -40,13 +57,18 @@ router.get('/:id', (req, res): void => {
|
|||||||
router.post('/', (req, res): void => {
|
router.post('/', (req, res): void => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { name, description, layout_id, api_token, refresh_sec } = req.body;
|
const { name, description, display_type, width, height, layout_id, api_token, refresh_sec } = req.body;
|
||||||
if (!name) { res.status(400).json({ error: 'name is required' }); return; }
|
if (!name) { res.status(400).json({ error: 'name is required' }); return; }
|
||||||
|
|
||||||
|
const dtype = display_type ?? 'epaper-7.5';
|
||||||
|
const defaults = DISPLAY_TYPES[dtype] ?? DISPLAY_TYPES['epaper-7.5'];
|
||||||
|
const w = width ?? defaults.width;
|
||||||
|
const h = height ?? defaults.height;
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
`INSERT INTO displays (name, description, layout_id, api_token, refresh_sec)
|
`INSERT INTO displays (name, description, display_type, width, height, layout_id, api_token, refresh_sec)
|
||||||
VALUES (?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
).run(name, description ?? null, layout_id ?? null, api_token ?? null, refresh_sec ?? 300);
|
).run(name, description ?? null, dtype, w, h, layout_id ?? null, api_token ?? null, refresh_sec ?? 300);
|
||||||
const id = result.lastInsertRowid as number;
|
const id = result.lastInsertRowid as number;
|
||||||
res.status(201).json(db.prepare('SELECT * FROM displays WHERE id = ?').get(id));
|
res.status(201).json(db.prepare('SELECT * FROM displays WHERE id = ?').get(id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -62,9 +84,12 @@ router.put('/:id', (req, res): void => {
|
|||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
|
|
||||||
const { name, description, layout_id, api_token, refresh_sec } = req.body;
|
const { name, description, display_type, width, height, layout_id, api_token, refresh_sec } = req.body;
|
||||||
if (name !== undefined) { fields.push('name = ?'); values.push(name); }
|
if (name !== undefined) { fields.push('name = ?'); values.push(name); }
|
||||||
if (description !== undefined) { fields.push('description = ?'); values.push(description); }
|
if (description !== undefined) { fields.push('description = ?'); values.push(description); }
|
||||||
|
if (display_type !== undefined) { fields.push('display_type = ?'); values.push(display_type); }
|
||||||
|
if (width !== undefined) { fields.push('width = ?'); values.push(width); }
|
||||||
|
if (height !== undefined) { fields.push('height = ?'); values.push(height); }
|
||||||
if (layout_id !== undefined) { fields.push('layout_id = ?'); values.push(layout_id); }
|
if (layout_id !== undefined) { fields.push('layout_id = ?'); values.push(layout_id); }
|
||||||
if (api_token !== undefined) { fields.push('api_token = ?'); values.push(api_token); }
|
if (api_token !== undefined) { fields.push('api_token = ?'); values.push(api_token); }
|
||||||
if (refresh_sec !== undefined) { fields.push('refresh_sec = ?'); values.push(refresh_sec); }
|
if (refresh_sec !== undefined) { fields.push('refresh_sec = ?'); values.push(refresh_sec); }
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { migrate001 } from './migrations/001-initial';
|
import { migrate001 } from './migrations/001-initial';
|
||||||
import { migrate002 } from './migrations/002-iobroker';
|
import { migrate002 } from './migrations/002-iobroker';
|
||||||
|
import { migrate003 } from './migrations/003-display-resolution';
|
||||||
|
|
||||||
let dbInstance: Database.Database | null = null;
|
let dbInstance: Database.Database | null = null;
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ export function initDb(path: string): Database.Database {
|
|||||||
// Run migrations
|
// Run migrations
|
||||||
migrate001(db);
|
migrate001(db);
|
||||||
migrate002(db);
|
migrate002(db);
|
||||||
|
migrate003(db);
|
||||||
|
|
||||||
dbInstance = db;
|
dbInstance = db;
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export function migrate003(db: Database): void {
|
||||||
|
// Check if column already exists
|
||||||
|
const columns = db.pragma('table_info(displays)') as { name: string }[];
|
||||||
|
if (columns.some((c) => c.name === 'display_type')) return;
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE displays ADD COLUMN display_type TEXT DEFAULT 'epaper-7.5' NOT NULL;
|
||||||
|
ALTER TABLE displays ADD COLUMN width INTEGER DEFAULT 800 NOT NULL;
|
||||||
|
ALTER TABLE displays ADD COLUMN height INTEGER DEFAULT 480 NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
@ -43,6 +43,9 @@ interface RawDisplay {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
display_type: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
layout_id: number | null;
|
layout_id: number | null;
|
||||||
api_token: string | null;
|
api_token: string | null;
|
||||||
last_seen: string | null;
|
last_seen: string | null;
|
||||||
@ -98,8 +101,8 @@ export function buildDisplayJson(displayId: number): DisplayJson | null {
|
|||||||
display: {
|
display: {
|
||||||
id: display.id,
|
id: display.id,
|
||||||
name: display.name,
|
name: display.name,
|
||||||
width: 800,
|
width: display.width,
|
||||||
height: 480,
|
height: display.height,
|
||||||
refresh_sec: display.refresh_sec,
|
refresh_sec: display.refresh_sec,
|
||||||
},
|
},
|
||||||
layout: { type: 'free' },
|
layout: { type: 'free' },
|
||||||
@ -166,8 +169,8 @@ export function buildDisplayJson(displayId: number): DisplayJson | null {
|
|||||||
display: {
|
display: {
|
||||||
id: display.id,
|
id: display.id,
|
||||||
name: display.name,
|
name: display.name,
|
||||||
width: layout.width,
|
width: display.width,
|
||||||
height: layout.height,
|
height: display.height,
|
||||||
refresh_sec: display.refresh_sec,
|
refresh_sec: display.refresh_sec,
|
||||||
},
|
},
|
||||||
layout: layoutResult,
|
layout: layoutResult,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user