Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion homeassistant/components/openevse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions homeassistant/components/openevse/button.py
Original file line number Diff line number Diff line change
@@ -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",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means restart the device right? we can then omit the translation key as it would follow the device class translation and call it restart

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joostlek There's 2 devices, the underlying EVSE arduino board and the Wifi module for all the API calls.

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
Comment on lines +84 to +92

async def async_press(self) -> None:
"""Press the button."""
with openevse_exception_handler(0.0):
await self.entity_description.press_fn(self.coordinator.charger)
8 changes: 8 additions & 0 deletions homeassistant/components/openevse/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
103 changes: 103 additions & 0 deletions tests/components/openevse/snapshots/test_button.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# serializer version: 1
# name: test_entities[button.openevse_mock_config_restart_evse-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
Comment on lines +4 to +6
'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_evse',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Restart EVSE',
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.RESTART: 'restart'>,
'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': <ANY>,
'entity_id': 'button.openevse_mock_config_restart_evse',
'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 changes: 146 additions & 0 deletions tests/components/openevse/test_button.py
Original file line number Diff line number Diff line change
@@ -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
Loading