Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb797ed8b7 | ||
|
|
f5ed7c5df6 | ||
|
|
b36b0194f7 | ||
|
|
04a8fb3125 | ||
|
|
d9f94d3f28 | ||
|
|
c4047fc804 | ||
|
|
5564a50c3c | ||
|
|
dc2df891fb | ||
|
|
a361c30f1b | ||
|
|
a9f33c8e9e | ||
|
|
512b743b16 | ||
|
|
cb5f23d486 | ||
|
|
7904d498b5 | ||
|
|
5a00c7b5d3 | ||
|
|
b64bcde203 | ||
|
|
b3f67df967 | ||
|
|
e72ad2db19 | ||
|
|
3b3d4055f6 | ||
|
|
15c0ede72e | ||
|
|
bbfb11fb9c | ||
|
|
2456f356b4 | ||
|
|
dfb42e6902 | ||
|
|
fec49ec4fb | ||
|
|
5ab8ee75fb |
@@ -6,3 +6,8 @@ shinelanx-modbus/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
|
||||
# versehentlich getrackte Alt-Dateien, gehoeren nicht ins Repo
|
||||
Modbus-Server-Kathrein.pdf
|
||||
ShineLAN-X/releases/*.bin
|
||||
growatt-diagnose-tool/
|
||||
|
||||
@@ -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
|
||||
|
||||
+53
-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,38 @@
|
||||
|
||||
- [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 |
|
||||
| v1.8.23 | 2026-05-07 | Fix: Goodwe Netzbezug-Periode zeigt 0 kWh — integrierter grid_power-Zähler statt e_total_imp-Delta |
|
||||
| v1.8.24 | 2026-05-07 | Fix: WR/Wallbox-Offline-Flapping — erst nach 3 aufeinanderfolgenden Lesefehlern offline schalten |
|
||||
| v1.8.25 | 2026-05-07 | Fix: TCP-Timeout auf max. 40% des Poll-Intervalls begrenzt (Wallbox + Modbus) — verhindert Poll-Überlappung |
|
||||
| v1.8.26 | 2026-05-07 | Fix: EMS-Countdown läuft ohne Auto — Kathrein 0x0060 IllegalAddress = kein Fahrzeug (default war fälschlich 1=Connected) |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
firmware/
|
||||
@@ -1,7 +1,7 @@
|
||||
name: ShineBridge
|
||||
version: "1.8.12"
|
||||
version: "1.9.0"
|
||||
slug: shinebridge
|
||||
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
||||
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
|
||||
url: https://gitea.bitfire.work/retr0/shinebridge
|
||||
arch:
|
||||
- aarch64
|
||||
|
||||
@@ -23,6 +23,11 @@ TARGET_HOUR = 6 # Zielstunde: Vollladung bis 06:00
|
||||
POLL_INTERVAL_S = 30 # Sekunden zwischen EMS-Updates
|
||||
PHASES = 3 # Anzahl der Phasen
|
||||
VOLTAGE_V = 230 # Netzspannung
|
||||
MAX_CHARGE_MA = 32000 # Absolutes Wallbox-Limit (Hardware-Grenze WallboxReader).
|
||||
# ACHTUNG: die tatsächlich installierte Wallbox/Zuleitung kann
|
||||
# niedriger dimensioniert sein (z.B. 11kW-Wallbox = 16A) — dann
|
||||
# über max_charge_ma im Konstruktor auf den echten Wert begrenzen,
|
||||
# sonst fordert das Zwangsladen mehr an als das Gerät liefern kann.
|
||||
|
||||
# Charging-State Werte (Kathrein Modbus)
|
||||
STATE_IDLE = 0
|
||||
@@ -39,11 +44,13 @@ class EmsController:
|
||||
pv_timeout_h: float = PV_TIMEOUT_H,
|
||||
target_hour: int = TARGET_HOUR,
|
||||
phases: int = PHASES,
|
||||
max_charge_ma: int = MAX_CHARGE_MA,
|
||||
):
|
||||
self.min_pv_power = min_pv_power
|
||||
self.pv_timeout_h = pv_timeout_h
|
||||
self.target_hour = target_hour
|
||||
self.phases = phases
|
||||
self.max_charge_ma = max_charge_ma
|
||||
|
||||
self._pv_ok_since: Optional[float] = None # Zeitpunkt letzter ausreichender PV
|
||||
self._no_pv_since: Optional[float] = None # Zeitpunkt seit kein PV
|
||||
@@ -66,7 +73,7 @@ class EmsController:
|
||||
total_w = surplus_w + wallbox_w
|
||||
current_a = total_w / (VOLTAGE_V * self.phases)
|
||||
ma = int(current_a * 1000)
|
||||
return max(6000, min(32000, ma))
|
||||
return max(6000, min(self.max_charge_ma, ma))
|
||||
|
||||
def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int,
|
||||
wallbox_power_w: float = 0.0) -> str:
|
||||
@@ -84,8 +91,8 @@ class EmsController:
|
||||
if wallbox_reader.enable_ems():
|
||||
self._ems_enabled = True
|
||||
log.info("[EMS] EMS aktiviert")
|
||||
else:
|
||||
# Auto weg → alles zurücksetzen
|
||||
elif charging_state == STATE_IDLE:
|
||||
# Wirklich kein Fahrzeug angesteckt → alles zurücksetzen, Timer neu starten
|
||||
if self._ems_enabled:
|
||||
wallbox_reader.set_current(0)
|
||||
self._ems_enabled = False
|
||||
@@ -93,6 +100,16 @@ class EmsController:
|
||||
self._no_pv_since = None
|
||||
log.info("[EMS] Auto abgesteckt — EMS zurückgesetzt")
|
||||
return "kein Fahrzeug"
|
||||
else:
|
||||
# Unbekannter/nicht abgedeckter Ladezustand (z.B. Fehler/Störung am Fahrzeug).
|
||||
# NICHT wie "abgesteckt" behandeln — sonst verliert der Zwangslade-Timer seinen
|
||||
# Fortschritt und die Deadline (Zielzeit) wird nie mehr erreicht, obwohl das Auto
|
||||
# die ganze Zeit angesteckt war. Strom sicherheitshalber pausieren, aber Zustand/
|
||||
# Timer erhalten, damit nach Rückkehr in einen bekannten Zustand nahtlos weitergeht.
|
||||
wallbox_reader.set_current(0)
|
||||
log.warning("[EMS] Unbekannter Ladezustand %d (evtl. Störung am Fahrzeug) — "
|
||||
"Strom pausiert, Zwangslade-Timer bleibt erhalten", charging_state)
|
||||
return f"unbekannter Zustand ({charging_state})"
|
||||
|
||||
if charging_state == STATE_COMPLETED:
|
||||
wallbox_reader.set_current(0)
|
||||
@@ -116,13 +133,16 @@ class EmsController:
|
||||
no_pv_h = (now - self._no_pv_since) / 3600.0
|
||||
|
||||
if no_pv_h >= self.pv_timeout_h:
|
||||
# Zwangsladen bis Zielzeit
|
||||
# Zwangsladen bis Zielzeit — Strom bei JEDEM Update erneut setzen (nicht nur beim
|
||||
# ersten Eintritt), sonst bleibt ein einzelner fehlgeschlagener/verworfener Write
|
||||
# (Modbus-Hänger, Fahrzeug-Störung) unbemerkt für den Rest der Nacht stehen.
|
||||
target = self._next_target()
|
||||
mins_left = (target - datetime.now()).total_seconds() / 60
|
||||
if not self._forced_charging:
|
||||
wallbox_reader.set_current(32000, self.phases)
|
||||
self._forced_charging = True
|
||||
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00", int(mins_left), self.target_hour)
|
||||
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA",
|
||||
int(mins_left), self.target_hour, self.max_charge_ma / 1000)
|
||||
wallbox_reader.set_current(self.max_charge_ma, self.phases)
|
||||
return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)"
|
||||
|
||||
# Warten auf PV
|
||||
|
||||
+78
-16
@@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Tuple
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = "/data/history.db"
|
||||
RETENTION_DAYS = 7
|
||||
RETENTION_DAYS = 14
|
||||
|
||||
_lock = threading.Lock()
|
||||
_conn: sqlite3.Connection | None = None
|
||||
@@ -47,11 +47,31 @@ 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()
|
||||
_start_cleanup_scheduler()
|
||||
log.info("History DB initialisiert: %s", DB_PATH)
|
||||
|
||||
|
||||
def _start_cleanup_scheduler():
|
||||
def _loop():
|
||||
while True:
|
||||
time.sleep(86400)
|
||||
cleanup_old()
|
||||
t = threading.Thread(target=_loop, daemon=True, name="history-cleanup")
|
||||
t.start()
|
||||
|
||||
|
||||
def period_key(period_type: str, billing_day: int = 1, billing_month: int = 1) -> str:
|
||||
import datetime
|
||||
today = datetime.date.today()
|
||||
@@ -107,21 +127,18 @@ def write_batch(inv_id: str, ts: float, values: Dict[str, float]):
|
||||
def load_recent(inv_id: str, limit: int = 300) -> Dict[str, List[Tuple[float, float]]]:
|
||||
"""Letzte `limit` Messpunkte pro Sensor — zum Befüllen der In-Memory-Deque beim Start."""
|
||||
with _lock:
|
||||
rows = _get_conn().execute("""
|
||||
SELECT sensor_id, ts, value
|
||||
FROM (
|
||||
SELECT sensor_id, ts, value,
|
||||
ROW_NUMBER() OVER (PARTITION BY sensor_id ORDER BY ts DESC) AS rn
|
||||
FROM measurements
|
||||
WHERE inv_id = ?
|
||||
)
|
||||
WHERE rn <= ?
|
||||
ORDER BY ts ASC
|
||||
""", (inv_id, limit)).fetchall()
|
||||
|
||||
result: Dict[str, List[Tuple[float, float]]] = {}
|
||||
for sensor_id, ts, value in rows:
|
||||
result.setdefault(sensor_id, []).append((ts, value))
|
||||
c = _get_conn()
|
||||
sensors = [r[0] for r in c.execute(
|
||||
"SELECT DISTINCT sensor_id FROM measurements WHERE inv_id = ?", (inv_id,)
|
||||
).fetchall()]
|
||||
result: Dict[str, List[Tuple[float, float]]] = {}
|
||||
for sid in sensors:
|
||||
rows = c.execute(
|
||||
"SELECT ts, value FROM measurements "
|
||||
"WHERE inv_id = ? AND sensor_id = ? ORDER BY ts DESC LIMIT ?",
|
||||
(inv_id, sid, limit),
|
||||
).fetchall()
|
||||
result[sid] = [(ts, val) for ts, val in reversed(rows)]
|
||||
return result
|
||||
|
||||
|
||||
@@ -137,6 +154,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:
|
||||
|
||||
@@ -247,8 +247,9 @@ INVERTERS = {
|
||||
name="Kathrein Wallbox",
|
||||
manufacturer="Kathrein",
|
||||
sensors=_kathrein_sensors(),
|
||||
# Meter-Block + EVSE-Status + EMS-Setpoint
|
||||
read_ranges=[(0x0030, 48), (0x0060, 10), (0x00A2, 1)],
|
||||
# Meter-Block 0x0030-0x005F + charging_state 0x0060 (solo, 0x0061-0x0064 existieren nicht)
|
||||
# + EVSE-Status 0x0065-0x006A + EMS-Setpoint 0x00A2
|
||||
read_ranges=[(0x0030, 48), (0x0060, 1), (0x0065, 6), (0x00A2, 1)],
|
||||
protocol="kathrein",
|
||||
goodwe_family="",
|
||||
),
|
||||
|
||||
+225
-36
@@ -30,6 +30,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)
|
||||
|
||||
@@ -39,8 +54,8 @@ AGG_SENSOR_IDS: Dict[str, List[str]] = {
|
||||
"total_energy_today": ["energy_today", "e_day"],
|
||||
"total_energy_total": ["energy_total", "e_total"],
|
||||
"grid_power": ["grid_power"],
|
||||
"grid_import_kwh": ["import_kwh", "e_total_imp"],
|
||||
"grid_export_kwh": ["export_kwh", "e_total_exp"],
|
||||
"grid_import_kwh": ["import_kwh", "_int_import", "e_total_imp"],
|
||||
"grid_export_kwh": ["export_kwh", "_int_export", "e_total_exp"],
|
||||
"bat_charge_power": ["bat_charge_power"],
|
||||
"bat_discharge_power": ["bat_discharge_power"],
|
||||
"bat_charge_total": ["bat_charge_total", "e_bat_charge_total"],
|
||||
@@ -77,6 +92,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 +117,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 +167,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 +227,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")
|
||||
@@ -208,17 +279,18 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||
log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds",
|
||||
inv_id, inverter.name, host, interval)
|
||||
elif inverter.protocol == "kathrein":
|
||||
reader = WallboxReader(host=host, port=port)
|
||||
reader = WallboxReader(host=host, port=port, timeout=min(5.0, interval * 0.4))
|
||||
ems = EmsController(
|
||||
min_pv_power=inv_cfg.get("ems_min_pv", 1400),
|
||||
pv_timeout_h=inv_cfg.get("ems_timeout", 4.0),
|
||||
target_hour=inv_cfg.get("ems_target_hour", 6),
|
||||
phases=inv_cfg.get("ems_phases", 3),
|
||||
max_charge_ma=inv_cfg.get("ems_max_current_ma", 32000),
|
||||
)
|
||||
log.info("[%s] Poll-Loop: %s @ %s:%s (Kathrein EMS) alle %ds",
|
||||
inv_id, inverter.name, host, port, interval)
|
||||
else:
|
||||
reader = ModbusReader(host=host, port=port, slave=slave)
|
||||
reader = ModbusReader(host=host, port=port, slave=slave, timeout=min(5.0, interval * 0.4))
|
||||
log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds",
|
||||
inv_id, inverter.name, host, port, interval)
|
||||
|
||||
@@ -238,6 +310,13 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||
q.append(pt)
|
||||
log.info("[%s] %d Sensoren aus DB geladen", inv_id, len(hist_data))
|
||||
|
||||
# Integrierte Grid-Zähler — starten bei 0, wachsen mit jedem Poll, persistieren in DB
|
||||
inv_int_import = hist_data["_int_import"][-1][1] if hist_data.get("_int_import") else 0.0
|
||||
inv_int_export = hist_data["_int_export"][-1][1] if hist_data.get("_int_export") else 0.0
|
||||
last_grid_ts = time.time()
|
||||
_fail_count = 0 # aufeinanderfolgende Lesefehler; erst ab >= 3 → offline
|
||||
_OFFLINE_THRESHOLD = 3
|
||||
|
||||
while not stop.is_set():
|
||||
t0 = time.time()
|
||||
values = reader.read(inverter)
|
||||
@@ -250,12 +329,24 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||
if values and "grid_power" not in values and "import_kwh" in values and "total_power" in values:
|
||||
values["grid_power"] = values["total_power"]
|
||||
|
||||
# grid_power integrieren → interne kWh-Zähler (Periodenberechnung für Goodwe / kein SDM-630)
|
||||
if values is not None and "grid_power" in values:
|
||||
dt_s = t0 - last_grid_ts
|
||||
if 0 < dt_s < 300:
|
||||
gp = values["grid_power"]
|
||||
if gp > 0:
|
||||
inv_int_import = round(inv_int_import + gp * dt_s / 3_600_000, 6)
|
||||
elif gp < 0:
|
||||
inv_int_export = round(inv_int_export + (-gp) * dt_s / 3_600_000, 6)
|
||||
values["_int_import"] = inv_int_import
|
||||
values["_int_export"] = inv_int_export
|
||||
last_grid_ts = t0
|
||||
|
||||
# EMS: PV-Überschuss aus anderen Geräten holen und Ladestrom regeln
|
||||
if ems is not None and values is not None and inv_cfg.get("ems_enabled", True):
|
||||
pv_surplus = _get_pv_surplus()
|
||||
# 0x0060 manchmal nicht lesbar → 1 (EV Connected) annehmen,
|
||||
# damit EMS aktiviert; Wallbox ignoriert Befehle wenn kein Auto da
|
||||
charging_state = int(values.get("charging_state", 1))
|
||||
# charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
|
||||
charging_state = int(values.get("charging_state", 0))
|
||||
wallbox_power = values.get("total_power", 0.0)
|
||||
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power)
|
||||
values["ems_status_code"] = float(charging_state)
|
||||
@@ -264,6 +355,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||
with State.lock:
|
||||
d = State.inv_data.setdefault(inv_id, {"poll_count": 0})
|
||||
if values is not None:
|
||||
_fail_count = 0
|
||||
d["values"] = values
|
||||
d["last_update"] = time.time()
|
||||
d["modbus_ok"] = True
|
||||
@@ -275,12 +367,15 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||
q.append((now, val))
|
||||
history.write_batch(inv_id, now, values)
|
||||
if _publisher:
|
||||
_publisher.publish_data(values, prefix)
|
||||
pub_values = {k: v for k, v in values.items() if not k.startswith("_")}
|
||||
_publisher.publish_data(pub_values, prefix)
|
||||
_publisher.publish_status("online", prefix)
|
||||
else:
|
||||
d["modbus_ok"] = False
|
||||
if _publisher:
|
||||
_publisher.publish_status("offline", prefix)
|
||||
_fail_count += 1
|
||||
if _fail_count >= _OFFLINE_THRESHOLD:
|
||||
d["modbus_ok"] = False
|
||||
if _publisher:
|
||||
_publisher.publish_status("offline", prefix)
|
||||
|
||||
# Aggregate nach jedem erfolgreichen Poll neu berechnen und publizieren
|
||||
if values is not None and _publisher:
|
||||
@@ -288,6 +383,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 +491,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 +503,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 +595,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 +605,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 +899,9 @@ def api_spot_price():
|
||||
log.warning("Spot-Price API Fehler: %s", e)
|
||||
return jsonify({"ok": False, "data": _spot_cache.get("data", [])})
|
||||
|
||||
# Flash/OTA-Endpunkte ausgelagert nach growatt-diagnose-tool (v1.9.0) —
|
||||
# ShineBridge fokussiert sich auf Energiemanagement, kein Firmware-Flashing mehr.
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return send_from_directory(WEB_DIR, "index.html")
|
||||
@@ -731,8 +916,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)
|
||||
|
||||
+273
-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,15 @@
|
||||
.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); }
|
||||
|
||||
@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; }
|
||||
header svg { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -175,6 +191,7 @@
|
||||
<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>
|
||||
@@ -185,6 +202,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 +221,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 +277,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 +322,8 @@
|
||||
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash/OTA-Panel ausgelagert nach growatt-diagnose-tool (v1.9.0) -->
|
||||
</main>
|
||||
|
||||
<!-- Inverter Edit Modal -->
|
||||
@@ -326,6 +368,9 @@
|
||||
<option value="2">2-phasig</option>
|
||||
<option value="3" selected>3-phasig</option>
|
||||
</select></div>
|
||||
<div class="field"><label>Max. Ladestrom (A)</label>
|
||||
<input type="number" id="ems-max-current" value="32" min="6" max="32" step="1">
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:3px">Obergrenze für Zwangsladen — auf die tatsächliche Wallbox-/Zuleitungs-Absicherung setzen (z.B. 16A bei einer 11kW-Wallbox), nicht höher als die Hardware zulässt</div></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
@@ -413,11 +458,12 @@ function showToast(msg, type) {
|
||||
}
|
||||
|
||||
function switchTab(name) {
|
||||
["energy","live","inverters","settings"].forEach((t, i) => {
|
||||
["energy","finance","live","inverters","settings"].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 +518,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 +662,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 +694,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() {
|
||||
@@ -806,6 +1000,7 @@ async function openModal(invId) {
|
||||
document.getElementById("ems-timeout").value = inv.ems_timeout ?? 4;
|
||||
document.getElementById("ems-target-hour").value = inv.ems_target_hour ?? 6;
|
||||
document.getElementById("ems-phases").value = inv.ems_phases ?? 3;
|
||||
document.getElementById("ems-max-current").value = inv.ems_max_current_ma ? inv.ems_max_current_ma / 1000 : 32;
|
||||
document.getElementById("ems-enabled").checked = inv.ems_enabled ?? true;
|
||||
} else {
|
||||
document.getElementById("modal-title").textContent = "Gerät hinzufügen";
|
||||
@@ -820,6 +1015,7 @@ async function openModal(invId) {
|
||||
document.getElementById("ems-timeout").value = "4";
|
||||
document.getElementById("ems-target-hour").value = "6";
|
||||
document.getElementById("ems-phases").value = "3";
|
||||
document.getElementById("ems-max-current").value = "32";
|
||||
}
|
||||
toggleEmsSection(modelSel.value);
|
||||
modal.classList.add("open");
|
||||
@@ -854,6 +1050,7 @@ async function saveInverter() {
|
||||
inv.ems_timeout = parseFloat(document.getElementById("ems-timeout").value);
|
||||
inv.ems_target_hour = parseInt(document.getElementById("ems-target-hour").value);
|
||||
inv.ems_phases = parseInt(document.getElementById("ems-phases").value);
|
||||
inv.ems_max_current_ma = Math.round(parseFloat(document.getElementById("ems-max-current").value) * 1000);
|
||||
inv.ems_enabled = document.getElementById("ems-enabled").checked;
|
||||
}
|
||||
const idx = invertersList.findIndex(i => i.id === id);
|
||||
@@ -894,10 +1091,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 +1119,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 +1135,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 +1205,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 +1273,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 +1316,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,6 +1336,9 @@ function renderSurplusStatus(surplusData) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Flash-Wizard + Setup-Wizard ausgelagert nach growatt-diagnose-tool (v1.9.0) —
|
||||
// Geräte werden jetzt direkt über "+ Gerät hinzufügen" im Geräte-Tab angelegt.
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────
|
||||
|
||||
(async () => {
|
||||
|
||||
Reference in New Issue
Block a user