Skip to content
1 change: 1 addition & 0 deletions packages/modules/consumers/idm/idm/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def __init__(self,
id: int = 0,
configuration: IdmConfiguration = None,
usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE,
ConsumerUsage.SELF_CONTROLLED,
ConsumerUsage.METER_ONLY),
**kwargs) -> None:
super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory(
Expand Down
63 changes: 44 additions & 19 deletions packages/modules/consumers/idm/idm/consumer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
from pymodbus.constants import Endian
import logging

from modules.common.abstract_consumer import CurrentValues
from modules.common.abstract_device import DeviceDescriptor
from modules.common.component_state import ConsumerState
from modules.common.component_type import ComponentType
Expand All @@ -24,42 +26,65 @@ def initializer():
def error_handler() -> None:
initializer()

def send_values(values: CurrentValues) -> None:
# Sendet die aktuellen Systemwerte an die IDM Navigator 2.0-Regelung über die
# Gebäudeleittechnik/Smartfox-Schnittstelle (Modbus TCP). IDM unterstützt keine
# direkte Leistungsvorgabe für die WP - die Regelung berechnet ihre Überschuss- und
# Speicherstrategie selbst anhand dieser Werte. Wird vom Core automatisch aufgerufen,
# solange dieser Verbraucher als ConsumerUsage.SELF_CONTROLLED konfiguriert ist.
# Register-Layout siehe IDM "Gebäudeleittechnik-Smartfox.pdf", Kap. 2.2.5.1
modbus_id = config.configuration.modbus_id

# values.pv_power kommt roh aus process.py (negativ während Produktion, gleiche
# Konvention wie beim EVU-Zähler) -> Vorzeichen drehen und auf 0 begrenzen, um die
# reine Erzeugung zu erhalten
pv_power = max(-values.pv_power, 0)
# Ladeleistung mit einrechnen, da diese sonst fälschlich als freier Überschuss an die
# WP gemeldet würde, obwohl sie bereits von einer Fahrzeugladung verbraucht wird
surplus = max(pv_power - values.home_consumption - values.cp_power, 0)
battery_soc = int(values.bat_soc) if values.bat_soc is not None else -1

# Reg 74: Aktueller PV-Überschuss [kW]
client.write_register(74, surplus / 1000, wordorder=Endian.Little, unit=modbus_id)
# Reg 78: Aktuelle PV-Produktion [kW]
client.write_register(78, pv_power / 1000, wordorder=Endian.Little, unit=modbus_id)
# Reg 82: Hausverbrauch [kW], Default 0
client.write_register(82, values.home_consumption / 1000, wordorder=Endian.Little, unit=modbus_id)
# Reg 84: Batterieentladung [kW], Default 0 (negativ = Ladung)
client.write_register(84, values.bat_power / 1000, wordorder=Endian.Little, unit=modbus_id)
# Reg 86: Batteriefüllstand [%], Default -1 (= kein Speicher)
client.write_register(86, battery_soc, unit=modbus_id)

def update() -> ConsumerState:
if config.configuration.version == 1:
power = client.read_holding_registers(4122, ModbusDataType.FLOAT_32,
unit=config.configuration.modbus_id)
power = client.read_holding_registers(
4122, ModbusDataType.FLOAT_32, unit=config.configuration.modbus_id)
else:
power = client.read_input_registers(4122, ModbusDataType.FLOAT_32,
unit=config.configuration.modbus_id)
power = client.read_input_registers(
4122, ModbusDataType.FLOAT_32, unit=config.configuration.modbus_id)
power *= 100
imported, exported = sim_counter.sim_count(power)

return ConsumerState(
power=power,
imported=imported,
exported=exported
)

def set_limit(power_limit: float) -> None:
# braucht man Reg 78? der Wärmepumpe kann es doch egal sein, wie viel PV-Leistung vom Dach kommt,
# vlt soll die anderweitig genutzt werden
# <option value="UP" data-option="UP">Überschuss als positive Zahl übertragen, Bezug negativ</option>
# <option value="UZ" data-option="UZ">Überschuss als positive Zahl übertragen, Bezug als 0</option>
# muss man unterscheiden, ob Bezug als negative Zahl oder null übertragen werden soll. Die Wärmepumpe kann
# den Bezug ja nicht kompensieren. Bei 0W oder 500W Bezug soll das Verhalten gleich sein (Wärmepumpe nur
# nach Bedarf einschalten, Eigensteuerung)
# if config.configuration.send_import:
# power_limit = min(power_limit, MAX_VALUE_UINT32)
# power_limit = max(power_limit, 0)
# else:
# power_limit = min(power_limit, MAX_VALUE_INT16)
# power_limit = max(power_limit, MIN_VALUE_INT16)
# Schreibt den von openWB berechneten Überschuss in Register 74 (PV-Überschuss [kW]).
# Keine echte Leistungsvorgabe - die IDM-Regelung berechnet ihre Strategie selbst anhand dieses Wertes.
# Hinweis: Dies überschreibt den intern berechneten PV-Überschuss der IDM-Regelung und kann
# die PV-Eigenverbrauchs-Statistik im IDM-Portal verfälschen.
client.write_register(74, max(power_limit, 0) / 1000, wordorder=Endian.Little,
unit=config.configuration.modbus_id)

client.write_register(74, max(power_limit, 0), wordorder=Endian.Little, unit=config.configuration.modbus_id)
return ConfigurableConsumer(consumer_config=config,
initializer=initializer,
error_handler=error_handler,
update=update,
set_power_limit=set_limit,)
send_values=send_values,
set_power_limit=set_limit)


device_descriptor = DeviceDescriptor(configuration_factory=Idm)
5 changes: 2 additions & 3 deletions packages/modules/consumers/lambda_/lambda_/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ class LambdaConfiguration:
def __init__(self,
ip_address: Optional[str] = None,
port: Optional[int] = 502,
modbus_id: Optional[int] = 1,
sign: int = 1):
modbus_id: Optional[int] = 1):
self.ip_address = ip_address
self.port = port
self.modbus_id = modbus_id
self.sign = sign


@auto_str
Expand All @@ -27,6 +25,7 @@ def __init__(self,
id: int = 0,
configuration: LambdaConfiguration = None,
usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE,
ConsumerUsage.SELF_CONTROLLED,
ConsumerUsage.METER_ONLY),
**kwargs) -> None:
super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory(
Expand Down
19 changes: 17 additions & 2 deletions packages/modules/consumers/lambda_/lambda_/consumer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
from pymodbus.constants import Endian
import logging

from modules.common.abstract_consumer import CurrentValues
from modules.common.abstract_device import DeviceDescriptor
from modules.common.component_state import ConsumerState
from modules.common.component_type import ComponentType
Expand All @@ -24,25 +26,38 @@ def initializer():
def error_handler() -> None:
initializer()

def send_values(values: CurrentValues) -> None:
# Sendet die aktuelle EVU-Leistung an den Lambda E-Manager (Modbus-Protokoll 1.0,
# Kap. 3.2 und 4.3, Reg 102). Der E-Manager muss zusätzlich einmalig auf der
# Wärmepumpe selbst auf Datenquelle "Modbus Client" umgestellt werden (Kap. 4.3),
# das kann openWB nicht per Modbus setzen. Wird vom Core automatisch aufgerufen,
# solange dieser Verbraucher als ConsumerUsage.SELF_CONTROLLED konfiguriert ist.
client.write_register(102, values.evu_power, wordorder=Endian.Little, unit=config.configuration.modbus_id)

def update() -> ConsumerState:
power = client.read_holding_registers(103, ModbusDataType.INT_16, unit=config.configuration.modbus_id)
power = client.read_holding_registers(
103, ModbusDataType.INT_16, unit=config.configuration.modbus_id)
imported, exported = sim_counter.sim_count(power)

return ConsumerState(
power=power,
imported=imported,
exported=exported
)

def set_limit(power_limit: float) -> None:
# Schreibt den von openWB berechneten Überschuss in Register 102 (E-Manager Bezug/Einspeisung).
# Keine echte Leistungsvorgabe - die Wärmepumpe regelt weiterhin selbst anhand dieses Wertes.
client.write_register(102,
max(power_limit * config.configuration.sign, 0),
max(power_limit, 0),
wordorder=Endian.Little,
unit=config.configuration.modbus_id)

return ConfigurableConsumer(consumer_config=config,
initializer=initializer,
error_handler=error_handler,
update=update,
send_values=send_values,
set_power_limit=set_limit,)


Expand Down
Empty file.
Empty file.
36 changes: 36 additions & 0 deletions packages/modules/consumers/ovum/ovum/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Optional, Tuple

from control.consumer.consumer_data import ConsumerUsage
from helpermodules.auto_str import auto_str
from modules.common.consumer_setup import ConsumerSetup
from ..vendor import vendor_descriptor


@auto_str
class OvumConfiguration:
def __init__(self,
ip_address: Optional[str] = None,
port: Optional[int] = 502,
modbus_id: Optional[int] = 1):
self.ip_address = ip_address
self.port = port
self.modbus_id = modbus_id


@auto_str
class Ovum(ConsumerSetup[OvumConfiguration]):
def __init__(self,
name: str = "OVUM Wärmepumpe (CubeSpeicher/MPlus)",
type: str = "ovum",
id: int = 0,
configuration: OvumConfiguration = None,
# OVUM unterstützt eine echte Leistungsvorgabe (SUSPENDABLE_TUNABLE),
# Eigenregelung anhand der Systemwerte (SELF_CONTROLLED) sowie
# SG-Ready-Ein-/Ausschalten (SUSPENDABLE_ONOFF)
usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE,
ConsumerUsage.SELF_CONTROLLED,
ConsumerUsage.SUSPENDABLE_ONOFF,
ConsumerUsage.METER_ONLY),
**kwargs) -> None:
super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory(
).type, configuration=configuration or OvumConfiguration(), usage=usage, **kwargs)
127 changes: 127 additions & 0 deletions packages/modules/consumers/ovum/ovum/consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
from typing import Optional
import logging

from modules.common.abstract_consumer import CurrentValues
from modules.common.abstract_device import DeviceDescriptor
from modules.common.component_state import ConsumerState
from modules.common.component_type import ComponentType
from modules.common.configurable_consumer import ConfigurableConsumer
from modules.common.modbus import ModbusDataType, ModbusTcpClient_
from modules.common.simcount._simcounter import SimCounterConsumer
from modules.consumers.ovum.ovum.config import Ovum

log = logging.getLogger(__name__)


def create_consumer(config: Ovum):
client = None
sim_counter = None

def initializer():
nonlocal client, sim_counter
client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port)
sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER)

def error_handler() -> None:
initializer()

def send_values(values: CurrentValues) -> None:
# ConsumerUsage.SELF_CONTROLLED: aktiviert Ovum-eigene PV-Watch-Regelung (Reg 709 = 0)
# und schreibt informativ den aktuellen Netzsaldo in Reg 710, damit OVUM-eigene
# Anzeigen (PV-Watch Messwert Reg 456, Autarkiegrad Reg 450) einen sinnvollen Wert
# zeigen. UNVERIFIZIERT, ob Reg 710 dabei tatsächlich nur angezeigt und nicht doch in
# interne Berechnungen einfließt - vor Dauerbetrieb an echter Hardware prüfen, ob
# Reg 708 dadurch vom erwarteten Eigenregelungs-Verhalten abweicht. Vor jedem Schreiben
# wird der Ist-Zustand gelesen, damit unnötige Moduswechsel-Befehle vermieden werden.
unit = config.configuration.modbus_id

mode = client.read_holding_registers(709, ModbusDataType.INT_16, unit=unit)
if mode != 0:
client.write_register(709, 0, ModbusDataType.INT_16, unit=unit)
log.debug("Wärmepumpe in Eigenregelung, Ovum-eigene PV-Watch-Regelung aktivieren.")
client.write_register(710, int(values.evu_power / 10), ModbusDataType.INT_16, unit=unit)

def update() -> ConsumerState:
# Reg 708: elektrische Leistungsaufnahme Wärmepumpe [kW]*100 -> Watt = Rohwert * 10
power = client.read_holding_registers(708, ModbusDataType.INT_16,
unit=config.configuration.modbus_id) * 10
imported, exported = sim_counter.sim_count(power)

return ConsumerState(
power=power,
imported=imported,
exported=exported
)

def set_limit(power_limit: Optional[float]) -> None:
# ConsumerUsage.SUSPENDABLE_TUNABLE: externe Leistungsvorgabe. Ist-Modus wird vor dem
# Schreiben gelesen, um unnötige Moduswechsel-Befehle zu vermeiden.
unit = config.configuration.modbus_id
mode = client.read_holding_registers(709, ModbusDataType.INT_16, unit=unit)

if power_limit is None:
# Keine Leistungsvorgabe gefordert, Ovum-eigene PV-Watch-Regelung übernimmt wieder
if mode != 0:
client.write_register(709, 0, ModbusDataType.INT_16, unit=unit)
log.debug("Keine Leistungsvorgabe, Ovum-eigene PV-Watch-Regelung aktivieren.")
else:
# Leistungsvorgabe gefordert: externe Steuerung aktivieren (falls noch nicht aktiv)
# und den absoluten Ziel-Leistungsbezug in Reg 711 schreiben. power_limit ist bereits
# von openWB berechnet und berücksichtigt die aktuelle WP-Leistungsaufnahme - bewusst
# NICHT Reg 710 (PV-Watch TCP, roher Netzsaldo) als Regelgröße verwendet, da das laut
# evcc-Issue #28372 einen Regelkreis-Konflikt erzeugt (WP-eigene Leistung ist im
# Saldo bereits enthalten, WP-Regler und openWB-Regler können gegeneinander
# aufschaukeln).
if mode != 2:
client.write_register(709, 2, ModbusDataType.INT_16, unit=unit)
log.debug("Leistungsvorgabe gefordert, externe Steuerung (Reg 711) aktivieren.")
raw_value = int(max(power_limit, 0) / 10) # W -> [kW]*100
client.write_register(711, raw_value, ModbusDataType.INT_16, unit=unit)

def switch_on() -> None:
# ConsumerUsage.SUSPENDABLE_ONOFF: SG-Ready "Empfehlung" (Kontakt 1 = 0, Kontakt 2 = 1) -
# die WP darf eigene Prioritäten (Legionellenschutz, Mindestlaufzeiten) weiter
# berücksichtigen, wird aber zum Einschalten angeregt. Ist-Zustand wird vor jedem
# Schreiben gelesen, um unnötige Befehle zu vermeiden.
unit = config.configuration.modbus_id

sg_ready_mode = client.read_holding_registers(1250, ModbusDataType.INT_16, unit=unit)
if sg_ready_mode != 1:
client.write_register(1250, 1, ModbusDataType.INT_16, unit=unit) # SG-Ready per TCP

contact_1 = client.read_holding_registers(1251, ModbusDataType.INT_16, unit=unit)
if contact_1 != 0:
client.write_register(1251, 0, ModbusDataType.INT_16, unit=unit)

contact_2 = client.read_holding_registers(1252, ModbusDataType.INT_16, unit=unit)
if contact_2 != 1:
client.write_register(1252, 1, ModbusDataType.INT_16, unit=unit)

def switch_off() -> None:
# ConsumerUsage.SUSPENDABLE_ONOFF: SG-Ready Normalbetrieb (Kontakt 1 = 0, Kontakt 2 = 0)
unit = config.configuration.modbus_id

sg_ready_mode = client.read_holding_registers(1250, ModbusDataType.INT_16, unit=unit)
if sg_ready_mode != 1:
client.write_register(1250, 1, ModbusDataType.INT_16, unit=unit) # SG-Ready per TCP

contact_1 = client.read_holding_registers(1251, ModbusDataType.INT_16, unit=unit)
if contact_1 != 0:
client.write_register(1251, 0, ModbusDataType.INT_16, unit=unit)

contact_2 = client.read_holding_registers(1252, ModbusDataType.INT_16, unit=unit)
if contact_2 != 0:
client.write_register(1252, 0, ModbusDataType.INT_16, unit=unit)

return ConfigurableConsumer(consumer_config=config,
initializer=initializer,
error_handler=error_handler,
update=update,
send_values=send_values,
set_power_limit=set_limit,
switch_on=switch_on,
switch_off=switch_off)


device_descriptor = DeviceDescriptor(configuration_factory=Ovum)
14 changes: 14 additions & 0 deletions packages/modules/consumers/ovum/vendor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from pathlib import Path

from modules.common.abstract_device import DeviceDescriptor
from modules.devices.vendors import VendorGroup


class Vendor:
def __init__(self):
self.type = Path(__file__).parent.name
self.vendor = "Ovum"
self.group = VendorGroup.VENDORS.value


vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor)