Add media browsing to Russound RIO (#148248)

This commit is contained in:
Noah Husby
2025-08-11 16:40:40 -04:00
committed by GitHub
parent 9cae0e0acc
commit 715dc12792
5 changed files with 249 additions and 3 deletions

View File

@@ -0,0 +1,97 @@
"""Support for Russound media browsing."""
from aiorussound import RussoundClient, Zone
from aiorussound.const import FeatureFlag
from aiorussound.util import is_feature_supported
from homeassistant.components.media_player import BrowseMedia, MediaClass
from homeassistant.core import HomeAssistant
async def async_browse_media(
hass: HomeAssistant,
client: RussoundClient,
media_content_id: str | None,
media_content_type: str | None,
zone: Zone,
) -> BrowseMedia:
"""Browse media."""
if media_content_type == "presets":
return await _presets_payload(_find_presets_by_zone(client, zone))
return await _root_payload(hass, _find_presets_by_zone(client, zone))
async def _root_payload(
hass: HomeAssistant, presets_by_zone: dict[int, dict[int, str]]
) -> BrowseMedia:
"""Return root payload for Russound RIO."""
children: list[BrowseMedia] = []
if presets_by_zone:
children.append(
BrowseMedia(
title="Presets",
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="presets",
thumbnail="https://brands.home-assistant.io/_/russound_rio/logo.png",
can_play=False,
can_expand=True,
)
)
return BrowseMedia(
title="Russound",
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="root",
can_play=False,
can_expand=True,
children=children,
)
async def _presets_payload(presets_by_zone: dict[int, dict[int, str]]) -> BrowseMedia:
"""Create payload to list presets."""
children: list[BrowseMedia] = []
for source_id, presets in presets_by_zone.items():
for preset_id, preset_name in presets.items():
children.append(
BrowseMedia(
title=preset_name,
media_class=MediaClass.CHANNEL,
media_content_id=f"{source_id},{preset_id}",
media_content_type="preset",
can_play=True,
can_expand=False,
)
)
return BrowseMedia(
title="Presets",
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="presets",
can_play=False,
can_expand=True,
children=children,
)
def _find_presets_by_zone(
client: RussoundClient, zone: Zone
) -> dict[int, dict[int, str]]:
"""Returns a dict by {source_id: {preset_id: preset_name}}."""
assert client.rio_version
return {
source_id: source.presets
for source_id, source in client.sources.items()
if source.presets
and (
not is_feature_supported(
client.rio_version, FeatureFlag.SUPPORT_ZONE_SOURCE_EXCLUSION
)
or source_id in zone.enabled_sources
)
}

View File

@@ -13,6 +13,7 @@ from aiorussound.models import PlayStatus, Source
from aiorussound.util import is_feature_supported
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
@@ -23,7 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RussoundConfigEntry
from . import RussoundConfigEntry, media_browser
from .const import DOMAIN, RUSSOUND_MEDIA_TYPE_PRESET, SELECT_SOURCE_DELAY
from .entity import RussoundBaseEntity, command
@@ -65,7 +66,8 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_media_content_type = MediaType.MUSIC
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_SET
MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON
@@ -264,3 +266,13 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
translation_placeholders={"preset_id": media_id},
)
await self._zone.restore_preset(preset_id)
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the media browsing helper."""
return await media_browser.async_browse_media(
self.hass, self._client, media_content_id, media_content_type, self._zone
)

View File

@@ -7,7 +7,8 @@
"volume": "10",
"status": "ON",
"enabled": "True",
"current_source": "1"
"current_source": "1",
"enabled_sources": [1, 2]
},
"2": {
"name": "Kitchen",

View File

@@ -0,0 +1,75 @@
# serializer version: 1
# name: test_browse_media_root
list([
dict({
'can_expand': True,
'can_play': False,
'can_search': False,
'children_media_class': None,
'media_class': 'directory',
'media_content_id': '',
'media_content_type': 'presets',
'thumbnail': 'https://brands.home-assistant.io/_/russound_rio/logo.png',
'title': 'Presets',
}),
])
# ---
# name: test_browse_presets
list([
dict({
'can_expand': False,
'can_play': True,
'can_search': False,
'children_media_class': None,
'media_class': 'channel',
'media_content_id': '1,1',
'media_content_type': 'preset',
'thumbnail': None,
'title': 'WOOD',
}),
dict({
'can_expand': False,
'can_play': True,
'can_search': False,
'children_media_class': None,
'media_class': 'channel',
'media_content_id': '1,2',
'media_content_type': 'preset',
'thumbnail': None,
'title': '89.7 MHz FM',
}),
dict({
'can_expand': False,
'can_play': True,
'can_search': False,
'children_media_class': None,
'media_class': 'channel',
'media_content_id': '1,7',
'media_content_type': 'preset',
'thumbnail': None,
'title': 'WWKR',
}),
dict({
'can_expand': False,
'can_play': True,
'can_search': False,
'children_media_class': None,
'media_class': 'channel',
'media_content_id': '1,8',
'media_content_type': 'preset',
'thumbnail': None,
'title': 'WKLA',
}),
dict({
'can_expand': False,
'can_play': True,
'can_search': False,
'children_media_class': None,
'media_class': 'channel',
'media_content_id': '1,11',
'media_content_type': 'preset',
'thumbnail': None,
'title': 'WGN',
}),
])
# ---

View File

@@ -0,0 +1,61 @@
"""Tests for the Russound RIO media browser."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from .const import ENTITY_ID_ZONE_1
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
async def test_browse_media_root(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_russound_client: AsyncMock,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the root browse page."""
await setup_integration(hass, mock_config_entry)
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": ENTITY_ID_ZONE_1,
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"]["children"] == snapshot
async def test_browse_presets(
hass: HomeAssistant,
mock_russound_client: AsyncMock,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the presets browse page."""
await setup_integration(hass, mock_config_entry)
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": ENTITY_ID_ZONE_1,
"media_content_type": "presets",
"media_content_id": "",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"]["children"] == snapshot