diff --git a/homeassistant/components/solaredge_modbus/__init__.py b/homeassistant/components/solaredge_modbus/__init__.py index 1b5d5e3bfe73..f192d41d8840 100644 --- a/homeassistant/components/solaredge_modbus/__init__.py +++ b/homeassistant/components/solaredge_modbus/__init__.py @@ -45,6 +45,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/solaredge_modbus/coordinator.py b/homeassistant/components/solaredge_modbus/coordinator.py index 98f08e2a440c..bb198e842858 100644 --- a/homeassistant/components/solaredge_modbus/coordinator.py +++ b/homeassistant/components/solaredge_modbus/coordinator.py @@ -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.""" diff --git a/homeassistant/components/solaredge_modbus/helpers.py b/homeassistant/components/solaredge_modbus/helpers.py index 7ffa0764ae3e..f1117ee87a61 100644 --- a/homeassistant/components/solaredge_modbus/helpers.py +++ b/homeassistant/components/solaredge_modbus/helpers.py @@ -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: diff --git a/homeassistant/components/solaredge_modbus/icons.json b/homeassistant/components/solaredge_modbus/icons.json index f9a6ceec5827..f1c361ec6336 100644 --- a/homeassistant/components/solaredge_modbus/icons.json +++ b/homeassistant/components/solaredge_modbus/icons.json @@ -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" + } } } } diff --git a/homeassistant/components/solaredge_modbus/strings.json b/homeassistant/components/solaredge_modbus/strings.json index c1d1e14712c5..a86e3e6f2977 100644 --- a/homeassistant/components/solaredge_modbus/strings.json +++ b/homeassistant/components/solaredge_modbus/strings.json @@ -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": { diff --git a/homeassistant/components/solaredge_modbus/switch.py b/homeassistant/components/solaredge_modbus/switch.py new file mode 100644 index 000000000000..437d33f4c786 --- /dev/null +++ b/homeassistant/components/solaredge_modbus/switch.py @@ -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) diff --git a/tests/components/solaredge_modbus/snapshots/test_switch.ambr b/tests/components/solaredge_modbus/snapshots/test_switch.ambr new file mode 100644 index 000000000000..8c660321d6b7 --- /dev/null +++ b/tests/components/solaredge_modbus/snapshots/test_switch.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.solaredge_se10000h_external_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'SolarEdge SE10000H External production', + }), + 'context': , + 'entity_id': 'switch.solaredge_se10000h_external_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.solaredge_se10000h_negative_site_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.solaredge_se10000h_negative_site_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : 'SolarEdge SE10000H Negative site limit', + }), + 'context': , + 'entity_id': 'switch.solaredge_se10000h_negative_site_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/solaredge_modbus/test_init.py b/tests/components/solaredge_modbus/test_init.py index 64c452aaf289..9da453c49d74 100644 --- a/tests/components/solaredge_modbus/test_init.py +++ b/tests/components/solaredge_modbus/test_init.py @@ -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, diff --git a/tests/components/solaredge_modbus/test_switch.py b/tests/components/solaredge_modbus/test_switch.py new file mode 100644 index 000000000000..68e6d920b139 --- /dev/null +++ b/tests/components/solaredge_modbus/test_switch.py @@ -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)