Compare commits
19
Commits
adb6a6f19e
...
main
@@ -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.
|
||||
@@ -1,12 +1,38 @@
|
||||
[env:esp32]
|
||||
[platformio]
|
||||
default_envs = wt32-sc01
|
||||
|
||||
; ── Shared settings ──────────────────────────────────────────────────────────
|
||||
[env]
|
||||
platform = espressif32
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
lib_deps =
|
||||
zinggjm/GxEPD2@^1.5.8
|
||||
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
|
||||
build_flags = -DBOARD_HAS_PSRAM
|
||||
|
||||
; ── 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
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#include <HTTPClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
// GxEPD2 for Waveshare 7.5" V2 (800x480, GxEPD2_750_T7)
|
||||
// GxEPD2 for Waveshare 7.5" V2 (800x480)
|
||||
#include <GxEPD2_BW.h>
|
||||
#include <GxEPD2_750_T7.h>
|
||||
#include <epd/GxEPD2_750_T7.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "renderer.h"
|
||||
@@ -59,11 +59,15 @@ static void showError(const char* message) {
|
||||
// ── setup ─────────────────────────────────────────────────────────────────────
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
Serial.println("[Boot] EPaper Display starting...");
|
||||
delay(3000); // Wait for serial monitor to connect
|
||||
Serial.println("\n\n[Boot] EPaper Display starting...");
|
||||
|
||||
// 1. Init display
|
||||
display.init(115200);
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -31,6 +32,7 @@ export default function App() {
|
||||
<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>} />
|
||||
|
||||
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 |
@@ -9,37 +9,35 @@ interface LayoutElement {
|
||||
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 DisplayJson {
|
||||
display_id: number;
|
||||
layout: {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
elements: Array<{
|
||||
interface DisplayApiElement {
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
font_size: number;
|
||||
style: string;
|
||||
static_text?: string;
|
||||
font_size?: number;
|
||||
font_weight?: string;
|
||||
text_align?: string;
|
||||
style?: string;
|
||||
icon?: string;
|
||||
text?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
unit?: string;
|
||||
}>;
|
||||
value?: string | null;
|
||||
unit?: string | null;
|
||||
}
|
||||
|
||||
interface Display {
|
||||
id: number;
|
||||
name: string;
|
||||
layout_id: number | null;
|
||||
interface DisplayApiResponse {
|
||||
display: { id: number; name: string; width: number; height: number; refresh_sec: number };
|
||||
layout: { type: string };
|
||||
elements: DisplayApiElement[];
|
||||
}
|
||||
|
||||
interface EpaperPreviewProps {
|
||||
@@ -49,119 +47,161 @@ interface EpaperPreviewProps {
|
||||
layoutHeight?: number;
|
||||
}
|
||||
|
||||
const SCALE = 0.5;
|
||||
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 [displayJson, setDisplayJson] = useState<DisplayJson | null>(null);
|
||||
const [liveElements, setLiveElements] = useState<DisplayApiElement[] | null>(null);
|
||||
const [displayId, setDisplayId] = useState<number | null>(null);
|
||||
|
||||
// Find a display that uses this layout
|
||||
useEffect(() => {
|
||||
// Find a display using this layout and fetch its JSON
|
||||
api.get<Display[]>('/api/displays')
|
||||
api.get<any[]>('/api/displays')
|
||||
.then((displays) => {
|
||||
const d = displays.find((disp) => disp.layout_id === layoutId);
|
||||
if (!d) return;
|
||||
return api.get<DisplayJson>(`/api/display/${d.id}`).then(setDisplayJson).catch(() => {});
|
||||
const d = displays.find((disp: any) => disp.layout_id === layoutId);
|
||||
setDisplayId(d ? d.id : null);
|
||||
})
|
||||
.catch(() => {});
|
||||
.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 renderEl = (el: typeof elements[0], _idx?: number) => {
|
||||
const left = el.x * SCALE;
|
||||
const top = el.y * SCALE;
|
||||
const fs = Math.max(8, el.font_size * SCALE);
|
||||
const renderElements = liveElements || [];
|
||||
const isLive = liveElements !== null;
|
||||
|
||||
if (el.type === 'line') {
|
||||
// If no display linked and no elements, show hint
|
||||
if (!isLive && elements.length === 0) {
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (el.type === 'rect') {
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
style={{
|
||||
position: 'absolute', left, top,
|
||||
width: (el.width ?? 80) * SCALE,
|
||||
height: (el.height ?? 40) * SCALE,
|
||||
border: '1px solid #000',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// label or value elements
|
||||
const text = el.static_text ?? (el.type === 'label' ? `[DP ${el.datapoint_id ?? '?'}]` : `[Wert]`);
|
||||
const isCard = el.style === 'card';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
style={{
|
||||
position: 'absolute', left, top,
|
||||
fontSize: fs,
|
||||
fontFamily: 'monospace',
|
||||
color: '#000',
|
||||
background: isCard ? '#e8e8e8' : 'transparent',
|
||||
border: isCard ? '1px solid #aaa' : 'none',
|
||||
padding: isCard ? '2px 4px' : 0,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
<div 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// Use live display JSON if available, else render from elements prop
|
||||
const liveEls = displayJson?.elements;
|
||||
// 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">
|
||||
E-Paper Vorschau ({layoutWidth}x{layoutHeight}px){liveEls ? ' — Live-Daten' : ' — Design-Modus'}
|
||||
Vorschau ({layoutWidth}x{layoutHeight})
|
||||
{isLive ? ' — Live-Daten' : ' — Layout-Struktur (kein Display zugewiesen)'}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: w,
|
||||
height: h,
|
||||
backgroundColor: '#f5f0e8',
|
||||
border: '2px solid #1a1a1a',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '2px 2px 8px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
{liveEls
|
||||
? liveEls.map((el, idx) => {
|
||||
<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 fs = Math.max(8, el.font_size * 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 ?? 100) * SCALE, height: 1, backgroundColor: '#000' }} />;
|
||||
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 ?? 80) * SCALE, height: (el.height ?? 40) * SCALE, border: '1px solid #000' }} />;
|
||||
return <div key={idx} style={{
|
||||
position: 'absolute', left, top,
|
||||
width: (el.width || 100) * SCALE,
|
||||
height: (el.height || 50) * SCALE,
|
||||
border: '1px solid #000',
|
||||
}} />;
|
||||
}
|
||||
|
||||
const text = el.static_text ?? (el.type === 'value' ? `${el.value ?? '?'} ${el.unit ?? ''}` : (el.label ?? ''));
|
||||
const isCard = el.style === 'card';
|
||||
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>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} style={{ position: 'absolute', left, top, fontSize: fs, fontFamily: 'monospace', color: '#000', background: isCard ? '#e8e8e8' : 'transparent', border: isCard ? '1px solid #aaa' : 'none', padding: isCard ? '2px 4px' : 0, whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{text}
|
||||
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>
|
||||
);
|
||||
})
|
||||
: elements.map((el) => renderEl(el, el.id))}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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' },
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Dashboard() {
|
||||
const [displays, setDisplays] = useState<Display[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
function loadAll() {
|
||||
Promise.all([
|
||||
api.get<Broker[]>('/api/mqtt/brokers'),
|
||||
api.get<Datapoint[]>('/api/datapoints'),
|
||||
@@ -23,7 +23,14 @@ export default function Dashboard() {
|
||||
setDatapoints(d);
|
||||
setDisplays(disp);
|
||||
})
|
||||
.catch((err) => setError((err as Error).message));
|
||||
.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
|
||||
|
||||
@@ -30,7 +30,12 @@ export default function Datapoints() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
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 {
|
||||
@@ -40,6 +45,16 @@ export default function Datapoints() {
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -55,11 +70,12 @@ export default function Datapoints() {
|
||||
<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={6} className="px-4 py-6 text-center text-gray-400">Keine Datenpunkte vorhanden</td></tr>
|
||||
<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">
|
||||
@@ -83,6 +99,9 @@ export default function Datapoints() {
|
||||
</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>
|
||||
|
||||
@@ -5,6 +5,9 @@ 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;
|
||||
@@ -12,23 +15,27 @@ interface Display {
|
||||
}
|
||||
|
||||
interface Layout { id: number; name: string }
|
||||
interface DisplayType { width: number; height: number; label: string }
|
||||
|
||||
const emptyForm = { name: '', layout_id: '', refresh_sec: '300' };
|
||||
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] = await Promise.all([
|
||||
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);
|
||||
}
|
||||
@@ -40,8 +47,12 @@ export default function Displays() {
|
||||
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),
|
||||
});
|
||||
@@ -61,6 +72,20 @@ export default function Displays() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -81,6 +106,7 @@ export default function Displays() {
|
||||
<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>
|
||||
@@ -90,11 +116,23 @@ export default function Displays() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{displays.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Displays konfiguriert</td></tr>
|
||||
<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"
|
||||
@@ -133,6 +171,18 @@ export default function Displays() {
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,10 +20,13 @@ interface LayoutElement {
|
||||
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 {
|
||||
@@ -34,50 +37,48 @@ interface Datapoint {
|
||||
id: number;
|
||||
name: string;
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
unit: string | null;
|
||||
}
|
||||
|
||||
const ELEMENT_TYPES = ['label', 'value', 'line', 'rect', 'static'];
|
||||
const emptyEl = { type: 'value', x: '0', y: '0', font_size: '24', style: 'plain', datapoint_id: '', icon: '', static_text: '' };
|
||||
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 [elForm, setElForm] = useState(emptyEl);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function loadLayouts() {
|
||||
try {
|
||||
setLayouts(await api.get<Layout[]>('/api/layouts'));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} 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);
|
||||
}
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLayouts();
|
||||
api.get<Datapoint[]>('/api/datapoints').then(setDatapoints).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { loadLayouts(); loadDatapoints(); }, []);
|
||||
|
||||
async function handleAddLayout(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await api.post('/api/layouts', { name: newName.trim() });
|
||||
const res = await api.post<{ id: number }>('/api/layouts', { name: newName.trim() });
|
||||
setNewName('');
|
||||
loadLayouts();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
selectLayout(res.id);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function handleDeleteLayout(id: number) {
|
||||
@@ -86,30 +87,46 @@ export default function Layouts() {
|
||||
await api.del(`/api/layouts/${id}`);
|
||||
if (selected?.id === id) setSelected(null);
|
||||
loadLayouts();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function handleAddElement(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
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.post(`/api/layouts/${selected.id}/elements`, {
|
||||
type: elForm.type,
|
||||
x: parseInt(elForm.x),
|
||||
y: parseInt(elForm.y),
|
||||
font_size: parseInt(elForm.font_size),
|
||||
style: elForm.style,
|
||||
datapoint_id: elForm.datapoint_id ? parseInt(elForm.datapoint_id) : null,
|
||||
icon: elForm.icon || null,
|
||||
static_text: elForm.static_text || null,
|
||||
});
|
||||
setElForm(emptyEl);
|
||||
await api.put(`/api/layouts/${selected.id}/elements/${elId}`, { [field]: value });
|
||||
selectLayout(selected.id);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} 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) {
|
||||
@@ -117,148 +134,231 @@ export default function Layouts() {
|
||||
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}</div>}
|
||||
{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-64 shrink-0">
|
||||
<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 vorhanden</p>
|
||||
)}
|
||||
{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'}`}
|
||||
>
|
||||
<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>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDeleteLayout(l.id); }} className="text-red-400 hover:text-red-600 text-xs ml-2">X</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddLayout} className="flex gap-2">
|
||||
<input
|
||||
placeholder="Neues Layout..."
|
||||
className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<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">
|
||||
<h3 className="font-medium text-gray-700 mb-4">
|
||||
Elemente in <span className="text-sky-600">{selected.name}</span>{' '}
|
||||
<span className="text-xs text-gray-400">({selected.width}x{selected.height})</span>
|
||||
<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 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Typ</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">X / Y</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Schriftgroesse</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Datenpunkt</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Statischer Text</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{/* Element list — editable */}
|
||||
<div className="space-y-2 mb-6">
|
||||
{selected.elements.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-400 text-xs">Noch keine Elemente</td></tr>
|
||||
<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 (
|
||||
<tr key={el.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-xs font-mono">{el.type}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500">{el.x} / {el.y}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500">{el.font_size}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{dp ? (dp.label || dp.name) : '-'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 max-w-xs truncate">{el.static_text ?? '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add element form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Element hinzufuegen</h4>
|
||||
<form onSubmit={handleAddElement} className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Typ</label>
|
||||
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.type} onChange={(e) => setElForm({ ...elForm, type: e.target.value })}>
|
||||
{ELEMENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
<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>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">X</label>
|
||||
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.x} onChange={(e) => setElForm({ ...elForm, x: e.target.value })} />
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Y</label>
|
||||
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.y} onChange={(e) => setElForm({ ...elForm, y: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Schriftgroesse</label>
|
||||
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.font_size} onChange={(e) => setElForm({ ...elForm, font_size: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Stil</label>
|
||||
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.style} onChange={(e) => setElForm({ ...elForm, style: e.target.value })}>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Datenpunkt</label>
|
||||
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.datapoint_id} onChange={(e) => setElForm({ ...elForm, datapoint_id: e.target.value })}>
|
||||
<option value="">-- keiner --</option>
|
||||
{datapoints.map((dp) => (
|
||||
<option key={dp.id} value={dp.id}>{dp.label || dp.name}</option>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Icon</label>
|
||||
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.icon} onChange={(e) => setElForm({ ...elForm, icon: e.target.value })} placeholder="z.B. thermometer" />
|
||||
)}
|
||||
|
||||
{/* 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="col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Statischer Text</label>
|
||||
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.static_text} onChange={(e) => setElForm({ ...elForm, static_text: e.target.value })} placeholder="Fester Text" />
|
||||
<div 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>
|
||||
<div className="col-span-3">
|
||||
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-4 py-2 rounded transition-colors">Element hinzufuegen</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</form>
|
||||
)}
|
||||
</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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
|
||||
@@ -8,47 +8,79 @@ interface Topic {
|
||||
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 [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
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>>({});
|
||||
|
||||
async function load() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<Topic[]>(`/api/mqtt/brokers/${brokerId}/topics`);
|
||||
setTopics(data);
|
||||
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> = {};
|
||||
data.forEach((t) => { paths[t.id] = t.json_path ?? ''; });
|
||||
setEditPaths(paths);
|
||||
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(); }, [brokerId]);
|
||||
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 gestartet. Topics erscheinen nach einigen Sekunden.');
|
||||
setTimeout(() => load(), 3000);
|
||||
setDiscoveryMsg('Discovery laeuft 30s...');
|
||||
const interval = setInterval(() => load(), 5000);
|
||||
setTimeout(() => { clearInterval(interval); setDiscoveryMsg(''); load(); }, 32000);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIgnore(id: number, ignored: number) {
|
||||
async function handleSelect(id: number, selected: boolean) {
|
||||
try {
|
||||
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { ignored: ignored ? 0 : 1 });
|
||||
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);
|
||||
@@ -63,79 +95,92 @@ export default function MqttTopics() {
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = topics.filter((t) =>
|
||||
!t.ignored && t.topic.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
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-6">Broker ID: {brokerId}</p>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
||||
<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-6 flex-wrap">
|
||||
<div className="flex gap-3 mb-4 flex-wrap items-center">
|
||||
<input
|
||||
placeholder="Filter Topics..."
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-56"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
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-Praefix (z.B. sensor/)"
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-64"
|
||||
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 transition-colors"
|
||||
>
|
||||
Discovery starten
|
||||
<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-4">{discoveryMsg}</p>}
|
||||
{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-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Topic</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Letzter Wert</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">JSON-Pfad</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
||||
<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">
|
||||
{filtered.length === 0 && (
|
||||
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Topics gefunden</td></tr>
|
||||
{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>
|
||||
)}
|
||||
{filtered.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-700">{t.topic}</td>
|
||||
<td className="px-4 py-2 text-xs text-gray-500 max-w-xs truncate">{t.last_payload ?? '-'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs w-40"
|
||||
{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="$.temperature"
|
||||
/>
|
||||
placeholder="$.value" />
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => handleIgnore(t.id, t.ignored)}
|
||||
className="text-xs text-orange-500 hover:text-orange-700"
|
||||
>
|
||||
Ignorieren
|
||||
</button>
|
||||
<td 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>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -30,6 +31,7 @@ export function createApp(): express.Application {
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getAllDatapoints,
|
||||
getDatapointById,
|
||||
updateDatapoint,
|
||||
deleteDatapoint,
|
||||
} from '../../modules/datapoints/datapoint-manager';
|
||||
|
||||
const router = Router();
|
||||
@@ -38,4 +39,15 @@ router.put('/:id', (req, res): void => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -44,10 +44,9 @@ router.get('/:id', (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
// Update last_seen
|
||||
db.run(
|
||||
`UPDATE displays SET last_seen = datetime('now') WHERE id = ?`,
|
||||
[displayId]
|
||||
);
|
||||
db.prepare(
|
||||
`UPDATE displays SET last_seen = datetime('now') WHERE id = ?`
|
||||
).run(displayId);
|
||||
|
||||
const json = buildDisplayJson(displayId);
|
||||
if (!json) {
|
||||
|
||||
@@ -5,6 +5,9 @@ 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;
|
||||
@@ -12,8 +15,22 @@ export interface Display {
|
||||
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 {
|
||||
@@ -40,14 +57,18 @@ router.get('/:id', (req, res): void => {
|
||||
router.post('/', (req, res): void => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const { name, description, layout_id, api_token, refresh_sec } = req.body;
|
||||
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 result = db.run(
|
||||
`INSERT INTO displays (name, description, layout_id, api_token, refresh_sec)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[name, description ?? null, layout_id ?? null, api_token ?? null, refresh_sec ?? 300]
|
||||
);
|
||||
const 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) {
|
||||
@@ -63,9 +84,12 @@ router.put('/:id', (req, res): void => {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
const { name, description, layout_id, api_token, refresh_sec } = req.body;
|
||||
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); }
|
||||
@@ -78,7 +102,7 @@ router.put('/:id', (req, res): void => {
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.run(`UPDATE displays SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
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);
|
||||
@@ -91,7 +115,7 @@ router.put('/:id', (req, res): void => {
|
||||
router.delete('/:id', (req, res): void => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const result = db.run('DELETE FROM displays WHERE id = ?', [parseInt(req.params.id)]);
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -64,6 +64,113 @@ router.delete('/:id', (req, res): void => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 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
|
||||
|
||||
@@ -5,14 +5,21 @@ import {
|
||||
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
|
||||
// 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);
|
||||
res.json(getTopicsForBroker(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 });
|
||||
}
|
||||
@@ -21,8 +28,23 @@ router.get('/', (req, res): void => {
|
||||
// PUT /api/mqtt/brokers/:brokerId/topics/:id
|
||||
router.put('/:id', (req, res): void => {
|
||||
try {
|
||||
const topic = updateTopic(parseInt(req.params.id), req.body);
|
||||
if (!topic) { res.status(404).json({ error: 'Topic not found' }); return; }
|
||||
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 });
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 PORT = process.env.PORT ? parseInt(process.env.PORT) : 4000;
|
||||
const DB_PATH = process.env.DB_PATH ?? './display.db';
|
||||
@@ -19,10 +20,9 @@ async function main(): Promise<void> {
|
||||
|
||||
if (userCount.count === 0) {
|
||||
const hash = bcrypt.hashSync('admin', 10);
|
||||
db.run(
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, ?)`,
|
||||
['admin', hash]
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO users (username, password_hash) VALUES (?, ?)`
|
||||
).run('admin', hash);
|
||||
console.log('Created default admin user (admin/admin)');
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ async function main(): Promise<void> {
|
||||
await connectAllEnabledSources();
|
||||
startAllQueryTimers();
|
||||
|
||||
// Start ioBroker state pollers for all enabled sources
|
||||
startAllPollers();
|
||||
|
||||
// Create and start Express app
|
||||
const app = 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,
|
||||
|
||||
@@ -92,17 +92,16 @@ export function getLayoutById(id: number): LayoutWithElements | undefined {
|
||||
|
||||
export function createLayout(data: CreateLayoutData): Layout {
|
||||
const db = getDb();
|
||||
const result = db.run(
|
||||
const result = db.prepare(
|
||||
`INSERT INTO layouts (name, type, width, height, grid_cols, grid_rows)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
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;
|
||||
@@ -125,24 +124,24 @@ export function updateLayout(id: number, data: UpdateLayoutData): Layout | undef
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.run(`UPDATE layouts SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
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.run('DELETE FROM layouts WHERE id = ?', [id]);
|
||||
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.run(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
layoutId,
|
||||
data.datapoint_id ?? null,
|
||||
data.type,
|
||||
@@ -159,7 +158,6 @@ export function addElement(layoutId: number, data: CreateElementData): LayoutEle
|
||||
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;
|
||||
@@ -200,12 +198,12 @@ export function updateElement(id: number, data: UpdateElementData): LayoutElemen
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.run(`UPDATE layout_elements SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
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.run('DELETE FROM layout_elements WHERE id = ?', [id]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,10 @@ export function getBrokerById(id: number): MqttBroker | undefined {
|
||||
|
||||
export function createBroker(data: CreateBrokerData): MqttBroker {
|
||||
const db = getDb();
|
||||
const result = db.run(
|
||||
const result = db.prepare(
|
||||
`INSERT INTO mqtt_brokers (name, host, port, username, password, use_tls, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
data.name,
|
||||
data.host,
|
||||
data.port ?? 1883,
|
||||
@@ -66,7 +66,6 @@ export function createBroker(data: CreateBrokerData): MqttBroker {
|
||||
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;
|
||||
@@ -89,14 +88,14 @@ export function updateBroker(id: number, data: UpdateBrokerData): MqttBroker | u
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
values.push(id);
|
||||
db.run(`UPDATE mqtt_brokers SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
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.run('DELETE FROM mqtt_brokers WHERE id = ?', [id]);
|
||||
const result = db.prepare('DELETE FROM mqtt_brokers WHERE id = ?').run(id);
|
||||
return (result.changes as number) > 0;
|
||||
}
|
||||
|
||||
@@ -107,10 +106,9 @@ function buildConnectUrl(broker: MqttBroker): string {
|
||||
|
||||
function setStatus(id: number, status: string): void {
|
||||
const db = getDb();
|
||||
db.run(
|
||||
`UPDATE mqtt_brokers SET status = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
[status, id]
|
||||
);
|
||||
db.prepare(
|
||||
`UPDATE mqtt_brokers SET status = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).run(status, id);
|
||||
}
|
||||
|
||||
export function connectBroker(id: number): Promise<mqtt.MqttClient> {
|
||||
@@ -162,33 +160,40 @@ export function connectBroker(id: number): Promise<mqtt.MqttClient> {
|
||||
resolve(client);
|
||||
});
|
||||
|
||||
client.on('message', (topic: string, payloadBuf: Buffer) => {
|
||||
// Throttle DB writes: cache payloads in memory, flush periodically
|
||||
const payloadCache = new Map<string, string>();
|
||||
const flushPayloads = (): void => {
|
||||
const db = getDb();
|
||||
const payload = payloadBuf.toString();
|
||||
|
||||
// Find the topic record
|
||||
for (const [t, p] of payloadCache.entries()) {
|
||||
const topicRecord = db
|
||||
.prepare(
|
||||
'SELECT * FROM mqtt_topics WHERE broker_id = ? AND topic = ?'
|
||||
)
|
||||
.get(id, topic) as MqttTopic | undefined;
|
||||
.prepare('SELECT * FROM mqtt_topics WHERE broker_id = ? AND topic = ?')
|
||||
.get(id, t) as MqttTopic | undefined;
|
||||
if (!topicRecord) continue;
|
||||
|
||||
if (!topicRecord) return;
|
||||
db.prepare('UPDATE mqtt_topics SET last_payload = ? WHERE id = ?').run(p, topicRecord.id);
|
||||
|
||||
// Update last_payload
|
||||
db.run(
|
||||
'UPDATE mqtt_topics SET last_payload = ? WHERE id = ?',
|
||||
[payload, topicRecord.id]
|
||||
);
|
||||
|
||||
// Extract value and update datapoint
|
||||
const extracted = extractValue(payload, topicRecord.json_path);
|
||||
if (extracted === null) return;
|
||||
const 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) => {
|
||||
|
||||
@@ -19,11 +19,32 @@ export interface UpdateTopicData {
|
||||
ignored?: number;
|
||||
}
|
||||
|
||||
export function getTopicsForBroker(brokerId: number): MqttTopic[] {
|
||||
export function getTopicsForBroker(brokerId: number, opts?: { search?: string; selectedOnly?: boolean; limit?: number; offset?: number }): { topics: MqttTopic[]; total: number } {
|
||||
const db = getDb();
|
||||
return db
|
||||
.prepare('SELECT * FROM mqtt_topics WHERE broker_id = ? ORDER BY id ASC')
|
||||
.all(brokerId) as MqttTopic[];
|
||||
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 {
|
||||
@@ -40,7 +61,7 @@ export function updateTopic(id: number, data: UpdateTopicData): MqttTopic | unde
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
db.run(`UPDATE mqtt_topics SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -51,11 +72,10 @@ function ensureTopicExists(brokerId: number, topic: string): void {
|
||||
.get(brokerId, topic);
|
||||
|
||||
if (!existing) {
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO mqtt_topics (broker_id, topic, selected, ignored)
|
||||
VALUES (?, ?, 0, 0)`,
|
||||
[brokerId, topic]
|
||||
);
|
||||
VALUES (?, ?, 0, 0)`
|
||||
).run(brokerId, topic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,17 +107,36 @@ export function startDiscovery(brokerId: number, prefix?: string): Promise<MqttT
|
||||
const discoveredTopics: Set<string> = new Set();
|
||||
|
||||
const finalize = (): void => {
|
||||
clearInterval(flushInterval);
|
||||
flushToDb(); // Final flush
|
||||
if (tempClient) {
|
||||
tempClient.end(true);
|
||||
}
|
||||
resolve(getTopicsForBroker(brokerId));
|
||||
};
|
||||
|
||||
const onMessage = (topic: string): void => {
|
||||
if (!discoveredTopics.has(topic)) {
|
||||
discoveredTopics.add(topic);
|
||||
ensureTopicExists(brokerId, topic);
|
||||
// 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) {
|
||||
@@ -159,11 +198,10 @@ export function addManualTopic(brokerId: number, topic: string): MqttTopic {
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
const result = db.run(
|
||||
const result = db.prepare(
|
||||
`INSERT INTO mqtt_topics (broker_id, topic, selected, ignored)
|
||||
VALUES (?, ?, 0, 0)`,
|
||||
[brokerId, topic]
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,16 +39,15 @@ export function getQueryById(id: number): PgQuery | undefined {
|
||||
|
||||
export function createQuery(data: CreateQueryData): PgQuery {
|
||||
const db = getDb();
|
||||
const result = db.run(
|
||||
const result = db.prepare(
|
||||
`INSERT INTO pg_queries (source_id, name, query, interval_sec, result_column)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[
|
||||
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;
|
||||
@@ -67,14 +66,14 @@ export function updateQuery(id: number, data: UpdateQueryData): PgQuery | undefi
|
||||
if (fields.length === 0) return getQueryById(id);
|
||||
|
||||
values.push(id);
|
||||
db.run(`UPDATE pg_queries SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
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.run('DELETE FROM pg_queries WHERE id = ?', [id]);
|
||||
const result = db.prepare('DELETE FROM pg_queries WHERE id = ?').run(id);
|
||||
return (result.changes as number) > 0;
|
||||
}
|
||||
|
||||
@@ -99,10 +98,9 @@ async function executeQuery(queryId: number): Promise<void> {
|
||||
const valueStr = String(rawValue);
|
||||
|
||||
const db = getDb();
|
||||
db.run(
|
||||
`UPDATE pg_queries SET last_result = ?, last_run_at = datetime('now') WHERE id = ?`,
|
||||
[valueStr, queryId]
|
||||
);
|
||||
db.prepare(
|
||||
`UPDATE pg_queries SET last_result = ?, last_run_at = datetime('now') WHERE id = ?`
|
||||
).run(valueStr, queryId);
|
||||
|
||||
const dp = getDatapointByQueryId(queryId);
|
||||
if (dp) {
|
||||
|
||||
@@ -42,10 +42,10 @@ export function getSourceById(id: number): PgSource | undefined {
|
||||
|
||||
export function createSource(data: CreateSourceData): PgSource {
|
||||
const db = getDb();
|
||||
const result = db.run(
|
||||
const result = db.prepare(
|
||||
`INSERT INTO pg_sources (name, host, port, database_name, username, password, use_tls, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
data.name,
|
||||
data.host,
|
||||
data.port ?? 5432,
|
||||
@@ -54,7 +54,6 @@ export function createSource(data: CreateSourceData): PgSource {
|
||||
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;
|
||||
@@ -77,20 +76,20 @@ export function updateSource(id: number, data: UpdateSourceData): PgSource | und
|
||||
if (fields.length === 0) return getSourceById(id);
|
||||
|
||||
values.push(id);
|
||||
db.run(`UPDATE pg_sources SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
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.run('DELETE FROM pg_sources WHERE id = ?', [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.run('UPDATE pg_sources SET status = ? WHERE id = ?', [status, id]);
|
||||
db.prepare('UPDATE pg_sources SET status = ? WHERE id = ?').run(status, id);
|
||||
}
|
||||
|
||||
export async function connectSource(id: number): Promise<Pool> {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -9,22 +9,22 @@ beforeAll(() => {
|
||||
const db = initDb(':memory:');
|
||||
|
||||
// Create a layout
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO layouts (id, name, type, width, height)
|
||||
VALUES (1, 'Test Layout', 'free', 800, 480)`
|
||||
);
|
||||
).run();
|
||||
|
||||
// Display without token
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO displays (id, name, layout_id, refresh_sec)
|
||||
VALUES (1, 'Open Display', 1, 60)`
|
||||
);
|
||||
).run();
|
||||
|
||||
// Display with token auth
|
||||
db.run(
|
||||
db.prepare(
|
||||
`INSERT INTO displays (id, name, layout_id, api_token, refresh_sec)
|
||||
VALUES (2, 'Secure Display', 1, 'secret-token-123', 300)`
|
||||
);
|
||||
).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -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