Add play media capability to Cambridge Audio (#129002)

This commit is contained in:
Noah Husby 2024-10-24 14:33:53 -04:00 committed by GitHub
parent 1663d8dfa9
commit 5f839ad3ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 239 additions and 1 deletions

View file

@ -17,3 +17,7 @@ STREAM_MAGIC_EXCEPTIONS = (
)
CONNECT_TIMEOUT = 5
CAMBRIDGE_MEDIA_TYPE_PRESET = "preset"
CAMBRIDGE_MEDIA_TYPE_AIRABLE = "airable"
CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO = "internet_radio"

View file

@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from aiostreammagic import (
RepeatMode as CambridgeRepeatMode,
@ -21,14 +22,22 @@ from homeassistant.components.media_player import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
CAMBRIDGE_MEDIA_TYPE_AIRABLE,
CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO,
CAMBRIDGE_MEDIA_TYPE_PRESET,
DOMAIN,
)
from .entity import CambridgeAudioEntity, command
BASE_FEATURES = (
MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.PLAY_MEDIA
)
PREAMP_FEATURES = (
@ -285,3 +294,48 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
if repeat in {RepeatMode.ALL, RepeatMode.ONE}:
repeat_mode = CambridgeRepeatMode.ALL
await self.client.set_repeat(repeat_mode)
@command
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play media on the Cambridge Audio device."""
if media_type not in {
CAMBRIDGE_MEDIA_TYPE_PRESET,
CAMBRIDGE_MEDIA_TYPE_AIRABLE,
CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO,
}:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unsupported_media_type",
translation_placeholders={"media_type": media_type},
)
if media_type == CAMBRIDGE_MEDIA_TYPE_PRESET:
try:
preset_id = int(media_id)
except ValueError as ve:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="preset_non_integer",
translation_placeholders={"preset_id": media_id},
) from ve
preset = None
for _preset in self.client.preset_list.presets:
if _preset.preset_id == preset_id:
preset = _preset
if not preset:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_preset",
translation_placeholders={"preset_id": media_id},
)
await self.client.recall_preset(preset.preset_id)
if media_type == CAMBRIDGE_MEDIA_TYPE_AIRABLE:
preset_id = int(media_id)
await self.client.play_radio_airable("Radio", preset_id)
if media_type == CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO:
await self.client.play_radio_url("Radio", media_id)

View file

@ -34,5 +34,16 @@
}
}
}
},
"exceptions": {
"unsupported_media_type": {
"message": "Unsupported media type for Cambridge Audio device: {media_type}"
},
"missing_preset": {
"message": "Missing preset for media_id: {preset_id}"
},
"preset_non_integer": {
"message": "Preset must be an integer, got: {preset_id}"
}
}
}

View file

@ -3,7 +3,15 @@
from collections.abc import Generator
from unittest.mock import Mock, patch
from aiostreammagic.models import Display, Info, NowPlaying, PlayState, Source, State
from aiostreammagic.models import (
Display,
Info,
NowPlaying,
PlayState,
PresetList,
Source,
State,
)
import pytest
from homeassistant.components.cambridge_audio.const import DOMAIN
@ -51,6 +59,9 @@ def mock_stream_magic_client() -> Generator[AsyncMock]:
load_fixture("get_now_playing.json", DOMAIN)
)
client.display = Display.from_json(load_fixture("get_display.json", DOMAIN))
client.preset_list = PresetList.from_json(
load_fixture("get_presets_list.json", DOMAIN)
)
client.is_connected = Mock(return_value=True)
client.position_last_updated = client.play_state.position
client.unregister_state_update_callbacks = AsyncMock(return_value=True)

View file

@ -0,0 +1,34 @@
{
"start": 1,
"end": 99,
"max_presets": 99,
"presettable": true,
"presets": [
{
"id": 1,
"name": "Chicago House Radio",
"type": "Radio",
"class": "stream.radio",
"state": "OK",
"is_playing": false,
"art_url": "https://static.airable.io/43/68/432868.png",
"airable_radio_id": 5317566146608442
},
{
"id": 2,
"name": "Spotify: Good & Evil",
"type": "Spotify",
"class": "stream.service.spotify",
"state": "OK",
"is_playing": true,
"art_url": "https://i.scdn.co/image/ab67616d0000b27325a5a1ed28871e8e53e62d59"
},
{
"id": 3,
"name": "Unknown Preset Type",
"type": "Unknown",
"class": "stream.unknown",
"state": "OK"
}
]
}

View file

@ -11,10 +11,13 @@ from aiostreammagic.models import CallbackType
import pytest
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_REPEAT,
ATTR_MEDIA_SEEK_POSITION,
ATTR_MEDIA_SHUFFLE,
DOMAIN as MP_DOMAIN,
SERVICE_PLAY_MEDIA,
MediaPlayerEntityFeature,
RepeatMode,
)
@ -40,6 +43,7 @@ from homeassistant.const import (
STATE_STANDBY,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from . import setup_integration
from .const import ENTITY_ID
@ -301,3 +305,123 @@ async def test_media_seek(
)
mock_stream_magic_client.media_seek.assert_called_once_with(100)
async def test_play_media_preset_item_id(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_stream_magic_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,
ATTR_MEDIA_CONTENT_TYPE: "preset",
ATTR_MEDIA_CONTENT_ID: "1",
},
blocking=True,
)
assert mock_stream_magic_client.recall_preset.call_count == 1
assert mock_stream_magic_client.recall_preset.call_args_list[0].args[0] == 1
with pytest.raises(ServiceValidationError, match="Missing preset for media_id: 10"):
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
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,
ATTR_MEDIA_CONTENT_TYPE: "preset",
ATTR_MEDIA_CONTENT_ID: "UNKNOWN_PRESET",
},
blocking=True,
)
async def test_play_media_airable_radio_id(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_stream_magic_client: AsyncMock,
) -> None:
"""Test playing media with an airable radio id."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: "airable",
ATTR_MEDIA_CONTENT_ID: "12345678",
},
blocking=True,
)
assert mock_stream_magic_client.play_radio_airable.call_count == 1
call_args = mock_stream_magic_client.play_radio_airable.call_args_list[0].args
assert call_args[0] == "Radio"
assert call_args[1] == 12345678
async def test_play_media_internet_radio(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_stream_magic_client: AsyncMock,
) -> None:
"""Test playing media with a url."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: "internet_radio",
ATTR_MEDIA_CONTENT_ID: "https://example.com",
},
blocking=True,
)
assert mock_stream_magic_client.play_radio_url.call_count == 1
call_args = mock_stream_magic_client.play_radio_url.call_args_list[0].args
assert call_args[0] == "Radio"
assert call_args[1] == "https://example.com"
async def test_play_media_unknown_type(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_stream_magic_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 Cambridge Audio device: unsupported_content_type",
):
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: "unsupported_content_type",
ATTR_MEDIA_CONTENT_ID: "1",
},
blocking=True,
)