diff --git a/homeassistant/components/yoto/icons.json b/homeassistant/components/yoto/icons.json index 4329a33f73dd..17e40d14f233 100644 --- a/homeassistant/components/yoto/icons.json +++ b/homeassistant/components/yoto/icons.json @@ -9,10 +9,7 @@ } }, "light": { - "day_ambient_light": { - "default": "mdi:lightbulb-on" - }, - "night_ambient_light": { + "ambient_light": { "default": "mdi:lightbulb-night" } }, diff --git a/homeassistant/components/yoto/light.py b/homeassistant/components/yoto/light.py index 4c84c4f74dc7..7493ff732c92 100644 --- a/homeassistant/components/yoto/light.py +++ b/homeassistant/components/yoto/light.py @@ -1,10 +1,8 @@ """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 yoto_api import YotoError, YotoPlayer, caps_for from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -13,49 +11,24 @@ from homeassistant.components.light import ( LightEntity, LightEntityDescription, ) -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 -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", - ), +AMBIENT_LIGHT = LightEntityDescription( + key="ambient_light", + translation_key="ambient_light", ) def _parse_colour(value: str | None) -> tuple[int, int, int] | None: - """Parse a ``#rrggbb`` config colour into an RGB tuple.""" + """Parse a ``#rrggbb``/``0xrrggbb`` colour into an RGB tuple.""" if value is None or value == "off": return None try: @@ -73,23 +46,22 @@ async def async_setup_entry( """Set up the Yoto light platform.""" coordinator = entry.runtime_data async_add_entities( - YotoAmbientLight(coordinator, player, description) + YotoAmbientLight(coordinator, player) 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's ambient light (nightlight). - 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. + The lamp has a single colour channel and no separate brightness, so + brightness is folded into the RGB value: the current colour is + reported as full-brightness RGB scaled down by a brightness equal to + its brightest channel. """ - entity_description: YotoLightEntityDescription + entity_description = AMBIENT_LIGHT _attr_color_mode = ColorMode.RGB _attr_supported_color_modes = {ColorMode.RGB} @@ -97,24 +69,22 @@ class YotoAmbientLight(YotoEntity, LightEntity): 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}" + self._attr_unique_id = f"{player.id}_ambient_light" @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)) + """Return the current lamp colour, None when off.""" + rgb = _parse_colour(self.player.status.nightlight_mode) 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 True if the ambient light is lit.""" return self._rgb is not None @property @@ -126,7 +96,7 @@ class YotoAmbientLight(YotoEntity, LightEntity): @property def rgb_color(self) -> tuple[int, int, int] | None: - """Return the configured colour scaled to full brightness.""" + """Return the current colour scaled to full brightness.""" if (rgb := self._rgb) is None: return None scale = max(rgb) @@ -141,12 +111,23 @@ class YotoAmbientLight(YotoEntity, LightEntity): 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}"} - ) + await self._async_set_ambients(red, green, blue) 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} - ) + await self._async_set_ambients(0, 0, 0) + + async def _async_set_ambients(self, red: int, green: int, blue: int) -> None: + """Send the lamp colour and ask for a status push to confirm it.""" + client = self.coordinator.client + try: + await client.set_ambients(self._player_id, red, green, blue) + # The firmware does not push data/status spontaneously; request a + # snapshot so the new lamp state lands via the MQTT callback. + await client.request_player_status(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/strings.json b/homeassistant/components/yoto/strings.json index 043649095d72..b57700318b2a 100644 --- a/homeassistant/components/yoto/strings.json +++ b/homeassistant/components/yoto/strings.json @@ -46,11 +46,8 @@ } }, "light": { - "day_ambient_light": { - "name": "Day ambient light" - }, - "night_ambient_light": { - "name": "Night ambient light" + "ambient_light": { + "name": "Ambient light" } }, "number": { diff --git a/tests/components/yoto/conftest.py b/tests/components/yoto/conftest.py index abd593c06bd7..7e50bb3acb69 100644 --- a/tests/components/yoto/conftest.py +++ b/tests/components/yoto/conftest.py @@ -123,6 +123,7 @@ def _build_player() -> YotoPlayer: day_mode=DayMode.DAY, is_audio_device_connected=False, is_bluetooth_audio_connected=True, + nightlight_mode="0x194a55", current_display_brightness=85, ) player.last_event = PlaybackEvent( diff --git a/tests/components/yoto/snapshots/test_diagnostics.ambr b/tests/components/yoto/snapshots/test_diagnostics.ambr index 62a18395bb2a..2131405a412f 100644 --- a/tests/components/yoto/snapshots/test_diagnostics.ambr +++ b/tests/components/yoto/snapshots/test_diagnostics.ambr @@ -142,7 +142,7 @@ 'is_audio_device_connected': False, 'is_bluetooth_audio_connected': True, 'is_charging': True, - 'nightlight_mode': None, + 'nightlight_mode': '0x194a55', 'system_volume_percentage': None, 'updated_at': None, 'user_volume_percentage': None, diff --git a/tests/components/yoto/snapshots/test_light.ambr b/tests/components/yoto/snapshots/test_light.ambr index 7329f6e9c9e9..c4df18ed0bf3 100644 --- a/tests/components/yoto/snapshots/test_light.ambr +++ b/tests/components/yoto/snapshots/test_light.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[light.nursery_yoto_day_ambient_light-entry] +# name: test_all_entities[light.nursery_yoto_ambient_light-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -16,8 +16,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'light', - 'entity_category': , - 'entity_id': 'light.nursery_yoto_day_ambient_light', + 'entity_category': None, + 'entity_id': 'light.nursery_yoto_ambient_light', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -25,34 +25,34 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Day ambient light', + 'object_id_base': 'Ambient light', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Day ambient light', + 'original_name': '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', + 'translation_key': 'ambient_light', + 'unique_id': 'player-test_ambient_light', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[light.nursery_yoto_day_ambient_light-state] +# name: test_all_entities[light.nursery_yoto_ambient_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': 217, + 'brightness': 85, 'color_mode': , - 'friendly_name': 'Nursery Yoto Day ambient light', + 'friendly_name': 'Nursery Yoto Ambient light', 'hs_color': tuple( - 190.333, + 191.0, 70.588, ), 'rgb_color': tuple( 75, - 224, + 222, 255, ), 'supported_color_modes': list([ @@ -61,77 +61,14 @@ 'supported_features': , 'xy_color': tuple( 0.168, - 0.293, + 0.29, ), }), 'context': , - 'entity_id': 'light.nursery_yoto_day_ambient_light', + 'entity_id': 'light.nursery_yoto_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 index 826d8c893b98..474ffb807649 100644 --- a/tests/components/yoto/test_light.py +++ b/tests/components/yoto/test_light.py @@ -29,8 +29,7 @@ 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" +LIGHT_ENTITY_ID = "light.nursery_yoto_ambient_light" async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: @@ -52,12 +51,12 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_no_lights_for_mini( +async def test_no_light_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.""" + """The Yoto Mini has no ambient light, so no light entity is created.""" player = mock_yoto_client.players[PLAYER_ID] player.device = replace(player.device, device_family="mini") @@ -67,35 +66,25 @@ async def test_no_lights_for_mini( @pytest.mark.parametrize( - ("entity_id", "service_data", "expected_colour"), + ("service_data", "expected_rgb"), [ - # Configured day colour #40bfd9 reports brightness 217 (its - # brightest channel); a new colour is scaled back by it. + # The lamp colour is 0x194a55: brightness 85 (its brightest + # channel), full-brightness colour (75, 222, 255). New values are + # combined with whichever half the call does not provide. pytest.param( - DAY_LIGHT_ENTITY_ID, {ATTR_RGB_COLOR: (255, 0, 0)}, - {"day_ambient_colour": "#d90000"}, - id="day-set-colour", + (85, 0, 0), + id="set-colour-keeps-brightness", ), pytest.param( - DAY_LIGHT_ENTITY_ID, {ATTR_BRIGHTNESS: 128}, - {"day_ambient_colour": "#267080"}, - id="day-set-brightness", + (38, 111, 128), + id="set-brightness-keeps-colour", ), 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", + (0, 255, 0), + id="set-colour-and-brightness", ), ], ) @@ -103,24 +92,40 @@ 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], + expected_rgb: tuple[int, int, int], ) -> None: - """Turning on writes the matching ambient colour config field.""" + """Turning on sends the combined colour to the player.""" await _setup(hass, mock_config_entry) await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, **service_data}, + {ATTR_ENTITY_ID: LIGHT_ENTITY_ID, **service_data}, blocking=True, ) - mock_yoto_client.set_player_config.assert_awaited_once_with( - PLAYER_ID, **expected_colour + mock_yoto_client.set_ambients.assert_awaited_once_with(PLAYER_ID, *expected_rgb) + mock_yoto_client.request_player_status.assert_awaited_once_with(PLAYER_ID) + + +async def test_turn_on_while_off( + hass: HomeAssistant, + mock_yoto_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Turning on an unlit lamp without arguments defaults to white.""" + mock_yoto_client.players[PLAYER_ID].status.nightlight_mode = "off" + await _setup(hass, mock_config_entry) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: LIGHT_ENTITY_ID}, + blocking=True, ) - mock_yoto_client.update_player_info.assert_awaited_once_with(PLAYER_ID) + + mock_yoto_client.set_ambients.assert_awaited_once_with(PLAYER_ID, 255, 255, 255) async def test_turn_off( @@ -128,19 +133,17 @@ async def test_turn_off( mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """Turning off writes black to the ambient colour config field.""" + """Turning off sends black to the player.""" await _setup(hass, mock_config_entry) await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: DAY_LIGHT_ENTITY_ID}, + {ATTR_ENTITY_ID: LIGHT_ENTITY_ID}, blocking=True, ) - mock_yoto_client.set_player_config.assert_awaited_once_with( - PLAYER_ID, day_ambient_colour="#000000" - ) + mock_yoto_client.set_ambients.assert_awaited_once_with(PLAYER_ID, 0, 0, 0) async def test_turn_on_failure( @@ -148,16 +151,14 @@ async def test_turn_on_failure( mock_yoto_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: - """A failed config write raises a Home Assistant error.""" + """A failed lamp command raises a Home Assistant error.""" await _setup(hass, mock_config_entry) - mock_yoto_client.set_player_config.side_effect = YotoError("MQTT timeout") + mock_yoto_client.set_ambients.side_effect = YotoError("MQTT timeout") - with pytest.raises( - HomeAssistantError, match="Failed to update Yoto player settings" - ): + with pytest.raises(HomeAssistantError, match="Yoto command failed"): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: DAY_LIGHT_ENTITY_ID}, + {ATTR_ENTITY_ID: LIGHT_ENTITY_ID}, blocking=True, )