mirror of
https://github.com/home-assistant/core.git
synced 2026-06-18 09:52:57 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31791d8e2c | |||
| bf11a0b214 | |||
| 8c5b09f89f | |||
| 01de7edcfa | |||
| 852b1bcb7b | |||
| 8e0e0fedc8 |
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
@@ -58,11 +58,23 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Yoto binary sensor platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
YotoBinarySensor(coordinator, player, description)
|
||||
for player in coordinator.client.players.values()
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
known_players: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _add_players() -> None:
|
||||
current = set(coordinator.data)
|
||||
new_players = current - known_players
|
||||
known_players.clear()
|
||||
known_players.update(current)
|
||||
if new_players:
|
||||
async_add_entities(
|
||||
YotoBinarySensor(coordinator, coordinator.data[player_id], description)
|
||||
for player_id in new_players
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_add_players))
|
||||
_add_players()
|
||||
|
||||
|
||||
class YotoBinarySensor(YotoPlayerEntity, BinarySensorEntity):
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.exceptions import (
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -46,6 +47,7 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
|
||||
)
|
||||
self._session = session
|
||||
self.client = YotoClient(session=async_get_clientsession(hass))
|
||||
self._subscribed_players: set[str] = set()
|
||||
self._sync_token()
|
||||
|
||||
def _sync_token(self) -> None:
|
||||
@@ -87,6 +89,8 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
self._subscribed_players = set(self.client.players)
|
||||
|
||||
# The MQTT data/status topic is not pushed spontaneously; the firmware
|
||||
# only emits it in response to a command/status/request publish.
|
||||
self.config_entry.async_on_unload(
|
||||
@@ -131,8 +135,37 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
await self._async_sync_subscriptions()
|
||||
self._remove_stale_devices()
|
||||
return self.client.players
|
||||
|
||||
async def _async_sync_subscriptions(self) -> None:
|
||||
"""Subscribe new players to MQTT events and unsubscribe removed ones."""
|
||||
current = set(self.client.players)
|
||||
try:
|
||||
for device_id in current - self._subscribed_players:
|
||||
await self.client.subscribe_player_events(device_id)
|
||||
for device_id in self._subscribed_players - current:
|
||||
await self.client.unsubscribe_player_events(device_id)
|
||||
except YotoError as err:
|
||||
_LOGGER.warning("Could not update Yoto event subscriptions: %s", err)
|
||||
return
|
||||
self._subscribed_players = current
|
||||
|
||||
def _remove_stale_devices(self) -> None:
|
||||
"""Drop devices for players no longer returned by the account."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, self.config_entry.entry_id
|
||||
):
|
||||
player_id = next(
|
||||
(ident[1] for ident in device.identifiers if ident[0] == DOMAIN), None
|
||||
)
|
||||
if player_id is not None and player_id not in self.client.players:
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
|
||||
async def _async_load_library(self) -> None:
|
||||
"""Load the card library and groups; failures only affect browsing."""
|
||||
try:
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -47,10 +47,22 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Yoto media player platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
YotoMediaPlayer(coordinator, player)
|
||||
for player in coordinator.client.players.values()
|
||||
)
|
||||
known_players: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _add_players() -> None:
|
||||
current = set(coordinator.data)
|
||||
new_players = current - known_players
|
||||
known_players.clear()
|
||||
known_players.update(current)
|
||||
if new_players:
|
||||
async_add_entities(
|
||||
YotoMediaPlayer(coordinator, coordinator.data[player_id])
|
||||
for player_id in new_players
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_add_players))
|
||||
_add_players()
|
||||
|
||||
|
||||
class YotoMediaPlayer(YotoPlayerEntity, MediaPlayerEntity):
|
||||
|
||||
@@ -56,7 +56,7 @@ rules:
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
@@ -71,7 +71,7 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair issues are raised yet.
|
||||
stale-devices: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
@@ -74,11 +74,23 @@ async def async_setup_entry(
|
||||
) -> 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
|
||||
)
|
||||
known_players: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _add_players() -> None:
|
||||
current = set(coordinator.data)
|
||||
new_players = current - known_players
|
||||
known_players.clear()
|
||||
known_players.update(current)
|
||||
if new_players:
|
||||
async_add_entities(
|
||||
YotoSensor(coordinator, coordinator.data[player_id], description)
|
||||
for player_id in new_players
|
||||
for description in SENSORS
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_add_players))
|
||||
_add_players()
|
||||
|
||||
|
||||
class YotoSensor(YotoPlayerEntity, SensorEntity):
|
||||
|
||||
@@ -8,7 +8,7 @@ from yoto_api import PlayerConfig, YotoPlayer
|
||||
|
||||
from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
@@ -53,11 +53,23 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Yoto time platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
YotoTime(coordinator, player, description)
|
||||
for player in coordinator.client.players.values()
|
||||
for description in TIME_ENTITIES
|
||||
)
|
||||
known_players: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _add_players() -> None:
|
||||
current = set(coordinator.data)
|
||||
new_players = current - known_players
|
||||
known_players.clear()
|
||||
known_players.update(current)
|
||||
if new_players:
|
||||
async_add_entities(
|
||||
YotoTime(coordinator, coordinator.data[player_id], description)
|
||||
for player_id in new_players
|
||||
for description in TIME_ENTITIES
|
||||
)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_add_players))
|
||||
_add_players()
|
||||
|
||||
|
||||
class YotoTime(YotoConfigEntity, TimeEntity):
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
import aiohttp
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from yoto_api import AuthenticationError, YotoAPIError, YotoError
|
||||
from yoto_api import AuthenticationError, Device, YotoAPIError, YotoError, YotoPlayer
|
||||
|
||||
from homeassistant.components.yoto.const import (
|
||||
DOMAIN,
|
||||
@@ -18,11 +18,13 @@ from homeassistant.exceptions import (
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import PLAYER_ID
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
@@ -316,3 +318,89 @@ async def test_periodic_poll_fails_on_api_error(
|
||||
|
||||
coordinator = mock_config_entry.runtime_data
|
||||
assert coordinator.last_update_success is False
|
||||
|
||||
|
||||
def _build_second_player() -> YotoPlayer:
|
||||
"""Build a second Yoto player discovered after setup."""
|
||||
return YotoPlayer(
|
||||
device=Device(
|
||||
device_id="player-2",
|
||||
name="Playroom Yoto",
|
||||
device_type="v3",
|
||||
device_family="v3",
|
||||
generation="gen3",
|
||||
),
|
||||
is_online=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_dynamic_device_added(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""A player discovered after setup gets its entity without a reload."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert hass.states.get("media_player.nursery_yoto") is not None
|
||||
assert hass.states.get("media_player.playroom_yoto") is None
|
||||
|
||||
mock_yoto_client.players["player-2"] = _build_second_player()
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("media_player.playroom_yoto") is not None
|
||||
assert hass.states.get("binary_sensor.playroom_yoto_charging") is not None
|
||||
assert hass.states.get("sensor.playroom_yoto_battery") is not None
|
||||
assert hass.states.get("time.playroom_yoto_day_mode_start") is not None
|
||||
mock_yoto_client.subscribe_player_events.assert_called_once_with("player-2")
|
||||
|
||||
|
||||
async def test_subscription_failure_does_not_fail_refresh(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""A subscription error keeps the refreshed data and retries next cycle."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_yoto_client.players["player-2"] = _build_second_player()
|
||||
mock_yoto_client.subscribe_player_events.side_effect = YotoAPIError("boom")
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("media_player.playroom_yoto") is not None
|
||||
assert mock_config_entry.runtime_data.last_update_success is True
|
||||
|
||||
mock_yoto_client.subscribe_player_events.side_effect = None
|
||||
mock_yoto_client.subscribe_player_events.reset_mock()
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_yoto_client.subscribe_player_events.assert_called_once_with("player-2")
|
||||
|
||||
|
||||
async def test_stale_device_removed(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""A player removed from the account has its device dropped."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert (
|
||||
device_registry.async_get_device(identifiers={(DOMAIN, PLAYER_ID)}) is not None
|
||||
)
|
||||
|
||||
mock_yoto_client.players.clear()
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, PLAYER_ID)}) is None
|
||||
mock_yoto_client.unsubscribe_player_events.assert_called_once_with(PLAYER_ID)
|
||||
|
||||
Reference in New Issue
Block a user