diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index 3d3ae5dc2f0f..b37405919359 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -27,7 +27,12 @@ from .sensor import SENSOR_DESCRIPTIONS _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SELECT, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] _IDENTITY_ATTEMPTS = 3 diff --git a/homeassistant/components/sofar/switch.py b/homeassistant/components/sofar/switch.py new file mode 100644 index 000000000000..86c595247996 --- /dev/null +++ b/homeassistant/components/sofar/switch.py @@ -0,0 +1,81 @@ +"""Support for Sofar switches.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from sofar_modbus.modern.device import SofarInverter +from sofar_modbus.modern.enums import RemoteSwitchOnOff + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SofarConfigEntry +from .entity import SofarEntity, SofarEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SofarSwitchEntityDescription(SwitchEntityDescription, SofarEntityDescription): + """Describe a Sofar switch entity.""" + + write_fn: Callable[[SofarInverter, bool], Awaitable[None]] + + +SWITCH_DESCRIPTIONS: tuple[SofarSwitchEntityDescription, ...] = ( + SofarSwitchEntityDescription( + key="remote_switch_on_off", + component="remote", + name=None, + write_fn=lambda device, value: device.remote.write( + "remote_switch_on_off", + RemoteSwitchOnOff.ON if value else RemoteSwitchOnOff.OFF, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SofarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sofar Inverter Modbus switch platform.""" + runtime_data = entry.runtime_data + served = runtime_data.served_components + async_add_entities( + SofarSwitch(runtime_data, description) + for description in SWITCH_DESCRIPTIONS + if description.component in served + ) + + +class SofarSwitch(SofarEntity, SwitchEntity): + """Defines a Sofar switch entity.""" + + entity_description: SofarSwitchEntityDescription + + @property + @override + def is_on(self) -> bool | None: + """Return whether the remote switch is on.""" + component = getattr(self.coordinator.device, self.entity_description.component) + value = getattr(component, self.entity_description.key) + return None if value is None else bool(value) + + async def _async_write(self, value: bool) -> None: + """Write the switch state to the device.""" + await self.entity_description.write_fn(self.coordinator.device, value) + await self.coordinator.async_request_refresh() + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the remote switch on.""" + await self._async_write(True) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the remote switch off.""" + await self._async_write(False) diff --git a/tests/components/sofar/snapshots/test_switch.ambr b/tests/components/sofar/snapshots/test_switch.ambr new file mode 100644 index 000000000000..267b695da287 --- /dev/null +++ b/tests/components/sofar/snapshots/test_switch.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_pv_entities[switch.4_4_ktlx_g3-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': None, + 'entity_id': 'switch.4_4_ktlx_g3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SS2ES104N5S445_remote_switch_on_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_pv_entities[switch.4_4_ktlx_g3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '4.4 KTLX-G3', + }), + 'context': , + 'entity_id': 'switch.4_4_ktlx_g3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/sofar/test_switch.py b/tests/components/sofar/test_switch.py new file mode 100644 index 000000000000..71df5c3dd3d9 --- /dev/null +++ b/tests/components/sofar/test_switch.py @@ -0,0 +1,116 @@ +"""Test the Sofar Inverter Modbus switch platform.""" + +from unittest.mock import patch + +from modbus_connection import ModbusError +from modbus_connection.mock import MockModbusConnection +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sofar.const import DOMAIN +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 . import MOCK_MODEL, MOCK_SERIAL, MOCK_USER_INPUT, seed_pv_inverter + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _setup_pv( + hass: HomeAssistant, *, remote_on: bool = False +) -> tuple[MockConfigEntry, MockModbusConnection]: + """Set up a PV-only inverter with only the switch platform loaded.""" + connection = MockModbusConnection() + seed_pv_inverter(connection.for_unit(1)) + if remote_on: + connection.for_unit(1).holding[0x1104] = 1 + entry = MockConfigEntry( + domain=DOMAIN, unique_id=MOCK_SERIAL, data=MOCK_USER_INPUT, title=MOCK_MODEL + ) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.sofar.PLATFORMS", [Platform.SWITCH]), + patch( + "homeassistant.components.sofar.async_get_unit", + side_effect=lambda hass, entry, params, unit_id: connection.for_unit( + unit_id + ), + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + return entry, connection + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_pv_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the switch entities a PV-only inverter serves.""" + entry, _ = await _setup_pv(hass) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("remote_on", "service", "initial", "final"), + [ + pytest.param(False, SERVICE_TURN_ON, STATE_OFF, STATE_ON, id="turn_on"), + pytest.param(True, SERVICE_TURN_OFF, STATE_ON, STATE_OFF, id="turn_off"), + ], +) +async def test_remote_switch_toggle( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + remote_on: bool, + service: str, + initial: str, + final: str, +) -> None: + """Test toggling the remote switch writes the register.""" + await _setup_pv(hass, remote_on=remote_on) + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_remote_switch_on_off" + ) + assert entity_id is not None + assert (state := hass.states.get(entity_id)) is not None + assert state.state == initial + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert (state := hass.states.get(entity_id)) is not None + assert state.state == final + + +async def test_turn_on_modbus_error( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a write failure propagates as-is.""" + _, connection = await _setup_pv(hass) + entity_id = entity_registry.async_get_entity_id( + SWITCH_DOMAIN, DOMAIN, f"{MOCK_SERIAL}_remote_switch_on_off" + ) + assert entity_id is not None + connection.for_unit(1).fail_write(0x1104, ModbusError("busy")) + + with pytest.raises(ModbusError, match="busy"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + )