Feature: Kathrein Wallbox + EMS-Controller (v1.5.0)

- 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 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-28 12:02:59 +02:00
parent 8b65666e80
commit ac965dcfa6
5 changed files with 315 additions and 1 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.4.0" version: "1.5.0"
slug: shinebridge slug: shinebridge
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
url: https://gitea.bitfire.work/retr0/shinebridge url: https://gitea.bitfire.work/retr0/shinebridge
+124
View File
@@ -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)"
+37
View File
@@ -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 = { INVERTERS = {
"MIC_1500_TL_X": Inverter( "MIC_1500_TL_X": Inverter(
id="MIC_1500_TL_X", id="MIC_1500_TL_X",
@@ -214,4 +241,14 @@ INVERTERS = {
protocol="goodwe_udp", protocol="goodwe_udp",
goodwe_family="ET", 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="",
),
} }
+40
View File
@@ -13,6 +13,8 @@ from inverters import INVERTERS
import history import history
from modbus_client import ModbusReader from modbus_client import ModbusReader
from goodwe_client import GoodweReader from goodwe_client import GoodweReader
from wallbox_client import WallboxReader
from ems_controller import EmsController
from mqtt_publisher import MqttPublisher from mqtt_publisher import MqttPublisher
logging.basicConfig( logging.basicConfig(
@@ -139,6 +141,29 @@ def _compute_aggregates() -> Dict[str, float]:
) )
return result 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 ───────────────────────────────────────────────── # ── Poll Loop ─────────────────────────────────────────────────
def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event): 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 return
host = inv_cfg["modbus_ip"] host = inv_cfg["modbus_ip"]
ems: Optional[EmsController] = None
if inverter.protocol == "goodwe_udp": if inverter.protocol == "goodwe_udp":
reader = GoodweReader(host=host, family=inverter.goodwe_family) reader = GoodweReader(host=host, family=inverter.goodwe_family)
log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds", log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds",
inv_id, inverter.name, host, interval) 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: else:
reader = ModbusReader(host=host, port=port, slave=slave) reader = ModbusReader(host=host, port=port, slave=slave)
log.info("[%s] Poll-Loop: %s @ %s:%s alle %ds", 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(): while not stop.is_set():
t0 = time.time() t0 = time.time()
values = reader.read(inverter) 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: with State.lock:
d = State.inv_data.setdefault(inv_id, {"poll_count": 0}) d = State.inv_data.setdefault(inv_id, {"poll_count": 0})
if values is not None: if values is not None:
+113
View File
@@ -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