From 6d9573e95fcc243bd84a87c3302e3a0fb8ad261b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 08:41:44 +0000 Subject: [PATCH] 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 --- homeassistant/components/yoto/__init__.py | 11 +- .../components/yoto/binary_sensor.py | 86 +++++++++ homeassistant/components/yoto/button.py | 64 +++++++ homeassistant/components/yoto/diagnostics.py | 31 +++ homeassistant/components/yoto/entity.py | 25 ++- homeassistant/components/yoto/icons.json | 101 ++++++++++ homeassistant/components/yoto/media_player.py | 5 - homeassistant/components/yoto/number.py | 161 ++++++++++++++++ .../components/yoto/quality_scale.yaml | 22 +-- homeassistant/components/yoto/select.py | 57 ++++++ homeassistant/components/yoto/sensor.py | 122 ++++++++++++ homeassistant/components/yoto/strings.json | 106 +++++++++++ homeassistant/components/yoto/switch.py | 180 ++++++++++++++++++ homeassistant/components/yoto/time.py | 86 +++++++++ tests/components/yoto/conftest.py | 37 +++- tests/components/yoto/test_binary_sensor.py | 30 +++ tests/components/yoto/test_button.py | 77 ++++++++ tests/components/yoto/test_diagnostics.py | 28 +++ tests/components/yoto/test_media_player.py | 7 +- tests/components/yoto/test_number.py | 134 +++++++++++++ tests/components/yoto/test_select.py | 86 +++++++++ tests/components/yoto/test_sensor.py | 49 +++++ tests/components/yoto/test_switch.py | 173 +++++++++++++++++ tests/components/yoto/test_time.py | 102 ++++++++++ 24 files changed, 1752 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/yoto/binary_sensor.py create mode 100644 homeassistant/components/yoto/button.py create mode 100644 homeassistant/components/yoto/diagnostics.py create mode 100644 homeassistant/components/yoto/icons.json create mode 100644 homeassistant/components/yoto/number.py create mode 100644 homeassistant/components/yoto/select.py create mode 100644 homeassistant/components/yoto/sensor.py create mode 100644 homeassistant/components/yoto/switch.py create mode 100644 homeassistant/components/yoto/time.py create mode 100644 tests/components/yoto/test_binary_sensor.py create mode 100644 tests/components/yoto/test_button.py create mode 100644 tests/components/yoto/test_diagnostics.py create mode 100644 tests/components/yoto/test_number.py create mode 100644 tests/components/yoto/test_select.py create mode 100644 tests/components/yoto/test_sensor.py create mode 100644 tests/components/yoto/test_switch.py create mode 100644 tests/components/yoto/test_time.py diff --git a/homeassistant/components/yoto/__init__.py b/homeassistant/components/yoto/__init__.py index 0d04228e1575..cae903cadb80 100644 --- a/homeassistant/components/yoto/__init__.py +++ b/homeassistant/components/yoto/__init__.py @@ -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: diff --git a/homeassistant/components/yoto/binary_sensor.py b/homeassistant/components/yoto/binary_sensor.py new file mode 100644 index 000000000000..d76e8e9c6629 --- /dev/null +++ b/homeassistant/components/yoto/binary_sensor.py @@ -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) diff --git a/homeassistant/components/yoto/button.py b/homeassistant/components/yoto/button.py new file mode 100644 index 000000000000..3d1e19b26aa2 --- /dev/null +++ b/homeassistant/components/yoto/button.py @@ -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 diff --git a/homeassistant/components/yoto/diagnostics.py b/homeassistant/components/yoto/diagnostics.py new file mode 100644 index 000000000000..9e0dd677ac02 --- /dev/null +++ b/homeassistant/components/yoto/diagnostics.py @@ -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() + }, + } diff --git a/homeassistant/components/yoto/entity.py b/homeassistant/components/yoto/entity.py index 571d6d87d2cd..ae79ac6c2d20 100644 --- a/homeassistant/components/yoto/entity.py +++ b/homeassistant/components/yoto/entity.py @@ -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) diff --git a/homeassistant/components/yoto/icons.json b/homeassistant/components/yoto/icons.json new file mode 100644 index 000000000000..5d50bceb22c7 --- /dev/null +++ b/homeassistant/components/yoto/icons.json @@ -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" + } + } + } +} diff --git a/homeassistant/components/yoto/media_player.py b/homeassistant/components/yoto/media_player.py index 5908025f5614..489bcb4ec644 100644 --- a/homeassistant/components/yoto/media_player.py +++ b/homeassistant/components/yoto/media_player.py @@ -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.""" diff --git a/homeassistant/components/yoto/number.py b/homeassistant/components/yoto/number.py new file mode 100644 index 000000000000..922ab72184b4 --- /dev/null +++ b/homeassistant/components/yoto/number.py @@ -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)} + ) diff --git a/homeassistant/components/yoto/quality_scale.yaml b/homeassistant/components/yoto/quality_scale.yaml index ff7bc62db760..6726eecbccf9 100644 --- a/homeassistant/components/yoto/quality_scale.yaml +++ b/homeassistant/components/yoto/quality_scale.yaml @@ -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. diff --git a/homeassistant/components/yoto/select.py b/homeassistant/components/yoto/select.py new file mode 100644 index 000000000000..3f10046c70f2 --- /dev/null +++ b/homeassistant/components/yoto/select.py @@ -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)) diff --git a/homeassistant/components/yoto/sensor.py b/homeassistant/components/yoto/sensor.py new file mode 100644 index 000000000000..0b99b2829b9e --- /dev/null +++ b/homeassistant/components/yoto/sensor.py @@ -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) diff --git a/homeassistant/components/yoto/strings.json b/homeassistant/components/yoto/strings.json index 30149b3bda24..a78ba7a8b7f2 100644 --- a/homeassistant/components/yoto/strings.json +++ b/homeassistant/components/yoto/strings.json @@ -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}" }, diff --git a/homeassistant/components/yoto/switch.py b/homeassistant/components/yoto/switch.py new file mode 100644 index 000000000000..bde695f2ddd3 --- /dev/null +++ b/homeassistant/components/yoto/switch.py @@ -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) + ) diff --git a/homeassistant/components/yoto/time.py b/homeassistant/components/yoto/time.py new file mode 100644 index 000000000000..76267bff84da --- /dev/null +++ b/homeassistant/components/yoto/time.py @@ -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}) diff --git a/tests/components/yoto/conftest.py b/tests/components/yoto/conftest.py index 49e37d6496be..6d5a9b22be6b 100644 --- a/tests/components/yoto/conftest.py +++ b/tests/components/yoto/conftest.py @@ -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, diff --git a/tests/components/yoto/test_binary_sensor.py b/tests/components/yoto/test_binary_sensor.py new file mode 100644 index 000000000000..99687a764031 --- /dev/null +++ b/tests/components/yoto/test_binary_sensor.py @@ -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) diff --git a/tests/components/yoto/test_button.py b/tests/components/yoto/test_button.py new file mode 100644 index 000000000000..c1b9f5e8cd16 --- /dev/null +++ b/tests/components/yoto/test_button.py @@ -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, + ) diff --git a/tests/components/yoto/test_diagnostics.py b/tests/components/yoto/test_diagnostics.py new file mode 100644 index 000000000000..9f472085c2d9 --- /dev/null +++ b/tests/components/yoto/test_diagnostics.py @@ -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 + ) diff --git a/tests/components/yoto/test_media_player.py b/tests/components/yoto/test_media_player.py index fdecace2b935..8d677210afd7 100644 --- a/tests/components/yoto/test_media_player.py +++ b/tests/components/yoto/test_media_player.py @@ -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) diff --git a/tests/components/yoto/test_number.py b/tests/components/yoto/test_number.py new file mode 100644 index 000000000000..1de14b2b244f --- /dev/null +++ b/tests/components/yoto/test_number.py @@ -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, + ) diff --git a/tests/components/yoto/test_select.py b/tests/components/yoto/test_select.py new file mode 100644 index 000000000000..c852ac6733ba --- /dev/null +++ b/tests/components/yoto/test_select.py @@ -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, + ) diff --git a/tests/components/yoto/test_sensor.py b/tests/components/yoto/test_sensor.py new file mode 100644 index 000000000000..78614d65b8f7 --- /dev/null +++ b/tests/components/yoto/test_sensor.py @@ -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 diff --git a/tests/components/yoto/test_switch.py b/tests/components/yoto/test_switch.py new file mode 100644 index 000000000000..0acc0966b480 --- /dev/null +++ b/tests/components/yoto/test_switch.py @@ -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, + ) diff --git a/tests/components/yoto/test_time.py b/tests/components/yoto/test_time.py new file mode 100644 index 000000000000..2aa6a08c43f0 --- /dev/null +++ b/tests/components/yoto/test_time.py @@ -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, + )