diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index 9647c419da0..7a8c0bb4fbc 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -6,6 +6,10 @@ from aiorussound import CommandError DOMAIN = "russound_rio" +RUSSOUND_MEDIA_TYPE_PRESET = "preset" + +SELECT_SOURCE_DELAY = 0.5 + RUSSOUND_RIO_EXCEPTIONS = ( CommandError, ConnectionRefusedError, diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index aaaad05a2bc..29944de09b0 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -2,9 +2,10 @@ from __future__ import annotations +import asyncio import datetime as dt import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from aiorussound import Controller from aiorussound.const import FeatureFlag @@ -19,9 +20,11 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RussoundConfigEntry +from .const import DOMAIN, RUSSOUND_MEDIA_TYPE_PRESET, SELECT_SOURCE_DELAY from .entity import RussoundBaseEntity, command _LOGGER = logging.getLogger(__name__) @@ -45,6 +48,17 @@ async def async_setup_entry( ) +def _parse_preset_source_id(media_id: str) -> tuple[int | None, int]: + source_id = None + if "," in media_id: + source_id_str, preset_id_str = media_id.split(",", maxsplit=1) + source_id = int(source_id_str.strip()) + preset_id = int(preset_id_str.strip()) + else: + preset_id = int(media_id) + return source_id, preset_id + + class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): """Representation of a Russound Zone.""" @@ -58,6 +72,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PLAY_MEDIA ) _attr_name = None @@ -215,3 +230,37 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): async def async_media_seek(self, position: float) -> None: """Seek to a position in the current media.""" await self._zone.set_seek_time(int(position)) + + @command + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play media on the Russound zone.""" + + if media_type != RUSSOUND_MEDIA_TYPE_PRESET: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_type", + translation_placeholders={ + "media_type": media_type, + }, + ) + + try: + source_id, preset_id = _parse_preset_source_id(media_id) + except ValueError as ve: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="preset_non_integer", + translation_placeholders={"preset_id": media_id}, + ) from ve + if source_id: + await self._zone.select_source(source_id) + await asyncio.sleep(SELECT_SOURCE_DELAY) + if not self._source.presets or preset_id not in self._source.presets: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_preset", + translation_placeholders={"preset_id": media_id}, + ) + await self._zone.restore_preset(preset_id) diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index aa9a1cbc65d..9149a22aac0 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -67,6 +67,15 @@ }, "command_error": { "message": "Error executing {function_name} on entity {entity_id}" + }, + "unsupported_media_type": { + "message": "Unsupported media type for Russound zone: {media_type}" + }, + "missing_preset": { + "message": "The specified preset is not available for this source: {preset_id}" + }, + "preset_non_integer": { + "message": "Preset must be an integer, got: {preset_id}" } } } diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 81091e1d5a8..15922f76b9f 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -84,6 +84,7 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.set_treble = AsyncMock() zone.set_turn_on_volume = AsyncMock() zone.set_loudness = AsyncMock() + zone.restore_preset = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/fixtures/get_sources.json b/tests/components/russound_rio/fixtures/get_sources.json index e39d702b8a1..a9f4b4e14af 100644 --- a/tests/components/russound_rio/fixtures/get_sources.json +++ b/tests/components/russound_rio/fixtures/get_sources.json @@ -1,7 +1,14 @@ { "1": { "name": "Aux", - "type": "Miscellaneous Audio" + "type": "RNET AM/FM Tuner (Internal)", + "presets": { + "1": "WOOD", + "2": "89.7 MHz FM", + "7": "WWKR", + "8": "WKLA", + "11": "WGN" + } }, "2": { "name": "Spotify", diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index 04e1057565d..d8eacd5f30b 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -9,10 +9,13 @@ import pytest from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, ) from homeassistant.const import ( @@ -32,7 +35,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import mock_state_update, setup_integration from .const import ENTITY_ID_ZONE_1 @@ -253,3 +256,94 @@ async def test_media_seek( mock_russound_client.controllers[1].zones[1].set_seek_time.assert_called_once_with( 100 ) + + +async def test_play_media_preset_item_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test playing media with a preset item id.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "1", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].restore_preset.assert_called_once_with( + 1 + ) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "1,2", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].select_source.assert_called_once_with( + 1 + ) + mock_russound_client.controllers[1].zones[1].restore_preset.assert_called_with(2) + + with pytest.raises( + ServiceValidationError, + match="The specified preset is not available for this source: 10", + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "10", + }, + blocking=True, + ) + + with pytest.raises( + ServiceValidationError, match="Preset must be an integer, got: UNKNOWN_PRESET" + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "UNKNOWN_PRESET", + }, + blocking=True, + ) + + +async def test_play_media_unknown_type( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test playing media with an unsupported content type.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises( + HomeAssistantError, + match="Unsupported media type for Russound zone: unsupported_content_type", + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "unsupported_content_type", + ATTR_MEDIA_CONTENT_ID: "1", + }, + blocking=True, + )