mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 14:29:21 -04:00
Report SolarEdge storage that only the DER block knows about (#183516)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user