Compare commits

..

No commits in common. "adb6a6f19e07a50cdfa2fee37c5d2f5c357db134" and "2990f9aa4da7fd604a93094224e389f1bb5a1bf4" have entirely different histories.

44 changed files with 12 additions and 7687 deletions

15
display/.gitignore vendored
View File

@ -1,15 +0,0 @@
node_modules/
dist/
build/
data/
.env
config/project.env
.superpowers/
*.log
.DS_Store
coverage/
.nyc_output/
*.pem
*.key
.pio/
.vscode/

View File

@ -1,127 +0,0 @@
#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

@ -1,41 +0,0 @@
#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

@ -1,385 +0,0 @@
#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

@ -1,152 +0,0 @@
#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

@ -1,12 +0,0 @@
[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

View File

@ -1,175 +0,0 @@
#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
}

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

View File

@ -1,41 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { isLoggedIn } from './api/client';
import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import MqttBrokers from './pages/MqttBrokers';
import MqttTopics from './pages/MqttTopics';
import PgSources from './pages/PgSources';
import Datapoints from './pages/Datapoints';
import Displays from './pages/Displays';
import Layouts from './pages/Layouts';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (!isLoggedIn()) return <Navigate to="/login" replace />;
return (
<div className="flex min-h-screen bg-gray-100">
<Sidebar />
<main className="ml-56 flex-1 p-6 overflow-auto">
{children}
</main>
</div>
);
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/mqtt/brokers" element={<ProtectedRoute><MqttBrokers /></ProtectedRoute>} />
<Route path="/mqtt/topics/:brokerId" element={<ProtectedRoute><MqttTopics /></ProtectedRoute>} />
<Route path="/pg/sources" element={<ProtectedRoute><PgSources /></ProtectedRoute>} />
<Route path="/datapoints" element={<ProtectedRoute><Datapoints /></ProtectedRoute>} />
<Route path="/displays" element={<ProtectedRoute><Displays /></ProtectedRoute>} />
<Route path="/layouts" element={<ProtectedRoute><Layouts /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}

View File

@ -1,48 +0,0 @@
const TOKEN_KEY = 'display_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export function isLoggedIn(): boolean {
return !!getToken();
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
clearToken();
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (res.status === 204) return undefined as T;
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
return data as T;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
del: <T>(path: string) => request<T>('DELETE', path),
};

View File

@ -1,168 +0,0 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
interface LayoutElement {
id: number;
type: string;
x: number;
y: number;
width: number | null;
height: number | null;
font_size: number;
style: string;
static_text: string | null;
datapoint_id: number | null;
}
interface DisplayJson {
display_id: number;
layout: {
id: number;
width: number;
height: number;
};
elements: Array<{
type: string;
x: number;
y: number;
width?: number;
height?: number;
font_size: number;
style: string;
static_text?: string;
label?: string;
value?: string;
unit?: string;
}>;
}
interface Display {
id: number;
name: string;
layout_id: number | null;
}
interface EpaperPreviewProps {
layoutId: number;
elements: LayoutElement[];
layoutWidth?: number;
layoutHeight?: number;
}
const SCALE = 0.5;
export default function EpaperPreview({ layoutId, elements, layoutWidth = 800, layoutHeight = 480 }: EpaperPreviewProps) {
const [displayJson, setDisplayJson] = useState<DisplayJson | null>(null);
useEffect(() => {
// Find a display using this layout and fetch its JSON
api.get<Display[]>('/api/displays')
.then((displays) => {
const d = displays.find((disp) => disp.layout_id === layoutId);
if (!d) return;
return api.get<DisplayJson>(`/api/display/${d.id}`).then(setDisplayJson).catch(() => {});
})
.catch(() => {});
}, [layoutId]);
const w = layoutWidth * SCALE;
const h = layoutHeight * SCALE;
const renderEl = (el: typeof elements[0], _idx?: number) => {
const left = el.x * SCALE;
const top = el.y * SCALE;
const fs = Math.max(8, el.font_size * SCALE);
if (el.type === 'line') {
return (
<div
key={el.id}
style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }}
/>
);
}
if (el.type === 'rect') {
return (
<div
key={el.id}
style={{
position: 'absolute', left, top,
width: (el.width ?? 80) * SCALE,
height: (el.height ?? 40) * SCALE,
border: '1px solid #000',
}}
/>
);
}
// label or value elements
const text = el.static_text ?? (el.type === 'label' ? `[DP ${el.datapoint_id ?? '?'}]` : `[Wert]`);
const isCard = el.style === 'card';
return (
<div
key={el.id}
style={{
position: 'absolute', left, top,
fontSize: fs,
fontFamily: 'monospace',
color: '#000',
background: isCard ? '#e8e8e8' : 'transparent',
border: isCard ? '1px solid #aaa' : 'none',
padding: isCard ? '2px 4px' : 0,
whiteSpace: 'nowrap',
lineHeight: 1.2,
}}
>
{text}
</div>
);
};
// Use live display JSON if available, else render from elements prop
const liveEls = displayJson?.elements;
return (
<div className="mt-4">
<p className="text-xs text-gray-500 mb-2">
E-Paper Vorschau ({layoutWidth}x{layoutHeight}px){liveEls ? ' — Live-Daten' : ' — Design-Modus'}
</p>
<div
style={{
position: 'relative',
width: w,
height: h,
backgroundColor: '#f5f0e8',
border: '2px solid #1a1a1a',
borderRadius: 4,
overflow: 'hidden',
boxShadow: '2px 2px 8px rgba(0,0,0,0.2)',
}}
>
{liveEls
? liveEls.map((el, idx) => {
const left = el.x * SCALE;
const top = el.y * SCALE;
const fs = Math.max(8, el.font_size * SCALE);
if (el.type === 'line') {
return <div key={idx} style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }} />;
}
if (el.type === 'rect') {
return <div key={idx} style={{ position: 'absolute', left, top, width: (el.width ?? 80) * SCALE, height: (el.height ?? 40) * SCALE, border: '1px solid #000' }} />;
}
const text = el.static_text ?? (el.type === 'value' ? `${el.value ?? '?'} ${el.unit ?? ''}` : (el.label ?? ''));
const isCard = el.style === 'card';
return (
<div key={idx} style={{ position: 'absolute', left, top, fontSize: fs, fontFamily: 'monospace', color: '#000', background: isCard ? '#e8e8e8' : 'transparent', border: isCard ? '1px solid #aaa' : 'none', padding: isCard ? '2px 4px' : 0, whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{text}
</div>
);
})
: elements.map((el) => renderEl(el, el.id))}
</div>
</div>
);
}

View File

@ -1,56 +0,0 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { clearToken } from '../api/client';
const links = [
{ to: '/', label: 'Dashboard', exact: true },
{ to: '/mqtt/brokers', label: 'MQTT Broker' },
{ to: '/pg/sources', label: 'PostgreSQL Quellen' },
{ to: '/datapoints', label: 'Datenpunkte' },
{ to: '/displays', label: 'Displays' },
{ to: '/layouts', label: 'Layouts' },
];
export default function Sidebar() {
const navigate = useNavigate();
function logout() {
clearToken();
navigate('/login');
}
return (
<aside className="fixed top-0 left-0 h-full w-56 flex flex-col" style={{ backgroundColor: '#1a1a2e' }}>
<div className="px-4 py-5 border-b border-slate-700">
<span className="text-white font-bold text-lg tracking-wide">E-Paper Display</span>
</div>
<nav className="flex-1 overflow-y-auto py-4">
{links.map(({ to, label, exact }) => (
<NavLink
key={to}
to={to}
end={exact}
className={({ isActive }) =>
`flex items-center px-4 py-2.5 text-sm font-medium transition-colors ${
isActive
? 'bg-sky-400 text-white'
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
}`
}
>
{label}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-slate-700">
<button
onClick={logout}
className="w-full text-sm text-slate-400 hover:text-white py-2 px-3 rounded hover:bg-slate-700 transition-colors text-left"
>
Abmelden
</button>
</div>
</aside>
);
}

View File

@ -1,20 +0,0 @@
interface StatusBadgeProps {
status: string | null | undefined;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const s = (status ?? '').toLowerCase();
let color = 'bg-gray-200 text-gray-700';
if (s === 'connected' || s === 'ok' || s === 'active') color = 'bg-green-100 text-green-800';
else if (s === 'error' || s === 'failed') color = 'bg-red-100 text-red-800';
else if (s === 'disconnected') color = 'bg-gray-200 text-gray-600';
else if (s === 'unknown') color = 'bg-yellow-100 text-yellow-800';
else if (s === 'stale') color = 'bg-orange-100 text-orange-800';
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${color}`}>
{status ?? 'unbekannt'}
</span>
);
}

View File

@ -1 +0,0 @@
@import "tailwindcss";

View File

@ -1,10 +0,0 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -1,88 +0,0 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
interface Broker { id: number; name: string; status: string }
interface Datapoint { id: number; name: string; label: string; value: string | null; unit: string | null; source_type: string; status: string; updated_at: string | null }
interface Display { id: number; name: string; last_seen: string | null }
export default function Dashboard() {
const [brokers, setBrokers] = useState<Broker[]>([]);
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
const [displays, setDisplays] = useState<Display[]>([]);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([
api.get<Broker[]>('/api/mqtt/brokers'),
api.get<Datapoint[]>('/api/datapoints'),
api.get<Display[]>('/api/displays'),
])
.then(([b, d, disp]) => {
setBrokers(b);
setDatapoints(d);
setDisplays(disp);
})
.catch((err) => setError((err as Error).message));
}, []);
const lastUpdate = datapoints
.map((d) => d.updated_at)
.filter(Boolean)
.sort()
.at(-1);
const tiles = [
{ label: 'MQTT Broker', value: brokers.length, color: 'bg-blue-50 border-blue-200' },
{ label: 'Datenpunkte', value: datapoints.length, color: 'bg-green-50 border-green-200' },
{ label: 'Displays', value: displays.length, color: 'bg-purple-50 border-purple-200' },
{ label: 'Letztes Update', value: lastUpdate ? new Date(lastUpdate).toLocaleString('de') : '-', color: 'bg-yellow-50 border-yellow-200' },
];
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Dashboard</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{tiles.map(({ label, value, color }) => (
<div key={label} className={`rounded-lg border p-4 ${color}`}>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">{label}</p>
<p className="text-2xl font-bold text-gray-800">{value}</p>
</div>
))}
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200">
<h3 className="font-medium text-gray-700">Datenpunkte</h3>
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Bezeichnung</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Wert</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Einheit</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{datapoints.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Datenpunkte vorhanden</td>
</tr>
)}
{datapoints.map((dp) => (
<tr key={dp.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{dp.label || dp.name}</td>
<td className="px-4 py-2 font-semibold text-gray-900">{dp.value ?? '-'}</td>
<td className="px-4 py-2 text-gray-500">{dp.unit ?? ''}</td>
<td className="px-4 py-2"><StatusBadge status={dp.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -1,93 +0,0 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
interface Datapoint {
id: number;
name: string;
label: string | null;
value: string | null;
unit: string | null;
source_type: string;
status: string;
updated_at: string | null;
}
export default function Datapoints() {
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
const [edits, setEdits] = useState<Record<number, { label: string; unit: string }>>({});
const [error, setError] = useState('');
async function load() {
try {
const data = await api.get<Datapoint[]>('/api/datapoints');
setDatapoints(data);
const e: Record<number, { label: string; unit: string }> = {};
data.forEach((dp) => { e[dp.id] = { label: dp.label ?? '', unit: dp.unit ?? '' }; });
setEdits(e);
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, []);
async function handleBlur(id: number, field: 'label' | 'unit') {
try {
await api.put(`/api/datapoints/${id}`, { [field]: edits[id]?.[field] || null });
} catch (e) {
setError((e as Error).message);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Datenpunkte</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Interner Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Bezeichnung</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Wert</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Einheit</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Quelle</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{datapoints.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Datenpunkte vorhanden</td></tr>
)}
{datapoints.map((dp) => (
<tr key={dp.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-mono text-xs text-gray-600">{dp.name}</td>
<td className="px-4 py-2">
<input
className="border border-transparent hover:border-gray-300 focus:border-sky-400 rounded px-2 py-0.5 text-sm w-full focus:outline-none"
value={edits[dp.id]?.label ?? ''}
onChange={(e) => setEdits({ ...edits, [dp.id]: { ...edits[dp.id], label: e.target.value } })}
onBlur={() => handleBlur(dp.id, 'label')}
/>
</td>
<td className="px-4 py-2 font-semibold text-gray-900">{dp.value ?? '-'}</td>
<td className="px-4 py-2">
<input
className="border border-transparent hover:border-gray-300 focus:border-sky-400 rounded px-2 py-0.5 text-sm w-20 focus:outline-none"
value={edits[dp.id]?.unit ?? ''}
onChange={(e) => setEdits({ ...edits, [dp.id]: { ...edits[dp.id], unit: e.target.value } })}
onBlur={() => handleBlur(dp.id, 'unit')}
/>
</td>
<td className="px-4 py-2 text-gray-500 text-xs">{dp.source_type}</td>
<td className="px-4 py-2"><StatusBadge status={dp.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -1,165 +0,0 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { api } from '../api/client';
interface Display {
id: number;
name: string;
layout_id: number | null;
api_token: string | null;
last_seen: string | null;
refresh_sec: number;
}
interface Layout { id: number; name: string }
const emptyForm = { name: '', layout_id: '', refresh_sec: '300' };
export default function Displays() {
const [displays, setDisplays] = useState<Display[]>([]);
const [layouts, setLayouts] = useState<Layout[]>([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
async function load() {
try {
const [d, l] = await Promise.all([
api.get<Display[]>('/api/displays'),
api.get<Layout[]>('/api/layouts'),
]);
setDisplays(d);
setLayouts(l);
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, []);
async function handleAdd(e: FormEvent) {
e.preventDefault();
setError('');
try {
await api.post('/api/displays', {
name: form.name,
layout_id: form.layout_id ? parseInt(form.layout_id) : null,
refresh_sec: parseInt(form.refresh_sec),
});
setForm(emptyForm);
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleLayoutChange(id: number, layout_id: string) {
try {
await api.put(`/api/displays/${id}`, { layout_id: layout_id ? parseInt(layout_id) : null });
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDelete(id: number) {
if (!confirm('Display wirklich loeschen?')) return;
try {
await api.del(`/api/displays/${id}`);
load();
} catch (e) {
setError((e as Error).message);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Displays</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<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">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">API URL</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Zuletzt gesehen</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{displays.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Displays konfiguriert</td></tr>
)}
{displays.map((d) => (
<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">
<select
className="border border-gray-300 rounded px-2 py-1 text-xs"
value={d.layout_id ?? ''}
onChange={(e) => handleLayoutChange(d.id, e.target.value)}
>
<option value="">-- kein Layout --</option>
{layouts.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</td>
<td className="px-4 py-2 text-gray-600">{d.refresh_sec}</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500">/api/display/{d.id}</td>
<td className="px-4 py-2 text-gray-500 text-xs">
{d.last_seen ? new Date(d.last_seen).toLocaleString('de') : '-'}
</td>
<td className="px-4 py-2">
<button onClick={() => handleDelete(d.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="font-medium text-gray-700 mb-4">Display hinzufuegen</h3>
<form onSubmit={handleAdd} className="flex gap-4 flex-wrap items-end">
<div>
<label className="block text-xs text-gray-600 mb-1">Name *</label>
<input
required
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Layout</label>
<select
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
value={form.layout_id}
onChange={(e) => setForm({ ...form, layout_id: e.target.value })}
>
<option value="">-- kein Layout --</option>
{layouts.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Refresh (s)</label>
<input
type="number"
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-24"
value={form.refresh_sec}
onChange={(e) => setForm({ ...form, refresh_sec: e.target.value })}
/>
</div>
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">
Hinzufuegen
</button>
</form>
</div>
</div>
);
}

View File

@ -1,273 +0,0 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { api } from '../api/client';
import EpaperPreview from '../components/EpaperPreview';
interface Layout {
id: number;
name: string;
type: string;
width: number;
height: number;
}
interface LayoutElement {
id: number;
layout_id: number;
type: string;
x: number;
y: number;
width: number | null;
height: number | null;
font_size: number;
style: string;
static_text: string | null;
datapoint_id: number | null;
icon: string | null;
}
interface LayoutWithElements extends Layout {
elements: LayoutElement[];
}
interface Datapoint {
id: number;
name: string;
label: string | null;
}
const ELEMENT_TYPES = ['label', 'value', 'line', 'rect', 'static'];
const emptyEl = { type: 'value', x: '0', y: '0', font_size: '24', style: 'plain', datapoint_id: '', icon: '', static_text: '' };
export default function Layouts() {
const [layouts, setLayouts] = useState<Layout[]>([]);
const [selected, setSelected] = useState<LayoutWithElements | null>(null);
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
const [newName, setNewName] = useState('');
const [elForm, setElForm] = useState(emptyEl);
const [error, setError] = useState('');
async function loadLayouts() {
try {
setLayouts(await api.get<Layout[]>('/api/layouts'));
} catch (e) {
setError((e as Error).message);
}
}
async function selectLayout(id: number) {
try {
setSelected(await api.get<LayoutWithElements>(`/api/layouts/${id}`));
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => {
loadLayouts();
api.get<Datapoint[]>('/api/datapoints').then(setDatapoints).catch(() => {});
}, []);
async function handleAddLayout(e: FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
await api.post('/api/layouts', { name: newName.trim() });
setNewName('');
loadLayouts();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDeleteLayout(id: number) {
if (!confirm('Layout wirklich loeschen?')) return;
try {
await api.del(`/api/layouts/${id}`);
if (selected?.id === id) setSelected(null);
loadLayouts();
} catch (e) {
setError((e as Error).message);
}
}
async function handleAddElement(e: FormEvent) {
e.preventDefault();
if (!selected) return;
try {
await api.post(`/api/layouts/${selected.id}/elements`, {
type: elForm.type,
x: parseInt(elForm.x),
y: parseInt(elForm.y),
font_size: parseInt(elForm.font_size),
style: elForm.style,
datapoint_id: elForm.datapoint_id ? parseInt(elForm.datapoint_id) : null,
icon: elForm.icon || null,
static_text: elForm.static_text || null,
});
setElForm(emptyEl);
selectLayout(selected.id);
} catch (e) {
setError((e as Error).message);
}
}
async function handleDeleteElement(elId: number) {
if (!selected) return;
try {
await api.del(`/api/layouts/${selected.id}/elements/${elId}`);
selectLayout(selected.id);
} catch (e) {
setError((e as Error).message);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">Layouts</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="flex gap-6">
{/* Left: layout list */}
<div className="w-64 shrink-0">
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-3">
{layouts.length === 0 && (
<p className="px-4 py-4 text-sm text-gray-400">Keine Layouts vorhanden</p>
)}
{layouts.map((l) => (
<div
key={l.id}
onClick={() => selectLayout(l.id)}
className={`flex items-center justify-between px-3 py-2.5 cursor-pointer text-sm border-b border-gray-100 last:border-0 ${selected?.id === l.id ? 'bg-sky-50 text-sky-700 font-medium' : 'hover:bg-gray-50'}`}
>
<span>{l.name}</span>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteLayout(l.id); }}
className="text-red-400 hover:text-red-600 text-xs ml-2"
>
X
</button>
</div>
))}
</div>
<form onSubmit={handleAddLayout} className="flex gap-2">
<input
placeholder="Neues Layout..."
className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-3 py-1.5 rounded">+</button>
</form>
</div>
{/* Right: element editor */}
{selected && (
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-700 mb-4">
Elemente in <span className="text-sky-600">{selected.name}</span>{' '}
<span className="text-xs text-gray-400">({selected.width}x{selected.height})</span>
</h3>
{/* Element list */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Typ</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">X / Y</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Schriftgroesse</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Datenpunkt</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Statischer Text</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{selected.elements.length === 0 && (
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-400 text-xs">Noch keine Elemente</td></tr>
)}
{selected.elements.map((el) => {
const dp = datapoints.find((d) => d.id === el.datapoint_id);
return (
<tr key={el.id} className="hover:bg-gray-50">
<td className="px-3 py-2 text-xs font-mono">{el.type}</td>
<td className="px-3 py-2 text-xs text-gray-500">{el.x} / {el.y}</td>
<td className="px-3 py-2 text-xs text-gray-500">{el.font_size}</td>
<td className="px-3 py-2 text-xs text-gray-600">{dp ? (dp.label || dp.name) : '-'}</td>
<td className="px-3 py-2 text-xs text-gray-500 max-w-xs truncate">{el.static_text ?? '-'}</td>
<td className="px-3 py-2">
<button onClick={() => handleDeleteElement(el.id)} className="text-red-400 hover:text-red-600 text-xs">Loeschen</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Add element form */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">Element hinzufuegen</h4>
<form onSubmit={handleAddElement} className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Typ</label>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.type} onChange={(e) => setElForm({ ...elForm, type: e.target.value })}>
{ELEMENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">X</label>
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.x} onChange={(e) => setElForm({ ...elForm, x: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Y</label>
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.y} onChange={(e) => setElForm({ ...elForm, y: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Schriftgroesse</label>
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.font_size} onChange={(e) => setElForm({ ...elForm, font_size: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Stil</label>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.style} onChange={(e) => setElForm({ ...elForm, style: e.target.value })}>
<option value="plain">plain</option>
<option value="card">card</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Datenpunkt</label>
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.datapoint_id} onChange={(e) => setElForm({ ...elForm, datapoint_id: e.target.value })}>
<option value="">-- keiner --</option>
{datapoints.map((dp) => (
<option key={dp.id} value={dp.id}>{dp.label || dp.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Icon</label>
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.icon} onChange={(e) => setElForm({ ...elForm, icon: e.target.value })} placeholder="z.B. thermometer" />
</div>
<div className="col-span-2">
<label className="block text-xs text-gray-600 mb-1">Statischer Text</label>
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.static_text} onChange={(e) => setElForm({ ...elForm, static_text: e.target.value })} placeholder="Fester Text" />
</div>
<div className="col-span-3">
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-4 py-2 rounded transition-colors">Element hinzufuegen</button>
</div>
</form>
</div>
{/* E-paper preview */}
<EpaperPreview
layoutId={selected.id}
elements={selected.elements}
layoutWidth={selected.width}
layoutHeight={selected.height}
/>
</div>
)}
</div>
</div>
);
}

View File

@ -1,66 +0,0 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, setToken } from '../api/client';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await api.post<{ token: string }>('/api/auth/login', { username, password });
setToken(res.token);
navigate('/');
} catch (err) {
setError((err as Error).message || 'Anmeldung fehlgeschlagen');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white rounded-lg shadow p-8 w-full max-w-sm">
<h1 className="text-2xl font-bold text-gray-800 mb-6 text-center">E-Paper Display</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400"
/>
</div>
{error && <p className="text-red-600 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-sky-500 hover:bg-sky-600 text-white font-medium py-2 rounded transition-colors disabled:opacity-50"
>
{loading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
</div>
</div>
);
}

View File

@ -1,158 +0,0 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
interface Broker {
id: number;
name: string;
host: string;
port: number;
username: string | null;
password: string | null;
use_tls: number;
status: string;
}
const emptyForm = { name: '', host: '', port: '1883', username: '', password: '', use_tls: false };
export default function MqttBrokers() {
const [brokers, setBrokers] = useState<Broker[]>([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
const [testMsg, setTestMsg] = useState('');
const [loading, setLoading] = useState(false);
async function load() {
try {
setBrokers(await api.get<Broker[]>('/api/mqtt/brokers'));
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, []);
async function handleAdd(e: FormEvent) {
e.preventDefault();
setError('');
try {
await api.post('/api/mqtt/brokers', {
...form,
port: parseInt(form.port),
use_tls: form.use_tls ? 1 : 0,
username: form.username || null,
password: form.password || null,
});
setForm(emptyForm);
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDelete(id: number) {
if (!confirm('Broker wirklich loeschen?')) return;
try {
await api.del(`/api/mqtt/brokers/${id}`);
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleTest() {
setTestMsg('');
setLoading(true);
try {
const res = await api.post<{ success: boolean }>('/api/mqtt/brokers/test', {
...form,
port: parseInt(form.port),
use_tls: form.use_tls ? 1 : 0,
});
setTestMsg(res.success ? 'Verbindung erfolgreich!' : 'Verbindung fehlgeschlagen.');
} catch (e) {
setTestMsg('Fehler: ' + (e as Error).message);
} finally {
setLoading(false);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">MQTT Broker</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
{/* Broker list */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<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">Host:Port</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{brokers.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Broker konfiguriert</td></tr>
)}
{brokers.map((b) => (
<tr key={b.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-medium text-gray-800">{b.name}</td>
<td className="px-4 py-2 text-gray-600">{b.host}:{b.port}</td>
<td className="px-4 py-2"><StatusBadge status={b.status} /></td>
<td className="px-4 py-2 flex gap-2">
<Link to={`/mqtt/topics/${b.id}`} className="text-sky-600 hover:underline text-xs">Topics</Link>
<button onClick={() => handleDelete(b.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Add form */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="font-medium text-gray-700 mb-4">Broker hinzufuegen</h3>
<form onSubmit={handleAdd} className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Name *</label>
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Host *</label>
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Port</label>
<input type="number" className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.port} onChange={(e) => setForm({ ...form, port: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Benutzername</label>
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Passwort</label>
<input type="password" className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
</div>
<div className="flex items-center gap-2 mt-5">
<input type="checkbox" id="tls" checked={form.use_tls} onChange={(e) => setForm({ ...form, use_tls: e.target.checked })} />
<label htmlFor="tls" className="text-sm text-gray-700">TLS verwenden</label>
</div>
{testMsg && (
<div className={`col-span-2 text-sm p-2 rounded ${testMsg.includes('erfolgreich') ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>{testMsg}</div>
)}
<div className="col-span-2 flex gap-3">
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">Hinzufuegen</button>
<button type="button" onClick={handleTest} disabled={loading} className="border border-gray-300 hover:bg-gray-50 text-sm px-4 py-2 rounded transition-colors disabled:opacity-50">Verbindung testen</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,141 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../api/client';
interface Topic {
id: number;
broker_id: number;
topic: string;
last_payload: string | null;
json_path: string | null;
ignored: number;
discovered_at: string;
}
export default function MqttTopics() {
const { brokerId } = useParams<{ brokerId: string }>();
const [topics, setTopics] = useState<Topic[]>([]);
const [filter, setFilter] = useState('');
const [prefix, setPrefix] = useState('');
const [error, setError] = useState('');
const [discoveryMsg, setDiscoveryMsg] = useState('');
const [editPaths, setEditPaths] = useState<Record<number, string>>({});
async function load() {
try {
const data = await api.get<Topic[]>(`/api/mqtt/brokers/${brokerId}/topics`);
setTopics(data);
const paths: Record<number, string> = {};
data.forEach((t) => { paths[t.id] = t.json_path ?? ''; });
setEditPaths(paths);
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, [brokerId]);
async function handleDiscover() {
setDiscoveryMsg('');
try {
await api.post(`/api/mqtt/brokers/${brokerId}/topics/discover`, { prefix: prefix || null });
setDiscoveryMsg('Discovery gestartet. Topics erscheinen nach einigen Sekunden.');
setTimeout(() => load(), 3000);
} catch (e) {
setError((e as Error).message);
}
}
async function handleIgnore(id: number, ignored: number) {
try {
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { ignored: ignored ? 0 : 1 });
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleJsonPathBlur(id: number) {
try {
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { json_path: editPaths[id] || null });
} catch (e) {
setError((e as Error).message);
}
}
const filtered = topics.filter((t) =>
!t.ignored && t.topic.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-2">MQTT Topics</h2>
<p className="text-sm text-gray-500 mb-6">Broker ID: {brokerId}</p>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
{/* Controls */}
<div className="flex gap-3 mb-6 flex-wrap">
<input
placeholder="Filter Topics..."
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-56"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<input
placeholder="Discovery-Praefix (z.B. sensor/)"
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-64"
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
/>
<button
onClick={handleDiscover}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm px-4 py-1.5 rounded transition-colors"
>
Discovery starten
</button>
</div>
{discoveryMsg && <p className="text-sm text-indigo-600 mb-4">{discoveryMsg}</p>}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Topic</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Letzter Wert</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">JSON-Pfad</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Topics gefunden</td></tr>
)}
{filtered.map((t) => (
<tr key={t.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-mono text-xs text-gray-700">{t.topic}</td>
<td className="px-4 py-2 text-xs text-gray-500 max-w-xs truncate">{t.last_payload ?? '-'}</td>
<td className="px-4 py-2">
<input
className="border border-gray-300 rounded px-2 py-1 text-xs w-40"
value={editPaths[t.id] ?? ''}
onChange={(e) => setEditPaths({ ...editPaths, [t.id]: e.target.value })}
onBlur={() => handleJsonPathBlur(t.id)}
placeholder="$.temperature"
/>
</td>
<td className="px-4 py-2">
<button
onClick={() => handleIgnore(t.id, t.ignored)}
className="text-xs text-orange-500 hover:text-orange-700"
>
Ignorieren
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -1,119 +0,0 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { api } from '../api/client';
import StatusBadge from '../components/StatusBadge';
interface PgSource {
id: number;
name: string;
host: string;
port: number;
database_name: string;
username: string;
status: string;
}
const emptyForm = { name: '', host: '', port: '5432', database_name: '', username: '', password: '' };
export default function PgSources() {
const [sources, setSources] = useState<PgSource[]>([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
async function load() {
try {
setSources(await api.get<PgSource[]>('/api/pg/sources'));
} catch (e) {
setError((e as Error).message);
}
}
useEffect(() => { load(); }, []);
async function handleAdd(e: FormEvent) {
e.preventDefault();
setError('');
try {
await api.post('/api/pg/sources', { ...form, port: parseInt(form.port) });
setForm(emptyForm);
load();
} catch (e) {
setError((e as Error).message);
}
}
async function handleDelete(id: number) {
if (!confirm('Quelle wirklich loeschen?')) return;
try {
await api.del(`/api/pg/sources/${id}`);
load();
} catch (e) {
setError((e as Error).message);
}
}
return (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-6">PostgreSQL Quellen</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<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">Host:Port/DB</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sources.length === 0 && (
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Quellen konfiguriert</td></tr>
)}
{sources.map((s) => (
<tr key={s.id} className="hover:bg-gray-50">
<td className="px-4 py-2 font-medium text-gray-800">{s.name}</td>
<td className="px-4 py-2 text-gray-600">{s.host}:{s.port}/{s.database_name}</td>
<td className="px-4 py-2"><StatusBadge status={s.status} /></td>
<td className="px-4 py-2">
<button onClick={() => handleDelete(s.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h3 className="font-medium text-gray-700 mb-4">Quelle hinzufuegen</h3>
<form onSubmit={handleAdd} className="grid grid-cols-2 gap-4">
{[
{ key: 'name', label: 'Name *', type: 'text' },
{ key: 'host', label: 'Host *', type: 'text' },
{ key: 'port', label: 'Port', type: 'number' },
{ key: 'database_name', label: 'Datenbank *', type: 'text' },
{ key: 'username', label: 'Benutzername *', type: 'text' },
{ key: 'password', label: 'Passwort *', type: 'password' },
].map(({ key, label, type }) => (
<div key={key}>
<label className="block text-xs text-gray-600 mb-1">{label}</label>
<input
type={type}
required={label.includes('*')}
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
value={(form as Record<string, string>)[key]}
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
/>
</div>
))}
<div className="col-span-2">
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">
Hinzufuegen
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,25 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,8 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: { port: 3000, proxy: { '/api': 'http://localhost:4000' } },
});

View File

@ -1,45 +0,0 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import authRouter from './routes/auth';
import displayApiRouter from './routes/display-api';
import mqttBrokersRouter from './routes/mqtt-brokers';
import mqttTopicsRouter from './routes/mqtt-topics';
import pgSourcesRouter from './routes/pg-sources';
import datapointsRouter from './routes/datapoints';
import displaysRouter from './routes/displays';
import layoutsRouter from './routes/layouts';
import { authMiddleware } from './middleware/auth';
export function createApp(): express.Application {
const app = express();
app.use(cors());
app.use(express.json());
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Public routes
app.use('/api/auth', authRouter);
app.use('/api/display', displayApiRouter);
// Protected management routes
app.use('/api/mqtt/brokers/:brokerId/topics', authMiddleware, mqttTopicsRouter);
app.use('/api/mqtt/brokers', authMiddleware, mqttBrokersRouter);
app.use('/api/pg/sources', authMiddleware, pgSourcesRouter);
app.use('/api/datapoints', authMiddleware, datapointsRouter);
app.use('/api/displays', authMiddleware, displaysRouter);
app.use('/api/layouts', authMiddleware, layoutsRouter);
// Serve frontend/dist as static files with SPA fallback
const frontendDist = path.join(__dirname, '..', '..', '..', 'frontend', 'dist');
app.use(express.static(frontendDist));
app.get('*', (_req, res) => {
res.sendFile(path.join(frontendDist, 'index.html'));
});
return app;
}

View File

@ -1,41 +0,0 @@
import { Router } from 'express';
import {
getAllDatapoints,
getDatapointById,
updateDatapoint,
} from '../../modules/datapoints/datapoint-manager';
const router = Router();
// GET /api/datapoints
router.get('/', (_req, res): void => {
try {
res.json(getAllDatapoints());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/datapoints/:id
router.get('/:id', (req, res): void => {
try {
const dp = getDatapointById(parseInt(req.params.id));
if (!dp) { res.status(404).json({ error: 'Datapoint not found' }); return; }
res.json(dp);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/datapoints/:id
router.put('/:id', (req, res): void => {
try {
const dp = updateDatapoint(parseInt(req.params.id), req.body);
if (!dp) { res.status(404).json({ error: 'Datapoint not found' }); return; }
res.json(dp);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@ -1,64 +0,0 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { getDb } from '../../db/index';
import { buildDisplayJson } from '../../modules/display/display-builder';
const router = Router();
/**
* GET /api/display/:id
* ESP32 endpoint returns display JSON.
* Optional token auth via ?token=... or Authorization: Bearer ...
* If the display has an api_token set, the token MUST match.
*/
router.get('/:id', (req: Request, res: Response): void => {
try {
const db = getDb();
const displayId = parseInt(req.params.id);
const display = db
.prepare('SELECT id, api_token FROM displays WHERE id = ?')
.get(displayId) as { id: number; api_token: string | null } | undefined;
if (!display) {
res.status(404).json({ error: 'Display not found' });
return;
}
// Check token if display has one configured
if (display.api_token) {
const authHeader = req.headers.authorization;
const queryToken = req.query.token as string | undefined;
let provided: string | null = null;
if (authHeader && authHeader.startsWith('Bearer ')) {
provided = authHeader.slice(7);
} else if (queryToken) {
provided = queryToken;
}
if (!provided || provided !== display.api_token) {
res.status(401).json({ error: 'Invalid or missing token' });
return;
}
}
// Update last_seen
db.run(
`UPDATE displays SET last_seen = datetime('now') WHERE id = ?`,
[displayId]
);
const json = buildDisplayJson(displayId);
if (!json) {
res.status(404).json({ error: 'Display configuration not found' });
return;
}
res.json(json);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@ -1,102 +0,0 @@
import { Router } from 'express';
import { getDb } from '../../db/index';
export interface Display {
id: number;
name: string;
description: string | null;
layout_id: number | null;
api_token: string | null;
last_seen: string | null;
refresh_sec: number;
created_at: string;
}
const router = Router();
// GET /api/displays
router.get('/', (_req, res): void => {
try {
const db = getDb();
res.json(db.prepare('SELECT * FROM displays ORDER BY id ASC').all());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/displays/:id
router.get('/:id', (req, res): void => {
try {
const db = getDb();
const display = db.prepare('SELECT * FROM displays WHERE id = ?').get(parseInt(req.params.id));
if (!display) { res.status(404).json({ error: 'Display not found' }); return; }
res.json(display);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/displays
router.post('/', (req, res): void => {
try {
const db = getDb();
const { name, description, layout_id, api_token, refresh_sec } = req.body;
if (!name) { res.status(400).json({ error: 'name is required' }); return; }
const result = db.run(
`INSERT INTO displays (name, description, layout_id, api_token, refresh_sec)
VALUES (?, ?, ?, ?, ?)`,
[name, description ?? null, layout_id ?? null, api_token ?? null, refresh_sec ?? 300]
);
const id = result.lastInsertRowid as number;
res.status(201).json(db.prepare('SELECT * FROM displays WHERE id = ?').get(id));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/displays/:id
router.put('/:id', (req, res): void => {
try {
const db = getDb();
const id = parseInt(req.params.id);
const fields: string[] = [];
const values: unknown[] = [];
const { name, description, layout_id, api_token, refresh_sec } = req.body;
if (name !== undefined) { fields.push('name = ?'); values.push(name); }
if (description !== undefined) { fields.push('description = ?'); values.push(description); }
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 (refresh_sec !== undefined) { fields.push('refresh_sec = ?'); values.push(refresh_sec); }
if (fields.length === 0) {
const display = db.prepare('SELECT * FROM displays WHERE id = ?').get(id);
if (!display) { res.status(404).json({ error: 'Display not found' }); return; }
res.json(display);
return;
}
values.push(id);
db.run(`UPDATE displays SET ${fields.join(', ')} WHERE id = ?`, values);
const display = db.prepare('SELECT * FROM displays WHERE id = ?').get(id);
if (!display) { res.status(404).json({ error: 'Display not found' }); return; }
res.json(display);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/displays/:id
router.delete('/:id', (req, res): void => {
try {
const db = getDb();
const result = db.run('DELETE FROM displays WHERE id = ?', [parseInt(req.params.id)]);
if ((result.changes as number) === 0) { res.status(404).json({ error: 'Display not found' }); return; }
res.status(204).send();
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@ -1,102 +0,0 @@
import { Router } from 'express';
import {
getAllLayouts,
getLayoutById,
createLayout,
updateLayout,
deleteLayout,
addElement,
updateElement,
deleteElement,
} from '../../modules/display/layout-manager';
const router = Router();
// GET /api/layouts
router.get('/', (_req, res): void => {
try {
res.json(getAllLayouts());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/layouts/:id
router.get('/:id', (req, res): void => {
try {
const layout = getLayoutById(parseInt(req.params.id));
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return; }
res.json(layout);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/layouts
router.post('/', (req, res): void => {
try {
if (!req.body.name) { res.status(400).json({ error: 'name is required' }); return; }
res.status(201).json(createLayout(req.body));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/layouts/:id
router.put('/:id', (req, res): void => {
try {
const layout = updateLayout(parseInt(req.params.id), req.body);
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return; }
res.json(layout);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/layouts/:id
router.delete('/:id', (req, res): void => {
try {
const deleted = deleteLayout(parseInt(req.params.id));
if (!deleted) { res.status(404).json({ error: 'Layout not found' }); return; }
res.status(204).send();
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// ---- Elements ----
// POST /api/layouts/:layoutId/elements
router.post('/:layoutId/elements', (req, res): void => {
try {
const layoutId = parseInt(req.params.layoutId);
if (!req.body.type) { res.status(400).json({ error: 'type is required' }); return; }
res.status(201).json(addElement(layoutId, req.body));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/layouts/:layoutId/elements/:id
router.put('/:layoutId/elements/:id', (req, res): void => {
try {
const el = updateElement(parseInt(req.params.id), req.body);
if (!el) { res.status(404).json({ error: 'Element not found' }); return; }
res.json(el);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/layouts/:layoutId/elements/:id
router.delete('/:layoutId/elements/:id', (req, res): void => {
try {
const deleted = deleteElement(parseInt(req.params.id));
if (!deleted) { res.status(404).json({ error: 'Element not found' }); return; }
res.status(204).send();
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@ -1,92 +0,0 @@
import { Router } from 'express';
import {
getAllBrokers,
getBrokerById,
createBroker,
updateBroker,
deleteBroker,
connectBroker,
disconnectBroker,
testBroker,
} from '../../modules/mqtt/broker-manager';
const router = Router();
// GET /api/mqtt/brokers
router.get('/', (_req, res): void => {
try {
res.json(getAllBrokers());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/mqtt/brokers/:id
router.get('/:id', (req, res): void => {
try {
const broker = getBrokerById(parseInt(req.params.id));
if (!broker) { res.status(404).json({ error: 'Broker not found' }); return; }
res.json(broker);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/mqtt/brokers
router.post('/', (req, res): void => {
try {
const { name, host } = req.body;
if (!name || !host) { res.status(400).json({ error: 'name and host are required' }); return; }
res.status(201).json(createBroker(req.body));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/mqtt/brokers/:id
router.put('/:id', (req, res): void => {
try {
const broker = updateBroker(parseInt(req.params.id), req.body);
if (!broker) { res.status(404).json({ error: 'Broker not found' }); return; }
res.json(broker);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/mqtt/brokers/:id
router.delete('/:id', (req, res): void => {
try {
const deleted = deleteBroker(parseInt(req.params.id));
if (!deleted) { res.status(404).json({ error: 'Broker not found' }); return; }
res.status(204).send();
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/mqtt/brokers/:id/connect
router.post('/:id/connect', (req, res): void => {
connectBroker(parseInt(req.params.id))
.then(() => res.json({ status: 'connected' }))
.catch((err) => res.status(500).json({ error: (err as Error).message }));
});
// POST /api/mqtt/brokers/:id/disconnect
router.post('/:id/disconnect', (req, res): void => {
try {
disconnectBroker(parseInt(req.params.id));
res.json({ status: 'disconnected' });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/mqtt/brokers/test
router.post('/test', (req, res): void => {
testBroker(req.body)
.then((ok) => res.json({ success: ok }))
.catch((err) => res.status(500).json({ error: (err as Error).message }));
});
export default router;

View File

@ -1,57 +0,0 @@
import { Router } from 'express';
import {
getTopicsForBroker,
updateTopic,
startDiscovery,
addManualTopic,
} from '../../modules/mqtt/topic-discovery';
const router = Router({ mergeParams: true });
// GET /api/mqtt/brokers/:brokerId/topics
router.get('/', (req, res): void => {
try {
const brokerId = parseInt(req.params.brokerId);
res.json(getTopicsForBroker(brokerId));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/mqtt/brokers/:brokerId/topics/:id
router.put('/:id', (req, res): void => {
try {
const topic = updateTopic(parseInt(req.params.id), req.body);
if (!topic) { res.status(404).json({ error: 'Topic not found' }); return; }
res.json(topic);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/mqtt/brokers/:brokerId/topics/discover
router.post('/discover', (req, res): void => {
const brokerId = parseInt(req.params.brokerId);
const { prefix } = req.body;
res.json({ message: 'Discovery started', brokerId, prefix: prefix ?? null });
// Run in background
startDiscovery(brokerId, prefix).catch((err) => {
console.error('Discovery error:', (err as Error).message);
});
});
// POST /api/mqtt/brokers/:brokerId/topics/manual
router.post('/manual', (req, res): void => {
try {
const brokerId = parseInt(req.params.brokerId);
const { topic } = req.body;
if (!topic) { res.status(400).json({ error: 'topic is required' }); return; }
res.status(201).json(addManualTopic(brokerId, topic));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@ -1,185 +0,0 @@
import { Router } from 'express';
import {
getAllSources,
getSourceById,
createSource,
updateSource,
deleteSource,
connectSource,
disconnectSource,
testSource,
} from '../../modules/postgres/source-manager';
import {
getAllQueries,
getQueryById,
createQuery,
updateQuery,
deleteQuery,
startQueryTimer,
stopQueryTimer,
} from '../../modules/postgres/query-scheduler';
const router = Router();
// ---- Sources ----
// GET /api/pg/sources
router.get('/', (_req, res): void => {
try {
res.json(getAllSources());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/pg/sources/:id
router.get('/:id', (req, res): void => {
try {
const source = getSourceById(parseInt(req.params.id));
if (!source) { res.status(404).json({ error: 'Source not found' }); return; }
res.json(source);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/pg/sources
router.post('/', (req, res): void => {
try {
const { name, host, database_name, username, password } = req.body;
if (!name || !host || !database_name || !username || !password) {
res.status(400).json({ error: 'name, host, database_name, username, password are required' });
return;
}
res.status(201).json(createSource(req.body));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/pg/sources/:id
router.put('/:id', (req, res): void => {
try {
const source = updateSource(parseInt(req.params.id), req.body);
if (!source) { res.status(404).json({ error: 'Source not found' }); return; }
res.json(source);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/pg/sources/:id
router.delete('/:id', (req, res): void => {
try {
const deleted = deleteSource(parseInt(req.params.id));
if (!deleted) { res.status(404).json({ error: 'Source not found' }); return; }
res.status(204).send();
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/pg/sources/:id/connect
router.post('/:id/connect', (req, res): void => {
connectSource(parseInt(req.params.id))
.then(() => res.json({ status: 'connected' }))
.catch((err) => res.status(500).json({ error: (err as Error).message }));
});
// POST /api/pg/sources/:id/disconnect
router.post('/:id/disconnect', (req, res): void => {
try {
disconnectSource(parseInt(req.params.id));
res.json({ status: 'disconnected' });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/pg/sources/test
router.post('/test', (req, res): void => {
testSource(req.body)
.then((ok) => res.json({ success: ok }))
.catch((err) => res.status(500).json({ error: (err as Error).message }));
});
// ---- Queries (sub-routes) ----
// GET /api/pg/sources/:sourceId/queries
router.get('/:sourceId/queries', (req, res): void => {
try {
res.json(getAllQueries(parseInt(req.params.sourceId)));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/pg/sources/:sourceId/queries/:id
router.get('/:sourceId/queries/:id', (req, res): void => {
try {
const q = getQueryById(parseInt(req.params.id));
if (!q) { res.status(404).json({ error: 'Query not found' }); return; }
res.json(q);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/pg/sources/:sourceId/queries
router.post('/:sourceId/queries', (req, res): void => {
try {
const sourceId = parseInt(req.params.sourceId);
const { name, query, result_column } = req.body;
if (!name || !query || !result_column) {
res.status(400).json({ error: 'name, query, result_column are required' });
return;
}
res.status(201).json(createQuery({ ...req.body, source_id: sourceId }));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// PUT /api/pg/sources/:sourceId/queries/:id
router.put('/:sourceId/queries/:id', (req, res): void => {
try {
const q = updateQuery(parseInt(req.params.id), req.body);
if (!q) { res.status(404).json({ error: 'Query not found' }); return; }
res.json(q);
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// DELETE /api/pg/sources/:sourceId/queries/:id
router.delete('/:sourceId/queries/:id', (req, res): void => {
try {
const deleted = deleteQuery(parseInt(req.params.id));
if (!deleted) { res.status(404).json({ error: 'Query not found' }); return; }
res.status(204).send();
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/pg/sources/:sourceId/queries/:id/start
router.post('/:sourceId/queries/:id/start', (req, res): void => {
try {
startQueryTimer(parseInt(req.params.id));
res.json({ status: 'started' });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/pg/sources/:sourceId/queries/:id/stop
router.post('/:sourceId/queries/:id/stop', (req, res): void => {
try {
stopQueryTimer(parseInt(req.params.id));
res.json({ status: 'stopped' });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
export default router;

View File

@ -1,54 +1,23 @@
import bcrypt from 'bcryptjs';
import { initDb } from './db/index';
import { createApp } from './api/index';
import { connectAllEnabledBrokers } from './modules/mqtt/broker-manager';
import { connectAllEnabledSources } from './modules/postgres/source-manager';
import { startAllQueryTimers } from './modules/postgres/query-scheduler';
import express from 'express';
import cors from 'cors';
import authRouter from './api/routes/auth';
const app = express();
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000;
const DB_PATH = process.env.DB_PATH ?? './display.db';
async function main(): Promise<void> {
// Initialize database
const db = initDb(DB_PATH);
app.use(cors());
app.use(express.json());
// Create default admin user if no users exist
const userCount = db
.prepare('SELECT COUNT(*) as count FROM users')
.get() as { count: number };
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
if (userCount.count === 0) {
const hash = bcrypt.hashSync('admin', 10);
db.run(
`INSERT INTO users (username, password_hash) VALUES (?, ?)`,
['admin', hash]
);
console.log('Created default admin user (admin/admin)');
}
// Connect all enabled MQTT brokers
await connectAllEnabledBrokers();
// Connect all enabled PostgreSQL sources and start query timers
await connectAllEnabledSources();
startAllQueryTimers();
// Create and start Express app
const app = createApp();
app.use('/api/auth', authRouter);
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Middleware server running on port ${PORT}`);
});
}
// Only run when executed directly (not during tests)
if (require.main === module) {
main().catch((err) => {
console.error('Fatal startup error:', err);
process.exit(1);
});
}
// Export createApp for tests that import from index
export { createApp };
export default createApp();
export default app;

View File

@ -1,211 +0,0 @@
import { getDb } from '../../db/index';
export interface Layout {
id: number;
name: string;
type: string;
width: number;
height: number;
grid_cols: number | null;
grid_rows: number | null;
created_at: string;
}
export interface LayoutWithElements extends Layout {
elements: LayoutElement[];
}
export interface LayoutElement {
id: number;
layout_id: number;
datapoint_id: number | null;
type: string;
x: number;
y: number;
width: number | null;
height: number | null;
font_size: number;
font_weight: string;
text_align: string;
icon: string | null;
style: string;
static_text: string | null;
grid_col: number | null;
grid_row: number | null;
sort_order: number;
}
export interface CreateLayoutData {
name: string;
type?: string;
width?: number;
height?: number;
grid_cols?: number;
grid_rows?: number;
}
export interface UpdateLayoutData {
name?: string;
type?: string;
width?: number;
height?: number;
grid_cols?: number | null;
grid_rows?: number | null;
}
export interface CreateElementData {
datapoint_id?: number;
type: string;
x?: number;
y?: number;
width?: number;
height?: number;
font_size?: number;
font_weight?: string;
text_align?: string;
icon?: string;
style?: string;
static_text?: string;
grid_col?: number;
grid_row?: number;
sort_order?: number;
}
export type UpdateElementData = Partial<CreateElementData>;
export function getAllLayouts(): Layout[] {
const db = getDb();
return db.prepare('SELECT * FROM layouts ORDER BY id ASC').all() as Layout[];
}
export function getLayoutById(id: number): LayoutWithElements | undefined {
const db = getDb();
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(id) as Layout | undefined;
if (!layout) return undefined;
const elements = db
.prepare('SELECT * FROM layout_elements WHERE layout_id = ? ORDER BY sort_order ASC, id ASC')
.all(id) as LayoutElement[];
return { ...layout, elements };
}
export function createLayout(data: CreateLayoutData): Layout {
const db = getDb();
const result = db.run(
`INSERT INTO layouts (name, type, width, height, grid_cols, grid_rows)
VALUES (?, ?, ?, ?, ?, ?)`,
[
data.name,
data.type ?? 'free',
data.width ?? 800,
data.height ?? 480,
data.grid_cols ?? null,
data.grid_rows ?? null,
]
);
const id = result.lastInsertRowid as number;
return db.prepare('SELECT * FROM layouts WHERE id = ?').get(id) as Layout;
}
export function updateLayout(id: number, data: UpdateLayoutData): Layout | undefined {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.type !== undefined) { fields.push('type = ?'); values.push(data.type); }
if (data.width !== undefined) { fields.push('width = ?'); values.push(data.width); }
if (data.height !== undefined) { fields.push('height = ?'); values.push(data.height); }
if (data.grid_cols !== undefined) { fields.push('grid_cols = ?'); values.push(data.grid_cols); }
if (data.grid_rows !== undefined) { fields.push('grid_rows = ?'); values.push(data.grid_rows); }
if (fields.length === 0) {
return db.prepare('SELECT * FROM layouts WHERE id = ?').get(id) as Layout | undefined;
}
values.push(id);
db.run(`UPDATE layouts SET ${fields.join(', ')} WHERE id = ?`, values);
return db.prepare('SELECT * FROM layouts WHERE id = ?').get(id) as Layout | undefined;
}
export function deleteLayout(id: number): boolean {
const db = getDb();
const result = db.run('DELETE FROM layouts WHERE id = ?', [id]);
return (result.changes as number) > 0;
}
export function addElement(layoutId: number, data: CreateElementData): LayoutElement {
const db = getDb();
const result = db.run(
`INSERT INTO layout_elements
(layout_id, datapoint_id, type, x, y, width, height, font_size, font_weight,
text_align, icon, style, static_text, grid_col, grid_row, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
layoutId,
data.datapoint_id ?? null,
data.type,
data.x ?? 0,
data.y ?? 0,
data.width ?? null,
data.height ?? null,
data.font_size ?? 24,
data.font_weight ?? 'normal',
data.text_align ?? 'left',
data.icon ?? null,
data.style ?? 'plain',
data.static_text ?? null,
data.grid_col ?? null,
data.grid_row ?? null,
data.sort_order ?? 0,
]
);
const id = result.lastInsertRowid as number;
return db.prepare('SELECT * FROM layout_elements WHERE id = ?').get(id) as LayoutElement;
}
export function updateElement(id: number, data: UpdateElementData): LayoutElement | undefined {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
const fieldMap: Record<string, string> = {
datapoint_id: 'datapoint_id',
type: 'type',
x: 'x',
y: 'y',
width: 'width',
height: 'height',
font_size: 'font_size',
font_weight: 'font_weight',
text_align: 'text_align',
icon: 'icon',
style: 'style',
static_text: 'static_text',
grid_col: 'grid_col',
grid_row: 'grid_row',
sort_order: 'sort_order',
};
for (const [key, col] of Object.entries(fieldMap)) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
fields.push(`${col} = ?`);
values.push((data as Record<string, unknown>)[key]);
}
}
if (fields.length === 0) {
return db.prepare('SELECT * FROM layout_elements WHERE id = ?').get(id) as LayoutElement | undefined;
}
values.push(id);
db.run(`UPDATE layout_elements SET ${fields.join(', ')} WHERE id = ?`, values);
return db.prepare('SELECT * FROM layout_elements WHERE id = ?').get(id) as LayoutElement | undefined;
}
export function deleteElement(id: number): boolean {
const db = getDb();
const result = db.run('DELETE FROM layout_elements WHERE id = ?', [id]);
return (result.changes as number) > 0;
}

View File

@ -1,287 +0,0 @@
import * as mqtt from 'mqtt';
import { getDb } from '../../db/index';
import { extractValue } from './payload-parser';
import {
getDatapointByTopicId,
updateDatapointValue,
} from '../datapoints/datapoint-manager';
export interface MqttBroker {
id: number;
name: string;
host: string;
port: number;
username: string | null;
password: string | null;
use_tls: number;
enabled: number;
status: string;
created_at: string;
updated_at: string;
}
export interface CreateBrokerData {
name: string;
host: string;
port?: number;
username?: string;
password?: string;
use_tls?: number;
enabled?: number;
}
export type UpdateBrokerData = Partial<CreateBrokerData>;
interface MqttTopic {
id: number;
broker_id: number;
topic: string;
json_path: string | null;
selected: number;
ignored: number;
}
const connections = new Map<number, mqtt.MqttClient>();
export function getAllBrokers(): MqttBroker[] {
const db = getDb();
return db.prepare('SELECT * FROM mqtt_brokers ORDER BY id ASC').all() as MqttBroker[];
}
export function getBrokerById(id: number): MqttBroker | undefined {
const db = getDb();
return db.prepare('SELECT * FROM mqtt_brokers WHERE id = ?').get(id) as MqttBroker | undefined;
}
export function createBroker(data: CreateBrokerData): MqttBroker {
const db = getDb();
const result = db.run(
`INSERT INTO mqtt_brokers (name, host, port, username, password, use_tls, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
data.name,
data.host,
data.port ?? 1883,
data.username ?? null,
data.password ?? null,
data.use_tls ?? 0,
data.enabled ?? 1,
]
);
const id = result.lastInsertRowid as number;
return db.prepare('SELECT * FROM mqtt_brokers WHERE id = ?').get(id) as MqttBroker;
}
export function updateBroker(id: number, data: UpdateBrokerData): MqttBroker | undefined {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.host !== undefined) { fields.push('host = ?'); values.push(data.host); }
if (data.port !== undefined) { fields.push('port = ?'); values.push(data.port); }
if (data.username !== undefined) { fields.push('username = ?'); values.push(data.username); }
if (data.password !== undefined) { fields.push('password = ?'); values.push(data.password); }
if (data.use_tls !== undefined) { fields.push('use_tls = ?'); values.push(data.use_tls); }
if (data.enabled !== undefined) { fields.push('enabled = ?'); values.push(data.enabled); }
if (fields.length === 0) return getBrokerById(id);
fields.push("updated_at = datetime('now')");
values.push(id);
db.run(`UPDATE mqtt_brokers SET ${fields.join(', ')} WHERE id = ?`, values);
return getBrokerById(id);
}
export function deleteBroker(id: number): boolean {
const db = getDb();
disconnectBroker(id);
const result = db.run('DELETE FROM mqtt_brokers WHERE id = ?', [id]);
return (result.changes as number) > 0;
}
function buildConnectUrl(broker: MqttBroker): string {
const protocol = broker.use_tls ? 'mqtts' : 'mqtt';
return `${protocol}://${broker.host}:${broker.port}`;
}
function setStatus(id: number, status: string): void {
const db = getDb();
db.run(
`UPDATE mqtt_brokers SET status = ?, updated_at = datetime('now') WHERE id = ?`,
[status, id]
);
}
export function connectBroker(id: number): Promise<mqtt.MqttClient> {
return new Promise((resolve, reject) => {
const broker = getBrokerById(id);
if (!broker) {
reject(new Error(`Broker ${id} not found`));
return;
}
// Disconnect existing connection if any
if (connections.has(id)) {
connections.get(id)!.end(true);
connections.delete(id);
}
const url = buildConnectUrl(broker);
const options: mqtt.IClientOptions = {
clientId: `display-middleware-${id}-${Date.now()}`,
reconnectPeriod: 5000,
connectTimeout: 10000,
};
if (broker.username) options.username = broker.username;
if (broker.password) options.password = broker.password;
const client = mqtt.connect(url, options);
client.on('connect', () => {
setStatus(id, 'connected');
connections.set(id, client);
// Subscribe to all selected topics for this broker
const db = getDb();
const topics = db
.prepare(
'SELECT * FROM mqtt_topics WHERE broker_id = ? AND selected = 1 AND ignored = 0'
)
.all(id) as MqttTopic[];
for (const t of topics) {
client.subscribe(t.topic, (err) => {
if (err) {
console.error(`Failed to subscribe to ${t.topic}:`, err.message);
}
});
}
resolve(client);
});
client.on('message', (topic: string, payloadBuf: Buffer) => {
const db = getDb();
const payload = payloadBuf.toString();
// Find the topic record
const topicRecord = db
.prepare(
'SELECT * FROM mqtt_topics WHERE broker_id = ? AND topic = ?'
)
.get(id, topic) as MqttTopic | undefined;
if (!topicRecord) return;
// Update last_payload
db.run(
'UPDATE mqtt_topics SET last_payload = ? WHERE id = ?',
[payload, topicRecord.id]
);
// Extract value and update datapoint
const extracted = extractValue(payload, topicRecord.json_path);
if (extracted === null) return;
const dp = getDatapointByTopicId(topicRecord.id);
if (dp) {
updateDatapointValue(dp.id, extracted);
}
});
client.on('error', (err: Error) => {
setStatus(id, 'error');
console.error(`MQTT broker ${id} error:`, err.message);
});
client.on('offline', () => {
setStatus(id, 'disconnected');
});
client.on('reconnect', () => {
setStatus(id, 'reconnecting');
});
// Reject if connection fails within timeout
const timer = setTimeout(() => {
if (!connections.has(id)) {
client.end(true);
setStatus(id, 'error');
reject(new Error(`Connection to broker ${id} timed out`));
}
}, 15000);
client.on('connect', () => clearTimeout(timer));
});
}
export function disconnectBroker(id: number): void {
const client = connections.get(id);
if (client) {
client.end(true);
connections.delete(id);
setStatus(id, 'disconnected');
}
}
export function testBroker(data: {
host: string;
port?: number;
username?: string;
password?: string;
use_tls?: number;
}): Promise<boolean> {
return new Promise((resolve) => {
const protocol = data.use_tls ? 'mqtts' : 'mqtt';
const port = data.port ?? 1883;
const url = `${protocol}://${data.host}:${port}`;
const options: mqtt.IClientOptions = {
clientId: `display-test-${Date.now()}`,
connectTimeout: 5000,
reconnectPeriod: 0,
};
if (data.username) options.username = data.username;
if (data.password) options.password = data.password;
const client = mqtt.connect(url, options);
const timer = setTimeout(() => {
client.end(true);
resolve(false);
}, 5000);
client.on('connect', () => {
clearTimeout(timer);
client.end(true);
resolve(true);
});
client.on('error', () => {
clearTimeout(timer);
client.end(true);
resolve(false);
});
});
}
export async function connectAllEnabledBrokers(): Promise<void> {
const db = getDb();
const brokers = db
.prepare('SELECT * FROM mqtt_brokers WHERE enabled = 1')
.all() as MqttBroker[];
for (const broker of brokers) {
try {
await connectBroker(broker.id);
} catch (err) {
console.error(`Failed to connect broker ${broker.id}:`, (err as Error).message);
}
}
}
export function getConnection(id: number): mqtt.MqttClient | undefined {
return connections.get(id);
}

View File

@ -1,169 +0,0 @@
import * as mqtt from 'mqtt';
import { getDb } from '../../db/index';
import { getConnection } from './broker-manager';
export interface MqttTopic {
id: number;
broker_id: number;
topic: string;
json_path: string | null;
selected: number;
ignored: number;
last_payload: string | null;
discovered_at: string;
}
export interface UpdateTopicData {
json_path?: string | null;
selected?: number;
ignored?: number;
}
export function getTopicsForBroker(brokerId: number): MqttTopic[] {
const db = getDb();
return db
.prepare('SELECT * FROM mqtt_topics WHERE broker_id = ? ORDER BY id ASC')
.all(brokerId) as MqttTopic[];
}
export function updateTopic(id: number, data: UpdateTopicData): MqttTopic | undefined {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
if (data.json_path !== undefined) { fields.push('json_path = ?'); values.push(data.json_path); }
if (data.selected !== undefined) { fields.push('selected = ?'); values.push(data.selected); }
if (data.ignored !== undefined) { fields.push('ignored = ?'); values.push(data.ignored); }
if (fields.length === 0) {
return db.prepare('SELECT * FROM mqtt_topics WHERE id = ?').get(id) as MqttTopic | undefined;
}
values.push(id);
db.run(`UPDATE mqtt_topics SET ${fields.join(', ')} WHERE id = ?`, values);
return db.prepare('SELECT * FROM mqtt_topics WHERE id = ?').get(id) as MqttTopic | undefined;
}
function ensureTopicExists(brokerId: number, topic: string): void {
const db = getDb();
const existing = db
.prepare('SELECT id FROM mqtt_topics WHERE broker_id = ? AND topic = ?')
.get(brokerId, topic);
if (!existing) {
db.run(
`INSERT INTO mqtt_topics (broker_id, topic, selected, ignored)
VALUES (?, ?, 0, 0)`,
[brokerId, topic]
);
}
}
export function startDiscovery(brokerId: number, prefix?: string): Promise<MqttTopic[]> {
return new Promise((resolve) => {
const db = getDb();
const broker = db
.prepare('SELECT * FROM mqtt_brokers WHERE id = ?')
.get(brokerId) as {
id: number;
host: string;
port: number;
username: string | null;
password: string | null;
use_tls: number;
} | undefined;
if (!broker) {
resolve([]);
return;
}
// Try to use existing connection first, otherwise create a temporary one
const existingClient = getConnection(brokerId);
let tempClient: mqtt.MqttClient | null = null;
let client: mqtt.MqttClient;
const subscribePattern = prefix ? `${prefix}#` : '#';
const discoveredTopics: Set<string> = new Set();
const finalize = (): void => {
if (tempClient) {
tempClient.end(true);
}
resolve(getTopicsForBroker(brokerId));
};
const onMessage = (topic: string): void => {
if (!discoveredTopics.has(topic)) {
discoveredTopics.add(topic);
ensureTopicExists(brokerId, topic);
}
};
if (existingClient && existingClient.connected) {
client = existingClient;
client.subscribe(subscribePattern, (err) => {
if (err) {
resolve(getTopicsForBroker(brokerId));
return;
}
client.on('message', onMessage);
setTimeout(() => {
client.unsubscribe(subscribePattern);
client.removeListener('message', onMessage);
finalize();
}, 30000);
});
} else {
const protocol = broker.use_tls ? 'mqtts' : 'mqtt';
const url = `${protocol}://${broker.host}:${broker.port}`;
const options: mqtt.IClientOptions = {
clientId: `display-discovery-${brokerId}-${Date.now()}`,
connectTimeout: 10000,
reconnectPeriod: 0,
};
if (broker.username) options.username = broker.username;
if (broker.password) options.password = broker.password;
tempClient = mqtt.connect(url, options);
client = tempClient;
tempClient.on('connect', () => {
client.subscribe(subscribePattern, (err) => {
if (err) {
finalize();
return;
}
client.on('message', onMessage);
setTimeout(() => {
finalize();
}, 30000);
});
});
tempClient.on('error', () => {
finalize();
});
}
});
}
export function addManualTopic(brokerId: number, topic: string): MqttTopic {
const db = getDb();
// Insert or ignore duplicate
const existing = db
.prepare('SELECT * FROM mqtt_topics WHERE broker_id = ? AND topic = ?')
.get(brokerId, topic) as MqttTopic | undefined;
if (existing) return existing;
const result = db.run(
`INSERT INTO mqtt_topics (broker_id, topic, selected, ignored)
VALUES (?, ?, 0, 0)`,
[brokerId, topic]
);
const id = result.lastInsertRowid as number;
return db.prepare('SELECT * FROM mqtt_topics WHERE id = ?').get(id) as MqttTopic;
}

View File

@ -1,152 +0,0 @@
import { getDb } from '../../db/index';
import { getPool } from './source-manager';
import { getDatapointByQueryId, updateDatapointValue } from '../datapoints/datapoint-manager';
export interface PgQuery {
id: number;
source_id: number;
name: string;
query: string;
interval_sec: number;
result_column: string;
last_result: string | null;
last_run_at: string | null;
}
export interface CreateQueryData {
source_id: number;
name: string;
query: string;
interval_sec?: number;
result_column: string;
}
export type UpdateQueryData = Partial<Omit<CreateQueryData, 'source_id'>>;
const timers = new Map<number, NodeJS.Timeout>();
export function getAllQueries(sourceId: number): PgQuery[] {
const db = getDb();
return db
.prepare('SELECT * FROM pg_queries WHERE source_id = ? ORDER BY id ASC')
.all(sourceId) as PgQuery[];
}
export function getQueryById(id: number): PgQuery | undefined {
const db = getDb();
return db.prepare('SELECT * FROM pg_queries WHERE id = ?').get(id) as PgQuery | undefined;
}
export function createQuery(data: CreateQueryData): PgQuery {
const db = getDb();
const result = db.run(
`INSERT INTO pg_queries (source_id, name, query, interval_sec, result_column)
VALUES (?, ?, ?, ?, ?)`,
[
data.source_id,
data.name,
data.query,
data.interval_sec ?? 60,
data.result_column,
]
);
const id = result.lastInsertRowid as number;
return db.prepare('SELECT * FROM pg_queries WHERE id = ?').get(id) as PgQuery;
}
export function updateQuery(id: number, data: UpdateQueryData): PgQuery | undefined {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.query !== undefined) { fields.push('query = ?'); values.push(data.query); }
if (data.interval_sec !== undefined) { fields.push('interval_sec = ?'); values.push(data.interval_sec); }
if (data.result_column !== undefined) { fields.push('result_column = ?'); values.push(data.result_column); }
if (fields.length === 0) return getQueryById(id);
values.push(id);
db.run(`UPDATE pg_queries SET ${fields.join(', ')} WHERE id = ?`, values);
return getQueryById(id);
}
export function deleteQuery(id: number): boolean {
const db = getDb();
stopQueryTimer(id);
const result = db.run('DELETE FROM pg_queries WHERE id = ?', [id]);
return (result.changes as number) > 0;
}
async function executeQuery(queryId: number): Promise<void> {
const query = getQueryById(queryId);
if (!query) return;
const pool = getPool(query.source_id);
if (!pool) {
console.warn(`No pool for PG source ${query.source_id}, skipping query ${queryId}`);
return;
}
try {
const result = await pool.query(query.query);
const row = result.rows[0];
if (!row) return;
const rawValue = row[query.result_column];
if (rawValue === undefined || rawValue === null) return;
const valueStr = String(rawValue);
const db = getDb();
db.run(
`UPDATE pg_queries SET last_result = ?, last_run_at = datetime('now') WHERE id = ?`,
[valueStr, queryId]
);
const dp = getDatapointByQueryId(queryId);
if (dp) {
updateDatapointValue(dp.id, valueStr);
}
} catch (err) {
console.error(`Query ${queryId} execution error:`, (err as Error).message);
}
}
export function startQueryTimer(queryId: number): void {
const query = getQueryById(queryId);
if (!query) return;
// Stop existing timer if any
stopQueryTimer(queryId);
// Execute immediately
void executeQuery(queryId);
const intervalMs = query.interval_sec * 1000;
const timer = setInterval(() => {
void executeQuery(queryId);
}, intervalMs);
timers.set(queryId, timer);
}
export function stopQueryTimer(queryId: number): void {
const timer = timers.get(queryId);
if (timer) {
clearInterval(timer);
timers.delete(queryId);
}
}
export function startAllQueryTimers(): void {
const db = getDb();
const queries = db.prepare('SELECT * FROM pg_queries').all() as PgQuery[];
for (const query of queries) {
const pool = getPool(query.source_id);
if (pool) {
startQueryTimer(query.id);
}
}
}

View File

@ -1,188 +0,0 @@
import { Pool } from 'pg';
import { getDb } from '../../db/index';
export interface PgSource {
id: number;
name: string;
host: string;
port: number;
database_name: string;
username: string;
password: string;
use_tls: number;
enabled: number;
status: string;
created_at: string;
}
export interface CreateSourceData {
name: string;
host: string;
port?: number;
database_name: string;
username: string;
password: string;
use_tls?: number;
enabled?: number;
}
export type UpdateSourceData = Partial<CreateSourceData>;
const pools = new Map<number, Pool>();
export function getAllSources(): PgSource[] {
const db = getDb();
return db.prepare('SELECT * FROM pg_sources ORDER BY id ASC').all() as PgSource[];
}
export function getSourceById(id: number): PgSource | undefined {
const db = getDb();
return db.prepare('SELECT * FROM pg_sources WHERE id = ?').get(id) as PgSource | undefined;
}
export function createSource(data: CreateSourceData): PgSource {
const db = getDb();
const result = db.run(
`INSERT INTO pg_sources (name, host, port, database_name, username, password, use_tls, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.name,
data.host,
data.port ?? 5432,
data.database_name,
data.username,
data.password,
data.use_tls ?? 0,
data.enabled ?? 1,
]
);
const id = result.lastInsertRowid as number;
return db.prepare('SELECT * FROM pg_sources WHERE id = ?').get(id) as PgSource;
}
export function updateSource(id: number, data: UpdateSourceData): PgSource | undefined {
const db = getDb();
const fields: string[] = [];
const values: unknown[] = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.host !== undefined) { fields.push('host = ?'); values.push(data.host); }
if (data.port !== undefined) { fields.push('port = ?'); values.push(data.port); }
if (data.database_name !== undefined) { fields.push('database_name = ?'); values.push(data.database_name); }
if (data.username !== undefined) { fields.push('username = ?'); values.push(data.username); }
if (data.password !== undefined) { fields.push('password = ?'); values.push(data.password); }
if (data.use_tls !== undefined) { fields.push('use_tls = ?'); values.push(data.use_tls); }
if (data.enabled !== undefined) { fields.push('enabled = ?'); values.push(data.enabled); }
if (fields.length === 0) return getSourceById(id);
values.push(id);
db.run(`UPDATE pg_sources SET ${fields.join(', ')} WHERE id = ?`, values);
return getSourceById(id);
}
export function deleteSource(id: number): boolean {
const db = getDb();
disconnectSource(id);
const result = db.run('DELETE FROM pg_sources WHERE id = ?', [id]);
return (result.changes as number) > 0;
}
function setStatus(id: number, status: string): void {
const db = getDb();
db.run('UPDATE pg_sources SET status = ? WHERE id = ?', [status, id]);
}
export async function connectSource(id: number): Promise<Pool> {
const source = getSourceById(id);
if (!source) throw new Error(`PG source ${id} not found`);
// Disconnect existing pool
if (pools.has(id)) {
await pools.get(id)!.end();
pools.delete(id);
}
const pool = new Pool({
host: source.host,
port: source.port,
database: source.database_name,
user: source.username,
password: source.password,
ssl: source.use_tls ? { rejectUnauthorized: false } : undefined,
max: 5,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
// Test connection
try {
const client = await pool.connect();
client.release();
setStatus(id, 'connected');
pools.set(id, pool);
return pool;
} catch (err) {
setStatus(id, 'error');
await pool.end();
throw err;
}
}
export function disconnectSource(id: number): void {
const pool = pools.get(id);
if (pool) {
pool.end().catch((err) => console.error(`Error closing PG pool ${id}:`, err));
pools.delete(id);
setStatus(id, 'disconnected');
}
}
export function getPool(id: number): Pool | undefined {
return pools.get(id);
}
export async function testSource(data: {
host: string;
port?: number;
database_name: string;
username: string;
password: string;
use_tls?: number;
}): Promise<boolean> {
const pool = new Pool({
host: data.host,
port: data.port ?? 5432,
database: data.database_name,
user: data.username,
password: data.password,
ssl: data.use_tls ? { rejectUnauthorized: false } : undefined,
connectionTimeoutMillis: 5000,
max: 1,
});
try {
const client = await pool.connect();
client.release();
await pool.end();
return true;
} catch {
await pool.end().catch(() => {});
return false;
}
}
export async function connectAllEnabledSources(): Promise<void> {
const db = getDb();
const sources = db
.prepare('SELECT * FROM pg_sources WHERE enabled = 1')
.all() as PgSource[];
for (const source of sources) {
try {
await connectSource(source.id);
} catch (err) {
console.error(`Failed to connect PG source ${source.id}:`, (err as Error).message);
}
}
}

View File

@ -1,82 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { initDb, closeDb } from '../../src/db/index';
import { createApp } from '../../src/api/index';
const app = createApp();
beforeAll(() => {
const db = initDb(':memory:');
// Create a layout
db.run(
`INSERT INTO layouts (id, name, type, width, height)
VALUES (1, 'Test Layout', 'free', 800, 480)`
);
// Display without token
db.run(
`INSERT INTO displays (id, name, layout_id, refresh_sec)
VALUES (1, 'Open Display', 1, 60)`
);
// Display with token auth
db.run(
`INSERT INTO displays (id, name, layout_id, api_token, refresh_sec)
VALUES (2, 'Secure Display', 1, 'secret-token-123', 300)`
);
});
afterAll(() => {
closeDb();
});
describe('GET /api/display/:id', () => {
it('returns display JSON for open display', async () => {
const res = await request(app).get('/api/display/1');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('display');
expect(res.body.display.id).toBe(1);
expect(res.body).toHaveProperty('layout');
expect(res.body).toHaveProperty('elements');
});
it('returns 404 for non-existent display', async () => {
const res = await request(app).get('/api/display/999');
expect(res.status).toBe(404);
});
it('returns 401 for token-protected display without token', async () => {
const res = await request(app).get('/api/display/2');
expect(res.status).toBe(401);
});
it('returns 401 for token-protected display with wrong token', async () => {
const res = await request(app)
.get('/api/display/2')
.set('Authorization', 'Bearer wrong-token');
expect(res.status).toBe(401);
});
it('returns display JSON with valid Bearer token', async () => {
const res = await request(app)
.get('/api/display/2')
.set('Authorization', 'Bearer secret-token-123');
expect(res.status).toBe(200);
expect(res.body.display.id).toBe(2);
});
it('returns display JSON with valid query token', async () => {
const res = await request(app).get('/api/display/2?token=secret-token-123');
expect(res.status).toBe(200);
expect(res.body.display.id).toBe(2);
});
it('updates last_seen on successful access', async () => {
await request(app).get('/api/display/1');
// last_seen is updated — verify by checking display 1 via another endpoint
const res = await request(app).get('/api/display/1');
expect(res.status).toBe(200);
// Just verify the request succeeds — last_seen updated in DB
});
});