From 3c9df295b9a8cdadaee3179a24599faf89e35870 Mon Sep 17 00:00:00 2001 From: Chris <1105672+firstof9@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:54:30 -0700 Subject: [PATCH] Add OpenEVSE select platform with override state entity (#181302) --- homeassistant/components/openevse/__init__.py | 1 + homeassistant/components/openevse/icons.json | 5 + homeassistant/components/openevse/select.py | 149 +++++++++++ .../components/openevse/strings.json | 10 + tests/components/openevse/conftest.py | 3 + .../openevse/snapshots/test_select.ambr | 62 +++++ tests/components/openevse/test_select.py | 253 ++++++++++++++++++ 7 files changed, 483 insertions(+) create mode 100644 homeassistant/components/openevse/select.py create mode 100644 tests/components/openevse/snapshots/test_select.ambr create mode 100644 tests/components/openevse/test_select.py diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 7597394ac799..daf12c5bc8dd 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -15,6 +15,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/openevse/icons.json b/homeassistant/components/openevse/icons.json index 3705c1559096..13ce512f6d3c 100644 --- a/homeassistant/components/openevse/icons.json +++ b/homeassistant/components/openevse/icons.json @@ -11,6 +11,11 @@ "default": "mdi:tune-variant" } }, + "select": { + "override_state": { + "default": "mdi:tune-variant" + } + }, "sensor": { "gfi_trip_count": { "default": "mdi:counter" diff --git a/homeassistant/components/openevse/select.py b/homeassistant/components/openevse/select.py new file mode 100644 index 000000000000..438e5ac9b54c --- /dev/null +++ b/homeassistant/components/openevse/select.py @@ -0,0 +1,149 @@ +"""Support for OpenEVSE select entities.""" + +import asyncio +from collections.abc import Awaitable, Callable +from contextlib import suppress +from dataclasses import dataclass +from typing import Any, override + +from openevsehttp import OpenEVSE + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator +from .helpers import openevse_exception_handler + +PARALLEL_UPDATES = 0 + +OVERRIDE_STATE_OPTIONS: list[str] = ["auto", "active", "disabled"] + + +async def _async_set_override_state(charger: OpenEVSE, option: str) -> None: + """Set the override state on the charger.""" + if option == "auto": + await charger.clear_override() + else: + await charger.set_override(state=option) + + +@dataclass(frozen=True, kw_only=True) +class OpenEVSESelectDescription(SelectEntityDescription): + """Describes an OpenEVSE select entity.""" + + current_option_fn: Callable[[OpenEVSE], Awaitable[str | None]] + select_option_fn: Callable[[OpenEVSE, str], Awaitable[Any]] + + +SELECT_TYPES: tuple[OpenEVSESelectDescription, ...] = ( + OpenEVSESelectDescription( + key="override_state", + translation_key="override_state", + entity_category=EntityCategory.CONFIG, + options=OVERRIDE_STATE_OPTIONS, + current_option_fn=lambda ev: ev.get_override_state(), + select_option_fn=_async_set_override_state, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenEVSEConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenEVSE selects based on config entry.""" + coordinator = entry.runtime_data + identifier = entry.unique_id or entry.entry_id + async_add_entities( + OpenEVSESelect(coordinator, description, identifier, entry.unique_id) + for description in SELECT_TYPES + ) + + +class OpenEVSESelect(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], SelectEntity): + """Implementation of an OpenEVSE select entity.""" + + _attr_has_entity_name = True + entity_description: OpenEVSESelectDescription + _attr_current_option: str | None = None + _update_task: asyncio.Task[None] | None = None + + def __init__( + self, + coordinator: OpenEVSEDataUpdateCoordinator, + description: OpenEVSESelectDescription, + identifier: str, + unique_id: str | None, + ) -> None: + """Initialize the select.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{identifier}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="OpenEVSE", + ) + if unique_id: + self._attr_device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, unique_id) + } + self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id + + @property + @override + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._attr_current_option is not None + + async def _async_update_current_option(self) -> None: + """Update the current option from the charger.""" + with openevse_exception_handler(): + self._attr_current_option = await self.entity_description.current_option_fn( + self.coordinator.charger + ) + + @override + async def async_added_to_hass(self) -> None: + """Handle entity added to hass.""" + await super().async_added_to_hass() + with suppress(HomeAssistantError): + await self._async_update_current_option() + + @override + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + if self._update_task is None or self._update_task.done(): + self._update_task = ( + self.coordinator.config_entry.async_create_background_task( + self.hass, + self._async_update_and_write_ha_state(), + name=f"{self.entity_id} update override state", + ) + ) + + async def _async_update_and_write_ha_state(self) -> None: + """Fetch updated option and write HA state.""" + try: + await self._async_update_current_option() + except HomeAssistantError: + self._attr_current_option = None + self.async_write_ha_state() + + @override + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + with openevse_exception_handler(option): + await self.entity_description.select_option_fn( + self.coordinator.charger, option + ) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index bffbf89e64ce..2d72923a4e0e 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -88,6 +88,16 @@ "name": "Charge rate" } }, + "select": { + "override_state": { + "name": "Override state", + "state": { + "active": "Active", + "auto": "Auto", + "disabled": "Disabled" + } + } + }, "sensor": { "ambient_temp": { "name": "Ambient temperature" diff --git a/tests/components/openevse/conftest.py b/tests/components/openevse/conftest.py index f11744ec5cc6..557bedf03f08 100644 --- a/tests/components/openevse/conftest.py +++ b/tests/components/openevse/conftest.py @@ -96,6 +96,9 @@ def mock_charger() -> Generator[MagicMock]: charger.shaper_active = False charger.has_limit = False charger.mqtt_connected = False + charger.get_override_state = AsyncMock(return_value="auto") + charger.set_override = AsyncMock() + charger.clear_override = AsyncMock() yield charger diff --git a/tests/components/openevse/snapshots/test_select.ambr b/tests/components/openevse/snapshots/test_select.ambr new file mode 100644 index 000000000000..84f3e3207eb4 --- /dev/null +++ b/tests/components/openevse/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_entities[select.openevse_mock_config_override_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'active', + 'disabled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.openevse_mock_config_override_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Override state', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Override state', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'override_state', + 'unique_id': 'deadbeeffeed-override_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select.openevse_mock_config_override_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'openevse_mock_config Override state', + : list([ + 'auto', + 'active', + 'disabled', + ]), + }), + 'context': , + 'entity_id': 'select.openevse_mock_config_override_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/openevse/test_select.py b/tests/components/openevse/test_select.py new file mode 100644 index 000000000000..de613d7fbd6a --- /dev/null +++ b/tests/components/openevse/test_select.py @@ -0,0 +1,253 @@ +"""Tests for the OpenEVSE select platform.""" + +from unittest.mock import MagicMock, patch + +from aiohttp import ContentTypeError, ServerTimeoutError +from openevsehttp.exceptions import ( + AuthenticationError, + ParseJSONError, + UnknownError, + UnsupportedFeature, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.openevse.const import DOMAIN +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + HomeAssistantError, + ServiceValidationError, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test the select entities.""" + with patch("homeassistant.components.openevse.PLATFORMS", [Platform.SELECT]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("option", "method_name", "kwargs"), + [ + pytest.param( + "auto", + "clear_override", + {}, + id="override_state_auto", + ), + pytest.param( + "active", + "set_override", + {"state": "active"}, + id="override_state_active", + ), + pytest.param( + "disabled", + "set_override", + {"state": "disabled"}, + id="override_state_disabled", + ), + ], +) +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, + option: str, + method_name: str, + kwargs: dict[str, str], +) -> None: + """Test selecting an option on the select entities.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.openevse_mock_config_override_state", + ATTR_OPTION: option, + }, + blocking=True, + ) + getattr(mock_charger, method_name).assert_called_once_with(**kwargs) + state = hass.states.get("select.openevse_mock_config_override_state") + assert state is not None + assert state.state == option + + +@pytest.mark.parametrize( + ("raised", "expected", "translation_key", "translation_placeholders"), + [ + pytest.param( + ValueError("invalid mode"), + ServiceValidationError, + "invalid_value", + {"value": "active"}, + id="value_error", + ), + pytest.param( + AuthenticationError("bad creds"), + ConfigEntryAuthFailed, + "authentication_error", + None, + id="auth_error", + ), + pytest.param( + TimeoutError("timed out"), + HomeAssistantError, + "communication_error", + None, + id="timeout_error", + ), + pytest.param( + ServerTimeoutError("timed out"), + HomeAssistantError, + "communication_error", + None, + id="server_timeout_error", + ), + pytest.param( + ParseJSONError("bad json"), + HomeAssistantError, + "communication_error", + None, + id="parse_json_error", + ), + pytest.param( + UnsupportedFeature("old firmware"), + HomeAssistantError, + "unsupported_feature", + None, + id="unsupported_feature", + ), + pytest.param( + ContentTypeError(MagicMock(), (), message="bad content"), + HomeAssistantError, + "communication_error", + None, + id="content_type_error", + ), + pytest.param( + UnknownError("unknown error"), + HomeAssistantError, + "communication_error", + None, + id="unknown_error", + ), + pytest.param( + RuntimeError("runtime error"), + HomeAssistantError, + "communication_error", + None, + id="runtime_error", + ), + ], +) +async def test_select_raises( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, + raised: Exception, + expected: type[HomeAssistantError], + translation_key: str, + translation_placeholders: dict[str, str] | None, +) -> None: + """Test that errors from the charger are translated to HA exceptions.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_charger.set_override.side_effect = raised + + with pytest.raises(expected) as exc_info: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.openevse_mock_config_override_state", + ATTR_OPTION: "active", + }, + blocking=True, + ) + + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_placeholders == translation_placeholders + + +async def test_select_unavailable_when_unsupported( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test select entity is unavailable when override state is unsupported.""" + mock_charger.get_override_state.return_value = None + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("select.openevse_mock_config_override_state") + assert state is not None + assert state.state == "unavailable" + + +async def test_select_unavailable_when_initial_read_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test select entity is registered and unavailable when initial read fails.""" + mock_charger.get_override_state.side_effect = TimeoutError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("select.openevse_mock_config_override_state") + assert state is not None + assert state.state == "unavailable" + + +async def test_select_coordinator_update_failure_marks_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test coordinator update failure marks select entity unavailable.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("select.openevse_mock_config_override_state") + assert state is not None + assert state.state == "auto" + + mock_charger.get_override_state.side_effect = TimeoutError + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("select.openevse_mock_config_override_state") + assert state is not None + assert state.state == "unavailable"