diff --git a/homeassistant/components/homevolt/__init__.py b/homeassistant/components/homevolt/__init__.py index 7a999b51084f..1826be7a0fc4 100644 --- a/homeassistant/components/homevolt/__init__.py +++ b/homeassistant/components/homevolt/__init__.py @@ -8,7 +8,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool: diff --git a/homeassistant/components/homevolt/select.py b/homeassistant/components/homevolt/select.py new file mode 100644 index 000000000000..0b10cb83bbbd --- /dev/null +++ b/homeassistant/components/homevolt/select.py @@ -0,0 +1,72 @@ +"""Support for Homevolt select entities.""" + +from typing import override + +from homevolt.const import CONTROLLABLE_SCHEDULE_TYPE + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator +from .entity import HomevoltEntity, homevolt_exception_handler + +PARALLEL_UPDATES = 0 # Coordinator-based updates + + +SELECT_DESCRIPTION = SelectEntityDescription( + key="battery_mode", + translation_key="battery_mode", + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + options=list(CONTROLLABLE_SCHEDULE_TYPE.values()), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Homevolt select entities.""" + coordinator = entry.runtime_data + async_add_entities([HomevoltModeSelect(coordinator, SELECT_DESCRIPTION)]) + + +class HomevoltModeSelect(HomevoltEntity, SelectEntity): + """Select entity for battery operational mode.""" + + entity_description: SelectEntityDescription + + def __init__( + self, + coordinator: HomevoltDataUpdateCoordinator, + description: SelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(coordinator, f"ems_{coordinator.data.unique_id}") + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}" + + @property + @override + def available(self) -> bool: + """Return whether local battery control is enabled.""" + return super().available and self.coordinator.client.local_mode_enabled + + @property + @override + def current_option(self) -> str | None: + """Return the current selected mode.""" + mode_int = self.coordinator.client.schedule.get("mode") + if mode_int is None: + return None + return CONTROLLABLE_SCHEDULE_TYPE.get(mode_int) + + @homevolt_exception_handler + @override + async def async_select_option(self, option: str) -> None: + """Change the selected mode.""" + await self.coordinator.client.set_battery_mode(mode=option) + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/homevolt/strings.json b/homeassistant/components/homevolt/strings.json index 6561c757a3db..c0eecc970f89 100644 --- a/homeassistant/components/homevolt/strings.json +++ b/homeassistant/components/homevolt/strings.json @@ -53,6 +53,18 @@ } }, "entity": { + "select": { + "battery_mode": { + "name": "Battery mode", + "state": { + "frequency_reserve": "Frequency reserve", + "idle": "Idle", + "inverter_charge": "Inverter charge", + "inverter_discharge": "Inverter discharge", + "solar_charge": "Solar charge" + } + } + }, "sensor": { "available_charging_energy": { "name": "Available charging energy" diff --git a/tests/components/homevolt/conftest.py b/tests/components/homevolt/conftest.py index 016291e461d7..11ea6bca232b 100644 --- a/tests/components/homevolt/conftest.py +++ b/tests/components/homevolt/conftest.py @@ -81,6 +81,7 @@ def mock_homevolt_client() -> Generator[MagicMock]: # Load schedule data from fixture client.current_schedule = load_json_object_fixture("schedule.json", DOMAIN) + client.schedule = {"mode": client.current_schedule["schedule"][0]["type"]} # Switch (local mode) support client.local_mode_enabled = False diff --git a/tests/components/homevolt/snapshots/test_select.ambr b/tests/components/homevolt/snapshots/test_select.ambr new file mode 100644 index 000000000000..a842d42414fe --- /dev/null +++ b/tests/components/homevolt/snapshots/test_select.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_select_entity[select.homevolt_ems_battery_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'idle', + 'inverter_charge', + 'inverter_discharge', + 'frequency_reserve', + 'solar_charge', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homevolt_ems_battery_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery mode', + 'platform': 'homevolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_mode', + 'unique_id': '40580137858664_battery_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entity[select.homevolt_ems_battery_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Homevolt EMS Battery mode', + : list([ + 'idle', + 'inverter_charge', + 'inverter_discharge', + 'frequency_reserve', + 'solar_charge', + ]), + }), + 'context': , + 'entity_id': 'select.homevolt_ems_battery_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inverter_charge', + }) +# --- diff --git a/tests/components/homevolt/test_select.py b/tests/components/homevolt/test_select.py new file mode 100644 index 000000000000..26ad52dc2836 --- /dev/null +++ b/tests/components/homevolt/test_select.py @@ -0,0 +1,171 @@ +"""Tests for the Homevolt select platform.""" + +from unittest.mock import MagicMock + +from homevolt import ( + HomevoltAuthenticationError, + HomevoltCommandOutcomeUnknownError, + HomevoltCommandRejectedError, + HomevoltCommandVerificationError, + HomevoltConnectionError, + HomevoltError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "select.homevolt_ems_battery_mode" + + +@pytest.fixture +def platforms(mock_homevolt_client: MagicMock) -> list[Platform]: + """Load the select platform with manual control enabled.""" + mock_homevolt_client.local_mode_enabled = True + return [Platform.SELECT] + + +async def test_select_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the battery mode select.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_select_option( + hass: HomeAssistant, + mock_homevolt_client: MagicMock, +) -> None: + """Test a command publishes the verified mode before returning.""" + + async def set_battery_mode(*, mode: str) -> None: + mock_homevolt_client.schedule["mode"] = 0 + + mock_homevolt_client.set_battery_mode.side_effect = set_battery_mode + mock_homevolt_client.update_info.reset_mock() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_OPTION: "idle"}, + blocking=True, + ) + + mock_homevolt_client.set_battery_mode.assert_awaited_once_with(mode="idle") + mock_homevolt_client.update_info.assert_not_awaited() + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "idle" + + +@pytest.mark.parametrize( + "mode", + [ + pytest.param(None, id="missing"), + pytest.param(3, id="unsupported"), + ], +) +async def test_select_unknown_mode( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_homevolt_client: MagicMock, + mode: int | None, +) -> None: + """Test missing and unsupported modes are unknown.""" + mock_homevolt_client.schedule["mode"] = mode + + await init_integration.runtime_data.async_request_refresh() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_select_unavailable_without_local_mode( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_homevolt_client: MagicMock, +) -> None: + """Test mode changes are unavailable until local mode is enabled.""" + mock_homevolt_client.local_mode_enabled = False + + await init_integration.runtime_data.async_request_refresh() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("error", "translation_key"), + [ + pytest.param( + HomevoltAuthenticationError("authentication failed"), + "auth_failed", + id="authentication", + ), + pytest.param( + HomevoltCommandRejectedError("command rejected"), + "command_rejected", + id="command-rejected", + ), + pytest.param( + HomevoltCommandVerificationError("command verification failed"), + "command_verification_failed", + id="command-verification", + ), + pytest.param( + HomevoltCommandOutcomeUnknownError("command outcome unknown"), + "command_outcome_unknown", + id="command-outcome-unknown", + ), + pytest.param( + HomevoltConnectionError("connection failed"), + "communication_error", + id="connection", + ), + pytest.param( + HomevoltError("unknown error"), + "unknown_error", + id="unknown", + ), + ], +) +async def test_select_option_error( + hass: HomeAssistant, + mock_homevolt_client: MagicMock, + error: HomevoltError, + translation_key: str, +) -> None: + """Test select actions use the shared exception handler.""" + mock_homevolt_client.set_battery_mode.side_effect = error + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_OPTION: "solar_charge"}, + blocking=True, + ) + + assert exc_info.value.translation_key == translation_key