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>
This commit is contained in:
retr0
2026-08-26 15:57:04 +02:00
co-authored by Claude Sonnet 5
parent 202759cc84
commit 8f5106e3a0
7 changed files with 144 additions and 8 deletions
+2
View File
@@ -80,6 +80,8 @@ 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.
**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. **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)
+1 -1
View File
@@ -20,7 +20,7 @@ Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit.
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** — in zwei Teilen, hintereinander gebaut: 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 A: Batteriebewusster PV-Überschuss**~~ — erledigt (v1.11.2): Batterie-Entladeleistung wird vom PV-Überschuss abgezogen, damit PV-Überschussladen nie versehentlich den Hausspeicher anzapft (Zwangsladen bleibt unverändert netzbasiert).
- **Teil B: Wetterprognose + Sonnenstand** — PV-Wetterprognose (z.B. Open-Meteo/Solcast) + Spot-Preis kombinieren, damit Zwangsladen nicht stur nach Timeout entscheidet, sondern z.B. "morgen wird's sonnig, noch etwas warten" berücksichtigt. Größter Umfang, externe API-Anbindung nötig. - ~~**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.2" version: "1.12.0"
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)"
+19 -2
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
@@ -125,6 +126,8 @@ def _defaults() -> Dict[str, Any]:
"surplus_devices": [], "surplus_devices": [],
"z2m_base": "zigbee2mqtt", "z2m_base": "zigbee2mqtt",
"config_backup_retention_days": 14, "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]:
@@ -176,6 +179,8 @@ def save_config():
"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), "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):
@@ -366,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)
@@ -527,6 +535,15 @@ 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 ("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"): 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:
@@ -1049,7 +1066,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", "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", [])
+86
View File
@@ -0,0 +1,86 @@
"""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
+26
View File
@@ -233,6 +233,18 @@
<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>
<div class="settings-section"> <div class="settings-section">
<h3>Stromtarif</h3> <h3>Stromtarif</h3>
<div class="field"> <div class="field">
@@ -1238,6 +1250,20 @@ async function loadSettings() {
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-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 ?? "";
}
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");
} catch(e) { showToast("Fehler beim Speichern", "err"); }
} }
async function savePrices() { async function savePrices() {