Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Media player entity for the Bang & Olufsen integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import contextlib
7 from datetime import timedelta
8 import json
9 import logging
10 from typing import TYPE_CHECKING, Any, cast
11 
12 from aiohttp import ClientConnectorError
13 from mozart_api import __version__ as MOZART_API_VERSION
14 from mozart_api.exceptions import ApiException, NotFoundException
15 from mozart_api.models import (
16  Action,
17  Art,
18  BeolinkLeader,
19  ListeningModeProps,
20  ListeningModeRef,
21  OverlayPlayRequest,
22  OverlayPlayRequestTextToSpeechTextToSpeech,
23  PlaybackContentMetadata,
24  PlaybackError,
25  PlaybackProgress,
26  PlayQueueItem,
27  PlayQueueItemType,
28  PlayQueueSettings,
29  RenderingState,
30  SceneProperties,
31  SoftwareUpdateState,
32  SoftwareUpdateStatus,
33  Source,
34  Uri,
35  UserFlow,
36  VolumeLevel,
37  VolumeMute,
38  VolumeState,
39 )
40 from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork
41 import voluptuous as vol
42 
43 from homeassistant.components import media_source
45  ATTR_MEDIA_EXTRA,
46  BrowseMedia,
47  MediaPlayerDeviceClass,
48  MediaPlayerEntity,
49  MediaPlayerEntityFeature,
50  MediaPlayerState,
51  MediaType,
52  RepeatMode,
53  async_process_play_media_url,
54 )
55 from homeassistant.config_entries import ConfigEntry
56 from homeassistant.const import CONF_MODEL, Platform
57 from homeassistant.core import HomeAssistant, callback
58 from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
59 from homeassistant.helpers import (
60  config_validation as cv,
61  device_registry as dr,
62  entity_registry as er,
63 )
64 from homeassistant.helpers.device_registry import DeviceInfo
65 from homeassistant.helpers.dispatcher import async_dispatcher_connect
67  AddEntitiesCallback,
68  async_get_current_platform,
69 )
70 from homeassistant.util.dt import utcnow
71 
72 from . import BangOlufsenConfigEntry
73 from .const import (
74  BANG_OLUFSEN_REPEAT_FROM_HA,
75  BANG_OLUFSEN_REPEAT_TO_HA,
76  BANG_OLUFSEN_STATES,
77  CONF_BEOLINK_JID,
78  CONNECTION_STATUS,
79  DOMAIN,
80  FALLBACK_SOURCES,
81  VALID_MEDIA_TYPES,
82  BangOlufsenMediaType,
83  BangOlufsenSource,
84  WebsocketNotification,
85 )
86 from .entity import BangOlufsenEntity
87 from .util import get_serial_number_from_jid
88 
89 PARALLEL_UPDATES = 0
90 
91 SCAN_INTERVAL = timedelta(seconds=30)
92 
93 _LOGGER = logging.getLogger(__name__)
94 
95 BANG_OLUFSEN_FEATURES = (
96  MediaPlayerEntityFeature.BROWSE_MEDIA
97  | MediaPlayerEntityFeature.CLEAR_PLAYLIST
98  | MediaPlayerEntityFeature.GROUPING
99  | MediaPlayerEntityFeature.MEDIA_ANNOUNCE
100  | MediaPlayerEntityFeature.NEXT_TRACK
101  | MediaPlayerEntityFeature.PAUSE
102  | MediaPlayerEntityFeature.PLAY
103  | MediaPlayerEntityFeature.PLAY_MEDIA
104  | MediaPlayerEntityFeature.PREVIOUS_TRACK
105  | MediaPlayerEntityFeature.REPEAT_SET
106  | MediaPlayerEntityFeature.SELECT_SOURCE
107  | MediaPlayerEntityFeature.SHUFFLE_SET
108  | MediaPlayerEntityFeature.STOP
109  | MediaPlayerEntityFeature.TURN_OFF
110  | MediaPlayerEntityFeature.VOLUME_MUTE
111  | MediaPlayerEntityFeature.VOLUME_SET
112  | MediaPlayerEntityFeature.SELECT_SOUND_MODE
113 )
114 
115 
117  hass: HomeAssistant,
118  config_entry: BangOlufsenConfigEntry,
119  async_add_entities: AddEntitiesCallback,
120 ) -> None:
121  """Set up a Media Player entity from config entry."""
122  # Add MediaPlayer entity
124  new_entities=[
125  BangOlufsenMediaPlayer(config_entry, config_entry.runtime_data.client)
126  ]
127  )
128 
129  # Register actions.
130  platform = async_get_current_platform()
131 
132  jid_regex = vol.Match(
133  r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
134  )
135 
136  platform.async_register_entity_service(
137  name="beolink_join",
138  schema={vol.Optional("beolink_jid"): jid_regex},
139  func="async_beolink_join",
140  )
141 
142  platform.async_register_entity_service(
143  name="beolink_expand",
144  schema={
145  vol.Exclusive("all_discovered", "devices", ""): cv.boolean,
146  vol.Exclusive(
147  "beolink_jids",
148  "devices",
149  "Define either specific Beolink JIDs or all discovered",
150  ): vol.All(
151  cv.ensure_list,
152  [jid_regex],
153  ),
154  },
155  func="async_beolink_expand",
156  )
157 
158  platform.async_register_entity_service(
159  name="beolink_unexpand",
160  schema={
161  vol.Required("beolink_jids"): vol.All(
162  cv.ensure_list,
163  [jid_regex],
164  ),
165  },
166  func="async_beolink_unexpand",
167  )
168 
169  platform.async_register_entity_service(
170  name="beolink_leave",
171  schema=None,
172  func="async_beolink_leave",
173  )
174 
175  platform.async_register_entity_service(
176  name="beolink_allstandby",
177  schema=None,
178  func="async_beolink_allstandby",
179  )
180 
181 
183  """Representation of a media player."""
184 
185  _attr_name = None
186  _attr_device_class = MediaPlayerDeviceClass.SPEAKER
187 
188  def __init__(self, entry: ConfigEntry, client: MozartClient) -> None:
189  """Initialize the media player."""
190  super().__init__(entry, client)
191 
192  self._beolink_jid: str = self.entry.data[CONF_BEOLINK_JID]
193  self._model: str = self.entry.data[CONF_MODEL]
194 
196  configuration_url=f"http://{self._host}/#/",
197  identifiers={(DOMAIN, self._unique_id)},
198  manufacturer="Bang & Olufsen",
199  model=self._model,
200  serial_number=self._unique_id,
201  )
202  self._attr_unique_id_attr_unique_id = self._unique_id
203  self._attr_should_poll_attr_should_poll_attr_should_poll = True
204 
205  # Misc. variables.
206  self._audio_sources_audio_sources: dict[str, str] = {}
207  self._media_image_media_image: Art = Art()
208  self._software_status_software_status: SoftwareUpdateStatus = SoftwareUpdateStatus(
209  software_version="",
210  state=SoftwareUpdateState(seconds_remaining=0, value="idle"),
211  )
212  self._sources_sources: dict[str, str] = {}
213  self._state_state: str = MediaPlayerState.IDLE
214  self._video_sources: dict[str, str] = {}
215  self._sound_modes: dict[str, int] = {}
216 
217  # Beolink compatible sources
218  self._beolink_sources_beolink_sources: dict[str, bool] = {}
219  self._remote_leader_remote_leader: BeolinkLeader | None = None
220  # Extra state attributes for showing Beolink: peer(s), listener(s), leader and self
221  self._beolink_attributes_beolink_attributes: dict[str, dict[str, dict[str, str]]] = {}
222 
223  async def async_added_to_hass(self) -> None:
224  """Turn on the dispatchers."""
225  await self._initialize_initialize()
226 
227  signal_handlers: dict[str, Callable] = {
228  CONNECTION_STATUS: self._async_update_connection_state_async_update_connection_state,
229  WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes_async_update_sound_modes,
230  WebsocketNotification.BEOLINK: self._async_update_beolink_async_update_beolink,
231  WebsocketNotification.CONFIGURATION: self._async_update_name_and_beolink_async_update_name_and_beolink,
232  WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error_async_update_playback_error,
233  WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink_async_update_playback_metadata_and_beolink,
234  WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress_async_update_playback_progress,
235  WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources_async_update_sources,
236  WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state_async_update_playback_state,
237  WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources_async_update_sources,
238  WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change_async_update_source_change,
239  WebsocketNotification.VOLUME: self._async_update_volume_async_update_volume,
240  }
241 
242  for signal, signal_handler in signal_handlers.items():
243  self.async_on_removeasync_on_remove(
245  self.hasshass,
246  f"{self._unique_id}_{signal}",
247  signal_handler,
248  )
249  )
250 
251  async def _initialize(self) -> None:
252  """Initialize connection dependent variables."""
253 
254  # Get software version.
255  self._software_status_software_status = await self._client_client.get_softwareupdate_status()
256 
257  _LOGGER.debug(
258  "Connected to: %s %s running SW %s",
259  self._model,
260  self._unique_id,
261  self._software_status_software_status.software_version,
262  )
263 
264  # Get overall device state once. This is handled by WebSocket events the rest of the time.
265  product_state = await self._client_client.get_product_state()
266 
267  # Get volume information.
268  if product_state.volume:
269  self._volume_volume = product_state.volume
270 
271  # Get all playback information.
272  # Ensure that the metadata is not None upon startup
273  if product_state.playback:
274  if product_state.playback.metadata:
275  self._playback_metadata_playback_metadata = product_state.playback.metadata
276  self._remote_leader_remote_leader = product_state.playback.metadata.remote_leader
277  if product_state.playback.progress:
278  self._playback_progress_playback_progress = product_state.playback.progress
279  if product_state.playback.source:
280  self._source_change_source_change = product_state.playback.source
281  if product_state.playback.state:
282  self._playback_state_playback_state = product_state.playback.state
283  # Set initial state
284  if self._playback_state_playback_state.value:
285  self._state_state = self._playback_state_playback_state.value
286 
287  self._attr_media_position_updated_at_attr_media_position_updated_at = utcnow()
288 
289  # Get the highest resolution available of the given images.
290  self._media_image_media_image = get_highest_resolution_artwork(self._playback_metadata_playback_metadata)
291 
292  # If the device has been updated with new sources, then the API will fail here.
293  await self._async_update_sources_async_update_sources()
294 
295  await self._async_update_sound_modes_async_update_sound_modes()
296 
297  # Update beolink attributes and device name.
298  await self._async_update_name_and_beolink_async_update_name_and_beolink()
299 
300  async def async_update(self) -> None:
301  """Update queue settings."""
302  # The WebSocket event listener is the main handler for connection state.
303  # The polling updates do therefore not set the device as available or unavailable
304  with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
305  queue_settings = await self._client_client.get_settings_queue(_request_timeout=5)
306 
307  if queue_settings.repeat is not None:
308  self._attr_repeat_attr_repeat = BANG_OLUFSEN_REPEAT_TO_HA[queue_settings.repeat]
309 
310  if queue_settings.shuffle is not None:
311  self._attr_shuffle_attr_shuffle = queue_settings.shuffle
312 
313  async def _async_update_sources(self, _: Source | None = None) -> None:
314  """Get sources for the specific product."""
315 
316  # Audio sources
317  try:
318  # Get all available sources.
319  sources = await self._client_client.get_available_sources(target_remote=False)
320 
321  # Use a fallback list of sources
322  except ValueError:
323  # Try to get software version from device
324  if self.device_infodevice_info:
325  sw_version = self.device_infodevice_info.get("sw_version")
326  if not sw_version:
327  sw_version = self._software_status_software_status.software_version
328 
329  _LOGGER.warning(
330  "The API is outdated compared to the device software version %s and %s. Using fallback sources",
331  MOZART_API_VERSION,
332  sw_version,
333  )
334  sources = FALLBACK_SOURCES
335 
336  # Save all of the relevant enabled sources, both the ID and the friendly name for displaying in a dict.
337  self._audio_sources_audio_sources = {
338  source.id: source.name
339  for source in cast(list[Source], sources.items)
340  if source.is_enabled and source.id and source.name and source.is_playable
341  }
342 
343  # Some sources are not Beolink expandable, meaning that they can't be joined by
344  # or expand to other Bang & Olufsen devices for a multi-room experience.
345  # _source_change, which is used throughout the entity for current source
346  # information, lacks this information, so source ID's and their expandability is
347  # stored in the self._beolink_sources variable.
348  self._beolink_sources_beolink_sources = {
349  source.id: (
350  source.is_multiroom_available
351  if source.is_multiroom_available is not None
352  else False
353  )
354  for source in cast(list[Source], sources.items)
355  if source.id
356  }
357 
358  # Video sources from remote menu
359  menu_items = await self._client_client.get_remote_menu()
360 
361  for key in menu_items:
362  menu_item = menu_items[key]
363 
364  if not menu_item.available:
365  continue
366 
367  # TV SOURCES
368  if (
369  menu_item.content is not None
370  and menu_item.content.categories
371  and len(menu_item.content.categories) > 0
372  and "music" not in menu_item.content.categories
373  and menu_item.label
374  and menu_item.label != "TV"
375  ):
376  self._video_sources[key] = menu_item.label
377 
378  # Combine the source dicts
379  self._sources_sources = self._audio_sources_audio_sources | self._video_sources
380 
381  self._attr_source_list_attr_source_list = list(self._sources_sources.values())
382 
383  # HASS won't necessarily be running the first time this method is run
384  if self.hasshass.is_running:
385  self.async_write_ha_stateasync_write_ha_state()
386 
388  self, data: PlaybackContentMetadata
389  ) -> None:
390  """Update _playback_metadata and related."""
391  self._playback_metadata_playback_metadata = data
392 
393  # Update current artwork and remote_leader.
394  self._media_image_media_image = get_highest_resolution_artwork(self._playback_metadata_playback_metadata)
395  await self._async_update_beolink_async_update_beolink()
396 
397  @callback
398  def _async_update_playback_error(self, data: PlaybackError) -> None:
399  """Show playback error."""
400  raise HomeAssistantError(data.error)
401 
402  @callback
403  def _async_update_playback_progress(self, data: PlaybackProgress) -> None:
404  """Update _playback_progress and last update."""
405  self._playback_progress_playback_progress = data
406  self._attr_media_position_updated_at_attr_media_position_updated_at = utcnow()
407 
408  self.async_write_ha_stateasync_write_ha_state()
409 
410  @callback
411  def _async_update_playback_state(self, data: RenderingState) -> None:
412  """Update _playback_state and related."""
413  self._playback_state_playback_state = data
414 
415  # Update entity state based on the playback state.
416  if self._playback_state_playback_state.value:
417  self._state_state = self._playback_state_playback_state.value
418 
419  self.async_write_ha_stateasync_write_ha_state()
420 
421  @callback
422  def _async_update_source_change(self, data: Source) -> None:
423  """Update _source_change and related."""
424  self._source_change_source_change = data
425 
426  # Check if source is line-in or optical and progress should be updated
427  if self._source_change_source_change.id in (
428  BangOlufsenSource.LINE_IN.id,
429  BangOlufsenSource.SPDIF.id,
430  ):
431  self._playback_progress_playback_progress = PlaybackProgress(progress=0)
432 
433  self.async_write_ha_stateasync_write_ha_state()
434 
435  @callback
436  def _async_update_volume(self, data: VolumeState) -> None:
437  """Update _volume."""
438  self._volume_volume = data
439 
440  self.async_write_ha_stateasync_write_ha_state()
441 
442  async def _async_update_name_and_beolink(self) -> None:
443  """Update the device friendly name."""
444  beolink_self = await self._client_client.get_beolink_self()
445 
446  # Update device name
447  device_registry = dr.async_get(self.hasshass)
448  assert self.device_entrydevice_entry is not None
449 
450  device_registry.async_update_device(
451  device_id=self.device_entrydevice_entry.id,
452  name=beolink_self.friendly_name,
453  )
454 
455  await self._async_update_beolink_async_update_beolink()
456 
457  async def _async_update_beolink(self) -> None:
458  """Update the current Beolink leader, listeners, peers and self."""
459 
460  self._beolink_attributes_beolink_attributes = {}
461 
462  assert self.device_entrydevice_entry is not None
463  assert self.device_entrydevice_entry.name is not None
464 
465  # Add Beolink self
466  self._beolink_attributes_beolink_attributes = {
467  "beolink": {"self": {self.device_entrydevice_entry.name: self._beolink_jid}}
468  }
469 
470  # Add Beolink peers
471  peers = await self._client_client.get_beolink_peers()
472 
473  if len(peers) > 0:
474  self._beolink_attributes_beolink_attributes["beolink"]["peers"] = {}
475  for peer in peers:
476  self._beolink_attributes_beolink_attributes["beolink"]["peers"][peer.friendly_name] = (
477  peer.jid
478  )
479 
480  # Add Beolink listeners / leader
481  self._remote_leader_remote_leader = self._playback_metadata_playback_metadata.remote_leader
482 
483  # Create group members list
484  group_members = []
485 
486  # If the device is a listener.
487  if self._remote_leader_remote_leader is not None:
488  # Add leader if available in Home Assistant
489  leader = self._get_entity_id_from_jid_get_entity_id_from_jid(self._remote_leader_remote_leader.jid)
490  group_members.append(
491  leader
492  if leader is not None
493  else f"leader_not_in_hass-{self._remote_leader.friendly_name}"
494  )
495 
496  # Add self
497  group_members.append(self.entity_identity_id)
498 
499  self._beolink_attributes_beolink_attributes["beolink"]["leader"] = {
500  self._remote_leader_remote_leader.friendly_name: self._remote_leader_remote_leader.jid,
501  }
502 
503  # If not listener, check if leader.
504  else:
505  beolink_listeners = await self._client_client.get_beolink_listeners()
506  beolink_listeners_attribute = {}
507 
508  # Check if the device is a leader.
509  if len(beolink_listeners) > 0:
510  # Add self
511  group_members.append(self.entity_identity_id)
512 
513  # Get the entity_ids of the listeners if available in Home Assistant
514  group_members.extend(
515  [
516  listener
517  if (
518  listener := self._get_entity_id_from_jid_get_entity_id_from_jid(
519  beolink_listener.jid
520  )
521  )
522  is not None
523  else f"listener_not_in_hass-{beolink_listener.jid}"
524  for beolink_listener in beolink_listeners
525  ]
526  )
527  # Update Beolink attributes
528  for beolink_listener in beolink_listeners:
529  for peer in peers:
530  if peer.jid == beolink_listener.jid:
531  # Get the friendly names for the listeners from the peers
532  beolink_listeners_attribute[peer.friendly_name] = (
533  beolink_listener.jid
534  )
535  break
536  self._beolink_attributes_beolink_attributes["beolink"]["listeners"] = (
537  beolink_listeners_attribute
538  )
539 
540  self._attr_group_members_attr_group_members = group_members
541 
542  self.async_write_ha_stateasync_write_ha_state()
543 
544  def _get_entity_id_from_jid(self, jid: str) -> str | None:
545  """Get entity_id from Beolink JID (if available)."""
546 
547  unique_id = get_serial_number_from_jid(jid)
548 
549  entity_registry = er.async_get(self.hasshass)
550  return entity_registry.async_get_entity_id(
551  Platform.MEDIA_PLAYER, DOMAIN, unique_id
552  )
553 
554  def _get_beolink_jid(self, entity_id: str) -> str:
555  """Get beolink JID from entity_id."""
556 
557  entity_registry = er.async_get(self.hasshass)
558 
559  # Check for valid bang_olufsen media_player entity
560  entity_entry = entity_registry.async_get(entity_id)
561 
562  if (
563  entity_entry is None
564  or entity_entry.domain != Platform.MEDIA_PLAYER
565  or entity_entry.platform != DOMAIN
566  or entity_entry.config_entry_id is None
567  ):
569  translation_domain=DOMAIN,
570  translation_key="invalid_grouping_entity",
571  translation_placeholders={"entity_id": entity_id},
572  )
573 
574  config_entry = self.hasshass.config_entries.async_get_entry(
575  entity_entry.config_entry_id
576  )
577  if TYPE_CHECKING:
578  assert config_entry
579 
580  # Return JID
581  return cast(str, config_entry.data[CONF_BEOLINK_JID])
582 
584  self, active_sound_mode: ListeningModeProps | ListeningModeRef | None = None
585  ) -> None:
586  """Update the available sound modes."""
587  sound_modes = await self._client_client.get_listening_mode_set()
588 
589  if active_sound_mode is None:
590  active_sound_mode = await self._client_client.get_active_listening_mode()
591 
592  # Add the key to make the labels unique (As labels are not required to be unique on B&O devices)
593  for sound_mode in sound_modes:
594  label = f"{sound_mode.name} ({sound_mode.id})"
595 
596  self._sound_modes[label] = sound_mode.id
597 
598  if sound_mode.id == active_sound_mode.id:
599  self._attr_sound_mode_attr_sound_mode = label
600 
601  # Set available options
602  self._attr_sound_mode_list_attr_sound_mode_list = list(self._sound_modes)
603 
604  self.async_write_ha_stateasync_write_ha_state()
605 
606  @property
607  def supported_features(self) -> MediaPlayerEntityFeature:
608  """Flag media player features that are supported."""
609  features = BANG_OLUFSEN_FEATURES
610 
611  # Add seeking if supported by the current source
612  if self._source_change_source_change.is_seekable is True:
613  features |= MediaPlayerEntityFeature.SEEK
614 
615  return features
616 
617  @property
618  def state(self) -> MediaPlayerState:
619  """Return the current state of the media player."""
620  return BANG_OLUFSEN_STATES[self._state_state]
621 
622  @property
623  def volume_level(self) -> float | None:
624  """Volume level of the media player (0..1)."""
625  if self._volume_volume.level and self._volume_volume.level.level is not None:
626  return float(self._volume_volume.level.level / 100)
627  return None
628 
629  @property
630  def is_volume_muted(self) -> bool | None:
631  """Boolean if volume is currently muted."""
632  if self._volume_volume.muted and self._volume_volume.muted.muted:
633  return self._volume_volume.muted.muted
634  return None
635 
636  @property
637  def media_content_type(self) -> str:
638  """Return the current media type."""
639  # Hard to determine content type
640  if self._source_change_source_change.id == BangOlufsenSource.URI_STREAMER.id:
641  return MediaType.URL
642  return MediaType.MUSIC
643 
644  @property
645  def media_duration(self) -> int | None:
646  """Return the total duration of the current track in seconds."""
647  return self._playback_metadata_playback_metadata.total_duration_seconds
648 
649  @property
650  def media_position(self) -> int | None:
651  """Return the current playback progress."""
652  return self._playback_progress_playback_progress.progress
653 
654  @property
655  def media_image_url(self) -> str | None:
656  """Return URL of the currently playing music."""
657  return self._media_image_media_image.url
658 
659  @property
661  """Return whether or not the image of the current media is available outside the local network."""
662  return not self._media_image_media_image.has_local_image
663 
664  @property
665  def media_title(self) -> str | None:
666  """Return the currently playing title."""
667  return self._playback_metadata_playback_metadata.title
668 
669  @property
670  def media_album_name(self) -> str | None:
671  """Return the currently playing album name."""
672  return self._playback_metadata_playback_metadata.album_name
673 
674  @property
675  def media_album_artist(self) -> str | None:
676  """Return the currently playing artist name."""
677  return self._playback_metadata_playback_metadata.artist_name
678 
679  @property
680  def media_track(self) -> int | None:
681  """Return the currently playing track."""
682  return self._playback_metadata_playback_metadata.track
683 
684  @property
685  def media_channel(self) -> str | None:
686  """Return the currently playing channel."""
687  return self._playback_metadata_playback_metadata.organization
688 
689  @property
690  def source(self) -> str | None:
691  """Return the current audio source."""
692  return self._source_change_source_change.name
693 
694  @property
695  def extra_state_attributes(self) -> dict[str, Any] | None:
696  """Return information that is not returned anywhere else."""
697  attributes: dict[str, Any] = {}
698 
699  # Add Beolink attributes
700  if self._beolink_attributes_beolink_attributes:
701  attributes.update(self._beolink_attributes_beolink_attributes)
702 
703  return attributes
704 
705  async def async_turn_off(self) -> None:
706  """Set the device to "networkStandby"."""
707  await self._client_client.post_standby()
708 
709  async def async_set_volume_level(self, volume: float) -> None:
710  """Set volume level, range 0..1."""
711  await self._client_client.set_current_volume_level(
712  volume_level=VolumeLevel(level=int(volume * 100))
713  )
714 
715  async def async_mute_volume(self, mute: bool) -> None:
716  """Mute or unmute media player."""
717  await self._client_client.set_volume_mute(volume_mute=VolumeMute(muted=mute))
718 
719  async def async_media_play_pause(self) -> None:
720  """Toggle play/pause media player."""
721  if self.statestatestatestatestatestate == MediaPlayerState.PLAYING:
722  await self.async_media_pauseasync_media_pauseasync_media_pause()
723  elif self.statestatestatestatestatestate in (MediaPlayerState.PAUSED, MediaPlayerState.IDLE):
724  await self.async_media_playasync_media_playasync_media_play()
725 
726  async def async_media_pause(self) -> None:
727  """Pause media player."""
728  await self._client_client.post_playback_command(command="pause")
729 
730  async def async_media_play(self) -> None:
731  """Play media player."""
732  await self._client_client.post_playback_command(command="play")
733 
734  async def async_media_stop(self) -> None:
735  """Pause media player."""
736  await self._client_client.post_playback_command(command="stop")
737 
738  async def async_media_next_track(self) -> None:
739  """Send the next track command."""
740  await self._client_client.post_playback_command(command="skip")
741 
742  async def async_media_seek(self, position: float) -> None:
743  """Seek to position in ms."""
744  await self._client_client.seek_to_position(position_ms=int(position * 1000))
745  # Try to prevent the playback progress from bouncing in the UI.
746  self._attr_media_position_updated_at_attr_media_position_updated_at = utcnow()
747  self._playback_progress_playback_progress = PlaybackProgress(progress=int(position))
748 
749  self.async_write_ha_stateasync_write_ha_state()
750 
751  async def async_media_previous_track(self) -> None:
752  """Send the previous track command."""
753  await self._client_client.post_playback_command(command="prev")
754 
755  async def async_clear_playlist(self) -> None:
756  """Clear the current playback queue."""
757  await self._client_client.post_clear_queue()
758 
759  async def async_set_repeat(self, repeat: RepeatMode) -> None:
760  """Set playback queues to repeat."""
761  await self._client_client.set_settings_queue(
762  play_queue_settings=PlayQueueSettings(
763  repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
764  )
765  )
766 
767  async def async_set_shuffle(self, shuffle: bool) -> None:
768  """Set playback queues to shuffle."""
769  await self._client_client.set_settings_queue(
770  play_queue_settings=PlayQueueSettings(shuffle=shuffle),
771  )
772 
773  async def async_select_source(self, source: str) -> None:
774  """Select an input source."""
775  if source not in self._sources_sources.values():
777  translation_domain=DOMAIN,
778  translation_key="invalid_source",
779  translation_placeholders={
780  "invalid_source": source,
781  "valid_sources": ",".join(list(self._sources_sources.values())),
782  },
783  )
784 
785  key = [x for x in self._sources_sources if self._sources_sources[x] == source][0]
786 
787  # Check for source type
788  if source in self._audio_sources_audio_sources.values():
789  # Audio
790  await self._client_client.set_active_source(source_id=key)
791  else:
792  # Video
793  await self._client_client.post_remote_trigger(id=key)
794 
795  async def async_select_sound_mode(self, sound_mode: str) -> None:
796  """Select a sound mode."""
797  # Ensure only known sound modes known by the integration can be activated.
798  if sound_mode not in self._sound_modes:
800  translation_domain=DOMAIN,
801  translation_key="invalid_sound_mode",
802  translation_placeholders={
803  "invalid_sound_mode": sound_mode,
804  "valid_sound_modes": ", ".join(list(self._sound_modes)),
805  },
806  )
807 
808  await self._client_client.activate_listening_mode(id=self._sound_modes[sound_mode])
809 
810  async def async_play_media(
811  self,
812  media_type: MediaType | str,
813  media_id: str,
814  announce: bool | None = None,
815  **kwargs: Any,
816  ) -> None:
817  """Play from: netradio station id, URI, favourite or Deezer."""
818  # Convert audio/mpeg, audio/aac etc. to MediaType.MUSIC
819  if media_type.startswith("audio/"):
820  media_type = MediaType.MUSIC
821 
822  if media_type not in VALID_MEDIA_TYPES:
824  translation_domain=DOMAIN,
825  translation_key="invalid_media_type",
826  translation_placeholders={
827  "invalid_media_type": media_type,
828  "valid_media_types": ",".join(VALID_MEDIA_TYPES),
829  },
830  )
831 
832  if media_source.is_media_source_id(media_id):
833  sourced_media = await media_source.async_resolve_media(
834  self.hasshass, media_id, self.entity_identity_id
835  )
836 
837  media_id = async_process_play_media_url(self.hasshass, sourced_media.url)
838 
839  # Exit if the source uses unsupported file.
840  if media_id.endswith(".m3u"):
841  raise HomeAssistantError(
842  translation_domain=DOMAIN, translation_key="m3u_invalid_format"
843  )
844 
845  if announce:
846  extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
847 
848  absolute_volume = extra.get("overlay_absolute_volume", None)
849  offset_volume = extra.get("overlay_offset_volume", None)
850  tts_language = extra.get("overlay_tts_language", "en-us")
851 
852  # Construct request
853  overlay_play_request = OverlayPlayRequest()
854 
855  # Define volume level
856  if absolute_volume:
857  overlay_play_request.volume_absolute = absolute_volume
858 
859  elif offset_volume:
860  # Ensure that the volume is not above 100
861  if not self._volume_volume.level or not self._volume_volume.level.level:
862  _LOGGER.warning("Error setting volume")
863  else:
864  overlay_play_request.volume_absolute = min(
865  self._volume_volume.level.level + offset_volume, 100
866  )
867 
868  if media_type == BangOlufsenMediaType.OVERLAY_TTS:
869  # Bang & Olufsen cloud TTS
870  overlay_play_request.text_to_speech = (
871  OverlayPlayRequestTextToSpeechTextToSpeech(
872  lang=tts_language, text=media_id
873  )
874  )
875  else:
876  overlay_play_request.uri = Uri(location=media_id)
877 
878  await self._client_client.post_overlay_play(overlay_play_request)
879 
880  elif media_type in (MediaType.URL, MediaType.MUSIC):
881  await self._client_client.post_uri_source(uri=Uri(location=media_id))
882 
883  # The "provider" media_type may not be suitable for overlay all the time.
884  # Use it for now.
885  elif media_type == BangOlufsenMediaType.TTS:
886  await self._client_client.post_overlay_play(
887  overlay_play_request=OverlayPlayRequest(
888  uri=Uri(location=media_id),
889  )
890  )
891 
892  elif media_type == BangOlufsenMediaType.RADIO:
893  await self._client_client.run_provided_scene(
894  scene_properties=SceneProperties(
895  action_list=[
896  Action(
897  type="radio",
898  radio_station_id=media_id,
899  )
900  ]
901  )
902  )
903 
904  elif media_type == BangOlufsenMediaType.FAVOURITE:
905  await self._client_client.activate_preset(id=int(media_id))
906 
907  elif media_type in (BangOlufsenMediaType.DEEZER, BangOlufsenMediaType.TIDAL):
908  try:
909  # Play Deezer flow.
910  if media_id == "flow" and media_type == BangOlufsenMediaType.DEEZER:
911  deezer_id = None
912 
913  if "id" in kwargs[ATTR_MEDIA_EXTRA]:
914  deezer_id = kwargs[ATTR_MEDIA_EXTRA]["id"]
915 
916  await self._client_client.start_deezer_flow(
917  user_flow=UserFlow(user_id=deezer_id)
918  )
919 
920  # Play a playlist or album.
921  elif any(match in media_id for match in ("playlist", "album")):
922  start_from = 0
923  if "start_from" in kwargs[ATTR_MEDIA_EXTRA]:
924  start_from = kwargs[ATTR_MEDIA_EXTRA]["start_from"]
925 
926  await self._client_client.add_to_queue(
927  play_queue_item=PlayQueueItem(
928  provider=PlayQueueItemType(value=media_type),
929  start_now_from_position=start_from,
930  type="playlist",
931  uri=media_id,
932  )
933  )
934 
935  # Play a track.
936  else:
937  await self._client_client.add_to_queue(
938  play_queue_item=PlayQueueItem(
939  provider=PlayQueueItemType(value=media_type),
940  start_now_from_position=0,
941  type="track",
942  uri=media_id,
943  )
944  )
945 
946  except ApiException as error:
947  raise HomeAssistantError(
948  translation_domain=DOMAIN,
949  translation_key="play_media_error",
950  translation_placeholders={
951  "media_type": media_type,
952  "error_message": json.loads(error.body)["message"],
953  },
954  ) from error
955 
957  self,
958  media_content_type: MediaType | str | None = None,
959  media_content_id: str | None = None,
960  ) -> BrowseMedia:
961  """Implement the WebSocket media browsing helper."""
962  return await media_source.async_browse_media(
963  self.hasshass,
964  media_content_id,
965  content_filter=lambda item: item.media_content_type.startswith("audio/"),
966  )
967 
968  async def async_join_players(self, group_members: list[str]) -> None:
969  """Create a Beolink session with defined group members."""
970 
971  # Use the touch to join if no entities have been defined
972  # Touch to join will make the device connect to any other currently-playing
973  # Beolink compatible B&O device.
974  # Repeated presses / calls will cycle between compatible playing devices.
975  if len(group_members) == 0:
976  await self.async_beolink_joinasync_beolink_join()
977  return
978 
979  # Get JID for each group member
980  jids = [self._get_beolink_jid_get_beolink_jid(group_member) for group_member in group_members]
981  await self.async_beolink_expandasync_beolink_expand(jids)
982 
983  async def async_unjoin_player(self) -> None:
984  """Unjoin Beolink session. End session if leader."""
985  await self.async_beolink_leaveasync_beolink_leave()
986 
987  # Custom actions:
988  async def async_beolink_join(self, beolink_jid: str | None = None) -> None:
989  """Join a Beolink multi-room experience."""
990  if beolink_jid is None:
991  await self._client_client.join_latest_beolink_experience()
992  else:
993  await self._client_client.join_beolink_peer(jid=beolink_jid)
994 
996  self, beolink_jids: list[str] | None = None, all_discovered: bool = False
997  ) -> None:
998  """Expand a Beolink multi-room experience with a device or devices."""
999 
1000  # Ensure that the current source is expandable
1001  if not self._beolink_sources_beolink_sources[cast(str, self._source_change_source_change.id)]:
1002  raise ServiceValidationError(
1003  translation_domain=DOMAIN,
1004  translation_key="invalid_source",
1005  translation_placeholders={
1006  "invalid_source": cast(str, self._source_change_source_change.id),
1007  "valid_sources": ", ".join(list(self._beolink_sources_beolink_sources)),
1008  },
1009  )
1010 
1011  # Expand to all discovered devices
1012  if all_discovered:
1013  peers = await self._client_client.get_beolink_peers()
1014 
1015  for peer in peers:
1016  try:
1017  await self._client_client.post_beolink_expand(jid=peer.jid)
1018  except NotFoundException:
1019  _LOGGER.warning("Unable to expand to %s", peer.jid)
1020 
1021  # Try to expand to all defined devices
1022  elif beolink_jids:
1023  for beolink_jid in beolink_jids:
1024  try:
1025  await self._client_client.post_beolink_expand(jid=beolink_jid)
1026  except NotFoundException:
1027  _LOGGER.warning(
1028  "Unable to expand to %s. Is the device available on the network?",
1029  beolink_jid,
1030  )
1031 
1032  async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None:
1033  """Unexpand a Beolink multi-room experience with a device or devices."""
1034  # Unexpand all defined devices
1035  for beolink_jid in beolink_jids:
1036  await self._client_client.post_beolink_unexpand(jid=beolink_jid)
1037 
1038  async def async_beolink_leave(self) -> None:
1039  """Leave the current Beolink experience."""
1040  await self._client_client.post_beolink_leave()
1041 
1042  async def async_beolink_allstandby(self) -> None:
1043  """Set all connected Beolink devices to standby."""
1044  await self._client_client.post_beolink_allstandby()
None _async_update_connection_state(self, bool connection_state)
Definition: entity.py:67
None async_beolink_expand(self, list[str]|None beolink_jids=None, bool all_discovered=False)
None async_play_media(self, MediaType|str media_type, str media_id, bool|None announce=None, **Any kwargs)
None _async_update_playback_metadata_and_beolink(self, PlaybackContentMetadata data)
None _async_update_sound_modes(self, ListeningModeProps|ListeningModeRef|None active_sound_mode=None)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None __init__(self, ConfigEntry entry, MozartClient client)
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
DeviceInfo|None device_info(self)
Definition: entity.py:798
None async_setup_entry(HomeAssistant hass, BangOlufsenConfigEntry config_entry, AddEntitiesCallback async_add_entities)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
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
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103