Add repeat media controls to Bang & Olufsen (#128170)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Markus Jacobsen 2024-10-25 20:10:08 +02:00 committed by GitHub
parent 2da0a91a36
commit 66ca424d3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 107 additions and 2 deletions

View File

@ -7,7 +7,11 @@ from typing import Final
from mozart_api.models import Source, SourceArray, SourceTypeEnum from mozart_api.models import Source, SourceArray, SourceTypeEnum
from homeassistant.components.media_player import MediaPlayerState, MediaType from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
RepeatMode,
)
class BangOlufsenSource: class BangOlufsenSource:
@ -36,6 +40,17 @@ BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
"unknown": MediaPlayerState.IDLE, "unknown": MediaPlayerState.IDLE,
} }
# Dict used for translating Home Assistant settings to device repeat settings.
BANG_OLUFSEN_REPEAT_FROM_HA: dict[RepeatMode, str] = {
RepeatMode.ALL: "all",
RepeatMode.ONE: "track",
RepeatMode.OFF: "none",
}
# Dict used for translating device repeat settings to Home Assistant settings.
BANG_OLUFSEN_REPEAT_TO_HA: dict[str, RepeatMode] = {
value: key for key, value in BANG_OLUFSEN_REPEAT_FROM_HA.items()
}
# Media types for play_media # Media types for play_media
class BangOlufsenMediaType(StrEnum): class BangOlufsenMediaType(StrEnum):

View File

@ -3,10 +3,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import contextlib
from datetime import timedelta
import json import json
import logging import logging
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from aiohttp import ClientConnectorError
from mozart_api import __version__ as MOZART_API_VERSION from mozart_api import __version__ as MOZART_API_VERSION
from mozart_api.exceptions import ApiException from mozart_api.exceptions import ApiException
from mozart_api.models import ( from mozart_api.models import (
@ -22,6 +25,7 @@ from mozart_api.models import (
PlaybackProgress, PlaybackProgress,
PlayQueueItem, PlayQueueItem,
PlayQueueItemType, PlayQueueItemType,
PlayQueueSettings,
RenderingState, RenderingState,
SceneProperties, SceneProperties,
SoftwareUpdateState, SoftwareUpdateState,
@ -44,6 +48,7 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
RepeatMode,
async_process_play_media_url, async_process_play_media_url,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -58,6 +63,8 @@ from homeassistant.util.dt import utcnow
from . import BangOlufsenConfigEntry from . import BangOlufsenConfigEntry
from .const import ( from .const import (
BANG_OLUFSEN_REPEAT_FROM_HA,
BANG_OLUFSEN_REPEAT_TO_HA,
BANG_OLUFSEN_STATES, BANG_OLUFSEN_STATES,
CONF_BEOLINK_JID, CONF_BEOLINK_JID,
CONNECTION_STATUS, CONNECTION_STATUS,
@ -72,6 +79,8 @@ from .const import (
from .entity import BangOlufsenEntity from .entity import BangOlufsenEntity
from .util import get_serial_number_from_jid from .util import get_serial_number_from_jid
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
BANG_OLUFSEN_FEATURES = ( BANG_OLUFSEN_FEATURES = (
@ -84,6 +93,7 @@ BANG_OLUFSEN_FEATURES = (
| MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.STOP
@ -131,6 +141,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
serial_number=self._unique_id, serial_number=self._unique_id,
) )
self._attr_unique_id = self._unique_id self._attr_unique_id = self._unique_id
self._attr_should_poll = True
# Misc. variables. # Misc. variables.
self._audio_sources: dict[str, str] = {} self._audio_sources: dict[str, str] = {}
@ -220,6 +231,16 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
await self._async_update_sound_modes() await self._async_update_sound_modes()
async def async_update(self) -> None:
"""Update queue settings."""
# The WebSocket event listener is the main handler for connection state.
# The polling updates do therefore not set the device as available or unavailable
with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
queue_settings = await self._client.get_settings_queue(_request_timeout=5)
if queue_settings.repeat is not None:
self._attr_repeat = BANG_OLUFSEN_REPEAT_TO_HA[queue_settings.repeat]
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."""
@ -630,6 +651,14 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
"""Clear the current playback queue.""" """Clear the current playback queue."""
await self._client.post_clear_queue() await self._client.post_clear_queue()
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set playback queues to repeat."""
await self._client.set_settings_queue(
play_queue_settings=PlayQueueSettings(
repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
)
)
async def async_select_source(self, source: str) -> None: async def async_select_source(self, source: str) -> None:
"""Select an input source.""" """Select an input source."""
if source not in self._sources.values(): if source not in self._sources.values():

View File

@ -15,6 +15,7 @@ from mozart_api.models import (
PlaybackContentMetadata, PlaybackContentMetadata,
PlaybackProgress, PlaybackProgress,
PlaybackState, PlaybackState,
PlayQueueSettings,
ProductState, ProductState,
RemoteMenuItem, RemoteMenuItem,
RenderingState, RenderingState,
@ -315,6 +316,12 @@ def mock_mozart_client() -> Generator[AsyncMock]:
href="", href="",
id=123, id=123,
) )
client.get_settings_queue = AsyncMock()
client.get_settings_queue.return_value = PlayQueueSettings(
repeat="none",
shuffle=False,
)
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()
@ -336,6 +343,7 @@ def mock_mozart_client() -> Generator[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() client.activate_listening_mode = AsyncMock()
client.set_settings_queue = AsyncMock()
# Non-REST API client methods # Non-REST API client methods
client.check_device_connection = AsyncMock() client.check_device_connection = AsyncMock()

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
from mozart_api.models import ( from mozart_api.models import (
BeolinkLeader, BeolinkLeader,
PlaybackContentMetadata, PlaybackContentMetadata,
PlayQueueSettings,
RenderingState, RenderingState,
Source, Source,
WebsocketNotificationTag, WebsocketNotificationTag,
@ -14,6 +15,7 @@ from mozart_api.models import (
import pytest import pytest
from homeassistant.components.bang_olufsen.const import ( from homeassistant.components.bang_olufsen.const import (
BANG_OLUFSEN_REPEAT_FROM_HA,
BANG_OLUFSEN_STATES, BANG_OLUFSEN_STATES,
DOMAIN, DOMAIN,
BangOlufsenSource, BangOlufsenSource,
@ -32,6 +34,7 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_EXTRA, ATTR_MEDIA_EXTRA,
ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION,
ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_POSITION_UPDATED_AT,
ATTR_MEDIA_REPEAT,
ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SEEK_POSITION,
ATTR_MEDIA_TITLE, ATTR_MEDIA_TITLE,
ATTR_MEDIA_TRACK, ATTR_MEDIA_TRACK,
@ -54,8 +57,9 @@ from homeassistant.components.media_player import (
SERVICE_VOLUME_SET, SERVICE_VOLUME_SET,
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
RepeatMode,
) )
from homeassistant.const import ATTR_ENTITY_ID from homeassistant.const import ATTR_ENTITY_ID, SERVICE_REPEAT_SET
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -1421,3 +1425,52 @@ async def test_async_unjoin_player(
) )
mock_mozart_client.post_beolink_leave.assert_called_once() mock_mozart_client.post_beolink_leave.assert_called_once()
@pytest.mark.parametrize(
("repeat"),
[
# Repeat all
(RepeatMode.ALL),
# Repeat track
(RepeatMode.ONE),
# Repeat none
(RepeatMode.OFF),
],
)
async def test_async_set_repeat(
hass: HomeAssistant,
mock_mozart_client: AsyncMock,
mock_config_entry: MockConfigEntry,
repeat: RepeatMode,
) -> None:
"""Test async_set_repeat."""
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 ATTR_MEDIA_REPEAT not in states.attributes
# Set the return value of the repeat endpoint to match service call
mock_mozart_client.get_settings_queue.return_value = PlayQueueSettings(
repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_REPEAT_SET,
{
ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID,
ATTR_MEDIA_REPEAT: repeat,
},
blocking=True,
)
mock_mozart_client.set_settings_queue.assert_called_once_with(
play_queue_settings=PlayQueueSettings(
repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
)
)
# Test the BANG_OLUFSEN_REPEAT_TO_HA dict by checking property value
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
assert states.attributes[ATTR_MEDIA_REPEAT] == repeat