Compare commits

..
12 Commits
Author SHA1 Message Date
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
retr0andClaude Sonnet 5 1c787349e3 ShineBridge v1.10.2 — Diagnose: Env-Var-Dump wenn SUPERVISOR_TOKEN fehlt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:22:34 +02:00
retr0andClaude Sonnet 5 1b78857b3d ShineBridge v1.10.1 — Fix: Config-Import scheiterte an Geräten ohne modbus_ip (ha_entity-Typ)
/api/import-config verlangte bisher zwingend modbus_ip fuer jedes Geraet in der
importierten Config — der neue GROWATT_WIFI_X-Typ (ha_entity) hat aber keins,
nutzt stattdessen ha_entity_prefix. Import schlug dadurch mit 400 fehl, sobald
so ein Geraet enthalten war. Validierung jetzt protokollabhaengig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:11:13 +02:00
6 changed files with 268 additions and 85 deletions
+3 -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,8 @@ Konfigurierbar pro Gerät im Web UI:
>
> Die EMS-Funktion muss zusätzlich in der Kathrein easyOperate-App aktiviert sein.
**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 + Sonnenstand** — PV-Wetterprognose (z.B. Open-Meteo/Solcast) + Spot-Preis kombinieren, damit Zwangsladen nicht stur nach Timeout entscheidet, sondern z.B. "morgen wird's sonnig, noch etwas warten" berücksichtigt. Größter Umfang, externe API-Anbindung nötig.
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.0"
version: "1.11.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
+11 -10
View File
@@ -1,6 +1,5 @@
import json
import logging
import os
import urllib.error
import urllib.request
from typing import Dict, Optional
@@ -29,22 +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", "")
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] = {}
@@ -58,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:
+142 -17
View File
@@ -124,6 +124,7 @@ def _defaults() -> Dict[str, Any]:
"inverters": [],
"surplus_devices": [],
"z2m_base": "zigbee2mqtt",
"config_backup_retention_days": 14,
}
def _load_json_safe(path: str) -> Optional[Dict]:
@@ -174,6 +175,7 @@ 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),
}
# Backup der letzten guten Config anlegen
if os.path.exists(CONFIG_PATH):
@@ -221,9 +223,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 +289,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":
@@ -514,7 +527,7 @@ 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 ("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 +817,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 +830,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,20 +880,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:
if not inv.get("modbus_ip"):
return jsonify({"error": f"modbus_ip fehlt in Gerät {inv.get('name', '?')}"}), 400
if inv.get("inverter_model") not in INVERTERS:
return jsonify({"error": f"Unbekanntes Modell: {inv.get('inverter_model')}"}), 400
model_id = inv.get("inverter_model")
if model_id not in INVERTERS:
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 False, f"ha_entity_prefix fehlt in Gerät {inv.get('name', '?')}", 0
elif not inv.get("modbus_ip"):
return False, f"modbus_ip fehlt in Gerät {inv.get('name', '?')}", 0
with State.lock:
if mqtt := data.get("mqtt"):
@@ -854,7 +915,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"}
@@ -926,6 +1049,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",
) if k in cfg}
State.inverters_cfg = cfg.get("inverters", [])
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
@@ -944,6 +1068,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)
+79 -1
View File
@@ -321,6 +321,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 +355,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 +486,7 @@ function switchTab(name) {
});
if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
if (name === "finance") loadFinance();
if (name === "settings") loadBackupList();
}
// ── Energy Dashboard ──────────────────────────────────────────
@@ -1081,6 +1097,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 +1115,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 +1155,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 +1237,7 @@ 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;
}
async function savePrices() {
@@ -1292,6 +1315,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 = [];