mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
Add battery support to SolarEdge Modbus (#180657)
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user