diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py index 076bf373cdfe..05983399bbcb 100644 --- a/homeassistant/components/solaredge_modbus/const.py +++ b/homeassistant/components/solaredge_modbus/const.py @@ -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. diff --git a/homeassistant/components/solaredge_modbus/sensor.py b/homeassistant/components/solaredge_modbus/sensor.py index 727ecc8d6e7f..fdd38358b4e8 100644 --- a/homeassistant/components/solaredge_modbus/sensor.py +++ b/homeassistant/components/solaredge_modbus/sensor.py @@ -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. diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index 8fddbc070dc5..160bc80d7855 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -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" }, diff --git a/tests/components/solaredge_modbus/conftest.py b/tests/components/solaredge_modbus/conftest.py index f0d53108c470..78851a19f563 100644 --- a/tests/components/solaredge_modbus/conftest.py +++ b/tests/components/solaredge_modbus/conftest.py @@ -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 diff --git a/tests/components/solaredge_modbus/test_sensor.py b/tests/components/solaredge_modbus/test_sensor.py index f18439a606d3..a6d977fb6510 100644 --- a/tests/components/solaredge_modbus/test_sensor.py +++ b/tests/components/solaredge_modbus/test_sensor.py @@ -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