From b6d617abba2e89a9497e1a6846fcf6b015cbf782 Mon Sep 17 00:00:00 2001 From: facefunny <529611579@qq.com> Date: Sat, 18 Jul 2026 04:50:59 +0800 Subject: [PATCH] feat: add enecess EcoMain device integration --- packages/modules/devices/enecess/__init__.py | 0 .../devices/enecess/ecomain/__init__.py | 0 .../modules/devices/enecess/ecomain/config.py | 73 +++++++ .../devices/enecess/ecomain/config_test.py | 71 ++++++ .../devices/enecess/ecomain/counter.py | 59 +++++ .../devices/enecess/ecomain/counter_test.py | 57 +++++ .../modules/devices/enecess/ecomain/device.py | 70 ++++++ .../devices/enecess/ecomain/device_test.py | 101 +++++++++ .../devices/enecess/ecomain/inverter.py | 81 +++++++ .../devices/enecess/ecomain/inverter_test.py | 126 +++++++++++ .../devices/enecess/ecomain/runtime.py | 167 ++++++++++++++ .../devices/enecess/ecomain/runtime_test.py | 205 ++++++++++++++++++ packages/modules/devices/enecess/vendor.py | 14 ++ 13 files changed, 1024 insertions(+) create mode 100644 packages/modules/devices/enecess/__init__.py create mode 100644 packages/modules/devices/enecess/ecomain/__init__.py create mode 100644 packages/modules/devices/enecess/ecomain/config.py create mode 100644 packages/modules/devices/enecess/ecomain/config_test.py create mode 100644 packages/modules/devices/enecess/ecomain/counter.py create mode 100644 packages/modules/devices/enecess/ecomain/counter_test.py create mode 100644 packages/modules/devices/enecess/ecomain/device.py create mode 100644 packages/modules/devices/enecess/ecomain/device_test.py create mode 100644 packages/modules/devices/enecess/ecomain/inverter.py create mode 100644 packages/modules/devices/enecess/ecomain/inverter_test.py create mode 100644 packages/modules/devices/enecess/ecomain/runtime.py create mode 100644 packages/modules/devices/enecess/ecomain/runtime_test.py create mode 100644 packages/modules/devices/enecess/vendor.py diff --git a/packages/modules/devices/enecess/__init__.py b/packages/modules/devices/enecess/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/devices/enecess/ecomain/__init__.py b/packages/modules/devices/enecess/ecomain/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/devices/enecess/ecomain/config.py b/packages/modules/devices/enecess/ecomain/config.py new file mode 100644 index 0000000000..d06d1b6ebb --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/config.py @@ -0,0 +1,73 @@ +from typing import Optional + +from modules.common.component_setup import ComponentSetup +from ..vendor import vendor_descriptor + + +class EcoMainConfiguration: + def __init__(self, ip_address: Optional[str] = None, serial_number: Optional[str] = None): + self.ip_address = ip_address + self.serial_number = serial_number + + +class EcoMain: + def __init__(self, name: str = "EcoMain", type: str = "ecomain", id: int = 0, + configuration: EcoMainConfiguration = None): + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or EcoMainConfiguration() + + +class EcoMainCounterConfiguration: + def __init__(self): + pass + + +class EcoMainCounterSetup(ComponentSetup[EcoMainCounterConfiguration]): + def __init__(self, name: str = "EcoMain EVU-Zähler", type: str = "counter", id: int = 0, + configuration: EcoMainCounterConfiguration = None, **kwargs): + super().__init__(name, type, id, configuration or EcoMainCounterConfiguration(), **kwargs) + + +class EcoMainChannelConfiguration: + def __init__(self, phase: int = 1, source: int = 0, channel: int = 1): + self.phase = phase + self.source = source + self.channel = channel + + +class EcoMainInverterConfiguration: + def __init__(self, phase_count: int = 1, invert: bool = False, + channels: Optional[list[EcoMainChannelConfiguration]] = None): + self.phase_count = phase_count + self.invert = invert + self.channels = channels if channels is not None else [EcoMainChannelConfiguration()] + + +class EcoMainInverterSetup(ComponentSetup[EcoMainInverterConfiguration]): + def __init__(self, name: str = "EcoMain Wechselrichter", type: str = "inverter", id: int = 0, + configuration: EcoMainInverterConfiguration = None, **kwargs): + super().__init__(name, type, id, configuration or EcoMainInverterConfiguration(), **kwargs) + + +def validate_inverter_configuration( + configuration: EcoMainInverterConfiguration) -> list[EcoMainChannelConfiguration]: + if configuration.phase_count not in (1, 3): + raise ValueError("Die Phasenanzahl muss 1 oder 3 sein.") + if len(configuration.channels) != configuration.phase_count: + raise ValueError("Die Anzahl der EcoMain-Kanäle stimmt nicht mit der Phasenanzahl überein.") + for item in configuration.channels: + if item.phase not in (1, 2, 3): + raise ValueError("Die Phase muss L1, L2 oder L3 sein.") + if item.source not in (0, 1, 2, 3): + raise ValueError("Die Quelle muss Hauptgerät oder Slave 1 bis 3 sein.") + if not 1 <= item.channel <= 10: + raise ValueError("Der EcoMain-Kanal muss zwischen 1 und 10 liegen.") + if configuration.phase_count == 3 and {item.phase for item in configuration.channels} != {1, 2, 3}: + raise ValueError("Bei dreiphasiger Messung müssen L1, L2 und L3 jeweils einmal konfiguriert sein.") + physical_channels = {(item.source, item.channel) for item in configuration.channels} + if len(physical_channels) != len(configuration.channels): + raise ValueError("Eine EcoMain-Quelle und ein Kanal dürfen nicht mehrfach verwendet werden.") + return sorted(configuration.channels, key=lambda item: item.phase) diff --git a/packages/modules/devices/enecess/ecomain/config_test.py b/packages/modules/devices/enecess/ecomain/config_test.py new file mode 100644 index 0000000000..9966d2aebc --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/config_test.py @@ -0,0 +1,71 @@ +from dataclass_utils import dataclass_from_dict +import pytest + +from modules.devices.enecess.ecomain import counter, inverter +from modules.devices.enecess.ecomain.config import ( + EcoMainCounterSetup, + EcoMainInverterSetup, + validate_inverter_configuration, +) +from modules.devices.enecess.vendor import vendor_descriptor + + +def test_nested_channels_are_deserialized(): + setup = dataclass_from_dict(EcoMainInverterSetup, { + "name": "PV", + "type": "inverter", + "id": 7, + "configuration": { + "phase_count": 3, + "invert": False, + "channels": [ + {"phase": 3, "source": 2, "channel": 10}, + {"phase": 1, "source": 0, "channel": 1}, + {"phase": 2, "source": 1, "channel": 4}, + ], + }, + }) + channels = validate_inverter_configuration(setup.configuration) + assert [(item.phase, item.source, item.channel) for item in channels] == [ + (1, 0, 1), (2, 1, 4), (3, 2, 10) + ] + + +@pytest.mark.parametrize("configuration", [ + {"phase_count": 2, "channels": [{"phase": 1, "source": 0, "channel": 1}]}, + {"phase_count": 1, "channels": []}, + {"phase_count": 3, "channels": [ + {"phase": 1, "source": 0, "channel": 1}, + {"phase": 1, "source": 0, "channel": 2}, + {"phase": 3, "source": 0, "channel": 3}, + ]}, + {"phase_count": 3, "channels": [ + {"phase": 1, "source": 0, "channel": 1}, + {"phase": 2, "source": 0, "channel": 1}, + {"phase": 3, "source": 0, "channel": 3}, + ]}, + {"phase_count": 1, "channels": [{"phase": 1, "source": 4, "channel": 1}]}, + {"phase_count": 1, "channels": [{"phase": 1, "source": 0, "channel": 11}]}, +]) +def test_invalid_inverter_configuration_is_rejected(configuration): + setup = dataclass_from_dict(EcoMainInverterSetup, { + "name": "PV", "type": "inverter", "id": 7, "configuration": configuration + }) + with pytest.raises(ValueError): + validate_inverter_configuration(setup.configuration) + + +def test_vendor_is_discovered_as_enecess(): + configuration = vendor_descriptor.configuration_factory() + assert configuration.type == "enecess" + assert configuration.vendor == "enecess" + + +@pytest.mark.parametrize(("module", "setup_class", "component_type"), [ + (counter, EcoMainCounterSetup, "counter"), + (inverter, EcoMainInverterSetup, "inverter"), +]) +def test_component_descriptors_are_discoverable(module, setup_class, component_type): + descriptor = module.component_descriptor + assert descriptor.configuration_factory is setup_class + assert descriptor.configuration_factory().type == component_type diff --git a/packages/modules/devices/enecess/ecomain/counter.py b/packages/modules/devices/enecess/ecomain/counter.py new file mode 100644 index 0000000000..8dd0f28256 --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/counter.py @@ -0,0 +1,59 @@ +from modules.common.abstract_device import AbstractCounter +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentDescriptor, ComponentType +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.store import get_component_value_store +from modules.common.utils.peak_filter import PeakFilter +from modules.devices.enecess.ecomain.config import EcoMainCounterSetup +from modules.devices.enecess.ecomain.runtime import EcoMainRuntime + + +class EcoMainCounter(AbstractCounter): + def __init__( + self, + component_config: EcoMainCounterSetup, + runtime: EcoMainRuntime, + device_id: int) -> None: + self.component_config = component_config + self.runtime = runtime + self.device_id = device_id + + def initialize(self) -> None: + self.store = get_component_value_store( + self.component_config.type, + self.component_config.id, + ) + self.fault_state = FaultState( + ComponentInfo.from_component_config(self.component_config) + ) + self.peak_filter = PeakFilter( + ComponentType.COUNTER, + self.component_config.id, + self.fault_state, + ) + self.runtime.ensure_compatible() + + def read_state(self) -> CounterState: + reading = self.runtime.read_counter() + imported, exported = self.peak_filter.check_values( + reading.power, + reading.imported, + reading.exported, + ) + return CounterState( + power=reading.power, + powers=reading.powers, + voltages=reading.voltages, + currents=reading.currents, + power_factors=reading.power_factors, + imported=imported, + exported=exported, + frequency=50, + serial_number=f"{self.runtime.device_serial}_evu", + ) + + def update(self) -> None: + self.store.set(self.read_state()) + + +component_descriptor = ComponentDescriptor(configuration_factory=EcoMainCounterSetup) diff --git a/packages/modules/devices/enecess/ecomain/counter_test.py b/packages/modules/devices/enecess/ecomain/counter_test.py new file mode 100644 index 0000000000..ac7d3edeb3 --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/counter_test.py @@ -0,0 +1,57 @@ +from unittest.mock import MagicMock, Mock + +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentType +from modules.devices.enecess.ecomain import counter +from modules.devices.enecess.ecomain.config import EcoMainCounterSetup +from modules.devices.enecess.ecomain.runtime import EcoMainCounterReading, EcoMainRuntime + + +def test_counter_update_maps_complete_evu_reading(monkeypatch): + value_store = Mock() + monkeypatch.setattr( + counter, + "get_component_value_store", + Mock(return_value=value_store), + ) + peak_filter = Mock() + peak_filter.check_values.return_value = (1234, 56) + peak_filter_factory = Mock(return_value=peak_filter) + monkeypatch.setattr(counter, "PeakFilter", peak_filter_factory) + runtime = MagicMock(spec=EcoMainRuntime) + runtime.device_serial = "099806571330" + runtime.read_counter.return_value = EcoMainCounterReading( + power=600, + powers=[100, 200, 300], + voltages=[230, 231, 232], + currents=[1, 2, 3], + power_factors=[0.98, 0.99, 1.0], + imported=1234, + exported=56, + ) + component_config = EcoMainCounterSetup(id=4) + component = counter.EcoMainCounter(component_config, runtime, device_id=1) + + component.initialize() + component.update() + + runtime.ensure_compatible.assert_called_once_with() + peak_filter_factory.assert_called_once_with( + ComponentType.COUNTER, + component_config.id, + component.fault_state, + ) + peak_filter.check_values.assert_called_once_with(600, 1234, 56) + expected = CounterState( + power=600, + powers=[100, 200, 300], + voltages=[230, 231, 232], + currents=[1, 2, 3], + power_factors=[0.98, 0.99, 1.0], + imported=1234, + exported=56, + frequency=50, + serial_number="099806571330_evu", + ) + assert value_store.set.call_count == 1 + assert vars(value_store.set.call_args.args[0]) == vars(expected) diff --git a/packages/modules/devices/enecess/ecomain/device.py b/packages/modules/devices/enecess/ecomain/device.py new file mode 100644 index 0000000000..b6d07009cd --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/device.py @@ -0,0 +1,70 @@ +from typing import Iterable, Optional, Union + +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.configurable_device import ( + ComponentFactoryByType, + ConfigurableDevice, + MultiComponentUpdater, +) +from modules.devices.enecess.ecomain.config import ( + EcoMain, + EcoMainCounterSetup, + EcoMainInverterSetup, +) +from modules.devices.enecess.ecomain.counter import EcoMainCounter +from modules.devices.enecess.ecomain.inverter import EcoMainInverter +from modules.devices.enecess.ecomain.runtime import EcoMainRuntime + + +EcoMainComponent = Union[EcoMainCounter, EcoMainInverter] + + +def create_device(device_config: EcoMain) -> ConfigurableDevice: + runtime: Optional[EcoMainRuntime] = None + + def initializer() -> None: + nonlocal runtime + runtime = EcoMainRuntime( + device_config.configuration.ip_address, + device_config.configuration.serial_number, + ) + + def create_counter(component_config: EcoMainCounterSetup) -> EcoMainCounter: + if runtime is None: + raise RuntimeError("EcoMain-Laufzeit wurde nicht initialisiert.") + return EcoMainCounter( + component_config=component_config, + runtime=runtime, + device_id=device_config.id, + ) + + def create_inverter(component_config: EcoMainInverterSetup) -> EcoMainInverter: + if runtime is None: + raise RuntimeError("EcoMain-Laufzeit wurde nicht initialisiert.") + return EcoMainInverter( + component_config=component_config, + runtime=runtime, + device_id=device_config.id, + ) + + def update_components(components: Iterable[EcoMainComponent]) -> None: + if runtime is None: + raise RuntimeError("EcoMain-Laufzeit wurde nicht initialisiert.") + with runtime.client: + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + return ConfigurableDevice( + device_config=device_config, + initializer=initializer, + component_factory=ComponentFactoryByType( + counter=create_counter, + inverter=create_inverter, + ), + component_updater=MultiComponentUpdater(update_components), + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=EcoMain) diff --git a/packages/modules/devices/enecess/ecomain/device_test.py b/packages/modules/devices/enecess/ecomain/device_test.py new file mode 100644 index 0000000000..b7b012bf04 --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/device_test.py @@ -0,0 +1,101 @@ +from unittest.mock import MagicMock, Mock + +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusTcpClient_ +from modules.devices.enecess.ecomain import counter, device, inverter +from modules.devices.enecess.ecomain.config import ( + EcoMain, + EcoMainChannelConfiguration, + EcoMainConfiguration, + EcoMainCounterSetup, + EcoMainInverterConfiguration, + EcoMainInverterSetup, +) +from modules.devices.enecess.ecomain.runtime import EcoMainRuntime + + +def _component_mock(component_id: int, name: str, component_type: str) -> Mock: + component = Mock() + component.fault_state = FaultState( + ComponentInfo(component_id, name, component_type) + ) + component.fault_state.store_error = Mock() + return component + + +def test_device_components_share_runtime_and_update_connection(monkeypatch): + runtime = Mock() + runtime.client = MagicMock() + runtime_factory = Mock(return_value=runtime) + counter_component = _component_mock(1, "counter", "counter") + inverter_component = _component_mock(2, "inverter", "inverter") + counter_factory = Mock(return_value=counter_component) + inverter_factory = Mock(return_value=inverter_component) + monkeypatch.setattr(device, "EcoMainRuntime", runtime_factory) + monkeypatch.setattr(device, "EcoMainCounter", counter_factory) + monkeypatch.setattr(device, "EcoMainInverter", inverter_factory) + device_config = EcoMain( + id=7, + configuration=EcoMainConfiguration("192.0.2.1", "eco-serial"), + ) + + configurable_device = device.create_device(device_config) + configurable_device.add_component(EcoMainCounterSetup(id=1)) + configurable_device.add_component(EcoMainInverterSetup(id=2)) + + runtime_factory.assert_called_once_with("192.0.2.1", "eco-serial") + assert counter_factory.call_args.kwargs["runtime"] is runtime + assert inverter_factory.call_args.kwargs["runtime"] is runtime + assert counter_factory.call_args.kwargs["device_id"] == 7 + assert inverter_factory.call_args.kwargs["device_id"] == 7 + + counter_component.initialized = True + inverter_component.initialized = True + configurable_device.update() + + runtime.client.__enter__.assert_called_once_with() + runtime.client.__exit__.assert_called_once() + counter_component.update.assert_called_once_with() + inverter_component.update.assert_called_once_with() + + +def test_device_update_uses_one_client_context_for_real_component_reads(monkeypatch): + client = MagicMock(spec=ModbusTcpClient_) + runtime = EcoMainRuntime( + "192.0.2.1", + "099806571330", + client=client, + ) + runtime._validated = True + runtime._device_serial = "099806571330" + runtime._read = Mock(side_effect=[ + 1_000, 0, [0, 0, 0, 0], [23000, 0, 100] * 3, + 0, 0, 0, 0, + ]) + monkeypatch.setattr(device, "EcoMainRuntime", Mock(return_value=runtime)) + monkeypatch.setattr(counter, "get_component_value_store", Mock(return_value=Mock())) + monkeypatch.setattr(inverter, "get_component_value_store", Mock(return_value=Mock())) + counter_filter = Mock() + counter_filter.check_values.return_value = (1, 0) + inverter_filter = Mock() + inverter_filter.check_values.return_value = (0, 0) + monkeypatch.setattr(counter, "PeakFilter", Mock(return_value=counter_filter)) + monkeypatch.setattr(inverter, "PeakFilter", Mock(return_value=inverter_filter)) + monkeypatch.setattr(FaultState, "store_error", Mock()) + configurable_device = device.create_device(EcoMain( + id=7, + configuration=EcoMainConfiguration("192.0.2.1", "099806571330"), + )) + configurable_device.add_component(EcoMainCounterSetup(id=1)) + configurable_device.add_component(EcoMainInverterSetup( + id=2, + configuration=EcoMainInverterConfiguration( + channels=[EcoMainChannelConfiguration()], + ), + )) + + configurable_device.update() + + client.__enter__.assert_called_once_with() + client.__exit__.assert_called_once() + assert runtime._read.call_count == 8 diff --git a/packages/modules/devices/enecess/ecomain/inverter.py b/packages/modules/devices/enecess/ecomain/inverter.py new file mode 100644 index 0000000000..55e53fdbc8 --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/inverter.py @@ -0,0 +1,81 @@ +from modules.common.abstract_device import AbstractInverter +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentDescriptor, ComponentType +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.store import get_component_value_store +from modules.common.utils.peak_filter import PeakFilter +from modules.devices.enecess.ecomain import config +from modules.devices.enecess.ecomain.config import EcoMainInverterSetup +from modules.devices.enecess.ecomain.runtime import EcoMainRuntime + + +SOURCE_TOKENS = {0: "h", 1: "s1", 2: "s2", 3: "s3"} + + +class EcoMainInverter(AbstractInverter): + def __init__( + self, + component_config: EcoMainInverterSetup, + runtime: EcoMainRuntime, + device_id: int) -> None: + self.component_config = component_config + self.runtime = runtime + self.device_id = device_id + + def initialize(self) -> None: + self.channels = config.validate_inverter_configuration( + self.component_config.configuration + ) + self.store = get_component_value_store( + self.component_config.type, + self.component_config.id, + ) + self.fault_state = FaultState( + ComponentInfo.from_component_config(self.component_config) + ) + self.peak_filter = PeakFilter( + ComponentType.INVERTER, + self.component_config.id, + self.fault_state, + ) + self.runtime.ensure_compatible() + + def read_state(self) -> InverterState: + configuration = self.component_config.configuration + factor = 1 if configuration.invert else -1 + powers = [] + currents = [0.0, 0.0, 0.0] + exported = 0.0 + imported = 0.0 + for channel in self.channels: + reading = self.runtime.read_channel(channel.source, channel.channel) + corrected_power = reading.power * factor + powers.append(corrected_power) + currents[channel.phase - 1] = ( + -abs(reading.current) if corrected_power < 0 else abs(reading.current) + ) + if configuration.invert: + exported += reading.reverse_energy + imported += reading.forward_energy + else: + exported += reading.forward_energy + imported += reading.reverse_energy + power = sum(powers) + imported, exported = self.peak_filter.check_values(power, imported, exported) + serial_channels = "_".join( + f"l{channel.phase}-{SOURCE_TOKENS[channel.source]}-c{channel.channel:02d}" + for channel in self.channels + ) + return InverterState( + power=power, + currents=currents, + exported=exported, + imported=imported, + serial_number=f"{self.runtime.device_serial}_inv_{serial_channels}", + ) + + def update(self) -> None: + self.store.set(self.read_state()) + + +component_descriptor = ComponentDescriptor(configuration_factory=EcoMainInverterSetup) diff --git a/packages/modules/devices/enecess/ecomain/inverter_test.py b/packages/modules/devices/enecess/ecomain/inverter_test.py new file mode 100644 index 0000000000..32a6ce6b21 --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/inverter_test.py @@ -0,0 +1,126 @@ +from unittest.mock import MagicMock, Mock, call + +import pytest + +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentType +from modules.devices.enecess.ecomain import config, inverter +from modules.devices.enecess.ecomain.config import ( + EcoMainChannelConfiguration, + EcoMainInverterConfiguration, + EcoMainInverterSetup, +) +from modules.devices.enecess.ecomain.runtime import EcoMainChannelReading, EcoMainRuntime + + +@pytest.mark.parametrize( + "invert, expected_state, expected_filter_values", + [ + ( + False, + InverterState( + power=-500, + currents=[0, 0, -2.5], + exported=1200, + imported=40, + serial_number="099806571330_inv_l3-h-c01", + ), + (-500, 40, 1200), + ), + ( + True, + InverterState( + power=500, + currents=[0, 0, 2.5], + exported=40, + imported=1200, + serial_number="099806571330_inv_l3-h-c01", + ), + (500, 1200, 40), + ), + ], +) +def test_single_phase_update_applies_direction( + monkeypatch, invert, expected_state, expected_filter_values): + value_store = Mock() + monkeypatch.setattr( + inverter, + "get_component_value_store", + Mock(return_value=value_store), + ) + peak_filter = Mock() + peak_filter.check_values.return_value = expected_filter_values[1:] + peak_filter_factory = Mock(return_value=peak_filter) + monkeypatch.setattr(inverter, "PeakFilter", peak_filter_factory) + validator = Mock(wraps=config.validate_inverter_configuration) + monkeypatch.setattr(inverter.config, "validate_inverter_configuration", validator) + runtime = MagicMock(spec=EcoMainRuntime) + runtime.device_serial = "099806571330" + runtime.read_channel.return_value = EcoMainChannelReading( + power=500, + current=2.5, + forward_energy=1200, + reverse_energy=40, + ) + component_config = EcoMainInverterSetup( + id=7, + configuration=EcoMainInverterConfiguration( + phase_count=1, + invert=invert, + channels=[EcoMainChannelConfiguration(phase=3, source=0, channel=1)], + ), + ) + component = inverter.EcoMainInverter(component_config, runtime, device_id=1) + + component.initialize() + component.update() + + validator.assert_called_once_with(component_config.configuration) + runtime.ensure_compatible.assert_called_once_with() + runtime.read_channel.assert_called_once_with(0, 1) + peak_filter_factory.assert_called_once_with( + ComponentType.INVERTER, + component_config.id, + component.fault_state, + ) + peak_filter.check_values.assert_called_once_with(*expected_filter_values) + assert value_store.set.call_count == 1 + assert vars(value_store.set.call_args.args[0]) == vars(expected_state) + + +def test_three_phase_read_sorts_channels_and_aggregates_values(monkeypatch): + peak_filter = Mock() + peak_filter.check_values.return_value = (600, 6000) + monkeypatch.setattr(inverter, "PeakFilter", Mock(return_value=peak_filter)) + runtime = MagicMock(spec=EcoMainRuntime) + runtime.device_serial = "099806571330" + runtime.read_channel.side_effect = [ + EcoMainChannelReading(100, 1, 1000, 100), + EcoMainChannelReading(200, 2, 2000, 200), + EcoMainChannelReading(300, 3, 3000, 300), + ] + component_config = EcoMainInverterSetup( + id=8, + configuration=EcoMainInverterConfiguration( + phase_count=3, + channels=[ + EcoMainChannelConfiguration(phase=3, source=2, channel=10), + EcoMainChannelConfiguration(phase=1, source=0, channel=1), + EcoMainChannelConfiguration(phase=2, source=1, channel=3), + ], + ), + ) + component = inverter.EcoMainInverter(component_config, runtime, device_id=1) + + component.initialize() + state = component.read_state() + + assert runtime.read_channel.call_args_list == [call(0, 1), call(1, 3), call(2, 10)] + peak_filter.check_values.assert_called_once_with(-600, 600, 6000) + assert vars(state) == vars(InverterState( + power=-600, + currents=[-1, -2, -3], + exported=6000, + imported=600, + serial_number="099806571330_inv_l1-h-c01_l2-s1-c03_l3-s2-c10", + )) diff --git a/packages/modules/devices/enecess/ecomain/runtime.py b/packages/modules/devices/enecess/ecomain/runtime.py new file mode 100644 index 0000000000..fd41098404 --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/runtime.py @@ -0,0 +1,167 @@ +import math +import time +from dataclasses import dataclass +from typing import Callable, Iterable, Union + +from modules.common.modbus import ( + Endian, + ModbusDataType, + ModbusTcpClient_, + Number, +) + + +UNIT_ID = 255 +MIN_SOFTWARE_VERSION = 138 +RETRY_INTERVAL = 300 +POWER_FACTOR = 0.01 +ELECTRICAL_FACTOR = 0.01 +ENERGY_FACTOR = 0.001 + + +class EcoMainCompatibilityError(Exception): + pass + + +@dataclass(frozen=True) +class EcoMainCounterReading: + power: float + powers: list[float] + voltages: list[float] + currents: list[float] + power_factors: list[float] + imported: float + exported: float + + +@dataclass(frozen=True) +class EcoMainChannelReading: + power: float + current: float + forward_energy: float + reverse_energy: float + + +class EcoMainRuntime: + def __init__( + self, + ip_address: str, + configured_serial: str, + client: ModbusTcpClient_ = None, + clock: Callable[[], float] = time.monotonic): + self.client = client or ModbusTcpClient_(ip_address) + self._configured_serial = configured_serial + self._clock = clock + self._device_serial = "" + self._validated = False + self._last_error = None + self._next_retry_at = 0.0 + + @property + def device_serial(self) -> str: + return self._device_serial + + @staticmethod + def decode_serial(registers: Iterable[int]) -> str: + serial_bytes = bytearray() + for register in registers: + serial_bytes.append(register & 0xFF) + serial_bytes.append((register >> 8) & 0xFF) + return serial_bytes.decode("ascii", errors="strict") + + @staticmethod + def channel_addresses(source: int, channel: int) -> tuple[int, int, int, int]: + if not 0 <= source <= 3: + raise ValueError("Die EcoMain-Quelle muss zwischen 0 und 3 liegen.") + if not 1 <= channel <= 10: + raise ValueError("Der EcoMain-Kanal muss zwischen 1 und 10 liegen.") + channel_offset = channel - 1 + return ( + 1008 + source * 20 + channel_offset * 2, + 1210 + source * 30 + channel_offset * 3, + 32 + source * 40 + channel_offset * 4, + 192 + source * 40 + channel_offset * 4, + ) + + def ensure_compatible(self) -> None: + if self._validated: + return + now = self._clock() + if self._last_error is not None and now < self._next_retry_at: + remaining = math.ceil(self._next_retry_at - now) + raise EcoMainCompatibilityError( + f"EcoMain-Kompatibilitätsprüfung fehlgeschlagen: {self._last_error} " + f"Erneuter Versuch in {remaining} Sekunden." + ) + try: + with self.client: + version = self._read(3009, ModbusDataType.UINT_16) + serial = self.decode_serial( + self._read(3002, [ModbusDataType.UINT_16] * 6) + ) + if version < MIN_SOFTWARE_VERSION: + raise EcoMainCompatibilityError( + f"EcoMain-Softwareversion {version} wird nicht unterstützt. " + f"Mindestens Version {MIN_SOFTWARE_VERSION} ist erforderlich." + ) + if serial != self._configured_serial: + raise EcoMainCompatibilityError( + f"Die konfigurierte Seriennummer {self._configured_serial} stimmt nicht mit " + f"der EcoMain-Seriennummer {serial} überein." + ) + except Exception as exc: + self._last_error = str(exc) + self._next_retry_at = now + RETRY_INTERVAL + if isinstance(exc, EcoMainCompatibilityError): + raise + raise EcoMainCompatibilityError( + f"EcoMain konnte nicht geprüft werden: {exc}" + ) from exc + self._device_serial = serial + self._validated = True + self._last_error = None + + def read_counter(self) -> EcoMainCounterReading: + imported = self._read(12, ModbusDataType.INT_64) + exported = self._read(28, ModbusDataType.INT_64) + raw_powers = self._read(1000, [ModbusDataType.INT_32] * 4) + raw_electrical = self._read( + 1200, + [ModbusDataType.UINT_16, ModbusDataType.UINT_16, + ModbusDataType.INT_16] * 3, + ) + return EcoMainCounterReading( + power=raw_powers[3] * POWER_FACTOR, + powers=[value * POWER_FACTOR for value in raw_powers[:3]], + voltages=[value * ELECTRICAL_FACTOR for value in raw_electrical[0::3]], + currents=[value * ELECTRICAL_FACTOR for value in raw_electrical[1::3]], + power_factors=[value * ELECTRICAL_FACTOR for value in raw_electrical[2::3]], + imported=imported * ENERGY_FACTOR, + exported=exported * ENERGY_FACTOR, + ) + + def read_channel(self, source: int, channel: int) -> EcoMainChannelReading: + power_address, current_address, forward_address, reverse_address = \ + self.channel_addresses(source, channel) + power = self._read(power_address, ModbusDataType.INT_32) + current = self._read(current_address, ModbusDataType.UINT_16) + forward_energy = self._read(forward_address, ModbusDataType.INT_64) + reverse_energy = self._read(reverse_address, ModbusDataType.INT_64) + return EcoMainChannelReading( + power=power * POWER_FACTOR, + current=current * ELECTRICAL_FACTOR, + forward_energy=forward_energy * ENERGY_FACTOR, + reverse_energy=reverse_energy * ENERGY_FACTOR, + ) + + def _read( + self, + address: int, + types: Union[Iterable[ModbusDataType], ModbusDataType]) -> Union[Number, list[Number]]: + return self.client.read_holding_registers( + address, + types, + unit=UNIT_ID, + byteorder=Endian.Big, + wordorder=Endian.Little, + ) diff --git a/packages/modules/devices/enecess/ecomain/runtime_test.py b/packages/modules/devices/enecess/ecomain/runtime_test.py new file mode 100644 index 0000000000..06ab58c9cb --- /dev/null +++ b/packages/modules/devices/enecess/ecomain/runtime_test.py @@ -0,0 +1,205 @@ +from dataclasses import FrozenInstanceError +from pathlib import Path +import subprocess +import sys +from unittest.mock import MagicMock, call + +import pytest + +from modules.common.modbus import Endian, ModbusDataType, ModbusTcpClient_ +from modules.devices.enecess.ecomain.runtime import ( + EcoMainChannelReading, + EcoMainCompatibilityError, + EcoMainCounterReading, + EcoMainRuntime, +) + + +SERIAL_REGISTERS = [0x3930, 0x3839, 0x3630, 0x3735, 0x3331, 0x3033] +READ_KWARGS = { + "unit": 255, + "byteorder": Endian.Big, + "wordorder": Endian.Little, +} + + +def make_client(*responses): + client = MagicMock(spec=ModbusTcpClient_) + client.read_holding_registers.side_effect = responses + return client + + +def test_runtime_import_does_not_load_device_module(): + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; " + "from modules.devices.enecess.ecomain.runtime import EcoMainRuntime; " + "assert 'modules.devices.enecess.ecomain.device' not in sys.modules", + ], + cwd=Path(__file__).resolve().parents[4], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_serial_registers_preserve_leading_zeroes(): + assert EcoMainRuntime.decode_serial(SERIAL_REGISTERS) == "099806571330" + + +@pytest.mark.parametrize("source,channel,expected", [ + (0, 1, (1008, 1210, 32, 192)), + (0, 10, (1026, 1237, 68, 228)), + (1, 1, (1028, 1240, 72, 232)), + (3, 10, (1086, 1327, 188, 348)), +]) +def test_channel_addresses(source, channel, expected): + assert EcoMainRuntime.channel_addresses(source, channel) == expected + + +@pytest.mark.parametrize("source,channel", [ + (-1, 1), + (4, 1), + (0, 0), + (0, 11), +]) +def test_channel_addresses_reject_invalid_ranges(source, channel): + with pytest.raises(ValueError): + EcoMainRuntime.channel_addresses(source, channel) + + +def test_successful_compatibility_check_is_cached(): + client = make_client(138, SERIAL_REGISTERS) + runtime = EcoMainRuntime("192.0.2.1", "099806571330", client=client) + + runtime.ensure_compatible() + runtime.ensure_compatible() + + client.__enter__.assert_called_once_with() + client.__exit__.assert_called_once() + assert client.read_holding_registers.call_count == 2 + assert runtime.device_serial == "099806571330" + assert client.read_holding_registers.call_args_list == [ + call(3009, ModbusDataType.UINT_16, **READ_KWARGS), + call(3002, [ModbusDataType.UINT_16] * 6, **READ_KWARGS), + ] + + +def test_unsupported_version_is_cached_until_retry_interval(): + now = [0] + client = make_client(137, SERIAL_REGISTERS, 137, SERIAL_REGISTERS) + runtime = EcoMainRuntime( + "192.0.2.1", "099806571330", client=client, clock=lambda: now[0] + ) + + with pytest.raises(EcoMainCompatibilityError) as first_error: + runtime.ensure_compatible() + assert str(first_error.value) == ( + "EcoMain-Softwareversion 137 wird nicht unterstützt. " + "Mindestens Version 138 ist erforderlich." + ) + assert client.read_holding_registers.call_count == 2 + + now[0] = 299 + with pytest.raises(EcoMainCompatibilityError) as cached_error: + runtime.ensure_compatible() + assert str(cached_error.value) == ( + "EcoMain-Kompatibilitätsprüfung fehlgeschlagen: " + "EcoMain-Softwareversion 137 wird nicht unterstützt. " + "Mindestens Version 138 ist erforderlich. " + "Erneuter Versuch in 1 Sekunden." + ) + assert client.read_holding_registers.call_count == 2 + + now[0] = 300 + with pytest.raises(EcoMainCompatibilityError): + runtime.ensure_compatible() + assert client.read_holding_registers.call_count == 4 + + +def test_serial_mismatch_message_includes_configured_and_detected_serials(): + client = make_client(138, SERIAL_REGISTERS) + runtime = EcoMainRuntime("192.0.2.1", "079632375788", client=client) + + with pytest.raises(EcoMainCompatibilityError) as error: + runtime.ensure_compatible() + + assert str(error.value) == ( + "Die konfigurierte Seriennummer 079632375788 stimmt nicht mit " + "der EcoMain-Seriennummer 099806571330 überein." + ) + + +def test_modbus_error_is_wrapped_for_compatibility_check(): + client = make_client(OSError("Verbindung getrennt")) + runtime = EcoMainRuntime("192.0.2.1", "099806571330", client=client) + + with pytest.raises(EcoMainCompatibilityError) as error: + runtime.ensure_compatible() + + assert str(error.value) == ( + "EcoMain konnte nicht geprüft werden: Verbindung getrennt" + ) + assert isinstance(error.value.__cause__, OSError) + + +def test_counter_reading_is_scaled_and_uses_required_register_layout(): + raw_powers = [10000, -2000, 3000, 11000] + raw_electrical = [ + 23000, 123, 99, + 23100, 234, 98, + 23200, 345, 97, + ] + client = make_client(1_234_000, 55_000, raw_powers, raw_electrical) + runtime = EcoMainRuntime("192.0.2.1", "099806571330", client=client) + + reading = runtime.read_counter() + + assert reading == EcoMainCounterReading( + power=110, + powers=[100, -20, 30], + voltages=[230, 231, 232], + currents=[1.23, 2.34, 3.45], + power_factors=[0.99, 0.98, 0.97], + imported=1234, + exported=55, + ) + assert client.read_holding_registers.call_args_list == [ + call(12, ModbusDataType.INT_64, **READ_KWARGS), + call(28, ModbusDataType.INT_64, **READ_KWARGS), + call(1000, [ModbusDataType.INT_32] * 4, **READ_KWARGS), + call(1200, [ModbusDataType.UINT_16, ModbusDataType.UINT_16, + ModbusDataType.INT_16] * 3, **READ_KWARGS), + ] + + +def test_channel_reading_is_scaled_and_uses_calculated_addresses(): + client = make_client(12_345, 678, 9_876_000, 54_000) + runtime = EcoMainRuntime("192.0.2.1", "099806571330", client=client) + + reading = runtime.read_channel(source=1, channel=1) + + assert reading == EcoMainChannelReading( + power=123.45, + current=6.78, + forward_energy=9876, + reverse_energy=54, + ) + assert client.read_holding_registers.call_args_list == [ + call(1028, ModbusDataType.INT_32, **READ_KWARGS), + call(1240, ModbusDataType.UINT_16, **READ_KWARGS), + call(72, ModbusDataType.INT_64, **READ_KWARGS), + call(232, ModbusDataType.INT_64, **READ_KWARGS), + ] + + +@pytest.mark.parametrize("reading", [ + EcoMainCounterReading(0, [], [], [], [], 0, 0), + EcoMainChannelReading(0, 0, 0, 0), +]) +def test_readings_are_immutable(reading): + with pytest.raises(FrozenInstanceError): + reading.power = 1 diff --git a/packages/modules/devices/enecess/vendor.py b/packages/modules/devices/enecess/vendor.py new file mode 100644 index 0000000000..b0dda17969 --- /dev/null +++ b/packages/modules/devices/enecess/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 = "enecess" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor)