diff --git a/README.md b/README.md index 5f4209d..afe7444 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ Konfigurierbar pro Gerät im Web UI: > > 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. ## Netzleistung (grid_power) diff --git a/ROADMAP.md b/ROADMAP.md index bad4f31..d78d76d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). 5. **Prognosebasiertes Laden** — in zwei Teilen, hintereinander gebaut: - ~~**Teil A: Batteriebewusster PV-Überschuss**~~ — erledigt (v1.11.2): Batterie-Entladeleistung wird vom PV-Überschuss abgezogen, damit PV-Überschussladen nie versehentlich den Hausspeicher anzapft (Zwangsladen bleibt unverändert netzbasiert). - - **Teil B: Wetterprognose + Sonnenstand** — PV-Wetterprognose (z.B. Open-Meteo/Solcast) + Spot-Preis kombinieren, damit Zwangsladen nicht stur nach Timeout entscheidet, sondern z.B. "morgen wird's sonnig, noch etwas warten" berücksichtigt. Größter Umfang, externe API-Anbindung nötig. + - ~~**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). diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index 8132300..de72f41 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -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 diff --git a/haos-addon/src/ems_controller.py b/haos-addon/src/ems_controller.py index c00ec90..491484f 100644 --- a/haos-addon/src/ems_controller.py +++ b/haos-addon/src/ems_controller.py @@ -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)" diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index c76811f..f56af89 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -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", []) diff --git a/haos-addon/src/weather_forecast.py b/haos-addon/src/weather_forecast.py new file mode 100644 index 0000000..054e83e --- /dev/null +++ b/haos-addon/src/weather_forecast.py @@ -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 diff --git a/haos-addon/src/web/index.html b/haos-addon/src/web/index.html index 2b8fc50..85a3cde 100644 --- a/haos-addon/src/web/index.html +++ b/haos-addon/src/web/index.html @@ -233,6 +233,18 @@ +
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.
+