diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py index e85477642f65..48155d6a5595 100644 --- a/homeassistant/components/solaredge_modbus/__init__.py +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -25,6 +25,8 @@ from .const import ( CONF_UNIT_ID, DOMAIN, LOGGER, + SCAN_INTERVAL, + SETTINGS_SCAN_INTERVAL, SUBSYSTEM_BATTERIES, SUBSYSTEM_COMMON, SUBSYSTEM_INVERTER, @@ -38,7 +40,7 @@ from .coordinator import ( from .entity import attachment_identity, inverter_device_info from .helpers import create_modbus_params -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry( @@ -76,7 +78,23 @@ async def async_setup_entry( translation_key="no_solaredge_device", ) from err - readings = SolarEdgeModbusDataUpdateCoordinator(hass, entry, solaredge) + readings = SolarEdgeModbusDataUpdateCoordinator( + hass, + entry, + solaredge, + poll=solaredge.async_update_readings, + interval=SCAN_INTERVAL, + label="readings", + ) + settings = SolarEdgeModbusDataUpdateCoordinator( + hass, + entry, + solaredge, + poll=solaredge.async_update_settings, + interval=SETTINGS_SCAN_INTERVAL, + label="settings", + ) + await readings.async_config_entry_first_refresh() # Identity arrives with that first read, and a poll can come back without @@ -107,8 +125,15 @@ async def async_setup_entry( inverter = dr.async_get(hass).async_get_or_create( config_entry_id=entry.entry_id, **device_info ) + # The readings poll already proved the link; a control block that refuses + # one read leaves its own entities unavailable instead of failing setup. + await settings.async_refresh() + entry.runtime_data = SolarEdgeModbusRuntimeData( - readings=readings, device_info=device_info, inverter_device_id=inverter.id + readings=readings, + settings=settings, + device_info=device_info, + inverter_device_id=inverter.id, ) if silent := solaredge.unresponsive_blocks & { diff --git a/homeassistant/components/solaredge_modbus/const.py b/homeassistant/components/solaredge_modbus/const.py index c7eaa5bba73f..5453110499cf 100644 --- a/homeassistant/components/solaredge_modbus/const.py +++ b/homeassistant/components/solaredge_modbus/const.py @@ -27,5 +27,15 @@ SUBSYSTEM_INVERTER: Final = "inverter" SUBSYSTEM_BATTERIES: Final = "batteries" SUBSYSTEM_METERS: Final = "meters" +# The writable control blocks, as an UpdateReport names them. Export control's +# read spans storage control, so the library reads and reports the two as one. +SUBSYSTEM_ADVANCED_POWER_CONTROL: Final = "advanced_power_control" +SUBSYSTEM_POWER_CONTROL: Final = "power_control" +SUBSYSTEM_SITE_CONTROL: Final = "site_control" + # Local Modbus is cheap to read and PV production moves fast. SCAN_INTERVAL: Final = timedelta(seconds=10) + +# The control blocks hold what the site was told to do; they only move when +# something writes them, so they do not need a live measurement's cadence. +SETTINGS_SCAN_INTERVAL: Final = timedelta(minutes=5) diff --git a/homeassistant/components/solaredge_modbus/coordinator.py b/homeassistant/components/solaredge_modbus/coordinator.py index 7a19ea96bd16..98f08e2a440c 100644 --- a/homeassistant/components/solaredge_modbus/coordinator.py +++ b/homeassistant/components/solaredge_modbus/coordinator.py @@ -1,7 +1,9 @@ -"""DataUpdateCoordinator for the SolarEdge Modbus integration.""" +"""DataUpdateCoordinators for the SolarEdge Modbus integration.""" +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import override +from datetime import timedelta +from typing import Final, override from solaredged import SolarEdge, SolarEdgeConnectionError, UpdateReport @@ -11,10 +13,25 @@ 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 +from .const import ( + DOMAIN, + LOGGER, + SUBSYSTEM_ADVANCED_POWER_CONTROL, + SUBSYSTEM_COMMON, + SUBSYSTEM_POWER_CONTROL, + SUBSYSTEM_SITE_CONTROL, +) type SolarEdgeModbusConfigEntry = ConfigEntry[SolarEdgeModbusRuntimeData] +SETTINGS_SUBSYSTEMS: Final = frozenset( + { + SUBSYSTEM_ADVANCED_POWER_CONTROL, + SUBSYSTEM_POWER_CONTROL, + SUBSYSTEM_SITE_CONTROL, + } +) + def _merge(first: UpdateReport, second: UpdateReport) -> UpdateReport: """Fold a retried poll into the one it followed. @@ -33,7 +50,7 @@ def _merge(first: UpdateReport, second: UpdateReport) -> UpdateReport: class SolarEdgeModbusDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): - """Polls the inverter's sub-systems over Modbus. + """Polls one set of 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 @@ -48,9 +65,14 @@ class SolarEdgeModbusDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): hass: HomeAssistant, entry: SolarEdgeModbusConfigEntry, solaredge: SolarEdge, + *, + poll: Callable[[], Awaitable[UpdateReport]], + interval: timedelta, + label: str, ) -> None: """Initialize the coordinator.""" self.solaredge = solaredge + self._poll = poll self._silent: set[str] = set() super().__init__( hass, @@ -58,8 +80,8 @@ class SolarEdgeModbusDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): 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, + name=f"{entry.title} {label}", + update_interval=interval, ) @override @@ -99,7 +121,7 @@ class SolarEdgeModbusDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): enough. """ try: - retried = await self.solaredge.async_update_readings() + retried = await self._poll() except SolarEdgeConnectionError as err: LOGGER.debug( "%s: nothing answered the retry (%s); keeping the first poll", @@ -113,7 +135,7 @@ class SolarEdgeModbusDataUpdateCoordinator(DataUpdateCoordinator[UpdateReport]): async def _async_poll(self) -> UpdateReport: """Poll the inverter's sub-systems, translating a dead link.""" try: - return await self.solaredge.async_update_readings() + return await self._poll() except SolarEdgeConnectionError as err: raise UpdateFailed( translation_domain=DOMAIN, @@ -143,10 +165,17 @@ class SolarEdgeModbusRuntimeData: """Runtime data for a SolarEdge Modbus config entry.""" readings: SolarEdgeModbusDataUpdateCoordinator + settings: SolarEdgeModbusDataUpdateCoordinator device_info: DeviceInfo inverter_device_id: str @property def solaredge(self) -> SolarEdge: - """Return the polled device.""" + """Return the polled device, which both coordinators share.""" return self.readings.solaredge + + def coordinator_for(self, subsystem: str) -> SolarEdgeModbusDataUpdateCoordinator: + """Return the coordinator that refreshes a given sub-system.""" + if subsystem in SETTINGS_SUBSYSTEMS: + return self.settings + return self.readings diff --git a/homeassistant/components/solaredge_modbus/entity.py b/homeassistant/components/solaredge_modbus/entity.py index 4f527397bc39..20805cede2a2 100644 --- a/homeassistant/components/solaredge_modbus/entity.py +++ b/homeassistant/components/solaredge_modbus/entity.py @@ -8,13 +8,25 @@ stores as the config entry unique ID. from typing import TYPE_CHECKING, override -from solaredged import Battery, Meter, SolarEdge +from solaredged import ( + Battery, + ExportControl, + Meter, + PowerControl, + SolarEdge, + StorageControl, +) 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 .const import ( + DOMAIN, + SUBSYSTEM_INVERTER, + SUBSYSTEM_POWER_CONTROL, + SUBSYSTEM_SITE_CONTROL, +) from .coordinator import ( SolarEdgeModbusConfigEntry, SolarEdgeModbusDataUpdateCoordinator, @@ -41,6 +53,21 @@ def inverter_name(model: str | None) -> str: return f"SolarEdge {commercial}" +# The control blocks that host entities. Advanced power control is polled but +# has none, so adding entities for it means widening this and the sub-system it +# maps to below. +type ControlComponent = ExportControl | PowerControl | StorageControl + + +def _control_subsystem(component: ControlComponent) -> str: + """Return the sub-system a control block's poll is reported under.""" + if isinstance(component, PowerControl): + return SUBSYSTEM_POWER_CONTROL + # Export control's read spans storage control, so the library reads the two + # as one pooled block and reports them together. + return SUBSYSTEM_SITE_CONTROL + + def attachment_identity(component: Battery | Meter, index: int) -> str: """Return what tells an attached device apart from the next in its place. @@ -81,7 +108,7 @@ class SolarEdgeModbusEntity(CoordinatorEntity[SolarEdgeModbusDataUpdateCoordinat key_prefix: str = "", ) -> None: """Initialize a SolarEdge Modbus entity.""" - super().__init__(coordinator=entry.runtime_data.readings) + super().__init__(coordinator=entry.runtime_data.coordinator_for(subsystem)) self.entity_description = description self._subsystem = subsystem @@ -110,11 +137,10 @@ class SolarEdgeModbusInverterEntity(SolarEdgeModbusEntity): *, entry: SolarEdgeModbusConfigEntry, description: EntityDescription, + subsystem: str = SUBSYSTEM_INVERTER, ) -> None: """Initialize a SolarEdge Modbus inverter entity.""" - super().__init__( - entry=entry, subsystem=SUBSYSTEM_INVERTER, description=description - ) + super().__init__(entry=entry, subsystem=subsystem, description=description) self._attr_device_info = entry.runtime_data.device_info @@ -183,3 +209,28 @@ class SolarEdgeModbusBatteryEntity(SolarEdgeModbusEntity): serial_number=battery.serial_number or None, via_device_id=entry.runtime_data.inverter_device_id, ) + + +class SolarEdgeModbusControlEntity[ComponentT: ControlComponent]( + SolarEdgeModbusInverterEntity +): + """Defines a SolarEdge Modbus entity for a writable control block. + + The library refreshes the component instances in place on every poll, so + the entity holds on to its control component directly. + """ + + def __init__( + self, + *, + entry: SolarEdgeModbusConfigEntry, + description: EntityDescription, + component: ComponentT, + ) -> None: + """Initialize a SolarEdge Modbus control entity.""" + super().__init__( + entry=entry, + subsystem=_control_subsystem(component), + description=description, + ) + self._component = component diff --git a/homeassistant/components/solaredge_modbus/helpers.py b/homeassistant/components/solaredge_modbus/helpers.py index 245b8834f0de..7ffa0764ae3e 100644 --- a/homeassistant/components/solaredge_modbus/helpers.py +++ b/homeassistant/components/solaredge_modbus/helpers.py @@ -1,13 +1,16 @@ """Helpers for the SolarEdge Modbus integration.""" -from collections.abc import Mapping -from typing import Any +from collections.abc import Callable, Coroutine, Mapping +from typing import Any, Concatenate from modbus_connection import ModbusSerialParams, ModbusTcpParams +from solaredged import SolarEdgeConnectionError, SolarEdgeError from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.exceptions import HomeAssistantError -from .const import CONF_BAUDRATE, TYPE_SERIAL +from .const import CONF_BAUDRATE, DOMAIN, TYPE_SERIAL +from .entity import SolarEdgeModbusEntity def create_modbus_params( @@ -23,3 +26,34 @@ def create_modbus_params( device=data[CONF_DEVICE], baudrate=data[CONF_BAUDRATE] ) return ModbusTcpParams(host=data[CONF_HOST], port=data[CONF_PORT]) + + +def solaredge_exception_handler[_EntityT: SolarEdgeModbusEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate SolarEdge writes to translate what the library raises. + + A successful write updates the library's decoded cache, so listeners are + nudged to re-read entity state without waiting for the next poll. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + self.coordinator.async_update_listeners() + + except SolarEdgeConnectionError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(error)}, + ) from error + + except SolarEdgeError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rejected_value", + translation_placeholders={"error": str(error)}, + ) from error + + return handler diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json index c7e0550aac4a..33959b00a866 100644 --- a/homeassistant/components/solaredge_modbus/icons.json +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -8,6 +8,29 @@ } } }, + "number": { + "active_power_limit": { + "default": "mdi:speedometer" + }, + "backup_reserve": { + "default": "mdi:battery-lock" + }, + "charge_limit": { + "default": "mdi:battery-plus" + }, + "cos_phi": { + "default": "mdi:sine-wave" + }, + "discharge_limit": { + "default": "mdi:battery-minus" + }, + "external_production_max": { + "default": "mdi:solar-power" + }, + "site_limit": { + "default": "mdi:transmission-tower-export" + } + }, "sensor": { "battery_status": { "default": "mdi:home-battery" diff --git a/homeassistant/components/solaredge_modbus/number.py b/homeassistant/components/solaredge_modbus/number.py new file mode 100644 index 000000000000..f9b2935648e0 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/number.py @@ -0,0 +1,186 @@ +"""Support for SolarEdge Modbus number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from solaredged import ExportControl, PowerControl, StorageControl + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SolarEdgeModbusConfigEntry +from .entity import ControlComponent, SolarEdgeModbusControlEntity +from .helpers import solaredge_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SolarEdgeModbusNumberEntityDescription[ComponentT](NumberEntityDescription): + """Describes a SolarEdge Modbus number entity.""" + + value_fn: Callable[[ComponentT], float | None] + set_fn: Callable[[ComponentT, float], Awaitable[Any]] + + +STORAGE_NUMBERS: tuple[SolarEdgeModbusNumberEntityDescription[StorageControl], ...] = ( + SolarEdgeModbusNumberEntityDescription( + key="backup_reserve", + translation_key="backup_reserve", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + native_min_value=0, + native_max_value=100, + native_step=1, + value_fn=lambda storage: storage.backup_reserve, + set_fn=lambda storage, value: storage.set_backup_reserve(value), + ), + SolarEdgeModbusNumberEntityDescription( + key="charge_limit", + translation_key="charge_limit", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0, + native_max_value=1_000_000, + native_step=1, + value_fn=lambda storage: storage.charge_limit, + set_fn=lambda storage, value: storage.set_charge_limit(value), + ), + SolarEdgeModbusNumberEntityDescription( + key="discharge_limit", + translation_key="discharge_limit", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0, + native_max_value=1_000_000, + native_step=1, + value_fn=lambda storage: storage.discharge_limit, + set_fn=lambda storage, value: storage.set_discharge_limit(value), + ), +) + +EXPORT_NUMBERS: tuple[SolarEdgeModbusNumberEntityDescription[ExportControl], ...] = ( + SolarEdgeModbusNumberEntityDescription( + key="site_limit", + translation_key="site_limit", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0, + native_max_value=1_000_000, + native_step=1, + value_fn=lambda export: export.site_limit, + set_fn=lambda export, value: export.set_site_limit(value), + ), + SolarEdgeModbusNumberEntityDescription( + key="external_production_max", + translation_key="external_production_max", + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + native_unit_of_measurement=UnitOfPower.WATT, + native_min_value=0, + native_max_value=1_000_000, + native_step=1, + value_fn=lambda export: export.external_production_max, + set_fn=lambda export, value: export.set_external_production_max(value), + ), +) + +POWER_NUMBERS: tuple[SolarEdgeModbusNumberEntityDescription[PowerControl], ...] = ( + SolarEdgeModbusNumberEntityDescription( + key="active_power_limit", + translation_key="active_power_limit", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + native_min_value=0, + native_max_value=100, + native_step=1, + value_fn=lambda power: power.active_power_limit, + set_fn=lambda power, value: power.set_active_power_limit(int(value)), + ), + SolarEdgeModbusNumberEntityDescription( + key="cos_phi", + translation_key="cos_phi", + entity_category=EntityCategory.CONFIG, + # Reactive power is grid-code territory, set by the installer or the + # network operator. Almost nobody should be moving it from Home + # Assistant, so it has to be asked for. + entity_registry_enabled_default=False, + mode=NumberMode.BOX, + native_min_value=-1.0, + native_max_value=1.0, + native_step=0.01, + value_fn=lambda power: power.cos_phi, + set_fn=lambda power, value: power.set_cos_phi(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SolarEdgeModbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SolarEdge Modbus number entities based on a config entry.""" + solaredge = entry.runtime_data.solaredge + + entities: list[NumberEntity] = [] + # The storage control block answers on inverters without storage too; the + # settings only mean something when a battery is actually attached. + if (storage := solaredge.storage_control) is not None and solaredge.batteries: + entities.extend( + SolarEdgeModbusNumberEntity( + entry=entry, description=description, component=storage + ) + for description in STORAGE_NUMBERS + ) + if (export := solaredge.export_control) is not None: + entities.extend( + SolarEdgeModbusNumberEntity( + entry=entry, description=description, component=export + ) + for description in EXPORT_NUMBERS + ) + if (power := solaredge.power_control) is not None: + entities.extend( + SolarEdgeModbusNumberEntity( + entry=entry, description=description, component=power + ) + for description in POWER_NUMBERS + ) + + async_add_entities(entities) + + +class SolarEdgeModbusNumberEntity[ComponentT: ControlComponent]( + SolarEdgeModbusControlEntity[ComponentT], NumberEntity +): + """Defines a SolarEdge Modbus number entity.""" + + entity_description: SolarEdgeModbusNumberEntityDescription[ComponentT] + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + return self.entity_description.value_fn(self._component) + + @solaredge_exception_handler + @override + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + await self.entity_description.set_fn(self._component, value) diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index 659a7c6caae2..2c1ee90fcec1 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -109,6 +109,29 @@ } } }, + "number": { + "active_power_limit": { + "name": "Active power limit" + }, + "backup_reserve": { + "name": "Backup reserve" + }, + "charge_limit": { + "name": "Storage charge limit" + }, + "cos_phi": { + "name": "Power factor setpoint" + }, + "discharge_limit": { + "name": "Storage discharge limit" + }, + "external_production_max": { + "name": "External production maximum" + }, + "site_limit": { + "name": "Site export limit" + } + }, "sensor": { "battery_status": { "name": "Status", @@ -254,6 +277,9 @@ "no_solaredge_device": { "message": "The configured Modbus device does not answer as a SolarEdge inverter." }, + "rejected_value": { + "message": "The value was rejected for the SolarEdge inverter: {error}" + }, "wrong_inverter": { "message": "A different inverter is answering than the one this entry was set up for. Reconfigure the entry to point at the right device." } diff --git a/tests/components/solaredge_modbus/snapshots/test_number.ambr b/tests/components/solaredge_modbus/snapshots/test_number.ambr new file mode 100644 index 000000000000..bb9434f1f73f --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_number.ambr @@ -0,0 +1,424 @@ +# serializer version: 1 +# name: test_numbers[number.solaredge_se10000h_active_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_active_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Active power limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active power limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_limit', + 'unique_id': '7E123ABC_active_power_limit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_active_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Active power limit', + : 100, + : 0, + : , + : 1, + : '%', + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_active_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_backup_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_backup_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Backup reserve', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backup reserve', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backup_reserve', + 'unique_id': '7E123ABC_backup_reserve', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Backup reserve', + : 100, + : 0, + : , + : 1, + : '%', + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_external_production_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000000, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_external_production_maximum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'External production maximum', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External production maximum', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_production_max', + 'unique_id': '7E123ABC_external_production_max', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.solaredge_se10000h_external_production_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H External production maximum', + : 1000000, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_external_production_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_power_factor_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1.0, + : -1.0, + : , + : 0.01, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_power_factor_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power factor setpoint', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power factor setpoint', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cos_phi', + 'unique_id': '7E123ABC_cos_phi', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number.solaredge_se10000h_power_factor_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'SolarEdge SE10000H Power factor setpoint', + : 1.0, + : -1.0, + : , + : 0.01, + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_power_factor_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_site_export_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000000, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_site_export_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Site export limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Site export limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'site_limit', + 'unique_id': '7E123ABC_site_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.solaredge_se10000h_site_export_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H Site export limit', + : 1000000, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_site_export_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_storage_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000000, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_storage_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage charge limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage charge limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_limit', + 'unique_id': '7E123ABC_charge_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.solaredge_se10000h_storage_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H Storage charge limit', + : 1000000, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_storage_charge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11400.0', + }) +# --- +# name: test_numbers[number.solaredge_se10000h_storage_discharge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000000, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.solaredge_se10000h_storage_discharge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage discharge limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage discharge limit', + 'platform': 'solaredge_modbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'discharge_limit', + 'unique_id': '7E123ABC_discharge_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.solaredge_se10000h_storage_discharge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'SolarEdge SE10000H Storage discharge limit', + : 1000000, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.solaredge_se10000h_storage_discharge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11400.0', + }) +# --- diff --git a/tests/components/solaredge_modbus/test_init.py b/tests/components/solaredge_modbus/test_init.py index 0ee935e5c212..64c452aaf289 100644 --- a/tests/components/solaredge_modbus/test_init.py +++ b/tests/components/solaredge_modbus/test_init.py @@ -10,8 +10,13 @@ from modbus_connection import ( ) from modbus_connection.mock import MockModbusConnection, MockModbusUnit import pytest +from solaredged import SolarEdgeConnectionError -from homeassistant.components.solaredge_modbus.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.solaredge_modbus.const import ( + DOMAIN, + SCAN_INTERVAL, + SETTINGS_SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -38,6 +43,9 @@ INVERTER_REGISTER = 40069 # The register the probe counts meters by. METER_MODEL_REGISTER = 40188 +# An address inside the pooled storage and export control read. +SITE_CONTROL_REGISTER = 57348 + async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None: entry.add_to_hass(hass) @@ -505,6 +513,47 @@ async def test_another_inverter_on_the_address_fails_the_refresh( assert state.state == STATE_UNAVAILABLE +async def test_silent_control_block_leaves_the_others_alone( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Storage and export controls share one read; power control has its own.""" + await _setup(hass, mock_config_entry) + + mock_modbus_unit.fail_read(SITE_CONTROL_REGISTER, ServerDeviceFailureError()) + freezer.tick(SETTINGS_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("number.solaredge_se10000h_backup_reserve") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("number.solaredge_se10000h_active_power_limit") + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +async def test_settings_failure_does_not_block_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Readings carry the entry even when the control blocks stay silent.""" + with patch( + "homeassistant.components.solaredge_modbus.SolarEdge.async_update_settings", + side_effect=SolarEdgeConnectionError("timed out"), + ): + await _setup(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert hass.states.get(POWER_ENTITY) is not None + + state = hass.states.get("number.solaredge_se10000h_backup_reserve") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + async def test_setup_retry_when_device_unresponsive( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/solaredge_modbus/test_number.py b/tests/components/solaredge_modbus/test_number.py new file mode 100644 index 000000000000..e71bb385fff0 --- /dev/null +++ b/tests/components/solaredge_modbus/test_number.py @@ -0,0 +1,124 @@ +"""Tests for the SolarEdge Modbus number entities.""" + +from unittest.mock import patch + +from modbus_connection import ModbusTimeoutError +from modbus_connection.mock import MockModbusUnit +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +BACKUP_RESERVE_ENTITY = "number.solaredge_se10000h_backup_reserve" +BACKUP_RESERVE_REGISTER = 57352 + + +async def _setup_number_platform(hass: HomeAssistant, entry: MockConfigEntry) -> None: + with patch( + "homeassistant.components.solaredge_modbus.PLATFORMS", [Platform.NUMBER] + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_numbers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """All number entities and their states match the snapshot.""" + await _setup_number_platform(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_power_factor_setpoint_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Reactive power is grid-code territory and stays out of the way.""" + await _setup_number_platform(hass, mock_config_entry) + + entity_id = "number.solaredge_se10000h_power_factor_setpoint" + + 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_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Setting a number writes to the device and updates the state.""" + await _setup_number_platform(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: BACKUP_RESERVE_ENTITY, ATTR_VALUE: 25}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(BACKUP_RESERVE_ENTITY) + assert state is not None + assert state.state == "25.0" + + +async def test_set_value_communication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A write that fails on the wire raises a translated error.""" + await _setup_number_platform(hass, mock_config_entry) + + mock_modbus_unit.fail_write(BACKUP_RESERVE_REGISTER, ModbusTimeoutError("timeout")) + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: BACKUP_RESERVE_ENTITY, ATTR_VALUE: 25}, + blocking=True, + ) + + assert excinfo.value.translation_key == "communication_error" + + +async def test_set_value_rejected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """A value the device rejects raises a translated error.""" + await _setup_number_platform(hass, mock_config_entry) + + mock_modbus_unit.fail_write(BACKUP_RESERVE_REGISTER, ValueError("does not fit")) + + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: BACKUP_RESERVE_ENTITY, ATTR_VALUE: 25}, + blocking=True, + ) + + assert excinfo.value.translation_key == "rejected_value"