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:
@@ -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)"
|
||||
@@ -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="",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user