Compare commits

...

2 Commits

Author SHA1 Message Date
Paul Bottein a165fe4273 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 15:03:24 +02:00
Paul Bottein eb207acb91 Add config switches to Yoto
Expose Bluetooth pairing, maximum headphone volume, and per-mode
automatic display brightness as config switches. Auto-brightness is
limited to devices with a light sensor, and turning it off writes a
manual brightness since the API has no standalone off.
2026-06-16 12:20:42 +02:00
7 changed files with 557 additions and 0 deletions
@@ -23,6 +23,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.MEDIA_PLAYER,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
]
+14
View File
@@ -26,6 +26,20 @@
}
}
},
"switch": {
"bluetooth_pairing": {
"default": "mdi:bluetooth"
},
"day_mode_auto_brightness": {
"default": "mdi:brightness-auto"
},
"max_headphone_volume": {
"default": "mdi:headphones-settings"
},
"night_mode_auto_brightness": {
"default": "mdi:brightness-auto"
}
},
"time": {
"day_mode_start": {
"default": "mdi:weather-sunny"
@@ -63,6 +63,20 @@
}
}
},
"switch": {
"bluetooth_pairing": {
"name": "Bluetooth pairing"
},
"day_mode_auto_brightness": {
"name": "Day mode automatic brightness"
},
"max_headphone_volume": {
"name": "Maximum headphone volume"
},
"night_mode_auto_brightness": {
"name": "Night mode automatic brightness"
}
},
"time": {
"day_mode_start": {
"name": "Day mode start"
+123
View File
@@ -0,0 +1,123 @@
"""Switch platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from yoto_api import Capabilities, PlayerConfig, YotoPlayer, caps_for
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
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 YotoConfigEntity
PARALLEL_UPDATES = 1
# Auto-brightness has no standalone "off": the API toggles it off by writing a
# manual brightness instead. Fall back to full brightness, then the matching
# number entity becomes available for the user to adjust.
_BRIGHTNESS_WHEN_AUTO_OFF = 100
@dataclass(frozen=True, kw_only=True)
class YotoSwitchEntityDescription(SwitchEntityDescription):
"""Describes a Yoto switch entity.
``value_fn`` reads the on/off state from the config.
``write_fn`` maps the desired state to ``set_player_config`` kwargs.
``supported_fn`` gates setup on the device's capabilities.
"""
value_fn: Callable[[PlayerConfig], bool | None]
write_fn: Callable[[bool], dict[str, Any]]
supported_fn: Callable[[Capabilities], bool] = lambda caps: True
SWITCHES: tuple[YotoSwitchEntityDescription, ...] = (
YotoSwitchEntityDescription(
key="bluetooth_pairing",
translation_key="bluetooth_pairing",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.bluetooth_enabled,
write_fn=lambda on: {"bluetooth_enabled": on},
),
YotoSwitchEntityDescription(
key="max_headphone_volume",
translation_key="max_headphone_volume",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.headphones_volume_limited,
write_fn=lambda on: {"headphones_volume_limited": on},
),
YotoSwitchEntityDescription(
key="day_mode_auto_brightness",
translation_key="day_mode_auto_brightness",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.day_display_brightness_auto,
write_fn=lambda on: (
{"day_display_brightness_auto": True}
if on
else {"day_display_brightness": _BRIGHTNESS_WHEN_AUTO_OFF}
),
supported_fn=lambda caps: caps.has_light_sensor,
),
YotoSwitchEntityDescription(
key="night_mode_auto_brightness",
translation_key="night_mode_auto_brightness",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.night_display_brightness_auto,
write_fn=lambda on: (
{"night_display_brightness_auto": True}
if on
else {"night_display_brightness": _BRIGHTNESS_WHEN_AUTO_OFF}
),
supported_fn=lambda caps: caps.has_light_sensor,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto switch platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoSwitch(coordinator, player, description)
for player in coordinator.client.players.values()
for description in SWITCHES
if description.supported_fn(caps_for(player.device))
)
class YotoSwitch(YotoConfigEntity, SwitchEntity):
"""Representation of a Yoto player config switch."""
entity_description: YotoSwitchEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return whether the setting is enabled."""
return self.entity_description.value_fn(self.player.info.config)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable the setting."""
await self._async_set_config(**self.entity_description.write_fn(True))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable the setting."""
await self._async_set_config(**self.entity_description.write_fn(False))
+4
View File
@@ -94,6 +94,10 @@ def _build_player() -> YotoPlayer:
config=PlayerConfig(
day_time=dt_time(7, 0),
night_time=dt_time(19, 0),
bluetooth_enabled=True,
headphones_volume_limited=True,
day_display_brightness_auto=False,
night_display_brightness_auto=True,
),
)
player.status = PlayerStatus(
@@ -0,0 +1,201 @@
# serializer version: 1
# name: test_all_entities[switch.nursery_yoto_bluetooth_pairing-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.nursery_yoto_bluetooth_pairing',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Bluetooth pairing',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Bluetooth pairing',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bluetooth_pairing',
'unique_id': 'player-test_bluetooth_pairing',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.nursery_yoto_bluetooth_pairing-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Nursery Yoto Bluetooth pairing',
}),
'context': <ANY>,
'entity_id': 'switch.nursery_yoto_bluetooth_pairing',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[switch.nursery_yoto_day_mode_automatic_brightness-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.nursery_yoto_day_mode_automatic_brightness',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Day mode automatic brightness',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Day mode automatic brightness',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'day_mode_auto_brightness',
'unique_id': 'player-test_day_mode_auto_brightness',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.nursery_yoto_day_mode_automatic_brightness-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Nursery Yoto Day mode automatic brightness',
}),
'context': <ANY>,
'entity_id': 'switch.nursery_yoto_day_mode_automatic_brightness',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[switch.nursery_yoto_maximum_headphone_volume-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.nursery_yoto_maximum_headphone_volume',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Maximum headphone volume',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Maximum headphone volume',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'max_headphone_volume',
'unique_id': 'player-test_max_headphone_volume',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.nursery_yoto_maximum_headphone_volume-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Nursery Yoto Maximum headphone volume',
}),
'context': <ANY>,
'entity_id': 'switch.nursery_yoto_maximum_headphone_volume',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[switch.nursery_yoto_night_mode_automatic_brightness-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.nursery_yoto_night_mode_automatic_brightness',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Night mode automatic brightness',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Night mode automatic brightness',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'night_mode_auto_brightness',
'unique_id': 'player-test_night_mode_auto_brightness',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.nursery_yoto_night_mode_automatic_brightness-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Nursery Yoto Night mode automatic brightness',
}),
'context': <ANY>,
'entity_id': 'switch.nursery_yoto_night_mode_automatic_brightness',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
+200
View File
@@ -0,0 +1,200 @@
"""Tests for the Yoto switch platform."""
from dataclasses import replace
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_UNAVAILABLE,
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")
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the integration with only the switch platform."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.SWITCH]):
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 switch entity."""
await _setup(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_available_when_offline(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Config is written over REST, so entities stay available when offline."""
player = next(iter(mock_yoto_client.players.values()))
player.is_online = False
await _setup(hass, mock_config_entry)
state = hass.states.get("switch.nursery_yoto_bluetooth_pairing")
assert state is not None
assert state.state != STATE_UNAVAILABLE
async def test_auto_brightness_requires_light_sensor(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Auto-brightness switches only exist on devices with a light sensor."""
player = next(iter(mock_yoto_client.players.values()))
player.device = replace(player.device, device_family="v2")
await _setup(hass, mock_config_entry)
assert hass.states.get("switch.nursery_yoto_bluetooth_pairing") is not None
assert hass.states.get("switch.nursery_yoto_day_mode_automatic_brightness") is None
assert (
hass.states.get("switch.nursery_yoto_night_mode_automatic_brightness") is None
)
@pytest.mark.parametrize(
("entity_id", "expected_fields"),
[
pytest.param(
"switch.nursery_yoto_bluetooth_pairing",
{"bluetooth_enabled": True},
id="bluetooth-pairing",
),
pytest.param(
"switch.nursery_yoto_maximum_headphone_volume",
{"headphones_volume_limited": True},
id="max-headphone-volume",
),
pytest.param(
"switch.nursery_yoto_day_mode_automatic_brightness",
{"day_display_brightness_auto": True},
id="day-auto-brightness",
),
pytest.param(
"switch.nursery_yoto_night_mode_automatic_brightness",
{"night_display_brightness_auto": True},
id="night-auto-brightness",
),
],
)
async def test_turn_on(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
expected_fields: dict[str, Any],
) -> None:
"""Turning a switch on writes the matching player config field."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
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)
@pytest.mark.parametrize(
("entity_id", "expected_fields"),
[
pytest.param(
"switch.nursery_yoto_bluetooth_pairing",
{"bluetooth_enabled": False},
id="bluetooth-pairing",
),
pytest.param(
"switch.nursery_yoto_maximum_headphone_volume",
{"headphones_volume_limited": False},
id="max-headphone-volume",
),
pytest.param(
"switch.nursery_yoto_day_mode_automatic_brightness",
{"day_display_brightness": 100},
id="day-auto-brightness",
),
pytest.param(
"switch.nursery_yoto_night_mode_automatic_brightness",
{"night_display_brightness": 100},
id="night-auto-brightness",
),
],
)
async def test_turn_off(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
expected_fields: dict[str, Any],
) -> None:
"""Turning a switch off writes the matching player config field.
Auto-brightness has no standalone off, so it writes a manual brightness.
"""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
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_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(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.nursery_yoto_bluetooth_pairing"},
blocking=True,
)