Add OpenEVSE button platform (#172964)

This commit is contained in:
Chris
2026-06-22 23:14:27 +02:00
committed by GitHub
parent b61ad539d9
commit ab8f1bf88e
5 changed files with 360 additions and 1 deletions
@@ -11,7 +11,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.NUMBER,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
@@ -0,0 +1,97 @@
"""Support for OpenEVSE button entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from openevsehttp.__main__ import OpenEVSE
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import ATTR_CONNECTIONS, ATTR_SERIAL_NUMBER, EntityCategory
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 OpenEVSEButtonDescription(ButtonEntityDescription):
"""Describes an OpenEVSE button entity."""
press_fn: Callable[[OpenEVSE], Awaitable[Any]]
BUTTON_TYPES: tuple[OpenEVSEButtonDescription, ...] = (
OpenEVSEButtonDescription(
key="restart_wifi",
translation_key="restart_wifi",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda ev: ev.restart_wifi(),
),
OpenEVSEButtonDescription(
key="restart_evse",
translation_key="restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda ev: ev.restart_evse(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OpenEVSEConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OpenEVSE buttons based on config entry."""
coordinator = entry.runtime_data
identifier = entry.unique_id or entry.entry_id
async_add_entities(
OpenEVSEButton(coordinator, description, identifier, entry.unique_id)
for description in BUTTON_TYPES
)
class OpenEVSEButton(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], ButtonEntity):
"""Implementation of an OpenEVSE button."""
_attr_has_entity_name = True
entity_description: OpenEVSEButtonDescription
def __init__(
self,
coordinator: OpenEVSEDataUpdateCoordinator,
description: OpenEVSEButtonDescription,
identifier: str,
unique_id: str | None,
) -> None:
"""Initialize the button."""
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
async def async_press(self) -> None:
"""Press the button."""
with openevse_exception_handler(0.0):
await self.entity_description.press_fn(self.coordinator.charger)
@@ -62,6 +62,14 @@
"name": "Vehicle connected"
}
},
"button": {
"restart": {
"name": "Restart"
},
"restart_wifi": {
"name": "Restart Wi-Fi"
}
},
"number": {
"charge_rate": {
"name": "Charge rate"
@@ -0,0 +1,103 @@
# serializer version: 1
# name: test_entities[button.openevse_mock_config_restart-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': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.openevse_mock_config_restart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Restart',
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.RESTART: 'restart'>,
'original_icon': None,
'original_name': 'Restart',
'platform': 'openevse',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'restart',
'unique_id': 'deadbeeffeed-restart_evse',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.openevse_mock_config_restart-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'restart',
'friendly_name': 'openevse_mock_config Restart',
}),
'context': <ANY>,
'entity_id': 'button.openevse_mock_config_restart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.openevse_mock_config_restart_wi_fi-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': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.openevse_mock_config_restart_wi_fi',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Restart Wi-Fi',
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.RESTART: 'restart'>,
'original_icon': None,
'original_name': 'Restart Wi-Fi',
'platform': 'openevse',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'restart_wifi',
'unique_id': 'deadbeeffeed-restart_wifi',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.openevse_mock_config_restart_wi_fi-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'restart',
'friendly_name': 'openevse_mock_config Restart Wi-Fi',
}),
'context': <ANY>,
'entity_id': 'button.openevse_mock_config_restart_wi_fi',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+146
View File
@@ -0,0 +1,146 @@
"""Tests for the OpenEVSE button 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.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.openevse.const import DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
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 button entities."""
with patch("homeassistant.components.openevse.PLATFORMS", [Platform.BUTTON]):
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", "method_name"),
[
pytest.param(
"button.openevse_mock_config_restart_wi_fi",
"restart_wifi",
id="restart_wifi",
),
pytest.param(
"button.openevse_mock_config_restart",
"restart_evse",
id="restart_evse",
),
],
)
async def test_press(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_charger: MagicMock,
entity_id: str,
method_name: str,
) -> None:
"""Test pressing the button."""
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(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
getattr(mock_charger, method_name).assert_called_once()
@pytest.mark.parametrize(
("raised", "expected", "translation_key", "translation_placeholders"),
[
(
AuthenticationError("bad creds"),
ConfigEntryAuthFailed,
"authentication_error",
None,
),
(
TimeoutError("timed out"),
HomeAssistantError,
"communication_error",
None,
),
(
ServerTimeoutError("timed out"),
HomeAssistantError,
"communication_error",
None,
),
(
ParseJSONError("bad json"),
HomeAssistantError,
"communication_error",
None,
),
(
UnsupportedFeature("old firmware"),
HomeAssistantError,
"unsupported_feature",
None,
),
(
ContentTypeError(MagicMock(), (), message="bad content"),
HomeAssistantError,
"communication_error",
None,
),
],
)
async def test_press_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.restart_wifi.side_effect = raised
with pytest.raises(expected) as exc_info:
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{
ATTR_ENTITY_ID: "button.openevse_mock_config_restart_wi_fi",
},
blocking=True,
)
assert isinstance(exc_info.value, HomeAssistantError)
assert exc_info.value.translation_key == translation_key
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_placeholders == translation_placeholders