2019-02-13 20:21:14 +00:00
|
|
|
"""Support to interface with Sonos players."""
|
2019-03-21 05:56:46 +00:00
|
|
|
import asyncio
|
2015-09-11 22:32:47 +00:00
|
|
|
import datetime
|
2017-04-06 06:24:30 +00:00
|
|
|
import functools as ft
|
2016-02-19 05:27:50 +00:00
|
|
|
import logging
|
2016-07-20 05:37:24 +00:00
|
|
|
import socket
|
2020-09-02 08:57:12 +00:00
|
|
|
import urllib.parse
|
2017-04-06 06:24:30 +00:00
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
import async_timeout
|
2019-04-27 17:05:50 +00:00
|
|
|
import pysonos
|
2019-11-03 04:25:24 +00:00
|
|
|
from pysonos import alarms
|
2019-10-19 21:52:42 +00:00
|
|
|
from pysonos.exceptions import SoCoException, SoCoUPnPException
|
2020-03-21 19:17:00 +00:00
|
|
|
import pysonos.music_library
|
2019-04-27 17:05:50 +00:00
|
|
|
import pysonos.snapshot
|
2020-01-20 01:55:18 +00:00
|
|
|
import voluptuous as vol
|
2019-04-27 17:05:50 +00:00
|
|
|
|
2020-09-06 13:52:59 +00:00
|
|
|
from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity
|
2019-02-08 22:18:18 +00:00
|
|
|
from homeassistant.components.media_player.const import (
|
2019-07-31 19:25:30 +00:00
|
|
|
ATTR_MEDIA_ENQUEUE,
|
2020-09-08 14:42:01 +00:00
|
|
|
MEDIA_CLASS_ALBUM,
|
|
|
|
MEDIA_CLASS_ARTIST,
|
|
|
|
MEDIA_CLASS_COMPOSER,
|
|
|
|
MEDIA_CLASS_CONTRIBUTING_ARTIST,
|
|
|
|
MEDIA_CLASS_DIRECTORY,
|
|
|
|
MEDIA_CLASS_GENRE,
|
|
|
|
MEDIA_CLASS_PLAYLIST,
|
|
|
|
MEDIA_CLASS_TRACK,
|
2020-09-02 08:57:12 +00:00
|
|
|
MEDIA_TYPE_ALBUM,
|
|
|
|
MEDIA_TYPE_ARTIST,
|
|
|
|
MEDIA_TYPE_COMPOSER,
|
|
|
|
MEDIA_TYPE_CONTRIBUTING_ARTIST,
|
|
|
|
MEDIA_TYPE_GENRE,
|
2019-07-31 19:25:30 +00:00
|
|
|
MEDIA_TYPE_MUSIC,
|
2019-08-20 17:53:45 +00:00
|
|
|
MEDIA_TYPE_PLAYLIST,
|
2020-09-02 08:57:12 +00:00
|
|
|
MEDIA_TYPE_TRACK,
|
|
|
|
SUPPORT_BROWSE_MEDIA,
|
2019-07-31 19:25:30 +00:00
|
|
|
SUPPORT_CLEAR_PLAYLIST,
|
|
|
|
SUPPORT_NEXT_TRACK,
|
|
|
|
SUPPORT_PAUSE,
|
|
|
|
SUPPORT_PLAY,
|
|
|
|
SUPPORT_PLAY_MEDIA,
|
|
|
|
SUPPORT_PREVIOUS_TRACK,
|
|
|
|
SUPPORT_SEEK,
|
|
|
|
SUPPORT_SELECT_SOURCE,
|
|
|
|
SUPPORT_SHUFFLE_SET,
|
|
|
|
SUPPORT_STOP,
|
|
|
|
SUPPORT_VOLUME_MUTE,
|
|
|
|
SUPPORT_VOLUME_SET,
|
|
|
|
)
|
2020-09-02 08:57:12 +00:00
|
|
|
from homeassistant.components.media_player.errors import BrowseError
|
2020-05-04 18:22:47 +00:00
|
|
|
from homeassistant.const import (
|
|
|
|
ATTR_TIME,
|
|
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
|
|
STATE_IDLE,
|
|
|
|
STATE_PAUSED,
|
|
|
|
STATE_PLAYING,
|
|
|
|
)
|
2020-01-20 01:55:18 +00:00
|
|
|
from homeassistant.core import ServiceCall, callback
|
|
|
|
from homeassistant.helpers import config_validation as cv, entity_platform, service
|
2020-05-17 21:16:50 +00:00
|
|
|
import homeassistant.helpers.device_registry as dr
|
2016-11-28 01:45:49 +00:00
|
|
|
from homeassistant.util.dt import utcnow
|
2015-09-13 04:09:51 +00:00
|
|
|
|
2020-09-02 08:57:12 +00:00
|
|
|
from . import CONF_ADVERTISE_ADDR, CONF_HOSTS, CONF_INTERFACE_ADDR
|
|
|
|
from .const import (
|
2020-05-27 22:36:08 +00:00
|
|
|
DATA_SONOS,
|
2019-10-19 21:52:42 +00:00
|
|
|
DOMAIN as SONOS_DOMAIN,
|
2020-09-02 08:57:12 +00:00
|
|
|
SONOS_ALBUM,
|
|
|
|
SONOS_ALBUM_ARTIST,
|
|
|
|
SONOS_ARTIST,
|
|
|
|
SONOS_COMPOSER,
|
|
|
|
SONOS_GENRE,
|
|
|
|
SONOS_PLAYLISTS,
|
|
|
|
SONOS_TRACKS,
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2016-11-01 17:42:38 +00:00
|
|
|
|
2015-09-11 22:32:47 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2019-06-19 08:09:50 +00:00
|
|
|
SCAN_INTERVAL = 10
|
2019-04-29 08:20:09 +00:00
|
|
|
DISCOVERY_INTERVAL = 60
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
SUPPORT_SONOS = (
|
2020-09-02 08:57:12 +00:00
|
|
|
SUPPORT_BROWSE_MEDIA
|
|
|
|
| SUPPORT_CLEAR_PLAYLIST
|
|
|
|
| SUPPORT_NEXT_TRACK
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_PAUSE
|
2020-09-02 08:57:12 +00:00
|
|
|
| SUPPORT_PLAY
|
|
|
|
| SUPPORT_PLAY_MEDIA
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_PREVIOUS_TRACK
|
|
|
|
| SUPPORT_SEEK
|
2020-09-02 08:57:12 +00:00
|
|
|
| SUPPORT_SELECT_SOURCE
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_SHUFFLE_SET
|
2020-09-02 08:57:12 +00:00
|
|
|
| SUPPORT_STOP
|
|
|
|
| SUPPORT_VOLUME_MUTE
|
|
|
|
| SUPPORT_VOLUME_SET
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
SOURCE_LINEIN = "Line-in"
|
|
|
|
SOURCE_TV = "TV"
|
2016-07-15 16:00:41 +00:00
|
|
|
|
2020-09-02 08:57:12 +00:00
|
|
|
EXPANDABLE_MEDIA_TYPES = [
|
|
|
|
MEDIA_TYPE_ALBUM,
|
|
|
|
MEDIA_TYPE_ARTIST,
|
|
|
|
MEDIA_TYPE_COMPOSER,
|
|
|
|
MEDIA_TYPE_GENRE,
|
|
|
|
MEDIA_TYPE_PLAYLIST,
|
|
|
|
SONOS_ALBUM,
|
|
|
|
SONOS_ALBUM_ARTIST,
|
|
|
|
SONOS_ARTIST,
|
|
|
|
SONOS_GENRE,
|
|
|
|
SONOS_COMPOSER,
|
|
|
|
SONOS_PLAYLISTS,
|
|
|
|
]
|
|
|
|
|
2020-09-08 14:42:01 +00:00
|
|
|
SONOS_TO_MEDIA_CLASSES = {
|
|
|
|
SONOS_ALBUM: MEDIA_CLASS_ALBUM,
|
|
|
|
SONOS_ALBUM_ARTIST: MEDIA_CLASS_ARTIST,
|
|
|
|
SONOS_ARTIST: MEDIA_CLASS_CONTRIBUTING_ARTIST,
|
|
|
|
SONOS_COMPOSER: MEDIA_CLASS_COMPOSER,
|
|
|
|
SONOS_GENRE: MEDIA_CLASS_GENRE,
|
|
|
|
SONOS_PLAYLISTS: MEDIA_CLASS_PLAYLIST,
|
|
|
|
SONOS_TRACKS: MEDIA_CLASS_TRACK,
|
|
|
|
"object.container.album.musicAlbum": MEDIA_CLASS_ALBUM,
|
|
|
|
"object.container.genre.musicGenre": MEDIA_CLASS_PLAYLIST,
|
|
|
|
"object.container.person.composer": MEDIA_CLASS_PLAYLIST,
|
|
|
|
"object.container.person.musicArtist": MEDIA_CLASS_ARTIST,
|
|
|
|
"object.container.playlistContainer.sameArtist": MEDIA_CLASS_ARTIST,
|
|
|
|
"object.container.playlistContainer": MEDIA_CLASS_PLAYLIST,
|
|
|
|
"object.item.audioItem.musicTrack": MEDIA_CLASS_TRACK,
|
|
|
|
}
|
|
|
|
|
2020-09-02 08:57:12 +00:00
|
|
|
SONOS_TO_MEDIA_TYPES = {
|
|
|
|
SONOS_ALBUM: MEDIA_TYPE_ALBUM,
|
|
|
|
SONOS_ALBUM_ARTIST: MEDIA_TYPE_ARTIST,
|
|
|
|
SONOS_ARTIST: MEDIA_TYPE_CONTRIBUTING_ARTIST,
|
|
|
|
SONOS_COMPOSER: MEDIA_TYPE_COMPOSER,
|
|
|
|
SONOS_GENRE: MEDIA_TYPE_GENRE,
|
|
|
|
SONOS_PLAYLISTS: MEDIA_TYPE_PLAYLIST,
|
|
|
|
SONOS_TRACKS: MEDIA_TYPE_TRACK,
|
|
|
|
"object.container.album.musicAlbum": MEDIA_TYPE_ALBUM,
|
|
|
|
"object.container.genre.musicGenre": MEDIA_TYPE_PLAYLIST,
|
|
|
|
"object.container.person.composer": MEDIA_TYPE_PLAYLIST,
|
|
|
|
"object.container.person.musicArtist": MEDIA_TYPE_ARTIST,
|
|
|
|
"object.container.playlistContainer.sameArtist": MEDIA_TYPE_ARTIST,
|
|
|
|
"object.container.playlistContainer": MEDIA_TYPE_PLAYLIST,
|
|
|
|
"object.item.audioItem.musicTrack": MEDIA_TYPE_TRACK,
|
|
|
|
}
|
|
|
|
|
|
|
|
MEDIA_TYPES_TO_SONOS = {
|
|
|
|
MEDIA_TYPE_ALBUM: SONOS_ALBUM,
|
|
|
|
MEDIA_TYPE_ARTIST: SONOS_ALBUM_ARTIST,
|
|
|
|
MEDIA_TYPE_CONTRIBUTING_ARTIST: SONOS_ARTIST,
|
|
|
|
MEDIA_TYPE_COMPOSER: SONOS_COMPOSER,
|
|
|
|
MEDIA_TYPE_GENRE: SONOS_GENRE,
|
|
|
|
MEDIA_TYPE_PLAYLIST: SONOS_PLAYLISTS,
|
|
|
|
MEDIA_TYPE_TRACK: SONOS_TRACKS,
|
|
|
|
}
|
|
|
|
|
|
|
|
SONOS_TYPES_MAPPING = {
|
|
|
|
"A:ALBUM": SONOS_ALBUM,
|
|
|
|
"A:ALBUMARTIST": SONOS_ALBUM_ARTIST,
|
|
|
|
"A:ARTIST": SONOS_ARTIST,
|
|
|
|
"A:COMPOSER": SONOS_COMPOSER,
|
|
|
|
"A:GENRE": SONOS_GENRE,
|
|
|
|
"A:PLAYLISTS": SONOS_PLAYLISTS,
|
|
|
|
"A:TRACKS": SONOS_TRACKS,
|
|
|
|
"object.container.album.musicAlbum": SONOS_ALBUM,
|
|
|
|
"object.container.genre.musicGenre": SONOS_GENRE,
|
|
|
|
"object.container.person.composer": SONOS_COMPOSER,
|
|
|
|
"object.container.person.musicArtist": SONOS_ALBUM_ARTIST,
|
|
|
|
"object.container.playlistContainer.sameArtist": SONOS_ARTIST,
|
|
|
|
"object.container.playlistContainer": SONOS_PLAYLISTS,
|
|
|
|
"object.item.audioItem.musicTrack": SONOS_TRACKS,
|
|
|
|
}
|
|
|
|
|
|
|
|
LIBRARY_TITLES_MAPPING = {
|
|
|
|
"A:ALBUM": "Albums",
|
|
|
|
"A:ALBUMARTIST": "Artists",
|
|
|
|
"A:ARTIST": "Contributing Artists",
|
|
|
|
"A:COMPOSER": "Composers",
|
|
|
|
"A:GENRE": "Genres",
|
|
|
|
"A:PLAYLISTS": "Playlists",
|
|
|
|
"A:TRACKS": "Tracks",
|
|
|
|
}
|
|
|
|
|
|
|
|
PLAYABLE_MEDIA_TYPES = [
|
|
|
|
MEDIA_TYPE_ALBUM,
|
|
|
|
MEDIA_TYPE_ARTIST,
|
|
|
|
MEDIA_TYPE_COMPOSER,
|
|
|
|
MEDIA_TYPE_CONTRIBUTING_ARTIST,
|
|
|
|
MEDIA_TYPE_GENRE,
|
|
|
|
MEDIA_TYPE_PLAYLIST,
|
|
|
|
MEDIA_TYPE_TRACK,
|
|
|
|
]
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
ATTR_SONOS_GROUP = "sonos_group"
|
2017-01-25 21:03:36 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
|
2017-06-02 07:03:10 +00:00
|
|
|
|
2020-01-20 01:55:18 +00:00
|
|
|
SERVICE_JOIN = "join"
|
|
|
|
SERVICE_UNJOIN = "unjoin"
|
|
|
|
SERVICE_SNAPSHOT = "snapshot"
|
|
|
|
SERVICE_RESTORE = "restore"
|
|
|
|
SERVICE_SET_TIMER = "set_sleep_timer"
|
|
|
|
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
|
|
|
|
SERVICE_UPDATE_ALARM = "update_alarm"
|
|
|
|
SERVICE_SET_OPTION = "set_option"
|
|
|
|
SERVICE_PLAY_QUEUE = "play_queue"
|
2020-06-08 14:37:35 +00:00
|
|
|
SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue"
|
2020-01-20 01:55:18 +00:00
|
|
|
|
|
|
|
ATTR_SLEEP_TIME = "sleep_time"
|
|
|
|
ATTR_ALARM_ID = "alarm_id"
|
|
|
|
ATTR_VOLUME = "volume"
|
|
|
|
ATTR_ENABLED = "enabled"
|
|
|
|
ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones"
|
|
|
|
ATTR_MASTER = "master"
|
|
|
|
ATTR_WITH_GROUP = "with_group"
|
|
|
|
ATTR_NIGHT_SOUND = "night_sound"
|
|
|
|
ATTR_SPEECH_ENHANCE = "speech_enhance"
|
|
|
|
ATTR_QUEUE_POSITION = "queue_position"
|
2020-06-08 21:41:12 +00:00
|
|
|
ATTR_STATUS_LIGHT = "status_light"
|
2020-01-20 01:55:18 +00:00
|
|
|
|
2020-03-12 21:47:57 +00:00
|
|
|
UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None}
|
|
|
|
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2020-09-11 11:08:13 +00:00
|
|
|
class UnknownMediaType(BrowseError):
|
|
|
|
"""Unknown media type."""
|
|
|
|
|
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
class SonosData:
|
|
|
|
"""Storage class for platform global data."""
|
|
|
|
|
2020-05-04 18:22:47 +00:00
|
|
|
def __init__(self):
|
2018-02-18 19:05:20 +00:00
|
|
|
"""Initialize the data."""
|
2019-02-24 17:45:08 +00:00
|
|
|
self.entities = []
|
2019-08-14 16:17:58 +00:00
|
|
|
self.discovered = []
|
2019-05-23 04:09:59 +00:00
|
|
|
self.topology_condition = asyncio.Condition()
|
2020-05-04 18:22:47 +00:00
|
|
|
self.discovery_thread = None
|
|
|
|
self.hosts_heartbeat = None
|
2018-02-18 19:05:20 +00:00
|
|
|
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
2019-04-26 06:56:43 +00:00
|
|
|
"""Set up the Sonos platform. Obsolete."""
|
|
|
|
_LOGGER.error(
|
2020-02-13 16:27:00 +00:00
|
|
|
"Loading Sonos by media_player platform configuration is no longer supported"
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2018-06-14 19:17:54 +00:00
|
|
|
|
|
|
|
|
2018-08-24 14:37:30 +00:00
|
|
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
2018-06-14 19:17:54 +00:00
|
|
|
"""Set up Sonos from a config entry."""
|
2017-01-27 06:50:36 +00:00
|
|
|
if DATA_SONOS not in hass.data:
|
2020-05-04 18:22:47 +00:00
|
|
|
hass.data[DATA_SONOS] = SonosData()
|
2015-09-13 04:09:51 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
config = hass.data[SONOS_DOMAIN].get("media_player", {})
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("Reached async_setup_entry, config=%s", config)
|
2019-04-26 06:56:43 +00:00
|
|
|
|
2018-08-17 05:41:56 +00:00
|
|
|
advertise_addr = config.get(CONF_ADVERTISE_ADDR)
|
|
|
|
if advertise_addr:
|
2018-09-20 21:50:11 +00:00
|
|
|
pysonos.config.EVENT_ADVERTISE_IP = advertise_addr
|
2018-08-17 05:41:56 +00:00
|
|
|
|
2020-05-04 18:22:47 +00:00
|
|
|
def _stop_discovery(event):
|
|
|
|
data = hass.data[DATA_SONOS]
|
|
|
|
if data.discovery_thread:
|
|
|
|
data.discovery_thread.stop()
|
|
|
|
data.discovery_thread = None
|
|
|
|
if data.hosts_heartbeat:
|
|
|
|
data.hosts_heartbeat()
|
|
|
|
data.hosts_heartbeat = None
|
|
|
|
|
2019-04-29 08:20:09 +00:00
|
|
|
def _discovery(now=None):
|
|
|
|
"""Discover players from network or configuration."""
|
2018-03-15 19:43:28 +00:00
|
|
|
hosts = config.get(CONF_HOSTS)
|
2019-04-26 06:56:43 +00:00
|
|
|
|
2019-04-29 08:20:09 +00:00
|
|
|
def _discovered_player(soco):
|
|
|
|
"""Handle a (re)discovered player."""
|
|
|
|
try:
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("Reached _discovered_player, soco=%s", soco)
|
2019-07-02 13:25:02 +00:00
|
|
|
|
2020-05-18 01:20:10 +00:00
|
|
|
if soco.uid not in hass.data[DATA_SONOS].discovered:
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("Adding new entity")
|
2020-05-18 01:20:10 +00:00
|
|
|
hass.data[DATA_SONOS].discovered.append(soco.uid)
|
2019-04-29 08:20:09 +00:00
|
|
|
hass.add_job(async_add_entities, [SonosEntity(soco)])
|
|
|
|
else:
|
2019-08-14 16:17:58 +00:00
|
|
|
entity = _get_entity_from_soco_uid(hass, soco.uid)
|
2020-05-18 01:20:10 +00:00
|
|
|
if entity and (entity.soco == soco or not entity.available):
|
2019-08-14 16:17:58 +00:00
|
|
|
_LOGGER.debug("Seen %s", entity)
|
2020-05-18 01:20:10 +00:00
|
|
|
hass.add_job(entity.async_seen(soco))
|
|
|
|
|
2019-07-10 16:30:45 +00:00
|
|
|
except SoCoException as ex:
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("SoCoException, ex=%s", ex)
|
2019-04-29 08:20:09 +00:00
|
|
|
|
2017-01-27 06:50:36 +00:00
|
|
|
if hosts:
|
|
|
|
for host in hosts:
|
2017-12-08 11:01:10 +00:00
|
|
|
try:
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("Testing %s", host)
|
2019-04-29 08:20:09 +00:00
|
|
|
player = pysonos.SoCo(socket.gethostbyname(host))
|
|
|
|
if player.is_visible:
|
2019-07-02 13:25:02 +00:00
|
|
|
# Make sure that the player is available
|
|
|
|
_ = player.volume
|
|
|
|
|
2019-04-29 08:20:09 +00:00
|
|
|
_discovered_player(player)
|
2019-07-10 16:30:45 +00:00
|
|
|
except (OSError, SoCoException) as ex:
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("Exception %s", ex)
|
2019-04-29 08:20:09 +00:00
|
|
|
if now is None:
|
|
|
|
_LOGGER.warning("Failed to initialize '%s'", host)
|
2019-07-02 13:25:02 +00:00
|
|
|
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("Tested all hosts")
|
2020-05-04 18:22:47 +00:00
|
|
|
hass.data[DATA_SONOS].hosts_heartbeat = hass.helpers.event.call_later(
|
|
|
|
DISCOVERY_INTERVAL, _discovery
|
|
|
|
)
|
2018-03-15 19:43:28 +00:00
|
|
|
else:
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("Starting discovery thread")
|
2020-05-04 18:22:47 +00:00
|
|
|
hass.data[DATA_SONOS].discovery_thread = pysonos.discover_thread(
|
2019-04-29 08:20:09 +00:00
|
|
|
_discovered_player,
|
2019-07-02 13:25:02 +00:00
|
|
|
interval=DISCOVERY_INTERVAL,
|
2019-07-31 19:25:30 +00:00
|
|
|
interface_addr=config.get(CONF_INTERFACE_ADDR),
|
|
|
|
)
|
2017-01-27 06:50:36 +00:00
|
|
|
|
2019-07-10 19:19:28 +00:00
|
|
|
_LOGGER.debug("Adding discovery job")
|
2019-04-29 08:20:09 +00:00
|
|
|
hass.async_add_executor_job(_discovery)
|
2020-05-04 18:22:47 +00:00
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_discovery)
|
2016-10-26 06:22:17 +00:00
|
|
|
|
2020-01-20 01:55:18 +00:00
|
|
|
platform = entity_platform.current_platform.get()
|
|
|
|
|
2020-02-05 23:50:20 +00:00
|
|
|
@service.verify_domain_control(hass, SONOS_DOMAIN)
|
2020-01-20 01:55:18 +00:00
|
|
|
async def async_service_handle(service_call: ServiceCall):
|
2019-05-05 19:25:57 +00:00
|
|
|
"""Handle dispatched services."""
|
2020-01-20 01:55:18 +00:00
|
|
|
entities = await platform.async_extract_from_service(service_call)
|
|
|
|
|
|
|
|
if not entities:
|
|
|
|
return
|
|
|
|
|
|
|
|
if service_call.service == SERVICE_JOIN:
|
|
|
|
master = platform.entities.get(service_call.data[ATTR_MASTER])
|
2019-03-13 09:17:09 +00:00
|
|
|
if master:
|
2020-01-20 01:55:18 +00:00
|
|
|
await SonosEntity.join_multi(hass, master, entities)
|
|
|
|
else:
|
|
|
|
_LOGGER.error(
|
|
|
|
"Invalid master specified for join service: %s",
|
|
|
|
service_call.data[ATTR_MASTER],
|
|
|
|
)
|
|
|
|
elif service_call.service == SERVICE_UNJOIN:
|
2019-03-13 09:17:09 +00:00
|
|
|
await SonosEntity.unjoin_multi(hass, entities)
|
2020-01-20 01:55:18 +00:00
|
|
|
elif service_call.service == SERVICE_SNAPSHOT:
|
|
|
|
await SonosEntity.snapshot_multi(
|
|
|
|
hass, entities, service_call.data[ATTR_WITH_GROUP]
|
|
|
|
)
|
|
|
|
elif service_call.service == SERVICE_RESTORE:
|
|
|
|
await SonosEntity.restore_multi(
|
|
|
|
hass, entities, service_call.data[ATTR_WITH_GROUP]
|
|
|
|
)
|
|
|
|
|
2020-02-05 23:50:20 +00:00
|
|
|
hass.services.async_register(
|
2020-01-20 01:55:18 +00:00
|
|
|
SONOS_DOMAIN,
|
|
|
|
SERVICE_JOIN,
|
|
|
|
async_service_handle,
|
|
|
|
cv.make_entity_service_schema({vol.Required(ATTR_MASTER): cv.entity_id}),
|
|
|
|
)
|
|
|
|
|
2020-02-05 23:50:20 +00:00
|
|
|
hass.services.async_register(
|
2020-01-20 01:55:18 +00:00
|
|
|
SONOS_DOMAIN,
|
|
|
|
SERVICE_UNJOIN,
|
|
|
|
async_service_handle,
|
|
|
|
cv.make_entity_service_schema({}),
|
|
|
|
)
|
|
|
|
|
|
|
|
join_unjoin_schema = cv.make_entity_service_schema(
|
|
|
|
{vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean}
|
|
|
|
)
|
|
|
|
|
2020-02-05 23:50:20 +00:00
|
|
|
hass.services.async_register(
|
|
|
|
SONOS_DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema
|
2020-01-20 01:55:18 +00:00
|
|
|
)
|
|
|
|
|
2020-02-05 23:50:20 +00:00
|
|
|
hass.services.async_register(
|
|
|
|
SONOS_DOMAIN, SERVICE_RESTORE, async_service_handle, join_unjoin_schema
|
2020-01-20 01:55:18 +00:00
|
|
|
)
|
2016-02-26 19:05:34 +00:00
|
|
|
|
2020-01-20 01:55:18 +00:00
|
|
|
platform.async_register_entity_service(
|
|
|
|
SERVICE_SET_TIMER,
|
|
|
|
{
|
|
|
|
vol.Required(ATTR_SLEEP_TIME): vol.All(
|
|
|
|
vol.Coerce(int), vol.Range(min=0, max=86399)
|
|
|
|
)
|
|
|
|
},
|
|
|
|
"set_sleep_timer",
|
|
|
|
)
|
2017-05-15 07:42:45 +00:00
|
|
|
|
2020-01-20 01:55:18 +00:00
|
|
|
platform.async_register_entity_service(SERVICE_CLEAR_TIMER, {}, "clear_sleep_timer")
|
|
|
|
|
|
|
|
platform.async_register_entity_service(
|
|
|
|
SERVICE_UPDATE_ALARM,
|
|
|
|
{
|
|
|
|
vol.Required(ATTR_ALARM_ID): cv.positive_int,
|
|
|
|
vol.Optional(ATTR_TIME): cv.time,
|
|
|
|
vol.Optional(ATTR_VOLUME): cv.small_float,
|
|
|
|
vol.Optional(ATTR_ENABLED): cv.boolean,
|
|
|
|
vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
|
|
|
|
},
|
|
|
|
"set_alarm",
|
|
|
|
)
|
2019-05-10 20:37:03 +00:00
|
|
|
|
2020-01-20 01:55:18 +00:00
|
|
|
platform.async_register_entity_service(
|
|
|
|
SERVICE_SET_OPTION,
|
|
|
|
{
|
|
|
|
vol.Optional(ATTR_NIGHT_SOUND): cv.boolean,
|
|
|
|
vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean,
|
2020-06-08 21:41:12 +00:00
|
|
|
vol.Optional(ATTR_STATUS_LIGHT): cv.boolean,
|
2020-01-20 01:55:18 +00:00
|
|
|
},
|
|
|
|
"set_option",
|
|
|
|
)
|
|
|
|
|
|
|
|
platform.async_register_entity_service(
|
|
|
|
SERVICE_PLAY_QUEUE,
|
|
|
|
{vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
|
|
|
|
"play_queue",
|
|
|
|
)
|
2017-12-17 12:08:35 +00:00
|
|
|
|
2020-06-08 14:37:35 +00:00
|
|
|
platform.async_register_entity_service(
|
|
|
|
SERVICE_REMOVE_FROM_QUEUE,
|
|
|
|
{vol.Optional(ATTR_QUEUE_POSITION): cv.positive_int},
|
|
|
|
"remove_from_queue",
|
|
|
|
)
|
|
|
|
|
2016-02-26 19:05:34 +00:00
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
class _ProcessSonosEventQueue:
|
2016-11-01 17:42:38 +00:00
|
|
|
"""Queue like object for dispatching sonos events."""
|
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
def __init__(self, handler):
|
2018-01-21 06:35:38 +00:00
|
|
|
"""Initialize Sonos event queue."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self._handler = handler
|
2016-11-01 17:42:38 +00:00
|
|
|
|
|
|
|
def put(self, item, block=True, timeout=None):
|
2018-02-18 19:05:20 +00:00
|
|
|
"""Process event."""
|
2019-08-14 16:17:58 +00:00
|
|
|
try:
|
|
|
|
self._handler(item)
|
|
|
|
except SoCoException as ex:
|
|
|
|
_LOGGER.warning("Error calling %s: %s", self._handler, ex)
|
2016-11-01 17:42:38 +00:00
|
|
|
|
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
def _get_entity_from_soco_uid(hass, uid):
|
2019-02-24 17:45:08 +00:00
|
|
|
"""Return SonosEntity from SoCo uid."""
|
|
|
|
for entity in hass.data[DATA_SONOS].entities:
|
|
|
|
if uid == entity.unique_id:
|
2018-02-18 19:05:20 +00:00
|
|
|
return entity
|
|
|
|
return None
|
2017-01-27 06:50:36 +00:00
|
|
|
|
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
def soco_error(errorcodes=None):
|
|
|
|
"""Filter out specified UPnP errors from logs and avoid exceptions."""
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2017-06-02 07:03:10 +00:00
|
|
|
def decorator(funct):
|
2018-01-21 06:35:38 +00:00
|
|
|
"""Decorate functions."""
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2017-06-02 07:03:10 +00:00
|
|
|
@ft.wraps(funct)
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
"""Wrap for all soco UPnP exception."""
|
|
|
|
try:
|
|
|
|
return funct(*args, **kwargs)
|
|
|
|
except SoCoUPnPException as err:
|
2020-05-08 22:10:17 +00:00
|
|
|
if not errorcodes or err.error_code not in errorcodes:
|
2018-01-13 08:59:50 +00:00
|
|
|
_LOGGER.error("Error on %s with %s", funct.__name__, err)
|
|
|
|
except SoCoException as err:
|
|
|
|
_LOGGER.error("Error on %s with %s", funct.__name__, err)
|
2017-06-02 07:03:10 +00:00
|
|
|
|
|
|
|
return wrapper
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2017-06-02 07:03:10 +00:00
|
|
|
return decorator
|
|
|
|
|
|
|
|
|
2017-04-06 06:24:30 +00:00
|
|
|
def soco_coordinator(funct):
|
2017-05-02 16:18:47 +00:00
|
|
|
"""Call function on coordinator."""
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2017-04-06 06:24:30 +00:00
|
|
|
@ft.wraps(funct)
|
2019-02-24 17:45:08 +00:00
|
|
|
def wrapper(entity, *args, **kwargs):
|
2017-05-02 16:18:47 +00:00
|
|
|
"""Wrap for call to coordinator."""
|
2019-02-24 17:45:08 +00:00
|
|
|
if entity.is_coordinator:
|
|
|
|
return funct(entity, *args, **kwargs)
|
|
|
|
return funct(entity.coordinator, *args, **kwargs)
|
2017-04-06 06:24:30 +00:00
|
|
|
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
def _timespan_secs(timespan):
|
|
|
|
"""Parse a time-span into number of seconds."""
|
2020-03-12 21:47:57 +00:00
|
|
|
if timespan in UNAVAILABLE_VALUES:
|
2018-02-18 19:05:20 +00:00
|
|
|
return None
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":"))))
|
2018-02-18 19:05:20 +00:00
|
|
|
|
|
|
|
|
2020-04-25 16:00:57 +00:00
|
|
|
class SonosEntity(MediaPlayerEntity):
|
2019-02-24 17:45:08 +00:00
|
|
|
"""Representation of a Sonos entity."""
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2017-04-06 06:24:30 +00:00
|
|
|
def __init__(self, player):
|
2019-02-24 17:45:08 +00:00
|
|
|
"""Initialize the Sonos entity."""
|
2018-11-17 12:18:51 +00:00
|
|
|
self._subscriptions = []
|
2019-06-19 08:09:50 +00:00
|
|
|
self._poll_timer = None
|
2019-07-02 13:25:02 +00:00
|
|
|
self._seen_timer = None
|
2018-10-03 13:09:05 +00:00
|
|
|
self._volume_increment = 2
|
2016-11-05 23:58:29 +00:00
|
|
|
self._unique_id = player.uid
|
2015-09-11 22:32:47 +00:00
|
|
|
self._player = player
|
2016-11-01 17:42:38 +00:00
|
|
|
self._player_volume = None
|
2018-03-21 02:27:07 +00:00
|
|
|
self._player_muted = None
|
2018-10-03 12:50:13 +00:00
|
|
|
self._shuffle = None
|
2016-10-25 22:37:47 +00:00
|
|
|
self._coordinator = None
|
2019-02-25 21:03:15 +00:00
|
|
|
self._sonos_group = [self]
|
2018-02-18 19:05:20 +00:00
|
|
|
self._status = None
|
2020-03-10 07:17:07 +00:00
|
|
|
self._uri = None
|
2020-09-02 08:57:12 +00:00
|
|
|
self._media_library = pysonos.music_library.MusicLibrary(self.soco)
|
2016-10-25 22:37:47 +00:00
|
|
|
self._media_duration = None
|
2016-11-28 01:45:49 +00:00
|
|
|
self._media_position = None
|
|
|
|
self._media_position_updated_at = None
|
2016-10-25 22:37:47 +00:00
|
|
|
self._media_image_url = None
|
2020-09-03 21:47:32 +00:00
|
|
|
self._media_channel = None
|
2016-10-25 22:37:47 +00:00
|
|
|
self._media_artist = None
|
|
|
|
self._media_album_name = None
|
|
|
|
self._media_title = None
|
2020-06-08 14:37:35 +00:00
|
|
|
self._is_playing_local_queue = None
|
|
|
|
self._queue_position = None
|
2017-12-17 12:08:35 +00:00
|
|
|
self._night_sound = None
|
|
|
|
self._speech_enhance = None
|
2016-12-14 18:05:03 +00:00
|
|
|
self._source_name = None
|
2019-08-14 16:17:58 +00:00
|
|
|
self._favorites = []
|
2017-04-06 06:24:30 +00:00
|
|
|
self._soco_snapshot = None
|
2017-01-27 06:50:36 +00:00
|
|
|
self._snapshot_group = None
|
2015-09-11 23:38:42 +00:00
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
# Set these early since device_info() needs them
|
|
|
|
speaker_info = self.soco.get_speaker_info(True)
|
2019-07-31 19:25:30 +00:00
|
|
|
self._name = speaker_info["zone_name"]
|
|
|
|
self._model = speaker_info["model_name"]
|
2020-05-17 21:16:50 +00:00
|
|
|
self._sw_version = speaker_info["software_version"]
|
|
|
|
self._mac_address = speaker_info["mac_address"]
|
2018-02-18 19:05:20 +00:00
|
|
|
|
2018-10-01 06:58:21 +00:00
|
|
|
async def async_added_to_hass(self):
|
2017-04-06 06:24:30 +00:00
|
|
|
"""Subscribe sonos events."""
|
2020-05-18 01:20:10 +00:00
|
|
|
await self.async_seen(self.soco)
|
2019-09-03 12:14:33 +00:00
|
|
|
|
2019-02-24 17:45:08 +00:00
|
|
|
self.hass.data[DATA_SONOS].entities.append(self)
|
2017-04-06 06:24:30 +00:00
|
|
|
|
2019-09-03 12:14:33 +00:00
|
|
|
def _rebuild_groups():
|
|
|
|
"""Build the current group topology."""
|
|
|
|
for entity in self.hass.data[DATA_SONOS].entities:
|
|
|
|
entity.update_groups()
|
|
|
|
|
|
|
|
self.hass.async_add_executor_job(_rebuild_groups)
|
|
|
|
|
2016-10-20 15:36:48 +00:00
|
|
|
@property
|
|
|
|
def unique_id(self):
|
2018-03-03 18:23:55 +00:00
|
|
|
"""Return a unique ID."""
|
2016-11-05 23:58:29 +00:00
|
|
|
return self._unique_id
|
2016-10-20 15:36:48 +00:00
|
|
|
|
2019-02-25 21:03:15 +00:00
|
|
|
def __hash__(self):
|
|
|
|
"""Return a hash of self."""
|
|
|
|
return hash(self.unique_id)
|
|
|
|
|
2015-09-11 22:32:47 +00:00
|
|
|
@property
|
|
|
|
def name(self):
|
2019-02-24 17:45:08 +00:00
|
|
|
"""Return the name of the entity."""
|
2015-09-11 22:32:47 +00:00
|
|
|
return self._name
|
|
|
|
|
2018-08-29 14:27:08 +00:00
|
|
|
@property
|
|
|
|
def device_info(self):
|
|
|
|
"""Return information about the device."""
|
|
|
|
return {
|
2019-07-31 19:25:30 +00:00
|
|
|
"identifiers": {(SONOS_DOMAIN, self._unique_id)},
|
|
|
|
"name": self._name,
|
|
|
|
"model": self._model.replace("Sonos ", ""),
|
2020-05-17 21:16:50 +00:00
|
|
|
"sw_version": self._sw_version,
|
|
|
|
"connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_address)},
|
2019-07-31 19:25:30 +00:00
|
|
|
"manufacturer": "Sonos",
|
2018-08-29 14:27:08 +00:00
|
|
|
}
|
|
|
|
|
2015-09-11 22:32:47 +00:00
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def state(self):
|
2019-02-24 17:45:08 +00:00
|
|
|
"""Return the state of the entity."""
|
2020-08-27 11:56:20 +00:00
|
|
|
if self._status in (
|
|
|
|
"PAUSED_PLAYBACK",
|
|
|
|
"STOPPED",
|
|
|
|
):
|
2020-03-12 21:47:57 +00:00
|
|
|
# Sonos can consider itself "paused" but without having media loaded
|
|
|
|
# (happens if playing Spotify and via Spotify app you pick another device to play on)
|
2020-04-16 22:16:18 +00:00
|
|
|
if self.media_title is None:
|
2020-03-12 21:47:57 +00:00
|
|
|
return STATE_IDLE
|
2015-09-11 22:32:47 +00:00
|
|
|
return STATE_PAUSED
|
2019-07-31 19:25:30 +00:00
|
|
|
if self._status in ("PLAYING", "TRANSITIONING"):
|
2015-09-11 22:32:47 +00:00
|
|
|
return STATE_PLAYING
|
2016-11-01 17:42:38 +00:00
|
|
|
return STATE_IDLE
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2016-02-26 19:05:34 +00:00
|
|
|
@property
|
|
|
|
def is_coordinator(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Return true if player is a coordinator."""
|
2016-11-01 17:42:38 +00:00
|
|
|
return self._coordinator is None
|
|
|
|
|
2017-01-27 06:50:36 +00:00
|
|
|
@property
|
2017-04-06 06:24:30 +00:00
|
|
|
def soco(self):
|
2019-02-24 17:45:08 +00:00
|
|
|
"""Return soco object."""
|
2017-01-27 06:50:36 +00:00
|
|
|
return self._player
|
|
|
|
|
|
|
|
@property
|
|
|
|
def coordinator(self):
|
|
|
|
"""Return coordinator of this player."""
|
|
|
|
return self._coordinator
|
|
|
|
|
2020-05-18 01:20:10 +00:00
|
|
|
async def async_seen(self, player):
|
2019-04-29 08:20:09 +00:00
|
|
|
"""Record that this player was seen right now."""
|
2019-07-02 13:25:02 +00:00
|
|
|
was_available = self.available
|
2019-04-29 08:20:09 +00:00
|
|
|
|
2020-05-18 01:20:10 +00:00
|
|
|
self._player = player
|
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
if self._seen_timer:
|
|
|
|
self._seen_timer()
|
2019-05-29 22:05:12 +00:00
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
self._seen_timer = self.hass.helpers.event.async_call_later(
|
2019-07-31 19:25:30 +00:00
|
|
|
2.5 * DISCOVERY_INTERVAL, self.async_unseen
|
|
|
|
)
|
2019-05-29 22:05:12 +00:00
|
|
|
|
2019-07-14 21:36:05 +00:00
|
|
|
if was_available:
|
|
|
|
return
|
|
|
|
|
|
|
|
self._poll_timer = self.hass.helpers.event.async_track_time_interval(
|
2019-07-31 19:25:30 +00:00
|
|
|
self.update, datetime.timedelta(seconds=SCAN_INTERVAL)
|
|
|
|
)
|
2019-07-14 21:36:05 +00:00
|
|
|
|
|
|
|
done = await self.hass.async_add_executor_job(self._attach_player)
|
|
|
|
if not done:
|
|
|
|
self._seen_timer()
|
|
|
|
self.async_unseen()
|
|
|
|
|
2020-04-01 21:19:51 +00:00
|
|
|
self.async_write_ha_state()
|
2019-05-29 22:05:12 +00:00
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
@callback
|
2019-07-14 21:36:05 +00:00
|
|
|
def async_unseen(self, now=None):
|
2019-07-02 13:25:02 +00:00
|
|
|
"""Make this player unavailable when it was not seen recently."""
|
|
|
|
self._seen_timer = None
|
2019-05-29 22:05:12 +00:00
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
if self._poll_timer:
|
|
|
|
self._poll_timer()
|
|
|
|
self._poll_timer = None
|
2019-06-19 08:09:50 +00:00
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
def _unsub(subscriptions):
|
|
|
|
for subscription in subscriptions:
|
|
|
|
subscription.unsubscribe()
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
self.hass.async_add_executor_job(_unsub, self._subscriptions)
|
2019-05-29 22:05:12 +00:00
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
self._subscriptions = []
|
2019-05-29 22:05:12 +00:00
|
|
|
|
2020-04-01 21:19:51 +00:00
|
|
|
self.async_write_ha_state()
|
2019-05-29 22:05:12 +00:00
|
|
|
|
2017-08-31 22:23:11 +00:00
|
|
|
@property
|
|
|
|
def available(self) -> bool:
|
|
|
|
"""Return True if entity is available."""
|
2019-07-02 13:25:02 +00:00
|
|
|
return self._seen_timer is not None
|
2018-07-23 10:31:03 +00:00
|
|
|
|
2020-03-28 23:15:32 +00:00
|
|
|
def _clear_media_position(self):
|
|
|
|
"""Clear the media_position."""
|
|
|
|
self._media_position = None
|
|
|
|
self._media_position_updated_at = None
|
|
|
|
|
2018-07-23 10:31:03 +00:00
|
|
|
def _set_favorites(self):
|
|
|
|
"""Set available favorites."""
|
2019-07-12 05:08:57 +00:00
|
|
|
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)
|
2018-02-18 19:05:20 +00:00
|
|
|
|
2019-07-02 13:25:02 +00:00
|
|
|
def _attach_player(self):
|
|
|
|
"""Get basic information and add event subscriptions."""
|
2019-07-14 21:36:05 +00:00
|
|
|
try:
|
|
|
|
self._shuffle = self.soco.shuffle
|
|
|
|
self.update_volume()
|
|
|
|
self._set_favorites()
|
2018-02-18 19:05:20 +00:00
|
|
|
|
2019-07-14 21:36:05 +00:00
|
|
|
player = self.soco
|
2018-03-08 23:39:31 +00:00
|
|
|
|
2020-01-20 01:55:18 +00:00
|
|
|
def subscribe(sonos_service, action):
|
2019-07-14 21:36:05 +00:00
|
|
|
"""Add a subscription to a pysonos service."""
|
|
|
|
queue = _ProcessSonosEventQueue(action)
|
2020-01-20 01:55:18 +00:00
|
|
|
sub = sonos_service.subscribe(auto_renew=True, event_queue=queue)
|
2019-07-14 21:36:05 +00:00
|
|
|
self._subscriptions.append(sub)
|
2018-02-18 19:05:20 +00:00
|
|
|
|
2019-07-14 21:36:05 +00:00
|
|
|
subscribe(player.avTransport, self.update_media)
|
|
|
|
subscribe(player.renderingControl, self.update_volume)
|
|
|
|
subscribe(player.zoneGroupTopology, self.update_groups)
|
|
|
|
subscribe(player.contentDirectory, self.update_content)
|
|
|
|
return True
|
|
|
|
except SoCoException as ex:
|
|
|
|
_LOGGER.warning("Could not connect %s: %s", self.entity_id, ex)
|
|
|
|
return False
|
2018-07-23 10:31:03 +00:00
|
|
|
|
2019-06-19 08:09:50 +00:00
|
|
|
@property
|
|
|
|
def should_poll(self):
|
|
|
|
"""Return that we should not be polled (we handle that internally)."""
|
|
|
|
return False
|
|
|
|
|
|
|
|
def update(self, now=None):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Retrieve latest state."""
|
2019-06-19 08:09:50 +00:00
|
|
|
try:
|
|
|
|
self.update_groups()
|
|
|
|
self.update_volume()
|
|
|
|
if self.is_coordinator:
|
|
|
|
self.update_media()
|
|
|
|
except SoCoException:
|
|
|
|
pass
|
2018-03-21 02:27:07 +00:00
|
|
|
|
|
|
|
def update_media(self, event=None):
|
|
|
|
"""Update information about currently playing media."""
|
2018-03-01 00:10:58 +00:00
|
|
|
transport_info = self.soco.get_current_transport_info()
|
2019-07-31 19:25:30 +00:00
|
|
|
new_status = transport_info.get("current_transport_state")
|
2018-02-18 19:05:20 +00:00
|
|
|
|
|
|
|
# Ignore transitions, we should get the target state soon
|
2019-07-31 19:25:30 +00:00
|
|
|
if new_status == "TRANSITIONING":
|
2017-02-07 08:27:55 +00:00
|
|
|
return
|
|
|
|
|
2018-10-03 12:50:13 +00:00
|
|
|
self._shuffle = self.soco.shuffle
|
2020-03-10 07:17:07 +00:00
|
|
|
self._uri = None
|
2020-03-21 19:17:00 +00:00
|
|
|
self._media_duration = None
|
|
|
|
self._media_image_url = None
|
2020-09-03 21:47:32 +00:00
|
|
|
self._media_channel = None
|
2020-03-21 19:17:00 +00:00
|
|
|
self._media_artist = None
|
|
|
|
self._media_album_name = None
|
|
|
|
self._media_title = None
|
|
|
|
self._source_name = None
|
2018-02-18 19:05:20 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
update_position = new_status != self._status
|
2019-05-26 19:49:26 +00:00
|
|
|
self._status = new_status
|
|
|
|
|
2020-06-08 14:37:35 +00:00
|
|
|
self._is_playing_local_queue = self.soco.is_playing_local_queue
|
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
if self.soco.is_playing_tv:
|
2018-03-21 02:27:07 +00:00
|
|
|
self.update_media_linein(SOURCE_TV)
|
2018-02-18 19:05:20 +00:00
|
|
|
elif self.soco.is_playing_line_in:
|
2018-03-21 02:27:07 +00:00
|
|
|
self.update_media_linein(SOURCE_LINEIN)
|
2017-02-07 08:27:55 +00:00
|
|
|
else:
|
2018-02-18 19:05:20 +00:00
|
|
|
track_info = self.soco.get_current_track_info()
|
2020-03-28 23:15:32 +00:00
|
|
|
if not track_info["uri"]:
|
|
|
|
self._clear_media_position()
|
2018-02-18 19:05:20 +00:00
|
|
|
else:
|
2020-03-28 23:15:32 +00:00
|
|
|
self._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 self.soco.is_radio_uri(track_info["uri"]):
|
|
|
|
variables = event and event.variables
|
|
|
|
self.update_media_radio(variables, track_info)
|
|
|
|
else:
|
|
|
|
self.update_media_music(update_position, track_info)
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
self.schedule_update_ha_state()
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
# Also update slaves
|
2019-02-24 17:45:08 +00:00
|
|
|
for entity in self.hass.data[DATA_SONOS].entities:
|
2018-02-18 19:05:20 +00:00
|
|
|
coordinator = entity.coordinator
|
|
|
|
if coordinator and coordinator.unique_id == self.unique_id:
|
|
|
|
entity.schedule_update_ha_state()
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2018-03-21 02:27:07 +00:00
|
|
|
def update_media_linein(self, source):
|
2018-02-18 19:05:20 +00:00
|
|
|
"""Update state when playing from line-in/tv."""
|
2020-03-28 23:15:32 +00:00
|
|
|
self._clear_media_position()
|
|
|
|
|
2020-03-12 21:47:57 +00:00
|
|
|
self._media_title = source
|
2018-02-18 19:05:20 +00:00
|
|
|
self._source_name = source
|
|
|
|
|
2018-03-21 02:27:07 +00:00
|
|
|
def update_media_radio(self, variables, track_info):
|
2018-02-18 19:05:20 +00:00
|
|
|
"""Update state when streaming radio."""
|
2020-03-28 23:15:32 +00:00
|
|
|
self._clear_media_position()
|
|
|
|
|
2020-03-21 19:17:00 +00:00
|
|
|
try:
|
|
|
|
album_art_uri = variables["current_track_meta_data"].album_art_uri
|
2020-09-02 08:57:12 +00:00
|
|
|
self._media_image_url = self._media_library.build_album_art_full_uri(
|
|
|
|
album_art_uri
|
|
|
|
)
|
2020-03-21 19:17:00 +00:00
|
|
|
except (TypeError, KeyError, AttributeError):
|
|
|
|
pass
|
2018-02-18 19:05:20 +00:00
|
|
|
|
2020-04-16 22:16:18 +00:00
|
|
|
# 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.
|
2020-03-21 19:17:00 +00:00
|
|
|
try:
|
2020-04-16 22:16:18 +00:00
|
|
|
uri_meta_data = variables["enqueued_transport_uri_meta_data"]
|
|
|
|
if isinstance(
|
|
|
|
uri_meta_data, pysonos.data_structures.DidlAudioBroadcast
|
|
|
|
) and (
|
|
|
|
self.state != STATE_PLAYING
|
|
|
|
or self.soco.is_radio_uri(self._media_title)
|
|
|
|
or self._media_title in self._uri
|
|
|
|
):
|
|
|
|
self._media_title = uri_meta_data.title
|
2020-03-21 19:17:00 +00:00
|
|
|
except (TypeError, KeyError, AttributeError):
|
|
|
|
pass
|
2018-02-18 19:05:20 +00:00
|
|
|
|
2020-09-03 21:47:32 +00:00
|
|
|
media_info = self.soco.get_current_media_info()
|
|
|
|
|
|
|
|
self._media_channel = media_info["channel"]
|
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
# Check if currently playing radio station is in favorites
|
|
|
|
for fav in self._favorites:
|
2020-09-03 21:47:32 +00:00
|
|
|
if fav.reference.get_uri() == media_info["uri"]:
|
2018-02-18 19:05:20 +00:00
|
|
|
self._source_name = fav.title
|
|
|
|
|
2018-03-21 02:27:07 +00:00
|
|
|
def update_media_music(self, update_media_position, track_info):
|
2018-02-18 19:05:20 +00:00
|
|
|
"""Update state when playing music tracks."""
|
2019-07-31 19:25:30 +00:00
|
|
|
self._media_duration = _timespan_secs(track_info.get("duration"))
|
2020-05-04 18:22:47 +00:00
|
|
|
current_position = _timespan_secs(track_info.get("position"))
|
2018-02-18 19:05:20 +00:00
|
|
|
|
|
|
|
# player started reporting position?
|
2020-05-04 18:22:47 +00:00
|
|
|
if current_position is not None and self._media_position is None:
|
|
|
|
update_media_position = True
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2018-03-01 00:10:58 +00:00
|
|
|
# position jumped?
|
2020-05-04 18:22:47 +00:00
|
|
|
if current_position is not None and self._media_position is not None:
|
2020-03-28 23:15:32 +00:00
|
|
|
if self.state == STATE_PLAYING:
|
|
|
|
time_diff = utcnow() - self._media_position_updated_at
|
|
|
|
time_diff = time_diff.total_seconds()
|
|
|
|
else:
|
|
|
|
time_diff = 0
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2018-03-01 00:10:58 +00:00
|
|
|
calculated_position = self._media_position + time_diff
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2020-05-04 18:22:47 +00:00
|
|
|
if abs(calculated_position - current_position) > 1.5:
|
|
|
|
update_media_position = True
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2020-05-04 18:22:47 +00:00
|
|
|
if current_position is None:
|
2020-03-28 23:15:32 +00:00
|
|
|
self._clear_media_position()
|
|
|
|
elif update_media_position:
|
2020-05-04 18:22:47 +00:00
|
|
|
self._media_position = current_position
|
2018-02-18 19:05:20 +00:00
|
|
|
self._media_position_updated_at = utcnow()
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
self._media_image_url = track_info.get("album_art")
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2020-06-08 14:37:35 +00:00
|
|
|
self._queue_position = int(track_info.get("playlist_position")) - 1
|
|
|
|
|
2018-03-21 02:27:07 +00:00
|
|
|
def update_volume(self, event=None):
|
|
|
|
"""Update information about currently volume settings."""
|
|
|
|
if event:
|
|
|
|
variables = event.variables
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if "volume" in variables:
|
|
|
|
self._player_volume = int(variables["volume"]["Master"])
|
2018-03-21 02:27:07 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if "mute" in variables:
|
|
|
|
self._player_muted = variables["mute"]["Master"] == "1"
|
2018-03-21 02:27:07 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if "night_mode" in variables:
|
|
|
|
self._night_sound = variables["night_mode"] == "1"
|
2018-03-21 02:27:07 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if "dialog_level" in variables:
|
|
|
|
self._speech_enhance = variables["dialog_level"] == "1"
|
2018-03-21 02:27:07 +00:00
|
|
|
|
|
|
|
self.schedule_update_ha_state()
|
|
|
|
else:
|
|
|
|
self._player_volume = self.soco.volume
|
|
|
|
self._player_muted = self.soco.mute
|
|
|
|
self._night_sound = self.soco.night_mode
|
|
|
|
self._speech_enhance = self.soco.dialog_mode
|
|
|
|
|
|
|
|
def update_groups(self, event=None):
|
2019-03-13 09:17:09 +00:00
|
|
|
"""Handle callback for topology change event."""
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
def _get_soco_group():
|
|
|
|
"""Ask SoCo cache for existing topology."""
|
|
|
|
coordinator_uid = self.unique_id
|
|
|
|
slave_uids = []
|
2018-03-21 02:27:07 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
try:
|
|
|
|
if self.soco.group and self.soco.group.coordinator:
|
|
|
|
coordinator_uid = self.soco.group.coordinator.uid
|
2019-07-31 19:25:30 +00:00
|
|
|
slave_uids = [
|
|
|
|
p.uid
|
|
|
|
for p in self.soco.group.members
|
|
|
|
if p.uid != coordinator_uid
|
|
|
|
]
|
2019-04-27 17:05:50 +00:00
|
|
|
except SoCoException:
|
2019-03-13 09:17:09 +00:00
|
|
|
pass
|
|
|
|
|
|
|
|
return [coordinator_uid] + slave_uids
|
2018-03-21 02:27:07 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
async def _async_extract_group(event):
|
|
|
|
"""Extract group layout from a topology event."""
|
2018-03-21 02:27:07 +00:00
|
|
|
group = event and event.zone_player_uui_ds_in_group
|
|
|
|
if group:
|
2019-07-31 19:25:30 +00:00
|
|
|
return group.split(",")
|
2018-03-21 02:27:07 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
return await self.hass.async_add_executor_job(_get_soco_group)
|
2018-11-17 12:18:51 +00:00
|
|
|
|
2020-02-14 18:00:22 +00:00
|
|
|
@callback
|
2019-03-13 09:17:09 +00:00
|
|
|
def _async_regroup(group):
|
|
|
|
"""Rebuild internal group layout."""
|
|
|
|
sonos_group = []
|
|
|
|
for uid in group:
|
|
|
|
entity = _get_entity_from_soco_uid(self.hass, uid)
|
|
|
|
if entity:
|
|
|
|
sonos_group.append(entity)
|
2019-02-28 18:25:31 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
self._coordinator = None
|
|
|
|
self._sonos_group = sonos_group
|
2020-04-01 21:19:51 +00:00
|
|
|
self.async_write_ha_state()
|
2018-04-25 18:31:42 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
for slave_uid in group[1:]:
|
|
|
|
slave = _get_entity_from_soco_uid(self.hass, slave_uid)
|
|
|
|
if slave:
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
slave._coordinator = self
|
|
|
|
slave._sonos_group = sonos_group
|
|
|
|
slave.async_schedule_update_ha_state()
|
2018-03-21 02:27:07 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
async def _async_handle_group_event(event):
|
|
|
|
"""Get async lock and handle event."""
|
2019-07-14 21:36:05 +00:00
|
|
|
if event and self._poll_timer:
|
|
|
|
# Cancel poll timer since we do receive events
|
|
|
|
self._poll_timer()
|
|
|
|
self._poll_timer = None
|
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
async with self.hass.data[DATA_SONOS].topology_condition:
|
2019-03-13 09:17:09 +00:00
|
|
|
group = await _async_extract_group(event)
|
|
|
|
|
|
|
|
if self.unique_id == group[0]:
|
|
|
|
_async_regroup(group)
|
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
self.hass.data[DATA_SONOS].topology_condition.notify_all()
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if event and not hasattr(event, "zone_player_uui_ds_in_group"):
|
2019-07-14 21:36:05 +00:00
|
|
|
return
|
2019-03-13 09:17:09 +00:00
|
|
|
|
|
|
|
self.hass.add_job(_async_handle_group_event(event))
|
2018-03-21 02:27:07 +00:00
|
|
|
|
2018-07-23 10:31:03 +00:00
|
|
|
def update_content(self, event=None):
|
|
|
|
"""Update information about available content."""
|
|
|
|
self._set_favorites()
|
|
|
|
self.schedule_update_ha_state()
|
|
|
|
|
2015-09-11 22:32:47 +00:00
|
|
|
@property
|
|
|
|
def volume_level(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Volume level of the media player (0..1)."""
|
2019-08-14 16:17:58 +00:00
|
|
|
if self._player_volume is None:
|
|
|
|
return None
|
2018-02-18 19:05:20 +00:00
|
|
|
return self._player_volume / 100
|
2015-09-11 22:32:47 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def is_volume_muted(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Return true if volume is muted."""
|
2018-03-21 02:27:07 +00:00
|
|
|
return self._player_muted
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2017-12-07 19:44:06 +00:00
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2017-12-07 19:44:06 +00:00
|
|
|
def shuffle(self):
|
|
|
|
"""Shuffling state."""
|
2018-10-03 12:50:13 +00:00
|
|
|
return self._shuffle
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2020-03-10 07:17:07 +00:00
|
|
|
@property
|
2020-03-28 23:15:32 +00:00
|
|
|
@soco_coordinator
|
2020-03-10 07:17:07 +00:00
|
|
|
def media_content_id(self):
|
|
|
|
"""Content id of current playing media."""
|
|
|
|
return self._uri
|
|
|
|
|
2015-09-11 22:32:47 +00:00
|
|
|
@property
|
|
|
|
def media_content_type(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Content type of current playing media."""
|
2015-09-11 22:32:47 +00:00
|
|
|
return MEDIA_TYPE_MUSIC
|
|
|
|
|
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def media_duration(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Duration of current playing media in seconds."""
|
2017-07-06 06:30:01 +00:00
|
|
|
return self._media_duration
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2016-11-28 01:45:49 +00:00
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2016-11-28 01:45:49 +00:00
|
|
|
def media_position(self):
|
|
|
|
"""Position of current playing media in seconds."""
|
2017-07-06 06:30:01 +00:00
|
|
|
return self._media_position
|
2016-11-28 01:45:49 +00:00
|
|
|
|
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2016-11-28 01:45:49 +00:00
|
|
|
def media_position_updated_at(self):
|
2018-02-18 19:05:20 +00:00
|
|
|
"""When was the position of the current playing media valid."""
|
2017-07-06 06:30:01 +00:00
|
|
|
return self._media_position_updated_at
|
2016-11-28 01:45:49 +00:00
|
|
|
|
2015-09-11 22:32:47 +00:00
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def media_image_url(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Image url of current playing media."""
|
2018-02-18 19:05:20 +00:00
|
|
|
return self._media_image_url or None
|
2016-10-25 22:37:47 +00:00
|
|
|
|
2020-09-03 21:47:32 +00:00
|
|
|
@property
|
|
|
|
@soco_coordinator
|
|
|
|
def media_channel(self):
|
|
|
|
"""Channel currently playing."""
|
|
|
|
return self._media_channel or None
|
|
|
|
|
2016-10-25 22:37:47 +00:00
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2016-10-25 22:37:47 +00:00
|
|
|
def media_artist(self):
|
|
|
|
"""Artist of current playing media, music track only."""
|
2020-03-12 21:47:57 +00:00
|
|
|
return self._media_artist or None
|
2016-10-25 22:37:47 +00:00
|
|
|
|
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2016-10-25 22:37:47 +00:00
|
|
|
def media_album_name(self):
|
|
|
|
"""Album name of current playing media, music track only."""
|
2020-03-12 21:47:57 +00:00
|
|
|
return self._media_album_name or None
|
2015-09-11 22:32:47 +00:00
|
|
|
|
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def media_title(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Title of current playing media."""
|
2020-03-12 21:47:57 +00:00
|
|
|
return self._media_title or None
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2020-06-08 14:37:35 +00:00
|
|
|
@property
|
|
|
|
@soco_coordinator
|
|
|
|
def queue_position(self):
|
|
|
|
"""If playing local queue return the position in the queue else None."""
|
|
|
|
if self._is_playing_local_queue:
|
|
|
|
return self._queue_position
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
2017-12-17 12:08:35 +00:00
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
|
|
|
def source(self):
|
|
|
|
"""Name of the current input source."""
|
2020-03-12 21:47:57 +00:00
|
|
|
return self._source_name or None
|
2017-12-17 12:08:35 +00:00
|
|
|
|
2015-09-11 22:32:47 +00:00
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2017-02-08 04:42:45 +00:00
|
|
|
def supported_features(self):
|
|
|
|
"""Flag media player features that are supported."""
|
2018-03-05 02:30:15 +00:00
|
|
|
return SUPPORT_SONOS
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2015-09-11 22:32:47 +00:00
|
|
|
def volume_up(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Volume up media player."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self._player.volume += self._volume_increment
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2015-09-11 22:32:47 +00:00
|
|
|
def volume_down(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Volume down media player."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self._player.volume -= self._volume_increment
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2015-09-11 22:32:47 +00:00
|
|
|
def set_volume_level(self, volume):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Set volume level, range 0..1."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.volume = str(int(volume * 100))
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-10-01 15:58:04 +00:00
|
|
|
@soco_error(UPNP_ERRORS_TO_IGNORE)
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2017-12-07 19:44:06 +00:00
|
|
|
def set_shuffle(self, shuffle):
|
|
|
|
"""Enable/Disable shuffle mode."""
|
2018-10-03 12:50:13 +00:00
|
|
|
self.soco.shuffle = shuffle
|
2017-12-07 19:44:06 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2015-09-11 22:32:47 +00:00
|
|
|
def mute_volume(self, mute):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Mute (true) or unmute (false) media player."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.mute = mute
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2016-07-15 16:00:41 +00:00
|
|
|
def select_source(self, source):
|
|
|
|
"""Select input source."""
|
2018-02-18 19:05:20 +00:00
|
|
|
if source == SOURCE_LINEIN:
|
|
|
|
self.soco.switch_to_line_in()
|
|
|
|
elif source == SOURCE_TV:
|
|
|
|
self.soco.switch_to_tv()
|
2016-12-14 18:05:03 +00:00
|
|
|
else:
|
2019-07-31 19:25:30 +00:00
|
|
|
fav = [fav for fav in self._favorites if fav.title == source]
|
2016-12-14 18:05:03 +00:00
|
|
|
if len(fav) == 1:
|
|
|
|
src = fav.pop()
|
2018-02-18 19:05:20 +00:00
|
|
|
uri = src.reference.get_uri()
|
2020-03-21 19:17:00 +00:00
|
|
|
if self.soco.is_radio_uri(uri):
|
2018-08-17 05:41:56 +00:00
|
|
|
self.soco.play_uri(uri, title=source)
|
2017-08-01 06:16:05 +00:00
|
|
|
else:
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.clear_queue()
|
|
|
|
self.soco.add_to_queue(src.reference)
|
|
|
|
self.soco.play_from_queue(0)
|
2016-07-15 16:00:41 +00:00
|
|
|
|
|
|
|
@property
|
2018-02-18 19:05:20 +00:00
|
|
|
@soco_coordinator
|
2016-07-15 16:00:41 +00:00
|
|
|
def source_list(self):
|
|
|
|
"""List of available input sources."""
|
2018-02-18 19:05:20 +00:00
|
|
|
sources = [fav.title for fav in self._favorites]
|
2017-02-07 08:27:55 +00:00
|
|
|
|
2018-07-23 10:29:37 +00:00
|
|
|
model = self._model.upper()
|
2019-07-31 19:25:30 +00:00
|
|
|
if "PLAY:5" in model or "CONNECT" in model:
|
2018-02-18 19:05:20 +00:00
|
|
|
sources += [SOURCE_LINEIN]
|
2019-07-31 19:25:30 +00:00
|
|
|
elif "PLAYBAR" in model:
|
2018-02-18 19:05:20 +00:00
|
|
|
sources += [SOURCE_LINEIN, SOURCE_TV]
|
2020-06-08 14:37:35 +00:00
|
|
|
elif "BEAM" in model or "PLAYBASE" in model:
|
2018-09-01 14:02:38 +00:00
|
|
|
sources += [SOURCE_TV]
|
2016-07-15 16:00:41 +00:00
|
|
|
|
2016-12-14 18:05:03 +00:00
|
|
|
return sources
|
2016-07-15 16:00:41 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error(UPNP_ERRORS_TO_IGNORE)
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def media_play(self):
|
2016-03-26 05:57:28 +00:00
|
|
|
"""Send play command."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.play()
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error(UPNP_ERRORS_TO_IGNORE)
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2016-12-08 08:36:37 +00:00
|
|
|
def media_stop(self):
|
|
|
|
"""Send stop command."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.stop()
|
2016-12-08 08:36:37 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error(UPNP_ERRORS_TO_IGNORE)
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def media_pause(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Send pause command."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.pause()
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-03-05 02:30:15 +00:00
|
|
|
@soco_error(UPNP_ERRORS_TO_IGNORE)
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def media_next_track(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Send next track command."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.next()
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-03-05 02:30:15 +00:00
|
|
|
@soco_error(UPNP_ERRORS_TO_IGNORE)
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def media_previous_track(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Send next track command."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.previous()
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-03-05 02:30:15 +00:00
|
|
|
@soco_error(UPNP_ERRORS_TO_IGNORE)
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2015-09-11 22:32:47 +00:00
|
|
|
def media_seek(self, position):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Send seek command."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.seek(str(datetime.timedelta(seconds=int(position))))
|
2015-09-11 22:32:47 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2016-07-15 16:00:41 +00:00
|
|
|
def clear_playlist(self):
|
|
|
|
"""Clear players playlist."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.clear_queue()
|
2016-03-26 05:57:14 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2016-05-20 06:30:19 +00:00
|
|
|
def play_media(self, media_type, media_id, **kwargs):
|
|
|
|
"""
|
|
|
|
Send the play_media command to the media player.
|
|
|
|
|
2019-08-20 17:53:45 +00:00
|
|
|
If media_type is "playlist", media_id should be a Sonos
|
|
|
|
Playlist name. Otherwise, media_id should be a URI.
|
|
|
|
|
2016-05-20 06:30:19 +00:00
|
|
|
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
|
|
|
|
"""
|
2020-09-02 08:57:12 +00:00
|
|
|
if media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK):
|
2019-08-20 17:53:45 +00:00
|
|
|
if kwargs.get(ATTR_MEDIA_ENQUEUE):
|
|
|
|
try:
|
|
|
|
self.soco.add_uri_to_queue(media_id)
|
|
|
|
except SoCoUPnPException:
|
|
|
|
_LOGGER.error(
|
|
|
|
'Error parsing media uri "%s", '
|
|
|
|
"please check it's a valid media resource "
|
|
|
|
"supported by Sonos",
|
|
|
|
media_id,
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
self.soco.play_uri(media_id)
|
|
|
|
elif media_type == MEDIA_TYPE_PLAYLIST:
|
2020-09-02 08:57:12 +00:00
|
|
|
if media_id.startswith("S:"):
|
|
|
|
item = get_media(self._media_library, media_id, media_type)
|
|
|
|
self.soco.play_uri(item.get_uri())
|
|
|
|
return
|
2017-04-06 06:24:30 +00:00
|
|
|
try:
|
2019-08-20 17:53:45 +00:00
|
|
|
playlists = self.soco.get_sonos_playlists()
|
|
|
|
playlist = next(p for p in playlists if p.title == media_id)
|
|
|
|
self.soco.clear_queue()
|
|
|
|
self.soco.add_to_queue(playlist)
|
|
|
|
self.soco.play_from_queue(0)
|
|
|
|
except StopIteration:
|
|
|
|
_LOGGER.error('Could not find a Sonos playlist named "%s"', media_id)
|
2020-09-02 08:57:12 +00:00
|
|
|
elif media_type in PLAYABLE_MEDIA_TYPES:
|
|
|
|
item = get_media(self._media_library, media_id, media_type)
|
|
|
|
|
|
|
|
if not item:
|
|
|
|
_LOGGER.error('Could not find "%s" in the library', media_id)
|
|
|
|
return
|
|
|
|
|
|
|
|
self.soco.play_uri(item.get_uri())
|
2016-05-20 06:30:19 +00:00
|
|
|
else:
|
2019-08-20 17:53:45 +00:00
|
|
|
_LOGGER.error('Sonos does not support a media type of "%s"', media_type)
|
2016-04-17 20:17:13 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2018-02-18 19:05:20 +00:00
|
|
|
def join(self, slaves):
|
|
|
|
"""Form a group with other players."""
|
|
|
|
if self._coordinator:
|
2018-03-15 23:12:43 +00:00
|
|
|
self.unjoin()
|
2019-03-13 19:51:41 +00:00
|
|
|
group = [self]
|
|
|
|
else:
|
|
|
|
group = self._sonos_group.copy()
|
2018-02-18 19:05:20 +00:00
|
|
|
|
|
|
|
for slave in slaves:
|
2018-03-14 19:07:50 +00:00
|
|
|
if slave.unique_id != self.unique_id:
|
|
|
|
slave.soco.join(self.soco)
|
2018-03-15 23:12:43 +00:00
|
|
|
# pylint: disable=protected-access
|
|
|
|
slave._coordinator = self
|
2019-03-13 19:51:41 +00:00
|
|
|
if slave not in group:
|
|
|
|
group.append(slave)
|
|
|
|
|
|
|
|
return group
|
2016-05-20 16:54:15 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
@staticmethod
|
|
|
|
async def join_multi(hass, master, entities):
|
|
|
|
"""Form a group with other players."""
|
2019-03-13 19:51:41 +00:00
|
|
|
async with hass.data[DATA_SONOS].topology_condition:
|
|
|
|
group = await hass.async_add_executor_job(master.join, entities)
|
|
|
|
await SonosEntity.wait_for_groups(hass, [group])
|
2019-03-13 09:17:09 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2016-06-30 21:21:57 +00:00
|
|
|
def unjoin(self):
|
|
|
|
"""Unjoin the player from a group."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.unjoin()
|
2018-03-15 23:12:43 +00:00
|
|
|
self._coordinator = None
|
2016-06-30 21:21:57 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
@staticmethod
|
|
|
|
async def unjoin_multi(hass, entities):
|
|
|
|
"""Unjoin several players from their group."""
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
def _unjoin_all(entities):
|
|
|
|
"""Sync helper."""
|
2019-03-13 19:51:08 +00:00
|
|
|
# Unjoin slaves first to prevent inheritance of queues
|
|
|
|
coordinators = [e for e in entities if e.is_coordinator]
|
|
|
|
slaves = [e for e in entities if not e.is_coordinator]
|
|
|
|
|
|
|
|
for entity in slaves + coordinators:
|
2019-03-13 09:17:09 +00:00
|
|
|
entity.unjoin()
|
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
async with hass.data[DATA_SONOS].topology_condition:
|
2019-03-13 09:17:09 +00:00
|
|
|
await hass.async_add_executor_job(_unjoin_all, entities)
|
2019-03-13 19:51:41 +00:00
|
|
|
await SonosEntity.wait_for_groups(hass, [[e] for e in entities])
|
2019-03-13 09:17:09 +00:00
|
|
|
|
2019-02-28 18:25:31 +00:00
|
|
|
@soco_error()
|
|
|
|
def snapshot(self, with_group):
|
|
|
|
"""Snapshot the state of a player."""
|
2019-04-27 17:05:50 +00:00
|
|
|
self._soco_snapshot = pysonos.snapshot.Snapshot(self.soco)
|
2019-02-28 18:25:31 +00:00
|
|
|
self._soco_snapshot.snapshot()
|
|
|
|
if with_group:
|
|
|
|
self._snapshot_group = self._sonos_group.copy()
|
|
|
|
else:
|
|
|
|
self._snapshot_group = None
|
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
@staticmethod
|
|
|
|
async def snapshot_multi(hass, entities, with_group):
|
|
|
|
"""Snapshot all the entities and optionally their groups."""
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
|
|
|
|
def _snapshot_all(entities):
|
|
|
|
"""Sync helper."""
|
|
|
|
for entity in entities:
|
|
|
|
entity.snapshot(with_group)
|
|
|
|
|
|
|
|
# Find all affected players
|
|
|
|
entities = set(entities)
|
|
|
|
if with_group:
|
|
|
|
for entity in list(entities):
|
|
|
|
entities.update(entity._sonos_group)
|
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
async with hass.data[DATA_SONOS].topology_condition:
|
2019-03-13 09:17:09 +00:00
|
|
|
await hass.async_add_executor_job(_snapshot_all, entities)
|
|
|
|
|
2019-02-28 18:25:31 +00:00
|
|
|
@soco_error()
|
|
|
|
def restore(self):
|
|
|
|
"""Restore a snapshotted state to a player."""
|
|
|
|
try:
|
|
|
|
self._soco_snapshot.restore()
|
|
|
|
except (TypeError, AttributeError, SoCoException) as ex:
|
|
|
|
# Can happen if restoring a coordinator onto a current slave
|
|
|
|
_LOGGER.warning("Error on restore %s: %s", self.entity_id, ex)
|
|
|
|
|
|
|
|
self._soco_snapshot = None
|
|
|
|
self._snapshot_group = None
|
|
|
|
|
|
|
|
@staticmethod
|
2019-03-13 09:17:09 +00:00
|
|
|
async def restore_multi(hass, entities, with_group):
|
|
|
|
"""Restore snapshots for all the entities."""
|
2019-02-28 18:25:31 +00:00
|
|
|
# pylint: disable=protected-access
|
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
def _restore_groups(entities, with_group):
|
|
|
|
"""Pause all current coordinators and restore groups."""
|
2019-03-13 09:17:09 +00:00
|
|
|
for entity in (e for e in entities if e.is_coordinator):
|
|
|
|
if entity.state == STATE_PLAYING:
|
|
|
|
entity.media_pause()
|
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
groups = []
|
|
|
|
|
2019-03-13 09:17:09 +00:00
|
|
|
if with_group:
|
2019-03-13 19:51:08 +00:00
|
|
|
# Unjoin slaves first to prevent inheritance of queues
|
2019-03-13 09:17:09 +00:00
|
|
|
for entity in [e for e in entities if not e.is_coordinator]:
|
|
|
|
if entity._snapshot_group != entity._sonos_group:
|
|
|
|
entity.unjoin()
|
|
|
|
|
|
|
|
# Bring back the original group topology
|
|
|
|
for entity in (e for e in entities if e._snapshot_group):
|
|
|
|
if entity._snapshot_group[0] == entity:
|
|
|
|
entity.join(entity._snapshot_group)
|
2019-03-13 19:51:41 +00:00
|
|
|
groups.append(entity._snapshot_group.copy())
|
2019-03-13 09:17:09 +00:00
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
return groups
|
|
|
|
|
|
|
|
def _restore_players(entities):
|
|
|
|
"""Restore state of all players."""
|
2019-03-13 09:17:09 +00:00
|
|
|
for entity in (e for e in entities if not e.is_coordinator):
|
|
|
|
entity.restore()
|
|
|
|
|
|
|
|
for entity in (e for e in entities if e.is_coordinator):
|
2019-03-13 19:51:41 +00:00
|
|
|
entity.restore()
|
2019-02-28 18:25:31 +00:00
|
|
|
|
|
|
|
# Find all affected players
|
2020-04-04 18:05:15 +00:00
|
|
|
entities = {e for e in entities if e._soco_snapshot}
|
2019-02-28 18:25:31 +00:00
|
|
|
if with_group:
|
|
|
|
for entity in [e for e in entities if e._snapshot_group]:
|
|
|
|
entities.update(entity._snapshot_group)
|
|
|
|
|
2019-03-13 19:51:41 +00:00
|
|
|
async with hass.data[DATA_SONOS].topology_condition:
|
|
|
|
groups = await hass.async_add_executor_job(
|
2019-07-31 19:25:30 +00:00
|
|
|
_restore_groups, entities, with_group
|
|
|
|
)
|
2019-03-13 19:51:41 +00:00
|
|
|
|
|
|
|
await SonosEntity.wait_for_groups(hass, groups)
|
|
|
|
|
|
|
|
await hass.async_add_executor_job(_restore_players, entities)
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
async def wait_for_groups(hass, groups):
|
|
|
|
"""Wait until all groups are present, or timeout."""
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
|
|
|
|
def _test_groups(groups):
|
|
|
|
"""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 entity in hass.data[DATA_SONOS].entities:
|
|
|
|
entity.soco._zgs_cache.clear()
|
2019-02-28 18:25:31 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2020-01-20 01:55:18 +00:00
|
|
|
def set_sleep_timer(self, sleep_time):
|
2016-10-26 06:22:17 +00:00
|
|
|
"""Set the timer on the player."""
|
2020-01-20 01:55:18 +00:00
|
|
|
self.soco.set_sleep_timer(sleep_time)
|
2016-10-26 06:22:17 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2017-04-06 06:24:30 +00:00
|
|
|
@soco_coordinator
|
2020-01-20 01:55:18 +00:00
|
|
|
def clear_sleep_timer(self):
|
2016-10-26 06:22:17 +00:00
|
|
|
"""Clear the timer on the player."""
|
2018-02-18 19:05:20 +00:00
|
|
|
self.soco.set_sleep_timer(None)
|
2017-01-25 21:03:36 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2017-05-15 07:42:45 +00:00
|
|
|
@soco_coordinator
|
2020-01-20 01:55:18 +00:00
|
|
|
def set_alarm(
|
|
|
|
self, alarm_id, time=None, volume=None, enabled=None, include_linked_zones=None
|
|
|
|
):
|
2017-05-15 07:42:45 +00:00
|
|
|
"""Set the alarm clock on the player."""
|
2018-02-18 19:05:20 +00:00
|
|
|
alarm = None
|
2019-11-03 04:25:24 +00:00
|
|
|
for one_alarm in alarms.get_alarms(self.soco):
|
2017-05-15 07:42:45 +00:00
|
|
|
# pylint: disable=protected-access
|
2020-01-20 01:55:18 +00:00
|
|
|
if one_alarm._alarm_id == str(alarm_id):
|
2018-02-18 19:05:20 +00:00
|
|
|
alarm = one_alarm
|
|
|
|
if alarm is None:
|
2020-01-20 01:55:18 +00:00
|
|
|
_LOGGER.warning("did not find alarm with id %s", alarm_id)
|
2017-05-15 07:42:45 +00:00
|
|
|
return
|
2020-01-20 01:55:18 +00:00
|
|
|
if time is not None:
|
|
|
|
alarm.start_time = time
|
|
|
|
if volume is not None:
|
|
|
|
alarm.volume = int(volume * 100)
|
|
|
|
if enabled is not None:
|
|
|
|
alarm.enabled = enabled
|
|
|
|
if include_linked_zones is not None:
|
|
|
|
alarm.include_linked_zones = include_linked_zones
|
2018-02-18 19:05:20 +00:00
|
|
|
alarm.save()
|
2017-05-15 07:42:45 +00:00
|
|
|
|
2018-01-13 08:59:50 +00:00
|
|
|
@soco_error()
|
2020-06-08 21:41:12 +00:00
|
|
|
def set_option(self, night_sound=None, speech_enhance=None, status_light=None):
|
2017-12-17 12:08:35 +00:00
|
|
|
"""Modify playback options."""
|
2020-01-20 01:55:18 +00:00
|
|
|
if night_sound is not None and self._night_sound is not None:
|
|
|
|
self.soco.night_mode = night_sound
|
2017-12-17 12:08:35 +00:00
|
|
|
|
2020-01-20 01:55:18 +00:00
|
|
|
if speech_enhance is not None and self._speech_enhance is not None:
|
|
|
|
self.soco.dialog_mode = speech_enhance
|
2017-12-17 12:08:35 +00:00
|
|
|
|
2020-06-08 21:41:12 +00:00
|
|
|
if status_light is not None:
|
|
|
|
self.soco.status_light = status_light
|
|
|
|
|
2019-07-06 15:19:03 +00:00
|
|
|
@soco_error()
|
2020-01-20 01:55:18 +00:00
|
|
|
def play_queue(self, queue_position=0):
|
2019-07-06 15:19:03 +00:00
|
|
|
"""Start playing the queue."""
|
2020-01-20 01:55:18 +00:00
|
|
|
self.soco.play_from_queue(queue_position)
|
2019-07-06 15:19:03 +00:00
|
|
|
|
2020-06-08 14:37:35 +00:00
|
|
|
@soco_error()
|
|
|
|
@soco_coordinator
|
|
|
|
def remove_from_queue(self, queue_position=0):
|
|
|
|
"""Remove item from the queue."""
|
|
|
|
self.soco.remove_from_queue(queue_position)
|
|
|
|
|
2017-01-25 21:03:36 +00:00
|
|
|
@property
|
|
|
|
def device_state_attributes(self):
|
2019-02-24 17:45:08 +00:00
|
|
|
"""Return entity specific state attributes."""
|
2019-07-31 19:25:30 +00:00
|
|
|
attributes = {ATTR_SONOS_GROUP: [e.entity_id for e in self._sonos_group]}
|
2017-12-17 12:08:35 +00:00
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
if self._night_sound is not None:
|
|
|
|
attributes[ATTR_NIGHT_SOUND] = self._night_sound
|
2017-12-17 12:08:35 +00:00
|
|
|
|
2018-02-18 19:05:20 +00:00
|
|
|
if self._speech_enhance is not None:
|
|
|
|
attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance
|
2017-12-17 12:08:35 +00:00
|
|
|
|
2020-06-08 14:37:35 +00:00
|
|
|
if self.queue_position is not None:
|
|
|
|
attributes[ATTR_QUEUE_POSITION] = self.queue_position
|
|
|
|
|
2017-12-17 12:08:35 +00:00
|
|
|
return attributes
|
2020-09-02 08:57:12 +00:00
|
|
|
|
|
|
|
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
|
|
|
"""Implement the websocket media browsing helper."""
|
|
|
|
if media_content_type in [None, "library"]:
|
|
|
|
return await self.hass.async_add_executor_job(
|
|
|
|
library_payload, self._media_library
|
|
|
|
)
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
"search_type": media_content_type,
|
|
|
|
"idstring": media_content_id,
|
|
|
|
}
|
|
|
|
response = await self.hass.async_add_executor_job(
|
|
|
|
build_item_response, self._media_library, payload
|
|
|
|
)
|
|
|
|
if response is None:
|
|
|
|
raise BrowseError(
|
|
|
|
f"Media not found: {media_content_type} / {media_content_id}"
|
|
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
def build_item_response(media_library, payload):
|
|
|
|
"""Create response payload for the provided media query."""
|
|
|
|
if payload["search_type"] == MEDIA_TYPE_ALBUM and payload["idstring"].startswith(
|
|
|
|
("A:GENRE", "A:COMPOSER")
|
|
|
|
):
|
|
|
|
payload["idstring"] = "A:ALBUMARTIST/" + "/".join(
|
|
|
|
payload["idstring"].split("/")[2:]
|
|
|
|
)
|
|
|
|
|
|
|
|
media = media_library.browse_by_idstring(
|
|
|
|
MEDIA_TYPES_TO_SONOS[payload["search_type"]],
|
|
|
|
payload["idstring"],
|
|
|
|
full_album_art_uri=True,
|
|
|
|
max_items=0,
|
|
|
|
)
|
|
|
|
|
|
|
|
if media is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
thumbnail = None
|
|
|
|
title = None
|
|
|
|
|
|
|
|
# Fetch album info for titles and thumbnails
|
|
|
|
# Can't be extracted from track info
|
|
|
|
if (
|
|
|
|
payload["search_type"] == MEDIA_TYPE_ALBUM
|
|
|
|
and media[0].item_class == "object.item.audioItem.musicTrack"
|
|
|
|
):
|
|
|
|
item = get_media(media_library, payload["idstring"], SONOS_ALBUM_ARTIST)
|
|
|
|
title = getattr(item, "title", None)
|
|
|
|
thumbnail = getattr(item, "album_art_uri", media[0].album_art_uri)
|
|
|
|
|
|
|
|
if not title:
|
|
|
|
try:
|
|
|
|
title = urllib.parse.unquote(payload["idstring"].split("/")[1])
|
|
|
|
except IndexError:
|
|
|
|
title = LIBRARY_TITLES_MAPPING[payload["idstring"]]
|
|
|
|
|
2020-09-11 11:08:13 +00:00
|
|
|
try:
|
|
|
|
media_class = SONOS_TO_MEDIA_CLASSES[
|
|
|
|
MEDIA_TYPES_TO_SONOS[payload["search_type"]]
|
|
|
|
]
|
|
|
|
except KeyError:
|
|
|
|
_LOGGER.debug("Unknown media type received %s", payload["search_type"])
|
|
|
|
return None
|
|
|
|
|
|
|
|
children = []
|
|
|
|
for item in media:
|
|
|
|
try:
|
|
|
|
children.append(item_payload(item))
|
|
|
|
except UnknownMediaType:
|
|
|
|
pass
|
2020-09-08 14:42:01 +00:00
|
|
|
|
2020-09-06 13:52:59 +00:00
|
|
|
return BrowseMedia(
|
|
|
|
title=title,
|
|
|
|
thumbnail=thumbnail,
|
2020-09-08 14:42:01 +00:00
|
|
|
media_class=media_class,
|
2020-09-06 13:52:59 +00:00
|
|
|
media_content_id=payload["idstring"],
|
|
|
|
media_content_type=payload["search_type"],
|
2020-09-11 11:08:13 +00:00
|
|
|
children=children,
|
2020-09-06 13:52:59 +00:00
|
|
|
can_play=can_play(payload["search_type"]),
|
|
|
|
can_expand=can_expand(payload["search_type"]),
|
|
|
|
)
|
2020-09-02 08:57:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
def item_payload(item):
|
|
|
|
"""
|
|
|
|
Create response payload for a single media item.
|
|
|
|
|
|
|
|
Used by async_browse_media.
|
|
|
|
"""
|
2020-09-11 11:08:13 +00:00
|
|
|
media_type = get_media_type(item)
|
|
|
|
try:
|
|
|
|
media_class = SONOS_TO_MEDIA_CLASSES[media_type]
|
|
|
|
except KeyError as err:
|
|
|
|
_LOGGER.debug("Unknown media type received %s", media_type)
|
|
|
|
raise UnknownMediaType from err
|
2020-09-07 09:24:31 +00:00
|
|
|
return BrowseMedia(
|
|
|
|
title=item.title,
|
|
|
|
thumbnail=getattr(item, "album_art_uri", None),
|
2020-09-11 11:08:13 +00:00
|
|
|
media_class=media_class,
|
2020-09-07 09:24:31 +00:00
|
|
|
media_content_id=get_content_id(item),
|
2020-09-11 11:08:13 +00:00
|
|
|
media_content_type=SONOS_TO_MEDIA_TYPES[media_type],
|
2020-09-07 09:24:31 +00:00
|
|
|
can_play=can_play(item.item_class),
|
|
|
|
can_expand=can_expand(item),
|
|
|
|
)
|
2020-09-02 08:57:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
def library_payload(media_library):
|
|
|
|
"""
|
|
|
|
Create response payload to describe contents of a specific library.
|
|
|
|
|
|
|
|
Used by async_browse_media.
|
|
|
|
"""
|
2020-09-15 07:30:00 +00:00
|
|
|
if not media_library.browse_by_idstring(
|
|
|
|
"tracks",
|
|
|
|
"",
|
|
|
|
max_items=1,
|
|
|
|
):
|
|
|
|
raise BrowseError("Local library not found")
|
|
|
|
|
2020-09-11 11:08:13 +00:00
|
|
|
children = []
|
|
|
|
for item in media_library.browse():
|
|
|
|
try:
|
|
|
|
children.append(item_payload(item))
|
|
|
|
except UnknownMediaType:
|
|
|
|
pass
|
|
|
|
|
2020-09-07 09:24:31 +00:00
|
|
|
return BrowseMedia(
|
|
|
|
title="Music Library",
|
2020-09-08 14:42:01 +00:00
|
|
|
media_class=MEDIA_CLASS_DIRECTORY,
|
2020-09-07 09:24:31 +00:00
|
|
|
media_content_id="library",
|
|
|
|
media_content_type="library",
|
|
|
|
can_play=False,
|
|
|
|
can_expand=True,
|
2020-09-11 11:08:13 +00:00
|
|
|
children=children,
|
2020-09-07 09:24:31 +00:00
|
|
|
)
|
2020-09-02 08:57:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
def get_media_type(item):
|
|
|
|
"""Extract media type of item."""
|
|
|
|
if item.item_class == "object.item.audioItem.musicTrack":
|
|
|
|
return SONOS_TRACKS
|
|
|
|
|
|
|
|
if (
|
|
|
|
item.item_class == "object.container.album.musicAlbum"
|
|
|
|
and SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0])
|
|
|
|
in [
|
|
|
|
SONOS_ALBUM_ARTIST,
|
|
|
|
SONOS_GENRE,
|
|
|
|
]
|
|
|
|
):
|
|
|
|
return SONOS_TYPES_MAPPING[item.item_class]
|
|
|
|
|
|
|
|
return SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0], item.item_class)
|
|
|
|
|
|
|
|
|
|
|
|
def can_play(item):
|
|
|
|
"""
|
|
|
|
Test if playable.
|
|
|
|
|
|
|
|
Used by async_browse_media.
|
|
|
|
"""
|
|
|
|
return SONOS_TO_MEDIA_TYPES.get(item) in PLAYABLE_MEDIA_TYPES
|
|
|
|
|
|
|
|
|
|
|
|
def can_expand(item):
|
|
|
|
"""
|
|
|
|
Test if expandable.
|
|
|
|
|
|
|
|
Used by async_browse_media.
|
|
|
|
"""
|
|
|
|
if isinstance(item, str):
|
|
|
|
return SONOS_TYPES_MAPPING.get(item) in EXPANDABLE_MEDIA_TYPES
|
|
|
|
|
|
|
|
if SONOS_TO_MEDIA_TYPES.get(item.item_class) in EXPANDABLE_MEDIA_TYPES:
|
|
|
|
return True
|
|
|
|
|
|
|
|
return SONOS_TYPES_MAPPING.get(item.item_id) in EXPANDABLE_MEDIA_TYPES
|
|
|
|
|
|
|
|
|
|
|
|
def get_content_id(item):
|
|
|
|
"""Extract content id or uri."""
|
|
|
|
if item.item_class == "object.item.audioItem.musicTrack":
|
|
|
|
return item.get_uri()
|
|
|
|
return item.item_id
|
|
|
|
|
|
|
|
|
|
|
|
def get_media(media_library, item_id, search_type):
|
|
|
|
"""Fetch media/album."""
|
|
|
|
search_type = MEDIA_TYPES_TO_SONOS.get(search_type, search_type)
|
|
|
|
|
|
|
|
if not item_id.startswith("A:ALBUM") and search_type == SONOS_ALBUM:
|
|
|
|
item_id = "A:ALBUMARTIST/" + "/".join(item_id.split("/")[2:])
|
|
|
|
|
|
|
|
for item in media_library.browse_by_idstring(
|
|
|
|
search_type,
|
|
|
|
"/".join(item_id.split("/")[:-1]),
|
|
|
|
full_album_art_uri=True,
|
2020-09-08 12:13:48 +00:00
|
|
|
max_items=0,
|
2020-09-02 08:57:12 +00:00
|
|
|
):
|
|
|
|
if item.item_id == item_id:
|
|
|
|
return item
|