Compare commits
29
Commits
2990f9aa4d
..
main
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
data/
|
||||
.env
|
||||
config/project.env
|
||||
.superpowers/
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.pem
|
||||
*.key
|
||||
.pio/
|
||||
.vscode/
|
||||
@@ -0,0 +1,288 @@
|
||||
# EPaper Display System — Anleitung
|
||||
|
||||
## Webinterface
|
||||
|
||||
Zugang: `http://<SERVER-IP>:4000`
|
||||
Login: `admin` / `admin`
|
||||
|
||||
---
|
||||
|
||||
## Schritt-für-Schritt: Von MQTT-Daten zum Display
|
||||
|
||||
### 1. MQTT-Broker anlegen
|
||||
|
||||
**MQTT Broker** in der Sidebar > "Broker hinzufuegen"
|
||||
|
||||
| Feld | Beispiel |
|
||||
|------|----------|
|
||||
| Name | Victron Cerbo |
|
||||
| Host | 192.168.85.161 |
|
||||
| Port | 1883 |
|
||||
| Username/Passwort | leer lassen wenn nicht noetig |
|
||||
| TLS | aus |
|
||||
|
||||
Nach dem Speichern verbindet sich die Middleware automatisch.
|
||||
|
||||
### 2. Topics entdecken
|
||||
|
||||
**Topics** in der Sidebar > Broker auswaehlen > "Discovery starten"
|
||||
|
||||
- **Prefix leer lassen** fuer alle Topics (`#` Wildcard)
|
||||
- **Prefix setzen** (z.B. `N/b827eb12e445/grid/`) fuer gezieltes Suchen
|
||||
- Die Liste aktualisiert sich automatisch waehrend der 30s Discovery
|
||||
- Topics werden mit ihrem letzten Wert angezeigt
|
||||
|
||||
### 3. Topics auswaehlen (= Datenpunkte erstellen)
|
||||
|
||||
In der Topic-Liste:
|
||||
|
||||
1. **Checkbox anklicken** bei den gewuenschten Topics — erstellt automatisch einen Datenpunkt
|
||||
2. **JSON-Pfad setzen** wenn der Payload JSON ist:
|
||||
- Payload `{"value": 23.5}` → JSON-Pfad: `$.value`
|
||||
- Payload `{"sensor": {"temp": 21.3}}` → JSON-Pfad: `$.sensor.temp`
|
||||
- Payload `42.5` (kein JSON) → JSON-Pfad leer lassen
|
||||
|
||||
### 4. Datenpunkte konfigurieren
|
||||
|
||||
**Datenpunkte** in der Sidebar — hier siehst du alle erstellten Punkte.
|
||||
|
||||
Inline bearbeitbar:
|
||||
- **Label**: Anzeigename (z.B. "Netzspannung L1" statt Topic-Pfad)
|
||||
- **Einheit**: z.B. `V`, `W`, `kWh`, `°C`, `%`
|
||||
|
||||
### 5. Layout erstellen
|
||||
|
||||
**Layouts** in der Sidebar > "Neues Layout"
|
||||
|
||||
| Feld | Beschreibung |
|
||||
|------|-------------|
|
||||
| Name | z.B. "PV Monitor" |
|
||||
| Typ | `free` (freie Positionierung) oder `grid` (Raster) |
|
||||
|
||||
Dann **Elemente hinzufuegen** — jedes Element ist ein Baustein auf dem Display.
|
||||
|
||||
### 6. Display erstellen und Layout zuweisen
|
||||
|
||||
**Displays** in der Sidebar > "Display hinzufuegen"
|
||||
|
||||
| Feld | Beschreibung |
|
||||
|------|-------------|
|
||||
| Name | z.B. "Werkstatt Display" |
|
||||
| Layout | Layout aus Schritt 5 auswaehlen |
|
||||
| Refresh | Update-Intervall in Sekunden (z.B. 300 = 5 Min) |
|
||||
|
||||
---
|
||||
|
||||
## Layout-Elemente
|
||||
|
||||
### Element-Typen
|
||||
|
||||
| Typ | Beschreibung | Felder |
|
||||
|-----|-------------|--------|
|
||||
| `value` | Datenwert mit Label, Wert, Einheit, optional Icon + Karte | Datenpunkt, Icon, Style |
|
||||
| `label` | Statischer Text (Titel, Ueberschriften) | Text |
|
||||
| `line` | Horizontale/vertikale Trennlinie | Width, Height |
|
||||
| `rect` | Rechteck (Rahmen) | Width, Height |
|
||||
|
||||
### Positionierung
|
||||
|
||||
Das Display hat **800 x 480 Pixel**. Jedes Element wird ueber X/Y-Koordinaten positioniert:
|
||||
|
||||
```
|
||||
(0,0) ────────────────────────────────── (800,0)
|
||||
│ │
|
||||
│ (20,20) Titel │
|
||||
│ │
|
||||
│ (20,80)──────────(410,80)────────── │
|
||||
│ │ Wert 1 ││ Wert 2 │ │
|
||||
│ │ (halbe Breite)││ (halbe Breite)│ │
|
||||
│ └───────────────┘└───────────────┘ │
|
||||
│ │
|
||||
│ (20,180)─────────────────────────── │
|
||||
│ │ Wert 3 (volle Breite) │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
(0,480) ──────────────────────────────── (800,480)
|
||||
```
|
||||
|
||||
### Styles
|
||||
|
||||
| Style | Beschreibung |
|
||||
|-------|-------------|
|
||||
| `plain` | Nur Text, kein Rahmen |
|
||||
| `card` | Karte mit abgerundetem Rahmen, Icon oben links, Wert gross unten rechts |
|
||||
|
||||
### Verfuegbare Icons
|
||||
|
||||
Fuer `value`-Elemente mit `card`-Style. Werden als 24x24 Pixel Bitmaps auf dem ESP32 gerendert.
|
||||
|
||||
| Icon-Name | Verwendung |
|
||||
|-----------|-----------|
|
||||
| `thermometer` | Temperatur |
|
||||
| `droplet` | Luftfeuchtigkeit, Regen |
|
||||
| `wind` | Windgeschwindigkeit, Lueftung |
|
||||
| `pressure` | Luftdruck, Druck |
|
||||
| `sun` | Sonneneinstrahlung, PV-Leistung |
|
||||
| `bolt` | Strom, Leistung, Energie |
|
||||
| `gauge` | Zaehlerstand, Fuellstand |
|
||||
| `wifi` | Netzwerk, Verbindungsstatus |
|
||||
| `battery` | Akkustand, Batterielevel |
|
||||
| `check` | Status OK, aktiv |
|
||||
| `warning` | Warnung, Fehler |
|
||||
| `clock` | Zeitstempel, Betriebsstunden |
|
||||
|
||||
### Schriftgroessen
|
||||
|
||||
| font_size | Verwendung |
|
||||
|-----------|-----------|
|
||||
| 12 | Footer, Zeitstempel |
|
||||
| 16 | Klein (Beschriftungen) |
|
||||
| 24 | Standard (Default) |
|
||||
| 28 | Ueberschriften |
|
||||
| 32 | Grosse Werte |
|
||||
| 40 | Sehr grosse Werte |
|
||||
|
||||
---
|
||||
|
||||
## Beispiel-Layouts
|
||||
|
||||
### Beispiel 1: PV-Monitor (5 Werte, gemischt)
|
||||
|
||||
```
|
||||
Layout: "PV Monitor", Typ: free
|
||||
|
||||
Elemente:
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Element 1: label │
|
||||
│ Text: "PV Anlage", x:400, y:15, font_size:28 │
|
||||
│ font_weight:bold, text_align:center │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Element 2: line │
|
||||
│ x:10, y:50, width:780, height:2 │
|
||||
├─────────────────────┬───────────────────────────────┤
|
||||
│ Element 3: value │ Element 4: value │
|
||||
│ x:20, y:70 │ x:410, y:70 │
|
||||
│ width:370, h:90 │ width:370, h:90 │
|
||||
│ Datenpunkt: PV W │ Datenpunkt: Grid W │
|
||||
│ Icon: sun │ Icon: bolt │
|
||||
│ Style: card │ Style: card │
|
||||
├─────────────────────┴───────────────────────────────┤
|
||||
│ Element 5: value │ Element 6: value │
|
||||
│ x:20, y:175 │ x:410, y:175 │
|
||||
│ width:370, h:90 │ width:370, h:90 │
|
||||
│ Datenpunkt: Batt% │ Datenpunkt: Verbrauch │
|
||||
│ Icon: battery │ Icon: gauge │
|
||||
│ Style: card │ Style: card │
|
||||
├─────────────────────┴───────────────────────────────┤
|
||||
│ Element 7: value (volle Breite) │
|
||||
│ x:20, y:280, width:760, height:90 │
|
||||
│ Datenpunkt: Ertrag heute │
|
||||
│ Icon: sun, Style: card │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Element 8: label │
|
||||
│ Text: (wird automatisch mit Zeitstempel gefuellt) │
|
||||
│ x:400, y:465, font_size:12, text_align:center │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Beispiel 2: Raumklima (3 Werte, schlicht)
|
||||
|
||||
```
|
||||
Layout: "Raumklima", Typ: free
|
||||
|
||||
Element 1: label
|
||||
Text: "Buero", x:400, y:20, font_size:28, bold, center
|
||||
|
||||
Element 2: value
|
||||
x:20, y:80, width:760, height:100
|
||||
Datenpunkt: Temperatur, Icon: thermometer, Style: card
|
||||
|
||||
Element 3: value
|
||||
x:20, y:200, width:760, height:100
|
||||
Datenpunkt: Luftfeuchte, Icon: droplet, Style: card
|
||||
|
||||
Element 4: value
|
||||
x:20, y:320, width:760, height:100
|
||||
Datenpunkt: CO2, Icon: wind, Style: card
|
||||
```
|
||||
|
||||
### Beispiel 3: Grid-Layout (6 Kacheln)
|
||||
|
||||
```
|
||||
Layout: "Uebersicht", Typ: grid, grid_cols:3, grid_rows:2
|
||||
|
||||
Element 1: value, grid_col:0, grid_row:0
|
||||
Datenpunkt: Temp, Icon: thermometer, Style: card
|
||||
|
||||
Element 2: value, grid_col:1, grid_row:0
|
||||
Datenpunkt: Feuchte, Icon: droplet, Style: card
|
||||
|
||||
Element 3: value, grid_col:2, grid_row:0
|
||||
Datenpunkt: CO2, Icon: wind, Style: card
|
||||
|
||||
Element 4: value, grid_col:0, grid_row:1
|
||||
Datenpunkt: PV, Icon: sun, Style: card
|
||||
|
||||
Element 5: value, grid_col:1, grid_row:1
|
||||
Datenpunkt: Batterie, Icon: battery, Style: card
|
||||
|
||||
Element 6: value, grid_col:2, grid_row:1
|
||||
Datenpunkt: Netz, Icon: bolt, Style: card
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Victron/Venus OS Tipps
|
||||
|
||||
Typische Topics und empfohlene Konfiguration:
|
||||
|
||||
| Topic | JSON-Pfad | Label | Einheit | Icon |
|
||||
|-------|-----------|-------|---------|------|
|
||||
| `N/.../grid/40/Ac/L1/Voltage` | `$.value` | Netzspannung L1 | V | bolt |
|
||||
| `N/.../grid/40/Ac/L1/Power` | `$.value` | Netzleistung L1 | W | bolt |
|
||||
| `N/.../pvinverter/36/Ac/Power` | `$.value` | PV Leistung | W | sun |
|
||||
| `N/.../pvinverter/36/Ac/Energy/Forward` | `$.value` | PV Ertrag | kWh | sun |
|
||||
| `N/.../battery/512/Soc` | `$.value` | Batterie SOC | % | battery |
|
||||
| `N/.../battery/512/Dc/0/Voltage` | `$.value` | Batterie Spannung | V | battery |
|
||||
| `N/.../battery/512/Dc/0/Power` | `$.value` | Batterie Leistung | W | battery |
|
||||
| `N/.../system/0/Ac/Consumption/L1/Power` | `$.value` | Verbrauch L1 | W | gauge |
|
||||
|
||||
Alle Venus OS Topics verwenden das Format `{"value": <zahl>}`, also immer `$.value` als JSON-Pfad.
|
||||
|
||||
---
|
||||
|
||||
## Datenpunkt-Transformationen
|
||||
|
||||
Im Feld "Transform" (JSON-Format) auf der Datenpunkte-Seite:
|
||||
|
||||
```json
|
||||
{"round": 1} // auf 1 Nachkommastelle runden
|
||||
{"round": 0} // ganzzahlig runden
|
||||
{"scale": 0.001, "round": 2} // Watt → kW mit 2 Stellen
|
||||
{"map": {"1": "AN", "0": "AUS"}} // Zahlwerte auf Text mappen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ESP32 einrichten
|
||||
|
||||
1. **Firmware flashen**: `cd esp32 && pio run -t upload`
|
||||
2. Mit WiFi **"EPaper-Setup"** verbinden (kein Passwort)
|
||||
3. Browser oeffnet automatisch Konfigurationsseite
|
||||
4. Eingeben:
|
||||
- WiFi SSID + Passwort
|
||||
- API URL: `http://<SERVER-IP>:4000`
|
||||
- Display ID: die ID aus dem Webinterface (z.B. `1`)
|
||||
- Token: leer lassen (oder API-Token vom Display)
|
||||
5. ESP32 startet neu und zeigt die Daten an
|
||||
|
||||
---
|
||||
|
||||
## API-Endpunkt fuer ESP32
|
||||
|
||||
```
|
||||
GET http://<SERVER-IP>:4000/api/display/<ID>
|
||||
GET http://<SERVER-IP>:4000/api/display/<ID>?token=<TOKEN>
|
||||
```
|
||||
|
||||
Liefert JSON mit Display-Info, Layout-Typ und allen Elementen inkl. aktuellem Wert.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[platformio]
|
||||
default_envs = wt32-sc01
|
||||
|
||||
; ── Shared settings ──────────────────────────────────────────────────────────
|
||||
[env]
|
||||
platform = espressif32
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson@^7.2.0
|
||||
build_flags = -DBOARD_HAS_PSRAM
|
||||
|
||||
; ── Waveshare 7.5" E-Paper + ESP32 Driver Board ─────────────────────────────
|
||||
[env:epaper]
|
||||
board = esp32dev
|
||||
lib_deps =
|
||||
${env.lib_deps}
|
||||
zinggjm/GxEPD2@^1.5.8
|
||||
adafruit/Adafruit GFX Library@^1.11.10
|
||||
adafruit/Adafruit BusIO@^1.16.1
|
||||
build_flags =
|
||||
${env.build_flags}
|
||||
-DUSE_EPAPER
|
||||
build_src_filter = +<*.cpp> +<epaper/>
|
||||
board_build.partitions = default.csv
|
||||
|
||||
; ── WT32-SC01 V1 (ESP32-WROVER, 3.5" LCD 480x320, Touch) ─────────────────────
|
||||
[env:wt32-sc01]
|
||||
board = esp-wrover-kit
|
||||
board_build.partitions = default.csv
|
||||
lib_deps =
|
||||
${env.lib_deps}
|
||||
lovyan03/LovyanGFX@^1.1.16
|
||||
build_flags =
|
||||
${env.build_flags}
|
||||
-DUSE_LCD
|
||||
build_src_filter = +<*.cpp> +<lcd/>
|
||||
upload_speed = 921600
|
||||
@@ -0,0 +1,179 @@
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// GxEPD2 for Waveshare 7.5" V2 (800x480)
|
||||
#include <GxEPD2_BW.h>
|
||||
#include <epd/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);
|
||||
delay(3000); // Wait for serial monitor to connect
|
||||
Serial.println("\n\n[Boot] EPaper Display starting...");
|
||||
|
||||
// 1. Init display
|
||||
display.init(115200, true, 20, false); // baud, initial, reset_duration_ms*10, pulldown_rst
|
||||
display.setRotation(0);
|
||||
display.setTextWrap(false);
|
||||
// 7.5" V2 needs up to 25s for full refresh — increase busy timeout
|
||||
display.epd2.setBusyCallback(nullptr); // use default busy wait
|
||||
|
||||
// 2. Load config from NVS
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#define LGFX_USE_V1
|
||||
#include <LovyanGFX.hpp>
|
||||
|
||||
// ── WT32-SC01 V1 (ESP32-WROVER, ST7796, 480x320, capacitive touch FT5x06) ──
|
||||
class LGFX_WT32_SC01 : public lgfx::LGFX_Device {
|
||||
lgfx::Panel_ST7796 _panel;
|
||||
lgfx::Bus_SPI _bus;
|
||||
lgfx::Light_PWM _light;
|
||||
lgfx::Touch_FT5x06 _touch;
|
||||
|
||||
public:
|
||||
LGFX_WT32_SC01() {
|
||||
// ── Bus (SPI) ───────────────────────────────────────────────────────
|
||||
{
|
||||
auto cfg = _bus.config();
|
||||
cfg.spi_host = VSPI_HOST;
|
||||
cfg.spi_mode = 0;
|
||||
cfg.freq_write = 40000000;
|
||||
cfg.freq_read = 16000000;
|
||||
cfg.spi_3wire = false;
|
||||
cfg.use_lock = true;
|
||||
cfg.dma_channel = SPI_DMA_CH_AUTO;
|
||||
cfg.pin_sclk = 14;
|
||||
cfg.pin_mosi = 13;
|
||||
cfg.pin_miso = -1;
|
||||
cfg.pin_dc = 21;
|
||||
_bus.config(cfg);
|
||||
_panel.setBus(&_bus);
|
||||
}
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────
|
||||
{
|
||||
auto cfg = _panel.config();
|
||||
cfg.pin_cs = 15;
|
||||
cfg.pin_rst = 22;
|
||||
cfg.pin_busy = -1;
|
||||
cfg.memory_width = 320;
|
||||
cfg.memory_height = 480;
|
||||
cfg.panel_width = 320;
|
||||
cfg.panel_height = 480;
|
||||
cfg.offset_x = 0;
|
||||
cfg.offset_y = 0;
|
||||
cfg.offset_rotation = 0;
|
||||
cfg.readable = false;
|
||||
cfg.invert = false;
|
||||
cfg.rgb_order = false;
|
||||
cfg.dlen_16bit = false;
|
||||
cfg.bus_shared = true;
|
||||
_panel.config(cfg);
|
||||
}
|
||||
|
||||
// ── Backlight ───────────────────────────────────────────────────────
|
||||
{
|
||||
auto cfg = _light.config();
|
||||
cfg.pin_bl = 23;
|
||||
cfg.invert = false;
|
||||
cfg.freq = 44100;
|
||||
cfg.pwm_channel = 7;
|
||||
_light.config(cfg);
|
||||
_panel.setLight(&_light);
|
||||
}
|
||||
|
||||
// ── Touch (FT5x06, I2C) ────────────────────────────────────────────
|
||||
{
|
||||
auto cfg = _touch.config();
|
||||
cfg.x_min = 0;
|
||||
cfg.x_max = 319;
|
||||
cfg.y_min = 0;
|
||||
cfg.y_max = 479;
|
||||
cfg.pin_int = 39;
|
||||
cfg.bus_shared = false;
|
||||
cfg.offset_rotation = 0;
|
||||
cfg.i2c_port = 1;
|
||||
cfg.i2c_addr = 0x38;
|
||||
cfg.pin_sda = 18;
|
||||
cfg.pin_scl = 19;
|
||||
cfg.freq = 400000;
|
||||
_touch.config(cfg);
|
||||
_panel.setTouch(&_touch);
|
||||
}
|
||||
|
||||
setPanel(&_panel);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#include "display_config.h"
|
||||
#include "renderer_lcd.h"
|
||||
#include "config.h"
|
||||
#include "captive_portal.h"
|
||||
|
||||
// ── Display instance ────────────────────────────────────────────────────────
|
||||
static LGFX_WT32_SC01 lcd;
|
||||
|
||||
// ── Status bar at bottom ────────────────────────────────────────────────────
|
||||
static void drawStatusBar(const char* status, uint32_t color = LCD_LABEL) {
|
||||
lcd.fillRect(0, 300, 480, 20, 0x0000);
|
||||
lcd.setTextSize(1.0f);
|
||||
lcd.setTextColor(color);
|
||||
lcd.setTextDatum(lgfx::bottom_left);
|
||||
lcd.drawString(status, 4, 318);
|
||||
}
|
||||
|
||||
// ── Show error full screen ──────────────────────────────────────────────────
|
||||
static void showError(const char* message) {
|
||||
Serial.printf("[Error] %s\n", message);
|
||||
lcd.fillScreen(0x0000);
|
||||
lcd.setTextSize(2.0f);
|
||||
lcd.setTextColor(TFT_RED);
|
||||
lcd.setTextDatum(lgfx::middle_center);
|
||||
lcd.drawString("FEHLER", 240, 140);
|
||||
lcd.setTextSize(1.2f);
|
||||
lcd.setTextColor(LCD_TEXT);
|
||||
lcd.drawString(message, 240, 170);
|
||||
}
|
||||
|
||||
// ── Show setup instructions on LCD ──────────────────────────────────────────
|
||||
static void showSetupScreen() {
|
||||
lcd.fillScreen(0x0000);
|
||||
lcd.setTextSize(2.0f);
|
||||
lcd.setTextColor(LCD_ACCENT);
|
||||
lcd.setTextDatum(lgfx::middle_center);
|
||||
lcd.drawString("EPaper Setup", 240, 80);
|
||||
lcd.setTextSize(1.2f);
|
||||
lcd.setTextColor(LCD_TEXT);
|
||||
lcd.drawString("Mit 'EPaper-Setup' WLAN verbinden", 240, 140);
|
||||
lcd.drawString("und http://192.168.4.1 oeffnen", 240, 165);
|
||||
}
|
||||
|
||||
// ── Fetch and render ────────────────────────────────────────────────────────
|
||||
static bool fetchAndRender(Config& config) {
|
||||
String url = config.api_url + "/api/display/" + config.display_id;
|
||||
Serial.printf("[HTTP] GET %s\n", url.c_str());
|
||||
drawStatusBar("Daten laden...", LCD_ACCENT);
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(url);
|
||||
http.setTimeout(10000);
|
||||
if (config.api_token.length() > 0) {
|
||||
http.addHeader("Authorization", "Bearer " + config.api_token);
|
||||
}
|
||||
|
||||
int httpCode = http.GET();
|
||||
if (httpCode <= 0 || httpCode >= 400) {
|
||||
Serial.printf("[HTTP] Error: %d\n", httpCode);
|
||||
http.end();
|
||||
String errMsg = "HTTP Fehler: " + String(httpCode);
|
||||
drawStatusBar(errMsg.c_str(), TFT_RED);
|
||||
return false;
|
||||
}
|
||||
|
||||
String payload = http.getString();
|
||||
http.end();
|
||||
Serial.printf("[HTTP] Payload length: %d\n", payload.length());
|
||||
|
||||
// Parse JSON
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, payload);
|
||||
if (err) {
|
||||
Serial.printf("[JSON] Parse error: %s\n", err.c_str());
|
||||
String errMsg = "JSON Fehler: " + String(err.c_str());
|
||||
drawStatusBar(errMsg.c_str(), TFT_RED);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract refresh_sec
|
||||
int refreshSec = doc["refresh_sec"] | config.refresh_sec;
|
||||
if (refreshSec < 5) refreshSec = 30;
|
||||
config.refresh_sec = refreshSec;
|
||||
|
||||
// Render
|
||||
JsonArray elements = doc["elements"].as<JsonArray>();
|
||||
|
||||
lcd.fillScreen(LCD_BG);
|
||||
renderElementsLCD(lcd, elements);
|
||||
|
||||
// Status bar with last update time
|
||||
char timeBuf[40];
|
||||
unsigned long sec = millis() / 1000;
|
||||
snprintf(timeBuf, sizeof(timeBuf), "Aktualisiert | Refresh: %ds", refreshSec);
|
||||
drawStatusBar(timeBuf, LCD_LABEL);
|
||||
|
||||
Serial.println("[Render] Done");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(2000);
|
||||
Serial.println("\n\n[Boot] WT32-SC01 Plus LCD Display starting...");
|
||||
|
||||
// 1. Init LCD
|
||||
lcd.init();
|
||||
lcd.setRotation(1); // Landscape: 480x320
|
||||
lcd.setBrightness(200);
|
||||
lcd.fillScreen(0x0000);
|
||||
lcd.setTextSize(1.5f);
|
||||
lcd.setTextColor(LCD_TEXT);
|
||||
lcd.setTextDatum(lgfx::middle_center);
|
||||
lcd.drawString("Starte...", 240, 160);
|
||||
|
||||
// 2. Load config from NVS
|
||||
Config config;
|
||||
config.load();
|
||||
|
||||
// 3. If not configured → captive portal
|
||||
if (!config.isConfigured()) {
|
||||
Serial.println("[Boot] Not configured, starting captive portal");
|
||||
showSetupScreen();
|
||||
startCaptivePortal(config); // never returns
|
||||
}
|
||||
|
||||
// 4. Connect WiFi
|
||||
lcd.fillScreen(0x0000);
|
||||
lcd.drawString("WiFi verbinden...", 240, 160);
|
||||
|
||||
Serial.printf("[WiFi] Connecting to %s\n", config.wifi_ssid.c_str());
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(config.wifi_ssid.c_str(), config.wifi_password.c_str());
|
||||
|
||||
int attempts = 0;
|
||||
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
|
||||
delay(500);
|
||||
Serial.print(".");
|
||||
++attempts;
|
||||
}
|
||||
Serial.println();
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
showError("WiFi Verbindung fehlgeschlagen");
|
||||
delay(10000);
|
||||
ESP.restart();
|
||||
return;
|
||||
}
|
||||
Serial.printf("[WiFi] Connected, IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
|
||||
// 5. Initial fetch
|
||||
if (!fetchAndRender(config)) {
|
||||
showError("Daten konnten nicht geladen werden");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loop: periodic refresh (no deep sleep for LCD) ──────────────────────────
|
||||
static unsigned long lastUpdate = 0;
|
||||
static Config loopConfig;
|
||||
static bool configLoaded = false;
|
||||
|
||||
void loop() {
|
||||
if (!configLoaded) {
|
||||
loopConfig.load();
|
||||
configLoaded = true;
|
||||
lastUpdate = millis();
|
||||
}
|
||||
|
||||
unsigned long now = millis();
|
||||
unsigned long intervalMs = (unsigned long)loopConfig.refresh_sec * 1000UL;
|
||||
|
||||
if (now - lastUpdate >= intervalMs) {
|
||||
lastUpdate = now;
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
Serial.println("[WiFi] Reconnecting...");
|
||||
WiFi.reconnect();
|
||||
delay(5000);
|
||||
}
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
fetchAndRender(loopConfig);
|
||||
} else {
|
||||
drawStatusBar("WiFi getrennt!", TFT_RED);
|
||||
}
|
||||
}
|
||||
|
||||
// Touch handling: tap to force refresh
|
||||
lgfx::touch_point_t tp;
|
||||
if (lcd.getTouch(&tp)) {
|
||||
Serial.println("[Touch] Manual refresh triggered");
|
||||
drawStatusBar("Manuell aktualisieren...", LCD_ACCENT);
|
||||
delay(300); // debounce
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
fetchAndRender(loopConfig);
|
||||
}
|
||||
lastUpdate = millis();
|
||||
}
|
||||
|
||||
delay(50); // small delay for touch responsiveness
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <LovyanGFX.hpp>
|
||||
|
||||
// ── Color constants ─────────────────────────────────────────────────────────
|
||||
#define LCD_BG 0x1082 // Dark background (#1a1a2e)
|
||||
#define LCD_CARD_BG 0x2124 // Card background (#222244)
|
||||
#define LCD_TEXT 0xFFFF // White text
|
||||
#define LCD_LABEL 0xAD55 // Light gray (#aaaaaa)
|
||||
#define LCD_ACCENT 0x07FF // Cyan accent
|
||||
#define LCD_LINE 0x39C7 // Subtle divider
|
||||
|
||||
// ── Font size mapping ───────────────────────────────────────────────────────
|
||||
static inline float mapFontSizeLCD(int fontSize) {
|
||||
// API font_size 8-40 → LovyanGFX textsize
|
||||
if (fontSize <= 10) return 1.0f;
|
||||
if (fontSize <= 14) return 1.2f;
|
||||
if (fontSize <= 18) return 1.5f;
|
||||
if (fontSize <= 24) return 2.0f;
|
||||
if (fontSize <= 32) return 2.5f;
|
||||
return 3.0f;
|
||||
}
|
||||
|
||||
// ── Scale coordinates from layout (800x480) to LCD (480x320) ────────────────
|
||||
static inline int scaleX(int x) { return (x * 480) / 800; }
|
||||
static inline int scaleY(int y) { return (y * 320) / 480; }
|
||||
static inline int scaleW(int w) { return (w * 480) / 800; }
|
||||
static inline int scaleH(int h) { return (h * 320) / 480; }
|
||||
|
||||
// ── Icon rendering (text-based for LCD, no bitmaps needed) ──────────────────
|
||||
static const char* getIconChar(const char* name) {
|
||||
if (strcmp(name, "thermometer") == 0) return "T";
|
||||
if (strcmp(name, "droplet") == 0) return "~";
|
||||
if (strcmp(name, "wind") == 0) return "W";
|
||||
if (strcmp(name, "pressure") == 0) return "P";
|
||||
if (strcmp(name, "sun") == 0) return "*";
|
||||
if (strcmp(name, "bolt") == 0) return "#";
|
||||
if (strcmp(name, "gauge") == 0) return "G";
|
||||
if (strcmp(name, "wifi") == 0) return "?";
|
||||
if (strcmp(name, "battery") == 0) return "B";
|
||||
if (strcmp(name, "check") == 0) return "V";
|
||||
if (strcmp(name, "warning") == 0) return "!";
|
||||
if (strcmp(name, "clock") == 0) return "@";
|
||||
return "";
|
||||
}
|
||||
|
||||
// ── Render all elements from JSON array onto LCD ────────────────────────────
|
||||
template <typename LGFX_Type>
|
||||
void renderElementsLCD(LGFX_Type& lcd, JsonArray elements) {
|
||||
for (JsonObject el : elements) {
|
||||
const char* type = el["type"] | "";
|
||||
int x = scaleX(el["x"] | 0);
|
||||
int y = scaleY(el["y"] | 0);
|
||||
int w = scaleW(el["w"] | 0);
|
||||
int h = scaleH(el["h"] | 0);
|
||||
|
||||
// ── line ────────────────────────────────────────────────────────────
|
||||
if (strcmp(type, "line") == 0) {
|
||||
int thickness = el["thickness"] | 1;
|
||||
lcd.fillRect(x, y, w > 1 ? w : thickness, h > 1 ? h : thickness, LCD_LINE);
|
||||
}
|
||||
// ── rect ────────────────────────────────────────────────────────────
|
||||
else if (strcmp(type, "rect") == 0) {
|
||||
lcd.drawRect(x, y, w, h, LCD_LINE);
|
||||
}
|
||||
// ── label ───────────────────────────────────────────────────────────
|
||||
else if (strcmp(type, "label") == 0) {
|
||||
const char* text = el["text"] | "";
|
||||
int fontSize = el["font_size"] | 8;
|
||||
const char* textAlign = el["text_align"] | "left";
|
||||
float sz = mapFontSizeLCD(fontSize);
|
||||
|
||||
lcd.setTextSize(sz);
|
||||
lcd.setTextColor(LCD_TEXT);
|
||||
|
||||
if (strcmp(textAlign, "center") == 0) {
|
||||
lcd.setTextDatum(lgfx::middle_center);
|
||||
lcd.drawString(text, x, y);
|
||||
} else if (strcmp(textAlign, "right") == 0) {
|
||||
lcd.setTextDatum(lgfx::middle_right);
|
||||
lcd.drawString(text, x, y);
|
||||
} else {
|
||||
lcd.setTextDatum(lgfx::top_left);
|
||||
lcd.drawString(text, x, y);
|
||||
}
|
||||
}
|
||||
// ── value ───────────────────────────────────────────────────────────
|
||||
else if (strcmp(type, "value") == 0) {
|
||||
const char* style = el["style"] | "plain";
|
||||
const char* label = el["label"] | "";
|
||||
const char* value = el["value"] | "";
|
||||
const char* unit = el["unit"] | "";
|
||||
const char* iconName = el["icon"] | "";
|
||||
int fontSize = el["font_size"] | 16;
|
||||
|
||||
if (strcmp(style, "card") == 0) {
|
||||
// ── card style ──────────────────────────────────────────────
|
||||
// Card background with rounded corners
|
||||
lcd.fillRoundRect(x, y, w, h, 4, LCD_CARD_BG);
|
||||
lcd.drawRoundRect(x, y, w, h, 4, LCD_ACCENT);
|
||||
|
||||
// Icon (text-based)
|
||||
if (strlen(iconName) > 0) {
|
||||
const char* ic = getIconChar(iconName);
|
||||
lcd.setTextSize(2.0f);
|
||||
lcd.setTextColor(LCD_ACCENT);
|
||||
lcd.setTextDatum(lgfx::top_left);
|
||||
lcd.drawString(ic, x + 6, y + 4);
|
||||
}
|
||||
|
||||
// Label
|
||||
lcd.setTextSize(1.0f);
|
||||
lcd.setTextColor(LCD_LABEL);
|
||||
lcd.setTextDatum(lgfx::top_left);
|
||||
lcd.drawString(label, x + 6, y + 24);
|
||||
|
||||
// Value + unit (large, right-aligned at bottom)
|
||||
float valSize = mapFontSizeLCD(fontSize);
|
||||
lcd.setTextSize(valSize);
|
||||
lcd.setTextColor(LCD_TEXT);
|
||||
|
||||
String valStr = String(value);
|
||||
if (strlen(unit) > 0) {
|
||||
valStr += " ";
|
||||
valStr += unit;
|
||||
}
|
||||
lcd.setTextDatum(lgfx::bottom_right);
|
||||
lcd.drawString(valStr.c_str(), x + w - 6, y + h - 4);
|
||||
|
||||
} else {
|
||||
// ── plain style ─────────────────────────────────────────────
|
||||
float sz = mapFontSizeLCD(fontSize);
|
||||
lcd.setTextSize(sz);
|
||||
lcd.setTextColor(LCD_TEXT);
|
||||
lcd.setTextDatum(lgfx::top_left);
|
||||
|
||||
String line;
|
||||
if (strlen(label) > 0) {
|
||||
line += label;
|
||||
line += ": ";
|
||||
}
|
||||
line += value;
|
||||
if (strlen(unit) > 0) {
|
||||
line += " ";
|
||||
line += unit;
|
||||
}
|
||||
lcd.drawString(line.c_str(), x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+3403
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 IoBroker from './pages/IoBroker';
|
||||
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="/iobroker" element={<ProtectedRoute><IoBroker /></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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,208 @@
|
||||
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;
|
||||
font_weight: string;
|
||||
text_align: string;
|
||||
style: string;
|
||||
static_text: string | null;
|
||||
datapoint_id: number | null;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
interface DisplayApiElement {
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
font_size?: number;
|
||||
font_weight?: string;
|
||||
text_align?: string;
|
||||
style?: string;
|
||||
icon?: string;
|
||||
text?: string;
|
||||
label?: string;
|
||||
value?: string | null;
|
||||
unit?: string | null;
|
||||
}
|
||||
|
||||
interface DisplayApiResponse {
|
||||
display: { id: number; name: string; width: number; height: number; refresh_sec: number };
|
||||
layout: { type: string };
|
||||
elements: DisplayApiElement[];
|
||||
}
|
||||
|
||||
interface EpaperPreviewProps {
|
||||
layoutId: number;
|
||||
elements: LayoutElement[];
|
||||
layoutWidth?: number;
|
||||
layoutHeight?: number;
|
||||
}
|
||||
|
||||
const SCALE = 0.6;
|
||||
|
||||
const ICON_SYMBOLS: Record<string, string> = {
|
||||
thermometer: '🌡',
|
||||
droplet: '💧',
|
||||
wind: '🌬',
|
||||
pressure: '⏱',
|
||||
sun: '☀',
|
||||
bolt: '⚡',
|
||||
gauge: '📊',
|
||||
wifi: '📶',
|
||||
battery: '🔋',
|
||||
check: '✅',
|
||||
warning: '⚠',
|
||||
clock: '🕐',
|
||||
};
|
||||
|
||||
export default function EpaperPreview({ layoutId, elements, layoutWidth = 800, layoutHeight = 480 }: EpaperPreviewProps) {
|
||||
const [liveElements, setLiveElements] = useState<DisplayApiElement[] | null>(null);
|
||||
const [displayId, setDisplayId] = useState<number | null>(null);
|
||||
|
||||
// Find a display that uses this layout
|
||||
useEffect(() => {
|
||||
api.get<any[]>('/api/displays')
|
||||
.then((displays) => {
|
||||
const d = displays.find((disp: any) => disp.layout_id === layoutId);
|
||||
setDisplayId(d ? d.id : null);
|
||||
})
|
||||
.catch(() => setDisplayId(null));
|
||||
}, [layoutId]);
|
||||
|
||||
// Reload display data whenever elements change OR displayId is found
|
||||
useEffect(() => {
|
||||
if (!displayId) { setLiveElements(null); return; }
|
||||
api.get<DisplayApiResponse>(`/api/display/${displayId}`)
|
||||
.then((data) => setLiveElements(data.elements))
|
||||
.catch(() => setLiveElements(null));
|
||||
}, [displayId, elements]); // elements is a new array ref after each selectLayout()
|
||||
|
||||
const w = layoutWidth * SCALE;
|
||||
const h = layoutHeight * SCALE;
|
||||
|
||||
const renderElements = liveElements || [];
|
||||
const isLive = liveElements !== null;
|
||||
|
||||
// If no display linked and no elements, show hint
|
||||
if (!isLive && elements.length === 0) {
|
||||
return (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded border text-sm text-gray-400 text-center">
|
||||
Fuege Elemente hinzu um die Vorschau zu sehen.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build render list: use live data if available, otherwise use raw elements
|
||||
const items: DisplayApiElement[] = isLive
|
||||
? renderElements
|
||||
: elements.map((el) => ({
|
||||
type: el.type,
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
width: el.width ?? undefined,
|
||||
height: el.height ?? undefined,
|
||||
font_size: el.font_size,
|
||||
font_weight: el.font_weight,
|
||||
text_align: el.text_align,
|
||||
style: el.style,
|
||||
icon: el.icon ?? undefined,
|
||||
text: el.type === 'label' ? (el.static_text || '') : undefined,
|
||||
label: el.type === 'value' ? `DP #${el.datapoint_id || '?'}` : undefined,
|
||||
value: el.type === 'value' ? '—' : undefined,
|
||||
unit: '',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Vorschau ({layoutWidth}x{layoutHeight})
|
||||
{isLive ? ' — Live-Daten' : ' — Layout-Struktur (kein Display zugewiesen)'}
|
||||
</p>
|
||||
<div style={{
|
||||
position: 'relative', width: w, height: h,
|
||||
backgroundColor: '#f0f0e8', border: '3px solid #222',
|
||||
borderRadius: 4, overflow: 'hidden',
|
||||
boxShadow: '2px 2px 8px rgba(0,0,0,0.25)',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
}}>
|
||||
{items.map((el, idx) => {
|
||||
const left = el.x * SCALE;
|
||||
const top = el.y * SCALE;
|
||||
const fontSize = Math.max(7, (el.font_size || 24) * SCALE);
|
||||
const isBold = el.font_weight === 'bold';
|
||||
|
||||
if (el.type === 'line') {
|
||||
return <div key={idx} style={{
|
||||
position: 'absolute', left, top,
|
||||
width: (el.width || layoutWidth) * SCALE,
|
||||
height: Math.max(1, (el.height || 2) * SCALE),
|
||||
backgroundColor: '#000',
|
||||
}} />;
|
||||
}
|
||||
|
||||
if (el.type === 'rect') {
|
||||
return <div key={idx} style={{
|
||||
position: 'absolute', left, top,
|
||||
width: (el.width || 100) * SCALE,
|
||||
height: (el.height || 50) * SCALE,
|
||||
border: '1px solid #000',
|
||||
}} />;
|
||||
}
|
||||
|
||||
if (el.type === 'label') {
|
||||
const align = el.text_align || 'left';
|
||||
return <div key={idx} style={{
|
||||
position: 'absolute', left, top,
|
||||
fontSize, fontWeight: isBold ? 'bold' : 'normal',
|
||||
color: '#000', whiteSpace: 'nowrap',
|
||||
...(align === 'center' ? { width: w, left: 0, textAlign: 'center' } : {}),
|
||||
...(align === 'right' ? { right: 0, textAlign: 'right' } : {}),
|
||||
}}>
|
||||
{el.text || ''}
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (el.type === 'value') {
|
||||
const isCard = el.style === 'card';
|
||||
const elW = (el.width || 370) * SCALE;
|
||||
const elH = (el.height || 90) * SCALE;
|
||||
|
||||
if (isCard) {
|
||||
return <div key={idx} style={{
|
||||
position: 'absolute', left, top,
|
||||
width: elW, height: elH,
|
||||
border: '1px solid #aaa', borderRadius: 4 * SCALE,
|
||||
background: '#e8e8e0', padding: 4 * SCALE,
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ fontSize: fontSize * 0.45, color: '#555', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{el.icon ? (ICON_SYMBOLS[el.icon] || el.icon) + ' ' : ''}{el.label || ''}
|
||||
</div>
|
||||
<div style={{ fontSize, fontWeight: 'bold', textAlign: 'right' }}>
|
||||
{el.value ?? '—'} <span style={{ fontSize: fontSize * 0.5, color: '#555' }}>{el.unit || ''}</span>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div key={idx} style={{
|
||||
position: 'absolute', left, top, fontSize, color: '#000', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{el.label}: <strong>{el.value ?? '—'}</strong> {el.unit || ''}
|
||||
</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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: '/iobroker', label: 'ioBroker' },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
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('');
|
||||
|
||||
function loadAll() {
|
||||
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((e) => setError((e as Error).message));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
const interval = setInterval(loadAll, 10000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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();
|
||||
const interval = setInterval(load, 10000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm('Datenpunkt wirklich loeschen?')) return;
|
||||
try {
|
||||
await api.del(`/api/datapoints/${id}`);
|
||||
load();
|
||||
} 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>
|
||||
<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">
|
||||
{datapoints.length === 0 && (
|
||||
<tr><td colSpan={7} 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>
|
||||
<td className="px-4 py-2">
|
||||
<button onClick={() => handleDelete(dp.id)} className="text-red-400 hover:text-red-600 text-xs">Loeschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface Display {
|
||||
id: number;
|
||||
name: string;
|
||||
display_type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
layout_id: number | null;
|
||||
api_token: string | null;
|
||||
last_seen: string | null;
|
||||
refresh_sec: number;
|
||||
}
|
||||
|
||||
interface Layout { id: number; name: string }
|
||||
interface DisplayType { width: number; height: number; label: string }
|
||||
|
||||
const emptyForm = { name: '', display_type: 'epaper-7.5', layout_id: '', refresh_sec: '300' };
|
||||
|
||||
export default function Displays() {
|
||||
const [displays, setDisplays] = useState<Display[]>([]);
|
||||
const [layouts, setLayouts] = useState<Layout[]>([]);
|
||||
const [types, setTypes] = useState<Record<string, DisplayType>>({});
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [d, l, t] = await Promise.all([
|
||||
api.get<Display[]>('/api/displays'),
|
||||
api.get<Layout[]>('/api/layouts'),
|
||||
api.get<Record<string, DisplayType>>('/api/displays/types'),
|
||||
]);
|
||||
setDisplays(d);
|
||||
setLayouts(l);
|
||||
setTypes(t);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleAdd(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
const dtype = types[form.display_type];
|
||||
await api.post('/api/displays', {
|
||||
name: form.name,
|
||||
display_type: form.display_type,
|
||||
width: dtype?.width ?? 800,
|
||||
height: dtype?.height ?? 480,
|
||||
layout_id: form.layout_id ? parseInt(form.layout_id) : null,
|
||||
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 handleTypeChange(id: number, display_type: string) {
|
||||
try {
|
||||
const dtype = types[display_type];
|
||||
await api.put(`/api/displays/${id}`, {
|
||||
display_type,
|
||||
width: dtype?.width ?? 800,
|
||||
height: dtype?.height ?? 480,
|
||||
});
|
||||
load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
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">Display-Typ</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Layout</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">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={7} 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.display_type}
|
||||
onChange={(e) => handleTypeChange(d.id, e.target.value)}
|
||||
>
|
||||
{Object.entries(types).map(([key, t]) => (
|
||||
<option key={key} value={key}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-2 text-xs text-gray-400">{d.width}x{d.height}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<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">Display-Typ</label>
|
||||
<select
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
value={form.display_type}
|
||||
onChange={(e) => setForm({ ...form, display_type: e.target.value })}
|
||||
>
|
||||
{Object.entries(types).map(([key, t]) => (
|
||||
<option key={key} value={key}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
|
||||
interface IoBrokerSource {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
enabled: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface IoBrokerState {
|
||||
id: number;
|
||||
source_id: number;
|
||||
state_id: string;
|
||||
selected: number;
|
||||
last_value: string | null;
|
||||
last_ts: number | null;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
interface StatesResponse {
|
||||
states: IoBrokerState[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const emptyForm = { name: '', host: '', port: '8087' };
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function IoBroker() {
|
||||
const [sources, setSources] = useState<IoBrokerSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<IoBrokerSource | null>(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState('');
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [discoveryMsg, setDiscoveryMsg] = useState('');
|
||||
|
||||
// States panel state
|
||||
const [statesData, setStatesData] = useState<StatesResponse>({ states: [], total: 0 });
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [showSelected, setShowSelected] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
setSources(await api.get<IoBrokerSource[]>('/api/iobroker/sources'));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const loadStates = useCallback(async () => {
|
||||
if (!selectedSource) return;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (showSelected) params.set('selected', '1');
|
||||
params.set('limit', String(PAGE_SIZE));
|
||||
params.set('offset', String(page * PAGE_SIZE));
|
||||
const result = await api.get<StatesResponse>(
|
||||
`/api/iobroker/sources/${selectedSource.id}/states?${params}`
|
||||
);
|
||||
setStatesData(result);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [selectedSource, search, showSelected, page]);
|
||||
|
||||
useEffect(() => { loadSources(); }, []);
|
||||
useEffect(() => { loadStates(); }, [loadStates]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { setSearch(searchInput); setPage(0); }, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
async function handleAdd(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/api/iobroker/sources', { ...form, port: parseInt(form.port) });
|
||||
setForm(emptyForm);
|
||||
loadSources();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm('Quelle wirklich loeschen?')) return;
|
||||
try {
|
||||
await api.del(`/api/iobroker/sources/${id}`);
|
||||
if (selectedSource?.id === id) {
|
||||
setSelectedSource(null);
|
||||
setStatesData({ states: [], total: 0 });
|
||||
}
|
||||
loadSources();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(source: IoBrokerSource) {
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await api.post<{ success: boolean }>(
|
||||
`/api/iobroker/sources/${source.id}/test`
|
||||
);
|
||||
setTestResult(result.success ? 'Verbindung erfolgreich' : 'Nicht erreichbar');
|
||||
} catch (e) {
|
||||
setTestResult('Fehler: ' + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscover(source: IoBrokerSource) {
|
||||
setDiscoveryMsg('Discovery laeuft...');
|
||||
try {
|
||||
await api.post(`/api/iobroker/sources/${source.id}/discover`);
|
||||
setDiscoveryMsg('Discovery gestartet. States werden geladen...');
|
||||
setTimeout(() => {
|
||||
loadStates();
|
||||
setDiscoveryMsg('');
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
setDiscoveryMsg('Fehler: ' + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectState(state: IoBrokerState, selected: boolean) {
|
||||
try {
|
||||
await api.put(`/api/iobroker/states/${state.id}`, { selected: selected ? 1 : 0 });
|
||||
loadStates();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectSource(source: IoBrokerSource) {
|
||||
setSelectedSource(source);
|
||||
setPage(0);
|
||||
setSearch('');
|
||||
setSearchInput('');
|
||||
setShowSelected(false);
|
||||
setDiscoveryMsg('');
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(statesData.total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-6">ioBroker Quellen</h2>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
|
||||
{error}
|
||||
<button className="ml-2 font-bold" onClick={() => setError('')}>x</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sources table */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
|
||||
<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">
|
||||
{sources.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">
|
||||
Keine ioBroker Quellen konfiguriert
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sources.map((s) => (
|
||||
<tr
|
||||
key={s.id}
|
||||
className={`hover:bg-gray-50 cursor-pointer ${selectedSource?.id === s.id ? 'bg-sky-50 ring-1 ring-inset ring-sky-200' : ''}`}
|
||||
onClick={() => handleSelectSource(s)}
|
||||
>
|
||||
<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}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={s.status} /></td>
|
||||
<td className="px-4 py-2 flex gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleTest(s); }}
|
||||
className="text-sky-500 hover:text-sky-700 text-xs"
|
||||
>
|
||||
Testen
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSelectSource(s); handleDiscover(s); }}
|
||||
className="text-indigo-500 hover:text-indigo-700 text-xs"
|
||||
>
|
||||
Discovery
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(s.id); }}
|
||||
className="text-red-500 hover:text-red-700 text-xs"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded text-blue-700 text-sm">
|
||||
{testResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-8">
|
||||
<h3 className="font-medium text-gray-700 mb-4">Quelle hinzufuegen</h3>
|
||||
<form onSubmit={handleAdd} className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Host *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
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 })}
|
||||
/>
|
||||
</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 className="col-span-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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* States panel */}
|
||||
{selectedSource && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">
|
||||
States — {selectedSource.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{statesData.total} States gefunden
|
||||
</p>
|
||||
|
||||
{discoveryMsg && (
|
||||
<p className="text-sm text-indigo-600 mb-3">{discoveryMsg}</p>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-3 mb-4 flex-wrap items-center">
|
||||
<input
|
||||
placeholder="States suchen..."
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-72"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showSelected}
|
||||
onChange={(e) => { setShowSelected(e.target.checked); setPage(0); }}
|
||||
className="accent-green-500"
|
||||
/>
|
||||
Nur ausgewaehlte
|
||||
</label>
|
||||
</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-3 py-2 text-left text-xs font-medium text-gray-500 w-10">Aktiv</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">State ID</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 w-48">Letzter Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{statesData.states.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-6 text-center text-gray-400">
|
||||
{statesData.total === 0
|
||||
? 'Keine States gefunden. Starte Discovery.'
|
||||
: 'Keine States fuer diese Suche.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{statesData.states.map((s) => (
|
||||
<tr key={s.id} className={`hover:bg-gray-50 ${s.selected ? 'bg-green-50' : ''}`}>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!s.selected}
|
||||
onChange={(e) => handleSelectState(s, e.target.checked)}
|
||||
className="w-4 h-4 accent-green-500 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-gray-700 truncate max-w-md" title={s.state_id}>
|
||||
{s.state_id}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-xs text-gray-500 truncate max-w-48" title={s.last_value ?? ''}>
|
||||
{s.last_value ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 mt-3 text-sm text-gray-600">
|
||||
<button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 rounded border border-gray-300 disabled:opacity-30 hover:bg-gray-50"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<span>Seite {page + 1} von {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 rounded border border-gray-300 disabled:opacity-30 hover:bg-gray-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
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;
|
||||
font_weight: string;
|
||||
text_align: string;
|
||||
style: string;
|
||||
static_text: string | null;
|
||||
datapoint_id: number | null;
|
||||
icon: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface LayoutWithElements extends Layout {
|
||||
elements: LayoutElement[];
|
||||
}
|
||||
|
||||
interface Datapoint {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
unit: string | null;
|
||||
}
|
||||
|
||||
const ICONS = ['', 'thermometer', 'droplet', 'wind', 'pressure', 'sun', 'bolt', 'gauge', 'wifi', 'battery', 'check', 'warning', 'clock'];
|
||||
|
||||
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 [error, setError] = useState('');
|
||||
|
||||
async function loadLayouts() {
|
||||
try {
|
||||
setLayouts(await api.get<Layout[]>('/api/layouts'));
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function loadDatapoints() {
|
||||
try {
|
||||
setDatapoints(await api.get<Datapoint[]>('/api/datapoints'));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function selectLayout(id: number) {
|
||||
try {
|
||||
setSelected(await api.get<LayoutWithElements>(`/api/layouts/${id}`));
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
useEffect(() => { loadLayouts(); loadDatapoints(); }, []);
|
||||
|
||||
async function handleAddLayout(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
const res = await api.post<{ id: number }>('/api/layouts', { name: newName.trim() });
|
||||
setNewName('');
|
||||
loadLayouts();
|
||||
selectLayout(res.id);
|
||||
} 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(type: string) {
|
||||
if (!selected) return;
|
||||
const sortOrder = selected.elements.length;
|
||||
const defaults: Record<string, any> = {
|
||||
value: { type: 'value', x: 20, y: 70 + sortOrder * 100, width: 370, height: 90, font_size: 32, font_weight: 'bold', style: 'card', sort_order: sortOrder },
|
||||
label: { type: 'label', x: 400, y: 15, font_size: 28, font_weight: 'bold', text_align: 'center', static_text: 'Titel', sort_order: sortOrder },
|
||||
line: { type: 'line', x: 10, y: 55, width: 780, height: 2, sort_order: sortOrder },
|
||||
rect: { type: 'rect', x: 10, y: 10, width: 100, height: 50, sort_order: sortOrder },
|
||||
};
|
||||
try {
|
||||
await api.post(`/api/layouts/${selected.id}/elements`, defaults[type] || defaults.value);
|
||||
selectLayout(selected.id);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function updateElement(elId: number, field: string, value: any) {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await api.put(`/api/layouts/${selected.id}/elements/${elId}`, { [field]: value });
|
||||
selectLayout(selected.id);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function updateDatapointLabel(dpId: number, label: string) {
|
||||
try {
|
||||
await api.put(`/api/datapoints/${dpId}`, { label });
|
||||
loadDatapoints();
|
||||
if (selected) selectLayout(selected.id);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function updateDatapointUnit(dpId: number, unit: string) {
|
||||
try {
|
||||
await api.put(`/api/datapoints/${dpId}`, { unit });
|
||||
loadDatapoints();
|
||||
if (selected) 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); }
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!selected) return;
|
||||
window.open(`/api/layouts/${selected.id}/export`, '_blank');
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const result = await api.post<any>('/api/layouts/import', data);
|
||||
loadLayouts();
|
||||
selectLayout(result.id);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function NumInput({ value, onChange, label, w }: { value: number; onChange: (v: number) => void; label: string; w?: string }) {
|
||||
return (
|
||||
<div className={w || 'w-16'}>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
<input type="number" className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
defaultValue={value} onBlur={(e) => { const v = parseInt(e.target.value); if (!isNaN(v) && v !== value) onChange(v); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}<button className="ml-2 text-red-400" onClick={() => setError('')}>x</button></div>}
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Left: layout list */}
|
||||
<div className="w-56 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</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 mb-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>
|
||||
<button onClick={handleImport} className="w-full text-xs text-gray-500 hover:text-sky-600 border border-dashed border-gray-300 rounded py-1.5 hover:border-sky-400">
|
||||
Layout importieren (.json)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: element editor */}
|
||||
{selected && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-sky-600">{selected.name}</span>
|
||||
<span className="text-xs text-gray-400 ml-2">({selected.width}x{selected.height})</span>
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleAddElement('value')} className="bg-green-500 hover:bg-green-600 text-white text-xs px-3 py-1.5 rounded">+ Wert</button>
|
||||
<button onClick={() => handleAddElement('label')} className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-3 py-1.5 rounded">+ Label</button>
|
||||
<button onClick={() => handleAddElement('line')} className="bg-gray-500 hover:bg-gray-600 text-white text-xs px-3 py-1.5 rounded">+ Linie</button>
|
||||
<button onClick={handleExport} className="border border-gray-300 text-gray-600 hover:bg-gray-100 text-xs px-3 py-1.5 rounded">Exportieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Element list — editable */}
|
||||
<div className="space-y-2 mb-6">
|
||||
{selected.elements.length === 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center text-gray-400 text-sm">
|
||||
Noch keine Elemente. Klicke oben auf "+ Wert" oder "+ Label" um ein Element hinzuzufuegen.
|
||||
</div>
|
||||
)}
|
||||
{selected.elements.map((el) => {
|
||||
const dp = datapoints.find((d) => d.id === el.datapoint_id);
|
||||
return (
|
||||
<div key={el.id} className="bg-white rounded-lg border border-gray-200 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded ${
|
||||
el.type === 'value' ? 'bg-green-100 text-green-700' :
|
||||
el.type === 'label' ? 'bg-blue-100 text-blue-700' :
|
||||
el.type === 'line' ? 'bg-gray-100 text-gray-600' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>{el.type.toUpperCase()}</span>
|
||||
{el.type === 'value' && dp && (
|
||||
<span className="text-xs text-gray-500">{dp.label || dp.name} = <strong>{dp.value ?? '—'}</strong> {dp.unit || ''}</span>
|
||||
)}
|
||||
{el.type === 'label' && (
|
||||
<span className="text-xs text-gray-500 italic">"{el.static_text}"</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => handleDeleteElement(el.id)} className="text-red-400 hover:text-red-600 text-xs">Loeschen</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
{/* Position */}
|
||||
<NumInput label="X" value={el.x} onChange={(v) => updateElement(el.id, 'x', v)} />
|
||||
<NumInput label="Y" value={el.y} onChange={(v) => updateElement(el.id, 'y', v)} />
|
||||
|
||||
{/* Size (for value, line, rect) */}
|
||||
{(el.type === 'value' || el.type === 'line' || el.type === 'rect') && (
|
||||
<>
|
||||
<NumInput label="Breite" value={el.width ?? 370} onChange={(v) => updateElement(el.id, 'width', v)} />
|
||||
<NumInput label="Hoehe" value={el.height ?? 90} onChange={(v) => updateElement(el.id, 'height', v)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Font size (for value, label) */}
|
||||
{(el.type === 'value' || el.type === 'label') && (
|
||||
<NumInput label="Schrift" value={el.font_size} onChange={(v) => updateElement(el.id, 'font_size', v)} />
|
||||
)}
|
||||
|
||||
{/* Font weight */}
|
||||
{(el.type === 'value' || el.type === 'label') && (
|
||||
<div className="w-20">
|
||||
<div className="text-[10px] text-gray-400">Gewicht</div>
|
||||
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
value={el.font_weight} onChange={(e) => updateElement(el.id, 'font_weight', e.target.value)}>
|
||||
<option value="normal">normal</option>
|
||||
<option value="bold">bold</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text align (for label) */}
|
||||
{el.type === 'label' && (
|
||||
<div className="w-20">
|
||||
<div className="text-[10px] text-gray-400">Ausrichtung</div>
|
||||
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
value={el.text_align} onChange={(e) => updateElement(el.id, 'text_align', e.target.value)}>
|
||||
<option value="left">links</option>
|
||||
<option value="center">mitte</option>
|
||||
<option value="right">rechts</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style (for value) */}
|
||||
{el.type === 'value' && (
|
||||
<div className="w-20">
|
||||
<div className="text-[10px] text-gray-400">Stil</div>
|
||||
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
value={el.style} onChange={(e) => updateElement(el.id, 'style', e.target.value)}>
|
||||
<option value="plain">plain</option>
|
||||
<option value="card">card</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon (for value) */}
|
||||
{el.type === 'value' && (
|
||||
<div className="w-28">
|
||||
<div className="text-[10px] text-gray-400">Icon</div>
|
||||
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
value={el.icon || ''} onChange={(e) => updateElement(el.id, 'icon', e.target.value || null)}>
|
||||
{ICONS.map((i) => <option key={i} value={i}>{i || '— kein Icon —'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Datapoint (for value) */}
|
||||
{el.type === 'value' && (
|
||||
<div className="w-48">
|
||||
<div className="text-[10px] text-gray-400">Datenpunkt</div>
|
||||
<select className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
value={el.datapoint_id || ''} onChange={(e) => updateElement(el.id, 'datapoint_id', e.target.value ? parseInt(e.target.value) : null)}>
|
||||
<option value="">— keiner —</option>
|
||||
{datapoints.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.label || d.name}{d.value ? ` (${d.value}${d.unit ? ' ' + d.unit : ''})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editable datapoint label + unit (for value with assigned datapoint) */}
|
||||
{el.type === 'value' && dp && (
|
||||
<>
|
||||
<div className="w-40">
|
||||
<div className="text-[10px] text-gray-400">Anzeigename</div>
|
||||
<input className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
defaultValue={dp.label || dp.name}
|
||||
onBlur={(e) => { if (e.target.value !== (dp.label || dp.name)) updateDatapointLabel(dp.id, e.target.value); }}
|
||||
placeholder="Anzeigename" />
|
||||
</div>
|
||||
<div className="w-16">
|
||||
<div className="text-[10px] text-gray-400">Einheit</div>
|
||||
<input className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
defaultValue={dp.unit || ''}
|
||||
onBlur={(e) => { if (e.target.value !== (dp.unit || '')) updateDatapointUnit(dp.id, e.target.value); }}
|
||||
placeholder="z.B. W" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Static text (for label) */}
|
||||
{el.type === 'label' && (
|
||||
<div className="flex-1 min-w-48">
|
||||
<div className="text-[10px] text-gray-400">Text</div>
|
||||
<input className="w-full border border-gray-300 rounded px-1.5 py-1 text-xs"
|
||||
defaultValue={el.static_text || ''} onBlur={(e) => updateElement(el.id, 'static_text', e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* E-paper preview */}
|
||||
<h4 className="text-sm font-medium text-gray-600 mb-2">Vorschau ({selected.width}x{selected.height})</h4>
|
||||
<EpaperPreview
|
||||
layoutId={selected.id}
|
||||
elements={selected.elements}
|
||||
layoutWidth={selected.width}
|
||||
layoutHeight={selected.height}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useState, useCallback } 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;
|
||||
selected: number;
|
||||
ignored: number;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
interface TopicsResponse {
|
||||
topics: Topic[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export default function MqttTopics() {
|
||||
const { brokerId } = useParams<{ brokerId: string }>();
|
||||
const [data, setData] = useState<TopicsResponse>({ topics: [], total: 0 });
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [showSelected, setShowSelected] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [prefix, setPrefix] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [discoveryMsg, setDiscoveryMsg] = useState('');
|
||||
const [editPaths, setEditPaths] = useState<Record<number, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (showSelected) params.set('selected', '1');
|
||||
params.set('limit', String(PAGE_SIZE));
|
||||
params.set('offset', String(page * PAGE_SIZE));
|
||||
const result = await api.get<TopicsResponse>(`/api/mqtt/brokers/${brokerId}/topics?${params}`);
|
||||
setData(result);
|
||||
const paths: Record<number, string> = {};
|
||||
result.topics.forEach((t) => { paths[t.id] = t.json_path ?? ''; });
|
||||
setEditPaths((prev) => ({ ...prev, ...paths }));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [brokerId, search, showSelected, page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { setSearch(searchInput); setPage(0); }, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
async function handleDiscover() {
|
||||
setDiscoveryMsg('');
|
||||
try {
|
||||
await api.post(`/api/mqtt/brokers/${brokerId}/topics/discover`, { prefix: prefix || null });
|
||||
setDiscoveryMsg('Discovery laeuft 30s...');
|
||||
const interval = setInterval(() => load(), 5000);
|
||||
setTimeout(() => { clearInterval(interval); setDiscoveryMsg(''); load(); }, 32000);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(id: number, selected: boolean) {
|
||||
try {
|
||||
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { selected: selected ? 1 : 0 });
|
||||
load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIgnore(id: number) {
|
||||
try {
|
||||
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { ignored: 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 totalPages = Math.ceil(data.total / PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-2">MQTT Topics</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">Broker #{brokerId} — {data.total} Topics gefunden</p>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}<button className="ml-2" onClick={() => setError('')}>x</button></div>}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-3 mb-4 flex-wrap items-center">
|
||||
<input
|
||||
placeholder="Topics suchen (z.B. grid, battery, pv)..."
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-72"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={showSelected} onChange={(e) => { setShowSelected(e.target.checked); setPage(0); }} className="accent-green-500" />
|
||||
Nur ausgewaehlte
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<input
|
||||
placeholder="Discovery-Prefix (z.B. N/)"
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-48"
|
||||
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">
|
||||
Discovery
|
||||
</button>
|
||||
</div>
|
||||
{discoveryMsg && <p className="text-sm text-indigo-600 mb-3">{discoveryMsg}</p>}
|
||||
|
||||
{/* Topic list */}
|
||||
<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-3 py-2 text-left text-xs font-medium text-gray-500 w-10">Aktiv</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Topic</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 w-48">Letzter Wert</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 w-36">JSON-Pfad</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 w-20">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.topics.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
||||
{search ? 'Keine Topics fuer diese Suche' : 'Keine Topics gefunden. Starte Discovery.'}
|
||||
</td></tr>
|
||||
)}
|
||||
{data.topics.map((t) => (
|
||||
<tr key={t.id} className={`hover:bg-gray-50 ${t.selected ? 'bg-green-50' : ''}`}>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<input type="checkbox" checked={!!t.selected}
|
||||
onChange={(e) => handleSelect(t.id, e.target.checked)}
|
||||
className="w-4 h-4 accent-green-500 cursor-pointer" />
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs text-gray-700 truncate max-w-md" title={t.topic}>{t.topic}</td>
|
||||
<td className="px-3 py-1.5 text-xs text-gray-500 truncate max-w-48" title={t.last_payload || ''}>{t.last_payload ?? '—'}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<input className="border border-gray-300 rounded px-2 py-0.5 text-xs w-full"
|
||||
value={editPaths[t.id] ?? ''}
|
||||
onChange={(e) => setEditPaths({ ...editPaths, [t.id]: e.target.value })}
|
||||
onBlur={() => handleJsonPathBlur(t.id)}
|
||||
placeholder="$.value" />
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<button onClick={() => handleIgnore(t.id)} className="text-xs text-orange-400 hover:text-orange-600">Ignorieren</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 mt-3 text-sm text-gray-600">
|
||||
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
|
||||
className="px-3 py-1 rounded border border-gray-300 disabled:opacity-30 hover:bg-gray-50">Zurueck</button>
|
||||
<span>Seite {page + 1} von {totalPages}</span>
|
||||
<button onClick={() => setPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 rounded border border-gray-300 disabled:opacity-30 hover:bg-gray-50">Weiter</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' } },
|
||||
});
|
||||
Generated
+2
-577
@@ -8,8 +8,6 @@
|
||||
"name": "display-middleware",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"cors": "^2.8.6",
|
||||
@@ -20,7 +18,7 @@
|
||||
"pg": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.25",
|
||||
@@ -505,26 +503,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"make-dir": "^3.1.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"nopt": "^5.0.0",
|
||||
"npmlog": "^5.0.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.5",
|
||||
"tar": "^6.1.11"
|
||||
},
|
||||
"bin": {
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -944,20 +922,11 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
|
||||
"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
@@ -1264,12 +1233,6 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -1321,50 +1284,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
@@ -1378,40 +1297,6 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/are-we-there-yet": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
|
||||
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delegates": "^1.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/are-we-there-yet/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -1442,12 +1327,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -1468,20 +1347,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
|
||||
"integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "^1.0.11",
|
||||
"node-addon-api": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
@@ -1547,16 +1412,6 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/broker-factory": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz",
|
||||
@@ -1685,24 +1540,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1732,12 +1569,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
@@ -1774,12 +1605,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -1911,12 +1736,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -1995,12 +1814,6 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -2343,36 +2156,6 @@
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2397,27 +2180,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gauge": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
|
||||
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"aproba": "^1.0.3 || ^2.0.0",
|
||||
"color-support": "^1.1.2",
|
||||
"console-control-strings": "^1.0.0",
|
||||
"has-unicode": "^2.0.1",
|
||||
"object-assign": "^4.1.1",
|
||||
"signal-exit": "^3.0.0",
|
||||
"string-width": "^4.2.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wide-align": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/get-func-name": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
||||
@@ -2497,27 +2259,6 @@
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -2558,12 +2299,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -2602,42 +2337,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
|
||||
@@ -2680,17 +2379,6 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -2721,15 +2409,6 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
|
||||
@@ -2901,30 +2580,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -3026,18 +2681,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
@@ -3047,52 +2690,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
|
||||
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -3260,53 +2857,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-sqlite3-wasm": {
|
||||
"version": "0.8.55",
|
||||
"resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.55.tgz",
|
||||
"integrity": "sha512-C2m7JzZgKiv9XVZ1ts9oPmS56PCvyHeQffTOF2KNO2TVZzq5IW2s+NFeEZn+eP6bnAuD2We/O9cOJSjQVf7Xxw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "1"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-path": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
|
||||
@@ -3336,19 +2892,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/npmlog": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
||||
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"are-we-there-yet": "^2.0.0",
|
||||
"console-control-strings": "^1.1.0",
|
||||
"gauge": "^3.0.0",
|
||||
"set-blocking": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/number-allocator": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
|
||||
@@ -3465,15 +3008,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -3863,22 +3397,6 @@
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
@@ -4007,12 +3525,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -4121,12 +3633,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
@@ -4247,32 +3753,6 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-final-newline": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
|
||||
@@ -4384,24 +3864,6 @@
|
||||
"node": ">=6.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"chownr": "^2.0.0",
|
||||
"fs-minipass": "^2.0.0",
|
||||
"minipass": "^5.0.0",
|
||||
"minizlib": "^2.1.1",
|
||||
"mkdirp": "^1.0.3",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
@@ -4521,12 +3983,6 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -5283,22 +4739,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -5332,15 +4772,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wide-align": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-factory": {
|
||||
"version": "7.0.49",
|
||||
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz",
|
||||
@@ -5424,12 +4855,6 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"build": "tsc --noEmitOnError false",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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 iobrokerRouter from './routes/iobroker';
|
||||
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/iobroker', authMiddleware, iobrokerRouter);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getAllDatapoints,
|
||||
getDatapointById,
|
||||
updateDatapoint,
|
||||
deleteDatapoint,
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/datapoints/:id
|
||||
router.delete('/:id', (req, res): void => {
|
||||
try {
|
||||
const deleted = deleteDatapoint(parseInt(req.params.id));
|
||||
if (!deleted) { res.status(404).json({ error: 'Datapoint not found' }); return; }
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,63 @@
|
||||
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.prepare(
|
||||
`UPDATE displays SET last_seen = datetime('now') WHERE id = ?`
|
||||
).run(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;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Router } from 'express';
|
||||
import { getDb } from '../../db/index';
|
||||
|
||||
export interface Display {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
display_type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
layout_id: number | null;
|
||||
api_token: string | null;
|
||||
last_seen: string | null;
|
||||
refresh_sec: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Predefined display types with resolutions
|
||||
export const DISPLAY_TYPES: Record<string, { width: number; height: number; label: string }> = {
|
||||
'epaper-7.5': { width: 800, height: 480, label: 'Waveshare 7.5" E-Paper (800x480)' },
|
||||
'wt32-sc01': { width: 480, height: 320, label: 'WT32-SC01 LCD (480x320)' },
|
||||
'epaper-4.2': { width: 400, height: 300, label: 'Waveshare 4.2" E-Paper (400x300)' },
|
||||
'epaper-5.83': { width: 648, height: 480, label: 'Waveshare 5.83" E-Paper (648x480)' },
|
||||
'custom': { width: 800, height: 480, label: 'Benutzerdefiniert' },
|
||||
};
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/displays/types — available display types
|
||||
router.get('/types', (_req, res): void => {
|
||||
res.json(DISPLAY_TYPES);
|
||||
});
|
||||
|
||||
// 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, display_type, width, height, layout_id, api_token, refresh_sec } = req.body;
|
||||
if (!name) { res.status(400).json({ error: 'name is required' }); return; }
|
||||
|
||||
const dtype = display_type ?? 'epaper-7.5';
|
||||
const defaults = DISPLAY_TYPES[dtype] ?? DISPLAY_TYPES['epaper-7.5'];
|
||||
const w = width ?? defaults.width;
|
||||
const h = height ?? defaults.height;
|
||||
|
||||
const result = db.prepare(
|
||||
`INSERT INTO displays (name, description, display_type, width, height, layout_id, api_token, refresh_sec)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(name, description ?? null, dtype, w, h, 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, display_type, width, height, 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 (display_type !== undefined) { fields.push('display_type = ?'); values.push(display_type); }
|
||||
if (width !== undefined) { fields.push('width = ?'); values.push(width); }
|
||||
if (height !== undefined) { fields.push('height = ?'); values.push(height); }
|
||||
if (layout_id !== undefined) { fields.push('layout_id = ?'); values.push(layout_id); }
|
||||
if (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.prepare(`UPDATE displays SET ${fields.join(', ')} WHERE id = ?`).run(...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.prepare('DELETE FROM displays WHERE id = ?').run(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;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getAllSources,
|
||||
getSourceById,
|
||||
createSource,
|
||||
updateSource,
|
||||
deleteSource,
|
||||
testSource,
|
||||
discoverStates,
|
||||
getStatesForSource,
|
||||
updateState,
|
||||
} from '../../modules/iobroker/source-manager';
|
||||
import {
|
||||
createDatapointFromIoBroker,
|
||||
getDatapointByIoBrokerStateId,
|
||||
} from '../../modules/datapoints/datapoint-manager';
|
||||
import { startPolling } from '../../modules/iobroker/state-poller';
|
||||
import { getDb } from '../../db/index';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/iobroker/sources
|
||||
router.get('/sources', (_req, res): void => {
|
||||
try {
|
||||
res.json(getAllSources());
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/iobroker/sources
|
||||
router.post('/sources', (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(createSource(req.body));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/iobroker/sources/:id
|
||||
router.put('/sources/: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/iobroker/sources/:id
|
||||
router.delete('/sources/: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/iobroker/sources/:id/test
|
||||
router.post('/sources/:id/test', (req, res): void => {
|
||||
const id = parseInt(req.params.id);
|
||||
const source = getSourceById(id);
|
||||
if (!source) { res.status(404).json({ error: 'Source not found' }); return; }
|
||||
|
||||
testSource(source.host, source.port)
|
||||
.then((ok) => res.json({ success: ok }))
|
||||
.catch((err) => res.status(500).json({ error: (err as Error).message }));
|
||||
});
|
||||
|
||||
// POST /api/iobroker/sources/:id/discover
|
||||
router.post('/sources/:id/discover', (req, res): void => {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
res.json({ message: 'Discovery started', sourceId: id });
|
||||
|
||||
discoverStates(id).then((count) => {
|
||||
console.log(`ioBroker discovery for source ${id}: found ${count} states`);
|
||||
}).catch((err) => {
|
||||
console.error(`ioBroker discovery error for source ${id}:`, (err as Error).message);
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/iobroker/sources/:id/states?search=&selected=1&limit=50&offset=0
|
||||
router.get('/sources/:id/states', (req, res): void => {
|
||||
try {
|
||||
const sourceId = parseInt(req.params.id);
|
||||
const search = req.query.search as string | undefined;
|
||||
const selectedOnly = req.query.selected === '1';
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
res.json(getStatesForSource(sourceId, { search, selectedOnly, limit, offset }));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/iobroker/states/:id
|
||||
router.put('/states/:id', (req, res): void => {
|
||||
try {
|
||||
const stateRowId = parseInt(req.params.id);
|
||||
const stateRow = getDb()
|
||||
.prepare('SELECT * FROM iobroker_states WHERE id = ?')
|
||||
.get(stateRowId) as { id: number; source_id: number; state_id: string; selected: number } | undefined;
|
||||
|
||||
if (!stateRow) { res.status(404).json({ error: 'State not found' }); return; }
|
||||
|
||||
const updated = updateState(stateRowId, req.body);
|
||||
|
||||
// If selecting a state, auto-create a datapoint and ensure poller is running
|
||||
if (req.body.selected === 1 || req.body.selected === true) {
|
||||
const existing = getDatapointByIoBrokerStateId(stateRow.state_id);
|
||||
if (!existing) {
|
||||
createDatapointFromIoBroker(stateRow.source_id, stateRow.state_id, stateRow.state_id);
|
||||
}
|
||||
startPolling(stateRow.source_id);
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,209 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Export / Import ----
|
||||
|
||||
// GET /api/layouts/:id/export — export layout with elements as JSON file
|
||||
router.get('/:id/export', (req, res): void => {
|
||||
try {
|
||||
const layout = getLayoutById(parseInt(req.params.id));
|
||||
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return; }
|
||||
|
||||
const exportData = {
|
||||
_format: 'epaper-layout-v1',
|
||||
name: layout.name,
|
||||
type: layout.type,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
grid_cols: layout.grid_cols,
|
||||
grid_rows: layout.grid_rows,
|
||||
elements: layout.elements.map((el: any) => ({
|
||||
type: el.type,
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
width: el.width,
|
||||
height: el.height,
|
||||
font_size: el.font_size,
|
||||
font_weight: el.font_weight,
|
||||
text_align: el.text_align,
|
||||
icon: el.icon,
|
||||
style: el.style,
|
||||
static_text: el.static_text,
|
||||
grid_col: el.grid_col,
|
||||
grid_row: el.grid_row,
|
||||
sort_order: el.sort_order,
|
||||
// Store datapoint name for re-linking on import
|
||||
datapoint_name: el.datapoint_id
|
||||
? (require('../../modules/datapoints/datapoint-manager').getDatapointById(el.datapoint_id)?.name || null)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
|
||||
const filename = `layout-${layout.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`;
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json(exportData);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/layouts/import — import layout from JSON
|
||||
router.post('/import', (req, res): void => {
|
||||
try {
|
||||
const data = req.body;
|
||||
if (!data._format || !data._format.startsWith('epaper-layout')) {
|
||||
res.status(400).json({ error: 'Ungueltiges Layout-Format. Erwartet: epaper-layout-v1' });
|
||||
return;
|
||||
}
|
||||
if (!data.name || !data.elements) {
|
||||
res.status(400).json({ error: 'name und elements sind erforderlich' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create layout
|
||||
const layout = createLayout({
|
||||
name: data.name + ' (Import)',
|
||||
type: data.type || 'free',
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
grid_cols: data.grid_cols,
|
||||
grid_rows: data.grid_rows,
|
||||
});
|
||||
|
||||
const { getDatapointByName } = require('../../modules/datapoints/datapoint-manager');
|
||||
|
||||
// Add elements
|
||||
for (const el of data.elements) {
|
||||
// Try to re-link datapoint by name
|
||||
let datapointId = null;
|
||||
if (el.datapoint_name) {
|
||||
const dp = getDatapointByName ? getDatapointByName(el.datapoint_name) : null;
|
||||
if (dp) datapointId = dp.id;
|
||||
}
|
||||
|
||||
addElement(layout.id, {
|
||||
type: el.type || 'label',
|
||||
x: el.x || 0,
|
||||
y: el.y || 0,
|
||||
width: el.width || null,
|
||||
height: el.height || null,
|
||||
font_size: el.font_size || 24,
|
||||
font_weight: el.font_weight || 'normal',
|
||||
text_align: el.text_align || 'left',
|
||||
icon: el.icon || null,
|
||||
style: el.style || 'plain',
|
||||
static_text: el.static_text || null,
|
||||
grid_col: el.grid_col || null,
|
||||
grid_row: el.grid_row || null,
|
||||
sort_order: el.sort_order || 0,
|
||||
datapoint_id: datapointId,
|
||||
});
|
||||
}
|
||||
|
||||
const result = getLayoutById(layout.id);
|
||||
res.status(201).json(result);
|
||||
} 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getTopicsForBroker,
|
||||
updateTopic,
|
||||
startDiscovery,
|
||||
addManualTopic,
|
||||
} from '../../modules/mqtt/topic-discovery';
|
||||
import { createDatapointFromMqtt, getDatapointByTopicId } from '../../modules/datapoints/datapoint-manager';
|
||||
import { getConnection } from './../../modules/mqtt/broker-manager';
|
||||
import { getDb } from '../../db/index';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
// GET /api/mqtt/brokers/:brokerId/topics?search=grid&selected=1&limit=50&offset=0
|
||||
router.get('/', (req, res): void => {
|
||||
try {
|
||||
const brokerId = parseInt(req.params.brokerId);
|
||||
const search = req.query.search as string | undefined;
|
||||
const selectedOnly = req.query.selected === '1';
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
res.json(getTopicsForBroker(brokerId, { search, selectedOnly, limit, offset }));
|
||||
} 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 topicId = parseInt(req.params.id);
|
||||
const topicRow = getDb().prepare('SELECT * FROM mqtt_topics WHERE id = ?').get(topicId) as any;
|
||||
if (!topicRow) { res.status(404).json({ error: 'Topic not found' }); return; }
|
||||
|
||||
const topic = updateTopic(topicId, req.body);
|
||||
|
||||
// If selecting a topic, auto-create a datapoint
|
||||
if (req.body.selected === 1 || req.body.selected === true) {
|
||||
const existing = getDatapointByTopicId(topicId);
|
||||
if (!existing) {
|
||||
createDatapointFromMqtt(topicRow.broker_id, topicId, topicRow.topic);
|
||||
}
|
||||
// Subscribe to the topic on the broker connection
|
||||
const client = getConnection(topicRow.broker_id);
|
||||
if (client?.connected) client.subscribe(topicRow.topic);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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,25 +1,26 @@
|
||||
import { Database } from 'node-sqlite3-wasm';
|
||||
import Database from 'better-sqlite3';
|
||||
import { migrate001 } from './migrations/001-initial';
|
||||
import { migrate002 } from './migrations/002-iobroker';
|
||||
import { migrate003 } from './migrations/003-display-resolution';
|
||||
|
||||
let dbInstance: Database | null = null;
|
||||
let dbInstance: Database.Database | null = null;
|
||||
|
||||
export function initDb(path: string): Database {
|
||||
// node-sqlite3-wasm: pass undefined for in-memory DB
|
||||
const db = new Database(path === ':memory:' ? undefined : path);
|
||||
export function initDb(path: string): Database.Database {
|
||||
const db = new Database(path === ':memory:' ? ':memory:' : path);
|
||||
|
||||
// Enable WAL mode (file-based databases only, :memory: uses 'memory' mode)
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
// Enable foreign key constraints
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Run migrations
|
||||
migrate001(db);
|
||||
migrate002(db);
|
||||
migrate003(db);
|
||||
|
||||
dbInstance = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getDb(): Database {
|
||||
export function getDb(): Database.Database {
|
||||
if (!dbInstance) {
|
||||
throw new Error('Database not initialized. Call initDb() first.');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database } from 'node-sqlite3-wasm';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export function migrate001(db: Database): void {
|
||||
db.exec(`
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export function migrate002(db: Database.Database): void {
|
||||
// Create ioBroker sources table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS iobroker_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER DEFAULT 8087,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'disconnected',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iobroker_states (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL REFERENCES iobroker_sources(id) ON DELETE CASCADE,
|
||||
state_id TEXT NOT NULL,
|
||||
selected INTEGER DEFAULT 0,
|
||||
last_value TEXT,
|
||||
last_ts INTEGER,
|
||||
discovered_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Update datapoints CHECK constraint to allow 'iobroker'
|
||||
// SQLite does not support ALTER CHECK, so recreate the table if needed
|
||||
const row = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='datapoints'")
|
||||
.get() as { sql: string } | undefined;
|
||||
|
||||
if (row && !row.sql.includes('iobroker')) {
|
||||
db.exec(`
|
||||
CREATE TABLE datapoints_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
unit TEXT,
|
||||
value TEXT,
|
||||
status TEXT DEFAULT 'unknown',
|
||||
source_type TEXT NOT NULL CHECK(source_type IN ('mqtt','postgresql','iobroker')),
|
||||
source_id INTEGER NOT NULL,
|
||||
topic_id INTEGER,
|
||||
query_id INTEGER,
|
||||
transform TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (topic_id) REFERENCES mqtt_topics(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (query_id) REFERENCES pg_queries(id) ON DELETE SET NULL
|
||||
);
|
||||
INSERT INTO datapoints_new SELECT * FROM datapoints;
|
||||
DROP TABLE datapoints;
|
||||
ALTER TABLE datapoints_new RENAME TO datapoints;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export function migrate003(db: Database): void {
|
||||
// Check if column already exists
|
||||
const columns = db.pragma('table_info(displays)') as { name: string }[];
|
||||
if (columns.some((c) => c.name === 'display_type')) return;
|
||||
|
||||
db.exec(`
|
||||
ALTER TABLE displays ADD COLUMN display_type TEXT DEFAULT 'epaper-7.5' NOT NULL;
|
||||
ALTER TABLE displays ADD COLUMN width INTEGER DEFAULT 800 NOT NULL;
|
||||
ALTER TABLE displays ADD COLUMN height INTEGER DEFAULT 480 NOT NULL;
|
||||
`);
|
||||
}
|
||||
@@ -1,23 +1,57 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import authRouter from './api/routes/auth';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { initDb } from './db/index';
|
||||
import { createApp } from './api/index';
|
||||
import { connectAllEnabledBrokers } from './modules/mqtt/broker-manager';
|
||||
import { connectAllEnabledSources } from './modules/postgres/source-manager';
|
||||
import { startAllQueryTimers } from './modules/postgres/query-scheduler';
|
||||
import { startAllPollers } from './modules/iobroker/state-poller';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000;
|
||||
const DB_PATH = process.env.DB_PATH ?? './display.db';
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
async function main(): Promise<void> {
|
||||
// Initialize database
|
||||
const db = initDb(DB_PATH);
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
// Create default admin user if no users exist
|
||||
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.prepare(
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, ?)`
|
||||
).run('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();
|
||||
|
||||
// Start ioBroker state pollers for all enabled sources
|
||||
startAllPollers();
|
||||
|
||||
// Create and start Express app
|
||||
const app = createApp();
|
||||
|
||||
if (require.main === module) {
|
||||
app.listen(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();
|
||||
|
||||
@@ -54,10 +54,9 @@ export function updateDatapointValue(id: number, rawValue: string): Datapoint |
|
||||
|
||||
const transformed = applyTransform(rawValue, config);
|
||||
|
||||
db.run(
|
||||
`UPDATE datapoints SET value = ?, status = 'ok', updated_at = datetime('now') WHERE id = ?`,
|
||||
[transformed, id]
|
||||
);
|
||||
db.prepare(
|
||||
`UPDATE datapoints SET value = ?, status = 'ok', updated_at = datetime('now') WHERE id = ?`
|
||||
).run(transformed, id);
|
||||
|
||||
return db.prepare('SELECT * FROM datapoints WHERE id = ?').get(id) as Datapoint | undefined;
|
||||
}
|
||||
@@ -87,7 +86,7 @@ export function updateDatapoint(id: number, data: DatapointUpdate): Datapoint |
|
||||
if (fields.length === 0) return getDatapointById(id);
|
||||
|
||||
values.push(id);
|
||||
db.run(`UPDATE datapoints SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
db.prepare(`UPDATE datapoints SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return getDatapointById(id);
|
||||
}
|
||||
|
||||
@@ -97,11 +96,10 @@ export function createDatapointFromMqtt(
|
||||
topicPath: string
|
||||
): Datapoint {
|
||||
const db = getDb();
|
||||
const result = db.run(
|
||||
const result = db.prepare(
|
||||
`INSERT INTO datapoints (name, label, source_type, source_id, topic_id, enabled)
|
||||
VALUES (?, ?, 'mqtt', ?, ?, 1)`,
|
||||
[topicPath, topicPath, brokerId, topicId]
|
||||
);
|
||||
VALUES (?, ?, 'mqtt', ?, ?, 1)`
|
||||
).run(topicPath, topicPath, brokerId, topicId);
|
||||
const id = result.lastInsertRowid as number;
|
||||
return db.prepare('SELECT * FROM datapoints WHERE id = ?').get(id) as Datapoint;
|
||||
}
|
||||
@@ -112,15 +110,25 @@ export function createDatapointFromPg(
|
||||
queryName: string
|
||||
): Datapoint {
|
||||
const db = getDb();
|
||||
const result = db.run(
|
||||
const result = db.prepare(
|
||||
`INSERT INTO datapoints (name, label, source_type, source_id, query_id, enabled)
|
||||
VALUES (?, ?, 'postgresql', ?, ?, 1)`,
|
||||
[queryName, queryName, sourceId, queryId]
|
||||
);
|
||||
VALUES (?, ?, 'postgresql', ?, ?, 1)`
|
||||
).run(queryName, queryName, sourceId, queryId);
|
||||
const id = result.lastInsertRowid as number;
|
||||
return db.prepare('SELECT * FROM datapoints WHERE id = ?').get(id) as Datapoint;
|
||||
}
|
||||
|
||||
export function getDatapointByName(name: string): Datapoint | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT * FROM datapoints WHERE name = ?').get(name) as Datapoint | undefined;
|
||||
}
|
||||
|
||||
export function deleteDatapoint(id: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM datapoints WHERE id = ?').run(id);
|
||||
return (result.changes as number) > 0;
|
||||
}
|
||||
|
||||
export function getDatapointByTopicId(topicId: number): Datapoint | undefined {
|
||||
const db = getDb();
|
||||
return db
|
||||
@@ -134,3 +142,24 @@ export function getDatapointByQueryId(queryId: number): Datapoint | undefined {
|
||||
.prepare('SELECT * FROM datapoints WHERE query_id = ?')
|
||||
.get(queryId) as Datapoint | undefined;
|
||||
}
|
||||
|
||||
export function createDatapointFromIoBroker(
|
||||
sourceId: number,
|
||||
stateId: string,
|
||||
stateName: string
|
||||
): Datapoint {
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO datapoints (name, label, source_type, source_id, enabled)
|
||||
VALUES (?, ?, 'iobroker', ?, 1)`
|
||||
).run(stateId, stateName, sourceId);
|
||||
const id = result.lastInsertRowid as number;
|
||||
return db.prepare('SELECT * FROM datapoints WHERE id = ?').get(id) as Datapoint;
|
||||
}
|
||||
|
||||
export function getDatapointByIoBrokerStateId(stateId: string): Datapoint | undefined {
|
||||
const db = getDb();
|
||||
return db
|
||||
.prepare("SELECT * FROM datapoints WHERE source_type = 'iobroker' AND name = ?")
|
||||
.get(stateId) as Datapoint | undefined;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ interface RawDisplay {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
display_type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
layout_id: number | null;
|
||||
api_token: string | null;
|
||||
last_seen: string | null;
|
||||
@@ -98,8 +101,8 @@ export function buildDisplayJson(displayId: number): DisplayJson | null {
|
||||
display: {
|
||||
id: display.id,
|
||||
name: display.name,
|
||||
width: 800,
|
||||
height: 480,
|
||||
width: display.width,
|
||||
height: display.height,
|
||||
refresh_sec: display.refresh_sec,
|
||||
},
|
||||
layout: { type: 'free' },
|
||||
@@ -151,7 +154,12 @@ export function buildDisplayJson(displayId: number): DisplayJson | null {
|
||||
base.text = el.static_text ?? '';
|
||||
} else if (el.type === 'value') {
|
||||
base.label = el.dp_label ?? '';
|
||||
base.value = el.dp_value ?? null;
|
||||
// Round numeric values to 2 decimal places
|
||||
let val = el.dp_value ?? null;
|
||||
if (val != null && !isNaN(Number(val)) && val.includes('.')) {
|
||||
val = parseFloat(val).toFixed(2);
|
||||
}
|
||||
base.value = val;
|
||||
base.unit = el.dp_unit ?? null;
|
||||
}
|
||||
|
||||
@@ -166,8 +174,8 @@ export function buildDisplayJson(displayId: number): DisplayJson | null {
|
||||
display: {
|
||||
id: display.id,
|
||||
name: display.name,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
width: display.width,
|
||||
height: display.height,
|
||||
refresh_sec: display.refresh_sec,
|
||||
},
|
||||
layout: layoutResult,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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.prepare(
|
||||
`INSERT INTO layouts (name, type, width, height, grid_cols, grid_rows)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
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.prepare(`UPDATE layouts SET ${fields.join(', ')} WHERE id = ?`).run(...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.prepare('DELETE FROM layouts WHERE id = ?').run(id);
|
||||
return (result.changes as number) > 0;
|
||||
}
|
||||
|
||||
export function addElement(layoutId: number, data: CreateElementData): LayoutElement {
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
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.prepare(`UPDATE layout_elements SET ${fields.join(', ')} WHERE id = ?`).run(...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.prepare('DELETE FROM layout_elements WHERE id = ?').run(id);
|
||||
return (result.changes as number) > 0;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { getDb } from '../../db/index';
|
||||
|
||||
export interface IoBrokerSource {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
enabled: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateSourceData {
|
||||
name: string;
|
||||
host: string;
|
||||
port?: number;
|
||||
enabled?: number;
|
||||
}
|
||||
|
||||
export type UpdateSourceData = Partial<CreateSourceData>;
|
||||
|
||||
export interface IoBrokerState {
|
||||
id: number;
|
||||
source_id: number;
|
||||
state_id: string;
|
||||
selected: number;
|
||||
last_value: string | null;
|
||||
last_ts: number | null;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
export function getAllSources(): IoBrokerSource[] {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT * FROM iobroker_sources ORDER BY id ASC').all() as IoBrokerSource[];
|
||||
}
|
||||
|
||||
export function getSourceById(id: number): IoBrokerSource | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT * FROM iobroker_sources WHERE id = ?').get(id) as IoBrokerSource | undefined;
|
||||
}
|
||||
|
||||
export function createSource(data: CreateSourceData): IoBrokerSource {
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO iobroker_sources (name, host, port, enabled)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
).run(
|
||||
data.name,
|
||||
data.host,
|
||||
data.port ?? 8087,
|
||||
data.enabled ?? 1,
|
||||
);
|
||||
const id = result.lastInsertRowid as number;
|
||||
return db.prepare('SELECT * FROM iobroker_sources WHERE id = ?').get(id) as IoBrokerSource;
|
||||
}
|
||||
|
||||
export function updateSource(id: number, data: UpdateSourceData): IoBrokerSource | 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.enabled !== undefined) { fields.push('enabled = ?'); values.push(data.enabled); }
|
||||
|
||||
if (fields.length === 0) return getSourceById(id);
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE iobroker_sources SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return getSourceById(id);
|
||||
}
|
||||
|
||||
export function deleteSource(id: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM iobroker_sources WHERE id = ?').run(id);
|
||||
return (result.changes as number) > 0;
|
||||
}
|
||||
|
||||
function setStatus(id: number, status: string): void {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE iobroker_sources SET status = ? WHERE id = ?').run(status, id);
|
||||
}
|
||||
|
||||
export async function testSource(host: string, port: number): Promise<boolean> {
|
||||
try {
|
||||
// Root endpoint returns API help JSON — if it responds, the API is reachable
|
||||
const url = `http://${host}:${port}/`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return false;
|
||||
const text = await res.text();
|
||||
return text.includes('getPlainValue') || text.includes('getBulk');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatesForSource(
|
||||
sourceId: number,
|
||||
opts?: { search?: string; selectedOnly?: boolean; limit?: number; offset?: number }
|
||||
): { states: IoBrokerState[]; total: number } {
|
||||
const db = getDb();
|
||||
const conditions = ['source_id = ?'];
|
||||
const params: unknown[] = [sourceId];
|
||||
|
||||
if (opts?.search) {
|
||||
conditions.push('state_id LIKE ?');
|
||||
params.push(`%${opts.search}%`);
|
||||
}
|
||||
if (opts?.selectedOnly) {
|
||||
conditions.push('selected = 1');
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
const total = (db.prepare(`SELECT count(*) as c FROM iobroker_states WHERE ${where}`).get(...params) as { c: number }).c;
|
||||
|
||||
const limit = opts?.limit ?? 50;
|
||||
const offset = opts?.offset ?? 0;
|
||||
params.push(limit, offset);
|
||||
|
||||
const states = db
|
||||
.prepare(`SELECT * FROM iobroker_states WHERE ${where} ORDER BY selected DESC, state_id ASC LIMIT ? OFFSET ?`)
|
||||
.all(...params) as IoBrokerState[];
|
||||
|
||||
return { states, total };
|
||||
}
|
||||
|
||||
export function updateState(id: number, data: { selected?: number; last_value?: string; last_ts?: number }): IoBrokerState | undefined {
|
||||
const db = getDb();
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (data.selected !== undefined) { fields.push('selected = ?'); values.push(data.selected); }
|
||||
if (data.last_value !== undefined) { fields.push('last_value = ?'); values.push(data.last_value); }
|
||||
if (data.last_ts !== undefined) { fields.push('last_ts = ?'); values.push(data.last_ts); }
|
||||
|
||||
if (fields.length === 0) {
|
||||
return db.prepare('SELECT * FROM iobroker_states WHERE id = ?').get(id) as IoBrokerState | undefined;
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE iobroker_states SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return db.prepare('SELECT * FROM iobroker_states WHERE id = ?').get(id) as IoBrokerState | undefined;
|
||||
}
|
||||
|
||||
export async function discoverStates(sourceId: number): Promise<number> {
|
||||
const source = getSourceById(sourceId);
|
||||
if (!source) throw new Error(`ioBroker source ${sourceId} not found`);
|
||||
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
setStatus(sourceId, 'discovering');
|
||||
|
||||
// Use /states endpoint to get all state IDs with current values
|
||||
const url = `http://${source.host}:${source.port}/states?pattern=*`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus(sourceId, 'error');
|
||||
throw new Error(`ioBroker API returned ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json() as Record<string, { val?: unknown } | null>;
|
||||
const entries = Object.entries(data).slice(0, 5000);
|
||||
const stateIds = entries.map(([key]) => key);
|
||||
|
||||
// Also store initial values
|
||||
const updateStmt = db.prepare(
|
||||
'UPDATE iobroker_states SET last_value = ? WHERE source_id = ? AND state_id = ?'
|
||||
);
|
||||
for (const [key, val] of entries) {
|
||||
if (val && val.val !== null && val.val !== undefined) {
|
||||
updateStmt.run(String(val.val), sourceId, key);
|
||||
}
|
||||
}
|
||||
|
||||
const insertStmt = db.prepare(
|
||||
`INSERT OR IGNORE INTO iobroker_states (source_id, state_id) VALUES (?, ?)`
|
||||
);
|
||||
|
||||
const insertMany = db.transaction((ids: string[]) => {
|
||||
for (const sid of ids) {
|
||||
insertStmt.run(sourceId, sid);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(stateIds);
|
||||
setStatus(sourceId, 'connected');
|
||||
|
||||
return stateIds.length;
|
||||
} catch (err) {
|
||||
setStatus(sourceId, 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getDb } from '../../db/index';
|
||||
import { getAllSources, getSourceById, type IoBrokerState } from './source-manager';
|
||||
import {
|
||||
getDatapointByIoBrokerStateId,
|
||||
updateDatapointValue,
|
||||
} from '../datapoints/datapoint-manager';
|
||||
|
||||
const pollerTimers = new Map<number, ReturnType<typeof setInterval>>();
|
||||
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
|
||||
async function pollSource(sourceId: number): Promise<void> {
|
||||
const source = getSourceById(sourceId);
|
||||
if (!source || !source.enabled) return;
|
||||
|
||||
const db = getDb();
|
||||
const selectedStates = db
|
||||
.prepare('SELECT * FROM iobroker_states WHERE source_id = ? AND selected = 1')
|
||||
.all(sourceId) as IoBrokerState[];
|
||||
|
||||
if (selectedStates.length === 0) return;
|
||||
|
||||
// getBulk expects: /getBulk/stateID1,stateID2/
|
||||
const stateIds = selectedStates.map((s) => s.state_id).join(',');
|
||||
const url = `http://${source.host}:${source.port}/getBulk/${stateIds}/`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const results = await res.json() as Array<{ id: string; val: unknown; ts?: number }>;
|
||||
|
||||
for (const item of results) {
|
||||
if (item.val === null || item.val === undefined) continue;
|
||||
const rawValue = String(item.val);
|
||||
|
||||
// Update last_value in iobroker_states
|
||||
db.prepare(
|
||||
'UPDATE iobroker_states SET last_value = ?, last_ts = ? WHERE source_id = ? AND state_id = ?'
|
||||
).run(rawValue, item.ts ?? null, sourceId, item.id);
|
||||
|
||||
// Update datapoint value if one exists
|
||||
const dp = getDatapointByIoBrokerStateId(item.id);
|
||||
if (dp) {
|
||||
updateDatapointValue(dp.id, rawValue);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently ignore polling errors — source may be temporarily unreachable
|
||||
console.warn(`ioBroker poll error for source ${sourceId}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function startPolling(sourceId: number): void {
|
||||
if (pollerTimers.has(sourceId)) return; // already running
|
||||
|
||||
const timer = setInterval(() => {
|
||||
pollSource(sourceId).catch((err) =>
|
||||
console.error(`ioBroker poller ${sourceId}:`, (err as Error).message)
|
||||
);
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
pollerTimers.set(sourceId, timer);
|
||||
console.log(`ioBroker poller started for source ${sourceId}`);
|
||||
}
|
||||
|
||||
export function stopPolling(sourceId: number): void {
|
||||
const timer = pollerTimers.get(sourceId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
pollerTimers.delete(sourceId);
|
||||
console.log(`ioBroker poller stopped for source ${sourceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startAllPollers(): void {
|
||||
const sources = getAllSources();
|
||||
for (const source of sources) {
|
||||
if (source.enabled) {
|
||||
startPolling(source.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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.prepare(
|
||||
`INSERT INTO mqtt_brokers (name, host, port, username, password, use_tls, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
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.prepare(`UPDATE mqtt_brokers SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return getBrokerById(id);
|
||||
}
|
||||
|
||||
export function deleteBroker(id: number): boolean {
|
||||
const db = getDb();
|
||||
disconnectBroker(id);
|
||||
const result = db.prepare('DELETE FROM mqtt_brokers WHERE id = ?').run(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.prepare(
|
||||
`UPDATE mqtt_brokers SET status = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).run(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);
|
||||
});
|
||||
|
||||
// Throttle DB writes: cache payloads in memory, flush periodically
|
||||
const payloadCache = new Map<string, string>();
|
||||
const flushPayloads = (): void => {
|
||||
const db = getDb();
|
||||
for (const [t, p] of payloadCache.entries()) {
|
||||
const topicRecord = db
|
||||
.prepare('SELECT * FROM mqtt_topics WHERE broker_id = ? AND topic = ?')
|
||||
.get(id, t) as MqttTopic | undefined;
|
||||
if (!topicRecord) continue;
|
||||
|
||||
db.prepare('UPDATE mqtt_topics SET last_payload = ? WHERE id = ?').run(p, topicRecord.id);
|
||||
|
||||
const extracted = extractValue(p, topicRecord.json_path);
|
||||
if (extracted === null) continue;
|
||||
|
||||
const dp = getDatapointByTopicId(topicRecord.id);
|
||||
if (dp) {
|
||||
updateDatapointValue(dp.id, extracted);
|
||||
}
|
||||
}
|
||||
payloadCache.clear();
|
||||
};
|
||||
|
||||
// Flush every 2 seconds
|
||||
const flushTimer = setInterval(flushPayloads, 2000);
|
||||
|
||||
client.on('message', (topic: string, payloadBuf: Buffer) => {
|
||||
payloadCache.set(topic, payloadBuf.toString());
|
||||
});
|
||||
|
||||
// Clean up flush timer on disconnect
|
||||
client.on('close', () => {
|
||||
clearInterval(flushTimer);
|
||||
flushPayloads();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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, opts?: { search?: string; selectedOnly?: boolean; limit?: number; offset?: number }): { topics: MqttTopic[]; total: number } {
|
||||
const db = getDb();
|
||||
const conditions = ['broker_id = ?'];
|
||||
const params: any[] = [brokerId];
|
||||
|
||||
if (opts?.search) {
|
||||
conditions.push('topic LIKE ?');
|
||||
params.push(`%${opts.search}%`);
|
||||
}
|
||||
if (opts?.selectedOnly) {
|
||||
conditions.push('selected = 1');
|
||||
}
|
||||
conditions.push('ignored = 0');
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
const total = (db.prepare(`SELECT count(*) as c FROM mqtt_topics WHERE ${where}`).get(...params) as any).c;
|
||||
|
||||
const limit = opts?.limit || 50;
|
||||
const offset = opts?.offset || 0;
|
||||
params.push(limit, offset);
|
||||
|
||||
const topics = db
|
||||
.prepare(`SELECT * FROM mqtt_topics WHERE ${where} ORDER BY selected DESC, last_payload IS NOT NULL DESC, topic ASC LIMIT ? OFFSET ?`)
|
||||
.all(...params) as MqttTopic[];
|
||||
|
||||
return { topics, total };
|
||||
}
|
||||
|
||||
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.prepare(`UPDATE mqtt_topics SET ${fields.join(', ')} WHERE id = ?`).run(...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.prepare(
|
||||
`INSERT INTO mqtt_topics (broker_id, topic, selected, ignored)
|
||||
VALUES (?, ?, 0, 0)`
|
||||
).run(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 => {
|
||||
clearInterval(flushInterval);
|
||||
flushToDb(); // Final flush
|
||||
if (tempClient) {
|
||||
tempClient.end(true);
|
||||
}
|
||||
resolve(getTopicsForBroker(brokerId));
|
||||
};
|
||||
|
||||
// Collect payloads in memory, batch-write to DB periodically
|
||||
const payloadCache: Map<string, string> = new Map();
|
||||
|
||||
const flushToDb = (): void => {
|
||||
const db = getDb();
|
||||
for (const [t, p] of payloadCache.entries()) {
|
||||
if (!discoveredTopics.has(t)) {
|
||||
discoveredTopics.add(t);
|
||||
ensureTopicExists(brokerId, t);
|
||||
}
|
||||
db.prepare(
|
||||
'UPDATE mqtt_topics SET last_payload = ? WHERE broker_id = ? AND topic = ?'
|
||||
).run(p, brokerId, t);
|
||||
}
|
||||
payloadCache.clear();
|
||||
};
|
||||
|
||||
// Flush every 2 seconds instead of on every message
|
||||
const flushInterval = setInterval(flushToDb, 2000);
|
||||
|
||||
const onMessage = (topic: string, payload: Buffer): void => {
|
||||
payloadCache.set(topic, payload.toString());
|
||||
};
|
||||
|
||||
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.prepare(
|
||||
`INSERT INTO mqtt_topics (broker_id, topic, selected, ignored)
|
||||
VALUES (?, ?, 0, 0)`
|
||||
).run(brokerId, topic);
|
||||
const id = result.lastInsertRowid as number;
|
||||
return db.prepare('SELECT * FROM mqtt_topics WHERE id = ?').get(id) as MqttTopic;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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.prepare(
|
||||
`INSERT INTO pg_queries (source_id, name, query, interval_sec, result_column)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
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.prepare(`UPDATE pg_queries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return getQueryById(id);
|
||||
}
|
||||
|
||||
export function deleteQuery(id: number): boolean {
|
||||
const db = getDb();
|
||||
stopQueryTimer(id);
|
||||
const result = db.prepare('DELETE FROM pg_queries WHERE id = ?').run(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.prepare(
|
||||
`UPDATE pg_queries SET last_result = ?, last_run_at = datetime('now') WHERE id = ?`
|
||||
).run(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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.prepare(
|
||||
`INSERT INTO pg_sources (name, host, port, database_name, username, password, use_tls, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
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.prepare(`UPDATE pg_sources SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return getSourceById(id);
|
||||
}
|
||||
|
||||
export function deleteSource(id: number): boolean {
|
||||
const db = getDb();
|
||||
disconnectSource(id);
|
||||
const result = db.prepare('DELETE FROM pg_sources WHERE id = ?').run(id);
|
||||
return (result.changes as number) > 0;
|
||||
}
|
||||
|
||||
function setStatus(id: number, status: string): void {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE pg_sources SET status = ? WHERE id = ?').run(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,9 @@ beforeAll(() => {
|
||||
const db = initDb(':memory:');
|
||||
// Create a test user
|
||||
const hash = bcrypt.hashSync('correctpassword', 10);
|
||||
db.run(
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, ?)`,
|
||||
['testuser', hash]
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, ?)`
|
||||
).run('testuser', hash);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -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.prepare(
|
||||
`INSERT INTO layouts (id, name, type, width, height)
|
||||
VALUES (1, 'Test Layout', 'free', 800, 480)`
|
||||
).run();
|
||||
|
||||
// Display without token
|
||||
db.prepare(
|
||||
`INSERT INTO displays (id, name, layout_id, refresh_sec)
|
||||
VALUES (1, 'Open Display', 1, 60)`
|
||||
).run();
|
||||
|
||||
// Display with token auth
|
||||
db.prepare(
|
||||
`INSERT INTO displays (id, name, layout_id, api_token, refresh_sec)
|
||||
VALUES (2, 'Secure Display', 1, 'secret-token-123', 300)`
|
||||
).run();
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { Database } from 'node-sqlite3-wasm';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { initDb, closeDb } from '../src/db/index';
|
||||
|
||||
let db: Database;
|
||||
let db: InstanceType<typeof Database>;
|
||||
|
||||
beforeAll(() => {
|
||||
db = initDb(':memory:');
|
||||
@@ -96,10 +96,9 @@ describe('Table creation', () => {
|
||||
|
||||
describe('Broker insert and retrieve', () => {
|
||||
it('should insert and retrieve a broker', () => {
|
||||
const result = db.run(
|
||||
`INSERT INTO mqtt_brokers (name, host, port) VALUES (?, ?, ?)`,
|
||||
['Test Broker', 'mqtt.example.com', 1883]
|
||||
);
|
||||
const result = db.prepare(
|
||||
`INSERT INTO mqtt_brokers (name, host, port) VALUES (?, ?, ?)`
|
||||
).run('Test Broker', 'mqtt.example.com', 1883);
|
||||
expect(result.lastInsertRowid).toBeGreaterThan(0);
|
||||
|
||||
const broker = db
|
||||
@@ -119,22 +118,22 @@ describe('Broker insert and retrieve', () => {
|
||||
});
|
||||
|
||||
it('should cascade delete topics when broker is deleted', () => {
|
||||
const broker = db.run(`INSERT INTO mqtt_brokers (name, host) VALUES (?, ?)`, [
|
||||
const broker = db.prepare(`INSERT INTO mqtt_brokers (name, host) VALUES (?, ?)`).run(
|
||||
'Cascade Broker',
|
||||
'cascade.example.com',
|
||||
]);
|
||||
);
|
||||
|
||||
db.run(`INSERT INTO mqtt_topics (broker_id, topic) VALUES (?, ?)`, [
|
||||
db.prepare(`INSERT INTO mqtt_topics (broker_id, topic) VALUES (?, ?)`).run(
|
||||
broker.lastInsertRowid,
|
||||
'sensors/temp',
|
||||
]);
|
||||
);
|
||||
|
||||
const topicsBefore = db
|
||||
.prepare(`SELECT * FROM mqtt_topics WHERE broker_id = ?`)
|
||||
.all(broker.lastInsertRowid);
|
||||
expect(topicsBefore.length).toBe(1);
|
||||
|
||||
db.run(`DELETE FROM mqtt_brokers WHERE id = ?`, [broker.lastInsertRowid]);
|
||||
db.prepare(`DELETE FROM mqtt_brokers WHERE id = ?`).run(broker.lastInsertRowid);
|
||||
|
||||
const topicsAfter = db
|
||||
.prepare(`SELECT * FROM mqtt_topics WHERE broker_id = ?`)
|
||||
@@ -144,10 +143,9 @@ describe('Broker insert and retrieve', () => {
|
||||
|
||||
it('should reject invalid source_type values', () => {
|
||||
expect(() => {
|
||||
db.run(
|
||||
`INSERT INTO datapoints (name, label, source_type, source_id) VALUES (?, ?, ?, ?)`,
|
||||
['dp1', 'Datapoint 1', 'invalid_source', 1]
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO datapoints (name, label, source_type, source_id) VALUES (?, ?, ?, ?)`
|
||||
).run('dp1', 'Datapoint 1', 'invalid_source', 1);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,48 +6,48 @@ beforeAll(() => {
|
||||
const db = initDb(':memory:');
|
||||
|
||||
// Create a layout
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO layouts (id, name, type, width, height, grid_cols, grid_rows)
|
||||
VALUES (1, 'Test Layout', 'grid', 800, 480, 2, 3)`
|
||||
);
|
||||
).run();
|
||||
|
||||
// Create a display with the layout
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO displays (id, name, layout_id, refresh_sec)
|
||||
VALUES (1, 'Test Display', 1, 60)`
|
||||
);
|
||||
).run();
|
||||
|
||||
// Create a display without layout
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO displays (id, name, layout_id, refresh_sec)
|
||||
VALUES (2, 'Empty Display', NULL, 300)`
|
||||
);
|
||||
).run();
|
||||
|
||||
// Create a broker and topic for datapoint
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO mqtt_brokers (id, name, host) VALUES (1, 'Test Broker', 'localhost')`
|
||||
);
|
||||
db.run(
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO mqtt_topics (id, broker_id, topic) VALUES (1, 1, 'sensors/temp')`
|
||||
);
|
||||
).run();
|
||||
|
||||
// Create a datapoint
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO datapoints (id, name, label, unit, value, source_type, source_id, topic_id, enabled)
|
||||
VALUES (1, 'temperature', 'Temperature', '°C', '22.5', 'mqtt', 1, 1, 1)`
|
||||
);
|
||||
).run();
|
||||
|
||||
// Create layout elements
|
||||
// Label element
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO layout_elements (id, layout_id, type, x, y, static_text, sort_order)
|
||||
VALUES (1, 1, 'label', 0, 0, 'Hello World', 0)`
|
||||
);
|
||||
).run();
|
||||
// Value element linked to datapoint
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO layout_elements (id, layout_id, datapoint_id, type, x, y, font_size, sort_order)
|
||||
VALUES (2, 1, 1, 'value', 100, 0, 32, 1)`
|
||||
);
|
||||
).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
Reference in New Issue
Block a user