Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """MediaPlayer platform for Roon integration."""
2 
3 from __future__ import annotations
4 
5 import logging
6 from typing import Any, cast
7 
8 from roonapi import split_media_path
9 import voluptuous as vol
10 
12  BrowseMedia,
13  MediaPlayerEntity,
14  MediaPlayerEntityFeature,
15  MediaPlayerState,
16  MediaType,
17  RepeatMode,
18 )
19 from homeassistant.config_entries import ConfigEntry
20 from homeassistant.const import DEVICE_DEFAULT_NAME
21 from homeassistant.core import HomeAssistant, callback
22 from homeassistant.helpers import config_validation as cv, entity_platform
23 from homeassistant.helpers.device_registry import DeviceInfo
25  async_dispatcher_connect,
26  async_dispatcher_send,
27 )
28 from homeassistant.helpers.entity_platform import AddEntitiesCallback
29 from homeassistant.util import convert
30 from homeassistant.util.dt import utcnow
31 
32 from .const import DOMAIN
33 from .media_browser import browse_media
34 
35 _LOGGER = logging.getLogger(__name__)
36 
37 SERVICE_TRANSFER = "transfer"
38 
39 ATTR_TRANSFER = "transfer_id"
40 
41 REPEAT_MODE_MAPPING_TO_HA = {
42  "loop": RepeatMode.ALL,
43  "disabled": RepeatMode.OFF,
44  "loop_one": RepeatMode.ONE,
45 }
46 
47 REPEAT_MODE_MAPPING_TO_ROON = {
48  value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items()
49 }
50 
51 
53  hass: HomeAssistant,
54  config_entry: ConfigEntry,
55  async_add_entities: AddEntitiesCallback,
56 ) -> None:
57  """Set up Roon MediaPlayer from Config Entry."""
58  roon_server = hass.data[DOMAIN][config_entry.entry_id]
59  media_players = set()
60 
61  # Register entity services
62  platform = entity_platform.async_get_current_platform()
63  platform.async_register_entity_service(
64  SERVICE_TRANSFER,
65  {vol.Required(ATTR_TRANSFER): cv.entity_id},
66  "async_transfer",
67  )
68 
69  @callback
70  def async_update_media_player(player_data):
71  """Add or update Roon MediaPlayer."""
72  dev_id = player_data["dev_id"]
73  if dev_id not in media_players:
74  # new player!
75  media_player = RoonDevice(roon_server, player_data)
76  media_players.add(dev_id)
77  async_add_entities([media_player])
78  else:
79  # update existing player
81  hass, f"room_media_player_update_{dev_id}", player_data
82  )
83 
84  # start listening for players to be added or changed by the server component
85  async_dispatcher_connect(hass, "roon_media_player", async_update_media_player)
86 
87 
89  """Representation of an Roon device."""
90 
91  _attr_should_poll = False
92  _attr_supported_features = (
93  MediaPlayerEntityFeature.BROWSE_MEDIA
94  | MediaPlayerEntityFeature.GROUPING
95  | MediaPlayerEntityFeature.PAUSE
96  | MediaPlayerEntityFeature.STOP
97  | MediaPlayerEntityFeature.PREVIOUS_TRACK
98  | MediaPlayerEntityFeature.NEXT_TRACK
99  | MediaPlayerEntityFeature.REPEAT_SET
100  | MediaPlayerEntityFeature.SHUFFLE_SET
101  | MediaPlayerEntityFeature.SEEK
102  | MediaPlayerEntityFeature.TURN_ON
103  | MediaPlayerEntityFeature.TURN_OFF
104  | MediaPlayerEntityFeature.VOLUME_MUTE
105  | MediaPlayerEntityFeature.PLAY
106  | MediaPlayerEntityFeature.PLAY_MEDIA
107  )
108 
109  def __init__(self, server, player_data):
110  """Initialize Roon device object."""
111  self._remove_signal_status_remove_signal_status = None
112  self._server_server = server
113  self._attr_available_attr_available = True
114  self._supports_standby_supports_standby = False
115  self._attr_state_attr_state = MediaPlayerState.IDLE
116  self._zone_id_zone_id = None
117  self._output_id_output_id = None
118  self._attr_name_attr_name = DEVICE_DEFAULT_NAME
119  self._attr_media_position_attr_media_position = 0
120  self._attr_media_duration_attr_media_duration = 0
121  self._attr_is_volume_muted_attr_is_volume_muted = False
122  self._attr_volume_step_attr_volume_step = 0
123  self._attr_shuffle_attr_shuffle = False
124  self._attr_media_image_url_attr_media_image_url = None
125  self._attr_volume_level_attr_volume_level = 0
126  self._volume_fixed_volume_fixed = True
127  self._volume_incremental_volume_incremental = False
128  self.update_dataupdate_data(player_data)
129 
130  async def async_added_to_hass(self) -> None:
131  """Register callback."""
132  self.async_on_removeasync_on_remove(
134  self.hasshass,
135  f"room_media_player_update_{self.unique_id}",
136  self.async_update_callbackasync_update_callback,
137  )
138  )
139  self._server_server.add_player_id(self.entity_identity_id, self.namename)
140 
141  @callback
142  def async_update_callback(self, player_data):
143  """Handle device updates."""
144  self.update_dataupdate_data(player_data)
145  self.async_write_ha_stateasync_write_ha_state()
146 
147  @property
148  def group_members(self):
149  """Return the grouped players."""
150 
151  roon_names = self._server_server.roonapi.grouped_zone_names(self._output_id_output_id)
152  return [self._server_server.entity_id(roon_name) for roon_name in roon_names]
153 
154  @property
155  def device_info(self) -> DeviceInfo | None:
156  """Return the device info."""
157  if self.unique_idunique_id is None:
158  return None
159  if self.player_dataplayer_data.get("source_controls"):
160  dev_model = self.player_dataplayer_data["source_controls"][0].get("display_name")
161  return DeviceInfo(
162  identifiers={(DOMAIN, self.unique_idunique_id)},
163  # Instead of setting the device name to the entity name, roon
164  # should be updated to set has_entity_name = True, and set the entity
165  # name to None
166  name=cast(str | None, self.namename),
167  manufacturer="RoonLabs",
168  model=dev_model,
169  via_device=(DOMAIN, self._server_server.roon_id),
170  )
171 
172  def update_data(self, player_data=None):
173  """Update session object."""
174  if player_data:
175  self.player_dataplayer_data = player_data
176  if not self.player_dataplayer_data["is_available"]:
177  # this player was removed
178  self._attr_available_attr_available = False
179  self._attr_state_attr_state = MediaPlayerState.OFF
180  else:
181  self._attr_available_attr_available = True
182  # determine player state
183  self.update_stateupdate_state()
184  if self.statestatestatestatestate == MediaPlayerState.PLAYING:
185  self._attr_media_position_updated_at_attr_media_position_updated_at = utcnow()
186 
187  @classmethod
188  def _parse_volume(cls, player_data):
189  """Parse volume data to determine volume levels and mute state."""
190  volume = {
191  "level": 0,
192  "step": 0,
193  "muted": False,
194  "fixed": True,
195  "incremental": False,
196  }
197 
198  try:
199  volume_data = player_data["volume"]
200  except KeyError:
201  return volume
202 
203  volume["fixed"] = False
204  volume["incremental"] = volume_data["type"] == "incremental"
205  volume["muted"] = volume_data.get("is_muted", False)
206  volume["step"] = convert(volume_data.get("step"), int, 0)
207 
208  try:
209  volume_max = volume_data["max"]
210  volume_min = volume_data["min"]
211 
212  raw_level = convert(volume_data["value"], float, 0)
213 
214  volume_range = volume_max - volume_min
215  volume_percentage_factor = volume_range / 100
216 
217  level = (raw_level - volume_min) / volume_percentage_factor
218  volume["level"] = round(level) / 100
219  except KeyError:
220  pass
221 
222  return volume
223 
224  def _parse_now_playing(self, player_data):
225  """Parse now playing data to determine title, artist, position, duration and artwork."""
226  now_playing = {
227  "title": None,
228  "artist": None,
229  "album": None,
230  "position": 0,
231  "duration": 0,
232  "image": None,
233  }
234  now_playing_data = None
235 
236  media_position = convert(player_data.get("seek_position"), int, 0)
237 
238  try:
239  now_playing_data = player_data["now_playing"]
240  media_title = now_playing_data["three_line"]["line1"]
241  media_artist = now_playing_data["three_line"]["line2"]
242  media_album_name = now_playing_data["three_line"]["line3"]
243  media_duration = convert(now_playing_data.get("length"), int, 0)
244  image_id = now_playing_data.get("image_key")
245  except KeyError:
246  # catch KeyError
247  pass
248  else:
249  now_playing["title"] = media_title
250  now_playing["artist"] = media_artist
251  now_playing["album"] = media_album_name
252  now_playing["position"] = media_position
253  now_playing["duration"] = media_duration
254  if image_id:
255  now_playing["image"] = self._server_server.roonapi.get_image(image_id)
256 
257  return now_playing
258 
259  def update_state(self):
260  """Update the power state and player state."""
261 
262  new_state = ""
263  # power state from source control (if supported)
264  if "source_controls" in self.player_dataplayer_data:
265  for source in self.player_dataplayer_data["source_controls"]:
266  if source["supports_standby"] and source["status"] != "indeterminate":
267  self._supports_standby_supports_standby = True
268  if source["status"] in ["standby", "deselected"]:
269  new_state = MediaPlayerState.OFF
270  break
271  # determine player state
272  if not new_state:
273  if (
274  self.player_dataplayer_data["state"] == "playing"
275  or self.player_dataplayer_data["state"] == "loading"
276  ):
277  new_state = MediaPlayerState.PLAYING
278  elif self.player_dataplayer_data["state"] == "stopped":
279  new_state = MediaPlayerState.IDLE
280  elif self.player_dataplayer_data["state"] == "paused":
281  new_state = MediaPlayerState.PAUSED
282  else:
283  new_state = MediaPlayerState.IDLE
284  self._attr_state_attr_state = new_state
285  self._attr_unique_id_attr_unique_id = self.player_dataplayer_data["dev_id"]
286  self._zone_id_zone_id = self.player_dataplayer_data["zone_id"]
287  self._output_id_output_id = self.player_dataplayer_data["output_id"]
288  self._attr_repeat_attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get(
289  self.player_dataplayer_data["settings"]["loop"]
290  )
291  self._attr_shuffle_attr_shuffle = self.player_dataplayer_data["settings"]["shuffle"]
292  self._attr_name_attr_name = self.player_dataplayer_data["display_name"]
293 
294  volume = RoonDevice._parse_volume(self.player_dataplayer_data)
295  self._attr_is_volume_muted_attr_is_volume_muted = volume["muted"]
296  self._attr_volume_step_attr_volume_step = volume["step"]
297  self._attr_volume_level_attr_volume_level = volume["level"]
298  self._volume_fixed_volume_fixed = volume["fixed"]
299  self._volume_incremental_volume_incremental = volume["incremental"]
300  if not self._volume_fixed_volume_fixed:
301  self._attr_supported_features_attr_supported_features_attr_supported_features = (
302  self._attr_supported_features_attr_supported_features_attr_supported_features | MediaPlayerEntityFeature.VOLUME_STEP
303  )
304  if not self._volume_incremental_volume_incremental:
305  self._attr_supported_features_attr_supported_features_attr_supported_features = (
306  self._attr_supported_features_attr_supported_features_attr_supported_features | MediaPlayerEntityFeature.VOLUME_SET
307  )
308 
309  now_playing = self._parse_now_playing_parse_now_playing(self.player_dataplayer_data)
310  self._attr_media_title_attr_media_title = now_playing["title"]
311  self._attr_media_artist_attr_media_artist = now_playing["artist"]
312  self._attr_media_album_name_attr_media_album_name = now_playing["album"]
313  self._attr_media_position_attr_media_position = now_playing["position"]
314  self._attr_media_duration_attr_media_duration = now_playing["duration"]
315  self._attr_media_image_url_attr_media_image_url = now_playing["image"]
316 
317  @property
318  def zone_id(self):
319  """Return current session Id."""
320  return self._zone_id_zone_id
321 
322  @property
323  def output_id(self):
324  """Return current session Id."""
325  return self._output_id_output_id
326 
327  @property
328  def media_album_artist(self) -> str | None:
329  """Album artist of current playing media (Music track only)."""
330  return self.media_artistmedia_artist
331 
332  @property
333  def supports_standby(self):
334  """Return power state of source controls."""
335  return self._supports_standby_supports_standby
336 
337  def media_play(self) -> None:
338  """Send play command to device."""
339  self._server_server.roonapi.playback_control(self.output_idoutput_id, "play")
340 
341  def media_pause(self) -> None:
342  """Send pause command to device."""
343  self._server_server.roonapi.playback_control(self.output_idoutput_id, "pause")
344 
345  def media_play_pause(self) -> None:
346  """Toggle play command to device."""
347  self._server_server.roonapi.playback_control(self.output_idoutput_id, "playpause")
348 
349  def media_stop(self) -> None:
350  """Send stop command to device."""
351  self._server_server.roonapi.playback_control(self.output_idoutput_id, "stop")
352 
353  def media_next_track(self) -> None:
354  """Send next track command to device."""
355  self._server_server.roonapi.playback_control(self.output_idoutput_id, "next")
356 
357  def media_previous_track(self) -> None:
358  """Send previous track command to device."""
359  self._server_server.roonapi.playback_control(self.output_idoutput_id, "previous")
360 
361  def media_seek(self, position: float) -> None:
362  """Send seek command to device."""
363  self._server_server.roonapi.seek(self.output_idoutput_id, position)
364  # Seek doesn't cause an async update - so force one
365  self._attr_media_position_attr_media_position = round(position)
366  self.schedule_update_ha_stateschedule_update_ha_state()
367 
368  def set_volume_level(self, volume: float) -> None:
369  """Send new volume_level to device."""
370  volume = volume * 100
371  self._server_server.roonapi.set_volume_percent(self.output_idoutput_id, volume)
372 
373  def mute_volume(self, mute=True):
374  """Send mute/unmute to device."""
375  self._server_server.roonapi.mute(self.output_idoutput_id, mute)
376 
377  def volume_up(self) -> None:
378  """Send new volume_level to device."""
379  if self._volume_incremental_volume_incremental:
380  self._server_server.roonapi.change_volume_raw(self.output_idoutput_id, 1, "relative")
381  else:
382  self._server_server.roonapi.change_volume_percent(self.output_idoutput_id, 3)
383 
384  def volume_down(self) -> None:
385  """Send new volume_level to device."""
386  if self._volume_incremental_volume_incremental:
387  self._server_server.roonapi.change_volume_raw(self.output_idoutput_id, -1, "relative")
388  else:
389  self._server_server.roonapi.change_volume_percent(self.output_idoutput_id, -3)
390 
391  def turn_on(self) -> None:
392  """Turn on device (if supported)."""
393  if not (self.supports_standbysupports_standby and "source_controls" in self.player_dataplayer_data):
394  self.media_playmedia_playmedia_play()
395  return
396  for source in self.player_dataplayer_data["source_controls"]:
397  if source["supports_standby"] and source["status"] != "indeterminate":
398  self._server_server.roonapi.convenience_switch(
399  self.output_idoutput_id, source["control_key"]
400  )
401  return
402 
403  def turn_off(self) -> None:
404  """Turn off device (if supported)."""
405  if not (self.supports_standbysupports_standby and "source_controls" in self.player_dataplayer_data):
406  self.media_stopmedia_stopmedia_stop()
407  return
408 
409  for source in self.player_dataplayer_data["source_controls"]:
410  if source["supports_standby"] and source["status"] != "indeterminate":
411  self._server_server.roonapi.standby(self.output_idoutput_id, source["control_key"])
412  return
413 
414  def set_shuffle(self, shuffle: bool) -> None:
415  """Set shuffle state."""
416  self._server_server.roonapi.shuffle(self.output_idoutput_id, shuffle)
417 
418  def set_repeat(self, repeat: RepeatMode) -> None:
419  """Set repeat mode."""
420  if repeat not in REPEAT_MODE_MAPPING_TO_ROON:
421  raise ValueError(f"Unsupported repeat mode: {repeat}")
422  self._server_server.roonapi.repeat(self.output_idoutput_id, REPEAT_MODE_MAPPING_TO_ROON[repeat])
423 
425  self, media_type: MediaType | str, media_id: str, **kwargs: Any
426  ) -> None:
427  """Send the play_media command to the media player."""
428 
429  _LOGGER.debug("Playback request for %s / %s", media_type, media_id)
430  if media_type in ("library", "track"):
431  # media_id is a roon browser id
432  self._server_server.roonapi.play_id(self.zone_idzone_id, media_id)
433  else:
434  # media_id is a path matching the Roon menu structure
435  path_list = split_media_path(media_id)
436  if not self._server_server.roonapi.play_media(self.zone_idzone_id, path_list):
437  _LOGGER.error(
438  "Playback request for %s / %s / %s was unsuccessful",
439  media_type,
440  media_id,
441  path_list,
442  )
443 
444  def join_players(self, group_members: list[str]) -> None:
445  """Join `group_members` as a player group with the current player."""
446 
447  zone_data = self._server_server.roonapi.zone_by_output_id(self._output_id_output_id)
448  if zone_data is None:
449  _LOGGER.error("No zone data for %s", self.namename)
450  return
451 
452  sync_available = {}
453  for zone in self._server_server.zones.values():
454  for output in zone["outputs"]:
455  if (
456  zone["display_name"] != self.namename
457  and output["output_id"]
458  in self.player_dataplayer_data["can_group_with_output_ids"]
459  and zone["display_name"] not in sync_available
460  ):
461  sync_available[zone["display_name"]] = output["output_id"]
462 
463  names = []
464  for entity_id in group_members:
465  name = self._server_server.roon_name(entity_id)
466  if name is None:
467  _LOGGER.error("No roon player found for %s", entity_id)
468  return
469  if name not in sync_available:
470  _LOGGER.error(
471  (
472  "Can't join player %s with %s because it's not in the join"
473  " available list %s"
474  ),
475  name,
476  self.namename,
477  list(sync_available),
478  )
479  return
480  names.append(name)
481 
482  _LOGGER.debug("Joining %s to %s", names, self.namename)
483  self._server_server.roonapi.group_outputs(
484  [self._output_id_output_id] + [sync_available[name] for name in names]
485  )
486 
487  def unjoin_player(self) -> None:
488  """Remove this player from any group."""
489 
490  if not self._server_server.roonapi.is_grouped(self._output_id_output_id):
491  _LOGGER.error(
492  "Can't unjoin player %s because it's not in a group",
493  self.namename,
494  )
495  return
496 
497  self._server_server.roonapi.ungroup_outputs([self._output_id_output_id])
498 
499  async def async_transfer(self, transfer_id):
500  """Transfer playback from this roon player to another."""
501 
502  name = self._server_server.roon_name(transfer_id)
503  if name is None:
504  _LOGGER.error("No roon player found for %s", transfer_id)
505  return
506 
507  zone_ids = {
508  output["display_name"]: output["zone_id"]
509  for output in self._server_server.zones.values()
510  if output["display_name"] != self.namename
511  }
512 
513  if (transfer_id := zone_ids.get(name)) is None:
514  _LOGGER.error(
515  "Can't transfer from %s to %s because destination is not known %s",
516  self.namename,
517  transfer_id,
518  list(zone_ids),
519  )
520 
521  _LOGGER.debug("Transferring from %s to %s", self.namename, name)
522  await self.hasshass.async_add_executor_job(
523  self._server_server.roonapi.transfer_zone, self._zone_id_zone_id, transfer_id
524  )
525 
527  self,
528  media_content_type: MediaType | str | None = None,
529  media_content_id: str | None = None,
530  ) -> BrowseMedia:
531  """Implement the websocket media browsing helper."""
532  return await self.hasshass.async_add_executor_job(
533  browse_media,
534  self.zone_idzone_id,
535  self._server_server,
536  media_content_type,
537  media_content_id,
538  )
None join_players(self, list[str] group_members)
None play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1244
str|UndefinedType|None name(self)
Definition: entity.py:738
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:56
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193