Move core Sonos functionality out of entities (#50277)

This commit is contained in:
jjlawren 2021-05-11 12:36:40 -05:00 committed by GitHub
parent 0fdc50408a
commit d6a202bd74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 733 additions and 839 deletions

View file

@ -66,7 +66,6 @@ class SonosData:
def __init__(self) -> None:
"""Initialize the data."""
self.discovered: dict[str, SonosSpeaker] = {}
self.media_player_entities = {}
self.topology_condition = asyncio.Condition()
self.discovery_thread = None
self.hosts_heartbeat = None

View file

@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import SONOS_CREATE_BATTERY
from .entity import SonosSensorEntity
from .entity import SonosEntity
from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
@ -32,7 +32,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
)
class SonosPowerEntity(SonosSensorEntity, BinarySensorEntity):
class SonosPowerEntity(SonosEntity, BinarySensorEntity):
"""Representation of a Sonos power entity."""
@property

View file

@ -128,18 +128,17 @@ PLAYABLE_MEDIA_TYPES = [
MEDIA_TYPE_TRACK,
]
SONOS_CONTENT_UPDATE = "sonos_content_update"
SONOS_CREATE_BATTERY = "sonos_create_battery"
SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player"
SONOS_ENTITY_CREATED = "sonos_entity_created"
SONOS_ENTITY_UPDATE = "sonos_entity_update"
SONOS_GROUP_UPDATE = "sonos_group_update"
SONOS_MEDIA_UPDATE = "sonos_media_update"
SONOS_PLAYER_RECONNECTED = "sonos_player_reconnected"
SONOS_STATE_UPDATED = "sonos_state_updated"
SONOS_VOLUME_UPDATE = "sonos_properties_update"
SONOS_SEEN = "sonos_seen"
SOURCE_LINEIN = "Line-in"
SOURCE_TV = "TV"
BATTERY_SCAN_INTERVAL = datetime.timedelta(minutes=15)
SCAN_INTERVAL = datetime.timedelta(seconds=10)
DISCOVERY_INTERVAL = datetime.timedelta(seconds=60)

View file

@ -48,6 +48,9 @@ class SonosEntity(Entity):
self.async_write_ha_state,
)
)
async_dispatcher_send(
self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain
)
@property
def soco(self) -> SoCo:
@ -76,14 +79,3 @@ class SonosEntity(Entity):
def should_poll(self) -> bool:
"""Return that we should not be polled (we handle that internally)."""
return False
class SonosSensorEntity(SonosEntity):
"""Representation of a Sonos sensor entity."""
async def async_added_to_hass(self) -> None:
"""Handle common setup when added to hass."""
await super().async_added_to_hass()
async_dispatcher_send(
self.hass, f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.platform.domain
)

View file

@ -0,0 +1,32 @@
"""Helper methods for common tasks."""
from __future__ import annotations
import functools as ft
import logging
from typing import Any, Callable
from pysonos.exceptions import SoCoException, SoCoUPnPException
_LOGGER = logging.getLogger(__name__)
def soco_error(errorcodes: list[str] | None = None) -> Callable:
"""Filter out specified UPnP errors from logs and avoid exceptions."""
def decorator(funct: Callable) -> Callable:
"""Decorate functions."""
@ft.wraps(funct)
def wrapper(*args: Any, **kwargs: Any) -> Any:
"""Wrap for all soco UPnP exception."""
try:
return funct(*args, **kwargs)
except SoCoUPnPException as err:
if not errorcodes or err.error_code not in errorcodes:
_LOGGER.error("Error on %s with %s", funct.__name__, err)
except SoCoException as err:
_LOGGER.error("Error on %s with %s", funct.__name__, err)
return wrapper
return decorator

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import SONOS_CREATE_BATTERY
from .entity import SonosSensorEntity
from .entity import SonosEntity
from .speaker import SonosSpeaker
_LOGGER = logging.getLogger(__name__)
@ -27,7 +27,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
)
class SonosBatteryEntity(SonosSensorEntity, SensorEntity):
class SonosBatteryEntity(SonosEntity, SensorEntity):
"""Representation of a Sonos Battery entity."""
@property

View file

@ -1,20 +1,28 @@
"""Base class for common speaker tasks."""
from __future__ import annotations
from asyncio import gather
import asyncio
from collections.abc import Coroutine
import contextlib
import datetime
from functools import partial
import logging
from typing import Any, Callable
from pysonos.core import SoCo
import async_timeout
from pysonos.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo
from pysonos.data_structures import DidlAudioBroadcast, DidlFavorite
from pysonos.events_base import Event as SonosEvent, SubscriptionBase
from pysonos.exceptions import SoCoException
from pysonos.music_library import MusicLibrary
from pysonos.snapshot import Snapshot
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import STATE_PLAYING
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as ent_reg
from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
dispatcher_connect,
@ -24,26 +32,29 @@ from homeassistant.util import dt as dt_util
from .const import (
BATTERY_SCAN_INTERVAL,
DATA_SONOS,
DOMAIN,
PLATFORMS,
SCAN_INTERVAL,
SEEN_EXPIRE_TIME,
SONOS_CONTENT_UPDATE,
SONOS_CREATE_BATTERY,
SONOS_CREATE_MEDIA_PLAYER,
SONOS_ENTITY_CREATED,
SONOS_ENTITY_UPDATE,
SONOS_GROUP_UPDATE,
SONOS_MEDIA_UPDATE,
SONOS_PLAYER_RECONNECTED,
SONOS_SEEN,
SONOS_STATE_UPDATED,
SONOS_VOLUME_UPDATE,
SOURCE_LINEIN,
SOURCE_TV,
)
from .helpers import soco_error
EVENT_CHARGING = {
"CHARGING": True,
"NOT_CHARGING": False,
}
UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None}
_LOGGER = logging.getLogger(__name__)
@ -58,6 +69,55 @@ def fetch_battery_info_or_none(soco: SoCo) -> dict[str, Any] | None:
return soco.get_battery_info()
def _timespan_secs(timespan: str | None) -> None | float:
"""Parse a time-span into number of seconds."""
if timespan in UNAVAILABLE_VALUES:
return None
assert timespan is not None
return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":"))))
class SonosMedia:
"""Representation of the current Sonos media."""
def __init__(self, soco: SoCo) -> None:
"""Initialize a SonosMedia."""
self.library = MusicLibrary(soco)
self.play_mode: str | None = None
self.playback_status: str | None = None
self.album_name: str | None = None
self.artist: str | None = None
self.channel: str | None = None
self.duration: float | None = None
self.image_url: str | None = None
self.queue_position: int | None = None
self.source_name: str | None = None
self.title: str | None = None
self.uri: str | None = None
self.position: float | None = None
self.position_updated_at: datetime.datetime | None = None
def clear(self) -> None:
"""Clear basic media info."""
self.album_name = None
self.artist = None
self.channel = None
self.duration = None
self.image_url = None
self.queue_position = None
self.source_name = None
self.title = None
self.uri = None
def clear_position(self) -> None:
"""Clear the position attributes."""
self.position = None
self.position_updated_at = None
class SonosSpeaker:
"""Representation of a Sonos speaker."""
@ -65,16 +125,19 @@ class SonosSpeaker:
self, hass: HomeAssistant, soco: SoCo, speaker_info: dict[str, Any]
) -> None:
"""Initialize a SonosSpeaker."""
self.hass: HomeAssistant = hass
self.soco: SoCo = soco
self.media = SonosMedia(soco)
self._is_ready: bool = False
self._subscriptions: list[SubscriptionBase] = []
self._poll_timer: Callable | None = None
self._seen_timer: Callable | None = None
self._seen_dispatcher: Callable | None = None
self._entity_creation_dispatcher: Callable | None = None
self._platforms_ready: set[str] = set()
self.hass: HomeAssistant = hass
self.soco: SoCo = soco
self._entity_creation_dispatcher: Callable | None = None
self._group_dispatcher: Callable | None = None
self._seen_dispatcher: Callable | None = None
self.mac_address = speaker_info["mac_address"]
self.model_name = speaker_info["model_name"]
@ -85,13 +148,33 @@ class SonosSpeaker:
self._last_battery_event: datetime.datetime | None = None
self._battery_poll_timer: Callable | None = None
self.volume: int | None = None
self.muted: bool | None = None
self.night_mode: bool | None = None
self.dialog_mode: bool | None = None
self.coordinator: SonosSpeaker | None = None
self.sonos_group: list[SonosSpeaker] = [self]
self.sonos_group_entities: list[str] = []
self.soco_snapshot: Snapshot | None = None
self.snapshot_group: list[SonosSpeaker] | None = None
self.favorites: list[DidlFavorite] = []
def setup(self) -> None:
"""Run initial setup of the speaker."""
self.set_basic_info()
self._entity_creation_dispatcher = dispatcher_connect(
self.hass,
f"{SONOS_ENTITY_CREATED}-{self.soco.uid}",
self.async_handle_new_entity,
)
self._group_dispatcher = dispatcher_connect(
self.hass,
SONOS_GROUP_UPDATE,
self.async_update_groups,
)
self._seen_dispatcher = dispatcher_connect(
self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen
)
@ -115,11 +198,21 @@ class SonosSpeaker:
await self.async_subscribe()
self._is_ready = True
def write_entity_states(self) -> None:
"""Write states for associated SonosEntity instances."""
dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
@callback
def async_write_entity_states(self) -> None:
"""Write states for associated SonosEntity instances."""
async_dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}")
def set_basic_info(self) -> None:
"""Set basic information when speaker is reconnected."""
self.media.play_mode = self.soco.play_mode
self.update_volume()
self.set_favorites()
@property
def available(self) -> bool:
"""Return whether this speaker is available."""
@ -129,7 +222,7 @@ class SonosSpeaker:
"""Initiate event subscriptions."""
_LOGGER.debug("Creating subscriptions for %s", self.zone_name)
try:
self.async_dispatch_player_reconnected()
await self.hass.async_add_executor_job(self.set_basic_info)
if self._subscriptions:
raise RuntimeError(
@ -137,12 +230,10 @@ class SonosSpeaker:
f"when existing subscriptions exist: {self._subscriptions}"
)
await gather(
self._subscribe(self.soco.avTransport, self.async_dispatch_media),
self._subscribe(self.soco.renderingControl, self.async_dispatch_volume),
self._subscribe(
self.soco.contentDirectory, self.async_dispatch_content
),
await asyncio.gather(
self._subscribe(self.soco.avTransport, self.async_update_media),
self._subscribe(self.soco.renderingControl, self.async_update_volume),
self._subscribe(self.soco.contentDirectory, self.async_update_content),
self._subscribe(
self.soco.zoneGroupTopology, self.async_dispatch_groups
),
@ -163,25 +254,6 @@ class SonosSpeaker:
subscription.callback = sub_callback
self._subscriptions.append(subscription)
@callback
def async_dispatch_media(self, event: SonosEvent | None = None) -> None:
"""Update currently playing media from event."""
async_dispatcher_send(self.hass, f"{SONOS_MEDIA_UPDATE}-{self.soco.uid}", event)
@callback
def async_dispatch_content(self, event: SonosEvent | None = None) -> None:
"""Update available content from event."""
async_dispatcher_send(
self.hass, f"{SONOS_CONTENT_UPDATE}-{self.soco.uid}", event
)
@callback
def async_dispatch_volume(self, event: SonosEvent | None = None) -> None:
"""Update volume from event."""
async_dispatcher_send(
self.hass, f"{SONOS_VOLUME_UPDATE}-{self.soco.uid}", event
)
@callback
def async_dispatch_properties(self, event: SonosEvent | None = None) -> None:
"""Update properties from event."""
@ -197,12 +269,7 @@ class SonosSpeaker:
self._poll_timer()
self._poll_timer = None
async_dispatcher_send(self.hass, SONOS_GROUP_UPDATE, event)
@callback
def async_dispatch_player_reconnected(self) -> None:
"""Signal that player has been reconnected."""
async_dispatcher_send(self.hass, f"{SONOS_PLAYER_RECONNECTED}-{self.soco.uid}")
self.async_update_groups(event)
async def async_seen(self, soco: SoCo | None = None) -> None:
"""Record that this speaker was seen right now."""
@ -280,6 +347,11 @@ class SonosSpeaker:
):
self.battery_info = battery_info
@property
def is_coordinator(self) -> bool:
"""Return true if player is a coordinator."""
return self.coordinator is None
@property
def power_source(self) -> str:
"""Return the name of the current power source.
@ -309,3 +381,460 @@ class SonosSpeaker:
):
self.battery_info = battery_info
self.async_write_entity_states()
def update_groups(self, event: SonosEvent | None = None) -> None:
"""Handle callback for topology change event."""
coro = self.create_update_groups_coro(event)
if coro:
self.hass.add_job(coro) # type: ignore
@callback
def async_update_groups(self, event: SonosEvent | None = None) -> None:
"""Handle callback for topology change event."""
coro = self.create_update_groups_coro(event)
if coro:
self.hass.async_add_job(coro) # type: ignore
def create_update_groups_coro(
self, event: SonosEvent | None = None
) -> Coroutine | None:
"""Handle callback for topology change event."""
def _get_soco_group() -> list[str]:
"""Ask SoCo cache for existing topology."""
coordinator_uid = self.soco.uid
slave_uids = []
with contextlib.suppress(SoCoException):
if self.soco.group and self.soco.group.coordinator:
coordinator_uid = self.soco.group.coordinator.uid
slave_uids = [
p.uid
for p in self.soco.group.members
if p.uid != coordinator_uid
]
return [coordinator_uid] + slave_uids
async def _async_extract_group(event: SonosEvent) -> list[str]:
"""Extract group layout from a topology event."""
group = event and event.zone_player_uui_ds_in_group
if group:
assert isinstance(group, str)
return group.split(",")
return await self.hass.async_add_executor_job(_get_soco_group)
@callback
def _async_regroup(group: list[str]) -> None:
"""Rebuild internal group layout."""
entity_registry = ent_reg.async_get(self.hass)
sonos_group = []
sonos_group_entities = []
for uid in group:
speaker = self.hass.data[DATA_SONOS].discovered.get(uid)
if speaker:
sonos_group.append(speaker)
entity_id = entity_registry.async_get_entity_id(
MP_DOMAIN, DOMAIN, uid
)
sonos_group_entities.append(entity_id)
self.coordinator = None
self.sonos_group = sonos_group
self.sonos_group_entities = sonos_group_entities
self.async_write_entity_states()
for slave_uid in group[1:]:
slave = self.hass.data[DATA_SONOS].discovered.get(slave_uid)
if slave:
slave.coordinator = self
slave.sonos_group = sonos_group
slave.sonos_group_entities = sonos_group_entities
slave.async_write_entity_states()
async def _async_handle_group_event(event: SonosEvent) -> None:
"""Get async lock and handle event."""
async with self.hass.data[DATA_SONOS].topology_condition:
group = await _async_extract_group(event)
if self.soco.uid == group[0]:
_async_regroup(group)
self.hass.data[DATA_SONOS].topology_condition.notify_all()
if event and not hasattr(event, "zone_player_uui_ds_in_group"):
return None
return _async_handle_group_event(event)
@soco_error()
def join(self, slaves: list[SonosSpeaker]) -> list[SonosSpeaker]:
"""Form a group with other players."""
if self.coordinator:
self.unjoin()
group = [self]
else:
group = self.sonos_group.copy()
for slave in slaves:
if slave.soco.uid != self.soco.uid:
slave.soco.join(self.soco)
slave.coordinator = self
if slave not in group:
group.append(slave)
return group
@staticmethod
async def join_multi(
hass: HomeAssistant,
master: SonosSpeaker,
speakers: list[SonosSpeaker],
) -> None:
"""Form a group with other players."""
async with hass.data[DATA_SONOS].topology_condition:
group: list[SonosSpeaker] = await hass.async_add_executor_job(
master.join, speakers
)
await SonosSpeaker.wait_for_groups(hass, [group])
@soco_error()
def unjoin(self) -> None:
"""Unjoin the player from a group."""
self.soco.unjoin()
self.coordinator = None
@staticmethod
async def unjoin_multi(hass: HomeAssistant, speakers: list[SonosSpeaker]) -> None:
"""Unjoin several players from their group."""
def _unjoin_all(speakers: list[SonosSpeaker]) -> None:
"""Sync helper."""
# Unjoin slaves first to prevent inheritance of queues
coordinators = [s for s in speakers if s.is_coordinator]
slaves = [s for s in speakers if not s.is_coordinator]
for speaker in slaves + coordinators:
speaker.unjoin()
async with hass.data[DATA_SONOS].topology_condition:
await hass.async_add_executor_job(_unjoin_all, speakers)
await SonosSpeaker.wait_for_groups(hass, [[s] for s in speakers])
@soco_error()
def snapshot(self, with_group: bool) -> None:
"""Snapshot the state of a player."""
self.soco_snapshot = Snapshot(self.soco)
self.soco_snapshot.snapshot()
if with_group:
self.snapshot_group = self.sonos_group.copy()
else:
self.snapshot_group = None
@staticmethod
async def snapshot_multi(
hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
) -> None:
"""Snapshot all the speakers and optionally their groups."""
def _snapshot_all(speakers: list[SonosSpeaker]) -> None:
"""Sync helper."""
for speaker in speakers:
speaker.snapshot(with_group)
# Find all affected players
speakers_set = set(speakers)
if with_group:
for speaker in list(speakers_set):
speakers_set.update(speaker.sonos_group)
async with hass.data[DATA_SONOS].topology_condition:
await hass.async_add_executor_job(_snapshot_all, speakers_set)
@soco_error()
def restore(self) -> None:
"""Restore a snapshotted state to a player."""
try:
assert self.soco_snapshot is not None
self.soco_snapshot.restore()
except (TypeError, AssertionError, AttributeError, SoCoException) as ex:
# Can happen if restoring a coordinator onto a current slave
_LOGGER.warning("Error on restore %s: %s", self.zone_name, ex)
self.soco_snapshot = None
self.snapshot_group = None
@staticmethod
async def restore_multi(
hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool
) -> None:
"""Restore snapshots for all the speakers."""
def _restore_groups(
speakers: list[SonosSpeaker], with_group: bool
) -> list[list[SonosSpeaker]]:
"""Pause all current coordinators and restore groups."""
for speaker in (s for s in speakers if s.is_coordinator):
if speaker.media.playback_status == STATE_PLAYING:
hass.async_create_task(speaker.soco.pause())
groups = []
if with_group:
# Unjoin slaves first to prevent inheritance of queues
for speaker in [s for s in speakers if not s.is_coordinator]:
if speaker.snapshot_group != speaker.sonos_group:
speaker.unjoin()
# Bring back the original group topology
for speaker in (s for s in speakers if s.snapshot_group):
assert speaker.snapshot_group is not None
if speaker.snapshot_group[0] == speaker:
speaker.join(speaker.snapshot_group)
groups.append(speaker.snapshot_group.copy())
return groups
def _restore_players(speakers: list[SonosSpeaker]) -> None:
"""Restore state of all players."""
for speaker in (s for s in speakers if not s.is_coordinator):
speaker.restore()
for speaker in (s for s in speakers if s.is_coordinator):
speaker.restore()
# Find all affected players
speakers_set = {s for s in speakers if s.soco_snapshot}
if with_group:
for speaker in [s for s in speakers_set if s.snapshot_group]:
assert speaker.snapshot_group is not None
speakers_set.update(speaker.snapshot_group)
async with hass.data[DATA_SONOS].topology_condition:
groups = await hass.async_add_executor_job(
_restore_groups, speakers_set, with_group
)
await SonosSpeaker.wait_for_groups(hass, groups)
await hass.async_add_executor_job(_restore_players, speakers_set)
@staticmethod
async def wait_for_groups(
hass: HomeAssistant, groups: list[list[SonosSpeaker]]
) -> None:
"""Wait until all groups are present, or timeout."""
def _test_groups(groups: list[list[SonosSpeaker]]) -> bool:
"""Return whether all groups exist now."""
for group in groups:
coordinator = group[0]
# Test that coordinator is coordinating
current_group = coordinator.sonos_group
if coordinator != current_group[0]:
return False
# Test that slaves match
if set(group[1:]) != set(current_group[1:]):
return False
return True
try:
with async_timeout.timeout(5):
while not _test_groups(groups):
await hass.data[DATA_SONOS].topology_condition.wait()
except asyncio.TimeoutError:
_LOGGER.warning("Timeout waiting for target groups %s", groups)
for speaker in hass.data[DATA_SONOS].discovered.values():
speaker.soco._zgs_cache.clear() # pylint: disable=protected-access
def set_favorites(self) -> None:
"""Set available favorites."""
self.favorites = []
for fav in self.soco.music_library.get_sonos_favorites():
try:
# Exclude non-playable favorites with no linked resources
if fav.reference.resources:
self.favorites.append(fav)
except SoCoException as ex:
# Skip unknown types
_LOGGER.error("Unhandled favorite '%s': %s", fav.title, ex)
@callback
def async_update_content(self, event: SonosEvent | None = None) -> None:
"""Update information about available content."""
if event and "favorites_update_id" in event.variables:
self.hass.async_add_job(self.set_favorites)
self.async_write_entity_states()
def update_volume(self) -> None:
"""Update information about current volume settings."""
self.volume = self.soco.volume
self.muted = self.soco.mute
self.night_mode = self.soco.night_mode
self.dialog_mode = self.soco.dialog_mode
@callback
def async_update_volume(self, event: SonosEvent) -> None:
"""Update information about currently volume settings."""
variables = event.variables
if "volume" in variables:
self.volume = int(variables["volume"]["Master"])
if "mute" in variables:
self.muted = variables["mute"]["Master"] == "1"
if "night_mode" in variables:
self.night_mode = variables["night_mode"] == "1"
if "dialog_level" in variables:
self.dialog_mode = variables["dialog_level"] == "1"
self.async_write_entity_states()
@callback
def async_update_media(self, event: SonosEvent | None = None) -> None:
"""Update information about currently playing media."""
self.hass.async_add_executor_job(self.update_media, event)
def update_media(self, event: SonosEvent | None = None) -> None:
"""Update information about currently playing media."""
variables = event and event.variables
if variables and "transport_state" in variables:
# If the transport has an error then transport_state will
# not be set
new_status = variables["transport_state"]
else:
transport_info = self.soco.get_current_transport_info()
new_status = transport_info["current_transport_state"]
# Ignore transitions, we should get the target state soon
if new_status == "TRANSITIONING":
return
self.media.clear()
update_position = new_status != self.media.playback_status
self.media.playback_status = new_status
if variables:
self.media.play_mode = variables["current_play_mode"]
track_uri = variables["current_track_uri"]
music_source = self.soco.music_source_from_uri(track_uri)
else:
# This causes a network round-trip so we avoid it when possible
self.media.play_mode = self.soco.play_mode
music_source = self.soco.music_source
if music_source == MUSIC_SRC_TV:
self.update_media_linein(SOURCE_TV)
elif music_source == MUSIC_SRC_LINE_IN:
self.update_media_linein(SOURCE_LINEIN)
else:
track_info = self.soco.get_current_track_info()
if not track_info["uri"]:
self.media.clear_position()
else:
self.media.uri = track_info["uri"]
self.media.artist = track_info.get("artist")
self.media.album_name = track_info.get("album")
self.media.title = track_info.get("title")
if music_source == MUSIC_SRC_RADIO:
self.update_media_radio(variables)
else:
self.update_media_music(update_position, track_info)
self.write_entity_states()
# Also update slaves
speakers = self.hass.data[DATA_SONOS].discovered.values()
for speaker in speakers:
if speaker.coordinator == self:
speaker.write_entity_states()
def update_media_linein(self, source: str) -> None:
"""Update state when playing from line-in/tv."""
self.media.clear_position()
self.media.title = source
self.media.source_name = source
def update_media_radio(self, variables: dict) -> None:
"""Update state when streaming radio."""
self.media.clear_position()
try:
album_art_uri = variables["current_track_meta_data"].album_art_uri
self.media.image_url = self.media.library.build_album_art_full_uri(
album_art_uri
)
except (TypeError, KeyError, AttributeError):
pass
# Non-playing radios will not have a current title. Radios without tagging
# can have part of the radio URI as title. In these cases we try to use the
# radio name instead.
try:
uri_meta_data = variables["enqueued_transport_uri_meta_data"]
if isinstance(uri_meta_data, DidlAudioBroadcast) and (
self.media.playback_status != STATE_PLAYING
or self.soco.music_source_from_uri(self.media.title) == MUSIC_SRC_RADIO
or (
isinstance(self.media.title, str)
and isinstance(self.media.uri, str)
and self.media.title in self.media.uri
)
):
self.media.title = uri_meta_data.title
except (TypeError, KeyError, AttributeError):
pass
media_info = self.soco.get_current_media_info()
self.media.channel = media_info["channel"]
# Check if currently playing radio station is in favorites
for fav in self.favorites:
if fav.reference.get_uri() == media_info["uri"]:
self.media.source_name = fav.title
def update_media_music(self, update_media_position: bool, track_info: dict) -> None:
"""Update state when playing music tracks."""
self.media.duration = _timespan_secs(track_info.get("duration"))
current_position = _timespan_secs(track_info.get("position"))
# player started reporting position?
if current_position is not None and self.media.position is None:
update_media_position = True
# position jumped?
if current_position is not None and self.media.position is not None:
if self.media.playback_status == STATE_PLAYING:
assert self.media.position_updated_at is not None
time_delta = dt_util.utcnow() - self.media.position_updated_at
time_diff = time_delta.total_seconds()
else:
time_diff = 0
calculated_position = self.media.position + time_diff
if abs(calculated_position - current_position) > 1.5:
update_media_position = True
if current_position is None:
self.media.clear_position()
elif update_media_position:
self.media.position = current_position
self.media.position_updated_at = dt_util.utcnow()
self.media.image_url = track_info.get("album_art")
playlist_position = int(track_info.get("playlist_position")) # type: ignore
if playlist_position > 0:
self.media.queue_position = playlist_position - 1

View file

@ -1,7 +1,7 @@
"""Tests for the Sonos Media Player platform."""
import pytest
from homeassistant.components.sonos import DOMAIN, media_player
from homeassistant.components.sonos import DATA_SONOS, DOMAIN, media_player
from homeassistant.const import STATE_IDLE
from homeassistant.core import Context
from homeassistant.exceptions import Unauthorized
@ -20,18 +20,24 @@ async def test_async_setup_entry_hosts(hass, config_entry, config, soco):
"""Test static setup."""
await setup_platform(hass, config_entry, config)
entities = list(hass.data[media_player.DATA_SONOS].media_player_entities.values())
entity = entities[0]
assert entity.soco == soco
speakers = list(hass.data[DATA_SONOS].discovered.values())
speaker = speakers[0]
assert speaker.soco == soco
media_player = hass.states.get("media_player.zone_a")
assert media_player.state == STATE_IDLE
async def test_async_setup_entry_discover(hass, config_entry, discover):
"""Test discovery setup."""
await setup_platform(hass, config_entry, {})
entities = list(hass.data[media_player.DATA_SONOS].media_player_entities.values())
entity = entities[0]
assert entity.unique_id == "RINCON_test"
speakers = list(hass.data[DATA_SONOS].discovered.values())
speaker = speakers[0]
assert speaker.soco.uid == "RINCON_test"
media_player = hass.states.get("media_player.zone_a")
assert media_player.state == STATE_IDLE
async def test_services(hass, config_entry, config, hass_read_only_user):