From 2fc100926f21205eb39332871007279cd7fa075a Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Tue, 8 Mar 2022 22:16:07 -0500 Subject: [PATCH] Add button entities to Mazda integration (#67597) --- homeassistant/components/mazda/__init__.py | 14 +- homeassistant/components/mazda/button.py | 156 +++++++++++++++++++++ tests/components/mazda/test_button.py | 151 ++++++++++++++++++++ 3 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/mazda/button.py create mode 100644 tests/components/mazda/test_button.py diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 8e25e08dc47..38054bc653e 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -37,7 +37,7 @@ from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN, SERVICE _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR] async def with_timeout(task, timeout_seconds=10): @@ -102,6 +102,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if vehicle_id == 0 or api_client is None: raise HomeAssistantError("Vehicle ID not found") + if service_call.service in ( + "start_engine", + "stop_engine", + "turn_on_hazard_lights", + "turn_off_hazard_lights", + ): + _LOGGER.warning( + "The mazda.%s service is deprecated and has been replaced by a button entity; " + "Please use the button entity instead", + service_call.service, + ) + api_method = getattr(api_client, service_call.service) try: if service_call.service == "send_poi": diff --git a/homeassistant/components/mazda/button.py b/homeassistant/components/mazda/button.py new file mode 100644 index 00000000000..e747cb33dc2 --- /dev/null +++ b/homeassistant/components/mazda/button.py @@ -0,0 +1,156 @@ +"""Platform for Mazda button integration.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from pymazda import ( + Client as MazdaAPIClient, + MazdaAccountLockedException, + MazdaAPIEncryptionException, + MazdaAuthenticationException, + MazdaException, + MazdaLoginFailedException, + MazdaTokenExpiredException, +) + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import MazdaEntity +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN + + +async def handle_button_press( + client: MazdaAPIClient, + key: str, + vehicle_id: int, + coordinator: DataUpdateCoordinator, +) -> None: + """Handle a press for a Mazda button entity.""" + api_method = getattr(client, key) + + try: + await api_method(vehicle_id) + except ( + MazdaException, + MazdaAuthenticationException, + MazdaAccountLockedException, + MazdaTokenExpiredException, + MazdaAPIEncryptionException, + MazdaLoginFailedException, + ) as ex: + raise HomeAssistantError(ex) from ex + + +async def handle_refresh_vehicle_status( + client: MazdaAPIClient, + key: str, + vehicle_id: int, + coordinator: DataUpdateCoordinator, +) -> None: + """Handle a request to refresh the vehicle status.""" + await handle_button_press(client, key, vehicle_id, coordinator) + + await coordinator.async_request_refresh() + + +@dataclass +class MazdaButtonRequiredKeysMixin: + """Mixin for required keys.""" + + # Suffix to be appended to the vehicle name to obtain the button name + name_suffix: str + + +@dataclass +class MazdaButtonEntityDescription( + ButtonEntityDescription, MazdaButtonRequiredKeysMixin +): + """Describes a Mazda button entity.""" + + # Function to determine whether the vehicle supports this button, given the coordinator data + is_supported: Callable[[dict[str, Any]], bool] = lambda data: True + + async_press: Callable[ + [MazdaAPIClient, str, int, DataUpdateCoordinator], Awaitable + ] = handle_button_press + + +BUTTON_ENTITIES = [ + MazdaButtonEntityDescription( + key="start_engine", + name_suffix="Start Engine", + icon="mdi:engine", + ), + MazdaButtonEntityDescription( + key="stop_engine", + name_suffix="Stop Engine", + icon="mdi:engine-off", + ), + MazdaButtonEntityDescription( + key="turn_on_hazard_lights", + name_suffix="Turn On Hazard Lights", + icon="mdi:hazard-lights", + ), + MazdaButtonEntityDescription( + key="turn_off_hazard_lights", + name_suffix="Turn Off Hazard Lights", + icon="mdi:hazard-lights", + ), + MazdaButtonEntityDescription( + key="refresh_vehicle_status", + name_suffix="Refresh Status", + icon="mdi:refresh", + async_press=handle_refresh_vehicle_status, + is_supported=lambda data: data["isElectric"], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the button platform.""" + client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] + + async_add_entities( + MazdaButtonEntity(client, coordinator, index, description) + for index, data in enumerate(coordinator.data) + for description in BUTTON_ENTITIES + if description.is_supported(data) + ) + + +class MazdaButtonEntity(MazdaEntity, ButtonEntity): + """Representation of a Mazda button.""" + + entity_description: MazdaButtonEntityDescription + + def __init__( + self, + client: MazdaAPIClient, + coordinator: DataUpdateCoordinator, + index: int, + description: MazdaButtonEntityDescription, + ) -> None: + """Initialize Mazda button.""" + super().__init__(client, coordinator, index) + self.entity_description = description + + self._attr_name = f"{self.vehicle_name} {description.name_suffix}" + self._attr_unique_id = f"{self.vin}_{description.key}" + + async def async_press(self) -> None: + """Press the button.""" + await self.entity_description.async_press( + self.client, self.entity_description.key, self.vehicle_id, self.coordinator + ) diff --git a/tests/components/mazda/test_button.py b/tests/components/mazda/test_button.py new file mode 100644 index 00000000000..cb9fdb40737 --- /dev/null +++ b/tests/components/mazda/test_button.py @@ -0,0 +1,151 @@ +"""The button tests for the Mazda Connected Services integration.""" + +from pymazda import MazdaException +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.components.mazda import init_integration + + +async def test_button_setup_non_electric_vehicle(hass) -> None: + """Test creation of button entities.""" + await init_integration(hass) + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("button.my_mazda3_start_engine") + assert entry + assert entry.unique_id == "JM000000000000000_start_engine" + state = hass.states.get("button.my_mazda3_start_engine") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Start Engine" + assert state.attributes.get(ATTR_ICON) == "mdi:engine" + + entry = entity_registry.async_get("button.my_mazda3_stop_engine") + assert entry + assert entry.unique_id == "JM000000000000000_stop_engine" + state = hass.states.get("button.my_mazda3_stop_engine") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Stop Engine" + assert state.attributes.get(ATTR_ICON) == "mdi:engine-off" + + entry = entity_registry.async_get("button.my_mazda3_turn_on_hazard_lights") + assert entry + assert entry.unique_id == "JM000000000000000_turn_on_hazard_lights" + state = hass.states.get("button.my_mazda3_turn_on_hazard_lights") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Turn On Hazard Lights" + assert state.attributes.get(ATTR_ICON) == "mdi:hazard-lights" + + entry = entity_registry.async_get("button.my_mazda3_turn_off_hazard_lights") + assert entry + assert entry.unique_id == "JM000000000000000_turn_off_hazard_lights" + state = hass.states.get("button.my_mazda3_turn_off_hazard_lights") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Turn Off Hazard Lights" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:hazard-lights" + + # Since this is a non-electric vehicle, electric vehicle buttons should not be created + entry = entity_registry.async_get("button.my_mazda3_refresh_vehicle_status") + assert entry is None + state = hass.states.get("button.my_mazda3_refresh_vehicle_status") + assert state is None + + +async def test_button_setup_electric_vehicle(hass) -> None: + """Test creation of button entities for an electric vehicle.""" + await init_integration(hass, electric_vehicle=True) + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("button.my_mazda3_start_engine") + assert entry + assert entry.unique_id == "JM000000000000000_start_engine" + state = hass.states.get("button.my_mazda3_start_engine") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Start Engine" + assert state.attributes.get(ATTR_ICON) == "mdi:engine" + + entry = entity_registry.async_get("button.my_mazda3_stop_engine") + assert entry + assert entry.unique_id == "JM000000000000000_stop_engine" + state = hass.states.get("button.my_mazda3_stop_engine") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Stop Engine" + assert state.attributes.get(ATTR_ICON) == "mdi:engine-off" + + entry = entity_registry.async_get("button.my_mazda3_turn_on_hazard_lights") + assert entry + assert entry.unique_id == "JM000000000000000_turn_on_hazard_lights" + state = hass.states.get("button.my_mazda3_turn_on_hazard_lights") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Turn On Hazard Lights" + assert state.attributes.get(ATTR_ICON) == "mdi:hazard-lights" + + entry = entity_registry.async_get("button.my_mazda3_turn_off_hazard_lights") + assert entry + assert entry.unique_id == "JM000000000000000_turn_off_hazard_lights" + state = hass.states.get("button.my_mazda3_turn_off_hazard_lights") + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Turn Off Hazard Lights" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:hazard-lights" + + entry = entity_registry.async_get("button.my_mazda3_refresh_status") + assert entry + assert entry.unique_id == "JM000000000000000_refresh_vehicle_status" + state = hass.states.get("button.my_mazda3_refresh_status") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Refresh Status" + assert state.attributes.get(ATTR_ICON) == "mdi:refresh" + + +@pytest.mark.parametrize( + "entity_id_suffix, api_method_name", + [ + ("start_engine", "start_engine"), + ("stop_engine", "stop_engine"), + ("turn_on_hazard_lights", "turn_on_hazard_lights"), + ("turn_off_hazard_lights", "turn_off_hazard_lights"), + ("refresh_status", "refresh_vehicle_status"), + ], +) +async def test_button_press(hass, entity_id_suffix, api_method_name) -> None: + """Test pressing the button entities.""" + client_mock = await init_integration(hass, electric_vehicle=True) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.my_mazda3_{entity_id_suffix}"}, + blocking=True, + ) + await hass.async_block_till_done() + + api_method = getattr(client_mock, api_method_name) + api_method.assert_called_once_with(12345) + + +async def test_button_press_error(hass) -> None: + """Test the Mazda API raising an error when a button entity is pressed.""" + client_mock = await init_integration(hass) + + client_mock.start_engine.side_effect = MazdaException("Test error") + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.my_mazda3_start_engine"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert str(err.value) == "Test error"