Add button platform to Ohme (#133267)

* Add button platform and reauth flow

* CI fixes

* Test comment change

* Remove reauth from this PR

* Move is_supported_fn to OhmeEntityDescription

* Set parallel updates to 1

* Add coordinator refresh to button press

* Add exception handling to button async_press
This commit is contained in:
Dan Raper 2024-12-15 13:22:21 +00:00 committed by GitHub
parent c2ee020eee
commit b13a54f605
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 229 additions and 8 deletions

View File

@ -0,0 +1,77 @@
"""Platform for button."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from ohme import ApiException, ChargerStatus, OhmeApiClient
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OhmeConfigEntry
from .const import DOMAIN
from .entity import OhmeEntity, OhmeEntityDescription
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription):
"""Class describing Ohme button entities."""
press_fn: Callable[[OhmeApiClient], Awaitable[None]]
available_fn: Callable[[OhmeApiClient], bool]
BUTTON_DESCRIPTIONS = [
OhmeButtonDescription(
key="approve",
translation_key="approve",
press_fn=lambda client: client.async_approve_charge(),
is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"),
available_fn=lambda client: client.status is ChargerStatus.PENDING_APPROVAL,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OhmeConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up buttons."""
coordinator = config_entry.runtime_data.charge_session_coordinator
async_add_entities(
OhmeButton(coordinator, description)
for description in BUTTON_DESCRIPTIONS
if description.is_supported_fn(coordinator.client)
)
class OhmeButton(OhmeEntity, ButtonEntity):
"""Generic button for Ohme."""
entity_description: OhmeButtonDescription
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator.client)
except ApiException as e:
raise HomeAssistantError(
translation_key="api_failed", translation_domain=DOMAIN
) from e
await self.coordinator.async_request_refresh()
@property
def available(self) -> bool:
"""Is entity available."""
return super().available and self.entity_description.available_fn(
self.coordinator.client
)

View File

@ -3,4 +3,4 @@
from homeassistant.const import Platform
DOMAIN = "ohme"
PLATFORMS = [Platform.SENSOR]
PLATFORMS = [Platform.BUTTON, Platform.SENSOR]

View File

@ -1,5 +1,10 @@
"""Base class for entities."""
from collections.abc import Callable
from dataclasses import dataclass
from ohme import OhmeApiClient
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -8,6 +13,13 @@ from .const import DOMAIN
from .coordinator import OhmeBaseCoordinator
@dataclass(frozen=True)
class OhmeEntityDescription(EntityDescription):
"""Class describing Ohme entities."""
is_supported_fn: Callable[[OhmeApiClient], bool] = lambda _: True
class OhmeEntity(CoordinatorEntity[OhmeBaseCoordinator]):
"""Base class for all Ohme entities."""

View File

@ -1,5 +1,10 @@
{
"entity": {
"button": {
"approve": {
"default": "mdi:check-decagram"
}
},
"sensor": {
"status": {
"default": "mdi:car",

View File

@ -29,10 +29,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration has no custom actions and read-only platform only.
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt

View File

@ -18,17 +18,16 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import OhmeConfigEntry
from .entity import OhmeEntity
from .entity import OhmeEntity, OhmeEntityDescription
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OhmeSensorDescription(SensorEntityDescription):
class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
"""Class describing Ohme sensor entities."""
value_fn: Callable[[OhmeApiClient], str | int | float]
is_supported_fn: Callable[[OhmeApiClient], bool] = lambda _: True
SENSOR_CHARGE_SESSION = [

View File

@ -22,6 +22,11 @@
}
},
"entity": {
"button": {
"approve": {
"name": "Approve charge"
}
},
"sensor": {
"status": {
"name": "Status",

View File

@ -0,0 +1,47 @@
# serializer version: 1
# name: test_buttons[button.ohme_home_pro_approve_charge-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.ohme_home_pro_approve_charge',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Approve charge',
'platform': 'ohme',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'approve',
'unique_id': 'chargerid_approve',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[button.ohme_home_pro_approve_charge-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Ohme Home Pro Approve charge',
}),
'context': <ANY>,
'entity_id': 'button.ohme_home_pro_approve_charge',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---

View File

@ -0,0 +1,79 @@
"""Tests for sensors."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from ohme import ChargerStatus
from syrupy import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_buttons(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test the Ohme buttons."""
with patch("homeassistant.components.ohme.PLATFORMS", [Platform.BUTTON]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_button_available(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test that button shows as unavailable when a charge is not pending approval."""
mock_client.status = ChargerStatus.PENDING_APPROVAL
await setup_integration(hass, mock_config_entry)
state = hass.states.get("button.ohme_home_pro_approve_charge")
assert state.state == STATE_UNKNOWN
mock_client.status = ChargerStatus.PLUGGED_IN
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("button.ohme_home_pro_approve_charge")
assert state.state == STATE_UNAVAILABLE
async def test_button_press(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test the button press action."""
mock_client.status = ChargerStatus.PENDING_APPROVAL
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{
ATTR_ENTITY_ID: "button.ohme_home_pro_approve_charge",
},
blocking=True,
)
assert len(mock_client.async_approve_charge.mock_calls) == 1