Add SolarEdge Modbus integration (#180508)

This commit is contained in:
Franck Nijhof
2026-08-29 00:02:58 +02:00
committed by GitHub
parent 5efa625de5
commit 471f2c28e2
25 changed files with 3116 additions and 1 deletions
+1
View File
@@ -547,6 +547,7 @@ homeassistant.components.smhi.*
homeassistant.components.smlight.*
homeassistant.components.smtp.*
homeassistant.components.snooz.*
homeassistant.components.solaredge_modbus.*
homeassistant.components.solarlog.*
homeassistant.components.sonarr.*
homeassistant.components.spaceapi.*
Generated
+2
View File
@@ -1742,6 +1742,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/solaredge/ @frenck @bdraco @tronikos
/tests/components/solaredge/ @frenck @bdraco @tronikos
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solaredge_modbus/ @frenck
/tests/components/solaredge_modbus/ @frenck
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli
/homeassistant/components/solarman/ @solarmanpv
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "solaredge",
"name": "SolarEdge",
"integrations": ["solaredge", "solaredge_local"]
"integrations": ["solaredge", "solaredge_local", "solaredge_modbus"]
}
@@ -0,0 +1,109 @@
"""Support for SolarEdge inverters over Modbus.
The inverter is a Modbus device. This integration does not own its connection:
it borrows a ``ModbusUnit`` from the ``modbus`` integration, which shares one
connection per device between everything talking to it, and hands that unit to
the ``solaredged`` library.
"""
from typing import TYPE_CHECKING
from solaredged import SolarEdge, SolarEdgeConnectionError, SolarEdgeError
from homeassistant.components.modbus import async_get_unit
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryError,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import device_registry as dr
from .const import CONF_UNIT_ID, DOMAIN, SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER
from .coordinator import (
SolarEdgeModbusConfigEntry,
SolarEdgeModbusDataUpdateCoordinator,
SolarEdgeModbusRuntimeData,
)
from .entity import inverter_device_info
from .helpers import create_modbus_params
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry
) -> bool:
"""Set up SolarEdge Modbus from a config entry."""
serial_number = entry.unique_id
if TYPE_CHECKING:
assert serial_number is not None
try:
unit = async_get_unit(
hass, entry, create_modbus_params(entry.data), entry.data[CONF_UNIT_ID]
)
except HomeAssistantError as err:
# The device is already in use over different link settings, which one
# shared connection cannot honour.
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="link_settings_in_use",
translation_placeholders={"error": str(err)},
) from err
try:
solaredge = await SolarEdge.async_probe(unit)
except SolarEdgeConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err
except SolarEdgeError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="no_solaredge_device",
) from err
readings = SolarEdgeModbusDataUpdateCoordinator(hass, entry, solaredge)
await readings.async_config_entry_first_refresh()
# Identity arrives with that first read, and a poll can come back without
# it. Nothing can be checked then, so try again rather than accept the
# entry: an address or device ID can end up pointing at another inverter (a
# reused DHCP lease, a changed setting), and every identity here derives
# from the entry's serial number.
if SUBSYSTEM_COMMON in readings.data.failed:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="identity_unavailable",
)
# The platforms read the inverter's DID once, so without it the phase
# entities would stay missing until a reload.
if SUBSYSTEM_INVERTER in readings.data.failed:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="measurements_unavailable",
)
# Built once here: every entity hangs on the same device.
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
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry
) -> bool:
"""Unload SolarEdge Modbus config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,170 @@
"""Config flow to configure the SolarEdge Modbus integration."""
from collections.abc import Mapping
from typing import Any, override
from solaredged import SolarEdge, SolarEdgeConnectionError, SolarEdgeError
import voluptuous as vol
from homeassistant.components.modbus import async_get_temporary_unit
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
TextSelector,
)
from .const import (
CONF_UNIT_ID,
DEFAULT_PORT,
DEFAULT_UNIT_ID,
DOMAIN,
SUBSYSTEM_COMMON,
SUBSYSTEM_INVERTER,
TYPE_TCP,
)
from .entity import inverter_name
from .helpers import create_modbus_params
SECTION_MORE_OPTIONS = "more_options"
STEP_USER = vol.Schema(
{
vol.Required(CONF_HOST): TextSelector(),
vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All(
NumberSelector(
NumberSelectorConfig(
min=1, max=65535, step=1, mode=NumberSelectorMode.BOX
)
),
vol.Coerce(int),
),
# Almost every inverter answers on the factory-default device ID, so
# that setting is tucked away in a collapsed section.
vol.Required(SECTION_MORE_OPTIONS): section(
vol.Schema(
{
vol.Required(CONF_UNIT_ID, default=DEFAULT_UNIT_ID): vol.All(
NumberSelector(
NumberSelectorConfig(
min=1, max=247, step=1, mode=NumberSelectorMode.BOX
)
),
vol.Coerce(int),
),
}
),
{"collapsed": True},
),
}
)
def _flatten(user_input: dict[str, Any]) -> dict[str, Any]:
"""Flatten the sectioned form input into config entry data."""
data = {CONF_TYPE: TYPE_TCP, **user_input}
data[CONF_UNIT_ID] = data.pop(SECTION_MORE_OPTIONS)[CONF_UNIT_ID]
# One connection is shared per host and port, so spelling matters.
data[CONF_HOST] = data[CONF_HOST].lower()
return data
def _sectioned(data: Mapping[str, Any]) -> dict[str, Any]:
"""Shape config entry data back into the sectioned form input."""
return {
CONF_HOST: data[CONF_HOST],
CONF_PORT: data[CONF_PORT],
SECTION_MORE_OPTIONS: {CONF_UNIT_ID: data[CONF_UNIT_ID]},
}
class SolarEdgeModbusFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a SolarEdge Modbus config flow."""
VERSION = 1
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Ask where the inverter is, then probe it."""
errors: dict[str, str] = {}
if user_input is not None:
data = _flatten(user_input)
errors, solaredge = await self._async_validate(data)
if solaredge is not None:
await self.async_set_unique_id(solaredge.common.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=inverter_name(solaredge.common.model), data=data
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER, errors=errors
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of how the inverter is reached.
The inverter may move to another address or device ID (a new gateway, a
changed setting), but it must stay the same inverter: the probed serial
number has to match the entry's unique ID.
"""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
data = _flatten(user_input)
errors, solaredge = await self._async_validate(data)
if solaredge is not None:
if solaredge.common.serial_number == entry.unique_id:
return self.async_update_reload_and_abort(entry, data_updates=data)
return self.async_abort(reason="wrong_device")
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER, user_input or _sectioned(entry.data)
),
errors=errors,
)
async def _async_validate(
self, data: dict[str, Any]
) -> tuple[dict[str, str], SolarEdge | None]:
"""Probe the inverter, returning form errors and the probed device."""
try:
async with async_get_temporary_unit(
self.hass, create_modbus_params(data), data[CONF_UNIT_ID]
) as unit:
solaredge = await SolarEdge.async_probe(unit)
# Identity (serial number, model name) is read on the first refresh.
report = await solaredge.async_update()
except HomeAssistantError, SolarEdgeConnectionError:
# HomeAssistantError: the device is already in use over different
# link settings, which one connection cannot honour.
return {"base": "cannot_connect"}, None
except SolarEdgeError:
return {"base": "no_solaredge_device"}, None
if solaredge.is_ev_charger:
return {"base": "ev_charger"}, None
# Setup needs both blocks, so a partial answer here would only create
# an entry that cannot start.
if {SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER} & report.failed.keys():
return {"base": "cannot_connect"}, None
if not solaredge.common.serial_number:
return {"base": "no_serial_number"}, None
return {}, solaredge
@@ -0,0 +1,25 @@
"""Constants for the SolarEdge Modbus integration."""
from datetime import timedelta
import logging
from typing import Final
DOMAIN: Final = "solaredge_modbus"
LOGGER = logging.getLogger(__package__)
CONF_UNIT_ID: Final = "unit_id"
# How the inverter is reached is stored from the start, so that an inverter on
# something other than the network needs no migration to say so.
TYPE_TCP: Final = "tcp"
# SolarEdge's factory defaults: Modbus TCP on port 1502, device ID 1.
DEFAULT_PORT: Final = 1502
DEFAULT_UNIT_ID: Final = 1
# Sub-system names as the library reports them in an UpdateReport.
SUBSYSTEM_COMMON: Final = "common"
SUBSYSTEM_INVERTER: Final = "inverter"
# Local Modbus is cheap to read and PV production moves fast.
SCAN_INTERVAL: Final = timedelta(seconds=10)
@@ -0,0 +1,151 @@
"""DataUpdateCoordinator for the SolarEdge Modbus integration."""
from dataclasses import dataclass
from typing import override
from solaredged import SolarEdge, SolarEdgeConnectionError, UpdateReport
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL, SUBSYSTEM_COMMON
type SolarEdgeModbusConfigEntry = ConfigEntry[SolarEdgeModbusRuntimeData]
def _merge(first: UpdateReport, second: UpdateReport) -> UpdateReport:
"""Fold a retried poll into the one it followed.
A sub-system that answered either attempt holds fresh values, so only the
ones that stayed silent throughout count as failed.
"""
return UpdateReport(
updated=first.updated | second.updated,
failed={
subsystem: error
for subsystem, error in second.failed.items()
if subsystem not in first.updated
},
)
class SolarEdgeModbusDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]):
"""Polls the inverter's sub-systems over Modbus.
A poll can come back partial: the library reads every sub-system on its
own, so one that falls silent no longer takes the others down with it. The
report names what refreshed, which is what entities read their availability
from.
"""
config_entry: SolarEdgeModbusConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: SolarEdgeModbusConfigEntry,
solaredge: SolarEdge,
) -> None:
"""Initialize the coordinator."""
self.solaredge = solaredge
self._silent: set[str] = set()
super().__init__(
hass,
LOGGER,
config_entry=entry,
# The serial number identifies this inverter, but it would also end
# up in every log line a name is written to, so the title stands in.
name=f"{entry.title} readings",
update_interval=SCAN_INTERVAL,
)
@override
async def _async_update_data(self) -> UpdateReport:
"""Poll the inverter, reporting what answered."""
report = await self._async_poll()
# A sub-system that just fell silent gets a second chance: SolarEdge
# answers a single request late often enough that one blip should not
# blank its entities. One that has been silent a while does not, so a
# sub-system that is really gone cannot double every poll from here on.
if report.failed.keys() - self._silent:
report = await self._async_retry(report)
# An address can move to another inverter, and its measurements are not
# this one's however the entities reading them are named. Checked on
# every poll that brought the identity along, not only at setup.
if (
SUBSYSTEM_COMMON in report.updated
and self.solaredge.common.serial_number != self.config_entry.unique_id
):
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="wrong_inverter",
)
self._log_silence(report)
return report
async def _async_retry(self, report: UpdateReport) -> UpdateReport:
"""Poll again, keeping the first attempt's report if the retry dies.
A link that drops between the two attempts does not make values from a
second ago stale, and failing the whole refresh would blank every
sub-system that did answer. The next poll reports the dead link soon
enough.
"""
try:
retried = await self.solaredge.async_update_readings()
except SolarEdgeConnectionError as err:
LOGGER.debug(
"%s: nothing answered the retry (%s); keeping the first poll",
self.name,
err,
)
return report
return _merge(report, retried)
async def _async_poll(self) -> UpdateReport:
"""Poll the inverter's sub-systems, translating a dead link."""
try:
return await self.solaredge.async_update_readings()
except SolarEdgeConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err
def _log_silence(self, report: UpdateReport) -> None:
"""Log a sub-system falling silent once, and log its return."""
for subsystem, error in report.failed.items():
if subsystem not in self._silent:
self._silent.add(subsystem)
LOGGER.warning(
"%s: %s did not answer this poll and kept its previous values: %s",
self.name,
subsystem,
error,
)
for subsystem in report.updated & self._silent:
self._silent.discard(subsystem)
LOGGER.info("%s: %s is answering again", self.name, subsystem)
@dataclass(kw_only=True)
class SolarEdgeModbusRuntimeData:
"""Runtime data for a SolarEdge Modbus config entry."""
readings: SolarEdgeModbusDataUpdateCoordinator
device_info: DeviceInfo
@property
def solaredge(self) -> SolarEdge:
"""Return the polled device."""
return self.readings.solaredge
@@ -0,0 +1,89 @@
"""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.
"""
from typing import TYPE_CHECKING, override
from solaredged import SolarEdge
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SUBSYSTEM_INVERTER
from .coordinator import (
SolarEdgeModbusConfigEntry,
SolarEdgeModbusDataUpdateCoordinator,
)
def inverter_model(model: str | None) -> str | None:
"""Return the model an inverter is sold as, without the variant code.
SolarEdge reports a part number like "SE17K-RW0T0BNN4". Everything up to
the dash is what the thing is called in a brochure and in conversation;
the rest spells out region, connectors and options. The full string is kept
as the model ID, where a part number belongs.
"""
if not model:
return None
return model.split("-", 1)[0]
def inverter_name(model: str | None) -> str:
"""Return a name for the inverter that reads like one."""
if (commercial := inverter_model(model)) is None:
return "SolarEdge inverter"
return f"SolarEdge {commercial}"
def inverter_device_info(solaredge: SolarEdge, serial_number: str) -> DeviceInfo:
"""Return device information for the inverter."""
common = solaredge.common
return DeviceInfo(
identifiers={(DOMAIN, serial_number)},
manufacturer=common.manufacturer or "SolarEdge",
model=inverter_model(common.model),
model_id=common.model or None,
name=inverter_name(common.model),
sw_version=common.version or None,
serial_number=serial_number,
)
class SolarEdgeModbusInverterEntity(
CoordinatorEntity[SolarEdgeModbusDataUpdateCoordinator]
):
"""Defines a SolarEdge Modbus entity on the inverter device."""
_attr_has_entity_name = True
def __init__(
self,
*,
entry: SolarEdgeModbusConfigEntry,
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}"
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
)
@@ -0,0 +1,13 @@
"""Helpers for the SolarEdge Modbus integration."""
from collections.abc import Mapping
from typing import Any
from modbus_connection import ModbusTcpParams
from homeassistant.const import CONF_HOST, CONF_PORT
def create_modbus_params(data: Mapping[str, Any]) -> ModbusTcpParams:
"""Build the Modbus link parameters from config entry data."""
return ModbusTcpParams(host=data[CONF_HOST], port=data[CONF_PORT])
@@ -0,0 +1,12 @@
{
"entity": {
"sensor": {
"inverter_status": {
"default": "mdi:solar-power"
},
"vendor_status": {
"default": "mdi:information-outline"
}
}
}
}
@@ -0,0 +1,13 @@
{
"domain": "solaredge_modbus",
"name": "SolarEdge Modbus",
"codeowners": ["@frenck"],
"config_flow": true,
"dependencies": ["modbus"],
"documentation": "https://www.home-assistant.io/integrations/solaredge_modbus",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["modbus_connection", "solaredged", "tmodbus"],
"quality_scale": "bronze",
"requirements": ["solaredged==0.2.3"]
}
@@ -0,0 +1,86 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not register any service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not register any service actions.
docs-conditions:
status: exempt
comment: This integration provides no conditions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: This integration provides no triggers.
entity-event-setup:
status: exempt
comment: |
Entities read cached state from the coordinator; they subscribe via
CoordinatorEntity and register no other event handlers.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not register any service actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: A Modbus link has no authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
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.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
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.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: This integration talks Modbus, not HTTP.
strict-typing: done
@@ -0,0 +1,379 @@
"""Support for SolarEdge Modbus sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import override
from solaredged import Inverter, InverterStatus, SunSpecDID
from homeassistant.components.sensor import (
RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfApparentPower,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfReactivePower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import LOGGER
from .coordinator import SolarEdgeModbusConfigEntry
from .entity import SolarEdgeModbusInverterEntity
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)
@dataclass(frozen=True, kw_only=True)
class SolarEdgeModbusSensorEntityDescription(SensorEntityDescription):
"""Describes a SolarEdge Modbus sensor entity."""
exists_fn: Callable[[Inverter], bool] = lambda _: True
value_fn: Callable[[Inverter], StateType]
INVERTER_SENSORS: tuple[SolarEdgeModbusSensorEntityDescription, ...] = (
SolarEdgeModbusSensorEntityDescription(
key="ac_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda inverter: inverter.ac_power,
),
SolarEdgeModbusSensorEntityDescription(
key="ac_energy",
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 inverter: inverter.ac_energy,
),
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 inverter: inverter.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 inverter: inverter.did in _MULTI_PHASE,
value_fn=lambda inverter: inverter.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 inverter: inverter.did in _MULTI_PHASE,
value_fn=lambda inverter: inverter.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 inverter: inverter.did is SunSpecDID.THREE_PHASE_INVERTER,
value_fn=lambda inverter: inverter.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 inverter: inverter.did is SunSpecDID.SINGLE_PHASE_INVERTER,
value_fn=lambda inverter: inverter.ac_voltage_an,
),
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 inverter: inverter.did in _MULTI_PHASE,
value_fn=lambda inverter: inverter.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 inverter: inverter.did is SunSpecDID.THREE_PHASE_INVERTER,
value_fn=lambda inverter: inverter.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 inverter: inverter.did is SunSpecDID.THREE_PHASE_INVERTER,
value_fn=lambda inverter: inverter.ac_voltage_ca,
),
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 inverter: inverter.did in _MULTI_PHASE,
value_fn=lambda inverter: inverter.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 inverter: inverter.did in _MULTI_PHASE,
value_fn=lambda inverter: inverter.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 inverter: inverter.did is SunSpecDID.THREE_PHASE_INVERTER,
value_fn=lambda inverter: inverter.ac_voltage_cn,
),
SolarEdgeModbusSensorEntityDescription(
key="dc_power",
translation_key="dc_power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda inverter: inverter.dc_power,
),
SolarEdgeModbusSensorEntityDescription(
key="dc_current",
translation_key="dc_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
value_fn=lambda inverter: inverter.dc_current,
),
SolarEdgeModbusSensorEntityDescription(
key="dc_voltage",
translation_key="dc_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
value_fn=lambda inverter: inverter.dc_voltage,
),
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 inverter: inverter.ac_frequency,
),
SolarEdgeModbusSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
value_fn=lambda inverter: inverter.temperature_heatsink,
),
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 inverter: inverter.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 inverter: inverter.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 inverter: inverter.ac_power_factor,
),
SolarEdgeModbusSensorEntityDescription(
key="status",
translation_key="inverter_status",
device_class=SensorDeviceClass.ENUM,
options=[status.name.lower() for status in InverterStatus],
value_fn=lambda inverter: (
inverter.status.name.lower() if inverter.status else None
),
),
SolarEdgeModbusSensorEntityDescription(
key="vendor_status",
translation_key="vendor_status",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda inverter: inverter.vendor_status,
),
)
def _inverter_sensor(
entry: SolarEdgeModbusConfigEntry,
description: SolarEdgeModbusSensorEntityDescription,
) -> SensorEntity:
"""Build an inverter sensor, monotonic where its state class asks for it."""
if description.state_class is SensorStateClass.TOTAL_INCREASING:
return SolarEdgeModbusInverterEnergySensorEntity(
entry=entry, description=description
)
return SolarEdgeModbusInverterSensorEntity(entry=entry, description=description)
async def async_setup_entry(
hass: HomeAssistant,
entry: SolarEdgeModbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SolarEdge Modbus sensor entities based on a config entry."""
solaredge = entry.runtime_data.solaredge
async_add_entities(
_inverter_sensor(entry, description)
for description in INVERTER_SENSORS
if description.exists_fn(solaredge.inverter)
)
class SolarEdgeModbusInverterSensorEntity(SolarEdgeModbusInverterEntity, SensorEntity):
"""Defines a SolarEdge Modbus inverter sensor entity."""
entity_description: SolarEdgeModbusSensorEntityDescription
@property
@override
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.solaredge.inverter)
class SolarEdgeModbusEnergySensorEntity(RestoreSensor):
"""Keeps a lifetime-energy sensor monotonic across glitches and restarts.
SolarEdge accumulators transiently report lower values (or zero) around
the inverter's sleep/wake transition; a single such sample fed to a
``total_increasing`` sensor registers as a meter reset and corrupts the
long-term statistics. The highest value seen wins, and it is restored
across restarts so an overnight restart does not lose that truth.
"""
_highest_value: float | None = None
_glitch_logged = False
@override
async def async_added_to_hass(self) -> None:
"""Restore the highest previously seen value."""
await super().async_added_to_hass()
data = await self.async_get_last_sensor_data()
if data is not None and isinstance(data.native_value, (int, float)):
self._highest_value = data.native_value
@property
@override
def native_value(self) -> StateType:
"""Return the sensor value, never lower than seen before."""
value = super().native_value
if not isinstance(value, (int, float)):
return self._highest_value
if self._highest_value is None or value >= self._highest_value:
self._highest_value = value
self._glitch_logged = False
return value
if not self._glitch_logged:
LOGGER.warning(
(
"%s reported a lifetime energy of %s Wh, lower than the"
" %s Wh seen before; ignoring the lower value (a known"
" SolarEdge glitch around its sleep/wake transition)"
),
self.entity_id,
value,
self._highest_value,
)
self._glitch_logged = True
return self._highest_value
class SolarEdgeModbusInverterEnergySensorEntity(
SolarEdgeModbusEnergySensorEntity, SolarEdgeModbusInverterSensorEntity
):
"""Defines a monotonic SolarEdge Modbus inverter energy sensor entity."""
@@ -0,0 +1,137 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_device": "The device at that address and device ID is a different inverter than the one this entry is set up for."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"ev_charger": "That device is a SolarEdge EV charger. It answers as an inverter, but serves no measurements over Modbus.",
"no_serial_number": "The inverter did not report a serial number, which is needed to identify it.",
"no_solaredge_device": "The device at that address and device ID does not answer as a SolarEdge inverter."
},
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "[%key:component::solaredge_modbus::config::step::user::data_description::host%]",
"port": "[%key:component::solaredge_modbus::config::step::user::data_description::port%]"
},
"description": "Update how this inverter is reached, for example after it moved to another address or its device ID changed.",
"sections": {
"more_options": {
"data": {
"unit_id": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::data::unit_id%]"
},
"data_description": {
"unit_id": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::data_description::unit_id%]"
},
"name": "[%key:component::solaredge_modbus::config::step::user::sections::more_options::name%]"
}
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your SolarEdge inverter. Modbus TCP has to be enabled on the inverter first, in the installer settings.",
"port": "The TCP port the inverter listens on for Modbus requests. The SolarEdge default is 1502."
},
"description": "Connect to your SolarEdge inverter over Modbus to monitor your solar energy production locally.",
"sections": {
"more_options": {
"data": {
"unit_id": "Device ID"
},
"data_description": {
"unit_id": "The Modbus device ID of the inverter, as configured on the inverter itself. The SolarEdge default is 1."
},
"name": "More options"
}
}
}
}
},
"entity": {
"sensor": {
"current_phase_a": {
"name": "Current phase A"
},
"current_phase_b": {
"name": "Current phase B"
},
"current_phase_c": {
"name": "Current phase C"
},
"dc_current": {
"name": "DC current"
},
"dc_power": {
"name": "DC power"
},
"dc_voltage": {
"name": "DC voltage"
},
"inverter_status": {
"name": "Status",
"state": {
"fault": "Fault",
"off": "[%key:common::state::off%]",
"producing": "Producing",
"shutting_down": "Shutting down",
"sleeping": "Sleeping",
"standby": "[%key:common::state::standby%]",
"starting": "Starting",
"throttled": "Throttled"
}
},
"vendor_status": {
"name": "Vendor status"
},
"voltage_phase_ab": {
"name": "Voltage phase A-B"
},
"voltage_phase_an": {
"name": "Voltage phase A-N"
},
"voltage_phase_bc": {
"name": "Voltage phase B-C"
},
"voltage_phase_bn": {
"name": "Voltage phase B-N"
},
"voltage_phase_ca": {
"name": "Voltage phase C-A"
},
"voltage_phase_cn": {
"name": "Voltage phase C-N"
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the SolarEdge inverter: {error}"
},
"identity_unavailable": {
"message": "The inverter did not report its identity, so it cannot be confirmed as the one this entry was set up for."
},
"link_settings_in_use": {
"message": "The inverter cannot be set up with these link settings: {error}"
},
"measurements_unavailable": {
"message": "The inverter did not report its measurements, so it is not yet known which sensors it offers."
},
"no_solaredge_device": {
"message": "The configured Modbus device does not answer as a SolarEdge inverter."
},
"wrong_inverter": {
"message": "The device at this address is a different inverter than the one this entry was set up for. Reconfigure the entry to point at the right device."
}
}
}
+1
View File
@@ -736,6 +736,7 @@ FLOWS = {
"snooz",
"sofar",
"solaredge",
"solaredge_modbus",
"solarlog",
"solarman",
"solax",
@@ -6833,6 +6833,12 @@
"config_flow": false,
"iot_class": "local_polling",
"name": "SolarEdge Local"
},
"solaredge_modbus": {
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "SolarEdge Modbus"
}
}
},
Generated
+10
View File
@@ -5228,6 +5228,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.solaredge_modbus.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.solarlog.*]
check_untyped_defs = true
disallow_incomplete_defs = true
+3
View File
@@ -3113,6 +3113,9 @@ solaredge-local==0.2.3
# homeassistant.components.solaredge
solaredge-web==0.3.1
# homeassistant.components.solaredge_modbus
solaredged==0.2.3
# homeassistant.components.solarlog
solarlog_cli==0.7.1
@@ -0,0 +1 @@
"""Tests for the SolarEdge Modbus integration."""
@@ -0,0 +1,116 @@
"""Fixtures for the SolarEdge Modbus tests.
The ``mock_modbus_connection`` / ``mock_modbus_unit`` fixtures come from the
``modbus-connection`` library's pytest plugin (registered as a ``pytest11``
entry point). Seeding the unit's holding store with a captured register dump
drives the real ``solaredged`` library exactly as a device would.
"""
from collections.abc import AsyncIterator, Generator
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import patch
from modbus_connection import ModbusUnit
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
from homeassistant.components.solaredge_modbus.const import (
CONF_UNIT_ID,
DOMAIN,
TYPE_TCP,
)
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_load_json_object_fixture
HOST = "1.2.3.4"
PORT = 1502
UNIT_ID = 1
SERIAL_NUMBER = "7E123ABC"
def tcp_data(unit_id: int = UNIT_ID) -> dict[str, Any]:
"""Config entry data for an inverter reached over Modbus TCP."""
return {
CONF_TYPE: TYPE_TCP,
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_UNIT_ID: unit_id,
}
async def async_seed_unit(
hass: HomeAssistant, unit: MockModbusUnit, serial_registers: list[int] | None = None
) -> None:
"""Seed a mock unit with the captured SE10000H register dump.
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
``serial_registers`` to override the inverter serial number ("7E123ABC" as
captured).
"""
registers = (await async_load_json_object_fixture(hass, "se10000h.json", DOMAIN))[
"holding"
]
unit.holding.update({int(address): value for address, value in registers.items()})
if serial_registers is not None:
unit.holding.update(
dict(zip(range(40052, 40056), serial_registers, strict=True))
)
@pytest.fixture
async def mock_modbus_unit(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
) -> MockModbusUnit:
"""A seeded SolarEdge inverter on unit ``UNIT_ID``.
Overrides the library plugin's ``mock_modbus_unit`` to preload a captured
register dump of an SE10000H.
"""
unit = mock_modbus_connection.for_unit(UNIT_ID)
await async_seed_unit(hass, unit)
return unit
@pytest.fixture(autouse=True)
def mock_shared_connection(
mock_modbus_connection: MockModbusConnection, mock_modbus_unit: MockModbusUnit
) -> Generator[None]:
"""Hand out units on the seeded mock instead of opening a real connection."""
@asynccontextmanager
async def async_temporary_unit(
hass: HomeAssistant, params: Any, unit_id: int
) -> AsyncIterator[ModbusUnit]:
yield mock_modbus_connection.for_unit(unit_id)
with (
patch(
"homeassistant.components.solaredge_modbus.async_get_unit",
side_effect=lambda hass, entry, params, unit_id: (
mock_modbus_connection.for_unit(unit_id)
),
),
patch(
"homeassistant.components.solaredge_modbus.config_flow.async_get_temporary_unit",
async_temporary_unit,
),
):
yield
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""A SolarEdge Modbus config entry for the seeded inverter."""
return MockConfigEntry(
domain=DOMAIN,
title="SolarEdge SE10000H",
unique_id=SERIAL_NUMBER,
data=tcp_data(),
)
@@ -0,0 +1,175 @@
{
"holding": {
"40000": 21365,
"40001": 28243,
"40004": 21359,
"40005": 27745,
"40006": 29253,
"40007": 25703,
"40008": 25856,
"40009": 0,
"40010": 0,
"40011": 0,
"40012": 0,
"40013": 0,
"40014": 0,
"40015": 0,
"40016": 0,
"40017": 0,
"40018": 0,
"40019": 0,
"40020": 21317,
"40021": 12592,
"40022": 12336,
"40023": 12360,
"40024": 11585,
"40025": 21843,
"40026": 20034,
"40027": 16984,
"40028": 12596,
"40029": 0,
"40030": 0,
"40031": 0,
"40032": 0,
"40033": 0,
"40034": 0,
"40035": 0,
"40052": 14149,
"40053": 12594,
"40054": 13121,
"40055": 16963,
"40069": 101,
"40071": 3999,
"40075": 65534,
"40076": 2502,
"40079": 2502,
"40082": 65535,
"40083": 9490,
"40084": 0,
"40085": 50037,
"40086": 65533,
"40087": 9600,
"40088": 0,
"40089": 1449,
"40090": 0,
"40091": 98,
"40092": 0,
"40093": 286,
"40094": 42356,
"40095": 0,
"40100": 9635,
"40101": 0,
"40103": 4767,
"40106": 65534,
"40107": 4,
"40108": 4,
"40188": 203,
"40190": 3008,
"40191": 1000,
"40192": 1002,
"40193": 1006,
"40194": 65534,
"40195": 232,
"40196": 231,
"40197": 232,
"40198": 233,
"40200": 400,
"40201": 401,
"40202": 402,
"40203": 0,
"40204": 5002,
"40205": 65534,
"40206": 5279,
"40207": 1750,
"40208": 1760,
"40209": 1769,
"40210": 0,
"40211": 5350,
"40215": 0,
"40216": 869,
"40220": 0,
"40221": 98,
"40225": 0,
"40226": 3547,
"40227": 6208,
"40228": 1181,
"40229": 1984,
"40230": 1182,
"40231": 36448,
"40232": 1183,
"40233": 33312,
"40234": 18799,
"40235": 44236,
"40236": 6265,
"40237": 16960,
"40238": 6266,
"40239": 51424,
"40240": 6267,
"40241": 41388,
"40242": 65534,
"57348": 1,
"57349": 0,
"57350": 0,
"57351": 0,
"57352": 0,
"57353": 16384,
"57354": 0,
"57355": 3600,
"57356": 0,
"57357": 65535,
"57358": 8192,
"57359": 17970,
"57360": 8192,
"57361": 17970,
"57666": 36864,
"57667": 17943,
"57668": 16384,
"57669": 17820,
"57670": 24576,
"57671": 17823,
"57672": 32768,
"57673": 17851,
"57674": 40960,
"57675": 17854,
"57708": 57046,
"57709": 16828,
"57710": 0,
"57711": 16836,
"57712": 60513,
"57713": 17353,
"57714": 0,
"57715": 32768,
"57716": 0,
"57717": 0,
"57726": 36864,
"57727": 17943,
"57728": 46858,
"57729": 17941,
"57730": 0,
"57731": 17096,
"57732": 58696,
"57733": 17095,
"57734": 6,
"57735": 0,
"57922": 36864,
"57923": 17943,
"57964": 58463,
"57965": 16830,
"57968": 26311,
"57969": 17354,
"57970": 0,
"57971": 32768,
"57972": 0,
"57973": 0,
"57982": 36864,
"57983": 17943,
"57984": 43581,
"57985": 17943,
"57986": 0,
"57987": 17096,
"57988": 28445,
"57989": 17094,
"57990": 6,
"57991": 0
}
}
@@ -0,0 +1,822 @@
# serializer version: 1
# name: test_sensors[sensor.solaredge_se10000h_apparent_power-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_apparent_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Apparent power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.APPARENT_POWER: 'apparent_power'>,
'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_ac_apparent_power',
'unit_of_measurement': <UnitOfApparentPower.VOLT_AMPERE: 'VA'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_apparent_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'apparent_power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Apparent power',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfApparentPower.VOLT_AMPERE: 'VA'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_apparent_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9600',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.solaredge_se10000h_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Current',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'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_ac_current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'current',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Current',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '39.99',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_dc_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_dc_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DC current',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'original_icon': None,
'original_name': 'DC current',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dc_current',
'unique_id': '7E123ABC_dc_current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_dc_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'current',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H DC current',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_dc_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_dc_power-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_dc_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DC power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'DC power',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dc_power',
'unique_id': '7E123ABC_dc_power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_dc_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H DC power',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_dc_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9635',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_dc_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_dc_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'DC voltage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'original_icon': None,
'original_name': 'DC voltage',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dc_voltage',
'unique_id': '7E123ABC_dc_voltage',
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_dc_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'voltage',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H DC voltage',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_dc_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.solaredge_se10000h_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '7E123ABC_ac_energy',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'energy',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Energy',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '18785.652',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_frequency-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_frequency',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Frequency',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.FREQUENCY: 'frequency'>,
'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_ac_frequency',
'unit_of_measurement': <UnitOfFrequency.HERTZ: 'Hz'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_frequency-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'frequency',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Frequency',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfFrequency.HERTZ: 'Hz'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_frequency',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '50.037',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_power-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.solaredge_se10000h_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'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_ac_power',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Power',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9490',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_power_factor-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_power_factor',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Power factor',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.POWER_FACTOR: 'power_factor'>,
'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_ac_power_factor',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_power_factor-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'power_factor',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Power factor',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_power_factor',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '98',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_reactive_power-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_reactive_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Reactive power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.REACTIVE_POWER: 'reactive_power'>,
'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_ac_reactive_power',
'unit_of_measurement': <UnitOfReactivePower.VOLT_AMPERE_REACTIVE: 'var'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_reactive_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'reactive_power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Reactive power',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfReactivePower.VOLT_AMPERE_REACTIVE: 'var'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_reactive_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1449',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_status-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'off',
'sleeping',
'starting',
'producing',
'throttled',
'shutting_down',
'fault',
'standby',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.solaredge_se10000h_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Status',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Status',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'inverter_status',
'unique_id': '7E123ABC_status',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'enum',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Status',
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'off',
'sleeping',
'starting',
'producing',
'throttled',
'shutting_down',
'fault',
'standby',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'producing',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '7E123ABC_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '47.67',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_vendor_status-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_vendor_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Vendor status',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Vendor status',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'vendor_status',
'unique_id': '7E123ABC_vendor_status',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_vendor_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Vendor status',
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_vendor_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '4',
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.solaredge_se10000h_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Voltage',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
'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_ac_voltage',
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
})
# ---
# name: test_sensors[sensor.solaredge_se10000h_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'voltage',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Voltage',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfElectricPotential.VOLT: 'V'>,
}),
'context': <ANY>,
'entity_id': 'sensor.solaredge_se10000h_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '250.2',
})
# ---
@@ -0,0 +1,274 @@
"""Tests for the SolarEdge Modbus config flow."""
from typing import Any
from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
from homeassistant.components.solaredge_modbus.config_flow import SECTION_MORE_OPTIONS
from homeassistant.components.solaredge_modbus.const import CONF_UNIT_ID, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import HOST, PORT, SERIAL_NUMBER, UNIT_ID, async_seed_unit, tcp_data
from tests.common import MockConfigEntry
TITLE = "SolarEdge SE10000H"
# The serial number of a second, different inverter: "OTHER123".
OTHER_SERIAL_REGISTERS = [20308, 18501, 21041, 12851]
def _user_input(unit_id: int = UNIT_ID) -> dict[str, Any]:
"""Form input for the user step, with the sectioned device ID."""
return {
CONF_HOST: HOST,
CONF_PORT: PORT,
SECTION_MORE_OPTIONS: {CONF_UNIT_ID: unit_id},
}
def _model_registers(model: str) -> dict[int, int]:
"""Registers holding a model name in the SunSpec common block."""
padded = model.ljust(32, "\0").encode()
return {
40020 + index: (padded[index * 2] << 8) | padded[index * 2 + 1]
for index in range(16)
}
async def test_user_flow_tcp(hass: HomeAssistant) -> None:
"""An inverter on the network is probed and its entry created."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TITLE # read from the device
assert result["data"] == tcp_data()
assert result["result"].unique_id == SERIAL_NUMBER # the inverter serial
async def test_user_flow_cannot_connect(
hass: HomeAssistant, mock_modbus_unit: MockModbusUnit
) -> None:
"""An unresponsive device surfaces cannot_connect, then the flow recovers."""
mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out"))
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# The device answers again.
mock_modbus_unit.fail_read(40000, None)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TITLE
async def test_user_flow_partial_answer(
hass: HomeAssistant, mock_modbus_unit: MockModbusUnit
) -> None:
"""An inverter that answers in part is not accepted, then the flow recovers.
Setting up needs the inverter block as much as the identity block: what
entities the entry gets is decided from it. Accepting the form here would
hand the user an entry that setup can only retry.
"""
mock_modbus_unit.fail_read(40069, ServerDeviceFailureError())
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# The inverter answers for its measurements again.
mock_modbus_unit.fail_read(40069, None)
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TITLE
async def test_user_flow_no_solaredge_device(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
) -> None:
"""A Modbus device without a SunSpec header surfaces no_solaredge_device."""
# A device that answers reads but is not a SolarEdge inverter.
unit = mock_modbus_connection.for_unit(2)
unit.holding.update(dict.fromkeys(range(40000, 40004), 0))
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
result = await hass.config_entries.flow.async_configure(flow_id, _user_input(2))
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_solaredge_device"}
async def test_user_flow_no_serial_number(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
) -> None:
"""An inverter without a serial number cannot be identified and is rejected."""
# A valid inverter image, but with the serial-number registers zeroed out.
unit = mock_modbus_connection.for_unit(3)
await async_seed_unit(hass, unit)
unit.holding.update(dict.fromkeys(range(40052, 40068), 0))
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
result = await hass.config_entries.flow.async_configure(flow_id, _user_input(3))
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_serial_number"}
async def test_user_flow_ev_charger(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
) -> None:
"""A SolarEdge EV charger answers as an inverter, but is rejected."""
unit = mock_modbus_connection.for_unit(4)
await async_seed_unit(hass, unit)
unit.holding.update(_model_registers("SE-EV-SA-KIT"))
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
result = await hass.config_entries.flow.async_configure(flow_id, _user_input(4))
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "ev_charger"}
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Setting up the same inverter twice aborts."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
flow_id = result["flow_id"]
result = await hass.config_entries.flow.async_configure(flow_id, _user_input())
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reconfigure_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_connection: MockModbusConnection,
) -> None:
"""The inverter can be reconfigured to a new device ID."""
mock_config_entry.add_to_hass(hass)
# The same inverter, now answering on device ID 2.
await async_seed_unit(hass, mock_modbus_connection.for_unit(2))
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _user_input(2)
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_UNIT_ID] == 2
async def test_reconfigure_flow_wrong_device(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_connection: MockModbusConnection,
) -> None:
"""Reconfiguring onto a different inverter is rejected."""
mock_config_entry.add_to_hass(hass)
await async_seed_unit(
hass,
mock_modbus_connection.for_unit(2),
serial_registers=OTHER_SERIAL_REGISTERS,
)
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _user_input(2)
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_device"
assert mock_config_entry.data[CONF_UNIT_ID] == UNIT_ID
async def test_reconfigure_flow_cannot_connect(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A reconfigure attempt surfaces cannot_connect, then recovers."""
mock_config_entry.add_to_hass(hass)
mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out"))
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _user_input()
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# The device answers again.
mock_modbus_unit.fail_read(40000, None)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _user_input()
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@@ -0,0 +1,316 @@
"""Tests for the SolarEdge Modbus config-entry setup."""
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from modbus_connection import ModbusTimeoutError, ServerDeviceFailureError
from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
from homeassistant.components.solaredge_modbus.const import DOMAIN, SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
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 tests.common import MockConfigEntry, async_fire_time_changed
POWER_ENTITY = "sensor.solaredge_se10000h_power"
# An address inside the inverter's read, to make that read fail.
INVERTER_REGISTER = 40069
async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None:
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def test_load_unload_entry(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""The entry loads, produces entities, and unloads cleanly."""
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state == "9490"
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_inverter_that_does_not_name_itself(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A device is still readable when the model string comes back empty."""
mock_modbus_unit.holding.update(dict.fromkeys(range(40020, 40036), 0))
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
assert inverter.name == "SolarEdge inverter"
assert inverter.model is None
assert inverter.model_id is None
async def test_single_late_answer_is_retried(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""One missed read gets a second chance before the entities go unavailable."""
await _setup(hass, mock_config_entry)
read_holding_registers = mock_modbus_unit.read_holding_registers
missed: list[int] = []
async def miss_the_inverter_once(address: int, count: int) -> list[int]:
"""Time out on the first read covering the inverter, then behave."""
if not missed and address <= INVERTER_REGISTER < address + count:
missed.append(address)
raise ModbusTimeoutError("timed out")
return await read_holding_registers(address, count)
with patch.object(
mock_modbus_unit, "read_holding_registers", miss_the_inverter_once
):
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert missed # the read really did fail
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state != STATE_UNAVAILABLE
async def test_dead_link_fails_the_refresh(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A device that answers nothing at all fails the poll outright.
A partial poll leaves what answered alone, but silence from end to end is
a dead link, and every value the entry can show is then stale.
"""
await _setup(hass, mock_config_entry)
mock_modbus_unit.fail_requests(ModbusTimeoutError("link died"))
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_config_entry.runtime_data.readings.last_update_success is False
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_another_inverter_on_the_address_fails_the_refresh(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""An address that moves to another inverter stops feeding these entities.
Setting up checks the serial number, but the entry keeps polling an
address, and a lease handed out again can put a different inverter behind
it. Its production is not this entry's, whatever the entities are named
after.
"""
await _setup(hass, mock_config_entry)
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state != STATE_UNAVAILABLE
await async_seed_unit(
hass, mock_modbus_unit, serial_registers=[20308, 18501, 21041, 12851]
)
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_config_entry.runtime_data.readings.last_update_success is False
state = hass.states.get(POWER_ENTITY)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_setup_retry_when_device_unresponsive(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A device that does not answer puts the entry in setup retry."""
mock_modbus_unit.fail_read(40000, ModbusTimeoutError("timed out"))
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_error_when_not_a_solaredge_device(
hass: HomeAssistant, mock_modbus_connection: MockModbusConnection
) -> None:
"""A device without a SunSpec header fails setup permanently."""
unit = mock_modbus_connection.for_unit(2)
unit.holding.update(dict.fromkeys(range(40000, 40004), 0))
entry = MockConfigEntry(
domain=DOMAIN,
title="SolarEdge SE10000H",
unique_id=SERIAL_NUMBER,
data=tcp_data(unit_id=2),
)
await _setup(hass, entry)
assert entry.state is ConfigEntryState.SETUP_ERROR
@pytest.mark.parametrize(
"serial_registers",
[
pytest.param([20308, 18501, 21041, 12851], id="another inverter"),
pytest.param([0, 0, 0, 0], id="no serial number"),
],
)
async def test_setup_error_when_the_identity_does_not_match(
hass: HomeAssistant,
mock_modbus_connection: MockModbusConnection,
serial_registers: list[int],
) -> None:
"""An address that no longer holds this inverter must not adopt its data.
Every identity in this integration derives from the entry's serial number,
so loading a device that reports another one, or none at all, would hang
this entry's name and history on the wrong inverter.
"""
await async_seed_unit(
hass, mock_modbus_connection.for_unit(2), serial_registers=serial_registers
)
entry = MockConfigEntry(
domain=DOMAIN,
title="SolarEdge SE10000H",
unique_id=SERIAL_NUMBER,
data=tcp_data(unit_id=2),
)
await _setup(hass, entry)
assert entry.state is ConfigEntryState.SETUP_ERROR
async def test_setup_retry_when_the_identity_is_unreadable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A poll without the identity block proves nothing, so setup tries again.
The rest of the device can answer perfectly well while the identity block
does not, and accepting the entry then would skip the check that this is
still the same inverter. A device fault says exactly that, where silence
from the first block on would mean a dead link.
"""
mock_modbus_unit.fail_read(40004, ServerDeviceFailureError())
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_retry_when_the_measurements_are_unreadable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A first poll without the inverter block would cost the phase entities.
Which entities exist is decided once, from the inverter's DID, and without
it none of the phase measurements match. An entry accepted here would be
missing those entities until a reload, however well the inverter answers
after that.
"""
mock_modbus_unit.fail_read(40069, ServerDeviceFailureError())
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_retry_that_finds_nothing_keeps_the_first_poll(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A link that drops during the retry does not fail the whole refresh.
The first attempt got the identity block a second ago. Failing the refresh
because the retry found a dead link would throw that away, and every
sub-system that did answer with it.
"""
await _setup(hass, mock_config_entry)
read_holding_registers = mock_modbus_unit.read_holding_registers
dead = False
async def die_from_the_inverter_on(address: int, count: int) -> list[int]:
"""Go quiet at the inverter block, and stay quiet from then on."""
nonlocal dead
if dead or address <= INVERTER_REGISTER < address + count:
dead = True
raise ModbusTimeoutError("link died")
return await read_holding_registers(address, count)
with patch.object(
mock_modbus_unit, "read_holding_registers", die_from_the_inverter_on
):
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# The identity block answered the first attempt and the refresh stands;
# only the sub-systems that stayed silent are reported as failed.
coordinator = mock_config_entry.runtime_data.readings
assert coordinator.last_update_success is True
assert coordinator.data.updated == {"common"}
assert "inverter" in coordinator.data.failed
async def test_setup_error_when_link_settings_are_in_use(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Another integration holding the device on other line settings is fatal."""
with patch(
"homeassistant.components.solaredge_modbus.async_get_unit",
side_effect=HomeAssistantError("already in use with different link settings"),
):
await _setup(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
@@ -0,0 +1,204 @@
"""Tests for the SolarEdge Modbus sensor entities."""
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from modbus_connection import ModbusTimeoutError
from modbus_connection.mock import MockModbusUnit
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.solaredge_modbus.const import SCAN_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
mock_restore_cache_with_extra_data,
snapshot_platform,
)
LIFETIME_ENERGY_ENTITY = "sensor.solaredge_se10000h_energy"
async def _setup_sensor_platform(hass: HomeAssistant, entry: MockConfigEntry) -> None:
with patch(
"homeassistant.components.solaredge_modbus.PLATFORMS", [Platform.SENSOR]
):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
async def _tick(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None:
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""All sensor entities and their states match the snapshot."""
await _setup_sensor_platform(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_diagnostic_tail_disabled_by_default(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""The niche diagnostic points stay out of the way until asked for."""
await _setup_sensor_platform(hass, mock_config_entry)
# What a solar owner looks at is there from the start.
assert hass.states.get("sensor.solaredge_se10000h_power") is not None
for entity_id in (
"sensor.solaredge_se10000h_apparent_power",
"sensor.solaredge_se10000h_frequency",
# Voltage barely moves and there is a lot of it; ask for it if you want it.
"sensor.solaredge_se10000h_voltage",
"sensor.solaredge_se10000h_dc_voltage",
):
assert hass.states.get(entity_id) is None
entry = entity_registry.async_get(entity_id)
assert entry is not None
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
async def test_sensors_unavailable_on_update_failure(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A failed refresh marks the sensor entities unavailable."""
await _setup_sensor_platform(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_se10000h_power")
assert state is not None
assert state.state == "9490"
# The device stops answering reads of the inverter block.
mock_modbus_unit.fail_read(40069, ModbusTimeoutError("timed out"))
await _tick(hass, freezer)
state = hass.states.get("sensor.solaredge_se10000h_power")
assert state is not None
assert state.state == STATE_UNAVAILABLE
# ...and comes back once the inverter answers again.
mock_modbus_unit.fail_read(40069, None)
await _tick(hass, freezer)
state = hass.states.get("sensor.solaredge_se10000h_power")
assert state is not None
assert state.state == "9490"
async def test_no_phase_currents_on_single_phase(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""A single-phase inverter gets a total current sensor, but no phase ones."""
await _setup_sensor_platform(hass, mock_config_entry)
assert hass.states.get("sensor.solaredge_se10000h_current") is not None
assert hass.states.get("sensor.solaredge_se10000h_current_phase_a") is None
async def test_phase_currents_on_three_phase(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""A three-phase inverter gets per-phase current sensors."""
mock_modbus_unit.holding[40069] = 103 # SunSpec three-phase inverter model
await _setup_sensor_platform(hass, mock_config_entry)
assert hass.states.get("sensor.solaredge_se10000h_current_phase_a") is not None
assert hass.states.get("sensor.solaredge_se10000h_current_phase_b") is not None
assert hass.states.get("sensor.solaredge_se10000h_current_phase_c") is not None
async def test_lifetime_energy_never_goes_backwards(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A transiently lower lifetime energy reading is held at the last maximum."""
await _setup_sensor_platform(hass, mock_config_entry)
state = hass.states.get(LIFETIME_ENERGY_ENTITY)
assert state is not None
initial = float(state.state)
# The inverter transiently reports 0 ("not accumulated", decodes to None);
# the sensor holds the last maximum instead of going unknown.
mock_modbus_unit.holding[40093] = 0
mock_modbus_unit.holding[40094] = 0
await _tick(hass, freezer)
state = hass.states.get(LIFETIME_ENERGY_ENTITY)
assert state is not None
assert float(state.state) == initial
# The inverter glitches and reports a far lower lifetime energy.
mock_modbus_unit.holding[40093] = 0
mock_modbus_unit.holding[40094] = 1000
await _tick(hass, freezer)
state = hass.states.get(LIFETIME_ENERGY_ENTITY)
assert state is not None
assert float(state.state) == initial
assert "lower than" in caplog.text
# The inverter recovers with a higher value; the sensor follows again.
mock_modbus_unit.holding[40093] = 0x1000
mock_modbus_unit.holding[40094] = 0
await _tick(hass, freezer)
state = hass.states.get(LIFETIME_ENERGY_ENTITY)
assert state is not None
assert float(state.state) > initial
async def test_lifetime_energy_restored_after_restart(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""The last seen maximum survives a restart and beats a lower device reading."""
# A previous run saw a higher lifetime energy than the device reports now.
mock_restore_cache_with_extra_data(
hass,
(
(
State(LIFETIME_ENERGY_ENTITY, "99999.999"),
{
"native_value": 99999999,
"native_unit_of_measurement": "Wh",
},
),
),
)
await _setup_sensor_platform(hass, mock_config_entry)
state = hass.states.get(LIFETIME_ENERGY_ENTITY)
assert state is not None
assert float(state.state) == 99999.999 # kWh, from the restored maximum