mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 15:47:12 +00:00
Add play media support to Russound RIO (#148240)
This commit is contained in:
parent
6d0891e970
commit
d44b822295
@ -6,6 +6,10 @@ from aiorussound import CommandError
|
|||||||
|
|
||||||
DOMAIN = "russound_rio"
|
DOMAIN = "russound_rio"
|
||||||
|
|
||||||
|
RUSSOUND_MEDIA_TYPE_PRESET = "preset"
|
||||||
|
|
||||||
|
SELECT_SOURCE_DELAY = 0.5
|
||||||
|
|
||||||
RUSSOUND_RIO_EXCEPTIONS = (
|
RUSSOUND_RIO_EXCEPTIONS = (
|
||||||
CommandError,
|
CommandError,
|
||||||
ConnectionRefusedError,
|
ConnectionRefusedError,
|
||||||
|
@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from aiorussound import Controller
|
from aiorussound import Controller
|
||||||
from aiorussound.const import FeatureFlag
|
from aiorussound.const import FeatureFlag
|
||||||
@ -19,9 +20,11 @@ from homeassistant.components.media_player import (
|
|||||||
MediaType,
|
MediaType,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import RussoundConfigEntry
|
from . import RussoundConfigEntry
|
||||||
|
from .const import DOMAIN, RUSSOUND_MEDIA_TYPE_PRESET, SELECT_SOURCE_DELAY
|
||||||
from .entity import RussoundBaseEntity, command
|
from .entity import RussoundBaseEntity, command
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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):
|
class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
|
||||||
"""Representation of a Russound Zone."""
|
"""Representation of a Russound Zone."""
|
||||||
|
|
||||||
@ -58,6 +72,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
|
|||||||
| MediaPlayerEntityFeature.TURN_OFF
|
| MediaPlayerEntityFeature.TURN_OFF
|
||||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
| MediaPlayerEntityFeature.SEEK
|
| MediaPlayerEntityFeature.SEEK
|
||||||
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||||
)
|
)
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
|
|
||||||
@ -215,3 +230,37 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
|
|||||||
async def async_media_seek(self, position: float) -> None:
|
async def async_media_seek(self, position: float) -> None:
|
||||||
"""Seek to a position in the current media."""
|
"""Seek to a position in the current media."""
|
||||||
await self._zone.set_seek_time(int(position))
|
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)
|
||||||
|
@ -67,6 +67,15 @@
|
|||||||
},
|
},
|
||||||
"command_error": {
|
"command_error": {
|
||||||
"message": "Error executing {function_name} on entity {entity_id}"
|
"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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,6 +84,7 @@ def mock_russound_client() -> Generator[AsyncMock]:
|
|||||||
zone.set_treble = AsyncMock()
|
zone.set_treble = AsyncMock()
|
||||||
zone.set_turn_on_volume = AsyncMock()
|
zone.set_turn_on_volume = AsyncMock()
|
||||||
zone.set_loudness = AsyncMock()
|
zone.set_loudness = AsyncMock()
|
||||||
|
zone.restore_preset = AsyncMock()
|
||||||
|
|
||||||
client.controllers = {
|
client.controllers = {
|
||||||
1: Controller(
|
1: Controller(
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
{
|
{
|
||||||
"1": {
|
"1": {
|
||||||
"name": "Aux",
|
"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": {
|
"2": {
|
||||||
"name": "Spotify",
|
"name": "Spotify",
|
||||||
|
@ -9,10 +9,13 @@ import pytest
|
|||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
ATTR_INPUT_SOURCE,
|
ATTR_INPUT_SOURCE,
|
||||||
|
ATTR_MEDIA_CONTENT_ID,
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE,
|
||||||
ATTR_MEDIA_SEEK_POSITION,
|
ATTR_MEDIA_SEEK_POSITION,
|
||||||
ATTR_MEDIA_VOLUME_LEVEL,
|
ATTR_MEDIA_VOLUME_LEVEL,
|
||||||
ATTR_MEDIA_VOLUME_MUTED,
|
ATTR_MEDIA_VOLUME_MUTED,
|
||||||
DOMAIN as MP_DOMAIN,
|
DOMAIN as MP_DOMAIN,
|
||||||
|
SERVICE_PLAY_MEDIA,
|
||||||
SERVICE_SELECT_SOURCE,
|
SERVICE_SELECT_SOURCE,
|
||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
@ -32,7 +35,7 @@ from homeassistant.const import (
|
|||||||
STATE_PLAYING,
|
STATE_PLAYING,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
|
|
||||||
from . import mock_state_update, setup_integration
|
from . import mock_state_update, setup_integration
|
||||||
from .const import ENTITY_ID_ZONE_1
|
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(
|
mock_russound_client.controllers[1].zones[1].set_seek_time.assert_called_once_with(
|
||||||
100
|
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,
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user