diff --git a/.gitignore b/.gitignore index 296f7ced8d..57189a06cb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .vscode/* __pycache__/ node_modules/ +data/config/eebus/certs/* data/log/* data/charge_log/* data/daily_log/* diff --git a/packages/control/io_device.py b/packages/control/io_device.py index c045a80c92..c752fd3e87 100644 --- a/packages/control/io_device.py +++ b/packages/control/io_device.py @@ -4,10 +4,12 @@ from control.limiting_value import LimitingValue from helpermodules.constants import NO_ERROR from modules.common.utils.component_parser import get_io_name_by_id -from modules.io_actions.controllable_consumers.dimming.api import Dimming +from modules.io_actions.controllable_consumers.dimming.api_eebus import DimmingEebus +from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo from modules.io_actions.controllable_consumers.dimming_direct_control.api import DimmingDirectControl from modules.io_actions.controllable_consumers.ripple_control_receiver.api import RippleControlReceiver -from modules.io_actions.generator_systems.stepwise_control.api import StepwiseControl +from modules.io_actions.generator_systems.stepwise_control.api_eebus import StepwiseControlEebus +from modules.io_actions.generator_systems.stepwise_control.api_io import StepwiseControlIo @dataclass @@ -54,7 +56,8 @@ def __init__(self, num: Union[int, str]): class IoActions: def __init__(self): - self.actions: Dict[int, Union[Dimming, DimmingDirectControl, RippleControlReceiver, StepwiseControl]] = {} + self.actions: Dict[int, Union[DimmingIo, DimmingEebus, DimmingDirectControl, + RippleControlReceiver, StepwiseControlEebus, StepwiseControlIo]] = {} def setup(self): for action in self.actions.values(): @@ -66,7 +69,7 @@ def _check_fault_state_io_device(self, io_device: int) -> None: def dimming_get_import_power_left(self, device: Dict) -> Optional[float]: for action in self.actions.values(): - if isinstance(action, Dimming): + if isinstance(action, (DimmingIo, DimmingEebus)): for d in action.config.configuration.devices: if device == d: self._check_fault_state_io_device(action.config.configuration.io_device) @@ -76,7 +79,7 @@ def dimming_get_import_power_left(self, device: Dict) -> Optional[float]: def dimming_set_import_power_left(self, device: Dict, used_power: float) -> Optional[float]: for action in self.actions.values(): - if isinstance(action, Dimming): + if isinstance(action, (DimmingIo, DimmingEebus)): for d in action.config.configuration.devices: if d == device: return action.dimming_set_import_power_left(used_power) @@ -103,7 +106,7 @@ def ripple_control_receiver(self, device: Dict) -> float: def stepwise_control(self, device_id: int) -> Optional[float]: for action in self.actions.values(): - if isinstance(action, StepwiseControl): + if isinstance(action, (StepwiseControlEebus, StepwiseControlIo)): if device_id in [component["id"] for component in action.config.configuration.devices]: self._check_fault_state_io_device(action.config.configuration.io_device) return action.control_stepwise() diff --git a/packages/control/process.py b/packages/control/process.py index 0ff08fc038..ebea5b5366 100644 --- a/packages/control/process.py +++ b/packages/control/process.py @@ -13,9 +13,9 @@ from helpermodules.utils._thread_handler import joined_thread_handler from modules.common.abstract_io import AbstractIoDevice from modules.common.fault_state_level import FaultStateLevel -from modules.io_actions.controllable_consumers.dimming.api import Dimming +from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo from modules.io_actions.controllable_consumers.dimming_direct_control.api import DimmingDirectControl -from modules.io_actions.generator_systems.stepwise_control.api import StepwiseControl +from modules.io_actions.generator_systems.stepwise_control.api_io import StepwiseControlIo log = logging.getLogger(__name__) @@ -77,13 +77,13 @@ def process_algorithm_results(self) -> None: data.data.io_states[f"io_states{d['id']}"].data.set.digital_output[d["digital_output"]] = ( action.dimming_via_direct_control() is None # active output (True) if no dimming ) - if isinstance(action, Dimming): + if isinstance(action, DimmingIo): for d in action.config.configuration.devices: if d["type"] == "io": data.data.io_states[f"io_states{d['id']}"].data.set.digital_output[d["digital_output"]] = ( not action.dimming_active() # active output (True) if no dimming ) - if isinstance(action, StepwiseControl): + if isinstance(action, StepwiseControlIo): # check if passthrough is enabled if action.config.configuration.passthrough_enabled: # find output pattern by value @@ -99,7 +99,8 @@ def process_algorithm_results(self) -> None: modules_threads.append( Thread( target=io.write, - args=(None, data.data.io_states[f"io_states{io.config.id}"].data.set.digital_output,), + args=(data.data.io_states[f"io_states{io.config.id}"].data.set.analog_output, + data.data.io_states[f"io_states{io.config.id}"].data.set.digital_output,), name=f"set output io{io.config.id}")) if modules_threads: joined_thread_handler(modules_threads, 3) diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index ba43da1506..345d8bc8d0 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -23,6 +23,7 @@ from helpermodules.utils.run_command import run_command # ToDo: move to module commands if implemented from modules.backup_clouds.onedrive.api import generateMSALAuthCode, retrieveMSALTokens +from modules.io_devices.eebus.api import create_pub_cert_ski from helpermodules.broker import BrokerClient from helpermodules.data_migration.data_migration import MigrateData @@ -924,6 +925,9 @@ def retrieveMSALTokens(self, connection_id: str, payload: dict) -> None: result = retrieveMSALTokens(cloud_backup_config.config) pub_user_message(payload, connection_id, result["message"], result["MessageType"]) + def createEebusCert(self, connection_id: str, payload: dict) -> None: + create_pub_cert_ski(payload["data"]["io_device"]) + def factoryReset(self, connection_id: str, payload: dict) -> None: Path(Path(__file__).resolve().parents[2] / 'data' / 'restore' / 'factory_reset').touch() pub_user_message(payload, connection_id, diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index f26e12339e..ecc14e8dc0 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -132,7 +132,6 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int): ("openWB/general/#", 2), ("openWB/graph/#", 2), ("openWB/internal_io/#", 2), - ("openWB/io/#", 2), ("openWB/optional/#", 2), ("openWB/counter/#", 2), ("openWB/command/command_completed", 2), @@ -148,6 +147,7 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int): ("openWB/system/device/+/config", 2), ("openWB/system/io/#", 2), ("openWB/LegacySmartHome/Status/wattnichtHaus", 2), + ("openWB/io/#", 2), ]) self.processing_counter.add_task() Pub().pub("openWB/system/subdata_initialized", True) @@ -677,7 +677,8 @@ def process_io_topic(self, var: Dict[str, Union[io_device.IoActions, io_device.I mod = importlib.import_module( f".io_actions.{payload['group']}.{payload['type']}.api", "modules") config = dataclass_from_dict(mod.device_descriptor.configuration_factory, payload) - var.actions[f"io_action{index}"] = mod.create_action(config) + var.actions[f"io_action{index}"] = mod.create_action( + config, self.system_data[f"io{config.configuration.io_device}"].config.type) elif re.search("/io/action/[0-9]+/timestamp", msg.topic) is not None: index = get_index(msg.topic) self.set_json_payload_class(var.actions[f"io_action{index}"], msg) diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index f436d90769..a65ff63018 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -2,6 +2,7 @@ """ import logging import datetime +import re from typing import List, Optional, Tuple, TypeVar, Union from helpermodules.utils.error_handling import ImportErrorContext @@ -332,3 +333,27 @@ def convert_timestamp_delta_to_time_string(timestamp: int, delta: int) -> str: def convert_to_timestamp(timestring: str) -> int: return int(datetime.datetime.fromisoformat(timestring).timestamp()) + + +def parse_iso8601_duration(duration: str) -> float: + """ + Parst eine ISO-8601 Duration wie 'PT3723S', 'P1DT2H30M', etc. + Gibt ein timedelta zurück. + """ + pattern = re.compile( + r'P' # beginnt immer mit P + r'(?:(?P\d+)D)?' # Tage + r'(?:T' # Zeit-Teil beginnt mit T + r'(?:(?P\d+)H)?' # Stunden + r'(?:(?P\d+)M)?' # Minuten + r'(?:(?P\d+)S)?' # Sekunden + r')?$' + ) + + match = pattern.fullmatch(duration) + if not match: + raise ValueError(f"Ungültiges ISO-8601 Duration Format: {duration}") + + parts = {name: int(val) if val else 0 for name, val in match.groupdict().items()} + return datetime.timedelta(days=parts["days"], hours=parts["hours"], + minutes=parts["minutes"], seconds=parts["seconds"]).total_seconds() diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 2b072475a5..8650124c05 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -282,10 +282,6 @@ class UpdateConfig: "^openWB/internal_chargepoint/[0-1]/get/rfid$", "^openWB/internal_chargepoint/[0-1]/get/rfid_timestamp$", - "^openWB/io/states/[0-9]+/get/digital_input$", - "^openWB/io/states/[0-9]+/get/analog_input$", - "^openWB/io/states/[0-9]+/set/digital_output$", - "^openWB/io/states/[0-9]+/set/analog_output$", "^openWB/io/action/[0-9]+/config$", "^openWB/io/action/[0-9]+/timestamp$", diff --git a/packages/modules/common/configurable_io.py b/packages/modules/common/configurable_io.py index 981bdfeb7d..8ce075c603 100644 --- a/packages/modules/common/configurable_io.py +++ b/packages/modules/common/configurable_io.py @@ -1,6 +1,7 @@ import logging from typing import Dict, Optional, TypeVar, Generic, Callable, Union +from helpermodules import timecheck from helpermodules.pub import Pub from modules.common import store from modules.common.abstract_io import AbstractIoDevice @@ -19,23 +20,47 @@ def __init__(self, config: T_IO_CONFIG, component_reader: Callable[[], IoState], component_writer: Callable[[Dict[int, Union[float, int]]], Optional[IoState]], - initializer: Callable = lambda: None) -> None: + initializer: Callable = lambda: None, + error_handler: Callable = lambda: None) -> None: self.config = config + self.__error_handler = error_handler self.fault_state = FaultState(ComponentInfo(self.config.id, self.config.name, ComponentType.IO.value)) self.store = store.get_io_value_store(self.config.id) self.set_manual: Dict = {"analog_output": {}, "digital_output": {}} + self.error_timestamp = None with SingleComponentUpdateContext(self.fault_state): self.component_reader = component_reader self.component_writer = component_writer initializer() + def error_handler(self) -> None: + error_timestamp_topic = f"openWB/set/system/device/{self.config.id}/error_timestamp" + if self.error_timestamp is None: + self.error_timestamp = timecheck.create_timestamp() + Pub().pub(error_timestamp_topic, self.error_timestamp) + log.debug( + f"Fehler bei Gerät {self.config.name} aufgetreten, Fehlerzeitstempel: {self.error_timestamp}") + if timecheck.check_timestamp(self.error_timestamp, 60) is False: + try: + self.__error_handler() + except Exception: + log.exception(f"Fehlerbehandlung für Gerät {self.config.name} fehlgeschlagen") + else: + log.debug(f"Fehlerbehandlung für Gerät {self.config.name} wurde durchgeführt.") + + self.error_timestamp = None + Pub().pub(error_timestamp_topic, self.error_timestamp) + def read(self): if hasattr(self, "component_reader"): # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten - with SingleComponentUpdateContext(self.fault_state): - io_state = self.component_reader() - self.store.set(io_state) + try: + with SingleComponentUpdateContext(self.fault_state, reraise=True): + io_state = self.component_reader() + self.store.set(io_state) + except Exception: + self.error_handler() def update_manual_output(self, manual: Dict[str, bool], output: Dict[str, bool], string: str, topic_suffix: str): if len(manual) > 0: @@ -48,12 +73,16 @@ def update_manual_output(self, manual: Dict[str, bool], output: Dict[str, bool], def write(self, analog_output, digital_output): if hasattr(self, "component_writer"): # Wenn beim Initialisieren etwas schief gelaufen ist, ursprüngliche Fehlermeldung beibehalten - with SingleComponentUpdateContext(self.fault_state): - self.update_manual_output(self.set_manual["analog_output"], analog_output, "analoge", "analog_output") - self.update_manual_output(self.set_manual["digital_output"], - digital_output, "digitale", "digital_output") - if ((analog_output and self.store.delegate.state.analog_output != analog_output) or - (digital_output and self.store.delegate.state.digital_output != digital_output)): - io_state = self.component_writer(analog_output, digital_output) - if io_state is not None: - self.store.set(io_state) + try: + with SingleComponentUpdateContext(self.fault_state, update_always=False, reraise=True): + self.update_manual_output(self.set_manual["analog_output"], + analog_output, "analoge", "analog_output") + self.update_manual_output(self.set_manual["digital_output"], + digital_output, "digitale", "digital_output") + if ((analog_output and self.store.delegate.state.analog_output != analog_output) or + (digital_output and self.store.delegate.state.digital_output != digital_output)): + io_state = self.component_writer(analog_output, digital_output) + if io_state is not None: + self.store.set(io_state) + except Exception: + self.error_handler() diff --git a/packages/modules/io_actions/controllable_consumers/dimming/api.py b/packages/modules/io_actions/controllable_consumers/dimming/api.py index 0312bc1945..2f3aa5703e 100644 --- a/packages/modules/io_actions/controllable_consumers/dimming/api.py +++ b/packages/modules/io_actions/controllable_consumers/dimming/api.py @@ -1,97 +1,19 @@ import logging -from control import data -from helpermodules.logger import ModifyLoglevelContext -from helpermodules.pub import Pub -from helpermodules.timecheck import create_timestamp -from dataclass_utils import asdict from modules.common.abstract_device import DeviceDescriptor -from modules.common.abstract_io import AbstractIoAction +from modules.io_actions.controllable_consumers.dimming.api_eebus import DimmingEebus +from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo from modules.io_actions.controllable_consumers.dimming.config import DimmingSetup log = logging.getLogger(__name__) -control_command_log = logging.getLogger("steuve_control_command") -class Dimming(AbstractIoAction): - def __init__(self, config: DimmingSetup): - self.config = config - self.import_power_left = None - for pattern in self.config.configuration.input_pattern: - input_matrix_list = list(pattern["matrix"].items()) - if len(input_matrix_list): - if pattern["value"]: - self.dimming_input, self.dimming_value = input_matrix_list[0] - control_command_log.info(f"Dimmen per EMS: Eingang {self.dimming_input} wird überwacht.") - if pattern["value"] is False: - self.no_dimming_input, self.no_dimming_value = input_matrix_list[0] - else: - control_command_log.warning("Dimmen per EMS: Kein Eingang zum Überwachen konfiguriert.") - - fixed_import_power = 0 - for device in self.config.configuration.devices: - if device["type"] != "cp": - fixed_import_power += 4200 - log.debug(f"Dimmen per EMS: Fest vergebene Mindestleistung: {fixed_import_power}W") - if fixed_import_power != self.config.configuration.fixed_import_power: - self.config.configuration.fixed_import_power = fixed_import_power - Pub().pub(f"openWB/set/io/action/{self.config.id}/config", asdict(self.config)) - - super().__init__() - - def setup(self) -> None: - surplus = data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].calc_raw_surplus() - if surplus > 0: - self.import_power_left = self.config.configuration.max_import_power + surplus - else: - self.import_power_left = self.config.configuration.max_import_power - self.import_power_left -= self.config.configuration.fixed_import_power - - log.debug(f"Dimmen: {self.import_power_left}W inkl. Überschuss") - - with ModifyLoglevelContext(control_command_log, logging.DEBUG): - if data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[ - self.dimming_input] == self.dimming_value: - if self.timestamp is None: - Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp()) - control_command_log.info("Dimmen aktiviert. Leistungswerte vor Ausführung des Steuerbefehls:") - - msg = (f"EVU-Zähler: " - f"{data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].data.get.powers}W") - for device in self.config.configuration.devices: - if device["type"] == "cp": - cp = f"cp{device['id']}" - msg += (f", Ladepunkt {data.data.cp_data[cp].data.config.name}: " - f"{data.data.cp_data[cp].data.get.powers}W") - if device["type"] == "io": - io = f"io{device['id']}" - msg += (f", {data.data.system_data[io].config.name}: " - "Leistung unbekannt") - control_command_log.info(msg) - elif self.timestamp: - Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None) - control_command_log.info("Dimmen deaktiviert.") - - def dimming_get_import_power_left(self) -> None: - if self.dimming_active(): - return self.import_power_left - elif data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[ - self.no_dimming_input] == self.no_dimming_value: - return None - else: - raise Exception("Pattern passt nicht zur Dimmung.") - - def dimming_set_import_power_left(self, used_power: float) -> None: - self.import_power_left -= used_power - log.debug(f"verbleibende Dimm-Leistung: {self.import_power_left}W inkl. Überschuss") - return self.import_power_left - - def dimming_active(self) -> bool: - return data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[ - self.dimming_input] == self.dimming_value - - -def create_action(config: DimmingSetup): - return Dimming(config=config) +def create_action(config: DimmingSetup, parent_device_type: str): + if config.configuration.io_device is None: + log.debug("Dimmen per EMS: Kein IO-Gerät konfiguriert.") + if parent_device_type == "eebus": + return DimmingEebus(config=config) + else: + return DimmingIo(config=config) device_descriptor = DeviceDescriptor(configuration_factory=DimmingSetup) diff --git a/packages/modules/io_actions/controllable_consumers/dimming/api_eebus.py b/packages/modules/io_actions/controllable_consumers/dimming/api_eebus.py new file mode 100644 index 0000000000..1c1dbadc10 --- /dev/null +++ b/packages/modules/io_actions/controllable_consumers/dimming/api_eebus.py @@ -0,0 +1,81 @@ +import logging +from control import data +from helpermodules import timecheck +from helpermodules.logger import ModifyLoglevelContext +from helpermodules.pub import Pub +from helpermodules.timecheck import create_timestamp +from dataclass_utils import asdict +from modules.common.abstract_io import AbstractIoAction +from modules.io_actions.controllable_consumers.dimming.config import DimmingSetup +from modules.io_devices.eebus.config import AnalogInputMapping, DigitalInputMapping + +log = logging.getLogger(__name__) +control_command_log = logging.getLogger("steuve_control_command") + + +class DimmingEebus(AbstractIoAction): + def __init__(self, config: DimmingSetup): + self.config = config + self.import_power_left = None + control_command_log.info("Dimmen per EMS: Steuerbox-Signale werden über EEbus empfangen.") + + fixed_import_power = 0 + for device in self.config.configuration.devices: + if device["type"] != "cp": + fixed_import_power += 4200 + log.debug(f"Dimmen per EMS: Fest vergebene Mindestleistung: {fixed_import_power}W") + if fixed_import_power != self.config.configuration.fixed_import_power: + self.config.configuration.fixed_import_power = fixed_import_power + Pub().pub(f"openWB/set/io/action/{self.config.id}/config", asdict(self.config)) + + super().__init__() + + def setup(self) -> None: + lpc_value = data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.analog_input[AnalogInputMapping.LPC_VALUE.name] + surplus = data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].calc_raw_surplus() + if surplus > 0: + self.import_power_left = lpc_value + surplus + else: + self.import_power_left = lpc_value + self.import_power_left -= self.config.configuration.fixed_import_power + log.debug(f"Dimmen: {self.import_power_left}W inkl. Überschuss") + + with ModifyLoglevelContext(control_command_log, logging.DEBUG): + if self.dimming_active(): + if self.timestamp is None: + Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp()) + control_command_log.info(f"Dimmen aktiviert. Übermittelter LPC-Wert: {lpc_value/1000}kWh. " + "Leistungswerte vor Ausführung des Steuerbefehls:") + + msg = (f"EVU-Zähler: " + f"{data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].data.get.powers}W") + for device in self.config.configuration.devices: + if device["type"] == "cp": + cp = f"cp{device['id']}" + msg += (f", Ladepunkt {data.data.cp_data[cp].data.config.name}: " + f"{data.data.cp_data[cp].data.get.powers}W") + if device["type"] == "io": + io = f"io{device['id']}" + msg += (f", {data.data.system_data[io].config.name}: " + "Leistung unbekannt") + control_command_log.info(msg) + elif self.timestamp: + Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None) + control_command_log.info("Dimmen deaktiviert.") + + def dimming_get_import_power_left(self) -> None: + if self.dimming_active(): + return self.import_power_left + else: + return None + + def dimming_set_import_power_left(self, used_power: float) -> None: + self.import_power_left -= used_power + log.debug(f"verbleibende Dimm-Leistung: {self.import_power_left}W inkl. Überschuss") + return self.import_power_left + + def dimming_active(self) -> bool: + io_get = data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get + return (io_get.digital_input[DigitalInputMapping.LPC_ACTIVE.name] and + io_get.analog_input[AnalogInputMapping.LPC_END_TIME.name] > timecheck.create_timestamp()) diff --git a/packages/modules/io_actions/controllable_consumers/dimming/api_io.py b/packages/modules/io_actions/controllable_consumers/dimming/api_io.py new file mode 100644 index 0000000000..e94b7029f1 --- /dev/null +++ b/packages/modules/io_actions/controllable_consumers/dimming/api_io.py @@ -0,0 +1,90 @@ +import logging + +from control import data +from helpermodules.logger import ModifyLoglevelContext +from helpermodules.pub import Pub +from helpermodules.timecheck import create_timestamp +from dataclass_utils import asdict +from modules.common.abstract_io import AbstractIoAction +from modules.io_actions.controllable_consumers.dimming.config import DimmingSetup + +log = logging.getLogger(__name__) +control_command_log = logging.getLogger("steuve_control_command") + + +class DimmingIo(AbstractIoAction): + def __init__(self, config: DimmingSetup): + self.config = config + self.import_power_left = None + for pattern in self.config.configuration.input_pattern: + input_matrix_list = list(pattern["matrix"].items()) + if len(input_matrix_list): + if pattern["value"]: + self.dimming_input, self.dimming_value = input_matrix_list[0] + control_command_log.info(f"Dimmen per EMS: Eingang {self.dimming_input} wird überwacht.") + if pattern["value"] is False: + self.no_dimming_input, self.no_dimming_value = input_matrix_list[0] + else: + control_command_log.warning("Dimmen per EMS: Kein Eingang zum Überwachen konfiguriert.") + + fixed_import_power = 0 + for device in self.config.configuration.devices: + if device["type"] != "cp": + fixed_import_power += 4200 + log.debug(f"Dimmen per EMS: Fest vergebene Mindestleistung: {fixed_import_power}W") + if fixed_import_power != self.config.configuration.fixed_import_power: + self.config.configuration.fixed_import_power = fixed_import_power + Pub().pub(f"openWB/set/io/action/{self.config.id}/config", asdict(self.config)) + + super().__init__() + + def setup(self) -> None: + surplus = data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].calc_raw_surplus() + if surplus > 0: + self.import_power_left = self.config.configuration.max_import_power + surplus + else: + self.import_power_left = self.config.configuration.max_import_power + self.import_power_left -= self.config.configuration.fixed_import_power + + log.debug(f"Dimmen: {self.import_power_left}W inkl. Überschuss") + + with ModifyLoglevelContext(control_command_log, logging.DEBUG): + if data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[ + self.dimming_input] == self.dimming_value: + if self.timestamp is None: + Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp()) + control_command_log.info("Dimmen aktiviert. Leistungswerte vor Ausführung des Steuerbefehls:") + + msg = (f"EVU-Zähler: " + f"{data.data.counter_data[data.data.counter_all_data.get_evu_counter_str()].data.get.powers}W") + for device in self.config.configuration.devices: + if device["type"] == "cp": + cp = f"cp{device['id']}" + msg += (f", Ladepunkt {data.data.cp_data[cp].data.config.name}: " + f"{data.data.cp_data[cp].data.get.powers}W") + if device["type"] == "io": + io = f"io{device['id']}" + msg += (f", {data.data.system_data[io].config.name}: " + "Leistung unbekannt") + control_command_log.info(msg) + elif self.timestamp: + Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None) + control_command_log.info("Dimmen deaktiviert.") + + def dimming_get_import_power_left(self) -> None: + if self.dimming_active(): + return self.import_power_left + elif data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[ + self.no_dimming_input] == self.no_dimming_value: + return None + else: + raise Exception("Pattern passt nicht zur Dimmung.") + + def dimming_set_import_power_left(self, used_power: float) -> None: + self.import_power_left -= used_power + log.debug(f"verbleibende Dimm-Leistung: {self.import_power_left}W inkl. Überschuss") + return self.import_power_left + + def dimming_active(self) -> bool: + return data.data.io_states[f"io_states{self.config.configuration.io_device}"].data.get.digital_input[ + self.dimming_input] == self.dimming_value diff --git a/packages/modules/io_actions/generator_systems/stepwise_control/api.py b/packages/modules/io_actions/generator_systems/stepwise_control/api.py index 2811ac5b9b..0f555c519a 100644 --- a/packages/modules/io_actions/generator_systems/stepwise_control/api.py +++ b/packages/modules/io_actions/generator_systems/stepwise_control/api.py @@ -1,100 +1,19 @@ import logging -from typing import Optional -from control import data -from helpermodules.logger import ModifyLoglevelContext -from helpermodules.pub import Pub -from helpermodules.timecheck import create_timestamp from modules.common.abstract_device import DeviceDescriptor -from modules.common.abstract_io import AbstractIoAction -from modules.common.utils.component_parser import get_component_name_by_id +from modules.io_actions.generator_systems.stepwise_control.api_eebus import StepwiseControlEebus +from modules.io_actions.generator_systems.stepwise_control.api_io import StepwiseControlIo from modules.io_actions.generator_systems.stepwise_control.config import StepwiseControlSetup -control_command_log = logging.getLogger("steuve_control_command") +log = logging.getLogger(__name__) -class StepwiseControl(AbstractIoAction): - def __init__(self, config: StepwiseControlSetup): - self.config = config - self.__unique_inputs = [] - for pattern in self.config.configuration.input_pattern: - for key in pattern["matrix"].keys(): - if key not in self.__unique_inputs: - self.__unique_inputs.append(key) - assigned_inverters = [ - f"{device['id']}" - for device in self.config.configuration.devices - if device["type"] == "inverter" - ] - assigned_outputs = [ - f"{device['id']}/{device['digital_output']}" - for device in self.config.configuration.devices - if device["type"] == "io" - ] - with ModifyLoglevelContext(control_command_log, logging.DEBUG): - # Log the configuration details - # We cannot use configured names here, as the devices are not yet initialized - # and thus the names are not available. - control_command_log.info( - f"Stufenweise Steuerung von EZA: I/O-Gerät: {self.config.configuration.io_device}, " - f"Überwachte digitale Eingänge: {self.__unique_inputs}, " - f"zugeordnete Erzeugungsanlagen: {assigned_inverters} " - f"zugeordnete IO-Ausgänge: {assigned_outputs} " - "Die Begrenzung muss in den EZA vorgenommen werden!" - ) - super().__init__() - - def setup(self) -> None: - with ModifyLoglevelContext(control_command_log, logging.DEBUG): - digital_input = ( - data.data.io_states[ - f"io_states{self.config.configuration.io_device}" - ].data.get.digital_input - ) - digital_input_prev = data.data.io_states[ - f"io_states{self.config.configuration.io_device}"].data.get.digital_input_prev - changed = len([ - input_name for input_name in self.__unique_inputs - if digital_input[input_name] != digital_input_prev[input_name] - ]) > 0 - - for pattern in self.config.configuration.input_pattern: - for action_input, value in pattern["matrix"].items(): - if digital_input[action_input] != value: - break - else: - # Alle digitalen Eingänge entsprechen dem Pattern - if pattern["value"] != 1: - if changed: - Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp()) - control_command_log.info(f"EZA-Begrenzung mit Wert {int(pattern['value']*100)}% aktiviert.") - for device in self.config.configuration.devices: - if device["type"] == "inverter": - control_command_log.info( - f"Erzeugungsanlage {get_component_name_by_id(device['id'])} " - f"auf {int(pattern['value']*100)}% begrenzt." - ) - break - else: - if changed: - Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None) - control_command_log.info("EZA-Begrenzung aufgehoben.") - - def control_stepwise(self) -> Optional[float]: - for pattern in self.config.configuration.input_pattern: - for digital_input, value in pattern["matrix"].items(): - if data.data.io_states[f"io_states{self.config.configuration.io_device}" - ].data.get.digital_input[digital_input] != value: - break - else: - # Alle digitalen Eingänge entsprechen dem Pattern - return pattern['value'] - else: - # Zustand entspricht keinem Pattern, Leistungsbegrenzung aufheben - return 1 - - -def create_action(config: StepwiseControlSetup): - return StepwiseControl(config=config) +def create_action(config: StepwiseControlSetup, parent_device_type: str): + if config.configuration.io_device is None: + log.debug("Stufenweise Steuerung von EZA: Kein IO-Gerät konfiguriert.") + if parent_device_type == "eebus": + return StepwiseControlEebus(config=config) + else: + return StepwiseControlIo(config=config) device_descriptor = DeviceDescriptor(configuration_factory=StepwiseControlSetup) diff --git a/packages/modules/io_actions/generator_systems/stepwise_control/api_eebus.py b/packages/modules/io_actions/generator_systems/stepwise_control/api_eebus.py new file mode 100644 index 0000000000..280944f547 --- /dev/null +++ b/packages/modules/io_actions/generator_systems/stepwise_control/api_eebus.py @@ -0,0 +1,106 @@ +import logging +from typing import Optional +from control import data +from helpermodules.logger import ModifyLoglevelContext +from helpermodules.pub import Pub +from helpermodules.timecheck import create_timestamp +from modules.common.abstract_device import DeviceDescriptor +from modules.common.abstract_io import AbstractIoAction +from modules.common.utils.component_parser import get_component_name_by_id +from modules.io_actions.generator_systems.stepwise_control.config import StepwiseControlSetup +from modules.io_devices.eebus.config import AnalogInputMapping, DigitalInputMapping + +control_command_log = logging.getLogger("steuve_control_command") +log = logging.getLogger(__name__) + + +class StepwiseControlEebus(AbstractIoAction): + def __init__(self, config: StepwiseControlSetup): + self.config = config + assigned_inverters = [ + f"{device['id']}" + for device in self.config.configuration.devices + if device["type"] == "inverter" + ] + assigned_outputs = [ + f"{device['id']}/{device['digital_output']}" + for device in self.config.configuration.devices + if device["type"] == "io" + ] + with ModifyLoglevelContext(control_command_log, logging.DEBUG): + # Log the configuration details + # We cannot use configured names here, as the devices are not yet initialized + # and thus the names are not available. + control_command_log.info( + f"Stufenweise Steuerung von EZA: I/O-Gerät: {self.config.configuration.io_device}, " + f"Schnittstelle für den Empfang des Steuerbefehls: EEBus, " + f"zugeordnete Erzeugungsanlagen: {assigned_inverters} " + f"zugeordnete IO-Ausgänge: {assigned_outputs} " + "Die Begrenzung muss in den EZA vorgenommen werden!" + ) + super().__init__() + + def setup(self) -> None: + with ModifyLoglevelContext(control_command_log, logging.DEBUG): + self.lpp_value = data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.analog_input[AnalogInputMapping.LPP_VALUE.name] + lpp_value_prev = data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.analog_input_prev[AnalogInputMapping.LPP_VALUE.name] + self.lpp_active = data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.digital_input[DigitalInputMapping.LPP_ACTIVE.name] + lpp_active_prev = data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.digital_input_prev[DigitalInputMapping.LPP_ACTIVE.name] + changed = True if self.lpp_value != lpp_value_prev or self.lpp_active != lpp_active_prev else False + + max_output_inverter = 0 + for inverter in self.config.configuration.devices: + max_output_inverter += data.data.pv_data[f"pv{inverter['id']}"].data.config.max_ac_out + + if self.lpp_active: + try: + self.step = self.lpp_value / max_output_inverter + except ZeroDivisionError: + msg = ("Bitte unter Konfiguration -> Lastmanagement die maximale Ausgangsleistung" + " des Wechselrichters angeben.") + log.exception(msg) + control_command_log.info(msg) + self.step = 0 + for s in [0, 0.25, 0.5, 0.75, 1.0]: + if self.step <= s: + self.step = s + break + + if changed: + Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp()) + control_command_log.info(f"EEBus-Steuerung: LPP-Wert {self.lpp_value} / " + f"max. PV-Leistung {max_output_inverter} = {self.step}") + control_command_log.info(f"EZA-Begrenzung mit Wert {self.step*100}% aktiviert.") + for device in self.config.configuration.devices: + control_command_log.info( + f"Erzeugungsanlage {get_component_name_by_id(device)} " + f"auf {self.step*100}% begrenzt." + ) + else: + if changed: + Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None) + control_command_log.info("EZA-Begrenzung aufgehoben.") + + def control_stepwise(self) -> Optional[float]: + for pattern in self.config.configuration.input_pattern: + for digital_input, value in pattern["matrix"].items(): + if data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.digital_input[digital_input] != value: + break + else: + # Alle digitalen Eingänge entsprechen dem Pattern + return pattern['value'] + else: + # Zustand entspricht keinem Pattern, Leistungsbegrenzung aufheben + return 1 + + +def create_action(config: StepwiseControlSetup): + return StepwiseControlEebus(config=config) + + +device_descriptor = DeviceDescriptor(configuration_factory=StepwiseControlSetup) diff --git a/packages/modules/io_actions/generator_systems/stepwise_control/api_io.py b/packages/modules/io_actions/generator_systems/stepwise_control/api_io.py new file mode 100644 index 0000000000..8d699728f8 --- /dev/null +++ b/packages/modules/io_actions/generator_systems/stepwise_control/api_io.py @@ -0,0 +1,100 @@ +import logging +from typing import Optional +from control import data +from helpermodules.logger import ModifyLoglevelContext +from helpermodules.pub import Pub +from helpermodules.timecheck import create_timestamp +from modules.common.abstract_device import DeviceDescriptor +from modules.common.abstract_io import AbstractIoAction +from modules.common.utils.component_parser import get_component_name_by_id +from modules.io_actions.generator_systems.stepwise_control.config import StepwiseControlSetup + +control_command_log = logging.getLogger("steuve_control_command") + + +class StepwiseControlIo(AbstractIoAction): + def __init__(self, config: StepwiseControlSetup): + self.config = config + self.__unique_inputs = [] + for pattern in self.config.configuration.input_pattern: + for key in pattern["matrix"].keys(): + if key not in self.__unique_inputs: + self.__unique_inputs.append(key) + assigned_inverters = [ + f"{device['id']}" + for device in self.config.configuration.devices + if device["type"] == "inverter" + ] + assigned_outputs = [ + f"{device['id']}/{device['digital_output']}" + for device in self.config.configuration.devices + if device["type"] == "io" + ] + with ModifyLoglevelContext(control_command_log, logging.DEBUG): + # Log the configuration details + # We cannot use configured names here, as the devices are not yet initialized + # and thus the names are not available. + control_command_log.info( + f"Stufenweise Steuerung von EZA: I/O-Gerät: {self.config.configuration.io_device}, " + f"Überwachte digitale Eingänge: {self.__unique_inputs}, " + f"zugeordnete Erzeugungsanlagen: {assigned_inverters} " + f"zugeordnete IO-Ausgänge: {assigned_outputs} " + "Die Begrenzung muss in den EZA vorgenommen werden!" + ) + super().__init__() + + def setup(self) -> None: + with ModifyLoglevelContext(control_command_log, logging.DEBUG): + digital_input = ( + data.data.io_states[ + f"io_states{self.config.configuration.io_device}" + ].data.get.digital_input + ) + digital_input_prev = data.data.io_states[ + f"io_states{self.config.configuration.io_device}"].data.get.digital_input_prev + changed = len([ + input_name for input_name in self.__unique_inputs + if digital_input[input_name] != digital_input_prev[input_name] + ]) > 0 + + for pattern in self.config.configuration.input_pattern: + for action_input, value in pattern["matrix"].items(): + if digital_input[action_input] != value: + break + else: + # Alle digitalen Eingänge entsprechen dem Pattern + if pattern["value"] != 1: + if changed: + Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", create_timestamp()) + control_command_log.info(f"EZA-Begrenzung mit Wert {int(pattern['value']*100)}% aktiviert.") + for device in self.config.configuration.devices: + if device["type"] == "inverter": + control_command_log.info( + f"Erzeugungsanlage {get_component_name_by_id(device['id'])} " + f"auf {int(pattern['value']*100)}% begrenzt." + ) + break + else: + if changed: + Pub().pub(f"openWB/set/io/action/{self.config.id}/timestamp", None) + control_command_log.info("EZA-Begrenzung aufgehoben.") + + def control_stepwise(self) -> Optional[float]: + for pattern in self.config.configuration.input_pattern: + for digital_input, value in pattern["matrix"].items(): + if data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.digital_input[digital_input] != value: + break + else: + # Alle digitalen Eingänge entsprechen dem Pattern + return pattern['value'] + else: + # Zustand entspricht keinem Pattern, Leistungsbegrenzung aufheben + return 1 + + +def create_action(config: StepwiseControlSetup): + return StepwiseControlIo(config=config) + + +device_descriptor = DeviceDescriptor(configuration_factory=StepwiseControlSetup) diff --git a/packages/modules/io_devices/eebus/__init__.py b/packages/modules/io_devices/eebus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/io_devices/eebus/api.py b/packages/modules/io_devices/eebus/api.py new file mode 100644 index 0000000000..2b560f7fa3 --- /dev/null +++ b/packages/modules/io_devices/eebus/api.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +from pathlib import Path +import logging +import subprocess +from threading import Thread + +from control import data +from dataclass_utils._dataclass_asdict import asdict +from helpermodules import timecheck +from helpermodules.broker import BrokerClient +from helpermodules.pub import pub_single +from helpermodules.utils.run_command import run_command +from helpermodules.utils._thread_handler import is_thread_alive, thread_handler +from helpermodules.utils.topic_parser import decode_payload +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import IoState +from modules.common.configurable_io import ConfigurableIo +from modules.io_devices.eebus.config import AnalogInputMapping, CertInfo, DigitalInputMapping, Eebus + +log = logging.getLogger(__name__) +control_command_log = logging.getLogger("steuve_control_command") + +cert_path = f"{Path(__file__).resolve().parents[4]}/data/config/eebus/certs" + +# Fehlercodes des eebus clients +# 1: "FEHLER: Zu wenig Argumente! Erwartet: " +# 2: "FEHLER: Zertifikat oder Key ungültig" +# 4: "FEHLER: Port ungültig" +# 5: "FEHLER: MQTT nicht erreichbar" +# 6: "FEHLER: Keine Verbindung zur Steuerbox (Controlbox) aufgebaut!" +# 7: "FEHLER: Remote SKI leer!" +# 8: "FEHLER: Der entfernte Dienst hat das Vertrauen verweigert." + + +def create_io(config: Eebus): + received_topics = {} + broker = None + thread_exception = None # Shared state für Thread-Exceptions + + def run_eebus(): + def run(): + nonlocal thread_exception + try: + log.debug(f"Starte EEbus-Client für Steuerbox mit ID {config.id} und " + f"SKI {config.configuration.remote_ski}") + subprocess.run( + [f"{Path(__file__).resolve().parents[0]}/eebus_hems_client", + str(config.configuration.port), + config.configuration.remote_ski, + f"{cert_path}/hems-cert-{config.id}.pem", + f"{cert_path}/hems-key-{config.id}.pem", + str(config.id), + f"{Path(__file__).resolve().parents[4]}/ramdisk/eebus_hems_client.log"], + + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + except subprocess.CalledProcessError as e: + if e.returncode == 2: + msg = ("Zertifikat oder Key ungültig. Wenn das Zertifikat abgelaufen ist, bitte in " + "den Einstellungen ein neues Zertifikat generieren und den SKI beim VNB " + "akutalisieren.") + control_command_log.error(msg) + thread_exception = ValueError(msg) + else: + control_command_log.error(f"Fehlercode: {e.returncode}, Fehler: {e.stderr}") + thread_exception = ValueError(f"Fehlercode: {e.returncode}, Fehler: {e.stderr}") + except Exception as e: + control_command_log.error(f"Fehler im EEbus-Client: {e}") + thread_exception = e + thread_handler(Thread( + target=run, + args=(), + name="eebus_binary")) + + def read(): + nonlocal broker + nonlocal received_topics + nonlocal thread_exception + + if is_thread_alive("eebus_binary") is False: + run_eebus() + if thread_exception is not None: + exception_to_raise = thread_exception + thread_exception = None + raise exception_to_raise + broker.start_finite_loop() + log.debug(f"Empfange MQTT Daten für EEBus {config.id}: {received_topics}") + io_state = IoState() + io_state.analog_input = getattr(io_state, "analog_input", None) or {} + io_state.analog_output = getattr(io_state, "analog_output", None) or {} + io_state.digital_input = getattr(io_state, "digital_input", None) or {} + io_state.digital_output = getattr(io_state, "digital_output", None) or {} + + def process_payload(payload, value_key, msg_counter_key, active_key, end_time_key): + io_state.analog_input.update({ + value_key: payload["limit"], + msg_counter_key: payload["msgCounter"], + end_time_key: timecheck.create_timestamp() + timecheck.parse_iso8601_duration(payload["duration"]) + }) + io_state.digital_input.update({active_key: payload["isLimitActive"]}) + + if received_topics.get(f"openWB/eebus/{config.id}/get/fault_state") != 0: + raise Exception(received_topics[f"openWB/eebus/{config.id}/get/fault_str"]) + + if received_topics.get(f"openWB/eebus/{config.id}/get/lpc"): + process_payload( + received_topics[f"openWB/eebus/{config.id}/get/lpc"], + AnalogInputMapping.LPC_VALUE.name, + AnalogInputMapping.LPC_MSG_COUNTER.name, + DigitalInputMapping.LPC_ACTIVE.name, + AnalogInputMapping.LPC_END_TIME.name + ) + if received_topics.get(f"openWB/eebus/{config.id}/get/lpp"): + process_payload( + received_topics[f"openWB/eebus/{config.id}/get/lpp"], + AnalogInputMapping.LPP_VALUE.name, + AnalogInputMapping.LPP_MSG_COUNTER.name, + DigitalInputMapping.LPP_ACTIVE.name, + AnalogInputMapping.LPP_END_TIME.name + ) + + return io_state + + def initializer(): + nonlocal broker + nonlocal received_topics + Path(f"{Path(__file__).resolve().parents[4]}/ramdisk/eebus_hems_client.log").touch(exist_ok=True) + run_eebus() + + def on_connect(client, userdata, flags, rc): + client.subscribe(f"openWB/eebus/{config.id}/#") + + def on_message(client, userdata, message): + received_topics.update({message.topic: decode_payload(message.payload)}) + + received_topics = {} + broker = BrokerClient(f"subscribeMqttEebus{config.id}", + on_connect, on_message) + + return ConfigurableIo(config=config, component_reader=read, component_writer=lambda: None, initializer=initializer) + + +device_descriptor = DeviceDescriptor(configuration_factory=Eebus) + + +def create_pub_cert_ski(id: int): + Path(cert_path).mkdir(parents=True, exist_ok=True) + run_command([ + "openssl", "req", "-x509", + "-newkey", "rsa:4096", "-keyout", f"{cert_path}/hems-key-{id}.pem", + "-out", f"{cert_path}/hems-cert-{id}.pem", + "-days", "365", "-nodes", + "-subj", "/CN=HEMS/C=DE/O=openWB GmbH" + ]) + + output = run_command([ + "openssl", "x509", "-in", f"{cert_path}/hems-cert-{id}.pem", "-noout", "-text" + ]) + cert_info = CertInfo() + lines = output.splitlines() + for i, line in enumerate(lines): + if "Subject Key Identifier" in line: + cert_info.client_ski = lines[i+1].strip().replace(":", "") + elif "Not Before:" in line: + cert_info.not_before = line.split("Not Before: ")[1].strip() + elif "Not After :" in line: + cert_info.not_after = line.split("Not After : ")[1].strip() + elif line.strip().startswith("Issuer:"): + cert_info.issuer = line.strip()[len("Issuer: "):].strip() + elif line.strip().startswith("Subject:"): + cert_info.subject = line.strip()[len("Subject: "):].strip() + if "" == cert_info.client_ski: + raise ValueError("SKI nicht gefunden") + config: Eebus = data.data.system_data[f"io{id}"].config + config.configuration.cert_info = cert_info + with open(f"{cert_path}/ski-{id}", "w") as ski_file: + ski_file.write(cert_info.client_ski) + pub_single(f"openWB/set/system/io/{config.id}/config", asdict(config)) diff --git a/packages/modules/io_devices/eebus/config.py b/packages/modules/io_devices/eebus/config.py new file mode 100644 index 0000000000..598237103b --- /dev/null +++ b/packages/modules/io_devices/eebus/config.py @@ -0,0 +1,58 @@ +from enum import Enum +from typing import Dict +from helpermodules.auto_str import auto_str +from modules.common.io_setup import IoDeviceSetup + + +class AnalogInputMapping(Enum): + LPC_END_TIME = "lpc_end_time" + LPC_VALUE = "lpc_value" + LPC_MSG_COUNTER = "lpc_msg_counter" + LPP_END_TIME = "lpp_end_time" + LPP_VALUE = "lpp_value" + LPP_MSG_COUNTER = "lpp_msg_counter" + + +class DigitalInputMapping(Enum): + LPC_ACTIVE = "lpc_active" + LPP_ACTIVE = "lpp_active" + + +class CertInfo: + def __init__(self, + client_ski: str = "", + not_before: str = "", + not_after: str = "", + issuer: str = "", + subject: str = ""): + self.client_ski = client_ski + self.not_before = not_before + self.not_after = not_after + self.issuer = issuer + self.subject = subject + + +class EebusConfiguration: + def __init__(self, cert_info: CertInfo = None, remote_ski: str = "", port: int = 4712) -> None: + self.cert_info = cert_info or CertInfo() + self.remote_ski = remote_ski + self.port = port + + +def init_input(): + return {"analog": {pin.name: None for pin in AnalogInputMapping}, + "digital": {pin.name: False for pin in DigitalInputMapping}} + + +@auto_str +class Eebus(IoDeviceSetup[EebusConfiguration]): + def __init__(self, + name: str = "Steuerbox mit EEBus-Schnittstelle", + type: str = "eebus", + id: int = 0, + configuration: EebusConfiguration = None, + input: Dict[str, Dict[int, float]] = None, + output: Dict[str, Dict[int, float]] = None) -> None: + if input is None: + input = init_input() + super().__init__(name, type, id, configuration or EebusConfiguration(), input=input, output=output) diff --git a/packages/modules/io_devices/eebus/eebus_hems_client b/packages/modules/io_devices/eebus/eebus_hems_client new file mode 100755 index 0000000000..8ea2cfbb9b Binary files /dev/null and b/packages/modules/io_devices/eebus/eebus_hems_client differ