Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f94d3f28 | |||
| c4047fc804 | |||
| 5564a50c3c | |||
| dc2df891fb | |||
| a361c30f1b | |||
| a9f33c8e9e | |||
| 512b743b16 | |||
| cb5f23d486 | |||
| 7904d498b5 | |||
| 5a00c7b5d3 | |||
| b64bcde203 | |||
| b3f67df967 | |||
| e72ad2db19 | |||
| 3b3d4055f6 | |||
| 15c0ede72e | |||
| bbfb11fb9c | |||
| 2456f356b4 | |||
| dfb42e6902 | |||
| fec49ec4fb | |||
| 5ab8ee75fb | |||
| 33ada90df4 | |||
| 0dbf0266a8 | |||
| 5942c18df6 | |||
| 58a33f966d |
@@ -1,6 +1,6 @@
|
|||||||
# ShineBridge
|
# 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`
|
> **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 |
|
| Komponente | Beschreibung | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **ShineBridge Add-on** | HAOS Add-on: Modbus TCP / UDP → MQTT Discovery | ✅ v1.5.3 |
|
| **ShineBridge Add-on** | HAOS Add-on: Modbus TCP / UDP → MQTT Discovery | ✅ v1.8.18 |
|
||||||
| **ShineLAN-X Firmware** | NuttX auf STM32F103: Modbus RTU ↔ TCP | ✅ Produktiv |
|
| **ShineLAN-X Firmware** | NuttX auf STM32F103: Modbus RTU ↔ TCP + OTA | ✅ Produktiv |
|
||||||
| **ShineWifi-X ESPHome** | ESPHome-Configs für ESP8266-Stick | ✅ Getestet |
|
| **ShineWifi-X ESPHome** | ESPHome-Configs für ESP8266-Stick | ✅ Getestet |
|
||||||
| **ShineDiag** | Portables Vor-Ort-Diagnose-Tool (Pi 3B) | ✅ Bereit |
|
| **ShineDiag** | Portables Vor-Ort-Diagnose-Tool (Pi 3B) | ✅ Bereit |
|
||||||
|
|
||||||
@@ -30,13 +30,15 @@ Wechselrichter / Energiezähler
|
|||||||
│ UDP/8899 (Goodwe)
|
│ UDP/8899 (Goodwe)
|
||||||
│ Modbus TCP Port 502 (Kathrein Wallbox)
|
│ Modbus TCP Port 502 (Kathrein Wallbox)
|
||||||
│
|
│
|
||||||
[ShineBridge Add-on]
|
[ShineBridge Add-on] Port 8099 (HAOS Ingress)
|
||||||
├── Modbus TCP / Goodwe UDP / Kathrein Wallbox lesen
|
├── Modbus TCP / Goodwe UDP / Kathrein Wallbox lesen
|
||||||
├── EMS-Controller: PV-Überschussladen + Zwangsladen
|
├── EMS-Controller: PV-Überschussladen + Zwangsladen
|
||||||
├── MQTT Discovery → Home Assistant Sensoren
|
├── MQTT Discovery → Home Assistant Sensoren
|
||||||
├── Aggregat-Gerät „ShineBridge Gesamt"
|
├── Aggregat-Gerät „ShineBridge Gesamt"
|
||||||
├── Persistente History (SQLite, 7 Tage)
|
├── 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
|
### Features
|
||||||
@@ -45,9 +47,16 @@ Wechselrichter / Energiezähler
|
|||||||
- **MQTT Discovery** — Sensoren erscheinen automatisch in HA
|
- **MQTT Discovery** — Sensoren erscheinen automatisch in HA
|
||||||
- **Aggregat-Gerät** — summiert alle Geräte für das HA Energie-Dashboard
|
- **Aggregat-Gerät** — summiert alle Geräte für das HA Energie-Dashboard
|
||||||
- **Persistente History** — Messwerte überleben Add-on-Neustarts (SQLite, 7 Tage)
|
- **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
|
- **Sparklines** — Live-Graphen der letzten 5 Minuten pro Sensor
|
||||||
- **EMS-Controller** — PV-Überschussladen der Kathrein Wallbox, Zwangsladen-Fallback
|
- **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
|
- **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
|
- **Kein Cloud-Zwang** — vollständig lokal
|
||||||
|
|
||||||
### Unterstützte Geräte
|
### Unterstützte Geräte
|
||||||
@@ -82,37 +91,37 @@ Konfigurierbar pro Gerät im Web UI:
|
|||||||
|
|
||||||
### Netzleistung (grid_power)
|
### 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)
|
- **Positiv** = Netzbezug (Strom vom Netz)
|
||||||
- **Negativ** = Einspeisung (Strom ins 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
|
### Installation
|
||||||
|
|
||||||
**Schritt 1 — NuttX auf den ShineLAN-X flashen** *(einmalig, ST-Link nötig)*
|
**Schritt 1 — Add-on in Home Assistant installieren**
|
||||||
|
|
||||||
```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**
|
|
||||||
|
|
||||||
1. **Einstellungen → Add-ons → Add-on-Store → ⋮ → Repositories**
|
1. **Einstellungen → Add-ons → Add-on-Store → ⋮ → Repositories**
|
||||||
2. URL: `https://gitea.bitfire.work/retr0/shinebridge`
|
2. URL: `https://gitea.bitfire.work/retr0/shinebridge`
|
||||||
3. „ShineBridge" → Installieren → Starten
|
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**
|
**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
|
- Name, Modell, IP, Port, Modbus-Adresse, MQTT Topic-Präfix, Abfrageintervall
|
||||||
- Bei Kathrein Wallbox: EMS-Parameter konfigurieren
|
- 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
|
## 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)**
|
> **Firmware basiert auf [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.
|
|
||||||
> Lizenzen: Board-Support Apache 2.0 · mbusd BSD 3-Clause · Gateway-App BSD 2-Clause.
|
> 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
|
### Flash-Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
0x08000000 dapboot (7 KB) ← Bootloader für USB DFU OTA
|
0x08000000 dapboot (8 KB) ← Bootloader, nicht überschrieben
|
||||||
0x08002000 NuttX (93 KB) ← Modbus RTU ↔ TCP, Port 502
|
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
|
### Hardware
|
||||||
|
|
||||||
| Komponente | Details |
|
| Komponente | Details |
|
||||||
|---|---|
|
|---|---|
|
||||||
| MCU | STM32F103RC — 256 kB Flash, 64 kB RAM |
|
| MCU | STM32F103RC — 256 kB Flash, 48 kB SRAM |
|
||||||
| Ethernet | ENC28J60 (SPI2) |
|
| Ethernet | ENC28J60 (SPI2) |
|
||||||
| USB | PA11=D−, PA12=D+, PA8=Pullup |
|
| USB | PA11=D−, PA12=D+, PA8=Pullup |
|
||||||
| SWD | PA13=SWDIO, PA14=SWCLK |
|
| SWD | PA13=SWDIO, PA14=SWCLK |
|
||||||
@@ -224,8 +250,11 @@ shinebridge/
|
|||||||
├── haos-addon/ ← HAOS Add-on
|
├── haos-addon/ ← HAOS Add-on
|
||||||
│ ├── config.yaml
|
│ ├── config.yaml
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
|
│ ├── firmware/ # Integrierte ShineLAN-X Binaries
|
||||||
|
│ │ ├── nuttx-mbusd-shinelanx.bin # Direktflash (0x08000000)
|
||||||
|
│ │ └── nuttx-mbusd-shinelanx-dfu.bin # OTA-fähig (0x08002000)
|
||||||
│ └── src/
|
│ └── 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
|
│ ├── modbus_client.py # Modbus TCP, Float32-Dekodierung
|
||||||
│ ├── goodwe_client.py # Goodwe UDP/8899 via goodwe-Bibliothek
|
│ ├── goodwe_client.py # Goodwe UDP/8899 via goodwe-Bibliothek
|
||||||
│ ├── wallbox_client.py # Kathrein Wallbox Modbus TCP
|
│ ├── wallbox_client.py # Kathrein Wallbox Modbus TCP
|
||||||
@@ -233,11 +262,16 @@ shinebridge/
|
|||||||
│ ├── mqtt_publisher.py # MQTT Discovery + Aggregat
|
│ ├── mqtt_publisher.py # MQTT Discovery + Aggregat
|
||||||
│ ├── inverters.py # Register-Maps aller Geräte
|
│ ├── inverters.py # Register-Maps aller Geräte
|
||||||
│ ├── history.py # SQLite Persistenz
|
│ ├── history.py # SQLite Persistenz
|
||||||
│ └── web/index.html # Web UI
|
│ └── web/index.html # Web UI (Energie, Finanzen, Live, Geräte, Flash)
|
||||||
├── ShineLAN-X/
|
├── ShineLAN-X/
|
||||||
│ └── releases/
|
│ └── releases/
|
||||||
│ ├── dapboot.bin
|
│ ├── nuttx-mbusd-shinelanx.bin
|
||||||
│ └── nuttx-mbusd-shinelanx-dfu.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
|
├── ShineWifi-X/ ← ESPHome Configs
|
||||||
├── tools/
|
├── tools/
|
||||||
│ └── shinediag/ ← Vor-Ort-Diagnose-Tool
|
│ └── shinediag/ ← Vor-Ort-Diagnose-Tool
|
||||||
|
|||||||
+53
-32
@@ -1,11 +1,10 @@
|
|||||||
# ShineBridge — Roadmap
|
# 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
|
- [ ] Kathrein EVSE-Register 0x0060 (Charging-State) nur lesbar wenn Auto angeschlossen
|
||||||
- [ ] Growatt-only: Netzbezug nicht messbar (nur Einspeisung via `power_to_grid`)
|
- [ ] NuttX OTA noch nicht auf echter Hardware getestet
|
||||||
- [ ] Port 8099 offen im LAN (`host_network: true`) — noch keine Authentifizierung
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,27 +13,38 @@
|
|||||||
- [x] Persistente History — SQLite `/data/history.db`, 7 Tage Retention (v1.3.0)
|
- [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] 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] 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-Controller — PV-Überschussladen + Zwangsladen-Fallback (v1.5.0)
|
||||||
- [x] EMS Web UI — Konfiguration direkt im Gerätedialog (v1.5.1)
|
- [x] EMS Web UI — Konfiguration direkt im Gerätedialog (v1.5.1)
|
||||||
- [x] grid_power Sensor — intuitive Anzeige positiv=Netzbezug (v1.5.2)
|
- [x] grid_power Sensor — positiv = Netzbezug, negativ = Einspeisung (v1.5.2)
|
||||||
- [x] EMS Oszillations-Fix — has_pv prüft Gesamt-PV inkl. Ladeleistung (v1.5.3)
|
- [x] Energie-Dashboard — HA-Style SVG-Flussdiagramm, animateMotion (v1.6.0)
|
||||||
- [ ] Flash-Wizard — NuttX-Firmware via USB DFU direkt aus dem HA Web UI flashen
|
- [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.
|
- [ ] 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
|
## 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)
|
### Goodwe WiFi-Stick (WIFILAN_2.0 / HF-LPT230)
|
||||||
- [x] UDP/8899 Protokoll via goodwe-Bibliothek integriert (v1.4.0)
|
- [x] UDP/8899 Protokoll via goodwe-Bibliothek integriert (v1.4.0)
|
||||||
- [ ] JTAG-Analyse (TP15/TP16/TP17) — eigene Firmware wenn JTAG-eFuse nicht gesetzt
|
- [ ] 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)
|
- [ ] RS485-Direktzugriff via 18-Pin-Stecker
|
||||||
|
|
||||||
### ShineLAN-X
|
|
||||||
- [x] dapboot + NuttX produktiv (v1.0.0)
|
|
||||||
- [ ] OTA-Update via USB DFU direkt aus HA Web UI (Flash-Wizard)
|
|
||||||
|
|
||||||
### ShineWifi-X als ShineBridge-Gateway
|
### ShineWifi-X als ShineBridge-Gateway
|
||||||
- [ ] Schlankes ESP8266-Projekt: RS485 → Modbus TCP Bridge
|
- [ ] Schlankes ESP8266-Projekt: RS485 → Modbus TCP Bridge
|
||||||
@@ -45,11 +55,10 @@
|
|||||||
## Phase 4 — Energiebilanz & Dashboard
|
## Phase 4 — Energiebilanz & Dashboard
|
||||||
|
|
||||||
- [x] Aggregat-Gerät „ShineBridge Gesamt" (v1.2.0)
|
- [x] Aggregat-Gerät „ShineBridge Gesamt" (v1.2.0)
|
||||||
- [x] grid_power korrekt für Multi-Wechselrichter-Anlagen (v1.5.2)
|
- [x] EPEX Spot-Chart — 24h-Balkendiagramm im Energie-Dashboard (v1.8.16)
|
||||||
- [x] EMS bezieht Wallbox-Ladeleistung in PV-Budget ein (v1.5.2)
|
- [x] Eigenversorgungskarte — kWh + € gespart (v1.8.8)
|
||||||
- [ ] Hausverbrauch als berechneter Sensor: `PV + Netzbezug - Einspeisung - Bat_Ladung + Bat_Entladung`
|
|
||||||
- [ ] Virtuelle MQTT-Sensoren für HA Energie-Dashboard automatisch anlegen
|
- [ ] 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] WiFi-Hotspot „ShineDiag" (Pi 3B), http://10.0.1.1 (v2.0)
|
||||||
- [x] Alle Sensoren, Rohdaten-Register-Dump, JSON-Export (v2.0)
|
- [x] Alle Sensoren, Rohdaten-Register-Dump, JSON-Export (v2.0)
|
||||||
- [x] Pi-Setup-Script `install.sh` (v2.0)
|
- [ ] Goodwe-Unterstützung (UDP/8899)
|
||||||
- [ ] Goodwe-Unterstützung in ShineDiag (UDP/8899)
|
- [ ] Kathrein Wallbox (EMS-Status, Ladezustand)
|
||||||
- [ ] Kathrein Wallbox in ShineDiag (EMS-Status, Ladezustand)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Erledigt
|
## Changelog
|
||||||
|
|
||||||
| Version | Datum | Inhalt |
|
| 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.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-Dekodierung |
|
||||||
| v1.1.5 | 2024 | Feature: Eastron SDM-630 + Float32 Decode |
|
| v1.2.0 | 2024 | Feature: Aggregat-Gerät + Energie-Dashboard-Sensoren |
|
||||||
| v1.2.0 | 2024 | Feature: Aggregat-Gerät + Energie-Dashboard Sensoren |
|
|
||||||
| v1.2.1 | 2024 | Fix: SDM-630 Gesamtwirkleistung aus Phasensumme |
|
|
||||||
| v1.3.0 | 2024 | Feature: SQLite History, Konfig-Export/Import |
|
| v1.3.0 | 2024 | Feature: SQLite History, Konfig-Export/Import |
|
||||||
| v1.4.0 | 2025 | Feature: Goodwe GW10KN-ET via UDP/8899, 39 Sensoren |
|
| 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.0 | 2026-04-28 | Feature: Kathrein Wallbox + EMS-Controller (PV-Überschussladen) |
|
||||||
| v1.5.1 | 2026-04-28 | Feature: EMS-Konfiguration im Web UI |
|
| v1.5.3 | 2026-04-28 | Fix: EMS Oszillation, grid_power Anzeige |
|
||||||
| v1.5.2 | 2026-04-28 | Fix: grid_power Anzeige, Aggregat, EMS Ladeleistung |
|
| v1.6.0 | 2026-04-28 | Feature: Energie-Dashboard SVG-Flussdiagramm, animateMotion |
|
||||||
| v1.5.3 | 2026-04-28 | Fix: EMS Oszillation (has_pv = Gesamt-PV) |
|
| v1.6.5 | 2026-04-28 | Redesign: Kreuz-Layout (Solar/Grid/Haus/Batterie/EV) |
|
||||||
| v1.6.0 | 2026-04-28 | Feature: Energie-Dashboard mit SVG-Flussdiagramm |
|
| 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) |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ FROM ${BUILD_FROM}
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY src/ /app/
|
COPY src/ /app/
|
||||||
|
COPY firmware/ /firmware/
|
||||||
|
|
||||||
RUN pip3 install --no-cache-dir \
|
RUN pip3 install --no-cache-dir \
|
||||||
pymodbus==3.6.9 \
|
pymodbus==3.6.9 \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.8.9"
|
version: "1.8.28"
|
||||||
slug: shinebridge
|
slug: shinebridge
|
||||||
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
||||||
url: https://gitea.bitfire.work/retr0/shinebridge
|
url: https://gitea.bitfire.work/retr0/shinebridge
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+78
-16
@@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Tuple
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
DB_PATH = "/data/history.db"
|
DB_PATH = "/data/history.db"
|
||||||
RETENTION_DAYS = 7
|
RETENTION_DAYS = 14
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_conn: sqlite3.Connection | None = None
|
_conn: sqlite3.Connection | None = None
|
||||||
@@ -47,11 +47,31 @@ def init_db():
|
|||||||
PRIMARY KEY (agg_id, period_type, period_key)
|
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()
|
c.commit()
|
||||||
cleanup_old()
|
cleanup_old()
|
||||||
|
_start_cleanup_scheduler()
|
||||||
log.info("History DB initialisiert: %s", DB_PATH)
|
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:
|
def period_key(period_type: str, billing_day: int = 1, billing_month: int = 1) -> str:
|
||||||
import datetime
|
import datetime
|
||||||
today = datetime.date.today()
|
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]]]:
|
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."""
|
"""Letzte `limit` Messpunkte pro Sensor — zum Befüllen der In-Memory-Deque beim Start."""
|
||||||
with _lock:
|
with _lock:
|
||||||
rows = _get_conn().execute("""
|
c = _get_conn()
|
||||||
SELECT sensor_id, ts, value
|
sensors = [r[0] for r in c.execute(
|
||||||
FROM (
|
"SELECT DISTINCT sensor_id FROM measurements WHERE inv_id = ?", (inv_id,)
|
||||||
SELECT sensor_id, ts, value,
|
).fetchall()]
|
||||||
ROW_NUMBER() OVER (PARTITION BY sensor_id ORDER BY ts DESC) AS rn
|
result: Dict[str, List[Tuple[float, float]]] = {}
|
||||||
FROM measurements
|
for sid in sensors:
|
||||||
WHERE inv_id = ?
|
rows = c.execute(
|
||||||
)
|
"SELECT ts, value FROM measurements "
|
||||||
WHERE rn <= ?
|
"WHERE inv_id = ? AND sensor_id = ? ORDER BY ts DESC LIMIT ?",
|
||||||
ORDER BY ts ASC
|
(inv_id, sid, limit),
|
||||||
""", (inv_id, limit)).fetchall()
|
).fetchall()
|
||||||
|
result[sid] = [(ts, val) for ts, val in reversed(rows)]
|
||||||
result: Dict[str, List[Tuple[float, float]]] = {}
|
|
||||||
for sensor_id, ts, value in rows:
|
|
||||||
result.setdefault(sensor_id, []).append((ts, value))
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -137,6 +154,51 @@ def query(inv_id: str, sensor_id: str,
|
|||||||
return rows
|
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):
|
def cleanup_old(days: int = RETENTION_DAYS):
|
||||||
cutoff = time.time() - days * 86400
|
cutoff = time.time() - days * 86400
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
@@ -247,8 +247,9 @@ INVERTERS = {
|
|||||||
name="Kathrein Wallbox",
|
name="Kathrein Wallbox",
|
||||||
manufacturer="Kathrein",
|
manufacturer="Kathrein",
|
||||||
sensors=_kathrein_sensors(),
|
sensors=_kathrein_sensors(),
|
||||||
# Meter-Block + EVSE-Status + EMS-Setpoint
|
# Meter-Block 0x0030-0x005F + charging_state 0x0060 (solo, 0x0061-0x0064 existieren nicht)
|
||||||
read_ranges=[(0x0030, 48), (0x0060, 10), (0x00A2, 1)],
|
# + EVSE-Status 0x0065-0x006A + EMS-Setpoint 0x00A2
|
||||||
|
read_ranges=[(0x0030, 48), (0x0060, 1), (0x0065, 6), (0x00A2, 1)],
|
||||||
protocol="kathrein",
|
protocol="kathrein",
|
||||||
goodwe_family="",
|
goodwe_family="",
|
||||||
),
|
),
|
||||||
|
|||||||
+289
-56
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -30,6 +31,21 @@ WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
|||||||
|
|
||||||
app = Flask(__name__, static_folder=WEB_DIR)
|
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 ───────────────────────────────────────────────
|
# ── Aggregation ───────────────────────────────────────────────
|
||||||
# Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG)
|
# Welche Sensor-IDs fließen in welchen Aggregat-Bucket (Summe, außer AGG_AVG)
|
||||||
|
|
||||||
@@ -39,8 +55,8 @@ AGG_SENSOR_IDS: Dict[str, List[str]] = {
|
|||||||
"total_energy_today": ["energy_today", "e_day"],
|
"total_energy_today": ["energy_today", "e_day"],
|
||||||
"total_energy_total": ["energy_total", "e_total"],
|
"total_energy_total": ["energy_total", "e_total"],
|
||||||
"grid_power": ["grid_power"],
|
"grid_power": ["grid_power"],
|
||||||
"grid_import_kwh": ["import_kwh", "e_total_imp"],
|
"grid_import_kwh": ["import_kwh", "_int_import", "e_total_imp"],
|
||||||
"grid_export_kwh": ["export_kwh", "e_total_exp"],
|
"grid_export_kwh": ["export_kwh", "_int_export", "e_total_exp"],
|
||||||
"bat_charge_power": ["bat_charge_power"],
|
"bat_charge_power": ["bat_charge_power"],
|
||||||
"bat_discharge_power": ["bat_discharge_power"],
|
"bat_discharge_power": ["bat_discharge_power"],
|
||||||
"bat_charge_total": ["bat_charge_total", "e_bat_charge_total"],
|
"bat_charge_total": ["bat_charge_total", "e_bat_charge_total"],
|
||||||
@@ -77,6 +93,7 @@ class State:
|
|||||||
surplus_devices_cfg: List[Dict[str, Any]] = []
|
surplus_devices_cfg: List[Dict[str, Any]] = []
|
||||||
z2m_base: str = "zigbee2mqtt"
|
z2m_base: str = "zigbee2mqtt"
|
||||||
z2m_devices: List[Dict[str, Any]] = []
|
z2m_devices: List[Dict[str, Any]] = []
|
||||||
|
last_tariff_snapshot_date: str = ""
|
||||||
|
|
||||||
_publisher: Optional[MqttPublisher] = None
|
_publisher: Optional[MqttPublisher] = None
|
||||||
_surplus_ctrl: Optional[SurplusDeviceController] = None
|
_surplus_ctrl: Optional[SurplusDeviceController] = None
|
||||||
@@ -101,30 +118,42 @@ def _defaults() -> Dict[str, Any]:
|
|||||||
"spot_country": "de",
|
"spot_country": "de",
|
||||||
"spot_markup": 0.0,
|
"spot_markup": 0.0,
|
||||||
"spot_chart": True,
|
"spot_chart": True,
|
||||||
|
"billing_tracker_enabled": False,
|
||||||
|
"monthly_rate_eur": 0.0,
|
||||||
|
"grundpreis_eur_per_month": 0.0,
|
||||||
"inverters": [],
|
"inverters": [],
|
||||||
"surplus_devices": [],
|
"surplus_devices": [],
|
||||||
"z2m_base": "zigbee2mqtt",
|
"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]:
|
def load_config() -> Dict[str, Any]:
|
||||||
cfg = _defaults()
|
cfg = _defaults()
|
||||||
|
# MQTT-Grundeinstellungen aus HAOS-Options (überschreibbar durch config.json)
|
||||||
if os.path.exists(HA_OPTIONS_PATH):
|
if os.path.exists(HA_OPTIONS_PATH):
|
||||||
try:
|
ha = _load_json_safe(HA_OPTIONS_PATH) or {}
|
||||||
with open(HA_OPTIONS_PATH) as f:
|
for k in ("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass"):
|
||||||
ha = json.load(f)
|
if k in ha:
|
||||||
for k in ("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass"):
|
cfg[k] = ha[k]
|
||||||
if k in ha:
|
# Eigene persistente Config — Hauptdatei, dann Backup als Fallback
|
||||||
cfg[k] = ha[k]
|
loaded = _load_json_safe(CONFIG_PATH)
|
||||||
except Exception as e:
|
if loaded is None and os.path.exists(CONFIG_PATH + ".bak"):
|
||||||
log.warning("HA options Fehler: %s", e)
|
log.warning("config.json korrupt — lade Backup")
|
||||||
if os.path.exists(CONFIG_PATH):
|
loaded = _load_json_safe(CONFIG_PATH + ".bak")
|
||||||
try:
|
if loaded:
|
||||||
with open(CONFIG_PATH) as f:
|
cfg.update(loaded)
|
||||||
cfg.update(json.load(f))
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("Config-Datei Fehler: %s", e)
|
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def save_config():
|
def save_config():
|
||||||
data = {
|
data = {
|
||||||
"mqtt_broker": State.mqtt_cfg.get("mqtt_broker", ""),
|
"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_country": State.mqtt_cfg.get("spot_country", "de"),
|
||||||
"spot_markup": State.mqtt_cfg.get("spot_markup", 0.0),
|
"spot_markup": State.mqtt_cfg.get("spot_markup", 0.0),
|
||||||
"spot_chart": State.mqtt_cfg.get("spot_chart", True),
|
"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,
|
"inverters": State.inverters_cfg,
|
||||||
"surplus_devices": State.surplus_devices_cfg,
|
"surplus_devices": State.surplus_devices_cfg,
|
||||||
"z2m_base": State.z2m_base,
|
"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)
|
json.dump(data, f, indent=2)
|
||||||
|
os.replace(tmp, CONFIG_PATH)
|
||||||
|
|
||||||
# ── Aggregation ───────────────────────────────────────────────
|
# ── Aggregation ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -180,29 +221,44 @@ def _compute_aggregates(allow_stale: bool = False) -> Dict[str, float]:
|
|||||||
# ── EMS Hilfsfunktionen ───────────────────────────────────────
|
# ── EMS Hilfsfunktionen ───────────────────────────────────────
|
||||||
|
|
||||||
def _get_pv_surplus() -> float:
|
def _get_pv_surplus() -> float:
|
||||||
"""PV-Überschuss in Watt aus laufenden Geräten ermitteln.
|
"""PV-Überschuss in Watt. Nutzt grid_power-Aggregat (negativ = Einspeisung)."""
|
||||||
Goodwe: active_power negativ = Einspeisung (Überschuss).
|
agg = _compute_aggregates(allow_stale=True)
|
||||||
Growatt: power_to_grid positiv = Einspeisung.
|
return max(0.0, -agg.get("grid_power", 0.0))
|
||||||
"""
|
|
||||||
surplus = 0.0
|
|
||||||
with State.lock:
|
|
||||||
for inv_cfg in State.inverters_cfg:
|
|
||||||
d = State.inv_data.get(inv_cfg["id"], {})
|
|
||||||
if not d.get("modbus_ok") or not d.get("values"):
|
|
||||||
continue
|
|
||||||
v = d["values"]
|
|
||||||
# Goodwe ET: active_power > 0 = Einspeisung, < 0 = Netzbezug
|
|
||||||
# house_consumption = ppv + pbattery1 - active_power (Bibliotheks-Formel)
|
|
||||||
if "active_power" in v:
|
|
||||||
surplus += max(0.0, v["active_power"])
|
|
||||||
# Growatt
|
|
||||||
if "power_to_grid" in v:
|
|
||||||
surplus += max(0.0, v["power_to_grid"])
|
|
||||||
return surplus
|
|
||||||
|
|
||||||
|
|
||||||
# ── Poll Loop ─────────────────────────────────────────────────
|
# ── 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):
|
def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
||||||
inv_id = inv_cfg["id"]
|
inv_id = inv_cfg["id"]
|
||||||
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
|
model_id = inv_cfg.get("inverter_model", "MIC_1500_TL_X")
|
||||||
@@ -224,7 +280,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds",
|
log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds",
|
||||||
inv_id, inverter.name, host, interval)
|
inv_id, inverter.name, host, interval)
|
||||||
elif inverter.protocol == "kathrein":
|
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(
|
ems = EmsController(
|
||||||
min_pv_power=inv_cfg.get("ems_min_pv", 1400),
|
min_pv_power=inv_cfg.get("ems_min_pv", 1400),
|
||||||
pv_timeout_h=inv_cfg.get("ems_timeout", 4.0),
|
pv_timeout_h=inv_cfg.get("ems_timeout", 4.0),
|
||||||
@@ -234,7 +290,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
log.info("[%s] Poll-Loop: %s @ %s:%s (Kathrein EMS) alle %ds",
|
log.info("[%s] Poll-Loop: %s @ %s:%s (Kathrein EMS) alle %ds",
|
||||||
inv_id, inverter.name, host, port, interval)
|
inv_id, inverter.name, host, port, interval)
|
||||||
else:
|
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",
|
log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds",
|
||||||
inv_id, inverter.name, host, port, interval)
|
inv_id, inverter.name, host, port, interval)
|
||||||
|
|
||||||
@@ -254,6 +310,13 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
q.append(pt)
|
q.append(pt)
|
||||||
log.info("[%s] %d Sensoren aus DB geladen", inv_id, len(hist_data))
|
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():
|
while not stop.is_set():
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
values = reader.read(inverter)
|
values = reader.read(inverter)
|
||||||
@@ -266,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:
|
if values and "grid_power" not in values and "import_kwh" in values and "total_power" in values:
|
||||||
values["grid_power"] = values["total_power"]
|
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
|
# 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):
|
if ems is not None and values is not None and inv_cfg.get("ems_enabled", True):
|
||||||
pv_surplus = _get_pv_surplus()
|
pv_surplus = _get_pv_surplus()
|
||||||
# 0x0060 manchmal nicht lesbar → 1 (EV Connected) annehmen,
|
# charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
|
||||||
# damit EMS aktiviert; Wallbox ignoriert Befehle wenn kein Auto da
|
charging_state = int(values.get("charging_state", 0))
|
||||||
charging_state = int(values.get("charging_state", 1))
|
|
||||||
wallbox_power = values.get("total_power", 0.0)
|
wallbox_power = values.get("total_power", 0.0)
|
||||||
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power)
|
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power)
|
||||||
values["ems_status_code"] = float(charging_state)
|
values["ems_status_code"] = float(charging_state)
|
||||||
@@ -280,6 +355,7 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
with State.lock:
|
with State.lock:
|
||||||
d = State.inv_data.setdefault(inv_id, {"poll_count": 0})
|
d = State.inv_data.setdefault(inv_id, {"poll_count": 0})
|
||||||
if values is not None:
|
if values is not None:
|
||||||
|
_fail_count = 0
|
||||||
d["values"] = values
|
d["values"] = values
|
||||||
d["last_update"] = time.time()
|
d["last_update"] = time.time()
|
||||||
d["modbus_ok"] = True
|
d["modbus_ok"] = True
|
||||||
@@ -291,12 +367,15 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
q.append((now, val))
|
q.append((now, val))
|
||||||
history.write_batch(inv_id, now, values)
|
history.write_batch(inv_id, now, values)
|
||||||
if _publisher:
|
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)
|
_publisher.publish_status("online", prefix)
|
||||||
else:
|
else:
|
||||||
d["modbus_ok"] = False
|
_fail_count += 1
|
||||||
if _publisher:
|
if _fail_count >= _OFFLINE_THRESHOLD:
|
||||||
_publisher.publish_status("offline", prefix)
|
d["modbus_ok"] = False
|
||||||
|
if _publisher:
|
||||||
|
_publisher.publish_status("offline", prefix)
|
||||||
|
|
||||||
# Aggregate nach jedem erfolgreichen Poll neu berechnen und publizieren
|
# Aggregate nach jedem erfolgreichen Poll neu berechnen und publizieren
|
||||||
if values is not None and _publisher:
|
if values is not None and _publisher:
|
||||||
@@ -304,6 +383,14 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
|
|||||||
if agg:
|
if agg:
|
||||||
_publisher.publish_aggregates(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)))
|
stop.wait(max(0.0, interval - (time.time() - t0)))
|
||||||
|
|
||||||
reader.close()
|
reader.close()
|
||||||
@@ -404,6 +491,7 @@ def api_get_config():
|
|||||||
cfg = {**State.mqtt_cfg, "inverters": State.inverters_cfg}
|
cfg = {**State.mqtt_cfg, "inverters": State.inverters_cfg}
|
||||||
cfg.pop("mqtt_pass", None)
|
cfg.pop("mqtt_pass", None)
|
||||||
cfg["mqtt_connected"] = _publisher.connected if _publisher else False
|
cfg["mqtt_connected"] = _publisher.connected if _publisher else False
|
||||||
|
cfg["mqtt_error"] = _publisher.last_error if _publisher else None
|
||||||
return jsonify(cfg)
|
return jsonify(cfg)
|
||||||
|
|
||||||
@app.post("/api/config")
|
@app.post("/api/config")
|
||||||
@@ -415,17 +503,25 @@ def api_save_config():
|
|||||||
State.mqtt_cfg[k] = data[k]
|
State.mqtt_cfg[k] = data[k]
|
||||||
if data.get("mqtt_pass"):
|
if data.get("mqtt_pass"):
|
||||||
State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"]
|
State.mqtt_cfg["mqtt_pass"] = data["mqtt_pass"]
|
||||||
for k in ("price_import", "price_export", "spot_markup"):
|
for k in ("price_import", "price_export", "spot_markup", "monthly_rate_eur", "grundpreis_eur_per_month"):
|
||||||
if k in data:
|
if k in data and data[k] is not None:
|
||||||
State.mqtt_cfg[k] = float(data[k])
|
try:
|
||||||
|
State.mqtt_cfg[k] = float(data[k])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
for k in ("billing_day", "billing_month"):
|
for k in ("billing_day", "billing_month"):
|
||||||
if k in data:
|
if k in data and data[k] is not None:
|
||||||
State.mqtt_cfg[k] = int(data[k])
|
try:
|
||||||
|
State.mqtt_cfg[k] = int(data[k])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
for k in ("tariff_type", "spot_country"):
|
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])
|
State.mqtt_cfg[k] = str(data[k])
|
||||||
if "spot_chart" in data:
|
if "spot_chart" in data:
|
||||||
State.mqtt_cfg["spot_chart"] = bool(data["spot_chart"])
|
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()
|
save_config()
|
||||||
threading.Thread(target=_restart_all, daemon=True).start()
|
threading.Thread(target=_restart_all, daemon=True).start()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
@@ -499,8 +595,8 @@ def api_period_energy():
|
|||||||
pv_total = entry.get("total_energy_total")
|
pv_total = entry.get("total_energy_total")
|
||||||
grid_exp = entry.get("grid_export_kwh")
|
grid_exp = entry.get("grid_export_kwh")
|
||||||
bat_dch = entry.get("bat_discharge_total")
|
bat_dch = entry.get("bat_discharge_total")
|
||||||
if pv_total is not None and grid_exp is not None:
|
if pv_total is not None:
|
||||||
savings = round(max(0.0, pv_total - grid_exp), 2)
|
savings = round(max(0.0, pv_total - (grid_exp or 0.0)), 2)
|
||||||
entry["savings_kwh"] = savings
|
entry["savings_kwh"] = savings
|
||||||
entry["savings_eur"] = round(savings * eff_price, 2)
|
entry["savings_eur"] = round(savings * eff_price, 2)
|
||||||
elif bat_dch is not None:
|
elif bat_dch is not None:
|
||||||
@@ -509,8 +605,78 @@ def api_period_energy():
|
|||||||
|
|
||||||
result[period_type] = entry
|
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)
|
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")
|
@app.get("/api/z2m-devices")
|
||||||
def api_z2m_devices():
|
def api_z2m_devices():
|
||||||
with State.lock:
|
with State.lock:
|
||||||
@@ -522,7 +688,8 @@ def api_get_surplus_devices():
|
|||||||
devices = State.surplus_devices_cfg
|
devices = State.surplus_devices_cfg
|
||||||
z2m_base = State.z2m_base
|
z2m_base = State.z2m_base
|
||||||
states = _surplus_ctrl.get_states() if _surplus_ctrl else {}
|
states = _surplus_ctrl.get_states() if _surplus_ctrl else {}
|
||||||
return jsonify({"devices": devices, "z2m_base": z2m_base, "states": states})
|
surplus_w = _get_pv_surplus()
|
||||||
|
return jsonify({"devices": devices, "z2m_base": z2m_base, "states": states, "surplus_w": surplus_w})
|
||||||
|
|
||||||
@app.post("/api/surplus-devices")
|
@app.post("/api/surplus-devices")
|
||||||
def api_save_surplus_devices():
|
def api_save_surplus_devices():
|
||||||
@@ -732,6 +899,68 @@ def api_spot_price():
|
|||||||
log.warning("Spot-Price API Fehler: %s", e)
|
log.warning("Spot-Price API Fehler: %s", e)
|
||||||
return jsonify({"ok": False, "data": _spot_cache.get("data", [])})
|
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("/")
|
@app.get("/")
|
||||||
def index():
|
def index():
|
||||||
return send_from_directory(WEB_DIR, "index.html")
|
return send_from_directory(WEB_DIR, "index.html")
|
||||||
@@ -746,8 +975,12 @@ if __name__ == "__main__":
|
|||||||
history.init_db()
|
history.init_db()
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
with State.lock:
|
with State.lock:
|
||||||
State.mqtt_cfg = {k: cfg[k] for k in
|
State.mqtt_cfg = {k: cfg[k] for k in (
|
||||||
("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")}
|
"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.inverters_cfg = cfg.get("inverters", [])
|
||||||
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
|
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
|
||||||
State.z2m_base = cfg.get("z2m_base", "zigbee2mqtt")
|
State.z2m_base = cfg.get("z2m_base", "zigbee2mqtt")
|
||||||
|
|||||||
@@ -12,12 +12,23 @@ AGG_DEVICE_ID = "shinebridge_aggregate"
|
|||||||
AGG_TOPIC = "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:
|
class MqttPublisher:
|
||||||
def __init__(self, broker: str, port: int, user: str, password: str,
|
def __init__(self, broker: str, port: int, user: str, password: str,
|
||||||
agg_meta: Optional[Dict] = None):
|
agg_meta: Optional[Dict] = None):
|
||||||
self._broker = broker
|
self._broker = broker
|
||||||
self._port = port
|
self._port = port
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
self._last_error: Optional[str] = None
|
||||||
|
self._auth_failed = False
|
||||||
self._registered: List[Tuple] = []
|
self._registered: List[Tuple] = []
|
||||||
self._agg_meta: Dict = agg_meta or {}
|
self._agg_meta: Dict = agg_meta or {}
|
||||||
|
|
||||||
@@ -32,6 +43,8 @@ class MqttPublisher:
|
|||||||
def _on_connect(self, client, userdata, flags, rc):
|
def _on_connect(self, client, userdata, flags, rc):
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
self._last_error = None
|
||||||
|
self._auth_failed = False
|
||||||
log.info("MQTT verbunden: %s:%d", self._broker, self._port)
|
log.info("MQTT verbunden: %s:%d", self._broker, self._port)
|
||||||
for entry in self._registered:
|
for entry in self._registered:
|
||||||
self._publish_discovery(*entry)
|
self._publish_discovery(*entry)
|
||||||
@@ -40,11 +53,17 @@ class MqttPublisher:
|
|||||||
for topic, _ in self._subscriptions:
|
for topic, _ in self._subscriptions:
|
||||||
client.subscribe(topic)
|
client.subscribe(topic)
|
||||||
else:
|
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):
|
def _on_disconnect(self, client, userdata, rc):
|
||||||
self._connected = False
|
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):
|
def _on_message(self, client, userdata, msg):
|
||||||
for topic, callback in self._subscriptions:
|
for topic, callback in self._subscriptions:
|
||||||
@@ -60,6 +79,9 @@ class MqttPublisher:
|
|||||||
self._client.subscribe(topic)
|
self._client.subscribe(topic)
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
|
if self._auth_failed:
|
||||||
|
log.warning("MQTT connect übersprungen — Authentifizierung fehlgeschlagen")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self._client.connect_async(self._broker, self._port, keepalive=60)
|
self._client.connect_async(self._broker, self._port, keepalive=60)
|
||||||
self._client.loop_start()
|
self._client.loop_start()
|
||||||
@@ -74,6 +96,10 @@ class MqttPublisher:
|
|||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> Optional[str]:
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
# ── Gerät-Discovery ──────────────────────────────────────
|
# ── Gerät-Discovery ──────────────────────────────────────
|
||||||
|
|
||||||
def register_inverter(self, inverter: Inverter, device_id: str,
|
def register_inverter(self, inverter: Inverter, device_id: str,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ class SurplusDeviceController:
|
|||||||
self._base = z2m_base
|
self._base = z2m_base
|
||||||
self._devices: List[Dict[str, Any]] = []
|
self._devices: List[Dict[str, Any]] = []
|
||||||
self._states: Dict[str, bool] = {}
|
self._states: Dict[str, bool] = {}
|
||||||
|
self._on_since: Dict[str, float] = {} # z2m_name → Einschaltzeitpunkt
|
||||||
|
|
||||||
def set_config(self, devices: List[Dict[str, Any]], z2m_base: str = "zigbee2mqtt"):
|
def set_config(self, devices: List[Dict[str, Any]], z2m_base: str = "zigbee2mqtt"):
|
||||||
enabled_now = {d["z2m_name"] for d in devices if d.get("enabled", True)}
|
enabled_now = {d["z2m_name"] for d in devices if d.get("enabled", True)}
|
||||||
@@ -24,6 +26,7 @@ class SurplusDeviceController:
|
|||||||
self._base = z2m_base
|
self._base = z2m_base
|
||||||
|
|
||||||
def update(self, surplus_w: float):
|
def update(self, surplus_w: float):
|
||||||
|
now = time.time()
|
||||||
for dev in self._devices:
|
for dev in self._devices:
|
||||||
name = dev["z2m_name"]
|
name = dev["z2m_name"]
|
||||||
if not dev.get("enabled", True):
|
if not dev.get("enabled", True):
|
||||||
@@ -32,14 +35,34 @@ class SurplusDeviceController:
|
|||||||
continue
|
continue
|
||||||
threshold = float(dev.get("threshold_w", 500))
|
threshold = float(dev.get("threshold_w", 500))
|
||||||
hysteresis = float(dev.get("hysteresis_w", 150))
|
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)
|
currently_on = self._states.get(name, False)
|
||||||
if not currently_on and surplus_w >= threshold:
|
|
||||||
self._send(name, True)
|
|
||||||
elif currently_on and surplus_w < (threshold - hysteresis):
|
|
||||||
self._send(name, False)
|
|
||||||
|
|
||||||
def get_states(self) -> Dict[str, bool]:
|
# Invertiert: EIN bei Netzbezug (kein Überschuss), AUS bei Überschuss
|
||||||
return dict(self._states)
|
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 should_turn_off:
|
||||||
|
on_since = self._on_since.get(name, 0.0)
|
||||||
|
if now - on_since >= min_on_s:
|
||||||
|
self._send(name, False)
|
||||||
|
else:
|
||||||
|
remaining = int((min_on_s - (now - on_since)) / 60)
|
||||||
|
log.info("[SurplusDevices] %s bleibt an — Mindestlaufzeit: noch %dmin", name, remaining)
|
||||||
|
|
||||||
|
def get_states(self) -> Dict[str, Any]:
|
||||||
|
now = time.time()
|
||||||
|
result = {}
|
||||||
|
for name, on in self._states.items():
|
||||||
|
entry: Dict[str, Any] = {"on": on}
|
||||||
|
if on and name in self._on_since:
|
||||||
|
entry["on_minutes"] = int((now - self._on_since[name]) / 60)
|
||||||
|
result[name] = entry
|
||||||
|
return result
|
||||||
|
|
||||||
def _send(self, z2m_name: str, on: bool):
|
def _send(self, z2m_name: str, on: bool):
|
||||||
topic = f"{self._base}/{z2m_name}/set"
|
topic = f"{self._base}/{z2m_name}/set"
|
||||||
@@ -47,6 +70,8 @@ class SurplusDeviceController:
|
|||||||
try:
|
try:
|
||||||
self._pub.publish_raw(topic, payload)
|
self._pub.publish_raw(topic, payload)
|
||||||
self._states[z2m_name] = on
|
self._states[z2m_name] = on
|
||||||
|
if not on:
|
||||||
|
self._on_since.pop(z2m_name, None)
|
||||||
log.info("[SurplusDevices] %s → %s", z2m_name, "ON" if on else "OFF")
|
log.info("[SurplusDevices] %s → %s", z2m_name, "ON" if on else "OFF")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("[SurplusDevices] MQTT Fehler: %s", e)
|
log.error("[SurplusDevices] MQTT Fehler: %s", e)
|
||||||
|
|||||||
+685
-41
@@ -13,32 +13,39 @@
|
|||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { max-width: 100vw; overflow-x: hidden; }
|
||||||
body { background: var(--bg); color: var(--text);
|
body { background: var(--bg); color: var(--text);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
font-size: 14px; min-height: 100vh; }
|
font-size: 14px; min-height: 100vh; }
|
||||||
|
|
||||||
header { display: flex; align-items: center; gap: 12px;
|
header { display: flex; align-items: center; gap: 10px;
|
||||||
padding: 16px 20px; background: var(--surface);
|
padding: 12px 16px; background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
position: sticky; top: 0; z-index: 100; }
|
position: sticky; top: 0; z-index: 100;
|
||||||
header h1 { font-size: 16px; font-weight: 600; }
|
min-width: 0; }
|
||||||
header .subtitle { font-size: 12px; color: var(--text-dim); }
|
header h1 { font-size: 16px; font-weight: 600; white-space: nowrap; }
|
||||||
.status-pill { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
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;
|
.pill { display: flex; align-items: center; gap: 5px; padding: 4px 10px;
|
||||||
border-radius: 20px; font-size: 12px; font-weight: 600;
|
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.ok { color: var(--green); border-color: var(--green); }
|
||||||
.pill.err { color: var(--red); border-color: var(--red); }
|
.pill.err { color: var(--red); border-color: var(--red); }
|
||||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor;
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor;
|
||||||
animation: pulse 2s infinite; }
|
animation: pulse 2s infinite; }
|
||||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||||
|
|
||||||
main { padding: 20px; max-width: 1100px; margin: 0 auto; }
|
main { padding: 16px; max-width: 1100px; margin: 0 auto; }
|
||||||
.tabs { display: flex; gap: 4px; margin-bottom: 20px;
|
.tabs { display: flex; gap: 2px; margin-bottom: 16px;
|
||||||
border-bottom: 1px solid var(--border); }
|
border-bottom: 1px solid var(--border);
|
||||||
.tab { padding: 10px 16px; cursor: pointer; color: var(--text-dim);
|
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;
|
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.active { color: var(--accent); border-color: var(--accent); }
|
||||||
.tab:hover { color: var(--text); }
|
.tab:hover { color: var(--text); }
|
||||||
.panel { display: none; }
|
.panel { display: none; }
|
||||||
@@ -137,11 +144,11 @@
|
|||||||
.info-chip span { color: var(--text); font-weight: 600; }
|
.info-chip span { color: var(--text); font-weight: 600; }
|
||||||
|
|
||||||
/* Energy Dashboard */
|
/* Energy Dashboard */
|
||||||
.energy-wrap { max-width: 580px; margin: 0 auto; }
|
.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; }
|
.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(95px, 1fr)); gap: 10px; }
|
.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: 14px 8px; text-align: center; }
|
.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: 18px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
.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; }
|
.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 */ }
|
.flow-dot { /* dots via animateMotion — no CSS animation needed */ }
|
||||||
|
|
||||||
@@ -154,6 +161,49 @@
|
|||||||
.toast.show { transform: translateY(0); opacity: 1; }
|
.toast.show { transform: translateY(0); opacity: 1; }
|
||||||
.toast.ok { border-color: var(--green); color: var(--green); }
|
.toast.ok { border-color: var(--green); color: var(--green); }
|
||||||
.toast.err { border-color: var(--red); color: var(--red); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -175,9 +225,11 @@
|
|||||||
<main>
|
<main>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" onclick="switchTab('energy')">Energie</div>
|
<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('live')">Live-Daten</div>
|
||||||
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
|
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
|
||||||
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
|
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
|
||||||
|
<div class="tab" onclick="switchTab('flash')">Flash</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Energy Panel -->
|
<!-- Energy Panel -->
|
||||||
@@ -185,6 +237,11 @@
|
|||||||
<div id="energy-content"><div class="no-data">Warte auf erste Messung…</div></div>
|
<div id="energy-content"><div class="no-data">Warte auf erste Messung…</div></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 -->
|
<!-- Live Panel -->
|
||||||
<div class="panel" id="panel-live">
|
<div class="panel" id="panel-live">
|
||||||
<div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div>
|
<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="panel" id="panel-settings">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>MQTT Broker</h3>
|
<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>
|
<div class="field"><label>Broker</label>
|
||||||
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto"></div>
|
<input type="text" id="cfg-mqtt-broker" placeholder="core-mosquitto"></div>
|
||||||
<div class="field"><label>Port</label>
|
<div class="field"><label>Port</label>
|
||||||
@@ -254,6 +312,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">z.B. 1 . 4 = 01. April</div>
|
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">z.B. 1 . 4 = 01. April</div>
|
||||||
</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>
|
<button class="btn btn-primary" onclick="savePrices()">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -282,6 +357,100 @@
|
|||||||
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
|
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
|
|
||||||
<!-- Inverter Edit Modal -->
|
<!-- Inverter Edit Modal -->
|
||||||
@@ -335,6 +504,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<datalist id="z2m-device-list"></datalist>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
@@ -413,11 +653,12 @@ function showToast(msg, type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchTab(name) {
|
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(".tab")[i].classList.toggle("active", t === name);
|
||||||
document.querySelectorAll(".panel")[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 === "energy" || name === "live") startRefresh(); else stopRefresh();
|
||||||
|
if (name === "finance") loadFinance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Energy Dashboard ──────────────────────────────────────────
|
// ── Energy Dashboard ──────────────────────────────────────────
|
||||||
@@ -472,6 +713,15 @@ function renderSpotChart(spotData) {
|
|||||||
</div>`;
|
</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) {
|
function renderEnergy(inverters, aggregates, period, spotData) {
|
||||||
const el = document.getElementById("energy-content");
|
const el = document.getElementById("energy-content");
|
||||||
if (!aggregates || !Object.keys(aggregates).length) {
|
if (!aggregates || !Object.keys(aggregates).length) {
|
||||||
@@ -607,15 +857,6 @@ function renderEnergy(inverters, aggregates, period, spotData) {
|
|||||||
const pi = period.price_import || 0.30;
|
const pi = period.price_import || 0.30;
|
||||||
const pe = period.price_export || 0.08;
|
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) {
|
function periodCard(label, kwh, cost, col, sub) {
|
||||||
if (kwh == null) return '';
|
if (kwh == null) return '';
|
||||||
return `<div class="kwh-card" style="border-top:3px solid ${col}">
|
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 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">
|
el.innerHTML = `<div class="energy-wrap">
|
||||||
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
|
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
|
||||||
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
|
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
|
||||||
|
${trackerHtml}
|
||||||
</div>`;
|
</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 ─────────────────────────────────────────────────
|
// ── Live Data ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
@@ -894,10 +1283,22 @@ function updateTariffUI() {
|
|||||||
document.getElementById("tariff-spot-fields").style.display = isSpot ? "" : "none";
|
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() {
|
async function loadSettings() {
|
||||||
const cfg = await fetchJSON(api("api/config"));
|
const cfg = await fetchJSON(api("api/config"));
|
||||||
globalConfig = cfg;
|
globalConfig = cfg;
|
||||||
loadSurplusDevices();
|
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-broker").value = cfg.mqtt_broker || "";
|
||||||
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
|
document.getElementById("cfg-mqtt-port").value = cfg.mqtt_port || 1883;
|
||||||
document.getElementById("cfg-mqtt-user").value = cfg.mqtt_user || "";
|
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-spot-chart").checked = cfg.spot_chart ?? true;
|
||||||
document.getElementById((cfg.tariff_type ?? "fixed") === "spot" ? "tariff-spot" : "tariff-fixed").checked = true;
|
document.getElementById((cfg.tariff_type ?? "fixed") === "spot" ? "tariff-spot" : "tariff-fixed").checked = true;
|
||||||
updateTariffUI();
|
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() {
|
async function savePrices() {
|
||||||
@@ -921,8 +1327,11 @@ async function savePrices() {
|
|||||||
spot_markup: parseFloat(document.getElementById("cfg-spot-markup").value || 0),
|
spot_markup: parseFloat(document.getElementById("cfg-spot-markup").value || 0),
|
||||||
spot_country: document.getElementById("cfg-spot-country").value,
|
spot_country: document.getElementById("cfg-spot-country").value,
|
||||||
spot_chart: document.getElementById("cfg-spot-chart").checked,
|
spot_chart: document.getElementById("cfg-spot-chart").checked,
|
||||||
billing_day: parseInt(document.getElementById("cfg-billing-day").value),
|
billing_day: parseInt(document.getElementById("cfg-billing-day").value) || 1,
|
||||||
billing_month: parseInt(document.getElementById("cfg-billing-month").value),
|
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 {
|
try {
|
||||||
await fetchJSON(api("api/config"), {
|
await fetchJSON(api("api/config"), {
|
||||||
@@ -988,18 +1397,31 @@ async function importConfig(input) {
|
|||||||
|
|
||||||
let surplusDevices = [];
|
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) {
|
function renderSurplusDeviceRow(dev) {
|
||||||
const id = dev.id || ('sd_' + Math.random().toString(36).slice(2));
|
const id = dev.id || ('sd_' + Math.random().toString(36).slice(2));
|
||||||
return `<div class="surplus-device-row" data-id="${esc(id)}">
|
return `<div class="surplus-device-row" data-id="${esc(id)}">
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||||||
<input type="text" placeholder="Name" style="flex:1;min-width:110px" value="${esc(dev.name||'')}" data-field="name">
|
${field('Name', `<input type="text" placeholder="Heizstab Keller" style="width:130px" 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">
|
${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">`)}
|
||||||
<input type="number" placeholder="Ab (W)" title="Schwellwert" style="width:76px" value="${dev.threshold_w||500}" min="0" step="50" data-field="threshold_w">
|
${field('Schwellwert (W)', `<input type="number" style="width:90px" 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">
|
${field('Hysterese (W)', `<input type="number" style="width:90px" value="${dev.hysteresis_w||150}" min="0" step="50" data-field="hysteresis_w">`)}
|
||||||
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap">
|
${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">`)}
|
||||||
<input type="checkbox" ${dev.enabled!==false?'checked':''} data-field="enabled" style="width:auto;margin:0"> Aktiv
|
<div style="display:flex;flex-direction:column;gap:6px;padding-bottom:2px">
|
||||||
</label>
|
<label style="cursor:pointer;display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap">
|
||||||
<button class="btn btn-secondary" style="padding:3px 8px;font-size:12px;flex-shrink:0" onclick="removeSurplusDevice(this)">✕</button>
|
<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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -1041,7 +1463,9 @@ function _collectSurplusDevices() {
|
|||||||
name: row.querySelector('[data-field=name]').value.trim(),
|
name: row.querySelector('[data-field=name]').value.trim(),
|
||||||
z2m_name: row.querySelector('[data-field=z2m_name]').value.trim(),
|
z2m_name: row.querySelector('[data-field=z2m_name]').value.trim(),
|
||||||
threshold_w: parseFloat(row.querySelector('[data-field=threshold_w]').value) || 0,
|
threshold_w: parseFloat(row.querySelector('[data-field=threshold_w]').value) || 0,
|
||||||
hysteresis_w: parseFloat(row.querySelector('[data-field=hysteresis_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,
|
enabled: row.querySelector('[data-field=enabled]').checked,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1069,29 +1493,249 @@ function renderSurplusStatus(surplusData) {
|
|||||||
const rows = surplusData.devices
|
const rows = surplusData.devices
|
||||||
.filter(d => d.enabled !== false)
|
.filter(d => d.enabled !== false)
|
||||||
.map(d => {
|
.map(d => {
|
||||||
const on = states[d.z2m_name] === true;
|
const st = states[d.z2m_name] || {};
|
||||||
|
const on = st.on === true;
|
||||||
|
const onMin = st.on_minutes ?? null;
|
||||||
|
const minOn = d.min_on_minutes || 0;
|
||||||
const dot = `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:${on?'var(--green)':'var(--border)'}"></span>`;
|
const dot = `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:${on?'var(--green)':'var(--border)'}"></span>`;
|
||||||
|
let info = '';
|
||||||
|
if (on && minOn > 0 && onMin !== null) {
|
||||||
|
const remaining = Math.max(0, minOn - onMin);
|
||||||
|
info = remaining > 0
|
||||||
|
? `<span style="font-size:11px;color:var(--text-dim)">(${remaining}min Mindestlaufzeit)</span>`
|
||||||
|
: `<span style="font-size:11px;color:var(--text-dim)">(${onMin}min)</span>`;
|
||||||
|
}
|
||||||
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0">
|
return `<div style="display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 0">
|
||||||
${dot}
|
${dot}
|
||||||
<span>${esc(d.name||d.z2m_name)}</span>
|
<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>
|
<span style="margin-left:auto;font-weight:600;color:${on?'var(--green)':'var(--text-dim)'}">${on?'EIN':'AUS'}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
if (!rows) return '';
|
if (!rows) return '';
|
||||||
|
const surplusW = surplusData.surplus_w ?? null;
|
||||||
|
const surplusInfo = surplusW !== null
|
||||||
|
? `<span style="font-size:11px;color:var(--text-dim);margin-left:auto">${surplusW >= 0 ? '+' : ''}${Math.round(surplusW)} W Überschuss</span>`
|
||||||
|
: '';
|
||||||
return `<div class="inv-section">
|
return `<div class="inv-section">
|
||||||
<div class="inv-header">
|
<div class="inv-header">
|
||||||
<div class="inv-title">Überschuss-Geräte</div>
|
<div class="inv-title">Überschuss-Geräte</div>
|
||||||
<div class="inv-badge ok">Z2M</div>
|
<div class="inv-badge ok">Z2M</div>
|
||||||
|
${surplusInfo}
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0 2px">${rows}</div>
|
<div style="padding:0 2px">${rows}</div>
|
||||||
</div>`;
|
</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 newInv = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
// Bestehende Geräte behalten — Wizard darf nicht die komplette Liste überschreiben
|
||||||
|
const body = [...invertersList, newInv];
|
||||||
|
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 ──────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await Promise.all([loadSettings(), loadInverters(), loadModels()]);
|
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();
|
startRefresh();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user