Add meter support to SolarEdge Modbus (#180572)

This commit is contained in:
Franck Nijhof
2026-08-29 14:43:23 +02:00
committed by GitHub
parent 0cae37c56b
commit 7ceb81674f
13 changed files with 2521 additions and 55 deletions
@@ -20,13 +20,20 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import device_registry as dr
from .const import CONF_UNIT_ID, DOMAIN, SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER
from .const import (
CONF_UNIT_ID,
DOMAIN,
LOGGER,
SUBSYSTEM_COMMON,
SUBSYSTEM_INVERTER,
SUBSYSTEM_METERS,
)
from .coordinator import (
SolarEdgeModbusConfigEntry,
SolarEdgeModbusDataUpdateCoordinator,
SolarEdgeModbusRuntimeData,
)
from .entity import inverter_device_info
from .entity import inverter_device_info, meter_identity
from .helpers import create_modbus_params
PLATFORMS = [Platform.SENSOR]
@@ -81,27 +88,62 @@ async def async_setup_entry(
translation_key="identity_unavailable",
)
# The platforms read the inverter's DID once, so without it the phase
# The platforms read a component's DID once, so without it the phase
# entities would stay missing until a reload.
if SUBSYSTEM_INVERTER in readings.data.failed:
measuring = {SUBSYSTEM_INVERTER}
measuring.update(f"meters[{index}]" for index in range(len(solaredge.meters)))
if measuring & readings.data.failed.keys():
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="measurements_unavailable",
)
# Built once here: every entity hangs on the same device.
# Registered up front: a meter sub-device can only name the inverter it
# hangs off once that device has an ID.
device_info = inverter_device_info(solaredge, serial_number)
inverter = dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id, **device_info
)
entry.runtime_data = SolarEdgeModbusRuntimeData(
readings=readings, device_info=inverter_device_info(solaredge, serial_number)
)
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id, **entry.runtime_data.device_info
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:
LOGGER.warning(
"%s did not answer for its meters while probing, so their entities"
" are missing until it does; reloading probes again",
entry.title,
)
else:
_async_remove_stale_devices(hass, entry, solaredge, serial_number)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
def _async_remove_stale_devices(
hass: HomeAssistant,
entry: SolarEdgeModbusConfigEntry,
solaredge: SolarEdge,
serial_number: str,
) -> None:
"""Remove devices for meters no longer attached to the inverter."""
current = {(DOMAIN, serial_number)}
current.update(
(DOMAIN, f"{serial_number}_meter_{meter_identity(meter, index)}")
for index, meter in enumerate(solaredge.meters, 1)
)
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)
async def async_unload_entry(
hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry
) -> bool:
@@ -21,5 +21,8 @@ DEFAULT_UNIT_ID: Final = 1
SUBSYSTEM_COMMON: Final = "common"
SUBSYSTEM_INVERTER: Final = "inverter"
# How the library names the meter block it probes for.
SUBSYSTEM_METERS: Final = "meters"
# Local Modbus is cheap to read and PV production moves fast.
SCAN_INTERVAL: Final = timedelta(seconds=10)
@@ -144,6 +144,7 @@ class SolarEdgeModbusRuntimeData:
readings: SolarEdgeModbusDataUpdateCoordinator
device_info: DeviceInfo
inverter_device_id: str
@property
def solaredge(self) -> SolarEdge:
@@ -1,12 +1,14 @@
"""Base entities for the SolarEdge Modbus integration.
Every identity derives from the inverter's serial number, which the config
flow stores as the config entry unique ID.
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
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 SolarEdge
from solaredged import Meter, SolarEdge
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
@@ -39,8 +41,19 @@ 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.
A meter that reports a serial number is known by it, so replacing one 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.
"""
return meter.serial_number or f"slot_{index}"
def inverter_device_info(solaredge: SolarEdge, serial_number: str) -> DeviceInfo:
"""Return device information for the inverter."""
"""Return device information for the inverter itself."""
common = solaredge.common
return DeviceInfo(
identifiers={(DOMAIN, serial_number)},
@@ -53,13 +66,44 @@ def inverter_device_info(solaredge: SolarEdge, serial_number: str) -> DeviceInfo
)
class SolarEdgeModbusInverterEntity(
CoordinatorEntity[SolarEdgeModbusDataUpdateCoordinator]
):
"""Defines a SolarEdge Modbus entity on the inverter device."""
class SolarEdgeModbusEntity(CoordinatorEntity[SolarEdgeModbusDataUpdateCoordinator]):
"""Defines a SolarEdge Modbus entity."""
_attr_has_entity_name = True
def __init__(
self,
*,
entry: SolarEdgeModbusConfigEntry,
subsystem: str,
description: EntityDescription,
key_prefix: str = "",
) -> None:
"""Initialize a SolarEdge Modbus entity."""
super().__init__(coordinator=entry.runtime_data.readings)
self.entity_description = description
self._subsystem = subsystem
serial_number = entry.unique_id
if TYPE_CHECKING:
assert serial_number is not None
self._serial_number = serial_number
self._attr_unique_id = f"{serial_number}_{key_prefix}{description.key}"
@property
@override
def available(self) -> bool:
"""Return whether this entity's sub-system answered the last poll.
A poll can come back partial, and an entity that reports a value from
an earlier read as if it were current is lying about the device.
"""
return super().available and self._subsystem not in self.coordinator.data.failed
class SolarEdgeModbusInverterEntity(SolarEdgeModbusEntity):
"""Defines a SolarEdge Modbus entity on the inverter device."""
def __init__(
self,
*,
@@ -67,23 +111,40 @@ class SolarEdgeModbusInverterEntity(
description: EntityDescription,
) -> None:
"""Initialize a SolarEdge Modbus inverter entity."""
super().__init__(coordinator=entry.runtime_data.readings)
self.entity_description = description
serial_number = entry.unique_id
if TYPE_CHECKING:
assert serial_number is not None
self._attr_unique_id = f"{serial_number}_{description.key}"
super().__init__(
entry=entry, subsystem=SUBSYSTEM_INVERTER, description=description
)
self._attr_device_info = entry.runtime_data.device_info
@property
@override
def available(self) -> bool:
"""Return whether the inverter answered the most recent poll.
A poll can come back partial, and an entity that reports a value from
an earlier read as if it were current is lying about the device.
"""
return (
super().available and SUBSYSTEM_INVERTER not in self.coordinator.data.failed
class SolarEdgeModbusMeterEntity(SolarEdgeModbusEntity):
"""Defines a SolarEdge Modbus entity on a meter sub-device."""
def __init__(
self,
*,
entry: SolarEdgeModbusConfigEntry,
description: EntityDescription,
index: int,
) -> None:
"""Initialize a SolarEdge Modbus meter entity."""
meter = entry.runtime_data.solaredge.meters[index - 1]
identity = meter_identity(meter, index)
super().__init__(
entry=entry,
subsystem=f"meters[{index - 1}]",
description=description,
key_prefix=f"meter_{identity}_",
)
self._index = index
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._serial_number}_meter_{identity}")},
manufacturer=meter.manufacturer or "SolarEdge",
# What a meter reports is a part number, like "SE-MTR-3Y-400V-A",
# and there is no shorter name it is sold under to put beside it.
model_id=meter.model or None,
name=f"Meter {index}",
serial_number=meter.serial_number or None,
via_device_id=entry.runtime_data.inverter_device_id,
)
@@ -61,9 +61,7 @@ rules:
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: A config entry is one inverter, which is one device.
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -74,9 +72,7 @@ rules:
repair-issues:
status: exempt
comment: No repairable issues are raised.
stale-devices:
status: exempt
comment: A config entry is one inverter, so no device can go stale.
stale-devices: todo
# Platinum
async-dependency: done
@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from solaredged import Inverter, InverterStatus, SunSpecDID
from solaredged import Inverter, InverterStatus, Meter, SunSpecDID
from homeassistant.components.sensor import (
RestoreSensor,
@@ -31,23 +31,45 @@ from homeassistant.helpers.typing import StateType
from .const import LOGGER
from .coordinator import SolarEdgeModbusConfigEntry
from .entity import SolarEdgeModbusInverterEntity
from .entity import SolarEdgeModbusInverterEntity, SolarEdgeModbusMeterEntity
PARALLEL_UPDATES = 0
# Per-phase points only carry data on split- and three-phase inverters.
_MULTI_PHASE = (SunSpecDID.SPLIT_PHASE_INVERTER, SunSpecDID.THREE_PHASE_INVERTER)
# Meters report per-phase points from two phases up; the third phase only on a
# three-phase meter, and line-to-neutral voltages on everything but a delta.
_MULTI_PHASE_METER = (
SunSpecDID.SPLIT_PHASE_METER,
SunSpecDID.THREE_PHASE_WYE_METER,
SunSpecDID.THREE_PHASE_DELTA_METER,
)
_THREE_PHASE_METER = (
SunSpecDID.THREE_PHASE_WYE_METER,
SunSpecDID.THREE_PHASE_DELTA_METER,
)
# A delta meter has no neutral, so it measures nothing against one.
_NEUTRAL_METER = (
SunSpecDID.SINGLE_PHASE_METER,
SunSpecDID.SPLIT_PHASE_METER,
SunSpecDID.THREE_PHASE_WYE_METER,
)
_PHASE_NEUTRAL_METER = (
SunSpecDID.SPLIT_PHASE_METER,
SunSpecDID.THREE_PHASE_WYE_METER,
)
@dataclass(frozen=True, kw_only=True)
class SolarEdgeModbusSensorEntityDescription(SensorEntityDescription):
class SolarEdgeModbusSensorEntityDescription[ComponentT](SensorEntityDescription):
"""Describes a SolarEdge Modbus sensor entity."""
exists_fn: Callable[[Inverter], bool] = lambda _: True
value_fn: Callable[[Inverter], StateType]
exists_fn: Callable[[ComponentT], bool] = lambda _: True
value_fn: Callable[[ComponentT], StateType]
INVERTER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription, ...] = (
INVERTER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription[Inverter], ...] = (
SolarEdgeModbusSensorEntityDescription(
key="ac_power",
device_class=SensorDeviceClass.POWER,
@@ -276,9 +298,302 @@ INVERTER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription, ...] = (
)
METER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription[Meter], ...] = (
SolarEdgeModbusSensorEntityDescription(
key="ac_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda meter: meter.ac_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 meter: meter.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 meter: meter.energy_imported,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_power_phase_a",
translation_key="power_phase_a",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.ac_power_a,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_power_phase_b",
translation_key="power_phase_b",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.ac_power_b,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_power_phase_c",
translation_key="power_phase_c",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
exists_fn=lambda meter: meter.did in _THREE_PHASE_METER,
value_fn=lambda meter: meter.ac_power_c,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
value_fn=lambda meter: meter.ac_current,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_current_phase_a",
translation_key="current_phase_a",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.ac_current_a,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_current_phase_b",
translation_key="current_phase_b",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.ac_current_b,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_current_phase_c",
translation_key="current_phase_c",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
exists_fn=lambda meter: meter.did in _THREE_PHASE_METER,
value_fn=lambda meter: meter.ac_current_c,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_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,
exists_fn=lambda meter: meter.did in _NEUTRAL_METER,
value_fn=lambda meter: meter.ac_voltage_ln,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_voltage_phase_an",
translation_key="voltage_phase_an",
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,
exists_fn=lambda meter: meter.did in _PHASE_NEUTRAL_METER,
value_fn=lambda meter: meter.ac_voltage_an,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_voltage_phase_bn",
translation_key="voltage_phase_bn",
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,
exists_fn=lambda meter: meter.did in _PHASE_NEUTRAL_METER,
value_fn=lambda meter: meter.ac_voltage_bn,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_voltage_phase_cn",
translation_key="voltage_phase_cn",
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,
exists_fn=lambda meter: meter.did is SunSpecDID.THREE_PHASE_WYE_METER,
value_fn=lambda meter: meter.ac_voltage_cn,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_voltage_phase_ab",
translation_key="voltage_phase_ab",
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,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.ac_voltage_ab,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_voltage_phase_bc",
translation_key="voltage_phase_bc",
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,
exists_fn=lambda meter: meter.did in _THREE_PHASE_METER,
value_fn=lambda meter: meter.ac_voltage_bc,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_voltage_phase_ca",
translation_key="voltage_phase_ca",
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,
exists_fn=lambda meter: meter.did in _THREE_PHASE_METER,
value_fn=lambda meter: meter.ac_voltage_ca,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_frequency",
device_class=SensorDeviceClass.FREQUENCY,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=2,
value_fn=lambda meter: meter.ac_frequency,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_apparent_power",
device_class=SensorDeviceClass.APPARENT_POWER,
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda meter: meter.ac_va,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_reactive_power",
device_class=SensorDeviceClass.REACTIVE_POWER,
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda meter: meter.ac_var,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_power_factor",
device_class=SensorDeviceClass.POWER_FACTOR,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=1,
value_fn=lambda meter: meter.ac_power_factor,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_exported_phase_a",
translation_key="energy_exported_phase_a",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.energy_exported_a,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_exported_phase_b",
translation_key="energy_exported_phase_b",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.energy_exported_b,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_exported_phase_c",
translation_key="energy_exported_phase_c",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
exists_fn=lambda meter: meter.did in _THREE_PHASE_METER,
value_fn=lambda meter: meter.energy_exported_c,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_imported_phase_a",
translation_key="energy_imported_phase_a",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.energy_imported_a,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_imported_phase_b",
translation_key="energy_imported_phase_b",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
exists_fn=lambda meter: meter.did in _MULTI_PHASE_METER,
value_fn=lambda meter: meter.energy_imported_b,
),
SolarEdgeModbusSensorEntityDescription(
key="energy_imported_phase_c",
translation_key="energy_imported_phase_c",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
exists_fn=lambda meter: meter.did in _THREE_PHASE_METER,
value_fn=lambda meter: meter.energy_imported_c,
),
)
def _inverter_sensor(
entry: SolarEdgeModbusConfigEntry,
description: SolarEdgeModbusSensorEntityDescription,
description: SolarEdgeModbusSensorEntityDescription[Inverter],
) -> SensorEntity:
"""Build an inverter sensor, monotonic where its state class asks for it."""
if description.state_class is SensorStateClass.TOTAL_INCREASING:
@@ -288,6 +603,21 @@ def _inverter_sensor(
return SolarEdgeModbusInverterSensorEntity(entry=entry, description=description)
def _meter_sensor(
entry: SolarEdgeModbusConfigEntry,
description: SolarEdgeModbusSensorEntityDescription[Meter],
index: int,
) -> SensorEntity:
"""Build a meter sensor, monotonic where its state class asks for it."""
if description.state_class is SensorStateClass.TOTAL_INCREASING:
return SolarEdgeModbusMeterEnergySensorEntity(
entry=entry, description=description, index=index
)
return SolarEdgeModbusMeterSensorEntity(
entry=entry, description=description, index=index
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SolarEdgeModbusConfigEntry,
@@ -296,17 +626,25 @@ async def async_setup_entry(
"""Set up SolarEdge Modbus sensor entities based on a config entry."""
solaredge = entry.runtime_data.solaredge
async_add_entities(
entities: list[SensorEntity] = [
_inverter_sensor(entry, description)
for description in INVERTER_SENSORS
if description.exists_fn(solaredge.inverter)
]
entities.extend(
_meter_sensor(entry, description, index)
for index, meter in enumerate(solaredge.meters, 1)
for description in METER_SENSORS
if description.exists_fn(meter)
)
async_add_entities(entities)
class SolarEdgeModbusInverterSensorEntity(SolarEdgeModbusInverterEntity, SensorEntity):
"""Defines a SolarEdge Modbus inverter sensor entity."""
entity_description: SolarEdgeModbusSensorEntityDescription
entity_description: SolarEdgeModbusSensorEntityDescription[Inverter]
@property
@override
@@ -315,6 +653,20 @@ class SolarEdgeModbusInverterSensorEntity(SolarEdgeModbusInverterEntity, SensorE
return self.entity_description.value_fn(self.coordinator.solaredge.inverter)
class SolarEdgeModbusMeterSensorEntity(SolarEdgeModbusMeterEntity, SensorEntity):
"""Defines a SolarEdge Modbus meter sensor entity."""
entity_description: SolarEdgeModbusSensorEntityDescription[Meter]
@property
@override
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(
self.coordinator.solaredge.meters[self._index - 1]
)
class SolarEdgeModbusEnergySensorEntity(RestoreSensor):
"""Keeps a lifetime-energy sensor monotonic across glitches and restarts.
@@ -370,3 +722,9 @@ class SolarEdgeModbusInverterEnergySensorEntity(
SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusInverterSensorEntity
):
"""Defines a monotonic SolarEdge Modbus inverter energy sensor entity."""
class SolarEdgeModbusMeterEnergySensorEntity(
SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusMeterSensorEntity
):
"""Defines a monotonic SolarEdge Modbus meter energy sensor entity."""
@@ -86,6 +86,30 @@
"dc_voltage": {
"name": "DC voltage"
},
"energy_exported": {
"name": "Energy exported"
},
"energy_exported_phase_a": {
"name": "Energy exported phase A"
},
"energy_exported_phase_b": {
"name": "Energy exported phase B"
},
"energy_exported_phase_c": {
"name": "Energy exported phase C"
},
"energy_imported": {
"name": "Energy imported"
},
"energy_imported_phase_a": {
"name": "Energy imported phase A"
},
"energy_imported_phase_b": {
"name": "Energy imported phase B"
},
"energy_imported_phase_c": {
"name": "Energy imported phase C"
},
"inverter_status": {
"name": "Status",
"state": {
@@ -99,6 +123,15 @@
"throttled": "Throttled"
}
},
"power_phase_a": {
"name": "Power phase A"
},
"power_phase_b": {
"name": "Power phase B"
},
"power_phase_c": {
"name": "Power phase C"
},
"voltage_phase_ab": {
"name": "Voltage phase A-B"
},
+33 -1
View File
@@ -29,6 +29,13 @@ HOST = "1.2.3.4"
PORT = 1502
UNIT_ID = 1
SERIAL_NUMBER = "7E123ABC"
METER_SERIAL_NUMBER = "7E4A11C2"
# 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.
METER_BASE = 40121
METER_STRIDE = 174
METER_SERIAL_BASE = 40171
def tcp_data(unit_id: int = UNIT_ID) -> dict[str, Any]:
@@ -49,7 +56,8 @@ async def async_seed_unit(
The capture predates several of the points this integration reads, so those
registers carry hand-picked values instead: distinct per point, and
consistent with what the device did report (phase values sum to the
recorded totals, apparent power exceeds real power). Pass
recorded totals, apparent power exceeds real power). The meter's identity
block is hand-picked the same way, since the capture skips it. Pass
``serial_registers`` to override the inverter serial number ("7E123ABC" as
captured).
"""
@@ -64,6 +72,30 @@ async def async_seed_unit(
)
def add_second_meter(unit: MockModbusUnit, serial_number: str) -> None:
"""Wire a second meter onto a seeded unit.
Every address of a meter shifts by the SunSpec stride per meter, so the
first meter's block, copied one stride up, is a second meter that reports
the same measurements under its own serial number.
"""
block = {
address + METER_STRIDE: value
for address, value in unit.holding.items()
if METER_BASE <= address < METER_BASE + METER_STRIDE
}
padded = serial_number.ljust(32, "\0").encode()
block.update(
{
METER_SERIAL_BASE + METER_STRIDE + index: (
(padded[index * 2] << 8) | padded[index * 2 + 1]
)
for index in range(16)
}
)
unit.holding.update(block)
@pytest.fixture
async def mock_modbus_unit(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
@@ -63,6 +63,62 @@
"40106": 65534,
"40107": 4,
"40108": 4,
"40123": 21359,
"40124": 27745,
"40125": 29253,
"40126": 25703,
"40127": 25856,
"40128": 0,
"40129": 0,
"40130": 0,
"40131": 0,
"40132": 0,
"40133": 0,
"40134": 0,
"40135": 0,
"40136": 0,
"40137": 0,
"40138": 0,
"40139": 21317,
"40140": 11597,
"40141": 21586,
"40142": 11571,
"40143": 22829,
"40144": 13360,
"40145": 12374,
"40146": 11585,
"40147": 0,
"40148": 0,
"40149": 0,
"40150": 0,
"40151": 0,
"40152": 0,
"40153": 0,
"40154": 0,
"40163": 13102,
"40164": 12590,
"40165": 12288,
"40166": 0,
"40167": 0,
"40168": 0,
"40169": 0,
"40170": 0,
"40171": 14149,
"40172": 13377,
"40173": 12593,
"40174": 17202,
"40175": 0,
"40176": 0,
"40177": 0,
"40178": 0,
"40179": 0,
"40180": 0,
"40181": 0,
"40182": 0,
"40183": 0,
"40184": 0,
"40185": 0,
"40186": 0,
"40188": 203,
"40190": 3008,
"40191": 1000,
@@ -130,15 +130,15 @@
'energy_imported_b': 4107000.0,
'energy_imported_c': 4107555.0,
'events': 0,
'manufacturer': '',
'model': '',
'manufacturer': 'SolarEdge',
'model': 'SE-MTR-3Y-400V-A',
'option': '',
'reactive_energy_q1': 0,
'reactive_energy_q2': 0,
'reactive_energy_q3': 0,
'reactive_energy_q4': 0,
'serial_number': '',
'version': '',
'serial_number': '**REDACTED**',
'version': '3.1.0',
}),
]),
'mmppt': None,
File diff suppressed because it is too large Load Diff
+185 -2
View File
@@ -3,7 +3,11 @@
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError
from modbus_connection import (
IllegalDataAddressError,
ModbusTimeoutError,
ServerDeviceFailureError,
)
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
@@ -14,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from .conftest import SERIAL_NUMBER, async_seed_unit, tcp_data
from .conftest import METER_SERIAL_NUMBER, SERIAL_NUMBER, async_seed_unit, tcp_data
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -67,6 +71,185 @@ async def test_inverter_that_does_not_name_itself(
assert inverter.model_id is None
async def test_meter_is_a_sub_device_of_the_inverter(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""A meter is real 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
meter = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}"),
mock_config_entry.entry_id,
)
assert meter is not None
assert meter.via_device_id == inverter.id
assert meter.name == "Meter 1"
assert meter.model_id == "SE-MTR-3Y-400V-A"
assert meter.serial_number == METER_SERIAL_NUMBER
async def test_meter_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 meter names itself, and then its place on the inverter does.
The fallback says which slot it is rather than just the number, so it
cannot be read as a serial number that happens to be short.
"""
mock_modbus_unit.holding.update(dict.fromkeys(range(40171, 40187), 0))
await _setup(hass, mock_config_entry)
meter = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_slot_1"), mock_config_entry.entry_id
)
assert meter is not None
assert meter.serial_number is None
async def test_meter_that_left_the_installation_is_removed(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter taken off the inverter does not linger as a device.
Which meters are attached is read while the entry is set up, so a meter
that was removed is gone by the time the entry loads again.
"""
await _setup(hass, mock_config_entry)
meter_identifier = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}")
assert (
device_registry.async_get_device_by_identifier(
meter_identifier, mock_config_entry.entry_id
)
is not None
)
mock_modbus_unit.fail_read(40188, 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_identifier, mock_config_entry.entry_id
)
is None
)
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, SERIAL_NUMBER), mock_config_entry.entry_id
)
is not None
)
async def test_setup_retry_when_a_meter_is_unreadable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter that answers the probe but not the poll holds up setup.
Which sensors a meter offers is decided from its DID, once, so an entry
accepted without it would be missing its phase measurements until a reload.
"""
mock_modbus_unit.fail_read(40190, ServerDeviceFailureError())
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_meter_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 meter is gone.
The library takes a block that does not answer for absent, which keeps the
rest of the device usable. Removing the device on that would throw away a
meter's history over a single timeout.
"""
await _setup(hass, mock_config_entry)
meter_identifier = (DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}")
assert (
device_registry.async_get_device_by_identifier(
meter_identifier, mock_config_entry.entry_id
)
is not None
)
mock_modbus_unit.fail_read(40188, 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(
meter_identifier, mock_config_entry.entry_id
)
is not None
)
async def test_replaced_meter_is_a_new_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Another meter in the same place is another device.
Its counters start where the old meter's did not, and reusing the device
would hold the new readings against the old meter's totals.
"""
await _setup(hass, mock_config_entry)
replacement = "7E5B22D3"
padded = replacement.ljust(32, "\0").encode()
mock_modbus_unit.holding.update(
{
40171 + index: (padded[index * 2] << 8) | padded[index * 2 + 1]
for index in range(16)
}
)
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(
(DOMAIN, f"{SERIAL_NUMBER}_meter_{METER_SERIAL_NUMBER}"),
mock_config_entry.entry_id,
)
is None
)
assert (
device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL_NUMBER}_meter_{replacement}"),
mock_config_entry.entry_id,
)
is not None
)
async def test_single_late_answer_is_retried(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
@@ -13,6 +13,8 @@ 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 tests.common import (
MockConfigEntry,
async_fire_time_changed,
@@ -132,6 +134,115 @@ async def test_phase_currents_on_three_phase(
assert hass.states.get("sensor.solaredge_se10000h_current_phase_c") is not None
async def test_two_meters_are_told_apart(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A second meter reads its own block and goes unavailable on its own.
Both meters report the same measurements here, so what is worth proving is
that each entity reaches the meter it belongs to: the values move apart
when one block does, and only that meter's entities go unavailable when it
stops answering.
"""
add_second_meter(mock_modbus_unit, "7E5B22D3")
await _setup_sensor_platform(hass, mock_config_entry)
first = hass.states.get("sensor.meter_1_power")
second = hass.states.get("sensor.meter_2_power")
assert first is not None
assert second is not None
assert first.state == second.state
# Only the second meter's power register moves.
mock_modbus_unit.holding[40206 + 174] = 1000
await _tick(hass, freezer)
assert hass.states.get("sensor.meter_1_power") == first
second = hass.states.get("sensor.meter_2_power")
assert second is not None
assert second.state != first.state
# Only the second meter falls silent.
mock_modbus_unit.fail_read(40297, ModbusTimeoutError("timed out"))
await _tick(hass, freezer)
await _tick(hass, freezer)
assert hass.states.get("sensor.meter_2_power").state == STATE_UNAVAILABLE
assert hass.states.get("sensor.meter_1_power").state != STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_delta_meter_measures_nothing_against_a_neutral(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A delta meter has no neutral, so no voltage is measured against one."""
mock_modbus_unit.holding[40188] = 204 # SunSpec three-phase delta meter
await _setup_sensor_platform(hass, mock_config_entry)
# Line-to-line is all a delta meter has.
assert hass.states.get("sensor.meter_1_voltage_phase_a_b") is not None
assert hass.states.get("sensor.meter_1_voltage_phase_b_c") is not None
assert hass.states.get("sensor.meter_1_voltage") is None
assert hass.states.get("sensor.meter_1_voltage_phase_a_n") is None
assert hass.states.get("sensor.meter_1_voltage_phase_b_n") is None
assert hass.states.get("sensor.meter_1_voltage_phase_c_n") is None
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_split_phase_meter_has_two_phases(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A split-phase meter measures two phases, so the third is not offered."""
mock_modbus_unit.holding[40188] = 202 # SunSpec split-phase meter
await _setup_sensor_platform(hass, mock_config_entry)
assert hass.states.get("sensor.meter_1_power_phase_a") is not None
assert hass.states.get("sensor.meter_1_power_phase_b") is not None
assert hass.states.get("sensor.meter_1_voltage_phase_a_n") is not None
assert hass.states.get("sensor.meter_1_power_phase_c") is None
assert hass.states.get("sensor.meter_1_voltage_phase_c_n") is None
assert hass.states.get("sensor.meter_1_voltage_phase_b_c") is None
async def test_meter_energy_ignores_transient_zero(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A meter accumulator transiently reporting zero is held at the last maximum."""
await _setup_sensor_platform(hass, mock_config_entry)
state = hass.states.get("sensor.meter_1_energy_exported")
assert state is not None
initial = float(state.state)
assert initial > 0
# The meter block transiently reports "not accumulated" (zero).
mock_modbus_unit.holding[40226] = 0
mock_modbus_unit.holding[40227] = 0
await _tick(hass, freezer)
state = hass.states.get("sensor.meter_1_energy_exported")
assert state is not None
assert float(state.state) == initial
async def test_lifetime_energy_never_goes_backwards(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,