Add OpenEVSE switch platform (#179660)

This commit is contained in:
Chris
2026-08-21 20:02:08 +02:00
committed by GitHub
parent a497edec45
commit 1e7d85397c
8 changed files with 521 additions and 3 deletions
@@ -16,6 +16,7 @@ PLATFORMS = [
Platform.BUTTON,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]
+1 -1
View File
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
from openevsehttp.__main__ import OpenEVSE
from openevsehttp import OpenEVSE
from homeassistant.components.button import (
ButtonDeviceClass,
+2 -1
View File
@@ -2,6 +2,7 @@
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from aiohttp import ContentTypeError, ServerTimeoutError
from openevsehttp.exceptions import (
@@ -20,7 +21,7 @@ from .const import DOMAIN
@contextmanager
def openevse_exception_handler(value: float) -> Iterator[None]:
def openevse_exception_handler(value: Any = None) -> Iterator[None]:
"""Context manager to handle and translate OpenEVSE exceptions."""
try:
yield
+1 -1
View File
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
from openevsehttp.__main__ import OpenEVSE
from openevsehttp import OpenEVSE
from homeassistant.components.number import (
NumberDeviceClass,
@@ -207,6 +207,17 @@
"vehicle_soc": {
"name": "Vehicle state of charge"
}
},
"switch": {
"current_shaper": {
"name": "Current shaper"
},
"manual_override": {
"name": "Manual override"
},
"solar_pv_divert": {
"name": "Solar PV divert"
}
}
},
"exceptions": {
+132
View File
@@ -0,0 +1,132 @@
"""Support for OpenEVSE switch entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
from openevsehttp import OpenEVSE
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER
from homeassistant.core import HomeAssistant
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
@dataclass(frozen=True, kw_only=True)
class OpenEVSESwitchDescription(SwitchEntityDescription):
"""Describes an OpenEVSE switch entity."""
is_on_fn: Callable[[OpenEVSE], bool | None]
turn_on_fn: Callable[[OpenEVSE], Awaitable[Any]]
turn_off_fn: Callable[[OpenEVSE], Awaitable[Any]]
SWITCH_TYPES: tuple[OpenEVSESwitchDescription, ...] = (
OpenEVSESwitchDescription(
key="solar_pv_divert",
translation_key="solar_pv_divert",
is_on_fn=lambda ev: (
ev.divertmode == "eco" if ev.divertmode is not None else None
),
turn_on_fn=lambda ev: ev.set_divert_mode("eco"),
turn_off_fn=lambda ev: ev.set_divert_mode(
"fast"
), # "fast" disables solar divert
),
OpenEVSESwitchDescription(
key="current_shaper",
translation_key="current_shaper",
is_on_fn=lambda ev: ev.shaper_active,
turn_on_fn=lambda ev: ev.set_shaper(True),
turn_off_fn=lambda ev: ev.set_shaper(False),
),
OpenEVSESwitchDescription(
key="manual_override",
translation_key="manual_override",
is_on_fn=lambda ev: ev.manual_override,
turn_on_fn=lambda ev: ev.toggle_override(),
turn_off_fn=lambda ev: ev.toggle_override(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OpenEVSEConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OpenEVSE switches based on config entry."""
coordinator = entry.runtime_data
async_add_entities(
OpenEVSESwitch(
coordinator,
description,
entry.unique_id or entry.entry_id,
entry.unique_id,
)
for description in SWITCH_TYPES
)
class OpenEVSESwitch(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], SwitchEntity):
"""Implementation of an OpenEVSE switch."""
_attr_has_entity_name = True
entity_description: OpenEVSESwitchDescription
def __init__(
self,
coordinator: OpenEVSEDataUpdateCoordinator,
description: OpenEVSESwitchDescription,
identifier: str,
unique_id: str | None,
) -> None:
"""Initialize the switch."""
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.entity_description.is_on_fn(self.coordinator.charger) is not None
)
@property
@override
def is_on(self) -> bool | None:
"""Return True if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator.charger)
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
with openevse_exception_handler():
await self.entity_description.turn_on_fn(self.coordinator.charger)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
with openevse_exception_handler():
await self.entity_description.turn_off_fn(self.coordinator.charger)
@@ -0,0 +1,151 @@
# serializer version: 1
# name: test_entities[switch.openevse_mock_config_current_shaper-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.openevse_mock_config_current_shaper',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Current shaper',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Current shaper',
'platform': 'openevse',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'current_shaper',
'unique_id': 'deadbeeffeed-current_shaper',
'unit_of_measurement': None,
})
# ---
# name: test_entities[switch.openevse_mock_config_current_shaper-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'openevse_mock_config Current shaper',
}),
'context': <ANY>,
'entity_id': 'switch.openevse_mock_config_current_shaper',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_entities[switch.openevse_mock_config_manual_override-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.openevse_mock_config_manual_override',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Manual override',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Manual override',
'platform': 'openevse',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'manual_override',
'unique_id': 'deadbeeffeed-manual_override',
'unit_of_measurement': None,
})
# ---
# name: test_entities[switch.openevse_mock_config_manual_override-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'openevse_mock_config Manual override',
}),
'context': <ANY>,
'entity_id': 'switch.openevse_mock_config_manual_override',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_entities[switch.openevse_mock_config_solar_pv_divert-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.openevse_mock_config_solar_pv_divert',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Solar PV divert',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Solar PV divert',
'platform': 'openevse',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'solar_pv_divert',
'unique_id': 'deadbeeffeed-solar_pv_divert',
'unit_of_measurement': None,
})
# ---
# name: test_entities[switch.openevse_mock_config_solar_pv_divert-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'openevse_mock_config Solar PV divert',
}),
'context': <ANY>,
'entity_id': 'switch.openevse_mock_config_solar_pv_divert',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+222
View File
@@ -0,0 +1,222 @@
"""Tests for the OpenEVSE switch platform."""
from unittest.mock import MagicMock, patch
from aiohttp import ContentTypeError, ServerTimeoutError
from openevsehttp.exceptions import (
AuthenticationError,
ParseJSONError,
UnsupportedFeature,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.openevse.const import DOMAIN
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNAVAILABLE, 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 switch entities."""
with patch("homeassistant.components.openevse.PLATFORMS", [Platform.SWITCH]):
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(
("entity_id", "service", "method_name", "args"),
[
pytest.param(
"switch.openevse_mock_config_solar_pv_divert",
SERVICE_TURN_ON,
"set_divert_mode",
("eco",),
id="solar_pv_divert_on",
),
pytest.param(
"switch.openevse_mock_config_solar_pv_divert",
SERVICE_TURN_OFF,
"set_divert_mode",
("fast",),
id="solar_pv_divert_off",
),
pytest.param(
"switch.openevse_mock_config_current_shaper",
SERVICE_TURN_ON,
"set_shaper",
(True,),
id="current_shaper_on",
),
pytest.param(
"switch.openevse_mock_config_current_shaper",
SERVICE_TURN_OFF,
"set_shaper",
(False,),
id="current_shaper_off",
),
pytest.param(
"switch.openevse_mock_config_manual_override",
SERVICE_TURN_ON,
"toggle_override",
(),
id="manual_override_on",
),
pytest.param(
"switch.openevse_mock_config_manual_override",
SERVICE_TURN_OFF,
"toggle_override",
(),
id="manual_override_off",
),
],
)
async def test_switch_turn_on_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_charger: MagicMock,
entity_id: str,
service: str,
method_name: str,
args: tuple[object, ...],
) -> None:
"""Test turning on and off the switch 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(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
getattr(mock_charger, method_name).assert_called_once_with(*args)
@pytest.mark.parametrize(
("raised", "expected", "translation_key", "translation_placeholders"),
[
pytest.param(
ValueError("invalid mode"),
ServiceValidationError,
"invalid_value",
{"value": "None"},
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",
),
],
)
async def test_switch_raises(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_charger: MagicMock,
raised: Exception,
expected: type[Exception],
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_shaper.side_effect = raised
with pytest.raises(expected) as exc_info:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "switch.openevse_mock_config_current_shaper",
},
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_switch_availability(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_charger: MagicMock,
) -> None:
"""Test switch entity availability when is_on_fn returns None."""
mock_charger.divertmode = None
mock_charger.shaper_active = True
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("switch.openevse_mock_config_solar_pv_divert")
assert state is not None
assert state.state == STATE_UNAVAILABLE
state = hass.states.get("switch.openevse_mock_config_current_shaper")
assert state is not None
assert state.state == STATE_ON