diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index fb561f8fb8..34f9a40247 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -58,7 +58,7 @@ class UpdateConfig: - DATASTORE_VERSION = 140 + DATASTORE_VERSION = 141 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -3554,3 +3554,31 @@ def upgrade(topic: str, payload) -> Optional[dict]: return None self._loop_all_received_topics(upgrade) self._append_datastore_version(140) + + def upgrade_datastore_141(self) -> None: + """Fronius-Modul aufgeräumt: in fronius_http_api umbenannt, sekundären Wechselrichter in den + Wechselrichter und S0-/SmartMeter-Zähler in einen gemeinsamen Zähler-Typ zusammengeführt.""" + def upgrade(topic: str, payload) -> Optional[dict]: + if re.search("^openWB/system/device/[0-9]+/config$", topic) is not None: + payload_device = decode_payload(payload) + if payload_device.get("type") == "fronius": + payload_device["type"] = "fronius_http_api" + return {topic: payload_device} + elif re.search("^openWB/system/device/[0-9]+/component/[0-9]+/config$", topic) is not None: + payload_component = decode_payload(payload) + comp_type = payload_component.get("type") + if comp_type == "inverter_secondary": + secondary_id = payload_component.get("configuration", {}).get("id", 1) + payload_component["type"] = "inverter" + payload_component["configuration"] = {"secondary_id": secondary_id} + return {topic: payload_component} + elif comp_type == "counter_s0": + # 3 == COUNTER_VARIANT_S0 in modules.devices.fronius.fronius_http_api.config + payload_component["type"] = "counter" + payload_component["configuration"] = {"variant": 3, "meter_id": 0} + return {topic: payload_component} + elif comp_type == "counter_sm": + payload_component["type"] = "counter" + return {topic: payload_component} + self._loop_all_received_topics(upgrade) + self._append_datastore_version(141) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 18e036b162..e0e6b4c4f4 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -87,6 +87,71 @@ def test_upgrade_datastore_124_adds_missing_odometer_pattern_for_json_soc_module assert "odometer_pattern" not in ha_config +def test_upgrade_datastore_141_cleans_up_fronius_module(): + update_con = UpdateConfig() + update_con.all_received_topics = { + "openWB/system/datastore_version": list(range(141)), + "openWB/system/device/0/config": { + "name": "Fronius", + "type": "fronius", + "id": 0, + "vendor": "fronius", + "configuration": {"ip_address": "192.168.1.10"} + }, + "openWB/system/device/1/config": { + "name": "Some other device", + "type": "sma_sunny_boy", + "id": 1, + "vendor": "sma", + "configuration": {} + }, + "openWB/system/device/0/component/1/config": { + "name": "Sekundärer Wechselrichter", + "type": "inverter_secondary", + "id": 1, + "configuration": {"id": 2} + }, + "openWB/system/device/0/component/2/config": { + "name": "Fronius Speicher", + "type": "bat", + "id": 2, + "configuration": {"meter_id": 0} + }, + "openWB/system/device/0/component/3/config": { + "name": "Fronius S0 Zähler", + "type": "counter_s0", + "id": 3, + "configuration": {} + }, + "openWB/system/device/1/component/1/config": { + "name": "Fronius SM Zähler", + "type": "counter_sm", + "id": 1, + "configuration": {"meter_id": 1, "variant": 2} + } + } + + update_con.upgrade_datastore_141() + + assert update_con.all_received_topics["openWB/system/device/0/config"]["type"] == "fronius_http_api" + assert update_con.all_received_topics["openWB/system/device/1/config"]["type"] == "sma_sunny_boy" + + migrated_inverter = update_con.all_received_topics["openWB/system/device/0/component/1/config"] + assert migrated_inverter["type"] == "inverter" + assert migrated_inverter["configuration"] == {"secondary_id": 2} + + unaffected_bat = update_con.all_received_topics["openWB/system/device/0/component/2/config"] + assert unaffected_bat["type"] == "bat" + + s0_migrated = update_con.all_received_topics["openWB/system/device/0/component/3/config"] + assert s0_migrated["type"] == "counter" + assert s0_migrated["configuration"] == {"variant": 3, "meter_id": 0} + + sm_migrated = update_con.all_received_topics["openWB/system/device/1/component/1/config"] + assert sm_migrated["type"] == "counter" + assert sm_migrated["configuration"] == {"meter_id": 1, "variant": 2} + + @pytest.mark.parametrize("name", [ "happy_path", "missing_prices_dict", diff --git a/packages/modules/devices/fronius/fronius/bat.py b/packages/modules/devices/fronius/fronius/bat.py deleted file mode 100644 index 685ba3cf4f..0000000000 --- a/packages/modules/devices/fronius/fronius/bat.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -from typing import Any, TypedDict - -from modules.common import req -from modules.common.abstract_device import AbstractBat -from modules.common.component_state import BatState -from modules.common.component_type import ComponentDescriptor -from modules.common.fault_state import ComponentInfo, FaultState -from modules.common.simcount import SimCounter -from modules.common.store import get_component_value_store -from modules.devices.fronius.fronius.config import FroniusBatSetup -from modules.devices.fronius.fronius.config import FroniusConfiguration -from modules.common.utils.peak_filter import PeakFilter -from modules.common.component_type import ComponentType - - -class KwargsDict(TypedDict): - device_config: FroniusConfiguration - device_id: int - - -class FroniusBat(AbstractBat): - def __init__(self, component_config: FroniusBatSetup, **kwargs: Any) -> None: - self.component_config = component_config - self.kwargs: KwargsDict = kwargs - - def initialize(self) -> None: - self.device_config: FroniusConfiguration = self.kwargs['device_config'] - self.__device_id: int = self.kwargs['device_id'] - 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) - - def update(self) -> None: - meter_id = str(self.component_config.configuration.meter_id) - - resp_json = req.get_http_session().get( - 'http://' + self.device_config.ip_address + '/solar_api/v1/GetPowerFlowRealtimeData.fcgi', - params=(('Scope', 'System'),), - timeout=5).json() - try: - power = int(resp_json["Body"]["Data"]["Site"]["P_Akku"]) * -1 - except TypeError: - # Wenn WR aus bzw. im Standby (keine Antwort), ersetze leeren Wert durch eine 0. - power = 0 - - try: - resp_json_id = dict(resp_json["Body"]["Data"]) - if "Inverters" in resp_json_id: - soc = float(resp_json_id["Inverters"]["1"]["SOC"]) - else: - soc = float(resp_json_id.get(meter_id)["Controller"]["StateOfCharge_Relative"]) - except TypeError: - # Wenn WR aus bzw. im Standby (keine Antwort), ersetze leeren Wert durch eine 0. - soc = 0 - - self.peak_filter.check_values(power) - imported, exported = self.sim_counter.sim_count(power) - bat_state = BatState( - power=power, - soc=soc, - imported=imported, - exported=exported - ) - self.store.set(bat_state) - - -component_descriptor = ComponentDescriptor(configuration_factory=FroniusBatSetup) diff --git a/packages/modules/devices/fronius/fronius/bat_test.py b/packages/modules/devices/fronius/fronius/bat_test.py deleted file mode 100644 index 0f6571a9e1..0000000000 --- a/packages/modules/devices/fronius/fronius/bat_test.py +++ /dev/null @@ -1,82 +0,0 @@ -from unittest.mock import Mock - -import requests_mock - -from dataclass_utils import dataclass_from_dict -from modules.common.store._api import LoggingValueStore -from modules.conftest import SAMPLE_IP -from modules.devices.fronius.fronius import bat -from modules.devices.fronius.fronius.config import FroniusBatSetup, FroniusConfiguration - - -def test_update(monkeypatch, requests_mock: requests_mock.Mocker, mock_simcount): - component_config = FroniusBatSetup() - device_config = FroniusConfiguration() - device_config.ip_address = SAMPLE_IP - assert component_config.configuration.meter_id == 0 - battery = bat.FroniusBat(component_config, device_config=dataclass_from_dict( - FroniusConfiguration, device_config), device_id=0) - battery.initialize() - - mock = Mock(return_value=None) - monkeypatch.setattr(LoggingValueStore, "set", mock) - mock_simcount.return_value = 0, 0 - requests_mock.get( - "http://" + SAMPLE_IP + "/solar_api/v1/GetPowerFlowRealtimeData.fcgi", - json=json) - - battery.update() - - # mock_valuestore.assert_called_once() - battery_state = mock.call_args[0][0] - assert battery_state.exported == 0 - assert battery_state.imported == 0 - assert battery_state.power == -2288 - assert battery_state.soc == 60.8 - - -json = { - "Body": { - "Data": { - "Inverters": { - "1": { - "Battery_Mode": "normal", - "DT": 1, - "E_Day": None, - "E_Total": 9805020.3608333338, - "E_Year": None, - "P": 2246.208984375, - "SOC": 60.799999999999997 - } - }, - "Site": { - "BackupMode": "false", - "BatteryStandby": "true", - "E_Day": None, - "E_Total": 9805020.3608333338, - "E_Year": None, - "Meter_Location": "grid", - "Mode": "bidirectional", - "P_Akku": 2288.587158203125, - "P_Grid": 280.39999999999998, - "P_Load": -2938.6320312500002, - "P_PV": 3.5314908027648926, - "rel_Autonomy": 90.458145252002623, - "rel_SelfConsumption": 100.0 - }, - "Smartloads": { - "Ohmpilots": {} - }, - "Version": "12" - } - }, - "Head": { - "RequestArguments": {}, - "Status": { - "Code": 0, - "Reason": "", - "UserMessage": "" - }, - "Timestamp": "2022-01-03T17:17:36+00:00" - } -} diff --git a/packages/modules/devices/fronius/fronius/counter_s0.py b/packages/modules/devices/fronius/fronius/counter_s0.py deleted file mode 100644 index 349bb48029..0000000000 --- a/packages/modules/devices/fronius/fronius/counter_s0.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -from typing import TypedDict, Any - -from modules.common import req -from modules.common.abstract_device import AbstractCounter -from modules.common.component_state import CounterState -from modules.common.component_type import ComponentDescriptor -from modules.common.fault_state import ComponentInfo, FaultState -from modules.common.simcount import SimCounter -from modules.common.store import get_component_value_store -from modules.devices.fronius.fronius.config import FroniusConfiguration, FroniusS0CounterSetup -from modules.common.utils.peak_filter import PeakFilter -from modules.common.component_type import ComponentType - - -class KwargsDict(TypedDict): - device_id: int - device_config: FroniusConfiguration - - -class FroniusS0Counter(AbstractCounter): - def __init__(self, component_config: FroniusS0CounterSetup, **kwargs: Any) -> None: - self.component_config = component_config - self.kwargs: KwargsDict = kwargs - - def initialize(self) -> None: - self.__device_id: int = self.kwargs['device_id'] - self.device_config: FroniusConfiguration = 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.COUNTER, self.component_config.id, self.fault_state) - - def update(self) -> None: - session = req.get_http_session() - response = session.get( - 'http://'+self.device_config.ip_address+'/solar_api/v1/GetPowerFlowRealtimeData.fcgi', - timeout=5) - # Wenn WR aus bzw. im Standby (keine Antwort), ersetze leeren Wert durch eine 0. - power = float(response.json()["Body"]["Data"]["Site"]["P_Grid"]) or 0 - - self.peak_filter.check_values(power) - imported, exported = self.sim_counter.sim_count(power) - - counter_state = CounterState( - imported=imported, - exported=exported, - power=power - ) - self.store.set(counter_state) - - -component_descriptor = ComponentDescriptor(configuration_factory=FroniusS0CounterSetup) diff --git a/packages/modules/devices/fronius/fronius/counter_s0_test.py b/packages/modules/devices/fronius/fronius/counter_s0_test.py deleted file mode 100644 index 235eea30c3..0000000000 --- a/packages/modules/devices/fronius/fronius/counter_s0_test.py +++ /dev/null @@ -1,85 +0,0 @@ -from unittest.mock import Mock - -import requests_mock - -from dataclass_utils import dataclass_from_dict -from modules.common.store._api import LoggingValueStore -from modules.conftest import SAMPLE_IP -from modules.devices.fronius.fronius import counter_s0 -from modules.devices.fronius.fronius.config import FroniusConfiguration, FroniusS0CounterSetup - - -def test_update(monkeypatch, requests_mock: requests_mock.Mocker, mock_simcount): - component_config = FroniusS0CounterSetup() - device_config = FroniusConfiguration() - device_config.ip_address = SAMPLE_IP - counter = counter_s0.FroniusS0Counter(component_config, device_config=dataclass_from_dict( - FroniusConfiguration, device_config), device_id=0) - counter.initialize() - - mock = Mock(return_value=None) - monkeypatch.setattr(LoggingValueStore, "set", mock) - mock_simcount.return_value = 0, 0 - requests_mock.get( - "http://" + SAMPLE_IP + "/solar_api/v1/GetPowerFlowRealtimeData.fcgi", - json=json) - - counter.update() - - # mock.assert_called_once() - counter_state = mock.call_args[0][0] - assert counter_state.exported == 0 - assert counter_state.imported == 0 - assert counter_state.currents == [0.0, 0.0, 0.0] - assert counter_state.frequency == 50 - assert counter_state.power == 330.7664210983294 - assert counter_state.powers == [0, 0, 0] - assert counter_state.power_factors == [0, 0, 0] - assert counter_state.voltages == [230, 230, 230] - - -json = { - "Body": { - "Data": { - "Inverters": { - "1": { - "DT": 105, - "E_Day": 9668, - "E_Total": 45503300, - "E_Year": 7010823.5, - "P": 0 - }, - "2": { - "DT": 115, - "E_Day": 16189, - "E_Total": 16581639, - "E_Year": 11989318, - "P": 0 - } - }, - "Site": { - "E_Day": 25857, - "E_Total": 62084939, - "E_Year": 19000141.5, - "Meter_Location": "load", - "Mode": "vague-meter", - "P_Akku": None, - "P_Grid": 330.7664210983294, - "P_Load": -330.7664210983294, - "P_PV": None, - "rel_Autonomy": 0, - "rel_SelfConsumption": None - }, - "Version": "12" - } - }, - "Head": { - "RequestArguments": {}, - "Status": { - "Code": 0, - "Reason": "", - "UserMessage": "" - }, - "Timestamp": "2021-08-11T06:27:35+00:00" - } -} diff --git a/packages/modules/devices/fronius/fronius/counter_sm.py b/packages/modules/devices/fronius/fronius/counter_sm.py deleted file mode 100644 index ff5cb67afe..0000000000 --- a/packages/modules/devices/fronius/fronius/counter_sm.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -import logging -from typing import Tuple, TypedDict, Any - -from requests import Session - -from modules.common import req -from modules.common.abstract_device import AbstractCounter -from modules.common.component_state import CounterState -from modules.common.component_type import ComponentDescriptor -from modules.common.fault_state import ComponentInfo, FaultState -from modules.common.simcount import SimCounter -from modules.common.store import get_component_value_store -from modules.devices.fronius.fronius.config import FroniusConfiguration, MeterLocation -from modules.devices.fronius.fronius.config import FroniusSmCounterSetup -from modules.common.utils.peak_filter import PeakFilter -from modules.common.component_type import ComponentType - -log = logging.getLogger(__name__) - - -class KwargsDict(TypedDict): - device_id: int - device_config: FroniusConfiguration - - -class FroniusSmCounter(AbstractCounter): - def __init__(self, component_config: FroniusSmCounterSetup, **kwargs: Any) -> None: - self.component_config = component_config - self.kwargs: KwargsDict = kwargs - - def initialize(self) -> None: - self.__device_id: int = self.kwargs['device_id'] - self.device_config: FroniusConfiguration = 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.COUNTER, self.component_config.id, self.fault_state) - - def update(self) -> None: - session = req.get_http_session() - variant = self.component_config.configuration.variant - if variant == 0 or variant == 1: - counter_state = self.__update_variant_0_1(session) - elif variant == 2: - counter_state = self.__update_variant_2(session) - else: - raise ValueError("Unbekannte Variante: "+str(variant)) - self.peak_filter.check_values(counter_state.power) - counter_state.imported, counter_state.exported = self.sim_counter.sim_count(counter_state.power) - self.store.set(counter_state) - - def __update_variant_0_1(self, session: Session) -> CounterState: - variant = self.component_config.configuration.variant - meter_id = self.component_config.configuration.meter_id - if variant == 0: - params = ( - ('Scope', 'Device'), - ('DeviceId', meter_id), - ) - elif variant == 1: - params = ( - ('Scope', 'Device'), - ('DeviceId', meter_id), - ('DataCollection', 'MeterRealtimeData'), - ) - else: - raise ValueError("Unbekannte Generation: "+str(variant)) - response = session.get( - 'http://' + self.device_config.ip_address + '/solar_api/v1/GetMeterRealtimeData.cgi', - params=params, - timeout=5) - response_json_id = response.json()["Body"]["Data"] - - meter_location = MeterLocation.get(response_json_id["Meter_Location_Current"]) - log.debug("Einbauort: "+str(meter_location)) - - powers = [response_json_id["PowerReal_P_Phase_"+str(num)] for num in range(1, 4)] - if meter_location == MeterLocation.load: - power, power_inverter = self.__get_flow_power(session) - # wenn SmartMeter im Verbrauchszweig sitzt sind folgende Annahmen getroffen: - # PV Leistung wird gleichmäßig auf alle Phasen verteilt - # Spannungen und Leistungsfaktoren sind am Verbrauchszweig == Einspeisepunkt - # Hier gehen wir mal davon aus, dass der Wechselrichter seine PV-Leistung gleichmäßig - # auf alle Phasen aufteilt. - powers = [-1 * power - power_inverter/3 for power in powers] - else: - power = response_json_id["PowerReal_P_Sum"] - # for all meter locations except "grid", negative power is consumption! - if meter_location in (MeterLocation.external, MeterLocation.subload): - power *= -1 - voltages = [response_json_id["Voltage_AC_Phase_"+str(num)] for num in range(1, 4)] - currents = [powers[i] / voltages[i] for i in range(0, 3)] - power_factors = [response_json_id["PowerFactor_Phase_"+str(num)] for num in range(1, 4)] - frequency = response_json_id["Frequency_Phase_Average"] - - return CounterState( - voltages=voltages, - currents=currents, - powers=powers, - power=power, - frequency=frequency, - power_factors=power_factors - ) - - def __update_variant_2(self, session: Session) -> CounterState: - meter_id = str(self.component_config.configuration.meter_id) - response = session.get( - 'http://' + self.device_config.ip_address + '/solar_api/v1/GetMeterRealtimeData.cgi', - params=(('Scope', 'System'),), - timeout=5) - response_json_id = dict(response.json()["Body"]["Data"]).get(meter_id) - - meter_location = MeterLocation.get(response_json_id["SMARTMETER_VALUE_LOCATION_U16"]) - log.debug("Einbauort: "+str(meter_location)) - - powers = [response_json_id["SMARTMETER_POWERACTIVE_MEAN_0"+str(num)+"_F64"] for num in range(1, 4)] - if meter_location == MeterLocation.load: - power, power_inverter = self.__get_flow_power(session) - # wenn SmartMeter im Verbrauchszweig sitzt sind folgende Annahmen getroffen: - # PV Leistung wird gleichmäßig auf alle Phasen verteilt - # Spannungen und Leistungsfaktoren sind am Verbrauchszweig == Einspeisepunkt - # Hier gehen wir mal davon aus, dass der Wechselrichter seine PV-Leistung gleichmäßig - # auf alle Phasen aufteilt. - powers = [-1 * power - power_inverter/3 for power in powers] - else: - power = response_json_id["SMARTMETER_POWERACTIVE_MEAN_SUM_F64"] - voltages = [response_json_id["SMARTMETER_VOLTAGE_0"+str(num)+"_F64"] for num in range(1, 4)] - currents = [powers[i] / voltages[i] for i in range(0, 3)] - power_factors = [response_json_id["SMARTMETER_FACTOR_POWER_0"+str(num)+"_F64"] for num in range(1, 4)] - frequency = response_json_id["GRID_FREQUENCY_MEAN_F32"] - - return CounterState( - voltages=voltages, - currents=currents, - powers=powers, - power=power, - frequency=frequency, - power_factors=power_factors - ) - - def __get_flow_power(self, session: Session) -> Tuple[float, float]: - # Beim Energiebezug ist nicht klar, welcher Anteil aus dem Netz bezogen wurde, und was aus - # dem Wechselrichter kam. - # Beim Energieexport ist nicht klar, wie hoch der Eigenverbrauch während der Produktion war. - response = session.get( - 'http://' + self.device_config.ip_address + '/solar_api/v1/GetPowerFlowRealtimeData.fcgi', - params=(('Scope', 'System'),), - timeout=5) - power_load = float(response.json()["Body"]["Data"]["Site"]["P_Grid"]) - power_inverter = float(response.json()["Body"]["Data"]["Site"]["P_PV"] or 0) - return power_load, power_inverter - - -component_descriptor = ComponentDescriptor(configuration_factory=FroniusSmCounterSetup) diff --git a/packages/modules/devices/fronius/fronius/device.py b/packages/modules/devices/fronius/fronius/device.py deleted file mode 100644 index 842c213cfb..0000000000 --- a/packages/modules/devices/fronius/fronius/device.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -import logging -from typing import Iterable, Union - -import requests - -from modules.common import req -from modules.common.abstract_device import DeviceDescriptor -from modules.common.configurable_device import ComponentFactoryByType, ConfigurableDevice, MultiComponentUpdater -from modules.devices.fronius.fronius.bat import FroniusBat -from modules.devices.fronius.fronius.config import (Fronius, FroniusBatSetup, FroniusSecondaryInverterSetup, - FroniusSmCounterSetup, FroniusS0CounterSetup, - FroniusProductionMeterSetup, FroniusInverterSetup) -from modules.devices.fronius.fronius.counter_s0 import FroniusS0Counter -from modules.devices.fronius.fronius.counter_sm import FroniusSmCounter -from modules.devices.fronius.fronius.inverter import FroniusInverter -from modules.devices.fronius.fronius.inverter_secondary import FroniusSecondaryInverter -from modules.devices.fronius.fronius.inverter_production_meter import FroniusProductionMeter - -log = logging.getLogger(__name__) - -fronius_component_classes = Union[FroniusBat, FroniusSmCounter, FroniusS0Counter, - FroniusInverter, FroniusSecondaryInverter, FroniusProductionMeter] - - -def create_device(device_config: Fronius): - def create_bat_component(component_config: FroniusBatSetup): - return FroniusBat(component_config=component_config, - device_id=device_config.id, - device_config=device_config.configuration) - - def create_counter_sm_component(component_config: FroniusSmCounterSetup): - return FroniusSmCounter(component_config=component_config, - device_id=device_config.id, - device_config=device_config.configuration) - - def create_counter_s0_component(component_config: FroniusS0CounterSetup): - return FroniusS0Counter(component_config=component_config, - device_id=device_config.id, - device_config=device_config.configuration) - - def create_inverter_component(component_config: FroniusInverterSetup): - return FroniusInverter(component_config=component_config, - device_id=device_config.id) - - def create_inverter_secondary_component(component_config: FroniusSecondaryInverterSetup): - return FroniusSecondaryInverter(component_config=component_config, - device_id=device_config.id) - - def create_inverter_production_meter_component(component_config: FroniusProductionMeterSetup): - return FroniusProductionMeter(component_config=component_config, - device_id=device_config.id, - device_config=device_config.configuration) - - def update_components(components: Iterable[fronius_component_classes]): - inverter_response = None - for component in components: - if ( - component.component_config.type == "inverter" or - component.component_config.type == "inverter_secondary" - ): - if inverter_response is None: - try: - inverter_response = req.get_http_session().get( - (f'http://{device_config.configuration.ip_address}' - '/solar_api/v1/GetPowerFlowRealtimeData.fcgi'), - params=(('Scope', 'System'),), - timeout=3).json() - except (requests.ConnectTimeout, requests.ConnectionError) as e: - inverter_response = e - # Nachtmodus: WR ist ausgeschaltet - component.update(inverter_response) - - for component in components: - if ( - component.component_config.type != "inverter" and - component.component_config.type != "inverter_secondary" - ): - component.update() - - return ConfigurableDevice( - device_config=device_config, - component_factory=ComponentFactoryByType( - bat=create_bat_component, - counter_sm=create_counter_sm_component, - counter_s0=create_counter_s0_component, - inverter=create_inverter_component, - inverter_secondary=create_inverter_secondary_component, - inverter_production_meter=create_inverter_production_meter_component, - ), - component_updater=MultiComponentUpdater(update_components) - ) - - -device_descriptor = DeviceDescriptor(configuration_factory=Fronius) diff --git a/packages/modules/devices/fronius/fronius/inverter_production_meter.py b/packages/modules/devices/fronius/fronius/inverter_production_meter.py deleted file mode 100644 index fe7b0d86ba..0000000000 --- a/packages/modules/devices/fronius/fronius/inverter_production_meter.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -import logging -from typing import TypedDict, Any - -from requests import Session - -from modules.common import req -from modules.common.abstract_device import AbstractInverter -from modules.common.component_state import InverterState -from modules.common.component_type import ComponentDescriptor -from modules.common.fault_state import ComponentInfo, FaultState -from modules.common.simcount import SimCounter -from modules.common.store import get_component_value_store -from modules.devices.fronius.fronius.config import FroniusConfiguration, MeterLocation -from modules.devices.fronius.fronius.config import FroniusProductionMeterSetup -from modules.common.utils.peak_filter import PeakFilter -from modules.common.component_type import ComponentType - -log = logging.getLogger(__name__) - - -class KwargsDict(TypedDict): - device_id: int - device_config: FroniusConfiguration - - -class FroniusProductionMeter(AbstractInverter): - def __init__(self, component_config: FroniusProductionMeterSetup, **kwargs: Any) -> None: - self.component_config = component_config - self.kwargs: KwargsDict = kwargs - - def initialize(self) -> None: - self.__device_id: int = self.kwargs['device_id'] - self.device_config: FroniusConfiguration = 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.INVERTER, self.component_config.id, self.fault_state) - - def update(self) -> None: - session = req.get_http_session() - variant = self.component_config.configuration.variant - if variant == 0 or variant == 1: - inverter_state = self.__update_variant_0_1(session) - elif variant == 2: - inverter_state = self.__update_variant_2(session) - else: - raise ValueError("Unbekannte Variante: "+str(variant)) - self.store.set(inverter_state) - - def __update_variant_0_1(self, session: Session) -> InverterState: - variant = self.component_config.configuration.variant - meter_id = self.component_config.configuration.meter_id - if variant == 0: - params = ( - ('Scope', 'Device'), - ('DeviceId', meter_id), - ) - elif variant == 1: - params = ( - ('Scope', 'Device'), - ('DeviceId', meter_id), - ('DataCollection', 'MeterRealtimeData'), - ) - else: - raise ValueError("Unbekannte Generation: "+str(variant)) - response = session.get( - 'http://' + self.device_config.ip_address + '/solar_api/v1/GetMeterRealtimeData.cgi', - params=params, - timeout=5) - response_json_id = response.json()["Body"]["Data"] - - meter_location = MeterLocation.get(response_json_id["Meter_Location_Current"]) - log.debug("Einbauort: "+str(meter_location)) - - powers = [response_json_id["PowerReal_P_Phase_"+str(num)] for num in range(1, 4)] - if meter_location == MeterLocation.grid: - raise ValueError("Fehler: Dieser Zähler ist kein Erzeugerzähler.") - else: - power = response_json_id["PowerReal_P_Sum"] * -1 - voltages = [response_json_id["Voltage_AC_Phase_"+str(num)] for num in range(1, 4)] - currents = [powers[i] / voltages[i] for i in range(0, 3)] - - self.peak_filter.check_values(power) - _, exported = self.sim_counter.sim_count(power) - return InverterState( - currents=currents, - power=power, - exported=exported - ) - - def __update_variant_2(self, session: Session) -> InverterState: - meter_id = str(self.component_config.configuration.meter_id) - response = session.get( - 'http://' + self.device_config.ip_address + '/solar_api/v1/GetMeterRealtimeData.cgi', - params=(('Scope', 'System'),), - timeout=5) - response_json_id = dict(response.json()["Body"]["Data"]).get(meter_id) - - meter_location = MeterLocation.get(response_json_id["SMARTMETER_VALUE_LOCATION_U16"]) - log.debug("Einbauort: "+str(meter_location)) - - powers = [response_json_id["SMARTMETER_POWERACTIVE_MEAN_0"+str(num)+"_F64"] for num in range(1, 4)] - if meter_location == MeterLocation.grid: - raise ValueError("Fehler: Dieser Zähler ist kein Erzeugerzähler.") - else: - power = response_json_id["SMARTMETER_POWERACTIVE_MEAN_SUM_F64"] - voltages = [response_json_id["SMARTMETER_VOLTAGE_0"+str(num)+"_F64"] for num in range(1, 4)] - currents = [powers[i] / voltages[i] for i in range(0, 3)] - - self.peak_filter.check_values(power) - _, exported = self.sim_counter.sim_count(power) - return InverterState( - currents=currents, - power=power, - exported=exported - ) - - -component_descriptor = ComponentDescriptor(configuration_factory=FroniusProductionMeterSetup) diff --git a/packages/modules/devices/fronius/fronius/inverter_secondary.py b/packages/modules/devices/fronius/fronius/inverter_secondary.py deleted file mode 100644 index 53a82e6373..0000000000 --- a/packages/modules/devices/fronius/fronius/inverter_secondary.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -from typing import Dict, TypedDict, Any - -from modules.common.abstract_device import AbstractInverter -from modules.common.component_state import InverterState -from modules.common.component_type import ComponentDescriptor -from modules.common.fault_state import ComponentInfo, FaultState -from modules.common.simcount import SimCounter -from modules.common.store import get_component_value_store -from modules.devices.fronius.fronius.config import FroniusSecondaryInverterSetup -from modules.common.utils.peak_filter import PeakFilter -from modules.common.component_type import ComponentType - - -class KwargsDict(TypedDict): - device_id: int - - -class FroniusSecondaryInverter(AbstractInverter): - def __init__(self, component_config: FroniusSecondaryInverterSetup, **kwargs: Any) -> None: - self.component_config = component_config - self.kwargs: KwargsDict = kwargs - - def initialize(self) -> None: - self.__device_id: int = self.kwargs['device_id'] - 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.INVERTER, self.component_config.id, self.fault_state) - - def update(self, response: Dict) -> None: - # Rückgabewert ist die aktuelle Wirkleistung in [W]. - if isinstance(response, Exception): - power = 0.0 - else: - try: - secondary_data = response["Body"]["Data"]["SecondaryMeters"][str( - self.component_config.configuration.id)] - if secondary_data["Category"] == "METER_CAT_WR": - power = float(secondary_data["P"]) * -1 - else: - raise ValueError(f"Sekundäres Gerät {self.component_config.configuration.id} " - "ist kein Wechselrichter.") - except TypeError: - # Ohne PV Produktion liefert der WR 'null', ersetze durch Zahl 0 - power = 0 - - self.peak_filter.check_values(power) - _, exported = self.sim_counter.sim_count(power) - - self.store.set(InverterState( - power=power, - exported=exported - )) - - -component_descriptor = ComponentDescriptor(configuration_factory=FroniusSecondaryInverterSetup) diff --git a/packages/modules/devices/fronius/fronius/inverter_secondary_test.py b/packages/modules/devices/fronius/fronius/inverter_secondary_test.py deleted file mode 100644 index a8038e8c88..0000000000 --- a/packages/modules/devices/fronius/fronius/inverter_secondary_test.py +++ /dev/null @@ -1,118 +0,0 @@ -from unittest.mock import Mock - - -from modules.common.store._api import LoggingValueStore -from modules.devices.fronius.fronius.config import FroniusSecondaryInverterConfiguration, FroniusSecondaryInverterSetup -from modules.devices.fronius.fronius.inverter_secondary import FroniusSecondaryInverter - - -def test_update(monkeypatch, mock_simcount): - wr = FroniusSecondaryInverter(FroniusSecondaryInverterSetup( - FroniusSecondaryInverterConfiguration(id=1)), device_id=0) - wr.initialize() - - mock = Mock(return_value=None) - monkeypatch.setattr(LoggingValueStore, "set", mock) - mock_simcount.return_value = 0, 0 - - wr.update(json_wr1) - - # mock.assert_called_once() - inverter_state = mock.call_args[0][0] - assert inverter_state.exported == 0 - assert inverter_state.currents == [0, 0, 0] - assert inverter_state.power == -4470.0 - - -json_wr1 = { - "Body": { - "Data": { - "Inverters": { - "1": { - "Battery_Mode": "normal", - "DT": 1, - "E_Day": None, - "E_Total": 148955.258055555, - "E_Year": None, - "P": 20.819091796875, - "SOC": 95.299999999999997 - } - }, - "SecondaryMeters": { - "1": { - "Category": "METER_CAT_WR", - "Label": "PV- 1", - "MLoc": 3.0, - "P": 4470.0 - } - }, - "Site": { - "BackupMode": False, - "BatteryStandby": False, - "E_Day": None, - "E_Total": 148955.258055555, - "E_Year": None, - "Meter_Location": "grid", - "Mode": "bidirectional", - "P_Akku": -11.142402648926, - "P_Grid": -2631.999999999, - "P_Load": -3933.5058593751, - "P_PV": 2173.672851562, - "rel_Autonomy": 100.0, - "rel_SelfConsumption": 59.4570229924 - }, - "Smartloads": { - "Ohmpilots": {} - }, - "Version": "12" - } - }, - "Head": { - "RequestArguments": {}, - "Status": { - "Code": 0, - "Reason": "", - "UserMessage": "" - }, - "Timestamp": "2023-09-25Txx:xx:xx+00:00" - } -} - -json_wr2 = { - "Body": { - "Data": { - "Inverters": { - "1": { - "DT": 232, - "E_Day": 172.69999694824219, - "E_Total": 3372.76953125, - "E_Year": 10754989, - "P": 108 - } - }, - "Site": { - "E_Day": 172.69999694824219, - "E_Total": 3372.7694444444446, - "E_Year": 10754989, - "Meter_Location": "unknown", - "Mode": "produce-only", - "P_Akku": None, - "P_Grid": None, - "P_Load": None, - "P_PV": 108, - "rel_Autonomy": None, - "rel_SelfConsumption": None - }, - "Version": "12" - } - }, - "Head": { - "RequestArguments": {}, - "Status": { - "Code": 0, - "Reason": "", - "UserMessage": "" - }, - "Timestamp": "2021-12-30T10:37:02+01:00" - } -} diff --git a/packages/modules/devices/fronius/fronius/inverter_test.py b/packages/modules/devices/fronius/fronius/inverter_test.py deleted file mode 100644 index a8038e8c88..0000000000 --- a/packages/modules/devices/fronius/fronius/inverter_test.py +++ /dev/null @@ -1,118 +0,0 @@ -from unittest.mock import Mock - - -from modules.common.store._api import LoggingValueStore -from modules.devices.fronius.fronius.config import FroniusSecondaryInverterConfiguration, FroniusSecondaryInverterSetup -from modules.devices.fronius.fronius.inverter_secondary import FroniusSecondaryInverter - - -def test_update(monkeypatch, mock_simcount): - wr = FroniusSecondaryInverter(FroniusSecondaryInverterSetup( - FroniusSecondaryInverterConfiguration(id=1)), device_id=0) - wr.initialize() - - mock = Mock(return_value=None) - monkeypatch.setattr(LoggingValueStore, "set", mock) - mock_simcount.return_value = 0, 0 - - wr.update(json_wr1) - - # mock.assert_called_once() - inverter_state = mock.call_args[0][0] - assert inverter_state.exported == 0 - assert inverter_state.currents == [0, 0, 0] - assert inverter_state.power == -4470.0 - - -json_wr1 = { - "Body": { - "Data": { - "Inverters": { - "1": { - "Battery_Mode": "normal", - "DT": 1, - "E_Day": None, - "E_Total": 148955.258055555, - "E_Year": None, - "P": 20.819091796875, - "SOC": 95.299999999999997 - } - }, - "SecondaryMeters": { - "1": { - "Category": "METER_CAT_WR", - "Label": "PV- 1", - "MLoc": 3.0, - "P": 4470.0 - } - }, - "Site": { - "BackupMode": False, - "BatteryStandby": False, - "E_Day": None, - "E_Total": 148955.258055555, - "E_Year": None, - "Meter_Location": "grid", - "Mode": "bidirectional", - "P_Akku": -11.142402648926, - "P_Grid": -2631.999999999, - "P_Load": -3933.5058593751, - "P_PV": 2173.672851562, - "rel_Autonomy": 100.0, - "rel_SelfConsumption": 59.4570229924 - }, - "Smartloads": { - "Ohmpilots": {} - }, - "Version": "12" - } - }, - "Head": { - "RequestArguments": {}, - "Status": { - "Code": 0, - "Reason": "", - "UserMessage": "" - }, - "Timestamp": "2023-09-25Txx:xx:xx+00:00" - } -} - -json_wr2 = { - "Body": { - "Data": { - "Inverters": { - "1": { - "DT": 232, - "E_Day": 172.69999694824219, - "E_Total": 3372.76953125, - "E_Year": 10754989, - "P": 108 - } - }, - "Site": { - "E_Day": 172.69999694824219, - "E_Total": 3372.7694444444446, - "E_Year": 10754989, - "Meter_Location": "unknown", - "Mode": "produce-only", - "P_Akku": None, - "P_Grid": None, - "P_Load": None, - "P_PV": 108, - "rel_Autonomy": None, - "rel_SelfConsumption": None - }, - "Version": "12" - } - }, - "Head": { - "RequestArguments": {}, - "Status": { - "Code": 0, - "Reason": "", - "UserMessage": "" - }, - "Timestamp": "2021-12-30T10:37:02+01:00" - } -} diff --git a/packages/modules/devices/fronius/fronius_http_api/API_LICENSE b/packages/modules/devices/fronius/fronius_http_api/API_LICENSE new file mode 100644 index 0000000000..e96df6c66d --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/API_LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Stephan Mükusch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/modules/devices/fronius/fronius/__init__.py b/packages/modules/devices/fronius/fronius_http_api/__init__.py similarity index 100% rename from packages/modules/devices/fronius/fronius/__init__.py rename to packages/modules/devices/fronius/fronius_http_api/__init__.py diff --git a/packages/modules/devices/fronius/fronius_http_api/bat.py b/packages/modules/devices/fronius/fronius_http_api/bat.py new file mode 100644 index 0000000000..8690fe7763 --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/bat.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +import logging +from typing import Any, Dict, Optional, TypedDict + +from modules.common.abstract_device import AbstractBat +from modules.common.component_state import BatState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.simcount import SimCounter +from modules.common.store import get_component_value_store +from modules.devices.fronius.fronius_http_api.config import FroniusBatSetup +from modules.devices.fronius.fronius_http_api.config import FroniusConfiguration +from modules.devices.fronius.fronius_http_api.fronius_api import FroniusWR +from modules.common.utils.peak_filter import PeakFilter +from modules.common.component_type import ComponentType + +log = logging.getLogger(__name__) + + +class KwargsDict(TypedDict): + device_config: FroniusConfiguration + device_id: int + + +class FroniusBat(AbstractBat): + def __init__(self, component_config: FroniusBatSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.device_config: FroniusConfiguration = self.kwargs['device_config'] + self.__device_id: int = self.kwargs['device_id'] + 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.last_mode: Optional[str] = 'Undefined' + self.bat_api: Optional[FroniusWR] = None + + def update(self, powerflow_response: Dict) -> None: + meter_id = str(self.component_config.configuration.meter_id) + + # Anders als beim Wechselrichter ist bei Speicher/Zähler ein "keine Antwort" kein normaler + # Nachtmodus-Zustand, der als 0 W angenommen werden darf -- der Speicher entlädt bzw. der + # Zähler misst weiterhin, daher hier fehlerhaft werden statt falsche 0-Werte zu melden. + if isinstance(powerflow_response, Exception): + raise powerflow_response + + try: + power = int(powerflow_response["Body"]["Data"]["Site"]["P_Akku"]) * -1 + except TypeError: + # Wenn WR aus bzw. im Standby (keine Antwort), ersetze leeren Wert durch eine 0. + power = 0 + + try: + resp_json_id = dict(powerflow_response["Body"]["Data"]) + if "Inverters" in resp_json_id: + soc = float(resp_json_id["Inverters"]["1"]["SOC"]) + else: + soc = float(resp_json_id.get(meter_id)["Controller"]["StateOfCharge_Relative"]) + except TypeError: + # Wenn WR aus bzw. im Standby (keine Antwort), ersetze leeren Wert durch eine 0. + soc = 0 + + self.peak_filter.check_values(power) + imported, exported = self.sim_counter.sim_count(power) + bat_state = BatState( + power=power, + soc=soc, + imported=imported, + exported=exported + ) + self.store.set(bat_state) + + def set_power_limit(self, power_limit: Optional[int]) -> None: + username = self.component_config.configuration.username + password = self.component_config.configuration.password + if username is None or password is None: + log.warning("Fronius Speicher: Keine Batteriesteuerung möglich, da keine Zugangsdaten hinterlegt sind.") + return + + if self.bat_api is None: + # Der Verbindungsaufbau (Login, Firmware-Erkennung) passiert erst hier und nicht schon in + # initialize(), da er echte HTTP-Requests an den Wechselrichter auslöst und ohne Zugangsdaten + # ohnehin fehlschlagen würde. + self.bat_api = FroniusWR({ + 'address': self.device_config.ip_address, + 'user': username, + 'password': password, + }) + else: + self.bat_api.set_config(self.device_config.ip_address, username, password) + + log.debug(f'last_mode: {self.last_mode}') + + if power_limit is None: + log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") + if self.last_mode is not None: + self.bat_api.set_mode_self_regulation() + self.last_mode = None + elif power_limit == 0: + log.debug("Aktive Batteriesteuerung. Batterie wird auf Stop gesetzt und nicht entladen") + if self.last_mode != 'stop': + self.bat_api.set_mode_avoid_discharge() + self.last_mode = 'stop' + elif power_limit < 0: + self.bat_api.set_mode_force_discharge(abs(power_limit)) + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {abs(power_limit)} W " + "entladen für den Hausverbrauch") + self.last_mode = 'discharge' + elif power_limit > 0: + self.bat_api.set_mode_force_charge(power_limit) + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_limit} W geladen") + self.last_mode = 'charge' + + def power_limit_controllable(self) -> bool: + # Nur steuerbar, wenn Installateur-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.username is not None and config.password is not None + + +component_descriptor = ComponentDescriptor(configuration_factory=FroniusBatSetup) diff --git a/packages/modules/devices/fronius/fronius_http_api/bat_test.py b/packages/modules/devices/fronius/fronius_http_api/bat_test.py new file mode 100644 index 0000000000..4707128295 --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/bat_test.py @@ -0,0 +1,181 @@ +from unittest.mock import Mock + +import pytest + +from dataclass_utils import dataclass_from_dict +from modules.common.store._api import LoggingValueStore +from modules.conftest import SAMPLE_IP +from modules.devices.fronius.fronius_http_api import bat +from modules.devices.fronius.fronius_http_api.config import FroniusBatSetup, FroniusConfiguration + + +def test_update(monkeypatch, mock_simcount): + component_config = FroniusBatSetup() + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + assert component_config.configuration.meter_id == 0 + battery = bat.FroniusBat(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + battery.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + battery.update(json) + + # mock_valuestore.assert_called_once() + battery_state = mock.call_args[0][0] + assert battery_state.exported == 0 + assert battery_state.imported == 0 + assert battery_state.power == -2288 + assert battery_state.soc == 60.8 + + +def test_update_connection_error_raises(monkeypatch, mock_simcount): + # Anders als beim Wechselrichter ist ein "keine Antwort" beim Speicher kein normaler + # Nachtmodus-Zustand (der Speicher entlädt weiterhin) -- die Komponente muss fehlerhaft werden, + # statt falsche 0-Werte zu melden. + component_config = FroniusBatSetup() + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + battery = bat.FroniusBat(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + battery.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + with pytest.raises(ConnectionError): + battery.update(ConnectionError("no response")) + + +def test_power_limit_controllable_without_credentials(): + component_config = FroniusBatSetup() + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + battery = bat.FroniusBat(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + battery.initialize() + + assert battery.power_limit_controllable() is False + + +def test_power_limit_controllable_with_credentials(): + component_config = FroniusBatSetup() + component_config.configuration.username = "installer" + component_config.configuration.password = "secret" + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + battery = bat.FroniusBat(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + battery.initialize() + + assert battery.power_limit_controllable() is True + + +def test_set_power_limit_without_credentials_does_not_connect(monkeypatch): + # Ohne Zugangsdaten darf gar nicht erst versucht werden, eine Verbindung aufzubauen -- das würde + # mit ungültigen Zugangsdaten fehlschlagende Login-Versuche gegen den Wechselrichter auslösen. + mock_fronius_wr = Mock(side_effect=AssertionError("FroniusWR sollte ohne Zugangsdaten nicht erzeugt werden")) + monkeypatch.setattr(bat, "FroniusWR", mock_fronius_wr) + + component_config = FroniusBatSetup() + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + battery = bat.FroniusBat(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + battery.initialize() + + battery.set_power_limit(0) + + mock_fronius_wr.assert_not_called() + + +@pytest.mark.parametrize("power_limit, expected_call, expected_mode, repeats_call", [ + pytest.param(None, "set_mode_self_regulation", None, False, id="self_regulation"), + pytest.param(0, "set_mode_avoid_discharge", "stop", False, id="stop"), + pytest.param(-500, "set_mode_force_discharge", "discharge", True, id="discharge"), + pytest.param(500, "set_mode_force_charge", "charge", True, id="charge"), +]) +def test_set_power_limit_with_credentials(monkeypatch, power_limit, expected_call, expected_mode, repeats_call): + mock_bat_api = Mock() + mock_fronius_wr = Mock(return_value=mock_bat_api) + monkeypatch.setattr(bat, "FroniusWR", mock_fronius_wr) + + component_config = FroniusBatSetup() + component_config.configuration.username = "installer" + component_config.configuration.password = "secret" + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + battery = bat.FroniusBat(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + battery.initialize() + + battery.set_power_limit(power_limit) + + mock_fronius_wr.assert_called_once_with({ + 'address': SAMPLE_IP, + 'user': "installer", + 'password': "secret", + }) + getattr(mock_bat_api, expected_call).assert_called_once() + assert battery.last_mode == expected_mode + + # Ein zweiter Aufruf muss die bestehende FroniusWR-Instanz wiederverwenden (nicht neu erzeugen). + # Self-regulation/Stop dürfen dabei nicht erneut angestoßen werden, sobald der Modus schon aktiv + # ist; Force-Charge/-Discharge werden hingegen bei jedem Aufruf erneut gesetzt, da sich die + # gewünschte Lade-/Entladeleistung geändert haben könnte. + battery.set_power_limit(power_limit) + mock_fronius_wr.assert_called_once() + expected_count = 2 if repeats_call else 1 + assert getattr(mock_bat_api, expected_call).call_count == expected_count + mock_bat_api.set_config.assert_called_with(SAMPLE_IP, "installer", "secret") + + +json = { + "Body": { + "Data": { + "Inverters": { + "1": { + "Battery_Mode": "normal", + "DT": 1, + "E_Day": None, + "E_Total": 9805020.3608333338, + "E_Year": None, + "P": 2246.208984375, + "SOC": 60.799999999999997 + } + }, + "Site": { + "BackupMode": "false", + "BatteryStandby": "true", + "E_Day": None, + "E_Total": 9805020.3608333338, + "E_Year": None, + "Meter_Location": "grid", + "Mode": "bidirectional", + "P_Akku": 2288.587158203125, + "P_Grid": 280.39999999999998, + "P_Load": -2938.6320312500002, + "P_PV": 3.5314908027648926, + "rel_Autonomy": 90.458145252002623, + "rel_SelfConsumption": 100.0 + }, + "Smartloads": { + "Ohmpilots": {} + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2022-01-03T17:17:36+00:00" + } +} diff --git a/packages/modules/devices/fronius/fronius/config.py b/packages/modules/devices/fronius/fronius_http_api/config.py similarity index 68% rename from packages/modules/devices/fronius/fronius/config.py rename to packages/modules/devices/fronius/fronius_http_api/config.py index d292f6a623..417005297d 100644 --- a/packages/modules/devices/fronius/fronius/config.py +++ b/packages/modules/devices/fronius/fronius_http_api/config.py @@ -33,7 +33,7 @@ def __init__(self, ip_address: Optional[str] = None): class Fronius: def __init__(self, name: str = "Fronius", - type: str = "fronius", + type: str = "fronius_http_api", id: int = 0, configuration: FroniusConfiguration = None) -> None: self.name = name @@ -44,8 +44,15 @@ def __init__(self, class FroniusBatConfiguration: - def __init__(self, meter_id: int = 0): + def __init__(self, + meter_id: int = 0, + username: Optional[str] = None, + password: Optional[str] = None): self.meter_id = meter_id + # Installateur-Zugangsdaten, nur für die aktive Speichersteuerung benötigt. Ohne sie wird der + # Speicher weiterhin ausgelesen, kann aber nicht aktiv gesteuert werden. + self.username = username + self.password = password class FroniusBatSetup(ComponentSetup[FroniusBatConfiguration]): @@ -58,40 +65,33 @@ def __init__(self, super().__init__(name, type, id, configuration or FroniusBatConfiguration(), **kwargs) -class FroniusS0CounterConfiguration: - def __init__(self): - pass +# Zähler ist im Wechselrichter integriert (S0), keine eigene SmartMeter-Hardware -- die Netz-Leistung +# wird aus der ohnehin für andere Komponenten abgerufenen PowerFlow-Antwort gelesen, meter_id ist dann +# ohne Bedeutung. +COUNTER_VARIANT_S0 = 3 -class FroniusS0CounterSetup(ComponentSetup[FroniusS0CounterConfiguration]): - def __init__(self, - name: str = "Fronius S0 Zähler", - type: str = "counter_s0", - id: int = 0, - configuration: FroniusS0CounterConfiguration = None, - **kwargs) -> None: - super().__init__(name, type, id, configuration or FroniusS0CounterConfiguration(), **kwargs) - - -class FroniusSmCounterConfiguration: +class FroniusCounterConfiguration: def __init__(self, meter_id: int = 0, variant: int = 0): self.meter_id = meter_id self.variant = variant -class FroniusSmCounterSetup(ComponentSetup[FroniusSmCounterConfiguration]): +class FroniusCounterSetup(ComponentSetup[FroniusCounterConfiguration]): def __init__(self, - name: str = "Fronius SM Zähler", - type: str = "counter_sm", + name: str = "Fronius Zähler", + type: str = "counter", id: int = 0, - configuration: FroniusSmCounterConfiguration = None, + configuration: FroniusCounterConfiguration = None, **kwargs) -> None: - super().__init__(name, type, id, configuration or FroniusSmCounterConfiguration(), **kwargs) + super().__init__(name, type, id, configuration or FroniusCounterConfiguration(), **kwargs) class FroniusInverterConfiguration: - def __init__(self): - pass + def __init__(self, secondary_id: Optional[int] = None): + # None: primärer Wechselrichter (Site.P_PV der PowerFlow-Antwort). + # gesetzt: sekundärer/companion Wechselrichter mit dieser ID (Body.Data.SecondaryMeters). + self.secondary_id = secondary_id class FroniusInverterSetup(ComponentSetup[FroniusInverterConfiguration]): @@ -104,21 +104,6 @@ def __init__(self, super().__init__(name, type, id, configuration or FroniusInverterConfiguration(), **kwargs) -class FroniusSecondaryInverterConfiguration: - def __init__(self, id: int = 1): - self.id = id - - -class FroniusSecondaryInverterSetup(ComponentSetup[FroniusSecondaryInverterConfiguration]): - def __init__(self, - name: str = "Sekundärer Wechselrichter", - type: str = "inverter_secondary", - id: int = 0, - configuration: FroniusSecondaryInverterConfiguration = None, - **kwargs) -> None: - super().__init__(name, type, id, configuration or FroniusSecondaryInverterConfiguration(), **kwargs) - - class FroniusProductionMeterConfiguration: def __init__(self, meter_id: int = 0, variant: int = 0): self.meter_id = meter_id diff --git a/packages/modules/devices/fronius/fronius_http_api/counter.py b/packages/modules/devices/fronius/fronius_http_api/counter.py new file mode 100644 index 0000000000..8bff6df4a5 --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/counter.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import logging +from typing import Dict, Optional, TypedDict, Any + +from modules.common import req +from modules.common.abstract_device import AbstractCounter +from modules.common.component_state import CounterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.simcount import SimCounter +from modules.common.store import get_component_value_store +from modules.devices.fronius.fronius_http_api import meter_reader +from modules.devices.fronius.fronius_http_api.config import FroniusConfiguration, MeterLocation, COUNTER_VARIANT_S0 +from modules.devices.fronius.fronius_http_api.config import FroniusCounterSetup +from modules.common.utils.peak_filter import PeakFilter +from modules.common.component_type import ComponentType + +log = logging.getLogger(__name__) + + +class KwargsDict(TypedDict): + device_id: int + device_config: FroniusConfiguration + + +class FroniusCounter(AbstractCounter): + def __init__(self, component_config: FroniusCounterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.device_config: FroniusConfiguration = 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.COUNTER, self.component_config.id, self.fault_state) + self.cache_key = f"{self.component_config.type}_{self.component_config.id}" + + def update(self, powerflow_response: Dict, meter_system_response: Optional[Dict] = None) -> None: + config = self.component_config.configuration + + if config.variant == COUNTER_VARIANT_S0: + # Im Wechselrichter integrierter S0-Zähler: keine eigene SmartMeter-Hardware, sondern der + # Netz-Wert aus der ohnehin abgerufenen PowerFlow-Antwort. + if isinstance(powerflow_response, Exception): + raise powerflow_response + power = float(powerflow_response["Body"]["Data"]["Site"]["P_Grid"]) or 0 + self.peak_filter.check_values(power) + imported, exported = self.sim_counter.sim_count(power) + self.store.set(CounterState(imported=imported, exported=exported, power=power)) + return + + meter = meter_reader.read_meter( + req.get_http_session(), self.device_config.ip_address, config.meter_id, config.variant, + self.cache_key, meter_system_response) + + if meter.location == MeterLocation.load: + power, power_inverter = meter_reader.get_flow_power(powerflow_response) + # wenn SmartMeter im Verbrauchszweig sitzt sind folgende Annahmen getroffen: + # PV Leistung wird gleichmäßig auf alle Phasen verteilt + # Spannungen und Leistungsfaktoren sind am Verbrauchszweig == Einspeisepunkt + # Hier gehen wir mal davon aus, dass der Wechselrichter seine PV-Leistung gleichmäßig + # auf alle Phasen aufteilt. + powers = [-1 * p - power_inverter / 3 for p in meter.powers] + else: + powers = meter.powers + power = meter.power_sum + # for all meter locations except "grid", negative power is consumption! + if meter.location in (MeterLocation.external, MeterLocation.subload): + power *= -1 + + currents = [powers[i] / meter.voltages[i] for i in range(0, 3)] + + counter_state = CounterState( + voltages=meter.voltages, + currents=currents, + powers=powers, + power=power, + frequency=meter.frequency, + power_factors=meter.power_factors + ) + self.peak_filter.check_values(counter_state.power) + counter_state.imported, counter_state.exported = self.sim_counter.sim_count(counter_state.power) + self.store.set(counter_state) + + +component_descriptor = ComponentDescriptor(configuration_factory=FroniusCounterSetup) diff --git a/packages/modules/devices/fronius/fronius/counter_sm_test.py b/packages/modules/devices/fronius/fronius_http_api/counter_test.py similarity index 72% rename from packages/modules/devices/fronius/fronius/counter_sm_test.py rename to packages/modules/devices/fronius/fronius_http_api/counter_test.py index e48460dd9b..a76efa014d 100644 --- a/packages/modules/devices/fronius/fronius/counter_sm_test.py +++ b/packages/modules/devices/fronius/fronius_http_api/counter_test.py @@ -1,21 +1,32 @@ +import copy from unittest.mock import Mock +import pytest import requests_mock from dataclass_utils import dataclass_from_dict from modules.common.store._api import LoggingValueStore from modules.conftest import SAMPLE_IP -from modules.devices.fronius.fronius import counter_sm -from modules.devices.fronius.fronius.config import FroniusConfiguration, FroniusSmCounterSetup +from modules.devices.fronius.fronius_http_api import meter_reader +from modules.devices.fronius.fronius_http_api.config import ( + FroniusConfiguration, FroniusCounterSetup, COUNTER_VARIANT_S0) +from modules.devices.fronius.fronius_http_api.counter import FroniusCounter + + +@pytest.fixture(autouse=True) +def clear_meter_location_cache(): + meter_reader._last_known_location.clear() + yield + meter_reader._last_known_location.clear() def test_update_grid(monkeypatch, requests_mock: requests_mock.Mocker, mock_simcount): - component_config = FroniusSmCounterSetup() + component_config = FroniusCounterSetup() assert component_config.configuration.variant == 0 device_config = FroniusConfiguration() device_config.ip_address = SAMPLE_IP assert component_config.configuration.meter_id == 0 - counter = counter_sm.FroniusSmCounter(component_config, device_config=dataclass_from_dict( + counter = FroniusCounter(component_config, device_config=dataclass_from_dict( FroniusConfiguration, device_config), device_id=0) counter.initialize() @@ -26,7 +37,7 @@ def test_update_grid(monkeypatch, requests_mock: requests_mock.Mocker, mock_simc "http://" + SAMPLE_IP + "/solar_api/v1/GetMeterRealtimeData.cgi", json=json_grid) - counter.update() + counter.update({}) # mock.assert_called_once() counter_state = mock.call_args[0][0] @@ -41,24 +52,21 @@ def test_update_grid(monkeypatch, requests_mock: requests_mock.Mocker, mock_simc assert counter_state.power == sum(counter_state.powers) -def test_update_grid_var2(monkeypatch, requests_mock: requests_mock.Mocker, mock_simcount): - component_config = FroniusSmCounterSetup() +def test_update_grid_var2(monkeypatch, mock_simcount): + component_config = FroniusCounterSetup() component_config.configuration.variant = 2 device_config = FroniusConfiguration() device_config.ip_address = SAMPLE_IP assert component_config.configuration.meter_id == 0 - counter = counter_sm.FroniusSmCounter(component_config, device_config=dataclass_from_dict( + counter = FroniusCounter(component_config, device_config=dataclass_from_dict( FroniusConfiguration, device_config), device_id=0) counter.initialize() mock = Mock(return_value=None) monkeypatch.setattr(LoggingValueStore, "set", mock) mock_simcount.return_value = 0, 0 - requests_mock.get( - "http://" + SAMPLE_IP + "/solar_api/v1/GetMeterRealtimeData.cgi", - json=json_grid_var2) - counter.update() + counter.update({}, json_grid_var2) # mock.assert_called_once() counter_state = mock.call_args[0][0] @@ -72,24 +80,21 @@ def test_update_grid_var2(monkeypatch, requests_mock: requests_mock.Mocker, mock assert counter_state.voltages == [232.3, 231.5, 233.4] -def test_update_external_var2(monkeypatch, requests_mock: requests_mock.Mocker, mock_simcount): - component_config = FroniusSmCounterSetup() +def test_update_external_var2(monkeypatch, mock_simcount): + component_config = FroniusCounterSetup() component_config.configuration.variant = 2 device_config = FroniusConfiguration() device_config.ip_address = SAMPLE_IP component_config.configuration.meter_id = 1 - counter = counter_sm.FroniusSmCounter(component_config, device_config=dataclass_from_dict( + counter = FroniusCounter(component_config, device_config=dataclass_from_dict( FroniusConfiguration, device_config), device_id=0) counter.initialize() mock = Mock(return_value=None) monkeypatch.setattr(LoggingValueStore, "set", mock) mock_simcount.return_value = 0, 0 - requests_mock.get( - "http://" + SAMPLE_IP + "/solar_api/v1/GetMeterRealtimeData.cgi", - json=json_ext_var2) - counter.update() + counter.update({}, json_ext_var2) # mock.assert_called_once() counter_state = mock.call_args[0][0] @@ -97,19 +102,22 @@ def test_update_external_var2(monkeypatch, requests_mock: requests_mock.Mocker, assert counter_state.imported == 0 assert counter_state.currents == [-5.373121093182142, -5.664436188811191, -5.585225225225224] assert counter_state.frequency == 49.9 - assert counter_state.power == 3809.4 + # external location: per Fronius Solar API V1 (4.8.7), positive PowerReal_P_Sum means generation + # and negative means consumption -- the opposite of the "grid" location convention that openWB's + # CounterState.power follows, so the raw value is sign-flipped. + assert counter_state.power == -3809.4 assert counter_state.powers == [-1232.0566666666653, -1296.0230000000006, -1281.2506666666663] assert counter_state.power_factors == [0.643, 0.68, 0.667] assert counter_state.voltages == [229.3, 228.8, 229.4] def test_update_load(monkeypatch, requests_mock: requests_mock.Mocker, mock_simcount): - component_config = FroniusSmCounterSetup() + component_config = FroniusCounterSetup() assert component_config.configuration.variant == 0 device_config = FroniusConfiguration() device_config.ip_address = SAMPLE_IP component_config.configuration.meter_id = 2 - counter = counter_sm.FroniusSmCounter(component_config, device_config=dataclass_from_dict( + counter = FroniusCounter(component_config, device_config=dataclass_from_dict( FroniusConfiguration, device_config), device_id=0) counter.initialize() @@ -120,11 +128,7 @@ def test_update_load(monkeypatch, requests_mock: requests_mock.Mocker, mock_simc "http://" + SAMPLE_IP + "/solar_api/v1/GetMeterRealtimeData.cgi", json=json_load_meter) - requests_mock.get( - "http://" + SAMPLE_IP + "/solar_api/v1/GetPowerFlowRealtimeData.fcgi", - json=json_load_power) - - counter.update() + counter.update(json_load_power) # mock.assert_called_once() counter_state = mock.call_args[0][0] @@ -139,6 +143,142 @@ def test_update_load(monkeypatch, requests_mock: requests_mock.Mocker, mock_simc assert abs(counter_state.power - sum(counter_state.powers)) < 5 +def test_update_s0(monkeypatch, mock_simcount): + # Im Wechselrichter integrierter S0-Zähler: liest den Netz-Wert direkt aus der PowerFlow-Antwort, + # ohne eigene SmartMeter-Abfrage. + component_config = FroniusCounterSetup() + component_config.configuration.variant = COUNTER_VARIANT_S0 + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + counter = FroniusCounter(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + counter.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + counter.update(json_s0_power) + + counter_state = mock.call_args[0][0] + assert counter_state.exported == 0 + assert counter_state.imported == 0 + assert counter_state.currents == [0.0, 0.0, 0.0] + assert counter_state.frequency == 50 + assert counter_state.power == 330.7664210983294 + assert counter_state.powers == [0, 0, 0] + assert counter_state.power_factors == [0, 0, 0] + assert counter_state.voltages == [230, 230, 230] + + +def test_update_s0_connection_error_raises(monkeypatch, mock_simcount): + # Anders als beim Wechselrichter ist ein "keine Antwort" beim Zähler kein normaler + # Nachtmodus-Zustand (der Zähler misst weiterhin) -- die Komponente muss fehlerhaft werden, + # statt falsche 0-Werte zu melden. + component_config = FroniusCounterSetup() + component_config.configuration.variant = COUNTER_VARIANT_S0 + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + counter = FroniusCounter(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + counter.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + with pytest.raises(ConnectionError): + counter.update(ConnectionError("no response")) + + +def test_update_var2_missing_location_falls_back_to_cache(monkeypatch, mock_simcount): + component_config = FroniusCounterSetup() + component_config.configuration.variant = 2 + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + counter = FroniusCounter(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + counter.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + json_grid_var2_missing_location = copy.deepcopy(json_grid_var2) + del json_grid_var2_missing_location["Body"]["Data"]["0"]["SMARTMETER_VALUE_LOCATION_U16"] + + counter.update({}, json_grid_var2) # populates the meter-location cache + counter.update({}, json_grid_var2_missing_location) # location field missing this time, must fall back + + assert mock.call_count == 2 + + +def test_update_var2_missing_location_without_cache_raises(monkeypatch, mock_simcount): + component_config = FroniusCounterSetup() + component_config.configuration.variant = 2 + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + counter = FroniusCounter(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + counter.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + json_grid_var2_missing_location = copy.deepcopy(json_grid_var2) + del json_grid_var2_missing_location["Body"]["Data"]["0"]["SMARTMETER_VALUE_LOCATION_U16"] + + with pytest.raises(KeyError): + counter.update({}, json_grid_var2_missing_location) + + +json_s0_power = { + "Body": { + "Data": { + "Inverters": { + "1": { + "DT": 105, + "E_Day": 9668, + "E_Total": 45503300, + "E_Year": 7010823.5, + "P": 0 + }, + "2": { + "DT": 115, + "E_Day": 16189, + "E_Total": 16581639, + "E_Year": 11989318, + "P": 0 + } + }, + "Site": { + "E_Day": 25857, + "E_Total": 62084939, + "E_Year": 19000141.5, + "Meter_Location": "load", + "Mode": "vague-meter", + "P_Akku": None, + "P_Grid": 330.7664210983294, + "P_Load": -330.7664210983294, + "P_PV": None, + "rel_Autonomy": 0, + "rel_SelfConsumption": None + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-08-11T06:27:35+00:00" + } +} + json_grid = { "Body": { "Data": { diff --git a/packages/modules/devices/fronius/fronius_http_api/device.py b/packages/modules/devices/fronius/fronius_http_api/device.py new file mode 100644 index 0000000000..3c57352bd5 --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/device.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable, Union + +import requests + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_device import ComponentFactoryByType, ConfigurableDevice, MultiComponentUpdater +from modules.devices.fronius.fronius_http_api.bat import FroniusBat +from modules.devices.fronius.fronius_http_api.config import ( + Fronius, FroniusBatSetup, FroniusCounterSetup, FroniusProductionMeterSetup, FroniusInverterSetup) +from modules.devices.fronius.fronius_http_api.counter import FroniusCounter +from modules.devices.fronius.fronius_http_api.inverter import FroniusInverter +from modules.devices.fronius.fronius_http_api.inverter_production_meter import FroniusProductionMeter + +log = logging.getLogger(__name__) + +fronius_component_classes = Union[FroniusBat, FroniusCounter, FroniusInverter, FroniusProductionMeter] + + +def create_device(device_config: Fronius): + def create_bat_component(component_config: FroniusBatSetup): + return FroniusBat(component_config=component_config, + device_id=device_config.id, + device_config=device_config.configuration) + + def create_counter_component(component_config: FroniusCounterSetup): + return FroniusCounter(component_config=component_config, + device_id=device_config.id, + device_config=device_config.configuration) + + def create_inverter_component(component_config: FroniusInverterSetup): + return FroniusInverter(component_config=component_config, + device_id=device_config.id) + + def create_inverter_production_meter_component(component_config: FroniusProductionMeterSetup): + return FroniusProductionMeter(component_config=component_config, + device_id=device_config.id, + device_config=device_config.configuration) + + def update_components(components: Iterable[fronius_component_classes]): + powerflow_response = None + meter_system_response = None + + def get_powerflow_response(): + nonlocal powerflow_response + if powerflow_response is None: + try: + powerflow_response = req.get_http_session().get( + (f'http://{device_config.configuration.ip_address}' + '/solar_api/v1/GetPowerFlowRealtimeData.fcgi'), + params=(('Scope', 'System'),), + timeout=5).json() + except (requests.ConnectTimeout, requests.ConnectionError) as e: + # Nachtmodus: WR ist ausgeschaltet + powerflow_response = e + return powerflow_response + + def get_meter_system_response(): + nonlocal meter_system_response + if meter_system_response is None: + meter_system_response = req.get_http_session().get( + (f'http://{device_config.configuration.ip_address}' + '/solar_api/v1/GetMeterRealtimeData.cgi'), + params=(('Scope', 'System'),), + timeout=5).json() + return meter_system_response + + for component in components: + component_type = component.component_config.type + if component_type in ("inverter", "bat"): + component.update(get_powerflow_response()) + elif component_type == "counter": + variant = component.component_config.configuration.variant + component.update(get_powerflow_response(), + get_meter_system_response() if variant == 2 else None) + elif component_type == "inverter_production_meter": + variant = component.component_config.configuration.variant + component.update(get_meter_system_response() if variant == 2 else None) + + return ConfigurableDevice( + device_config=device_config, + component_factory=ComponentFactoryByType( + bat=create_bat_component, + counter=create_counter_component, + inverter=create_inverter_component, + inverter_production_meter=create_inverter_production_meter_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=Fronius) diff --git a/packages/modules/devices/fronius/fronius_http_api/fronius_api.py b/packages/modules/devices/fronius/fronius_http_api/fronius_api.py new file mode 100644 index 0000000000..640423938c --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/fronius_api.py @@ -0,0 +1,709 @@ +""" +Modified Version of the Fronius API: +https://github.com/MaStr/batcontrol + +This module provides a class `FroniusWR` for handling Fronius GEN24 Inverters. +It includes methods for interacting with the inverter's API, managing battery +configurations, and controlling various inverter settings. + +The Fronius Web-API is a bit quirky, which is reflected in the code. + +The Web-Login form does send a first request without authentication, which +returns a nonce. This nonce is then used to create a digest for the login +request. + +Parts of the information can be called without authentication, but some +settings require authentication. We tackle a 401 as a signal to login again +and retry the request. + +Yes, the Webfrontend does send the password on each authenticated request hashed +with MD5, nounce etc. + +""" +import time +import os +import logging +import json +import hashlib +from dataclasses import dataclass +import requests +from packaging import version + +logger = logging.getLogger(__name__) +logger.info('Loading Fronius bat control API module') + + +def hash_utf8(x, algorithm="MD5"): + """Hash a string or bytes object. + + Args: + x: String or bytes to hash + algorithm: Hash algorithm to use ("MD5" or "SHA256") + """ + if isinstance(x, str): + x = x.encode("utf-8") + + if algorithm.upper() == "SHA256": + return hashlib.sha256(x).hexdigest() + else: # Default to MD5 for backward compatibility + return hashlib.md5(x).hexdigest() + + +class MockResponse: + """ Mock response object to return when no update is needed """ + + def __init__(self): + self.text = '{"writeSuccess": ["timeofuse"]}' + self.status_code = 200 + + +@dataclass +class FroniusApiConfig: + """Configuration for Fronius API endpoints and behavior.""" + from_version: version.Version + to_version: version.Version + version_path: str + powerflow_path: str + storage_path: str + config_battery_path: str + config_powerunit_path: str + config_solar_api_path: str + config_timeofuse_path: str + commands_login_path: str + commands_logout_path: str + auth_algorithm: str = "SHA256" # Authentication algorithm: "MD5" or "SHA256" + + +# Alle Konfigurationen in einer Liste +API_CONFIGS = [ + FroniusApiConfig( + from_version=version.parse("0.0.0"), + to_version=version.parse("1.28.7-1"), + version_path='/status/version', + powerflow_path='/solar_api/v1/GetPowerFlowRealtimeData.fcgi', + storage_path='/solar_api/v1/GetStorageRealtimeData.cgi', + config_battery_path='/config/batteries', + config_powerunit_path='/config/setup/powerunit', + config_solar_api_path='/config/solar_api', + config_timeofuse_path='/config/timeofuse', + commands_login_path='/commands/Login', + commands_logout_path='/commands/Logout', + auth_algorithm="MD5", + ), + FroniusApiConfig( + from_version=version.parse("1.28.7-1"), + to_version=version.parse("1.36"), + version_path='/status/version', + powerflow_path='/solar_api/v1/GetPowerFlowRealtimeData.fcgi', + storage_path='/solar_api/v1/GetStorageRealtimeData.cgi', + config_battery_path='/config/batteries', + config_powerunit_path='/config/powerunit', + config_solar_api_path='/config/solar_api', + config_timeofuse_path='/config/timeofuse', + commands_login_path='/commands/Login', + commands_logout_path='/commands/Logout', + auth_algorithm="MD5", + ), + FroniusApiConfig( + from_version=version.parse("1.36"), + to_version=version.parse("1.38.6-1"), + version_path='/api/status/version', + powerflow_path='/solar_api/v1/GetPowerFlowRealtimeData.fcgi', + storage_path='/solar_api/v1/GetStorageRealtimeData.cgi', + config_battery_path='/api/config/batteries', + config_powerunit_path='/api/config/powerunit', + config_solar_api_path='/api/config/solar_api', + config_timeofuse_path='/api/config/timeofuse', + commands_login_path='/api/commands/Login', + commands_logout_path='/api/commands/Logout', + auth_algorithm="MD5", + ), + FroniusApiConfig( + from_version=version.parse("1.38.6-1"), + to_version=version.parse("9999.99.99"), + version_path='/api/status/version', + powerflow_path='/solar_api/v1/GetPowerFlowRealtimeData.fcgi', + storage_path='/solar_api/v1/GetStorageRealtimeData.cgi', + config_battery_path='/api/config/batteries', + config_powerunit_path='/api/config/powerunit', + config_solar_api_path='/api/config/solar_api', + config_timeofuse_path='/api/config/timeofuse', + commands_login_path='/api/commands/Login', + commands_logout_path='/api/commands/Logout', + auth_algorithm="SHA256", + ), +] + + +def get_api_config(fw_version: version) -> FroniusApiConfig: + """Get the API configuration for the given firmware version.""" + for config in API_CONFIGS: + if config.from_version <= fw_version < config.to_version: + return config + raise RuntimeError( + f"Keine API Konfiguration fuer Firmware-Version {fw_version}") + + +class FroniusWR: + """ Class for Handling Fronius GEN24 Inverters """ + + def __init__(self, config: dict) -> None: + + # We are doing three login tests during first login. + # As MD5 was the default on the old firmware, the latest + # retries should be MD5. + self.usable_password_hash_methods = [ + "SHA256", # First try: SHA256 + "MD5", # Second try: MD5 + "MD5" # Third try: MD5 again (retry with same method) + ] + self._last_password_hash_method_index = -1 + self.password_hash = None + self.subsequent_login = False + self.ncvalue_num = 1 + self.cnonce = hashlib.md5(os.urandom(8)).hexdigest() + self.login_attempts = 0 + self.address = str(config.get('address', None)) + self.nonce = 0 + self.user = str(config.get('user', None)) + self.password = str(config.get('password', None)) + + # Fronius API IDs - configurable with defaults + self.controller_id = str(config.get('fronius_controller_id', '0')) + self.fronius_version = self.get_firmware_version() + self.api_config = get_api_config(self.fronius_version) + + # Verify that the configured IDs are valid + self._verify_fronius_ids() + self.set_solar_api_active(True) + + self.set_allow_grid_charging(True) + + self.prev_time_of_use = None + self._time_of_use_expires = 0 + + def get_firmware_version(self) -> version: + """ Get the firmware version of the inverter.""" + response = None + + # This stays as a hardcoded path for now + # since 1.36 /api/status/version + path = '/api/status/version' + + # Try to get the version from the new path + try: + response = self.send_request( + path, method='GET', payload={}, auth=False) + except RuntimeError: + # If it fails, try the old path + path = '/status/version' + response = self.send_request( + path, method='GET', payload={}, auth=False) + + if not response: + raise RuntimeError('Failed to retrieve firmware version') + version_dict = json.loads(response.text) + version_string = version_dict["swrevisions"]["GEN24"] + logger.info('Fronius firmware version: %s', version_string) + return version.parse(version_string) + + def get_powerunit_config(self): + """ Get additional PowerUnit configuration for backup power. + Returns: dict with backup power configuration + """ + path = self.api_config.config_powerunit_path + response = self.send_request(path, auth=True) + if not response: + logger.error( + 'Failed to get power unit configuration. Returning empty dict' + ) + return {} + result = json.loads(response.text) + return result + + def set_allow_grid_charging(self, value: bool): + """ Switches grid charging on (true) or off.""" + if value: + payload = '{"HYB_EVU_CHARGEFROMGRID": true}' + else: + payload = '{"HYB_EVU_CHARGEFROMGRID": false}' + path = self.api_config.config_battery_path + response = self.send_request( + path, method='POST', payload=payload, auth=True) + response_dict = json.loads(response.text) + expected_write_successes = ['HYB_EVU_CHARGEFROMGRID'] + for expected_write_success in expected_write_successes: + if expected_write_success not in response_dict['writeSuccess']: + raise RuntimeError(f'failed to set {expected_write_success}') + return response + + def set_solar_api_active(self, value: bool): + """ Switches Solar.API on (true) or off. Solar.API is required to get SOC values.""" + if value: + payload = '{"SolarAPIv1Enabled": true}' + else: + payload = '{"SolarAPIv1Enabled": false}' + path = self.api_config.config_solar_api_path + response = self.send_request( + path, method='POST', payload=payload, auth=True) + response_dict = json.loads(response.text) + expected_write_successes = ['SolarAPIv1Enabled'] + for expected_write_success in expected_write_successes: + if expected_write_success not in response_dict['writeSuccess']: + raise RuntimeError(f'failed to set {expected_write_success}') + return response + + def get_time_of_use(self): + """ Get time of use configuration from inverter with 15-minute caching.""" + now = time.time() + if (self.prev_time_of_use is None or now > self._time_of_use_expires): + self._time_of_use_expires = now + 910 + # Fetch fresh time of use configuration from inverter + logger.debug("Fetching fresh time of use configuration from inverter") + path = self.api_config.config_timeofuse_path + response = self.send_request(path, auth=True) + if not response: + return None + result = json.loads(response.text)['timeofuse'] + + # Save the result + self.prev_time_of_use = result + return result + else: + logger.debug("Returning previous time of use configuration") + return self.prev_time_of_use + + def set_mode_self_regulation(self): + """ Set the inverter to discharge the battery.""" + timeofuselist = [] + response = self.set_time_of_use(timeofuselist) + return response + + def set_mode_avoid_discharge(self): + """ Set the inverter to avoid discharging the battery.""" + timeofuselist = [{'Active': True, + 'Power': int(0), + 'ScheduleType': 'DISCHARGE_MAX', + "TimeTable": {"Start": "00:00", "End": "23:59"}, + "Weekdays": + {"Mon": True, + "Tue": True, + "Wed": True, + "Thu": True, + "Fri": True, + "Sat": True, + "Sun": True} + }] + return self.set_time_of_use(timeofuselist) + + def set_mode_force_charge(self, chargerate=500): + """ Set the inverter to charge the battery with a specific power from GRID.""" + # activate timeofuse rules + timeofuselist = [{'Active': True, + 'Power': int(chargerate), + 'ScheduleType': 'CHARGE_MIN', + "TimeTable": {"Start": "00:00", "End": "23:59"}, + "Weekdays": + {"Mon": True, + "Tue": True, + "Wed": True, + "Thu": True, + "Fri": True, + "Sat": True, + "Sun": True} + }] + return self.set_time_of_use(timeofuselist) + + def set_mode_force_discharge(self, dischargerate=500): + """ Set the inverter to discharge the battery with a specific power""" + # activate timeofuse rules + timeofuselist = [{'Active': True, + 'Power': int(dischargerate), + 'ScheduleType': 'DISCHARGE_MAX', + "TimeTable": {"Start": "00:00", "End": "23:59"}, + "Weekdays": + {"Mon": True, + "Tue": True, + "Wed": True, + "Thu": True, + "Fri": True, + "Sat": True, + "Sun": True} + }] + return self.set_time_of_use(timeofuselist) + + def _compare_timeofuse_essentials(self, current_timeofuse, new_timeofuse): + """Compare only ScheduleType and Power values of timeofuse configurations.""" + if len(current_timeofuse) != len(new_timeofuse): + return False + + for i, (current_item, new_item) in enumerate(zip(current_timeofuse, new_timeofuse)): + # Compare only ScheduleType and Power values + if (current_item.get('ScheduleType') != new_item.get('ScheduleType') or + current_item.get('Power') != new_item.get('Power')): + logger.debug("Time of use item %d differs in essential values: " + "ScheduleType current=%s vs new=%s, " + "Power current=%s vs new=%s", + i, current_item.get( + 'ScheduleType'), new_item.get('ScheduleType'), + current_item.get('Power'), new_item.get('Power')) + return False + + return True + + def set_time_of_use(self, timeofuselist): + """ Set the planned battery charge/discharge schedule.""" + # Get current time of use configuration to check if update is needed + current_timeofuse = self.get_time_of_use() + + # Compare only ScheduleType and Power values to avoid unnecessary updates + if current_timeofuse is not None and \ + self._compare_timeofuse_essentials(current_timeofuse, timeofuselist): + logger.debug("Time of use configuration (ScheduleType and Power) is" + " already identical, skipping update") + # Return a mock response object to maintain compatibility + return MockResponse() + + config = { + 'timeofuse': timeofuselist + } + payload = json.dumps(config) + path = self.api_config.config_timeofuse_path + logger.info("Updating time of use configuration") + response = self.send_request( + path, method='POST', payload=payload, auth=True + ) + if not response: + raise RuntimeError('Failed to set time of use configuration') + response_dict = json.loads(response.text) + expected_write_successes = ['timeofuse'] + for expected_write_success in expected_write_successes: + if expected_write_success not in response_dict['writeSuccess']: + raise RuntimeError(f'failed to set {expected_write_success}') + + # Invalidate the cache after successfully updating the configuration + if self.prev_time_of_use is not None: + logger.debug("Invalidating previous time of use after update") + self.prev_time_of_use = None + self._time_of_use_expires = 0 + return response + + def _verify_fronius_ids(self): + """ + Verify that the configured controller_id is valid. + + This method makes test calls to the Fronius API to ensure the configured + ID exists in the actual API responses. If verification fails, it logs + the complete JSON response to help with debugging. + """ + # Verify controller_id by checking storage data + try: + logger.info('Verifying Fronius controller_id: %s', + self.controller_id) + path = self.api_config.storage_path + response = self.send_request(path) + if response: + result = json.loads(response.text) + data = result.get('Body', {}).get('Data', {}) + + if self.controller_id not in data: + logger.error( + 'Configured controller_id "%s" not found in Fronius API response.', + self.controller_id + ) + logger.error( + 'Available controller IDs: %s', + list(data.keys()) + ) + logger.error( + 'Complete Storage Data JSON response:\n%s', + json.dumps(data, indent=2) + ) + raise RuntimeError( + f'Invalid fronius_controller_id "{self.controller_id}". ' + f'Available IDs: {list(data.keys())}' + ) + + # Also verify that the Controller key exists within the data + controller_data = data.get(self.controller_id, {}) + if 'Controller' not in controller_data: + logger.error( + 'Controller data not found for controller_id "%s"', + self.controller_id + ) + logger.error( + 'Complete data for controller_id "%s":\n%s', + self.controller_id, + json.dumps(controller_data, indent=2) + ) + raise RuntimeError( + f'No Controller data found for fronius_controller_id "{self.controller_id}"' + ) + logger.info( + 'Controller ID "%s" verified successfully', self.controller_id) + except (KeyError, json.JSONDecodeError) as e: + logger.error( + 'Failed to verify controller_id due to unexpected response format: %s', + e + ) + if response: + logger.error('Complete API response:\n%s', response.text) + raise RuntimeError( + f'Failed to verify fronius_controller_id "{self.controller_id}": {e}' + ) + + def send_request(self, path, method='GET', payload="", params=None, headers=None, auth=False): + """Send a HTTP REST request to the inverter. + + auth = This request needs to be run with authentication. + is_login = This request is a login request. Do not retry on 401. + """ + logger.debug("Sending request to %s", path) + if not headers: + headers = {} + for i in range(3): + # Try tp send the request, if it fails, try to login and resend + response = self.__send_one_http_request( + path, method, payload, params, headers, auth) + if response.status_code == 200: + if auth: + self.__retrieve_auth_from_response(response) + return response + # 401 - unauthorized , relogin + # 403 - is forbidden, what happens at 01.00 in the night + if response.status_code in (401, 403): + self.__retrieve_auth_from_response(response) + self.login() + else: + raise RuntimeError( + f"[Inverter] Request {i} failed with {response.status_code}-" + f"{response.reason}. \n" + f"\t path:{path}, \n\tparams:{params} \n\theaders {headers} \n" + f"\tnonce {self.nonce} \n" + f"\tpayload {payload}" + ) + return None + + def __send_one_http_request(self, path, method='GET', payload="", + params=None, headers=None, auth=False): + """ Send one HTTP Request to the backend. + This method does not handle application errors, only connection errors. + """ + if not headers: + headers = {} + url = 'http://' + self.address + path + fullpath = path + if params: + fullpath += '?' + \ + "&".join( + [f'{k+"="+str(params[k])}' for k in params.keys()]) + if auth: + headers['Authorization'] = self.get_auth_header( + method=method, path=fullpath) + logger.debug("Fronius Bat Auth: Requesting %s , header: %s", fullpath, headers) + + for i in range(3): + # 3 retries if connection can't be established + try: + response = requests.request( + method=method, + url=url, + params=params, + headers=headers, + data=payload, + timeout=30 + ) + return response + except requests.exceptions.ConnectionError as err: + logger.error( + "Connection to Inverter failed on %s. (%d) " + "Retrying in 60 seconds, Error %s", + self.address, + i, + err + ) + time.sleep(60) + + logger.error('Request failed without response.') + raise RuntimeError( + f"\turl:{url}, \n\tparams:{params} \n\theaders {headers} \n" + f"\tnonce {self.nonce} \n" + f"\tpayload {payload}" + ) + + def login(self): + """Login to Fronius API""" + logger.debug("Fronius Bat Auth: Logging in") + path = self.api_config.commands_login_path + self.cnonce = hashlib.md5(os.urandom(8)).hexdigest() + self.ncvalue_num = 1 + self.login_attempts = 0 + for i in range(3): + self.login_attempts += 1 + response = self.__send_one_http_request(path, auth=True) + if response.status_code == 200: + if not self.subsequent_login: + self.__store_latest_password_hash_method() + self.subsequent_login = True + logger.info('Fronius Bat Auth: Login successful %s', response) + logger.debug("Fronius Bat Auth: Response: %s", response.headers) + self.__retrieve_auth_from_response(response) + self.login_attempts = 0 + return + elif response.status_code == 401: + self.__retrieve_auth_from_response(response) + + logger.error( + 'Fronius Bat Auth: Login -%d- failed, Response: %s', i, response) + logger.error('Fronius Bat Auth: Response: %s ; %s', response.headers, response) + if self.subsequent_login: + logger.info( + "Fronius Bat Auth: Retrying login in 10 seconds") + time.sleep(10) + if self.login_attempts >= 3: + logger.info( + 'Fronius Bat Auth: Login failed 3 times .. aborting' + ) + raise RuntimeError( + 'Fronius Bat Auth: Login failed repeatedly .. wrong credentials?' + ) + + def logout(self): + """Logout from Fronius API""" + path = self.api_config.commands_logout_path + response = self.send_request(path, auth=True) + if not response: + logger.warning('Fronius Bat Auth: Logout failed. No response from server') + if response.status_code == 200: + logger.info('Fronius Bat Auth: Logout successful') + else: + logger.info('Fronius Bat Auth: Logout failed') + return response + + def __retrieve_auth_from_response(self, response): + """Get & store the authentication parts from response auth header. + - nc + - cnonce + - nonce + """ + auth_dict = self.__split_response_auth_header(response) + if auth_dict.get('nc'): + self.ncvalue_num = int(auth_dict['nc']) + 1 + else: + self.ncvalue_num = 1 + if auth_dict.get('cnonce'): + self.cnonce = auth_dict['cnonce'] + if auth_dict.get('nonce'): + self.nonce = auth_dict['nonce'] + + logger.debug("Fronius Bat Auth: nc: %s, cnonce: %s, nonce: %s", + self.ncvalue_num, + self.cnonce, + self.nonce + ) + + def __split_response_auth_header(self, response): + """ Split the response header into a dictionary.""" + auth_dict = {} + # stupid API bug: nonce headers with different capitalization at different end points + if 'X-WWW-Authenticate' in response.headers: + auth_string = response.headers['X-WWW-Authenticate'] + elif 'X-Www-Authenticate' in response.headers: + auth_string = response.headers['X-Www-Authenticate'] + elif 'Authentication-Info' in response.headers: + auth_string = response.headers['Authentication-Info'] + else: + # Return an empty dict to work with Fronius below 1.35.4-1 + logger.debug('Fronius Bat Auth: No authentication header found in response') + return auth_dict + + # Remove quotes and split by comma + auth_list = auth_string.replace('"', '').split(',') + logger.debug("Fronius Bat Auth: Authentication header: %s", auth_list) + auth_dict = {} + for item in auth_list: + # Strip whitespace from each item and check if it contains '=' + item = item.strip() + if '=' in item: + key, value = item.split("=", 1) # Split only on first '=' + key = key.strip() + value = value.strip() + auth_dict[key] = value + logger.debug( + "Fronius Bat Auth: Authentication header key-value pair - %s: %s", key, value) + return auth_dict + + def get_auth_header(self, method, path) -> str: + """Create the Authorization header for the request.""" + nonce = self.nonce + realm = 'Webinterface area' + ncvalue = f"{self.ncvalue_num:08d}" + cnonce = self.cnonce + user = self.user + password = self.password + algorithm = self.api_config.auth_algorithm + password_algorithm = algorithm + + password_algorithm = self.__get_password_hash_method() + + if len(self.user) < 4: + raise RuntimeError("User needed for Authorization") + if len(self.password) < 4: + raise RuntimeError("Password needed for Authorization") + + a1 = f"{user}:{realm}:{password}" + a2 = f"{method}:{path}" + ha1 = hash_utf8(a1, password_algorithm) + ha2 = hash_utf8(a2, algorithm) + noncebit = f"{nonce}:{ncvalue}:{cnonce}:auth:{ha2}" + respdig = hash_utf8(f"{ha1}:{noncebit}", algorithm) + auth_header = f'Digest username="{user}", realm="{realm}", nonce="{nonce}", uri="{path}", ' + auth_header += f'algorithm="{algorithm}", qop=auth, nc={ncvalue}, cnonce="{cnonce}", ' + auth_header += f'response="{respdig}"' + return auth_header + + def __get_password_hash_method(self) -> str: + """ Figure out the password hash method during first login.""" + # If we already found a working method, use it + if self.password_hash is not None: + return self.password_hash + + # Index is initialized to -1. Increment to get the next method. + password_algorithm = "" + if self.api_config.auth_algorithm == "SHA256": + self._last_password_hash_method_index += 1 + if self._last_password_hash_method_index >= len(self.usable_password_hash_methods): + self._last_password_hash_method_index = 0 + password_algorithm = self.usable_password_hash_methods[ + self._last_password_hash_method_index + ] + logger.debug( + "Fronius Bat Auth: Trying password hash method %s", password_algorithm) + else: + # Fallback to MD5 only for older firmwares + password_algorithm = "MD5" + # Set password_hash immediately for MD5 since there's only one option + # Setting this here prevents __store_latest_password_hash_method from changing it later + self.password_hash = password_algorithm + + return password_algorithm + + def __store_latest_password_hash_method(self): + """ Save the password hash method to use after a successful login.""" + if self.password_hash is not None: + # We already have a working method, do not change it + return + self.password_hash = self.usable_password_hash_methods[ + self._last_password_hash_method_index + ] + logger.debug("Fronius Bat Auth: Password hash method set to %s", + self.password_hash) + + def set_config(self, address, user, password): + """ Update config.""" + if self.address != address: + self.address = address + if self.user != user: + self.user = user + if self.password != password: + self.password = password diff --git a/packages/modules/devices/fronius/fronius/inverter.py b/packages/modules/devices/fronius/fronius_http_api/inverter.py similarity index 74% rename from packages/modules/devices/fronius/fronius/inverter.py rename to packages/modules/devices/fronius/fronius_http_api/inverter.py index 03723bad21..1ce556c5cc 100644 --- a/packages/modules/devices/fronius/fronius/inverter.py +++ b/packages/modules/devices/fronius/fronius_http_api/inverter.py @@ -7,7 +7,7 @@ from modules.common.fault_state import ComponentInfo, FaultState from modules.common.simcount import SimCounter from modules.common.store import get_component_value_store -from modules.devices.fronius.fronius.config import FroniusInverterSetup +from modules.devices.fronius.fronius_http_api.config import FroniusInverterSetup from modules.common.utils.peak_filter import PeakFilter from modules.common.component_type import ComponentType @@ -33,8 +33,16 @@ def update(self, response: Dict) -> None: if isinstance(response, Exception): power = 0.0 else: + secondary_id = self.component_config.configuration.secondary_id try: - power = float(response["Body"]["Data"]["Site"]["P_PV"]) * -1 + if secondary_id is None: + power = float(response["Body"]["Data"]["Site"]["P_PV"]) * -1 + else: + secondary_data = response["Body"]["Data"]["SecondaryMeters"][str(secondary_id)] + if secondary_data["Category"] == "METER_CAT_WR": + power = float(secondary_data["P"]) * -1 + else: + raise ValueError(f"Sekundäres Gerät {secondary_id} ist kein Wechselrichter.") except TypeError: # Ohne PV Produktion liefert der WR 'null', ersetze durch Zahl 0 power = 0 diff --git a/packages/modules/devices/fronius/fronius_http_api/inverter_production_meter.py b/packages/modules/devices/fronius/fronius_http_api/inverter_production_meter.py new file mode 100644 index 0000000000..bba99b4c08 --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/inverter_production_meter.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +import logging +from typing import Dict, Optional, TypedDict, Any + +from modules.common import req +from modules.common.abstract_device import AbstractInverter +from modules.common.component_state import InverterState +from modules.common.component_type import ComponentDescriptor +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.simcount import SimCounter +from modules.common.store import get_component_value_store +from modules.devices.fronius.fronius_http_api import meter_reader +from modules.devices.fronius.fronius_http_api.config import FroniusConfiguration, MeterLocation +from modules.devices.fronius.fronius_http_api.config import FroniusProductionMeterSetup +from modules.common.utils.peak_filter import PeakFilter +from modules.common.component_type import ComponentType + +log = logging.getLogger(__name__) + + +class KwargsDict(TypedDict): + device_id: int + device_config: FroniusConfiguration + + +class FroniusProductionMeter(AbstractInverter): + def __init__(self, component_config: FroniusProductionMeterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__device_id: int = self.kwargs['device_id'] + self.device_config: FroniusConfiguration = 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.INVERTER, self.component_config.id, self.fault_state) + self.cache_key = f"{self.component_config.type}_{self.component_config.id}" + + def update(self, meter_system_response: Optional[Dict] = None) -> None: + config = self.component_config.configuration + meter = meter_reader.read_meter( + req.get_http_session(), self.device_config.ip_address, config.meter_id, config.variant, + self.cache_key, meter_system_response) + + if meter.location == MeterLocation.grid: + raise ValueError("Fehler: Dieser Zähler ist kein Erzeugerzähler.") + + # Für alle zulässigen Einbauorte (load/external/subload) meldet die Fronius-API laut Solar API V1 + # Dokumentation (4.8.6/4.8.7) positive Werte für Erzeugung -- openWBs Konvention ist umgekehrt + # (negativ = Erzeugung, siehe inverter.py), daher wird hier immer invertiert. + power = meter.power_sum * -1 + currents = [meter.powers[i] / meter.voltages[i] for i in range(0, 3)] + + self.peak_filter.check_values(power) + _, exported = self.sim_counter.sim_count(power) + + self.store.set(InverterState( + currents=currents, + power=power, + exported=exported + )) + + +component_descriptor = ComponentDescriptor(configuration_factory=FroniusProductionMeterSetup) diff --git a/packages/modules/devices/fronius/fronius/inverter_production_meter_test.py b/packages/modules/devices/fronius/fronius_http_api/inverter_production_meter_test.py similarity index 66% rename from packages/modules/devices/fronius/fronius/inverter_production_meter_test.py rename to packages/modules/devices/fronius/fronius_http_api/inverter_production_meter_test.py index a90f83d876..1005f49694 100644 --- a/packages/modules/devices/fronius/fronius/inverter_production_meter_test.py +++ b/packages/modules/devices/fronius/fronius_http_api/inverter_production_meter_test.py @@ -1,19 +1,23 @@ +import copy from unittest.mock import Mock -import requests_mock +import pytest from dataclass_utils import dataclass_from_dict from modules.conftest import SAMPLE_IP from modules.common.component_state import InverterState -from modules.devices.fronius.fronius import inverter_production_meter -from modules.devices.fronius.fronius.config import FroniusConfiguration, FroniusProductionMeterSetup +from modules.devices.fronius.fronius_http_api import inverter_production_meter, meter_reader +from modules.devices.fronius.fronius_http_api.config import FroniusConfiguration, FroniusProductionMeterSetup -def test_production_count(monkeypatch, requests_mock: requests_mock.mock): - mock_inverter_value_store = Mock() - monkeypatch.setattr(inverter_production_meter, "get_component_value_store", - Mock(return_value=mock_inverter_value_store)) - requests_mock.get(f"http://{SAMPLE_IP}/solar_api/v1/GetMeterRealtimeData.cgi", json=json_ext_var2) +@pytest.fixture(autouse=True) +def clear_meter_location_cache(): + meter_reader._last_known_location.clear() + yield + meter_reader._last_known_location.clear() + + +def test_production_count(monkeypatch): mock_inverter_value_store = Mock() monkeypatch.setattr(inverter_production_meter, "get_component_value_store", Mock(return_value=mock_inverter_value_store)) @@ -28,13 +32,60 @@ def test_production_count(monkeypatch, requests_mock: requests_mock.mock): i.initialize() # execution - i.update() + i.update(json_ext_var2) # evaluation assert vars(mock_inverter_value_store.set.call_args[0][0]) == vars(SAMPLE_INVERTER_STATE) -SAMPLE_INVERTER_STATE = InverterState(power=3809.4, +def test_update_var2_missing_location_falls_back_to_cache(monkeypatch): + mock_inverter_value_store = Mock() + monkeypatch.setattr(inverter_production_meter, "get_component_value_store", + Mock(return_value=mock_inverter_value_store)) + + json_ext_var2_missing_location = copy.deepcopy(json_ext_var2) + del json_ext_var2_missing_location["Body"]["Data"]["1"]["SMARTMETER_VALUE_LOCATION_U16"] + + component_config = FroniusProductionMeterSetup() + component_config.configuration.variant = 2 + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + component_config.configuration.meter_id = 1 + i = inverter_production_meter.FroniusProductionMeter(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + i.initialize() + + i.update(json_ext_var2) # populates the meter-location cache + i.update(json_ext_var2_missing_location) # location field missing this time, must fall back + + assert mock_inverter_value_store.set.call_count == 2 + + +def test_update_var2_missing_location_without_cache_raises(monkeypatch): + mock_inverter_value_store = Mock() + monkeypatch.setattr(inverter_production_meter, "get_component_value_store", + Mock(return_value=mock_inverter_value_store)) + + json_ext_var2_missing_location = copy.deepcopy(json_ext_var2) + del json_ext_var2_missing_location["Body"]["Data"]["1"]["SMARTMETER_VALUE_LOCATION_U16"] + + component_config = FroniusProductionMeterSetup() + component_config.configuration.variant = 2 + device_config = FroniusConfiguration() + device_config.ip_address = SAMPLE_IP + component_config.configuration.meter_id = 1 + i = inverter_production_meter.FroniusProductionMeter(component_config, device_config=dataclass_from_dict( + FroniusConfiguration, device_config), device_id=0) + i.initialize() + + with pytest.raises(KeyError): + i.update(json_ext_var2_missing_location) + + +# external location: per Fronius Solar API V1 (4.8.7), positive PowerReal_P_Sum means generation -- +# openWB's InverterState.power convention is the opposite (negative = production, see inverter.py), +# so the raw value is sign-flipped. +SAMPLE_INVERTER_STATE = InverterState(power=-3809.4, currents=[-5.373121093182142, -5.664436188811191, -5.585225225225224], exported=200) diff --git a/packages/modules/devices/fronius/fronius_http_api/inverter_test.py b/packages/modules/devices/fronius/fronius_http_api/inverter_test.py new file mode 100644 index 0000000000..7b1edf769d --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/inverter_test.py @@ -0,0 +1,234 @@ +from unittest.mock import Mock + +import pytest + +from modules.common.store._api import LoggingValueStore +from modules.devices.fronius.fronius_http_api.config import FroniusInverterConfiguration, FroniusInverterSetup +from modules.devices.fronius.fronius_http_api.inverter import FroniusInverter + + +def test_update(monkeypatch, mock_simcount): + wr = FroniusInverter(FroniusInverterSetup(), device_id=0) + wr.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + wr.update(json_producing) + + inverter_state = mock.call_args[0][0] + assert inverter_state.exported == 0 + assert inverter_state.power == -2173.672851562 + + +def test_update_no_production_returns_zero(monkeypatch, mock_simcount): + # Ohne PV-Produktion liefert der WR 'null' für P_PV. + wr = FroniusInverter(FroniusInverterSetup(), device_id=0) + wr.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + wr.update(json_no_production) + + inverter_state = mock.call_args[0][0] + assert inverter_state.power == 0 + + +def test_update_night_mode(monkeypatch, mock_simcount): + # Kann die PowerFlow-Antwort gar nicht abgerufen werden (WR nachts nicht erreichbar), wird das + # als 0 W Produktion gewertet statt das ganze Gerät fehlerhaft werden zu lassen. + wr = FroniusInverter(FroniusInverterSetup(), device_id=0) + wr.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + wr.update(ConnectionError("no response")) + + inverter_state = mock.call_args[0][0] + assert inverter_state.power == 0.0 + + +def test_update_secondary(monkeypatch, mock_simcount): + wr = FroniusInverter(FroniusInverterSetup(configuration=FroniusInverterConfiguration(secondary_id=1)), + device_id=0) + wr.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + wr.update(json_secondary) + + inverter_state = mock.call_args[0][0] + assert inverter_state.exported == 0 + assert inverter_state.power == -4470.0 + + +def test_update_secondary_rejects_non_inverter(monkeypatch, mock_simcount): + wr = FroniusInverter(FroniusInverterSetup(configuration=FroniusInverterConfiguration(secondary_id=1)), + device_id=0) + wr.initialize() + + mock = Mock(return_value=None) + monkeypatch.setattr(LoggingValueStore, "set", mock) + mock_simcount.return_value = 0, 0 + + json_secondary_not_wr = { + "Body": { + "Data": { + "SecondaryMeters": { + "1": { + "Category": "METER_CAT_LOAD", + "Label": "Verbraucher 1", + "MLoc": 256.0, + "P": 500.0 + } + } + } + } + } + + with pytest.raises(ValueError): + wr.update(json_secondary_not_wr) + + +json_producing = { + "Body": { + "Data": { + "Inverters": { + "1": { + "Battery_Mode": "normal", + "DT": 1, + "E_Day": None, + "E_Total": 148955.258055555, + "E_Year": None, + "P": 20.819091796875, + "SOC": 95.299999999999997 + } + }, + "Site": { + "BackupMode": False, + "BatteryStandby": False, + "E_Day": None, + "E_Total": 148955.258055555, + "E_Year": None, + "Meter_Location": "grid", + "Mode": "bidirectional", + "P_Akku": -11.142402648926, + "P_Grid": -2631.999999999, + "P_Load": -3933.5058593751, + "P_PV": 2173.672851562, + "rel_Autonomy": 100.0, + "rel_SelfConsumption": 59.4570229924 + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-09-25Txx:xx:xx+00:00" + } +} + +json_no_production = { + "Body": { + "Data": { + "Inverters": { + "1": { + "DT": 232, + "E_Day": 172.69999694824219, + "E_Total": 3372.76953125, + "E_Year": 10754989, + "P": 108 + } + }, + "Site": { + "E_Day": 172.69999694824219, + "E_Total": 3372.7694444444446, + "E_Year": 10754989, + "Meter_Location": "unknown", + "Mode": "produce-only", + "P_Akku": None, + "P_Grid": None, + "P_Load": None, + "P_PV": None, + "rel_Autonomy": None, + "rel_SelfConsumption": None + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-12-30T10:37:02+01:00" + } +} + +json_secondary = { + "Body": { + "Data": { + "Inverters": { + "1": { + "Battery_Mode": "normal", + "DT": 1, + "E_Day": None, + "E_Total": 148955.258055555, + "E_Year": None, + "P": 20.819091796875, + "SOC": 95.299999999999997 + } + }, + "SecondaryMeters": { + "1": { + "Category": "METER_CAT_WR", + "Label": "PV- 1", + "MLoc": 3.0, + "P": 4470.0 + } + }, + "Site": { + "BackupMode": False, + "BatteryStandby": False, + "E_Day": None, + "E_Total": 148955.258055555, + "E_Year": None, + "Meter_Location": "grid", + "Mode": "bidirectional", + "P_Akku": -11.142402648926, + "P_Grid": -2631.999999999, + "P_Load": -3933.5058593751, + "P_PV": 2173.672851562, + "rel_Autonomy": 100.0, + "rel_SelfConsumption": 59.4570229924 + }, + "Smartloads": { + "Ohmpilots": {} + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-09-25Txx:xx:xx+00:00" + } +} diff --git a/packages/modules/devices/fronius/fronius_http_api/meter_reader.py b/packages/modules/devices/fronius/fronius_http_api/meter_reader.py new file mode 100644 index 0000000000..e86322051e --- /dev/null +++ b/packages/modules/devices/fronius/fronius_http_api/meter_reader.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +import logging +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +from requests import Session + +from modules.devices.fronius.fronius_http_api.config import MeterLocation + +log = logging.getLogger(__name__) + +# Der Einbauort eines Zählers ändert sich zur Laufzeit nicht. Liefert die Fronius-API das +# Location-Feld einmal nicht (siehe Fronius-API-Eigenheiten), wird auf den zuletzt erfolgreich +# gelesenen Wert zurückgegriffen, statt die Komponente abstürzen zu lassen. +_last_known_location: Dict[str, MeterLocation] = {} + + +@dataclass +class FroniusMeterValues: + location: MeterLocation + powers: List[float] + power_sum: float + voltages: List[float] + power_factors: List[float] + frequency: float + + +def read_meter(session: Session, + ip_address: str, + meter_id: int, + variant: int, + cache_key: str, + meter_system_response: Optional[Dict] = None) -> FroniusMeterValues: + """Liest und normalisiert die Werte eines Fronius SmartMeters, unabhängig von der API-Variante.""" + if variant in (0, 1): + data = _fetch_device_scope(session, ip_address, meter_id, variant) + location_key = "Meter_Location_Current" + power_sum_key = "PowerReal_P_Sum" + powers = [data["PowerReal_P_Phase_"+str(num)] for num in range(1, 4)] + voltages = [data["Voltage_AC_Phase_"+str(num)] for num in range(1, 4)] + power_factors = [data["PowerFactor_Phase_"+str(num)] for num in range(1, 4)] + frequency = data["Frequency_Phase_Average"] + elif variant == 2: + if meter_system_response is None: + raise ValueError("meter_system_response wird für Variante 2 benötigt") + data = dict(meter_system_response["Body"]["Data"]).get(str(meter_id)) + location_key = "SMARTMETER_VALUE_LOCATION_U16" + power_sum_key = "SMARTMETER_POWERACTIVE_MEAN_SUM_F64" + powers = [data["SMARTMETER_POWERACTIVE_MEAN_0"+str(num)+"_F64"] for num in range(1, 4)] + voltages = [data["SMARTMETER_VOLTAGE_0"+str(num)+"_F64"] for num in range(1, 4)] + power_factors = [data["SMARTMETER_FACTOR_POWER_0"+str(num)+"_F64"] for num in range(1, 4)] + frequency = data["GRID_FREQUENCY_MEAN_F32"] + else: + raise ValueError("Unbekannte Variante: "+str(variant)) + + location = _get_location(data, location_key, cache_key) + + return FroniusMeterValues( + location=location, + powers=powers, + power_sum=data[power_sum_key], + voltages=voltages, + power_factors=power_factors, + frequency=frequency + ) + + +def get_flow_power(powerflow_response) -> Tuple[float, float]: + # Beim Energiebezug ist nicht klar, welcher Anteil aus dem Netz bezogen wurde, und was aus + # dem Wechselrichter kam. + # Beim Energieexport ist nicht klar, wie hoch der Eigenverbrauch während der Produktion war. + if isinstance(powerflow_response, Exception): + raise powerflow_response + power_load = float(powerflow_response["Body"]["Data"]["Site"]["P_Grid"]) + power_inverter = float(powerflow_response["Body"]["Data"]["Site"]["P_PV"] or 0) + return power_load, power_inverter + + +def _fetch_device_scope(session: Session, ip_address: str, meter_id: int, variant: int) -> Dict: + if variant == 0: + params = (('Scope', 'Device'), ('DeviceId', meter_id)) + else: + params = (('Scope', 'Device'), ('DeviceId', meter_id), ('DataCollection', 'MeterRealtimeData')) + response = session.get( + 'http://' + ip_address + '/solar_api/v1/GetMeterRealtimeData.cgi', + params=params, + timeout=5) + return response.json()["Body"]["Data"] + + +def _get_location(data: Dict, location_key: str, cache_key: str) -> MeterLocation: + try: + location = MeterLocation.get(data[location_key]) + except KeyError: + cached = _last_known_location.get(cache_key) + if cached is None: + raise + log.warning(f"Feld '{location_key}' fehlt in der Fronius-Antwort, " + f"verwende zwischengespeicherten Einbauort {cached} (Zähler {cache_key})") + return cached + _last_known_location[cache_key] = location + return location diff --git a/packages/modules/devices/fronius/fronius/meter_test.py b/packages/modules/devices/fronius/fronius_http_api/meter_test.py similarity index 92% rename from packages/modules/devices/fronius/fronius/meter_test.py rename to packages/modules/devices/fronius/fronius_http_api/meter_test.py index 65df5db9b2..fac68b9c64 100644 --- a/packages/modules/devices/fronius/fronius/meter_test.py +++ b/packages/modules/devices/fronius/fronius_http_api/meter_test.py @@ -1,6 +1,6 @@ import pytest -from modules.devices.fronius.fronius.config import MeterLocation +from modules.devices.fronius.fronius_http_api.config import MeterLocation def test_meter_enum(): diff --git a/packages/modules/devices/fronius/fronius_sunspec/__init__.py b/packages/modules/devices/fronius/fronius_sunspec/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/devices/fronius/fronius_sunspec/bat.py b/packages/modules/devices/fronius/fronius_sunspec/bat.py new file mode 100644 index 0000000000..fb3e1a4cd3 --- /dev/null +++ b/packages/modules/devices/fronius/fronius_sunspec/bat.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +import logging +from typing import Any, Optional, TypedDict + +from modules.common import modbus +from modules.common.abstract_device import AbstractBat +from modules.common.component_state import BatState +from modules.common.component_type import ComponentDescriptor, ComponentType +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusDataType +from modules.common.store import get_component_value_store +from modules.common.utils.peak_filter import PeakFilter +from modules.devices.fronius.fronius_sunspec.config import FroniusSunspecBatSetup + +log = logging.getLogger(__name__) + +# StorCtl_Mod: Bit 0 aktiviert die Lade-, Bit 1 die Entladesteuerung. Ist ein Bit nicht gesetzt, +# regelt der Wechselrichter die jeweilige Richtung selbstständig. +STOR_CTL_MOD_CHARGE = 0b01 +STOR_CTL_MOD_DISCHARGE = 0b10 + + +class KwargsDict(TypedDict): + device_id: int + client: modbus.ModbusTcpClient_ + + +class FroniusSunspecBat(AbstractBat): + def __init__(self, component_config: FroniusSunspecBatSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__tcp_client: modbus.ModbusTcpClient_ = self.kwargs['client'] + 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.last_mode: Optional[str] = 'Undefined' + + def update(self) -> None: + unit = 1 + + soc_raw = self.__tcp_client.read_holding_registers(40362, ModbusDataType.UINT_16, unit=unit) # ChaState + soc_scale = self.__tcp_client.read_holding_registers( + 40376, ModbusDataType.INT_16, unit=unit) # ChaState_SF + soc = soc_raw * (10 ** soc_scale) + + mppt_index = self.component_config.configuration.mppt_index + if mppt_index is None: + power = 0 + log.debug("Kein MPPT-Kanal für Speicherleistung konfiguriert, melde 0 W.") + else: + module_count = self.__tcp_client.read_holding_registers(40272, ModbusDataType.UINT_16, unit=unit) # N + if mppt_index < 1 or mppt_index > module_count: + raise ValueError( + f"Konfigurierter MPPT-Kanal {mppt_index} existiert nicht -- Gerät meldet " + f"{module_count} Kanäle. Bitte Konfiguration am Gerät prüfen.") + power_scale = self.__tcp_client.read_holding_registers( + 40268, ModbusDataType.INT_16, unit=unit) # DCW_SF + address = 40285 + (mppt_index-1) * 20 # module/1/DCW, +20 je weiterem Kanal + power_raw = self.__tcp_client.read_holding_registers(address, ModbusDataType.UINT_16, unit=unit) # DCW + # Vorzeichenkonvention am Speicher-Kanal ist nicht offiziell dokumentiert und muss am + # Gerät verifiziert werden -- ggf. muss hier ein Vorzeichenwechsel ergänzt werden. + power = power_raw * (10 ** power_scale) + + self.peak_filter.check_values(power) + self.store.set(BatState(power=power, soc=soc)) + + def set_power_limit(self, power_limit: Optional[int]) -> None: + # Nicht an echter Fronius-Hardware verifiziert -- vor Produktiveinsatz an einem realen + # Gerät testen. + unit = 1 + rate_scale = self.__tcp_client.read_holding_registers( + 40379, ModbusDataType.INT_16, unit=unit) # InOutWRte_SF + + def rate_percent(power: float) -> int: + max_power_raw = self.__tcp_client.read_holding_registers( + 40356, ModbusDataType.UINT_16, unit=unit) # WChaMax + max_power_scale = self.__tcp_client.read_holding_registers( + 40372, ModbusDataType.INT_16, unit=unit) # WChaMax_SF + max_power = max_power_raw * (10 ** max_power_scale) + percent = 0 if max_power == 0 else min(abs(power) / max_power * 100, 100) + return int(round(percent / (10 ** rate_scale))) + + if power_limit is None: + log.debug("Keine Batteriesteuerung, Selbstregelung durch Wechselrichter") + if self.last_mode is not None: + self.__tcp_client.write_register( + 40359, 0, data_type=ModbusDataType.UINT_16, unit=unit) # StorCtl_Mod + self.last_mode = None + elif power_limit == 0: + log.debug("Aktive Batteriesteuerung. Batterie wird auf Stop gesetzt und nicht entladen") + self.__tcp_client.write_register(40366, 0, data_type=ModbusDataType.INT_16, unit=unit) # OutWRte + self.__tcp_client.write_register( + 40359, STOR_CTL_MOD_DISCHARGE, data_type=ModbusDataType.UINT_16, unit=unit) # StorCtl_Mod + self.last_mode = 'stop' + elif power_limit < 0: + self.__tcp_client.write_register( + 40366, rate_percent(power_limit), data_type=ModbusDataType.INT_16, unit=unit) # OutWRte + self.__tcp_client.write_register( + 40359, STOR_CTL_MOD_DISCHARGE, data_type=ModbusDataType.UINT_16, unit=unit) # StorCtl_Mod + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {abs(power_limit)} W " + "entladen für den Hausverbrauch") + self.last_mode = 'discharge' + elif power_limit > 0: + self.__tcp_client.write_register(40371, 1, data_type=ModbusDataType.UINT_16, unit=unit) # ChaGriSet + self.__tcp_client.write_register( + 40367, rate_percent(power_limit), data_type=ModbusDataType.INT_16, unit=unit) # InWRte + self.__tcp_client.write_register( + 40359, STOR_CTL_MOD_CHARGE, data_type=ModbusDataType.UINT_16, unit=unit) # StorCtl_Mod + log.debug(f"Aktive Batteriesteuerung. Batterie wird mit {power_limit} W geladen") + self.last_mode = 'charge' + + def power_limit_controllable(self) -> bool: + return True + + +component_descriptor = ComponentDescriptor(configuration_factory=FroniusSunspecBatSetup) diff --git a/packages/modules/devices/fronius/fronius_sunspec/config.py b/packages/modules/devices/fronius/fronius_sunspec/config.py new file mode 100644 index 0000000000..57efff696c --- /dev/null +++ b/packages/modules/devices/fronius/fronius_sunspec/config.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +from typing import List, Optional + +from modules.common.component_setup import ComponentSetup +from modules.devices.fronius.fronius_sunspec.inv_version import FroniusInverterVersion +from ..vendor import vendor_descriptor + + +class FroniusSunspecConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: int = 502, counter_modbus_id: int = 200): + self.ip_address = ip_address + self.port = port + # Modbus-Adresse ("Zähleradresse") des Smart Meters, zu finden auf der Weboberfläche des + # Wechselrichters unter Kommunikation -> Modbus. Werkseinstellung laut Fronius: 200. Für + # den Wechselrichter selbst ist die Modbus-Adresse bei Modbus TCP fest auf 1 definiert und + # daher nicht konfigurierbar. + self.counter_modbus_id = counter_modbus_id + + +class FroniusSunspec: + def __init__(self, + name: str = "Fronius (Modbus/SunSpec)", + type: str = "fronius_sunspec", + id: int = 0, + configuration: FroniusSunspecConfiguration = None) -> None: + self.name = name + self.type = type + self.vendor = vendor_descriptor.configuration_factory().type + self.id = id + self.configuration = configuration or FroniusSunspecConfiguration() + + +class FroniusSunspecCounterConfiguration: + def __init__(self): + pass + + +class FroniusSunspecCounterSetup(ComponentSetup[FroniusSunspecCounterConfiguration]): + def __init__(self, + name: str = "Fronius SunSpec Zähler", + type: str = "counter", + id: int = 0, + configuration: FroniusSunspecCounterConfiguration = None, + **kwargs) -> None: + super().__init__(name, type, id, configuration or FroniusSunspecCounterConfiguration(), **kwargs) + + +class FroniusSunspecInverterConfiguration: + def __init__(self, + version: FroniusInverterVersion = FroniusInverterVersion.mppt, + pv_mppt_indices: Optional[List[int]] = None): + self.version = version + # Nur für version=mppt relevant: welche MPPT-Kanäle (SunSpec-Modell 160, 1-indiziert) + # PV-Strings sind. Auf Hybrid-Geräten belegen die Speicher-Lade-/Entladekanäle eigene, + # höhere Indizes (siehe FroniusSunspecBatConfiguration.mppt_index) -- welche Indizes + # tatsächlich PV vs. Speicher sind, unterscheidet sich zwischen Geräteserien und muss am + # Gerät (Kanalbezeichnung im Fronius-Webinterface bzw. per Modbus-Tool) geprüft werden. + self.pv_mppt_indices = pv_mppt_indices if pv_mppt_indices is not None else [1, 2] + + +class FroniusSunspecInverterSetup(ComponentSetup[FroniusSunspecInverterConfiguration]): + def __init__(self, + name: str = "Fronius SunSpec Wechselrichter", + type: str = "inverter", + id: int = 0, + configuration: FroniusSunspecInverterConfiguration = None, + **kwargs) -> None: + super().__init__(name, type, id, configuration or FroniusSunspecInverterConfiguration(), **kwargs) + + +class FroniusSunspecBatConfiguration: + def __init__(self, mppt_index: Optional[int] = None): + # MPPT-Kanal (SunSpec-Modell 160, 1-indiziert), der die tatsächliche Lade-/Entladeleistung + # des Speichers liefert. None, falls unbekannt -- dann liefert die Komponente SOC und + # Steuerung, aber keine Momentanleistung (power bleibt 0). + self.mppt_index = mppt_index + + +class FroniusSunspecBatSetup(ComponentSetup[FroniusSunspecBatConfiguration]): + def __init__(self, + name: str = "Fronius SunSpec Speicher", + type: str = "bat", + id: int = 0, + configuration: FroniusSunspecBatConfiguration = None, + **kwargs) -> None: + super().__init__(name, type, id, configuration or FroniusSunspecBatConfiguration(), **kwargs) diff --git a/packages/modules/devices/fronius/fronius_sunspec/counter.py b/packages/modules/devices/fronius/fronius_sunspec/counter.py new file mode 100644 index 0000000000..97683c3635 --- /dev/null +++ b/packages/modules/devices/fronius/fronius_sunspec/counter.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +from typing import Any, TypedDict + +from modules.common import modbus +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.modbus import ModbusDataType +from modules.common.utils.peak_filter import PeakFilter +from modules.common.store import get_component_value_store +from modules.devices.fronius.fronius_sunspec.config import FroniusSunspecConfiguration, FroniusSunspecCounterSetup + + +class KwargsDict(TypedDict): + device_id: int + client: modbus.ModbusTcpClient_ + device_config: FroniusSunspecConfiguration + + +class FroniusSunspecCounter(AbstractCounter): + def __init__(self, component_config: FroniusSunspecCounterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__tcp_client: modbus.ModbusTcpClient_ = self.kwargs['client'] + self.device_config: FroniusSunspecConfiguration = self.kwargs['device_config'] + 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) + + def update(self) -> None: + unit = self.device_config.counter_modbus_id + + power = self.__tcp_client.read_holding_registers(40098, ModbusDataType.FLOAT_32, unit=unit) * -1 # W + powers = [ + self.__tcp_client.read_holding_registers(40100, ModbusDataType.FLOAT_32, unit=unit) * -1, # WphA + self.__tcp_client.read_holding_registers(40102, ModbusDataType.FLOAT_32, unit=unit) * -1, # WphB + self.__tcp_client.read_holding_registers(40104, ModbusDataType.FLOAT_32, unit=unit) * -1, # WphC + ] + currents = [ + self.__tcp_client.read_holding_registers(40074, ModbusDataType.FLOAT_32, unit=unit), # AphA + self.__tcp_client.read_holding_registers(40076, ModbusDataType.FLOAT_32, unit=unit), # AphB + self.__tcp_client.read_holding_registers(40078, ModbusDataType.FLOAT_32, unit=unit), # AphC + ] + voltages = [ + self.__tcp_client.read_holding_registers(40082, ModbusDataType.FLOAT_32, unit=unit), # PhVphA + self.__tcp_client.read_holding_registers(40084, ModbusDataType.FLOAT_32, unit=unit), # PhVphB + self.__tcp_client.read_holding_registers(40086, ModbusDataType.FLOAT_32, unit=unit), # PhVphC + ] + power_factors = [ + self.__tcp_client.read_holding_registers(40124, ModbusDataType.FLOAT_32, unit=unit), # PFphA + self.__tcp_client.read_holding_registers(40126, ModbusDataType.FLOAT_32, unit=unit), # PFphB + self.__tcp_client.read_holding_registers(40128, ModbusDataType.FLOAT_32, unit=unit), # PFphC + ] + frequency = self.__tcp_client.read_holding_registers(40096, ModbusDataType.FLOAT_32, unit=unit) # Hz + exported = self.__tcp_client.read_holding_registers(40130, ModbusDataType.FLOAT_32, unit=unit) # TotWhExp + imported = self.__tcp_client.read_holding_registers(40138, ModbusDataType.FLOAT_32, unit=unit) # TotWhImp + + self.peak_filter.check_values(power) + self.store.set(CounterState( + power=power, + powers=powers, + currents=currents, + voltages=voltages, + power_factors=power_factors, + frequency=frequency, + imported=imported, + exported=exported, + )) + + +component_descriptor = ComponentDescriptor(configuration_factory=FroniusSunspecCounterSetup) diff --git a/packages/modules/devices/fronius/fronius_sunspec/device.py b/packages/modules/devices/fronius/fronius_sunspec/device.py new file mode 100644 index 0000000000..4ed739e35c --- /dev/null +++ b/packages/modules/devices/fronius/fronius_sunspec/device.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import logging +from typing import Iterable, 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.common.modbus import ModbusTcpClient_ +from modules.devices.fronius.fronius_sunspec.bat import FroniusSunspecBat +from modules.devices.fronius.fronius_sunspec.config import ( + FroniusSunspec, FroniusSunspecBatSetup, FroniusSunspecCounterSetup, FroniusSunspecInverterSetup) +from modules.devices.fronius.fronius_sunspec.counter import FroniusSunspecCounter +from modules.devices.fronius.fronius_sunspec.inverter import FroniusSunspecInverter + +log = logging.getLogger(__name__) + + +fronius_sunspec_component_classes = Union[FroniusSunspecBat, FroniusSunspecCounter, FroniusSunspecInverter] + + +def create_device(device_config: FroniusSunspec): + client = None + + def create_bat_component(component_config: FroniusSunspecBatSetup): + return FroniusSunspecBat(component_config, device_id=device_config.id, client=client) + + def create_counter_component(component_config: FroniusSunspecCounterSetup): + return FroniusSunspecCounter(component_config, device_id=device_config.id, client=client, + device_config=device_config.configuration) + + def create_inverter_component(component_config: FroniusSunspecInverterSetup): + return FroniusSunspecInverter(component_config, device_id=device_config.id, client=client) + + def update_components(components: Iterable[fronius_sunspec_component_classes]): + with client: + for component in components: + with SingleComponentUpdateContext(component.fault_state): + component.update() + + def initializer(): + nonlocal client + client = ModbusTcpClient_(device_config.configuration.ip_address, device_config.configuration.port) + + return ConfigurableDevice( + device_config=device_config, + initializer=initializer, + component_factory=ComponentFactoryByType( + bat=create_bat_component, + counter=create_counter_component, + inverter=create_inverter_component, + ), + component_updater=MultiComponentUpdater(update_components) + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=FroniusSunspec) diff --git a/packages/modules/devices/fronius/fronius_sunspec/inv_version.py b/packages/modules/devices/fronius/fronius_sunspec/inv_version.py new file mode 100644 index 0000000000..d398da3e4e --- /dev/null +++ b/packages/modules/devices/fronius/fronius_sunspec/inv_version.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +from enum import IntEnum + + +class FroniusInverterVersion(IntEnum): + mppt = 0 + ac = 1 diff --git a/packages/modules/devices/fronius/fronius_sunspec/inverter.py b/packages/modules/devices/fronius/fronius_sunspec/inverter.py new file mode 100644 index 0000000000..a7b37d1dcb --- /dev/null +++ b/packages/modules/devices/fronius/fronius_sunspec/inverter.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import logging +from typing import Any, TypedDict + +from modules.common import modbus +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.modbus import ModbusDataType +from modules.common.simcount import SimCounter +from modules.common.store import get_component_value_store +from modules.common.utils.peak_filter import PeakFilter +from modules.devices.fronius.fronius_sunspec.config import FroniusSunspecInverterSetup +from modules.devices.fronius.fronius_sunspec.inv_version import FroniusInverterVersion + +log = logging.getLogger(__name__) + + +class KwargsDict(TypedDict): + device_id: int + client: modbus.ModbusTcpClient_ + + +class FroniusSunspecInverter(AbstractInverter): + def __init__(self, component_config: FroniusSunspecInverterSetup, **kwargs: Any) -> None: + self.component_config = component_config + self.kwargs: KwargsDict = kwargs + + def initialize(self) -> None: + self.__tcp_client: modbus.ModbusTcpClient_ = self.kwargs['client'] + 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.sim_counter = SimCounter(self.kwargs['device_id'], self.component_config.id, self.component_config.type) + + def update(self) -> None: + unit = 1 + version = self.component_config.configuration.version + + if version == FroniusInverterVersion.mppt: + # AC-Leistung ist bei Hybrid-Wechselrichtern bereits mit dem Speicherfluss verrechnet, + # reine PV-Erzeugung kommt daher aus den konfigurierten DC-MPPT-Kanälen. + module_count = self.__tcp_client.read_holding_registers(40272, ModbusDataType.UINT_16, unit=unit) # N + power_scale = self.__tcp_client.read_holding_registers( + 40268, ModbusDataType.INT_16, unit=unit) # DCW_SF + power = 0.0 + for module_index in self.component_config.configuration.pv_mppt_indices: + if module_index < 1 or module_index > module_count: + raise ValueError( + f"Konfigurierter MPPT-Kanal {module_index} existiert nicht -- Gerät meldet " + f"{module_count} Kanäle. Bitte Konfiguration am Gerät prüfen.") + address = 40285 + (module_index-1) * 20 # module/1/DCW, +20 je weiterem Kanal + power_raw = self.__tcp_client.read_holding_registers( + address, ModbusDataType.UINT_16, unit=unit) # DCW + power += power_raw * (10 ** power_scale) + power *= -1 + elif version == FroniusInverterVersion.ac: + power = self.__tcp_client.read_holding_registers(40092, ModbusDataType.FLOAT_32, unit=unit) * -1 # W + else: + raise ValueError("Unbekannte Version "+str(version)) + + exported = self.__tcp_client.read_holding_registers(40102, ModbusDataType.FLOAT_32, unit=unit) # WH + + self.peak_filter.check_values(power) + imported, _ = self.sim_counter.sim_count(power) + + self.store.set(InverterState(power=power, exported=exported, imported=imported)) + + +component_descriptor = ComponentDescriptor(configuration_factory=FroniusSunspecInverterSetup) diff --git a/requirements.txt b/requirements.txt index f027767480..80d67166ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,3 +26,4 @@ pycarwings3==0.7.14 asyncio==3.4.3 passlib==1.7.4 pysolarmanv5==3.0.6 +packaging==24.2