Add play media support to Russound RIO (#148240)

This commit is contained in:
Noah Husby 2025-07-08 02:51:18 -04:00 committed by GitHub
parent 6d0891e970
commit d44b822295
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 167 additions and 3 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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}"
}
}
}

View File

@ -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(

View File

@ -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",

View File

@ -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,
)