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