Files
Shinebridge/haos-addon/src/ems_controller.py
T
retr0 ff428145f4 Fix: EMS Ladestrom = PV-Überschuss + aktuelle Wallbox-Leistung
Goodwe misst Überschuss NACH Wallbox-Verbrauch. EMS muss deshalb
bestehende Ladeleistung (total_power) addieren um Gesamtbudget zu kennen:
  new_current = (surplus + wallbox_power) / (230V × phases)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 13:22:57 +02:00

132 lines
5.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
EMS-Controller: PV-Überschussladen mit Deadline-Fallback.
Logik:
1. Wenn PV-Überschuss >= MIN_PV_POWER → Ladestrom = Überschuss / 230V
2. Wenn kein ausreichender Überschuss für > PV_TIMEOUT_H Stunden:
→ Zwangsladen mit Maximalstrom
→ solange bis Zielzeit (default 06:00) erreicht oder Auto voll
3. Auto nicht angeschlossen oder Laden beendet → EMS zurücksetzen
"""
import logging
import time
from datetime import datetime, timedelta
from typing import Optional
log = logging.getLogger(__name__)
# Konfiguration (Defaults, überschreibbar über EmsController.__init__)
MIN_PV_POWER_W = 1400 # Mindest-PV-Überschuss für Laden (6A × 230V)
PV_TIMEOUT_H = 4.0 # Stunden ohne PV-Überschuss → Zwangsladen
TARGET_HOUR = 6 # Zielstunde: Vollladung bis 06:00
POLL_INTERVAL_S = 30 # Sekunden zwischen EMS-Updates
PHASES = 3 # Anzahl der Phasen
VOLTAGE_V = 230 # Netzspannung
# Charging-State Werte (Kathrein Modbus)
STATE_IDLE = 0
STATE_CONNECTED = 1
STATE_CHARGING = 4
STATE_PAUSED = 5
STATE_COMPLETED = 6
class EmsController:
def __init__(
self,
min_pv_power: int = MIN_PV_POWER_W,
pv_timeout_h: float = PV_TIMEOUT_H,
target_hour: int = TARGET_HOUR,
phases: int = PHASES,
):
self.min_pv_power = min_pv_power
self.pv_timeout_h = pv_timeout_h
self.target_hour = target_hour
self.phases = phases
self._pv_ok_since: Optional[float] = None # Zeitpunkt letzter ausreichender PV
self._no_pv_since: Optional[float] = None # Zeitpunkt seit kein PV
self._forced_charging = False
self._ems_enabled = False
self._last_state = STATE_IDLE
def _next_target(self) -> datetime:
now = datetime.now()
t = now.replace(hour=self.target_hour, minute=0, second=0, microsecond=0)
if t <= now:
t += timedelta(days=1)
return t
def _surplus_to_ma(self, surplus_w: float, wallbox_w: float = 0.0) -> int:
"""Gesamte verfügbare PV-Leistung → Ladestrom in mA (pro Phase).
surplus_w: noch ins Netz eingespeister Überschuss
wallbox_w: aktuell von der Wallbox gezogene Leistung
Summe = gesamt verfügbar für Laden."""
total_w = surplus_w + wallbox_w
current_a = total_w / (VOLTAGE_V * self.phases)
ma = int(current_a * 1000)
return max(6000, min(32000, ma))
def update(self, wallbox_reader, pv_surplus_w: float, charging_state: int,
wallbox_power_w: float = 0.0) -> 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)
Gibt eine kurze Status-Beschreibung zurück.
"""
now = time.time()
# EMS einmalig aktivieren wenn Auto angesteckt
if charging_state in (STATE_CONNECTED, STATE_CHARGING, STATE_PAUSED):
if not self._ems_enabled:
if wallbox_reader.enable_ems():
self._ems_enabled = True
log.info("[EMS] EMS aktiviert")
else:
# Auto weg → alles zurücksetzen
if self._ems_enabled:
wallbox_reader.set_current(0)
self._ems_enabled = False
self._forced_charging = False
self._no_pv_since = None
log.info("[EMS] Auto abgesteckt — EMS zurückgesetzt")
return "kein Fahrzeug"
if charging_state == STATE_COMPLETED:
wallbox_reader.set_current(0)
return "vollgeladen"
# PV-Überschuss auswerten
has_pv = pv_surplus_w >= self.min_pv_power
if has_pv:
self._no_pv_since = None
self._forced_charging = False
ma = self._surplus_to_ma(pv_surplus_w, wallbox_power_w)
total_w = pv_surplus_w + wallbox_power_w
wallbox_reader.set_current(ma, self.phases)
return f"PV-Laden {ma / 1000:.1f}A ({total_w:.0f}W, Überschuss {pv_surplus_w:.0f}W)"
# Kein PV
if self._no_pv_since is None:
self._no_pv_since = now
no_pv_h = (now - self._no_pv_since) / 3600.0
if no_pv_h >= self.pv_timeout_h:
# Zwangsladen bis Zielzeit
target = self._next_target()
mins_left = (target - datetime.now()).total_seconds() / 60
if not self._forced_charging:
wallbox_reader.set_current(32000, self.phases)
self._forced_charging = True
log.info("[EMS] Zwangsladen gestartet — %dmin bis %02d:00", int(mins_left), self.target_hour)
return f"Zwangsladen ({no_pv_h:.1f}h kein PV, {int(mins_left)}min bis {self.target_hour:02d}:00)"
# Warten auf PV
wallbox_reader.set_current(0)
remaining_min = int((self.pv_timeout_h - no_pv_h) * 60)
return f"warte auf PV ({remaining_min}min bis Zwangsladen)"