From 66ca424d3af27e7b8d902b2e7d178c753b94733c Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Fri, 25 Oct 2024 20:10:08 +0200 Subject: [PATCH] Add repeat media controls to Bang & Olufsen (#128170) Co-authored-by: Joost Lekkerkerker --- .../components/bang_olufsen/const.py | 17 +++++- .../components/bang_olufsen/media_player.py | 29 ++++++++++ tests/components/bang_olufsen/conftest.py | 8 +++ .../bang_olufsen/test_media_player.py | 55 ++++++++++++++++++- 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 64ee4cf275d..95d0aca6ed6 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -7,7 +7,11 @@ from typing import Final 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: @@ -36,6 +40,17 @@ BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = { "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 class BangOlufsenMediaType(StrEnum): diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 7c6ea640b38..7aedcaeb5db 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -3,10 +3,13 @@ from __future__ import annotations from collections.abc import Callable +import contextlib +from datetime import timedelta import json import logging from typing import TYPE_CHECKING, Any, cast +from aiohttp import ClientConnectorError from mozart_api import __version__ as MOZART_API_VERSION from mozart_api.exceptions import ApiException from mozart_api.models import ( @@ -22,6 +25,7 @@ from mozart_api.models import ( PlaybackProgress, PlayQueueItem, PlayQueueItemType, + PlayQueueSettings, RenderingState, SceneProperties, SoftwareUpdateState, @@ -44,6 +48,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, MediaType, + RepeatMode, async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry @@ -58,6 +63,8 @@ from homeassistant.util.dt import utcnow from . import BangOlufsenConfigEntry from .const import ( + BANG_OLUFSEN_REPEAT_FROM_HA, + BANG_OLUFSEN_REPEAT_TO_HA, BANG_OLUFSEN_STATES, CONF_BEOLINK_JID, CONNECTION_STATUS, @@ -72,6 +79,8 @@ from .const import ( from .entity import BangOlufsenEntity from .util import get_serial_number_from_jid +SCAN_INTERVAL = timedelta(seconds=30) + _LOGGER = logging.getLogger(__name__) BANG_OLUFSEN_FEATURES = ( @@ -84,6 +93,7 @@ BANG_OLUFSEN_FEATURES = ( | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.STOP @@ -131,6 +141,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): serial_number=self._unique_id, ) self._attr_unique_id = self._unique_id + self._attr_should_poll = True # Misc. variables. self._audio_sources: dict[str, str] = {} @@ -220,6 +231,16 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): 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: """Get sources for the specific product.""" @@ -630,6 +651,14 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): """Clear the current playback 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: """Select an input source.""" if source not in self._sources.values(): diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index e415dd50c72..a644b395c69 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -15,6 +15,7 @@ from mozart_api.models import ( PlaybackContentMetadata, PlaybackProgress, PlaybackState, + PlayQueueSettings, ProductState, RemoteMenuItem, RenderingState, @@ -315,6 +316,12 @@ def mock_mozart_client() -> Generator[AsyncMock]: href="", id=123, ) + client.get_settings_queue = AsyncMock() + client.get_settings_queue.return_value = PlayQueueSettings( + repeat="none", + shuffle=False, + ) + client.post_standby = AsyncMock() client.set_current_volume_level = AsyncMock() client.set_volume_mute = AsyncMock() @@ -336,6 +343,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: client.post_beolink_allstandby = AsyncMock() client.join_latest_beolink_experience = AsyncMock() client.activate_listening_mode = AsyncMock() + client.set_settings_queue = AsyncMock() # Non-REST API client methods client.check_device_connection = AsyncMock() diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index ff42ae2a867..a19423d8e82 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from mozart_api.models import ( BeolinkLeader, PlaybackContentMetadata, + PlayQueueSettings, RenderingState, Source, WebsocketNotificationTag, @@ -14,6 +15,7 @@ from mozart_api.models import ( import pytest from homeassistant.components.bang_olufsen.const import ( + BANG_OLUFSEN_REPEAT_FROM_HA, BANG_OLUFSEN_STATES, DOMAIN, BangOlufsenSource, @@ -32,6 +34,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_EXTRA, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, @@ -54,8 +57,9 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_SET, MediaPlayerState, 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.exceptions import HomeAssistantError, ServiceValidationError 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() + + +@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