Add number entities to SolarEdge Modbus (#180691)

This commit is contained in:
Franck Nijhof
2026-08-29 22:17:05 +02:00
committed by GitHub
parent 3052056820
commit b50835da9b
11 changed files with 1003 additions and 22 deletions
@@ -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 & {
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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"
@@ -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)
@@ -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."
}
@@ -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({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.solaredge_se10000h_active_power_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Active power limit',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'number.solaredge_se10000h_active_power_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_numbers[number.solaredge_se10000h_backup_reserve-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.solaredge_se10000h_backup_reserve',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Backup reserve',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'number.solaredge_se10000h_backup_reserve',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2.0',
})
# ---
# name: test_numbers[number.solaredge_se10000h_external_production_maximum-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000000,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.solaredge_se10000h_external_production_maximum',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'External production maximum',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.POWER: 'power'>,
'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': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_numbers[number.solaredge_se10000h_external_production_maximum-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H External production maximum',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000000,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'number.solaredge_se10000h_external_production_maximum',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_numbers[number.solaredge_se10000h_power_factor_setpoint-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: -1.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.01,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.solaredge_se10000h_power_factor_setpoint',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Power factor setpoint',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1.0,
<NumberEntityCapabilityAttribute.MIN: 'min'>: -1.0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.01,
}),
'context': <ANY>,
'entity_id': 'number.solaredge_se10000h_power_factor_setpoint',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_numbers[number.solaredge_se10000h_site_export_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000000,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.solaredge_se10000h_site_export_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Site export limit',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.POWER: 'power'>,
'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': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_numbers[number.solaredge_se10000h_site_export_limit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Site export limit',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000000,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'number.solaredge_se10000h_site_export_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_numbers[number.solaredge_se10000h_storage_charge_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000000,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.solaredge_se10000h_storage_charge_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Storage charge limit',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.POWER: 'power'>,
'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': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_numbers[number.solaredge_se10000h_storage_charge_limit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Storage charge limit',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000000,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'number.solaredge_se10000h_storage_charge_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '11400.0',
})
# ---
# name: test_numbers[number.solaredge_se10000h_storage_discharge_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000000,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.solaredge_se10000h_storage_discharge_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Storage discharge limit',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.POWER: 'power'>,
'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': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_numbers[number.solaredge_se10000h_storage_discharge_limit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'power',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Storage discharge limit',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 1000000,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.BOX: 'box'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'number.solaredge_se10000h_storage_discharge_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '11400.0',
})
# ---
+50 -1
View File
@@ -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,
@@ -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"