Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sound modes to Bang & Olufsen devices #121209

Open
wants to merge 7 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions homeassistant/components/bang_olufsen/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class BangOlufsenModel(StrEnum):
class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""

ACTIVE_LISTENING_MODE: Final[str] = "active_listening_mode"
PLAYBACK_ERROR: Final[str] = "playback_error"
PLAYBACK_METADATA: Final[str] = "playback_metadata"
PLAYBACK_PROGRESS: Final[str] = "playback_progress"
Expand Down
41 changes: 41 additions & 0 deletions homeassistant/components/bang_olufsen/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from mozart_api.models import (
Action,
Art,
ListeningModeProps,
ListeningModeRef,
OverlayPlayRequest,
OverlayPlayRequestTextToSpeechTextToSpeech,
PlaybackContentMetadata,
Expand Down Expand Up @@ -83,6 +85,7 @@
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.SELECT_SOUND_MODE
)


Expand Down Expand Up @@ -132,6 +135,7 @@ def __init__(self, entry: ConfigEntry, client: MozartClient) -> None:
self._sources: dict[str, str] = {}
self._state: str = MediaPlayerState.IDLE
self._video_sources: dict[str, str] = {}
self._sound_modes: dict[str, int] = {}

async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
Expand Down Expand Up @@ -196,6 +200,13 @@ async def async_added_to_hass(self) -> None:
self._async_update_volume,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{self._unique_id}_{WebsocketNotification.ACTIVE_LISTENING_MODE}",
self._async_update_sound_modes,
)
)

async def _initialize(self) -> None:
"""Initialize connection dependent variables."""
Expand Down Expand Up @@ -240,6 +251,8 @@ async def _initialize(self) -> None:
# If the device has been updated with new sources, then the API will fail here.
await self._async_update_sources()

await self._async_update_sound_modes()

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

Expand Down Expand Up @@ -358,6 +371,30 @@ def _async_update_volume(self, data: VolumeState) -> None:

self.async_write_ha_state()

@callback
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."""
Expand Down Expand Up @@ -546,6 +583,10 @@ async def async_select_source(self, source: str) -> None:
# Video
await self._client.post_remote_trigger(id=key)

async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select a sound mode."""
await self._client.activate_listening_mode(id=self._sound_modes[sound_mode])

async def async_play_media(
self,
media_type: MediaType | str,
Expand Down
12 changes: 12 additions & 0 deletions homeassistant/components/bang_olufsen/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging

from mozart_api.models import (
ListeningModeProps,
PlaybackContentMetadata,
PlaybackError,
PlaybackProgress,
Expand Down Expand Up @@ -49,6 +50,9 @@
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
)
Expand Down Expand Up @@ -88,6 +92,14 @@
_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(

Check warning on line 97 in homeassistant/components/bang_olufsen/websocket.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/bang_olufsen/websocket.py#L97

Added line #L97 was not covered by tests
self.hass,
f"{self._unique_id}_{WebsocketNotification.ACTIVE_LISTENING_MODE}",
notification,
)

def on_notification_notification(
self, notification: WebsocketNotificationTag
) -> None:
Expand Down
34 changes: 34 additions & 0 deletions tests/components/bang_olufsen/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
Action,
BeolinkPeer,
ContentItem,
ListeningMode,
ListeningModeFeatures,
ListeningModeRef,
ListeningModeTrigger,
PlaybackContentMetadata,
PlaybackProgress,
PlaybackState,
Expand All @@ -30,6 +34,9 @@
TEST_JID_1,
TEST_NAME,
TEST_SERIAL_NUMBER,
TEST_SOUND_MODE,
TEST_SOUND_MODE_2,
TEST_SOUND_MODE_NAME,
)

from tests.common import MockConfigEntry
Expand Down Expand Up @@ -223,6 +230,32 @@ def mock_mozart_client() -> Generator[AsyncMock]:
id="64c9da45-3682-44a4-8030-09ed3ef44160",
),
}
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()
Expand All @@ -237,6 +270,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
client.add_to_queue = AsyncMock()
client.post_remote_trigger = AsyncMock()
client.set_active_source = AsyncMock()
client.activate_listening_mode = AsyncMock()

# Non-REST API client methods
client.check_device_connection = AsyncMock()
Expand Down
12 changes: 12 additions & 0 deletions tests/components/bang_olufsen/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from mozart_api.exceptions import ApiException
from mozart_api.models import (
Action,
ListeningModeRef,
OverlayPlayRequest,
OverlayPlayRequestTextToSpeechTextToSpeech,
PlaybackContentMetadata,
Expand Down Expand Up @@ -177,3 +178,14 @@
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)",
]
48 changes: 48 additions & 0 deletions tests/components/bang_olufsen/test_media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
ATTR_MEDIA_TRACK,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
ATTR_SOUND_MODE,
ATTR_SOUND_MODE_LIST,
MediaPlayerState,
MediaType,
)
Expand All @@ -40,12 +42,15 @@
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,
TEST_DEEZER_PLAYLIST,
TEST_DEEZER_TRACK,
TEST_FALLBACK_SOURCES,
TEST_LISTENING_MODE_REF,
TEST_MEDIA_PLAYER_ENTITY_ID,
TEST_OVERLAY_INVALID_OFFSET_VOLUME_TTS,
TEST_OVERLAY_OFFSET_VOLUME_TTS,
Expand All @@ -58,6 +63,8 @@
TEST_RADIO_STATION,
TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT,
TEST_SERIAL_NUMBER,
TEST_SOUND_MODE_2,
TEST_SOUND_MODES,
TEST_SOURCES,
TEST_VIDEO_SOURCES,
TEST_VOLUME,
Expand Down Expand Up @@ -92,12 +99,15 @@ async def test_initialization(
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(
Expand Down Expand Up @@ -649,6 +659,44 @@ 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,
mock_config_entry,
) -> None:
"""Test async_select_sound_mode."""

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 states.attributes[ATTR_SOUND_MODE] == TEST_ACTIVE_SOUND_MODE_NAME

await hass.services.async_call(
"media_player",
"select_sound_mode",
{
ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID,
ATTR_SOUND_MODE: TEST_ACTIVE_SOUND_MODE_NAME_2,
},
blocking=True,
)

# Simulate WebSocket notification to change active sound mode
async_dispatcher_send(
hass,
f"{TEST_SERIAL_NUMBER}_{WebsocketNotification.ACTIVE_LISTENING_MODE}",
TEST_LISTENING_MODE_REF,
)

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_play_media_invalid_type(
hass: HomeAssistant, mock_mozart_client, mock_config_entry
) -> None:
Expand Down