From ddee1c2697e25bba5a292cdd0804e03daa858324 Mon Sep 17 00:00:00 2001 From: darkrain-nl <24763370+darkrain-nl@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:05:14 +0200 Subject: [PATCH] Add select entities for Sofar charger mode and EPS mode (#180385) --- homeassistant/components/sofar/__init__.py | 2 +- homeassistant/components/sofar/select.py | 89 ++++++++++ homeassistant/components/sofar/strings.json | 23 +++ .../sofar/snapshots/test_select.ambr | 133 +++++++++++++++ tests/components/sofar/test_select.py | 155 ++++++++++++++++++ 5 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sofar/select.py create mode 100644 tests/components/sofar/snapshots/test_select.ambr create mode 100644 tests/components/sofar/test_select.py diff --git a/homeassistant/components/sofar/__init__.py b/homeassistant/components/sofar/__init__.py index 0a6fd89442fd..6499636d5621 100644 --- a/homeassistant/components/sofar/__init__.py +++ b/homeassistant/components/sofar/__init__.py @@ -17,7 +17,7 @@ from .coordinator import SofarConfigEntry, SofarDataUpdateCoordinator, SofarRunt _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SELECT, Platform.SENSOR] _IDENTITY_ATTEMPTS = 3 diff --git a/homeassistant/components/sofar/select.py b/homeassistant/components/sofar/select.py new file mode 100644 index 000000000000..b9e6b201e0ec --- /dev/null +++ b/homeassistant/components/sofar/select.py @@ -0,0 +1,89 @@ +"""Support for Sofar selects.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import IntEnum +from typing import override + +from sofar_modbus.modern.device import SofarInverter +from sofar_modbus.modern.enums import ChargerUseMode, EpsControlMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SofarConfigEntry +from .entity import SofarEntity, SofarEntityDescription + +PARALLEL_UPDATES = 1 + + +def _enum_options(enum_type: type[IntEnum]) -> list[str]: + """The select options a device enum maps to.""" + return [member.name.lower() for member in enum_type] + + +@dataclass(frozen=True, kw_only=True) +class SofarSelectEntityDescription(SelectEntityDescription, SofarEntityDescription): + """Describe a Sofar select entity.""" + + options_enum: type[IntEnum] + write_fn: Callable[[SofarInverter, int], Awaitable[None]] + + +SELECT_DESCRIPTIONS: tuple[SofarSelectEntityDescription, ...] = ( + SofarSelectEntityDescription( + key="charger_use_mode", + component="charger", + translation_key="charger_use_mode", + options=_enum_options(ChargerUseMode), + options_enum=ChargerUseMode, + write_fn=lambda device, value: device.charger.write("charger_use_mode", value), + ), + SofarSelectEntityDescription( + key="eps_control", + component="eps", + translation_key="eps_control", + options=_enum_options(EpsControlMode), + options_enum=EpsControlMode, + write_fn=lambda device, value: device.eps.async_write_control( + EpsControlMode(value) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SofarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sofar Inverter Modbus select platform.""" + runtime_data = entry.runtime_data + served = runtime_data.served_components + async_add_entities( + SofarSelect(runtime_data, description) + for description in SELECT_DESCRIPTIONS + if description.component in served + ) + + +class SofarSelect(SofarEntity, SelectEntity): + """Defines a Sofar select entity.""" + + entity_description: SofarSelectEntityDescription + + @property + @override + def current_option(self) -> str | None: + """Return the currently selected option.""" + component = getattr(self.coordinator.device, self.entity_description.component) + value: IntEnum | None = getattr(component, self.entity_description.key) + return value.name.lower() if value is not None else None + + @override + async def async_select_option(self, option: str) -> None: + """Write the selected option to the device.""" + value = self.entity_description.options_enum[option.upper()] + await self.entity_description.write_fn(self.coordinator.device, value.value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/sofar/strings.json b/homeassistant/components/sofar/strings.json index 55c3e68343bf..cd21b53efd04 100644 --- a/homeassistant/components/sofar/strings.json +++ b/homeassistant/components/sofar/strings.json @@ -40,6 +40,29 @@ "name": "RTC sync" } }, + "select": { + "charger_use_mode": { + "name": "Charger use mode", + "state": { + "feed_in_priority_mode": "Feed-in priority mode", + "generator_mode": "Generator mode", + "off_grid_mode": "Off-grid mode", + "passive_mode": "Passive mode", + "peak_cut_mode": "Peak cut mode", + "self_use": "Self use", + "time_of_use": "Time of use", + "timing_mode": "Timing mode" + } + }, + "eps_control": { + "name": "EPS mode", + "state": { + "turn_off": "Turn off", + "turn_on_enable_cold_start": "Turn on, enable cold start", + "turn_on_prohibit_cold_start": "Turn on, prohibit cold start" + } + } + }, "sensor": { "active_power_load_sys": { "name": "Active power load system" diff --git a/tests/components/sofar/snapshots/test_select.ambr b/tests/components/sofar/snapshots/test_select.ambr new file mode 100644 index 000000000000..959a09e9461c --- /dev/null +++ b/tests/components/sofar/snapshots/test_select.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_hybrid_entities[select.hydxxktl_3p_charger_use_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'self_use', + 'time_of_use', + 'timing_mode', + 'passive_mode', + 'peak_cut_mode', + 'off_grid_mode', + 'generator_mode', + 'feed_in_priority_mode', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hydxxktl_3p_charger_use_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charger use mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charger use mode', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charger_use_mode', + 'unique_id': 'SP1XXES100XX_charger_use_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_hybrid_entities[select.hydxxktl_3p_charger_use_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'HYDxxKTL-3P Charger use mode', + : list([ + 'self_use', + 'time_of_use', + 'timing_mode', + 'passive_mode', + 'peak_cut_mode', + 'off_grid_mode', + 'generator_mode', + 'feed_in_priority_mode', + ]), + }), + 'context': , + 'entity_id': 'select.hydxxktl_3p_charger_use_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'self_use', + }) +# --- +# name: test_hybrid_entities[select.hydxxktl_3p_eps_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'turn_off', + 'turn_on_prohibit_cold_start', + 'turn_on_enable_cold_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hydxxktl_3p_eps_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'EPS mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'EPS mode', + 'platform': 'sofar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'eps_control', + 'unique_id': 'SP1XXES100XX_eps_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_hybrid_entities[select.hydxxktl_3p_eps_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'HYDxxKTL-3P EPS mode', + : list([ + 'turn_off', + 'turn_on_prohibit_cold_start', + 'turn_on_enable_cold_start', + ]), + }), + 'context': , + 'entity_id': 'select.hydxxktl_3p_eps_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'turn_off', + }) +# --- diff --git a/tests/components/sofar/test_select.py b/tests/components/sofar/test_select.py new file mode 100644 index 000000000000..e86c5ff85522 --- /dev/null +++ b/tests/components/sofar/test_select.py @@ -0,0 +1,155 @@ +"""Test the Sofar Inverter Modbus select 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.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.sofar.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + MOCK_HYBRID_MODEL, + MOCK_HYBRID_SERIAL, + MOCK_MODEL, + MOCK_SERIAL, + MOCK_USER_INPUT, + seed_hybrid_inverter, + seed_pv_inverter, +) + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _setup_hybrid( + hass: HomeAssistant, +) -> tuple[MockConfigEntry, MockModbusConnection]: + """Set up a hybrid inverter with only the select platform loaded.""" + connection = MockModbusConnection() + seed_hybrid_inverter(connection.for_unit(1)) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_HYBRID_SERIAL, + data=MOCK_USER_INPUT, + title=MOCK_HYBRID_MODEL, + ) + entry.add_to_hass(hass) + with ( + patch("homeassistant.components.sofar.PLATFORMS", [Platform.SELECT]), + 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 + + +async def _setup_pv( + hass: HomeAssistant, +) -> tuple[MockConfigEntry, MockModbusConnection]: + """Set up a PV-only inverter with only the select platform loaded.""" + connection = MockModbusConnection() + seed_pv_inverter(connection.for_unit(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.SELECT]), + 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_hybrid_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the select entities a hybrid inverter serves.""" + entry, _ = await _setup_hybrid(hass) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_pv_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a PV-only inverter gets no select entities.""" + entry, _ = await _setup_pv(hass) + assert er.async_entries_for_config_entry(entity_registry, entry.entry_id) == [] + + +@pytest.mark.parametrize( + ("key", "initial", "option"), + [ + pytest.param( + "charger_use_mode", "self_use", "feed_in_priority_mode", id="charger" + ), + pytest.param("eps_control", "turn_off", "turn_on_enable_cold_start", id="eps"), + ], +) +async def test_select_option( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + key: str, + initial: str, + option: str, +) -> None: + """Test selecting an option writes it and refreshes the read-back.""" + await _setup_hybrid(hass) + entity_id = entity_registry.async_get_entity_id( + SELECT_DOMAIN, DOMAIN, f"{MOCK_HYBRID_SERIAL}_{key}" + ) + 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( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, + blocking=True, + ) + assert (state := hass.states.get(entity_id)) is not None + assert state.state == option + + +async def test_select_option_modbus_error( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a write failure propagates as-is.""" + _, connection = await _setup_hybrid(hass) + charger_id = entity_registry.async_get_entity_id( + SELECT_DOMAIN, DOMAIN, f"{MOCK_HYBRID_SERIAL}_charger_use_mode" + ) + assert charger_id is not None + connection.for_unit(1).fail_write(0x1110, ModbusError("busy")) + + with pytest.raises(ModbusError, match="busy"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: charger_id, ATTR_OPTION: "feed_in_priority_mode"}, + blocking=True, + )