diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 96d12045..45b03278 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -105,6 +105,8 @@ inverter: # Only affects mode 8; ignored in other modes. # fronius_inverter_id: '1' # Optional: ID of the inverter in Fronius API (default: '1') # fronius_controller_id: '0' # Optional: ID of the controller in Fronius API (default: '0') + # capacity: 10000 # Optional: fixed battery capacity in Wh. If set, overrides the + # DesignedCapacity value that would otherwise be queried from the inverter. enable_resilient_wrapper: true # Skip a control cycle on transient inverter outages instead of terminating (default: true) outage_tolerance_minutes: 24 # Minutes to tolerate inverter outages before terminating (default: 24) diff --git a/docs/configuration/inverter-configuration.md b/docs/configuration/inverter-configuration.md index c3b3a3ae..92a669d3 100644 --- a/docs/configuration/inverter-configuration.md +++ b/docs/configuration/inverter-configuration.md @@ -75,6 +75,7 @@ inverter: max_grid_charge_rate: 5000 # Watt fronius_inverter_id: '1' # Optional: ID of the inverter in Fronius API (default: '1') (ab 0.5.6) fronius_controller_id: '0' # Optional: ID of the controller in Fronius API (default: '0') (ab 0.5.6) + capacity: 10000 # Optional: fixed battery capacity in Wh, overrides the value queried from the inverter ``` ### Requirements @@ -103,6 +104,8 @@ Both files are deleted after a successful restore. The Solar.API stays enabled ### Additional Parameters (since 0.5.6) - **fronius_inverter_id**: Optional parameter to specify the inverter ID in the Fronius API. Default is '1'. - **fronius_controller_id**: Optional parameter to specify the controller ID in the Fronius API. Default is '0'. +- **capacity**: Optional parameter to specify a fixed battery capacity in Wh. If set, batcontrol uses + this value instead of querying `DesignedCapacity` from the inverter's Solar.API at startup. ## fronius-modbus This enables the Fronius Modbus TCP inverter backend. It controls a Fronius GEN24/BYD battery through SunSpec storage-control registers and does not require inverter web-login credentials. diff --git a/src/batcontrol/inverter/fronius.py b/src/batcontrol/inverter/fronius.py index 2a225ec8..ff7e704b 100644 --- a/src/batcontrol/inverter/fronius.py +++ b/src/batcontrol/inverter/fronius.py @@ -22,6 +22,7 @@ import logging import json import hashlib +import math from dataclasses import dataclass import requests from packaging import version @@ -183,7 +184,9 @@ def __init__(self, config: dict) -> None: self.cnonce = hashlib.md5(os.urandom(8)).hexdigest() self.login_attempts = 0 self.address = config['address'] - self.capacity = -1 + # Optional override: use a fixed capacity from config instead of + # querying it from the inverter (DesignedCapacity via Solar.API). + self.capacity = self._parse_capacity_config(config.get('capacity', -1)) self.max_grid_charge_rate = config['max_grid_charge_rate'] self.max_pv_charge_rate = config['max_pv_charge_rate'] self.nonce = 0 @@ -683,8 +686,30 @@ def set_time_of_use(self, timeofuselist): return response + @staticmethod + def _parse_capacity_config(capacity) -> float: + """ Coerce the configured 'capacity' value to a float. + Accepts numbers or numeric strings (as YAML may quote them), + so runtime behavior stays deterministic instead of raising a + TypeError later out of get_capacity(). + """ + try: + parsed_capacity = float(capacity) + except (TypeError, ValueError) as e: + raise RuntimeError( + f"Invalid 'capacity' config value: {capacity!r}. Must be a number (Wh)." + ) from e + if not math.isfinite(parsed_capacity): + raise RuntimeError( + f"Invalid 'capacity' config value: {capacity!r}. Must be a finite number (Wh)." + ) + return parsed_capacity + def get_capacity(self): - """ Get the full and raw capacity of the battery in Wh.""" + """ Get the full and raw capacity of the battery in Wh. + Returns the configured capacity override if set, otherwise + queries and caches DesignedCapacity from the inverter. + """ if self.capacity >= 0: return self.capacity diff --git a/src/batcontrol/inverter/inverter.py b/src/batcontrol/inverter/inverter.py index f60fc0db..07b6dd33 100644 --- a/src/batcontrol/inverter/inverter.py +++ b/src/batcontrol/inverter/inverter.py @@ -43,7 +43,8 @@ def create_inverter(config: dict) -> InverterInterface: 'max_grid_charge_rate': config['max_grid_charge_rate'], 'max_pv_charge_rate': config['max_pv_charge_rate'], 'fronius_inverter_id': config.get('fronius_inverter_id', 1), - 'fronius_controller_id': config.get('fronius_controller_id', 0) + 'fronius_controller_id': config.get('fronius_controller_id', 0), + 'capacity': config.get('capacity', -1) } inverter=FroniusWR(iv_config) elif config['type'].lower() == 'dummy': diff --git a/tests/batcontrol/inverter/test_fronius_capacity.py b/tests/batcontrol/inverter/test_fronius_capacity.py new file mode 100644 index 00000000..e7cf6c64 --- /dev/null +++ b/tests/batcontrol/inverter/test_fronius_capacity.py @@ -0,0 +1,179 @@ +"""Tests for optional battery capacity override in the Fronius GEN24 inverter.""" +import unittest +from unittest.mock import Mock, patch +import json +from packaging import version + +from batcontrol.inverter.fronius import FroniusWR + + +class TestFroniusCapacityOverride(unittest.TestCase): + """Test that a configured capacity overrides the value queried from the inverter.""" + + def setUp(self): + self.base_config = { + 'address': '192.168.1.100', + 'user': 'customer', + 'password': 'testpass', + 'max_grid_charge_rate': 5000, + 'max_pv_charge_rate': 0 + } + + def _setup_mocks(self, mock_get_firmware, mock_get_battery, mock_get_powerunit, + mock_send_request, designed_capacity=10000): + mock_get_firmware.return_value = version.parse("1.36.0") + mock_get_battery.return_value = { + 'HYB_EM_MODE': 0, + 'HYB_EM_POWER': 0, + 'BAT_M0_SOC_MIN': 5, + 'BAT_M0_SOC_MAX': 100, + 'HYB_BACKUP_RESERVED': 10 + } + mock_get_powerunit.return_value = { + 'backuppower': {'DEVICE_MODE_BACKUPMODE_TYPE_U16': 0} + } + + mock_powerflow = Mock() + mock_powerflow.text = json.dumps({ + 'Body': {'Data': {'Inverters': {'1': {'SOC': 50}}}} + }) + mock_storage = Mock() + mock_storage.text = json.dumps({ + 'Body': {'Data': {'0': { + 'Controller': {'DesignedCapacity': designed_capacity} + }}} + }) + mock_send_request.side_effect = [mock_powerflow, mock_storage] + + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + def test_capacity_queried_from_inverter_by_default( + self, mock_send_request, mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """Without a 'capacity' config key, the value comes from the inverter API.""" + self._setup_mocks(mock_get_firmware, mock_get_battery, mock_get_powerunit, + mock_send_request, designed_capacity=10000) + mock_capacity_response = Mock() + mock_capacity_response.text = json.dumps({ + 'Body': {'Data': {'0': {'Controller': {'DesignedCapacity': 10000}}}} + }) + mock_send_request.side_effect = list(mock_send_request.side_effect) + [ + mock_capacity_response + ] + + inverter = FroniusWR(self.base_config) + + self.assertEqual(inverter.get_capacity(), 10000) + # One extra call to the storage endpoint was needed to fetch capacity. + self.assertEqual(mock_send_request.call_count, 3) + + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + def test_capacity_override_from_config( + self, mock_send_request, mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """A configured 'capacity' overrides the value queried from the inverter.""" + self._setup_mocks(mock_get_firmware, mock_get_battery, mock_get_powerunit, + mock_send_request, designed_capacity=10000) + + config = self.base_config.copy() + config['capacity'] = 7500 + inverter = FroniusWR(config) + + self.assertEqual(inverter.get_capacity(), 7500) + # No additional call to the storage endpoint for capacity was needed. + self.assertEqual(mock_send_request.call_count, 2) + + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + def test_max_capacity_uses_configured_capacity( + self, mock_send_request, mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """get_max_capacity() reflects the configured capacity override.""" + self._setup_mocks(mock_get_firmware, mock_get_battery, mock_get_powerunit, + mock_send_request, designed_capacity=10000) + + config = self.base_config.copy() + config['capacity'] = 8000 + inverter = FroniusWR(config) + + # BAT_M0_SOC_MAX is 100 in the mocked battery config. + self.assertEqual(inverter.get_max_capacity(), 8000) + + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + def test_capacity_string_from_yaml_is_coerced( + self, mock_send_request, mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """A quoted numeric string in YAML (e.g. '10000') is coerced to a number.""" + self._setup_mocks(mock_get_firmware, mock_get_battery, mock_get_powerunit, + mock_send_request, designed_capacity=10000) + + config = self.base_config.copy() + config['capacity'] = '7500' + inverter = FroniusWR(config) + + self.assertEqual(inverter.get_capacity(), 7500) + + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + def test_invalid_capacity_raises_error( + self, mock_send_request, mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """A non-numeric capacity value raises a clear RuntimeError.""" + config = self.base_config.copy() + config['capacity'] = 'not-a-number' + + with self.assertRaises(RuntimeError) as context: + FroniusWR(config) + + self.assertIn('capacity', str(context.exception)) + + @patch('batcontrol.inverter.fronius.FroniusWR.get_firmware_version') + @patch('batcontrol.inverter.fronius.FroniusWR.get_battery_config') + @patch('batcontrol.inverter.fronius.FroniusWR.get_powerunit_config') + @patch('batcontrol.inverter.fronius.FroniusWR.backup_time_of_use') + @patch('batcontrol.inverter.fronius.FroniusWR.set_solar_api_active') + @patch('batcontrol.inverter.fronius.FroniusWR.set_allow_grid_charging') + @patch('batcontrol.inverter.fronius.FroniusWR.send_request') + def test_non_finite_capacity_raises_error( + self, mock_send_request, mock_set_allow, mock_set_solar, mock_backup_tou, + mock_get_powerunit, mock_get_battery, mock_get_firmware): + """'inf'/'nan' capacity values are rejected instead of propagating NaN/inf.""" + for bad_value in ('inf', '-inf', 'nan', float('inf'), float('nan')): + config = self.base_config.copy() + config['capacity'] = bad_value + + with self.assertRaises(RuntimeError) as context: + FroniusWR(config) + + self.assertIn('capacity', str(context.exception)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/batcontrol/inverter/test_inverter_factory.py b/tests/batcontrol/inverter/test_inverter_factory.py index 43b5c79a..0b17621d 100644 --- a/tests/batcontrol/inverter/test_inverter_factory.py +++ b/tests/batcontrol/inverter/test_inverter_factory.py @@ -134,6 +134,7 @@ def test_factory_builds_fronius_with_expected_config(mocker): "max_pv_charge_rate": 1700, "fronius_inverter_id": 3, "fronius_controller_id": 4, + "capacity": -1, } ) assert inverter is mock_instance @@ -167,6 +168,7 @@ def test_factory_defaults_max_pv_charge_rate_for_fronius(mocker): "max_pv_charge_rate": 0, "fronius_inverter_id": 1, "fronius_controller_id": 0, + "capacity": -1, } ) @@ -200,5 +202,41 @@ def test_factory_applies_fronius_id_defaults(mocker): "max_pv_charge_rate": 1200, "fronius_inverter_id": 1, "fronius_controller_id": 0, + "capacity": -1, + } + ) + + +def test_factory_forwards_fronius_capacity_override(mocker): + """Factory should forward an explicit 'capacity' override to FroniusWR.""" + mock_instance = mocker.MagicMock() + mock_fronius = mocker.patch( + "batcontrol.inverter.fronius.FroniusWR", + autospec=True, + return_value=mock_instance, + ) + + config = { + "type": "fronius_gen24", + "address": "192.168.1.100", + "user": "customer", + "password": "secret", + "max_grid_charge_rate": 5000, + "max_pv_charge_rate": 1200, + "capacity": 9600, + } + + Inverter.create_inverter(config) + + mock_fronius.assert_called_once_with( + { + "address": "192.168.1.100", + "user": "customer", + "password": "secret", + "max_grid_charge_rate": 5000, + "max_pv_charge_rate": 1200, + "fronius_inverter_id": 1, + "fronius_controller_id": 0, + "capacity": 9600, } )