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, + )