21 Commits

Author SHA1 Message Date
retr0 b36b0194f7 ShineBridge v1.8.30 — Ingress-Fix: Flask wieder auf 0.0.0.0
127.0.0.1 brach HAOS Ingress (Proxy kommt von außerhalb localhost).
Sicherheit bleibt gewahrt: kein ports:-Eintrag in config.yaml,
Port 8099 wird nicht ans Host-Netz gemappt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:04:15 +02:00
retr0 04a8fb3125 ShineBridge v1.8.29 — Port 8099 nur auf localhost binden
Flask bindet auf 127.0.0.1 statt 0.0.0.0. Mit host_network: true war Port
8099 direkt vom LAN erreichbar. HAOS-Ingress verbindet sich über localhost,
daher kein Funktionsverlust.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:23:01 +02:00
retr0 d9f94d3f28 ShineBridge v1.8.28 — history.py: load_recent Fix + periodisches Cleanup
- load_recent(): Window-Funktion durch pro-Sensor-Indexabfragen ersetzt
  (SELECT ... ORDER BY ts DESC LIMIT N per sensor_id) — nutzt Index optimal,
  kein Full-Table-Scan mehr auf 1M+ Zeilen beim Start
- Periodisches Cleanup: täglich via Daemon-Thread statt nur beim Start —
  DB bleibt dauerhaft auf RETENTION_DAYS beschränkt
- RETENTION_DAYS: 7 → 14 (explizites Maximum per Konfiguration)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:31:46 +02:00
retr0 c4047fc804 fix(v1.8.27): Fahrzeugerkennung + Wizard überschreibt Inverterliste
Bug 1 (inverters.py): Kathrein-Register 0x0061-0x0064 existieren nicht —
(0x0060, 10) schlug daher immer mit IllegalAddress fehl, charging_state
wurde nie gelesen → EMS meldete dauerhaft "kein Fahrzeug". Fix: aufgeteilt
in (0x0060, 1) + (0x0065, 6), sodass die Lücke übersprungen wird.

Bug 2 (index.html): wizardStep2Next() postete [neues_gerät] statt
[...invertersList, neues_gerät] → überschrieb die gesamte Inverterliste.
Wenn der Wizard bei einem bestehenden Setup erschien, flogen alle anderen
Geräte raus. Fix: bestehende Geräte werden beibehalten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:58:44 +02:00
retr0 5564a50c3c fix(ems): Zwangsladen-Countdown läuft ohne angestecktes Auto
Kathrein Reg 0x0060 liefert IllegalAddress wenn kein Fahrzeug angeschlossen.
Default war 1 (STATE_CONNECTED) → EMS nahm Auto als verbunden an → Countdown.

IllegalAddress ist kein sporadischer Lesefehler, sondern das definierte Signal
der Wallbox für "kein Fahrzeug". Default auf 0 (STATE_IDLE) → EMS kehrt sofort
zu "kein Fahrzeug" zurück, _no_pv_since-Timer wird nicht gestartet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:12:35 +02:00
retr0 dc2df891fb fix: TCP-Timeout auf 40% des Poll-Intervalls begrenzt (max. 5s)
Logs zeigen: Bei 10s Poll-Rate + 10s TCP-Timeout läuft ein fehlgeschlagener
Connect exakt so lange wie das Interval. stop.wait() wird 0 → nächster Poll
startet sofort → zweite parallele TCP-Verbindung → Wallbox überfordert → Spirale.

Fix: timeout = min(5.0, interval * 0.4). Bei 15s → 5.0s Timeout; bei 10s → 4.0s.
Ein Fehler belegt max. 40% des Intervalls, der Rest ist Wartezeit vor dem nächsten Versuch.
Gilt für WallboxReader (Kathrein) und ModbusReader (ShineLAN-X / SDM-630).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:08:59 +02:00
retr0 a361c30f1b fix: Offline-Flapping — erst nach 3 aufeinanderfolgenden Lesefehlern offline
Ein einzelner UDP-Paketverlust (Goodwe) oder TCP-Timeout (Modbus) hat sofort
MQTT-Status "offline" getriggert. Bei 10s Poll-Rate reicht ein Ausreißer.

Fix: _fail_count pro Poll-Loop, OFFLINE_THRESHOLD=3. Erst wenn 3 Reads in Folge
scheitern (≥30s bei 10s Interval) wird offline publiziert. Erfolg resettet
den Zähler auf 0 und stellt online sofort wieder her.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:58:48 +02:00
retr0 a9f33c8e9e fix(goodwe): Netzbezug-Periode zeigt 0 kWh — integrierter grid_power-Zähler
e_total_imp vom Goodwe-WR ist ein Lifetime-Zähler seit Inbetriebnahme.
Beim ersten Verbinden speichert save_period_start_if_new() den aktuellen
Wert als Periodenstart → Delta = 0.

Fix: _int_import/_int_export werden je Poll-Zyklus aus grid_power integriert
(W × dt / 3.600.000 → kWh), in der measurements-DB persistiert und
beim Neustart aus der DB wiederhergestellt. AGG_SENSOR_IDS bevorzugt nun
_int_import vor e_total_imp, SDM-630 (import_kwh) bleibt erste Wahl.
Private Keys (Prefix _) werden nicht an MQTT gepublished.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:55:24 +02:00
retr0 512b743b16 Fix: Stromtarif-Einstellungen nach Neustart verloren + Finanzen-Layout (v1.8.22)
State.mqtt_cfg wurde beim Start nur mit 4 MQTT-Keys initialisiert — alle
Tarif/Billing-Keys fehlten, wurden nach Neustart auf Defaults zurückgesetzt.
Fix: alle persistenten Keys aus load_config() in State.mqtt_cfg übernehmen.

Finanzen-Tab: mehr Abstände, größere Karten (22px Wert), Abschnittsüberschriften,
Trennlinie vor dem Chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:26:52 +02:00
retr0 cb5f23d486 Fix: Finanzen-Tab bleibt bei 'Lade...' hängen (v1.8.21)
fEur/fKwh waren lokale Funktionen in renderEnergy() — loadFinance()
konnte sie nicht aufrufen (ReferenceError außerhalb des Scopes).
Beide Funktionen in den globalen Scope verschoben, lokale Kopien entfernt.
loadFinance() Rendering-Block in try/catch gewrappt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:18:17 +02:00
retr0 7904d498b5 v1.8.20: Fix Eigenversorgungskarte + Stromtarif-Einstellungen verloren
Bug 1 — Eigenversorgungskarte verschwindet wenn PV offline:
- savings_kwh wird jetzt gesetzt wenn pv_total vorhanden (grid_exp muss
  nicht mehr explizit vorhanden sein, default 0.0)
- Karte bleibt sichtbar auch wenn Wechselrichter offline geht

Bug 2 — Stromtarif-Einstellungen gehen beim Speichern verloren:
- savePrices(): parseInt mit || 1 Fallback, verhindert NaN → JSON-null
- api_save_config(): None-Checks + try/except für alle numerischen Keys,
  save_config() wird garantiert immer aufgerufen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:25:01 +02:00
retr0 5a00c7b5d3 docs: Roadmap auf v1.8.19 aktualisiert
Alle erledigten Features als [x] markiert, neue Einträge für
Flash-Wizard, NuttX OTA, Setup-Wizard, MQTT rc=5, Port-Sicherheit,
Mobile-Layout. Changelog bis v1.8.19 ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:53:58 +02:00
retr0 b64bcde203 v1.8.19: Version erhöht
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:45:27 +02:00
retr0 b3f67df967 fix: Mobile-Layout — Tabs scrollen, kein horizontaler Overflow
- html/body: overflow-x: hidden verhindert seitlichen Scroll
- Tabs: overflow-x: auto + white-space: nowrap + flex-shrink: 0
  → horizontal scrollbar-frei auf Handy
- Header: min-width: 0, flex-shrink: 0 für Pill, Text-Overflow
- main: padding 16px statt 20px
- energy-wrap: width: 100%, kwh-card min-width: 0
- @media ≤520px: sensor-grid, settings, header-icon ausgeblendet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:39:59 +02:00
retr0 e72ad2db19 docs: README auf v1.8.18 aktualisiert
Flash-Wizard, OTA-Abschnitt (Zwei-Phasen), Setup-Wizard, Finanzen-Tab,
Abschlags-Tracker, Port-Sicherheit, Überschuss-Geräte, repo-Struktur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:32:29 +02:00
retr0 3b3d4055f6 v1.8.18: Version in config.yaml angehoben
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 20:05:07 +02:00
retr0 15c0ede72e v1.8.18: MQTT rc=5 Fehlerhandling, Port-Absicherung, Flash-Wizard + NuttX OTA
MQTT:
- rc=5 (Not Authorized) stoppt Reconnect-Loop via _auth_failed Flag
- Fehlermeldung im MQTT-Einstellungen-Banner sichtbar

Sicherheit:
- /api/* nur über HAOS-Ingress (X-Ingress-Path) oder Loopback erreichbar

Flash-Wizard (Baustelle B):
- Neuer Tab "Flash" mit IP-Eingabe und OTA-Modus-Erkennung
- OTA: integrierte oder eigene Firmware via POST /api/flash/update auf Stick
- Fortschrittsbalken + Polling bis Stick nach Reset wieder online
- ST-Link-Erstflash-Anleitung (Pinout, st-flash Kommando)
- Firmware-Binaries im Docker-Image unter /firmware/

NuttX OTA (Baustelle A, shinelanx-modbus):
- ota_http.c: Zwei-Phasen OTA für STM32F103 Single-Bank Flash
  Stage 1: Firmware in Staging-Bereich (obere Flashhälfte) schreiben
  Stage 2: .ramfuncs aus SRAM heraus — Staging → App-Bereich kopieren, Reset
- ota_http.h, Makefile und main.c entsprechend erweitert
- ld.script.dfu: .ramfuncs in .data Section → Ausführung aus SRAM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:45:40 +02:00
retr0 bbfb11fb9c v1.8.17: Atomarer Config-Write + Backup-Fallback
Verhindert Datenverlust wenn HAOS den Container während eines Saves
stoppt. Schreibt erst config.json.tmp, dann atomares os.replace().
Hält config.json.bak als Fallback für den nächsten Start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:40:56 +02:00
retr0 2456f356b4 v1.8.16: Finanzen-Tab — Festpreis vs. Spot-Vergleich
Täglicher Tarif-Tracking: kWh + EPEX-Ø-Preis werden ab jetzt täglich
gespeichert. Neuer Finanzen-Tab zeigt Balkendiagramm (Festpreis vs.
Spot hypothetisch), Summen-Karten und Empfehlung ob flexibler Tarif
sich lohnen würde.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:29:55 +02:00
retr0 dfb42e6902 v1.8.15: Abschlags-Tracker zeigt ganze Zahlungen statt Bruchmonate
Berechnung auf geleistete Abschlagszahlungen umgestellt:
Anzahl ganzer Monate seit Abrechnungsstart statt Tage/30.4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:12:43 +02:00
retr0 fec49ec4fb v1.8.14: Abschlags-Tracker (monatliche Rate + Grundpreis)
Neues optionales Feature: Abschlags-Übersicht im Energie-Dashboard.
Zeigt Bereits bezahlt, Grundpreis anteilig, Energiekosten sowie
voraussichtliche Nachzahlung oder Guthaben für das laufende Abrechnungsjahr.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:45:56 +02:00
11 changed files with 1157 additions and 151 deletions
+67 -33
View File
@@ -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
View File
@@ -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.1416)
- [x] Überschuss-Geräte — Zigbee2MQTT-Geräte bei PV-Überschuss steuern (v1.8.813)
- [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) |
+1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.8.13" version: "1.8.30"
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
Binary file not shown.
Binary file not shown.
+78 -16
View File
@@ -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:
+3 -2
View File
@@ -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="",
), ),
+284 -36
View File
@@ -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 ───────────────────────────────────────────────
@@ -187,6 +228,37 @@ def _get_pv_surplus() -> float:
# ── 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")
@@ -208,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),
@@ -218,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)
@@ -238,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)
@@ -250,12 +329,24 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
if values and "grid_power" not in values and "import_kwh" in values and "total_power" in values: 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 lesbar1 (EV Connected) annehmen, # charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug0 (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)
@@ -264,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
@@ -275,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:
@@ -288,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()
@@ -388,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")
@@ -399,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})
@@ -483,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:
@@ -493,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:
@@ -717,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")
@@ -731,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")
+28 -2
View File
@@ -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,
+642 -29
View File
@@ -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"), {
@@ -1119,10 +1528,214 @@ function renderSurplusStatus(surplusData) {
</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();
})(); })();