From ac965dcfa6005e0da47efdb11a55ea4be7398a3a Mon Sep 17 00:00:00 2001 From: retr0 <42kdesigners@gmail.com> Date: Tue, 28 Apr 2026 12:02:59 +0200 Subject: [PATCH] Feature: Kathrein Wallbox + EMS-Controller (v1.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wallbox_client.py: WallboxReader FC03, EMS enable/set_current - ems_controller.py: PV-Überschussladen + 4h-Timeout Zwangsladen bis 06:00 - inverters.py: KATHREIN_WALLBOX mit 18 Sensoren (Meter + EVSE + EMS) - main.py: kathrein-Protokollzweig, _get_pv_surplus() aus laufenden Geräten Co-Authored-By: Claude Sonnet 4.6 --- haos-addon/config.yaml | 2 +- haos-addon/src/ems_controller.py | 124 +++++++++++++++++++++++++++++++ haos-addon/src/inverters.py | 37 +++++++++ haos-addon/src/main.py | 40 ++++++++++ haos-addon/src/wallbox_client.py | 113 ++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 haos-addon/src/ems_controller.py create mode 100644 haos-addon/src/wallbox_client.py diff --git a/haos-addon/config.yaml b/haos-addon/config.yaml index 4330151..47d2bff 100644 --- a/haos-addon/config.yaml +++ b/haos-addon/config.yaml @@ -1,5 +1,5 @@ name: ShineBridge -version: "1.4.0" +version: "1.5.0" slug: shinebridge description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI url: https://gitea.bitfire.work/retr0/shinebridge diff --git a/haos-addon/src/ems_controller.py b/haos-addon/src/ems_controller.py new file mode 100644 index 0000000..eccaa98 --- /dev/null +++ b/haos-addon/src/ems_controller.py @@ -0,0 +1,124 @@ +""" +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) -> int: + """PV-Überschuss in Watt → Ladestrom in mA (pro Phase).""" + current_a = surplus_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) -> str: + """ + Wird vom Poll-Loop aufgerufen. + pv_surplus_w: positiv = PV exportiert ins Netz (verfügbar für Laden) + 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_reader.set_current(ma) + return f"PV-Laden {ma // 1000:.1f}A ({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._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)" diff --git a/haos-addon/src/inverters.py b/haos-addon/src/inverters.py index 3b24fa5..ba72dc0 100644 --- a/haos-addon/src/inverters.py +++ b/haos-addon/src/inverters.py @@ -169,6 +169,33 @@ def _sdm630_sensors() -> List[Sensor]: ] +def _kathrein_sensors() -> List[Sensor]: + f = "float32" + return [ + # Meter (Float32, FC03 Holding Registers ab 0x0030) + Sensor("voltage_l1", "Spannung L1", 0x0030, 2, 1.0, "V", "voltage", "measurement", "mdi:flash", f), + Sensor("voltage_l2", "Spannung L2", 0x0032, 2, 1.0, "V", "voltage", "measurement", "mdi:flash", f), + Sensor("voltage_l3", "Spannung L3", 0x0034, 2, 1.0, "V", "voltage", "measurement", "mdi:flash", f), + Sensor("current_l1", "Strom L1", 0x0036, 2, 1.0, "A", "current", "measurement", "mdi:flash", f), + Sensor("current_l2", "Strom L2", 0x0038, 2, 1.0, "A", "current", "measurement", "mdi:flash", f), + Sensor("current_l3", "Strom L3", 0x003A, 2, 1.0, "A", "current", "measurement", "mdi:flash", f), + Sensor("power_l1", "Ladeleistung L1", 0x003C, 2, 1.0, "W", "power", "measurement", "mdi:ev-station", f), + Sensor("power_l2", "Ladeleistung L2", 0x003E, 2, 1.0, "W", "power", "measurement", "mdi:ev-station", f), + Sensor("power_l3", "Ladeleistung L3", 0x0040, 2, 1.0, "W", "power", "measurement", "mdi:ev-station", f), + Sensor("total_power", "Ladeleistung Gesamt", 0x0054, 2, 1.0, "W", "power", "measurement", "mdi:ev-station", f), + Sensor("session_energy", "Energie Session", 0x0069, 2, 1.0, "Wh", "energy", "total_increasing", "mdi:battery-charging", "uint32"), + Sensor("total_energy", "Energie Gesamt", 0x005C, 2, 1.0, "Wh", "energy", "total_increasing", "mdi:counter", f), + Sensor("frequency", "Frequenz", 0x005E, 2, 1.0, "Hz", "frequency", "measurement", "mdi:sine-wave", f), + # EVSE Status (UINT16) + Sensor("charging_state", "Ladezustand", 0x0060, 1, 1.0, "", None, "measurement", "mdi:ev-plug-type2"), + Sensor("granted_current", "Gewährter Strom", 0x0065, 1, 1.0, "mA", "current", "measurement", "mdi:current-ac"), + Sensor("granted_power", "Gewährte Leistung", 0x0066, 1, 1.0, "W", "power", "measurement", "mdi:ev-station"), + Sensor("charging_duration","Ladedauer", 0x0067, 2, 1.0, "s", "duration", "measurement", "mdi:timer", "uint32"), + # EMS (wird vom Controller geschrieben, hier zum Monitoring) + Sensor("ems_current_sp", "EMS Stromsollwert", 0x00A2, 1, 1.0, "mA", "current", "measurement", "mdi:tune"), + ] + + INVERTERS = { "MIC_1500_TL_X": Inverter( id="MIC_1500_TL_X", @@ -214,4 +241,14 @@ INVERTERS = { protocol="goodwe_udp", goodwe_family="ET", ), + "KATHREIN_WALLBOX": Inverter( + id="KATHREIN_WALLBOX", + name="Kathrein Wallbox", + manufacturer="Kathrein", + sensors=_kathrein_sensors(), + # Meter-Block + EVSE-Status + EMS-Setpoint + read_ranges=[(0x0030, 48), (0x0060, 10), (0x00A2, 1)], + protocol="kathrein", + goodwe_family="", + ), } diff --git a/haos-addon/src/main.py b/haos-addon/src/main.py index 2283d09..e87daaf 100644 --- a/haos-addon/src/main.py +++ b/haos-addon/src/main.py @@ -13,6 +13,8 @@ from inverters import INVERTERS import history from modbus_client import ModbusReader from goodwe_client import GoodweReader +from wallbox_client import WallboxReader +from ems_controller import EmsController from mqtt_publisher import MqttPublisher logging.basicConfig( @@ -139,6 +141,29 @@ def _compute_aggregates() -> Dict[str, float]: ) return result +# ── EMS Hilfsfunktionen ─────────────────────────────────────── + +def _get_pv_surplus() -> float: + """PV-Überschuss in Watt aus laufenden Geräten ermitteln. + Goodwe: active_power negativ = Einspeisung (Überschuss). + Growatt: power_to_grid positiv = Einspeisung. + """ + surplus = 0.0 + with State.lock: + for inv_cfg in State.inverters_cfg: + d = State.inv_data.get(inv_cfg["id"], {}) + if not d.get("modbus_ok") or not d.get("values"): + continue + v = d["values"] + # Goodwe: active_power < 0 bedeutet Einspeisung + if "active_power" in v: + surplus += max(0.0, -v["active_power"]) + # Growatt + if "power_to_grid" in v: + surplus += max(0.0, v["power_to_grid"]) + return surplus + + # ── Poll Loop ───────────────────────────────────────────────── def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): @@ -156,10 +181,16 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): return host = inv_cfg["modbus_ip"] + ems: Optional[EmsController] = None if inverter.protocol == "goodwe_udp": reader = GoodweReader(host=host, family=inverter.goodwe_family) log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds", inv_id, inverter.name, host, interval) + elif inverter.protocol == "kathrein": + reader = WallboxReader(host=host, port=port) + ems = EmsController() + log.info("[%s] Poll-Loop: %s @ %s:%s (Kathrein EMS) alle %ds", + inv_id, inverter.name, host, port, interval) else: reader = ModbusReader(host=host, port=port, slave=slave) log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds", @@ -184,6 +215,15 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): while not stop.is_set(): t0 = time.time() values = reader.read(inverter) + + # EMS: PV-Überschuss aus anderen Geräten holen und Ladestrom regeln + if ems is not None and values is not None: + pv_surplus = _get_pv_surplus() + charging_state = int(values.get("charging_state", 0)) + ems_status = ems.update(reader, pv_surplus, charging_state) + values["ems_status_code"] = float(charging_state) + log.debug("[%s] EMS: %s", inv_id, ems_status) + with State.lock: d = State.inv_data.setdefault(inv_id, {"poll_count": 0}) if values is not None: diff --git a/haos-addon/src/wallbox_client.py b/haos-addon/src/wallbox_client.py new file mode 100644 index 0000000..8a30ca6 --- /dev/null +++ b/haos-addon/src/wallbox_client.py @@ -0,0 +1,113 @@ +import logging +import struct +import time +from typing import Dict, Optional + +from pymodbus.client import ModbusTcpClient +from pymodbus.exceptions import ModbusException + +from inverters import Inverter, Sensor + +log = logging.getLogger(__name__) + +SLAVE = 0 # Kathrein: Unit-ID 0 (Broadcast) +EMS_CTRL_REG = 0x00A0 +EMS_CURRENT_REG = 0x00A2 +EMS_ENABLE = 0x8000 +MIN_CURRENT_MA = 6000 +MAX_CURRENT_MA = 32000 + + +class WallboxReader: + def __init__(self, host: str, port: int = 502, timeout: float = 10.0): + self.host = host + self.port = port + self.timeout = timeout + self._client: Optional[ModbusTcpClient] = None + + def _connect(self) -> bool: + if self._client and self._client.connected: + return True + self._client = ModbusTcpClient(self.host, port=self.port, timeout=self.timeout) + if not self._client.connect(): + log.error("[Wallbox %s] Verbindung fehlgeschlagen", self.host) + self._client = None + return False + return True + + def _disconnect(self): + if self._client: + self._client.close() + self._client = None + + def read(self, inverter: Inverter) -> Optional[Dict[str, float]]: + if not self._connect(): + return None + + reg_cache: Dict[int, int] = {} + for start, length in inverter.read_ranges: + try: + result = self._client.read_holding_registers(start, count=length, slave=SLAVE) + if result.isError(): + log.error("[Wallbox] FC03 Fehler Reg 0x%04X: %s", start, result) + self._disconnect() + return None + for i, val in enumerate(result.registers): + reg_cache[start + i] = val + except ModbusException as e: + log.error("[Wallbox] Modbus Ausnahme: %s", e) + self._disconnect() + return None + + return _extract(inverter.sensors, reg_cache) + + def enable_ems(self) -> bool: + if not self._connect(): + return False + try: + r = self._client.write_register(EMS_CTRL_REG, EMS_ENABLE, slave=SLAVE) + return not r.isError() + except ModbusException as e: + log.error("[Wallbox] EMS enable Fehler: %s", e) + return False + + def set_current(self, ma: int) -> bool: + """Ladestrom setzen. ma=0 → Pause, ma=0xFFFF → Abbruch.""" + if not self._connect(): + return False + val = 0 if ma == 0 else max(MIN_CURRENT_MA, min(MAX_CURRENT_MA, ma)) + try: + r = self._client.write_register(EMS_CURRENT_REG, val, slave=SLAVE) + ok = not r.isError() + if ok: + log.info("[Wallbox] Strom gesetzt: %d mA", val) + return ok + except ModbusException as e: + log.error("[Wallbox] set_current Fehler: %s", e) + return False + + def close(self): + self._disconnect() + + +def _extract(sensors: list, regs: Dict[int, int]) -> Dict[str, float]: + values: Dict[str, float] = {} + for s in sensors: + if s.reg not in regs: + continue + dt = getattr(s, "data_type", "uint16") + try: + if dt == "float32": + if s.reg + 1 not in regs: + continue + raw = struct.unpack(">f", struct.pack(">HH", regs[s.reg], regs[s.reg + 1]))[0] + elif dt == "uint32": + if s.reg + 1 not in regs: + continue + raw = (regs[s.reg] << 16) | regs[s.reg + 1] + else: + raw = regs[s.reg] + values[s.id] = round(float(raw) * s.scale, 3) + except Exception: + pass + return values