Compare commits
10 Commits
2990f9aa4d
...
adb6a6f19e
| Author | SHA1 | Date | |
|---|---|---|---|
| adb6a6f19e | |||
| a52f164299 | |||
| a50ef96eb7 | |||
| 49089d6d17 | |||
| 9e96ecc231 | |||
| 44f4b6d010 | |||
| c922762dfb | |||
| 2c9d218211 | |||
| db3ece1862 | |||
| aaac2bedff |
15
display/.gitignore
vendored
Normal file
15
display/.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
config/project.env
|
||||||
|
.superpowers/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
.pio/
|
||||||
|
.vscode/
|
||||||
127
display/esp32/include/captive_portal.h
Normal file
127
display/esp32/include/captive_portal.h
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <WebServer.h>
|
||||||
|
#include <DNSServer.h>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
static const char CAPTIVE_PORTAL_HTML[] PROGMEM = R"rawhtml(
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>EPaper Setup</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; max-width: 480px; margin: 40px auto; padding: 16px; }
|
||||||
|
h1 { font-size: 1.4em; }
|
||||||
|
label { display: block; margin-top: 12px; font-weight: bold; }
|
||||||
|
input { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; }
|
||||||
|
button { margin-top: 20px; padding: 10px 24px; background: #0078d4; color: #fff;
|
||||||
|
border: none; border-radius: 4px; font-size: 1em; cursor: pointer; }
|
||||||
|
button:hover { background: #005a9e; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>EPaper Display Setup</h1>
|
||||||
|
<form method="POST" action="/save">
|
||||||
|
<label for="ssid">WiFi SSID</label>
|
||||||
|
<input id="ssid" name="ssid" type="text" placeholder="Netzwerkname" required>
|
||||||
|
|
||||||
|
<label for="pass">WiFi Password</label>
|
||||||
|
<input id="pass" name="pass" type="password" placeholder="Passwort (leer lassen wenn kein Passwort)">
|
||||||
|
|
||||||
|
<label for="api_url">API URL</label>
|
||||||
|
<input id="api_url" name="api_url" type="url" placeholder="http://192.168.1.100:3000" required>
|
||||||
|
|
||||||
|
<label for="display_id">Display ID</label>
|
||||||
|
<input id="display_id" name="display_id" type="text" placeholder="1" value="1">
|
||||||
|
|
||||||
|
<label for="api_token">API Token (optional)</label>
|
||||||
|
<input id="api_token" name="api_token" type="text" placeholder="Bearer Token">
|
||||||
|
|
||||||
|
<button type="submit">Speichern & 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ä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();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
display/esp32/include/config.h
Normal file
41
display/esp32/include/config.h
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <Preferences.h>
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
String wifi_ssid;
|
||||||
|
String wifi_password;
|
||||||
|
String api_url;
|
||||||
|
String display_id;
|
||||||
|
String api_token;
|
||||||
|
int refresh_sec;
|
||||||
|
|
||||||
|
void load() {
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin("epaper", true); // read-only
|
||||||
|
wifi_ssid = prefs.getString("wifi_ssid", "");
|
||||||
|
wifi_password = prefs.getString("wifi_password", "");
|
||||||
|
api_url = prefs.getString("api_url", "");
|
||||||
|
display_id = prefs.getString("display_id", "1");
|
||||||
|
api_token = prefs.getString("api_token", "");
|
||||||
|
refresh_sec = prefs.getInt ("refresh_sec", 300);
|
||||||
|
prefs.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
void save() const {
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin("epaper", false); // read-write
|
||||||
|
prefs.putString("wifi_ssid", wifi_ssid);
|
||||||
|
prefs.putString("wifi_password", wifi_password);
|
||||||
|
prefs.putString("api_url", api_url);
|
||||||
|
prefs.putString("display_id", display_id);
|
||||||
|
prefs.putString("api_token", api_token);
|
||||||
|
prefs.putInt ("refresh_sec", refresh_sec);
|
||||||
|
prefs.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isConfigured() const {
|
||||||
|
return wifi_ssid.length() > 0 && api_url.length() > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
385
display/esp32/include/icons.h
Normal file
385
display/esp32/include/icons.h
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <pgmspace.h>
|
||||||
|
|
||||||
|
// 24x24 monochrome bitmaps, 1 bit per pixel, stored row-major (24 bits = 3 bytes per row, 24 rows = 72 bytes each)
|
||||||
|
// Pixels are MSB-first. A 0 bit = black, 1 bit = white (GxEPD2 convention for drawBitmap).
|
||||||
|
// For use with display.drawBitmap(x, y, data, 24, 24, GxEPD_BLACK)
|
||||||
|
|
||||||
|
// ── Thermometer ─────────────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_THERMOMETER[] PROGMEM = {
|
||||||
|
0xFF, 0xE7, 0xFF, // row 0
|
||||||
|
0xFF, 0xC3, 0xFF, // row 1
|
||||||
|
0xFF, 0xC3, 0xFF, // row 2
|
||||||
|
0xFF, 0xDB, 0xFF, // row 3
|
||||||
|
0xFF, 0xDB, 0xFF, // row 4
|
||||||
|
0xFF, 0xDB, 0xFF, // row 5
|
||||||
|
0xFF, 0xDB, 0xFF, // row 6
|
||||||
|
0xFF, 0xDB, 0xFF, // row 7
|
||||||
|
0xFF, 0xDB, 0xFF, // row 8
|
||||||
|
0xFF, 0xDB, 0xFF, // row 9
|
||||||
|
0xFF, 0xDB, 0xFF, // row 10
|
||||||
|
0xFF, 0xDB, 0xFF, // row 11
|
||||||
|
0xFF, 0xDB, 0xFF, // row 12
|
||||||
|
0xFF, 0xDB, 0xFF, // row 13
|
||||||
|
0xFF, 0xDB, 0xFF, // row 14
|
||||||
|
0xFE, 0x01, 0x7F, // row 15
|
||||||
|
0xFC, 0x00, 0x3F, // row 16
|
||||||
|
0xFC, 0x00, 0x3F, // row 17
|
||||||
|
0xFC, 0x00, 0x3F, // row 18
|
||||||
|
0xFC, 0x00, 0x3F, // row 19
|
||||||
|
0xFE, 0x01, 0x7F, // row 20
|
||||||
|
0xFF, 0x03, 0xFF, // row 21
|
||||||
|
0xFF, 0x03, 0xFF, // row 22
|
||||||
|
0xFF, 0xE7, 0xFF, // row 23
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Droplet / Humidity ───────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_DROPLET[] PROGMEM = {
|
||||||
|
0xFF, 0xFF, 0xFF, // row 0
|
||||||
|
0xFF, 0xE7, 0xFF, // row 1
|
||||||
|
0xFF, 0xC3, 0xFF, // row 2
|
||||||
|
0xFF, 0x81, 0xFF, // row 3
|
||||||
|
0xFF, 0x00, 0xFF, // row 4
|
||||||
|
0xFE, 0x00, 0x7F, // row 5
|
||||||
|
0xFE, 0x00, 0x7F, // row 6
|
||||||
|
0xFC, 0x00, 0x3F, // row 7
|
||||||
|
0xFC, 0x00, 0x3F, // row 8
|
||||||
|
0xFC, 0x00, 0x3F, // row 9
|
||||||
|
0xFC, 0x00, 0x3F, // row 10
|
||||||
|
0xFC, 0x00, 0x3F, // row 11
|
||||||
|
0xFC, 0x00, 0x3F, // row 12
|
||||||
|
0xFE, 0x00, 0x7F, // row 13
|
||||||
|
0xFE, 0x00, 0x7F, // row 14
|
||||||
|
0xFF, 0x00, 0xFF, // row 15
|
||||||
|
0xFF, 0x81, 0xFF, // row 16
|
||||||
|
0xFF, 0xC3, 0xFF, // row 17
|
||||||
|
0xFF, 0xFF, 0xFF, // row 18
|
||||||
|
0xFF, 0xFF, 0xFF, // row 19
|
||||||
|
0xFF, 0xFF, 0xFF, // row 20
|
||||||
|
0xFF, 0xFF, 0xFF, // row 21
|
||||||
|
0xFF, 0xFF, 0xFF, // row 22
|
||||||
|
0xFF, 0xFF, 0xFF, // row 23
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Wind ─────────────────────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_WIND[] PROGMEM = {
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xC0, 0x00, 0xFF,
|
||||||
|
0x9F, 0xFF, 0x7F,
|
||||||
|
0xFF, 0xFE, 0x7F,
|
||||||
|
0x80, 0x00, 0x7F,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0x80, 0x00, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pressure / Barometer ─────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_PRESSURE[] PROGMEM = {
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFE, 0x00, 0x7F,
|
||||||
|
0xFC, 0x00, 0x3F,
|
||||||
|
0xF8, 0x00, 0x1F,
|
||||||
|
0xF3, 0xFF, 0xCF,
|
||||||
|
0xE7, 0xFF, 0xE7,
|
||||||
|
0xEF, 0xFF, 0xF7,
|
||||||
|
0xEF, 0x03, 0xF7,
|
||||||
|
0xEF, 0xFD, 0xF7,
|
||||||
|
0xEF, 0xFD, 0xF7,
|
||||||
|
0xEF, 0xFF, 0xF7,
|
||||||
|
0xE7, 0xFF, 0xE7,
|
||||||
|
0xF3, 0xFF, 0xCF,
|
||||||
|
0xF8, 0x00, 0x1F,
|
||||||
|
0xFC, 0x00, 0x3F,
|
||||||
|
0xFE, 0x00, 0x7F,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Sun ──────────────────────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_SUN[] PROGMEM = {
|
||||||
|
0xFF, 0xE7, 0xFF,
|
||||||
|
0xFF, 0xE7, 0xFF,
|
||||||
|
0xFB, 0xE7, 0xDF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xCF, 0x00, 0xF3,
|
||||||
|
0xFE, 0x00, 0x7F,
|
||||||
|
0xFC, 0x00, 0x3F,
|
||||||
|
0x80, 0x00, 0x01,
|
||||||
|
0x80, 0x00, 0x01,
|
||||||
|
0xFC, 0x00, 0x3F,
|
||||||
|
0xFE, 0x00, 0x7F,
|
||||||
|
0xCF, 0x00, 0xF3,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFB, 0xE7, 0xDF,
|
||||||
|
0xFF, 0xE7, 0xFF,
|
||||||
|
0xFF, 0xE7, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Bolt / Lightning ─────────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_BOLT[] PROGMEM = {
|
||||||
|
0xFF, 0x87, 0xFF,
|
||||||
|
0xFF, 0x0F, 0xFF,
|
||||||
|
0xFE, 0x1F, 0xFF,
|
||||||
|
0xFC, 0x3F, 0xFF,
|
||||||
|
0xF8, 0x00, 0xFF,
|
||||||
|
0xF0, 0x01, 0xFF,
|
||||||
|
0xE0, 0x03, 0xFF,
|
||||||
|
0xC0, 0x07, 0xFF,
|
||||||
|
0xFF, 0x80, 0x1F,
|
||||||
|
0xFF, 0x00, 0x3F,
|
||||||
|
0xFE, 0x01, 0x7F,
|
||||||
|
0xFC, 0x03, 0xFF,
|
||||||
|
0xF8, 0x07, 0xFF,
|
||||||
|
0xF0, 0x0F, 0xFF,
|
||||||
|
0xFF, 0x1F, 0xFF,
|
||||||
|
0xFF, 0x3F, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Gauge / Speedometer ───────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_GAUGE[] PROGMEM = {
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFE, 0x00, 0x7F,
|
||||||
|
0xF8, 0x00, 0x1F,
|
||||||
|
0xF3, 0xFF, 0xCF,
|
||||||
|
0xEF, 0xFF, 0xF7,
|
||||||
|
0xEF, 0x07, 0xF7,
|
||||||
|
0xEF, 0x03, 0xF7,
|
||||||
|
0xEF, 0x81, 0xF7,
|
||||||
|
0xEF, 0xC0, 0xF7,
|
||||||
|
0xEF, 0xFF, 0xF7,
|
||||||
|
0xF3, 0xFF, 0xCF,
|
||||||
|
0xF8, 0x00, 0x1F,
|
||||||
|
0xFE, 0x00, 0x7F,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── WiFi ─────────────────────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_WIFI[] PROGMEM = {
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xC0, 0x00, 0x03,
|
||||||
|
0x3F, 0xFF, 0xFC,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0x00, 0xFF,
|
||||||
|
0xFC, 0x00, 0x3F,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xE7, 0xFF,
|
||||||
|
0xFF, 0x81, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Battery ───────────────────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_BATTERY[] PROGMEM = {
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xE0, 0x00, 0x07,
|
||||||
|
0xDF, 0xFF, 0xFB,
|
||||||
|
0xDF, 0x00, 0xFB,
|
||||||
|
0xDF, 0x00, 0xFB,
|
||||||
|
0xDF, 0x00, 0xFB,
|
||||||
|
0xDF, 0xFF, 0xFB,
|
||||||
|
0xE0, 0x00, 0x07,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Check / Checkmark ────────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_CHECK[] PROGMEM = {
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xF9,
|
||||||
|
0xFF, 0xFF, 0xF3,
|
||||||
|
0xFF, 0xFF, 0xE7,
|
||||||
|
0xFF, 0xFF, 0xCF,
|
||||||
|
0xFF, 0xFF, 0x9F,
|
||||||
|
0xE7, 0xFF, 0x3F,
|
||||||
|
0xCF, 0xFE, 0x7F,
|
||||||
|
0x9F, 0xFC, 0xFF,
|
||||||
|
0x3F, 0xF9, 0xFF,
|
||||||
|
0x7F, 0xF3, 0xFF,
|
||||||
|
0xFF, 0xE7, 0xFF,
|
||||||
|
0xFF, 0xCF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Warning / Exclamation ─────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_WARNING[] PROGMEM = {
|
||||||
|
0xFF, 0xE7, 0xFF,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFF, 0x81, 0xFF,
|
||||||
|
0xFF, 0x81, 0xFF,
|
||||||
|
0xFF, 0x00, 0xFF,
|
||||||
|
0xFE, 0x18, 0x7F,
|
||||||
|
0xFE, 0x18, 0x7F,
|
||||||
|
0xFE, 0x18, 0x7F,
|
||||||
|
0xFC, 0x18, 0x3F,
|
||||||
|
0xFC, 0x18, 0x3F,
|
||||||
|
0xFC, 0x18, 0x3F,
|
||||||
|
0xFC, 0x00, 0x3F,
|
||||||
|
0xFC, 0x18, 0x3F,
|
||||||
|
0xFC, 0x18, 0x3F,
|
||||||
|
0xFE, 0x18, 0x7F,
|
||||||
|
0xFF, 0x00, 0xFF,
|
||||||
|
0xFF, 0x81, 0xFF,
|
||||||
|
0xFF, 0x81, 0xFF,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFF, 0xE7, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Clock ─────────────────────────────────────────────────────────────────────
|
||||||
|
static const unsigned char ICON_CLOCK[] PROGMEM = {
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFE, 0x00, 0x7F,
|
||||||
|
0xFC, 0x00, 0x3F,
|
||||||
|
0xF8, 0x00, 0x1F,
|
||||||
|
0xF3, 0xFF, 0xCF,
|
||||||
|
0xE7, 0xE7, 0xE7,
|
||||||
|
0xEF, 0xE7, 0xF7,
|
||||||
|
0xEF, 0xE7, 0xF7,
|
||||||
|
0xEF, 0xE0, 0xF7,
|
||||||
|
0xEF, 0xE0, 0x07,
|
||||||
|
0xEF, 0xFF, 0xF7,
|
||||||
|
0xE7, 0xFF, 0xE7,
|
||||||
|
0xF3, 0xFF, 0xCF,
|
||||||
|
0xF8, 0x00, 0x1F,
|
||||||
|
0xFC, 0x00, 0x3F,
|
||||||
|
0xFE, 0x00, 0x7F,
|
||||||
|
0xFF, 0xC3, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
0xFF, 0xFF, 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Icon registry ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct IconEntry {
|
||||||
|
const char* name;
|
||||||
|
const unsigned char* data;
|
||||||
|
};
|
||||||
|
|
||||||
|
static const IconEntry ICON_TABLE[] = {
|
||||||
|
{ "thermometer", ICON_THERMOMETER },
|
||||||
|
{ "droplet", ICON_DROPLET },
|
||||||
|
{ "wind", ICON_WIND },
|
||||||
|
{ "pressure", ICON_PRESSURE },
|
||||||
|
{ "sun", ICON_SUN },
|
||||||
|
{ "bolt", ICON_BOLT },
|
||||||
|
{ "gauge", ICON_GAUGE },
|
||||||
|
{ "wifi", ICON_WIFI },
|
||||||
|
{ "battery", ICON_BATTERY },
|
||||||
|
{ "check", ICON_CHECK },
|
||||||
|
{ "warning", ICON_WARNING },
|
||||||
|
{ "clock", ICON_CLOCK },
|
||||||
|
};
|
||||||
|
|
||||||
|
static const size_t ICON_TABLE_SIZE = sizeof(ICON_TABLE) / sizeof(ICON_TABLE[0]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up an icon bitmap by name.
|
||||||
|
* Returns nullptr if the name is not found.
|
||||||
|
*/
|
||||||
|
inline const unsigned char* getIcon(const char* name) {
|
||||||
|
for (size_t i = 0; i < ICON_TABLE_SIZE; ++i) {
|
||||||
|
if (strcmp(name, ICON_TABLE[i].name) == 0) {
|
||||||
|
return ICON_TABLE[i].data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline const unsigned char* getIcon(const String& name) {
|
||||||
|
return getIcon(name.c_str());
|
||||||
|
}
|
||||||
152
display/esp32/include/renderer.h
Normal file
152
display/esp32/include/renderer.h
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <GxEPD2_BW.h>
|
||||||
|
#include "icons.h"
|
||||||
|
|
||||||
|
// ── Font size helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an API font_size value (typically 8–40) to GFX setTextSize(1–5).
|
||||||
|
* font_size / 8, clamped to [1, 5].
|
||||||
|
*/
|
||||||
|
static inline uint8_t mapFontSize(int fontSize) {
|
||||||
|
int s = fontSize / 8;
|
||||||
|
if (s < 1) s = 1;
|
||||||
|
if (s > 5) s = 5;
|
||||||
|
return static_cast<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
display/esp32/platformio.ini
Normal file
12
display/esp32/platformio.ini
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[env:esp32]
|
||||||
|
platform = espressif32
|
||||||
|
board = esp32dev
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
lib_deps =
|
||||||
|
zinggjm/GxEPD2@^1.5.8
|
||||||
|
bblanchon/ArduinoJson@^7.2.0
|
||||||
|
adafruit/Adafruit GFX Library@^1.11.10
|
||||||
|
adafruit/Adafruit BusIO@^1.16.1
|
||||||
|
board_build.partitions = default.csv
|
||||||
|
build_flags = -DBOARD_HAS_PSRAM
|
||||||
175
display/esp32/src/main.cpp
Normal file
175
display/esp32/src/main.cpp
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <HTTPClient.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
// GxEPD2 for Waveshare 7.5" V2 (800x480, GxEPD2_750_T7)
|
||||||
|
#include <GxEPD2_BW.h>
|
||||||
|
#include <GxEPD2_750_T7.h>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include "renderer.h"
|
||||||
|
#include "captive_portal.h"
|
||||||
|
|
||||||
|
// ── Pin mapping for Waveshare ESP32 Driver Board ──────────────────────────────
|
||||||
|
#define EPD_CS 15
|
||||||
|
#define EPD_DC 27
|
||||||
|
#define EPD_RST 26
|
||||||
|
#define EPD_BUSY 25
|
||||||
|
|
||||||
|
// ── Display instance ──────────────────────────────────────────────────────────
|
||||||
|
GxEPD2_BW<GxEPD2_750_T7, GxEPD2_750_T7::HEIGHT> display(
|
||||||
|
GxEPD2_750_T7(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Deep sleep helper ─────────────────────────────────────────────────────────
|
||||||
|
static void goDeepSleep(int seconds) {
|
||||||
|
Serial.printf("[Sleep] Entering deep sleep for %d seconds\n", seconds);
|
||||||
|
display.hibernate();
|
||||||
|
WiFi.disconnect(true);
|
||||||
|
WiFi.mode(WIFI_OFF);
|
||||||
|
esp_sleep_enable_timer_wakeup((uint64_t)seconds * 1000000ULL);
|
||||||
|
esp_deep_sleep_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── showError: white screen with centered error message ───────────────────────
|
||||||
|
static void showError(const char* message) {
|
||||||
|
Serial.printf("[Error] %s\n", message);
|
||||||
|
display.setFullWindow();
|
||||||
|
display.firstPage();
|
||||||
|
do {
|
||||||
|
display.fillScreen(GxEPD_WHITE);
|
||||||
|
display.setTextColor(GxEPD_BLACK);
|
||||||
|
display.setTextSize(2);
|
||||||
|
|
||||||
|
// Print "FEHLER:" header
|
||||||
|
const char* header = "FEHLER:";
|
||||||
|
int headerW = 6 * 2 * strlen(header);
|
||||||
|
display.setCursor((display.width() - headerW) / 2, display.height() / 2 - 24);
|
||||||
|
display.print(header);
|
||||||
|
|
||||||
|
// Print message below
|
||||||
|
display.setTextSize(1);
|
||||||
|
int msgW = 6 * strlen(message);
|
||||||
|
display.setCursor((display.width() - msgW) / 2, display.height() / 2 + 4);
|
||||||
|
display.print(message);
|
||||||
|
} while (display.nextPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── setup ─────────────────────────────────────────────────────────────────────
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
Serial.println("[Boot] EPaper Display starting...");
|
||||||
|
|
||||||
|
// 1. Init display
|
||||||
|
display.init(115200);
|
||||||
|
display.setRotation(0);
|
||||||
|
|
||||||
|
// 2. Load config from NVS
|
||||||
|
Config config;
|
||||||
|
config.load();
|
||||||
|
|
||||||
|
// 3. If not configured → captive portal
|
||||||
|
if (!config.isConfigured()) {
|
||||||
|
Serial.println("[Boot] Not configured, starting captive portal");
|
||||||
|
display.setFullWindow();
|
||||||
|
display.firstPage();
|
||||||
|
do {
|
||||||
|
display.fillScreen(GxEPD_WHITE);
|
||||||
|
display.setTextColor(GxEPD_BLACK);
|
||||||
|
display.setTextSize(2);
|
||||||
|
const char* line1 = "Mit 'EPaper-Setup' WLAN verbinden";
|
||||||
|
const char* line2 = "und http://192.168.4.1 oeffnen";
|
||||||
|
int w1 = 6 * 2 * strlen(line1);
|
||||||
|
int w2 = 6 * 2 * strlen(line2);
|
||||||
|
display.setCursor((display.width() - w1) / 2, display.height() / 2 - 20);
|
||||||
|
display.print(line1);
|
||||||
|
display.setCursor((display.width() - w2) / 2, display.height() / 2 + 10);
|
||||||
|
display.print(line2);
|
||||||
|
} while (display.nextPage());
|
||||||
|
|
||||||
|
startCaptivePortal(config); // never returns
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Connect to WiFi (30 attempts, 500 ms each)
|
||||||
|
Serial.printf("[WiFi] Connecting to %s\n", config.wifi_ssid.c_str());
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.begin(config.wifi_ssid.c_str(), config.wifi_password.c_str());
|
||||||
|
|
||||||
|
int attempts = 0;
|
||||||
|
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
|
||||||
|
delay(500);
|
||||||
|
Serial.print(".");
|
||||||
|
++attempts;
|
||||||
|
}
|
||||||
|
Serial.println();
|
||||||
|
|
||||||
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
|
showError("WiFi failed");
|
||||||
|
goDeepSleep(60);
|
||||||
|
return; // not reached
|
||||||
|
}
|
||||||
|
Serial.printf("[WiFi] Connected, IP: %s\n", WiFi.localIP().toString().c_str());
|
||||||
|
|
||||||
|
// 5. Build request URL
|
||||||
|
String url = config.api_url + "/api/display/" + config.display_id;
|
||||||
|
if (config.api_token.length() > 0) {
|
||||||
|
url += "?token=" + config.api_token;
|
||||||
|
}
|
||||||
|
Serial.printf("[HTTP] GET %s\n", url.c_str());
|
||||||
|
|
||||||
|
// 6. HTTP GET
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(url);
|
||||||
|
http.setTimeout(10000);
|
||||||
|
if (config.api_token.length() > 0) {
|
||||||
|
http.addHeader("Authorization", "Bearer " + config.api_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
int httpCode = http.GET();
|
||||||
|
if (httpCode <= 0 || httpCode >= 400) {
|
||||||
|
Serial.printf("[HTTP] Error: %d\n", httpCode);
|
||||||
|
http.end();
|
||||||
|
showError(("HTTP " + String(httpCode)).c_str());
|
||||||
|
goDeepSleep(60);
|
||||||
|
return; // not reached
|
||||||
|
}
|
||||||
|
|
||||||
|
String payload = http.getString();
|
||||||
|
http.end();
|
||||||
|
Serial.printf("[HTTP] Payload length: %d\n", payload.length());
|
||||||
|
|
||||||
|
// 7. Parse JSON (ArduinoJson v7)
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, payload);
|
||||||
|
if (err) {
|
||||||
|
Serial.printf("[JSON] Parse error: %s\n", err.c_str());
|
||||||
|
showError(("JSON: " + String(err.c_str())).c_str());
|
||||||
|
goDeepSleep(60);
|
||||||
|
return; // not reached
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Extract refresh_sec (prefer from JSON, fall back to config)
|
||||||
|
int refreshSec = doc["refresh_sec"] | config.refresh_sec;
|
||||||
|
if (refreshSec < 10) refreshSec = 60; // safety minimum
|
||||||
|
|
||||||
|
// 9. Render elements
|
||||||
|
JsonArray elements = doc["elements"].as<JsonArray>();
|
||||||
|
|
||||||
|
display.setFullWindow();
|
||||||
|
display.firstPage();
|
||||||
|
do {
|
||||||
|
display.fillScreen(GxEPD_WHITE);
|
||||||
|
renderElements(display, elements);
|
||||||
|
} while (display.nextPage());
|
||||||
|
|
||||||
|
Serial.println("[Render] Done");
|
||||||
|
|
||||||
|
// 10. Disconnect WiFi and deep sleep
|
||||||
|
goDeepSleep(refreshSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── loop ─────────────────────────────────────────────────────────────────────
|
||||||
|
void loop() {
|
||||||
|
// Never reached: setup() ends in deep sleep
|
||||||
|
}
|
||||||
13
display/frontend/index.html
Normal file
13
display/frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
3403
display/frontend/package-lock.json
generated
Normal file
3403
display/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
display/frontend/package.json
Normal file
33
display/frontend/package.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
display/frontend/src/App.tsx
Normal file
41
display/frontend/src/App.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
display/frontend/src/api/client.ts
Normal file
48
display/frontend/src/api/client.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
168
display/frontend/src/components/EpaperPreview.tsx
Normal file
168
display/frontend/src/components/EpaperPreview.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
display/frontend/src/components/Sidebar.tsx
Normal file
56
display/frontend/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
display/frontend/src/components/StatusBadge.tsx
Normal file
20
display/frontend/src/components/StatusBadge.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
display/frontend/src/index.css
Normal file
1
display/frontend/src/index.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
10
display/frontend/src/main.tsx
Normal file
10
display/frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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>,
|
||||||
|
);
|
||||||
88
display/frontend/src/pages/Dashboard.tsx
Normal file
88
display/frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
display/frontend/src/pages/Datapoints.tsx
Normal file
93
display/frontend/src/pages/Datapoints.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
display/frontend/src/pages/Displays.tsx
Normal file
165
display/frontend/src/pages/Displays.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
display/frontend/src/pages/Layouts.tsx
Normal file
273
display/frontend/src/pages/Layouts.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
display/frontend/src/pages/Login.tsx
Normal file
66
display/frontend/src/pages/Login.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
display/frontend/src/pages/MqttBrokers.tsx
Normal file
158
display/frontend/src/pages/MqttBrokers.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
display/frontend/src/pages/MqttTopics.tsx
Normal file
141
display/frontend/src/pages/MqttTopics.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
display/frontend/src/pages/PgSources.tsx
Normal file
119
display/frontend/src/pages/PgSources.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
display/frontend/tsconfig.app.json
Normal file
25
display/frontend/tsconfig.app.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
7
display/frontend/tsconfig.json
Normal file
7
display/frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
display/frontend/tsconfig.node.json
Normal file
24
display/frontend/tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
8
display/frontend/vite.config.ts
Normal file
8
display/frontend/vite.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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' } },
|
||||||
|
});
|
||||||
45
display/middleware/src/api/index.ts
Normal file
45
display/middleware/src/api/index.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
41
display/middleware/src/api/routes/datapoints.ts
Normal file
41
display/middleware/src/api/routes/datapoints.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
64
display/middleware/src/api/routes/display-api.ts
Normal file
64
display/middleware/src/api/routes/display-api.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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;
|
||||||
102
display/middleware/src/api/routes/displays.ts
Normal file
102
display/middleware/src/api/routes/displays.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
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;
|
||||||
102
display/middleware/src/api/routes/layouts.ts
Normal file
102
display/middleware/src/api/routes/layouts.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
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;
|
||||||
92
display/middleware/src/api/routes/mqtt-brokers.ts
Normal file
92
display/middleware/src/api/routes/mqtt-brokers.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
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;
|
||||||
57
display/middleware/src/api/routes/mqtt-topics.ts
Normal file
57
display/middleware/src/api/routes/mqtt-topics.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
185
display/middleware/src/api/routes/pg-sources.ts
Normal file
185
display/middleware/src/api/routes/pg-sources.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
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;
|
||||||
@ -1,23 +1,54 @@
|
|||||||
import express from 'express';
|
import bcrypt from 'bcryptjs';
|
||||||
import cors from 'cors';
|
import { initDb } from './db/index';
|
||||||
import authRouter from './api/routes/auth';
|
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';
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000;
|
||||||
|
const DB_PATH = process.env.DB_PATH ?? './display.db';
|
||||||
|
|
||||||
app.use(cors());
|
async function main(): Promise<void> {
|
||||||
app.use(express.json());
|
// Initialize database
|
||||||
|
const db = initDb(DB_PATH);
|
||||||
|
|
||||||
app.get('/health', (_req, res) => {
|
// Create default admin user if no users exist
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
const userCount = db
|
||||||
});
|
.prepare('SELECT COUNT(*) as count FROM users')
|
||||||
|
.get() as { count: number };
|
||||||
|
|
||||||
app.use('/api/auth', authRouter);
|
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();
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Middleware server running on port ${PORT}`);
|
console.log(`Middleware server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default app;
|
// 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();
|
||||||
|
|||||||
211
display/middleware/src/modules/display/layout-manager.ts
Normal file
211
display/middleware/src/modules/display/layout-manager.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
287
display/middleware/src/modules/mqtt/broker-manager.ts
Normal file
287
display/middleware/src/modules/mqtt/broker-manager.ts
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
169
display/middleware/src/modules/mqtt/topic-discovery.ts
Normal file
169
display/middleware/src/modules/mqtt/topic-discovery.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
152
display/middleware/src/modules/postgres/query-scheduler.ts
Normal file
152
display/middleware/src/modules/postgres/query-scheduler.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
188
display/middleware/src/modules/postgres/source-manager.ts
Normal file
188
display/middleware/src/modules/postgres/source-manager.ts
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
display/middleware/tests/api/display-api.test.ts
Normal file
82
display/middleware/tests/api/display-api.test.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user