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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ asyncio.run(main())
and control blocks are present, plus whether the firmware serves the grid
status extension (not all firmware does). `async_update` refreshes every
component in as few Modbus reads as possible. Values decode to `None` when the
device reports a point as not implemented, and `on_grid` is `None` on firmware
without the extension.
device reports a point as not implemented, `on_grid` is `None` on firmware
without the extension, and a battery state of energy or health outside 0-100
(reported by initializing batteries) decodes to `None` as well.

See the [examples](examples) directory for more.

Expand Down
29 changes: 27 additions & 2 deletions src/solaredged/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ def _le_float32(
)


def _percentage(value: float | None) -> float | None:
"""Normalise a percentage point: a value outside 0-100 decodes to None."""
if value is None or not 0 <= value <= 100:
return None
return value


class SolarEdgeComponent(Component):
"""A ``Component`` whose writes raise the library's own connection error.

Expand Down Expand Up @@ -477,11 +484,29 @@ class Battery(SolarEdgeComponent):
energy_max = _le_float32(57726, unit="Wh")
energy_available = _le_float32(57728, unit="Wh")

state_of_health = _le_float32(57730, unit="%")
state_of_energy = _le_float32(57732, unit="%")
_state_of_health_raw = _le_float32(57730, unit="%")
_state_of_energy_raw = _le_float32(57732, unit="%")

status = enum(57734, BatteryStatus, count=2, word_order="little", nan=0xFFFFFFFF)

@property
def state_of_health(self) -> float | None:
"""Battery state of health in percent, or None when not meaningful.

An initializing battery (and the odd communication glitch) reports
values outside 0-100; those are garbage, not readings.
"""
return _percentage(self._state_of_health_raw)

@property
def state_of_energy(self) -> float | None:
"""Battery state of energy in percent, or None when not meaningful.

An initializing battery (and the odd communication glitch) reports
values outside 0-100; those are garbage, not readings.
"""
return _percentage(self._state_of_energy_raw)


class StorageControl(SolarEdgeComponent):
"""Storage charge/discharge control (proprietary block, base 57348).
Expand Down
24 changes: 12 additions & 12 deletions tests/__snapshots__/test_community_dumps.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@
dict({
'batteries': list([
dict({
'_state_of_energy_raw': 16.0,
'_state_of_health_raw': 100.0,
'dc_current': -0.04737443849444389,
'dc_power': 0.0,
'dc_voltage': 819.9321899414062,
Expand All @@ -168,8 +170,6 @@
'model': '',
'rated_energy': 9800.0,
'serial_number': '',
'state_of_energy': 16.0,
'state_of_health': 100.0,
'status': <BatteryStatus.PRESERVE_CHARGE: 6>,
'temperature_average': 23.399999618530273,
'temperature_max': 0.0,
Expand Down Expand Up @@ -268,6 +268,8 @@
dict({
'batteries': list([
dict({
'_state_of_energy_raw': 66.66667175292969,
'_state_of_health_raw': 99.0,
'dc_current': -6.070691108703613,
'dc_power': 5002.0,
'dc_voltage': 822.8054809570312,
Expand All @@ -283,8 +285,6 @@
'model': '',
'rated_energy': 23000.0,
'serial_number': '',
'state_of_energy': 66.66667175292969,
'state_of_health': 99.0,
'status': <BatteryStatus.CHARGE: 3>,
'temperature_average': 25.80000114440918,
'temperature_max': 0.0,
Expand Down Expand Up @@ -683,6 +683,8 @@
dict({
'batteries': list([
dict({
'_state_of_energy_raw': 99.94781494140625,
'_state_of_health_raw': 100.0,
'dc_current': -0.0,
'dc_power': 0.0,
'dc_voltage': 403.8467102050781,
Expand All @@ -698,14 +700,14 @@
'model': '',
'rated_energy': 9700.0,
'serial_number': '',
'state_of_energy': 99.94781494140625,
'state_of_health': 100.0,
'status': <BatteryStatus.PRESERVE_CHARGE: 6>,
'temperature_average': 23.608806610107422,
'temperature_max': 0.0,
'version': '',
}),
dict({
'_state_of_energy_raw': 99.2170181274414,
'_state_of_health_raw': 100.0,
'dc_current': -0.0,
'dc_power': 0.0,
'dc_voltage': 404.8029479980469,
Expand All @@ -721,8 +723,6 @@
'model': '',
'rated_energy': 9700.0,
'serial_number': '',
'state_of_energy': 99.2170181274414,
'state_of_health': 100.0,
'status': <BatteryStatus.PRESERVE_CHARGE: 6>,
'temperature_average': 23.861509323120117,
'temperature_max': 0.0,
Expand Down Expand Up @@ -977,6 +977,8 @@
dict({
'batteries': list([
dict({
'_state_of_energy_raw': 95.82250213623047,
'_state_of_health_raw': 98.0,
'dc_current': 0.16793091595172882,
'dc_power': -66.0,
'dc_voltage': 403.646728515625,
Expand All @@ -992,14 +994,14 @@
'model': '',
'rated_energy': 9700.0,
'serial_number': '',
'state_of_energy': 95.82250213623047,
'state_of_health': 98.0,
'status': <BatteryStatus.DISCHARGE: 4>,
'temperature_average': 8.505219459533691,
'temperature_max': 0.0,
'version': '',
}),
dict({
'_state_of_energy_raw': 94.600830078125,
'_state_of_health_raw': 99.0,
'dc_current': 0.16148528456687927,
'dc_power': -66.0,
'dc_voltage': 402.603759765625,
Expand All @@ -1015,8 +1017,6 @@
'model': '',
'rated_energy': 9700.0,
'serial_number': '',
'state_of_energy': 94.600830078125,
'state_of_health': 99.0,
'status': <BatteryStatus.DISCHARGE: 4>,
'temperature_average': 10.410821914672852,
'temperature_max': 0.0,
Expand Down
43 changes: 43 additions & 0 deletions tests/test_solaredged.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,49 @@ async def test_battery_readings(mock_modbus_unit: MockModbusUnit) -> None:
assert battery.status is BatteryStatus.DISCHARGE


@pytest.mark.parametrize("value", [-0.5, 100.5, float("inf")])
async def test_battery_percentage_out_of_range_is_none(
mock_modbus_unit: MockModbusUnit, value: float
) -> None:
"""A battery state of energy or health outside 0-100 decodes to None.

An initializing battery (and the odd communication glitch) reports
percentages outside the meaningful range; those are garbage, not readings.
"""
Comment thread
frenck marked this conversation as resolved.
Comment on lines +328 to +332
seed(mock_modbus_unit, FIXTURE)
_seed_battery(mock_modbus_unit, offset=0)
words = encode_float32(value, word_order="little")
for i, word in enumerate(words):
mock_modbus_unit.holding[57730 + i] = word # state of health
mock_modbus_unit.holding[57732 + i] = word # state of energy

client = await SolarEdge.async_probe(mock_modbus_unit)
await client.async_update()

battery = client.batteries[0]
assert battery.state_of_health is None
assert battery.state_of_energy is None


async def test_battery_percentage_boundaries_kept(
mock_modbus_unit: MockModbusUnit,
) -> None:
"""The 0 and 100 percent boundaries are genuine readings, not garbage."""
seed(mock_modbus_unit, FIXTURE)
_seed_battery(mock_modbus_unit, offset=0)
for i, word in enumerate(encode_float32(100.0, word_order="little")):
mock_modbus_unit.holding[57730 + i] = word # state of health
for i, word in enumerate(encode_float32(0.0, word_order="little")):
mock_modbus_unit.holding[57732 + i] = word # state of energy

client = await SolarEdge.async_probe(mock_modbus_unit)
await client.async_update()

battery = client.batteries[0]
assert battery.state_of_health == pytest.approx(100.0)
assert battery.state_of_energy == pytest.approx(0.0)


async def test_second_battery_uses_base_offset(
mock_modbus_unit: MockModbusUnit,
) -> None:
Expand Down