Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for interface with a Bose SoundTouch."""
2 
3 from __future__ import annotations
4 
5 from functools import partial
6 import logging
7 from typing import Any
8 
9 from libsoundtouch.device import SoundTouchDevice
10 from libsoundtouch.utils import Source
11 
12 from homeassistant.components import media_source
14  BrowseMedia,
15  MediaPlayerDeviceClass,
16  MediaPlayerEntity,
17  MediaPlayerEntityFeature,
18  MediaPlayerState,
19  MediaType,
20  async_process_play_media_url,
21 )
22 from homeassistant.config_entries import ConfigEntry
23 from homeassistant.const import EVENT_HOMEASSISTANT_START
24 from homeassistant.core import HomeAssistant, callback
26  CONNECTION_NETWORK_MAC,
27  DeviceInfo,
28  format_mac,
29 )
30 from homeassistant.helpers.entity_platform import AddEntitiesCallback
31 
32 from .const import DOMAIN
33 
34 _LOGGER = logging.getLogger(__name__)
35 
36 MAP_STATUS = {
37  "PLAY_STATE": MediaPlayerState.PLAYING,
38  "BUFFERING_STATE": MediaPlayerState.PLAYING,
39  "PAUSE_STATE": MediaPlayerState.PAUSED,
40  "STOP_STATE": MediaPlayerState.OFF,
41 }
42 
43 ATTR_SOUNDTOUCH_GROUP = "soundtouch_group"
44 ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone"
45 
46 
48  hass: HomeAssistant,
49  entry: ConfigEntry,
50  async_add_entities: AddEntitiesCallback,
51 ) -> None:
52  """Set up the Bose SoundTouch media player based on a config entry."""
53  device = hass.data[DOMAIN][entry.entry_id].device
54  media_player = SoundTouchMediaPlayer(device)
55 
56  async_add_entities([media_player], True)
57 
58  hass.data[DOMAIN][entry.entry_id].media_player = media_player
59 
60 
62  """Representation of a SoundTouch Bose device."""
63 
64  _attr_supported_features = (
65  MediaPlayerEntityFeature.PAUSE
66  | MediaPlayerEntityFeature.VOLUME_STEP
67  | MediaPlayerEntityFeature.VOLUME_MUTE
68  | MediaPlayerEntityFeature.PREVIOUS_TRACK
69  | MediaPlayerEntityFeature.NEXT_TRACK
70  | MediaPlayerEntityFeature.TURN_OFF
71  | MediaPlayerEntityFeature.VOLUME_SET
72  | MediaPlayerEntityFeature.TURN_ON
73  | MediaPlayerEntityFeature.PLAY
74  | MediaPlayerEntityFeature.PLAY_MEDIA
75  | MediaPlayerEntityFeature.SELECT_SOURCE
76  | MediaPlayerEntityFeature.BROWSE_MEDIA
77  )
78  _attr_device_class = MediaPlayerDeviceClass.SPEAKER
79  _attr_has_entity_name = True
80  _attr_name = None
81  _attr_source_list = [
82  Source.AUX.value,
83  Source.BLUETOOTH.value,
84  ]
85 
86  def __init__(self, device: SoundTouchDevice) -> None:
87  """Create SoundTouch media player entity."""
88 
89  self._device_device = device
90 
91  self._attr_unique_id_attr_unique_id = device.config.device_id
92  self._attr_device_info_attr_device_info = DeviceInfo(
93  identifiers={(DOMAIN, device.config.device_id)},
94  connections={
95  (CONNECTION_NETWORK_MAC, format_mac(device.config.mac_address))
96  },
97  manufacturer="Bose Corporation",
98  model=device.config.type,
99  name=device.config.name,
100  )
101 
102  self._status_status = None
103  self._volume_volume = None
104  self._zone_zone = None
105 
106  @property
107  def device(self):
108  """Return SoundTouch device."""
109  return self._device_device
110 
111  def update(self) -> None:
112  """Retrieve the latest data."""
113  self._status_status = self._device_device.status()
114  self._volume_volume = self._device_device.volume()
115  self._zone_zone = self.get_zone_infoget_zone_info()
116 
117  @property
118  def volume_level(self):
119  """Volume level of the media player (0..1)."""
120  return self._volume_volume.actual / 100
121 
122  @property
123  def state(self) -> MediaPlayerState | None:
124  """Return the state of the device."""
125  if self._status_status is None or self._status_status.source == "STANDBY":
126  return MediaPlayerState.OFF
127 
128  if self._status_status.source == "INVALID_SOURCE":
129  return None
130 
131  return MAP_STATUS.get(self._status_status.play_status)
132 
133  @property
134  def source(self):
135  """Name of the current input source."""
136  return self._status_status.source
137 
138  @property
139  def is_volume_muted(self):
140  """Boolean if volume is currently muted."""
141  return self._volume_volume.muted
142 
143  def turn_off(self) -> None:
144  """Turn off media player."""
145  self._device_device.power_off()
146 
147  def turn_on(self) -> None:
148  """Turn on media player."""
149  self._device_device.power_on()
150 
151  def volume_up(self) -> None:
152  """Volume up the media player."""
153  self._device_device.volume_up()
154 
155  def volume_down(self) -> None:
156  """Volume down media player."""
157  self._device_device.volume_down()
158 
159  def set_volume_level(self, volume: float) -> None:
160  """Set volume level, range 0..1."""
161  self._device_device.set_volume(int(volume * 100))
162 
163  def mute_volume(self, mute: bool) -> None:
164  """Send mute command."""
165  self._device_device.mute()
166 
167  def media_play_pause(self) -> None:
168  """Simulate play pause media player."""
169  self._device_device.play_pause()
170 
171  def media_play(self) -> None:
172  """Send play command."""
173  self._device_device.play()
174 
175  def media_pause(self) -> None:
176  """Send media pause command to media player."""
177  self._device_device.pause()
178 
179  def media_next_track(self) -> None:
180  """Send next track command."""
181  self._device_device.next_track()
182 
183  def media_previous_track(self) -> None:
184  """Send the previous track command."""
185  self._device_device.previous_track()
186 
187  @property
188  def media_image_url(self):
189  """Image url of current playing media."""
190  return self._status_status.image
191 
192  @property
193  def media_title(self):
194  """Title of current playing media."""
195  if self._status_status.station_name is not None:
196  return self._status_status.station_name
197  if self._status_status.artist is not None:
198  return f"{self._status.artist} - {self._status.track}"
199 
200  return None
201 
202  @property
203  def media_duration(self):
204  """Duration of current playing media in seconds."""
205  return self._status_status.duration
206 
207  @property
208  def media_artist(self):
209  """Artist of current playing media."""
210  return self._status_status.artist
211 
212  @property
213  def media_track(self):
214  """Artist of current playing media."""
215  return self._status_status.track
216 
217  @property
218  def media_album_name(self):
219  """Album name of current playing media."""
220  return self._status_status.album
221 
222  async def async_added_to_hass(self) -> None:
223  """Populate zone info which requires entity_id."""
224 
225  @callback
226  def async_update_on_start(event):
227  """Schedule an update when all platform entities have been added."""
228  self.async_schedule_update_ha_stateasync_schedule_update_ha_state(True)
229 
230  self.hasshass.bus.async_listen_once(
231  EVENT_HOMEASSISTANT_START, async_update_on_start
232  )
233 
234  async def async_play_media(
235  self, media_type: MediaType | str, media_id: str, **kwargs: Any
236  ) -> None:
237  """Play a piece of media."""
238  if media_source.is_media_source_id(media_id):
239  play_item = await media_source.async_resolve_media(
240  self.hasshass, media_id, self.entity_identity_id
241  )
242  media_id = async_process_play_media_url(self.hasshass, play_item.url)
243 
244  await self.hasshass.async_add_executor_job(
245  partial(self.play_mediaplay_mediaplay_media, media_type, media_id, **kwargs)
246  )
247 
249  self, media_type: MediaType | str, media_id: str, **kwargs: Any
250  ) -> None:
251  """Play a piece of media."""
252  _LOGGER.debug("Starting media with media_id: %s", media_id)
253  if str(media_id).lower().startswith("http://"): # no https support
254  # URL
255  _LOGGER.debug("Playing URL %s", str(media_id))
256  self._device_device.play_url(str(media_id))
257  else:
258  # Preset
259  presets = self._device_device.presets()
260  preset = next(
261  iter(
262  [preset for preset in presets if preset.preset_id == str(media_id)]
263  ),
264  None,
265  )
266  if preset is not None:
267  _LOGGER.debug("Playing preset: %s", preset.name)
268  self._device_device.select_preset(preset)
269  else:
270  _LOGGER.warning("Unable to find preset with id %s", media_id)
271 
272  def select_source(self, source: str) -> None:
273  """Select input source."""
274  if source == Source.AUX.value:
275  _LOGGER.debug("Selecting source AUX")
276  self._device_device.select_source_aux()
277  elif source == Source.BLUETOOTH.value:
278  _LOGGER.debug("Selecting source Bluetooth")
279  self._device_device.select_source_bluetooth()
280  else:
281  _LOGGER.warning("Source %s is not supported", source)
282 
283  def create_zone(self, slaves):
284  """Create a zone (multi-room) and play on selected devices.
285 
286  :param slaves: slaves on which to play
287 
288  """
289  if not slaves:
290  _LOGGER.warning("Unable to create zone without slaves")
291  else:
292  _LOGGER.debug("Creating zone with master %s", self._device_device.config.name)
293  self._device_device.create_zone([slave.device for slave in slaves])
294 
295  def remove_zone_slave(self, slaves):
296  """Remove slave(s) from and existing zone (multi-room).
297 
298  Zone must already exist and slaves array cannot be empty.
299  Note: If removing last slave, the zone will be deleted and you'll have
300  to create a new one. You will not be able to add a new slave anymore
301 
302  :param slaves: slaves to remove from the zone
303 
304  """
305  if not slaves:
306  _LOGGER.warning("Unable to find slaves to remove")
307  else:
308  _LOGGER.debug(
309  "Removing slaves from zone with master %s", self._device_device.config.name
310  )
311  # SoundTouch API seems to have a bug and won't remove slaves if there are
312  # more than one in the payload. Therefore we have to loop over all slaves
313  # and remove them individually
314  for slave in slaves:
315  # make sure to not try to remove the master (aka current device)
316  if slave.entity_id != self.entity_identity_id:
317  self._device_device.remove_zone_slave([slave.device])
318 
319  def add_zone_slave(self, slaves):
320  """Add slave(s) to and existing zone (multi-room).
321 
322  Zone must already exist and slaves array cannot be empty.
323 
324  :param slaves:slaves to add
325 
326  """
327  if not slaves:
328  _LOGGER.warning("Unable to find slaves to add")
329  else:
330  _LOGGER.debug(
331  "Adding slaves to zone with master %s", self._device_device.config.name
332  )
333  self._device_device.add_zone_slave([slave.device for slave in slaves])
334 
335  @property
337  """Return entity specific state attributes."""
338  attributes = {}
339 
340  if self._zone_zone and "master" in self._zone_zone:
341  attributes[ATTR_SOUNDTOUCH_ZONE] = self._zone_zone
342  # Compatibility with how other components expose their groups (like SONOS).
343  # First entry is the master, others are slaves
344  group_members = [self._zone_zone["master"]] + self._zone_zone["slaves"]
345  attributes[ATTR_SOUNDTOUCH_GROUP] = group_members
346 
347  return attributes
348 
350  self,
351  media_content_type: MediaType | str | None = None,
352  media_content_id: str | None = None,
353  ) -> BrowseMedia:
354  """Implement the websocket media browsing helper."""
355  return await media_source.async_browse_media(self.hasshass, media_content_id)
356 
357  def get_zone_info(self):
358  """Return the current zone info."""
359  zone_status = self._device_device.zone_status()
360  if not zone_status:
361  return None
362 
363  # Client devices do NOT return their siblings as part of the "slaves" list.
364  # Only the master has the full list of slaves. To compensate for this
365  # shortcoming we have to fetch the zone info from the master when the current
366  # device is a slave.
367  # In addition to this shortcoming, libsoundtouch seems to report the "is_master"
368  # property wrong on some slaves, so the only reliable way to detect
369  # if the current devices is the master, is by comparing the master_id
370  # of the zone with the device_id.
371  if zone_status.master_id == self._device_device.config.device_id:
372  return self._build_zone_info_build_zone_info(self.entity_identity_id, zone_status.slaves)
373 
374  # The master device has to be searched by it's ID and not IP since
375  # libsoundtouch / BOSE API do not return the IP of the master
376  # for some slave objects/responses
377  master_instance = self._get_instance_by_id_get_instance_by_id(zone_status.master_id)
378  if master_instance is not None:
379  master_zone_status = master_instance.device.zone_status()
380  return self._build_zone_info_build_zone_info(
381  master_instance.entity_id, master_zone_status.slaves
382  )
383 
384  # We should never end up here since this means we haven't found a master
385  # device to get the correct zone info from. In this case,
386  # assume current device is master
387  return self._build_zone_info_build_zone_info(self.entity_identity_id, zone_status.slaves)
388 
389  def _get_instance_by_ip(self, ip_address):
390  """Search and return a SoundTouchDevice instance by it's IP address."""
391  for data in self.hasshass.data[DOMAIN].values():
392  if data.device.config.device_ip == ip_address:
393  return data.media_player
394  return None
395 
396  def _get_instance_by_id(self, instance_id):
397  """Search and return a SoundTouchDevice instance by it's ID (aka MAC address)."""
398  for data in self.hasshass.data[DOMAIN].values():
399  if data.device.config.device_id == instance_id:
400  return data.media_player
401  return None
402 
403  def _build_zone_info(self, master, zone_slaves):
404  """Build the exposed zone attributes."""
405  slaves = []
406 
407  for slave in zone_slaves:
408  slave_instance = self._get_instance_by_ip_get_instance_by_ip(slave.device_ip)
409  if slave_instance and slave_instance.entity_id != master:
410  slaves.append(slave_instance.entity_id)
411 
412  return {
413  "master": master,
414  "is_master": master == self.entity_identity_id,
415  "slaves": slaves,
416  }
None play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
Definition: __init__.py:871
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
None async_schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1265
str async_process_play_media_url(HomeAssistant hass, str media_content_id, *bool allow_relative_url=False, bool for_supervisor_network=False)
Definition: browse_media.py:36
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:51