Skip to content

Commit 251bb30

Browse files
authored
Add streaming media platform to Teslemetry (home-assistant#140482)
* Update media player * Add media player platform with tests and bump firmware
1 parent de0efd6 commit 251bb30

File tree

9 files changed

+522
-108
lines changed

9 files changed

+522
-108
lines changed

homeassistant/components/teslemetry/media_player.py

Lines changed: 232 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from tesla_fleet_api import VehicleSpecific
56
from tesla_fleet_api.const import Scope
67

78
from homeassistant.components.media_player import (
@@ -12,9 +13,14 @@
1213
)
1314
from homeassistant.core import HomeAssistant
1415
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
16+
from homeassistant.helpers.restore_state import RestoreEntity
1517

1618
from . import TeslemetryConfigEntry
17-
from .entity import TeslemetryVehicleEntity
19+
from .entity import (
20+
TeslemetryRootEntity,
21+
TeslemetryVehicleEntity,
22+
TeslemetryVehicleStreamEntity,
23+
)
1824
from .helpers import handle_vehicle_command
1925
from .models import TeslemetryVehicleData
2026

@@ -24,8 +30,16 @@
2430
"Stopped": MediaPlayerState.IDLE,
2531
"Off": MediaPlayerState.OFF,
2632
}
27-
VOLUME_MAX = 11.0
28-
VOLUME_STEP = 1.0 / 3
33+
DISPLAY_STATES = {
34+
"On": MediaPlayerState.IDLE,
35+
"Accessory": MediaPlayerState.IDLE,
36+
"Charging": MediaPlayerState.OFF,
37+
"Sentry": MediaPlayerState.OFF,
38+
"Off": MediaPlayerState.OFF,
39+
}
40+
# Tesla uses 31 steps, in 0.333 increments up to 10.333
41+
VOLUME_STEP = 1 / 31
42+
VOLUME_FACTOR = 31 / 3 # 10.333
2943

3044
PARALLEL_UPDATES = 0
3145

@@ -38,68 +52,99 @@ async def async_setup_entry(
3852
"""Set up the Teslemetry Media platform from a config entry."""
3953

4054
async_add_entities(
41-
TeslemetryMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes)
55+
TeslemetryPollingMediaEntity(vehicle, entry.runtime_data.scopes)
56+
if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6"
57+
else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes)
4258
for vehicle in entry.runtime_data.vehicles
4359
)
4460

4561

46-
class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity):
47-
"""Vehicle media player class."""
62+
class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity):
63+
"""Base vehicle media player class."""
64+
65+
api: VehicleSpecific
4866

4967
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
50-
_attr_supported_features = (
51-
MediaPlayerEntityFeature.NEXT_TRACK
52-
| MediaPlayerEntityFeature.PAUSE
53-
| MediaPlayerEntityFeature.PLAY
54-
| MediaPlayerEntityFeature.PREVIOUS_TRACK
55-
| MediaPlayerEntityFeature.VOLUME_SET
56-
)
57-
_volume_max: float = VOLUME_MAX
68+
_attr_volume_step = VOLUME_STEP
69+
70+
async def async_set_volume_level(self, volume: float) -> None:
71+
"""Set volume level, range 0..1."""
72+
self.raise_for_scope(Scope.VEHICLE_CMDS)
73+
74+
await handle_vehicle_command(self.api.adjust_volume(volume * VOLUME_FACTOR))
75+
self._attr_volume_level = volume
76+
self.async_write_ha_state()
77+
78+
async def async_media_play(self) -> None:
79+
"""Send play command."""
80+
if self.state != MediaPlayerState.PLAYING:
81+
self.raise_for_scope(Scope.VEHICLE_CMDS)
82+
83+
await handle_vehicle_command(self.api.media_toggle_playback())
84+
self._attr_state = MediaPlayerState.PLAYING
85+
self.async_write_ha_state()
86+
87+
async def async_media_pause(self) -> None:
88+
"""Send pause command."""
89+
90+
if self.state == MediaPlayerState.PLAYING:
91+
self.raise_for_scope(Scope.VEHICLE_CMDS)
92+
93+
await handle_vehicle_command(self.api.media_toggle_playback())
94+
self._attr_state = MediaPlayerState.PAUSED
95+
self.async_write_ha_state()
96+
97+
async def async_media_next_track(self) -> None:
98+
"""Send next track command."""
99+
100+
self.raise_for_scope(Scope.VEHICLE_CMDS)
101+
await handle_vehicle_command(self.api.media_next_track())
102+
103+
async def async_media_previous_track(self) -> None:
104+
"""Send previous track command."""
105+
106+
self.raise_for_scope(Scope.VEHICLE_CMDS)
107+
await handle_vehicle_command(self.api.media_prev_track())
108+
109+
110+
class TeslemetryPollingMediaEntity(TeslemetryVehicleEntity, TeslemetryMediaEntity):
111+
"""Polling vehicle media player class."""
58112

59113
def __init__(
60114
self,
61115
data: TeslemetryVehicleData,
62-
scoped: bool,
116+
scopes: list[Scope],
63117
) -> None:
64118
"""Initialize the media player entity."""
65119
super().__init__(data, "media")
66-
self.scoped = scoped
67-
if not scoped:
120+
121+
self._attr_supported_features = (
122+
MediaPlayerEntityFeature.NEXT_TRACK
123+
| MediaPlayerEntityFeature.PAUSE
124+
| MediaPlayerEntityFeature.PLAY
125+
| MediaPlayerEntityFeature.PREVIOUS_TRACK
126+
| MediaPlayerEntityFeature.VOLUME_SET
127+
)
128+
self.scoped = Scope.VEHICLE_CMDS in scopes
129+
if not self.scoped:
68130
self._attr_supported_features = MediaPlayerEntityFeature(0)
69131

70132
def _async_update_attrs(self) -> None:
71133
"""Update entity attributes."""
72-
self._volume_max = (
73-
self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX
74-
)
75-
self._attr_state = STATES.get(
76-
self.get("vehicle_state_media_info_media_playback_status") or "Off",
77-
)
78-
self._attr_volume_step = (
79-
1.0
80-
/ self._volume_max
81-
/ (
82-
self.get("vehicle_state_media_info_audio_volume_increment")
83-
or VOLUME_STEP
84-
)
85-
)
134+
state = self.get("vehicle_state_media_info_media_playback_status")
135+
self._attr_state = STATES.get(state) if state else None
136+
self._attr_volume_level = (
137+
self.get("vehicle_state_media_info_audio_volume") or 0
138+
) / VOLUME_FACTOR
86139

87-
if volume := self.get("vehicle_state_media_info_audio_volume"):
88-
self._attr_volume_level = volume / self._volume_max
89-
else:
90-
self._attr_volume_level = None
140+
duration = self.get("vehicle_state_media_info_now_playing_duration")
141+
self._attr_media_duration = duration / 1000 if duration is not None else None
91142

92-
if duration := self.get("vehicle_state_media_info_now_playing_duration"):
93-
self._attr_media_duration = duration / 1000
94-
else:
95-
self._attr_media_duration = None
96-
97-
if duration and (
98-
position := self.get("vehicle_state_media_info_now_playing_elapsed")
99-
):
100-
self._attr_media_position = position / 1000
101-
else:
102-
self._attr_media_position = None
143+
# Return media position only when a media duration is > 0.
144+
elapsed = self.get("vehicle_state_media_info_now_playing_elapsed")
145+
self._attr_media_position = (
146+
elapsed / 1000 if duration and elapsed is not None else None
147+
)
103148

104149
self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title")
105150
self._attr_media_artist = self.get(
@@ -113,42 +158,151 @@ def _async_update_attrs(self) -> None:
113158
)
114159
self._attr_source = self.get("vehicle_state_media_info_now_playing_source")
115160

116-
async def async_set_volume_level(self, volume: float) -> None:
117-
"""Set volume level, range 0..1."""
118-
self.raise_for_scope(Scope.VEHICLE_CMDS)
119-
await self.wake_up_if_asleep()
120-
await handle_vehicle_command(
121-
self.api.adjust_volume(int(volume * self._volume_max))
161+
162+
class TeslemetryStreamingMediaEntity(
163+
TeslemetryVehicleStreamEntity, TeslemetryMediaEntity, RestoreEntity
164+
):
165+
"""Streaming vehicle media player class."""
166+
167+
def __init__(
168+
self,
169+
data: TeslemetryVehicleData,
170+
scopes: list[Scope],
171+
) -> None:
172+
"""Initialize the media player entity."""
173+
super().__init__(data, "media")
174+
175+
self._attr_supported_features = (
176+
MediaPlayerEntityFeature.NEXT_TRACK
177+
| MediaPlayerEntityFeature.PAUSE
178+
| MediaPlayerEntityFeature.PLAY
179+
| MediaPlayerEntityFeature.PREVIOUS_TRACK
180+
| MediaPlayerEntityFeature.VOLUME_SET
122181
)
123-
self._attr_volume_level = volume
124-
self.async_write_ha_state()
182+
self.scoped = Scope.VEHICLE_CMDS in scopes
183+
if not self.scoped:
184+
self._attr_supported_features = MediaPlayerEntityFeature(0)
185+
186+
async def async_added_to_hass(self) -> None:
187+
"""Call when entity is added to hass."""
188+
189+
await super().async_added_to_hass()
190+
if (state := await self.async_get_last_state()) is not None:
191+
try:
192+
self._attr_state = MediaPlayerState(state.state)
193+
except ValueError:
194+
self._attr_state = None
195+
self._attr_volume_level = state.attributes.get("volume_level")
196+
self._attr_media_title = state.attributes.get("media_title")
197+
self._attr_media_artist = state.attributes.get("media_artist")
198+
self._attr_media_album_name = state.attributes.get("media_album_name")
199+
self._attr_media_playlist = state.attributes.get("media_playlist")
200+
self._attr_media_duration = state.attributes.get("media_duration")
201+
self._attr_media_position = state.attributes.get("media_position")
202+
self._attr_source = state.attributes.get("source")
125203

126-
async def async_media_play(self) -> None:
127-
"""Send play command."""
128-
if self.state != MediaPlayerState.PLAYING:
129-
self.raise_for_scope(Scope.VEHICLE_CMDS)
130-
await self.wake_up_if_asleep()
131-
await handle_vehicle_command(self.api.media_toggle_playback())
132-
self._attr_state = MediaPlayerState.PLAYING
133204
self.async_write_ha_state()
134205

135-
async def async_media_pause(self) -> None:
136-
"""Send pause command."""
137-
if self.state == MediaPlayerState.PLAYING:
138-
self.raise_for_scope(Scope.VEHICLE_CMDS)
139-
await self.wake_up_if_asleep()
140-
await handle_vehicle_command(self.api.media_toggle_playback())
141-
self._attr_state = MediaPlayerState.PAUSED
206+
self.async_on_remove(
207+
self.vehicle.stream_vehicle.listen_CenterDisplay(
208+
self._async_handle_center_display
209+
)
210+
)
211+
self.async_on_remove(
212+
self.vehicle.stream_vehicle.listen_MediaPlaybackStatus(
213+
self._async_handle_media_playback_status
214+
)
215+
)
216+
self.async_on_remove(
217+
self.vehicle.stream_vehicle.listen_MediaPlaybackSource(
218+
self._async_handle_media_playback_source
219+
)
220+
)
221+
self.async_on_remove(
222+
self.vehicle.stream_vehicle.listen_MediaAudioVolume(
223+
self._async_handle_media_audio_volume
224+
)
225+
)
226+
self.async_on_remove(
227+
self.vehicle.stream_vehicle.listen_MediaNowPlayingDuration(
228+
self._async_handle_media_now_playing_duration
229+
)
230+
)
231+
self.async_on_remove(
232+
self.vehicle.stream_vehicle.listen_MediaNowPlayingElapsed(
233+
self._async_handle_media_now_playing_elapsed
234+
)
235+
)
236+
self.async_on_remove(
237+
self.vehicle.stream_vehicle.listen_MediaNowPlayingArtist(
238+
self._async_handle_media_now_playing_artist
239+
)
240+
)
241+
self.async_on_remove(
242+
self.vehicle.stream_vehicle.listen_MediaNowPlayingAlbum(
243+
self._async_handle_media_now_playing_album
244+
)
245+
)
246+
self.async_on_remove(
247+
self.vehicle.stream_vehicle.listen_MediaNowPlayingTitle(
248+
self._async_handle_media_now_playing_title
249+
)
250+
)
251+
self.async_on_remove(
252+
self.vehicle.stream_vehicle.listen_MediaNowPlayingStation(
253+
self._async_handle_media_now_playing_station
254+
)
255+
)
256+
257+
def _async_handle_center_display(self, value: str | None) -> None:
258+
"""Update entity attributes."""
259+
if value is not None:
260+
self._attr_state = DISPLAY_STATES.get(value)
142261
self.async_write_ha_state()
143262

144-
async def async_media_next_track(self) -> None:
145-
"""Send next track command."""
146-
self.raise_for_scope(Scope.VEHICLE_CMDS)
147-
await self.wake_up_if_asleep()
148-
await handle_vehicle_command(self.api.media_next_track())
263+
def _async_handle_media_playback_status(self, value: str | None) -> None:
264+
"""Update entity attributes."""
265+
self._attr_state = MediaPlayerState.OFF if value is None else STATES.get(value)
266+
self.async_write_ha_state()
149267

150-
async def async_media_previous_track(self) -> None:
151-
"""Send previous track command."""
152-
self.raise_for_scope(Scope.VEHICLE_CMDS)
153-
await self.wake_up_if_asleep()
154-
await handle_vehicle_command(self.api.media_prev_track())
268+
def _async_handle_media_playback_source(self, value: str | None) -> None:
269+
"""Update entity attributes."""
270+
self._attr_source = value
271+
self.async_write_ha_state()
272+
273+
def _async_handle_media_audio_volume(self, value: float | None) -> None:
274+
"""Update entity attributes."""
275+
self._attr_volume_level = None if value is None else value / VOLUME_FACTOR
276+
self.async_write_ha_state()
277+
278+
def _async_handle_media_now_playing_duration(self, value: int | None) -> None:
279+
"""Update entity attributes."""
280+
self._attr_media_duration = None if value is None else int(value / 1000)
281+
self.async_write_ha_state()
282+
283+
def _async_handle_media_now_playing_elapsed(self, value: int | None) -> None:
284+
"""Update entity attributes."""
285+
self._attr_media_position = None if value is None else int(value / 1000)
286+
self.async_write_ha_state()
287+
288+
def _async_handle_media_now_playing_artist(self, value: str | None) -> None:
289+
"""Update entity attributes."""
290+
self._attr_media_artist = value # Check if this is album artist or not
291+
self.async_write_ha_state()
292+
293+
def _async_handle_media_now_playing_album(self, value: str | None) -> None:
294+
"""Update entity attributes."""
295+
self._attr_media_album_name = value
296+
self.async_write_ha_state()
297+
298+
def _async_handle_media_now_playing_title(self, value: str | None) -> None:
299+
"""Update entity attributes."""
300+
self._attr_media_title = value
301+
self.async_write_ha_state()
302+
303+
def _async_handle_media_now_playing_station(self, value: str | None) -> None:
304+
"""Update entity attributes."""
305+
self._attr_media_channel = (
306+
value # could also be _attr_media_playlist when Spotify
307+
)
308+
self.async_write_ha_state()

tests/components/teslemetry/const.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN)
1919
SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN)
2020
ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN)
21-
METADATA = load_json_object_fixture("metadata.json", DOMAIN)
2221

2322
COMMAND_OK = {"response": {"result": True, "reason": ""}}
2423
COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}}
@@ -52,7 +51,7 @@
5251
"proxy": False,
5352
"access": True,
5453
"polling": True,
55-
"firmware": "2024.44.25",
54+
"firmware": "2026.0.0",
5655
}
5756
},
5857
}

0 commit comments

Comments
 (0)