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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions docs/configuration/inverter-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 27 additions & 2 deletions src/batcontrol/inverter/fronius.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import logging
import json
import hashlib
import math
from dataclasses import dataclass
import requests
from packaging import version
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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().
"""
Comment on lines +691 to +695

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this against the rest of the file: send_request's docstring already uses this exact same continuation-indent style (aligned under the opening """ text rather than flush with the def body), so _parse_capacity_config is consistent with existing precedent here. pylint also raises no docstring-formatting warning for it. Leaving as-is; happy to revisit if there's a specific lint rule this should satisfy.


Generated by Claude Code

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
Comment on lines +702 to +706

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

Expand Down
3 changes: 2 additions & 1 deletion src/batcontrol/inverter/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
179 changes: 179 additions & 0 deletions tests/batcontrol/inverter/test_fronius_capacity.py
Original file line number Diff line number Diff line change
@@ -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()
38 changes: 38 additions & 0 deletions tests/batcontrol/inverter/test_inverter_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
)

Expand Down Expand Up @@ -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,
}
)