Report SolarEdge storage that only the DER block knows about (#183516)

This commit is contained in:
Franck Nijhof
2026-09-29 00:28:45 +02:00
committed by GitHub
parent 8dd5fc39d9
commit f83224fb9b
5 changed files with 217 additions and 3 deletions
@@ -26,6 +26,7 @@ SUBSYSTEM_INVERTER: Final = "inverter"
# How the library names the blocks it probes for.
SUBSYSTEM_BATTERIES: Final = "batteries"
SUBSYSTEM_METERS: Final = "meters"
SUBSYSTEM_STORAGE_CAPACITY: Final = "storage_capacity"
# The writable control blocks, as an UpdateReport names them. Export control's
# read spans storage control, so the library reads and reports the two as one.
@@ -10,6 +10,8 @@ from solaredged import (
Inverter,
InverterStatus,
Meter,
SolarEdge,
StorageCapacity,
SunSpecDID,
)
@@ -36,7 +38,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import LOGGER
from .const import LOGGER, SUBSYSTEM_STORAGE_CAPACITY
from .coordinator import SolarEdgeModbusConfigEntry
from .entity import (
SolarEdgeModbusBatteryEntity,
@@ -816,6 +818,21 @@ def _battery_sensor(
)
STORAGE_CAPACITY_SENSORS: tuple[
SolarEdgeModbusSensorEntityDescription[StorageCapacity], ...
] = (
SolarEdgeModbusSensorEntityDescription(
key="storage_state_of_charge",
translation_key="storage_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda storage: storage.state_of_charge,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SolarEdgeModbusConfigEntry,
@@ -842,9 +859,39 @@ async def async_setup_entry(
if description.exists_fn(battery)
)
if (storage := _reported_storage(solaredge)) is not None:
entities.extend(
SolarEdgeModbusStorageCapacitySensorEntity(
entry=entry, description=description, component=storage
)
for description in STORAGE_CAPACITY_SENSORS
)
async_add_entities(entities)
def _reported_storage(solaredge: SolarEdge) -> StorageCapacity | None:
"""The DER storage block worth making entities from, if there is one.
An inverter running a grid profile with IEEE 1547-2018 support serves this
block whether or not a battery is attached, and the spec fixes the state of
charge at 0% when none is. Nothing in the block distinguishes that from a
pack that happens to sit at 0%, so a charge above zero is the evidence that
there is something to report. It is only worth reading at all where the
battery block itself gave nothing: that one is richer and has a serial
number, and some inverters expose no battery over Modbus but do serve this.
"""
storage = solaredge.storage_capacity
if solaredge.batteries or storage is None:
return None
state_of_charge = storage.state_of_charge
if state_of_charge is None or state_of_charge <= 0:
return None
return storage
class SolarEdgeModbusInverterSensorEntity(SolarEdgeModbusInverterEntity, SensorEntity):
"""Defines a SolarEdge Modbus inverter sensor entity."""
@@ -871,6 +918,39 @@ class SolarEdgeModbusMeterSensorEntity(SolarEdgeModbusMeterEntity, SensorEntity)
)
class SolarEdgeModbusStorageCapacitySensorEntity(
SolarEdgeModbusInverterEntity, SensorEntity
):
"""Defines a SolarEdge Modbus DER storage sensor entity.
The block describes the whole DER's storage and carries no identity of its
own, so it belongs on the inverter rather than on a sub-device.
"""
entity_description: SolarEdgeModbusSensorEntityDescription[StorageCapacity]
def __init__(
self,
*,
entry: SolarEdgeModbusConfigEntry,
description: SolarEdgeModbusSensorEntityDescription[StorageCapacity],
component: StorageCapacity,
) -> None:
"""Initialize a SolarEdge Modbus DER storage sensor entity."""
super().__init__(
entry=entry,
description=description,
subsystem=SUBSYSTEM_STORAGE_CAPACITY,
)
self._component = component
@property
@override
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self._component)
class SolarEdgeModbusEnergySensorEntity(RestoreSensor):
"""Keeps a lifetime-energy sensor monotonic across glitches and restarts.
@@ -298,6 +298,9 @@
"state_of_health": {
"name": "State of health"
},
"storage_state_of_charge": {
"name": "Storage state of charge"
},
"temperature_max": {
"name": "Maximum temperature"
},
@@ -44,6 +44,31 @@ BATTERY_SERIAL_BASE = 57648
BATTERY_RATED_ENERGY = 57666
BATTERY_OFFSETS = (0, 256, 768)
# The SunSpec marker sits at the start of the common block; the model chain
# begins in the two registers after it.
SUNSPEC_BASE = 40000
# The captured dump records no SunSpec model chain, so a test that needs one
# lays this over it. Each entry is a model's ID and the data length it declares,
# which is what a chain walk steps on: two header registers, then that many
# registers of data, then the next model. It describes what the dump holds and
# ends with the DER storage block an inverter on an IEEE 1547-2018 grid profile
# serves, which is the block model 713 is:
#
# @40002 model 1 (65) common block, the inverter's identity
# @40069 model 103 (50) three-phase inverter measurements
# @40121 model 1 (65) the meter's own identity block
# @40188 model 203 (105) three-phase wye meter measurements
# @40295 model 713 (7) DER storage capacity
#
# That puts model 713 where a second meter would go, so it cannot be combined
# with add_second_meter.
_CHAIN = ((1, 65), (103, 50), (1, 65), (203, 105), (713, 7))
# Where the chain above lands model 713, rather than a number to keep in step
# with it by hand.
STORAGE_CAPACITY_BASE = SUNSPEC_BASE + 2 + sum(length + 2 for _, length in _CHAIN[:-1])
def tcp_data(unit_id: int = UNIT_ID) -> dict[str, Any]:
"""Config entry data for an inverter reached over Modbus TCP."""
@@ -103,6 +128,33 @@ def add_second_meter(unit: MockModbusUnit, serial_number: str) -> None:
unit.holding.update(block)
def add_storage_capacity(unit: MockModbusUnit, state_of_charge: int) -> None:
"""Wire a DER storage block (SunSpec model 713) onto a seeded unit.
``state_of_charge`` is the raw register value; the scale factor written
here is -2, so 5960 is 59.60%. The block sits where a second meter would,
so do not combine this with ``add_second_meter``.
"""
address = SUNSPEC_BASE + 2
for model_id, length in _CHAIN:
unit.holding.update({address: model_id, address + 1: length})
address += length + 2
unit.holding[address] = 0xFFFF # end of chain
unit.holding.update(
{
STORAGE_CAPACITY_BASE + 2: 0xFFFF, # energy rating, not implemented
STORAGE_CAPACITY_BASE + 3: 0xFFFF, # energy available, not implemented
STORAGE_CAPACITY_BASE + 4: state_of_charge,
STORAGE_CAPACITY_BASE + 5: 0xFFFF, # state of health, not implemented
STORAGE_CAPACITY_BASE + 6: 0xFFFF, # status, not implemented
STORAGE_CAPACITY_BASE + 7: 0xFFFE, # both scale factors are -2
STORAGE_CAPACITY_BASE + 8: 0xFFFE,
}
)
@pytest.fixture
async def mock_modbus_unit(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
@@ -3,7 +3,7 @@
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from modbus_connection import ModbusTimeoutError
from modbus_connection import IllegalDataAddressError, ModbusTimeoutError
from modbus_connection.mock import MockModbusUnit
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -13,7 +13,12 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from .conftest import add_second_meter
from .conftest import (
BATTERY_RATED_ENERGY,
STORAGE_CAPACITY_BASE,
add_second_meter,
add_storage_capacity,
)
from tests.common import (
MockConfigEntry,
@@ -23,6 +28,7 @@ from tests.common import (
)
LIFETIME_ENERGY_ENTITY = "sensor.solaredge_se10000h_energy"
DER_STORAGE_ENTITY = "sensor.solaredge_se10000h_storage_state_of_charge"
async def _setup_sensor_platform(hass: HomeAssistant, entry: MockConfigEntry) -> None:
@@ -313,3 +319,75 @@ async def test_lifetime_energy_restored_after_restart(
state = hass.states.get(LIFETIME_ENERGY_ENTITY)
assert state is not None
assert float(state.state) == 99999.999 # kWh, from the restored maximum
async def test_der_storage_state_of_charge(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""An inverter reporting storage as a DER gets a state-of-charge sensor.
Only where the battery block gave nothing: some inverters expose no battery
over Modbus but do serve model 713.
"""
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, IllegalDataAddressError())
add_storage_capacity(mock_modbus_unit, state_of_charge=5960)
await _setup_sensor_platform(hass, mock_config_entry)
state = hass.states.get(DER_STORAGE_ENTITY)
assert state is not None
assert state.state == "59.6"
@pytest.mark.parametrize(
("state_of_charge", "absent_blocks", "reason"),
[
pytest.param(
0, (BATTERY_RATED_ENERGY,), "no storage attached", id="charge-zero"
),
pytest.param(5960, (), "the battery block reports it", id="has-batteries"),
],
)
async def test_no_der_storage_sensor(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
state_of_charge: int,
absent_blocks: tuple[int, ...],
reason: str,
) -> None:
"""The block is served in cases that are not worth an entity.
An inverter on an IEEE 1547-2018 grid profile serves model 713 whether or
not a battery is attached, reporting the 0% the spec fixes it at.
"""
for address in absent_blocks:
mock_modbus_unit.fail_read(address, IllegalDataAddressError())
add_storage_capacity(mock_modbus_unit, state_of_charge=state_of_charge)
await _setup_sensor_platform(hass, mock_config_entry)
assert hass.states.get(DER_STORAGE_ENTITY) is None, reason
async def test_der_storage_unavailable_when_its_block_fails(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""The sensor follows its own sub-system, not the whole poll."""
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, IllegalDataAddressError())
add_storage_capacity(mock_modbus_unit, state_of_charge=5960)
await _setup_sensor_platform(hass, mock_config_entry)
assert hass.states.get(DER_STORAGE_ENTITY).state == "59.6"
mock_modbus_unit.fail_read(STORAGE_CAPACITY_BASE, ModbusTimeoutError("timed out"))
await _tick(hass, freezer)
assert hass.states.get(DER_STORAGE_ENTITY).state == STATE_UNAVAILABLE
assert hass.states.get("sensor.solaredge_se10000h_power").state != STATE_UNAVAILABLE