Skip to content

Commit

Permalink
Add Bang & Olufsen media_player grouping (#123020)
Browse files Browse the repository at this point in the history
* Add Beolink custom services
Add support for media player grouping via beolink
Give media player entity name

* Fix progress not being set to None as Beolink listener
Revert naming changes

* Update API
simplify Beolink attributes

* Improve beolink custom services

* Fix Beolink expandable source check
Add unexpand return value
Set entity name on initialization

* Handle entity naming as intended

* Fix "null" Beolink self friendly name

* Add regex service input validation
Add all_discovered to beolink_expand service
Improve beolink_expand response

* Add service icons

* Fix merge
Remove unnecessary assignment

* Remove invalid typing
Update response typing for updated API

* Revert to old typed response dict method
Remove mypy ignore line
Fix jid possibly used before assignment

* Re add debugging logging

* Fix coroutine
Fix formatting

* Remove unnecessary update control

* Make tests pass
Fix remote leader media position bug
Improve remote leader BangOlufsenSource comparison

* Fix naming and add callback decorators

* Move regex service check to variable
Suppress KeyError
Update tests

* Re-add hass running check

* Improve comments, naming and type hinting

* Remove old temporary fix

* Convert logged warning to raised exception for invalid media_player
Simplify code using walrus operator

* Fix test for invalid media_player grouping

* Improve method naming

* Improve _beolink_sources explanation

* Improve _beolink_sources explanation

* Add initial media_player grouping

* Convert custom service methods to media_player methods
Fix testing

* Remove beolink JID extra state attribute

* Modify custom services to only work as expected for media_player grouping
Fix tests

* Remove unused dispatch

* Remove wrong comment

* Remove commented out code

* Add config entry mock typing

* Fix beolink listener playback progress
Fix formatting
Add and use get_serial_number_from_jid function

* Fix testing

* Clarify beolink WebSocket notifications

* Further clarify beolink WebSocket notifications

* Convert notification value to enum value

* Improve comments for touch to join

* Fix None being cast to str if leader is not in HA

* Add error messages to devices in Beolink session and not Home Assistant
Rework _get_beolink_jid

* Replace redundant function call

* Show friendly name for unavailable remote leader instead of JID

* Update homeassistant/components/bang_olufsen/media_player.py

Co-authored-by: Erik Montnemery <[email protected]>

* Remove unneeded typing

* Rework _get_beolink_jid entity check
Clarify invalid entity error message

* Remove redundant "entity" from string

* Fix invalid typing
fix state assertions

* Fix raised error type

---------

Co-authored-by: Erik Montnemery <[email protected]>
  • Loading branch information
mj23000 and emontnemery authored Sep 16, 2024
1 parent e8bacd8 commit a8648b7
Show file tree
Hide file tree
Showing 9 changed files with 457 additions and 14 deletions.
3 changes: 2 additions & 1 deletion homeassistant/components/bang_olufsen/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
DEFAULT_MODEL,
DOMAIN,
)
from .util import get_serial_number_from_jid


class EntryData(TypedDict, total=False):
Expand Down Expand Up @@ -107,7 +108,7 @@ async def async_step_user(
)

self._beolink_jid = beolink_self.jid
self._serial_number = beolink_self.jid.split(".")[2].split("@")[0]
self._serial_number = get_serial_number_from_jid(beolink_self.jid)

await self.async_set_unique_id(self._serial_number)
self._abort_if_unique_id_configured()
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/bang_olufsen/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ class WebsocketNotification(StrEnum):
VOLUME = "volume"

# Sub-notifications
BEOLINK = "beolink"
BEOLINK_PEERS = "beolinkPeers"
BEOLINK_LISTENERS = "beolinkListeners"
BEOLINK_AVAILABLE_LISTENERS = "beolinkAvailableListeners"
CONFIGURATION = "configuration"
NOTIFICATION = "notification"
REMOTE_MENU_CHANGED = "remoteMenuChanged"

Expand Down
179 changes: 169 additions & 10 deletions homeassistant/components/bang_olufsen/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
from collections.abc import Callable
import json
import logging
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast

from mozart_api import __version__ as MOZART_API_VERSION
from mozart_api.exceptions import ApiException
from mozart_api.models import (
Action,
Art,
BeolinkLeader,
OverlayPlayRequest,
OverlayPlayRequestTextToSpeechTextToSpeech,
PlaybackContentMetadata,
Expand Down Expand Up @@ -44,9 +45,10 @@
async_process_play_media_url,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL
from homeassistant.const import CONF_MODEL, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
Expand All @@ -66,12 +68,14 @@
WebsocketNotification,
)
from .entity import BangOlufsenEntity
from .util import get_serial_number_from_jid

_LOGGER = logging.getLogger(__name__)

BANG_OLUFSEN_FEATURES = (
MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PAUSE
Expand Down Expand Up @@ -134,14 +138,19 @@ def __init__(self, entry: ConfigEntry, client: MozartClient) -> None:
self._state: str = MediaPlayerState.IDLE
self._video_sources: dict[str, str] = {}

# Beolink compatible sources
self._beolink_sources: dict[str, bool] = {}
self._remote_leader: BeolinkLeader | None = None

async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
await self._initialize()

signal_handlers: dict[str, Callable] = {
CONNECTION_STATUS: self._async_update_connection_state,
WebsocketNotification.BEOLINK: self._async_update_beolink,
WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata,
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink,
WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress,
WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state,
WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources,
Expand Down Expand Up @@ -183,6 +192,7 @@ async def _initialize(self) -> None:
if product_state.playback:
if product_state.playback.metadata:
self._playback_metadata = product_state.playback.metadata
self._remote_leader = product_state.playback.metadata.remote_leader
if product_state.playback.progress:
self._playback_progress = product_state.playback.progress
if product_state.playback.source:
Expand All @@ -201,9 +211,6 @@ async def _initialize(self) -> None:
# If the device has been updated with new sources, then the API will fail here.
await self._async_update_sources()

# Set the static entity attributes that needed more information.
self._attr_source_list = list(self._sources.values())

async def _async_update_sources(self) -> None:
"""Get sources for the specific product."""

Expand Down Expand Up @@ -237,6 +244,21 @@ async def _async_update_sources(self) -> None:
and source.id not in HIDDEN_SOURCE_IDS
}

# Some sources are not Beolink expandable, meaning that they can't be joined by
# or expand to other Bang & Olufsen devices for a multi-room experience.
# _source_change, which is used throughout the entity for current source
# information, lacks this information, so source ID's and their expandability is
# stored in the self._beolink_sources variable.
self._beolink_sources = {
source.id: (
source.is_multiroom_available
if source.is_multiroom_available is not None
else False
)
for source in cast(list[Source], sources.items)
if source.id
}

# Video sources from remote menu
menu_items = await self._client.get_remote_menu()

Expand All @@ -260,19 +282,22 @@ async def _async_update_sources(self) -> None:
# Combine the source dicts
self._sources = self._audio_sources | self._video_sources

self._attr_source_list = list(self._sources.values())

# HASS won't necessarily be running the first time this method is run
if self.hass.is_running:
self.async_write_ha_state()

@callback
def _async_update_playback_metadata(self, data: PlaybackContentMetadata) -> None:
async def _async_update_playback_metadata_and_beolink(
self, data: PlaybackContentMetadata
) -> None:
"""Update _playback_metadata and related."""
self._playback_metadata = data

# Update current artwork.
# Update current artwork and remote_leader.
self._media_image = get_highest_resolution_artwork(self._playback_metadata)

self.async_write_ha_state()
await self._async_update_beolink()

@callback
def _async_update_playback_error(self, data: PlaybackError) -> None:
Expand Down Expand Up @@ -319,6 +344,96 @@ def _async_update_volume(self, data: VolumeState) -> None:

self.async_write_ha_state()

@callback
async def _async_update_beolink(self) -> None:
"""Update the current Beolink leader, listeners, peers and self."""

# Add Beolink listeners / leader
self._remote_leader = self._playback_metadata.remote_leader

# Create group members list
group_members = []

# If the device is a listener.
if self._remote_leader is not None:
# Add leader if available in Home Assistant
leader = self._get_entity_id_from_jid(self._remote_leader.jid)
group_members.append(
leader
if leader is not None
else f"leader_not_in_hass-{self._remote_leader.friendly_name}"
)

# Add self
group_members.append(self.entity_id)

# If not listener, check if leader.
else:
beolink_listeners = await self._client.get_beolink_listeners()

# Check if the device is a leader.
if len(beolink_listeners) > 0:
# Add self
group_members.append(self.entity_id)

# Get the entity_ids of the listeners if available in Home Assistant
group_members.extend(
[
listener
if (
listener := self._get_entity_id_from_jid(
beolink_listener.jid
)
)
is not None
else f"listener_not_in_hass-{beolink_listener.jid}"
for beolink_listener in beolink_listeners
]
)

self._attr_group_members = group_members

self.async_write_ha_state()

def _get_entity_id_from_jid(self, jid: str) -> str | None:
"""Get entity_id from Beolink JID (if available)."""

unique_id = get_serial_number_from_jid(jid)

entity_registry = er.async_get(self.hass)
return entity_registry.async_get_entity_id(
Platform.MEDIA_PLAYER, DOMAIN, unique_id
)

def _get_beolink_jid(self, entity_id: str) -> str:
"""Get beolink JID from entity_id."""

entity_registry = er.async_get(self.hass)

# Check for valid bang_olufsen media_player entity
entity_entry = entity_registry.async_get(entity_id)

if (
entity_entry is None
or entity_entry.domain != Platform.MEDIA_PLAYER
or entity_entry.platform != DOMAIN
or entity_entry.config_entry_id is None
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_grouping_entity",
translation_placeholders={"entity_id": entity_id},
)

config_entry = self.hass.config_entries.async_get_entry(
entity_entry.config_entry_id
)
if TYPE_CHECKING:
assert config_entry

# Return JID
return cast(str, config_entry.data[CONF_BEOLINK_JID])

@property
def state(self) -> MediaPlayerState:
"""Return the current state of the media player."""
Expand Down Expand Up @@ -664,3 +779,47 @@ async def async_browse_media(
media_content_id,
content_filter=lambda item: item.media_content_type.startswith("audio/"),
)

async def async_join_players(self, group_members: list[str]) -> None:
"""Create a Beolink session with defined group members."""

# Use the touch to join if no entities have been defined
# Touch to join will make the device connect to any other currently-playing
# Beolink compatible B&O device.
# Repeated presses / calls will cycle between compatible playing devices.
if len(group_members) == 0:
await self._async_beolink_join()
return

# Get JID for each group member
jids = [self._get_beolink_jid(group_member) for group_member in group_members]
await self._async_beolink_expand(jids)

async def async_unjoin_player(self) -> None:
"""Unjoin Beolink session. End session if leader."""
await self._async_beolink_leave()

async def _async_beolink_join(self) -> None:
"""Join a Beolink multi-room experience."""
await self._client.join_latest_beolink_experience()

async def _async_beolink_expand(self, beolink_jids: list[str]) -> None:
"""Expand a Beolink multi-room experience with a device or devices."""
# Ensure that the current source is expandable
if not self._beolink_sources[cast(str, self._source_change.id)]:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_source",
translation_placeholders={
"invalid_source": cast(str, self._source_change.id),
"valid_sources": ", ".join(list(self._beolink_sources.keys())),
},
)

# Try to expand to all defined devices
for beolink_jid in beolink_jids:
await self._client.post_beolink_expand(jid=beolink_jid)

async def _async_beolink_leave(self) -> None:
"""Leave the current Beolink experience."""
await self._client.post_beolink_leave()
3 changes: 3 additions & 0 deletions homeassistant/components/bang_olufsen/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
},
"play_media_error": {
"message": "An error occurred while attempting to play {media_type}: {error_message}."
},
"invalid_grouping_entity": {
"message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?"
}
}
}
5 changes: 5 additions & 0 deletions homeassistant/components/bang_olufsen/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
assert device

return device


def get_serial_number_from_jid(jid: str) -> str:
"""Get serial number from Beolink JID."""
return jid.split(".")[2].split("@")[0]
11 changes: 10 additions & 1 deletion homeassistant/components/bang_olufsen/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,16 @@ def on_notification_notification(
# Try to match the notification type with available WebsocketNotification members
notification_type = try_parse_enum(WebsocketNotification, notification.value)

if notification_type is WebsocketNotification.REMOTE_MENU_CHANGED:
if notification_type in (
WebsocketNotification.BEOLINK_PEERS,
WebsocketNotification.BEOLINK_LISTENERS,
WebsocketNotification.BEOLINK_AVAILABLE_LISTENERS,
):
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.BEOLINK}",
)
elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED:
async_dispatcher_send(
self.hass,
f"{self._unique_id}_{WebsocketNotification.REMOTE_MENU_CHANGED}",
Expand Down
Loading

0 comments on commit a8648b7

Please sign in to comment.