diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 6b1090e11f7de7..cbfc903ec5408a 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -11,7 +11,12 @@ 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: diff --git a/homeassistant/components/openevse/button.py b/homeassistant/components/openevse/button.py new file mode 100644 index 00000000000000..fcc88fa8aff6ce --- /dev/null +++ b/homeassistant/components/openevse/button.py @@ -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_evse", + 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) diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index 71b8fec91947e9..10634cebc59ae6 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -62,6 +62,14 @@ "name": "Vehicle connected" } }, + "button": { + "restart_evse": { + "name": "Restart EVSE" + }, + "restart_wifi": { + "name": "Restart Wi-Fi" + } + }, "number": { "charge_rate": { "name": "Charge rate" diff --git a/tests/components/openevse/snapshots/test_button.ambr b/tests/components/openevse/snapshots/test_button.ambr new file mode 100644 index 00000000000000..47067703ac728b --- /dev/null +++ b/tests/components/openevse/snapshots/test_button.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_entities[button.openevse_mock_config_restart_evse-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.openevse_mock_config_restart_evse', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart EVSE', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart EVSE', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'restart_evse', + 'unique_id': 'deadbeeffeed-restart_evse', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[button.openevse_mock_config_restart_evse-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'openevse_mock_config Restart EVSE', + }), + 'context': , + 'entity_id': 'button.openevse_mock_config_restart_evse', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.openevse_mock_config_restart_wi_fi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart Wi-Fi', + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'button.openevse_mock_config_restart_wi_fi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/openevse/test_button.py b/tests/components/openevse/test_button.py new file mode 100644 index 00000000000000..eff3db7deeb140 --- /dev/null +++ b/tests/components/openevse/test_button.py @@ -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_evse", + "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