Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions packages/modules/devices/e3dc/e3dc/bat.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
#!/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
from modules.common.fault_state import ComponentInfo, FaultState
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__)
Expand All @@ -28,6 +29,7 @@ class KwargsDict(TypedDict):
device_id: int
modbus_id: int
client: modbus.ModbusTcpClient_
device_config: E3dcConfiguration


class E3dcBat(AbstractBat):
Expand All @@ -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)
Expand All @@ -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)
54 changes: 54 additions & 0 deletions packages/modules/devices/e3dc/e3dc/bat_test.py
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 15 additions & 2 deletions packages/modules/devices/e3dc/e3dc/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/modules/devices/e3dc/e3dc/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions packages/modules/devices/e3dc/e3dc/rscp_power_control.py
Original file line number Diff line number Diff line change
@@ -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)
53 changes: 53 additions & 0 deletions packages/modules/devices/e3dc/e3dc/rscp_power_control_test.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading