Compare commits

..
13 Commits
Author SHA1 Message Date
retr0andClaude Sonnet 5 8be3a7eb47 ShineBridge v1.12.2 — Feature: Solarprognose-Diagramm im Energie-Dashboard
Neues 24h-Balkendiagramm direkt unter dem EPEX-Spot-Chart im Energie-Tab,
nach demselben Muster (renderSpotChart als Vorbild) — stündliche Solar-
strahlung (GHI), aktueller Wert hervorgehoben, Balken über der Sonnenschwelle
gelb statt grau. Nur sichtbar, wenn ein Standort gesetzt ist.

/api/weather-forecast liefert jetzt zusätzlich die rohe 24h-Stundenreihe
(hourly: [{ts, ghi}]) fürs Diagramm, nicht nur die Zusammenfassung fürs
Einstellungen-Tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:19:45 +02:00
retr0andClaude Sonnet 5 1ce98df596 ShineBridge v1.12.1 — Feature: Wetterprognose-Vorschau im Einstellungen-Tab
v1.12.0 hat die Prognose nur intern verrechnet, nirgends sichtbar gemacht.
Neuer Endpunkt GET /api/weather-forecast (weather_forecast.summary()) liefert
aktuelle Solarstrahlung (naechste 3h/12h Maximum) + ein Beispiel, wie sich der
Zwangslade-Timeout dadurch gerade veraendern wuerde (Basis: erster konfigurierter
Kathrein-Timeout, sonst 2h-Default). Wird direkt unter den Standort-Feldern
angezeigt, aktualisiert sich nach dem Speichern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:28:34 +02:00
retr0andClaude Sonnet 5 8f5106e3a0 ShineBridge v1.12.0 — Feature: Prognosebasierter Zwangslade-Timeout (Open-Meteo)
Neuer Standort-Bereich im Einstellungen-Tab (Breitengrad/Längengrad, optional,
site-weit statt pro Gerät). Neues Modul weather_forecast.py fragt Open-Meteos
kostenlose stündliche Solarstrahlungs-Prognose (shortwave_radiation, 48h
voraus, timezone=auto) ab, gecacht alle 3h.

adjusted_timeout_h() passt den Zwangslade-Timeout adaptiv an:
- bald nennenswerte Sonne zu erwarten (naechste 3h) -> Timeout verlaengern
  (x1.75, gedeckelt auf Basiswert+4h)
- auch im 12h-Fenster keine nennenswerte Sonne zu erwarten -> Timeout
  verkuerzen (x0.5, min. 0.5h) statt sinnlos den vollen Timeout abzuwarten
- ohne Standort (lat/lon leer): unveraendertes Verhalten, Feature ist Opt-in

EmsController.update() bekommt einen neuen optionalen timeout_override_h-
Parameter statt selbst Standortdaten zu kennen — Wetter-Logik bleibt im
Poll-Loop, EmsController fokussiert auf die eigentliche Lade-Steuerung.

Auf Sonnenstand-Berechnung bewusst verzichtet — die stuendliche Strahlungs-
prognose bildet Tag/Nacht-Verlauf implizit schon ab, eigene Ephemeriden-
Berechnung waere doppelte Arbeit ohne Mehrwert.

Teil 2 von 2 des 'Prognosebasiertes Laden'-Roadmap-Punkts, damit komplett.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:57:04 +02:00
retr0andClaude Sonnet 5 202759cc84 ShineBridge v1.11.2 — Feature: PV-Überschuss batteriebewusst berechnen
_get_pv_surplus() zieht jetzt die aktuelle Batterie-Entladeleistung vom
Netzexport ab, bevor er als PV-Überschuss fürs Auto verwendet wird. Verhindert,
dass PV-Überschussladen bei bewölktem Wetter versehentlich den Hausspeicher
anzapft, wenn der Export eigentlich aus der Batterie statt frischer PV-Produktion
kommt. Betrifft nur PV-Überschussladen — Zwangsladen bleibt bewusst unverändert
netzbasiert. Anlagen ohne Batterie (bat_discharge_power fehlt) verhalten sich
unverändert wie zuvor.

Teil 1 von 2 des 'Prognosebasiertes Laden'-Roadmap-Punkts — Teil 2
(Wetterprognose + Sonnenstand) folgt als eigener Schritt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:41:41 +02:00
retr0andClaude Sonnet 5 0f564a561f ROADMAP: endgültige Root Cause des 6,5-Minuten-Zyklus — Schütz in der Wallbox zog nicht an
Reines Hardware-Problem (Leistungskontakt), Steuerungs-/Kommunikationsebene der
Wallbox lief einwandfrei. ShineBridge zu 100% als Ursache ausgeschlossen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:10:06 +02:00
retr0andClaude Sonnet 5 c524a0189a ROADMAP: 6,5-Minuten-Zyklus live diagnostiziert — kein ShineBridge-Bug
Live-Test 2026-08-26: Wallbox gibt korrekt und stabil 16A/11kW frei, tatsächlicher
Strom/Leistung blieb bei 0 auf allen drei Phasen. Nutzer bestätigt Auto hing in
einem Lade-Loop. ShineBridge/EMS damit endgültig als Ursache ausgeschlossen —
Verhandlungs-/Kompatibilitätsproblem zwischen Kathrein-Wallbox und Fahrzeug.
Discord-Alerts-Punkt damit auch nicht mehr blockiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 14:31:27 +02:00
retr0andClaude Sonnet 5 4ff91a2674 ROADMAP: Discord-Alerts bewusst hinter Zyklus-Bug-Fix zurückgestellt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:50:35 +02:00
retr0andClaude Sonnet 5 117a2d2beb ROADMAP: Backup-Verwaltung als erledigt markiert
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:27:23 +02:00
retr0andClaude Sonnet 5 b7dc008cd7 ShineBridge v1.11.1 — Feature: Backup-Verwaltung im Einstellungen-Tab
Neue Sektion 'Automatische Backups':
- Aufbewahrung (Tage) konfigurierbar statt hartcodierter 14 Tage
  (config_backup_retention_days, Teil der normalen Config-Persistenz)
- Liste aller vorhandenen Backups (Datum, Größe), neueste zuerst
- Download-Button pro Backup
- Wiederherstellen-Button pro Backup (mit Bestätigungsdialog, da überschreibend)

Backend: Validierung+Anwendung aus /api/import-config in _apply_import_data()
extrahiert, von Datei-Upload und neuem Backup-Restore-Endpunkt gemeinsam
genutzt. Neue Endpunkte GET /api/config-backups, GET .../download,
POST .../restore — mit Path-Traversal-Schutz (_safe_backup_filename, nur
Dateinamen aus dem Backup-Ordner selbst).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:27:00 +02:00
retr0andClaude Sonnet 5 3318d1344d ShineBridge v1.11.0 — Feature: automatisches tägliches Config-Backup nach /share
Neuer Daemon-Thread (gleiches Muster wie der History-Cleanup-Thread) schreibt
täglich einen Config-Export nach /share/shinebridge-backups/ — bewusst nicht
nach /data, da genau dieser Ordner bei Add-on-Uninstall+Reinstall gelöscht
wird (siehe Vorfall 2026-08-25). Export sofort beim Start plus alle 24h,
14 Tage Aufbewahrung mit automatischem Cleanup alter Dateien.

Export-Datenaufbau aus /api/export-config in _build_export_data() extrahiert,
von Endpoint und Scheduler gemeinsam genutzt. Neue Berechtigung map: share:rw
in config.yaml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:52:52 +02:00
retr0andClaude Sonnet 5 c924955be9 ROADMAP: Wallbox-Duplikat-Fund dokumentiert, Punkt 6 zurückgestellt, Auto-Cleanup als neuer Punkt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:47:42 +02:00
retr0andClaude Sonnet 5 89250e16bf ROADMAP: auf v1.9.0+-Stand aktualisiert, neue Roadmap für Energiemanagement-Features
Hardware-/Firmware-Roadmap ist mit dem growatt-diagnose-tool-Split raus. Neue
priorisierte Liste: Wallbox-Duplikat klären, automatischer Config-Export,
Discord-Alerts, Fahrzeug-Sessions, Peak Shaving, Multi-Wallbox-Verteilung,
Prognosebasiertes Laden, Chart-Feintuning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:43:28 +02:00
retr0andClaude Sonnet 5 789e25967a ShineBridge v1.10.3 — Fix: HA-Entity-Anbindung auf Long-Lived Token umgestellt (host_network verhindert SUPERVISOR_TOKEN)
homeassistant_api: true funktioniert nicht, solange host_network: true gesetzt
ist — der Container ist dann nicht im internen Supervisor-Netzwerk und bekommt
keinen SUPERVISOR_TOKEN injiziert (bestätigt per Diagnose-Log: keine TOKEN/
SUPERVISOR/HASSIO-Env-Var im Container vorhanden).

Statt host_network anzufassen (Risiko für Modbus/Goodwe-UDP): HAEntityReader
nutzt jetzt ein normales, manuell in HA erstelltes Long-Lived Access Token
gegen http://localhost:8123/api — funktioniert dank host_network direkt ohne
Umweg über den Supervisor-Proxy. homeassistant_api wieder aus config.yaml
entfernt (unnötig).

Neue Felder im Geräte-Dialog: 'Home Assistant URL' (Default localhost:8123)
und 'Long-Lived Access Token'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:26:57 +02:00
8 changed files with 531 additions and 97 deletions
+7 -1
View File
@@ -42,7 +42,7 @@ Wechselrichter / Energiezähler
- **Sparklines** — Live-Graphen der letzten 5 Minuten pro Sensor
- **EMS-Controller** — PV-Überschussladen der Kathrein Wallbox, Zwangsladen-Fallback mit konfigurierbarem Max-Ladestrom
- **Ü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, zusätzlich automatisches tägliches Backup nach `/share/shinebridge-backups/` (übersteht auch Add-on-Uninstall+Reinstall, 14 Tage Aufbewahrung)
- **Port-Sicherheit** — `/api/*` nur über HAOS-Ingress erreichbar, kein Direktzugriff von außen
- **Kein Cloud-Zwang** — vollständig lokal
@@ -80,6 +80,12 @@ Konfigurierbar pro Gerät im Web UI:
>
> Die EMS-Funktion muss zusätzlich in der Kathrein easyOperate-App aktiviert sein.
Die Solarstrahlungs-Prognose wird als eigenes 24h-Balkendiagramm im Energie-Dashboard angezeigt, direkt unter dem EPEX-Spot-Chart (nur sichtbar mit gesetztem Standort).
**Prognosebasierter Zwangslade-Timeout (optional):** Mit Standort (Breitengrad/Längengrad, Einstellungen-Tab) passt sich der Zwangslade-Timeout an die Solarstrahlungs-Prognose (Open-Meteo, kostenlos) an — bei bald zu erwartender Sonne wird länger gewartet (gedeckelt), bei aussichtsloser Prognose eher vom Netz geladen statt sinnlos den vollen Timeout abzuwarten. Ohne Standort unverändertes Verhalten (fester Timeout).
**Batterie-Schutz beim PV-Überschussladen:** Bei Anlagen mit Hausbatterie (z. B. Goodwe) wird die aktuelle Batterie-Entladeleistung vom berechneten PV-Überschuss abgezogen — ein Netzexport, der eigentlich aus dem Speicher kommt (z. B. bei bewölktem Wetter), wird so nicht fälschlich als PV-Überschuss fürs Auto verwendet. Betrifft nur PV-Überschussladen, Zwangsladen bleibt unverändert netzbasiert.
## Netzleistung (grid_power)
Der Sensor `grid_power` zeigt die Netzleistung intuitiv:
+30 -54
View File
@@ -1,73 +1,41 @@
# ShineBridge — Roadmap
> Seit v1.9.0 reines Energiemanagement-Tool. Hardware-/Firmware-Roadmap (ShineLAN-X, ShineWifi-X, shinediag) liegt jetzt im separaten Repo `growatt-diagnose-tool`.
## Bekannte Einschränkungen
- [x] Firma: Wallbox lädt in einem ~6,5-Minuten-Zyklus (Laden→Abgeschlossen→Pausiert→Laden) statt durchzuladen — **Ursache endgültig gefunden 2026-08-26: Schütz in der Wallbox hat nicht angezogen.** Live-Diagnose zeigte Wallbox meldete korrekt "lädt, 16A frei" (`gewährter Strom`/`ems_stromsollwert` 16000mA), aber 0A auf allen drei Phasen — Steuerungs-/Kommunikationsebene lief einwandfrei, nur der Leistungskontakt selbst hat nie geschlossen. Erklärt auch den Loop: Auto negiert Ladevorgang, bekommt nie echten Strom, setzt immer wieder neu an. **Reines Hardware-Problem, ShineBridge zu 100% ausgeschlossen.** Nach Wallbox-Neustart Status offen — falls Schütz weiter nicht anzieht, braucht es einen Elektriker/Ersatzteil.
- [ ] Growatt-only: Netzbezug nicht direkt messbar — nur Einspeisung via `power_to_grid`
- [ ] Kathrein EVSE-Register 0x0060 (Charging-State) nur lesbar wenn Auto angeschlossen
- [ ] NuttX OTA noch nicht auf echter Hardware getestet
---
## Phase 2 — ShineBridge Add-on
## Nächste Schritte (Stand 2026-08-26)
- [x] Persistente History — SQLite `/data/history.db`, 7 Tage Retention (v1.3.0)
- [x] Konfig-Export/Import — JSON im Einstellungen-Tab (v1.3.0)
- [x] Goodwe GW10KN-ET — via goodwe-Bibliothek UDP/8899, 39 Sensoren (v1.4.0)
- [x] Kathrein Wallbox — Modbus TCP, 18 Sensoren + EMS-Controller (v1.5.0)
- [x] EMS-Controller — PV-Überschussladen + Zwangsladen-Fallback (v1.5.0)
- [x] EMS Web UI — Konfiguration direkt im Gerätedialog (v1.5.1)
- [x] grid_power Sensor — positiv = Netzbezug, negativ = Einspeisung (v1.5.2)
- [x] Energie-Dashboard — HA-Style SVG-Flussdiagramm, animateMotion (v1.6.0)
- [x] Abrechnungsperiode + Strompreise — kWh + Kosten je Periode (v1.7.0)
- [x] Flexibler Tarif — EPEX Spot (aWATTar DE/AT) + Aufschlag (v1.8.16)
- [x] Finanzen-Tab — Eigenversorgung, Spot-Vergleich, Abschlags-Tracker (v1.8.14–16)
- [x] Überschuss-Geräte — Zigbee2MQTT-Geräte bei PV-Überschuss steuern (v1.8.8–13)
- [x] Setup-Wizard — geführte Ersteinrichtung (MQTT + erster Wechselrichter) (v1.8.18)
- [x] Flash-Wizard — ShineLAN-X direkt aus Web UI flashen, OTA + ST-Link-Anleitung (v1.8.18)
- [x] Port-Sicherheit — `/api/*` nur über HAOS-Ingress erreichbar (v1.8.18)
- [x] MQTT rc=5 — Reconnect-Loop verhindert, Fehlermeldung im UI (v1.8.18)
- [x] Mobile-Layout — horizontaler Overflow behoben, Tabs scrollen (v1.8.19)
- [ ] Hausverbrauch als berechneter Sensor: `PV + Netzbezug − Einspeisung − Bat_Laden + Bat_Entladen`
- [ ] Weitere Growatt-Modelle — MOD 10000, SPH 10000 etc.
- [ ] Ladekosten-Berechnung: kWh je Session × Arbeitspreis
Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit.
---
1. ~~**Automatischer täglicher Config-Export**~~ — erledigt (v1.11.0–1.11.1): täglicher Export nach `/share/shinebridge-backups/`, Aufbewahrung konfigurierbar (Default 14 Tage), sofort beim Start plus alle 24h. Backup-Liste + Download + Wiederherstellen direkt im Einstellungen-Tab.
2. **Discord-Alerts** — bei Zwangsladen-Start, Fehlerzuständen (unbekannter Ladezustand), besonders günstigen Spot-Preisfenstern. Nutzt den bestehenden Homelab-Discord-Bot. War zurückgestellt bis zur Klärung des 6,5-Minuten-Zyklus — **ist jetzt geklärt** (siehe "Bekannte Einschränkungen", war kein ShineBridge-Bug), Punkt kann wieder angegangen werden. Braucht trotzdem generelles Debounce (Mindestabstand pro Alert-Typ, Zustand muss erst eine Weile stabil sein, günstige Preise als tägliche Zusammenfassung statt Live-Alert) — normales Alert-Design, keine Sonderbehandlung mehr nötig.
3. **Sessions pro Fahrzeug trennen** — falls mehrere Fahrer/Autos an einer Wallbox laden, aktuell nur eine Gesamt-Statistik.
4. **Lastspitzenkappung (Peak Shaving)** — Ladestrom drosseln, wenn Netzbezug (mehrere Autos + Haushalt gleichzeitig) einen Grenzwert überschreitet. Eigenes Sicherheits-Design nötig (was passiert bei Messfehler/Ausfall — nie ungebremst weiterladen).
5. **Prognosebasiertes Laden** — in zwei Teilen, hintereinander gebaut:
- ~~**Teil A: Batteriebewusster PV-Überschuss**~~ — erledigt (v1.11.2): Batterie-Entladeleistung wird vom PV-Überschuss abgezogen, damit PV-Überschussladen nie versehentlich den Hausspeicher anzapft (Zwangsladen bleibt unverändert netzbasiert).
- ~~**Teil B: Wetterprognose**~~ — erledigt (v1.12.0): Standort (Breitengrad/Längengrad) im Einstellungen-Tab, optional. Open-Meteo-Solarstrahlungsprognose (kostenlos, kein Sonnenstand extra nötig — steckt implizit in der stündlichen Prognose) passt den Zwangslade-Timeout adaptiv an: bald Sonne zu erwarten → länger warten (gedeckelt); aussichtslose Prognose → früher vom Netz laden. Ohne Standort unverändertes Verhalten.
6. **Chart-Feintuning** — läuft nebenbei mit, keine feste Priorität (z.B. Live-Kosten-Ticker statt nur Rückblick).
7. **Auto-Cleanup verwaister MQTT-Geräte** — wenn ein Gerät aus ShineBridge entfernt wird, dessen MQTT-Discovery-Topics aktiv abmelden statt sie als tote HA-Geräte-Leichen zurückzulassen (siehe Fund unten).
## Phase 3 — Hardware-Erweiterungen
**Zurückgestellt:** Mehrere Wallboxen fair verteilen — bei der Duplikat-Prüfung (siehe unten) stellte sich heraus, dass es aktuell nur eine echte Wallbox gibt. Erst relevant, wenn wirklich eine zweite dazukommt.
### 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)
### Fund: Wallbox-Duplikat war kein echtes Duplikat
### Goodwe WiFi-Stick (WIFILAN_2.0 / HF-LPT230)
- [x] UDP/8899 Protokoll via goodwe-Bibliothek integriert (v1.4.0)
- [ ] JTAG-Analyse (TP15/TP16/TP17) — eigene Firmware wenn JTAG-eFuse nicht gesetzt
- [ ] RS485-Direktzugriff via 18-Pin-Stecker
Firma-HA zeigte drei Wallbox-Geräte (`wallbox_ladezustand`/`_2`/`_3`). Per `device_id()`/`device_attr()`-Template-Check verifiziert: drei unterschiedliche ShineBridge-`inv_id`s, aber nur **eine** davon (`1c042e72`) hat noch aktive Poll-Loop-Logeinträge — die anderen zwei sind verwaiste Reste aus früheren Config-Änderungen (0 Log-Treffer). Kein Konflikt-Risiko (keine drei gleichzeitigen Poller auf demselben physischen Gerät), nur unaufgeräumte Geräte-Registry. Manuell entfernbar unter Einstellungen → Geräte & Dienste → Geräte.
### ShineWifi-X als ShineBridge-Gateway
- [ ] Schlankes ESP8266-Projekt: RS485 → Modbus TCP Bridge
- [ ] Im ShineBridge Web UI verwaltbar (statt separater ESPHome-YAMLs)
## Erledigt vor dieser Roadmap (Kontext)
---
## Phase 4 — Energiebilanz & Dashboard
- [x] Aggregat-Gerät „ShineBridge Gesamt" (v1.2.0)
- [x] EPEX Spot-Chart — 24h-Balkendiagramm im Energie-Dashboard (v1.8.16)
- [x] Eigenversorgungskarte — kWh + € gespart (v1.8.8)
- [ ] Virtuelle MQTT-Sensoren für HA Energie-Dashboard automatisch anlegen
- [ ] Prognose-gestütztes Laden — Spot-Preise × PV-Forecast für optimale Ladezeiten
---
## ShineDiag — Vor-Ort-Diagnose
- [x] WiFi-Hotspot „ShineDiag" (Pi 3B), http://10.0.1.1 (v2.0)
- [x] Alle Sensoren, Rohdaten-Register-Dump, JSON-Export (v2.0)
- [ ] Goodwe-Unterstützung (UDP/8899)
- [ ] Kathrein Wallbox (EMS-Status, Ladezustand)
- [x] `growatt-diagnose-tool`-Split — Flash/OTA, Setup-Wizard, ShineLAN-X, ShineWifi-X, shinediag ausgelagert (v1.9.0)
- [x] Finanzen-Chart komplett überarbeitet — Wochen-Aggregation, responsiv, Beispiel-Vorschau (v1.9.1–1.9.6)
- [x] ESPHome-Geräte-Anbindung (Long-Lived Token statt Supervisor-Proxy, `host_network` ist damit inkompatibel) (v1.10.0–1.10.3)
- [x] Firma-EMS-Fixes: konfigurierbarer Max-Ladestrom, Zwangsladen-Retry, Fehlerzustand-Handling (v1.8.31)
---
@@ -101,3 +69,11 @@
| 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) |
| v1.8.29 | 2026-05-26 | Fix: Port 8099 bindet auf 127.0.0.1 statt 0.0.0.0 |
| v1.8.30 | 2026-05-28 | Fix: Ingress-Fix — Flask wieder auf 0.0.0.0 |
| v1.8.31 | 2026-08-25 | Fix: Firma-EMS — konfigurierbarer Max-Ladestrom statt hardcoded 32A, Zwangsladen-Retry, Fehlerzustand pausiert statt zurückzusetzen |
| v1.9.0 | 2026-08-25 | Repositionierung: Flash/OTA, Setup-Wizard, ShineLAN-X, ShineWifi-X, shinediag ausgelagert nach `growatt-diagnose-tool` |
| v1.9.1–1.9.6 | 2026-08-25 | Finanzen-Chart: unlesbar bei vielen Tagen behoben, Wochen-Aggregation, abgerundete Balken, responsiv über gemessene Breite, Beispiel-Diagramm im Leerzustand |
| v1.10.0 | 2026-08-25 | Feature: Geräte-Typ "Growatt ShineWifi-X (ESPHome)" — liest Werte aus bestehenden HA-Entities |
| v1.10.1–1.10.2 | 2026-08-25 | Fix: Config-Import mit ha_entity-Geräten; Diagnose-Log für SUPERVISOR_TOKEN |
| v1.10.3 | 2026-08-25 | Fix: HA-Entity-Anbindung auf Long-Lived Access Token umgestellt (host_network + Supervisor-Token inkompatibel) |
+3 -2
View File
@@ -1,5 +1,5 @@
name: ShineBridge
version: "1.10.2"
version: "1.12.2"
slug: shinebridge
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
url: https://gitea.bitfire.work/retr0/shinebridge
@@ -13,7 +13,8 @@ host_network: true
ingress: true
ingress_port: 8099
panel_icon: mdi:solar-power
homeassistant_api: true
map:
- share:rw
options:
mqtt_broker: "core-mosquitto"
mqtt_port: 1883
+9 -4
View File
@@ -76,14 +76,18 @@ class EmsController:
return max(6000, min(self.max_charge_ma, ma))
def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int,
wallbox_power_w: float = 0.0) -> str:
wallbox_power_w: float = 0.0, timeout_override_h: Optional[float] = None) -> str:
"""
Wird vom Poll-Loop aufgerufen.
pv_surplus_w: positiv = PV exportiert ins Netz (verbleibender Überschuss)
wallbox_power_w: aktuell von der Wallbox gezogene Leistung (aus Meter-Daten)
timeout_override_h: falls gesetzt, ersetzt self.pv_timeout_h für diesen Aufruf
(Prognose-basierte Anpassung, siehe weather_forecast.adjusted_timeout_h —
wird vom Poll-Loop berechnet, EmsController kennt selbst keine Standortdaten)
Gibt eine kurze Status-Beschreibung zurück.
"""
now = time.time()
effective_timeout_h = timeout_override_h if timeout_override_h is not None else self.pv_timeout_h
# EMS einmalig aktivieren wenn Auto angesteckt
if charging_state in (STATE_CONNECTED, STATE_CHARGING, STATE_PAUSED):
@@ -132,7 +136,7 @@ class EmsController:
no_pv_h = (now - self._no_pv_since) / 3600.0
if no_pv_h >= self.pv_timeout_h:
if no_pv_h >= effective_timeout_h:
# Zwangsladen bis Zielzeit — Strom bei JEDEM Update erneut setzen (nicht nur beim
# ersten Eintritt), sonst bleibt ein einzelner fehlgeschlagener/verworfener Write
# (Modbus-Hänger, Fahrzeug-Störung) unbemerkt für den Rest der Nacht stehen.
@@ -140,8 +144,9 @@ class EmsController:
mins_left = (target - datetime.now()).total_seconds() / 60
if not self._forced_charging:
self._forced_charging = True
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA",
int(mins_left), self.target_hour, self.max_charge_ma / 1000)
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA (Timeout %.1fh%s)",
int(mins_left), self.target_hour, self.max_charge_ma / 1000, effective_timeout_h,
" prognose-angepasst" if timeout_override_h is not None else "")
wallbox_reader.set_current(self.max_charge_ma, self.phases)
return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)"
+11 -13
View File
@@ -1,6 +1,5 @@
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Dict, Optional
@@ -29,25 +28,24 @@ class HAEntityReader:
(z.B. ein per ESPHome direkt in HA eingebundener ShineWifi-X-Stick) statt sie
selbst per Modbus/UDP zu pollen.
Braucht 'homeassistant_api: true' in config.yaml — HAOS injiziert dann
SUPERVISOR_TOKEN und die interne API ist unter http://supervisor/core/api
erreichbar (kein manuell erstelltes Long-Lived Token nötig)."""
Nutzt bewusst ein normales Long-Lived Access Token gegen die HA-REST-API statt
den Supervisor-Proxy (http://supervisor/core/api): ShineBridge läuft mit
host_network: true, dadurch ist der Container NICHT im internen Supervisor-
Netzwerk und bekommt keinen SUPERVISOR_TOKEN injiziert. Der direkte API-Weg
funktioniert trotzdem, weil HA Core dank host_network ganz normal unter
localhost erreichbar ist."""
BASE_URL = "http://supervisor/core/api"
def __init__(self, entity_prefix: str):
def __init__(self, entity_prefix: str, base_url: str, token: str):
self.entity_prefix = entity_prefix
self.token = os.environ.get("SUPERVISOR_TOKEN", "")
if not self.token:
related = [k for k in os.environ if "TOKEN" in k.upper() or "SUPERVISOR" in k.upper() or "HASSIO" in k.upper()]
log.warning("[HA-Entity] SUPERVISOR_TOKEN nicht gesetzt. Verwandte Env-Vars gefunden: %s", related or "keine")
self.base_url = (base_url or "http://localhost:8123").rstrip("/")
self.token = token
def read(self, inverter=None) -> Optional[Dict[str, float]]:
if not self.entity_prefix:
log.error("[HA-Entity] Kein Entity-Prefix konfiguriert")
return None
if not self.token:
log.error("[HA-Entity %s] Kein SUPERVISOR_TOKEN — 'homeassistant_api: true' in config.yaml gesetzt und Add-on neu installiert?", self.entity_prefix)
log.error("[HA-Entity %s] Kein Long-Lived Access Token konfiguriert", self.entity_prefix)
return None
result: Dict[str, float] = {}
@@ -61,7 +59,7 @@ class HAEntityReader:
def _get_state(self, entity_id: str) -> Optional[float]:
req = urllib.request.Request(
f"{self.BASE_URL}/states/{entity_id}",
f"{self.base_url}/api/states/{entity_id}",
headers={"Authorization": f"Bearer {self.token}"},
)
try:
+165 -17
View File
@@ -15,6 +15,7 @@ from modbus_client import ModbusReader
from goodwe_client import GoodweReader
from wallbox_client import WallboxReader
from ha_entity_client import HAEntityReader
import weather_forecast
from ems_controller import EmsController
from mqtt_publisher import MqttPublisher
from surplus_devices import SurplusDeviceController
@@ -124,6 +125,9 @@ def _defaults() -> Dict[str, Any]:
"inverters": [],
"surplus_devices": [],
"z2m_base": "zigbee2mqtt",
"config_backup_retention_days": 14,
"site_lat": None,
"site_lon": None,
}
def _load_json_safe(path: str) -> Optional[Dict]:
@@ -174,6 +178,9 @@ def save_config():
"inverters": State.inverters_cfg,
"surplus_devices": State.surplus_devices_cfg,
"z2m_base": State.z2m_base,
"config_backup_retention_days": State.mqtt_cfg.get("config_backup_retention_days", 14),
"site_lat": State.mqtt_cfg.get("site_lat"),
"site_lon": State.mqtt_cfg.get("site_lon"),
}
# Backup der letzten guten Config anlegen
if os.path.exists(CONFIG_PATH):
@@ -221,9 +228,16 @@ def _compute_aggregates(allow_stale: bool = False) -> Dict[str, float]:
# ── EMS Hilfsfunktionen ───────────────────────────────────────
def _get_pv_surplus() -> float:
"""PV-Überschuss in Watt. Nutzt grid_power-Aggregat (negativ = Einspeisung)."""
"""PV-Überschuss in Watt für PV-Überschussladen (nicht für Zwangsladen — das
bleibt unverändert netzbasiert). Netzexport (grid_power negativ = Einspeisung)
abzüglich aktueller Batterie-Entladeleistung, damit ein Überschuss, der
tatsächlich aus dem Hausspeicher kommt (z.B. bei bewölktem Wetter), nicht
fälschlich als PV-Überschuss fürs Auto verwendet wird. Anlagen ohne Batterie
(bat_discharge_power fehlt) verhalten sich unverändert wie zuvor."""
agg = _compute_aggregates(allow_stale=True)
return max(0.0, -agg.get("grid_power", 0.0))
grid_export = max(0.0, -agg.get("grid_power", 0.0))
bat_discharge = agg.get("bat_discharge_power", 0.0)
return max(0.0, grid_export - bat_discharge)
# ── Poll Loop ─────────────────────────────────────────────────
@@ -280,7 +294,11 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds",
inv_id, inverter.name, host, interval)
elif inverter.protocol == "ha_entity":
reader = HAEntityReader(entity_prefix=inv_cfg.get("ha_entity_prefix", ""))
reader = HAEntityReader(
entity_prefix=inv_cfg.get("ha_entity_prefix", ""),
base_url=inv_cfg.get("ha_base_url", "http://localhost:8123"),
token=inv_cfg.get("ha_token", ""),
)
log.info("[%s] Poll-Loop: %s (HA-Entity Prefix: %s) alle %ds",
inv_id, inverter.name, inv_cfg.get("ha_entity_prefix", ""), interval)
elif inverter.protocol == "kathrein":
@@ -353,7 +371,10 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
# charging_state aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
charging_state = int(values.get("charging_state", 0))
wallbox_power = values.get("total_power", 0.0)
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power)
site_lat = State.mqtt_cfg.get("site_lat")
site_lon = State.mqtt_cfg.get("site_lon")
timeout_override = weather_forecast.adjusted_timeout_h(ems.pv_timeout_h, site_lat, site_lon)
ems_status = ems.update(reader, pv_surplus, charging_state, wallbox_power, timeout_override)
values["ems_status_code"] = float(charging_state)
log.info("[%s] EMS: %s | PV-Überschuss: %.0fW", inv_id, ems_status, pv_surplus)
@@ -490,6 +511,19 @@ def _restart_all():
# ── REST API ──────────────────────────────────────────────────
@app.get("/api/weather-forecast")
def api_weather_forecast():
with State.lock:
lat = State.mqtt_cfg.get("site_lat")
lon = State.mqtt_cfg.get("site_lon")
# Beispiel-Basiswert: erstes konfigurierte Kathrein-Timeout, sonst 2h-Default
base = next(
(inv.get("ems_timeout") for inv in State.inverters_cfg if inv.get("ems_timeout")),
2.0,
)
return jsonify(weather_forecast.summary(lat, lon, float(base)))
@app.get("/api/config")
def api_get_config():
with State.lock:
@@ -514,7 +548,16 @@ def api_save_config():
State.mqtt_cfg[k] = float(data[k])
except (ValueError, TypeError):
pass
for k in ("billing_day", "billing_month"):
for k in ("site_lat", "site_lon"):
if k in data:
if data[k] in (None, ""):
State.mqtt_cfg[k] = None
else:
try:
State.mqtt_cfg[k] = float(data[k])
except (ValueError, TypeError):
pass
for k in ("billing_day", "billing_month", "config_backup_retention_days"):
if k in data and data[k] is not None:
try:
State.mqtt_cfg[k] = int(data[k])
@@ -804,10 +847,9 @@ def api_new_id():
return jsonify({"id": uuid.uuid4().hex[:8]})
@app.get("/api/export-config")
def api_export_config():
def _build_export_data() -> Dict[str, Any]:
with State.lock:
data = {
return {
"shinebridge_export": True,
"version": 1,
"exported_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
@@ -818,7 +860,48 @@ def api_export_config():
},
"inverters": State.inverters_cfg,
}
CONFIG_BACKUP_DIR = "/share/shinebridge-backups"
CONFIG_BACKUP_RETENTION_DAYS_DEFAULT = 14
def _export_config_backup():
"""Schreibt einen taeglichen Config-Export nach /share — bewusst NICHT nach
/data, weil genau das der Ordner ist, der bei Add-on-Uninstall+Reinstall
geloescht wird (siehe Vorfall 2026-08-25). /share uebersteht das."""
try:
os.makedirs(CONFIG_BACKUP_DIR, exist_ok=True)
filename = f"config-{time.strftime('%Y-%m-%d')}.json"
path = os.path.join(CONFIG_BACKUP_DIR, filename)
with open(path, "w", encoding="utf-8") as f:
json.dump(_build_export_data(), f, indent=2, ensure_ascii=False)
log.info("Config-Backup geschrieben: %s", path)
retention_days = State.mqtt_cfg.get("config_backup_retention_days", CONFIG_BACKUP_RETENTION_DAYS_DEFAULT)
cutoff = time.time() - retention_days * 86400
for name in os.listdir(CONFIG_BACKUP_DIR):
fpath = os.path.join(CONFIG_BACKUP_DIR, name)
if name.startswith("config-") and name.endswith(".json") and os.path.getmtime(fpath) < cutoff:
os.remove(fpath)
log.info("Altes Config-Backup gelöscht: %s", fpath)
except OSError as e:
log.error("Config-Backup fehlgeschlagen: %s", e)
def _start_config_backup_scheduler():
def _loop():
_export_config_backup() # sofort beim Start, nicht erst nach 24h
while True:
time.sleep(86400)
_export_config_backup()
threading.Thread(target=_loop, daemon=True, name="config-backup").start()
@app.get("/api/export-config")
def api_export_config():
from flask import Response
data = _build_export_data()
filename = f"shinebridge-config-{time.strftime('%Y%m%d-%H%M%S')}.json"
return Response(
json.dumps(data, indent=2, ensure_ascii=False),
@@ -827,27 +910,28 @@ def api_export_config():
)
@app.post("/api/import-config")
def api_import_config():
data = request.get_json(force=True) or {}
def _apply_import_data(data: Dict[str, Any]) -> "tuple[bool, str, int]":
"""Validiert + übernimmt eine ShineBridge-Export-Struktur. Gemeinsam genutzt
von /api/import-config (Datei-Upload) und /api/config-backups/.../restore
(automatisches Backup). Gibt (ok, error, anzahl_geräte) zurück."""
if not data.get("shinebridge_export"):
return jsonify({"error": "Keine gültige ShineBridge-Export-Datei"}), 400
return False, "Keine gültige ShineBridge-Export-Datei", 0
inverters = data.get("inverters", [])
if not isinstance(inverters, list):
return jsonify({"error": "inverters ungültig"}), 400
return False, "inverters ungültig", 0
for inv in inverters:
model_id = inv.get("inverter_model")
if model_id not in INVERTERS:
return jsonify({"error": f"Unbekanntes Modell: {model_id}"}), 400
return False, f"Unbekanntes Modell: {model_id}", 0
protocol = INVERTERS[model_id].protocol
# modbus_ip wird als generisches Host-Feld fuer alle IP-basierten Protokolle
# wiederverwendet (modbus, goodwe_udp, kathrein) — nur ha_entity braucht keins.
if protocol == "ha_entity":
if not inv.get("ha_entity_prefix"):
return jsonify({"error": f"ha_entity_prefix fehlt in Gerät {inv.get('name', '?')}"}), 400
return False, f"ha_entity_prefix fehlt in Gerät {inv.get('name', '?')}", 0
elif not inv.get("modbus_ip"):
return jsonify({"error": f"modbus_ip fehlt in Gerät {inv.get('name', '?')}"}), 400
return False, f"modbus_ip fehlt in Gerät {inv.get('name', '?')}", 0
with State.lock:
if mqtt := data.get("mqtt"):
@@ -861,7 +945,69 @@ def api_import_config():
save_config()
threading.Thread(target=_restart_all, daemon=True).start()
return jsonify({"ok": True, "inverters": len(inverters)})
return True, "", len(inverters)
@app.post("/api/import-config")
def api_import_config():
data = request.get_json(force=True) or {}
ok, error, count = _apply_import_data(data)
if not ok:
return jsonify({"error": error}), 400
return jsonify({"ok": True, "inverters": count})
def _safe_backup_filename(filename: str) -> Optional[str]:
"""Nur Dateinamen aus CONFIG_BACKUP_DIR selbst zulassen, kein Path-Traversal."""
name = os.path.basename(filename)
if name != filename or not name.startswith("config-") or not name.endswith(".json"):
return None
path = os.path.join(CONFIG_BACKUP_DIR, name)
return path if os.path.isfile(path) else None
@app.get("/api/config-backups")
def api_list_config_backups():
try:
files = [f for f in os.listdir(CONFIG_BACKUP_DIR) if f.startswith("config-") and f.endswith(".json")]
except OSError:
files = []
backups = []
for name in files:
path = os.path.join(CONFIG_BACKUP_DIR, name)
try:
st = os.stat(path)
backups.append({"filename": name, "modified_at": int(st.st_mtime), "size_bytes": st.st_size})
except OSError:
continue
backups.sort(key=lambda b: b["modified_at"], reverse=True)
return jsonify({
"backups": backups,
"retention_days": State.mqtt_cfg.get("config_backup_retention_days", CONFIG_BACKUP_RETENTION_DAYS_DEFAULT),
})
@app.get("/api/config-backups/<path:filename>/download")
def api_download_config_backup(filename):
path = _safe_backup_filename(filename)
if not path:
return jsonify({"error": "Datei nicht gefunden"}), 404
from flask import send_file
return send_file(path, mimetype="application/json", as_attachment=True, download_name=os.path.basename(path))
@app.post("/api/config-backups/<path:filename>/restore")
def api_restore_config_backup(filename):
path = _safe_backup_filename(filename)
if not path:
return jsonify({"error": "Datei nicht gefunden"}), 404
data = _load_json_safe(path)
if data is None:
return jsonify({"error": "Backup-Datei ist beschädigt"}), 400
ok, error, count = _apply_import_data(data)
if not ok:
return jsonify({"error": error}), 400
return jsonify({"ok": True, "inverters": count})
_hist_spot_cache: Dict[str, Any] = {} # key → {"ts", "avg_ct"}
@@ -933,6 +1079,7 @@ if __name__ == "__main__":
"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",
"config_backup_retention_days", "site_lat", "site_lon",
) if k in cfg}
State.inverters_cfg = cfg.get("inverters", [])
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
@@ -951,6 +1098,7 @@ if __name__ == "__main__":
save_config()
_restart_all()
_start_config_backup_scheduler()
port = int(os.environ.get("INGRESS_PORT", "8099"))
log.info("Web UI startet auf Port %d", port)
app.run(host="0.0.0.0", port=port, threaded=True)
+126
View File
@@ -0,0 +1,126 @@
"""Solarstrahlungs-Prognose über Open-Meteo (kostenlos, kein API-Key nötig).
Wird genutzt, um den Zwangslade-Timeout im EMS-Controller adaptiv zu machen:
bei noch zu erwartender Sonne wird länger gewartet, bei aussichtsloser Prognose
wird eher vom Netz geladen statt sinnlos den vollen Timeout abzuwarten.
"""
import logging
import time
import urllib.error
import urllib.request
import json
from typing import List, Optional, Tuple
log = logging.getLogger(__name__)
_CACHE_TTL_S = 3 * 3600 # Prognose alle 3h neu holen, nicht bei jedem Poll
_cache: dict = {} # key: (lat, lon) -> {"ts": float, "hourly": [(epoch_s, ghi_w_m2), ...]}
def _fetch(lat: float, lon: float) -> Optional[List[Tuple[int, float]]]:
url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={lat}&longitude={lon}"
"&hourly=shortwave_radiation&forecast_days=2&timezone=auto"
)
try:
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
times = data["hourly"]["time"]
values = data["hourly"]["shortwave_radiation"]
# ISO-Zeitstrings (lokale Zeit dank timezone=auto) -> Unix-Timestamp
import datetime
out = []
for t, v in zip(times, values):
dt = datetime.datetime.fromisoformat(t)
out.append((int(dt.timestamp()), float(v or 0.0)))
return out
except (urllib.error.URLError, urllib.error.HTTPError, KeyError, ValueError) as e:
log.warning("[Weather] Open-Meteo Abfrage fehlgeschlagen: %s", e)
return None
def get_hourly_ghi(lat: float, lon: float) -> Optional[List[Tuple[int, float]]]:
"""Liefert [(unix_ts, ghi_w_m2), ...] für die nächsten ~48h, gecacht."""
key = (round(lat, 3), round(lon, 3))
cached = _cache.get(key)
now = time.time()
if cached and now - cached["ts"] < _CACHE_TTL_S:
return cached["hourly"]
hourly = _fetch(lat, lon)
if hourly is None:
return cached["hourly"] if cached else None # alten Stand weiterverwenden statt nichts
_cache[key] = {"ts": now, "hourly": hourly}
return hourly
def adjusted_timeout_h(base_timeout_h: float, lat: Optional[float], lon: Optional[float]) -> float:
"""Passt den Zwangslade-Timeout an die Prognose an. Ohne Standort (lat/lon
nicht gesetzt) unverändert den Basiswert zurückgeben — Feature ist Opt-in."""
if lat is None or lon is None:
return base_timeout_h
hourly = get_hourly_ghi(lat, lon)
if not hourly:
return base_timeout_h
now = time.time()
# Strahlung in den naechsten 3h (kurzfristig: lohnt es sich, gleich noch zu warten?)
near = [ghi for ts, ghi in hourly if now <= ts < now + 3 * 3600]
# Strahlung im Rest des "Tagesfensters" (grob 12h voraus, deckt auch noch
# kommende Vormittagssonne ab, falls schon frueh am Morgen geprueft wird)
horizon = [ghi for ts, ghi in hourly if now <= ts < now + 12 * 3600]
SUN_THRESHOLD = 80.0 # W/m² — deutlich mehr als Daemmerung, aber kein voller Sonnentag noetig
if near and max(near) >= SUN_THRESHOLD:
# Bald noch nennenswerte Sonne zu erwarten -> mehr Geduld, gedeckelt
return min(base_timeout_h * 1.75, base_timeout_h + 4.0)
if horizon and max(horizon) < SUN_THRESHOLD:
# Auch im Rest des Fensters keine nennenswerte Sonne zu erwarten ->
# nicht sinnlos den vollen Timeout abwarten, aber nie unter 0.5h
return max(base_timeout_h * 0.5, 0.5)
return base_timeout_h
def summary(lat: Optional[float], lon: Optional[float], base_timeout_h: float = 2.0) -> dict:
"""Für die UI: aktueller Prognose-Stand + Beispiel, wie sich ein Timeout
dadurch veraendern wuerde. Nicht fuers eigentliche EMS-Update genutzt
(das ruft adjusted_timeout_h direkt auf) — nur zur Anzeige/Diagnose."""
if lat is None or lon is None:
return {"configured": False}
hourly = get_hourly_ghi(lat, lon)
if not hourly:
return {"configured": True, "error": "Prognose aktuell nicht abrufbar"}
now = time.time()
near = [ghi for ts, ghi in hourly if now <= ts < now + 3 * 3600]
horizon = [ghi for ts, ghi in hourly if now <= ts < now + 12 * 3600]
next_3h_max = round(max(near), 0) if near else 0
horizon_12h_max = round(max(horizon), 0) if horizon else 0
adjusted = adjusted_timeout_h(base_timeout_h, lat, lon)
if adjusted > base_timeout_h:
verdict = "verlängert"
elif adjusted < base_timeout_h:
verdict = "verkürzt"
else:
verdict = "unverändert"
# Naechste 24h fuers Diagramm im Dashboard (unter dem Strompreis-Chart)
horizon_24h = [{"ts": ts, "ghi": round(ghi, 0)} for ts, ghi in hourly if now <= ts < now + 24 * 3600]
return {
"configured": True,
"next_3h_max_ghi": next_3h_max,
"horizon_12h_max_ghi": horizon_12h_max,
"sun_threshold": SUN_THRESHOLD,
"example_base_h": base_timeout_h,
"example_adjusted_h": round(adjusted, 2),
"verdict": verdict,
"hourly": horizon_24h,
}
+180 -6
View File
@@ -233,6 +233,19 @@
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
</div>
<div class="settings-section">
<h3>Standort</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Für die prognosebasierte Anpassung des Zwangslade-Timeouts (Solarstrahlungs-Vorhersage via Open-Meteo). Optional — leer lassen deaktiviert die Anpassung, Zwangsladen nutzt dann weiter den festen Timeout.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<div class="field" style="max-width:180px"><label>Breitengrad</label>
<input type="number" id="cfg-site-lat" step="0.0001" placeholder="z.B. 52.5200"></div>
<div class="field" style="max-width:180px"><label>Längengrad</label>
<input type="number" id="cfg-site-lon" step="0.0001" placeholder="z.B. 13.4050"></div>
</div>
<button class="btn btn-primary" onclick="saveLocation()">Speichern</button>
<div id="weather-preview" style="margin-top:.75rem;font-size:.85rem"></div>
</div>
<div class="settings-section">
<h3>Stromtarif</h3>
<div class="field">
@@ -321,6 +334,15 @@
</div>
<div id="import-result" style="margin-top:.5rem;font-size:.85rem"></div>
</div>
<div class="settings-section">
<h3>Automatische Backups</h3>
<p style="color:var(--text-dim);font-size:.85rem;margin:0 0 .75rem">Täglich automatisch nach <code style="font-size:11px;background:var(--surface2);padding:1px 5px;border-radius:3px">/share/shinebridge-backups/</code> gesichert — übersteht auch einen Add-on-Uninstall+Reinstall.</p>
<div class="field" style="max-width:200px"><label>Aufbewahrung (Tage)</label>
<input type="number" id="cfg-backup-retention" min="1" max="365" value="14"></div>
<button class="btn btn-primary" onclick="saveBackupRetention()">Speichern</button>
<div id="backup-list" style="margin-top:1rem;display:flex;flex-direction:column;gap:6px"></div>
</div>
</div>
<!-- Flash/OTA-Panel ausgelagert nach growatt-diagnose-tool (v1.9.0) -->
@@ -346,7 +368,13 @@
<div id="ha-entity-fields" style="display:none">
<div class="field"><label>HA Entity-Prefix</label>
<input type="text" id="modal-ha-prefix" placeholder="growatt_wifi_x_1">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Ohne "sensor."-Präfix — z.B. bei <code>sensor.growatt_wifi_x_1_dcpower</code> ist der Wert <code>growatt_wifi_x_1</code>. Braucht ein bereits per ESPHome in HA eingebundenes Gerät, keinen direkten Netzwerkzugriff.</div></div>
<div style="font-size:11px;color:var(--muted);margin-top:3px">Ohne "sensor."-Präfix — z.B. bei <code>sensor.growatt_wifi_x_1_dcpower</code> ist der Wert <code>growatt_wifi_x_1</code>. Braucht ein bereits per ESPHome in HA eingebundenes Gerät.</div></div>
<div class="field"><label>Home Assistant URL</label>
<input type="text" id="modal-ha-url" placeholder="http://localhost:8123">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Meist unverändert lassen — dank host_network erreicht ShineBridge HA Core direkt über localhost.</div></div>
<div class="field"><label>Long-Lived Access Token</label>
<input type="password" id="modal-ha-token" placeholder="Einstellungen → Profil → Sicherheit → Langlebige Zugriffs-Token">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Kein Supervisor-Token nutzbar (host_network) — braucht ein manuell in HA erstelltes Token.</div></div>
</div>
<div class="field"><label>MQTT Topic-Präfix</label>
<input type="text" id="modal-prefix" placeholder="growatt/wechselrichter1"></div>
@@ -471,6 +499,7 @@ function switchTab(name) {
});
if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
if (name === "finance") loadFinance();
if (name === "settings") loadBackupList();
}
// ── Energy Dashboard ──────────────────────────────────────────
@@ -525,6 +554,51 @@ function renderSpotChart(spotData) {
</div>`;
}
function renderWeatherChart(weather) {
if (!weather || !weather.configured || weather.error) return '';
const hourly = weather.hourly || [];
if (hourly.length < 2) return '';
const now = Math.floor(Date.now() / 1000);
const maxGhi = Math.max(...hourly.map(h => h.ghi), 100);
const sunCol = g => g >= weather.sun_threshold ? '#f0c040' : g > 0 ? '#5b6472' : '#30363d';
const W=520, H=108, PL=34, PR=8, PT=14, PB=22;
const cW=W-PL-PR, cH=H-PT-PB, bW=cW/hourly.length;
const bars = hourly.map((h,i) => {
const isNow = now >= h.ts && now < h.ts+3600;
const barH = Math.max(2, (h.ghi/maxGhi)*cH);
const x=(PL+i*bW), y=PT+cH-barH;
const col = sunCol(h.ghi);
const hh = new Date(h.ts*1000).getHours();
const tl = hh%6===0 ? `<text x="${(x+bW/2).toFixed(1)}" y="${H-3}" text-anchor="middle" font-size="8" fill="#8b949e">${String(hh).padStart(2,'0')}h</text>` : '';
return `<rect x="${(x+1).toFixed(1)}" y="${y.toFixed(1)}" width="${(bW-2).toFixed(1)}" height="${barH.toFixed(1)}" fill="${col}" opacity="${isNow?1:.7}" rx="1.5"/>
${isNow?`<rect x="${x.toFixed(1)}" y="${PT}" width="${bW.toFixed(1)}" height="${cH}" fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5" rx="1.5"/>`:''}
${tl}`;
}).join('');
const yTicks = [[0,PT+cH],[maxGhi,PT]].map(([g,y]) =>
`<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="#30363d" stroke-width="0.5"/>
<text x="${PL-3}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="8" fill="#8b949e">${g.toFixed(0)}</text>`
).join('');
const cur = hourly.find(h => now>=h.ts && now<h.ts+3600);
const curLabel = cur
? `<span style="font-size:16px;font-weight:700;color:${sunCol(cur.ghi)}">${cur.ghi.toFixed(0)} W/m²</span><span style="font-size:10px;color:#8b949e;margin-left:6px">Jetzt</span>`
: '';
return `<div style="border-top:1px solid #30363d;padding:14px 14px 4px">
<div style="display:flex;align-items:baseline;gap:6px;margin-bottom:8px">
<span style="font-size:10px;font-weight:700;letter-spacing:.08em;color:#8b949e;text-transform:uppercase">Solarprognose · 24h</span>
${curLabel}
</div>
<svg viewBox="0 0 ${W} ${H}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
${yTicks}${bars}
</svg>
</div>`;
}
function fEur(v) {
if (v == null) return null;
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
@@ -534,7 +608,7 @@ function fKwh(v) {
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh';
}
function renderEnergy(inverters, aggregates, period, spotData) {
function renderEnergy(inverters, aggregates, period, spotData, weatherData) {
const el = document.getElementById("energy-content");
if (!aggregates || !Object.keys(aggregates).length) {
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
@@ -700,6 +774,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
sectionCards(yr.label || 'Dieses Jahr', yr);
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : '';
const weatherHtml = renderWeatherChart(weatherData);
const bt = period.billing_tracker;
const trackerHtml = bt ? (() => {
@@ -735,7 +810,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
})() : '';
el.innerHTML = `<div class="energy-wrap">
<div class="energy-svg-wrap">${svg}${spotHtml}</div>
<div class="energy-svg-wrap">${svg}${spotHtml}${weatherHtml}</div>
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
${trackerHtml}
</div>`;
@@ -943,9 +1018,10 @@ async function refreshData() {
document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {}, d.aggregates || {}, surplusData);
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []));
const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
const weather = await fetchJSON(api("api/weather-forecast")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []), weather);
} catch(e) {
document.getElementById("pill-mqtt").className = "pill err";
}
@@ -1081,6 +1157,8 @@ async function openModal(invId) {
document.getElementById("modal-port").value = inv.modbus_port || 502;
document.getElementById("modal-addr").value = inv.modbus_address || 1;
document.getElementById("modal-ha-prefix").value = inv.ha_entity_prefix || "";
document.getElementById("modal-ha-url").value = inv.ha_base_url || "http://localhost:8123";
document.getElementById("modal-ha-token").value = inv.ha_token || "";
document.getElementById("modal-prefix").value = inv.mqtt_topic_prefix || "";
document.getElementById("modal-interval").value = inv.update_interval || 30;
document.getElementById("ems-min-pv").value = inv.ems_min_pv ?? 1400;
@@ -1097,6 +1175,8 @@ async function openModal(invId) {
document.getElementById("modal-port").value = "502";
document.getElementById("modal-addr").value = "1";
document.getElementById("modal-ha-prefix").value = "";
document.getElementById("modal-ha-url").value = "http://localhost:8123";
document.getElementById("modal-ha-token").value = "";
document.getElementById("modal-prefix").value = "growatt/wechselrichter" + (invertersList.length + 1);
document.getElementById("modal-interval").value = "30";
document.getElementById("ems-min-pv").value = "1400";
@@ -1135,6 +1215,8 @@ async function saveInverter() {
};
if (model === "GROWATT_WIFI_X") {
inv.ha_entity_prefix = document.getElementById("modal-ha-prefix").value.trim();
inv.ha_base_url = document.getElementById("modal-ha-url").value.trim() || "http://localhost:8123";
inv.ha_token = document.getElementById("modal-ha-token").value.trim();
}
if (model === "KATHREIN_WALLBOX") {
inv.ems_min_pv = parseInt(document.getElementById("ems-min-pv").value);
@@ -1215,6 +1297,43 @@ async function loadSettings() {
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;
document.getElementById("cfg-backup-retention").value = cfg.config_backup_retention_days ?? 14;
document.getElementById("cfg-site-lat").value = cfg.site_lat ?? "";
document.getElementById("cfg-site-lon").value = cfg.site_lon ?? "";
loadWeatherPreview();
}
async function saveLocation() {
const lat = document.getElementById("cfg-site-lat").value;
const lon = document.getElementById("cfg-site-lon").value;
try {
await fetchJSON(api("api/config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({ site_lat: lat === "" ? null : lat, site_lon: lon === "" ? null : lon }),
});
showToast("Gespeichert!", "ok");
setTimeout(loadWeatherPreview, 500);
} catch(e) { showToast("Fehler beim Speichern", "err"); }
}
async function loadWeatherPreview() {
const el = document.getElementById("weather-preview");
let data;
try { data = await fetchJSON(api("api/weather-forecast")); }
catch(e) { el.innerHTML = ""; return; }
if (!data.configured) {
el.innerHTML = `<span style="color:var(--text-dim)">Kein Standort gesetzt — Zwangslade-Timeout bleibt fest.</span>`;
return;
}
if (data.error) {
el.innerHTML = `<span style="color:var(--red)">${esc(data.error)}</span>`;
return;
}
const verdictColor = data.verdict === "verlängert" ? "var(--green)" : data.verdict === "verkürzt" ? "var(--orange, #ffa657)" : "var(--text-dim)";
el.innerHTML = `
<div style="color:var(--text-dim)">Solarstrahlung nächste 3h: <strong style="color:var(--text)">${data.next_3h_max_ghi} W/m²</strong> · nächste 12h: <strong style="color:var(--text)">${data.horizon_12h_max_ghi} W/m²</strong></div>
<div style="margin-top:4px;color:${verdictColor}">Beispiel bei ${data.example_base_h}h Basis-Timeout: <strong>${data.example_adjusted_h}h</strong> (${data.verdict})</div>`;
}
async function savePrices() {
@@ -1292,6 +1411,61 @@ async function importConfig(input) {
input.value = "";
}
// ── Automatische Backups ─────────────────────────────────────
async function saveBackupRetention() {
const days = parseInt(document.getElementById("cfg-backup-retention").value) || 14;
try {
await fetchJSON(api("api/config"), {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({ config_backup_retention_days: days }),
});
showToast("Gespeichert!", "ok");
} catch(e) { showToast("Fehler beim Speichern", "err"); }
}
function fBytes(n) {
if (n < 1024) return `${n} B`;
if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
return `${(n/1024/1024).toFixed(1)} MB`;
}
async function loadBackupList() {
const el = document.getElementById("backup-list");
let data;
try { data = await fetchJSON(api("api/config-backups")); }
catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; return; }
const backups = data.backups || [];
if (backups.length === 0) {
el.innerHTML = `<div style="font-size:12px;color:var(--text-dim)">Noch keine Backups — das erste entsteht beim nächsten Add-on-Start oder spätestens morgen.</div>`;
return;
}
el.innerHTML = backups.map(b => {
const date = new Date(b.modified_at * 1000).toLocaleString('de-DE');
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:13px">
<div><strong>${esc(b.filename)}</strong><br><span style="color:var(--text-dim);font-size:11px">${date} · ${fBytes(b.size_bytes)}</span></div>
<div style="display:flex;gap:6px;flex-shrink:0">
<button class="btn" style="padding:4px 10px;font-size:12px" onclick="downloadBackup('${esc(b.filename)}')">Download</button>
<button class="btn btn-danger" style="padding:4px 10px;font-size:12px" onclick="restoreBackup('${esc(b.filename)}')">Wiederherstellen</button>
</div>
</div>`;
}).join("");
}
function downloadBackup(filename) {
window.location.href = api(`api/config-backups/${encodeURIComponent(filename)}/download`);
}
async function restoreBackup(filename) {
if (!confirm(`"${filename}" wiederherstellen? Das überschreibt die aktuelle Konfiguration (Geräte, MQTT) komplett.`)) return;
try {
const res = await fetchJSON(api(`api/config-backups/${encodeURIComponent(filename)}/restore`), { method: "POST" });
showToast(`${res.inverters} Gerät(e) wiederhergestellt`, "ok");
setTimeout(() => { loadSettings(); loadInverters(); }, 3000);
} catch(e) { showToast("Wiederherstellen fehlgeschlagen", "err"); }
}
// ── Surplus Devices ───────────────────────────────────────────
let surplusDevices = [];