From a5a674224d71d98574ae8baa17efa7260cb64f0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:59:25 +0000 Subject: [PATCH] Add ambient light color preset selects to Yoto https://claude.ai/code/session_015G3sygJ3js3qaMvw7Kn2Jo --- homeassistant/components/yoto/__init__.py | 1 + homeassistant/components/yoto/icons.json | 8 + homeassistant/components/yoto/select.py | 117 ++++++++++++++ homeassistant/components/yoto/strings.json | 28 ++++ .../yoto/snapshots/test_select.ambr | 143 ++++++++++++++++ tests/components/yoto/test_select.py | 153 ++++++++++++++++++ 6 files changed, 450 insertions(+) create mode 100644 homeassistant/components/yoto/select.py create mode 100644 tests/components/yoto/snapshots/test_select.ambr create mode 100644 tests/components/yoto/test_select.py diff --git a/homeassistant/components/yoto/__init__.py b/homeassistant/components/yoto/__init__.py index 42f69d960689..c26550f08382 100644 --- a/homeassistant/components/yoto/__init__.py +++ b/homeassistant/components/yoto/__init__.py @@ -25,6 +25,7 @@ PLATFORMS: list[Platform] = [ Platform.LIGHT, Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, diff --git a/homeassistant/components/yoto/icons.json b/homeassistant/components/yoto/icons.json index 17e40d14f233..c3e24b84296a 100644 --- a/homeassistant/components/yoto/icons.json +++ b/homeassistant/components/yoto/icons.json @@ -36,6 +36,14 @@ "default": "mdi:power-sleep" } }, + "select": { + "day_ambient_color": { + "default": "mdi:palette" + }, + "night_ambient_color": { + "default": "mdi:palette" + } + }, "sensor": { "card_insertion_state": { "default": "mdi:card-bulleted-outline", diff --git a/homeassistant/components/yoto/select.py b/homeassistant/components/yoto/select.py new file mode 100644 index 000000000000..efdcfd8cec98 --- /dev/null +++ b/homeassistant/components/yoto/select.py @@ -0,0 +1,117 @@ +"""Select platform for the Yoto integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from yoto_api import PlayerConfig, YotoPlayer, caps_for + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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 + +# The ambient light presets offered by the Yoto app. The app has written +# different hex values for the same preset over time, so reads recognise +# every known variant while writes use the current canonical value. +OPTION_TO_HEX = { + "sky_blue": "#40bfd9", + "apple_green": "#9eff00", + "lilac": "#f57399", + "tambourine_red": "#ff0000", + "orange_peel": "#ff3900", + "bumblebee_yellow": "#ff8500", + "white": "#ffffff", + "off": "#000000", +} + +HEX_TO_OPTION = { + "#41c0f0": "sky_blue", + "#e6ff00": "apple_green", + "#f72a69": "lilac", + "#ff8c00": "orange_peel", + "#ffb800": "bumblebee_yellow", + "#0": "off", + "off": "off", +} | {hex_value: option for option, hex_value in OPTION_TO_HEX.items()} + + +@dataclass(frozen=True, kw_only=True) +class YotoSelectEntityDescription(SelectEntityDescription): + """Describes a Yoto select entity. + + ``config_field`` is the ``set_player_config`` kwarg written on change. + """ + + value_fn: Callable[[PlayerConfig], str | None] + config_field: str + + +SELECTS: tuple[YotoSelectEntityDescription, ...] = ( + YotoSelectEntityDescription( + key="day_ambient_color", + translation_key="day_ambient_color", + options=list(OPTION_TO_HEX), + entity_category=EntityCategory.CONFIG, + value_fn=lambda config: config.day_ambient_colour, + config_field="day_ambient_colour", + ), + YotoSelectEntityDescription( + key="night_ambient_color", + translation_key="night_ambient_color", + options=list(OPTION_TO_HEX), + entity_category=EntityCategory.CONFIG, + value_fn=lambda config: config.night_ambient_colour, + config_field="night_ambient_colour", + ), +) + + +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( + YotoSelect(coordinator, player, description) + for player in coordinator.client.players.values() + if caps_for(player.device).has_ambient_light + for description in SELECTS + ) + + +class YotoSelect(YotoEntity, SelectEntity): + """Representation of a Yoto ambient light colour preset.""" + + entity_description: YotoSelectEntityDescription + + def __init__( + self, + coordinator: YotoDataUpdateCoordinator, + player: YotoPlayer, + description: YotoSelectEntityDescription, + ) -> None: + """Initialize the select.""" + super().__init__(coordinator, player) + self.entity_description = description + self._attr_unique_id = f"{player.id}_{description.key}" + + @property + def current_option(self) -> str | None: + """Return the configured preset, None for an unrecognised colour.""" + value = self.entity_description.value_fn(self.player.info.config) + if value is None: + return None + return HEX_TO_OPTION.get(value.lower()) + + async def async_select_option(self, option: str) -> None: + """Write the preset's colour to the player config.""" + await self._async_set_config( + **{self.entity_description.config_field: OPTION_TO_HEX[option]} + ) diff --git a/homeassistant/components/yoto/strings.json b/homeassistant/components/yoto/strings.json index b57700318b2a..3bed0ee7966c 100644 --- a/homeassistant/components/yoto/strings.json +++ b/homeassistant/components/yoto/strings.json @@ -73,6 +73,34 @@ "name": "Auto-shutdown delay" } }, + "select": { + "day_ambient_color": { + "name": "Day ambient light color", + "state": { + "apple_green": "Apple green", + "bumblebee_yellow": "Bumblebee yellow", + "lilac": "Lilac", + "off": "[%key:common::state::off%]", + "orange_peel": "Orange peel", + "sky_blue": "Sky blue", + "tambourine_red": "Tambourine red", + "white": "White" + } + }, + "night_ambient_color": { + "name": "Night ambient light color", + "state": { + "apple_green": "[%key:component::yoto::entity::select::day_ambient_color::state::apple_green%]", + "bumblebee_yellow": "[%key:component::yoto::entity::select::day_ambient_color::state::bumblebee_yellow%]", + "lilac": "[%key:component::yoto::entity::select::day_ambient_color::state::lilac%]", + "off": "[%key:common::state::off%]", + "orange_peel": "[%key:component::yoto::entity::select::day_ambient_color::state::orange_peel%]", + "sky_blue": "[%key:component::yoto::entity::select::day_ambient_color::state::sky_blue%]", + "tambourine_red": "[%key:component::yoto::entity::select::day_ambient_color::state::tambourine_red%]", + "white": "[%key:component::yoto::entity::select::day_ambient_color::state::white%]" + } + } + }, "sensor": { "card_insertion_state": { "name": "Card slot", diff --git a/tests/components/yoto/snapshots/test_select.ambr b/tests/components/yoto/snapshots/test_select.ambr new file mode 100644 index 000000000000..11a336fc1c27 --- /dev/null +++ b/tests/components/yoto/snapshots/test_select.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_all_entities[select.nursery_yoto_day_ambient_light_color-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'sky_blue', + 'apple_green', + 'lilac', + 'tambourine_red', + 'orange_peel', + 'bumblebee_yellow', + 'white', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.nursery_yoto_day_ambient_light_color', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Day ambient light color', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Day ambient light color', + 'platform': 'yoto', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'day_ambient_color', + 'unique_id': 'player-test_day_ambient_color', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.nursery_yoto_day_ambient_light_color-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nursery Yoto Day ambient light color', + 'options': list([ + 'sky_blue', + 'apple_green', + 'lilac', + 'tambourine_red', + 'orange_peel', + 'bumblebee_yellow', + 'white', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.nursery_yoto_day_ambient_light_color', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'sky_blue', + }) +# --- +# name: test_all_entities[select.nursery_yoto_night_ambient_light_color-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'sky_blue', + 'apple_green', + 'lilac', + 'tambourine_red', + 'orange_peel', + 'bumblebee_yellow', + 'white', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.nursery_yoto_night_ambient_light_color', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Night ambient light color', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night ambient light color', + 'platform': 'yoto', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'night_ambient_color', + 'unique_id': 'player-test_night_ambient_color', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.nursery_yoto_night_ambient_light_color-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nursery Yoto Night ambient light color', + 'options': list([ + 'sky_blue', + 'apple_green', + 'lilac', + 'tambourine_red', + 'orange_peel', + 'bumblebee_yellow', + 'white', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.nursery_yoto_night_ambient_light_color', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/yoto/test_select.py b/tests/components/yoto/test_select.py new file mode 100644 index 000000000000..47c2acdbbd1f --- /dev/null +++ b/tests/components/yoto/test_select.py @@ -0,0 +1,153 @@ +"""Tests for the Yoto select platform.""" + +from dataclasses import replace +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, STATE_UNKNOWN, 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") + +DAY_SELECT_ENTITY_ID = "select.nursery_yoto_day_ambient_light_color" +NIGHT_SELECT_ENTITY_ID = "select.nursery_yoto_night_ambient_light_color" + + +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_no_selects_for_mini( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """The Yoto Mini has no ambient light, so no select entities are created.""" + player = mock_yoto_client.players[PLAYER_ID] + player.device = replace(player.device, device_family="mini") + + await _setup(hass, mock_config_entry) + + assert not hass.states.async_entity_ids(SELECT_DOMAIN) + + +@pytest.mark.parametrize( + ("colour", "expected_state"), + [ + # The app has written several hex variants per preset over time. + pytest.param("#41C0F0", "sky_blue", id="alias-hex-case-insensitive"), + pytest.param("#ffb800", "bumblebee_yellow", id="alias-hex"), + pytest.param("off", "off", id="off-sentinel"), + pytest.param("#123456", STATE_UNKNOWN, id="unrecognised-colour"), + pytest.param(None, STATE_UNKNOWN, id="unset"), + ], +) +async def test_colour_parsing( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + colour: str | None, + expected_state: str, +) -> None: + """Every known colour variant maps to its preset; unknown ones do not.""" + mock_yoto_client.players[PLAYER_ID].info.config.day_ambient_colour = colour + await _setup(hass, mock_config_entry) + + assert hass.states.get(DAY_SELECT_ENTITY_ID).state == expected_state + + +@pytest.mark.parametrize( + ("entity_id", "option", "expected_fields"), + [ + pytest.param( + DAY_SELECT_ENTITY_ID, + "tambourine_red", + {"day_ambient_colour": "#ff0000"}, + id="day-preset", + ), + pytest.param( + NIGHT_SELECT_ENTITY_ID, + "white", + {"night_ambient_colour": "#ffffff"}, + id="night-preset", + ), + pytest.param( + NIGHT_SELECT_ENTITY_ID, + "off", + {"night_ambient_colour": "#000000"}, + id="night-off", + ), + ], +) +async def test_select_option( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + option: str, + expected_fields: dict[str, str], +) -> None: + """Selecting a preset writes its colour to the player config.""" + await _setup(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, + 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_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: DAY_SELECT_ENTITY_ID, ATTR_OPTION: "white"}, + blocking=True, + )