From 471f2c28e285c268cc4ca67ad80ff4044b365d70 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Aug 2026 00:02:58 +0200 Subject: [PATCH] Add SolarEdge Modbus integration (#180508) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/solaredge.json | 2 +- .../components/solaredge_modbus/__init__.py | 109 +++ .../solaredge_modbus/config_flow.py | 170 ++++ .../components/solaredge_modbus/const.py | 25 + .../solaredge_modbus/coordinator.py | 151 ++++ .../components/solaredge_modbus/entity.py | 89 ++ .../components/solaredge_modbus/helpers.py | 13 + .../components/solaredge_modbus/icons.json | 12 + .../components/solaredge_modbus/manifest.json | 13 + .../solaredge_modbus/quality_scale.yaml | 86 ++ .../components/solaredge_modbus/sensor.py | 379 ++++++++ .../components/solaredge_modbus/strings.json | 137 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/solaredge_modbus/__init__.py | 1 + tests/components/solaredge_modbus/conftest.py | 116 +++ .../solaredge_modbus/fixtures/se10000h.json | 175 ++++ .../snapshots/test_sensor.ambr | 822 ++++++++++++++++++ .../solaredge_modbus/test_config_flow.py | 274 ++++++ .../components/solaredge_modbus/test_init.py | 316 +++++++ .../solaredge_modbus/test_sensor.py | 204 +++++ 25 files changed, 3116 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/solaredge_modbus/__init__.py create mode 100644 homeassistant/components/solaredge_modbus/config_flow.py create mode 100644 homeassistant/components/solaredge_modbus/const.py create mode 100644 homeassistant/components/solaredge_modbus/coordinator.py create mode 100644 homeassistant/components/solaredge_modbus/entity.py create mode 100644 homeassistant/components/solaredge_modbus/helpers.py create mode 100644 homeassistant/components/solaredge_modbus/icons.json create mode 100644 homeassistant/components/solaredge_modbus/manifest.json create mode 100644 homeassistant/components/solaredge_modbus/quality_scale.yaml create mode 100644 homeassistant/components/solaredge_modbus/sensor.py create mode 100644 homeassistant/components/solaredge_modbus/strings.json create mode 100644 tests/components/solaredge_modbus/__init__.py create mode 100644 tests/components/solaredge_modbus/conftest.py create mode 100644 tests/components/solaredge_modbus/fixtures/se10000h.json create mode 100644 tests/components/solaredge_modbus/snapshots/test_sensor.ambr create mode 100644 tests/components/solaredge_modbus/test_config_flow.py create mode 100644 tests/components/solaredge_modbus/test_init.py create mode 100644 tests/components/solaredge_modbus/test_sensor.py diff --git a/.strict-typing b/.strict-typing index ddb4f2adf223..8c8075013873 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 1a5acdc24529..0c74cbd05d23 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/brands/solaredge.json b/homeassistant/brands/solaredge.json index 90190f9c7861..1138992f1fb5 100644 --- a/homeassistant/brands/solaredge.json +++ b/homeassistant/brands/solaredge.json @@ -1,5 +1,5 @@ { "domain": "solaredge", "name": "SolarEdge", - "integrations": ["solaredge", "solaredge_local"] + "integrations": ["solaredge", "solaredge_local", "solaredge_modbus"] } diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py new file mode 100644 index 000000000000..b9b93256494a --- /dev/null +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -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) diff --git a/homeassistant/components/solaredge_modbus/config_flow.py b/homeassistant/components/solaredge_modbus/config_flow.py new file mode 100644 index 000000000000..7036f450ead8 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/config_flow.py @@ -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 diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py new file mode 100644 index 000000000000..18f62b8ed8fc --- /dev/null +++ b/homeassistant/components/solaredge_modbus/const.py @@ -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) diff --git a/homeassistant/components/solaredge_modbus/coordinator.py b/homeassistant/components/solaredge_modbus/coordinator.py new file mode 100644 index 000000000000..2d6e7818ad6d --- /dev/null +++ b/homeassistant/components/solaredge_modbus/coordinator.py @@ -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 diff --git a/homeassistant/components/solaredge_modbus/entity.py b/homeassistant/components/solaredge_modbus/entity.py new file mode 100644 index 000000000000..3c5d41e87f60 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/entity.py @@ -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 + ) diff --git a/homeassistant/components/solaredge_modbus/helpers.py b/homeassistant/components/solaredge_modbus/helpers.py new file mode 100644 index 000000000000..5a33382a1f9d --- /dev/null +++ b/homeassistant/components/solaredge_modbus/helpers.py @@ -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]) diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json new file mode 100644 index 000000000000..5477fcd56fc5 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "inverter_status": { + "default": "mdi:solar-power" + }, + "vendor_status": { + "default": "mdi:information-outline" + } + } + } +} diff --git a/homeassistant/components/solaredge_modbus/manifest.json b/homeassistant/components/solaredge_modbus/manifest.json new file mode 100644 index 000000000000..140f5d9d41bc --- /dev/null +++ b/homeassistant/components/solaredge_modbus/manifest.json @@ -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"] +} diff --git a/homeassistant/components/solaredge_modbus/quality_scale.yaml b/homeassistant/components/solaredge_modbus/quality_scale.yaml new file mode 100644 index 000000000000..9c77c41a0c34 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/solaredge_modbus/sensor.py b/homeassistant/components/solaredge_modbus/sensor.py new file mode 100644 index 000000000000..aa34fb7b411f --- /dev/null +++ b/homeassistant/components/solaredge_modbus/sensor.py @@ -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.""" diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json new file mode 100644 index 000000000000..0d5e3535a870 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -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." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cdd6f503640f..6630e10402be 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -736,6 +736,7 @@ FLOWS = { "snooz", "sofar", "solaredge", + "solaredge_modbus", "solarlog", "solarman", "solax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 01029c963ae5..1d08f6364040 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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" } } }, diff --git a/mypy.ini b/mypy.ini index 71bb6037404d..02b32b2f8826 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index 6bbf5eaad808..f256ae64c635 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/tests/components/solaredge_modbus/__init__.py b/tests/components/solaredge_modbus/__init__.py new file mode 100644 index 000000000000..f081742b54a4 --- /dev/null +++ b/tests/components/solaredge_modbus/__init__.py @@ -0,0 +1 @@ +"""Tests for the SolarEdge Modbus integration.""" diff --git a/tests/components/solaredge_modbus/conftest.py b/tests/components/solaredge_modbus/conftest.py new file mode 100644 index 000000000000..998fe382ba1e --- /dev/null +++ b/tests/components/solaredge_modbus/conftest.py @@ -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(), + ) diff --git a/tests/components/solaredge_modbus/fixtures/se10000h.json b/tests/components/solaredge_modbus/fixtures/se10000h.json new file mode 100644 index 000000000000..604743a5f8bd --- /dev/null +++ b/tests/components/solaredge_modbus/fixtures/se10000h.json @@ -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 + } +} diff --git a/tests/components/solaredge_modbus/snapshots/test_sensor.ambr b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..d0055341ecf0 --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_sensor.ambr @@ -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({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Apparent power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_apparent_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'apparent_power', + : 'SolarEdge SE10000H Apparent power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9600', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_se10000h_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'SolarEdge SE10000H Current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '39.99', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'SolarEdge SE10000H DC current', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_dc_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H DC power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_dc_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9635', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DC voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'SolarEdge SE10000H DC voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_se10000h_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'SolarEdge SE10000H Energy', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18785.652', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_frequency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Frequency', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'frequency', + : 'SolarEdge SE10000H Frequency', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.037', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_se10000h_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H Power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9490', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_power_factor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power factor', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_power_factor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power_factor', + : 'SolarEdge SE10000H Power factor', + : , + : '%', + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_reactive_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_reactive_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Reactive power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reactive power', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_reactive_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_reactive_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'reactive_power', + : 'SolarEdge SE10000H Reactive power', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_reactive_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1449', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'sleeping', + 'starting', + 'producing', + 'throttled', + 'shutting_down', + 'fault', + 'standby', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_se10000h_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + '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({ + : 'enum', + : 'SolarEdge SE10000H Status', + : list([ + 'off', + 'sleeping', + 'starting', + 'producing', + 'throttled', + 'shutting_down', + 'fault', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'producing', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'SolarEdge SE10000H Temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.67', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_vendor_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_vendor_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'SolarEdge SE10000H Vendor status', + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_vendor_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.solaredge_se10000h_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7E123ABC_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.solaredge_se10000h_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'SolarEdge SE10000H Voltage', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.solaredge_se10000h_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250.2', + }) +# --- diff --git a/tests/components/solaredge_modbus/test_config_flow.py b/tests/components/solaredge_modbus/test_config_flow.py new file mode 100644 index 000000000000..d6cec8da82c5 --- /dev/null +++ b/tests/components/solaredge_modbus/test_config_flow.py @@ -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" diff --git a/tests/components/solaredge_modbus/test_init.py b/tests/components/solaredge_modbus/test_init.py new file mode 100644 index 000000000000..4d4d240cadb6 --- /dev/null +++ b/tests/components/solaredge_modbus/test_init.py @@ -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 diff --git a/tests/components/solaredge_modbus/test_sensor.py b/tests/components/solaredge_modbus/test_sensor.py new file mode 100644 index 000000000000..77bacc18a382 --- /dev/null +++ b/tests/components/solaredge_modbus/test_sensor.py @@ -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