mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 04:37:06 +00:00
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:
parent
2699eb62bd
commit
bb29c7a02f
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
@ -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)",
|
||||
]
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user