mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add switch entities to SolarEdge Modbus (#180826)
This commit is contained in:
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user