Add ambient light platform to Yoto

https://claude.ai/code/session_015G3sygJ3js3qaMvw7Kn2Jo
This commit is contained in:
Claude
2026-06-11 09:53:23 +00:00
parent 97c5815bf6
commit f84ac420af
8 changed files with 473 additions and 2 deletions
@@ -22,6 +22,7 @@ from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SENSOR,
+8
View File
@@ -8,6 +8,14 @@
"default": "mdi:bluetooth-audio"
}
},
"light": {
"day_ambient_light": {
"default": "mdi:lightbulb-on"
},
"night_ambient_light": {
"default": "mdi:lightbulb-night"
}
},
"number": {
"day_display_brightness": {
"default": "mdi:brightness-7"
+152
View File
@@ -0,0 +1,152 @@
"""Light platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from yoto_api import PlayerConfig, YotoPlayer, caps_for
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
LightEntityDescription,
)
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
OFF_COLOUR = "#000000"
@dataclass(frozen=True, kw_only=True)
class YotoLightEntityDescription(LightEntityDescription):
"""Describes a Yoto ambient light entity.
``config_field`` is the ``set_player_config`` kwarg written on change.
"""
value_fn: Callable[[PlayerConfig], str | None]
config_field: str
LIGHTS: tuple[YotoLightEntityDescription, ...] = (
YotoLightEntityDescription(
key="day_ambient_light",
translation_key="day_ambient_light",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.day_ambient_colour,
config_field="day_ambient_colour",
),
YotoLightEntityDescription(
key="night_ambient_light",
translation_key="night_ambient_light",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.night_ambient_colour,
config_field="night_ambient_colour",
),
)
def _parse_colour(value: str | None) -> tuple[int, int, int] | None:
"""Parse a ``#rrggbb`` config colour into an RGB tuple."""
if value is None or value == "off":
return None
try:
raw = int(value.removeprefix("#"), 16)
except ValueError:
return None
return ((raw >> 16) & 0xFF, (raw >> 8) & 0xFF, raw & 0xFF)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto light platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoAmbientLight(coordinator, player, description)
for player in coordinator.client.players.values()
if caps_for(player.device).has_ambient_light
for description in LIGHTS
)
class YotoAmbientLight(YotoEntity, LightEntity):
"""Day/night ambient light colour stored in the player config.
The player exposes a single colour per mode with no separate
brightness channel, so brightness is folded into the RGB value:
the stored colour is reported as full-brightness RGB scaled down
by a brightness equal to its brightest channel.
"""
entity_description: YotoLightEntityDescription
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoLightEntityDescription,
) -> None:
"""Initialize the light."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def _rgb(self) -> tuple[int, int, int] | None:
"""Return the raw configured colour, None when unset or black."""
rgb = _parse_colour(self.entity_description.value_fn(self.player.info.config))
if rgb is None or max(rgb) == 0:
return None
return rgb
@property
def is_on(self) -> bool:
"""Return True if the ambient light colour is set."""
return self._rgb is not None
@property
def brightness(self) -> int | None:
"""Return the brightness derived from the brightest channel."""
if (rgb := self._rgb) is None:
return None
return max(rgb)
@property
def rgb_color(self) -> tuple[int, int, int] | None:
"""Return the configured colour scaled to full brightness."""
if (rgb := self._rgb) is None:
return None
scale = max(rgb)
return (
round(rgb[0] * 255 / scale),
round(rgb[1] * 255 / scale),
round(rgb[2] * 255 / scale),
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Set the ambient light colour."""
rgb = kwargs.get(ATTR_RGB_COLOR) or self.rgb_color or (255, 255, 255)
brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255)
red, green, blue = (round(channel * brightness / 255) for channel in rgb)
await self._async_set_config(
**{self.entity_description.config_field: f"#{red:02x}{green:02x}{blue:02x}"}
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the ambient light off."""
await self._async_set_config(
**{self.entity_description.config_field: OFF_COLOUR}
)
@@ -45,6 +45,14 @@
"name": "Bluetooth audio"
}
},
"light": {
"day_ambient_light": {
"name": "Day ambient light"
},
"night_ambient_light": {
"name": "Night ambient light"
}
},
"number": {
"day_display_brightness": {
"name": "Day display brightness"
+2
View File
@@ -94,11 +94,13 @@ def _build_player() -> YotoPlayer:
config=PlayerConfig(
day_time=dt_time(7, 0),
day_display_brightness_auto=True,
day_ambient_colour="#40bfd9",
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_ambient_colour="#000000",
night_max_volume_limit=50,
night_sounds_off=True,
hour_format=12,
@@ -58,7 +58,7 @@
'bluetooth_enabled': True,
'bt_headphones_enabled': False,
'clock_face': None,
'day_ambient_colour': None,
'day_ambient_colour': '#40bfd9',
'day_display_brightness': None,
'day_display_brightness_auto': True,
'day_max_volume_limit': 80,
@@ -75,7 +75,7 @@
'hour_format': 12,
'locale': None,
'log_level': None,
'night_ambient_colour': None,
'night_ambient_colour': '#000000',
'night_display_brightness': 40,
'night_display_brightness_auto': False,
'night_max_volume_limit': 50,
@@ -0,0 +1,137 @@
# serializer version: 1
# name: test_all_entities[light.nursery_yoto_day_ambient_light-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.RGB: 'rgb'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'light.nursery_yoto_day_ambient_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Day ambient light',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Day ambient light',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'day_ambient_light',
'unique_id': 'player-test_day_ambient_light',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[light.nursery_yoto_day_ambient_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': 217,
'color_mode': <ColorMode.RGB: 'rgb'>,
'friendly_name': 'Nursery Yoto Day ambient light',
'hs_color': tuple(
190.333,
70.588,
),
'rgb_color': tuple(
75,
224,
255,
),
'supported_color_modes': list([
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': tuple(
0.168,
0.293,
),
}),
'context': <ANY>,
'entity_id': 'light.nursery_yoto_day_ambient_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[light.nursery_yoto_night_ambient_light-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'supported_color_modes': list([
<ColorMode.RGB: 'rgb'>,
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'light',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'light.nursery_yoto_night_ambient_light',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Night ambient light',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Night ambient light',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'night_ambient_light',
'unique_id': 'player-test_night_ambient_light',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[light.nursery_yoto_night_ambient_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': None,
'color_mode': None,
'friendly_name': 'Nursery Yoto Night ambient light',
'hs_color': None,
'rgb_color': None,
'supported_color_modes': list([
<ColorMode.RGB: 'rgb'>,
]),
'supported_features': <LightEntityFeature: 0>,
'xy_color': None,
}),
'context': <ANY>,
'entity_id': 'light.nursery_yoto_night_ambient_light',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+163
View File
@@ -0,0 +1,163 @@
"""Tests for the Yoto light 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.light import (
ATTR_BRIGHTNESS,
ATTR_RGB_COLOR,
DOMAIN as LIGHT_DOMAIN,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
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_LIGHT_ENTITY_ID = "light.nursery_yoto_day_ambient_light"
NIGHT_LIGHT_ENTITY_ID = "light.nursery_yoto_night_ambient_light"
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the integration with only the light platform."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.LIGHT]):
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 light entity."""
await _setup(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_no_lights_for_mini(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""The Yoto Mini has no ambient light, so no light 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(LIGHT_DOMAIN)
@pytest.mark.parametrize(
("entity_id", "service_data", "expected_colour"),
[
# Configured day colour #40bfd9 reports brightness 217 (its
# brightest channel); a new colour is scaled back by it.
pytest.param(
DAY_LIGHT_ENTITY_ID,
{ATTR_RGB_COLOR: (255, 0, 0)},
{"day_ambient_colour": "#d90000"},
id="day-set-colour",
),
pytest.param(
DAY_LIGHT_ENTITY_ID,
{ATTR_BRIGHTNESS: 128},
{"day_ambient_colour": "#267080"},
id="day-set-brightness",
),
pytest.param(
DAY_LIGHT_ENTITY_ID,
{ATTR_RGB_COLOR: (0, 255, 0), ATTR_BRIGHTNESS: 255},
{"day_ambient_colour": "#00ff00"},
id="day-set-colour-and-brightness",
),
# Night light is off (#000000); turning it on without arguments
# defaults to white at full brightness.
pytest.param(
NIGHT_LIGHT_ENTITY_ID,
{},
{"night_ambient_colour": "#ffffff"},
id="night-turn-on-default",
),
],
)
async def test_turn_on(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
service_data: dict[str, object],
expected_colour: dict[str, str],
) -> None:
"""Turning on writes the matching ambient colour config field."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id, **service_data},
blocking=True,
)
mock_yoto_client.set_player_config.assert_awaited_once_with(
PLAYER_ID, **expected_colour
)
mock_yoto_client.update_player_info.assert_awaited_once_with(PLAYER_ID)
async def test_turn_off(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Turning off writes black to the ambient colour config field."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: DAY_LIGHT_ENTITY_ID},
blocking=True,
)
mock_yoto_client.set_player_config.assert_awaited_once_with(
PLAYER_ID, day_ambient_colour="#000000"
)
async def test_turn_on_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(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: DAY_LIGHT_ENTITY_ID},
blocking=True,
)