mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add meter support to SolarEdge Modbus (#180572)
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user