Add sensors to Yoto

Add the sensor platform: battery, card slot and day mode, plus power
source, SSID and Wi-Fi RSSI as diagnostics (RSSI disabled by default).

Move the online check to the base entity so all entities (including the
media player) report unavailable when the player is offline.
This commit is contained in:
Paul Bottein
2026-06-07 23:22:34 +02:00
parent c27e43c570
commit 65fd4005a5
12 changed files with 621 additions and 24 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
+1 -2
View File
@@ -128,10 +128,9 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
"""Ask each player to push a fresh status snapshot over MQTT."""
if not self.client.is_mqtt_connected:
return
# Fire-and-forget: the data/status response lands via the on_update
# callback later, which already triggers async_set_updated_data.
for device_id in list(self.client.players):
await self.client.request_player_status(device_id)
await self.client.request_player_extended_status(device_id)
def _mqtt_event(self, _player: YotoPlayer) -> None:
"""Handle a real-time update pushed by the Yoto MQTT broker."""
+5 -1
View File
@@ -43,4 +43,8 @@ class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._player_id in self.coordinator.data
return (
super().available
and self._player_id in self.coordinator.data
and bool(self.player.is_online)
)
+28
View File
@@ -0,0 +1,28 @@
{
"entity": {
"sensor": {
"card_insertion_state": {
"default": "mdi:card-bulleted-outline",
"state": {
"none": "mdi:card-bulleted-off-outline",
"physical": "mdi:card-bulleted",
"remote": "mdi:cast-audio",
"streaming": "mdi:radio-tower"
}
},
"day_mode": {
"default": "mdi:theme-light-dark",
"state": {
"day": "mdi:weather-sunny",
"night": "mdi:weather-night"
}
},
"power_source": {
"default": "mdi:power-plug"
},
"ssid": {
"default": "mdi:router-wireless"
}
}
}
}
@@ -82,11 +82,6 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
super().__init__(coordinator, player)
self._attr_unique_id = player.id
@property
def available(self) -> bool:
"""Return whether the player is reachable through the Yoto cloud."""
return super().available and bool(self.player.is_online)
@property
def state(self) -> MediaPlayerState:
"""Return the playback state."""
@@ -57,20 +57,12 @@ rules:
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category:
status: exempt
comment: Only the media_player entity ships in this PR; no diagnostic entities yet.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: Only the media_player entity ships in this PR; no entities are disabled by default.
entity-translations:
status: exempt
comment: The media_player uses the device name; no translatable strings yet.
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: No custom icon translations are needed yet.
icon-translations: done
reconfiguration-flow:
status: exempt
comment: Authorization is the only configuration; reauth covers re-linking the account.
+130
View File
@@ -0,0 +1,130 @@
"""Sensor platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from yoto_api import CardInsertionState, DayMode, PowerSource, YotoPlayer
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 0
def _enum_state(value: CardInsertionState | PowerSource | None) -> str | None:
"""Return an enum member as a lowercase string, or None if unset."""
return value.name.lower() if value is not None else None
def _day_mode_state(value: DayMode | None) -> str | None:
"""Return day/night, treating the firmware's UNKNOWN as unset."""
if value is None or value is DayMode.UNKNOWN:
return None
return value.name.lower()
@dataclass(frozen=True, kw_only=True)
class YotoSensorEntityDescription(SensorEntityDescription):
"""Describes a Yoto sensor entity."""
value_fn: Callable[[YotoPlayer], StateType]
SENSORS: tuple[YotoSensorEntityDescription, ...] = (
YotoSensorEntityDescription(
key="battery_level",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda player: player.status.battery_level_percentage,
),
YotoSensorEntityDescription(
key="card_insertion_state",
translation_key="card_insertion_state",
device_class=SensorDeviceClass.ENUM,
options=[state.name.lower() for state in CardInsertionState],
value_fn=lambda player: _enum_state(player.status.card_insertion_state),
),
YotoSensorEntityDescription(
key="day_mode",
translation_key="day_mode",
device_class=SensorDeviceClass.ENUM,
options=["day", "night"],
value_fn=lambda player: _day_mode_state(player.status.day_mode),
),
YotoSensorEntityDescription(
key="power_source",
translation_key="power_source",
device_class=SensorDeviceClass.ENUM,
options=[source.name.lower() for source in PowerSource],
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda player: _enum_state(player.extended_status.power_source),
),
YotoSensorEntityDescription(
key="ssid",
translation_key="ssid",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda player: player.extended_status.network_ssid,
),
YotoSensorEntityDescription(
key="wifi_rssi",
translation_key="wifi_rssi",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda player: player.extended_status.wifi_strength,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto sensor platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoSensor(coordinator, player, description)
for player in coordinator.client.players.values()
for description in SENSORS
)
class YotoSensor(YotoEntity, SensorEntity):
"""Representation of a Yoto player sensor."""
entity_description: YotoSensorEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.player)
@@ -30,6 +30,41 @@
}
}
},
"entity": {
"sensor": {
"card_insertion_state": {
"name": "Card slot",
"state": {
"none": "Empty",
"physical": "Physical card",
"remote": "Remote",
"streaming": "Streaming"
}
},
"day_mode": {
"name": "Day mode",
"state": {
"day": "Day",
"night": "Night"
}
},
"power_source": {
"name": "Power source",
"state": {
"battery": "Battery",
"qi_dock": "Wireless dock",
"usb_c": "USB-C",
"v2_dock": "Dock"
}
},
"ssid": {
"name": "SSID"
},
"wifi_rssi": {
"name": "Wi-Fi RSSI"
}
}
},
"exceptions": {
"card_detail_failed": {
"message": "Could not load Yoto card details: {error}"
+15
View File
@@ -9,12 +9,17 @@ import jwt
import pytest
from yoto_api import (
Card,
CardInsertionState,
Chapter,
DayMode,
Device,
Group,
PlaybackEvent,
PlaybackStatus,
PlayerExtendedStatus,
PlayerInfo,
PlayerStatus,
PowerSource,
Track,
YotoPlayer,
)
@@ -88,6 +93,16 @@ def _build_player() -> YotoPlayer:
firmware_version="v2.17.5",
mac="aa:bb:cc:dd:ee:ff",
)
player.status = PlayerStatus(
battery_level_percentage=75,
card_insertion_state=CardInsertionState.PHYSICAL,
day_mode=DayMode.DAY,
)
player.extended_status = PlayerExtendedStatus(
power_source=PowerSource.USB_C,
network_ssid="HomeNet",
wifi_strength=-55,
)
player.last_event = PlaybackEvent(
player_id=PLAYER_ID,
playback_status=PlaybackStatus.PLAYING,
@@ -0,0 +1,349 @@
# serializer version: 1
# name: test_all_entities[sensor.nursery_yoto_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nursery_yoto_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'player-test_battery_level',
'unit_of_measurement': '%',
})
# ---
# name: test_all_entities[sensor.nursery_yoto_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Nursery Yoto Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.nursery_yoto_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '75',
})
# ---
# name: test_all_entities[sensor.nursery_yoto_card_slot-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'none',
'physical',
'remote',
'streaming',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nursery_yoto_card_slot',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Card slot',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Card slot',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'card_insertion_state',
'unique_id': 'player-test_card_insertion_state',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.nursery_yoto_card_slot-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Nursery Yoto Card slot',
'options': list([
'none',
'physical',
'remote',
'streaming',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.nursery_yoto_card_slot',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'physical',
})
# ---
# name: test_all_entities[sensor.nursery_yoto_day_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'day',
'night',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.nursery_yoto_day_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Day mode',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Day mode',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'day_mode',
'unique_id': 'player-test_day_mode',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.nursery_yoto_day_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Nursery Yoto Day mode',
'options': list([
'day',
'night',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.nursery_yoto_day_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'day',
})
# ---
# name: test_all_entities[sensor.nursery_yoto_power_source-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'options': list([
'battery',
'v2_dock',
'usb_c',
'qi_dock',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.nursery_yoto_power_source',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Power source',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Power source',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'power_source',
'unique_id': 'player-test_power_source',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.nursery_yoto_power_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Nursery Yoto Power source',
'options': list([
'battery',
'v2_dock',
'usb_c',
'qi_dock',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.nursery_yoto_power_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'usb_c',
})
# ---
# name: test_all_entities[sensor.nursery_yoto_ssid-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': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.nursery_yoto_ssid',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'SSID',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'SSID',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ssid',
'unique_id': 'player-test_ssid',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.nursery_yoto_ssid-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Nursery Yoto SSID',
}),
'context': <ANY>,
'entity_id': 'sensor.nursery_yoto_ssid',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'HomeNet',
})
# ---
# name: test_all_entities[sensor.nursery_yoto_wi_fi_rssi-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.nursery_yoto_wi_fi_rssi',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Wi-Fi RSSI',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Wi-Fi RSSI',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wifi_rssi',
'unique_id': 'player-test_wifi_rssi',
'unit_of_measurement': 'dBm',
})
# ---
# name: test_all_entities[sensor.nursery_yoto_wi_fi_rssi-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'signal_strength',
'friendly_name': 'Nursery Yoto Wi-Fi RSSI',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.nursery_yoto_wi_fi_rssi',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-55',
})
# ---
+4 -3
View File
@@ -1,7 +1,7 @@
"""Tests for the Yoto media player platform."""
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import (
SERVICE_VOLUME_SET,
MediaPlayerState,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
@@ -68,7 +68,8 @@ async def test_entity_state(
) -> None:
"""Snapshot the media player entity state."""
freezer.move_to("2026-05-08T12:00:00+00:00")
await setup_integration(hass, mock_config_entry)
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.MEDIA_PLAYER]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+49
View File
@@ -0,0 +1,49 @@
"""Tests for the Yoto sensor platform."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
ENTITY_ID = "sensor.nursery_yoto_battery"
@pytest.mark.usefixtures("mock_yoto_client", "entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto sensor entity."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sensor_unavailable_when_offline(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Sensors are unavailable while the player is offline."""
player = next(iter(mock_yoto_client.players.values()))
player.is_online = False
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE