Add sound modes to Bang & Olufsen devices (#121209)

* Add sound mode functionality

* Fix naming

* Change unique sound mode symbol

* Add testing for sound modes

* Add test typing

* Use constants for service call parameters

* Add state assertions

* Remove invalid decorator

* Add valid sound mode check

* Add test for invalid sound mode
This commit is contained in:
Markus Jacobsen 2024-09-25 15:58:24 +02:00 committed by GitHub
parent 2699eb62bd
commit bb29c7a02f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 181 additions and 0 deletions

View File

@ -68,6 +68,7 @@ class BangOlufsenModel(StrEnum):
class WebsocketNotification(StrEnum): class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types.""" """Enum for WebSocket notification types."""
ACTIVE_LISTENING_MODE = "active_listening_mode"
PLAYBACK_ERROR = "playback_error" PLAYBACK_ERROR = "playback_error"
PLAYBACK_METADATA = "playback_metadata" PLAYBACK_METADATA = "playback_metadata"
PLAYBACK_PROGRESS = "playback_progress" PLAYBACK_PROGRESS = "playback_progress"

View File

@ -13,6 +13,8 @@ from mozart_api.models import (
Action, Action,
Art, Art,
BeolinkLeader, BeolinkLeader,
ListeningModeProps,
ListeningModeRef,
OverlayPlayRequest, OverlayPlayRequest,
OverlayPlayRequestTextToSpeechTextToSpeech, OverlayPlayRequestTextToSpeechTextToSpeech,
PlaybackContentMetadata, PlaybackContentMetadata,
@ -88,6 +90,7 @@ BANG_OLUFSEN_FEATURES = (
| MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.SELECT_SOUND_MODE
) )
@ -137,6 +140,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
self._sources: dict[str, str] = {} self._sources: dict[str, str] = {}
self._state: str = MediaPlayerState.IDLE self._state: str = MediaPlayerState.IDLE
self._video_sources: dict[str, str] = {} self._video_sources: dict[str, str] = {}
self._sound_modes: dict[str, int] = {}
# Beolink compatible sources # Beolink compatible sources
self._beolink_sources: dict[str, bool] = {} self._beolink_sources: dict[str, bool] = {}
@ -148,6 +152,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
signal_handlers: dict[str, Callable] = { signal_handlers: dict[str, Callable] = {
CONNECTION_STATUS: self._async_update_connection_state, CONNECTION_STATUS: self._async_update_connection_state,
WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes,
WebsocketNotification.BEOLINK: self._async_update_beolink, WebsocketNotification.BEOLINK: self._async_update_beolink,
WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, 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. # If the device has been updated with new sources, then the API will fail here.
await self._async_update_sources() await self._async_update_sources()
await self._async_update_sound_modes()
async def _async_update_sources(self) -> None: async def _async_update_sources(self) -> None:
"""Get sources for the specific product.""" """Get sources for the specific product."""
@ -432,6 +439,29 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Return JID # Return JID
return cast(str, config_entry.data[CONF_BEOLINK_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 @property
def state(self) -> MediaPlayerState: def state(self) -> MediaPlayerState:
"""Return the current state of the media player.""" """Return the current state of the media player."""
@ -620,6 +650,21 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
# Video # Video
await self._client.post_remote_trigger(id=key) 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( async def async_play_media(
self, self,
media_type: MediaType | str, media_type: MediaType | str,

View File

@ -43,6 +43,9 @@
}, },
"invalid_grouping_entity": { "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?" "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}."
} }
} }
} }

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
from mozart_api.models import ( from mozart_api.models import (
ListeningModeProps,
PlaybackContentMetadata, PlaybackContentMetadata,
PlaybackError, PlaybackError,
PlaybackProgress, PlaybackProgress,
@ -50,6 +51,9 @@ class BangOlufsenWebsocket(BangOlufsenBase):
self._client.get_notification_notifications(self.on_notification_notification) self._client.get_notification_notifications(self.on_notification_notification)
self._client.get_on_connection_lost(self.on_connection_lost) self._client.get_on_connection_lost(self.on_connection_lost)
self._client.get_on_connection(self.on_connection) 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._client.get_playback_error_notifications(
self.on_playback_error_notification self.on_playback_error_notification
) )
@ -89,6 +93,14 @@ class BangOlufsenWebsocket(BangOlufsenBase):
_LOGGER.error("Lost connection to the %s", self.entry.title) _LOGGER.error("Lost connection to the %s", self.entry.title)
self._update_connection_status() 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( def on_notification_notification(
self, notification: WebsocketNotificationTag self, notification: WebsocketNotificationTag
) -> None: ) -> None:

View File

@ -7,6 +7,10 @@ from mozart_api.models import (
Action, Action,
BeolinkPeer, BeolinkPeer,
ContentItem, ContentItem,
ListeningMode,
ListeningModeFeatures,
ListeningModeRef,
ListeningModeTrigger,
PlaybackContentMetadata, PlaybackContentMetadata,
PlaybackProgress, PlaybackProgress,
PlaybackState, PlaybackState,
@ -38,6 +42,9 @@ from .const import (
TEST_NAME_2, TEST_NAME_2,
TEST_SERIAL_NUMBER, TEST_SERIAL_NUMBER,
TEST_SERIAL_NUMBER_2, TEST_SERIAL_NUMBER_2,
TEST_SOUND_MODE,
TEST_SOUND_MODE_2,
TEST_SOUND_MODE_NAME,
) )
from tests.common import MockConfigEntry 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), 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.post_standby = AsyncMock()
client.set_current_volume_level = AsyncMock() client.set_current_volume_level = AsyncMock()
client.set_volume_mute = AsyncMock() client.set_volume_mute = AsyncMock()
@ -283,6 +316,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
client.post_beolink_leave = AsyncMock() client.post_beolink_leave = AsyncMock()
client.post_beolink_allstandby = AsyncMock() client.post_beolink_allstandby = AsyncMock()
client.join_latest_beolink_experience = AsyncMock() client.join_latest_beolink_experience = AsyncMock()
client.activate_listening_mode = AsyncMock()
# Non-REST API client methods # Non-REST API client methods
client.check_device_connection = AsyncMock() client.check_device_connection = AsyncMock()

View File

@ -6,6 +6,7 @@ from unittest.mock import Mock
from mozart_api.exceptions import ApiException from mozart_api.exceptions import ApiException
from mozart_api.models import ( from mozart_api.models import (
Action, Action,
ListeningModeRef,
OverlayPlayRequest, OverlayPlayRequest,
OverlayPlayRequestTextToSpeechTextToSpeech, OverlayPlayRequestTextToSpeechTextToSpeech,
PlaybackContentMetadata, PlaybackContentMetadata,
@ -197,3 +198,14 @@ TEST_DEEZER_INVALID_FLOW = ApiException(
data='{"message": "Couldn\'t start user flow for me"}', # codespell:ignore 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)",
]

View File

@ -37,6 +37,8 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_TRACK, ATTR_MEDIA_TRACK,
ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED, ATTR_MEDIA_VOLUME_MUTED,
ATTR_SOUND_MODE,
ATTR_SOUND_MODE_LIST,
DOMAIN as MEDIA_PLAYER_DOMAIN, DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_CLEAR_PLAYLIST, SERVICE_CLEAR_PLAYLIST,
SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_NEXT_TRACK,
@ -45,6 +47,7 @@ from homeassistant.components.media_player import (
SERVICE_MEDIA_SEEK, SERVICE_MEDIA_SEEK,
SERVICE_MEDIA_STOP, SERVICE_MEDIA_STOP,
SERVICE_PLAY_MEDIA, SERVICE_PLAY_MEDIA,
SERVICE_SELECT_SOUND_MODE,
SERVICE_SELECT_SOURCE, SERVICE_SELECT_SOURCE,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
SERVICE_VOLUME_MUTE, SERVICE_VOLUME_MUTE,
@ -58,6 +61,8 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .const import ( from .const import (
TEST_ACTIVE_SOUND_MODE_NAME,
TEST_ACTIVE_SOUND_MODE_NAME_2,
TEST_AUDIO_SOURCES, TEST_AUDIO_SOURCES,
TEST_DEEZER_FLOW, TEST_DEEZER_FLOW,
TEST_DEEZER_INVALID_FLOW, TEST_DEEZER_INVALID_FLOW,
@ -66,6 +71,7 @@ from .const import (
TEST_FALLBACK_SOURCES, TEST_FALLBACK_SOURCES,
TEST_FRIENDLY_NAME_2, TEST_FRIENDLY_NAME_2,
TEST_JID_2, TEST_JID_2,
TEST_LISTENING_MODE_REF,
TEST_MEDIA_PLAYER_ENTITY_ID, TEST_MEDIA_PLAYER_ENTITY_ID,
TEST_MEDIA_PLAYER_ENTITY_ID_2, TEST_MEDIA_PLAYER_ENTITY_ID_2,
TEST_MEDIA_PLAYER_ENTITY_ID_3, TEST_MEDIA_PLAYER_ENTITY_ID_3,
@ -79,6 +85,8 @@ from .const import (
TEST_PLAYBACK_STATE_TURN_OFF, TEST_PLAYBACK_STATE_TURN_OFF,
TEST_RADIO_STATION, TEST_RADIO_STATION,
TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT, TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT,
TEST_SOUND_MODE_2,
TEST_SOUND_MODES,
TEST_SOURCES, TEST_SOURCES,
TEST_VIDEO_SOURCES, TEST_VIDEO_SOURCES,
TEST_VOLUME, TEST_VOLUME,
@ -113,12 +121,15 @@ async def test_initialization(
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_SOURCES assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_SOURCES
assert states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] assert states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT]
assert states.attributes[ATTR_SOUND_MODE_LIST] == TEST_SOUND_MODES
# Check API calls # Check API calls
mock_mozart_client.get_softwareupdate_status.assert_called_once() mock_mozart_client.get_softwareupdate_status.assert_called_once()
mock_mozart_client.get_product_state.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_available_sources.assert_called_once()
mock_mozart_client.get_remote_menu.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( 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 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( async def test_async_play_media_invalid_type(
hass: HomeAssistant, hass: HomeAssistant,
mock_mozart_client: AsyncMock, mock_mozart_client: AsyncMock,