Add battery support to SolarEdge Modbus (#180657)

This commit is contained in:
Franck Nijhof
2026-08-29 21:10:29 +02:00
committed by GitHub
parent a1eba846af
commit 5c94fb9e6a
11 changed files with 2702 additions and 36 deletions
@@ -6,6 +6,7 @@ connection per device between everything talking to it, and hands that unit to
the ``solaredged`` library.
"""
from collections.abc import Set as AbstractSet
from typing import TYPE_CHECKING
from solaredged import SolarEdge, SolarEdgeConnectionError, SolarEdgeError
@@ -24,6 +25,7 @@ from .const import (
CONF_UNIT_ID,
DOMAIN,
LOGGER,
SUBSYSTEM_BATTERIES,
SUBSYSTEM_COMMON,
SUBSYSTEM_INVERTER,
SUBSYSTEM_METERS,
@@ -33,7 +35,7 @@ from .coordinator import (
SolarEdgeModbusDataUpdateCoordinator,
SolarEdgeModbusRuntimeData,
)
from .entity import inverter_device_info, meter_identity
from .entity import attachment_identity, inverter_device_info
from .helpers import create_modbus_params
PLATFORMS = [Platform.SENSOR]
@@ -92,6 +94,7 @@ async def async_setup_entry(
# entities would stay missing until a reload.
measuring = {SUBSYSTEM_INVERTER}
measuring.update(f"meters[{index}]" for index in range(len(solaredge.meters)))
measuring.update(f"batteries[{index}]" for index in range(len(solaredge.batteries)))
if measuring & readings.data.failed.keys():
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
@@ -108,17 +111,18 @@ async def async_setup_entry(
readings=readings, device_info=device_info, inverter_device_id=inverter.id
)
# A block that stayed silent while probing is taken for absent, so a meter
# that timed out cannot be told from one that was unwired. Its device stays
# where it is until the device says for itself that it is gone.
if SUBSYSTEM_METERS in solaredge.unresponsive_blocks:
if silent := solaredge.unresponsive_blocks & {
SUBSYSTEM_BATTERIES,
SUBSYSTEM_METERS,
}:
LOGGER.warning(
"%s did not answer for its meters while probing, so their entities"
" are missing until it does; reloading probes again",
"%s did not answer for its %s while probing, so their entities are"
" missing until it does; reloading probes again",
entry.title,
" and ".join(sorted(silent)),
)
else:
_async_remove_stale_devices(hass, entry, solaredge, serial_number)
_async_remove_stale_devices(hass, entry, solaredge, serial_number, silent=silent)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -130,18 +134,41 @@ def _async_remove_stale_devices(
entry: SolarEdgeModbusConfigEntry,
solaredge: SolarEdge,
serial_number: str,
*,
silent: AbstractSet[str],
) -> None:
"""Remove devices for meters no longer attached to the inverter."""
"""Remove devices for meters and batteries no longer attached.
A block that stayed silent while probing is taken for absent, and silence
is not the inverter saying its hardware is gone. Devices of that kind stay
where they are; the kind that did answer is still cleaned up.
"""
current = {(DOMAIN, serial_number)}
current.update(
(DOMAIN, f"{serial_number}_meter_{meter_identity(meter, index)}")
(DOMAIN, f"{serial_number}_meter_{attachment_identity(meter, index)}")
for index, meter in enumerate(solaredge.meters, 1)
)
current.update(
(DOMAIN, f"{serial_number}_battery_{attachment_identity(battery, index)}")
for index, battery in enumerate(solaredge.batteries, 1)
)
unproven = tuple(
f"{serial_number}_{kind}_"
for block, kind in (
(SUBSYSTEM_BATTERIES, "battery"),
(SUBSYSTEM_METERS, "meter"),
)
if block in silent
)
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
if not current.intersection(device.identifiers):
device_registry.async_remove_device(device.id)
if current.intersection(device.identifiers):
continue
if any(identifier.startswith(unproven) for _, identifier in device.identifiers):
continue
device_registry.async_remove_device(device.id)
async def async_unload_entry(
@@ -23,7 +23,8 @@ DEFAULT_UNIT_ID: Final = 1
SUBSYSTEM_COMMON: Final = "common"
SUBSYSTEM_INVERTER: Final = "inverter"
# How the library names the meter block it probes for.
# How the library names the blocks it probes for.
SUBSYSTEM_BATTERIES: Final = "batteries"
SUBSYSTEM_METERS: Final = "meters"
# Local Modbus is cheap to read and PV production moves fast.
@@ -1,14 +1,14 @@
"""Base entities for the SolarEdge Modbus integration.
Each meter attached to the inverter is its own sub-device, linked to the
inverter as its parent; everything else belongs to the inverter. All
Each meter and battery attached to the inverter is its own sub-device, linked
to the inverter as its parent; everything else belongs to the inverter. All
identities derive from the inverter's serial number, which the config flow
stores as the config entry unique ID.
"""
from typing import TYPE_CHECKING, override
from solaredged import Meter, SolarEdge
from solaredged import Battery, Meter, SolarEdge
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
@@ -41,15 +41,16 @@ def inverter_name(model: str | None) -> str:
return f"SolarEdge {commercial}"
def meter_identity(meter: Meter, index: int) -> str:
"""Return what tells a meter apart from the next one in its place.
def attachment_identity(component: Battery | Meter, index: int) -> str:
"""Return what tells an attached device apart from the next in its place.
A meter that reports a serial number is known by it, so replacing one is a
One that reports a serial number is known by it, so replacing it is a
different device rather than the same slot with other numbers in it. Not
every meter reports one, and then the slot it is wired to is all there is.
That fallback says so, since a bare number could be a serial itself.
every meter or battery reports one, and then the slot it is wired to is all
there is. That fallback says so, since a bare number could be a serial
itself.
"""
return meter.serial_number or f"slot_{index}"
return component.serial_number or f"slot_{index}"
def inverter_device_info(solaredge: SolarEdge, serial_number: str) -> DeviceInfo:
@@ -129,7 +130,7 @@ class SolarEdgeModbusMeterEntity(SolarEdgeModbusEntity):
) -> None:
"""Initialize a SolarEdge Modbus meter entity."""
meter = entry.runtime_data.solaredge.meters[index - 1]
identity = meter_identity(meter, index)
identity = attachment_identity(meter, index)
super().__init__(
entry=entry,
subsystem=f"meters[{index - 1}]",
@@ -148,3 +149,37 @@ class SolarEdgeModbusMeterEntity(SolarEdgeModbusEntity):
serial_number=meter.serial_number or None,
via_device_id=entry.runtime_data.inverter_device_id,
)
class SolarEdgeModbusBatteryEntity(SolarEdgeModbusEntity):
"""Defines a SolarEdge Modbus entity on a battery sub-device."""
def __init__(
self,
*,
entry: SolarEdgeModbusConfigEntry,
description: EntityDescription,
index: int,
) -> None:
"""Initialize a SolarEdge Modbus battery entity."""
battery = entry.runtime_data.solaredge.batteries[index - 1]
identity = attachment_identity(battery, index)
super().__init__(
entry=entry,
subsystem=f"batteries[{index - 1}]",
description=description,
key_prefix=f"battery_{identity}_",
)
self._index = index
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._serial_number}_battery_{identity}")},
manufacturer=battery.manufacturer or "SolarEdge",
# A battery names itself the same way a meter does, with a part
# number rather than something it is sold under.
model_id=battery.model or None,
name=f"Battery {index}",
sw_version=battery.version or None,
serial_number=battery.serial_number or None,
via_device_id=entry.runtime_data.inverter_device_id,
)
@@ -1,8 +1,14 @@
{
"entity": {
"sensor": {
"battery_status": {
"default": "mdi:home-battery"
},
"inverter_status": {
"default": "mdi:solar-power"
},
"state_of_health": {
"default": "mdi:battery-heart-variant"
}
}
}
@@ -4,7 +4,14 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from solaredged import Inverter, InverterStatus, Meter, SunSpecDID
from solaredged import (
Battery,
BatteryStatus,
Inverter,
InverterStatus,
Meter,
SunSpecDID,
)
from homeassistant.components.sensor import (
RestoreSensor,
@@ -31,7 +38,11 @@ from homeassistant.helpers.typing import StateType
from .const import LOGGER
from .coordinator import SolarEdgeModbusConfigEntry
from .entity import SolarEdgeModbusInverterEntity, SolarEdgeModbusMeterEntity
from .entity import (
SolarEdgeModbusBatteryEntity,
SolarEdgeModbusInverterEntity,
SolarEdgeModbusMeterEntity,
)
PARALLEL_UPDATES = 0
@@ -292,7 +303,7 @@ INVERTER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription[Inverter], ...] =
device_class=SensorDeviceClass.ENUM,
options=[status.name.lower() for status in InverterStatus],
value_fn=lambda inverter: (
inverter.status.name.lower() if inverter.status else None
inverter.status.name.lower() if inverter.status is not None else None
),
),
)
@@ -591,6 +602,178 @@ METER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription[Meter], ...] = (
)
BATTERY_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription[Battery], ...] = (
SolarEdgeModbusSensorEntityDescription(
key="dc_power",
translation_key="dc_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda battery: battery.dc_power,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_exported",
translation_key="energy_exported",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda battery: battery.energy_exported,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_imported",
translation_key="energy_imported",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda battery: battery.energy_imported,
),
SolarEdgeModbusSensorEntityDescription(
key="state_of_energy",
translation_key="state_of_energy",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda battery: battery.state_of_energy,
),
SolarEdgeModbusSensorEntityDescription(
key="state_of_health",
translation_key="state_of_health",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=0,
value_fn=lambda battery: battery.state_of_health,
),
SolarEdgeModbusSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
value_fn=lambda battery: battery.temperature_average,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_available",
translation_key="energy_available",
device_class=SensorDeviceClass.ENERGY_STORAGE,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
value_fn=lambda battery: battery.energy_available,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_max",
translation_key="energy_max",
device_class=SensorDeviceClass.ENERGY_STORAGE,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=2,
value_fn=lambda battery: battery.energy_max,
),
SolarEdgeModbusSensorEntityDescription(
key="rated_energy",
translation_key="rated_energy",
device_class=SensorDeviceClass.ENERGY_STORAGE,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=2,
value_fn=lambda battery: battery.rated_energy,
),
SolarEdgeModbusSensorEntityDescription(
key="dc_voltage",
translation_key="dc_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
value_fn=lambda battery: battery.dc_voltage,
),
SolarEdgeModbusSensorEntityDescription(
key="dc_current",
translation_key="dc_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
value_fn=lambda battery: battery.dc_current,
),
SolarEdgeModbusSensorEntityDescription(
key="max_charge_power",
translation_key="max_charge_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda battery: battery.max_charge_power,
),
SolarEdgeModbusSensorEntityDescription(
key="max_discharge_power",
translation_key="max_discharge_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda battery: battery.max_discharge_power,
),
SolarEdgeModbusSensorEntityDescription(
key="max_charge_peak_power",
translation_key="max_charge_peak_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda battery: battery.max_charge_peak_power,
),
SolarEdgeModbusSensorEntityDescription(
key="max_discharge_peak_power",
translation_key="max_discharge_peak_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda battery: battery.max_discharge_peak_power,
),
SolarEdgeModbusSensorEntityDescription(
key="temperature_max",
translation_key="temperature_max",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=1,
value_fn=lambda battery: battery.temperature_max,
),
SolarEdgeModbusSensorEntityDescription(
key="status",
translation_key="battery_status",
device_class=SensorDeviceClass.ENUM,
options=[status.name.lower() for status in BatteryStatus],
value_fn=lambda battery: (
battery.status.name.lower() if battery.status is not None else None
),
),
)
def _inverter_sensor(
entry: SolarEdgeModbusConfigEntry,
description: SolarEdgeModbusSensorEntityDescription[Inverter],
@@ -618,6 +801,21 @@ def _meter_sensor(
)
def _battery_sensor(
entry: SolarEdgeModbusConfigEntry,
description: SolarEdgeModbusSensorEntityDescription[Battery],
index: int,
) -> SensorEntity:
"""Build a battery sensor, monotonic where its state class asks for it."""
if description.state_class is SensorStateClass.TOTAL_INCREASING:
return SolarEdgeModbusBatteryEnergySensorEntity(
entry=entry, description=description, index=index
)
return SolarEdgeModbusBatterySensorEntity(
entry=entry, description=description, index=index
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SolarEdgeModbusConfigEntry,
@@ -637,6 +835,12 @@ async def async_setup_entry(
for description in METER_SENSORS
if description.exists_fn(meter)
)
entities.extend(
_battery_sensor(entry, description, index)
for index, battery in enumerate(solaredge.batteries, 1)
for description in BATTERY_SENSORS
if description.exists_fn(battery)
)
async_add_entities(entities)
@@ -728,3 +932,23 @@ class SolarEdgeModbusMeterEnergySensorEntity(
SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusMeterSensorEntity
):
"""Defines a monotonic SolarEdge Modbus meter energy sensor entity."""
class SolarEdgeModbusBatterySensorEntity(SolarEdgeModbusBatteryEntity, SensorEntity):
"""Defines a SolarEdge Modbus battery sensor entity."""
entity_description: SolarEdgeModbusSensorEntityDescription[Battery]
@property
@override
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(
self.coordinator.solaredge.batteries[self._index - 1]
)
class SolarEdgeModbusBatteryEnergySensorEntity(
SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusBatterySensorEntity
):
"""Defines a monotonic SolarEdge Modbus battery energy sensor entity."""
@@ -101,6 +101,20 @@
},
"entity": {
"sensor": {
"battery_status": {
"name": "Status",
"state": {
"charge": "Charging",
"discharge": "Discharging",
"fault": "Fault",
"idle": "[%key:common::state::idle%]",
"init": "Initializing",
"off": "[%key:common::state::off%]",
"power_saving": "Power saving",
"preserve_charge": "Preserving charge",
"standby": "[%key:common::state::standby%]"
}
},
"current_phase_a": {
"name": "Current phase A"
},
@@ -119,6 +133,9 @@
"dc_voltage": {
"name": "DC voltage"
},
"energy_available": {
"name": "Available energy"
},
"energy_exported": {
"name": "Energy exported"
},
@@ -143,6 +160,9 @@
"energy_imported_phase_c": {
"name": "Energy imported phase C"
},
"energy_max": {
"name": "Usable capacity"
},
"inverter_status": {
"name": "Status",
"state": {
@@ -156,6 +176,18 @@
"throttled": "Throttled"
}
},
"max_charge_peak_power": {
"name": "Maximum charge peak power"
},
"max_charge_power": {
"name": "Maximum charge power"
},
"max_discharge_peak_power": {
"name": "Maximum discharge peak power"
},
"max_discharge_power": {
"name": "Maximum discharge power"
},
"power_phase_a": {
"name": "Power phase A"
},
@@ -165,6 +197,18 @@
"power_phase_c": {
"name": "Power phase C"
},
"rated_energy": {
"name": "Rated energy"
},
"state_of_energy": {
"name": "State of energy"
},
"state_of_health": {
"name": "State of health"
},
"temperature_max": {
"name": "Maximum temperature"
},
"voltage_phase_ab": {
"name": "Voltage phase A-B"
},
@@ -30,6 +30,7 @@ PORT = 1502
UNIT_ID = 1
SERIAL_NUMBER = "7E123ABC"
METER_SERIAL_NUMBER = "7E4A11C2"
BATTERY_SERIAL_NUMBERS = ("7E7C33E4", "7E8D44F5")
# Where a meter's block starts, how far the next one sits, and where in it the
# serial number lives, as SunSpec lays them out.
@@ -37,6 +38,12 @@ METER_BASE = 40121
METER_STRIDE = 174
METER_SERIAL_BASE = 40171
# The same for the batteries, whose blocks sit at fixed offsets rather than a
# stride, with the rated-energy register the probe counts them by.
BATTERY_SERIAL_BASE = 57648
BATTERY_RATED_ENERGY = 57666
BATTERY_OFFSETS = (0, 256, 768)
def tcp_data(unit_id: int = UNIT_ID) -> dict[str, Any]:
"""Config entry data for an inverter reached over Modbus TCP."""
@@ -177,6 +177,70 @@
"57359": 17970,
"57360": 8192,
"57361": 17970,
"57600": 21359,
"57601": 27745,
"57602": 29253,
"57603": 25703,
"57604": 25856,
"57605": 0,
"57606": 0,
"57607": 0,
"57608": 0,
"57609": 0,
"57610": 0,
"57611": 0,
"57612": 0,
"57613": 0,
"57614": 0,
"57615": 0,
"57616": 21317,
"57617": 11586,
"57618": 16724,
"57619": 11572,
"57620": 14422,
"57621": 11569,
"57622": 12363,
"57623": 22344,
"57624": 0,
"57625": 0,
"57626": 0,
"57627": 0,
"57628": 0,
"57629": 0,
"57630": 0,
"57631": 0,
"57632": 12590,
"57633": 12846,
"57634": 13056,
"57635": 0,
"57636": 0,
"57637": 0,
"57638": 0,
"57639": 0,
"57640": 0,
"57641": 0,
"57642": 0,
"57643": 0,
"57644": 0,
"57645": 0,
"57646": 0,
"57647": 0,
"57648": 14149,
"57649": 14147,
"57650": 13107,
"57651": 17716,
"57652": 0,
"57653": 0,
"57654": 0,
"57655": 0,
"57656": 0,
"57657": 0,
"57658": 0,
"57659": 0,
"57660": 0,
"57661": 0,
"57662": 0,
"57663": 0,
"57666": 36864,
"57667": 17943,
"57668": 16384,
@@ -207,6 +271,70 @@
"57733": 17095,
"57734": 6,
"57735": 0,
"57856": 21359,
"57857": 27745,
"57858": 29253,
"57859": 25703,
"57860": 25856,
"57861": 0,
"57862": 0,
"57863": 0,
"57864": 0,
"57865": 0,
"57866": 0,
"57867": 0,
"57868": 0,
"57869": 0,
"57870": 0,
"57871": 0,
"57872": 21317,
"57873": 11586,
"57874": 16724,
"57875": 11572,
"57876": 14422,
"57877": 11569,
"57878": 12363,
"57879": 22344,
"57880": 0,
"57881": 0,
"57882": 0,
"57883": 0,
"57884": 0,
"57885": 0,
"57886": 0,
"57887": 0,
"57888": 12590,
"57889": 12846,
"57890": 13056,
"57891": 0,
"57892": 0,
"57893": 0,
"57894": 0,
"57895": 0,
"57896": 0,
"57897": 0,
"57898": 0,
"57899": 0,
"57900": 0,
"57901": 0,
"57902": 0,
"57903": 0,
"57904": 14149,
"57905": 14404,
"57906": 13364,
"57907": 17973,
"57908": 0,
"57909": 0,
"57910": 0,
"57911": 0,
"57912": 0,
"57913": 0,
"57914": 0,
"57915": 0,
"57916": 0,
"57917": 0,
"57918": 0,
"57919": 0,
"57922": 36864,
"57923": 17943,
"57964": 58463,
@@ -10,20 +10,20 @@
'energy_exported': 0,
'energy_imported': 0,
'energy_max': 9700.0,
'manufacturer': '',
'manufacturer': 'SolarEdge',
'max_charge_peak_power': 6000.0,
'max_charge_power': 5000.0,
'max_discharge_peak_power': 6100.0,
'max_discharge_power': 5100.0,
'model': '',
'model': 'SE-BAT-48V-10KWH',
'rated_energy': 9700.0,
'serial_number': '',
'serial_number': '**REDACTED**',
'state_of_energy': 99.94781494140625,
'state_of_health': 100.0,
'status': 6,
'temperature_average': 23.608806610107422,
'temperature_max': 24.5,
'version': '',
'version': '1.2.3',
}),
dict({
'dc_current': -0.0,
@@ -33,20 +33,20 @@
'energy_exported': 0,
'energy_imported': 0,
'energy_max': 9700.0,
'manufacturer': '',
'manufacturer': 'SolarEdge',
'max_charge_peak_power': 0.0,
'max_charge_power': 0.0,
'max_discharge_peak_power': 0.0,
'max_discharge_power': 0.0,
'model': '',
'model': 'SE-BAT-48V-10KWH',
'rated_energy': 9700.0,
'serial_number': '',
'serial_number': '**REDACTED**',
'state_of_energy': 99.2170181274414,
'state_of_health': 100.0,
'status': 6,
'temperature_average': 23.861509323120117,
'temperature_max': 0.0,
'version': '',
'version': '1.2.3',
}),
]),
'common': dict({
File diff suppressed because it is too large Load Diff
+163 -1
View File
@@ -18,7 +18,15 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from .conftest import METER_SERIAL_NUMBER, SERIAL_NUMBER, async_seed_unit, tcp_data
from .conftest import (
BATTERY_RATED_ENERGY,
BATTERY_SERIAL_BASE,
BATTERY_SERIAL_NUMBERS,
METER_SERIAL_NUMBER,
SERIAL_NUMBER,
async_seed_unit,
tcp_data,
)
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -27,6 +35,9 @@ POWER_ENTITY = "sensor.solaredge_se10000h_power"
# An address inside the inverter's read, to make that read fail.
INVERTER_REGISTER = 40069
# The register the probe counts meters by.
METER_MODEL_REGISTER = 40188
async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None:
entry.add_to_hass(hass)
@@ -250,6 +261,157 @@ async def test_replaced_meter_is_a_new_device(
)
async def test_batteries_are_sub_devices_of_the_inverter(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Each battery is hardware of its own, hanging off the inverter."""
await _setup(hass, mock_config_entry)
inverter = device_registry.async_get_device_by_identifier(
(DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id
)
assert inverter is not None
for index, serial_number in enumerate(BATTERY_SERIAL_NUMBERS, 1):
battery = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_battery_{serial_number}"),
mock_config_entry.entry_id,
)
assert battery is not None
assert battery.via_device_id == inverter.id
assert battery.name == f"Battery {index}"
assert battery.model_id == "SE-BAT-48V-10KWH"
assert battery.serial_number == serial_number
async def test_battery_that_left_the_installation_is_removed(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A battery taken out does not linger as a device.
The inverter refusing its block is the device saying it is gone, where
silence would only mean it did not answer this time.
"""
await _setup(hass, mock_config_entry)
identifiers = [
(DOMAIN, f"{SERIAL_NUMBER}_battery_{serial_number}")
for serial_number in BATTERY_SERIAL_NUMBERS
]
assert all(
device_registry.async_get_device_by_identifier(
identifier, mock_config_entry.entry_id
)
is not None
for identifier in identifiers
)
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, IllegalDataAddressError())
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert all(
device_registry.async_get_device_by_identifier(
identifier, mock_config_entry.entry_id
)
is None
for identifier in identifiers
)
async def test_battery_that_did_not_answer_the_probe_is_kept(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Silence while probing is not proof that a battery is gone."""
await _setup(hass, mock_config_entry)
identifier = (DOMAIN, f"{SERIAL_NUMBER}_battery_{BATTERY_SERIAL_NUMBERS[0]}")
assert (
device_registry.async_get_device_by_identifier(
identifier, mock_config_entry.entry_id
)
is not None
)
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, ModbusTimeoutError("timed out"))
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
device_registry.async_get_device_by_identifier(
identifier, mock_config_entry.entry_id
)
is not None
)
async def test_silence_about_one_kind_does_not_shield_the_other(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter that is really gone goes, even when the batteries kept quiet.
Silence about one kind of attached hardware says nothing about the other,
and holding on to everything would leave a removed meter behind for as long
as a battery is slow to answer.
"""
await _setup(hass, mock_config_entry)
meter = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}")
battery = (DOMAIN, f"{SERIAL_NUMBER}_battery_{BATTERY_SERIAL_NUMBERS[0]}")
mock_modbus_unit.fail_read(BATTERY_RATED_ENERGY, ModbusTimeoutError("timed out"))
mock_modbus_unit.fail_read(METER_MODEL_REGISTER, IllegalDataAddressError())
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (
device_registry.async_get_device_by_identifier(
meter, mock_config_entry.entry_id
)
is None
)
assert (
device_registry.async_get_device_by_identifier(
battery, mock_config_entry.entry_id
)
is not None
)
async def test_battery_without_a_serial_number_is_known_by_its_slot(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Not every battery names itself, and then its place on the inverter does."""
mock_modbus_unit.holding.update(
dict.fromkeys(range(BATTERY_SERIAL_BASE, BATTERY_SERIAL_BASE + 16), 0)
)
await _setup(hass, mock_config_entry)
battery = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_battery_slot_1"), mock_config_entry.entry_id
)
assert battery is not None
assert battery.serial_number is None
async def test_single_late_answer_is_retried(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,