Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5f8cb7af | ||
|
|
8be3a7eb47 | ||
|
|
1ce98df596 | ||
|
|
8f5106e3a0 | ||
|
|
202759cc84 | ||
|
|
0f564a561f | ||
|
|
c524a0189a | ||
|
|
4ff91a2674 | ||
|
|
117a2d2beb | ||
|
|
b7dc008cd7 | ||
|
|
3318d1344d | ||
|
|
c924955be9 | ||
|
|
89250e16bf | ||
|
|
789e25967a | ||
|
|
1c787349e3 | ||
|
|
1b78857b3d |
@@ -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
@@ -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) |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ShineBridge
|
||||
version: "1.10.0"
|
||||
version: "1.12.3"
|
||||
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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+173
-18
@@ -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,20 +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:
|
||||
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 +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"}
|
||||
|
||||
@@ -926,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", [])
|
||||
@@ -944,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)
|
||||
|
||||
@@ -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), ...]}
|
||||
|
||||
SUN_THRESHOLD = 80.0 # W/m² — deutlich mehr als Daemmerung, aber kein voller Sonnentag noetig
|
||||
|
||||
|
||||
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]
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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>`;
|
||||
@@ -945,7 +1020,8 @@ async function refreshData() {
|
||||
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 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 = [];
|
||||
|
||||
Reference in New Issue
Block a user