diff --git a/packages/modules/consumers/idm/idm/config.py b/packages/modules/consumers/idm/idm/config.py index baf3c7bcc0..e417129637 100644 --- a/packages/modules/consumers/idm/idm/config.py +++ b/packages/modules/consumers/idm/idm/config.py @@ -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( diff --git a/packages/modules/consumers/idm/idm/consumer.py b/packages/modules/consumers/idm/idm/consumer.py index 792b412626..d3a6b27d38 100644 --- a/packages/modules/consumers/idm/idm/consumer.py +++ b/packages/modules/consumers/idm/idm/consumer.py @@ -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 @@ -24,15 +26,45 @@ 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, @@ -40,26 +72,19 @@ def update() -> ConsumerState: ) 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 - # - # - # 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) diff --git a/packages/modules/consumers/lambda_/lambda_/config.py b/packages/modules/consumers/lambda_/lambda_/config.py index 01b02a5970..c02249395f 100644 --- a/packages/modules/consumers/lambda_/lambda_/config.py +++ b/packages/modules/consumers/lambda_/lambda_/config.py @@ -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 @@ -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( diff --git a/packages/modules/consumers/lambda_/lambda_/consumer.py b/packages/modules/consumers/lambda_/lambda_/consumer.py index 83063bbb0b..d31a3f9375 100644 --- a/packages/modules/consumers/lambda_/lambda_/consumer.py +++ b/packages/modules/consumers/lambda_/lambda_/consumer.py @@ -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 @@ -24,9 +26,19 @@ 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, @@ -34,8 +46,10 @@ def update() -> ConsumerState: ) 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) @@ -43,6 +57,7 @@ def set_limit(power_limit: float) -> None: initializer=initializer, error_handler=error_handler, update=update, + send_values=send_values, set_power_limit=set_limit,) diff --git a/packages/modules/consumers/ovum/__init__.py b/packages/modules/consumers/ovum/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ovum/ovum/__init__.py b/packages/modules/consumers/ovum/ovum/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ovum/ovum/config.py b/packages/modules/consumers/ovum/ovum/config.py new file mode 100644 index 0000000000..d37f948158 --- /dev/null +++ b/packages/modules/consumers/ovum/ovum/config.py @@ -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) diff --git a/packages/modules/consumers/ovum/ovum/consumer.py b/packages/modules/consumers/ovum/ovum/consumer.py new file mode 100644 index 0000000000..48b6e9c5ce --- /dev/null +++ b/packages/modules/consumers/ovum/ovum/consumer.py @@ -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) diff --git a/packages/modules/consumers/ovum/vendor.py b/packages/modules/consumers/ovum/vendor.py new file mode 100644 index 0000000000..0523a05cf4 --- /dev/null +++ b/packages/modules/consumers/ovum/vendor.py @@ -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)