1 """Media player entity for the Bang & Olufsen integration."""
3 from __future__
import annotations
5 from collections.abc
import Callable
7 from datetime
import timedelta
10 from typing
import TYPE_CHECKING, Any, cast
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 (
22 OverlayPlayRequestTextToSpeechTextToSpeech,
23 PlaybackContentMetadata,
40 from mozart_api.mozart_client
import MozartClient, get_highest_resolution_artwork
41 import voluptuous
as vol
47 MediaPlayerDeviceClass,
49 MediaPlayerEntityFeature,
53 async_process_play_media_url,
60 config_validation
as cv,
61 device_registry
as dr,
62 entity_registry
as er,
68 async_get_current_platform,
72 from .
import BangOlufsenConfigEntry
74 BANG_OLUFSEN_REPEAT_FROM_HA,
75 BANG_OLUFSEN_REPEAT_TO_HA,
84 WebsocketNotification,
86 from .entity
import BangOlufsenEntity
87 from .util
import get_serial_number_from_jid
93 _LOGGER = logging.getLogger(__name__)
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
118 config_entry: BangOlufsenConfigEntry,
119 async_add_entities: AddEntitiesCallback,
121 """Set up a Media Player entity from config entry."""
132 jid_regex = vol.Match(
133 r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$"
136 platform.async_register_entity_service(
138 schema={vol.Optional(
"beolink_jid"): jid_regex},
139 func=
"async_beolink_join",
142 platform.async_register_entity_service(
143 name=
"beolink_expand",
145 vol.Exclusive(
"all_discovered",
"devices",
""): cv.boolean,
149 "Define either specific Beolink JIDs or all discovered",
155 func=
"async_beolink_expand",
158 platform.async_register_entity_service(
159 name=
"beolink_unexpand",
161 vol.Required(
"beolink_jids"): vol.All(
166 func=
"async_beolink_unexpand",
169 platform.async_register_entity_service(
170 name=
"beolink_leave",
172 func=
"async_beolink_leave",
175 platform.async_register_entity_service(
176 name=
"beolink_allstandby",
178 func=
"async_beolink_allstandby",
183 """Representation of a media player."""
186 _attr_device_class = MediaPlayerDeviceClass.SPEAKER
188 def __init__(self, entry: ConfigEntry, client: MozartClient) ->
None:
189 """Initialize the media player."""
192 self._beolink_jid: str = self.entry.data[CONF_BEOLINK_JID]
193 self._model: str = self.entry.data[CONF_MODEL]
196 configuration_url=f
"http://{self._host}/#/",
197 identifiers={(DOMAIN, self._unique_id)},
198 manufacturer=
"Bang & Olufsen",
200 serial_number=self._unique_id,
208 self.
_software_status_software_status: SoftwareUpdateStatus = SoftwareUpdateStatus(
210 state=SoftwareUpdateState(seconds_remaining=0, value=
"idle"),
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] = {}
224 """Turn on the dispatchers."""
227 signal_handlers: dict[str, Callable] = {
242 for signal, signal_handler
in signal_handlers.items():
246 f
"{self._unique_id}_{signal}",
252 """Initialize connection dependent variables."""
258 "Connected to: %s %s running SW %s",
265 product_state = await self.
_client_client.get_product_state()
268 if product_state.volume:
273 if product_state.playback:
274 if product_state.playback.metadata:
276 self.
_remote_leader_remote_leader = product_state.playback.metadata.remote_leader
277 if product_state.playback.progress:
279 if product_state.playback.source:
281 if product_state.playback.state:
301 """Update queue settings."""
304 with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
305 queue_settings = await self.
_client_client.get_settings_queue(_request_timeout=5)
307 if queue_settings.repeat
is not None:
308 self.
_attr_repeat_attr_repeat = BANG_OLUFSEN_REPEAT_TO_HA[queue_settings.repeat]
310 if queue_settings.shuffle
is not None:
314 """Get sources for the specific product."""
319 sources = await self.
_client_client.get_available_sources(target_remote=
False)
330 "The API is outdated compared to the device software version %s and %s. Using fallback sources",
334 sources = FALLBACK_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
350 source.is_multiroom_available
351 if source.is_multiroom_available
is not None
354 for source
in cast(list[Source], sources.items)
359 menu_items = await self.
_client_client.get_remote_menu()
361 for key
in menu_items:
362 menu_item = menu_items[key]
364 if not menu_item.available:
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
374 and menu_item.label !=
"TV"
376 self._video_sources[key] = menu_item.label
384 if self.
hasshass.is_running:
388 self, data: PlaybackContentMetadata
390 """Update _playback_metadata and related."""
399 """Show playback error."""
404 """Update _playback_progress and last update."""
412 """Update _playback_state and related."""
423 """Update _source_change and related."""
428 BangOlufsenSource.LINE_IN.id,
429 BangOlufsenSource.SPDIF.id,
437 """Update _volume."""
443 """Update the device friendly name."""
444 beolink_self = await self.
_client_client.get_beolink_self()
447 device_registry = dr.async_get(self.
hasshass)
450 device_registry.async_update_device(
452 name=beolink_self.friendly_name,
458 """Update the current Beolink leader, listeners, peers and self."""
467 "beolink": {
"self": {self.
device_entrydevice_entry.name: self._beolink_jid}}
471 peers = await self.
_client_client.get_beolink_peers()
490 group_members.append(
492 if leader
is not None
493 else f
"leader_not_in_hass-{self._remote_leader.friendly_name}"
497 group_members.append(self.
entity_identity_id)
505 beolink_listeners = await self.
_client_client.get_beolink_listeners()
506 beolink_listeners_attribute = {}
509 if len(beolink_listeners) > 0:
511 group_members.append(self.
entity_identity_id)
514 group_members.extend(
523 else f
"listener_not_in_hass-{beolink_listener.jid}"
524 for beolink_listener
in beolink_listeners
528 for beolink_listener
in beolink_listeners:
530 if peer.jid == beolink_listener.jid:
532 beolink_listeners_attribute[peer.friendly_name] = (
537 beolink_listeners_attribute
545 """Get entity_id from Beolink JID (if available)."""
549 entity_registry = er.async_get(self.
hasshass)
550 return entity_registry.async_get_entity_id(
551 Platform.MEDIA_PLAYER, DOMAIN, unique_id
555 """Get beolink JID from entity_id."""
557 entity_registry = er.async_get(self.
hasshass)
560 entity_entry = entity_registry.async_get(entity_id)
564 or entity_entry.domain != Platform.MEDIA_PLAYER
565 or entity_entry.platform != DOMAIN
566 or entity_entry.config_entry_id
is None
569 translation_domain=DOMAIN,
570 translation_key=
"invalid_grouping_entity",
571 translation_placeholders={
"entity_id": entity_id},
574 config_entry = self.
hasshass.config_entries.async_get_entry(
575 entity_entry.config_entry_id
581 return cast(str, config_entry.data[CONF_BEOLINK_JID])
584 self, active_sound_mode: ListeningModeProps | ListeningModeRef |
None =
None
586 """Update the available sound modes."""
587 sound_modes = await self.
_client_client.get_listening_mode_set()
589 if active_sound_mode
is None:
590 active_sound_mode = await self.
_client_client.get_active_listening_mode()
593 for sound_mode
in sound_modes:
594 label = f
"{sound_mode.name} ({sound_mode.id})"
596 self._sound_modes[label] = sound_mode.id
598 if sound_mode.id == active_sound_mode.id:
608 """Flag media player features that are supported."""
609 features = BANG_OLUFSEN_FEATURES
613 features |= MediaPlayerEntityFeature.SEEK
618 def state(self) -> MediaPlayerState:
619 """Return the current state of the media player."""
620 return BANG_OLUFSEN_STATES[self.
_state_state]
624 """Volume level of the media player (0..1)."""
625 if self.
_volume_volume.level
and self.
_volume_volume.level.level
is not None:
631 """Boolean if volume is currently muted."""
633 return self.
_volume_volume.muted.muted
638 """Return the current media type."""
640 if self.
_source_change_source_change.id == BangOlufsenSource.URI_STREAMER.id:
642 return MediaType.MUSIC
646 """Return the total duration of the current track in seconds."""
651 """Return the current playback progress."""
656 """Return URL of the currently playing music."""
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
666 """Return the currently playing title."""
671 """Return the currently playing album name."""
676 """Return the currently playing artist name."""
681 """Return the currently playing track."""
686 """Return the currently playing channel."""
691 """Return the current audio source."""
696 """Return information that is not returned anywhere else."""
697 attributes: dict[str, Any] = {}
706 """Set the device to "networkStandby"."""
707 await self.
_client_client.post_standby()
710 """Set volume level, range 0..1."""
711 await self.
_client_client.set_current_volume_level(
712 volume_level=VolumeLevel(level=
int(volume * 100))
716 """Mute or unmute media player."""
717 await self.
_client_client.set_volume_mute(volume_mute=VolumeMute(muted=mute))
720 """Toggle play/pause media player."""
727 """Pause media player."""
728 await self.
_client_client.post_playback_command(command=
"pause")
731 """Play media player."""
732 await self.
_client_client.post_playback_command(command=
"play")
735 """Pause media player."""
736 await self.
_client_client.post_playback_command(command=
"stop")
739 """Send the next track command."""
740 await self.
_client_client.post_playback_command(command=
"skip")
743 """Seek to position in ms."""
744 await self.
_client_client.seek_to_position(position_ms=
int(position * 1000))
752 """Send the previous track command."""
753 await self.
_client_client.post_playback_command(command=
"prev")
756 """Clear the current playback queue."""
757 await self.
_client_client.post_clear_queue()
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]
768 """Set playback queues to shuffle."""
769 await self.
_client_client.set_settings_queue(
770 play_queue_settings=PlayQueueSettings(shuffle=shuffle),
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())),
785 key = [x
for x
in self.
_sources_sources
if self.
_sources_sources[x] == source][0]
790 await self.
_client_client.set_active_source(source_id=key)
793 await self.
_client_client.post_remote_trigger(id=key)
796 """Select a sound mode."""
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)),
808 await self.
_client_client.activate_listening_mode(id=self._sound_modes[sound_mode])
812 media_type: MediaType | str,
814 announce: bool |
None =
None,
817 """Play from: netradio station id, URI, favourite or Deezer."""
819 if media_type.startswith(
"audio/"):
820 media_type = MediaType.MUSIC
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),
832 if media_source.is_media_source_id(media_id):
833 sourced_media = await media_source.async_resolve_media(
840 if media_id.endswith(
".m3u"):
842 translation_domain=DOMAIN, translation_key=
"m3u_invalid_format"
846 extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
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")
853 overlay_play_request = OverlayPlayRequest()
857 overlay_play_request.volume_absolute = absolute_volume
861 if not self.
_volume_volume.level
or not self.
_volume_volume.level.level:
862 _LOGGER.warning(
"Error setting volume")
864 overlay_play_request.volume_absolute =
min(
865 self.
_volume_volume.level.level + offset_volume, 100
868 if media_type == BangOlufsenMediaType.OVERLAY_TTS:
870 overlay_play_request.text_to_speech = (
871 OverlayPlayRequestTextToSpeechTextToSpeech(
872 lang=tts_language, text=media_id
876 overlay_play_request.uri = Uri(location=media_id)
878 await self.
_client_client.post_overlay_play(overlay_play_request)
880 elif media_type
in (MediaType.URL, MediaType.MUSIC):
881 await self.
_client_client.post_uri_source(uri=Uri(location=media_id))
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),
892 elif media_type == BangOlufsenMediaType.RADIO:
893 await self.
_client_client.run_provided_scene(
894 scene_properties=SceneProperties(
898 radio_station_id=media_id,
904 elif media_type == BangOlufsenMediaType.FAVOURITE:
905 await self.
_client_client.activate_preset(id=
int(media_id))
907 elif media_type
in (BangOlufsenMediaType.DEEZER, BangOlufsenMediaType.TIDAL):
910 if media_id ==
"flow" and media_type == BangOlufsenMediaType.DEEZER:
913 if "id" in kwargs[ATTR_MEDIA_EXTRA]:
914 deezer_id = kwargs[ATTR_MEDIA_EXTRA][
"id"]
916 await self.
_client_client.start_deezer_flow(
917 user_flow=UserFlow(user_id=deezer_id)
921 elif any(match
in media_id
for match
in (
"playlist",
"album")):
923 if "start_from" in kwargs[ATTR_MEDIA_EXTRA]:
924 start_from = kwargs[ATTR_MEDIA_EXTRA][
"start_from"]
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,
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,
946 except ApiException
as error:
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"],
958 media_content_type: MediaType | str |
None =
None,
959 media_content_id: str |
None =
None,
961 """Implement the WebSocket media browsing helper."""
962 return await media_source.async_browse_media(
965 content_filter=
lambda item: item.media_content_type.startswith(
"audio/"),
969 """Create a Beolink session with defined group members."""
975 if len(group_members) == 0:
980 jids = [self.
_get_beolink_jid_get_beolink_jid(group_member)
for group_member
in group_members]
984 """Unjoin Beolink session. End session if leader."""
989 """Join a Beolink multi-room experience."""
990 if beolink_jid
is None:
991 await self.
_client_client.join_latest_beolink_experience()
993 await self.
_client_client.join_beolink_peer(jid=beolink_jid)
996 self, beolink_jids: list[str] |
None =
None, all_discovered: bool =
False
998 """Expand a Beolink multi-room experience with a device or devices."""
1003 translation_domain=DOMAIN,
1004 translation_key=
"invalid_source",
1005 translation_placeholders={
1006 "invalid_source": cast(str, self.
_source_change_source_change.id),
1013 peers = await self.
_client_client.get_beolink_peers()
1017 await self.
_client_client.post_beolink_expand(jid=peer.jid)
1018 except NotFoundException:
1019 _LOGGER.warning(
"Unable to expand to %s", peer.jid)
1023 for beolink_jid
in beolink_jids:
1025 await self.
_client_client.post_beolink_expand(jid=beolink_jid)
1026 except NotFoundException:
1028 "Unable to expand to %s. Is the device available on the network?",
1033 """Unexpand a Beolink multi-room experience with a device or devices."""
1035 for beolink_jid
in beolink_jids:
1036 await self.
_client_client.post_beolink_unexpand(jid=beolink_jid)
1039 """Leave the current Beolink experience."""
1040 await self.
_client_client.post_beolink_leave()
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)
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
DeviceInfo|None device_info(self)
str get_serial_number_from_jid(str jid)
web.Response get(self, web.Request request, str config_key)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)