diff --git a/packages/modules/consumers/daikin/__init__.py b/packages/modules/consumers/daikin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/daikin/daikin_homehub_air2air/__init__.py b/packages/modules/consumers/daikin/daikin_homehub_air2air/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/daikin/daikin_homehub_air2air/config.py b/packages/modules/consumers/daikin/daikin_homehub_air2air/config.py new file mode 100644 index 0000000000..579b42552d --- /dev/null +++ b/packages/modules/consumers/daikin/daikin_homehub_air2air/config.py @@ -0,0 +1,33 @@ +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 DaikinConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Daikin(ConsumerSetup[DaikinConfiguration]): + def __init__(self, + name: str = "Daikin Air2Air (HomeHub EKRHH)", + type: str = "daikin_homehub_air2air", + id: int = 0, + configuration: Optional[DaikinConfiguration] = None, + # Register gegen das offizielle EKRHH Installer Reference Guide (4PDE744838, + # Kap. 10 "Modbus TCP/IP oder RTU für Luft-zu-Luft-Wärmepumpe") geprüft. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or DaikinConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/daikin/daikin_homehub_air2air/consumer.py b/packages/modules/consumers/daikin/daikin_homehub_air2air/consumer.py new file mode 100644 index 0000000000..601d4d1cc8 --- /dev/null +++ b/packages/modules/consumers/daikin/daikin_homehub_air2air/consumer.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.consumers.daikin.daikin_homehub_air2air.config import Daikin + +log = logging.getLogger(__name__) + + +def create_consumer(config: Daikin): + client: Optional[ModbusTcpClient_] = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def switch_on() -> None: + # Reg 1000: Smart-Grid-Modus, 2 = Empfehlung ein (Boost) + client.write_register(1000, 2, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + # Reg 1000: Smart-Grid-Modus, 0 = freier Betrieb (Normal) + client.write_register(1000, 0, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 1001: Leistungsgrenze für Bedarfssteuerung, Pow16 (raw = kW * 100), lt. offiziellem + # Daikin-Referenzhandbuch (EKRHH Installer Reference Guide, Kap. 10.2.1/10.2). Bereich 0..20kW. + # Die Einheit setzt intern trotzdem eine Untergrenze von 40% der Nennleistung der + # Außeneinheit um - Werte darunter reduzieren die Leistung nicht weiter; echtes Abschalten + # läuft über switch_off() (Reg 1000, Zwangsabschaltung). + value = 0 if power_limit is None else round(max(power_limit, 0) / 10) + client.write_register(1001, value, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + # Keine update()-Funktion: die HomeHub-Anbindung stellt für Air2Air-Geräte keine + # Leistungsmessung zur Verfügung. Wer den Verbrauch erfassen will, braucht eine separate + # Leistungsmessung (zB Zwischenzähler/Smart-Plug), die diesem Verbraucher zugeordnet wird. + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + switch_on=switch_on, + switch_off=switch_off, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=Daikin) diff --git a/packages/modules/consumers/daikin/daikin_homehub_altherma/__init__.py b/packages/modules/consumers/daikin/daikin_homehub_altherma/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/daikin/daikin_homehub_altherma/config.py b/packages/modules/consumers/daikin/daikin_homehub_altherma/config.py new file mode 100644 index 0000000000..1a60ecb5c7 --- /dev/null +++ b/packages/modules/consumers/daikin/daikin_homehub_altherma/config.py @@ -0,0 +1,34 @@ +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 DaikinAlthermaConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class DaikinAltherma(ConsumerSetup[DaikinAlthermaConfiguration]): + def __init__(self, + name: str = "Daikin Altherma (HomeHub)", + type: str = "daikin_homehub_altherma", + id: int = 0, + configuration: Optional[DaikinAlthermaConfiguration] = None, + # Register gegen das offizielle EKRHH Installer Reference Guide (4PDE744838, + # Kap. 9 "Modbus TCP/IP oder RTU für Daikin Altherma") geprüft. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or DaikinAlthermaConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/daikin/daikin_homehub_altherma/consumer.py b/packages/modules/consumers/daikin/daikin_homehub_altherma/consumer.py new file mode 100644 index 0000000000..31ac5ebe9b --- /dev/null +++ b/packages/modules/consumers/daikin/daikin_homehub_altherma/consumer.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +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, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.daikin.daikin_homehub_altherma.config import DaikinAltherma + +log = logging.getLogger(__name__) + + +def create_consumer(config: DaikinAltherma): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def update() -> ConsumerState: + # Reg 51: Stromverbrauch Wärmepumpe, Pow16 (raw = kW * 100) + power = client.read_input_registers( + 50, 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 switch_on() -> None: + # Reg 56: Smart-Grid-Modus, 2 = Empfehlung ein (Boost) + client.write_register(55, 2, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + # Reg 56: Smart-Grid-Modus, 0 = freier Betrieb (Normal) + client.write_register(55, 0, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 58: Allgemeine Leistungsbegrenzung, Pow16 (raw = kW * 100), lt. offiziellem + # Daikin-Referenzhandbuch (EKRHH Installer Reference Guide, Kap. 9.2.1/9.3). Bereich 0..20kW, + # gilt laut Referenzhandbuch unabhängig vom Smart-Grid-Modus (anders als Reg 57, das nur + # während der Pufferung im Modus "Empfehlung ein" greift). + # Die Einheit setzt intern trotzdem eine Untergrenze von 4,5kW für 15 Minuten nach + # Verdichterstart durch - Werte darunter reduzieren die Leistung in dieser Zeit nicht weiter; + # echtes Abschalten läuft über switch_off() (Reg 56, Zwangsabschaltung). + value = 0 if power_limit is None else round(max(power_limit, 0) / 10) + client.write_register(57, value, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + switch_on=switch_on, + switch_off=switch_off, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=DaikinAltherma) diff --git a/packages/modules/consumers/daikin/vendor.py b/packages/modules/consumers/daikin/vendor.py new file mode 100644 index 0000000000..1e0201b718 --- /dev/null +++ b/packages/modules/consumers/daikin/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 = "Daikin" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/ego/__init__.py b/packages/modules/consumers/ego/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ego/ego/__init__.py b/packages/modules/consumers/ego/ego/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ego/ego/config.py b/packages/modules/consumers/ego/ego/config.py new file mode 100644 index 0000000000..c0f2c6ea73 --- /dev/null +++ b/packages/modules/consumers/ego/ego/config.py @@ -0,0 +1,32 @@ +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 EgoConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: int = 502, + modbus_id: int = 247): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Ego(ConsumerSetup[EgoConfiguration]): + def __init__(self, + name: str = "E.G.O. Smart Heater", + type: str = "ego", + id: int = 0, + configuration: Optional[EgoConfiguration] = 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( + ).type, configuration=configuration or EgoConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/ego/ego/consumer.py b/packages/modules/consumers/ego/ego/consumer.py new file mode 100644 index 0000000000..cf84e0a683 --- /dev/null +++ b/packages/modules/consumers/ego/ego/consumer.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +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, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.ego.ego.config import Ego + +log = logging.getLogger(__name__) + + +def create_consumer(config: Ego): + client: Optional[ModbusTcpClient_] = None + sim_counter: Optional[SimCounterConsumer] = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def send_values(values: CurrentValues) -> None: + unit = config.configuration.modbus_id + # Reg 4864 = -1: Heizstab nutzt selbst Reg 4865 (HomeTotalPower) zur Regelung. + client.write_register(4864, -1, data_type=ModbusDataType.INT_16, unit=unit) + # Reg 4865/4866: Netzsaldo, negativ = Überschuss (gleiche Konvention wie evu_power). + client.write_register(4865, round(values.evu_power), data_type=ModbusDataType.INT_32, unit=unit) + + def update() -> ConsumerState: + unit = config.configuration.modbus_id + status = client.read_holding_registers(5128, ModbusDataType.UINT_16, unit=unit) + power = 0 + if status & 0x01: + power += client.read_holding_registers(4096, ModbusDataType.UINT_16, unit=unit) + if status & 0x02: + power += client.read_holding_registers(4128, ModbusDataType.UINT_16, unit=unit) + if status & 0x04: + power += client.read_holding_registers(4160, ModbusDataType.UINT_16, unit=unit) + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # Reg 4864: gewünschte Leistung [W], bestmöglich auf die 500/1000/2000W-Relais verteilt. + value = 0 if power_limit is None else max(round(power_limit), 0) + client.write_register(4864, value, data_type=ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + send_values=send_values, + set_power_limit=set_power_limit) + + +device_descriptor = DeviceDescriptor(configuration_factory=Ego) diff --git a/packages/modules/consumers/ego/vendor.py b/packages/modules/consumers/ego/vendor.py new file mode 100644 index 0000000000..c6114229e0 --- /dev/null +++ b/packages/modules/consumers/ego/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 = "E.G.O." + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/ems_esp/__init__.py b/packages/modules/consumers/ems_esp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ems_esp/ems_esp/__init__.py b/packages/modules/consumers/ems_esp/ems_esp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ems_esp/ems_esp/config.py b/packages/modules/consumers/ems_esp/ems_esp/config.py new file mode 100644 index 0000000000..ba89be03e8 --- /dev/null +++ b/packages/modules/consumers/ems_esp/ems_esp/config.py @@ -0,0 +1,33 @@ +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 EmsEspConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + token: Optional[str] = None): + self.ip_address = ip_address + self.token = token + + +@auto_str +class EmsEsp(ConsumerSetup[EmsEspConfiguration]): + def __init__(self, + name: str = "EMS-ESP Wärmepumpe (Bosch/Buderus/Junkers)", + type: str = "ems_esp", + id: int = 0, + configuration: Optional[EmsEspConfiguration] = None, + # kein SELF_CONTROLLED: die energyPriceEl/-PV-Entities steuern laut + # EMS-ESP32-Quellcode die Wärmequellen-Auswahl bei Hybridanlagen, nicht die + # Kompressorleistung einer reinen Wärmepumpe. + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or EmsEspConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/ems_esp/ems_esp/consumer.py b/packages/modules/consumers/ems_esp/ems_esp/consumer.py new file mode 100644 index 0000000000..802cbff11c --- /dev/null +++ b/packages/modules/consumers/ems_esp/ems_esp/consumer.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from modules.common import req +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, SetLimitData +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.ems_esp.ems_esp.config import EmsEsp + +log = logging.getLogger(__name__) + + +def create_consumer(config: EmsEsp): + session: Optional[req.CustomSession] = None + sim_counter: Optional[SimCounterConsumer] = None + base_url: str = "" + headers: dict = {} + + def initializer(): + nonlocal session, sim_counter, base_url, headers + session = req.get_http_session() + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + # "heatpump" ist der EMS-ESP-Gerätename für eine reine Wärmepumpe; Hybridanlagen mit + # Gas-/Ölkessel laufen zB unter "boiler" (https://emsesp.org/All-Entities). + base_url = f"http://{config.configuration.ip_address}/api/heatpump" + headers = {"Content-Type": "application/json", + "Authorization": f"Bearer {config.configuration.token}"} + + def _write_entity(entity: str, value) -> None: + session.post(f"{base_url}/{entity}", json={"value": value}, headers=headers) + + def switch_on() -> None: + _write_entity("hpin4opt", "1xxxxxxxxxxx") + + def switch_off() -> None: + _write_entity("hpin4opt", "0xxxxxxxxxxx") + _write_entity("hpin1opt", "0xxxxxxxxxxxxxx") + + def update() -> ConsumerState: + # fehlender Schlüssel (zB falsches Modell) kommt als 200 OK zurück, nicht als HTTP-Fehler - + # ohne diese Prüfung würde das unbemerkt als 0W durchgehen. + values = session.get(base_url, headers=headers).json() + if "hpcurrpower" not in values: + raise ValueError( + f"Entity 'hpcurrpower' nicht in der Antwort von {base_url} enthalten - " + f"vorhandene Werte: {list(values.keys())}") + power = float(values["hpcurrpower"]) + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # pvmaxcomp wirkt nur bei aktivem SG-Ready-Boost (Eingang 4); bei SUSPENDABLE_TUNABLE ruft + # der Core nie switch_on, daher hier selbst mitschalten. Wirksamkeit von pvmaxcomp ist + # modellabhängig (EMS-ESP32 Diskussion #2062: funktioniert auf Buderus WLW186i-12E, nicht + # auf Bosch Compress CS7000iAW). + if power_limit is None: + _write_entity("hpin4opt", "0xxxxxxxxxxx") + _write_entity("hpin1opt", "0xxxxxxxxxxxxxx") + else: + _write_entity("hpin4opt", "1xxxxxxxxxxx") + _write_entity("pvmaxcomp", round(max(power_limit, 0) / 1000, 1)) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=EmsEsp) diff --git a/packages/modules/consumers/ems_esp/vendor.py b/packages/modules/consumers/ems_esp/vendor.py new file mode 100644 index 0000000000..aae2c507b2 --- /dev/null +++ b/packages/modules/consumers/ems_esp/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 = "EMS-ESP" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor)