From f84ac420af00d7f565f1927eced50059d8bd5575 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 09:53:23 +0000 Subject: [PATCH] Add ambient light platform to Yoto https://claude.ai/code/session_015G3sygJ3js3qaMvw7Kn2Jo --- homeassistant/components/yoto/__init__.py | 1 + homeassistant/components/yoto/icons.json | 8 + homeassistant/components/yoto/light.py | 152 ++++++++++++++++ homeassistant/components/yoto/strings.json | 8 + tests/components/yoto/conftest.py | 2 + .../yoto/snapshots/test_diagnostics.ambr | 4 +- .../components/yoto/snapshots/test_light.ambr | 137 +++++++++++++++ tests/components/yoto/test_light.py | 163 ++++++++++++++++++ 8 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/yoto/light.py create mode 100644 tests/components/yoto/snapshots/test_light.ambr create mode 100644 tests/components/yoto/test_light.py diff --git a/homeassistant/components/yoto/__init__.py b/homeassistant/components/yoto/__init__.py index 2c2d84be8caa..42f69d960689 100644 --- a/homeassistant/components/yoto/__init__.py +++ b/homeassistant/components/yoto/__init__.py @@ -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, diff --git a/homeassistant/components/yoto/icons.json b/homeassistant/components/yoto/icons.json index cd0753397548..4329a33f73dd 100644 --- a/homeassistant/components/yoto/icons.json +++ b/homeassistant/components/yoto/icons.json @@ -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" diff --git a/homeassistant/components/yoto/light.py b/homeassistant/components/yoto/light.py new file mode 100644 index 000000000000..4c84c4f74dc7 --- /dev/null +++ b/homeassistant/components/yoto/light.py @@ -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} + ) diff --git a/homeassistant/components/yoto/strings.json b/homeassistant/components/yoto/strings.json index 7fb0a01ed962..043649095d72 100644 --- a/homeassistant/components/yoto/strings.json +++ b/homeassistant/components/yoto/strings.json @@ -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" diff --git a/tests/components/yoto/conftest.py b/tests/components/yoto/conftest.py index 6d5a9b22be6b..abd593c06bd7 100644 --- a/tests/components/yoto/conftest.py +++ b/tests/components/yoto/conftest.py @@ -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, diff --git a/tests/components/yoto/snapshots/test_diagnostics.ambr b/tests/components/yoto/snapshots/test_diagnostics.ambr index 3044d054ab12..62a18395bb2a 100644 --- a/tests/components/yoto/snapshots/test_diagnostics.ambr +++ b/tests/components/yoto/snapshots/test_diagnostics.ambr @@ -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, diff --git a/tests/components/yoto/snapshots/test_light.ambr b/tests/components/yoto/snapshots/test_light.ambr new file mode 100644 index 000000000000..7329f6e9c9e9 --- /dev/null +++ b/tests/components/yoto/snapshots/test_light.ambr @@ -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([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.nursery_yoto_day_ambient_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'friendly_name': 'Nursery Yoto Day ambient light', + 'hs_color': tuple( + 190.333, + 70.588, + ), + 'rgb_color': tuple( + 75, + 224, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.168, + 0.293, + ), + }), + 'context': , + 'entity_id': 'light.nursery_yoto_day_ambient_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': , + 'entity_id': 'light.nursery_yoto_night_ambient_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.nursery_yoto_night_ambient_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/yoto/test_light.py b/tests/components/yoto/test_light.py new file mode 100644 index 000000000000..826d8c893b98 --- /dev/null +++ b/tests/components/yoto/test_light.py @@ -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, + )