mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
Add play media capability to Cambridge Audio (#129002)
This commit is contained in:
parent
1663d8dfa9
commit
5f839ad3ee
@ -17,3 +17,7 @@ STREAM_MAGIC_EXCEPTIONS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
CONNECT_TIMEOUT = 5
|
CONNECT_TIMEOUT = 5
|
||||||
|
|
||||||
|
CAMBRIDGE_MEDIA_TYPE_PRESET = "preset"
|
||||||
|
CAMBRIDGE_MEDIA_TYPE_AIRABLE = "airable"
|
||||||
|
CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO = "internet_radio"
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aiostreammagic import (
|
from aiostreammagic import (
|
||||||
RepeatMode as CambridgeRepeatMode,
|
RepeatMode as CambridgeRepeatMode,
|
||||||
@ -21,14 +22,22 @@ from homeassistant.components.media_player import (
|
|||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
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
|
from .entity import CambridgeAudioEntity, command
|
||||||
|
|
||||||
BASE_FEATURES = (
|
BASE_FEATURES = (
|
||||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
| MediaPlayerEntityFeature.TURN_OFF
|
| MediaPlayerEntityFeature.TURN_OFF
|
||||||
| MediaPlayerEntityFeature.TURN_ON
|
| MediaPlayerEntityFeature.TURN_ON
|
||||||
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||||
)
|
)
|
||||||
|
|
||||||
PREAMP_FEATURES = (
|
PREAMP_FEATURES = (
|
||||||
@ -285,3 +294,48 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
|
|||||||
if repeat in {RepeatMode.ALL, RepeatMode.ONE}:
|
if repeat in {RepeatMode.ALL, RepeatMode.ONE}:
|
||||||
repeat_mode = CambridgeRepeatMode.ALL
|
repeat_mode = CambridgeRepeatMode.ALL
|
||||||
await self.client.set_repeat(repeat_mode)
|
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)
|
||||||
|
@ -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}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,15 @@
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from unittest.mock import Mock, patch
|
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
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.cambridge_audio.const import DOMAIN
|
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)
|
load_fixture("get_now_playing.json", DOMAIN)
|
||||||
)
|
)
|
||||||
client.display = Display.from_json(load_fixture("get_display.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.is_connected = Mock(return_value=True)
|
||||||
client.position_last_updated = client.play_state.position
|
client.position_last_updated = client.play_state.position
|
||||||
client.unregister_state_update_callbacks = AsyncMock(return_value=True)
|
client.unregister_state_update_callbacks = AsyncMock(return_value=True)
|
||||||
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -11,10 +11,13 @@ from aiostreammagic.models import CallbackType
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
|
ATTR_MEDIA_CONTENT_ID,
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE,
|
||||||
ATTR_MEDIA_REPEAT,
|
ATTR_MEDIA_REPEAT,
|
||||||
ATTR_MEDIA_SEEK_POSITION,
|
ATTR_MEDIA_SEEK_POSITION,
|
||||||
ATTR_MEDIA_SHUFFLE,
|
ATTR_MEDIA_SHUFFLE,
|
||||||
DOMAIN as MP_DOMAIN,
|
DOMAIN as MP_DOMAIN,
|
||||||
|
SERVICE_PLAY_MEDIA,
|
||||||
MediaPlayerEntityFeature,
|
MediaPlayerEntityFeature,
|
||||||
RepeatMode,
|
RepeatMode,
|
||||||
)
|
)
|
||||||
@ -40,6 +43,7 @@ from homeassistant.const import (
|
|||||||
STATE_STANDBY,
|
STATE_STANDBY,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
|
|
||||||
from . import setup_integration
|
from . import setup_integration
|
||||||
from .const import ENTITY_ID
|
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)
|
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,
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user