Currently we interact with players regardless of thir coordinator role, hence soco.exceptions.SoCoSlaveException are thrown. The use of the decorator for each interactive method should address this
251 lines
7.5 KiB
Python
251 lines
7.5 KiB
Python
"""
|
|
homeassistant.components.media_player.sonos
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
Provides an interface to Sonos players (via SoCo)
|
|
|
|
For more details about this platform, please refer to the documentation at
|
|
https://home-assistant.io/components/media_player.sonos/
|
|
"""
|
|
import datetime
|
|
import logging
|
|
|
|
from homeassistant.components.media_player import (
|
|
MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
|
|
SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_VOLUME_MUTE,
|
|
SUPPORT_VOLUME_SET, MediaPlayerDevice)
|
|
from homeassistant.const import (
|
|
STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN)
|
|
|
|
REQUIREMENTS = ['SoCo==0.11.1']
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# The soco library is excessively chatty when it comes to logging and
|
|
# causes a LOT of spam in the logs due to making a http connection to each
|
|
# speaker every 10 seconds. Quiet it down a bit to just actual problems.
|
|
_SOCO_LOGGER = logging.getLogger('soco')
|
|
_SOCO_LOGGER.setLevel(logging.ERROR)
|
|
_REQUESTS_LOGGER = logging.getLogger('requests')
|
|
_REQUESTS_LOGGER.setLevel(logging.ERROR)
|
|
|
|
SUPPORT_SONOS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\
|
|
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK
|
|
|
|
|
|
# pylint: disable=unused-argument
|
|
def setup_platform(hass, config, add_devices, discovery_info=None):
|
|
""" Sets up the Sonos platform. """
|
|
import soco
|
|
import socket
|
|
|
|
if discovery_info:
|
|
add_devices([SonosDevice(hass, soco.SoCo(discovery_info))])
|
|
return True
|
|
|
|
players = None
|
|
hosts = config.get('hosts', None)
|
|
if hosts:
|
|
# Support retro compatibility with comma separated list of hosts
|
|
# from config
|
|
hosts = hosts.split(',') if isinstance(hosts, str) else hosts
|
|
players = []
|
|
for host in hosts:
|
|
players.append(soco.SoCo(socket.gethostbyname(host)))
|
|
|
|
if not players:
|
|
players = soco.discover(interface_addr=config.get('interface_addr',
|
|
None))
|
|
|
|
if not players:
|
|
_LOGGER.warning('No Sonos speakers found.')
|
|
return False
|
|
|
|
add_devices(SonosDevice(hass, p) for p in players)
|
|
_LOGGER.info('Added %s Sonos speakers', len(players))
|
|
|
|
return True
|
|
|
|
|
|
def only_if_coordinator(func):
|
|
"""
|
|
If used as decorator, avoid calling the decorated method if
|
|
player is not a coordinator.
|
|
If not, a grouped speaker (not in coordinator role)
|
|
will throw soco.exceptions.SoCoSlaveException
|
|
"""
|
|
|
|
def wrapper(*args, **kwargs):
|
|
""" Decorator wrapper """
|
|
if args[0].is_coordinator:
|
|
return func(*args, **kwargs)
|
|
else:
|
|
_LOGGER.debug('Ignore command "%s" for Sonos device "%s" '
|
|
'(not coordinator)',
|
|
func.__name__, args[0].name)
|
|
|
|
return wrapper
|
|
|
|
|
|
# pylint: disable=too-many-instance-attributes, too-many-public-methods
|
|
# pylint: disable=abstract-method
|
|
class SonosDevice(MediaPlayerDevice):
|
|
""" Represents a Sonos device. """
|
|
|
|
# pylint: disable=too-many-arguments
|
|
def __init__(self, hass, player):
|
|
self.hass = hass
|
|
super(SonosDevice, self).__init__()
|
|
self._player = player
|
|
self.update()
|
|
|
|
@property
|
|
def should_poll(self):
|
|
return True
|
|
|
|
def update_sonos(self, now):
|
|
""" Updates state, called by track_utc_time_change. """
|
|
self.update_ha_state(True)
|
|
|
|
@property
|
|
def name(self):
|
|
""" Returns the name of the device. """
|
|
return self._name
|
|
|
|
@property
|
|
def unique_id(self):
|
|
""" Returns a unique id. """
|
|
return "{}.{}".format(self.__class__, self._player.uid)
|
|
|
|
@property
|
|
def state(self):
|
|
""" Returns the state of the device. """
|
|
if self._status == 'PAUSED_PLAYBACK':
|
|
return STATE_PAUSED
|
|
if self._status == 'PLAYING':
|
|
return STATE_PLAYING
|
|
if self._status == 'STOPPED':
|
|
return STATE_IDLE
|
|
return STATE_UNKNOWN
|
|
|
|
@property
|
|
def is_coordinator(self):
|
|
""" Returns true if player is a coordinator """
|
|
return self._player.is_coordinator
|
|
|
|
def update(self):
|
|
""" Retrieve latest state. """
|
|
self._name = self._player.get_speaker_info()['zone_name'].replace(
|
|
' (R)', '').replace(' (L)', '')
|
|
self._status = self._player.get_current_transport_info().get(
|
|
'current_transport_state')
|
|
self._trackinfo = self._player.get_current_track_info()
|
|
|
|
@property
|
|
def volume_level(self):
|
|
""" Volume level of the media player (0..1). """
|
|
return self._player.volume / 100.0
|
|
|
|
@property
|
|
def is_volume_muted(self):
|
|
return self._player.mute
|
|
|
|
@property
|
|
def media_content_id(self):
|
|
""" Content ID of current playing media. """
|
|
return self._trackinfo.get('title', None)
|
|
|
|
@property
|
|
def media_content_type(self):
|
|
""" Content type of current playing media. """
|
|
return MEDIA_TYPE_MUSIC
|
|
|
|
@property
|
|
def media_duration(self):
|
|
""" Duration of current playing media in seconds. """
|
|
dur = self._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 dur == 'NOT_IMPLEMENTED':
|
|
return None
|
|
|
|
return sum(60 ** x[0] * int(x[1]) for x in
|
|
enumerate(reversed(dur.split(':'))))
|
|
|
|
@property
|
|
def media_image_url(self):
|
|
""" Image url of current playing media. """
|
|
if 'album_art' in self._trackinfo:
|
|
return self._trackinfo['album_art']
|
|
|
|
@property
|
|
def media_title(self):
|
|
""" Title of current playing media. """
|
|
if 'artist' in self._trackinfo and 'title' in self._trackinfo:
|
|
return '{artist} - {title}'.format(
|
|
artist=self._trackinfo['artist'],
|
|
title=self._trackinfo['title']
|
|
)
|
|
if 'title' in self._status:
|
|
return self._trackinfo['title']
|
|
|
|
@property
|
|
def supported_media_commands(self):
|
|
""" Flags of media commands that are supported. """
|
|
return SUPPORT_SONOS
|
|
|
|
@only_if_coordinator
|
|
def turn_off(self):
|
|
""" Turn off media player. """
|
|
self._player.pause()
|
|
|
|
@only_if_coordinator
|
|
def volume_up(self):
|
|
""" Volume up media player. """
|
|
self._player.volume += 1
|
|
|
|
@only_if_coordinator
|
|
def volume_down(self):
|
|
""" Volume down media player. """
|
|
self._player.volume -= 1
|
|
|
|
@only_if_coordinator
|
|
def set_volume_level(self, volume):
|
|
""" Set volume level, range 0..1. """
|
|
self._player.volume = str(int(volume * 100))
|
|
|
|
@only_if_coordinator
|
|
def mute_volume(self, mute):
|
|
""" Mute (true) or unmute (false) media player. """
|
|
self._player.mute = mute
|
|
|
|
@only_if_coordinator
|
|
def media_play(self):
|
|
""" Send paly command. """
|
|
self._player.play()
|
|
|
|
@only_if_coordinator
|
|
def media_pause(self):
|
|
""" Send pause command. """
|
|
self._player.pause()
|
|
|
|
@only_if_coordinator
|
|
def media_next_track(self):
|
|
""" Send next track command. """
|
|
self._player.next()
|
|
|
|
@only_if_coordinator
|
|
def media_previous_track(self):
|
|
""" Send next track command. """
|
|
self._player.previous()
|
|
|
|
@only_if_coordinator
|
|
def media_seek(self, position):
|
|
""" Send seek command. """
|
|
self._player.seek(str(datetime.timedelta(seconds=int(position))))
|
|
|
|
@only_if_coordinator
|
|
def turn_on(self):
|
|
""" Turn the media player on. """
|
|
self._player.play()
|