mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 07:51:46 -05:00
Add SolarEdge Modbus integration (#180508)
This commit is contained in:
@@ -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
@@ -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,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+3
@@ -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
|
||||
Reference in New Issue
Block a user