Compare commits

...

10 Commits

Author SHA1 Message Date
Paul Bottein fd93c112ed Check media id format 2026-05-19 14:14:24 +02:00
Paul Bottein fcc0ab5452 Remove status calls 2026-05-19 13:53:15 +02:00
Paul Bottein 26804ab408 Bump requirements 2026-05-19 12:10:39 +02:00
Paul Bottein 177dcbc751 Clean up 2026-05-19 12:10:39 +02:00
Paul Bottein 6d64d98250 Improve test naming 2026-05-19 12:10:39 +02:00
Paul Bottein 8234c61ca8 Improve coverage 2026-05-19 12:10:39 +02:00
Paul Bottein 99d6be1097 Migrate Yoto integration to async client 2026-05-19 12:10:39 +02:00
Paul Bottein e720c1b378 Continue integration 2026-05-19 12:10:39 +02:00
Paul Bottein 4a0ba0a830 Bump lib version 2026-05-19 12:10:39 +02:00
Paul Bottein 4fb1aa6923 WIP: Add yoto integration 2026-05-19 12:10:38 +02:00
22 changed files with 1758 additions and 0 deletions
Generated
+2
View File
@@ -2056,6 +2056,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/yi/ @bachya
/homeassistant/components/yolink/ @matrixd2
/tests/components/yolink/ @matrixd2
/homeassistant/components/yoto/ @cdnninja @piitaya
/tests/components/yoto/ @cdnninja @piitaya
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
+45
View File
@@ -0,0 +1,45 @@
"""The Yoto integration."""
import aiohttp
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
"""Set up Yoto from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
coordinator = YotoDataUpdateCoordinator(hass, entry, session)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
"""Unload a Yoto config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,40 @@
"""Application credentials platform for the Yoto integration."""
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
LocalOAuth2ImplementationWithPkce,
)
from .const import YOTO_AUDIENCE, YOTO_SCOPES
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
async def async_get_auth_implementation(
hass: HomeAssistant,
auth_domain: str,
credential: ClientCredential,
) -> YotoOAuth2Implementation:
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
return YotoOAuth2Implementation(
hass,
auth_domain,
credential.client_id,
AUTHORIZE_URL,
TOKEN_URL,
credential.client_secret,
)
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
@property
def extra_authorize_data(self) -> dict:
"""Append Yoto's audience and scopes to every authorize URL."""
return super().extra_authorize_data | {
"audience": YOTO_AUDIENCE,
"scope": " ".join(YOTO_SCOPES),
}
@@ -0,0 +1,58 @@
"""Config flow for the Yoto integration."""
from collections.abc import Mapping
import logging
from typing import Any
from yoto_api import YotoError, get_account_id
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import _LOGGER, DOMAIN
class YotoOAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Authorize Home Assistant with a Yoto account using OAuth2."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return the logger used for the OAuth2 flow."""
return _LOGGER
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Identify the Yoto account from the access token."""
try:
user_id = get_account_id(data["token"]["access_token"])
except YotoError:
return self.async_abort(reason="oauth_unauthorized")
await self.async_set_unique_id(user_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="account_mismatch")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data=data,
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Yoto", data=data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Trigger the OAuth flow when the stored token is rejected."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthorization before opening the browser flow."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
+26
View File
@@ -0,0 +1,26 @@
"""Constants for the Yoto integration."""
from datetime import timedelta
import logging
DOMAIN = "yoto"
_LOGGER = logging.getLogger(__package__)
YOTO_AUDIENCE = "https://api.yotoplay.com"
YOTO_SCOPES = [
"offline_access",
"family:view",
"family:devices:view",
"family:devices:control",
"family:devices:manage",
"family:library:view",
"user:content:view",
"user:icons:manage",
]
SCAN_INTERVAL = timedelta(minutes=5)
STATUS_PUSH_INTERVAL = timedelta(seconds=60)
MANUFACTURER = "Yoto"
@@ -0,0 +1,149 @@
"""Coordinator for the Yoto integration."""
from datetime import datetime
import aiohttp
from yoto_api import AuthenticationError, Token, YotoClient, YotoError, YotoPlayer
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN, SCAN_INTERVAL, STATUS_PUSH_INTERVAL
type YotoConfigEntry = ConfigEntry[YotoDataUpdateCoordinator]
class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
"""Coordinator that drives the Yoto cloud polling cycle."""
config_entry: YotoConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: YotoConfigEntry,
session: OAuth2Session,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self._session = session
self.client = YotoClient(session=async_get_clientsession(hass))
self._sync_token()
def _sync_token(self) -> None:
"""Sync the OAuth2 access token to the Yoto client."""
token = self._session.token
self.client.token = Token(
access_token=token[CONF_ACCESS_TOKEN],
refresh_token=token.get("refresh_token", ""),
token_type=token.get("token_type", "Bearer"),
valid_until=dt_util.utc_from_timestamp(token["expires_at"]),
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.client.refresh()
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except YotoError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
await self._async_load_library()
try:
await self.client.connect_events(
list(self.client.players), self._mqtt_event
)
except YotoError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
# The MQTT data/status topic is not pushed spontaneously; the firmware
# only emits it in response to a command/status/request publish.
self.config_entry.async_on_unload(
async_track_time_interval(
self.hass, self._async_status_push_tick, STATUS_PUSH_INTERVAL
)
)
async def _async_update_data(self) -> dict[str, YotoPlayer]:
"""Fetch fresh data from the Yoto cloud."""
# _async_setup already populated the client; skip the duplicate first fetch.
if self.data is None:
return self.client.players
try:
await self._session.async_ensure_token_valid()
except aiohttp.ClientError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
self._sync_token()
try:
await self.client.refresh()
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except YotoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
return self.client.players
async def _async_load_library(self) -> None:
"""Load the card library; failures only affect titles and artwork."""
try:
await self.client.update_library()
except YotoError as err:
_LOGGER.warning("Could not load Yoto card library: %s", err)
async def _async_status_push_tick(self, _now: datetime) -> None:
"""Ask each player to push a fresh status snapshot over MQTT."""
# The response arrives via the on_update callback, which already
# triggers async_set_updated_data — nothing to await here.
if not self.client.is_mqtt_connected:
return
for device_id in list(self.client.players):
await self.client.request_status_push(device_id)
def _mqtt_event(self, _player: YotoPlayer) -> None:
"""Handle a real-time update pushed by the Yoto MQTT broker."""
self.async_set_updated_data(self.client.players)
async def async_shutdown(self) -> None:
"""Shut down the coordinator."""
await self.client.disconnect_events()
await super().async_shutdown()
+46
View File
@@ -0,0 +1,46 @@
"""Base entity for the Yoto integration."""
from yoto_api import YotoPlayer
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import YotoDataUpdateCoordinator
class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
"""Base class for Yoto entities tied to a single player."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._player_id = player.id
device = player.device
mac = player.info.mac
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, player.id)},
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
manufacturer=MANUFACTURER,
model=player.model,
model_id=device.device_type,
hw_version=device.generation,
name=player.name,
sw_version=player.info.firmware_version,
)
@property
def player(self) -> YotoPlayer:
"""Return the live player record from the client."""
return self.coordinator.data[self._player_id]
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._player_id in self.coordinator.data
@@ -0,0 +1,13 @@
{
"domain": "yoto",
"name": "Yoto",
"codeowners": ["@cdnninja", "@piitaya"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yoto",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==3.0.0"]
}
@@ -0,0 +1,230 @@
"""Media player platform for the Yoto integration."""
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from yoto_api import Card, PlaybackStatus, YotoError, YotoPlayer
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant
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
PARALLEL_UPDATES = 0
# Yoto players expose 16 hardware volume steps.
VOLUME_STEP = 1 / 16
PLAYBACK_STATE_MAP = {
PlaybackStatus.PLAYING: MediaPlayerState.PLAYING,
PlaybackStatus.PAUSED: MediaPlayerState.PAUSED,
PlaybackStatus.STOPPED: MediaPlayerState.IDLE,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto media player platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoMediaPlayer(coordinator, player)
for player in coordinator.client.players.values()
)
class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
"""Representation of a Yoto Player."""
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_media_image_remotely_accessible = True
_attr_volume_step = VOLUME_STEP
_attr_supported_features = (
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
)
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
) -> None:
"""Initialize the media player."""
super().__init__(coordinator, player)
self._attr_unique_id = player.id
@property
def state(self) -> MediaPlayerState:
"""Return the playback state."""
if self.player.status.is_online is False:
return MediaPlayerState.OFF
return PLAYBACK_STATE_MAP.get(
self.player.last_event.playback_status, MediaPlayerState.IDLE
)
@property
def volume_level(self) -> float | None:
"""Return the current volume level."""
return self.player.last_event.volume_percentage
@property
def media_duration(self) -> int | None:
"""Return the current track duration in seconds."""
return self.player.last_event.track_length
@property
def media_position(self) -> int | None:
"""Return the current playback position in seconds."""
return self.player.last_event.position
@property
def media_position_updated_at(self) -> datetime | None:
"""Return the time the media position was last refreshed."""
return self.player.last_event_received_at
@property
def media_title(self) -> str | None:
"""Return the title of the currently playing track."""
event = self.player.last_event
return event.track_title or event.chapter_title
@property
def media_album_name(self) -> str | None:
"""Return the title of the active card."""
card = self._current_card()
return card.title if card else None
@property
def media_artist(self) -> str | None:
"""Return the author of the active card."""
card = self._current_card()
return card.author if card else None
@property
def media_image_url(self) -> str | None:
"""Return the cover image URL of the active card."""
card = self._current_card()
return card.cover_image_large if card else None
def _current_card(self) -> Card | None:
"""Return the cached library card for the currently active media."""
card_id = self.player.last_event.card_id
if not card_id:
return None
return self.coordinator.client.library.get(card_id)
async def async_media_play(self) -> None:
"""Resume playback."""
await self._async_run(self.coordinator.client.resume, self._player_id)
async def async_media_pause(self) -> None:
"""Pause playback."""
await self._async_run(self.coordinator.client.pause, self._player_id)
async def async_media_stop(self) -> None:
"""Stop playback."""
await self._async_run(self.coordinator.client.stop, self._player_id)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the playback volume (0.0 - 1.0)."""
await self._async_run(
self.coordinator.client.set_volume,
self._player_id,
round(volume * 100),
)
async def async_media_seek(self, position: float) -> None:
"""Seek to ``position`` seconds in the active track."""
await self._async_run(
self.coordinator.client.seek, self._player_id, int(position)
)
async def async_media_next_track(self) -> None:
"""Skip to the next track on the active card."""
await self._async_run(self.coordinator.client.next_track, self._player_id)
async def async_media_previous_track(self) -> None:
"""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 by id.
``media_id`` accepts either a bare card id or
``"<card_id>+<chapter_key>+<track_key>+<seconds_in>"``. Extra fields
are optional and may be left empty.
"""
card_id, chapter_key, track_key, seconds_in = _parse_media_id(media_id)
await self._async_run(
self.coordinator.client.play_card,
self._player_id,
card_id=card_id,
seconds_in=seconds_in,
chapter_key=chapter_key,
track_key=track_key,
)
async def _async_run(
self,
func: Callable[..., Awaitable[Any]],
/,
*args: Any,
**kwargs: Any,
) -> None:
"""Await a Yoto command and surface failures as HA errors."""
try:
await func(*args, **kwargs)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"error": str(err)},
) from err
def _parse_media_id(media_id: str) -> tuple[str, str | None, str | None, int | None]:
"""Split a Yoto play_media id into its components."""
parts = media_id.split("+")
if not parts[0] or len(parts) > 4:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_media_id",
translation_placeholders={"media_id": media_id},
)
parts.extend([""] * (4 - len(parts)))
card_id, chapter_key, track_key, seconds_raw = parts[:4]
if seconds_raw:
try:
seconds_in: int | None = int(seconds_raw)
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_media_id",
translation_placeholders={"media_id": media_id},
) from err
else:
seconds_in = None
return card_id, chapter_key or None, track_key or None, seconds_in
@@ -0,0 +1,88 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not register custom service actions.
appropriate-polling:
status: done
comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not register custom service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Real-time updates are dispatched through the coordinator, not via per-entity event subscriptions.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not register custom service actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no options flow.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Yoto is a cloud integration with no local discovery.
discovery:
status: exempt
comment: Yoto players are discovered through the cloud account, not the local network.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category:
status: exempt
comment: Only the media_player entity ships in this PR; no diagnostic entities yet.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: Only the media_player entity ships in this PR; no entities are disabled by default.
entity-translations:
status: exempt
comment: The media_player uses the device name; no translatable strings yet.
exception-translations: done
icon-translations:
status: exempt
comment: No custom icon translations are needed yet.
reconfiguration-flow:
status: exempt
comment: Authorization is the only configuration; reauth covers re-linking the account.
repair-issues:
status: exempt
comment: No repair issues are raised yet.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -0,0 +1,53 @@
{
"config": {
"abort": {
"account_mismatch": "The Yoto account that was reauthorized does not match the original account.",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": {
"pick_implementation": {
"data": {
"implementation": "[%key:common::config_flow::data::implementation%]"
},
"data_description": {
"implementation": "[%key:common::config_flow::description::implementation%]"
},
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"description": "Your Yoto credentials are no longer valid. Continue to authorize Home Assistant again.",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"auth_error": {
"message": "Could not authenticate with Yoto. Please reauthorize the integration."
},
"command_failed": {
"message": "Yoto command failed: {error}"
},
"invalid_media_id": {
"message": "Could not parse media id {media_id}. Expected a card id, optionally followed by +chapter+track+seconds."
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"update_error": {
"message": "Error communicating with the Yoto API: {error}"
}
}
}
+1
View File
@@ -51,5 +51,6 @@ APPLICATION_CREDENTIALS = [
"xbox",
"yale",
"yolink",
"yoto",
"youtube",
]
+1
View File
@@ -860,6 +860,7 @@ FLOWS = {
"yardian",
"yeelight",
"yolink",
"yoto",
"youless",
"youtube",
"zamg",
@@ -8220,6 +8220,12 @@
"config_flow": true,
"iot_class": "cloud_push"
},
"yoto": {
"name": "Yoto",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"youless": {
"name": "YouLess",
"integration_type": "device",
+3
View File
@@ -3407,6 +3407,9 @@ yeelightsunflower==0.0.10
# homeassistant.components.yolink
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==3.0.0
# homeassistant.components.youless
youless-api==2.2.0
+3
View File
@@ -2904,6 +2904,9 @@ yeelight==0.7.16
# homeassistant.components.yolink
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==3.0.0
# homeassistant.components.youless
youless-api==2.2.0
+12
View File
@@ -0,0 +1,12 @@
"""Tests for the Yoto integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Set up the Yoto integration for testing."""
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
+164
View File
@@ -0,0 +1,164 @@
"""Fixtures for the Yoto integration tests."""
from collections.abc import Generator
from datetime import UTC, datetime
import time
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from yoto_api import (
Card,
Device,
PlaybackEvent,
PlaybackStatus,
PlayerInfo,
PlayerStatus,
YotoPlayer,
)
from homeassistant.components.application_credentials import (
DOMAIN as APPLICATION_CREDENTIALS_DOMAIN,
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.yoto.const import DOMAIN, YOTO_SCOPES
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
USER_ID = "auth0|user-test"
PLAYER_ID = "player-test"
CARD_ID = "card-test"
SCOPES = " ".join(YOTO_SCOPES)
ACCESS_TOKEN = jwt.encode({"sub": USER_ID}, "test-secret-long-enough-for-hmac-sha256")
def _build_card() -> Card:
"""Build a representative Yoto library card."""
return Card(
id=CARD_ID,
title="Outer Space",
author="Ladybird Audio Adventures",
cover_image_large="https://example.test/cover.jpg",
)
def _build_player() -> YotoPlayer:
"""Build a representative Yoto player for tests."""
now = datetime(2026, 5, 8, 12, 0, tzinfo=UTC)
player = YotoPlayer(
device=Device(
device_id=PLAYER_ID,
name="Nursery Yoto",
device_type="v3",
device_family="v3",
generation="gen3",
),
devices_refreshed_at=now,
info_refreshed_at=now,
last_event_received_at=now,
)
player.info = PlayerInfo(
device_id=PLAYER_ID,
firmware_version="v2.17.5",
mac="aa:bb:cc:dd:ee:ff",
)
player.status = PlayerStatus(device_id=PLAYER_ID, is_online=True)
player.last_event = PlaybackEvent(
player_id=PLAYER_ID,
playback_status=PlaybackStatus.PLAYING,
volume=8,
volume_max=16,
track_length=300,
position=120,
card_id=CARD_ID,
chapter_key="01",
chapter_title="Chapter 1",
track_key="01-INT",
track_title="Introduction",
)
return player
@pytest.fixture
def mock_token_hex() -> Generator[MagicMock]:
"""Pin the access token used for proxy URLs to keep snapshots stable."""
with patch("secrets.token_hex", return_value="abcdef") as token:
yield token
@pytest.fixture
def mock_yoto_client() -> Generator[MagicMock]:
"""Patch YotoClient used by the runtime to a configurable mock."""
with patch(
"homeassistant.components.yoto.coordinator.YotoClient",
) as client_class:
client = MagicMock()
client_class.return_value = client
client.players = {PLAYER_ID: _build_player()}
client.library = {CARD_ID: _build_card()}
client.token = MagicMock(refresh_token="mock-refresh-token")
# All YotoClient methods we call are async.
for name in (
"refresh",
"update_player_status",
"update_library",
"connect_events",
"disconnect_events",
"request_status_push",
"resume",
"pause",
"stop",
"set_volume",
"seek",
"next_track",
"previous_track",
"play_card",
):
setattr(client, name, AsyncMock())
yield client
@pytest.fixture(name="expires_at")
def mock_expires_at() -> float:
"""Fixture to set the OAuth token expiration time."""
return time.time() + 3600
@pytest.fixture
def mock_config_entry(expires_at: float) -> MockConfigEntry:
"""Return a Yoto OAuth2 config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Yoto",
unique_id=USER_ID,
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": ACCESS_TOKEN,
"refresh_token": "mock-refresh-token",
"expires_at": expires_at,
"expires_in": 3600,
"token_type": "Bearer",
"scope": SCOPES,
},
},
entry_id="01J5TX5A0FF6G5V0QJX6HBC94T",
)
@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Register fake OAuth2 client credentials for the Yoto integration."""
assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential("CLIENT_ID", "CLIENT_SECRET"),
DOMAIN,
)
@@ -0,0 +1,63 @@
# serializer version: 1
# name: test_entity_state[media_player.nursery_yoto-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.nursery_yoto',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'original_icon': None,
'original_name': None,
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 22071>,
'translation_key': None,
'unique_id': 'player-test',
'unit_of_measurement': None,
})
# ---
# name: test_entity_state[media_player.nursery_yoto-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'entity_picture': 'https://example.test/cover.jpg',
'entity_picture_local': '/api/media_player_proxy/media_player.nursery_yoto?token=abcdef&cache=1cbba102718cbf3f',
'friendly_name': 'Nursery Yoto',
'media_album_name': 'Outer Space',
'media_artist': 'Ladybird Audio Adventures',
'media_duration': 300,
'media_position': 120,
'media_position_updated_at': datetime.datetime(2026, 5, 8, 12, 0, tzinfo=datetime.timezone.utc),
'media_title': 'Introduction',
'supported_features': <MediaPlayerEntityFeature: 22071>,
'volume_level': 0.5,
}),
'context': <ANY>,
'entity_id': 'media_player.nursery_yoto',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
+208
View File
@@ -0,0 +1,208 @@
"""Tests for the Yoto config flow."""
from http import HTTPStatus
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
import jwt
import pytest
from homeassistant.components.yoto.const import DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from .conftest import ACCESS_TOKEN, USER_ID
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
REDIRECT_URI = "https://example.com/auth/external/callback"
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
async def _initiate_user_flow(hass: HomeAssistant) -> dict:
"""Start the OAuth2 user flow and return the EXTERNAL_STEP result."""
return await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
async def _complete_callback(
hass: HomeAssistant,
result: dict,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
*,
refresh_token: str = "mock-refresh-token",
access_token: str = ACCESS_TOKEN,
) -> dict:
"""Drive the OAuth2 callback through the token exchange."""
state = config_entry_oauth2_flow._encode_jwt(
hass,
{"flow_id": result["flow_id"], "redirect_uri": REDIRECT_URI},
)
client = await hass_client_no_auth()
response = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert response.status == HTTPStatus.OK
aioclient_mock.clear_requests()
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": refresh_token,
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
},
)
return result
async def test_abort_if_no_credentials(hass: HomeAssistant) -> None:
"""The flow aborts when no application credentials are configured."""
result = await _initiate_user_flow(hass)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "missing_credentials"
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Walk a happy-path OAuth2 flow end to end."""
result = await _initiate_user_flow(hass)
assert result["type"] is FlowResultType.EXTERNAL_STEP
parsed = urlparse(result["url"])
query = {key: value[0] for key, value in parse_qs(parsed.query).items()}
assert parsed.scheme == "https"
assert parsed.netloc == "login.yotoplay.com"
assert parsed.path == "/authorize"
assert query["audience"] == YOTO_AUDIENCE
assert query["scope"] == " ".join(YOTO_SCOPES)
assert query["client_id"] == "CLIENT_ID"
assert query["redirect_uri"] == REDIRECT_URI
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
with patch("homeassistant.components.yoto.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Yoto"
assert result["result"].unique_id == USER_ID
assert result["data"]["auth_implementation"] == DOMAIN
assert result["data"]["token"]["access_token"] == ACCESS_TOKEN
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
async def test_already_configured(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
) -> None:
"""Re-authorizing the same account aborts as already configured."""
mock_config_entry.add_to_hass(hass)
result = await _initiate_user_flow(hass)
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
"access_token",
[
"not-a-jwt",
jwt.encode({"foo": "bar"}, "test-secret-long-enough-for-hmac-sha256"),
],
)
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
async def test_invalid_access_token(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token: str,
) -> None:
"""The flow aborts when the access token is not a usable JWT."""
result = await _initiate_user_flow(hass)
await _complete_callback(
hass, result, hass_client_no_auth, aioclient_mock, access_token=access_token
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "oauth_unauthorized"
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
async def test_reauth_success(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
) -> None:
"""Reauth refreshes the stored token data."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.EXTERNAL_STEP
rotated_token = jwt.encode(
{"sub": USER_ID}, "test-secret-long-enough-for-hmac-sha256"
)
await _complete_callback(
hass,
result,
hass_client_no_auth,
aioclient_mock,
refresh_token="rotated-refresh",
access_token=rotated_token,
)
with patch("homeassistant.components.yoto.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data["token"]["access_token"] == rotated_token
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
async def test_reauth_account_mismatch(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
) -> None:
"""Reauth fails when the user authorizes a different account."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
other_token = jwt.encode(
{"sub": "auth0|other-user"}, "test-secret-long-enough-for-hmac-sha256"
)
await _complete_callback(
hass, result, hass_client_no_auth, aioclient_mock, access_token=other_token
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "account_mismatch"
+268
View File
@@ -0,0 +1,268 @@
"""Tests for the Yoto integration setup."""
from unittest.mock import MagicMock, patch
import aiohttp
from freezegun.api import FrozenDateTimeFactory
from yoto_api import AuthenticationError, YotoAPIError, YotoError
from homeassistant.components.yoto.const import (
DOMAIN,
SCAN_INTERVAL,
STATUS_PUSH_INTERVAL,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
)
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_setup_unload(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""The integration loads and unloads cleanly."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
mock_yoto_client.disconnect_events.assert_called_once()
async def test_setup_triggers_reauth_on_auth_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""An authentication error during refresh triggers reauth."""
mock_yoto_client.refresh.side_effect = AuthenticationError("denied")
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert any(
flow["context"].get("source") == SOURCE_REAUTH
and flow["context"].get("entry_id") == mock_config_entry.entry_id
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
)
async def test_setup_retries_on_api_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""A non-auth API failure surfaces as a setup retry."""
mock_yoto_client.refresh.side_effect = YotoAPIError("boom")
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_mqtt_event_dispatches_update(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""An MQTT event published by the broker pushes fresh data to listeners."""
await setup_integration(hass, mock_config_entry)
coordinator = mock_config_entry.runtime_data
assert coordinator.data is mock_yoto_client.players
received: list[dict | None] = []
coordinator.async_add_listener(lambda: received.append(coordinator.data))
# connect_events(device_ids, on_update, on_disconnect) — pull the callback out
callback = mock_yoto_client.connect_events.call_args.args[1]
callback(next(iter(mock_yoto_client.players.values())))
await hass.async_block_till_done()
assert received == [mock_yoto_client.players]
async def test_status_push_tick(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
freezer: FrozenDateTimeFactory,
) -> None:
"""The status-push timer publishes a request every 60 s."""
mock_yoto_client.is_mqtt_connected = True
await setup_integration(hass, mock_config_entry)
mock_yoto_client.request_status_push.reset_mock()
freezer.tick(STATUS_PUSH_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_yoto_client.request_status_push.assert_called_once_with("player-test")
async def test_status_push_skipped_when_mqtt_disconnected(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
freezer: FrozenDateTimeFactory,
) -> None:
"""The status-push timer is a no-op while MQTT is reconnecting."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.request_status_push.reset_mock()
mock_yoto_client.is_mqtt_connected = False
freezer.tick(STATUS_PUSH_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_yoto_client.request_status_push.assert_not_called()
async def test_periodic_poll_refreshes_players(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
freezer: FrozenDateTimeFactory,
) -> None:
"""The coordinator refreshes the player list on every tick."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.refresh.reset_mock()
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_yoto_client.refresh.assert_called_once()
async def test_periodic_poll_triggers_reauth_on_auth_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
freezer: FrozenDateTimeFactory,
) -> None:
"""An auth failure mid-session triggers reauth."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.refresh.side_effect = AuthenticationError("denied")
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert any(
flow["context"].get("source") == SOURCE_REAUTH
and flow["context"].get("entry_id") == mock_config_entry.entry_id
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
)
async def test_setup_retries_when_implementation_missing(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""Missing OAuth2 implementation defers setup as not-ready."""
with patch(
"homeassistant.components.yoto.async_get_config_entry_implementation",
side_effect=ImplementationUnavailableError("gone"),
):
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_retries_on_token_refresh_network_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""A network error while validating the token defers setup."""
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=aiohttp.ClientError("boom"),
):
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_retries_when_mqtt_unavailable(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""MQTT connect failure surfaces as a setup retry."""
mock_yoto_client.connect_events.side_effect = YotoError("mqtt down")
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_succeeds_without_card_library(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""A library load failure doesn't block setup; titles and artwork stay empty."""
mock_yoto_client.update_library.side_effect = YotoError("library down")
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_periodic_poll_fails_on_network_error(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
freezer: FrozenDateTimeFactory,
) -> None:
"""A network error during periodic refresh marks the coordinator failed."""
await setup_integration(hass, mock_config_entry)
with patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
side_effect=aiohttp.ClientError("boom"),
):
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
coordinator = mock_config_entry.runtime_data
assert coordinator.last_update_success is False
async def test_periodic_poll_fails_on_api_error(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
freezer: FrozenDateTimeFactory,
) -> None:
"""A non-auth API error during periodic refresh marks the coordinator failed."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.refresh.side_effect = YotoAPIError("boom")
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
coordinator = mock_config_entry.runtime_data
assert coordinator.last_update_success is False
+279
View File
@@ -0,0 +1,279 @@
"""Tests for the Yoto media player platform."""
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.media_player import (
ATTR_MEDIA_SEEK_POSITION,
ATTR_MEDIA_VOLUME_LEVEL,
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_MEDIA_SEEK,
SERVICE_MEDIA_STOP,
SERVICE_PLAY_MEDIA,
SERVICE_VOLUME_SET,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
from homeassistant.core import HomeAssistant
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
ENTITY_ID = "media_player.nursery_yoto"
async def test_entity_state(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_token_hex: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
freezer: FrozenDateTimeFactory,
) -> None:
"""Snapshot the media player entity state."""
freezer.move_to("2026-05-08T12:00:00+00:00")
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("service", "method"),
[
(SERVICE_MEDIA_PLAY, "resume"),
(SERVICE_MEDIA_PAUSE, "pause"),
(SERVICE_MEDIA_STOP, "stop"),
(SERVICE_MEDIA_NEXT_TRACK, "next_track"),
(SERVICE_MEDIA_PREVIOUS_TRACK, "previous_track"),
],
)
async def test_playback_commands(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
service: str,
method: str,
) -> None:
"""Playback service calls reach the client."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
getattr(mock_yoto_client, method).assert_called_once_with("player-test")
async def test_set_volume(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""Volume is forwarded as an integer 0-100."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_VOLUME_SET,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
blocking=True,
)
mock_yoto_client.set_volume.assert_called_once_with("player-test", 50)
async def test_play_media_with_full_id(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""play_media parses the structured media id."""
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": "card-test+02+02-INT+30",
},
blocking=True,
)
mock_yoto_client.play_card.assert_called_once_with(
"player-test",
card_id="card-test",
seconds_in=30,
chapter_key="02",
track_key="02-INT",
)
async def test_seek(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""Seek delegates to the client with the integer position."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_SEEK,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_SEEK_POSITION: 30},
blocking=True,
)
mock_yoto_client.seek.assert_called_once_with("player-test", 30)
@pytest.mark.parametrize(
"media_id",
[
"",
"+02+02-INT+30",
"card-test+02+02-INT+abc",
"card-test+02+02-INT+30+extra",
],
)
async def test_play_media_invalid(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
media_id: str,
) -> None:
"""An empty card id, missing card id, malformed seconds, or extra segment 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_id,
},
blocking=True,
)
async def test_play_media_card_only(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""play_media defaults missing fields to None."""
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": "card-test",
},
blocking=True,
)
mock_yoto_client.play_card.assert_called_once_with(
"player-test",
card_id="card-test",
seconds_in=None,
chapter_key=None,
track_key=None,
)
async def test_state_off_when_offline(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""When the player reports offline the state is OFF."""
player = next(iter(mock_yoto_client.players.values()))
player.status.is_online = False
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
async def test_no_card_metadata_when_card_id_missing(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""Card metadata properties return None when no card is active."""
player = next(iter(mock_yoto_client.players.values()))
player.last_event.card_id = None
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert "media_album_name" not in state.attributes
assert "media_artist" not in state.attributes
assert "media_image_url" not in state.attributes
async def test_state_idle_before_first_event(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""A freshly-online player with no playback event yet reports IDLE."""
player = next(iter(mock_yoto_client.players.values()))
player.last_event.playback_status = None
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == "idle"
async def test_command_error_raises(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""Yoto command failures surface as HomeAssistantError."""
await setup_integration(hass, mock_config_entry)
mock_yoto_client.pause.side_effect = YotoError("nope")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_PAUSE,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)