Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 512b743b16 | |||
| cb5f23d486 | |||
| 7904d498b5 | |||
| 5a00c7b5d3 | |||
| b64bcde203 | |||
| b3f67df967 | |||
| e72ad2db19 | |||
| 3b3d4055f6 | |||
| 15c0ede72e | |||
| bbfb11fb9c | |||
| 2456f356b4 | |||
| dfb42e6902 | |||
| fec49ec4fb | |||
| 5ab8ee75fb |
@@ -1,6 +1,6 @@
|
||||
# ShineBridge
|
||||
|
||||
Lokale Solar-Wechselrichter-Integration für Home Assistant — ohne Cloud, ohne Hersteller-App. Modbus TCP via ShineLAN-X, MQTT Discovery, persistente History, PV-Überschussladen.
|
||||
Lokale Solar-Wechselrichter-Integration für Home Assistant — ohne Cloud, ohne Hersteller-App. Modbus TCP via ShineLAN-X, MQTT Discovery, persistente History, PV-Überschussladen, Energie-Dashboard.
|
||||
|
||||
> **Repository:** `https://gitea.bitfire.work/retr0/shinebridge`
|
||||
|
||||
@@ -10,8 +10,8 @@ Lokale Solar-Wechselrichter-Integration für Home Assistant — ohne Cloud, ohne
|
||||
|
||||
| Komponente | Beschreibung | Status |
|
||||
|---|---|---|
|
||||
| **ShineBridge Add-on** | HAOS Add-on: Modbus TCP / UDP → MQTT Discovery | ✅ v1.5.3 |
|
||||
| **ShineLAN-X Firmware** | NuttX auf STM32F103: Modbus RTU ↔ TCP | ✅ Produktiv |
|
||||
| **ShineBridge Add-on** | HAOS Add-on: Modbus TCP / UDP → MQTT Discovery | ✅ v1.8.18 |
|
||||
| **ShineLAN-X Firmware** | NuttX auf STM32F103: Modbus RTU ↔ TCP + OTA | ✅ Produktiv |
|
||||
| **ShineWifi-X ESPHome** | ESPHome-Configs für ESP8266-Stick | ✅ Getestet |
|
||||
| **ShineDiag** | Portables Vor-Ort-Diagnose-Tool (Pi 3B) | ✅ Bereit |
|
||||
|
||||
@@ -30,13 +30,15 @@ Wechselrichter / Energiezähler
|
||||
│ UDP/8899 (Goodwe)
|
||||
│ Modbus TCP Port 502 (Kathrein Wallbox)
|
||||
│
|
||||
[ShineBridge Add-on]
|
||||
[ShineBridge Add-on] Port 8099 (HAOS Ingress)
|
||||
├── Modbus TCP / Goodwe UDP / Kathrein Wallbox lesen
|
||||
├── EMS-Controller: PV-Überschussladen + Zwangsladen
|
||||
├── MQTT Discovery → Home Assistant Sensoren
|
||||
├── Aggregat-Gerät „ShineBridge Gesamt"
|
||||
├── Persistente History (SQLite, 7 Tage)
|
||||
└── Web UI: Geräte verwalten, Live-Daten, Sparklines, EMS-Konfig
|
||||
├── Energie-Dashboard (SVG-Flussdiagramm, EPEX Spot)
|
||||
├── Finanzen-Tab (Abschlags-Tracker, Eigenversorgung)
|
||||
└── Web UI: Live-Daten, Geräte, Einstellungen, Flash-Wizard
|
||||
```
|
||||
|
||||
### Features
|
||||
@@ -45,9 +47,16 @@ Wechselrichter / Energiezähler
|
||||
- **MQTT Discovery** — Sensoren erscheinen automatisch in HA
|
||||
- **Aggregat-Gerät** — summiert alle Geräte für das HA Energie-Dashboard
|
||||
- **Persistente History** — Messwerte überleben Add-on-Neustarts (SQLite, 7 Tage)
|
||||
- **Energie-Dashboard** — HA-Style SVG-Flussdiagramm mit animierten Dots; EPEX-Spot-Chart
|
||||
- **Finanzen-Tab** — kWh-Kosten, Eigenversorgung, Abschlags-Tracker (monatliche Rate + Grundpreis)
|
||||
- **Flexibler Tarif** — Festpreis oder EPEX Spot (aWATTar DE/AT) mit Aufschlag
|
||||
- **Sparklines** — Live-Graphen der letzten 5 Minuten pro Sensor
|
||||
- **EMS-Controller** — PV-Überschussladen der Kathrein Wallbox, Zwangsladen-Fallback
|
||||
- **Setup-Wizard** — Geführte Ersteinrichtung (MQTT + erster Wechselrichter)
|
||||
- **Flash-Wizard** — ShineLAN-X Firmware direkt aus dem Add-on flashen (OTA oder ST-Link-Anleitung)
|
||||
- **Überschuss-Geräte** — Zigbee2MQTT-Geräte bei PV-Überschuss automatisch einschalten
|
||||
- **Konfig-Export/Import** — JSON im Einstellungen-Tab
|
||||
- **Port-Sicherheit** — `/api/*` nur über HAOS-Ingress erreichbar, kein Direktzugriff von außen
|
||||
- **Kein Cloud-Zwang** — vollständig lokal
|
||||
|
||||
### Unterstützte Geräte
|
||||
@@ -82,37 +91,37 @@ Konfigurierbar pro Gerät im Web UI:
|
||||
|
||||
### Netzleistung (grid_power)
|
||||
|
||||
Der Sensor `grid_power` (Goodwe) zeigt die Netzleistung intuitiv:
|
||||
Der Sensor `grid_power` zeigt die Netzleistung intuitiv:
|
||||
- **Positiv** = Netzbezug (Strom vom Netz)
|
||||
- **Negativ** = Einspeisung (Strom ins Netz)
|
||||
|
||||
Bei reinen Growatt-Anlagen (ohne Goodwe) wird `power_to_grid` als Proxy verwendet (nur Einspeisung bekannt).
|
||||
Bei reinen Growatt-Anlagen (ohne Goodwe) wird `power_to_grid` als Proxy verwendet.
|
||||
|
||||
### Installation
|
||||
|
||||
**Schritt 1 — NuttX auf den ShineLAN-X flashen** *(einmalig, ST-Link nötig)*
|
||||
|
||||
```bash
|
||||
# dapboot Bootloader
|
||||
st-flash write ShineLAN-X/releases/dapboot.bin 0x08000000
|
||||
|
||||
# NuttX Firmware
|
||||
st-flash write ShineLAN-X/releases/nuttx-mbusd-shinelanx-dfu.bin 0x08002000
|
||||
```
|
||||
|
||||
SWD-Pinbelegung: `PA13=SWDIO PA14=SWCLK GND 3.3V`
|
||||
|
||||
**Schritt 2 — Add-on in Home Assistant installieren**
|
||||
**Schritt 1 — Add-on in Home Assistant installieren**
|
||||
|
||||
1. **Einstellungen → Add-ons → Add-on-Store → ⋮ → Repositories**
|
||||
2. URL: `https://gitea.bitfire.work/retr0/shinebridge`
|
||||
3. „ShineBridge" → Installieren → Starten
|
||||
4. MQTT-Zugangsdaten in der Add-on-Konfiguration eintragen
|
||||
5. Web UI öffnen → Gerät hinzufügen
|
||||
|
||||
**Schritt 2 — ShineLAN-X flashen**
|
||||
|
||||
Beim ersten Start erscheint der **Setup-Wizard** automatisch. Der Tab **Flash** im Web UI führt durch beide Szenarien:
|
||||
|
||||
- **OTA-Modus** (Stick bereits geflasht): IP eingeben → Firmware auswählen → Flashen. Die aktuelle Firmware ist im Add-on integriert.
|
||||
- **Erstflash via ST-Link** (Stick noch ungeflasht): Schritt-für-Schritt-Anleitung mit Pinout und `st-flash`-Befehl.
|
||||
|
||||
Für den manuellen Erstflash:
|
||||
```bash
|
||||
# SWD-Pinbelegung: PA13=SWDIO PA14=SWCLK GND 3.3V
|
||||
|
||||
st-flash --reset write nuttx-mbusd-shinelanx.bin 0x08000000
|
||||
```
|
||||
|
||||
**Schritt 3 — Gerät konfigurieren**
|
||||
|
||||
Im Web UI → „+ Gerät hinzufügen":
|
||||
Im Web UI → „+ Gerät hinzufügen" (oder Setup-Wizard beim ersten Start):
|
||||
- Name, Modell, IP, Port, Modbus-Adresse, MQTT Topic-Präfix, Abfrageintervall
|
||||
- Bei Kathrein Wallbox: EMS-Parameter konfigurieren
|
||||
|
||||
@@ -142,25 +151,42 @@ Gibt alle Messpunkte des gewählten Zeitfensters zurück (max. 7 Tage).
|
||||
|
||||
## ShineLAN-X Firmware
|
||||
|
||||
Der **Growatt ShineLAN-X** ist ein LAN-Monitoring-Stick (STM32F103RC + ENC28J60) der im USB-Port des Wechselrichters steckt. Die Custom-Firmware (NuttX + mbusd) macht ihn zum Modbus TCP Gateway.
|
||||
Der **Growatt ShineLAN-X** ist ein LAN-Monitoring-Stick (STM32F103RC + ENC28J60) der im USB-Port des Wechselrichters steckt. Die Custom-Firmware (NuttX + mbusd) macht ihn zum Modbus TCP Gateway mit OTA-Update-Funktion.
|
||||
|
||||
> **Firmware von [mwalle (Martin Walle)](https://github.com/mwalle/shinelanx-modbus)**
|
||||
> Das Binary `nuttx-mbusd-shinelanx-dfu.bin` ist das Werk von Martin Walle und wird hier unverändert weitergegeben.
|
||||
> **Firmware basiert auf [mwalle (Martin Walle)](https://github.com/mwalle/shinelanx-modbus)**
|
||||
> Lizenzen: Board-Support Apache 2.0 · mbusd BSD 3-Clause · Gateway-App BSD 2-Clause.
|
||||
> Quellcode und Kompilieranleitung: [github.com/mwalle/shinelanx-modbus](https://github.com/mwalle/shinelanx-modbus)
|
||||
> Quellcode: [github.com/mwalle/shinelanx-modbus](https://github.com/mwalle/shinelanx-modbus)
|
||||
|
||||
### Flash-Layout
|
||||
|
||||
```
|
||||
0x08000000 dapboot (7 KB) ← Bootloader für USB DFU OTA
|
||||
0x08002000 NuttX (93 KB) ← Modbus RTU ↔ TCP, Port 502
|
||||
0x08000000 dapboot (8 KB) ← Bootloader, nicht überschrieben
|
||||
0x08002000 NuttX (93 KB) ← Modbus RTU ↔ TCP, Port 502 + OTA HTTP, Port 80
|
||||
↕
|
||||
0x08020000 Staging (≤124 KB) ← OTA: Neue Firmware temporär hier
|
||||
```
|
||||
|
||||
### OTA-Update
|
||||
|
||||
Ab der DFU-Variante (`nuttx-mbusd-shinelanx-dfu.bin`) ist OTA ohne ST-Link möglich. Der Stick hostet einen HTTP-Server auf Port 80:
|
||||
|
||||
| Endpunkt | Beschreibung |
|
||||
|---|---|
|
||||
| `GET /` | Status-JSON: `{"ota":true,"app_base":"0x08002000",...}` |
|
||||
| `POST /update` | Firmware-Binary empfangen, in Staging schreiben, anwenden, Reset |
|
||||
| `POST /reboot` | Software-Reset |
|
||||
|
||||
Der OTA-Prozess läuft zweistufig (Single-Bank STM32 Einschränkung):
|
||||
1. **Stage 1** — Firmware in die obere Flash-Hälfte (Staging) schreiben, TCP-Stack läuft noch
|
||||
2. **Stage 2** — Aus SRAM heraus: Staging → App-Bereich kopieren, Reset
|
||||
|
||||
Das Add-on steuert OTA über den **Flash-Tab** im Web UI.
|
||||
|
||||
### Hardware
|
||||
|
||||
| Komponente | Details |
|
||||
|---|---|
|
||||
| MCU | STM32F103RC — 256 kB Flash, 64 kB RAM |
|
||||
| MCU | STM32F103RC — 256 kB Flash, 48 kB SRAM |
|
||||
| Ethernet | ENC28J60 (SPI2) |
|
||||
| USB | PA11=D−, PA12=D+, PA8=Pullup |
|
||||
| SWD | PA13=SWDIO, PA14=SWCLK |
|
||||
@@ -224,8 +250,11 @@ shinebridge/
|
||||
├── haos-addon/ ← HAOS Add-on
|
||||
│ ├── config.yaml
|
||||
│ ├── Dockerfile
|
||||
│ ├── firmware/ # Integrierte ShineLAN-X Binaries
|
||||
│ │ ├── nuttx-mbusd-shinelanx.bin # Direktflash (0x08000000)
|
||||
│ │ └── nuttx-mbusd-shinelanx-dfu.bin # OTA-fähig (0x08002000)
|
||||
│ └── src/
|
||||
│ ├── main.py # Flask, Poll-Threads, REST API, Aggregation
|
||||
│ ├── main.py # Flask, Poll-Threads, REST API, Flash-Proxy
|
||||
│ ├── modbus_client.py # Modbus TCP, Float32-Dekodierung
|
||||
│ ├── goodwe_client.py # Goodwe UDP/8899 via goodwe-Bibliothek
|
||||
│ ├── wallbox_client.py # Kathrein Wallbox Modbus TCP
|
||||
@@ -233,11 +262,16 @@ shinebridge/
|
||||
│ ├── mqtt_publisher.py # MQTT Discovery + Aggregat
|
||||
│ ├── inverters.py # Register-Maps aller Geräte
|
||||
│ ├── history.py # SQLite Persistenz
|
||||
│ └── web/index.html # Web UI
|
||||
│ └── web/index.html # Web UI (Energie, Finanzen, Live, Geräte, Flash)
|
||||
├── ShineLAN-X/
|
||||
│ └── releases/
|
||||
│ ├── dapboot.bin
|
||||
│ ├── nuttx-mbusd-shinelanx.bin
|
||||
│ └── nuttx-mbusd-shinelanx-dfu.bin
|
||||
├── shinelanx-modbus/ ← NuttX Firmware Quellcode
|
||||
│ └── apps/shinelanx-modbus-gw/
|
||||
│ ├── main.c # Startup: MAC, DHCP, OTA-Thread, mbusd
|
||||
│ ├── ota_http.c # OTA HTTP-Server (Zwei-Phasen Flash)
|
||||
│ └── ota_http.h
|
||||
├── ShineWifi-X/ ← ESPHome Configs
|
||||
├── tools/
|
||||
│ └── shinediag/ ← Vor-Ort-Diagnose-Tool
|
||||
|
||||
+49
-32
@@ -1,11 +1,10 @@
|
||||
# ShineBridge — Roadmap
|
||||
|
||||
## Offen / Bekannte Einschränkungen
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- [ ] MQTT Dauerreconnect bei hohem Datenvolumen — rc=5 (Credentials / Broker-Last)
|
||||
- [ ] Growatt-only: Netzbezug nicht direkt messbar — nur Einspeisung via `power_to_grid`
|
||||
- [ ] Kathrein EVSE-Register 0x0060 (Charging-State) nur lesbar wenn Auto angeschlossen
|
||||
- [ ] Growatt-only: Netzbezug nicht messbar (nur Einspeisung via `power_to_grid`)
|
||||
- [ ] Port 8099 offen im LAN (`host_network: true`) — noch keine Authentifizierung
|
||||
- [ ] NuttX OTA noch nicht auf echter Hardware getestet
|
||||
|
||||
---
|
||||
|
||||
@@ -14,27 +13,38 @@
|
||||
- [x] Persistente History — SQLite `/data/history.db`, 7 Tage Retention (v1.3.0)
|
||||
- [x] Konfig-Export/Import — JSON im Einstellungen-Tab (v1.3.0)
|
||||
- [x] Goodwe GW10KN-ET — via goodwe-Bibliothek UDP/8899, 39 Sensoren (v1.4.0)
|
||||
- [x] Kathrein Wallbox — Modbus TCP, 18 Sensoren Meter + EVSE + EMS (v1.5.0)
|
||||
- [x] Kathrein Wallbox — Modbus TCP, 18 Sensoren + EMS-Controller (v1.5.0)
|
||||
- [x] EMS-Controller — PV-Überschussladen + Zwangsladen-Fallback (v1.5.0)
|
||||
- [x] EMS Web UI — Konfiguration direkt im Gerätedialog (v1.5.1)
|
||||
- [x] grid_power Sensor — intuitive Anzeige positiv=Netzbezug (v1.5.2)
|
||||
- [x] EMS Oszillations-Fix — has_pv prüft Gesamt-PV inkl. Ladeleistung (v1.5.3)
|
||||
- [ ] Flash-Wizard — NuttX-Firmware via USB DFU direkt aus dem HA Web UI flashen
|
||||
- [x] grid_power Sensor — positiv = Netzbezug, negativ = Einspeisung (v1.5.2)
|
||||
- [x] Energie-Dashboard — HA-Style SVG-Flussdiagramm, animateMotion (v1.6.0)
|
||||
- [x] Abrechnungsperiode + Strompreise — kWh + Kosten je Periode (v1.7.0)
|
||||
- [x] Flexibler Tarif — EPEX Spot (aWATTar DE/AT) + Aufschlag (v1.8.16)
|
||||
- [x] Finanzen-Tab — Eigenversorgung, Spot-Vergleich, Abschlags-Tracker (v1.8.14–16)
|
||||
- [x] Überschuss-Geräte — Zigbee2MQTT-Geräte bei PV-Überschuss steuern (v1.8.8–13)
|
||||
- [x] Setup-Wizard — geführte Ersteinrichtung (MQTT + erster Wechselrichter) (v1.8.18)
|
||||
- [x] Flash-Wizard — ShineLAN-X direkt aus Web UI flashen, OTA + ST-Link-Anleitung (v1.8.18)
|
||||
- [x] Port-Sicherheit — `/api/*` nur über HAOS-Ingress erreichbar (v1.8.18)
|
||||
- [x] MQTT rc=5 — Reconnect-Loop verhindert, Fehlermeldung im UI (v1.8.18)
|
||||
- [x] Mobile-Layout — horizontaler Overflow behoben, Tabs scrollen (v1.8.19)
|
||||
- [ ] Hausverbrauch als berechneter Sensor: `PV + Netzbezug − Einspeisung − Bat_Laden + Bat_Entladen`
|
||||
- [ ] Weitere Growatt-Modelle — MOD 10000, SPH 10000 etc.
|
||||
- [ ] Port 8099 absichern — optionale Basic-Auth für Web UI
|
||||
- [ ] Ladekosten-Berechnung: kWh je Session × Arbeitspreis
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Hardware-Erweiterungen
|
||||
|
||||
### ShineLAN-X (STM32F103RC)
|
||||
- [x] dapboot + NuttX produktiv (v1.0.0)
|
||||
- [x] OTA HTTP-Server implementiert — Zwei-Phasen Flash (Stage 1: Staging, Stage 2: SRAM) (v1.8.18)
|
||||
- [ ] OTA auf echter Hardware validieren
|
||||
- [ ] Weitere Firmware-Varianten (modellspezifische Builds)
|
||||
|
||||
### Goodwe WiFi-Stick (WIFILAN_2.0 / HF-LPT230)
|
||||
- [x] UDP/8899 Protokoll via goodwe-Bibliothek integriert (v1.4.0)
|
||||
- [ ] JTAG-Analyse (TP15/TP16/TP17) — eigene Firmware wenn JTAG-eFuse nicht gesetzt
|
||||
- [ ] RS485-Direktzugriff via 18-Pin-Stecker (USR-TCP232-304 getestet, kein COM-Port)
|
||||
|
||||
### ShineLAN-X
|
||||
- [x] dapboot + NuttX produktiv (v1.0.0)
|
||||
- [ ] OTA-Update via USB DFU direkt aus HA Web UI (Flash-Wizard)
|
||||
- [ ] RS485-Direktzugriff via 18-Pin-Stecker
|
||||
|
||||
### ShineWifi-X als ShineBridge-Gateway
|
||||
- [ ] Schlankes ESP8266-Projekt: RS485 → Modbus TCP Bridge
|
||||
@@ -45,11 +55,10 @@
|
||||
## Phase 4 — Energiebilanz & Dashboard
|
||||
|
||||
- [x] Aggregat-Gerät „ShineBridge Gesamt" (v1.2.0)
|
||||
- [x] grid_power korrekt für Multi-Wechselrichter-Anlagen (v1.5.2)
|
||||
- [x] EMS bezieht Wallbox-Ladeleistung in PV-Budget ein (v1.5.2)
|
||||
- [ ] Hausverbrauch als berechneter Sensor: `PV + Netzbezug - Einspeisung - Bat_Ladung + Bat_Entladung`
|
||||
- [x] EPEX Spot-Chart — 24h-Balkendiagramm im Energie-Dashboard (v1.8.16)
|
||||
- [x] Eigenversorgungskarte — kWh + € gespart (v1.8.8)
|
||||
- [ ] Virtuelle MQTT-Sensoren für HA Energie-Dashboard automatisch anlegen
|
||||
- [ ] Ladekosten-Berechnung: kWh je Session × Arbeitspreis
|
||||
- [ ] Prognose-gestütztes Laden — Spot-Preise × PV-Forecast für optimale Ladezeiten
|
||||
|
||||
---
|
||||
|
||||
@@ -57,26 +66,34 @@
|
||||
|
||||
- [x] WiFi-Hotspot „ShineDiag" (Pi 3B), http://10.0.1.1 (v2.0)
|
||||
- [x] Alle Sensoren, Rohdaten-Register-Dump, JSON-Export (v2.0)
|
||||
- [x] Pi-Setup-Script `install.sh` (v2.0)
|
||||
- [ ] Goodwe-Unterstützung in ShineDiag (UDP/8899)
|
||||
- [ ] Kathrein Wallbox in ShineDiag (EMS-Status, Ladezustand)
|
||||
- [ ] Goodwe-Unterstützung (UDP/8899)
|
||||
- [ ] Kathrein Wallbox (EMS-Status, Ladezustand)
|
||||
|
||||
---
|
||||
|
||||
## Erledigt
|
||||
## Changelog
|
||||
|
||||
| Version | Datum | Inhalt |
|
||||
|---|---|---|
|
||||
| v1.0.0 | 2024 | Grundfunktion: ShineLAN-X + Growatt MIC, Modbus TCP + MQTT |
|
||||
| v1.0.0 | 2024 | Grundfunktion: ShineLAN-X + Growatt MIC, Modbus TCP + MQTT Discovery |
|
||||
| v1.1.3 | 2024 | Security: XSS-Fix, Flask-Binding, API-Validierung |
|
||||
| v1.1.4 | 2024 | Fix: Flask zurück auf 0.0.0.0 (HA Ingress) |
|
||||
| v1.1.5 | 2024 | Feature: Eastron SDM-630 + Float32 Decode |
|
||||
| v1.2.0 | 2024 | Feature: Aggregat-Gerät + Energie-Dashboard Sensoren |
|
||||
| v1.2.1 | 2024 | Fix: SDM-630 Gesamtwirkleistung aus Phasensumme |
|
||||
| v1.1.5 | 2024 | Feature: Eastron SDM-630 + Float32-Dekodierung |
|
||||
| v1.2.0 | 2024 | Feature: Aggregat-Gerät + Energie-Dashboard-Sensoren |
|
||||
| v1.3.0 | 2024 | Feature: SQLite History, Konfig-Export/Import |
|
||||
| v1.4.0 | 2025 | Feature: Goodwe GW10KN-ET via UDP/8899, 39 Sensoren |
|
||||
| v1.5.0 | 2026-04-28 | Feature: Kathrein Wallbox + EMS-Controller |
|
||||
| v1.5.1 | 2026-04-28 | Feature: EMS-Konfiguration im Web UI |
|
||||
| v1.5.2 | 2026-04-28 | Fix: grid_power Anzeige, Aggregat, EMS Ladeleistung |
|
||||
| v1.5.3 | 2026-04-28 | Fix: EMS Oszillation (has_pv = Gesamt-PV) |
|
||||
| v1.6.0 | 2026-04-28 | Feature: Energie-Dashboard mit SVG-Flussdiagramm |
|
||||
| v1.5.0 | 2026-04-28 | Feature: Kathrein Wallbox + EMS-Controller (PV-Überschussladen) |
|
||||
| v1.5.3 | 2026-04-28 | Fix: EMS Oszillation, grid_power Anzeige |
|
||||
| v1.6.0 | 2026-04-28 | Feature: Energie-Dashboard SVG-Flussdiagramm, animateMotion |
|
||||
| v1.6.5 | 2026-04-28 | Redesign: Kreuz-Layout (Solar/Grid/Haus/Batterie/EV) |
|
||||
| v1.7.0 | 2026-04-29 | Feature: Abrechnungsperiode, Strompreise, kWh-Kosten |
|
||||
| v1.7.7 | 2026-04-30 | Fix: Goodwe pbattery1 Vorzeichen, Batterie-Flussrichtung |
|
||||
| v1.8.8 | 2026-05-01 | Feature: Überschuss-Geräte (Zigbee2MQTT), Eigenversorgungskarte |
|
||||
| v1.8.13 | 2026-05-02 | Feature: Labels, Invertiert-Modus, Mindest-Laufzeit, Z2M-Dropdown |
|
||||
| v1.8.14 | 2026-05-03 | Feature: Abschlags-Tracker (monatliche Rate + Grundpreis) |
|
||||
| v1.8.16 | 2026-05-04 | Feature: Finanzen-Tab, EPEX Spot-Chart, Festpreis vs. Spot-Vergleich |
|
||||
| v1.8.17 | 2026-05-04 | Fix: Atomarer Config-Write + Backup-Fallback |
|
||||
| v1.8.18 | 2026-05-05 | Feature: Flash-Wizard, Setup-Wizard, NuttX OTA, MQTT rc=5, Port-Sicherheit |
|
||||
| v1.8.19 | 2026-05-05 | Fix: Mobile-Layout — Tabs scrollen, kein horizontaler Overflow |
|
||||
| v1.8.20 | 2026-05-05 | Fix: Eigenversorgungskarte bei PV offline, Stromtarif-Einstellungen gehen nicht verloren |
|
||||
| v1.8.21 | 2026-05-05 | Fix: Finanzen-Tab bleibt nicht bei "Lade..." hängen (fEur/fKwh Scope-Bug) |
|
||||
| v1.8.22 | 2026-05-06 | Fix: Stromtarif-Einstellungen bleiben nach Neustart erhalten; Finanzen-Tab Layout |
|
||||
|
||||
@@ -4,6 +4,7 @@ FROM ${BUILD_FROM}
|
||||
WORKDIR /app
|
||||
|
||||
COPY src/ /app/
|
||||
COPY firmware/ /firmware/
|
||||
|
||||
RUN pip3 install --no-cache-dir \
|
||||
pymodbus==3.6.9 \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ShineBridge
|
||||
version: "1.8.12"
|
||||
version: "1.8.22"
|
||||
slug: shinebridge
|
||||
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
||||
url: https://gitea.bitfire.work/retr0/shinebridge
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -47,6 +47,16 @@ def init_db():
|
||||
PRIMARY KEY (agg_id, period_type, period_key)
|
||||
)
|
||||
""")
|
||||
# Tariftage: täglicher Verbrauch + Preisvergleich
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tariff_days (
|
||||
date TEXT PRIMARY KEY,
|
||||
kwh REAL NOT NULL,
|
||||
spot_ct REAL,
|
||||
fixed_ct REAL NOT NULL,
|
||||
markup_ct REAL NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
c.commit()
|
||||
cleanup_old()
|
||||
log.info("History DB initialisiert: %s", DB_PATH)
|
||||
@@ -137,6 +147,51 @@ def query(inv_id: str, sensor_id: str,
|
||||
return rows
|
||||
|
||||
|
||||
def save_daily_tariff_snapshot(date_iso: str, prev_kwh: float, cur_kwh: float,
|
||||
spot_ct: Optional[float], fixed_ct: float, markup_ct: float):
|
||||
"""Speichert den Tagesverbrauch + Preisdaten für einen abgeschlossenen Tag."""
|
||||
delta = max(0.0, cur_kwh - prev_kwh)
|
||||
with _lock:
|
||||
c = _get_conn()
|
||||
c.execute("""
|
||||
INSERT OR IGNORE INTO tariff_days(date, kwh, spot_ct, fixed_ct, markup_ct)
|
||||
VALUES(?, ?, ?, ?, ?)
|
||||
""", (date_iso, round(delta, 4), spot_ct, fixed_ct, markup_ct))
|
||||
c.commit()
|
||||
|
||||
|
||||
def get_tariff_days(from_date: str, to_date: str) -> List[Tuple]:
|
||||
"""Gibt alle Tariftage im Bereich [from_date, to_date] zurück."""
|
||||
with _lock:
|
||||
return _get_conn().execute("""
|
||||
SELECT date, kwh, spot_ct, fixed_ct, markup_ct
|
||||
FROM tariff_days
|
||||
WHERE date >= ? AND date <= ?
|
||||
ORDER BY date ASC
|
||||
""", (from_date, to_date)).fetchall()
|
||||
|
||||
|
||||
def get_daily_kwh_start(date_iso: str) -> Optional[float]:
|
||||
"""Gibt den gespeicherten kWh-Tagesstartwert zurück (aus period_starts)."""
|
||||
with _lock:
|
||||
row = _get_conn().execute("""
|
||||
SELECT value FROM period_starts
|
||||
WHERE agg_id='tariff' AND period_type='daily' AND period_key=?
|
||||
""", (date_iso,)).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def save_daily_kwh_start(date_iso: str, kwh: float):
|
||||
"""Speichert den kWh-Stand als Tagesstartwert (einmalig, INSERT OR IGNORE)."""
|
||||
with _lock:
|
||||
c = _get_conn()
|
||||
c.execute("""
|
||||
INSERT OR IGNORE INTO period_starts(agg_id, period_type, period_key, value)
|
||||
VALUES('tariff', 'daily', ?, ?)
|
||||
""", (date_iso, kwh))
|
||||
c.commit()
|
||||
|
||||
|
||||
def cleanup_old(days: int = RETENTION_DAYS):
|
||||
cutoff = time.time() - days * 86400
|
||||
with _lock:
|
||||
|
||||
+250
-25
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -30,6 +31,21 @@ WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
||||
|
||||
app = Flask(__name__, static_folder=WEB_DIR)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _check_ingress():
|
||||
# Static files and the root page are always served (no API data inside)
|
||||
if request.path == "/" or not request.path.startswith("/api/"):
|
||||
return None
|
||||
# Requests routed through HAOS ingress proxy carry this header
|
||||
if request.headers.get("X-Ingress-Path"):
|
||||
return None
|
||||
# Allow loopback for local debugging / health checks
|
||||
if request.remote_addr in ("127.0.0.1", "::1"):
|
||||
return None
|
||||
return jsonify({"error": "Zugriff nur über die HAOS-Oberfläche erlaubt"}), 403
|
||||
|
||||
|
||||
# ── Aggregation ───────────────────────────────────────────────
|
||||
# Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG)
|
||||
|
||||
@@ -77,6 +93,7 @@ class State:
|
||||
surplus_devices_cfg: List[Dict[str, Any]] = []
|
||||
z2m_base: str = "zigbee2mqtt"
|
||||
z2m_devices: List[Dict[str, Any]] = []
|
||||
last_tariff_snapshot_date: str = ""
|
||||
|
||||
_publisher: Optional[MqttPublisher] = None
|
||||
_surplus_ctrl: Optional[SurplusDeviceController] = None
|
||||
@@ -101,30 +118,42 @@ def _defaults() -> Dict[str, Any]:
|
||||
"spot_country": "de",
|
||||
"spot_markup": 0.0,
|
||||
"spot_chart": True,
|
||||
"billing_tracker_enabled": False,
|
||||
"monthly_rate_eur": 0.0,
|
||||
"grundpreis_eur_per_month": 0.0,
|
||||
"inverters": [],
|
||||
"surplus_devices": [],
|
||||
"z2m_base": "zigbee2mqtt",
|
||||
}
|
||||
|
||||
def _load_json_safe(path: str) -> Optional[Dict]:
|
||||
"""Lädt eine JSON-Datei; gibt None zurück wenn fehlend oder korrupt."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
log.warning("JSON-Ladefehler %s: %s", path, e)
|
||||
return None
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
cfg = _defaults()
|
||||
# MQTT-Grundeinstellungen aus HAOS-Options (überschreibbar durch config.json)
|
||||
if os.path.exists(HA_OPTIONS_PATH):
|
||||
try:
|
||||
with open(HA_OPTIONS_PATH) as f:
|
||||
ha = json.load(f)
|
||||
for k in ("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass"):
|
||||
if k in ha:
|
||||
cfg[k] = ha[k]
|
||||
except Exception as e:
|
||||
log.warning("HA options Fehler: %s", e)
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH) as f:
|
||||
cfg.update(json.load(f))
|
||||
except Exception as e:
|
||||
log.warning("Config-Datei Fehler: %s", e)
|
||||
ha = _load_json_safe(HA_OPTIONS_PATH) or {}
|
||||
for k in ("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass"):
|
||||
if k in ha:
|
||||
cfg[k] = ha[k]
|
||||
# Eigene persistente Config — Hauptdatei, dann Backup als Fallback
|
||||
loaded = _load_json_safe(CONFIG_PATH)
|
||||
if loaded is None and os.path.exists(CONFIG_PATH + ".bak"):
|
||||
log.warning("config.json korrupt — lade Backup")
|
||||
loaded = _load_json_safe(CONFIG_PATH + ".bak")
|
||||
if loaded:
|
||||
cfg.update(loaded)
|
||||
return cfg
|
||||
|
||||
|
||||
def save_config():
|
||||
data = {
|
||||
"mqtt_broker": State.mqtt_cfg.get("mqtt_broker", ""),
|
||||
@@ -139,12 +168,24 @@ def save_config():
|
||||
"spot_country": State.mqtt_cfg.get("spot_country", "de"),
|
||||
"spot_markup": State.mqtt_cfg.get("spot_markup", 0.0),
|
||||
"spot_chart": State.mqtt_cfg.get("spot_chart", True),
|
||||
"billing_tracker_enabled": State.mqtt_cfg.get("billing_tracker_enabled", False),
|
||||
"monthly_rate_eur": State.mqtt_cfg.get("monthly_rate_eur", 0.0),
|
||||
"grundpreis_eur_per_month": State.mqtt_cfg.get("grundpreis_eur_per_month", 0.0),
|
||||
"inverters": State.inverters_cfg,
|
||||
"surplus_devices": State.surplus_devices_cfg,
|
||||
"z2m_base": State.z2m_base,
|
||||
}
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
# Backup der letzten guten Config anlegen
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
os.replace(CONFIG_PATH, CONFIG_PATH + ".bak")
|
||||
except OSError:
|
||||
pass
|
||||
# Atomarer Write: erst .tmp schreiben, dann umbenennen
|
||||
tmp = CONFIG_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, CONFIG_PATH)
|
||||
|
||||
# ── Aggregation ───────────────────────────────────────────────
|
||||
|
||||
@@ -187,6 +228,37 @@ def _get_pv_surplus() -> float:
|
||||
|
||||
# ── Poll Loop ─────────────────────────────────────────────────
|
||||
|
||||
def _run_daily_tariff_snapshot():
|
||||
import datetime
|
||||
today = datetime.date.today()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
today_iso = today.isoformat()
|
||||
yesterday_iso = yesterday.isoformat()
|
||||
|
||||
agg = _compute_aggregates(allow_stale=True)
|
||||
cur_kwh = agg.get("grid_import_kwh")
|
||||
if cur_kwh is None:
|
||||
return
|
||||
|
||||
history.save_daily_kwh_start(today_iso, cur_kwh)
|
||||
|
||||
prev_kwh = history.get_daily_kwh_start(yesterday_iso)
|
||||
if prev_kwh is None:
|
||||
return
|
||||
|
||||
fixed_ct = float(State.mqtt_cfg.get("price_import", 0.30)) * 100
|
||||
markup_ct = float(State.mqtt_cfg.get("spot_markup", 0.0))
|
||||
country = str(State.mqtt_cfg.get("spot_country", "de"))
|
||||
|
||||
y_start = datetime.datetime.combine(yesterday, datetime.time.min).timestamp()
|
||||
y_end = datetime.datetime.combine(today, datetime.time.min).timestamp()
|
||||
spot_ct = _get_avg_spot_price(y_start, y_end, country)
|
||||
|
||||
history.save_daily_tariff_snapshot(yesterday_iso, prev_kwh, cur_kwh, spot_ct, fixed_ct, markup_ct)
|
||||
log.info("Tariftag %s gespeichert: %.3f kWh, spot=%.2f ct, fixed=%.2f ct",
|
||||
yesterday_iso, max(0, cur_kwh - prev_kwh), spot_ct or 0, fixed_ct)
|
||||
|
||||
|
||||
def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||
inv_id = inv_cfg["id"]
|
||||
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
|
||||
@@ -288,6 +360,14 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||
if agg:
|
||||
_publisher.publish_aggregates(agg)
|
||||
|
||||
# Täglicher Tarif-Snapshot (einmal pro Tag, beim ersten Poll nach Mitternacht)
|
||||
if values is not None:
|
||||
import datetime
|
||||
today_iso = datetime.date.today().isoformat()
|
||||
if State.last_tariff_snapshot_date != today_iso:
|
||||
State.last_tariff_snapshot_date = today_iso
|
||||
threading.Thread(target=_run_daily_tariff_snapshot, daemon=True).start()
|
||||
|
||||
stop.wait(max(0.0, interval - (time.time() - t0)))
|
||||
|
||||
reader.close()
|
||||
@@ -388,6 +468,7 @@ def api_get_config():
|
||||
cfg = {**State.mqtt_cfg, "inverters": State.inverters_cfg}
|
||||
cfg.pop("mqtt_pass", None)
|
||||
cfg["mqtt_connected"] = _publisher.connected if _publisher else False
|
||||
cfg["mqtt_error"] = _publisher.last_error if _publisher else None
|
||||
return jsonify(cfg)
|
||||
|
||||
@app.post("/api/config")
|
||||
@@ -399,17 +480,25 @@ def api_save_config():
|
||||
State.mqtt_cfg[k] = data[k]
|
||||
if data.get("mqtt_pass"):
|
||||
State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"]
|
||||
for k in ("price_import", "price_export", "spot_markup"):
|
||||
if k in data:
|
||||
State.mqtt_cfg[k] = float(data[k])
|
||||
for k in ("price_import", "price_export", "spot_markup", "monthly_rate_eur", "grundpreis_eur_per_month"):
|
||||
if k in data and data[k] is not None:
|
||||
try:
|
||||
State.mqtt_cfg[k] = float(data[k])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
for k in ("billing_day", "billing_month"):
|
||||
if k in data:
|
||||
State.mqtt_cfg[k] = int(data[k])
|
||||
if k in data and data[k] is not None:
|
||||
try:
|
||||
State.mqtt_cfg[k] = int(data[k])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
for k in ("tariff_type", "spot_country"):
|
||||
if k in data:
|
||||
if k in data and data[k] is not None:
|
||||
State.mqtt_cfg[k] = str(data[k])
|
||||
if "spot_chart" in data:
|
||||
State.mqtt_cfg["spot_chart"] = bool(data["spot_chart"])
|
||||
if "billing_tracker_enabled" in data:
|
||||
State.mqtt_cfg["billing_tracker_enabled"] = bool(data["billing_tracker_enabled"])
|
||||
save_config()
|
||||
threading.Thread(target=_restart_all, daemon=True).start()
|
||||
return jsonify({"ok": True})
|
||||
@@ -483,8 +572,8 @@ def api_period_energy():
|
||||
pv_total = entry.get("total_energy_total")
|
||||
grid_exp = entry.get("grid_export_kwh")
|
||||
bat_dch = entry.get("bat_discharge_total")
|
||||
if pv_total is not None and grid_exp is not None:
|
||||
savings = round(max(0.0, pv_total - grid_exp), 2)
|
||||
if pv_total is not None:
|
||||
savings = round(max(0.0, pv_total - (grid_exp or 0.0)), 2)
|
||||
entry["savings_kwh"] = savings
|
||||
entry["savings_eur"] = round(savings * eff_price, 2)
|
||||
elif bat_dch is not None:
|
||||
@@ -493,8 +582,78 @@ def api_period_energy():
|
||||
|
||||
result[period_type] = entry
|
||||
|
||||
if State.mqtt_cfg.get("billing_tracker_enabled", False):
|
||||
monthly_rate = float(State.mqtt_cfg.get("monthly_rate_eur", 0.0))
|
||||
grundpreis = float(State.mqtt_cfg.get("grundpreis_eur_per_month", 0.0))
|
||||
yr_key = history.period_key("yearly", billing_day, billing_month)
|
||||
yr_start = datetime.date.fromisoformat(yr_key)
|
||||
today = datetime.date.today()
|
||||
# Anzahl geleisteter Abschlagszahlungen (ganze Monate seit Periodenstart)
|
||||
months_diff = (today.year - yr_start.year) * 12 + (today.month - yr_start.month)
|
||||
if today.day >= yr_start.day:
|
||||
months_diff += 1
|
||||
payments_made = max(0, months_diff)
|
||||
total_paid = round(payments_made * monthly_rate, 2)
|
||||
grundpreis_total= round(payments_made * grundpreis, 2)
|
||||
energy_cost = result.get("yearly", {}).get("import_cost", 0.0)
|
||||
total_cost = round(energy_cost + grundpreis_total, 2)
|
||||
nachzahlung = round(total_cost - total_paid, 2)
|
||||
result["billing_tracker"] = {
|
||||
"monthly_rate_eur": monthly_rate,
|
||||
"grundpreis_eur_per_month": grundpreis,
|
||||
"payments_made": payments_made,
|
||||
"total_paid_eur": total_paid,
|
||||
"grundpreis_total_eur": grundpreis_total,
|
||||
"energy_cost_eur": energy_cost,
|
||||
"total_cost_eur": total_cost,
|
||||
"nachzahlung_eur": nachzahlung,
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@app.get("/api/finance")
|
||||
def api_finance():
|
||||
import datetime
|
||||
billing_day = int(State.mqtt_cfg.get("billing_day", 1))
|
||||
billing_month = int(State.mqtt_cfg.get("billing_month", 1))
|
||||
yr_key = history.period_key("yearly", billing_day, billing_month)
|
||||
yr_start = datetime.date.fromisoformat(yr_key)
|
||||
today = datetime.date.today()
|
||||
|
||||
rows = history.get_tariff_days(yr_key, today.isoformat())
|
||||
|
||||
days = []
|
||||
fixed_total = 0.0
|
||||
spot_total = 0.0
|
||||
spot_available = 0
|
||||
for date_iso, kwh, spot_ct, fixed_ct, markup_ct in rows:
|
||||
fixed_day = round(kwh * fixed_ct / 100, 4)
|
||||
spot_day = round(kwh * ((spot_ct + markup_ct) / 100), 4) if spot_ct is not None else None
|
||||
fixed_total += fixed_day
|
||||
if spot_day is not None:
|
||||
spot_total += spot_day
|
||||
spot_available += 1
|
||||
days.append({
|
||||
"date": date_iso,
|
||||
"kwh": round(kwh, 3),
|
||||
"fixed_eur": fixed_day,
|
||||
"spot_eur": spot_day,
|
||||
"spot_ct": spot_ct,
|
||||
"fixed_ct": fixed_ct,
|
||||
})
|
||||
|
||||
result = {
|
||||
"period_start": yr_key,
|
||||
"days": days,
|
||||
"fixed_total_eur": round(fixed_total, 2),
|
||||
"spot_total_eur": round(spot_total, 2) if spot_available else None,
|
||||
"spot_days": spot_available,
|
||||
"total_days": len(days),
|
||||
"savings_eur": round(fixed_total - spot_total, 2) if spot_available else None,
|
||||
}
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.get("/api/z2m-devices")
|
||||
def api_z2m_devices():
|
||||
with State.lock:
|
||||
@@ -717,6 +876,68 @@ def api_spot_price():
|
||||
log.warning("Spot-Price API Fehler: %s", e)
|
||||
return jsonify({"ok": False, "data": _spot_cache.get("data", [])})
|
||||
|
||||
_IP_RE = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
|
||||
|
||||
@app.get("/api/flash/probe")
|
||||
def api_flash_probe():
|
||||
import urllib.request as _ur
|
||||
ip = request.args.get("ip", "").strip()
|
||||
if not ip or not _IP_RE.match(ip):
|
||||
return jsonify({"ota": False, "error": "invalid ip"}), 400
|
||||
try:
|
||||
with _ur.urlopen(f"http://{ip}/", timeout=5) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
return jsonify({"ota": bool(data.get("ota")), "info": data})
|
||||
except Exception:
|
||||
return jsonify({"ota": False})
|
||||
|
||||
@app.get("/api/flash/firmware")
|
||||
def api_flash_firmware():
|
||||
fw_dir = "/firmware"
|
||||
try:
|
||||
files = sorted(f for f in os.listdir(fw_dir) if f.endswith(".bin"))
|
||||
return jsonify({"files": files})
|
||||
except Exception:
|
||||
return jsonify({"files": []})
|
||||
|
||||
@app.post("/api/flash/update")
|
||||
def api_flash_update():
|
||||
import urllib.request as _ur
|
||||
ip = request.args.get("ip", "").strip()
|
||||
fw_name = request.args.get("fw", "").strip()
|
||||
if not ip or not _IP_RE.match(ip):
|
||||
return jsonify({"ok": False, "error": "invalid ip"}), 400
|
||||
try:
|
||||
if fw_name:
|
||||
fw_path = os.path.join("/firmware", os.path.basename(fw_name))
|
||||
with open(fw_path, "rb") as f:
|
||||
data = f.read()
|
||||
else:
|
||||
data = request.get_data()
|
||||
if len(data) < 256:
|
||||
return jsonify({"ok": False, "error": "firmware too small"}), 400
|
||||
req = _ur.Request(
|
||||
f"http://{ip}/update", data=data, method="POST",
|
||||
headers={"Content-Type": "application/octet-stream",
|
||||
"Content-Length": str(len(data))})
|
||||
with _ur.urlopen(req, timeout=90) as r:
|
||||
return jsonify({"ok": True, "response": r.read().decode(errors="replace")})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 502
|
||||
|
||||
@app.post("/api/flash/reboot")
|
||||
def api_flash_reboot():
|
||||
import urllib.request as _ur
|
||||
ip = request.args.get("ip", "").strip()
|
||||
if not ip or not _IP_RE.match(ip):
|
||||
return jsonify({"ok": False, "error": "invalid ip"}), 400
|
||||
try:
|
||||
req = _ur.Request(f"http://{ip}/reboot", data=b"", method="POST")
|
||||
with _ur.urlopen(req, timeout=10) as r:
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 502
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return send_from_directory(WEB_DIR, "index.html")
|
||||
@@ -731,8 +952,12 @@ if __name__ == "__main__":
|
||||
history.init_db()
|
||||
cfg = load_config()
|
||||
with State.lock:
|
||||
State.mqtt_cfg = {k: cfg[k] for k in
|
||||
("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")}
|
||||
State.mqtt_cfg = {k: cfg[k] for k in (
|
||||
"mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass",
|
||||
"price_import", "price_export", "billing_day", "billing_month",
|
||||
"tariff_type", "spot_country", "spot_markup", "spot_chart",
|
||||
"billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month",
|
||||
) if k in cfg}
|
||||
State.inverters_cfg = cfg.get("inverters", [])
|
||||
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
|
||||
State.z2m_base = cfg.get("z2m_base", "zigbee2mqtt")
|
||||
|
||||
@@ -12,12 +12,23 @@ AGG_DEVICE_ID = "shinebridge_aggregate"
|
||||
AGG_TOPIC = "shinebridge/aggregate"
|
||||
|
||||
|
||||
_RC_MESSAGES = {
|
||||
1: "Falsche Protokollversion",
|
||||
2: "Client-ID abgelehnt",
|
||||
3: "Broker nicht verfügbar",
|
||||
4: "Falscher Benutzername oder Passwort",
|
||||
5: "Nicht autorisiert — Credentials prüfen",
|
||||
}
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
def __init__(self, broker: str, port: int, user: str, password: str,
|
||||
agg_meta: Optional[Dict] = None):
|
||||
self._broker = broker
|
||||
self._port = port
|
||||
self._connected = False
|
||||
self._last_error: Optional[str] = None
|
||||
self._auth_failed = False
|
||||
self._registered: List[Tuple] = []
|
||||
self._agg_meta: Dict = agg_meta or {}
|
||||
|
||||
@@ -32,6 +43,8 @@ class MqttPublisher:
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
if rc == 0:
|
||||
self._connected = True
|
||||
self._last_error = None
|
||||
self._auth_failed = False
|
||||
log.info("MQTT verbunden: %s:%d", self._broker, self._port)
|
||||
for entry in self._registered:
|
||||
self._publish_discovery(*entry)
|
||||
@@ -40,11 +53,17 @@ class MqttPublisher:
|
||||
for topic, _ in self._subscriptions:
|
||||
client.subscribe(topic)
|
||||
else:
|
||||
log.error("MQTT Verbindungsfehler rc=%d", rc)
|
||||
msg = _RC_MESSAGES.get(rc, f"Unbekannter Fehler")
|
||||
self._last_error = f"rc={rc}: {msg}"
|
||||
log.error("MQTT Verbindungsfehler %s", self._last_error)
|
||||
if rc == 5:
|
||||
self._auth_failed = True
|
||||
client.loop_stop()
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
self._connected = False
|
||||
log.warning("MQTT getrennt rc=%d", rc)
|
||||
if rc != 0:
|
||||
log.warning("MQTT getrennt rc=%d", rc)
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
for topic, callback in self._subscriptions:
|
||||
@@ -60,6 +79,9 @@ class MqttPublisher:
|
||||
self._client.subscribe(topic)
|
||||
|
||||
def connect(self):
|
||||
if self._auth_failed:
|
||||
log.warning("MQTT connect übersprungen — Authentifizierung fehlgeschlagen")
|
||||
return
|
||||
try:
|
||||
self._client.connect_async(self._broker, self._port, keepalive=60)
|
||||
self._client.loop_start()
|
||||
@@ -74,6 +96,10 @@ class MqttPublisher:
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def last_error(self) -> Optional[str]:
|
||||
return self._last_error
|
||||
|
||||
# ── Gerät-Discovery ──────────────────────────────────────
|
||||
|
||||
def register_inverter(self, inverter: Inverter, device_id: str,
|
||||
|
||||
@@ -36,12 +36,17 @@ class SurplusDeviceController:
|
||||
threshold = float(dev.get("threshold_w", 500))
|
||||
hysteresis = float(dev.get("hysteresis_w", 150))
|
||||
min_on_s = float(dev.get("min_on_minutes", 0)) * 60
|
||||
inverted = bool(dev.get("inverted", False))
|
||||
currently_on = self._states.get(name, False)
|
||||
|
||||
if not currently_on and surplus_w >= threshold:
|
||||
# Invertiert: EIN bei Netzbezug (kein Überschuss), AUS bei Überschuss
|
||||
should_turn_on = not currently_on and (surplus_w >= threshold if not inverted else surplus_w < (threshold - hysteresis))
|
||||
should_turn_off = currently_on and (surplus_w < (threshold - hysteresis) if not inverted else surplus_w >= threshold)
|
||||
|
||||
if should_turn_on:
|
||||
self._send(name, True)
|
||||
self._on_since[name] = now
|
||||
elif currently_on and surplus_w < (threshold - hysteresis):
|
||||
elif should_turn_off:
|
||||
on_since = self._on_since.get(name, 0.0)
|
||||
if now - on_since >= min_on_s:
|
||||
self._send(name, False)
|
||||
|
||||
+664
-40
@@ -13,32 +13,39 @@
|
||||
--radius: 10px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { max-width: 100vw; overflow-x: hidden; }
|
||||
body { background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px; min-height: 100vh; }
|
||||
|
||||
header { display: flex; align-items: center; gap: 12px;
|
||||
padding: 16px 20px; background: var(--surface);
|
||||
header { display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 100; }
|
||||
header h1 { font-size: 16px; font-weight: 600; }
|
||||
header .subtitle { font-size: 12px; color: var(--text-dim); }
|
||||
.status-pill { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
min-width: 0; }
|
||||
header h1 { font-size: 16px; font-weight: 600; white-space: nowrap; }
|
||||
header .subtitle { font-size: 12px; color: var(--text-dim);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.status-pill { margin-left: auto; display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||
.pill { display: flex; align-items: center; gap: 5px; padding: 4px 10px;
|
||||
border-radius: 20px; font-size: 12px; font-weight: 600;
|
||||
background: var(--surface2); border: 1px solid var(--border); }
|
||||
background: var(--surface2); border: 1px solid var(--border); white-space: nowrap; }
|
||||
.pill.ok { color: var(--green); border-color: var(--green); }
|
||||
.pill.err { color: var(--red); border-color: var(--red); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor;
|
||||
animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||
|
||||
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border); }
|
||||
.tab { padding: 10px 16px; cursor: pointer; color: var(--text-dim);
|
||||
main { padding: 16px; max-width: 1100px; margin: 0 auto; }
|
||||
.tabs { display: flex; gap: 2px; margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto; -webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { padding: 10px 14px; cursor: pointer; color: var(--text-dim);
|
||||
font-weight: 500; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px; transition: color .15s, border-color .15s; }
|
||||
margin-bottom: -1px; transition: color .15s, border-color .15s;
|
||||
white-space: nowrap; flex-shrink: 0; }
|
||||
.tab.active { color: var(--accent); border-color: var(--accent); }
|
||||
.tab:hover { color: var(--text); }
|
||||
.panel { display: none; }
|
||||
@@ -137,11 +144,11 @@
|
||||
.info-chip span { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* Energy Dashboard */
|
||||
.energy-wrap { max-width: 580px; margin: 0 auto; }
|
||||
.energy-svg-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; overflow: hidden; }
|
||||
.energy-kwh { display: grid; grid-template-columns: repeat(auto-fit, minmax(95px, 1fr)); gap: 10px; }
|
||||
.kwh-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 8px; text-align: center; }
|
||||
.kwh-card .kv { font-size: 18px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||
.energy-wrap { max-width: 580px; margin: 0 auto; width: 100%; }
|
||||
.energy-svg-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; overflow: hidden; width: 100%; }
|
||||
.energy-kwh { display: grid; grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); gap: 8px; }
|
||||
.kwh-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 6px; text-align: center; min-width: 0; }
|
||||
.kwh-card .kv { font-size: 17px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||
.kwh-card .kl { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: 4px; }
|
||||
.flow-dot { /* dots via animateMotion — no CSS animation needed */ }
|
||||
|
||||
@@ -154,6 +161,49 @@
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
.toast.ok { border-color: var(--green); color: var(--green); }
|
||||
.toast.err { border-color: var(--red); color: var(--red); }
|
||||
|
||||
/* Setup Wizard */
|
||||
.wz-overlay { position:fixed; inset:0; background:rgba(0,0,0,.88); z-index:300;
|
||||
display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||
.wz-box { background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:14px; padding:32px; width:100%; max-width:460px; }
|
||||
.wz-logo { text-align:center; margin-bottom:22px; }
|
||||
.wz-logo h2 { font-size:19px; margin-bottom:4px; }
|
||||
.wz-logo p { color:var(--text-dim); font-size:13px; }
|
||||
.wz-stepper { display:flex; margin-bottom:28px; }
|
||||
.wz-step { flex:1; text-align:center; font-size:11px; font-weight:600;
|
||||
text-transform:uppercase; letter-spacing:.05em; padding:7px 4px;
|
||||
color:var(--text-dim); border-bottom:2px solid var(--border);
|
||||
transition:color .2s, border-color .2s; }
|
||||
.wz-step.active { color:var(--accent); border-color:var(--accent); }
|
||||
.wz-step.done { color:var(--green); border-color:var(--green); }
|
||||
.wz-panel { display:none; }
|
||||
.wz-panel.active { display:block; }
|
||||
.wz-actions { display:flex; justify-content:space-between; align-items:center; margin-top:22px; }
|
||||
.wz-skip { font-size:12px; color:var(--text-dim); cursor:pointer;
|
||||
background:none; border:none; color:var(--text-dim); padding:0; }
|
||||
.wz-skip:hover { color:var(--text); }
|
||||
|
||||
@media (max-width: 520px) {
|
||||
main { padding: 10px; }
|
||||
.settings-section { max-width: 100%; }
|
||||
.sensor-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||
.inv-list { grid-template-columns: 1fr; }
|
||||
.kwh-card .kv { font-size: 15px; }
|
||||
.flash-section { max-width: 100% !important; }
|
||||
header svg { display: none; }
|
||||
}
|
||||
|
||||
/* Flash-Wizard */
|
||||
.flash-section { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px; max-width:560px; }
|
||||
.flash-section h3 { font-size:13px; font-weight:600; color:var(--text-dim); text-transform:uppercase; letter-spacing:.06em; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--border); }
|
||||
.flash-code { font-family:monospace; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:10px 14px; margin:6px 0; word-break:break-all; }
|
||||
.flash-step { display:flex; gap:10px; margin-bottom:14px; align-items:flex-start; }
|
||||
.flash-step-num { width:22px; height:22px; min-width:22px; border-radius:50%; background:var(--accent); color:#000; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; }
|
||||
.flash-pinout { font-family:monospace; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:10px 14px; line-height:1.9; white-space:pre; }
|
||||
.flash-progress { width:100%; height:6px; background:var(--surface2); border-radius:3px; overflow:hidden; margin:12px 0 6px; }
|
||||
.flash-progress-bar { height:100%; background:var(--accent); border-radius:3px; transition:width .4s; }
|
||||
.flash-status-msg { font-size:12px; color:var(--text-dim); min-height:18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -175,9 +225,11 @@
|
||||
<main>
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('energy')">Energie</div>
|
||||
<div class="tab" onclick="switchTab('finance')">Finanzen</div>
|
||||
<div class="tab" onclick="switchTab('live')">Live-Daten</div>
|
||||
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
|
||||
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
|
||||
<div class="tab" onclick="switchTab('flash')">Flash</div>
|
||||
</div>
|
||||
|
||||
<!-- Energy Panel -->
|
||||
@@ -185,6 +237,11 @@
|
||||
<div id="energy-content"><div class="no-data">Warte auf erste Messung…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Finance Panel -->
|
||||
<div class="panel" id="panel-finance">
|
||||
<div id="finance-content"><div class="no-data">Lade…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Live Panel -->
|
||||
<div class="panel" id="panel-live">
|
||||
<div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div>
|
||||
@@ -199,6 +256,7 @@
|
||||
<div class="panel" id="panel-settings">
|
||||
<div class="settings-section">
|
||||
<h3>MQTT Broker</h3>
|
||||
<div id="mqtt-error-banner" style="display:none;background:var(--red,#c0392b);color:#fff;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:.9rem;line-height:1.4"></div>
|
||||
<div class="field"><label>Broker</label>
|
||||
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto"></div>
|
||||
<div class="field"><label>Port</label>
|
||||
@@ -254,6 +312,23 @@
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">z.B. 1 . 4 = 01. April</div>
|
||||
</div>
|
||||
<div class="field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:4px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
||||
<input type="checkbox" id="cfg-billing-tracker" style="width:auto;margin:0" onchange="updateBillingTrackerUI()">
|
||||
<label for="cfg-billing-tracker" style="margin:0;cursor:pointer;font-size:13px;font-weight:600">Abschlags-Tracker aktivieren</label>
|
||||
</div>
|
||||
<div id="billing-tracker-fields" style="display:none">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<div><label style="font-size:11px;color:var(--text-dim)">Monatliche Rate (€)</label>
|
||||
<input type="number" id="cfg-monthly-rate" step="0.01" min="0" placeholder="120.00">
|
||||
</div>
|
||||
<div><label style="font-size:11px;color:var(--text-dim)">Grundpreis pro Monat (€)</label>
|
||||
<input type="number" id="cfg-grundpreis" step="0.01" min="0" placeholder="12.50">
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-dim);margin-top:6px">Zeigt Nachzahlung / Guthaben für das laufende Abrechnungsjahr.</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="savePrices()">Speichern</button>
|
||||
</div>
|
||||
|
||||
@@ -282,6 +357,100 @@
|
||||
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Panel -->
|
||||
<div class="panel" id="panel-flash">
|
||||
|
||||
<!-- Probe / IP input -->
|
||||
<div class="flash-section">
|
||||
<h3>ShineLAN-X Stick</h3>
|
||||
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||||
<input type="text" id="flash-ip" placeholder="192.168.1.xxx"
|
||||
style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:8px 10px;outline:none"
|
||||
onkeydown="if(event.key==='Enter')flashProbe()">
|
||||
<button class="btn btn-secondary" onclick="flashProbe()">Verbinden</button>
|
||||
</div>
|
||||
<div id="flash-probe-status" style="font-size:13px;color:var(--text-dim)">IP eingeben und auf Verbinden klicken.</div>
|
||||
</div>
|
||||
|
||||
<!-- OTA mode -->
|
||||
<div class="flash-section" id="flash-ota-section" style="display:none">
|
||||
<h3>OTA-Update</h3>
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="display:flex;gap:16px;margin-bottom:12px;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
||||
<input type="radio" name="flash-fw-src" value="bundled" id="flash-fw-bundled" onchange="flashFwSourceChange()" checked>
|
||||
Integrierte Firmware
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
|
||||
<input type="radio" name="flash-fw-src" value="custom" id="flash-fw-custom" onchange="flashFwSourceChange()">
|
||||
Eigene Datei
|
||||
</label>
|
||||
</div>
|
||||
<div id="flash-fw-bundled-ui">
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label>Firmware</label>
|
||||
<select id="flash-fw-select" style="width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:8px 10px;outline:none"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="flash-fw-custom-ui" style="display:none">
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label>Firmware-Datei (.bin)</label>
|
||||
<input type="file" id="flash-fw-file" accept=".bin" style="color:var(--text);font-size:13px">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="flash-progress-wrap" style="display:none">
|
||||
<div class="flash-progress"><div class="flash-progress-bar" id="flash-progress-bar" style="width:0%"></div></div>
|
||||
<div class="flash-status-msg" id="flash-status-msg"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||
<button class="btn btn-primary" id="flash-start-btn" onclick="flashStart()">Firmware flashen</button>
|
||||
<button class="btn" onclick="flashReboot()">Neu starten</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ST-Link first-flash guide (always visible) -->
|
||||
<div class="flash-section" id="flash-stlink-section">
|
||||
<h3>Erstflash via ST-Link</h3>
|
||||
<p style="color:var(--text-dim);font-size:13px;margin-bottom:16px">Wenn der Stick noch nie geflasht wurde, ist OTA noch nicht verfügbar. Einen ST-Link v2 und <code style="font-size:11px;background:var(--surface2);padding:1px 5px;border-radius:3px">stlink-tools</code> werden benötigt.</p>
|
||||
|
||||
<div class="flash-step">
|
||||
<div class="flash-step-num">1</div>
|
||||
<div>
|
||||
<div style="font-weight:600;margin-bottom:8px">SWD-Verbindung herstellen</div>
|
||||
<div class="flash-pinout">PA13 → SWDIO
|
||||
PA14 → SWCLK
|
||||
GND → GND
|
||||
3.3V → 3.3V (falls nicht extern versorgt)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flash-step">
|
||||
<div class="flash-step-num">2</div>
|
||||
<div>
|
||||
<div style="font-weight:600;margin-bottom:8px">Flash-Kommando ausführen</div>
|
||||
<div class="flash-code">st-flash --reset write nuttx-mbusd-shinelanx.bin 0x08000000</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:6px">Datei im ShineLAN-X Releases-Ordner des Repos oder als GitHub-Release.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flash-step">
|
||||
<div class="flash-step-num">3</div>
|
||||
<div>
|
||||
<div style="font-weight:600;margin-bottom:6px">Auf DHCP-Adresse warten</div>
|
||||
<div style="font-size:13px;color:var(--text-dim)">Nach dem Neustart erscheint der Stick im Netz. IP oben eintragen → Verbinden — danach sind OTA-Updates möglich.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:4px;padding:10px 14px;background:var(--surface2);border-radius:6px;font-size:12px;color:var(--text-dim)">
|
||||
Nach dem Erstflash empfiehlt sich ein OTA-Update auf die DFU-Variante
|
||||
(<code style="font-size:11px;background:var(--bg);padding:1px 5px;border-radius:3px">nuttx-mbusd-shinelanx-dfu.bin</code>),
|
||||
die zukünftige OTA-Updates ohne ST-Link ermöglicht.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Inverter Edit Modal -->
|
||||
@@ -335,6 +504,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Wizard -->
|
||||
<div id="wizard-overlay" class="wz-overlay" style="display:none">
|
||||
<div class="wz-box">
|
||||
<div class="wz-logo">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" style="margin-bottom:8px">
|
||||
<circle cx="12" cy="12" r="5" fill="#f0c040"/>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"
|
||||
stroke="#f0c040" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<h2>Willkommen bei ShineBridge</h2>
|
||||
<p>Schnelle Einrichtung — 3 Schritte</p>
|
||||
</div>
|
||||
<div class="wz-stepper">
|
||||
<div class="wz-step active" id="wz-step-1">1 · MQTT</div>
|
||||
<div class="wz-step" id="wz-step-2">2 · Wechselrichter</div>
|
||||
<div class="wz-step" id="wz-step-3">3 · Fertig</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: MQTT -->
|
||||
<div class="wz-panel active" id="wz-panel-1">
|
||||
<div class="field"><label>MQTT Broker</label>
|
||||
<input type="text" id="wz-mqtt-broker" placeholder="core-mosquitto"></div>
|
||||
<div class="field"><label>Port</label>
|
||||
<input type="number" id="wz-mqtt-port" value="1883"></div>
|
||||
<div class="field"><label>Benutzername <span style="color:var(--text-dim);font-weight:400">(optional)</span></label>
|
||||
<input type="text" id="wz-mqtt-user" autocomplete="off"></div>
|
||||
<div class="field"><label>Passwort <span style="color:var(--text-dim);font-weight:400">(optional)</span></label>
|
||||
<input type="password" id="wz-mqtt-pass"></div>
|
||||
<div class="wz-actions">
|
||||
<button class="wz-skip" onclick="wizardSkip()">Überspringen</button>
|
||||
<button class="btn btn-primary" onclick="wizardStep1Next()">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Inverter -->
|
||||
<div class="wz-panel" id="wz-panel-2">
|
||||
<div class="field"><label>Name</label>
|
||||
<input type="text" id="wz-inv-name" placeholder="z.B. Dach Süd"></div>
|
||||
<div class="field"><label>Gerätetyp</label>
|
||||
<select id="wz-inv-model"></select></div>
|
||||
<div class="field"><label>IP-Adresse (ShineLAN-X)</label>
|
||||
<input type="text" id="wz-inv-ip" placeholder="192.168.1.100"></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<div class="field"><label>Modbus Port</label>
|
||||
<input type="number" id="wz-inv-port" value="502"></div>
|
||||
<div class="field"><label>Slave-Adresse</label>
|
||||
<input type="number" id="wz-inv-addr" value="1" min="1" max="247"></div>
|
||||
</div>
|
||||
<div class="wz-actions">
|
||||
<button class="btn" onclick="wizardGoStep(1)">← Zurück</button>
|
||||
<button class="btn btn-primary" onclick="wizardStep2Next()">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Done -->
|
||||
<div class="wz-panel" id="wz-panel-3">
|
||||
<div style="text-align:center;padding:20px 0 10px">
|
||||
<div style="font-size:52px;margin-bottom:14px;line-height:1">✓</div>
|
||||
<p style="font-size:15px;font-weight:600;margin-bottom:8px">Einrichtung abgeschlossen!</p>
|
||||
<p style="color:var(--text-dim);font-size:13px;line-height:1.6">
|
||||
ShineBridge verbindet sich jetzt mit MQTT und deinem Wechselrichter.<br>
|
||||
Die ersten Messwerte erscheinen nach wenigen Sekunden.
|
||||
</p>
|
||||
</div>
|
||||
<div class="wz-actions" style="justify-content:center;margin-top:28px">
|
||||
<button class="btn btn-primary" onclick="wizardFinish()">Los geht's →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<datalist id="z2m-device-list"></datalist>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
@@ -413,11 +653,12 @@ function showToast(msg, type) {
|
||||
}
|
||||
|
||||
function switchTab(name) {
|
||||
["energy","live","inverters","settings"].forEach((t, i) => {
|
||||
["energy","finance","live","inverters","settings","flash"].forEach((t, i) => {
|
||||
document.querySelectorAll(".tab")[i].classList.toggle("active", t === name);
|
||||
document.querySelectorAll(".panel")[i].classList.toggle("active", t === name);
|
||||
});
|
||||
if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
|
||||
if (name === "finance") loadFinance();
|
||||
}
|
||||
|
||||
// ── Energy Dashboard ──────────────────────────────────────────
|
||||
@@ -472,6 +713,15 @@ function renderSpotChart(spotData) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fEur(v) {
|
||||
if (v == null) return null;
|
||||
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
|
||||
}
|
||||
function fKwh(v) {
|
||||
if (v == null) return null;
|
||||
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh';
|
||||
}
|
||||
|
||||
function renderEnergy(inverters, aggregates, period, spotData) {
|
||||
const el = document.getElementById("energy-content");
|
||||
if (!aggregates || !Object.keys(aggregates).length) {
|
||||
@@ -607,15 +857,6 @@ function renderEnergy(inverters, aggregates, period, spotData) {
|
||||
const pi = period.price_import || 0.30;
|
||||
const pe = period.price_export || 0.08;
|
||||
|
||||
function fEur(v) {
|
||||
if (v == null) return null;
|
||||
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
|
||||
}
|
||||
function fKwh(v) {
|
||||
if (v == null) return null;
|
||||
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh';
|
||||
}
|
||||
|
||||
function periodCard(label, kwh, cost, col, sub) {
|
||||
if (kwh == null) return '';
|
||||
return `<div class="kwh-card" style="border-top:3px solid ${col}">
|
||||
@@ -648,12 +889,160 @@ function renderEnergy(inverters, aggregates, period, spotData) {
|
||||
|
||||
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : '';
|
||||
|
||||
const bt = period.billing_tracker;
|
||||
const trackerHtml = bt ? (() => {
|
||||
const nz = bt.nachzahlung_eur;
|
||||
const isNachzahlung = nz > 0;
|
||||
const nzColor = isNachzahlung ? '#e05c5c' : '#4caf82';
|
||||
const nzLabel = isNachzahlung ? 'Voraussichtliche Nachzahlung' : 'Voraussichtliches Guthaben';
|
||||
const nzSign = isNachzahlung ? '+' : '−';
|
||||
return `<div style="margin-top:16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px">
|
||||
<div style="font-size:10px;font-weight:700;letter-spacing:.08em;color:var(--text-dim);text-transform:uppercase;margin-bottom:12px">Abschlags-Übersicht · ${bt.payments_made} Zahlung${bt.payments_made !== 1 ? 'en' : ''}</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px">
|
||||
<div class="kwh-card">
|
||||
<div class="kv">${fEur(bt.total_paid_eur)}</div>
|
||||
<div class="kl">Bereits bezahlt<br><span style="opacity:.6">${bt.payments_made} × ${fEur(bt.monthly_rate_eur)}</span></div>
|
||||
</div>
|
||||
<div class="kwh-card">
|
||||
<div class="kv">${fEur(bt.grundpreis_total_eur)}</div>
|
||||
<div class="kl">Grundpreis anteilig<br><span style="opacity:.6">${bt.payments_made} × ${fEur(bt.grundpreis_eur_per_month)}</span></div>
|
||||
</div>
|
||||
<div class="kwh-card">
|
||||
<div class="kv">${fEur(bt.energy_cost_eur)}</div>
|
||||
<div class="kl">Energiekosten<br><span style="opacity:.6">Netzbezug</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;background:var(--bg);border-radius:8px;padding:12px 16px">
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--text-dim)">${nzLabel}</div>
|
||||
<div style="font-size:11px;color:var(--text-dim);margin-top:2px">Gesamtkosten ${fEur(bt.total_cost_eur)} − Bezahlt ${fEur(bt.total_paid_eur)}</div>
|
||||
</div>
|
||||
<div style="font-size:24px;font-weight:700;color:${nzColor};font-variant-numeric:tabular-nums">${nzSign}${fEur(Math.abs(nz))}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
})() : '';
|
||||
|
||||
el.innerHTML = `<div class="energy-wrap">
|
||||
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
|
||||
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
|
||||
${trackerHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Finance Tab ───────────────────────────────────────────────
|
||||
|
||||
async function loadFinance() {
|
||||
const el = document.getElementById("finance-content");
|
||||
let data;
|
||||
try { data = await fetchJSON(api("api/finance")); }
|
||||
catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; return; }
|
||||
|
||||
try {
|
||||
const { days, fixed_total_eur, spot_total_eur, savings_eur, period_start, spot_days, total_days } = data;
|
||||
|
||||
if (!days || days.length === 0) {
|
||||
el.innerHTML = `<div style="text-align:center;padding:40px;color:var(--text-dim)">
|
||||
<div style="font-size:32px;margin-bottom:12px">📊</div>
|
||||
<div style="font-weight:600;margin-bottom:6px">Noch keine Daten</div>
|
||||
<div style="font-size:13px">Der Tracker sammelt ab morgen täglich Daten.<br>Abrechnungsjahr ab ${period_start}.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const C = { fixed: '#58a6ff', spot: '#f0883e', grid: '#30363d', green: '#4caf82', red: '#e05c5c' };
|
||||
|
||||
// ── Empfehlung ───────────────────────────────────────────────
|
||||
let empfehlung = '';
|
||||
if (spot_total_eur !== null && spot_days >= 7) {
|
||||
const lohnt = savings_eur > 0;
|
||||
const col = lohnt ? C.green : C.red;
|
||||
const icon = lohnt ? '✓' : '✗';
|
||||
empfehlung = `<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid ${col};border-radius:var(--radius);padding:18px 22px;margin-bottom:24px">
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:15px;color:${col}">${icon} Flexibler Tarif würde sich ${lohnt ? 'lohnen' : 'nicht lohnen'}</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-top:5px">Basierend auf ${spot_days} Tagen im Abrechnungsjahr</div>
|
||||
</div>
|
||||
<div style="font-size:26px;font-weight:700;color:${col};margin-left:20px;white-space:nowrap">${lohnt ? '−' : '+'}${fEur(Math.abs(savings_eur))}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Summary-Karten ───────────────────────────────────────────
|
||||
const spotCard = spot_total_eur !== null
|
||||
? `<div class="kwh-card" style="border-top:3px solid ${C.spot};padding:18px 10px">
|
||||
<div class="kv" style="color:${C.spot};font-size:22px">${fEur(spot_total_eur)}</div>
|
||||
<div class="kl" style="font-size:11px;margin-top:6px">Spot-Tarif (hypothetisch)<br><span style="opacity:.6">${spot_days} Tage mit Preisdaten</span></div>
|
||||
</div>`
|
||||
: `<div class="kwh-card" style="padding:18px 10px"><div class="kv" style="color:var(--text-dim);font-size:22px">–</div><div class="kl" style="font-size:11px;margin-top:6px">Spot-Tarif<br><span style="opacity:.6">Noch keine Daten</span></div></div>`;
|
||||
|
||||
const savCard = savings_eur !== null
|
||||
? `<div class="kwh-card" style="border-top:3px solid ${savings_eur > 0 ? C.green : C.red};padding:18px 10px">
|
||||
<div class="kv" style="color:${savings_eur > 0 ? C.green : C.red};font-size:22px">${savings_eur > 0 ? '−' : '+'}${fEur(Math.abs(savings_eur))}</div>
|
||||
<div class="kl" style="font-size:11px;margin-top:6px">${savings_eur > 0 ? 'Ersparnis mit Spot' : 'Mehrkosten mit Spot'}<br><span style="opacity:.6">gegenüber Festpreis</span></div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const cards = `
|
||||
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:10px">Kostenübersicht · Abrechnungsjahr</div>
|
||||
<div class="energy-kwh" style="margin-bottom:28px;gap:12px">
|
||||
<div class="kwh-card" style="border-top:3px solid ${C.fixed};padding:18px 10px">
|
||||
<div class="kv" style="color:${C.fixed};font-size:22px">${fEur(fixed_total_eur)}</div>
|
||||
<div class="kl" style="font-size:11px;margin-top:6px">Festpreis-Kosten<br><span style="opacity:.6">${total_days} Tage</span></div>
|
||||
</div>
|
||||
${spotCard}${savCard}
|
||||
</div>`;
|
||||
|
||||
// ── SVG-Balkendiagramm ────────────────────────────────────────
|
||||
const W = 600, H = 180, PL = 44, PR = 12, PT = 10, PB = 28;
|
||||
const cW = W - PL - PR;
|
||||
const cH = H - PT - PB;
|
||||
const n = days.length;
|
||||
const barW = Math.max(2, Math.floor(cW / n) - 2);
|
||||
const gap = Math.max(1, Math.floor(cW / n) - barW);
|
||||
|
||||
const allVals = days.flatMap(d => [d.fixed_eur, d.spot_eur].filter(v => v != null));
|
||||
const maxVal = Math.max(...allVals, 0.01);
|
||||
|
||||
const yTicks = 4;
|
||||
let gridLines = '', yLabels = '';
|
||||
for (let i = 0; i <= yTicks; i++) {
|
||||
const v = maxVal * i / yTicks;
|
||||
const y = PT + cH - (cH * i / yTicks);
|
||||
gridLines += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="${C.grid}" stroke-width="0.5"/>`;
|
||||
yLabels += `<text x="${PL-4}" y="${(y+4).toFixed(1)}" text-anchor="end" font-size="9" fill="#8b949e">${fEur(v)}</text>`;
|
||||
}
|
||||
|
||||
let bars = '', xLabels = '';
|
||||
days.forEach((d, i) => {
|
||||
const x0 = PL + i * (barW + gap);
|
||||
const hFixed = cH * d.fixed_eur / maxVal;
|
||||
bars += `<rect x="${x0}" y="${(PT + cH - hFixed).toFixed(1)}" width="${barW/2}" height="${hFixed.toFixed(1)}" fill="${C.fixed}" opacity="0.85"/>`;
|
||||
if (d.spot_eur != null) {
|
||||
const hSpot = cH * d.spot_eur / maxVal;
|
||||
bars += `<rect x="${x0 + barW/2}" y="${(PT + cH - hSpot).toFixed(1)}" width="${barW/2}" height="${hSpot.toFixed(1)}" fill="${C.spot}" opacity="0.85"/>`;
|
||||
}
|
||||
if (i % Math.max(1, Math.floor(n / 8)) === 0) {
|
||||
const label = d.date.slice(5);
|
||||
xLabels += `<text x="${(x0 + barW/2).toFixed(1)}" y="${H - 4}" text-anchor="middle" font-size="9" fill="#8b949e">${label}</text>`;
|
||||
}
|
||||
});
|
||||
|
||||
const legend = `<text x="${PL}" y="${H+18}" font-size="10" fill="${C.fixed}">■ Festpreis</text>
|
||||
<text x="${PL+70}" y="${H+18}" font-size="10" fill="${C.spot}">■ Spot (hypothetisch)</text>`;
|
||||
|
||||
const chartSection = `
|
||||
<div style="border-top:1px solid var(--border);padding-top:24px;margin-top:4px">
|
||||
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:14px">Kosten je Tag</div>
|
||||
<div style="overflow-x:auto">
|
||||
<svg viewBox="0 0 ${W} ${H+24}" style="width:100%;max-width:${W}px;display:block">
|
||||
${gridLines}${yLabels}${bars}${xLabels}${legend}
|
||||
</svg>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = `<div style="padding:4px 0">${empfehlung}${cards}${chartSection}</div>`;
|
||||
} catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; console.error('loadFinance:', e); }
|
||||
}
|
||||
|
||||
// ── Live Data ─────────────────────────────────────────────────
|
||||
|
||||
async function refreshData() {
|
||||
@@ -894,10 +1283,22 @@ function updateTariffUI() {
|
||||
document.getElementById("tariff-spot-fields").style.display = isSpot ? "" : "none";
|
||||
}
|
||||
|
||||
function updateBillingTrackerUI() {
|
||||
const enabled = document.getElementById("cfg-billing-tracker").checked;
|
||||
document.getElementById("billing-tracker-fields").style.display = enabled ? "" : "none";
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const cfg = await fetchJSON(api("api/config"));
|
||||
globalConfig = cfg;
|
||||
loadSurplusDevices();
|
||||
const errBanner = document.getElementById("mqtt-error-banner");
|
||||
if (cfg.mqtt_error) {
|
||||
errBanner.textContent = "⚠ MQTT Fehler: " + cfg.mqtt_error;
|
||||
errBanner.style.display = "";
|
||||
} else {
|
||||
errBanner.style.display = "none";
|
||||
}
|
||||
document.getElementById("cfg-mqtt-broker").value = cfg.mqtt_broker || "";
|
||||
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
|
||||
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
|
||||
@@ -910,6 +1311,11 @@ async function loadSettings() {
|
||||
document.getElementById("cfg-spot-chart").checked = cfg.spot_chart ?? true;
|
||||
document.getElementById((cfg.tariff_type ?? "fixed") === "spot" ? "tariff-spot" : "tariff-fixed").checked = true;
|
||||
updateTariffUI();
|
||||
const trackerEnabled = cfg.billing_tracker_enabled ?? false;
|
||||
document.getElementById("cfg-billing-tracker").checked = trackerEnabled;
|
||||
document.getElementById("billing-tracker-fields").style.display = trackerEnabled ? "" : "none";
|
||||
document.getElementById("cfg-monthly-rate").value = cfg.monthly_rate_eur ?? 0;
|
||||
document.getElementById("cfg-grundpreis").value = cfg.grundpreis_eur_per_month ?? 0;
|
||||
}
|
||||
|
||||
async function savePrices() {
|
||||
@@ -921,8 +1327,11 @@ async function savePrices() {
|
||||
spot_markup: parseFloat(document.getElementById("cfg-spot-markup").value || 0),
|
||||
spot_country: document.getElementById("cfg-spot-country").value,
|
||||
spot_chart: document.getElementById("cfg-spot-chart").checked,
|
||||
billing_day: parseInt(document.getElementById("cfg-billing-day").value),
|
||||
billing_month: parseInt(document.getElementById("cfg-billing-month").value),
|
||||
billing_day: parseInt(document.getElementById("cfg-billing-day").value) || 1,
|
||||
billing_month: parseInt(document.getElementById("cfg-billing-month").value) || 1,
|
||||
billing_tracker_enabled: document.getElementById("cfg-billing-tracker").checked,
|
||||
monthly_rate_eur: parseFloat(document.getElementById("cfg-monthly-rate").value || 0),
|
||||
grundpreis_eur_per_month: parseFloat(document.getElementById("cfg-grundpreis").value || 0),
|
||||
};
|
||||
try {
|
||||
await fetchJSON(api("api/config"), {
|
||||
@@ -988,19 +1397,31 @@ async function importConfig(input) {
|
||||
|
||||
let surplusDevices = [];
|
||||
|
||||
function field(label, input) {
|
||||
return `<div style="display:flex;flex-direction:column;gap:3px">
|
||||
<span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap">${label}</span>
|
||||
${input}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSurplusDeviceRow(dev) {
|
||||
const id = dev.id || ('sd_' + Math.random().toString(36).slice(2));
|
||||
return `<div class="surplus-device-row" data-id="${esc(id)}">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<input type="text" placeholder="Name" style="flex:1;min-width:110px" value="${esc(dev.name||'')}" data-field="name">
|
||||
<input type="text" placeholder="Z2M Friendly Name" list="z2m-device-list" style="flex:1;min-width:140px" value="${esc(dev.z2m_name||'')}" data-field="z2m_name">
|
||||
<input type="number" placeholder="Ab (W)" title="Schwellwert" style="width:76px" value="${dev.threshold_w||500}" min="0" step="50" data-field="threshold_w">
|
||||
<input type="number" placeholder="Hysterese (W)" title="Hysterese" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w">
|
||||
<input type="number" placeholder="Min. Laufzeit (min)" title="Mindest-Laufzeit in Minuten" style="width:110px" value="${dev.min_on_minutes||0}" min="0" step="5" data-field="min_on_minutes">
|
||||
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap">
|
||||
<input type="checkbox" ${dev.enabled!==false?'checked':''} data-field="enabled" style="width:auto;margin:0"> Aktiv
|
||||
</label>
|
||||
<button class="btn btn-secondary" style="padding:3px 8px;font-size:12px;flex-shrink:0" onclick="removeSurplusDevice(this)">✕</button>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||||
${field('Name', `<input type="text" placeholder="Heizstab Keller" style="width:130px" value="${esc(dev.name||'')}" data-field="name">`)}
|
||||
${field('Z2M Friendly Name', `<input type="text" placeholder="heizstab-keller" list="z2m-device-list" style="width:160px" value="${esc(dev.z2m_name||'')}" data-field="z2m_name">`)}
|
||||
${field('Schwellwert (W)', `<input type="number" style="width:90px" value="${dev.threshold_w||500}" min="0" step="50" data-field="threshold_w">`)}
|
||||
${field('Hysterese (W)', `<input type="number" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w">`)}
|
||||
${field('Min. Laufzeit (min)', `<input type="number" style="width:90px" value="${dev.min_on_minutes||0}" min="0" step="5" data-field="min_on_minutes">`)}
|
||||
<div style="display:flex;flex-direction:column;gap:6px;padding-bottom:2px">
|
||||
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap">
|
||||
<input type="checkbox" ${dev.enabled!==false?'checked':''} data-field="enabled" style="width:auto;margin:0"> Aktiv
|
||||
</label>
|
||||
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap" title="AUS bei Überschuss, EIN bei Netzbezug">
|
||||
<input type="checkbox" ${dev.inverted?'checked':''} data-field="inverted" style="width:auto;margin:0"> Invertiert
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="padding:3px 8px;font-size:12px;flex-shrink:0;align-self:flex-end" onclick="removeSurplusDevice(this)">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1044,6 +1465,7 @@ function _collectSurplusDevices() {
|
||||
threshold_w: parseFloat(row.querySelector('[data-field=threshold_w]').value) || 0,
|
||||
hysteresis_w: parseFloat(row.querySelector('[data-field=hysteresis_w]').value) || 0,
|
||||
min_on_minutes: parseFloat(row.querySelector('[data-field=min_on_minutes]').value) || 0,
|
||||
inverted: row.querySelector('[data-field=inverted]').checked,
|
||||
enabled: row.querySelector('[data-field=enabled]').checked,
|
||||
}));
|
||||
}
|
||||
@@ -1086,7 +1508,7 @@ function renderSurplusStatus(surplusData) {
|
||||
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0">
|
||||
${dot}
|
||||
<span>${esc(d.name||d.z2m_name)}</span>
|
||||
<span style="color:var(--text-dim);font-size:11px">ab ${d.threshold_w}W</span>
|
||||
<span style="color:var(--text-dim);font-size:11px">${d.inverted ? '↓' : '↑'} ${d.threshold_w}W</span>
|
||||
${info}
|
||||
<span style="margin-left:auto;font-weight:600;color:${on?'var(--green)':'var(--text-dim)'}">${on?'EIN':'AUS'}</span>
|
||||
</div>`;
|
||||
@@ -1106,10 +1528,212 @@ function renderSurplusStatus(surplusData) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Flash-Wizard ──────────────────────────────────────────────
|
||||
|
||||
function flashFwSourceChange() {
|
||||
const bundled = document.getElementById("flash-fw-bundled").checked;
|
||||
document.getElementById("flash-fw-bundled-ui").style.display = bundled ? "" : "none";
|
||||
document.getElementById("flash-fw-custom-ui").style.display = bundled ? "none" : "";
|
||||
}
|
||||
|
||||
async function flashProbe() {
|
||||
const ip = document.getElementById("flash-ip").value.trim();
|
||||
const statusEl = document.getElementById("flash-probe-status");
|
||||
const otaSection = document.getElementById("flash-ota-section");
|
||||
if (!ip) { showToast("IP-Adresse eingeben", "err"); return; }
|
||||
statusEl.textContent = "Verbinde...";
|
||||
statusEl.style.color = "var(--text-dim)";
|
||||
try {
|
||||
const r = await fetchJSON(api(`api/flash/probe?ip=${encodeURIComponent(ip)}`));
|
||||
if (r.ota) {
|
||||
statusEl.textContent = "✓ OTA-Modus aktiv";
|
||||
statusEl.style.color = "var(--green)";
|
||||
otaSection.style.display = "";
|
||||
await flashLoadFirmwareList();
|
||||
} else {
|
||||
statusEl.textContent = "✗ Nicht erreichbar — ST-Link-Erstflash erforderlich";
|
||||
statusEl.style.color = "var(--red)";
|
||||
otaSection.style.display = "none";
|
||||
}
|
||||
} catch(e) {
|
||||
statusEl.textContent = "Verbindungsfehler";
|
||||
statusEl.style.color = "var(--red)";
|
||||
otaSection.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
async function flashLoadFirmwareList() {
|
||||
try {
|
||||
const r = await fetchJSON(api("api/flash/firmware"));
|
||||
const sel = document.getElementById("flash-fw-select");
|
||||
sel.innerHTML = "";
|
||||
if (r.files && r.files.length) {
|
||||
r.files.forEach(f => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = f; opt.textContent = f;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
const dfu = r.files.find(f => f.includes("dfu"));
|
||||
if (dfu) sel.value = dfu;
|
||||
} else {
|
||||
sel.innerHTML = '<option value="">Keine integrierten Firmwares</option>';
|
||||
document.getElementById("flash-fw-custom").checked = true;
|
||||
flashFwSourceChange();
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function flashStart() {
|
||||
const ip = document.getElementById("flash-ip").value.trim();
|
||||
if (!ip) { showToast("IP-Adresse eingeben", "err"); return; }
|
||||
const bundled = document.getElementById("flash-fw-bundled").checked;
|
||||
const progressWrap = document.getElementById("flash-progress-wrap");
|
||||
const progressBar = document.getElementById("flash-progress-bar");
|
||||
const statusMsg = document.getElementById("flash-status-msg");
|
||||
const startBtn = document.getElementById("flash-start-btn");
|
||||
|
||||
progressWrap.style.display = "";
|
||||
progressBar.style.background = "var(--accent)";
|
||||
progressBar.style.width = "0%";
|
||||
startBtn.disabled = true;
|
||||
|
||||
let url, opts;
|
||||
if (bundled) {
|
||||
const fw = document.getElementById("flash-fw-select").value;
|
||||
if (!fw) { showToast("Keine Firmware ausgewählt", "err"); startBtn.disabled = false; return; }
|
||||
url = api(`api/flash/update?ip=${encodeURIComponent(ip)}&fw=${encodeURIComponent(fw)}`);
|
||||
opts = { method: "POST" };
|
||||
statusMsg.textContent = `Übertrage ${fw} …`;
|
||||
} else {
|
||||
const file = document.getElementById("flash-fw-file").files[0];
|
||||
if (!file) { showToast("Datei auswählen", "err"); startBtn.disabled = false; return; }
|
||||
url = api(`api/flash/update?ip=${encodeURIComponent(ip)}`);
|
||||
opts = { method: "POST", body: await file.arrayBuffer(),
|
||||
headers: {"Content-Type": "application/octet-stream"} };
|
||||
statusMsg.textContent = `Übertrage ${file.name} …`;
|
||||
}
|
||||
|
||||
progressBar.style.width = "25%";
|
||||
try {
|
||||
const resp = await fetch(url, opts);
|
||||
progressBar.style.width = "70%";
|
||||
const result = await resp.json();
|
||||
if (result.ok) {
|
||||
statusMsg.textContent = "Warte auf Neustart …";
|
||||
progressBar.style.width = "85%";
|
||||
await flashPollReady(ip);
|
||||
progressBar.style.width = "100%";
|
||||
progressBar.style.background = "var(--green)";
|
||||
statusMsg.textContent = "✓ Update abgeschlossen — Stick ist wieder online.";
|
||||
showToast("Firmware erfolgreich geflasht", "ok");
|
||||
} else {
|
||||
progressBar.style.width = "100%";
|
||||
progressBar.style.background = "var(--red)";
|
||||
statusMsg.textContent = `Fehler: ${result.error || "Unbekannt"}`;
|
||||
showToast("Flash fehlgeschlagen", "err");
|
||||
}
|
||||
} catch(e) {
|
||||
progressBar.style.background = "var(--red)";
|
||||
statusMsg.textContent = `Verbindungsfehler: ${e.message}`;
|
||||
showToast("Flash fehlgeschlagen", "err");
|
||||
}
|
||||
startBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function flashPollReady(ip, maxSec = 90) {
|
||||
const start = Date.now();
|
||||
while ((Date.now() - start) / 1000 < maxSec) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
try {
|
||||
const r = await fetchJSON(api(`api/flash/probe?ip=${encodeURIComponent(ip)}`));
|
||||
if (r.ota) return;
|
||||
} catch(e) { /* still booting */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function flashReboot() {
|
||||
const ip = document.getElementById("flash-ip").value.trim();
|
||||
if (!ip) { showToast("IP-Adresse eingeben", "err"); return; }
|
||||
try {
|
||||
await fetch(api(`api/flash/reboot?ip=${encodeURIComponent(ip)}`), { method: "POST" });
|
||||
showToast("Neustart-Befehl gesendet", "ok");
|
||||
} catch(e) { showToast("Neustart fehlgeschlagen", "err"); }
|
||||
}
|
||||
|
||||
// ── Setup Wizard ──────────────────────────────────────────────
|
||||
|
||||
function wizardGoStep(n) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
document.getElementById(`wz-panel-${i}`).classList.toggle("active", i === n);
|
||||
const el = document.getElementById(`wz-step-${i}`);
|
||||
el.classList.toggle("active", i === n);
|
||||
el.classList.toggle("done", i < n);
|
||||
}
|
||||
}
|
||||
|
||||
async function wizardStep1Next() {
|
||||
const body = {
|
||||
mqtt_broker: document.getElementById("wz-mqtt-broker").value.trim() || "core-mosquitto",
|
||||
mqtt_port: parseInt(document.getElementById("wz-mqtt-port").value) || 1883,
|
||||
mqtt_user: document.getElementById("wz-mqtt-user").value,
|
||||
mqtt_pass: document.getElementById("wz-mqtt-pass").value,
|
||||
};
|
||||
try {
|
||||
await fetchJSON(api("api/config"), {
|
||||
method: "POST", headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch(e) { showToast("Speichern fehlgeschlagen", "err"); return; }
|
||||
wizardGoStep(2);
|
||||
}
|
||||
|
||||
async function wizardStep2Next() {
|
||||
const name = document.getElementById("wz-inv-name").value.trim();
|
||||
const ip = document.getElementById("wz-inv-ip").value.trim();
|
||||
if (!name || !ip) { showToast("Name und IP sind Pflichtfelder", "err"); return; }
|
||||
const model = document.getElementById("wz-inv-model").value;
|
||||
const id = Math.random().toString(36).slice(2, 10);
|
||||
const body = [{
|
||||
id,
|
||||
name,
|
||||
inverter_model: model,
|
||||
modbus_ip: ip,
|
||||
modbus_port: parseInt(document.getElementById("wz-inv-port").value) || 502,
|
||||
modbus_address: parseInt(document.getElementById("wz-inv-addr").value) || 1,
|
||||
mqtt_topic_prefix: `growatt/${name.toLowerCase().replace(/\s+/g, "_")}`,
|
||||
update_interval: 30,
|
||||
}];
|
||||
try {
|
||||
await fetchJSON(api("api/inverters-config"), {
|
||||
method: "POST", headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch(e) { showToast("Speichern fehlgeschlagen", "err"); return; }
|
||||
wizardGoStep(3);
|
||||
}
|
||||
|
||||
function wizardFinish() {
|
||||
document.getElementById("wizard-overlay").style.display = "none";
|
||||
loadSettings();
|
||||
loadInverters();
|
||||
}
|
||||
|
||||
function wizardSkip() {
|
||||
document.getElementById("wizard-overlay").style.display = "none";
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────
|
||||
|
||||
(async () => {
|
||||
await Promise.all([loadSettings(), loadInverters(), loadModels()]);
|
||||
if (!globalConfig.inverters || globalConfig.inverters.length === 0) {
|
||||
document.getElementById("wz-mqtt-broker").value = globalConfig.mqtt_broker || "core-mosquitto";
|
||||
document.getElementById("wz-mqtt-port").value = globalConfig.mqtt_port || 1883;
|
||||
document.getElementById("wz-mqtt-user").value = globalConfig.mqtt_user || "";
|
||||
document.getElementById("wz-inv-model").innerHTML =
|
||||
document.getElementById("modal-model").innerHTML;
|
||||
document.getElementById("wizard-overlay").style.display = "flex";
|
||||
}
|
||||
startRefresh();
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user