From b19f5fb60858d4cfdbbca90d39e214a0d494c121 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Fri, 9 Feb 2024 18:15:21 +0100 Subject: [PATCH 01/34] Add Beolink custom services Add support for media player grouping via beolink Give media player entity name --- .../components/bang_olufsen/__init__.py | 1 + .../components/bang_olufsen/config_flow.py | 1 + .../components/bang_olufsen/const.py | 5 + .../components/bang_olufsen/media_player.py | 394 +++++++++++++++++- .../components/bang_olufsen/services.yaml | 65 +++ .../components/bang_olufsen/strings.json | 40 ++ .../components/bang_olufsen/websocket.py | 21 +- 7 files changed, 503 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/bang_olufsen/services.yaml diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index 3071b8fc6b239..2488c2e64f5d7 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -1,4 +1,5 @@ """The Bang & Olufsen integration.""" + from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index 6a26c4c598428..8c22bc2ca7a5b 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -1,4 +1,5 @@ """Config flow for the Bang & Olufsen integration.""" + from __future__ import annotations from ipaddress import AddressValueError, IPv4Address diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 3a6638fe31a6f..9f158fbea2eab 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -85,6 +85,11 @@ class WEBSOCKET_NOTIFICATION(StrEnum): VOLUME: Final[str] = "volume" # Sub-notifications + BEOLINK: Final[str] = "beolink" + BEOLINK_LISTENERS: Final[str] = "beolinkListeners" + BEOLINK_AVAILABLE_LISTENERS: Final[str] = "beolinkAvailableListeners" + BEOLINK_PEERS: Final[str] = "beolinkPeers" + CONFIGURATION: Final[str] = "configuration" NOTIFICATION: Final[str] = "notification" REMOTE_MENU_CHANGED: Final[str] = "remoteMenuChanged" diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 869cabc5a4a13..5119ac5178546 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -1,6 +1,8 @@ """Media player entity for the Bang & Olufsen integration.""" + from __future__ import annotations +import asyncio import json import logging from typing import Any, cast @@ -10,6 +12,7 @@ from mozart_api.models import ( Action, Art, + BeolinkLeader, OverlayPlayRequest, PlaybackContentMetadata, PlaybackError, @@ -27,7 +30,12 @@ VolumeMute, VolumeState, ) -from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork +from mozart_api.mozart_client import ( + MozartClient, + check_valid_jid, + get_highest_resolution_artwork, +) +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -41,11 +49,16 @@ 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 +from homeassistant.helpers import config_validation as cv, 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 +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) +from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.util.dt import utcnow from . import BangOlufsenData @@ -66,19 +79,20 @@ _LOGGER = logging.getLogger(__name__) BANG_OLUFSEN_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.SEEK - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.PREVIOUS_TRACK + MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.CLEAR_PLAYLIST - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET ) @@ -93,11 +107,69 @@ async def async_setup_entry( # Add MediaPlayer entity async_add_entities(new_entities=[BangOlufsenMediaPlayer(config_entry, data.client)]) + # Register services. + platform = async_get_current_platform() + + platform.async_register_entity_service( + name="beolink_join", + schema={ + vol.Optional("beolink_jid"): vol.All( + vol.Coerce(type=cv.string), + vol.Length(min=47, max=47), + ), + }, + func="async_beolink_join", + ) + + platform.async_register_entity_service( + name="beolink_expand", + schema={ + vol.Required("beolink_jids"): vol.All( + cv.ensure_list, + [ + vol.All( + vol.Coerce(type=cv.string), + vol.Length(min=47, max=47), + ) + ], + ) + }, + func="async_beolink_expand", + ) + + platform.async_register_entity_service( + name="beolink_unexpand", + schema={ + vol.Required("beolink_jids"): vol.All( + cv.ensure_list, + [ + vol.All( + vol.Coerce(type=cv.string), + vol.Length(min=47, max=47), + ) + ], + ) + }, + func="async_beolink_unexpand", + ) + + platform.async_register_entity_service( + name="beolink_leave", + schema=None, + func="async_beolink_leave", + ) + + platform.async_register_entity_service( + name="beolink_allstandby", + schema=None, + func="async_beolink_allstandby", + ) + class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity): """Representation of a media player.""" - _attr_has_entity_name = False + _attr_name: str | None = None _attr_icon = "mdi:speaker-wireless" _attr_supported_features = BANG_OLUFSEN_FEATURES @@ -131,6 +203,14 @@ def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: self._state: str = MediaPlayerState.IDLE self._video_sources: dict[str, str] = {} + # Beolink + self._remote_leader: BeolinkLeader | None = None + + self._beolink_self: dict[str, dict[str, str]] | None = None + self._beolink_listeners: dict[str, dict[str, str]] | None = None + self._beolink_leader: dict[str, dict[str, str]] | None = None + self._beolink_peers: dict[str, dict[str, str]] | None = None + async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" await self._initialize() @@ -194,6 +274,34 @@ async def async_added_to_hass(self) -> None: self._update_volume, ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_LISTENERS}", + self._update_beolink_listeners, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_AVAILABLE_LISTENERS}", + self._update_beolink_listeners, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_PEERS}", + self._update_beolink_peers, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.CONFIGURATION}", + self._update_beolink_self_and_name, + ) + ) async def _initialize(self) -> None: """Initialize connection dependent variables.""" @@ -220,6 +328,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: @@ -238,10 +347,15 @@ async def _initialize(self) -> None: # If the device has been updated with new sources, then the API will fail here. await self._update_sources() - # Set the static entity attributes that needed more information. - self._attr_source_list = list(self._sources.values()) + # Update beolink attributes and set friendly name. + self._update_beolink_leader() + await self._update_beolink_self_and_name(update_ha_state=False) + await self._update_beolink_peers(update_ha_state=False) + await self._update_beolink_listeners(update_ha_state=False) - async def _update_sources(self) -> None: + self.async_write_ha_state() + + async def _update_sources(self, update_ha_state: bool = False) -> None: """Get sources for the specific product.""" # Audio sources @@ -297,31 +411,42 @@ async def _update_sources(self) -> None: # Combine the source dicts self._sources = self._audio_sources | self._video_sources - # HASS won't necessarily be running the first time this method is run - if self.hass.is_running: + self._attr_source_list = list(self._sources.values()) + + if update_ha_state: self.async_write_ha_state() async def _update_playback_metadata(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._remote_leader = self._playback_metadata.remote_leader + + # # Temp fix for mismatch in WebSocket metadata and "real" REST endpoint where the remote leader is not deleted. + if self._remote_leader and self.source in ( + SOURCE_ENUM.lineIn, + SOURCE_ENUM.uriStreamer, + ): + self._remote_leader = None + + self._update_beolink_leader() self.async_write_ha_state() - async def _update_playback_error(self, data: PlaybackError) -> None: + def _update_playback_error(self, data: PlaybackError) -> None: """Show playback error.""" _LOGGER.error(data.error) - async def _update_playback_progress(self, data: PlaybackProgress) -> None: + def _update_playback_progress(self, data: PlaybackProgress) -> None: """Update _playback_progress and last update.""" self._playback_progress = data self._attr_media_position_updated_at = utcnow() self.async_write_ha_state() - async def _update_playback_state(self, data: RenderingState) -> None: + def _update_playback_state(self, data: RenderingState) -> None: """Update _playback_state and related.""" self._playback_state = data @@ -331,7 +456,7 @@ async def _update_playback_state(self, data: RenderingState) -> None: self.async_write_ha_state() - async def _update_source_change(self, data: Source) -> None: + def _update_source_change(self, data: Source) -> None: """Update _source_change and related.""" self._source_change = data @@ -339,12 +464,131 @@ async def _update_source_change(self, data: Source) -> None: if self._source_change.id in (SOURCE_ENUM.lineIn, SOURCE_ENUM.spdif): self._playback_progress = PlaybackProgress(progress=0) - async def _update_volume(self, data: VolumeState) -> None: + self.async_write_ha_state() + + def _update_volume(self, data: VolumeState) -> None: """Update _volume.""" self._volume = data self.async_write_ha_state() + def _update_beolink_leader(self) -> None: + """Update the current Beolink leader.""" + self._beolink_leader = None + group_members = [] + + if self._remote_leader: + # Check and add leader if device is added in Home Assistant + if leader_entity_id := self._get_entity_id_from_jid( + self._remote_leader.jid + ): + group_members.append(leader_entity_id) + + # Add listener to Home Assistant group. + # Listeners are not aware of other listeners in the same session + # which means that only the leader may have a complete picture of the Home Assistant group + group_members.append( + cast(str, self._get_entity_id_from_jid(self._beolink_jid)) + ) + + self._beolink_leader = { + "leader": {self._remote_leader.friendly_name: self._remote_leader.jid} + } + + self._attr_group_members = group_members + + async def _update_beolink_self_and_name(self, update_ha_state: bool = True) -> None: + """Update the device friendly name and beolink self attribute.""" + beolink_self = await self._client.get_beolink_self() + self._attr_name = beolink_self.friendly_name + + # Add the current device's information + self._beolink_self = {"self": {cast(str, self.name): self._beolink_jid}} + + if update_ha_state: + self.async_write_ha_state() + + async def _update_beolink_peers(self, update_ha_state: bool = True) -> None: + """Update the Beolink peers.""" + peers = await self._client.get_beolink_peers() + self._beolink_peers = None + + # Add peers + if len(peers) > 0: + self._beolink_peers = {"peers": {}} + for peer in peers: + self._beolink_peers["peers"][peer.friendly_name] = peer.jid + + if update_ha_state: + self.async_write_ha_state() + + async def _update_beolink_listeners(self, update_ha_state: bool = True) -> None: + """Update the current Beolink listeners.""" + # Create a new session dict + self._beolink_listeners = None + group_members = [] + + beolink_listeners = await self._client.get_beolink_listeners() + + if len(beolink_listeners) > 0: + # Add self + group_members.append( + cast(str, self._get_entity_id_from_jid(self._beolink_jid)) + ) + + # Get the friendly names from listeners from the peers + peers = await self._client.get_beolink_peers() + + self._beolink_listeners = {"listeners": {}} + + for beolink_listener in beolink_listeners: + group_members.append( + cast(str, self._get_entity_id_from_jid(beolink_listener.jid)) + ) + for peer in peers: + if peer.jid == beolink_listener.jid: + self._beolink_listeners["listeners"][ + peer.friendly_name + ] = beolink_listener.jid + break + + self._attr_group_members = group_members + + if update_ha_state: + 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 = jid.split(".")[2].split("@")[0] + + entity_registry = er.async_get(self.hass) + entity_id = entity_registry.async_get_entity_id( + Platform.MEDIA_PLAYER, DOMAIN, unique_id + ) + + return entity_id + + def _get_beolink_jid(self, entity_id: str) -> str | None: + """Get beolink JID from entity_id.""" + entity_registry = er.async_get(self.hass) + + # Make mypy happy + entity_entry = cast(RegistryEntry, entity_registry.async_get(entity_id)) + config_entry = cast( + ConfigEntry, + self.hass.config_entries.async_get_entry( + cast(str, entity_entry.config_entry_id) + ), + ) + + try: + jid = cast(str, config_entry.data[CONF_BEOLINK_JID]) + except KeyError: + jid = None + + return jid + @property def state(self) -> MediaPlayerState: """Return the current state of the media player.""" @@ -380,6 +624,9 @@ def media_duration(self) -> int | None: @property def media_position(self) -> int | None: """Return the current playback progress.""" + # Don't show progress if the the device is a Beolink listener. + if self._remote_leader is None: + return self._playback_progress.progress return self._playback_progress.progress @property @@ -456,12 +703,37 @@ def source(self) -> str | None: return self._source_change.name + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return information that is not returned anywhere else.""" + attributes: dict[str, Any] = {"beolink": {}} + + for beolink_attribute in ( + self._beolink_self, + self._beolink_peers, + self._beolink_listeners, + self._beolink_leader, + ): + if beolink_attribute is not None: + attributes["beolink"].update(beolink_attribute) + + # Remote any keys without values + for key, value in attributes.items(): + if not value: + attributes.pop(key) + + if attributes: + return attributes + + return None + async def async_turn_off(self) -> None: """Set the device to "networkStandby".""" await self._client.post_standby() async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" + # _LOGGER.error(self._beolink_attribute) await self._client.set_current_volume_level( volume_level=VolumeLevel(level=int(volume * 100)) ) @@ -645,3 +917,79 @@ 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 + if len(group_members) == 0: + await self.async_beolink_join() + return + + jids = [] + # Get JID for each group member + for group_member in group_members: + jid = self._get_beolink_jid(group_member) + + # Invalid entity + if jid is None: + _LOGGER.warning("Error adding %s to group", group_member) + continue + + jids.append(jid) + + await self.async_beolink_expand(jids) + + async def async_unjoin_player(self) -> None: + """Unjoin Beolink session. End session if leader.""" + await self._client.post_beolink_leave() + + # Custom services: + async def async_beolink_join(self, beolink_jid: str | None = None) -> None: + """Join a Beolink multi-room experience.""" + if beolink_jid is None: + await self._client.join_latest_beolink_experience() + else: + if not check_valid_jid(beolink_jid): + return + + await self._client.join_beolink_peer(jid=beolink_jid) + + async def async_beolink_expand(self, beolink_jids: list[str]) -> None: + """Expand a Beolink multi-room experience with a device or devices.""" + # Check if the Beolink JIDs are valid. + for beolink_jid in beolink_jids: + if not check_valid_jid(beolink_jid): + _LOGGER.error("Invalid Beolink JID: %s", beolink_jid) + return + + self.hass.async_create_task(self._beolink_expand(beolink_jids)) + + async def _beolink_expand(self, beolink_jids: list[str]) -> None: + """Expand the Beolink experience with a non blocking delay.""" + for beolink_jid in beolink_jids: + await self._client.post_beolink_expand(jid=beolink_jid) + # await asyncio.sleep(1) + + async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None: + """Unexpand a Beolink multi-room experience with a device or devices.""" + # Check if the Beolink JIDs are valid. + for beolink_jid in beolink_jids: + if not check_valid_jid(beolink_jid): + return + + self.hass.async_create_task(self._beolink_unexpand(beolink_jids)) + + async def _beolink_unexpand(self, beolink_jids: list[str]) -> None: + """Unexpand the Beolink experience with a non blocking delay.""" + for beolink_jid in beolink_jids: + await self._client.post_beolink_unexpand(jid=beolink_jid) + await asyncio.sleep(1) + + async def async_beolink_leave(self) -> None: + """Leave the current Beolink experience.""" + await self._client.post_beolink_leave() + + async def async_beolink_allstandby(self) -> None: + """Set all connected Beolink devices to standby.""" + await self._client.post_beolink_allstandby() diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml new file mode 100644 index 0000000000000..5bc5e6c236698 --- /dev/null +++ b/homeassistant/components/bang_olufsen/services.yaml @@ -0,0 +1,65 @@ +beolink_join: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + beolink_jid: + required: false + example: 1111.2222222.33333333@products.bang-olufsen.com + selector: + text: + +beolink_expand: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + beolink_jids: + required: true + example: >- + [ + 1111.2222222.33333333@products.bang-olufsen.com, + 4444.5555555.66666666@products.bang-olufsen.com + ] + selector: + object: + +beolink_unexpand: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + beolink_jids: + required: true + example: >- + [ + 1111.2222222.33333333@products.bang-olufsen.com, + 4444.5555555.66666666@products.bang-olufsen.com + ] + selector: + object: + +beolink_leave: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + +beolink_allstandby: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 3cebfb891bc04..99593aa75537e 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -24,5 +24,45 @@ "description": "Confirm the configuration of the {model}-{serial_number} @ {host}." } } + }, + "services": { + "beolink_join": { + "name": "Beolink join", + "description": "Join a Beolink experience.", + "fields": { + "beolink_jid": { + "name": "Beolink JID", + "description": "Manually specify Beolink JID to join." + } + } + }, + "beolink_expand": { + "name": "Beolink expand", + "description": "Expand current Beolink experience.", + "fields": { + "beolink_jids": { + "name": "Beolink JIDs", + "description": "Specify which Beolink JIDs will join current Beolink experience." + } + } + }, + "beolink_unexpand": { + "name": "Beolink unexpand", + "description": "Unexpand from current Beolink experience.", + "fields": { + "beolink_jids": { + "name": "Beolink JIDs", + "description": "Specify which Beolink JIDs will leave from current Beolink experience." + } + } + }, + "beolink_leave": { + "name": "Beolink leave", + "description": "Leave a Beolink experience." + }, + "beolink_allstandby": { + "name": "Beolink allstandby", + "description": "Set all Connected Beolink devices to standby." + } } } diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index fd378a40bd3f9..6b3373f75bf4f 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -92,8 +92,27 @@ def on_notification_notification( self, notification: WebsocketNotificationTag ) -> None: """Send notification dispatch.""" + if notification.value: - if WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED in notification.value: + if notification.value in ( + WEBSOCKET_NOTIFICATION.BEOLINK_LISTENERS.value, + WEBSOCKET_NOTIFICATION.BEOLINK_AVAILABLE_LISTENERS.value, + ): + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_LISTENERS}", + ) + elif notification.value is WEBSOCKET_NOTIFICATION.BEOLINK_PEERS.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_PEERS}", + ) + elif notification.value is WEBSOCKET_NOTIFICATION.CONFIGURATION.value: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.CONFIGURATION}", + ) + elif notification.value is WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED.value: async_dispatcher_send( self.hass, f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.REMOTE_MENU_CHANGED}", From bbc062e6908bc5d223c47c5707b81c8674c477e5 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Mon, 26 Feb 2024 17:02:25 +0100 Subject: [PATCH 02/34] Fix progress not being set to None as Beolink listener Revert naming changes --- homeassistant/components/bang_olufsen/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 57302dbb64137..16c50a5e34c3b 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -169,7 +169,7 @@ async def async_setup_entry( class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity): """Representation of a media player.""" - _attr_name: str | None = None + _attr_has_entity_name = False _attr_icon = "mdi:speaker-wireless" _attr_supported_features = BANG_OLUFSEN_FEATURES @@ -626,7 +626,7 @@ def media_position(self) -> int | None: """Return the current playback progress.""" # Don't show progress if the the device is a Beolink listener. if self._remote_leader is None: - return self._playback_progress.progress + return None return self._playback_progress.progress @property From 87cd8f5f99bc76cd5e2ddb157512bd42fb814c5e Mon Sep 17 00:00:00 2001 From: mj23000 Date: Mon, 11 Mar 2024 17:08:33 +0100 Subject: [PATCH 03/34] Update API simplify Beolink attributes --- .../components/bang_olufsen/__init__.py | 19 +- .../components/bang_olufsen/const.py | 3 - .../components/bang_olufsen/manifest.json | 2 +- .../components/bang_olufsen/media_player.py | 210 +++++++----------- .../components/bang_olufsen/websocket.py | 13 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 96 insertions(+), 155 deletions(-) diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index 2488c2e64f5d7..09f074e072284 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -4,8 +4,6 @@ from dataclasses import dataclass -from aiohttp.client_exceptions import ClientConnectorError -from mozart_api.exceptions import ApiException from mozart_api.mozart_client import MozartClient from homeassistant.config_entries import ConfigEntry @@ -44,14 +42,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=entry.data[CONF_MODEL], ) - client = MozartClient(host=entry.data[CONF_HOST], websocket_reconnect=True) + client = MozartClient(host=entry.data[CONF_HOST]) - # Check connection and try to initialize it. - try: - await client.get_battery_state(_request_timeout=3) - except (ApiException, ClientConnectorError, TimeoutError) as error: + # Check API and WebSocket connection + if not await client.check_device_connection(): await client.close_api_client() - raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error + raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") websocket = BangOlufsenWebsocket(hass, entry, client) @@ -61,11 +57,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client, ) - # Check and start WebSocket connection - if not await client.connect_notifications(remote_control=True): - raise ConfigEntryNotReady( - f"Unable to connect to {entry.title} WebSocket notification channel" - ) + # Start WebSocket connection + await client.connect_notifications(remote_control=True, reconnect=True) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 9f158fbea2eab..08d956f8fef3b 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -86,9 +86,6 @@ class WEBSOCKET_NOTIFICATION(StrEnum): # Sub-notifications BEOLINK: Final[str] = "beolink" - BEOLINK_LISTENERS: Final[str] = "beolinkListeners" - BEOLINK_AVAILABLE_LISTENERS: Final[str] = "beolinkAvailableListeners" - BEOLINK_PEERS: Final[str] = "beolinkPeers" CONFIGURATION: Final[str] = "configuration" NOTIFICATION: Final[str] = "notification" REMOTE_MENU_CHANGED: Final[str] = "remoteMenuChanged" diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json index 3c920a99d7f0c..4878623106f16 100644 --- a/homeassistant/components/bang_olufsen/manifest.json +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mozart-api==3.2.1.150.6"], + "requirements": ["mozart-api==3.4.1.8.0"], "zeroconf": ["_bangolufsen._tcp.local."] } diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 16c50a5e34c3b..343b1e7fafedc 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -58,7 +58,6 @@ AddEntitiesCallback, async_get_current_platform, ) -from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.util.dt import utcnow from . import BangOlufsenData @@ -205,11 +204,7 @@ def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: # Beolink self._remote_leader: BeolinkLeader | None = None - - self._beolink_self: dict[str, dict[str, str]] | None = None - self._beolink_listeners: dict[str, dict[str, str]] | None = None - self._beolink_leader: dict[str, dict[str, str]] | None = None - self._beolink_peers: dict[str, dict[str, str]] | None = None + self._beolink_attribute: dict[str, dict] = {} async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" @@ -277,29 +272,15 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_LISTENERS}", - self._update_beolink_listeners, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_AVAILABLE_LISTENERS}", - self._update_beolink_listeners, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_PEERS}", - self._update_beolink_peers, + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK}", + self._update_beolink, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.CONFIGURATION}", - self._update_beolink_self_and_name, + self._update_name, ) ) @@ -347,11 +328,8 @@ async def _initialize(self) -> None: # If the device has been updated with new sources, then the API will fail here. await self._update_sources() - # Update beolink attributes and set friendly name. - self._update_beolink_leader() - await self._update_beolink_self_and_name(update_ha_state=False) - await self._update_beolink_peers(update_ha_state=False) - await self._update_beolink_listeners(update_ha_state=False) + # Update beolink attributes. + await self._update_beolink(should_update=False) self.async_write_ha_state() @@ -422,16 +400,7 @@ async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None # Update current artwork and remote_leader. self._media_image = get_highest_resolution_artwork(self._playback_metadata) - self._remote_leader = self._playback_metadata.remote_leader - - # # Temp fix for mismatch in WebSocket metadata and "real" REST endpoint where the remote leader is not deleted. - if self._remote_leader and self.source in ( - SOURCE_ENUM.lineIn, - SOURCE_ENUM.uriStreamer, - ): - self._remote_leader = None - - self._update_beolink_leader() + await self._update_beolink(should_update=False) self.async_write_ha_state() @@ -472,89 +441,94 @@ def _update_volume(self, data: VolumeState) -> None: self.async_write_ha_state() - def _update_beolink_leader(self) -> None: - """Update the current Beolink leader.""" - self._beolink_leader = None - group_members = [] + async def _update_name(self) -> None: + """Update the device friendly name.""" + beolink_self = await self._client.get_beolink_self() + self._attr_name = beolink_self.friendly_name - if self._remote_leader: - # Check and add leader if device is added in Home Assistant - if leader_entity_id := self._get_entity_id_from_jid( - self._remote_leader.jid - ): - group_members.append(leader_entity_id) + await self._update_beolink(should_update=False) - # Add listener to Home Assistant group. - # Listeners are not aware of other listeners in the same session - # which means that only the leader may have a complete picture of the Home Assistant group - group_members.append( - cast(str, self._get_entity_id_from_jid(self._beolink_jid)) - ) - - self._beolink_leader = { - "leader": {self._remote_leader.friendly_name: self._remote_leader.jid} - } + self.async_write_ha_state() - self._attr_group_members = group_members + async def _update_beolink(self, should_update: bool = True) -> None: + """Update the current Beolink leader, listeners, peers and self.""" - async def _update_beolink_self_and_name(self, update_ha_state: bool = True) -> None: - """Update the device friendly name and beolink self attribute.""" - beolink_self = await self._client.get_beolink_self() - self._attr_name = beolink_self.friendly_name + self._beolink_attribute = {} - # Add the current device's information - self._beolink_self = {"self": {cast(str, self.name): self._beolink_jid}} + # Add Beolink self + self._beolink_attribute = {"beolink": {"self": {self.name: self._beolink_jid}}} - if update_ha_state: - self.async_write_ha_state() - - async def _update_beolink_peers(self, update_ha_state: bool = True) -> None: - """Update the Beolink peers.""" + # Add Beolink peers peers = await self._client.get_beolink_peers() - self._beolink_peers = None - # Add peers if len(peers) > 0: - self._beolink_peers = {"peers": {}} + self._beolink_attribute["beolink"]["peers"] = {} for peer in peers: - self._beolink_peers["peers"][peer.friendly_name] = peer.jid + self._beolink_attribute["beolink"]["peers"][ + peer.friendly_name + ] = peer.jid - if update_ha_state: - self.async_write_ha_state() + self._remote_leader = self._playback_metadata.remote_leader - async def _update_beolink_listeners(self, update_ha_state: bool = True) -> None: - """Update the current Beolink listeners.""" - # Create a new session dict - self._beolink_listeners = None + # Temp fix for mismatch in WebSocket metadata and "real" REST endpoint where the remote leader is not deleted. + if self.source in ( + SOURCE_ENUM.lineIn, + SOURCE_ENUM.uriStreamer, + ): + self._remote_leader = None + + # Add Beolink listeners / leader + + # Create group members list group_members = [] - beolink_listeners = await self._client.get_beolink_listeners() + # If the device is a listener. + if self._remote_leader is not None: + # Add leader + group_members.append( + cast(str, self._get_entity_id_from_jid(self._remote_leader.jid)) + ) - if len(beolink_listeners) > 0: # Add self group_members.append( cast(str, self._get_entity_id_from_jid(self._beolink_jid)) ) - # Get the friendly names from listeners from the peers - peers = await self._client.get_beolink_peers() + self._beolink_attribute["beolink"]["leader"] = { + self._remote_leader.friendly_name: self._remote_leader.jid, + } - self._beolink_listeners = {"listeners": {}} + # If not listener, check if leader. + else: + beolink_listeners = await self._client.get_beolink_listeners() - for beolink_listener in beolink_listeners: + # Check if the device is a leader. + if len(beolink_listeners) > 0: + # Add self group_members.append( - cast(str, self._get_entity_id_from_jid(beolink_listener.jid)) + cast(str, self._get_entity_id_from_jid(self._beolink_jid)) ) - for peer in peers: - if peer.jid == beolink_listener.jid: - self._beolink_listeners["listeners"][ - peer.friendly_name - ] = beolink_listener.jid - break + + # Get the friendly names for the listeners from the peers + beolink_listeners_attribute = {} + for beolink_listener in beolink_listeners: + group_members.append( + cast(str, self._get_entity_id_from_jid(beolink_listener.jid)) + ) + for peer in peers: + if peer.jid == beolink_listener.jid: + beolink_listeners_attribute[ + peer.friendly_name + ] = beolink_listener.jid + break + + self._beolink_attribute["beolink"][ + "listeners" + ] = beolink_listeners_attribute self._attr_group_members = group_members - if update_ha_state: + if should_update: self.async_write_ha_state() def _get_entity_id_from_jid(self, jid: str) -> str | None: @@ -573,19 +547,19 @@ def _get_beolink_jid(self, entity_id: str) -> str | None: """Get beolink JID from entity_id.""" entity_registry = er.async_get(self.hass) - # Make mypy happy - entity_entry = cast(RegistryEntry, entity_registry.async_get(entity_id)) - config_entry = cast( - ConfigEntry, - self.hass.config_entries.async_get_entry( - cast(str, entity_entry.config_entry_id) - ), - ) + entity_entry = entity_registry.async_get(entity_id) + if entity_entry: + config_entry = cast( + ConfigEntry, + self.hass.config_entries.async_get_entry( + cast(str, entity_entry.config_entry_id) + ), + ) - try: - jid = cast(str, config_entry.data[CONF_BEOLINK_JID]) - except KeyError: - jid = None + try: + jid = cast(str, config_entry.data[CONF_BEOLINK_JID]) + except KeyError: + jid = None return jid @@ -706,26 +680,13 @@ def source(self) -> str | None: @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return information that is not returned anywhere else.""" - attributes: dict[str, Any] = {"beolink": {}} + attributes: dict[str, Any] = {} - for beolink_attribute in ( - self._beolink_self, - self._beolink_peers, - self._beolink_listeners, - self._beolink_leader, - ): - if beolink_attribute is not None: - attributes["beolink"].update(beolink_attribute) + # Add Beolink attributes + if self._beolink_attribute: + attributes.update(self._beolink_attribute) - # Remote any keys without values - for key, value in attributes.items(): - if not value: - attributes.pop(key) - - if attributes: - return attributes - - return None + return attributes async def async_turn_off(self) -> None: """Set the device to "networkStandby".""" @@ -733,7 +694,6 @@ async def async_turn_off(self) -> None: async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - # _LOGGER.error(self._beolink_attribute) await self._client.set_current_volume_level( volume_level=VolumeLevel(level=int(volume * 100)) ) diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 6b3373f75bf4f..0061d27db75d1 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -94,18 +94,10 @@ def on_notification_notification( """Send notification dispatch.""" if notification.value: - if notification.value in ( - WEBSOCKET_NOTIFICATION.BEOLINK_LISTENERS.value, - WEBSOCKET_NOTIFICATION.BEOLINK_AVAILABLE_LISTENERS.value, - ): + if WEBSOCKET_NOTIFICATION.BEOLINK.value in notification.value: async_dispatcher_send( self.hass, - f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_LISTENERS}", - ) - elif notification.value is WEBSOCKET_NOTIFICATION.BEOLINK_PEERS.value: - async_dispatcher_send( - self.hass, - f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK_PEERS}", + f"{self._unique_id}_{WEBSOCKET_NOTIFICATION.BEOLINK}", ) elif notification.value is WEBSOCKET_NOTIFICATION.CONFIGURATION.value: async_dispatcher_send( @@ -197,5 +189,4 @@ def on_all_notifications_raw(self, notification: dict) -> None: notification["device_id"] = self._device.id notification["serial_number"] = int(self._unique_id) - _LOGGER.debug("%s", notification) self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, notification) diff --git a/requirements_all.txt b/requirements_all.txt index 5e30dabff845c..acb4e9ad95cdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1328,7 +1328,7 @@ motionblinds==0.6.23 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==3.2.1.150.6 +mozart-api==3.4.1.8.0 # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f7aaf1ab5538..c070a7770ef71 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1067,7 +1067,7 @@ motionblinds==0.6.23 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==3.2.1.150.6 +mozart-api==3.4.1.8.0 # homeassistant.components.mullvad mullvad-api==1.0.0 From beba8f45c696fd86dcaf5bc74ecdef71e188bf74 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 13 Mar 2024 18:56:59 +0100 Subject: [PATCH 04/34] Improve beolink custom services --- .../components/bang_olufsen/media_player.py | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 343b1e7fafedc..57c944b8c1a30 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import json import logging from typing import Any, cast @@ -50,7 +49,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -118,6 +117,7 @@ async def async_setup_entry( ), }, func="async_beolink_join", + supports_response=SupportsResponse.OPTIONAL, ) platform.async_register_entity_service( @@ -134,6 +134,7 @@ async def async_setup_entry( ) }, func="async_beolink_expand", + supports_response=SupportsResponse.OPTIONAL, ) platform.async_register_entity_service( @@ -901,49 +902,48 @@ async def async_join_players(self, group_members: list[str]) -> None: async def async_unjoin_player(self) -> None: """Unjoin Beolink session. End session if leader.""" - await self._client.post_beolink_leave() + await self.async_beolink_leave() # Custom services: - async def async_beolink_join(self, beolink_jid: str | None = None) -> None: + async def async_beolink_join( + self, beolink_jid: str | None = None + ) -> None | ServiceResponse: """Join a Beolink multi-room experience.""" if beolink_jid is None: - await self._client.join_latest_beolink_experience() + response = await self._client.join_latest_beolink_experience() else: if not check_valid_jid(beolink_jid): - return + return {"invalid_jid": beolink_jid} + response = await self._client.join_beolink_peer(jid=beolink_jid) - await self._client.join_beolink_peer(jid=beolink_jid) + return response.dict() - async def async_beolink_expand(self, beolink_jids: list[str]) -> None: + async def async_beolink_expand(self, beolink_jids: list[str]) -> ServiceResponse: """Expand a Beolink multi-room experience with a device or devices.""" - # Check if the Beolink JIDs are valid. - for beolink_jid in beolink_jids: - if not check_valid_jid(beolink_jid): - _LOGGER.error("Invalid Beolink JID: %s", beolink_jid) - return + response: dict[str, Any] = {"invalid_jid": []} - self.hass.async_create_task(self._beolink_expand(beolink_jids)) + # Ensure that the current source is expandable + if not self._source_change.is_multiroom_available: + return {"invalid_source": self.source} - async def _beolink_expand(self, beolink_jids: list[str]) -> None: - """Expand the Beolink experience with a non blocking delay.""" for beolink_jid in beolink_jids: + if not check_valid_jid(beolink_jid): + response["invalid_jid"].append(beolink_jid) + continue await self._client.post_beolink_expand(jid=beolink_jid) - # await asyncio.sleep(1) + + if len(response["invalid_jid"]) > 0: + return response + + return None async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None: """Unexpand a Beolink multi-room experience with a device or devices.""" - # Check if the Beolink JIDs are valid. for beolink_jid in beolink_jids: if not check_valid_jid(beolink_jid): - return - - self.hass.async_create_task(self._beolink_unexpand(beolink_jids)) - - async def _beolink_unexpand(self, beolink_jids: list[str]) -> None: - """Unexpand the Beolink experience with a non blocking delay.""" - for beolink_jid in beolink_jids: + _LOGGER.error("Invalid Beolink JID: %s", beolink_jid) + continue await self._client.post_beolink_unexpand(jid=beolink_jid) - await asyncio.sleep(1) async def async_beolink_leave(self) -> None: """Leave the current Beolink experience.""" From be0d05ca19fd200ec5bc57624203aad6f6b3ec02 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Thu, 14 Mar 2024 18:03:53 +0100 Subject: [PATCH 05/34] Fix Beolink expandable source check Add unexpand return value Set entity name on initialization --- .../components/bang_olufsen/media_player.py | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 58cb07ed62914..188f94e20fad7 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -151,6 +151,7 @@ async def async_setup_entry( ) }, func="async_beolink_unexpand", + supports_response=SupportsResponse.OPTIONAL, ) platform.async_register_entity_service( @@ -202,6 +203,7 @@ def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: self._video_sources: dict[str, str] = {} # Beolink + self._beolink_sources: dict[str, bool] = {} self._remote_leader: BeolinkLeader | None = None self._beolink_attribute: dict[str, dict] = {} @@ -299,6 +301,10 @@ async def _initialize(self) -> None: # Get overall device state once. This is handled by WebSocket events the rest of the time. product_state = await self._client.get_product_state() + # Set the entity name to the device's friendly name + beolink_self = await self._client.get_beolink_self() + self._attr_name = beolink_self.friendly_name + # Get volume information. if product_state.volume: self._volume = product_state.volume @@ -365,6 +371,18 @@ async def _update_sources(self, update_ha_state: bool = False) -> None: and source.id not in HIDDEN_SOURCE_IDS } + # Some sources are not Beolink expandable. _source_change, which is used throughout the entity does not have this information. + # Save expandable sources for Beolink services + 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() @@ -908,7 +926,7 @@ async def async_unjoin_player(self) -> None: # Custom services: async def async_beolink_join( self, beolink_jid: str | None = None - ) -> None | ServiceResponse: + ) -> ServiceResponse: """Join a Beolink multi-room experience.""" if beolink_jid is None: response = await self._client.join_latest_beolink_experience() @@ -924,7 +942,7 @@ async def async_beolink_expand(self, beolink_jids: list[str]) -> ServiceResponse response: dict[str, Any] = {"invalid_jid": []} # Ensure that the current source is expandable - if not self._source_change.is_multiroom_available: + if not self._beolink_sources[cast(str, self._source_change.id)]: return {"invalid_source": self.source} for beolink_jid in beolink_jids: @@ -938,14 +956,21 @@ async def async_beolink_expand(self, beolink_jids: list[str]) -> ServiceResponse return None - async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None: + async def async_beolink_unexpand(self, beolink_jids: list[str]) -> ServiceResponse: """Unexpand a Beolink multi-room experience with a device or devices.""" + response: dict[str, Any] = {"invalid_jid": []} + for beolink_jid in beolink_jids: if not check_valid_jid(beolink_jid): - _LOGGER.error("Invalid Beolink JID: %s", beolink_jid) + response["invalid_jid"].append(beolink_jid) continue await self._client.post_beolink_unexpand(jid=beolink_jid) + if len(response["invalid_jid"]) > 0: + return response + + return None + async def async_beolink_leave(self) -> None: """Leave the current Beolink experience.""" await self._client.post_beolink_leave() From e6dbadaab98729b6e7c60f5232549b5ddc44e086 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Fri, 15 Mar 2024 16:26:54 +0100 Subject: [PATCH 06/34] Handle entity naming as intended --- .../components/bang_olufsen/media_player.py | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 188f94e20fad7..a52401abd0185 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -50,8 +50,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, Platform from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse -from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, @@ -281,7 +285,7 @@ async def async_added_to_hass(self) -> None: async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}", - self._update_name, + self._update_name_and_beolink, ) ) @@ -301,10 +305,6 @@ async def _initialize(self) -> None: # Get overall device state once. This is handled by WebSocket events the rest of the time. product_state = await self._client.get_product_state() - # Set the entity name to the device's friendly name - beolink_self = await self._client.get_beolink_self() - self._attr_name = beolink_self.friendly_name - # Get volume information. if product_state.volume: self._volume = product_state.volume @@ -333,10 +333,8 @@ async def _initialize(self) -> None: # If the device has been updated with new sources, then the API will fail here. await self._update_sources() - # Update beolink attributes. - await self._update_beolink(should_update=False) - - self.async_write_ha_state() + # Update beolink attributes and device name. + await self._update_name_and_beolink() async def _update_sources(self, update_ha_state: bool = False) -> None: """Get sources for the specific product.""" @@ -461,12 +459,18 @@ def _update_volume(self, data: VolumeState) -> None: self.async_write_ha_state() - async def _update_name(self) -> None: + async def _update_name_and_beolink(self) -> None: """Update the device friendly name.""" + await self._update_beolink(should_update=False) + beolink_self = await self._client.get_beolink_self() - self._attr_name = beolink_self.friendly_name - await self._update_beolink(should_update=False) + # Update device name + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( + device_id=cast(DeviceEntry, self.device_entry).id, + name=beolink_self.friendly_name, + ) self.async_write_ha_state() From 1c4413f7ab6b192f7a69adacf1b14b71831d270e Mon Sep 17 00:00:00 2001 From: mj23000 Date: Sat, 16 Mar 2024 15:38:06 +0100 Subject: [PATCH 07/34] Fix "null" Beolink self friendly name --- homeassistant/components/bang_olufsen/media_player.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index a52401abd0185..a671ec5eb70f8 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -461,8 +461,6 @@ def _update_volume(self, data: VolumeState) -> None: async def _update_name_and_beolink(self) -> None: """Update the device friendly name.""" - await self._update_beolink(should_update=False) - beolink_self = await self._client.get_beolink_self() # Update device name @@ -472,6 +470,8 @@ async def _update_name_and_beolink(self) -> None: name=beolink_self.friendly_name, ) + await self._update_beolink(should_update=False) + self.async_write_ha_state() async def _update_beolink(self, should_update: bool = True) -> None: @@ -480,7 +480,11 @@ async def _update_beolink(self, should_update: bool = True) -> None: self._beolink_attribute = {} # Add Beolink self - self._beolink_attribute = {"beolink": {"self": {self.name: self._beolink_jid}}} + assert self.device_entry + + self._beolink_attribute = { + "beolink": {"self": {self.device_entry.name: self._beolink_jid}} + } # Add Beolink peers peers = await self._client.get_beolink_peers() From 0b8526487e89f2a8cfe5707737b40bf958dc0d1b Mon Sep 17 00:00:00 2001 From: mj23000 Date: Thu, 4 Apr 2024 15:57:34 +0200 Subject: [PATCH 08/34] Add regex service input validation Add all_discovered to beolink_expand service Improve beolink_expand response --- .../components/bang_olufsen/media_player.py | 81 +++++++++---------- .../components/bang_olufsen/services.yaml | 7 +- .../components/bang_olufsen/strings.json | 4 + 3 files changed, 49 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index a671ec5eb70f8..40561fce7fef1 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -7,7 +7,7 @@ from typing import Any, cast from mozart_api import __version__ as MOZART_API_VERSION -from mozart_api.exceptions import ApiException +from mozart_api.exceptions import ApiException, NotFoundException from mozart_api.models import ( Action, Art, @@ -29,11 +29,7 @@ VolumeMute, VolumeState, ) -from mozart_api.mozart_client import ( - MozartClient, - check_valid_jid, - get_highest_resolution_artwork, -) +from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork import voluptuous as vol from homeassistant.components import media_source @@ -115,10 +111,9 @@ async def async_setup_entry( platform.async_register_entity_service( name="beolink_join", schema={ - vol.Optional("beolink_jid"): vol.All( - vol.Coerce(type=cv.string), - vol.Length(min=47, max=47), - ), + vol.Optional("beolink_jid"): vol.Match( + r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" + ) }, func="async_beolink_join", supports_response=SupportsResponse.OPTIONAL, @@ -127,15 +122,19 @@ async def async_setup_entry( platform.async_register_entity_service( name="beolink_expand", schema={ - vol.Required("beolink_jids"): vol.All( + vol.Exclusive("all_discovered", "devices", ""): cv.boolean, + vol.Exclusive( + "beolink_jids", + "devices", + "Define either specific Beolink JIDs or all discovered", + ): vol.All( cv.ensure_list, [ - vol.All( - vol.Coerce(type=cv.string), - vol.Length(min=47, max=47), + vol.Match( + r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" ) ], - ) + ), }, func="async_beolink_expand", supports_response=SupportsResponse.OPTIONAL, @@ -147,15 +146,13 @@ async def async_setup_entry( vol.Required("beolink_jids"): vol.All( cv.ensure_list, [ - vol.All( - vol.Coerce(type=cv.string), - vol.Length(min=47, max=47), + vol.Match( + r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" ) ], - ) + ), }, func="async_beolink_unexpand", - supports_response=SupportsResponse.OPTIONAL, ) platform.async_register_entity_service( @@ -939,46 +936,46 @@ async def async_beolink_join( if beolink_jid is None: response = await self._client.join_latest_beolink_experience() else: - if not check_valid_jid(beolink_jid): - return {"invalid_jid": beolink_jid} response = await self._client.join_beolink_peer(jid=beolink_jid) return response.dict() - async def async_beolink_expand(self, beolink_jids: list[str]) -> ServiceResponse: + async def async_beolink_expand( + self, beolink_jids: list[str] | None = None, all_discovered: bool = False + ) -> ServiceResponse: """Expand a Beolink multi-room experience with a device or devices.""" - response: dict[str, Any] = {"invalid_jid": []} + response: dict[str, Any] = {"not_on_network": []} # Ensure that the current source is expandable if not self._beolink_sources[cast(str, self._source_change.id)]: return {"invalid_source": self.source} - for beolink_jid in beolink_jids: - if not check_valid_jid(beolink_jid): - response["invalid_jid"].append(beolink_jid) - continue - await self._client.post_beolink_expand(jid=beolink_jid) + # Expand to all discovered devices + if all_discovered: + peers = await self._client.get_beolink_peers() + + for peer in peers: + await self._client.post_beolink_expand(jid=peer.jid) + + # Try to expand to all defined devices + elif beolink_jids: + for beolink_jid in beolink_jids: + try: + await self._client.post_beolink_expand(jid=beolink_jid) + except NotFoundException: + response["not_on_network"].append(beolink_jid) - if len(response["invalid_jid"]) > 0: - return response + if len(response["not_on_network"]) > 0: + return response return None - async def async_beolink_unexpand(self, beolink_jids: list[str]) -> ServiceResponse: + async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None: """Unexpand a Beolink multi-room experience with a device or devices.""" - response: dict[str, Any] = {"invalid_jid": []} - + # Unexpand all defined devices for beolink_jid in beolink_jids: - if not check_valid_jid(beolink_jid): - response["invalid_jid"].append(beolink_jid) - continue await self._client.post_beolink_unexpand(jid=beolink_jid) - if len(response["invalid_jid"]) > 0: - return response - - return None - async def async_beolink_leave(self) -> None: """Leave the current Beolink experience.""" await self._client.post_beolink_leave() diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml index 5bc5e6c236698..685703ddb9bc3 100644 --- a/homeassistant/components/bang_olufsen/services.yaml +++ b/homeassistant/components/bang_olufsen/services.yaml @@ -20,8 +20,13 @@ beolink_expand: device: integration: bang_olufsen fields: + all_discovered: + required: false + example: false + selector: + boolean: beolink_jids: - required: true + required: false example: >- [ 1111.2222222.33333333@products.bang-olufsen.com, diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 99593aa75537e..5dad7ae2a63f0 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -40,6 +40,10 @@ "name": "Beolink expand", "description": "Expand current Beolink experience.", "fields": { + "all_discovered": { + "name": "All discovered", + "description": "Expand Beolink experience to all discovered devices." + }, "beolink_jids": { "name": "Beolink JIDs", "description": "Specify which Beolink JIDs will join current Beolink experience." From bdea91b4d2942dcc43d5bc1c2c8be6efae512b07 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Thu, 4 Apr 2024 17:03:49 +0200 Subject: [PATCH 09/34] Add service icons --- homeassistant/components/bang_olufsen/icons.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 homeassistant/components/bang_olufsen/icons.json diff --git a/homeassistant/components/bang_olufsen/icons.json b/homeassistant/components/bang_olufsen/icons.json new file mode 100644 index 0000000000000..12efd69bc722e --- /dev/null +++ b/homeassistant/components/bang_olufsen/icons.json @@ -0,0 +1,9 @@ +{ + "services": { + "beolink_join": "mdi:location-enter", + "beolink_expand": "mdi:location-enter", + "beolink_unexpand": "mdi:location-exit", + "beolink_leave": "mdi:close-circle-outline", + "beolink_allstandby": "mdi:close-circle-multiple-outline" + } +} From bea3691a509b1a2513fa68d8d6201b7df371e5c0 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Thu, 16 May 2024 12:43:19 +0200 Subject: [PATCH 10/34] Fix merge Remove unnecessary assignment --- .../components/bang_olufsen/__init__.py | 2 +- .../components/bang_olufsen/media_player.py | 22 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index a3b36fadc9981..07b9d0befe1c6 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -61,7 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: TimeoutError, ) as error: await client.close_api_client() - raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") + raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error websocket = BangOlufsenWebsocket(hass, entry, client) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 40561fce7fef1..4a81eacb9636c 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -489,9 +489,9 @@ async def _update_beolink(self, should_update: bool = True) -> None: if len(peers) > 0: self._beolink_attribute["beolink"]["peers"] = {} for peer in peers: - self._beolink_attribute["beolink"]["peers"][ - peer.friendly_name - ] = peer.jid + self._beolink_attribute["beolink"]["peers"][peer.friendly_name] = ( + peer.jid + ) self._remote_leader = self._playback_metadata.remote_leader @@ -542,14 +542,14 @@ async def _update_beolink(self, should_update: bool = True) -> None: ) for peer in peers: if peer.jid == beolink_listener.jid: - beolink_listeners_attribute[ - peer.friendly_name - ] = beolink_listener.jid + beolink_listeners_attribute[peer.friendly_name] = ( + beolink_listener.jid + ) break - self._beolink_attribute["beolink"][ - "listeners" - ] = beolink_listeners_attribute + self._beolink_attribute["beolink"]["listeners"] = ( + beolink_listeners_attribute + ) self._attr_group_members = group_members @@ -562,12 +562,10 @@ def _get_entity_id_from_jid(self, jid: str) -> str | None: unique_id = jid.split(".")[2].split("@")[0] entity_registry = er.async_get(self.hass) - entity_id = entity_registry.async_get_entity_id( + return entity_registry.async_get_entity_id( Platform.MEDIA_PLAYER, DOMAIN, unique_id ) - return entity_id - def _get_beolink_jid(self, entity_id: str) -> str | None: """Get beolink JID from entity_id.""" entity_registry = er.async_get(self.hass) From 95d29bf1136ace34010b3a609bab6c09d7fd03ee Mon Sep 17 00:00:00 2001 From: mj23000 Date: Tue, 28 May 2024 14:18:16 +0200 Subject: [PATCH 11/34] Remove invalid typing Update response typing for updated API --- homeassistant/components/bang_olufsen/media_player.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 4a81eacb9636c..be11c9da4216a 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -172,7 +172,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): """Representation of a media player.""" _attr_icon = "mdi:speaker-wireless" - _attr_name: None | str = None + _attr_name = None _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_supported_features = BANG_OLUFSEN_FEATURES @@ -602,7 +602,7 @@ def volume_level(self) -> float | None: def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" if self._volume.muted and self._volume.muted.muted: - return self._volume.muted.muted + return self._volume.muted.muted # type: ignore[no-any-return] return None @property @@ -936,7 +936,9 @@ async def async_beolink_join( else: response = await self._client.join_beolink_peer(jid=beolink_jid) - return response.dict() + if response: + return cast(dict, response.to_dict()) + return None async def async_beolink_expand( self, beolink_jids: list[str] | None = None, all_discovered: bool = False From 4d8591671858beada5916905eef940dbba83a22b Mon Sep 17 00:00:00 2001 From: mj23000 Date: Tue, 28 May 2024 15:19:36 +0200 Subject: [PATCH 12/34] Revert to old typed response dict method Remove mypy ignore line Fix jid possibly used before assignment --- homeassistant/components/bang_olufsen/media_player.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index be11c9da4216a..5d9d171502d81 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import json import logging from typing import Any, cast @@ -568,6 +569,8 @@ def _get_entity_id_from_jid(self, jid: str) -> str | None: def _get_beolink_jid(self, entity_id: str) -> str | None: """Get beolink JID from entity_id.""" + jid = None + entity_registry = er.async_get(self.hass) entity_entry = entity_registry.async_get(entity_id) @@ -579,10 +582,8 @@ def _get_beolink_jid(self, entity_id: str) -> str | None: ), ) - try: + with contextlib.suppress(KeyError): jid = cast(str, config_entry.data[CONF_BEOLINK_JID]) - except KeyError: - jid = None return jid @@ -602,7 +603,7 @@ def volume_level(self) -> float | None: def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" if self._volume.muted and self._volume.muted.muted: - return self._volume.muted.muted # type: ignore[no-any-return] + return self._volume.muted.muted return None @property @@ -937,7 +938,7 @@ async def async_beolink_join( response = await self._client.join_beolink_peer(jid=beolink_jid) if response: - return cast(dict, response.to_dict()) + return response.dict(by_alias=True, exclude={}, exclude_none=True) return None async def async_beolink_expand( From deafd85812dcaec62f096c550b02116004ee68ac Mon Sep 17 00:00:00 2001 From: mj23000 Date: Tue, 28 May 2024 15:33:17 +0200 Subject: [PATCH 13/34] Re add debugging logging --- homeassistant/components/bang_olufsen/websocket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 6a3c133ad83c1..758e74afb6fd2 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -189,4 +189,5 @@ def on_all_notifications_raw(self, notification: dict) -> None: notification["device_id"] = self._device.id notification["serial_number"] = int(self._unique_id) + _LOGGER.debug("%s", notification) self.hass.bus.async_fire(BANG_OLUFSEN_WEBSOCKET_EVENT, notification) From 08b94a7bd5c7266cefaa10986d0eb39fd0b54e45 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 29 May 2024 17:02:31 +0200 Subject: [PATCH 14/34] Fix coroutine Fix formatting --- homeassistant/components/bang_olufsen/media_player.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 7b26eaa5bb578..b35d6567eb027 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -58,7 +58,6 @@ entity_registry as er, ) from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo - from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, @@ -414,7 +413,9 @@ async def _update_sources(self, update_ha_state: bool = False) -> None: self.async_write_ha_state() @callback - def _async_update_playback_metadata(self, data: PlaybackContentMetadata) -> None: + async def _async_update_playback_metadata( + self, data: PlaybackContentMetadata + ) -> None: """Update _playback_metadata and related.""" self._playback_metadata = data From c1a6c6d22835a5c9d74c5d154885d76e13391b9d Mon Sep 17 00:00:00 2001 From: mj23000 Date: Fri, 31 May 2024 12:02:28 +0200 Subject: [PATCH 15/34] Remove unnecessary update control --- .../components/bang_olufsen/media_player.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index cb223d89b8f3f..3211f052e9195 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -339,7 +339,6 @@ async def _initialize(self) -> None: # Update beolink attributes and device name. await self._update_name_and_beolink() - async def _async_update_sources(self) -> None: """Get sources for the specific product.""" @@ -410,8 +409,7 @@ async def _async_update_sources(self) -> None: self._attr_source_list = list(self._sources.values()) - if update_ha_state: - self.async_write_ha_state() + self.async_write_ha_state() @callback async def _async_update_playback_metadata( @@ -422,9 +420,7 @@ async def _async_update_playback_metadata( # Update current artwork and remote_leader. self._media_image = get_highest_resolution_artwork(self._playback_metadata) - await self._update_beolink(should_update=False) - - self.async_write_ha_state() + await self._update_beolink() @callback def _async_update_playback_error(self, data: PlaybackError) -> None: @@ -482,11 +478,9 @@ async def _update_name_and_beolink(self) -> None: name=beolink_self.friendly_name, ) - await self._update_beolink(should_update=False) + await self._update_beolink() - self.async_write_ha_state() - - async def _update_beolink(self, should_update: bool = True) -> None: + async def _update_beolink(self) -> None: """Update the current Beolink leader, listeners, peers and self.""" self._beolink_attribute = {} @@ -568,8 +562,7 @@ async def _update_beolink(self, should_update: bool = True) -> None: self._attr_group_members = group_members - if should_update: - self.async_write_ha_state() + self.async_write_ha_state() def _get_entity_id_from_jid(self, jid: str) -> str | None: """Get entity_id from Beolink JID (if available).""" From 4569bea75ac30765318bdc5cc33522eeb73a7180 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 10 Jul 2024 16:54:10 +0200 Subject: [PATCH 16/34] Make tests pass Fix remote leader media position bug Improve remote leader BangOlufsenSource comparison --- .../components/bang_olufsen/media_player.py | 10 ++++----- tests/components/bang_olufsen/conftest.py | 21 +++++++++++++++++++ tests/components/bang_olufsen/const.py | 8 +++++++ tests/components/bang_olufsen/test_init.py | 5 +++-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index d5e8f742ad604..fc9d63bedc6d8 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -508,9 +508,9 @@ async def _update_beolink(self) -> None: self._remote_leader = self._playback_metadata.remote_leader # Temp fix for mismatch in WebSocket metadata and "real" REST endpoint where the remote leader is not deleted. - if self.source in ( - BangOlufsenSource.LINE_IN, - BangOlufsenSource.URI_STREAMER, + if self._source_change.id in ( + BangOlufsenSource.LINE_IN.id, + BangOlufsenSource.URI_STREAMER.id, ): self._remote_leader = None @@ -634,8 +634,8 @@ def media_position(self) -> int | None: """Return the current playback progress.""" # Don't show progress if the the device is a Beolink listener. if self._remote_leader is None: - return None - return self._playback_progress.progress + return self._playback_progress.progress + return None @property def media_image_url(self) -> str | None: diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 4764798f34d4a..2f406fbfab726 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -27,7 +27,11 @@ from .const import ( TEST_DATA_CREATE_ENTRY, TEST_FRIENDLY_NAME, + TEST_FRIENDLY_NAME_2, + TEST_FRIENDLY_NAME_3, TEST_JID_1, + TEST_JID_2, + TEST_JID_3, TEST_NAME, TEST_SERIAL_NUMBER, ) @@ -223,6 +227,17 @@ def mock_mozart_client() -> Generator[AsyncMock]: id="64c9da45-3682-44a4-8030-09ed3ef44160", ), } + client.get_beolink_peers = AsyncMock() + client.get_beolink_peers.return_value = [ + BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2), + BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3), + ] + client.get_beolink_listeners = AsyncMock() + client.get_beolink_listeners.return_value = [ + BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2), + BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3), + ] + client.post_standby = AsyncMock() client.set_current_volume_level = AsyncMock() client.set_volume_mute = AsyncMock() @@ -237,6 +252,12 @@ def mock_mozart_client() -> Generator[AsyncMock]: client.add_to_queue = AsyncMock() client.post_remote_trigger = AsyncMock() client.set_active_source = AsyncMock() + client.post_beolink_expand = AsyncMock() + client.join_beolink_peer = AsyncMock() + client.post_beolink_unexpand = AsyncMock() + client.post_beolink_leave = AsyncMock() + client.post_beolink_allstandby = AsyncMock() + client.join_latest_beolink_experience = AsyncMock() # Non-REST API client methods client.check_device_connection = AsyncMock() diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index d5e2221675aed..c07803366d13b 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -46,6 +46,14 @@ TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.beosound_balance_11111111" +TEST_FRIENDLY_NAME_2 = "Laundry room Balance" +TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.22222222@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beosound_balance_22222222" + +TEST_FRIENDLY_NAME_3 = "Lego room Balance" +TEST_JID_3 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.33333333@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_3 = "media_player.beosound_balance_33333333" + TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." TEST_NAME_ZEROCONF = TEST_NAME.replace(" ", "-") + "." + TEST_TYPE_ZEROCONF diff --git a/tests/components/bang_olufsen/test_init.py b/tests/components/bang_olufsen/test_init.py index 11742b846ae8f..c51dfe2365156 100644 --- a/tests/components/bang_olufsen/test_init.py +++ b/tests/components/bang_olufsen/test_init.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry -from .const import TEST_MODEL_BALANCE, TEST_NAME, TEST_SERIAL_NUMBER +from .const import TEST_FRIENDLY_NAME, TEST_MODEL_BALANCE, TEST_SERIAL_NUMBER async def test_setup_entry( @@ -31,7 +31,8 @@ async def test_setup_entry( identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} ) assert device is not None - assert device.name == TEST_NAME + # Is usually TEST_NAME, but is updated to the device's friendly name by _update_name_and_beolink + assert device.name == TEST_FRIENDLY_NAME assert device.model == TEST_MODEL_BALANCE # Ensure that the connection has been checked WebSocket connection has been initialized From 6f45dfcba7c30e6413728030153c4e3ee37d8452 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Thu, 11 Jul 2024 10:42:39 +0200 Subject: [PATCH 17/34] Fix naming and add callback decorators --- .../components/bang_olufsen/media_player.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index fc9d63bedc6d8..1a5cf1a4ca99c 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -284,14 +284,14 @@ async def async_added_to_hass(self) -> None: async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.BEOLINK}", - self._update_beolink, + self._async_update_beolink, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}", - self._update_name_and_beolink, + self._async_update_name_and_beolink, ) ) @@ -340,7 +340,7 @@ async def _initialize(self) -> None: await self._async_update_sources() # Update beolink attributes and device name. - await self._update_name_and_beolink() + await self._async_update_name_and_beolink() async def _async_update_sources(self) -> None: """Get sources for the specific product.""" @@ -423,7 +423,7 @@ async def _async_update_playback_metadata( # Update current artwork and remote_leader. self._media_image = get_highest_resolution_artwork(self._playback_metadata) - await self._update_beolink() + await self._async_update_beolink() @callback def _async_update_playback_error(self, data: PlaybackError) -> None: @@ -470,7 +470,8 @@ def _async_update_volume(self, data: VolumeState) -> None: self.async_write_ha_state() - async def _update_name_and_beolink(self) -> None: + @callback + async def _async_update_name_and_beolink(self) -> None: """Update the device friendly name.""" beolink_self = await self._client.get_beolink_self() @@ -481,9 +482,10 @@ async def _update_name_and_beolink(self) -> None: name=beolink_self.friendly_name, ) - await self._update_beolink() + await self._async_update_beolink() - async def _update_beolink(self) -> None: + @callback + async def _async_update_beolink(self) -> None: """Update the current Beolink leader, listeners, peers and self.""" self._beolink_attribute = {} From 4882f22d5a1c82766903c29b8c4650e9c06f5619 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Thu, 11 Jul 2024 17:12:39 +0200 Subject: [PATCH 18/34] Move regex service check to variable Suppress KeyError Update tests --- .../components/bang_olufsen/media_player.py | 27 +- tests/components/bang_olufsen/conftest.py | 22 +- tests/components/bang_olufsen/const.py | 14 +- .../bang_olufsen/test_media_player.py | 445 +++++++++++++++++- 4 files changed, 487 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 1a5cf1a4ca99c..c4507f518ecc0 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -117,13 +117,13 @@ async def async_setup_entry( # Register services. platform = async_get_current_platform() + jid_regex = vol.Match( + r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" + ) + platform.async_register_entity_service( name="beolink_join", - schema={ - vol.Optional("beolink_jid"): vol.Match( - r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" - ) - }, + schema={vol.Optional("beolink_jid"): jid_regex}, func="async_beolink_join", supports_response=SupportsResponse.OPTIONAL, ) @@ -138,11 +138,7 @@ async def async_setup_entry( "Define either specific Beolink JIDs or all discovered", ): vol.All( cv.ensure_list, - [ - vol.Match( - r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" - ) - ], + [jid_regex], ), }, func="async_beolink_expand", @@ -154,11 +150,7 @@ async def async_setup_entry( schema={ vol.Required("beolink_jids"): vol.All( cv.ensure_list, - [ - vol.Match( - r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" - ) - ], + [jid_regex], ), }, func="async_beolink_unexpand", @@ -1006,8 +998,9 @@ async def async_beolink_expand( response: dict[str, Any] = {"not_on_network": []} # Ensure that the current source is expandable - if not self._beolink_sources[cast(str, self._source_change.id)]: - return {"invalid_source": self.source} + with contextlib.suppress(KeyError): + if not self._beolink_sources[cast(str, self._source_change.id)]: + return {"invalid_source": self.source} # Expand to all discovered devices if all_discovered: diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 2f406fbfab726..0975816045333 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -26,6 +26,7 @@ from .const import ( TEST_DATA_CREATE_ENTRY, + TEST_DATA_CREATE_ENTRY_2, TEST_FRIENDLY_NAME, TEST_FRIENDLY_NAME_2, TEST_FRIENDLY_NAME_3, @@ -33,7 +34,9 @@ TEST_JID_2, TEST_JID_3, TEST_NAME, + TEST_NAME_2, TEST_SERIAL_NUMBER, + TEST_SERIAL_NUMBER_2, ) from tests.common import MockConfigEntry @@ -50,6 +53,17 @@ def mock_config_entry(): ) +@pytest.fixture +def mock_config_entry_2(): + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SERIAL_NUMBER_2, + data=TEST_DATA_CREATE_ENTRY_2, + title=TEST_NAME_2, + ) + + @pytest.fixture async def mock_media_player(hass: HomeAssistant, mock_config_entry, mock_mozart_client): """Mock media_player entity.""" @@ -101,13 +115,19 @@ def mock_mozart_client() -> Generator[AsyncMock]: is_enabled=True, is_multiroom_available=False, ), - # The only available source + # The only available beolink source Source( name="Tidal", id="tidal", is_enabled=True, is_multiroom_available=True, ), + Source( + name="Line-In", + id="lineIn", + is_enabled=True, + is_multiroom_available=False, + ), # Is disabled, so should not be user selectable Source( name="Powerlink", diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index c07803366d13b..e8d8653c5b757 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -39,7 +39,9 @@ TEST_MODEL_THEATRE = "Beosound Theatre" TEST_MODEL_LEVEL = "Beosound Level" TEST_SERIAL_NUMBER = "11111111" +TEST_SERIAL_NUMBER_2 = "22222222" TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}" +TEST_NAME_2 = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER_2}" TEST_FRIENDLY_NAME = "Living room Balance" TEST_TYPE_NUMBER = "1111" TEST_ITEM_NUMBER = "1111111" @@ -54,6 +56,10 @@ TEST_JID_3 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.33333333@products.bang-olufsen.com" TEST_MEDIA_PLAYER_ENTITY_ID_3 = "media_player.beosound_balance_33333333" +TEST_FRIENDLY_NAME_4 = "Lounge room Balance" +TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.44444444@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_4 = "media_player.beosound_balance_44444444" + TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." TEST_NAME_ZEROCONF = TEST_NAME.replace(" ", "-") + "." + TEST_TYPE_ZEROCONF @@ -68,6 +74,12 @@ CONF_BEOLINK_JID: TEST_JID_1, CONF_NAME: TEST_NAME, } +TEST_DATA_CREATE_ENTRY_2 = { + CONF_HOST: TEST_HOST, + CONF_MODEL: TEST_MODEL_BALANCE, + CONF_BEOLINK_JID: TEST_JID_2, + CONF_NAME: TEST_NAME_2, +} TEST_DATA_ZEROCONF = ZeroconfServiceInfo( ip_address=IPv4Address(TEST_HOST), @@ -109,7 +121,7 @@ }, ) -TEST_AUDIO_SOURCES = [BangOlufsenSource.TIDAL.name] +TEST_AUDIO_SOURCES = [BangOlufsenSource.TIDAL.name, BangOlufsenSource.LINE_IN.name] TEST_VIDEO_SOURCES = ["HDMI A"] TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES TEST_FALLBACK_SOURCES = [ diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 74867a8eedfb7..6cc0441371530 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -3,8 +3,10 @@ from contextlib import nullcontext as does_not_raise from unittest.mock import ANY, patch -from mozart_api.models import PlaybackContentMetadata +from mozart_api.exceptions import NotFoundException +from mozart_api.models import BeolinkLeader, PlaybackContentMetadata import pytest +from voluptuous import MultipleInvalid from homeassistant.components.bang_olufsen.const import ( BANG_OLUFSEN_STATES, @@ -13,6 +15,7 @@ WebsocketNotification, ) from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ALBUM_ARTIST, @@ -46,7 +49,18 @@ TEST_DEEZER_PLAYLIST, TEST_DEEZER_TRACK, TEST_FALLBACK_SOURCES, + TEST_FRIENDLY_NAME, + TEST_FRIENDLY_NAME_2, + TEST_FRIENDLY_NAME_3, + TEST_FRIENDLY_NAME_4, + TEST_JID_1, + TEST_JID_2, + TEST_JID_3, + TEST_JID_4, TEST_MEDIA_PLAYER_ENTITY_ID, + TEST_MEDIA_PLAYER_ENTITY_ID_2, + TEST_MEDIA_PLAYER_ENTITY_ID_3, + TEST_NAME, TEST_OVERLAY_INVALID_OFFSET_VOLUME_TTS, TEST_OVERLAY_OFFSET_VOLUME_TTS, TEST_PLAYBACK_ERROR, @@ -98,6 +112,9 @@ async def test_initialization( mock_mozart_client.get_product_state.assert_called_once() mock_mozart_client.get_available_sources.assert_called_once() mock_mozart_client.get_remote_menu.assert_called_once() + mock_mozart_client.get_beolink_self.assert_called_once() + mock_mozart_client.get_beolink_peers.assert_called_once() + mock_mozart_client.get_beolink_listeners.assert_called_once() async def test_async_update_sources_audio_only( @@ -191,7 +208,9 @@ async def test_async_update_playback_error( async def test_async_update_playback_progress( - hass: HomeAssistant, mock_mozart_client, mock_config_entry + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, ) -> None: """Test _async_update_playback_progress.""" @@ -216,6 +235,40 @@ async def test_async_update_playback_progress( assert old_updated_at != new_updated_at +async def test_async_update_playback_progress_remote_leader( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test _async_update_playback_progress.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert ATTR_MEDIA_POSITION not in states.attributes + old_updated_at = states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] + assert old_updated_at + + # Send metadata to set remote leader + async_dispatcher_send( + hass, + f"{TEST_SERIAL_NUMBER}_{WebsocketNotification.PLAYBACK_METADATA}", + PlaybackContentMetadata(remote_leader=BeolinkLeader(friendly_name="", jid="")), + ) + async_dispatcher_send( + hass, + f"{TEST_SERIAL_NUMBER}_{WebsocketNotification.PLAYBACK_PROGRESS}", + TEST_PLAYBACK_PROGRESS, + ) + + states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert ATTR_MEDIA_POSITION not in states.attributes + new_updated_at = states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] + assert new_updated_at + assert old_updated_at != new_updated_at + + async def test_async_update_playback_state( hass: HomeAssistant, mock_mozart_client, mock_config_entry ) -> None: @@ -402,6 +455,74 @@ async def test_async_set_volume_level( ) +async def test_async_update_beolink_line_in( + hass: HomeAssistant, mock_mozart_client, mock_config_entry +) -> None: + """Test _async_update_beolink with line-in and no active Beolink session.""" + # Ensure no listeners + mock_mozart_client.get_beolink_listeners.return_value = [] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Set source + async_dispatcher_send( + hass, + f"{TEST_SERIAL_NUMBER}_{WebsocketNotification.SOURCE_CHANGE}", + BangOlufsenSource.LINE_IN, + ) + async_dispatcher_send( + hass, + f"{TEST_SERIAL_NUMBER}_{WebsocketNotification.BEOLINK}", + ) + + states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert states.attributes["beolink"]["self"] == {TEST_FRIENDLY_NAME: TEST_JID_1} + assert states.attributes["beolink"]["peers"] == { + TEST_FRIENDLY_NAME_2: TEST_JID_2, + TEST_FRIENDLY_NAME_3: TEST_JID_3, + } + + assert "listeners" not in states.attributes["beolink"] + assert "leader" not in states.attributes["beolink"] + + # call_count is 2 because the method is called on startup + assert mock_mozart_client.get_beolink_peers.call_count == 2 + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + + +async def test_async_update_beolink_listener( + hass: HomeAssistant, mock_mozart_client, mock_config_entry +) -> None: + """Test _async_update_beolink as a listener.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Runs _async_update_beolink + async_dispatcher_send( + hass, + f"{TEST_SERIAL_NUMBER}_{WebsocketNotification.PLAYBACK_METADATA}", + PlaybackContentMetadata( + remote_leader=BeolinkLeader( + friendly_name=TEST_FRIENDLY_NAME_4, jid=TEST_JID_4 + ) + ), + ) + + states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert states.attributes["beolink"]["self"] == {TEST_FRIENDLY_NAME: TEST_JID_1} + assert states.attributes["beolink"]["peers"] == { + TEST_FRIENDLY_NAME_2: TEST_JID_2, + TEST_FRIENDLY_NAME_3: TEST_JID_3, + } + assert states.attributes["beolink"]["leader"] == {TEST_FRIENDLY_NAME_4: TEST_JID_4} + + assert "listeners" not in states.attributes["beolink"] + + assert mock_mozart_client.get_beolink_peers.call_count == 2 + assert mock_mozart_client.get_beolink_listeners.call_count == 1 + + async def test_async_mute_volume( hass: HomeAssistant, mock_mozart_client, mock_config_entry ) -> None: @@ -1065,3 +1186,323 @@ async def test_async_browse_media( assert response["success"] assert (child in response["result"]["children"]) is present + + +@pytest.mark.parametrize( + ("group_members", "expand_count", "join_count", "logger_count"), + [ + # Valid member + ([TEST_MEDIA_PLAYER_ENTITY_ID_2], 1, 0, 0), + # Touch to join + ([], 0, 1, 0), + # Not registered member + ([TEST_MEDIA_PLAYER_ENTITY_ID_3], 0, 0, 1), + ], +) +async def test_async_join_players( + group_members, + expand_count, + join_count, + logger_count, + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, + mock_config_entry_2, +) -> None: + """Test async_join_players.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Add another entity + mock_config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + + # Set the source to a beolink expandable source + async_dispatcher_send( + hass, + f"{TEST_SERIAL_NUMBER}_{WebsocketNotification.SOURCE_CHANGE}", + BangOlufsenSource.TIDAL, + ) + + # Calling the async_beolink_join method from a service call causes the method to not be awaited. + # Replace the real service call with a patch. + with ( + patch( + "homeassistant.components.bang_olufsen.media_player._LOGGER.warning" + ) as mock_logger, + patch( + "homeassistant.components.bang_olufsen.media_player.BangOlufsenMediaPlayer.async_beolink_join" + ) as join_call, + ): + await hass.services.async_call( + "media_player", + "join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: group_members, + }, + blocking=True, + ) + + assert mock_mozart_client.post_beolink_expand.call_count == expand_count + assert join_count == join_call.call_count + assert mock_logger.call_count == logger_count + + +async def test_async_unjoin_player( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_unjoin_player.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + "media_player", + "unjoin", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_mozart_client.post_beolink_leave.assert_called_once() + + +async def test_async_beolink_join( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_join.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Check no service response + mock_mozart_client.join_beolink_peer.return_value = None + + assert await hass.services.async_call( + "bang_olufsen", + "beolink_join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jid": TEST_JID_2, + }, + blocking=True, + return_response=True, + ) == {TEST_MEDIA_PLAYER_ENTITY_ID: None} + + mock_mozart_client.join_beolink_peer.assert_called_once_with(jid=TEST_JID_2) + + +async def test_async_beolink_join_touch_to_join( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_join using the "Touch to join" method.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + "bang_olufsen", + "beolink_join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + }, + blocking=True, + ) + + mock_mozart_client.join_latest_beolink_experience.assert_called_once() + + +async def test_async_beolink_expand( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_expand.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert await hass.services.async_call( + "bang_olufsen", + "beolink_expand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jids": [ + TEST_JID_2, + TEST_JID_3, + ], + }, + blocking=True, + return_response=True, + ) == {TEST_MEDIA_PLAYER_ENTITY_ID: None} + + mock_mozart_client.post_beolink_expand.assert_any_call(jid=TEST_JID_2) + mock_mozart_client.post_beolink_expand.assert_any_call(jid=TEST_JID_3) + assert mock_mozart_client.post_beolink_expand.call_count == 2 + + +async def test_async_beolink_expand_not_on_network( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_expand with a device not on the network.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + mock_mozart_client.post_beolink_expand.side_effect = NotFoundException() + + assert await hass.services.async_call( + "bang_olufsen", + "beolink_expand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jids": [TEST_JID_2], + }, + blocking=True, + return_response=True, + ) == {TEST_MEDIA_PLAYER_ENTITY_ID: {"not_on_network": [TEST_JID_2]}} + + mock_mozart_client.post_beolink_expand.assert_called_once_with(jid=TEST_JID_2) + + +async def test_async_beolink_expand_invalid_source( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_expand with an invalid source.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Change to an invalid Beolink source + async_dispatcher_send( + hass, + f"{TEST_SERIAL_NUMBER}_{WebsocketNotification.SOURCE_CHANGE}", + BangOlufsenSource.LINE_IN, + ) + + assert await hass.services.async_call( + "bang_olufsen", + "beolink_expand", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + return_response=True, + ) == { + TEST_MEDIA_PLAYER_ENTITY_ID: { + "invalid_source": BangOlufsenSource.CHROMECAST.name + } + } + + mock_mozart_client.post_beolink_expand.assert_not_called() + + +async def test_async_beolink_expand_all_discovered( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_expand to all of the discovered devices..""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + "bang_olufsen", + "beolink_expand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "all_discovered": True, + }, + blocking=True, + ) + + assert mock_mozart_client.get_beolink_peers.call_count == 2 + mock_mozart_client.post_beolink_expand.assert_any_call(jid=TEST_JID_2) + mock_mozart_client.post_beolink_expand.assert_any_call(jid=TEST_JID_3) + assert mock_mozart_client.post_beolink_expand.call_count == 2 + + +@pytest.mark.parametrize( + ("beolink_jids", "expected_result", "call_count"), + [ + # Valid JIDs + ([TEST_JID_1, TEST_JID_2], does_not_raise(), 2), + # Malformed JIDs + ([TEST_JID_1, TEST_NAME], pytest.raises(MultipleInvalid), 0), + ], +) +async def test_async_beolink_unexpand( + beolink_jids, + expected_result, + call_count, + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_unexpand.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + with expected_result: + await hass.services.async_call( + "bang_olufsen", + "beolink_unexpand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jids": beolink_jids, + }, + blocking=True, + ) + + assert mock_mozart_client.post_beolink_unexpand.call_count == call_count + + +async def test_async_beolink_leave( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_leave.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + "bang_olufsen", + "beolink_leave", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_mozart_client.post_beolink_leave.assert_called_once() + + +async def test_async_beolink_allstandby( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_beolink_allstandby.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + "bang_olufsen", + "beolink_allstandby", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_mozart_client.post_beolink_allstandby.assert_called_once() From c2583a2ba3814e47fd5873354c7e6da0f8d1605d Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 31 Jul 2024 14:13:52 +0200 Subject: [PATCH 19/34] Re-add hass running check --- homeassistant/components/bang_olufsen/media_player.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index c4507f518ecc0..74f9ec9a8c464 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -404,7 +404,9 @@ async def _async_update_sources(self) -> None: self._attr_source_list = list(self._sources.values()) - self.async_write_ha_state() + # HASS won't necessarily be running the first time this method is run + if self.hass.is_running: + self.async_write_ha_state() @callback async def _async_update_playback_metadata( From fe76b2c74df9edd5af480c2fd0aa3eeaf545ebc2 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 31 Jul 2024 14:41:17 +0200 Subject: [PATCH 20/34] Improve comments, naming and type hinting --- .../components/bang_olufsen/media_player.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 74f9ec9a8c464..e940c2efc68fe 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -204,10 +204,11 @@ def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: self._state: str = MediaPlayerState.IDLE self._video_sources: dict[str, str] = {} - # Beolink + # Beolink compatible sources self._beolink_sources: dict[str, bool] = {} self._remote_leader: BeolinkLeader | None = None - self._beolink_attribute: dict[str, dict] = {} + # Extra state attributes for showing Beolink: peer(s), listener(s), leader and self + self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {} async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" @@ -482,12 +483,13 @@ async def _async_update_name_and_beolink(self) -> None: async def _async_update_beolink(self) -> None: """Update the current Beolink leader, listeners, peers and self.""" - self._beolink_attribute = {} + self._beolink_attributes = {} # Add Beolink self assert self.device_entry + assert self.device_entry.name - self._beolink_attribute = { + self._beolink_attributes = { "beolink": {"self": {self.device_entry.name: self._beolink_jid}} } @@ -495,9 +497,9 @@ async def _async_update_beolink(self) -> None: peers = await self._client.get_beolink_peers() if len(peers) > 0: - self._beolink_attribute["beolink"]["peers"] = {} + self._beolink_attributes["beolink"]["peers"] = {} for peer in peers: - self._beolink_attribute["beolink"]["peers"][peer.friendly_name] = ( + self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = ( peer.jid ) @@ -527,7 +529,7 @@ async def _async_update_beolink(self) -> None: cast(str, self._get_entity_id_from_jid(self._beolink_jid)) ) - self._beolink_attribute["beolink"]["leader"] = { + self._beolink_attributes["beolink"]["leader"] = { self._remote_leader.friendly_name: self._remote_leader.jid, } @@ -555,7 +557,7 @@ async def _async_update_beolink(self) -> None: ) break - self._beolink_attribute["beolink"]["listeners"] = ( + self._beolink_attributes["beolink"]["listeners"] = ( beolink_listeners_attribute ) @@ -709,8 +711,8 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes: dict[str, Any] = {} # Add Beolink attributes - if self._beolink_attribute: - attributes.update(self._beolink_attribute) + if self._beolink_attributes: + attributes.update(self._beolink_attributes) return attributes From a82b0cadf50ab33913cc0236292f96aa0e392665 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 31 Jul 2024 14:54:57 +0200 Subject: [PATCH 21/34] Remove old temporary fix --- homeassistant/components/bang_olufsen/media_player.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index e940c2efc68fe..a700bf35f2577 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -505,13 +505,6 @@ async def _async_update_beolink(self) -> None: self._remote_leader = self._playback_metadata.remote_leader - # Temp fix for mismatch in WebSocket metadata and "real" REST endpoint where the remote leader is not deleted. - if self._source_change.id in ( - BangOlufsenSource.LINE_IN.id, - BangOlufsenSource.URI_STREAMER.id, - ): - self._remote_leader = None - # Add Beolink listeners / leader # Create group members list From bf04790da8e1281e14d34fc47fc7cf7c3efe849f Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 31 Jul 2024 15:14:13 +0200 Subject: [PATCH 22/34] Convert logged warning to raised exception for invalid media_player Simplify code using walrus operator --- .../components/bang_olufsen/media_player.py | 15 +++++++++------ .../components/bang_olufsen/strings.json | 3 +++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index a700bf35f2577..492951795fb20 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -959,12 +959,15 @@ async def async_join_players(self, group_members: list[str]) -> None: jids = [] # Get JID for each group member for group_member in group_members: - jid = self._get_beolink_jid(group_member) - - # Invalid entity - if jid is None: - _LOGGER.warning("Error adding %s to group", group_member) - continue + # Check if an invalid entity + if (jid := self._get_beolink_jid(group_member)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_beolink_jid", + translation_placeholders={ + "group_member": group_member, + }, + ) jids.append(jid) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index f2dca49655f3f..5a0b840e12b11 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -84,6 +84,9 @@ }, "play_media_error": { "message": "An error occurred while attempting to play {media_type}: {error_message}." + }, + "missing_beolink_jid": { + "message": "Group member: {group_member} is missing a Beolink JID. Is the media_player a Bang & Olufsen device?" } } } From a8de4494cd6da1b657deebf66b0c5e936e459d4c Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 31 Jul 2024 15:27:34 +0200 Subject: [PATCH 23/34] Fix test for invalid media_player grouping --- .../bang_olufsen/test_media_player.py | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 6cc0441371530..035e7c4a78494 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -1189,21 +1189,18 @@ async def test_async_browse_media( @pytest.mark.parametrize( - ("group_members", "expand_count", "join_count", "logger_count"), + ("group_members", "expand_count", "join_count"), [ # Valid member - ([TEST_MEDIA_PLAYER_ENTITY_ID_2], 1, 0, 0), + ([TEST_MEDIA_PLAYER_ENTITY_ID_2], 1, 0), # Touch to join - ([], 0, 1, 0), - # Not registered member - ([TEST_MEDIA_PLAYER_ENTITY_ID_3], 0, 0, 1), + ([], 0, 1), ], ) async def test_async_join_players( group_members, expand_count, join_count, - logger_count, hass: HomeAssistant, mock_mozart_client, mock_config_entry, @@ -1227,14 +1224,9 @@ async def test_async_join_players( # Calling the async_beolink_join method from a service call causes the method to not be awaited. # Replace the real service call with a patch. - with ( - patch( - "homeassistant.components.bang_olufsen.media_player._LOGGER.warning" - ) as mock_logger, - patch( - "homeassistant.components.bang_olufsen.media_player.BangOlufsenMediaPlayer.async_beolink_join" - ) as join_call, - ): + with patch( + "homeassistant.components.bang_olufsen.media_player.BangOlufsenMediaPlayer.async_beolink_join" + ) as join_call: await hass.services.async_call( "media_player", "join", @@ -1247,7 +1239,34 @@ async def test_async_join_players( assert mock_mozart_client.post_beolink_expand.call_count == expand_count assert join_count == join_call.call_count - assert mock_logger.call_count == logger_count + + +async def test_async_join_players_invalid_media_player( + hass: HomeAssistant, + mock_mozart_client, + mock_config_entry, +) -> None: + """Test async_join_players with an invalid media_player entity.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + "media_player", + "join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: [TEST_MEDIA_PLAYER_ENTITY_ID_3], + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "missing_beolink_jid" + assert exc_info.errisinstance(HomeAssistantError) + + assert mock_mozart_client.post_beolink_expand.call_count == 0 async def test_async_unjoin_player( From 92f42401d65a567a751632c29a16239c20594cc6 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 31 Jul 2024 15:35:32 +0200 Subject: [PATCH 24/34] Improve method naming --- homeassistant/components/bang_olufsen/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 492951795fb20..26ce448c80635 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -234,7 +234,7 @@ async def async_added_to_hass(self) -> None: async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.PLAYBACK_METADATA}", - self._async_update_playback_metadata, + self._async_update_playback_metadata_and_beolink, ) ) @@ -410,7 +410,7 @@ async def _async_update_sources(self) -> None: self.async_write_ha_state() @callback - async def _async_update_playback_metadata( + async def _async_update_playback_metadata_and_beolink( self, data: PlaybackContentMetadata ) -> None: """Update _playback_metadata and related.""" From 06e14a310703cdc894d1ca0046fd55f477432ed6 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 31 Jul 2024 16:33:40 +0200 Subject: [PATCH 25/34] Improve _beolink_sources explanation --- homeassistant/components/bang_olufsen/media_player.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 26ce448c80635..5e5d701b779ff 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -368,8 +368,9 @@ async def _async_update_sources(self) -> None: and source.id not in HIDDEN_SOURCE_IDS } - # Some sources are not Beolink expandable. _source_change, which is used throughout the entity does not have this information. - # Save expandable sources for Beolink services + # Some sources are not Beolink expandable, meaning that they can't be joined by 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 From 99ccb3a1f1e5440a8617af42d26d797b26210307 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 31 Jul 2024 16:34:19 +0200 Subject: [PATCH 26/34] Improve _beolink_sources explanation --- homeassistant/components/bang_olufsen/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 5e5d701b779ff..5650b14fb0ccb 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -368,7 +368,7 @@ 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 other Bang & Olufsen devices for a multi-room experience. + # 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 = { From ed8711ab7f76264c283638f6a17ae674d97cddee Mon Sep 17 00:00:00 2001 From: mj23000 Date: Tue, 17 Sep 2024 12:13:51 +0200 Subject: [PATCH 27/34] Fix tests --- tests/components/bang_olufsen/test_media_player.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 41ea3585a6023..c577a1f589f54 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -528,8 +528,9 @@ async def test_async_update_beolink_line_in( assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes["group_members"] == [] - assert mock_mozart_client.get_beolink_listeners.call_count == 1 - assert mock_mozart_client.get_beolink_peers.call_count == 1 + # Called once during _initialize and once during _async_update_beolink + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + assert mock_mozart_client.get_beolink_peers.call_count == 2 async def test_async_update_beolink_listener( @@ -566,7 +567,11 @@ async def test_async_update_beolink_listener( TEST_MEDIA_PLAYER_ENTITY_ID, ] - assert mock_mozart_client.get_beolink_listeners.call_count == 0 + # Called once for each entity during _initialize + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + # Called once for each entity during _initialize and + # once more during _async_update_beolink for the entity that has the callback associated with it. + assert mock_mozart_client.get_beolink_peers.call_count == 3 async def test_async_mute_volume( From 7e3e69a221601b3f1d259f898e1ecc2999fc0707 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Tue, 17 Sep 2024 14:02:46 +0200 Subject: [PATCH 28/34] Remove service responses Fix and add tests --- .../components/bang_olufsen/media_player.py | 21 +- .../bang_olufsen/test_media_player.py | 217 ++++++++++++++++-- 2 files changed, 204 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 281e7c8db0f58..51058c7906cd8 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -47,12 +47,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, Platform -from homeassistant.core import ( - HomeAssistant, - ServiceResponse, - SupportsResponse, - callback, -) +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -126,7 +121,6 @@ async def async_setup_entry( name="beolink_join", schema={vol.Optional("beolink_jid"): jid_regex}, func="async_beolink_join", - supports_response=SupportsResponse.OPTIONAL, ) platform.async_register_entity_service( @@ -143,7 +137,6 @@ async def async_setup_entry( ), }, func="async_beolink_expand", - supports_response=SupportsResponse.OPTIONAL, ) platform.async_register_entity_service( @@ -939,18 +932,12 @@ async def async_unjoin_player(self) -> None: await self.async_beolink_leave() # Custom services: - async def async_beolink_join( - self, beolink_jid: str | None = None - ) -> ServiceResponse: + async def async_beolink_join(self, beolink_jid: str | None = None) -> None: """Join a Beolink multi-room experience.""" if beolink_jid is None: - response = await self._client.join_latest_beolink_experience() + await self._client.join_latest_beolink_experience() else: - response = await self._client.join_beolink_peer(jid=beolink_jid) - - if response: - return response.dict(by_alias=True, exclude={}, exclude_none=True) - return None + await self._client.join_beolink_peer(jid=beolink_jid) async def async_beolink_expand( self, beolink_jids: list[str] | None = None, all_discovered: bool = False diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index c577a1f589f54..172db62f5e301 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -4,9 +4,10 @@ import logging from unittest.mock import AsyncMock, patch -# from mozart_api.exceptions import NotFoundException +from mozart_api.exceptions import NotFoundException from mozart_api.models import ( BeolinkLeader, + BeolinkPeer, PlaybackContentMetadata, RenderingState, Source, @@ -14,7 +15,6 @@ ) import pytest -# from voluptuous import MultipleInvalid from homeassistant.components.bang_olufsen.const import ( BANG_OLUFSEN_STATES, DOMAIN, @@ -41,6 +41,7 @@ ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, @@ -49,6 +50,7 @@ SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, SERVICE_TURN_OFF, + SERVICE_UNJOIN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, MediaPlayerState, @@ -57,6 +59,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component from .const import ( @@ -66,18 +69,13 @@ TEST_DEEZER_PLAYLIST, TEST_DEEZER_TRACK, TEST_FALLBACK_SOURCES, - # TEST_FRIENDLY_NAME, TEST_FRIENDLY_NAME_2, - # TEST_FRIENDLY_NAME_3, - # TEST_FRIENDLY_NAME_4, - # TEST_JID_1, + TEST_JID_1, TEST_JID_2, - # TEST_JID_3, - # TEST_JID_4, + TEST_JID_3, TEST_MEDIA_PLAYER_ENTITY_ID, TEST_MEDIA_PLAYER_ENTITY_ID_2, TEST_MEDIA_PLAYER_ENTITY_ID_3, - # TEST_NAME, TEST_OVERLAY_INVALID_OFFSET_VOLUME_TTS, TEST_OVERLAY_OFFSET_VOLUME_TTS, TEST_PLAYBACK_ERROR, @@ -284,8 +282,8 @@ async def test_async_update_playback_progress( async def test_async_update_playback_progress_remote_leader( hass: HomeAssistant, - mock_mozart_client, - mock_config_entry, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test _async_update_playback_progress.""" @@ -574,6 +572,43 @@ async def test_async_update_beolink_listener( assert mock_mozart_client.get_beolink_peers.call_count == 3 +async def test_async_update_name_and_beolink( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test _async_update_name_and_beolink.""" + # Change response to ensure device name is changed + mock_mozart_client.get_beolink_self.return_value = BeolinkPeer( + friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_1 + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + configuration_callback = ( + mock_mozart_client.get_notification_notifications.call_args[0][0] + ) + # Trigger callback + configuration_callback(WebsocketNotificationTag(value="configuration")) + + await hass.async_block_till_done() + + assert mock_mozart_client.get_beolink_self.call_count == 2 + assert mock_mozart_client.get_beolink_peers.call_count == 2 + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + + # Check that device name has been changed + assert mock_config_entry.unique_id + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + ) + assert device.name == TEST_FRIENDLY_NAME_2 + + async def test_async_mute_volume( hass: HomeAssistant, mock_mozart_client: AsyncMock, @@ -1306,8 +1341,8 @@ async def test_async_join_players( source_change_callback(BangOlufsenSource.TIDAL) await hass.services.async_call( - "media_player", - "join", + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_GROUP_MEMBERS: group_members, @@ -1364,8 +1399,8 @@ async def test_async_join_players_invalid( with expected_result as exc_info: await hass.services.async_call( - "media_player", - "join", + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_GROUP_MEMBERS: group_members, @@ -1392,10 +1427,158 @@ async def test_async_unjoin_player( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "unjoin", + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) mock_mozart_client.post_beolink_leave.assert_called_once() + + +async def test_async_beolink_join( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_beolink_join with defined JID.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jid": TEST_JID_2, + }, + blocking=True, + ) + + mock_mozart_client.join_beolink_peer.assert_called_once_with(jid=TEST_JID_2) + + +@pytest.mark.parametrize( + ( + "parameter", + "parameter_value", + "expand_side_effect", + "log_messages", + "peers_call_count", + ), + [ + # All discovered + # Valid peers + ("all_discovered", True, None, [], 2), + # Invalid peers + ( + "all_discovered", + True, + NotFoundException(), + [f"Unable to expand to {TEST_JID_2}", f"Unable to expand to {TEST_JID_3}"], + 2, + ), + # Beolink JIDs + # Valid peer + ("beolink_jids", [TEST_JID_2, TEST_JID_3], None, [], 1), + # Invalid peer + ( + "beolink_jids", + [TEST_JID_2, TEST_JID_3], + NotFoundException(), + [ + f"Unable to expand to {TEST_JID_2}. Is the device available on the network?", + f"Unable to expand to {TEST_JID_3}. Is the device available on the network?", + ], + 1, + ), + ], +) +async def test_async_beolink_expand( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + parameter: str, + parameter_value: bool | list[str], + expand_side_effect: NotFoundException | None, + log_messages: list[str], + peers_call_count: int, +) -> None: + """Test async_beolink_expand.""" + mock_mozart_client.post_beolink_expand.side_effect = expand_side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + source_change_callback = ( + mock_mozart_client.get_source_change_notifications.call_args[0][0] + ) + + # Set the source to a beolink expandable source + source_change_callback(BangOlufsenSource.TIDAL) + + await hass.services.async_call( + DOMAIN, + "beolink_expand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + parameter: parameter_value, + }, + blocking=True, + ) + + # Check log messages + for log_message in log_messages: + assert log_message in caplog.text + + # Called once during _initialize and once during async_beolink_expand for all_discovered + assert mock_mozart_client.get_beolink_peers.call_count == peers_call_count + + assert mock_mozart_client.post_beolink_expand.call_count == len( + await mock_mozart_client.get_beolink_peers() + ) + + +async def test_async_beolink_unexpand( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test test_async_beolink_unexpand.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_unexpand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jids": [TEST_JID_2, TEST_JID_3], + }, + blocking=True, + ) + + assert mock_mozart_client.post_beolink_unexpand.call_count == 2 + + +async def test_async_beolink_allstandby( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_beolink_allstandby.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_allstandby", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_mozart_client.post_beolink_allstandby.assert_called_once() From 70f16fc597e20242ca4c0870c5d6eba63a8f2a9e Mon Sep 17 00:00:00 2001 From: mj23000 Date: Tue, 17 Sep 2024 14:19:35 +0200 Subject: [PATCH 29/34] Change service to action where applicable --- homeassistant/components/bang_olufsen/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 51058c7906cd8..61ed4d1a71edd 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -110,7 +110,7 @@ async def async_setup_entry( # Add MediaPlayer entity async_add_entities(new_entities=[BangOlufsenMediaPlayer(config_entry, data.client)]) - # Register services. + # Register actions. platform = async_get_current_platform() jid_regex = vol.Match( @@ -931,7 +931,7 @@ async def async_unjoin_player(self) -> None: """Unjoin Beolink session. End session if leader.""" await self.async_beolink_leave() - # Custom services: + # Custom actions: async def async_beolink_join(self, beolink_jid: str | None = None) -> None: """Join a Beolink multi-room experience.""" if beolink_jid is None: From 5ec7a6ea1be8740385b767c2f010defbf7a7ab96 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Tue, 17 Sep 2024 15:26:36 +0200 Subject: [PATCH 30/34] Show playback progress for listeners --- homeassistant/components/bang_olufsen/media_player.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 61ed4d1a71edd..fa11969c4221e 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -586,10 +586,7 @@ def media_duration(self) -> int | None: @property def media_position(self) -> int | None: """Return the current playback progress.""" - # Don't show progress if the the device is a Beolink listener. - if self._remote_leader is None: - return self._playback_progress.progress - return None + return self._playback_progress.progress @property def media_image_url(self) -> str | None: From c4eaaece325791a683f9f1d20fe7b62c28b26575 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Tue, 17 Sep 2024 15:32:40 +0200 Subject: [PATCH 31/34] Fix testing --- .../bang_olufsen/test_media_player.py | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 172db62f5e301..33be93678a0bc 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -280,40 +280,6 @@ async def test_async_update_playback_progress( assert old_updated_at != new_updated_at -async def test_async_update_playback_progress_remote_leader( - hass: HomeAssistant, - mock_mozart_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test _async_update_playback_progress.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - - playback_metadata_callback = ( - mock_mozart_client.get_playback_metadata_notifications.call_args[0][0] - ) - playback_progress_callback = ( - mock_mozart_client.get_playback_progress_notifications.call_args[0][0] - ) - assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) - assert ATTR_MEDIA_POSITION not in states.attributes - old_updated_at = states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] - assert old_updated_at - - # Send metadata to set remote leader - playback_metadata_callback( - PlaybackContentMetadata(remote_leader=BeolinkLeader(friendly_name="", jid="")) - ) - playback_progress_callback(TEST_PLAYBACK_PROGRESS) - - assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) - assert ATTR_MEDIA_POSITION not in states.attributes - new_updated_at = states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] - assert new_updated_at - assert old_updated_at != new_updated_at - - async def test_async_update_playback_state( hass: HomeAssistant, mock_mozart_client: AsyncMock, From 03cf63e17e363909ba04543a73985d4e0903bcb4 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Thu, 19 Sep 2024 18:36:53 +0200 Subject: [PATCH 32/34] Remove useless initialization --- homeassistant/components/bang_olufsen/media_player.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index fa11969c4221e..0cfe3dea4360a 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -919,7 +919,6 @@ async def async_join_players(self, group_members: list[str]) -> None: await self.async_beolink_join() return - jids = [] # 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) From 140273de1e06e59b52f09aba8746b253be866df4 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 25 Sep 2024 17:06:17 +0200 Subject: [PATCH 33/34] Fix allstandby name --- homeassistant/components/bang_olufsen/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 8bda3a1833a89..3dc0bd1977fed 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -65,7 +65,7 @@ "description": "Leave a Beolink experience." }, "beolink_allstandby": { - "name": "Beolink allstandby", + "name": "Beolink all standby", "description": "Set all Connected Beolink devices to standby." } }, From d5171690a93281d9189d8366cd1ffa774dbdeb63 Mon Sep 17 00:00:00 2001 From: mj23000 Date: Wed, 25 Sep 2024 19:30:54 +0200 Subject: [PATCH 34/34] Fix various casts with assertions Fix comment placement Fix group leader group_members rebase error Replace entity_id method call with attribute --- .../components/bang_olufsen/media_player.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 1ece34295ab8d..9ef46575928d1 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -56,7 +56,7 @@ device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, @@ -422,8 +422,10 @@ async def _async_update_name_and_beolink(self) -> None: # Update device name device_registry = dr.async_get(self.hass) + assert self.device_entry is not None + device_registry.async_update_device( - device_id=cast(DeviceEntry, self.device_entry).id, + device_id=self.device_entry.id, name=beolink_self.friendly_name, ) @@ -434,10 +436,10 @@ async def _async_update_beolink(self) -> None: self._beolink_attributes = {} - # Add Beolink self - assert self.device_entry - assert self.device_entry.name + assert self.device_entry is not None + assert self.device_entry.name is not None + # Add Beolink self self._beolink_attributes = { "beolink": {"self": {self.device_entry.name: self._beolink_jid}} } @@ -452,24 +454,24 @@ async def _async_update_beolink(self) -> None: peer.jid ) - self._remote_leader = self._playback_metadata.remote_leader - # 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 + # Add leader if available in Home Assistant + leader = self._get_entity_id_from_jid(self._remote_leader.jid) group_members.append( - cast(str, self._get_entity_id_from_jid(self._remote_leader.jid)) + leader + if leader is not None + else f"leader_not_in_hass-{self._remote_leader.friendly_name}" ) # Add self - group_members.append( - cast(str, self._get_entity_id_from_jid(self._beolink_jid)) - ) + group_members.append(self.entity_id) self._beolink_attributes["beolink"]["leader"] = { self._remote_leader.friendly_name: self._remote_leader.jid,