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
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge
version: "1.11.2"
version: "1.12.0"
slug: shinebridge
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
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))
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)"
+19 -2
View File
@@ -15,6 +15,7 @@ from modbus_client import ModbusReader
from goodwe_client import GoodweReader
from wallbox_client import WallboxReader
from ha_entity_client import HAEntityReader
import weather_forecast
from ems_controller import EmsController
from mqtt_publisher import MqttPublisher
from surplus_devices import SurplusDeviceController
@@ -125,6 +126,8 @@ def _defaults() -> Dict[str, Any]:
"surplus_devices": [],
"z2m_base": "zigbee2mqtt",
"config_backup_retention_days": 14,
"site_lat": None,
"site_lon": None,
}
def _load_json_safe(path: str) -> Optional[Dict]:
@@ -176,6 +179,8 @@ def save_config():
"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):
@@ -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 = 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)
@@ -527,6 +535,15 @@ def api_save_config():
State.mqtt_cfg[k] = float(data[k])
except (ValueError, TypeError):
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"):
if k in data and data[k] is not None:
try:
@@ -1049,7 +1066,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",
"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", [])
+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>
</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">
<h3>Stromtarif</h3>
<div class="field">
@@ -1238,6 +1250,20 @@ async function loadSettings() {
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 ?? "";
}
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() {