diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py new file mode 100644 index 00000000000..21792770bb4 --- /dev/null +++ b/homeassistant/components/ohme/button.py @@ -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 + ) diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py index adc5ddfd61b..b44262ad509 100644 --- a/homeassistant/components/ohme/const.py +++ b/homeassistant/components/ohme/const.py @@ -3,4 +3,4 @@ from homeassistant.const import Platform DOMAIN = "ohme" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.SENSOR] diff --git a/homeassistant/components/ohme/entity.py b/homeassistant/components/ohme/entity.py index 2c662f7fccb..6a7d0ea16e4 100644 --- a/homeassistant/components/ohme/entity.py +++ b/homeassistant/components/ohme/entity.py @@ -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.""" diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 228907b3dbe..d5bf3fa1187 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "approve": { + "default": "mdi:check-decagram" + } + }, "sensor": { "status": { "default": "mdi:car", diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index cffc9eb7b82..15697cb11a3 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index d4abaf85b1f..6d111cf7af6 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -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 = [ diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 06231ed5cf4..42e0a60b83e 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -22,6 +22,11 @@ } }, "entity": { + "button": { + "approve": { + "name": "Approve charge" + } + }, "sensor": { "status": { "name": "Status", diff --git a/tests/components/ohme/snapshots/test_button.ambr b/tests/components/ohme/snapshots/test_button.ambr new file mode 100644 index 00000000000..32de16208f4 --- /dev/null +++ b/tests/components/ohme/snapshots/test_button.ambr @@ -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': , + 'device_class': None, + 'device_id': , + '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': , + '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': , + 'entity_id': 'button.ohme_home_pro_approve_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/ohme/test_button.py b/tests/components/ohme/test_button.py new file mode 100644 index 00000000000..1728563b2e9 --- /dev/null +++ b/tests/components/ohme/test_button.py @@ -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