Sonos improvements (#3997)

* Sonos improvements: media_* properties delegate to coordinator if speaker is a slave, media_image_url and media_title now works for radio streams, source selection/list takes speaker model into account, commands on slaves delegate to coordinator.

* Fixed failing unit tests.
This commit is contained in:
Bjarni Ivarsson 2016-10-26 00:37:47 +02:00 committed by Pascal Vizeli
parent 79da1ec0d9
commit 961c02f72a
2 changed files with 182 additions and 58 deletions

229
homeassistant/components/media_player/sonos.py Normal file → Executable file
View File

@ -8,6 +8,7 @@ import datetime
import logging import logging
from os import path from os import path
import socket import socket
import urllib
import voluptuous as vol import voluptuous as vol
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@ -44,7 +45,6 @@ SERVICE_RESTORE = 'sonos_restore'
SUPPORT_SOURCE_LINEIN = 'Line-in' SUPPORT_SOURCE_LINEIN = 'Line-in'
SUPPORT_SOURCE_TV = 'TV' SUPPORT_SOURCE_TV = 'TV'
SUPPORT_SOURCE_RADIO = 'Radio'
SONOS_SCHEMA = vol.Schema({ SONOS_SCHEMA = vol.Schema({
ATTR_ENTITY_ID: cv.entity_ids, ATTR_ENTITY_ID: cv.entity_ids,
@ -204,7 +204,15 @@ class SonosDevice(MediaPlayerDevice):
self.hass = hass self.hass = hass
self.volume_increment = 5 self.volume_increment = 5
self._player = player self._player = player
self._speaker_info = None
self._name = None self._name = None
self._coordinator = None
self._media_content_id = None
self._media_duration = None
self._media_image_url = None
self._media_artist = None
self._media_album_name = None
self._media_title = None
self.update() self.update()
self.soco_snapshot = Snapshot(self._player) self.soco_snapshot = Snapshot(self._player)
@ -236,6 +244,8 @@ class SonosDevice(MediaPlayerDevice):
return STATE_PLAYING return STATE_PLAYING
if self._status == 'STOPPED': if self._status == 'STOPPED':
return STATE_IDLE return STATE_IDLE
if self._status == 'OFF':
return STATE_OFF
return STATE_UNKNOWN return STATE_UNKNOWN
@property @property
@ -245,16 +255,89 @@ class SonosDevice(MediaPlayerDevice):
def update(self): def update(self):
"""Retrieve latest state.""" """Retrieve latest state."""
self._name = self._player.get_speaker_info()['zone_name'].replace( self._speaker_info = self._player.get_speaker_info()
self._name = self._speaker_info['zone_name'].replace(
' (R)', '').replace(' (L)', '') ' (R)', '').replace(' (L)', '')
if self.available: if self.available:
self._status = self._player.get_current_transport_info().get( self._status = self._player.get_current_transport_info().get(
'current_transport_state') 'current_transport_state')
self._trackinfo = self._player.get_current_track_info() trackinfo = self._player.get_current_track_info()
if trackinfo['uri'].startswith('x-rincon:'):
# this speaker is a slave, find the coordinator
# the uri of the track is 'x-rincon:{coordinator-id}'
coordinator_id = trackinfo['uri'][9:]
coordinators = [device for device in DEVICES
if device.unique_id == coordinator_id]
self._coordinator = coordinators[0] if coordinators else None
else:
self._coordinator = None
if not self._coordinator:
mediainfo = self._player.avTransport.GetMediaInfo([
('InstanceID', 0)
])
duration = trackinfo.get('duration', '0:00')
# if the speaker is playing from the "line-in" source, getting
# track metadata can return NOT_IMPLEMENTED, which breaks the
# volume logic below
if duration == 'NOT_IMPLEMENTED':
duration = None
else:
duration = sum(60 ** x[0] * int(x[1]) for x in enumerate(
reversed(duration.split(':'))))
media_image_url = trackinfo.get('album_art', None)
media_artist = trackinfo.get('artist', None)
media_album_name = trackinfo.get('album', None)
media_title = trackinfo.get('title', None)
if media_image_url in ('', 'NOT_IMPLEMENTED', None):
# fallback to asking the speaker directly
media_image_url = \
'http://{host}:{port}/getaa?s=1&u={uri}'.format(
host=self._player.ip_address,
port=1400,
uri=urllib.parse.quote(mediainfo['CurrentURI'])
)
if media_artist in ('', 'NOT_IMPLEMENTED', None):
# if listening to a radio stream the media_artist field
# will be empty and the title field will contain the
# filename that is being streamed
current_uri_metadata = mediainfo["CurrentURIMetaData"]
if current_uri_metadata not in \
('', 'NOT_IMPLEMENTED', None):
# currently soco does not have an API for this
import soco
current_uri_metadata = soco.xml.XML.fromstring(
soco.utils.really_utf8(current_uri_metadata))
md_title = current_uri_metadata.findtext(
'.//{http://purl.org/dc/elements/1.1/}title')
if md_title not in ('', 'NOT_IMPLEMENTED', None):
media_artist = ''
media_title = md_title
self._media_content_id = trackinfo.get('title', None)
self._media_duration = duration
self._media_image_url = media_image_url
self._media_artist = media_artist
self._media_album_name = media_album_name
self._media_title = media_title
else: else:
self._status = STATE_OFF self._status = 'OFF'
self._trackinfo = {} self._coordinator = None
self._media_content_id = None
self._media_duration = None
self._media_image_url = None
self._media_artist = None
self._media_album_name = None
self._media_title = None
@property @property
def volume_level(self): def volume_level(self):
@ -269,7 +352,10 @@ class SonosDevice(MediaPlayerDevice):
@property @property
def media_content_id(self): def media_content_id(self):
"""Content ID of current playing media.""" """Content ID of current playing media."""
return self._trackinfo.get('title', None) if self._coordinator:
return self._coordinator.media_content_id
else:
return self._media_content_id
@property @property
def media_content_type(self): def media_content_type(self):
@ -279,22 +365,34 @@ class SonosDevice(MediaPlayerDevice):
@property @property
def media_duration(self): def media_duration(self):
"""Duration of current playing media in seconds.""" """Duration of current playing media in seconds."""
dur = self._trackinfo.get('duration', '0:00') if self._coordinator:
return self._coordinator.media_duration
# If the speaker is playing from the "line-in" source, getting else:
# track metadata can return NOT_IMPLEMENTED, which breaks the return self._media_duration
# volume logic below
if dur == 'NOT_IMPLEMENTED':
return None
return sum(60 ** x[0] * int(x[1]) for x in
enumerate(reversed(dur.split(':'))))
@property @property
def media_image_url(self): def media_image_url(self):
"""Image url of current playing media.""" """Image url of current playing media."""
if 'album_art' in self._trackinfo: if self._coordinator:
return self._trackinfo['album_art'] return self._coordinator.media_image_url
else:
return self._media_image_url
@property
def media_artist(self):
"""Artist of current playing media, music track only."""
if self._coordinator:
return self._coordinator.media_artist
else:
return self._media_artist
@property
def media_album_name(self):
"""Album name of current playing media, music track only."""
if self._coordinator:
return self._coordinator.media_album_name
else:
return self._media_album_name
@property @property
def media_title(self): def media_title(self):
@ -303,17 +401,19 @@ class SonosDevice(MediaPlayerDevice):
return SUPPORT_SOURCE_LINEIN return SUPPORT_SOURCE_LINEIN
if self._player.is_playing_tv: if self._player.is_playing_tv:
return SUPPORT_SOURCE_TV return SUPPORT_SOURCE_TV
if 'artist' in self._trackinfo and 'title' in self._trackinfo:
return '{artist} - {title}'.format( if self._coordinator:
artist=self._trackinfo['artist'], return self._coordinator.media_title
title=self._trackinfo['title'] else:
) return self._media_title
if 'title' in self._status:
return self._trackinfo['title']
@property @property
def supported_media_commands(self): def supported_media_commands(self):
"""Flag of media commands that are supported.""" """Flag of media commands that are supported."""
if not self.source_list:
# some devices do not allow source selection
return SUPPORT_SONOS ^ SUPPORT_SELECT_SOURCE
return SUPPORT_SONOS return SUPPORT_SONOS
def volume_up(self): def volume_up(self):
@ -342,14 +442,12 @@ class SonosDevice(MediaPlayerDevice):
@property @property
def source_list(self): def source_list(self):
"""List of available input sources.""" """List of available input sources."""
source = [] model_name = self._speaker_info['model_name']
# generate list of supported device if 'PLAY:5' in model_name:
source.append(SUPPORT_SOURCE_LINEIN) return [SUPPORT_SOURCE_LINEIN]
source.append(SUPPORT_SOURCE_TV) elif 'PLAYBAR' in model_name:
source.append(SUPPORT_SOURCE_RADIO) return [SUPPORT_SOURCE_LINEIN, SUPPORT_SOURCE_TV]
return source
@property @property
def source(self): def source(self):
@ -358,8 +456,7 @@ class SonosDevice(MediaPlayerDevice):
return SUPPORT_SOURCE_LINEIN return SUPPORT_SOURCE_LINEIN
if self._player.is_playing_tv: if self._player.is_playing_tv:
return SUPPORT_SOURCE_TV return SUPPORT_SOURCE_TV
if self._player.is_playing_radio:
return SUPPORT_SOURCE_RADIO
return None return None
@only_if_coordinator @only_if_coordinator
@ -367,63 +464,79 @@ class SonosDevice(MediaPlayerDevice):
"""Turn off media player.""" """Turn off media player."""
self._player.pause() self._player.pause()
@only_if_coordinator
def media_play(self): def media_play(self):
"""Send play command.""" """Send play command."""
self._player.play() if self._coordinator:
self._coordinator.media_play()
else:
self._player.play()
@only_if_coordinator
def media_pause(self): def media_pause(self):
"""Send pause command.""" """Send pause command."""
self._player.pause() if self._coordinator:
self._coordinator.media_pause()
else:
self._player.pause()
@only_if_coordinator
def media_next_track(self): def media_next_track(self):
"""Send next track command.""" """Send next track command."""
self._player.next() if self._coordinator:
self._coordinator.media_next_track()
else:
self._player.next()
@only_if_coordinator
def media_previous_track(self): def media_previous_track(self):
"""Send next track command.""" """Send next track command."""
self._player.previous() if self._coordinator:
self._coordinator.media_previous_track()
else:
self._player.previous()
@only_if_coordinator
def media_seek(self, position): def media_seek(self, position):
"""Send seek command.""" """Send seek command."""
self._player.seek(str(datetime.timedelta(seconds=int(position)))) if self._coordinator:
self._coordinator.media_seek(position)
else:
self._player.seek(str(datetime.timedelta(seconds=int(position))))
@only_if_coordinator
def clear_playlist(self): def clear_playlist(self):
"""Clear players playlist.""" """Clear players playlist."""
self._player.clear_queue() if self._coordinator:
self._coordinator.clear_playlist()
else:
self._player.clear_queue()
@only_if_coordinator @only_if_coordinator
def turn_on(self): def turn_on(self):
"""Turn the media player on.""" """Turn the media player on."""
self._player.play() self._player.play()
@only_if_coordinator
def play_media(self, media_type, media_id, **kwargs): def play_media(self, media_type, media_id, **kwargs):
""" """
Send the play_media command to the media player. Send the play_media command to the media player.
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
""" """
if kwargs.get(ATTR_MEDIA_ENQUEUE): if self._coordinator:
from soco.exceptions import SoCoUPnPException self._coordinator.play_media(media_type, media_id, **kwargs)
try:
self._player.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: else:
self._player.play_uri(media_id) if kwargs.get(ATTR_MEDIA_ENQUEUE):
from soco.exceptions import SoCoUPnPException
try:
self._player.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._player.play_uri(media_id)
@only_if_coordinator
def group_players(self): def group_players(self):
"""Group all players under this coordinator.""" """Group all players under this coordinator."""
self._player.partymode() if self._coordinator:
self._coordinator.group_players()
else:
self._player.partymode()
@only_if_coordinator @only_if_coordinator
def unjoin(self): def unjoin(self):

11
tests/components/media_player/test_sonos.py Normal file → Executable file
View File

@ -19,6 +19,16 @@ class socoDiscoverMock():
return {SoCoMock('192.0.2.1')} return {SoCoMock('192.0.2.1')}
class AvTransportMock():
"""Mock class for the avTransport property on soco.SoCo object."""
def __init__(self):
pass
def GetMediaInfo(self, _):
return {'CurrentURI': '',
'CurrentURIMetaData': ''}
class SoCoMock(): class SoCoMock():
"""Mock class for the soco.SoCo object.""" """Mock class for the soco.SoCo object."""
@ -26,6 +36,7 @@ class SoCoMock():
"""Initialize soco object.""" """Initialize soco object."""
self.ip_address = ip self.ip_address = ip
self.is_visible = True self.is_visible = True
self.avTransport = AvTransportMock()
def get_speaker_info(self): def get_speaker_info(self):
"""Return a dict with various data points about the speaker.""" """Return a dict with various data points about the speaker."""