Add entity platforms to Yoto

Add binary sensor, button, number, select, sensor, switch and time
platforms built on the player status and config data:

- Sensors: battery, card slot, day mode, free disk space and display
  brightness (disabled by default).
- Binary sensors: charging, audio device and Bluetooth audio.
- Switches: Bluetooth, Bluetooth headphones, headphone volume limit,
  repeat all, day/night system sounds, pause shortcuts and day/night
  automatic brightness.
- Numbers: day/night maximum volume, day/night display brightness,
  dimmed display brightness, auto-shutdown delay and display dim delay.
- Times: day/night mode start.
- Select: clock hour format.
- Button: restart.

Config writes go through set_player_config followed by a config
re-read so the entities reflect the new state immediately.

Move the online check to the base entity so all entities report
unavailable when the player is offline, and add config entry
diagnostics with token/network details redacted.

https://claude.ai/code/session_015G3sygJ3js3qaMvw7Kn2Jo
This commit is contained in:
Claude
2026-06-11 08:41:44 +00:00
parent 5a27b29003
commit 6d9573e95f
24 changed files with 1752 additions and 28 deletions
+10 -1
View File
@@ -19,7 +19,16 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
]
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
@@ -0,0 +1,86 @@
"""Binary sensor platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from yoto_api import YotoPlayer
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class YotoBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes a Yoto binary sensor entity."""
is_on_fn: Callable[[YotoPlayer], bool | None]
BINARY_SENSORS: tuple[YotoBinarySensorEntityDescription, ...] = (
YotoBinarySensorEntityDescription(
key="charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
is_on_fn=lambda player: player.status.is_charging,
),
YotoBinarySensorEntityDescription(
key="audio_device",
translation_key="audio_device",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda player: player.status.is_audio_device_connected,
),
YotoBinarySensorEntityDescription(
key="bluetooth_audio",
translation_key="bluetooth_audio",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda player: player.status.is_bluetooth_audio_connected,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto binary sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoBinarySensor(coordinator, player, description)
for player in coordinator.client.players.values()
for description in BINARY_SENSORS
)
class YotoBinarySensor(YotoEntity, BinarySensorEntity):
"""Representation of a Yoto player binary sensor."""
entity_description: YotoBinarySensorEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return the binary sensor state."""
return self.entity_description.is_on_fn(self.player)
+64
View File
@@ -0,0 +1,64 @@
"""Button platform for the Yoto integration."""
from yoto_api import YotoError, YotoPlayer
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 1
RESTART_BUTTON = ButtonEntityDescription(
key="restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto button platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoRestartButton(coordinator, player)
for player in coordinator.client.players.values()
)
class YotoRestartButton(YotoEntity, ButtonEntity):
"""Button that restarts a Yoto player."""
entity_description = RESTART_BUTTON
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
) -> None:
"""Initialize the button."""
super().__init__(coordinator, player)
self._attr_unique_id = f"{player.id}_restart"
async def async_press(self) -> None:
"""Restart the player."""
try:
await self.coordinator.client.restart(self._player_id)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"error": str(err)},
) from err
@@ -0,0 +1,31 @@
"""Diagnostics support for the Yoto integration."""
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import YotoConfigEntry
TO_REDACT = {
"token",
"mac",
"network_ssid",
"pop_code",
"activation_pop_code",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: YotoConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"players": {
player_id: async_redact_data(asdict(player), TO_REDACT)
for player_id, player in coordinator.data.items()
},
}
+23 -2
View File
@@ -1,7 +1,10 @@
"""Base entity for the Yoto integration."""
from yoto_api import YotoPlayer
from typing import Any
from yoto_api import YotoError, YotoPlayer
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -43,4 +46,22 @@ class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._player_id in self.coordinator.data
return (
super().available
and self._player_id in self.coordinator.data
and bool(self.player.is_online)
)
async def _async_set_config(self, **fields: Any) -> None:
"""Write player config fields and refresh the local copy."""
client = self.coordinator.client
try:
await client.set_player_config(self._player_id, **fields)
await client.update_player_info(self._player_id)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="config_update_failed",
translation_placeholders={"error": str(err)},
) from err
self.coordinator.async_set_updated_data(client.players)
+101
View File
@@ -0,0 +1,101 @@
{
"entity": {
"binary_sensor": {
"audio_device": {
"default": "mdi:audio-input-stereo-minijack"
},
"bluetooth_audio": {
"default": "mdi:bluetooth-audio"
}
},
"number": {
"day_display_brightness": {
"default": "mdi:brightness-7"
},
"day_max_volume_limit": {
"default": "mdi:volume-high"
},
"display_dim_brightness": {
"default": "mdi:brightness-2"
},
"display_dim_timeout": {
"default": "mdi:timer-outline"
},
"night_display_brightness": {
"default": "mdi:brightness-4"
},
"night_max_volume_limit": {
"default": "mdi:volume-high"
},
"shutdown_timeout": {
"default": "mdi:power-sleep"
}
},
"select": {
"hour_format": {
"default": "mdi:clock-digital"
}
},
"sensor": {
"card_insertion_state": {
"default": "mdi:card-bulleted-outline",
"state": {
"none": "mdi:card-bulleted-off-outline",
"physical": "mdi:card-bulleted",
"remote": "mdi:cast-audio",
"streaming": "mdi:radio-tower"
}
},
"day_mode": {
"default": "mdi:theme-light-dark",
"state": {
"day": "mdi:weather-sunny",
"night": "mdi:weather-night"
}
},
"display_brightness": {
"default": "mdi:brightness-6"
},
"free_disk_space": {
"default": "mdi:harddisk"
}
},
"switch": {
"bluetooth": {
"default": "mdi:bluetooth"
},
"bluetooth_headphones": {
"default": "mdi:headphones-bluetooth"
},
"day_auto_brightness": {
"default": "mdi:brightness-auto"
},
"day_sounds": {
"default": "mdi:volume-high"
},
"headphones_volume_limit": {
"default": "mdi:headphones-settings"
},
"night_auto_brightness": {
"default": "mdi:brightness-auto"
},
"night_sounds": {
"default": "mdi:volume-high"
},
"pause_power_button": {
"default": "mdi:power"
},
"pause_volume_down": {
"default": "mdi:volume-minus"
}
},
"time": {
"day_mode_start": {
"default": "mdi:weather-sunny"
},
"night_mode_start": {
"default": "mdi:weather-night"
}
}
}
}
@@ -82,11 +82,6 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
super().__init__(coordinator, player)
self._attr_unique_id = player.id
@property
def available(self) -> bool:
"""Return whether the player is reachable through the Yoto cloud."""
return super().available and bool(self.player.is_online)
@property
def state(self) -> MediaPlayerState:
"""Return the playback state."""
+161
View File
@@ -0,0 +1,161 @@
"""Number platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from yoto_api import PlayerConfig, YotoPlayer
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class YotoNumberEntityDescription(NumberEntityDescription):
"""Describes a Yoto number entity.
``config_field`` is the ``set_player_config`` kwarg written on change.
"""
value_fn: Callable[[PlayerConfig], int | None]
config_field: str
NUMBERS: tuple[YotoNumberEntityDescription, ...] = (
YotoNumberEntityDescription(
key="day_max_volume_limit",
translation_key="day_max_volume_limit",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.day_max_volume_limit,
config_field="day_max_volume_limit",
),
YotoNumberEntityDescription(
key="night_max_volume_limit",
translation_key="night_max_volume_limit",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.night_max_volume_limit,
config_field="night_max_volume_limit",
),
# Day/night display brightness report None while auto brightness is
# active; writing a value switches the player to manual brightness.
YotoNumberEntityDescription(
key="day_display_brightness",
translation_key="day_display_brightness",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.day_display_brightness,
config_field="day_display_brightness",
),
YotoNumberEntityDescription(
key="night_display_brightness",
translation_key="night_display_brightness",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.night_display_brightness,
config_field="night_display_brightness",
),
YotoNumberEntityDescription(
key="display_dim_brightness",
translation_key="display_dim_brightness",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.display_dim_brightness,
config_field="display_dim_brightness",
),
YotoNumberEntityDescription(
key="shutdown_timeout",
translation_key="shutdown_timeout",
device_class=NumberDeviceClass.DURATION,
native_min_value=0,
native_max_value=14400,
native_step=60,
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.shutdown_timeout,
config_field="shutdown_timeout",
),
YotoNumberEntityDescription(
key="display_dim_timeout",
translation_key="display_dim_timeout",
device_class=NumberDeviceClass.DURATION,
native_min_value=0,
native_max_value=3600,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.display_dim_timeout,
config_field="display_dim_timeout",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto number platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoNumber(coordinator, player, description)
for player in coordinator.client.players.values()
for description in NUMBERS
)
class YotoNumber(YotoEntity, NumberEntity):
"""Representation of a Yoto player config number."""
entity_description: YotoNumberEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoNumberEntityDescription,
) -> None:
"""Initialize the number."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def native_value(self) -> int | None:
"""Return the number value."""
return self.entity_description.value_fn(self.player.info.config)
async def async_set_native_value(self, value: float) -> None:
"""Update the config value."""
await self._async_set_config(
**{self.entity_description.config_field: int(value)}
)
@@ -28,9 +28,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not register custom service actions.
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
@@ -45,7 +43,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
@@ -57,20 +55,12 @@ rules:
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-category: done
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.
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: No custom icon translations are needed yet.
icon-translations: done
reconfiguration-flow:
status: exempt
comment: Authorization is the only configuration; reauth covers re-linking the account.
+57
View File
@@ -0,0 +1,57 @@
"""Select platform for the Yoto integration."""
from yoto_api import YotoPlayer
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 1
HOUR_FORMATS = ["12", "24"]
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto select platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoHourFormatSelect(coordinator, player)
for player in coordinator.client.players.values()
)
class YotoHourFormatSelect(YotoEntity, SelectEntity):
"""Clock hour format setting of a Yoto player."""
_attr_translation_key = "hour_format"
_attr_entity_category = EntityCategory.CONFIG
_attr_options = HOUR_FORMATS
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
) -> None:
"""Initialize the select."""
super().__init__(coordinator, player)
self._attr_unique_id = f"{player.id}_hour_format"
@property
def current_option(self) -> str | None:
"""Return the configured hour format."""
hour_format = self.player.info.config.hour_format
if hour_format is None:
return None
return str(hour_format)
async def async_select_option(self, option: str) -> None:
"""Update the hour format."""
await self._async_set_config(hour_format=int(option))
+122
View File
@@ -0,0 +1,122 @@
"""Sensor platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from yoto_api import CardInsertionState, DayMode, YotoPlayer
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 0
def _enum_state(value: CardInsertionState | None) -> str | None:
"""Return an enum member as a lowercase string, or None if unset."""
return value.name.lower() if value is not None else None
def _day_mode_state(value: DayMode | None) -> str | None:
"""Return day/night, treating the firmware's UNKNOWN as unset."""
if value is None or value is DayMode.UNKNOWN:
return None
return value.name.lower()
@dataclass(frozen=True, kw_only=True)
class YotoSensorEntityDescription(SensorEntityDescription):
"""Describes a Yoto sensor entity."""
value_fn: Callable[[YotoPlayer], StateType]
SENSORS: tuple[YotoSensorEntityDescription, ...] = (
YotoSensorEntityDescription(
key="battery_level",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda player: player.status.battery_level_percentage,
),
YotoSensorEntityDescription(
key="card_insertion_state",
translation_key="card_insertion_state",
device_class=SensorDeviceClass.ENUM,
options=[state.name.lower() for state in CardInsertionState],
value_fn=lambda player: _enum_state(player.status.card_insertion_state),
),
YotoSensorEntityDescription(
key="day_mode",
translation_key="day_mode",
device_class=SensorDeviceClass.ENUM,
options=["day", "night"],
value_fn=lambda player: _day_mode_state(player.status.day_mode),
),
YotoSensorEntityDescription(
key="free_disk_space",
translation_key="free_disk_space",
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
suggested_display_precision=1,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda player: player.status.free_disk_space_bytes,
),
YotoSensorEntityDescription(
key="display_brightness",
translation_key="display_brightness",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda player: player.status.current_display_brightness,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoSensor(coordinator, player, description)
for player in coordinator.client.players.values()
for description in SENSORS
)
class YotoSensor(YotoEntity, SensorEntity):
"""Representation of a Yoto player sensor."""
entity_description: YotoSensorEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.player)
+106
View File
@@ -36,6 +36,109 @@
}
}
},
"entity": {
"binary_sensor": {
"audio_device": {
"name": "Audio device"
},
"bluetooth_audio": {
"name": "Bluetooth audio"
}
},
"number": {
"day_display_brightness": {
"name": "Day display brightness"
},
"day_max_volume_limit": {
"name": "Day maximum volume"
},
"display_dim_brightness": {
"name": "Dimmed display brightness"
},
"display_dim_timeout": {
"name": "Display dim delay"
},
"night_display_brightness": {
"name": "Night display brightness"
},
"night_max_volume_limit": {
"name": "Night maximum volume"
},
"shutdown_timeout": {
"name": "Auto-shutdown delay"
}
},
"select": {
"hour_format": {
"name": "Hour format",
"state": {
"12": "12-hour",
"24": "24-hour"
}
}
},
"sensor": {
"card_insertion_state": {
"name": "Card slot",
"state": {
"none": "Empty",
"physical": "Physical card",
"remote": "Remote",
"streaming": "Streaming"
}
},
"day_mode": {
"name": "Day mode",
"state": {
"day": "Day",
"night": "Night"
}
},
"display_brightness": {
"name": "Display brightness"
},
"free_disk_space": {
"name": "Free disk space"
}
},
"switch": {
"bluetooth": {
"name": "Bluetooth"
},
"bluetooth_headphones": {
"name": "Bluetooth headphones"
},
"day_auto_brightness": {
"name": "Day automatic brightness"
},
"day_sounds": {
"name": "Day system sounds"
},
"headphones_volume_limit": {
"name": "Limit headphone volume"
},
"night_auto_brightness": {
"name": "Night automatic brightness"
},
"night_sounds": {
"name": "Night system sounds"
},
"pause_power_button": {
"name": "Pause with power button"
},
"pause_volume_down": {
"name": "Pause with volume down"
}
},
"time": {
"day_mode_start": {
"name": "Day mode start"
},
"night_mode_start": {
"name": "Night mode start"
}
}
},
"exceptions": {
"authentication_failed": {
"message": "Yoto credentials are no longer valid. Please reauthenticate your account."
@@ -46,6 +149,9 @@
"command_failed": {
"message": "Yoto command failed: {error}"
},
"config_update_failed": {
"message": "Failed to update Yoto player settings: {error}"
},
"invalid_media_id": {
"message": "Not a Yoto media identifier: {media_id}"
},
+180
View File
@@ -0,0 +1,180 @@
"""Switch platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from yoto_api import PlayerConfig, YotoPlayer
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 1
# When auto brightness is switched off the API needs an explicit brightness
# value to replace the "auto" sentinel; fall back to full brightness if the
# config never held a manual value.
DEFAULT_DISPLAY_BRIGHTNESS = 100
def _invert(value: bool | None) -> bool | None:
"""Invert an optional boolean, keeping None as None."""
return None if value is None else not value
@dataclass(frozen=True, kw_only=True)
class YotoSwitchEntityDescription(SwitchEntityDescription):
"""Describes a Yoto switch entity.
The turn_on/turn_off callables return the ``set_player_config`` kwargs
that put the player in the requested state.
"""
is_on_fn: Callable[[PlayerConfig], bool | None]
turn_on_fn: Callable[[PlayerConfig], dict[str, Any]]
turn_off_fn: Callable[[PlayerConfig], dict[str, Any]]
SWITCHES: tuple[YotoSwitchEntityDescription, ...] = (
YotoSwitchEntityDescription(
key="bluetooth",
translation_key="bluetooth",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: config.bluetooth_enabled,
turn_on_fn=lambda config: {"bluetooth_enabled": True},
turn_off_fn=lambda config: {"bluetooth_enabled": False},
),
YotoSwitchEntityDescription(
key="bluetooth_headphones",
translation_key="bluetooth_headphones",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: config.bt_headphones_enabled,
turn_on_fn=lambda config: {"bt_headphones_enabled": True},
turn_off_fn=lambda config: {"bt_headphones_enabled": False},
),
YotoSwitchEntityDescription(
key="headphones_volume_limit",
translation_key="headphones_volume_limit",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: config.headphones_volume_limited,
turn_on_fn=lambda config: {"headphones_volume_limited": True},
turn_off_fn=lambda config: {"headphones_volume_limited": False},
),
YotoSwitchEntityDescription(
key="repeat_all",
translation_key="repeat_all",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: config.repeat_all,
turn_on_fn=lambda config: {"repeat_all": True},
turn_off_fn=lambda config: {"repeat_all": False},
),
# The Yoto API stores "sounds off"; expose it as a "sounds on" switch to
# match the system sounds toggle in the Yoto app.
YotoSwitchEntityDescription(
key="day_sounds",
translation_key="day_sounds",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: _invert(config.day_sounds_off),
turn_on_fn=lambda config: {"day_sounds_off": False},
turn_off_fn=lambda config: {"day_sounds_off": True},
),
YotoSwitchEntityDescription(
key="night_sounds",
translation_key="night_sounds",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: _invert(config.night_sounds_off),
turn_on_fn=lambda config: {"night_sounds_off": False},
turn_off_fn=lambda config: {"night_sounds_off": True},
),
YotoSwitchEntityDescription(
key="pause_volume_down",
translation_key="pause_volume_down",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: config.pause_volume_down,
turn_on_fn=lambda config: {"pause_volume_down": True},
turn_off_fn=lambda config: {"pause_volume_down": False},
),
YotoSwitchEntityDescription(
key="pause_power_button",
translation_key="pause_power_button",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: config.pause_power_button,
turn_on_fn=lambda config: {"pause_power_button": True},
turn_off_fn=lambda config: {"pause_power_button": False},
),
YotoSwitchEntityDescription(
key="day_auto_brightness",
translation_key="day_auto_brightness",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: config.day_display_brightness_auto,
turn_on_fn=lambda config: {"day_display_brightness_auto": True},
turn_off_fn=lambda config: {
"day_display_brightness": config.day_display_brightness
or DEFAULT_DISPLAY_BRIGHTNESS
},
),
YotoSwitchEntityDescription(
key="night_auto_brightness",
translation_key="night_auto_brightness",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda config: config.night_display_brightness_auto,
turn_on_fn=lambda config: {"night_display_brightness_auto": True},
turn_off_fn=lambda config: {
"night_display_brightness": config.night_display_brightness
or DEFAULT_DISPLAY_BRIGHTNESS
},
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto switch platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoSwitch(coordinator, player, description)
for player in coordinator.client.players.values()
for description in SWITCHES
)
class YotoSwitch(YotoEntity, SwitchEntity):
"""Representation of a Yoto player config switch."""
entity_description: YotoSwitchEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return the switch state."""
return self.entity_description.is_on_fn(self.player.info.config)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_set_config(
**self.entity_description.turn_on_fn(self.player.info.config)
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_set_config(
**self.entity_description.turn_off_fn(self.player.info.config)
)
+86
View File
@@ -0,0 +1,86 @@
"""Time platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import time
from yoto_api import PlayerConfig, YotoPlayer
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class YotoTimeEntityDescription(TimeEntityDescription):
"""Describes a Yoto time entity.
``config_field`` is the ``set_player_config`` kwarg written on change.
"""
value_fn: Callable[[PlayerConfig], time | None]
config_field: str
TIME_ENTITIES: tuple[YotoTimeEntityDescription, ...] = (
YotoTimeEntityDescription(
key="day_mode_start",
translation_key="day_mode_start",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.day_time,
config_field="day_time",
),
YotoTimeEntityDescription(
key="night_mode_start",
translation_key="night_mode_start",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.night_time,
config_field="night_time",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto time platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoTime(coordinator, player, description)
for player in coordinator.client.players.values()
for description in TIME_ENTITIES
)
class YotoTime(YotoEntity, TimeEntity):
"""Representation of a Yoto player config time."""
entity_description: YotoTimeEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoTimeEntityDescription,
) -> None:
"""Initialize the time entity."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def native_value(self) -> time | None:
"""Return the configured time."""
return self.entity_description.value_fn(self.player.info.config)
async def async_set_value(self, value: time) -> None:
"""Update the configured time."""
await self._async_set_config(**{self.entity_description.config_field: value})
+36 -1
View File
@@ -1,7 +1,7 @@
"""Fixtures for the Yoto integration tests."""
from collections.abc import Generator
from datetime import UTC, datetime
from datetime import UTC, datetime, time as dt_time
import time
from unittest.mock import AsyncMock, MagicMock, patch
@@ -9,12 +9,16 @@ import jwt
import pytest
from yoto_api import (
Card,
CardInsertionState,
Chapter,
DayMode,
Device,
Group,
PlaybackEvent,
PlaybackStatus,
PlayerConfig,
PlayerInfo,
PlayerStatus,
Track,
YotoPlayer,
)
@@ -87,6 +91,37 @@ def _build_player() -> YotoPlayer:
player.info = PlayerInfo(
firmware_version="v2.17.5",
mac="aa:bb:cc:dd:ee:ff",
config=PlayerConfig(
day_time=dt_time(7, 0),
day_display_brightness_auto=True,
day_max_volume_limit=80,
day_sounds_off=False,
night_time=dt_time(19, 0),
night_display_brightness_auto=False,
night_display_brightness=40,
night_max_volume_limit=50,
night_sounds_off=True,
hour_format=12,
bluetooth_enabled=True,
bt_headphones_enabled=False,
headphones_volume_limited=True,
repeat_all=False,
shutdown_timeout=3600,
display_dim_timeout=30,
display_dim_brightness=10,
pause_volume_down=True,
pause_power_button=False,
),
)
player.status = PlayerStatus(
battery_level_percentage=75,
is_charging=True,
free_disk_space_bytes=14_500_000_000,
card_insertion_state=CardInsertionState.PHYSICAL,
day_mode=DayMode.DAY,
is_audio_device_connected=False,
is_bluetooth_audio_connected=True,
current_display_brightness=85,
)
player.last_event = PlaybackEvent(
player_id=PLAYER_ID,
@@ -0,0 +1,30 @@
"""Tests for the Yoto binary sensor platform."""
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
@pytest.mark.usefixtures("mock_yoto_client")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto binary sensor entity."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.BINARY_SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+77
View File
@@ -0,0 +1,77 @@
"""Tests for the Yoto button platform."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import PLAYER_ID
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
ENTITY_ID = "button.nursery_yoto_restart"
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the integration with only the button platform."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.BUTTON]):
await setup_integration(hass, mock_config_entry)
@pytest.mark.usefixtures("mock_yoto_client")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto button entity."""
await _setup(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_restart(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Pressing the restart button restarts the player."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
mock_yoto_client.restart.assert_awaited_once_with(PLAYER_ID)
async def test_restart_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A failed restart raises a Home Assistant error."""
await _setup(hass, mock_config_entry)
mock_yoto_client.restart.side_effect = YotoError("MQTT timeout")
with pytest.raises(HomeAssistantError, match="Yoto command failed"):
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
+28
View File
@@ -0,0 +1,28 @@
"""Tests for the Yoto diagnostics."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@pytest.mark.usefixtures("setup_credentials", "mock_yoto_client")
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot the config entry diagnostics."""
await setup_integration(hass, mock_config_entry)
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)
+4 -3
View File
@@ -1,7 +1,7 @@
"""Tests for the Yoto media player platform."""
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import (
SERVICE_VOLUME_SET,
MediaPlayerState,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
@@ -68,7 +68,8 @@ async def test_entity_state(
) -> None:
"""Snapshot the media player entity state."""
freezer.move_to("2026-05-08T12:00:00+00:00")
await setup_integration(hass, mock_config_entry)
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.MEDIA_PLAYER]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+134
View File
@@ -0,0 +1,134 @@
"""Tests for the Yoto number platform."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import PLAYER_ID
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the integration with only the number platform."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, mock_config_entry)
@pytest.mark.usefixtures("mock_yoto_client")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto number entity."""
await _setup(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_id", "value", "expected_fields"),
[
pytest.param(
"number.nursery_yoto_day_maximum_volume",
70,
{"day_max_volume_limit": 70},
id="day-max-volume",
),
pytest.param(
"number.nursery_yoto_night_maximum_volume",
30,
{"night_max_volume_limit": 30},
id="night-max-volume",
),
pytest.param(
"number.nursery_yoto_day_display_brightness",
90,
{"day_display_brightness": 90},
id="day-display-brightness",
),
pytest.param(
"number.nursery_yoto_night_display_brightness",
20,
{"night_display_brightness": 20},
id="night-display-brightness",
),
pytest.param(
"number.nursery_yoto_dimmed_display_brightness",
5,
{"display_dim_brightness": 5},
id="dim-brightness",
),
pytest.param(
"number.nursery_yoto_auto_shutdown_delay",
1800,
{"shutdown_timeout": 1800},
id="shutdown-timeout",
),
pytest.param(
"number.nursery_yoto_display_dim_delay",
60,
{"display_dim_timeout": 60},
id="display-dim-timeout",
),
],
)
async def test_set_value(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
value: int,
expected_fields: dict[str, int],
) -> None:
"""Setting a number writes the matching player config field."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value},
blocking=True,
)
mock_yoto_client.set_player_config.assert_awaited_once_with(
PLAYER_ID, **expected_fields
)
mock_yoto_client.update_player_info.assert_awaited_once_with(PLAYER_ID)
async def test_set_value_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A failed config write raises a Home Assistant error."""
await _setup(hass, mock_config_entry)
mock_yoto_client.set_player_config.side_effect = YotoError("MQTT timeout")
with pytest.raises(
HomeAssistantError, match="Failed to update Yoto player settings"
):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "number.nursery_yoto_day_maximum_volume", ATTR_VALUE: 50},
blocking=True,
)
+86
View File
@@ -0,0 +1,86 @@
"""Tests for the Yoto select platform."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.select import (
ATTR_OPTION,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import PLAYER_ID
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
ENTITY_ID = "select.nursery_yoto_hour_format"
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the integration with only the select platform."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.SELECT]):
await setup_integration(hass, mock_config_entry)
@pytest.mark.usefixtures("mock_yoto_client")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto select entity."""
await _setup(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_select_option(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Selecting an hour format writes the player config field."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_OPTION: "24"},
blocking=True,
)
mock_yoto_client.set_player_config.assert_awaited_once_with(
PLAYER_ID, hour_format=24
)
mock_yoto_client.update_player_info.assert_awaited_once_with(PLAYER_ID)
async def test_select_option_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A failed config write raises a Home Assistant error."""
await _setup(hass, mock_config_entry)
mock_yoto_client.set_player_config.side_effect = YotoError("MQTT timeout")
with pytest.raises(
HomeAssistantError, match="Failed to update Yoto player settings"
):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_OPTION: "24"},
blocking=True,
)
+49
View File
@@ -0,0 +1,49 @@
"""Tests for the Yoto sensor platform."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
ENTITY_ID = "sensor.nursery_yoto_battery"
@pytest.mark.usefixtures("mock_yoto_client", "entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto sensor entity."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sensor_unavailable_when_offline(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Sensors are unavailable while the player is offline."""
player = next(iter(mock_yoto_client.players.values()))
player.is_online = False
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
+173
View File
@@ -0,0 +1,173 @@
"""Tests for the Yoto switch platform."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import PLAYER_ID
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the integration with only the switch platform."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
@pytest.mark.usefixtures("mock_yoto_client")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto switch entity."""
await _setup(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_id", "service", "expected_fields"),
[
pytest.param(
"switch.nursery_yoto_bluetooth",
SERVICE_TURN_ON,
{"bluetooth_enabled": True},
id="bluetooth-on",
),
pytest.param(
"switch.nursery_yoto_bluetooth",
SERVICE_TURN_OFF,
{"bluetooth_enabled": False},
id="bluetooth-off",
),
pytest.param(
"switch.nursery_yoto_bluetooth_headphones",
SERVICE_TURN_ON,
{"bt_headphones_enabled": True},
id="bluetooth-headphones-on",
),
pytest.param(
"switch.nursery_yoto_limit_headphone_volume",
SERVICE_TURN_ON,
{"headphones_volume_limited": True},
id="limit-headphone-volume-on",
),
pytest.param(
"switch.nursery_yoto_repeat_all",
SERVICE_TURN_ON,
{"repeat_all": True},
id="repeat-all-on",
),
pytest.param(
"switch.nursery_yoto_day_system_sounds",
SERVICE_TURN_ON,
{"day_sounds_off": False},
id="day-sounds-on",
),
pytest.param(
"switch.nursery_yoto_day_system_sounds",
SERVICE_TURN_OFF,
{"day_sounds_off": True},
id="day-sounds-off",
),
pytest.param(
"switch.nursery_yoto_night_system_sounds",
SERVICE_TURN_ON,
{"night_sounds_off": False},
id="night-sounds-on",
),
pytest.param(
"switch.nursery_yoto_pause_with_volume_down",
SERVICE_TURN_ON,
{"pause_volume_down": True},
id="pause-volume-down-on",
),
pytest.param(
"switch.nursery_yoto_pause_with_power_button",
SERVICE_TURN_ON,
{"pause_power_button": True},
id="pause-power-button-on",
),
pytest.param(
"switch.nursery_yoto_day_automatic_brightness",
SERVICE_TURN_ON,
{"day_display_brightness_auto": True},
id="day-auto-brightness-on",
),
# Day brightness is auto in the fixture, so switching auto off falls
# back to full manual brightness.
pytest.param(
"switch.nursery_yoto_day_automatic_brightness",
SERVICE_TURN_OFF,
{"day_display_brightness": 100},
id="day-auto-brightness-off",
),
# Night brightness has a manual value in the fixture, which is kept.
pytest.param(
"switch.nursery_yoto_night_automatic_brightness",
SERVICE_TURN_OFF,
{"night_display_brightness": 40},
id="night-auto-brightness-off",
),
],
)
async def test_switch_actions(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
service: str,
expected_fields: dict[str, bool | int],
) -> None:
"""Switch actions write the matching player config fields."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_yoto_client.set_player_config.assert_awaited_once_with(
PLAYER_ID, **expected_fields
)
mock_yoto_client.update_player_info.assert_awaited_once_with(PLAYER_ID)
async def test_switch_action_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A failed config write raises a Home Assistant error."""
await _setup(hass, mock_config_entry)
mock_yoto_client.set_player_config.side_effect = YotoError("MQTT timeout")
with pytest.raises(
HomeAssistantError, match="Failed to update Yoto player settings"
):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.nursery_yoto_bluetooth"},
blocking=True,
)
+102
View File
@@ -0,0 +1,102 @@
"""Tests for the Yoto time platform."""
from datetime import time
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.time import (
ATTR_TIME,
DOMAIN as TIME_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import PLAYER_ID
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the integration with only the time platform."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.TIME]):
await setup_integration(hass, mock_config_entry)
@pytest.mark.usefixtures("mock_yoto_client")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto time entity."""
await _setup(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_id", "expected_fields"),
[
pytest.param(
"time.nursery_yoto_day_mode_start",
{"day_time": time(8, 30)},
id="day-mode-start",
),
pytest.param(
"time.nursery_yoto_night_mode_start",
{"night_time": time(8, 30)},
id="night-mode-start",
),
],
)
async def test_set_value(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
expected_fields: dict[str, time],
) -> None:
"""Setting a time writes the matching player config field."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
TIME_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: entity_id, ATTR_TIME: time(8, 30)},
blocking=True,
)
mock_yoto_client.set_player_config.assert_awaited_once_with(
PLAYER_ID, **expected_fields
)
mock_yoto_client.update_player_info.assert_awaited_once_with(PLAYER_ID)
async def test_set_value_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A failed config write raises a Home Assistant error."""
await _setup(hass, mock_config_entry)
mock_yoto_client.set_player_config.side_effect = YotoError("MQTT timeout")
with pytest.raises(
HomeAssistantError, match="Failed to update Yoto player settings"
):
await hass.services.async_call(
TIME_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "time.nursery_yoto_day_mode_start", ATTR_TIME: time(7, 0)},
blocking=True,
)