diff --git a/packages/modules/devices/e3dc/e3dc/bat.py b/packages/modules/devices/e3dc/e3dc/bat.py index 4c05411ccb..d70d56a654 100644 --- a/packages/modules/devices/e3dc/e3dc/bat.py +++ b/packages/modules/devices/e3dc/e3dc/bat.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 import logging -from typing import Tuple, TypedDict, Any +from typing import Optional, Tuple, TypedDict, Any from modules.common import modbus -from modules.common.abstract_device import AbstractBat +from modules.common.abstract_device import AbstractBat, SetPoint from modules.common.component_state import BatState from modules.common.component_type import ComponentDescriptor from modules.common.modbus import ModbusDataType, Endian @@ -10,7 +10,8 @@ from modules.common.store import get_component_value_store from modules.common.simcount._simcounter import SimCounter from modules.common.utils.peak_filter import PeakFilter -from modules.devices.e3dc.e3dc.config import E3dcBatSetup +from modules.devices.e3dc.e3dc.config import E3dcBatSetup, E3dcConfiguration +from modules.devices.e3dc.e3dc.rscp_power_control import E3dcRscpBatControl from modules.common.component_type import ComponentType log = logging.getLogger(__name__) @@ -28,6 +29,7 @@ class KwargsDict(TypedDict): device_id: int modbus_id: int client: modbus.ModbusTcpClient_ + device_config: E3dcConfiguration class E3dcBat(AbstractBat): @@ -39,10 +41,12 @@ def initialize(self) -> None: self.__device_id: int = self.kwargs['device_id'] self.__modbus_id: int = self.kwargs['modbus_id'] self.client: modbus.ModbusTcpClient_ = self.kwargs['client'] + self.device_config: E3dcConfiguration = self.kwargs['device_config'] self.sim_counter = SimCounter(self.__device_id, self.component_config.id, self.component_config.type) 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.BAT, self.component_config.id, self.fault_state) + self.rscp_control: Optional[E3dcRscpBatControl] = None def update(self) -> None: soc, power = read_bat(self.client, self.__modbus_id) @@ -57,5 +61,25 @@ def update(self) -> None: ) self.store.set(bat_state) + def set_power_limit(self, setpoint: SetPoint) -> None: + config = self.component_config.configuration + if config.portal_username is None or config.portal_password is None or config.rscp_key is None: + log.warning("E3DC Speicher: Keine Batteriesteuerung möglich, da keine RSCP-Zugangsdaten hinterlegt sind.") + return + + if self.rscp_control is None: + self.rscp_control = E3dcRscpBatControl( + self.device_config.address, config.portal_username, config.portal_password, config.rscp_key) + + self.rscp_control.set_power_limit(setpoint.power_limit) + + def power_limit_controllable(self) -> bool: + # Nur steuerbar, wenn RSCP-Zugangsdaten hinterlegt sind -- sonst würde die aktive + # Speichersteuerung fälschlich mit diesem Speicher rechnen, obwohl er gar nicht gesteuert wird. + config = self.component_config.configuration + return (config.portal_username is not None and + config.portal_password is not None and + config.rscp_key is not None) + component_descriptor = ComponentDescriptor(configuration_factory=E3dcBatSetup) diff --git a/packages/modules/devices/e3dc/e3dc/bat_test.py b/packages/modules/devices/e3dc/e3dc/bat_test.py new file mode 100644 index 0000000000..6e1046aae0 --- /dev/null +++ b/packages/modules/devices/e3dc/e3dc/bat_test.py @@ -0,0 +1,54 @@ +from unittest.mock import Mock + +from control.bat import Set +from modules.devices.e3dc.e3dc import bat +from modules.devices.e3dc.e3dc.config import E3dcBatSetup, E3dcConfiguration + + +def _create_bat(**configuration_kwargs) -> bat.E3dcBat: + component_config = E3dcBatSetup() + component_config.configuration.__dict__.update(configuration_kwargs) + battery = bat.E3dcBat(component_config, device_id=0, modbus_id=1, client=Mock(), + device_config=E3dcConfiguration(address="192.0.2.50")) + battery.initialize() + return battery + + +def test_power_limit_controllable_without_credentials(): + battery = _create_bat() + + assert battery.power_limit_controllable() is False + + +def test_power_limit_controllable_with_credentials(): + battery = _create_bat(portal_username="user", portal_password="secret", rscp_key="key") + + assert battery.power_limit_controllable() is True + + +def test_set_power_limit_without_credentials_does_not_connect(monkeypatch): + mock_rscp_control = Mock( + side_effect=AssertionError("E3dcRscpBatControl sollte ohne Zugangsdaten nicht erzeugt werden")) + monkeypatch.setattr(bat, "E3dcRscpBatControl", mock_rscp_control) + battery = _create_bat() + + battery.set_power_limit(Set(power_limit=0)) + + mock_rscp_control.assert_not_called() + + +def test_set_power_limit_with_credentials_forwards_to_rscp_control(monkeypatch): + mock_control = Mock() + mock_rscp_control = Mock(return_value=mock_control) + monkeypatch.setattr(bat, "E3dcRscpBatControl", mock_rscp_control) + battery = _create_bat(portal_username="user", portal_password="secret", rscp_key="key") + + battery.set_power_limit(Set(power_limit=-2000)) + + mock_rscp_control.assert_called_once_with("192.0.2.50", "user", "secret", "key") + mock_control.set_power_limit.assert_called_once_with(-2000) + + # Ein zweiter Aufruf muss die bestehende E3dcRscpBatControl-Instanz wiederverwenden. + battery.set_power_limit(Set(power_limit=3000)) + mock_rscp_control.assert_called_once() + mock_control.set_power_limit.assert_called_with(3000) diff --git a/packages/modules/devices/e3dc/e3dc/config.py b/packages/modules/devices/e3dc/e3dc/config.py index d30e51ec15..f3cd70daeb 100644 --- a/packages/modules/devices/e3dc/e3dc/config.py +++ b/packages/modules/devices/e3dc/e3dc/config.py @@ -1,3 +1,5 @@ +from typing import Optional + from modules.common.component_setup import ComponentSetup from helpermodules.auto_str import auto_str from ..vendor import vendor_descriptor @@ -27,8 +29,19 @@ def __init__(self, @auto_str class E3dcBatConfiguration: - def __init__(self) -> None: - pass + def __init__(self, + portal_username: Optional[str] = None, + portal_password: Optional[str] = None, + rscp_key: Optional[str] = None) -> None: + # Nur für die aktive Speichersteuerung benötigt. Ohne sie wird der Speicher weiterhin per + # Modbus ausgelesen, kann aber nicht aktiv gesteuert werden. + # portal_username/portal_password: Zugangsdaten des E3DC-Portal-Accounts (auch für die lokale + # RSCP-Verbindung zur Authentifizierung nötig). + # rscp_key: lokaler RSCP-Verschlüsselungscode, in den E3DC-Einstellungen unter + # "Hauptgerät -> Personalisieren -> RSCP-Passwort" vergeben. + self.portal_username = portal_username + self.portal_password = portal_password + self.rscp_key = rscp_key @auto_str diff --git a/packages/modules/devices/e3dc/e3dc/device.py b/packages/modules/devices/e3dc/e3dc/device.py index befe5f8bcc..9079b19d8d 100644 --- a/packages/modules/devices/e3dc/e3dc/device.py +++ b/packages/modules/devices/e3dc/e3dc/device.py @@ -26,7 +26,8 @@ def create_bat_component(component_config: E3dcBatSetup) -> E3dcBat: return E3dcBat(component_config=component_config, device_id=device_config.id, modbus_id=device_config.configuration.modbus_id, - client=client) + client=client, + device_config=device_config.configuration) def create_counter_component(component_config: E3dcCounterSetup) -> E3dcCounter: return E3dcCounter(component_config=component_config, diff --git a/packages/modules/devices/e3dc/e3dc/rscp_power_control.py b/packages/modules/devices/e3dc/e3dc/rscp_power_control.py new file mode 100644 index 0000000000..535cbbeaed --- /dev/null +++ b/packages/modules/devices/e3dc/e3dc/rscp_power_control.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +import logging +from typing import Optional + +from e3dc import E3DC +from e3dc._rscpTags import RscpTag, RscpType + +log = logging.getLogger(__name__) + +# EMS_REQ_SET_POWER_MODE-Werte. Tag-Namen und Modus-Werte sind an echter Hardware verifiziert +# (openWB/core#3450). +MODE_AUTO = 0 +MODE_IDLE = 1 +MODE_DISCH = 2 +MODE_CHRG = 3 + + +class E3dcRscpBatControl: + def __init__(self, ip_address: str, portal_username: str, portal_password: str, rscp_key: str) -> None: + self.ip_address = ip_address + self.portal_username = portal_username + self.portal_password = portal_password + self.rscp_key = rscp_key + self.client: Optional[E3DC] = None + + def __connect(self) -> E3DC: + if self.client is None: + self.client = E3DC(E3DC.CONNECT_LOCAL, + username=self.portal_username, + password=self.portal_password, + ipAddress=self.ip_address, + key=self.rscp_key) + return self.client + + def __set_power(self, mode: int, value_w: int) -> None: + self.__connect().sendRequest( + (RscpTag.EMS_REQ_SET_POWER, RscpType.Container, + [(RscpTag.EMS_REQ_SET_POWER_MODE, RscpType.UChar8, mode), + (RscpTag.EMS_REQ_SET_POWER_VALUE, RscpType.Int32, max(0, int(value_w)))]), + keepAlive=True) + + def set_power_limit(self, power_limit: Optional[float]) -> None: + if power_limit is None: + log.debug("Keine Batteriesteuerung, Selbstregelung durch E3DC") + self.__set_power(MODE_AUTO, 0) + elif power_limit == 0: + log.debug("Aktive Batteriesteuerung. Batterie wird auf Stop gesetzt und nicht entladen") + self.__set_power(MODE_IDLE, 0) + elif power_limit < 0: + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {abs(power_limit)} W " + "entladen für den Hausverbrauch") + self.__set_power(MODE_DISCH, abs(power_limit)) + else: + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_limit} W geladen") + self.__set_power(MODE_CHRG, power_limit) diff --git a/packages/modules/devices/e3dc/e3dc/rscp_power_control_test.py b/packages/modules/devices/e3dc/e3dc/rscp_power_control_test.py new file mode 100644 index 0000000000..5b25866959 --- /dev/null +++ b/packages/modules/devices/e3dc/e3dc/rscp_power_control_test.py @@ -0,0 +1,53 @@ +from unittest.mock import Mock + +import pytest + +from modules.devices.e3dc.e3dc import rscp_power_control +from modules.devices.e3dc.e3dc.rscp_power_control import E3dcRscpBatControl + + +@pytest.fixture +def mock_e3dc(monkeypatch) -> Mock: + mock_client = Mock() + mock_e3dc_class = Mock(return_value=mock_client) + monkeypatch.setattr(rscp_power_control, "E3DC", mock_e3dc_class) + return mock_client + + +def _sent_mode_and_value(mock_client: Mock): + request = mock_client.sendRequest.call_args[0][0] + sub_tags = request[2] + mode = next(value for tag, _, value in sub_tags if tag == rscp_power_control.RscpTag.EMS_REQ_SET_POWER_MODE) + power_value = next(value for tag, _, value in sub_tags if tag == rscp_power_control.RscpTag.EMS_REQ_SET_POWER_VALUE) + return mode, power_value + + +@pytest.mark.parametrize("power_limit, expected_mode, expected_value", [ + pytest.param(None, rscp_power_control.MODE_AUTO, 0, id="auto"), + pytest.param(0, rscp_power_control.MODE_IDLE, 0, id="idle"), + pytest.param(-2000, rscp_power_control.MODE_DISCH, 2000, id="discharge"), + pytest.param(3000, rscp_power_control.MODE_CHRG, 3000, id="charge"), +]) +def test_set_power_limit_translates_to_rscp_mode_and_value(mock_e3dc, power_limit, expected_mode, expected_value): + control = E3dcRscpBatControl("192.0.2.50", "user", "secret", "key") + + control.set_power_limit(power_limit) + + mode, value = _sent_mode_and_value(mock_e3dc) + assert mode == expected_mode + assert value == expected_value + + +def test_connection_is_created_lazily_and_reused(monkeypatch): + mock_client = Mock() + mock_e3dc_class = Mock(return_value=mock_client) + monkeypatch.setattr(rscp_power_control, "E3DC", mock_e3dc_class) + control = E3dcRscpBatControl("192.0.2.50", "user", "secret", "key") + mock_e3dc_class.assert_not_called() + + control.set_power_limit(1000) + control.set_power_limit(2000) + + mock_e3dc_class.assert_called_once_with( + mock_e3dc_class.CONNECT_LOCAL, username="user", password="secret", ipAddress="192.0.2.50", key="key") + assert mock_client.sendRequest.call_count == 2 diff --git a/requirements.txt b/requirements.txt index 80d67166ec..bb169c7bce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,7 @@ asyncio==3.4.3 passlib==1.7.4 pysolarmanv5==3.0.6 packaging==24.2 +pye3dc==0.9.3 +py3rijndael==0.3.3 +tzlocal==5.3.1 +websocket-client==1.9.0