From 7ceb81674f36c2ac756b887b36c9913beffcbdc3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 14:43:23 +0200 Subject: [PATCH] Add meter support to SolarEdge Modbus (#180572) --- .../components/solaredge_modbus/__init__.py | 60 +- .../components/solaredge_modbus/const.py | 3 + .../solaredge_modbus/coordinator.py | 1 + .../components/solaredge_modbus/entity.py | 109 +- .../solaredge_modbus/quality_scale.yaml | 8 +- .../components/solaredge_modbus/sensor.py | 376 +++- .../components/solaredge_modbus/strings.json | 33 + tests/components/solaredge_modbus/conftest.py | 34 +- .../solaredge_modbus/fixtures/se10000h.json | 56 + .../snapshots/test_diagnostics.ambr | 8 +- .../snapshots/test_sensor.ambr | 1590 +++++++++++++++++ .../components/solaredge_modbus/test_init.py | 187 +- .../solaredge_modbus/test_sensor.py | 111 ++ 13 files changed, 2521 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py index b9b93256494a..e32533594ca8 100644 --- a/homeassistant/components/solaredge_modbus/__init__.py +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -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: diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py index 18f62b8ed8fc..980df2f3c5c1 100644 --- a/homeassistant/components/solaredge_modbus/const.py +++ b/homeassistant/components/solaredge_modbus/const.py @@ -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) diff --git a/homeassistant/components/solaredge_modbus/coordinator.py b/homeassistant/components/solaredge_modbus/coordinator.py index 2d6e7818ad6d..7a19ea96bd16 100644 --- a/homeassistant/components/solaredge_modbus/coordinator.py +++ b/homeassistant/components/solaredge_modbus/coordinator.py @@ -144,6 +144,7 @@ class SolarEdgeModbusRuntimeData: readings: SolarEdgeModbusDataUpdateCoordinator device_info: DeviceInfo + inverter_device_id: str @property def solaredge(self) -> SolarEdge: diff --git a/homeassistant/components/solaredge_modbus/entity.py b/homeassistant/components/solaredge_modbus/entity.py index 3c5d41e87f60..132306d6b33f 100644 --- a/homeassistant/components/solaredge_modbus/entity.py +++ b/homeassistant/components/solaredge_modbus/entity.py @@ -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, ) diff --git a/homeassistant/components/solaredge_modbus/quality_scale.yaml b/homeassistant/components/solaredge_modbus/quality_scale.yaml index 75a8d222258d..332c05eaa4dc 100644 --- a/homeassistant/components/solaredge_modbus/quality_scale.yaml +++ b/homeassistant/components/solaredge_modbus/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/solaredge_modbus/sensor.py b/homeassistant/components/solaredge_modbus/sensor.py index a200ef30d8d9..0f9ca9e4b974 100644 --- a/homeassistant/components/solaredge_modbus/sensor.py +++ b/homeassistant/components/solaredge_modbus/sensor.py @@ -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.""" diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index 096a0eab09dd..d008b7653e16 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -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" }, diff --git a/tests/components/solaredge_modbus/conftest.py b/tests/components/solaredge_modbus/conftest.py index 998fe382ba1e..64071d6d0abe 100644 --- a/tests/components/solaredge_modbus/conftest.py +++ b/tests/components/solaredge_modbus/conftest.py @@ -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 diff --git a/tests/components/solaredge_modbus/fixtures/se10000h.json b/tests/components/solaredge_modbus/fixtures/se10000h.json index 604743a5f8bd..a6e6d9a1afe0 100644 --- a/tests/components/solaredge_modbus/fixtures/se10000h.json +++ b/tests/components/solaredge_modbus/fixtures/se10000h.json @@ -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, diff --git a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr index b1c0d683353c..e086af93533c 100644 --- a/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr +++ b/tests/components/solaredge_modbus/snapshots/test_diagnostics.ambr @@ -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, diff --git a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr index c2eb360a9b00..a240a8825adc 100644 --- a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr +++ b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr @@ -1,4 +1,1594 @@ # serializer version: 1 +# name: test_sensors[sensor.meter_1_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Apparent power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_apparent_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'apparent_power', + : 'Meter 1 Apparent power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5350', + }) +# --- +# name: test_sensors[sensor.meter_1_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Meter 1 Current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.08', + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_current_phase_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current phase A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_a', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_current_phase_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Meter 1 Current phase A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_current_phase_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_current_phase_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current phase B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_b', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_current_phase_b', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Meter 1 Current phase B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_current_phase_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.02', + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_current_phase_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current phase C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase_c', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_current_phase_c', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_current_phase_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Meter 1 Current phase C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_current_phase_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.06', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_energy_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy exported', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2324.624', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported phase A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported phase A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported_phase_a', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_exported_phase_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy exported phase A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '774.0', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported phase B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported phase B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported_phase_b', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_exported_phase_b', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy exported phase B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '775.0', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy exported phase C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy exported phase C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_exported_phase_c', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_exported_phase_c', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_exported_phase_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy exported phase C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_exported_phase_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '775.624', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_energy_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy imported', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12320.555', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported phase A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported phase A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported_phase_a', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_imported_phase_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy imported phase A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4106.0', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported phase B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported phase B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported_phase_b', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_imported_phase_b', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy imported phase B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4107.0', + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy imported phase C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy imported phase C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_imported_phase_c', + 'unique_id': '7E123ABC_meter_7E4A11C2_energy_imported_phase_c', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_energy_imported_phase_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Meter 1 Energy imported phase C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_energy_imported_phase_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4107.555', + }) +# --- +# name: test_sensors[sensor.meter_1_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frequency', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'frequency', + : 'Meter 1 Frequency', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.02', + }) +# --- +# name: test_sensors[sensor.meter_1_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Meter 1 Power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5279', + }) +# --- +# name: test_sensors[sensor.meter_1_power_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power factor', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.meter_1_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power_factor', + : 'Meter 1 Power factor', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_power_phase_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power phase A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_a', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power_phase_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Meter 1 Power phase A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power_phase_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1750', + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_power_phase_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power phase B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_b', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power_phase_b', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Meter 1 Power phase B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power_phase_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1760', + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_power_phase_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power phase C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_phase_c', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_power_phase_c', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_power_phase_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Meter 1 Power phase C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_power_phase_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1769', + }) +# --- +# name: test_sensors[sensor.meter_1_reactive_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_reactive_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'Meter 1 Reactive power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '869', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '232', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_a_b-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_voltage_phase_a_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase A-B', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase A-B', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_ab', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_ab', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_a_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase A-B', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_a_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '400', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_a_n-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_voltage_phase_a_n', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase A-N', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase A-N', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_an', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_an', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_a_n-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase A-N', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_a_n', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_b_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_voltage_phase_b_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase B-C', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase B-C', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_bc', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_bc', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_b_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase B-C', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_b_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '401', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_b_n-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_voltage_phase_b_n', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase B-N', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase B-N', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_bn', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_bn', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_b_n-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase B-N', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_b_n', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '232', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_c_a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_voltage_phase_c_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase C-A', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase C-A', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_ca', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_ca', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_c_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase C-A', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_c_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '402', + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_c_n-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.meter_1_voltage_phase_c_n', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage phase C-N', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase C-N', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_phase_cn', + 'unique_id': '7E123ABC_meter_7E4A11C2_ac_voltage_phase_cn', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.meter_1_voltage_phase_c_n-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'Meter 1 Voltage phase C-N', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.meter_1_voltage_phase_c_n', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233', + }) +# --- # name: test_sensors[sensor.solaredge_se10000h_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/solaredge_modbus/test_init.py b/tests/components/solaredge_modbus/test_init.py index 4d4d240cadb6..3b052d34a849 100644 --- a/tests/components/solaredge_modbus/test_init.py +++ b/tests/components/solaredge_modbus/test_init.py @@ -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, diff --git a/tests/components/solaredge_modbus/test_sensor.py b/tests/components/solaredge_modbus/test_sensor.py index 77bacc18a382..f18439a606d3 100644 --- a/tests/components/solaredge_modbus/test_sensor.py +++ b/tests/components/solaredge_modbus/test_sensor.py @@ -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,