From af8542ebe133e32babbaa885773598ffc677306f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 26 May 2024 17:04:07 +1000 Subject: [PATCH] Add button platform to Teslemetry (#117227) * Add buttons * Add buttons * Fix docstrings * Rebase entry.runtime_data * Revert testing change * Fix tests * format json * Type callable * Remove refresh * Update icons.json * Update strings.json * Update homeassistant/components/teslemetry/button.py Co-authored-by: Joost Lekkerkerker * import Awaitable --------- Co-authored-by: Joost Lekkerkerker --- .../components/teslemetry/__init__.py | 1 + homeassistant/components/teslemetry/button.py | 85 +++++ .../components/teslemetry/icons.json | 20 ++ .../components/teslemetry/strings.json | 20 ++ .../teslemetry/snapshots/test_button.ambr | 323 ++++++++++++++++++ tests/components/teslemetry/test_button.py | 53 +++ 6 files changed, 502 insertions(+) create mode 100644 homeassistant/components/teslemetry/button.py create mode 100644 tests/components/teslemetry/snapshots/test_button.ambr create mode 100644 tests/components/teslemetry/test_button.py diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index af2276dbcda..63636a54cc0 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -28,6 +28,7 @@ from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData PLATFORMS: Final = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py new file mode 100644 index 00000000000..188613d92f7 --- /dev/null +++ b/homeassistant/components/teslemetry/button.py @@ -0,0 +1,85 @@ +"""Button platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from tesla_fleet_api.const import Scope + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import TeslemetryVehicleEntity +from .models import TeslemetryVehicleData + + +@dataclass(frozen=True, kw_only=True) +class TeslemetryButtonEntityDescription(ButtonEntityDescription): + """Describes a Teslemetry Button entity.""" + + func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] | None = None + + +DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( + TeslemetryButtonEntityDescription(key="wake"), # Every button runs wakeup + TeslemetryButtonEntityDescription( + key="flash_lights", func=lambda self: self.api.flash_lights() + ), + TeslemetryButtonEntityDescription( + key="honk", func=lambda self: self.api.honk_horn() + ), + TeslemetryButtonEntityDescription( + key="enable_keyless_driving", func=lambda self: self.api.remote_start_drive() + ), + TeslemetryButtonEntityDescription( + key="boombox", func=lambda self: self.api.remote_boombox(0) + ), + TeslemetryButtonEntityDescription( + key="homelink", + func=lambda self: self.api.trigger_homelink( + lat=self.coordinator.data["drive_state_latitude"], + lon=self.coordinator.data["drive_state_longitude"], + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry Button platform from a config entry.""" + + async_add_entities( + TeslemetryButtonEntity(vehicle, description) + for vehicle in entry.runtime_data.vehicles + for description in DESCRIPTIONS + if Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + + +class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): + """Base class for Teslemetry buttons.""" + + entity_description: TeslemetryButtonEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryButtonEntityDescription, + ) -> None: + """Initialize the button.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + async def async_press(self) -> None: + """Press the button.""" + await self.wake_up_if_asleep() + if self.entity_description.func: + await self.handle_command(self.entity_description.func(self)) diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 3224fee603b..089a3bea548 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -38,6 +38,26 @@ } } }, + "button": { + "boombox": { + "default": "mdi:volume-high" + }, + "enable_keyless_driving": { + "default": "mdi:car-key" + }, + "flash_lights": { + "default": "mdi:flashlight" + }, + "homelink": { + "default": "mdi:garage" + }, + "honk": { + "default": "mdi:bullhorn" + }, + "wake": { + "default": "mdi:sleep-off" + } + }, "climate": { "driver_temp": { "state_attributes": { diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index e41fbbd4507..c59cc844330 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -96,6 +96,26 @@ "name": "Tire pressure warning rear right" } }, + "button": { + "boombox": { + "name": "Play fart" + }, + "enable_keyless_driving": { + "name": "Keyless driving" + }, + "flash_lights": { + "name": "Flash lights" + }, + "homelink": { + "name": "Homelink" + }, + "honk": { + "name": "Honk horn" + }, + "wake": { + "name": "Wake" + } + }, "climate": { "driver_temp": { "name": "[%key:component::climate::title%]", diff --git a/tests/components/teslemetry/snapshots/test_button.ambr b/tests/components/teslemetry/snapshots/test_button.ambr new file mode 100644 index 00000000000..b36a33c282d --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_button.ambr @@ -0,0 +1,323 @@ +# serializer version: 1 +# name: test_button[button.test_flash_lights-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.test_flash_lights', + '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': 'Flash lights', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flash_lights', + 'unique_id': 'VINVINVIN-flash_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Flash lights', + }), + 'context': , + 'entity_id': 'button.test_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_force_refresh-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.test_force_refresh', + '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': 'Force refresh', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'refresh', + 'unique_id': 'VINVINVIN-refresh', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_force_refresh-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Force refresh', + }), + 'context': , + 'entity_id': 'button.test_force_refresh', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_homelink-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.test_homelink', + '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': 'Homelink', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'homelink', + 'unique_id': 'VINVINVIN-homelink', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_homelink-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink', + }), + 'context': , + 'entity_id': 'button.test_homelink', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_honk_horn-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.test_honk_horn', + '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': 'Honk horn', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'VINVINVIN-honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_honk_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Honk horn', + }), + 'context': , + 'entity_id': 'button.test_honk_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_keyless_driving-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.test_keyless_driving', + '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': 'Keyless driving', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'enable_keyless_driving', + 'unique_id': 'VINVINVIN-enable_keyless_driving', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_keyless_driving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Keyless driving', + }), + 'context': , + 'entity_id': 'button.test_keyless_driving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_play_fart-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.test_play_fart', + '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': 'Play fart', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boombox', + 'unique_id': 'VINVINVIN-boombox', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_play_fart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Play fart', + }), + 'context': , + 'entity_id': 'button.test_play_fart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_wake-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.test_wake', + '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': 'Wake', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake', + 'unique_id': 'VINVINVIN-wake', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_wake-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wake', + }), + 'context': , + 'entity_id': 'button.test_wake', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py new file mode 100644 index 00000000000..a10e3efdff2 --- /dev/null +++ b/tests/components/teslemetry/test_button.py @@ -0,0 +1,53 @@ +"""Test the Teslemetry button platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the button entities are correct.""" + + entry = await setup_platform(hass, [Platform.BUTTON]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.parametrize( + ("name", "func"), + [ + ("flash_lights", "flash_lights"), + ("honk_horn", "honk_horn"), + ("keyless_driving", "remote_start_drive"), + ("play_fart", "remote_boombox"), + ("homelink", "trigger_homelink"), + ], +) +async def test_press(hass: HomeAssistant, name: str, func: str) -> None: + """Test pressing the API buttons.""" + await setup_platform(hass, [Platform.BUTTON]) + + with patch( + f"homeassistant.components.teslemetry.VehicleSpecific.{func}", + return_value=COMMAND_OK, + ) as command: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: [f"button.test_{name}"]}, + blocking=True, + ) + command.assert_called_once()