From 711f7e1ac335d0b20d889be11afe67ca6ea60056 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 26 May 2024 18:36:35 +1000 Subject: [PATCH] Add media player platform to Teslemetry (#117394) * Add media player * Add tests * Better service assertions * Update strings.json * Update snapshot * Docstrings * Fix json * Update diag * Review feedback * Update snapshot * use key for title --- .../components/teslemetry/__init__.py | 1 + .../components/teslemetry/media_player.py | 149 +++++++++++++++++ .../components/teslemetry/strings.json | 5 + .../teslemetry/fixtures/vehicle_data.json | 19 +-- .../snapshots/test_diagnostics.ambr | 19 +-- .../snapshots/test_media_player.ambr | 136 ++++++++++++++++ .../teslemetry/test_media_player.py | 152 ++++++++++++++++++ 7 files changed, 463 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/teslemetry/media_player.py create mode 100644 tests/components/teslemetry/snapshots/test_media_player.ambr create mode 100644 tests/components/teslemetry/test_media_player.py diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 63636a54cc0..ff34c8f8963 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -33,6 +33,7 @@ PLATFORMS: Final = [ Platform.COVER, Platform.DEVICE_TRACKER, Platform.LOCK, + Platform.MEDIA_PLAYER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py new file mode 100644 index 00000000000..c7fc1c87438 --- /dev/null +++ b/homeassistant/components/teslemetry/media_player.py @@ -0,0 +1,149 @@ +"""Media player platform for Teslemetry integration.""" + +from __future__ import annotations + +from tesla_fleet_api.const import Scope + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +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 + +STATES = { + "Playing": MediaPlayerState.PLAYING, + "Paused": MediaPlayerState.PAUSED, + "Stopped": MediaPlayerState.IDLE, + "Off": MediaPlayerState.OFF, +} +VOLUME_MAX = 11.0 +VOLUME_STEP = 1.0 / 3 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry Media platform from a config entry.""" + + async_add_entities( + TeslemetryMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): + """Vehicle media player class.""" + + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + ) + _volume_max: float = VOLUME_MAX + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the media player entity.""" + super().__init__(data, "media") + self.scoped = scoped + if not scoped: + self._attr_supported_features = MediaPlayerEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._volume_max = ( + self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX + ) + self._attr_state = STATES.get( + self.get("vehicle_state_media_info_media_playback_status") or "Off", + ) + self._attr_volume_step = ( + 1.0 + / self._volume_max + / ( + self.get("vehicle_state_media_info_audio_volume_increment") + or VOLUME_STEP + ) + ) + + if volume := self.get("vehicle_state_media_info_audio_volume"): + self._attr_volume_level = volume / self._volume_max + else: + self._attr_volume_level = None + + if duration := self.get("vehicle_state_media_info_now_playing_duration"): + self._attr_media_duration = duration / 1000 + else: + self._attr_media_duration = None + + if duration and ( + position := self.get("vehicle_state_media_info_now_playing_elapsed") + ): + self._attr_media_position = position / 1000 + else: + self._attr_media_position = None + + self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title") + self._attr_media_artist = self.get( + "vehicle_state_media_info_now_playing_artist" + ) + self._attr_media_album_name = self.get( + "vehicle_state_media_info_now_playing_album" + ) + self._attr_media_playlist = self.get( + "vehicle_state_media_info_now_playing_station" + ) + self._attr_source = self.get("vehicle_state_media_info_now_playing_source") + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command( + self.api.adjust_volume(int(volume * self._volume_max)) + ) + self._attr_volume_level = volume + self.async_write_ha_state() + + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + async def async_media_pause(self) -> None: + """Send pause command.""" + if self.state == MediaPlayerState.PLAYING: + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + async def async_media_next_track(self) -> None: + """Send next track command.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.media_next_track()) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.media_prev_track()) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index c59cc844330..90e4bbb6e83 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -239,6 +239,11 @@ } } }, + "media_player": { + "media": { + "name": "[%key:component::media_player::title%]" + } + }, "cover": { "charge_state_charge_port_door_open": { "name": "Charge port door" diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 25f98406fac..01cf5f111c7 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -204,17 +204,18 @@ "is_user_present": false, "locked": false, "media_info": { - "audio_volume": 2.6667, + "a2dp_source_name": "Pixel 8 Pro", + "audio_volume": 1.6667, "audio_volume_increment": 0.333333, "audio_volume_max": 10.333333, - "media_playback_status": "Stopped", - "now_playing_album": "", - "now_playing_artist": "", - "now_playing_duration": 0, - "now_playing_elapsed": 0, - "now_playing_source": "Spotify", - "now_playing_station": "", - "now_playing_title": "" + "media_playback_status": "Playing", + "now_playing_album": "Elon Musk", + "now_playing_artist": "Walter Isaacson", + "now_playing_duration": 651000, + "now_playing_elapsed": 1000, + "now_playing_source": "Audible", + "now_playing_station": "Elon Musk", + "now_playing_title": "Chapter 51: Cybertruck: Tesla, 2018–2019" }, "media_state": { "remote_control_enabled": true diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 41d7ea69f4f..32f4e398843 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -361,17 +361,18 @@ 'vehicle_state_ft': 0, 'vehicle_state_is_user_present': False, 'vehicle_state_locked': False, - 'vehicle_state_media_info_audio_volume': 2.6667, + 'vehicle_state_media_info_a2dp_source_name': 'Pixel 8 Pro', + 'vehicle_state_media_info_audio_volume': 1.6667, 'vehicle_state_media_info_audio_volume_increment': 0.333333, 'vehicle_state_media_info_audio_volume_max': 10.333333, - 'vehicle_state_media_info_media_playback_status': 'Stopped', - 'vehicle_state_media_info_now_playing_album': '', - 'vehicle_state_media_info_now_playing_artist': '', - 'vehicle_state_media_info_now_playing_duration': 0, - 'vehicle_state_media_info_now_playing_elapsed': 0, - 'vehicle_state_media_info_now_playing_source': 'Spotify', - 'vehicle_state_media_info_now_playing_station': '', - 'vehicle_state_media_info_now_playing_title': '', + 'vehicle_state_media_info_media_playback_status': 'Playing', + 'vehicle_state_media_info_now_playing_album': 'Elon Musk', + 'vehicle_state_media_info_now_playing_artist': 'Walter Isaacson', + 'vehicle_state_media_info_now_playing_duration': 651000, + 'vehicle_state_media_info_now_playing_elapsed': 1000, + 'vehicle_state_media_info_now_playing_source': 'Audible', + 'vehicle_state_media_info_now_playing_station': 'Elon Musk', + 'vehicle_state_media_info_now_playing_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'vehicle_state_media_state_remote_control_enabled': True, 'vehicle_state_notifications_supported': True, 'vehicle_state_odometer': 6481.019282, diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..f0344ddef4c --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -0,0 +1,136 @@ +# serializer version: 1 +# name: test_media_player[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'media', + 'unique_id': 'VINVINVIN-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_media_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Elon Musk', + 'media_artist': 'Walter Isaacson', + 'media_duration': 651.0, + 'media_playlist': 'Elon Musk', + 'media_position': 1.0, + 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'source': 'Audible', + 'supported_features': , + 'volume_level': 0.16129355359011466, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player_alt[media_player.test_media_player-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': '', + 'media_artist': '', + 'media_playlist': '', + 'media_title': '', + 'source': 'Spotify', + 'supported_features': , + 'volume_level': 0.25806775026025003, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_noscope[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media', + 'unique_id': 'VINVINVIN-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_noscope[media_player.test_media_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Elon Musk', + 'media_artist': 'Walter Isaacson', + 'media_duration': 651.0, + 'media_playlist': 'Elon Musk', + 'media_position': 1.0, + 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'source': 'Audible', + 'supported_features': , + 'volume_level': 0.16129355359011466, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py new file mode 100644 index 00000000000..8544c11a625 --- /dev/null +++ b/tests/components/teslemetry/test_media_player.py @@ -0,0 +1,152 @@ +"""Test the Teslemetry media player platform.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_VOLUME_SET, + MediaPlayerState, +) +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, assert_entities_alt, setup_platform +from .const import COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT + + +async def test_media_player( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the media player entities are correct.""" + + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the media player entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the media player entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.MEDIA_PLAYER]) + state = hass.states.get("media_player.test_media_player") + assert state.state == MediaPlayerState.OFF + + +async def test_media_player_noscope( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_metadata, +) -> None: + """Tests that the media player entities are correct without required scope.""" + + mock_metadata.return_value = METADATA_NOSCOPE + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_services( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the media player services work.""" + + await setup_platform(hass, [Platform.MEDIA_PLAYER]) + + entity_id = "media_player.test_media_player" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.adjust_volume", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5 + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == MediaPlayerState.PAUSED + call.assert_called_once() + + # This test will fail without the previous call to pause playback + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == MediaPlayerState.PLAYING + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_next_track", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_prev_track", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + call.assert_called_once()