mirror of
https://github.com/home-assistant/core.git
synced 2026-05-20 07:45:09 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd93c112ed | |||
| fcc0ab5452 | |||
| 26804ab408 | |||
| 177dcbc751 | |||
| 6d64d98250 | |||
| 8234c61ca8 | |||
| 99d6be1097 | |||
| e720c1b378 | |||
| 4a0ba0a830 | |||
| 4fb1aa6923 |
Generated
+2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,5 +51,6 @@ APPLICATION_CREDENTIALS = [
|
||||
"xbox",
|
||||
"yale",
|
||||
"yolink",
|
||||
"yoto",
|
||||
"youtube",
|
||||
]
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user