Add OpenEVSE select platform with override state entity (#181302)

This commit is contained in:
Chris
2026-09-14 21:54:30 +02:00
committed by GitHub
parent 4531ed5634
commit 3c9df295b9
7 changed files with 483 additions and 0 deletions
@@ -15,6 +15,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -11,6 +11,11 @@
"default": "mdi:tune-variant"
}
},
"select": {
"override_state": {
"default": "mdi:tune-variant"
}
},
"sensor": {
"gfi_trip_count": {
"default": "mdi:counter"
+149
View File
@@ -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()
@@ -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"
+3
View File
@@ -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
@@ -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({
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'auto',
'active',
'disabled',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.openevse_mock_config_override_state',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'openevse_mock_config Override state',
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'auto',
'active',
'disabled',
]),
}),
'context': <ANY>,
'entity_id': 'select.openevse_mock_config_override_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---
+253
View File
@@ -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"