Add switch entities to SolarEdge Modbus (#180826)

This commit is contained in:
Franck Nijhof
2026-08-30 17:38:56 +02:00
committed by GitHub
parent cea0a35613
commit a0345b758c
9 changed files with 427 additions and 4 deletions
@@ -45,6 +45,7 @@ PLATFORMS = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -1,7 +1,8 @@
"""DataUpdateCoordinators for the SolarEdge Modbus integration."""
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Final, override
@@ -169,6 +170,13 @@ class SolarEdgeModbusRuntimeData:
device_info: DeviceInfo
inverter_device_id: str
# The export mode and its flags share one register, which the library
# changes by taking its cached value, flipping bits and writing it back.
# Every platform has its own parallel-update semaphore, so a select and a
# switch can reach that read-modify-write at once and one loses the other's
# change; every write goes through this lock instead.
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@property
def solaredge(self) -> SolarEdge:
"""Return the polled device, which both coordinators share."""
@@ -31,7 +31,7 @@ def create_modbus_params(
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.
"""Decorate SolarEdge writes to serialize them and translate library errors.
A successful write updates the library's decoded cache, so listeners are
nudged to re-read entity state without waiting for the next poll.
@@ -39,7 +39,8 @@ def solaredge_exception_handler[_EntityT: SolarEdgeModbusEntity, **_P](
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
async with self.coordinator.config_entry.runtime_data.write_lock:
await func(self, *args, **kwargs)
self.coordinator.async_update_listeners()
except SolarEdgeConnectionError as error:
@@ -61,6 +61,14 @@
"state_of_health": {
"default": "mdi:battery-heart-variant"
}
},
"switch": {
"external_production": {
"default": "mdi:solar-power-variant"
},
"negative_site_limit": {
"default": "mdi:transmission-tower-import"
}
}
}
}
@@ -320,6 +320,14 @@
"voltage_phase_cn": {
"name": "Voltage phase C-N"
}
},
"switch": {
"external_production": {
"name": "External production"
},
"negative_site_limit": {
"name": "Negative site limit"
}
}
},
"exceptions": {
@@ -0,0 +1,93 @@
"""Support for SolarEdge Modbus switch entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
from solaredged import ExportControl
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SolarEdgeModbusConfigEntry
from .entity import SolarEdgeModbusControlEntity
from .helpers import solaredge_exception_handler
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class SolarEdgeModbusSwitchEntityDescription(SwitchEntityDescription):
"""Describes a SolarEdge Modbus switch entity."""
is_on_fn: Callable[[ExportControl], bool | None]
set_fn: Callable[[ExportControl, bool], Awaitable[Any]]
EXPORT_SWITCHES: tuple[SolarEdgeModbusSwitchEntityDescription, ...] = (
SolarEdgeModbusSwitchEntityDescription(
key="external_production",
translation_key="external_production",
entity_category=EntityCategory.CONFIG,
# An export-control flag the installer sets, and which needs a meter
# configuration this integration cannot see, so it has to be asked for.
entity_registry_enabled_default=False,
is_on_fn=lambda export: export.external_production,
set_fn=lambda export, enabled: export.set_external_production(enabled=enabled),
),
SolarEdgeModbusSwitchEntityDescription(
key="negative_site_limit",
translation_key="negative_site_limit",
entity_category=EntityCategory.CONFIG,
# An export-control flag the installer sets, and which needs a meter
# configuration this integration cannot see, so it has to be asked for.
entity_registry_enabled_default=False,
is_on_fn=lambda export: export.negative_site_limit,
set_fn=lambda export, enabled: export.set_negative_site_limit(enabled=enabled),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SolarEdgeModbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SolarEdge Modbus switch entities based on a config entry."""
if (export := entry.runtime_data.solaredge.export_control) is None:
return
async_add_entities(
SolarEdgeModbusSwitchEntity(
entry=entry, description=description, component=export
)
for description in EXPORT_SWITCHES
)
class SolarEdgeModbusSwitchEntity(
SolarEdgeModbusControlEntity[ExportControl], SwitchEntity
):
"""Defines a SolarEdge Modbus switch entity."""
entity_description: SolarEdgeModbusSwitchEntityDescription
@property
@override
def is_on(self) -> bool | None:
"""Return the state of the switch."""
return self.entity_description.is_on_fn(self._component)
@solaredge_exception_handler
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
await self.entity_description.set_fn(self._component, True)
@solaredge_exception_handler
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
await self.entity_description.set_fn(self._component, False)
@@ -0,0 +1,101 @@
# serializer version: 1
# name: test_switches[switch.solaredge_se10000h_external_production-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.solaredge_se10000h_external_production',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'External production',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'External production',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'external_production',
'unique_id': '7E123ABC_external_production',
'unit_of_measurement': None,
})
# ---
# name: test_switches[switch.solaredge_se10000h_external_production-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H External production',
}),
'context': <ANY>,
'entity_id': 'switch.solaredge_se10000h_external_production',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switches[switch.solaredge_se10000h_negative_site_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.solaredge_se10000h_negative_site_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Negative site limit',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Negative site limit',
'platform': 'solaredge_modbus',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'negative_site_limit',
'unique_id': '7E123ABC_negative_site_limit',
'unit_of_measurement': None,
})
# ---
# name: test_switches[switch.solaredge_se10000h_negative_site_limit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SolarEdge SE10000H Negative site limit',
}),
'context': <ANY>,
'entity_id': 'switch.solaredge_se10000h_negative_site_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+67 -1
View File
@@ -1,5 +1,6 @@
"""Tests for the SolarEdge Modbus config-entry setup."""
import asyncio
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
@@ -12,13 +13,24 @@ from modbus_connection.mock import MockModbusConnection, MockModbusUnit
import pytest
from solaredged import SolarEdgeConnectionError
from homeassistant.components.select import (
ATTR_OPTION,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.components.solaredge_modbus.const import (
DOMAIN,
SCAN_INTERVAL,
SETTINGS_SCAN_INTERVAL,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_ON,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
@@ -46,6 +58,9 @@ METER_MODEL_REGISTER = 40188
# An address inside the pooled storage and export control read.
SITE_CONTROL_REGISTER = 57348
EXPORT_LIMITATION_ENTITY = "select.solaredge_se10000h_export_limitation"
EXTERNAL_PRODUCTION_ENTITY = "switch.solaredge_se10000h_external_production"
async def _setup(hass: HomeAssistant, entry: MockConfigEntry) -> None:
entry.add_to_hass(hass)
@@ -554,6 +569,57 @@ async def test_settings_failure_does_not_block_setup(
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_concurrent_control_writes_keep_both_changes(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""Two writes to the same control register do not clobber each other.
The export mode and its flags live in one register, which the library
changes by taking its cached value, flipping bits and writing it back.
Select and switch have separate parallel-update semaphores, so without
serialization the second write undoes the first.
"""
await _setup(hass, mock_config_entry)
write_register = mock_modbus_unit.write_register
async def write_register_slowly(address: int, value: int) -> None:
"""Write with a suspension point, which a real link has and a mock lacks."""
await asyncio.sleep(0)
await write_register(address, value)
mock_modbus_unit.write_register = write_register_slowly
await asyncio.gather(
hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: EXPORT_LIMITATION_ENTITY,
ATTR_OPTION: "production_control",
},
blocking=True,
),
hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: EXTERNAL_PRODUCTION_ENTITY},
blocking=True,
),
)
state = hass.states.get(EXPORT_LIMITATION_ENTITY)
assert state is not None
assert state.state == "production_control"
state = hass.states.get(EXTERNAL_PRODUCTION_ENTITY)
assert state is not None
assert state.state == STATE_ON
async def test_setup_retry_when_device_unresponsive(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -0,0 +1,137 @@
"""Tests for the SolarEdge Modbus switch entities."""
from unittest.mock import patch
from modbus_connection import IllegalDataAddressError
from modbus_connection.mock import MockModbusUnit
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
EXTERNAL_PRODUCTION_ENTITY = "switch.solaredge_se10000h_external_production"
NEGATIVE_SITE_LIMIT_ENTITY = "switch.solaredge_se10000h_negative_site_limit"
# The export mode register, which carries both flags and reads as absent when
# the whole block is.
EXPORT_MODE_REGISTER = 57344
async def _setup_switch_platform(hass: HomeAssistant, entry: MockConfigEntry) -> None:
with patch(
"homeassistant.components.solaredge_modbus.PLATFORMS", [Platform.SWITCH]
):
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_switches(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""All switch entities and their states match the snapshot."""
await _setup_switch_platform(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_switches_disabled_by_default(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Export-control flags are installer settings, not day-to-day switches."""
await _setup_switch_platform(hass, mock_config_entry)
for entity_id in (
EXTERNAL_PRODUCTION_ENTITY,
"switch.solaredge_se10000h_negative_site_limit",
):
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_no_switches_without_export_control(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
) -> None:
"""An inverter without the export control block gets no switches.
Both switches are flags in that block, so there is nothing to show for an
installation that does not have it.
"""
# A real device answers reads of a block it does not have with a Modbus
# exception (illegal data address).
mock_modbus_unit.fail_read(EXPORT_MODE_REGISTER, IllegalDataAddressError())
await _setup_switch_platform(hass, mock_config_entry)
assert hass.states.async_entity_ids(SWITCH_DOMAIN) == []
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
("entity_id", "bit"),
[
pytest.param(EXTERNAL_PRODUCTION_ENTITY, 10, id="external production"),
pytest.param(NEGATIVE_SITE_LIMIT_ENTITY, 11, id="negative site limit"),
],
)
async def test_turn_on_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_modbus_unit: MockModbusUnit,
entity_id: str,
bit: int,
) -> None:
"""Turning a switch on and off writes its own flag bit to the device.
Both flags live in the export mode register, each with a bit and a setter
of its own, so each has to reach the one it names.
"""
await _setup_switch_platform(hass, mock_config_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
assert mock_modbus_unit.holding[EXPORT_MODE_REGISTER] & (1 << bit)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
assert not mock_modbus_unit.holding[EXPORT_MODE_REGISTER] & (1 << bit)