feat: add ESP32 firmware with WiFi, API fetch, e-paper rendering, and captive portal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Mueller 2026-04-06 13:11:15 +02:00
parent a50ef96eb7
commit a52f164299
6 changed files with 892 additions and 0 deletions

View File

@ -0,0 +1,127 @@
#pragma once
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include "config.h"
static const char CAPTIVE_PORTAL_HTML[] PROGMEM = R"rawhtml(
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EPaper Setup</title>
<style>
body { font-family: Arial, sans-serif; max-width: 480px; margin: 40px auto; padding: 16px; }
h1 { font-size: 1.4em; }
label { display: block; margin-top: 12px; font-weight: bold; }
input { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; }
button { margin-top: 20px; padding: 10px 24px; background: #0078d4; color: #fff;
border: none; border-radius: 4px; font-size: 1em; cursor: pointer; }
button:hover { background: #005a9e; }
</style>
</head>
<body>
<h1>EPaper Display Setup</h1>
<form method="POST" action="/save">
<label for="ssid">WiFi SSID</label>
<input id="ssid" name="ssid" type="text" placeholder="Netzwerkname" required>
<label for="pass">WiFi Password</label>
<input id="pass" name="pass" type="password" placeholder="Passwort (leer lassen wenn kein Passwort)">
<label for="api_url">API URL</label>
<input id="api_url" name="api_url" type="url" placeholder="http://192.168.1.100:3000" required>
<label for="display_id">Display ID</label>
<input id="display_id" name="display_id" type="text" placeholder="1" value="1">
<label for="api_token">API Token (optional)</label>
<input id="api_token" name="api_token" type="text" placeholder="Bearer Token">
<button type="submit">Speichern &amp; Neustart</button>
</form>
</body>
</html>
)rawhtml";
static const char CAPTIVE_PORTAL_SAVED_HTML[] PROGMEM = R"rawhtml(
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Gespeichert</title>
<style>body{font-family:Arial,sans-serif;text-align:center;padding:40px;}</style>
</head>
<body>
<h1>Konfiguration gespeichert!</h1>
<p>Das Ger&#228;t startet jetzt neu...</p>
</body>
</html>
)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();
}
}

View File

@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
#include <Preferences.h>
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;
}
};

View File

@ -0,0 +1,385 @@
#pragma once
#include <Arduino.h>
#include <pgmspace.h>
// 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());
}

View File

@ -0,0 +1,152 @@
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
#include <GxEPD2_BW.h>
#include "icons.h"
// ── Font size helper ──────────────────────────────────────────────────────────
/**
* Map an API font_size value (typically 840) to GFX setTextSize(15).
* 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<uint8_t>(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 <typename GxEPD2_Type>
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);
}
}
}
}

View File

@ -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

175
display/esp32/src/main.cpp Normal file
View File

@ -0,0 +1,175 @@
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
// GxEPD2 for Waveshare 7.5" V2 (800x480, GxEPD2_750_T7)
#include <GxEPD2_BW.h>
#include <GxEPD2_750_T7.h>
#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<GxEPD2_750_T7, GxEPD2_750_T7::HEIGHT> 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<JsonArray>();
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
}