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:
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
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="",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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