From a52f164299b408442a4c4644cfe0786d4feac5f0 Mon Sep 17 00:00:00 2001 From: Christian Mueller Date: Mon, 6 Apr 2026 13:11:15 +0200 Subject: [PATCH] feat: add ESP32 firmware with WiFi, API fetch, e-paper rendering, and captive portal Co-Authored-By: Claude Sonnet 4.6 --- display/esp32/include/captive_portal.h | 127 ++++++++ display/esp32/include/config.h | 41 +++ display/esp32/include/icons.h | 385 +++++++++++++++++++++++++ display/esp32/include/renderer.h | 152 ++++++++++ display/esp32/platformio.ini | 12 + display/esp32/src/main.cpp | 175 +++++++++++ 6 files changed, 892 insertions(+) create mode 100644 display/esp32/include/captive_portal.h create mode 100644 display/esp32/include/config.h create mode 100644 display/esp32/include/icons.h create mode 100644 display/esp32/include/renderer.h create mode 100644 display/esp32/platformio.ini create mode 100644 display/esp32/src/main.cpp diff --git a/display/esp32/include/captive_portal.h b/display/esp32/include/captive_portal.h new file mode 100644 index 0000000..701a0a8 --- /dev/null +++ b/display/esp32/include/captive_portal.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include +#include +#include "config.h" + +static const char CAPTIVE_PORTAL_HTML[] PROGMEM = R"rawhtml( + + + + + +EPaper Setup + + + +

EPaper Display Setup

+
+ + + + + + + + + + + + + + + + +
+ + +)rawhtml"; + +static const char CAPTIVE_PORTAL_SAVED_HTML[] PROGMEM = R"rawhtml( + + + + +Gespeichert + + + +

Konfiguration gespeichert!

+

Das Gerät startet jetzt neu...

+ + +)rawhtml"; + +/** + * Start a captive WiFi access point for initial configuration. + * Creates AP "EPaper-Setup", serves a config form on port 80. + * POST /save persists the config and reboots. + * This function never returns. + */ +inline void startCaptivePortal(Config& config) { + const byte DNS_PORT = 53; + DNSServer dnsServer; + WebServer server(80); + + // ── Start AP ────────────────────────────────────────────────────────────── + WiFi.mode(WIFI_AP); + WiFi.softAP("EPaper-Setup"); // open network, no password + + IPAddress apIP = WiFi.softAPIP(); + Serial.print("[Portal] AP started, IP: "); + Serial.println(apIP); + + // ── DNS: redirect all queries to us ─────────────────────────────────────── + dnsServer.start(DNS_PORT, "*", apIP); + + // ── Routes ──────────────────────────────────────────────────────────────── + server.on("/", HTTP_GET, [&server]() { + server.send_P(200, "text/html", CAPTIVE_PORTAL_HTML); + }); + + server.on("/save", HTTP_POST, [&server, &config]() { + if (server.hasArg("ssid") && server.hasArg("api_url")) { + config.wifi_ssid = server.arg("ssid"); + config.wifi_password = server.arg("pass"); + config.api_url = server.arg("api_url"); + config.display_id = server.arg("display_id").length() > 0 + ? server.arg("display_id") + : "1"; + config.api_token = server.arg("api_token"); + config.save(); + + server.send_P(200, "text/html", CAPTIVE_PORTAL_SAVED_HTML); + delay(2000); + ESP.restart(); + } else { + server.send(400, "text/plain", "Fehlende Felder: ssid und api_url sind Pflichtfelder."); + } + }); + + // ── Captive portal redirect (all unknown URLs → /) ──────────────────────── + server.onNotFound([&server, &apIP]() { + // Some OSes check a specific URL; redirect them all to our form + server.sendHeader("Location", String("http://") + apIP.toString() + "/", true); + server.send(302, "text/plain", ""); + }); + + server.begin(); + Serial.println("[Portal] HTTP server started"); + + // ── Main loop (blocks forever) ──────────────────────────────────────────── + for (;;) { + dnsServer.processNextRequest(); + server.handleClient(); + yield(); + } +} diff --git a/display/esp32/include/config.h b/display/esp32/include/config.h new file mode 100644 index 0000000..f0a22fe --- /dev/null +++ b/display/esp32/include/config.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +struct Config { + String wifi_ssid; + String wifi_password; + String api_url; + String display_id; + String api_token; + int refresh_sec; + + void load() { + Preferences prefs; + prefs.begin("epaper", true); // read-only + wifi_ssid = prefs.getString("wifi_ssid", ""); + wifi_password = prefs.getString("wifi_password", ""); + api_url = prefs.getString("api_url", ""); + display_id = prefs.getString("display_id", "1"); + api_token = prefs.getString("api_token", ""); + refresh_sec = prefs.getInt ("refresh_sec", 300); + prefs.end(); + } + + void save() const { + Preferences prefs; + prefs.begin("epaper", false); // read-write + prefs.putString("wifi_ssid", wifi_ssid); + prefs.putString("wifi_password", wifi_password); + prefs.putString("api_url", api_url); + prefs.putString("display_id", display_id); + prefs.putString("api_token", api_token); + prefs.putInt ("refresh_sec", refresh_sec); + prefs.end(); + } + + bool isConfigured() const { + return wifi_ssid.length() > 0 && api_url.length() > 0; + } +}; diff --git a/display/esp32/include/icons.h b/display/esp32/include/icons.h new file mode 100644 index 0000000..6685dc2 --- /dev/null +++ b/display/esp32/include/icons.h @@ -0,0 +1,385 @@ +#pragma once + +#include +#include + +// 24x24 monochrome bitmaps, 1 bit per pixel, stored row-major (24 bits = 3 bytes per row, 24 rows = 72 bytes each) +// Pixels are MSB-first. A 0 bit = black, 1 bit = white (GxEPD2 convention for drawBitmap). +// For use with display.drawBitmap(x, y, data, 24, 24, GxEPD_BLACK) + +// ── Thermometer ───────────────────────────────────────────────────────────── +static const unsigned char ICON_THERMOMETER[] PROGMEM = { + 0xFF, 0xE7, 0xFF, // row 0 + 0xFF, 0xC3, 0xFF, // row 1 + 0xFF, 0xC3, 0xFF, // row 2 + 0xFF, 0xDB, 0xFF, // row 3 + 0xFF, 0xDB, 0xFF, // row 4 + 0xFF, 0xDB, 0xFF, // row 5 + 0xFF, 0xDB, 0xFF, // row 6 + 0xFF, 0xDB, 0xFF, // row 7 + 0xFF, 0xDB, 0xFF, // row 8 + 0xFF, 0xDB, 0xFF, // row 9 + 0xFF, 0xDB, 0xFF, // row 10 + 0xFF, 0xDB, 0xFF, // row 11 + 0xFF, 0xDB, 0xFF, // row 12 + 0xFF, 0xDB, 0xFF, // row 13 + 0xFF, 0xDB, 0xFF, // row 14 + 0xFE, 0x01, 0x7F, // row 15 + 0xFC, 0x00, 0x3F, // row 16 + 0xFC, 0x00, 0x3F, // row 17 + 0xFC, 0x00, 0x3F, // row 18 + 0xFC, 0x00, 0x3F, // row 19 + 0xFE, 0x01, 0x7F, // row 20 + 0xFF, 0x03, 0xFF, // row 21 + 0xFF, 0x03, 0xFF, // row 22 + 0xFF, 0xE7, 0xFF, // row 23 +}; + +// ── Droplet / Humidity ─────────────────────────────────────────────────────── +static const unsigned char ICON_DROPLET[] PROGMEM = { + 0xFF, 0xFF, 0xFF, // row 0 + 0xFF, 0xE7, 0xFF, // row 1 + 0xFF, 0xC3, 0xFF, // row 2 + 0xFF, 0x81, 0xFF, // row 3 + 0xFF, 0x00, 0xFF, // row 4 + 0xFE, 0x00, 0x7F, // row 5 + 0xFE, 0x00, 0x7F, // row 6 + 0xFC, 0x00, 0x3F, // row 7 + 0xFC, 0x00, 0x3F, // row 8 + 0xFC, 0x00, 0x3F, // row 9 + 0xFC, 0x00, 0x3F, // row 10 + 0xFC, 0x00, 0x3F, // row 11 + 0xFC, 0x00, 0x3F, // row 12 + 0xFE, 0x00, 0x7F, // row 13 + 0xFE, 0x00, 0x7F, // row 14 + 0xFF, 0x00, 0xFF, // row 15 + 0xFF, 0x81, 0xFF, // row 16 + 0xFF, 0xC3, 0xFF, // row 17 + 0xFF, 0xFF, 0xFF, // row 18 + 0xFF, 0xFF, 0xFF, // row 19 + 0xFF, 0xFF, 0xFF, // row 20 + 0xFF, 0xFF, 0xFF, // row 21 + 0xFF, 0xFF, 0xFF, // row 22 + 0xFF, 0xFF, 0xFF, // row 23 +}; + +// ── Wind ───────────────────────────────────────────────────────────────────── +static const unsigned char ICON_WIND[] PROGMEM = { + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xC0, 0x00, 0xFF, + 0x9F, 0xFF, 0x7F, + 0xFF, 0xFE, 0x7F, + 0x80, 0x00, 0x7F, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0x80, 0x00, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Pressure / Barometer ───────────────────────────────────────────────────── +static const unsigned char ICON_PRESSURE[] PROGMEM = { + 0xFF, 0xC3, 0xFF, + 0xFE, 0x00, 0x7F, + 0xFC, 0x00, 0x3F, + 0xF8, 0x00, 0x1F, + 0xF3, 0xFF, 0xCF, + 0xE7, 0xFF, 0xE7, + 0xEF, 0xFF, 0xF7, + 0xEF, 0x03, 0xF7, + 0xEF, 0xFD, 0xF7, + 0xEF, 0xFD, 0xF7, + 0xEF, 0xFF, 0xF7, + 0xE7, 0xFF, 0xE7, + 0xF3, 0xFF, 0xCF, + 0xF8, 0x00, 0x1F, + 0xFC, 0x00, 0x3F, + 0xFE, 0x00, 0x7F, + 0xFF, 0xC3, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Sun ────────────────────────────────────────────────────────────────────── +static const unsigned char ICON_SUN[] PROGMEM = { + 0xFF, 0xE7, 0xFF, + 0xFF, 0xE7, 0xFF, + 0xFB, 0xE7, 0xDF, + 0xFF, 0xFF, 0xFF, + 0xCF, 0x00, 0xF3, + 0xFE, 0x00, 0x7F, + 0xFC, 0x00, 0x3F, + 0x80, 0x00, 0x01, + 0x80, 0x00, 0x01, + 0xFC, 0x00, 0x3F, + 0xFE, 0x00, 0x7F, + 0xCF, 0x00, 0xF3, + 0xFF, 0xFF, 0xFF, + 0xFB, 0xE7, 0xDF, + 0xFF, 0xE7, 0xFF, + 0xFF, 0xE7, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Bolt / Lightning ───────────────────────────────────────────────────────── +static const unsigned char ICON_BOLT[] PROGMEM = { + 0xFF, 0x87, 0xFF, + 0xFF, 0x0F, 0xFF, + 0xFE, 0x1F, 0xFF, + 0xFC, 0x3F, 0xFF, + 0xF8, 0x00, 0xFF, + 0xF0, 0x01, 0xFF, + 0xE0, 0x03, 0xFF, + 0xC0, 0x07, 0xFF, + 0xFF, 0x80, 0x1F, + 0xFF, 0x00, 0x3F, + 0xFE, 0x01, 0x7F, + 0xFC, 0x03, 0xFF, + 0xF8, 0x07, 0xFF, + 0xF0, 0x0F, 0xFF, + 0xFF, 0x1F, 0xFF, + 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Gauge / Speedometer ─────────────────────────────────────────────────────── +static const unsigned char ICON_GAUGE[] PROGMEM = { + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xC3, 0xFF, + 0xFE, 0x00, 0x7F, + 0xF8, 0x00, 0x1F, + 0xF3, 0xFF, 0xCF, + 0xEF, 0xFF, 0xF7, + 0xEF, 0x07, 0xF7, + 0xEF, 0x03, 0xF7, + 0xEF, 0x81, 0xF7, + 0xEF, 0xC0, 0xF7, + 0xEF, 0xFF, 0xF7, + 0xF3, 0xFF, 0xCF, + 0xF8, 0x00, 0x1F, + 0xFE, 0x00, 0x7F, + 0xFF, 0xC3, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── WiFi ───────────────────────────────────────────────────────────────────── +static const unsigned char ICON_WIFI[] PROGMEM = { + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xC0, 0x00, 0x03, + 0x3F, 0xFF, 0xFC, + 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0xFF, + 0xFC, 0x00, 0x3F, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xE7, 0xFF, + 0xFF, 0x81, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xC3, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Battery ─────────────────────────────────────────────────────────────────── +static const unsigned char ICON_BATTERY[] PROGMEM = { + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xC3, 0xFF, + 0xFF, 0xC3, 0xFF, + 0xE0, 0x00, 0x07, + 0xDF, 0xFF, 0xFB, + 0xDF, 0x00, 0xFB, + 0xDF, 0x00, 0xFB, + 0xDF, 0x00, 0xFB, + 0xDF, 0xFF, 0xFB, + 0xE0, 0x00, 0x07, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Check / Checkmark ──────────────────────────────────────────────────────── +static const unsigned char ICON_CHECK[] PROGMEM = { + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF9, + 0xFF, 0xFF, 0xF3, + 0xFF, 0xFF, 0xE7, + 0xFF, 0xFF, 0xCF, + 0xFF, 0xFF, 0x9F, + 0xE7, 0xFF, 0x3F, + 0xCF, 0xFE, 0x7F, + 0x9F, 0xFC, 0xFF, + 0x3F, 0xF9, 0xFF, + 0x7F, 0xF3, 0xFF, + 0xFF, 0xE7, 0xFF, + 0xFF, 0xCF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Warning / Exclamation ───────────────────────────────────────────────────── +static const unsigned char ICON_WARNING[] PROGMEM = { + 0xFF, 0xE7, 0xFF, + 0xFF, 0xC3, 0xFF, + 0xFF, 0x81, 0xFF, + 0xFF, 0x81, 0xFF, + 0xFF, 0x00, 0xFF, + 0xFE, 0x18, 0x7F, + 0xFE, 0x18, 0x7F, + 0xFE, 0x18, 0x7F, + 0xFC, 0x18, 0x3F, + 0xFC, 0x18, 0x3F, + 0xFC, 0x18, 0x3F, + 0xFC, 0x00, 0x3F, + 0xFC, 0x18, 0x3F, + 0xFC, 0x18, 0x3F, + 0xFE, 0x18, 0x7F, + 0xFF, 0x00, 0xFF, + 0xFF, 0x81, 0xFF, + 0xFF, 0x81, 0xFF, + 0xFF, 0xC3, 0xFF, + 0xFF, 0xE7, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Clock ───────────────────────────────────────────────────────────────────── +static const unsigned char ICON_CLOCK[] PROGMEM = { + 0xFF, 0xC3, 0xFF, + 0xFE, 0x00, 0x7F, + 0xFC, 0x00, 0x3F, + 0xF8, 0x00, 0x1F, + 0xF3, 0xFF, 0xCF, + 0xE7, 0xE7, 0xE7, + 0xEF, 0xE7, 0xF7, + 0xEF, 0xE7, 0xF7, + 0xEF, 0xE0, 0xF7, + 0xEF, 0xE0, 0x07, + 0xEF, 0xFF, 0xF7, + 0xE7, 0xFF, 0xE7, + 0xF3, 0xFF, 0xCF, + 0xF8, 0x00, 0x1F, + 0xFC, 0x00, 0x3F, + 0xFE, 0x00, 0x7F, + 0xFF, 0xC3, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, +}; + +// ── Icon registry ───────────────────────────────────────────────────────────── + +struct IconEntry { + const char* name; + const unsigned char* data; +}; + +static const IconEntry ICON_TABLE[] = { + { "thermometer", ICON_THERMOMETER }, + { "droplet", ICON_DROPLET }, + { "wind", ICON_WIND }, + { "pressure", ICON_PRESSURE }, + { "sun", ICON_SUN }, + { "bolt", ICON_BOLT }, + { "gauge", ICON_GAUGE }, + { "wifi", ICON_WIFI }, + { "battery", ICON_BATTERY }, + { "check", ICON_CHECK }, + { "warning", ICON_WARNING }, + { "clock", ICON_CLOCK }, +}; + +static const size_t ICON_TABLE_SIZE = sizeof(ICON_TABLE) / sizeof(ICON_TABLE[0]); + +/** + * Look up an icon bitmap by name. + * Returns nullptr if the name is not found. + */ +inline const unsigned char* getIcon(const char* name) { + for (size_t i = 0; i < ICON_TABLE_SIZE; ++i) { + if (strcmp(name, ICON_TABLE[i].name) == 0) { + return ICON_TABLE[i].data; + } + } + return nullptr; +} + +inline const unsigned char* getIcon(const String& name) { + return getIcon(name.c_str()); +} diff --git a/display/esp32/include/renderer.h b/display/esp32/include/renderer.h new file mode 100644 index 0000000..8e61e84 --- /dev/null +++ b/display/esp32/include/renderer.h @@ -0,0 +1,152 @@ +#pragma once + +#include +#include +#include +#include "icons.h" + +// ── Font size helper ────────────────────────────────────────────────────────── + +/** + * Map an API font_size value (typically 8–40) to GFX setTextSize(1–5). + * font_size / 8, clamped to [1, 5]. + */ +static inline uint8_t mapFontSize(int fontSize) { + int s = fontSize / 8; + if (s < 1) s = 1; + if (s > 5) s = 5; + return static_cast(s); +} + +// ── Render all elements from JSON array ─────────────────────────────────────── + +/** + * Iterates every element in the JsonArray and draws it onto `display`. + * Must be called inside a firstPage/nextPage loop so that display is + * already prepared for drawing. + */ +template +void renderElements(GxEPD2_Type& display, JsonArray elements) { + for (JsonObject el : elements) { + const char* type = el["type"] | ""; + int x = el["x"] | 0; + int y = el["y"] | 0; + int w = el["w"] | 0; + int h = el["h"] | 0; + + // ── line ───────────────────────────────────────────────────────────── + if (strcmp(type, "line") == 0) { + // Use fillRect: height==1 → horizontal, width==1 → vertical + int thickness = el["thickness"] | 1; + if (h <= 1 && w > 1) { + // horizontal line + display.fillRect(x, y, w, (thickness > 0 ? thickness : 1), GxEPD_BLACK); + } else if (w <= 1 && h > 1) { + // vertical line + display.fillRect(x, y, (thickness > 0 ? thickness : 1), h, GxEPD_BLACK); + } else { + // generic: draw as thin rect + display.fillRect(x, y, w, h, GxEPD_BLACK); + } + } + // ── rect ───────────────────────────────────────────────────────────── + else if (strcmp(type, "rect") == 0) { + display.drawRect(x, y, w, h, GxEPD_BLACK); + } + // ── 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"; + uint8_t sz = mapFontSize(fontSize); + + display.setTextColor(GxEPD_BLACK); + display.setTextSize(sz); + + if (strcmp(textAlign, "center") == 0) { + // Calculate approximate text width: each char is 6px wide at size 1 + int charWidth = 6 * sz; + int textLen = strlen(text); + int textWidth = charWidth * textLen; + int textHeight = 8 * sz; + display.setCursor(x - textWidth / 2, y - textHeight / 2); + } else if (strcmp(textAlign, "right") == 0) { + int charWidth = 6 * sz; + int textLen = strlen(text); + int textWidth = charWidth * textLen; + display.setCursor(x - textWidth, y); + } else { + // left (default) + display.setCursor(x, y); + } + display.print(text); + } + // ── 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 ───────────────────────────────────────────── + // Border + display.drawRoundRect(x, y, w, h, 4, GxEPD_BLACK); + + // Icon top-left (24x24), with small padding + const unsigned char* iconData = getIcon(iconName); + if (iconData != nullptr) { + display.drawBitmap(x + 4, y + 4, iconData, 24, 24, GxEPD_BLACK); + } + + // Label text (small, below icon) + display.setTextColor(GxEPD_BLACK); + display.setTextSize(1); + display.setCursor(x + 4, y + 32); + display.print(label); + + // Value + unit (large, bottom-right) + uint8_t valSize = mapFontSize(fontSize); + display.setTextSize(valSize); + + // Build value string with unit + String valStr = String(value); + if (strlen(unit) > 0) { + valStr += " "; + valStr += unit; + } + + // Right-align inside card + int charWidth = 6 * valSize; + int valWidth = charWidth * valStr.length(); + int valX = x + w - valWidth - 4; + int valY = y + h - 8 * valSize - 4; + if (valX < x + 4) valX = x + 4; + display.setCursor(valX, valY); + display.print(valStr); + + } else { + // ── plain style ───────────────────────────────────────────── + uint8_t sz = mapFontSize(fontSize); + display.setTextColor(GxEPD_BLACK); + display.setTextSize(sz); + display.setCursor(x, y); + + // "Label: value unit" + String line; + if (strlen(label) > 0) { + line += label; + line += ": "; + } + line += value; + if (strlen(unit) > 0) { + line += " "; + line += unit; + } + display.print(line); + } + } + } +} diff --git a/display/esp32/platformio.ini b/display/esp32/platformio.ini new file mode 100644 index 0000000..16c3e01 --- /dev/null +++ b/display/esp32/platformio.ini @@ -0,0 +1,12 @@ +[env:esp32] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +lib_deps = + zinggjm/GxEPD2@^1.5.8 + bblanchon/ArduinoJson@^7.2.0 + adafruit/Adafruit GFX Library@^1.11.10 + adafruit/Adafruit BusIO@^1.16.1 +board_build.partitions = default.csv +build_flags = -DBOARD_HAS_PSRAM diff --git a/display/esp32/src/main.cpp b/display/esp32/src/main.cpp new file mode 100644 index 0000000..c83f79d --- /dev/null +++ b/display/esp32/src/main.cpp @@ -0,0 +1,175 @@ +#include +#include +#include +#include + +// GxEPD2 for Waveshare 7.5" V2 (800x480, GxEPD2_750_T7) +#include +#include + +#include "config.h" +#include "renderer.h" +#include "captive_portal.h" + +// ── Pin mapping for Waveshare ESP32 Driver Board ────────────────────────────── +#define EPD_CS 15 +#define EPD_DC 27 +#define EPD_RST 26 +#define EPD_BUSY 25 + +// ── Display instance ────────────────────────────────────────────────────────── +GxEPD2_BW display( + GxEPD2_750_T7(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) +); + +// ── Deep sleep helper ───────────────────────────────────────────────────────── +static void goDeepSleep(int seconds) { + Serial.printf("[Sleep] Entering deep sleep for %d seconds\n", seconds); + display.hibernate(); + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + esp_sleep_enable_timer_wakeup((uint64_t)seconds * 1000000ULL); + esp_deep_sleep_start(); +} + +// ── showError: white screen with centered error message ─────────────────────── +static void showError(const char* message) { + Serial.printf("[Error] %s\n", message); + display.setFullWindow(); + display.firstPage(); + do { + display.fillScreen(GxEPD_WHITE); + display.setTextColor(GxEPD_BLACK); + display.setTextSize(2); + + // Print "FEHLER:" header + const char* header = "FEHLER:"; + int headerW = 6 * 2 * strlen(header); + display.setCursor((display.width() - headerW) / 2, display.height() / 2 - 24); + display.print(header); + + // Print message below + display.setTextSize(1); + int msgW = 6 * strlen(message); + display.setCursor((display.width() - msgW) / 2, display.height() / 2 + 4); + display.print(message); + } while (display.nextPage()); +} + +// ── setup ───────────────────────────────────────────────────────────────────── +void setup() { + Serial.begin(115200); + Serial.println("[Boot] EPaper Display starting..."); + + // 1. Init display + display.init(115200); + display.setRotation(0); + + // 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"); + display.setFullWindow(); + display.firstPage(); + do { + display.fillScreen(GxEPD_WHITE); + display.setTextColor(GxEPD_BLACK); + display.setTextSize(2); + const char* line1 = "Mit 'EPaper-Setup' WLAN verbinden"; + const char* line2 = "und http://192.168.4.1 oeffnen"; + int w1 = 6 * 2 * strlen(line1); + int w2 = 6 * 2 * strlen(line2); + display.setCursor((display.width() - w1) / 2, display.height() / 2 - 20); + display.print(line1); + display.setCursor((display.width() - w2) / 2, display.height() / 2 + 10); + display.print(line2); + } while (display.nextPage()); + + startCaptivePortal(config); // never returns + } + + // 4. Connect to WiFi (30 attempts, 500 ms each) + 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 failed"); + goDeepSleep(60); + return; // not reached + } + Serial.printf("[WiFi] Connected, IP: %s\n", WiFi.localIP().toString().c_str()); + + // 5. Build request URL + String url = config.api_url + "/api/display/" + config.display_id; + if (config.api_token.length() > 0) { + url += "?token=" + config.api_token; + } + Serial.printf("[HTTP] GET %s\n", url.c_str()); + + // 6. HTTP GET + 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(); + showError(("HTTP " + String(httpCode)).c_str()); + goDeepSleep(60); + return; // not reached + } + + String payload = http.getString(); + http.end(); + Serial.printf("[HTTP] Payload length: %d\n", payload.length()); + + // 7. Parse JSON (ArduinoJson v7) + JsonDocument doc; + DeserializationError err = deserializeJson(doc, payload); + if (err) { + Serial.printf("[JSON] Parse error: %s\n", err.c_str()); + showError(("JSON: " + String(err.c_str())).c_str()); + goDeepSleep(60); + return; // not reached + } + + // 8. Extract refresh_sec (prefer from JSON, fall back to config) + int refreshSec = doc["refresh_sec"] | config.refresh_sec; + if (refreshSec < 10) refreshSec = 60; // safety minimum + + // 9. Render elements + JsonArray elements = doc["elements"].as(); + + display.setFullWindow(); + display.firstPage(); + do { + display.fillScreen(GxEPD_WHITE); + renderElements(display, elements); + } while (display.nextPage()); + + Serial.println("[Render] Done"); + + // 10. Disconnect WiFi and deep sleep + goDeepSleep(refreshSec); +} + +// ── loop ───────────────────────────────────────────────────────────────────── +void loop() { + // Never reached: setup() ends in deep sleep +}