diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 6803a141cee..64ee4cf275d 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -68,6 +68,7 @@ class BangOlufsenModel(StrEnum): class WebsocketNotification(StrEnum): """Enum for WebSocket notification types.""" + ACTIVE_LISTENING_MODE = "active_listening_mode" PLAYBACK_ERROR = "playback_error" PLAYBACK_METADATA = "playback_metadata" PLAYBACK_PROGRESS = "playback_progress" diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index bd74f15ddf9..474319fc3a1 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -13,6 +13,8 @@ from mozart_api.models import ( Action, Art, BeolinkLeader, + ListeningModeProps, + ListeningModeRef, OverlayPlayRequest, OverlayPlayRequestTextToSpeechTextToSpeech, PlaybackContentMetadata, @@ -88,6 +90,7 @@ BANG_OLUFSEN_FEATURES = ( | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) @@ -137,6 +140,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self._sources: dict[str, str] = {} self._state: str = MediaPlayerState.IDLE self._video_sources: dict[str, str] = {} + self._sound_modes: dict[str, int] = {} # Beolink compatible sources self._beolink_sources: dict[str, bool] = {} @@ -148,6 +152,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): signal_handlers: dict[str, Callable] = { CONNECTION_STATUS: self._async_update_connection_state, + WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes, WebsocketNotification.BEOLINK: self._async_update_beolink, WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, @@ -211,6 +216,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # If the device has been updated with new sources, then the API will fail here. await self._async_update_sources() + await self._async_update_sound_modes() + async def _async_update_sources(self) -> None: """Get sources for the specific product.""" @@ -432,6 +439,29 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # Return JID return cast(str, config_entry.data[CONF_BEOLINK_JID]) + async def _async_update_sound_modes( + self, active_sound_mode: ListeningModeProps | ListeningModeRef | None = None + ) -> None: + """Update the available sound modes.""" + sound_modes = await self._client.get_listening_mode_set() + + if active_sound_mode is None: + active_sound_mode = await self._client.get_active_listening_mode() + + # Add the key to make the labels unique (As labels are not required to be unique on B&O devices) + for sound_mode in sound_modes: + label = f"{sound_mode.name} ({sound_mode.id})" + + self._sound_modes[label] = sound_mode.id + + if sound_mode.id == active_sound_mode.id: + self._attr_sound_mode = label + + # Set available options + self._attr_sound_mode_list = list(self._sound_modes.keys()) + + self.async_write_ha_state() + @property def state(self) -> MediaPlayerState: """Return the current state of the media player.""" @@ -620,6 +650,21 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # Video await self._client.post_remote_trigger(id=key) + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select a sound mode.""" + # Ensure only known sound modes known by the integration can be activated. + if sound_mode not in self._sound_modes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_mode", + translation_placeholders={ + "invalid_sound_mode": sound_mode, + "valid_sound_modes": ", ".join(list(self._sound_modes.keys())), + }, + ) + + await self._client.activate_listening_mode(id=self._sound_modes[sound_mode]) + async def async_play_media( self, media_type: MediaType | str, diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 6c4b7f1370c..b0cb88985d2 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -43,6 +43,9 @@ }, "invalid_grouping_entity": { "message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" + }, + "invalid_sound_mode": { + "message": "{invalid_sound_mode} is an invalid sound mode. Valid values are: {valid_sound_modes}." } } } diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 6e5c1d4c76c..3519fcd9a48 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging from mozart_api.models import ( + ListeningModeProps, PlaybackContentMetadata, PlaybackError, PlaybackProgress, @@ -50,6 +51,9 @@ class BangOlufsenWebsocket(BangOlufsenBase): self._client.get_notification_notifications(self.on_notification_notification) self._client.get_on_connection_lost(self.on_connection_lost) self._client.get_on_connection(self.on_connection) + self._client.get_active_listening_mode_notifications( + self.on_active_listening_mode + ) self._client.get_playback_error_notifications( self.on_playback_error_notification ) @@ -89,6 +93,14 @@ class BangOlufsenWebsocket(BangOlufsenBase): _LOGGER.error("Lost connection to the %s", self.entry.title) self._update_connection_status() + def on_active_listening_mode(self, notification: ListeningModeProps) -> None: + """Send active_listening_mode dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.ACTIVE_LISTENING_MODE}", + notification, + ) + def on_notification_notification( self, notification: WebsocketNotificationTag ) -> None: diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 0ad9d34a170..ff29592b137 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -7,6 +7,10 @@ from mozart_api.models import ( Action, BeolinkPeer, ContentItem, + ListeningMode, + ListeningModeFeatures, + ListeningModeRef, + ListeningModeTrigger, PlaybackContentMetadata, PlaybackProgress, PlaybackState, @@ -38,6 +42,9 @@ from .const import ( TEST_NAME_2, TEST_SERIAL_NUMBER, TEST_SERIAL_NUMBER_2, + TEST_SOUND_MODE, + TEST_SOUND_MODE_2, + TEST_SOUND_MODE_NAME, ) from tests.common import MockConfigEntry @@ -263,6 +270,32 @@ def mock_mozart_client() -> Generator[AsyncMock]: BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3), ] + client.get_listening_mode_set = AsyncMock() + client.get_listening_mode_set.return_value = [ + ListeningMode( + id=TEST_SOUND_MODE, + name=TEST_SOUND_MODE_NAME, + features=ListeningModeFeatures(), + triggers=[ListeningModeTrigger()], + ), + ListeningMode( + id=TEST_SOUND_MODE_2, + name=TEST_SOUND_MODE_NAME, + features=ListeningModeFeatures(), + triggers=[ListeningModeTrigger()], + ), + ListeningMode( + id=345, + name=f"{TEST_SOUND_MODE_NAME} 2", + features=ListeningModeFeatures(), + triggers=[ListeningModeTrigger()], + ), + ] + client.get_active_listening_mode = AsyncMock() + client.get_active_listening_mode.return_value = ListeningModeRef( + href="", + id=123, + ) client.post_standby = AsyncMock() client.set_current_volume_level = AsyncMock() client.set_volume_mute = AsyncMock() @@ -283,6 +316,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: client.post_beolink_leave = AsyncMock() client.post_beolink_allstandby = AsyncMock() client.join_latest_beolink_experience = AsyncMock() + client.activate_listening_mode = 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 e8d8653c5b7..7cbe81dc06a 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -6,6 +6,7 @@ from unittest.mock import Mock from mozart_api.exceptions import ApiException from mozart_api.models import ( Action, + ListeningModeRef, OverlayPlayRequest, OverlayPlayRequestTextToSpeechTextToSpeech, PlaybackContentMetadata, @@ -197,3 +198,14 @@ TEST_DEEZER_INVALID_FLOW = ApiException( data='{"message": "Couldn\'t start user flow for me"}', # codespell:ignore ), ) +TEST_SOUND_MODE = 123 +TEST_SOUND_MODE_2 = 234 +TEST_SOUND_MODE_NAME = "Test Listening Mode" +TEST_ACTIVE_SOUND_MODE_NAME = f"{TEST_SOUND_MODE_NAME} ({TEST_SOUND_MODE})" +TEST_ACTIVE_SOUND_MODE_NAME_2 = f"{TEST_SOUND_MODE_NAME} ({TEST_SOUND_MODE_2})" +TEST_LISTENING_MODE_REF = ListeningModeRef(href="", id=TEST_SOUND_MODE_2) +TEST_SOUND_MODES = [ + TEST_ACTIVE_SOUND_MODE_NAME, + TEST_ACTIVE_SOUND_MODE_NAME_2, + f"{TEST_SOUND_MODE_NAME} 2 (345)", +] diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 12dee794709..ff42ae2a867 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -37,6 +37,8 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_MEDIA_NEXT_TRACK, @@ -45,6 +47,7 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_TURN_OFF, SERVICE_VOLUME_MUTE, @@ -58,6 +61,8 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component from .const import ( + TEST_ACTIVE_SOUND_MODE_NAME, + TEST_ACTIVE_SOUND_MODE_NAME_2, TEST_AUDIO_SOURCES, TEST_DEEZER_FLOW, TEST_DEEZER_INVALID_FLOW, @@ -66,6 +71,7 @@ from .const import ( TEST_FALLBACK_SOURCES, TEST_FRIENDLY_NAME_2, TEST_JID_2, + TEST_LISTENING_MODE_REF, TEST_MEDIA_PLAYER_ENTITY_ID, TEST_MEDIA_PLAYER_ENTITY_ID_2, TEST_MEDIA_PLAYER_ENTITY_ID_3, @@ -79,6 +85,8 @@ from .const import ( TEST_PLAYBACK_STATE_TURN_OFF, TEST_RADIO_STATION, TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT, + TEST_SOUND_MODE_2, + TEST_SOUND_MODES, TEST_SOURCES, TEST_VIDEO_SOURCES, TEST_VOLUME, @@ -113,12 +121,15 @@ async def test_initialization( assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_SOURCES assert states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] + assert states.attributes[ATTR_SOUND_MODE_LIST] == TEST_SOUND_MODES # Check API calls mock_mozart_client.get_softwareupdate_status.assert_called_once() 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_listening_mode_set.assert_called_once() + mock_mozart_client.get_active_listening_mode.assert_called_once() async def test_async_update_sources_audio_only( @@ -779,6 +790,69 @@ async def test_async_select_source( assert mock_mozart_client.post_remote_trigger.call_count == video_source_call +async def test_async_select_sound_mode( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_select_sound_mode.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes[ATTR_SOUND_MODE] == TEST_ACTIVE_SOUND_MODE_NAME + + active_listening_mode_callback = ( + mock_mozart_client.get_active_listening_mode_notifications.call_args[0][0] + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + ATTR_SOUND_MODE: TEST_ACTIVE_SOUND_MODE_NAME_2, + }, + blocking=True, + ) + + active_listening_mode_callback(TEST_LISTENING_MODE_REF) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes[ATTR_SOUND_MODE] == TEST_ACTIVE_SOUND_MODE_NAME_2 + + mock_mozart_client.activate_listening_mode.assert_called_once_with( + id=TEST_SOUND_MODE_2 + ) + + +async def test_async_select_sound_mode_invalid( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_select_sound_mode with an invalid sound_mode.""" + + 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_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + ATTR_SOUND_MODE: "invalid_sound_mode", + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_sound_mode" + assert exc_info.errisinstance(ServiceValidationError) + + async def test_async_play_media_invalid_type( hass: HomeAssistant, mock_mozart_client: AsyncMock,