Compare commits

...

4 Commits

Author SHA1 Message Date
Paul Bottein dd86e84470 Fix comments and bump lib 2026-05-27 10:49:39 +02:00
Paul Bottein b9f98274af Improve tests 2026-05-27 10:16:54 +02:00
Paul Bottein 02c7582fb6 Fix media type 2026-05-27 10:08:26 +02:00
Paul Bottein 00ebccf168 Add browse and play media support to Yoto 2026-05-27 00:20:42 +02:00
7 changed files with 703 additions and 13 deletions
+1 -1
View File
@@ -10,5 +10,5 @@
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==3.1.3"]
"requirements": ["yoto-api==3.1.4"]
}
+256 -2
View File
@@ -4,22 +4,28 @@ from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from yoto_api import Card, PlaybackStatus, YotoError, YotoPlayer
from yoto_api import Card, Chapter, PlaybackStatus, Track, YotoError, YotoPlayer
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
URI_SCHEME = "yoto"
PARALLEL_UPDATES = 0
# Yoto players expose 16 hardware volume steps.
@@ -56,6 +62,8 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
@@ -169,6 +177,220 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
"""Skip to the previous track on the active card."""
await self._async_run(self.coordinator.client.previous_track, self._player_id)
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play a Yoto card, chapter, or track from the browse tree."""
try:
card_id, chapter_key, track_key = _parse_uri(media_id)
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_media_id",
translation_placeholders={"media_id": media_id},
) from err
client = self.coordinator.client
card = client.library.get(card_id)
if card is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unknown_card",
translation_placeholders={"card_id": card_id},
)
if chapter_key is not None:
# Library list may not include chapters yet; fetch detail on demand.
if not card.chapters:
try:
await client.update_card_detail(card_id)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="card_detail_failed",
translation_placeholders={"error": str(err)},
) from err
chapter = card.chapters.get(chapter_key)
if chapter is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unknown_chapter",
translation_placeholders={
"chapter_key": chapter_key,
"card_id": card_id,
},
)
if track_key is not None and track_key not in chapter.tracks:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unknown_track",
translation_placeholders={
"track_key": track_key,
"card_id": card_id,
},
)
# A chapter plays from its first track.
if track_key is None and chapter.tracks:
track_key = next(iter(chapter.tracks))
# Chapter/track plays start at 0; a card play keeps its resume point.
seconds_in = 0 if track_key is not None else None
try:
await client.play_card(
self._player_id,
card_id,
chapter_key=chapter_key,
track_key=track_key,
seconds_in=seconds_in,
)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="play_failed",
translation_placeholders={"error": str(err)},
) from err
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Browse the Yoto card library."""
if not media_content_id:
return self._browse_root()
try:
card_id, chapter_key, _ = _parse_uri(media_content_id)
except ValueError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="invalid_media_id",
translation_placeholders={"media_id": media_content_id},
) from err
card = self.coordinator.client.library.get(card_id)
if card is None:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_card",
translation_placeholders={"card_id": card_id},
)
if not card.chapters:
try:
await self.coordinator.client.update_card_detail(card_id)
except YotoError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="card_detail_failed",
translation_placeholders={"error": str(err)},
) from err
if chapter_key is not None:
chapter = card.chapters.get(chapter_key)
if chapter is None:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_chapter",
translation_placeholders={
"chapter_key": chapter_key,
"card_id": card_id,
},
)
return self._browse_chapter(card_id, chapter_key, chapter)
return self._browse_card(card)
def _browse_root(self) -> BrowseMedia:
"""List every card in the user's library."""
return BrowseMedia(
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type=MediaType.MUSIC,
title="Yoto library",
can_play=False,
can_expand=True,
children=[
self._card_node(card)
for card in self.coordinator.client.library.values()
],
children_media_class=MediaClass.ALBUM,
)
def _browse_card(self, card: Card) -> BrowseMedia:
"""List a card's chapters, collapsing single-chapter cards to tracks."""
chapters = card.chapters
# Single-chapter cards expand straight to tracks (skip a one-item level).
if len(chapters) == 1:
chapter_key, chapter = next(iter(chapters.items()))
children = [
self._track_node(card.id, chapter_key, track_key, track)
for track_key, track in chapter.tracks.items()
]
else:
children = [
self._chapter_node(card.id, chapter_key, chapter)
for chapter_key, chapter in chapters.items()
]
node = self._card_node(card)
node.children = children
return node
def _browse_chapter(
self, card_id: str, chapter_key: str, chapter: Chapter
) -> BrowseMedia:
"""List the tracks of a chapter."""
node = self._chapter_node(card_id, chapter_key, chapter)
node.can_expand = True
node.children = [
self._track_node(card_id, chapter_key, track_key, track)
for track_key, track in chapter.tracks.items()
]
return node
def _card_node(self, card: Card) -> BrowseMedia:
"""Build a browse node for a card."""
# MUSIC (not ALBUM) so children render in list view with thumbnails.
return BrowseMedia(
media_class=MediaClass.MUSIC,
media_content_id=_build_uri(card.id),
media_content_type=MediaType.MUSIC,
title=card.title or card.id,
can_play=True,
can_expand=True,
thumbnail=card.cover_image_large,
)
def _chapter_node(
self, card_id: str, chapter_key: str, chapter: Chapter
) -> BrowseMedia:
"""Build a browse node for a chapter."""
# Single-track chapters aren't expandable: click plays the track.
return BrowseMedia(
media_class=MediaClass.MUSIC,
media_content_id=_build_uri(card_id, chapter_key),
media_content_type=MediaType.MUSIC,
title=chapter.title or chapter_key,
can_play=True,
can_expand=len(chapter.tracks) > 1,
thumbnail=chapter.icon,
)
def _track_node(
self, card_id: str, chapter_key: str, track_key: str, track: Track
) -> BrowseMedia:
"""Build a browse node for a track."""
return BrowseMedia(
media_class=MediaClass.MUSIC,
media_content_id=_build_uri(card_id, chapter_key, track_key),
media_content_type=MediaType.MUSIC,
title=track.title or track_key,
can_play=True,
can_expand=False,
thumbnail=track.icon,
)
async def _async_run(
self, func: Callable[..., Awaitable[Any]], /, *args: Any
) -> None:
@@ -181,3 +403,35 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
translation_key="command_failed",
translation_placeholders={"error": str(err)},
) from err
def _build_uri(
card_id: str,
chapter_key: str | None = None,
track_key: str | None = None,
) -> str:
"""Build a yoto:// URI from card/chapter/track parts."""
segments = [card_id]
if chapter_key is not None:
segments.append(chapter_key)
if track_key is not None:
segments.append(track_key)
return f"{URI_SCHEME}://{'/'.join(segments)}"
def _parse_uri(media_id: str) -> tuple[str, str | None, str | None]:
"""Parse a yoto:// URI into card/chapter/track parts.
Parsed manually because URL parsers lower-case the authority and Yoto
IDs are case-sensitive.
"""
prefix = f"{URI_SCHEME}://"
if not media_id.startswith(prefix):
raise ValueError(f"Not a Yoto media identifier: {media_id}")
parts = [segment for segment in media_id[len(prefix) :].split("/") if segment]
if not parts or len(parts) > 3:
raise ValueError(f"Not a Yoto media identifier: {media_id}")
card_id = parts[0]
chapter_key = parts[1] if len(parts) > 1 else None
track_key = parts[2] if len(parts) > 2 else None
return card_id, chapter_key, track_key
@@ -31,12 +31,30 @@
}
},
"exceptions": {
"card_detail_failed": {
"message": "Could not load Yoto card details: {error}"
},
"command_failed": {
"message": "Yoto command failed: {error}"
},
"invalid_media_id": {
"message": "Not a Yoto media identifier: {media_id}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"play_failed": {
"message": "Failed to play Yoto media: {error}"
},
"unknown_card": {
"message": "Unknown Yoto card: {card_id}"
},
"unknown_chapter": {
"message": "Unknown chapter {chapter_key} on card {card_id}"
},
"unknown_track": {
"message": "Unknown track {track_key} on card {card_id}"
},
"update_error": {
"message": "Error communicating with Yoto: {error}"
}
+1 -1
View File
@@ -3411,7 +3411,7 @@ yeelightsunflower==0.0.10
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==3.1.3
yoto-api==3.1.4
# homeassistant.components.youless
youless-api==2.2.0
+23 -1
View File
@@ -9,11 +9,13 @@ import jwt
import pytest
from yoto_api import (
Card,
Chapter,
Device,
PlaybackEvent,
PlaybackStatus,
PlayerInfo,
PlayerStatus,
Track,
YotoPlayer,
)
@@ -36,12 +38,32 @@ ACCESS_TOKEN = jwt.encode({"sub": USER_ID}, "test-secret-long-enough-for-hmac-sh
def _build_card() -> Card:
"""Build a representative Yoto library card."""
"""Build a representative Yoto library card with chapters and tracks."""
return Card(
id=CARD_ID,
title="Outer Space",
author="Ladybird Audio Adventures",
cover_image_large="https://example.test/cover.jpg",
chapters={
"01": Chapter(
key="01",
title="Introduction",
icon="https://example.test/ch01.png",
tracks={
"01-INT": Track(key="01-INT", title="Welcome", duration=120),
"01-MAIN": Track(
key="01-MAIN", title="The Story Begins", duration=240
),
},
),
"02": Chapter(
key="02",
title="Planets",
tracks={
"02-MER": Track(key="02-MER", title="Mercury", duration=180),
},
),
},
)
@@ -31,7 +31,7 @@
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 21559>,
'supported_features': <MediaPlayerEntityFeature: 153143>,
'translation_key': None,
'unique_id': 'player-test',
'unit_of_measurement': None,
@@ -50,7 +50,7 @@
'media_position': 120,
'media_position_updated_at': datetime.datetime(2026, 5, 8, 12, 0, tzinfo=datetime.timezone.utc),
'media_title': 'Introduction',
'supported_features': <MediaPlayerEntityFeature: 21559>,
'supported_features': <MediaPlayerEntityFeature: 153143>,
'volume_level': 0.5,
}),
'context': <ANY>,
+402 -6
View File
@@ -1,11 +1,12 @@
"""Tests for the Yoto media player platform."""
from typing import Any
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from yoto_api import Chapter, Track, YotoError
from homeassistant.components.media_player import (
ATTR_MEDIA_SEEK_POSITION,
@@ -17,22 +18,46 @@ from homeassistant.components.media_player import (
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_MEDIA_SEEK,
SERVICE_MEDIA_STOP,
SERVICE_PLAY_MEDIA,
SERVICE_VOLUME_SET,
MediaPlayerState,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
from tests.typing import WebSocketGenerator
ENTITY_ID = "media_player.nursery_yoto"
pytestmark = pytest.mark.usefixtures("setup_credentials")
def _build_chapters(structure: list[tuple[str, int]]) -> dict[str, Chapter]:
"""Build chapters from a list of ``(chapter_title, track_count)`` tuples."""
chapters = {}
for index, (title, track_count) in enumerate(structure, start=1):
chapter_key = f"{index:02d}"
chapters[chapter_key] = Chapter(
key=chapter_key,
title=title,
icon=f"https://example.test/ch{chapter_key}.png",
tracks={
f"{chapter_key}-{track:02d}": Track(
key=f"{chapter_key}-{track:02d}",
title=f"{title} - Track {track}",
duration=60,
)
for track in range(1, track_count + 1)
},
)
return chapters
@pytest.mark.usefixtures("mock_token_hex", "mock_yoto_client")
async def test_entity_state(
hass: HomeAssistant,
@@ -160,22 +185,393 @@ async def test_state_idle_before_first_event(
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == "idle"
assert state.state == MediaPlayerState.IDLE
@pytest.mark.parametrize(
("media_content_id", "expected_call"),
[
(
"yoto://card-test",
{"chapter_key": None, "track_key": None, "seconds_in": None},
),
(
"yoto://card-test/01",
{"chapter_key": "01", "track_key": "01-INT", "seconds_in": 0},
),
(
"yoto://card-test/01/01-INT",
{"chapter_key": "01", "track_key": "01-INT", "seconds_in": 0},
),
],
)
async def test_play_media(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
media_content_id: str,
expected_call: dict[str, Any],
) -> None:
"""play_media routes a yoto:// URI to the right play_card call."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
"media_content_type": "music",
"media_content_id": media_content_id,
},
blocking=True,
)
mock_yoto_client.play_card.assert_called_once_with(
"player-test", "card-test", **expected_call
)
@pytest.mark.usefixtures("mock_yoto_client")
@pytest.mark.parametrize(
"media_content_id",
[
pytest.param("spotify:track:abc", id="wrong_scheme"),
pytest.param("yoto://", id="empty_path"),
pytest.param("yoto://card/chapter/track/extra", id="too_many_segments"),
],
)
async def test_play_media_invalid_uri_raises(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
media_content_id: str,
) -> None:
"""A media_id that isn't a complete yoto:// URI is rejected."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
"media_content_type": "music",
"media_content_id": media_content_id,
},
blocking=True,
)
@pytest.mark.parametrize(
"media_content_id",
[
pytest.param("yoto://does-not-exist", id="unknown_card"),
pytest.param("yoto://card-test/does-not-exist", id="unknown_chapter"),
pytest.param("yoto://card-test/01/does-not-exist", id="unknown_track"),
],
)
async def test_play_media_unknown_target_raises(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
media_content_id: str,
) -> None:
"""A yoto:// URI pointing at unknown content is rejected."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
"media_content_type": "music",
"media_content_id": media_content_id,
},
blocking=True,
)
mock_yoto_client.play_card.assert_not_called()
@pytest.mark.usefixtures("mock_yoto_client")
async def test_browse_media_root_lists_cards(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Browsing without a content id lists every library card."""
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}
)
response = await client.receive_json()
assert response["success"]
children = response["result"]["children"]
assert len(children) == 1
assert children[0]["title"] == "Outer Space"
assert children[0]["media_content_id"] == "yoto://card-test"
assert children[0]["can_play"] is True
assert children[0]["can_expand"] is True
async def test_browse_card_with_multiple_chapters_and_multiple_tracks(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""N-N: multi-chapter card, multi-track chapters: list expandable chapters."""
card = mock_yoto_client.library["card-test"]
card.chapters = _build_chapters([("Intro", 2), ("Planets", 3)])
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,
"media_content_type": "music",
"media_content_id": "yoto://card-test",
}
)
response = await client.receive_json()
assert response["success"]
children = response["result"]["children"]
assert [c["title"] for c in children] == ["Intro", "Planets"]
assert all(c["can_expand"] for c in children)
async def test_browse_card_with_multiple_chapters_and_single_track(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""N-1: multi-chapter card, single-track chapters: list non-expandable chapters."""
card = mock_yoto_client.library["card-test"]
card.chapters = _build_chapters([("Song A", 1), ("Song B", 1), ("Song C", 1)])
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,
"media_content_type": "music",
"media_content_id": "yoto://card-test",
}
)
response = await client.receive_json()
assert response["success"]
children = response["result"]["children"]
assert [c["title"] for c in children] == ["Song A", "Song B", "Song C"]
assert not any(c["can_expand"] for c in children)
async def test_browse_card_with_single_chapter_collapses_to_tracks(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""1-N: single-chapter card expands straight to tracks (skips chapter level)."""
card = mock_yoto_client.library["card-test"]
card.chapters = _build_chapters([("Only chapter", 3)])
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,
"media_content_type": "music",
"media_content_id": "yoto://card-test",
}
)
response = await client.receive_json()
assert response["success"]
children = response["result"]["children"]
assert [c["title"] for c in children] == [
"Only chapter - Track 1",
"Only chapter - Track 2",
"Only chapter - Track 3",
]
assert children[0]["media_content_id"] == "yoto://card-test/01/01-01"
@pytest.mark.usefixtures("mock_yoto_client")
async def test_browse_media_chapter_shows_tracks(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Browsing a chapter lists its tracks."""
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,
"media_content_type": "playlist",
"media_content_id": "yoto://card-test/01",
}
)
response = await client.receive_json()
assert response["success"]
children = response["result"]["children"]
assert [c["title"] for c in children] == ["Welcome", "The Story Begins"]
assert children[0]["media_content_id"] == "yoto://card-test/01/01-INT"
async def test_browse_media_fetches_card_detail_lazily(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Browsing a card without loaded chapters triggers update_card_detail."""
card = mock_yoto_client.library["card-test"]
card.chapters = {}
async def _populate(card_id: str) -> None:
card.chapters = {"01": Chapter(key="01", title="Intro", tracks={})}
mock_yoto_client.update_card_detail.side_effect = _populate
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,
"media_content_type": "album",
"media_content_id": "yoto://card-test",
}
)
response = await client.receive_json()
assert response["success"]
mock_yoto_client.update_card_detail.assert_called_once_with("card-test")
@pytest.mark.usefixtures("mock_yoto_client")
async def test_browse_media_unknown_card_raises(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Browsing a card that's not in the library returns a browse error."""
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,
"media_content_type": "album",
"media_content_id": "yoto://does-not-exist",
}
)
response = await client.receive_json()
assert response["success"] is False
@pytest.mark.usefixtures("mock_yoto_client")
async def test_browse_media_unknown_chapter_raises(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Browsing a chapter that's not in the card returns a browse error."""
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,
"media_content_type": "playlist",
"media_content_id": "yoto://card-test/does-not-exist",
}
)
response = await client.receive_json()
assert response["success"] is False
async def test_browse_media_card_detail_failure_raises(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""A failure fetching card chapters bubbles up as a browse error."""
card = mock_yoto_client.library["card-test"]
card.chapters = {}
mock_yoto_client.update_card_detail.side_effect = YotoError("offline")
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,
"media_content_type": "album",
"media_content_id": "yoto://card-test",
}
)
response = await client.receive_json()
assert response["success"] is False
@pytest.mark.parametrize(
("client_method", "service", "service_data"),
[
pytest.param("pause", SERVICE_MEDIA_PAUSE, {}, id="playback"),
pytest.param(
"play_card",
SERVICE_PLAY_MEDIA,
{"media_content_type": "music", "media_content_id": "yoto://card-test"},
id="play_media",
),
],
)
async def test_command_error_raises(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
client_method: str,
service: str,
service_data: dict[str, Any],
) -> None:
"""Yoto command failures surface as HomeAssistantError."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.pause.side_effect = YotoError("nope")
getattr(mock_yoto_client, client_method).side_effect = YotoError("nope")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_PAUSE,
{ATTR_ENTITY_ID: ENTITY_ID},
service,
{ATTR_ENTITY_ID: ENTITY_ID, **service_data},
blocking=True,
)