Compare commits

..
9 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
7 changed files with 434 additions and 29 deletions
+6
View File
@@ -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 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) ## Netzleistung (grid_power)
Der Sensor `grid_power` zeigt die Netzleistung intuitiv: Der Sensor `grid_power` zeigt die Netzleistung intuitiv:
+6 -4
View File
@@ -4,7 +4,7 @@
## Bekannte Einschränkungen ## Bekannte Einschränkungen
- [ ] Firma: Wallbox lädt in einem ~6,5-Minuten-Zyklus (Laden→Abgeschlossen→Pausiert→Laden) statt durchzuladen — Root Cause noch offen, live-Diagnose folgt beim nächsten Ladevorgang - [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` - [ ] 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
@@ -14,11 +14,13 @@
Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit. Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit.
1. ~~**Automatischer täglicher Config-Export**~~ — erledigt (v1.11.0): täglicher Export nach `/share/shinebridge-backups/`, 14 Tage Aufbewahrung, sofort beim Start plus alle 24h. 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. 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. 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). 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** — 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. 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). 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). 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).
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.11.0" version: "1.12.2"
slug: shinebridge slug: shinebridge
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
url: https://gitea.bitfire.work/retr0/shinebridge url: https://gitea.bitfire.work/retr0/shinebridge
+9 -4
View File
@@ -76,14 +76,18 @@ class EmsController:
return max(6000, min(self.max_charge_ma, ma)) return max(6000, min(self.max_charge_ma, ma))
def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int, 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. Wird vom Poll-Loop aufgerufen.
pv_surplus_w: positiv = PV exportiert ins Netz (verbleibender Überschuss) pv_surplus_w: positiv = PV exportiert ins Netz (verbleibender Überschuss)
wallbox_power_w: aktuell von der Wallbox gezogene Leistung (aus Meter-Daten) 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. Gibt eine kurze Status-Beschreibung zurück.
""" """
now = time.time() 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 # EMS einmalig aktivieren wenn Auto angesteckt
if charging_state in (STATE_CONNECTED, STATE_CHARGING, STATE_PAUSED): 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 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 # Zwangsladen bis Zielzeit — Strom bei JEDEM Update erneut setzen (nicht nur beim
# ersten Eintritt), sonst bleibt ein einzelner fehlgeschlagener/verworfener Write # ersten Eintritt), sonst bleibt ein einzelner fehlgeschlagener/verworfener Write
# (Modbus-Hänger, Fahrzeug-Störung) unbemerkt für den Rest der Nacht stehen. # (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 mins_left = (target - datetime.now()).total_seconds() / 60
if not self._forced_charging: if not self._forced_charging:
self._forced_charging = True self._forced_charging = True
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00, max %.0fA", 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) 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) 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)" return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)"
+119 -15
View File
@@ -15,6 +15,7 @@ from modbus_client import ModbusReader
from goodwe_client import GoodweReader from goodwe_client import GoodweReader
from wallbox_client import WallboxReader from wallbox_client import WallboxReader
from ha_entity_client import HAEntityReader from ha_entity_client import HAEntityReader
import weather_forecast
from ems_controller import EmsController from ems_controller import EmsController
from mqtt_publisher import MqttPublisher from mqtt_publisher import MqttPublisher
from surplus_devices import SurplusDeviceController from surplus_devices import SurplusDeviceController
@@ -124,6 +125,9 @@ def _defaults() -> Dict[str, Any]:
"inverters": [], "inverters": [],
"surplus_devices": [], "surplus_devices": [],
"z2m_base": "zigbee2mqtt", "z2m_base": "zigbee2mqtt",
"config_backup_retention_days": 14,
"site_lat": None,
"site_lon": None,
} }
def _load_json_safe(path: str) -> Optional[Dict]: def _load_json_safe(path: str) -> Optional[Dict]:
@@ -174,6 +178,9 @@ def save_config():
"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,
"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 # Backup der letzten guten Config anlegen
if os.path.exists(CONFIG_PATH): if os.path.exists(CONFIG_PATH):
@@ -221,9 +228,16 @@ def _compute_aggregates(allow_stale: bool = False) -> Dict[str, float]:
# ── EMS Hilfsfunktionen ─────────────────────────────────────── # ── EMS Hilfsfunktionen ───────────────────────────────────────
def _get_pv_surplus() -> float: def _get_pv_surplus() -> float:
"""PV-Überschuss in Watt. 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) 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 ───────────────────────────────────────────────── # ── Poll Loop ─────────────────────────────────────────────────
@@ -357,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 aus 0x0060 (solo-Read); fehlt bei kein Fahrzeug → 0 (Idle)
charging_state = int(values.get("charging_state", 0)) charging_state = int(values.get("charging_state", 0))
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) 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) values["ems_status_code"] = float(charging_state)
log.info("[%s] EMS: %s | PV-Überschuss: %.0fW", inv_id, ems_status, pv_surplus) log.info("[%s] EMS: %s | PV-Überschuss: %.0fW", inv_id, ems_status, pv_surplus)
@@ -494,6 +511,19 @@ def _restart_all():
# ── REST API ────────────────────────────────────────────────── # ── 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") @app.get("/api/config")
def api_get_config(): def api_get_config():
with State.lock: with State.lock:
@@ -518,7 +548,16 @@ def api_save_config():
State.mqtt_cfg[k] = float(data[k]) State.mqtt_cfg[k] = float(data[k])
except (ValueError, TypeError): except (ValueError, TypeError):
pass 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: if k in data and data[k] is not None:
try: try:
State.mqtt_cfg[k] = int(data[k]) State.mqtt_cfg[k] = int(data[k])
@@ -824,7 +863,7 @@ def _build_export_data() -> Dict[str, Any]:
CONFIG_BACKUP_DIR = "/share/shinebridge-backups" CONFIG_BACKUP_DIR = "/share/shinebridge-backups"
CONFIG_BACKUP_RETENTION_DAYS = 14 CONFIG_BACKUP_RETENTION_DAYS_DEFAULT = 14
def _export_config_backup(): def _export_config_backup():
@@ -839,7 +878,8 @@ def _export_config_backup():
json.dump(_build_export_data(), f, indent=2, ensure_ascii=False) json.dump(_build_export_data(), f, indent=2, ensure_ascii=False)
log.info("Config-Backup geschrieben: %s", path) log.info("Config-Backup geschrieben: %s", path)
cutoff = time.time() - CONFIG_BACKUP_RETENTION_DAYS * 86400 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): for name in os.listdir(CONFIG_BACKUP_DIR):
fpath = os.path.join(CONFIG_BACKUP_DIR, name) fpath = os.path.join(CONFIG_BACKUP_DIR, name)
if name.startswith("config-") and name.endswith(".json") and os.path.getmtime(fpath) < cutoff: if name.startswith("config-") and name.endswith(".json") and os.path.getmtime(fpath) < cutoff:
@@ -870,27 +910,28 @@ def api_export_config():
) )
@app.post("/api/import-config") def _apply_import_data(data: Dict[str, Any]) -> "tuple[bool, str, int]":
def api_import_config(): """Validiert + übernimmt eine ShineBridge-Export-Struktur. Gemeinsam genutzt
data = request.get_json(force=True) or {} 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"): 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", []) inverters = data.get("inverters", [])
if not isinstance(inverters, list): if not isinstance(inverters, list):
return jsonify({"error": "inverters ungültig"}), 400 return False, "inverters ungültig", 0
for inv in inverters: for inv in inverters:
model_id = inv.get("inverter_model") model_id = inv.get("inverter_model")
if model_id not in INVERTERS: 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 protocol = INVERTERS[model_id].protocol
# modbus_ip wird als generisches Host-Feld fuer alle IP-basierten Protokolle # modbus_ip wird als generisches Host-Feld fuer alle IP-basierten Protokolle
# wiederverwendet (modbus, goodwe_udp, kathrein) — nur ha_entity braucht keins. # wiederverwendet (modbus, goodwe_udp, kathrein) — nur ha_entity braucht keins.
if protocol == "ha_entity": if protocol == "ha_entity":
if not inv.get("ha_entity_prefix"): 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"): 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: with State.lock:
if mqtt := data.get("mqtt"): if mqtt := data.get("mqtt"):
@@ -904,7 +945,69 @@ def api_import_config():
save_config() save_config()
threading.Thread(target=_restart_all, daemon=True).start() 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"} _hist_spot_cache: Dict[str, Any] = {} # key → {"ts", "avg_ct"}
@@ -976,6 +1079,7 @@ if __name__ == "__main__":
"price_import", "price_export", "billing_day", "billing_month", "price_import", "price_export", "billing_day", "billing_month",
"tariff_type", "spot_country", "spot_markup", "spot_chart", "tariff_type", "spot_country", "spot_markup", "spot_chart",
"billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month", "billing_tracker_enabled", "monthly_rate_eur", "grundpreis_eur_per_month",
"config_backup_retention_days", "site_lat", "site_lon",
) if k in cfg} ) 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", [])
+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,
}
+167 -5
View File
@@ -233,6 +233,19 @@
<button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button> <button class="btn btn-primary" onclick="saveMqtt()">Speichern & Neu starten</button>
</div> </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"> <div class="settings-section">
<h3>Stromtarif</h3> <h3>Stromtarif</h3>
<div class="field"> <div class="field">
@@ -321,6 +334,15 @@
</div> </div>
<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 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> </div>
<!-- Flash/OTA-Panel ausgelagert nach growatt-diagnose-tool (v1.9.0) --> <!-- Flash/OTA-Panel ausgelagert nach growatt-diagnose-tool (v1.9.0) -->
@@ -477,6 +499,7 @@ function switchTab(name) {
}); });
if (name === "energy" || name === "live") startRefresh(); else stopRefresh(); if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
if (name === "finance") loadFinance(); if (name === "finance") loadFinance();
if (name === "settings") loadBackupList();
} }
// ── Energy Dashboard ────────────────────────────────────────── // ── Energy Dashboard ──────────────────────────────────────────
@@ -531,6 +554,51 @@ function renderSpotChart(spotData) {
</div>`; </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) { function fEur(v) {
if (v == null) return null; if (v == null) return null;
return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €'; return v >= 100 ? v.toFixed(0) + ' €' : v.toFixed(2) + ' €';
@@ -540,7 +608,7 @@ function fKwh(v) {
return (v >= 100 ? v.toFixed(0) : v.toFixed(1)) + ' kWh'; 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"); const el = document.getElementById("energy-content");
if (!aggregates || !Object.keys(aggregates).length) { if (!aggregates || !Object.keys(aggregates).length) {
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>'; el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
@@ -706,6 +774,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
sectionCards(yr.label || 'Dieses Jahr', yr); sectionCards(yr.label || 'Dieses Jahr', yr);
const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : ''; const spotHtml = (period.spot_chart !== false) ? renderSpotChart(spotData) : '';
const weatherHtml = renderWeatherChart(weatherData);
const bt = period.billing_tracker; const bt = period.billing_tracker;
const trackerHtml = bt ? (() => { const trackerHtml = bt ? (() => {
@@ -741,7 +810,7 @@ function renderEnergy(inverters, aggregates, period, spotData) {
})() : ''; })() : '';
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}${weatherHtml}</div>
${cards ? `<div style="margin-top:16px">${cards}</div>` : ''} ${cards ? `<div style="margin-top:16px">${cards}</div>` : ''}
${trackerHtml} ${trackerHtml}
</div>`; </div>`;
@@ -949,9 +1018,10 @@ async function refreshData() {
document.getElementById("subtitle").textContent = document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte"; keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {}, d.aggregates || {}, surplusData); renderLive(d.inverters || {}, d.aggregates || {}, surplusData);
const period = await fetchJSON(api("api/period-energy")).catch(() => ({})); const period = await fetchJSON(api("api/period-energy")).catch(() => ({}));
const spot = await fetchJSON(api("api/spot-price")).catch(() => ({})); const spot = await fetchJSON(api("api/spot-price")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || [])); const weather = await fetchJSON(api("api/weather-forecast")).catch(() => ({}));
renderEnergy(d.inverters || {}, d.aggregates || {}, period, (spot.data || []), weather);
} catch(e) { } catch(e) {
document.getElementById("pill-mqtt").className = "pill err"; document.getElementById("pill-mqtt").className = "pill err";
} }
@@ -1227,6 +1297,43 @@ async function loadSettings() {
document.getElementById("billing-tracker-fields").style.display = trackerEnabled ? "" : "none"; document.getElementById("billing-tracker-fields").style.display = trackerEnabled ? "" : "none";
document.getElementById("cfg-monthly-rate").value = cfg.monthly_rate_eur ?? 0; 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-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() { async function savePrices() {
@@ -1304,6 +1411,61 @@ async function importConfig(input) {
input.value = ""; 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 ─────────────────────────────────────────── // ── Surplus Devices ───────────────────────────────────────────
let surplusDevices = []; let surplusDevices = [];