Add ambient light color preset selects to Yoto

https://claude.ai/code/session_015G3sygJ3js3qaMvw7Kn2Jo
This commit is contained in:
Claude
2026-06-11 13:59:25 +00:00
parent a73f15f1ea
commit a5a674224d
6 changed files with 450 additions and 0 deletions
@@ -25,6 +25,7 @@ PLATFORMS: list[Platform] = [
Platform.LIGHT,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
+8
View File
@@ -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",
+117
View File
@@ -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]}
)
@@ -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",
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.nursery_yoto_day_ambient_light_color',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'select.nursery_yoto_day_ambient_light_color',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.nursery_yoto_night_ambient_light_color',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'select.nursery_yoto_night_ambient_light_color',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+153
View File
@@ -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,
)