From 0c79aa5a4592324fa1405ac8ff75bb4ad9db9cf9 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 1 Feb 2018 20:25:26 +0100 Subject: [PATCH 1/7] Rework Sonos media player platform for push --- .../components/media_player/sonos.py | 997 +++++++----------- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/media_player/test_sonos.py | 122 +-- 4 files changed, 452 insertions(+), 679 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index d4a7fd3adb580..854ea773d81d3 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -10,6 +10,7 @@ import logging import socket import urllib +import threading import voluptuous as vol @@ -25,23 +26,19 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.13'] +REQUIREMENTS = [ + 'https://github.com/SoCo/SoCo/archive/' + '9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.14a5'] _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) +# Quiet down soco logging to just actual problems. +logging.getLogger('soco').setLevel(logging.WARNING) _SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') -_REQUESTS_LOGGER = logging.getLogger('requests') -_REQUESTS_LOGGER.setLevel(logging.ERROR) -SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ - SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ +SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_STOP SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -54,8 +51,8 @@ DATA_SONOS = 'sonos' -SUPPORT_SOURCE_LINEIN = 'Line-in' -SUPPORT_SOURCE_TV = 'TV' +SOURCE_LINEIN = 'Line-in' +SOURCE_TV = 'TV' CONF_ADVERTISE_ADDR = 'advertise_addr' CONF_INTERFACE_ADDR = 'interface_addr' @@ -112,12 +109,21 @@ }) +class SonosData: + """Storage class for platform global data.""" + + def __init__(self): + """Initialize the data.""" + self.devices = [] + self.topology_lock = threading.Lock() + + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" import soco if DATA_SONOS not in hass.data: - hass.data[DATA_SONOS] = [] + hass.data[DATA_SONOS] = SonosData() advertise_addr = config.get(CONF_ADVERTISE_ADDR, None) if advertise_addr: @@ -127,14 +133,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): player = soco.SoCo(discovery_info.get('host')) # If device already exists by config - if player.uid in [x.unique_id for x in hass.data[DATA_SONOS]]: + if player.uid in [x.unique_id for x in hass.data[DATA_SONOS].devices]: return if player.is_visible: device = SonosDevice(player) - add_devices([device], True) - hass.data[DATA_SONOS].append(device) - if len(hass.data[DATA_SONOS]) > 1: + hass.data[DATA_SONOS].devices.append(device) + add_devices([device]) + if len(hass.data[DATA_SONOS].devices) > 1: return else: players = None @@ -159,14 +165,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning("No Sonos speakers found") return - # Add coordinators first so they can be queried by slaves - coordinators = [SonosDevice(p) for p in players if p.is_coordinator] - slaves = [SonosDevice(p) for p in players if not p.is_coordinator] - hass.data[DATA_SONOS] = coordinators + slaves - if coordinators: - add_devices(coordinators, True) - if slaves: - add_devices(slaves, True) + hass.data[DATA_SONOS].devices = [SonosDevice(p) for p in players] + add_devices(hass.data[DATA_SONOS].devices) _LOGGER.debug("Added %s Sonos speakers", len(players)) def service_handle(service): @@ -174,16 +174,20 @@ def service_handle(service): entity_ids = service.data.get('entity_id') if entity_ids: - devices = [device for device in hass.data[DATA_SONOS] + devices = [device for device in hass.data[DATA_SONOS].devices if device.entity_id in entity_ids] else: - devices = hass.data[DATA_SONOS] + devices = hass.data[DATA_SONOS].devices + + if service.service == SERVICE_JOIN: + master = [device for device in hass.data[DATA_SONOS].devices + if device.entity_id == service.data[ATTR_MASTER]] + if master: + master[0].join(devices) + return for device in devices: - if service.service == SERVICE_JOIN: - if device.entity_id != service.data[ATTR_MASTER]: - device.join(service.data[ATTR_MASTER]) - elif service.service == SERVICE_UNJOIN: + if service.service == SERVICE_UNJOIN: device.unjoin() elif service.service == SERVICE_SNAPSHOT: device.snapshot(service.data[ATTR_WITH_GROUP]) @@ -233,35 +237,24 @@ def service_handle(service): schema=SONOS_SET_OPTION_SCHEMA) -def _parse_timespan(timespan): - """Parse a time-span into number of seconds.""" - if timespan in ('', 'NOT_IMPLEMENTED', None): - return None - - return sum(60 ** x[0] * int(x[1]) for x in enumerate( - reversed(timespan.split(':')))) - - -class _ProcessSonosEventQueue(object): +class _ProcessSonosEventQueue: """Queue like object for dispatching sonos events.""" - def __init__(self, sonos_device): + def __init__(self, handler): """Initialize Sonos event queue.""" - self._sonos_device = sonos_device + self._handler = handler def put(self, item, block=True, timeout=None): - """Queue up event for processing.""" - # Instead of putting events on a queue, dispatch them to the event - # processing method. - self._sonos_device.process_sonos_event(item) + """Process event.""" + self._handler(item) -def _get_entity_from_soco(hass, soco): - """Return SonosDevice from SoCo.""" - for device in hass.data[DATA_SONOS]: - if soco == device.soco: - return device - raise ValueError("No entity for SoCo device") +def _get_entity_from_soco_uid(hass, uid): + """Return SonosDevice from SoCo uid.""" + for entity in hass.data[DATA_SONOS].devices: + if uid == entity.soco.uid: + return entity + return None def soco_error(errorcodes=None): @@ -305,21 +298,37 @@ def wrapper(device, *args, **kwargs): return wrapper +def _timespan_secs(timespan): + """Parse a time-span into number of seconds.""" + if timespan in ('', 'NOT_IMPLEMENTED', None): + return None + + return sum(60 ** x[0] * int(x[1]) for x in enumerate( + reversed(timespan.split(':')))) + + +def _is_radio_uri(uri): + """Return whether the URI is a radio stream.""" + return uri.startswith('x-rincon-mp3radio:') or \ + uri.startswith('x-sonosapi-stream:') + + class SonosDevice(MediaPlayerDevice): """Representation of a Sonos device.""" def __init__(self, player): """Initialize the Sonos device.""" - self.volume_increment = 5 + self._volume_increment = 5 self._unique_id = player.uid self._player = player + self._model = None self._player_volume = None self._player_volume_muted = None - self._speaker_info = None + self._play_mode = None self._name = None - self._status = None self._coordinator = None - self._media_content_id = None + self._status = None + self._extra_features = 0 self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -327,37 +336,21 @@ def __init__(self, player): self._media_artist = None self._media_album_name = None self._media_title = None - self._media_radio_show = None - self._available = True - self._support_previous_track = False - self._support_next_track = False - self._support_play = False - self._support_shuffle_set = True - self._support_stop = False - self._support_pause = False self._night_sound = None self._speech_enhance = None - self._current_track_uri = None - self._current_track_is_radio_stream = False - self._queue = None - self._last_avtransport_event = None - self._is_playing_line_in = None - self._is_playing_tv = None - self._favorite_sources = None self._source_name = None + self._available = True + self._favorites = None self._soco_snapshot = None self._snapshot_group = None + self._set_basic_information() + @asyncio.coroutine def async_added_to_hass(self): """Subscribe sonos events.""" self.hass.async_add_job(self._subscribe_to_player_events) - @property - def should_poll(self): - """Return the polling state.""" - return True - @property def unique_id(self): """Return an unique ID.""" @@ -369,10 +362,9 @@ def name(self): return self._name @property + @soco_coordinator def state(self): """Return the state of the device.""" - if self._coordinator: - return self._coordinator.state if self._status in ('PAUSED_PLAYBACK', 'STOPPED'): return STATE_PAUSED if self._status in ('PLAYING', 'TRANSITIONING'): @@ -401,260 +393,285 @@ def available(self) -> bool: """Return True if entity is available.""" return self._available - def _is_available(self): + def _check_available(self): + """Check that we can still connect to the player.""" try: sock = socket.create_connection( - address=(self._player.ip_address, 1443), timeout=3) + address=(self.soco.ip_address, 1443), timeout=3) sock.close() return True except socket.error: return False - # pylint: disable=invalid-name + def _set_basic_information(self): + """Set initial device information.""" + speaker_info = self.soco.get_speaker_info(True) + self._name = speaker_info['zone_name'] + self._model = speaker_info['model_name'] + self._player_volume = self.soco.volume + self._player_volume_muted = self.soco.mute + self._play_mode = self.soco.play_mode + self._night_sound = self.soco.night_mode + self._speech_enhance = self.soco.dialog_mode + self._favorites = self.soco.music_library.get_sonos_favorites() + def _subscribe_to_player_events(self): - if self._queue is None: - self._queue = _ProcessSonosEventQueue(self) - self._player.avTransport.subscribe( - auto_renew=True, - event_queue=self._queue) - self._player.renderingControl.subscribe( - auto_renew=True, - event_queue=self._queue) + """Add event subscriptions.""" + player = self.soco + + queue = _ProcessSonosEventQueue(self.process_avtransport_event) + player.avTransport.subscribe(auto_renew=True, event_queue=queue) + + queue = _ProcessSonosEventQueue(self.process_rendering_event) + player.renderingControl.subscribe(auto_renew=True, event_queue=queue) + + queue = _ProcessSonosEventQueue(self.process_zonegrouptopology_event) + player.zoneGroupTopology.subscribe(auto_renew=True, event_queue=queue) def update(self): """Retrieve latest state.""" - if self._speaker_info is None: - self._speaker_info = self._player.get_speaker_info(True) - self._name = self._speaker_info['zone_name'].replace( - ' (R)', '').replace(' (L)', '') - self._favorite_sources = \ - self._player.get_sonos_favorites()['favorites'] - - if self._last_avtransport_event: - self._available = True - else: - self._available = self._is_available() - - if not self._available: - self._player_volume = None - self._player_volume_muted = None - self._status = 'OFF' - self._coordinator = None - self._media_content_id = None - self._media_duration = None - self._media_position = None - self._media_position_updated_at = None - self._media_image_url = None - self._media_artist = None - self._media_album_name = None - self._media_title = None - self._media_radio_show = None - self._current_track_uri = None - self._current_track_is_radio_stream = False - self._support_previous_track = False - self._support_next_track = False - self._support_play = False - self._support_shuffle_set = False - self._support_stop = False - self._support_pause = False - self._night_sound = None - self._speech_enhance = None - self._is_playing_tv = False - self._is_playing_line_in = False - self._source_name = None - self._last_avtransport_event = None + available = self._check_available() + if self._available != available: + self._available = available + if available: + self._set_basic_information() + self._subscribe_to_player_events() + else: + self._player_volume = None + self._player_volume_muted = None + self._status = 'OFF' + self._coordinator = None + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + self._media_image_url = None + self._media_artist = None + self._media_album_name = None + self._media_title = None + self._extra_features = 0 + self._source_name = None + + def process_avtransport_event(self, event): + """Process a track change event coming from a coordinator.""" + variables = event.variables + + # Ignore transitions, we should get the target state soon + new_status = variables.get('transport_state') + if new_status == 'TRANSITIONING': return - # set group coordinator - if self._player.is_coordinator: - self._coordinator = None + self._play_mode = variables.get('current_play_mode') + + if self.soco.is_playing_tv: + self._refresh_linein(SOURCE_TV) + elif self.soco.is_playing_line_in: + self._refresh_linein(SOURCE_LINEIN) else: - try: - self._coordinator = _get_entity_from_soco( - self.hass, self._player.group.coordinator) - - # protect for loop - if not self._coordinator.is_coordinator: - # pylint: disable=protected-access - self._coordinator._coordinator = None - except ValueError: - self._coordinator = None + track_info = self.soco.get_current_track_info() - track_info = None - if self._last_avtransport_event: - variables = self._last_avtransport_event.variables - current_track_metadata = variables.get( - 'current_track_meta_data', {} + media_info = self.soco.avTransport.GetMediaInfo( + [('InstanceID', 0)] ) - self._status = variables.get('transport_state') + if _is_radio_uri(track_info['uri']): + self._refresh_radio(variables, media_info, track_info) + else: + self._refresh_music(variables, media_info, track_info) - if current_track_metadata: - # no need to ask speaker for information we already have - current_track_metadata = current_track_metadata.__dict__ - - track_info = { - 'uri': variables.get('current_track_uri'), - 'artist': current_track_metadata.get('creator'), - 'album': current_track_metadata.get('album'), - 'title': current_track_metadata.get('title'), - 'playlist_position': variables.get('current_track'), - 'duration': variables.get('current_track_duration') - } - else: - self._player_volume = self._player.volume - self._player_volume_muted = self._player.mute - transport_info = self._player.get_current_transport_info() - self._status = transport_info.get('current_transport_state') + if new_status: + self._status = new_status - if not track_info: - track_info = self._player.get_current_track_info() + self.schedule_update_ha_state() - if self._coordinator: - self._last_avtransport_event = None - return + # Also update slaves + for entity in self.hass.data[DATA_SONOS].devices: + if entity.coordinator == self: + entity.schedule_update_ha_state() - is_playing_tv = self._player.is_playing_tv - is_playing_line_in = self._player.is_playing_line_in + def process_rendering_event(self, event): + """Process a volume change event coming from a player.""" + variables = event.variables - media_info = self._player.avTransport.GetMediaInfo( - [('InstanceID', 0)] - ) + if 'volume' in variables: + self._player_volume = int(variables['volume']['Master']) - current_media_uri = media_info['CurrentURI'] - media_artist = track_info.get('artist') - media_album_name = track_info.get('album') - media_title = track_info.get('title') - media_image_url = track_info.get('album_art', None) + if 'mute' in variables: + self._player_volume_muted = (variables['mute']['Master'] == '1') - media_position = None - media_position_updated_at = None - source_name = None + if 'night_mode' in variables: + self._night_sound = (variables['night_mode'] == '1') - night_sound = self._player.night_mode - speech_enhance = self._player.dialog_mode + if 'dialog_level' in variables: + self._speech_enhance = (variables['dialog_level'] == '1') - is_radio_stream = \ - current_media_uri.startswith('x-sonosapi-stream:') or \ - current_media_uri.startswith('x-rincon-mp3radio:') + self.schedule_update_ha_state() - if is_playing_tv or is_playing_line_in: - # playing from line-in/tv. - - support_previous_track = False - support_next_track = False - support_play = False - support_stop = True - support_pause = False - support_shuffle_set = False + def process_zonegrouptopology_event(self, event): + """Process a zone group topology event coming from a player.""" + if not hasattr(event, 'zone_player_uui_ds_in_group'): + return - if is_playing_tv: - media_artist = SUPPORT_SOURCE_TV + with self.hass.data[DATA_SONOS].topology_lock: + group = event.zone_player_uui_ds_in_group + if group: + # New group information is pushed + coordinator_uid, *slave_uids = group.split(',') else: - media_artist = SUPPORT_SOURCE_LINEIN + # Use SoCo cache for existing topology + grp = self.soco.group + coordinator = grp.coordinator + coordinator_uid = coordinator.uid + slave_uids = [p.uid for p in grp.members if p != coordinator] - source_name = media_artist + if self == _get_entity_from_soco_uid(self.hass, coordinator_uid): + self._coordinator = None + self.schedule_update_ha_state() + + for slave_uid in slave_uids: + slave = _get_entity_from_soco_uid(self.hass, slave_uid) + if slave: + # pylint: disable=protected-access + slave._coordinator = self + slave.schedule_update_ha_state() + + def _radio_artwork(self, url): + """Return the private URL with artwork for a radio stream.""" + if url not in ('', 'NOT_IMPLEMENTED', None): + if url.find('tts_proxy') > 0: + # If the content is a tts don't try to fetch an image from it. + return None + url = 'http://{host}:{port}/getaa?s=1&u={uri}'.format( + host=self.soco.ip_address, + port=1400, + uri=urllib.parse.quote(url, safe='') + ) + return url - media_album_name = None - media_title = None - media_image_url = None + def _refresh_linein(self, source): + """Update state when playing from line-in/tv.""" + self._extra_features = 0 - elif is_radio_stream: - media_image_url = self._format_media_image_url( - media_image_url, - current_media_uri - ) - support_previous_track = False - support_next_track = False - support_play = True - support_stop = True - support_pause = False - support_shuffle_set = False - - source_name = 'Radio' - # Check if currently playing radio station is in favorites - favc = [fav for fav in self._favorite_sources - if fav['uri'] == current_media_uri] - if len(favc) == 1: - src = favc.pop() - source_name = src['title'] - - # for radio streams we set the radio station name as the - # title. - if media_artist and media_title: - # artist and album name are in the data, concatenate - # that do display as artist. - # "Information" field in the sonos pc app - - media_artist = '{artist} - {title}'.format( - artist=media_artist, - title=media_title - ) - else: - # "On Now" field in the sonos pc app - media_artist = self._media_radio_show - - current_uri_metadata = media_info["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_title = md_title - - if media_artist and media_title: - # some radio stations put their name into the artist - # name, e.g.: - # media_title = "Station" - # media_artist = "Station - Artist - Title" - # detect this case and trim from the front of - # media_artist for cosmetics - str_to_trim = '{title} - '.format( - title=media_title - ) - chars = min(len(media_artist), len(str_to_trim)) - - if media_artist[:chars].upper() == str_to_trim[:chars].upper(): - media_artist = media_artist[chars:] + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None - else: - # not a radio stream - media_image_url = self._format_media_image_url( - media_image_url, - track_info['uri'] - ) - support_previous_track = True - support_next_track = True - support_play = True - support_stop = True - support_pause = True - support_shuffle_set = True - - position_info = self._player.avTransport.GetPositionInfo( - [('InstanceID', 0), - ('Channel', 'Master')] + self._media_image_url = None + + self._media_artist = source + self._media_album_name = None + self._media_title = None + + self._source_name = source + + def _refresh_radio(self, variables, media_info, track_info): + """Update state when streaming radio.""" + self._extra_features = 0 + + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + + self._media_image_url = self._radio_artwork(media_info['CurrentURI']) + + self._media_artist = track_info.get('artist') + self._media_album_name = None + self._media_title = track_info.get('title') + + if self._media_artist and self._media_title: + # artist and album name are in the data, concatenate + # that do display as artist. + # "Information" field in the sonos pc app + self._media_artist = '{artist} - {title}'.format( + artist=self._media_artist, + title=self._media_title ) - rel_time = _parse_timespan( - position_info.get("RelTime") + else: + # "On Now" field in the sonos pc app + current_track_metadata = variables.get( + 'current_track_meta_data' ) + if current_track_metadata: + self._media_artist = \ + current_track_metadata.radio_show.split(',')[0] + + # For radio streams we set the radio station name as the title. + current_uri_metadata = media_info["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): + self._media_title = md_title + + if self._media_artist and self._media_title: + # some radio stations put their name into the artist + # name, e.g.: + # media_title = "Station" + # media_artist = "Station - Artist - Title" + # detect this case and trim from the front of + # media_artist for cosmetics + trim = '{title} - '.format(title=self._media_title) + chars = min(len(self._media_artist), len(trim)) + + if self._media_artist[:chars].upper() == trim[:chars].upper(): + self._media_artist = self._media_artist[chars:] + + # Check if currently playing radio station is in favorites + self._source_name = None + for fav in self._favorites: + if fav.reference.get_uri() == media_info['CurrentURI']: + self._source_name = fav.title + + def _refresh_music(self, variables, media_info, track_info): + """Update state when playing music tracks.""" + self._extra_features = SUPPORT_PAUSE | SUPPORT_SHUFFLE_SET |\ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + + playlist_position = track_info.get('playlist_position') + if playlist_position in ('', 'NOT_IMPLEMENTED', None): + playlist_position = None + else: + playlist_position = int(playlist_position) - # player no longer reports position? - update_media_position = rel_time is None and \ - self._media_position is not None + playlist_size = media_info.get('NrTracks') + if playlist_size in ('', 'NOT_IMPLEMENTED', None): + playlist_size = None + else: + playlist_size = int(playlist_size) - # player started reporting position? - update_media_position |= rel_time is not None and \ - self._media_position is None + if playlist_position is not None and playlist_size is not None: + if playlist_position <= 1: + self._extra_features &= ~SUPPORT_PREVIOUS_TRACK - # position changed? - if rel_time is not None and self._media_position is not None: + if playlist_position == playlist_size: + self._extra_features &= ~SUPPORT_NEXT_TRACK + + self._media_duration = _timespan_secs(track_info.get('duration')) + + position_info = self.soco.avTransport.GetPositionInfo( + [('InstanceID', 0), + ('Channel', 'Master')] + ) + rel_time = _timespan_secs(position_info.get("RelTime")) + + # player no longer reports position? + update_media_position = rel_time is None and \ + self._media_position is not None + + # player started reporting position? + update_media_position |= rel_time is not None and \ + self._media_position is None + if self._status != variables.get('transport_state'): + update_media_position = True + else: + # position jumped? + if rel_time is not None and self._media_position is not None: time_diff = utcnow() - self._media_position_updated_at time_diff = time_diff.total_seconds() @@ -663,115 +680,22 @@ def update(self): update_media_position = \ abs(calculated_position - rel_time) > 1.5 - if update_media_position and self.state == STATE_PLAYING: - media_position = rel_time - media_position_updated_at = utcnow() - else: - # don't update media_position (don't want unneeded - # state transitions) - media_position = self._media_position - media_position_updated_at = self._media_position_updated_at - - playlist_position = track_info.get('playlist_position') - if playlist_position in ('', 'NOT_IMPLEMENTED', None): - playlist_position = None - else: - playlist_position = int(playlist_position) - - playlist_size = media_info.get('NrTracks') - if playlist_size in ('', 'NOT_IMPLEMENTED', None): - playlist_size = None - else: - playlist_size = int(playlist_size) - - if playlist_position is not None and playlist_size is not None: - - if playlist_position <= 1: - support_previous_track = False + if update_media_position: + self._media_position = rel_time + self._media_position_updated_at = utcnow() - if playlist_position == playlist_size: - support_next_track = False + self._media_image_url = track_info.get('album_art') - self._media_content_id = track_info.get('title') - self._media_duration = _parse_timespan( - track_info.get('duration') - ) - self._media_position = media_position - self._media_position_updated_at = media_position_updated_at - self._media_image_url = media_image_url - self._media_artist = media_artist - self._media_album_name = media_album_name - self._media_title = media_title - self._current_track_uri = track_info['uri'] - self._current_track_is_radio_stream = is_radio_stream - self._support_previous_track = support_previous_track - self._support_next_track = support_next_track - self._support_play = support_play - self._support_shuffle_set = support_shuffle_set - self._support_stop = support_stop - self._support_pause = support_pause - self._night_sound = night_sound - self._speech_enhance = speech_enhance - self._is_playing_tv = is_playing_tv - self._is_playing_line_in = is_playing_line_in - self._source_name = source_name - self._last_avtransport_event = None - - def _format_media_image_url(self, url, fallback_uri): - if url in ('', 'NOT_IMPLEMENTED', None): - if fallback_uri in ('', 'NOT_IMPLEMENTED', None): - return None - if fallback_uri.find('tts_proxy') > 0: - # If the content is a tts don't try to fetch an image from it. - return None - return 'http://{host}:{port}/getaa?s=1&u={uri}'.format( - host=self._player.ip_address, - port=1400, - uri=urllib.parse.quote(fallback_uri) - ) - return url + self._media_artist = track_info.get('artist') + self._media_album_name = track_info.get('album') + self._media_title = track_info.get('title') - def process_sonos_event(self, event): - """Process a service event coming from the speaker.""" - next_track_image_url = None - if event.service == self._player.avTransport: - self._last_avtransport_event = event - - self._media_radio_show = None - if self._current_track_is_radio_stream: - current_track_metadata = event.variables.get( - 'current_track_meta_data' - ) - if current_track_metadata: - self._media_radio_show = \ - current_track_metadata.radio_show.split(',')[0] - - next_track_uri = event.variables.get('next_track_uri') - if next_track_uri: - next_track_image_url = self._format_media_image_url( - None, - next_track_uri - ) - - elif event.service == self._player.renderingControl: - if 'volume' in event.variables: - self._player_volume = int( - event.variables['volume'].get('Master') - ) - - if 'mute' in event.variables: - self._player_volume_muted = \ - event.variables['mute'].get('Master') == '1' - - self.schedule_update_ha_state(True) - - if next_track_image_url: - self.preload_media_image_url(next_track_image_url) + self._source_name = None @property def volume_level(self): """Volume level of the media player (0..1).""" - return self._player_volume / 100.0 + return self._player_volume / 100 @property def is_volume_muted(self): @@ -779,17 +703,10 @@ def is_volume_muted(self): return self._player_volume_muted @property + @soco_coordinator def shuffle(self): """Shuffling state.""" - return True if self._player.play_mode == 'SHUFFLE' else False - - @property - def media_content_id(self): - """Content ID of current playing media.""" - if self._coordinator: - return self._coordinator.media_content_id - - return self._media_content_id + return 'SHUFFLE' in self._play_mode @property def media_content_type(self): @@ -797,260 +714,170 @@ def media_content_type(self): return MEDIA_TYPE_MUSIC @property + @soco_coordinator def media_duration(self): """Duration of current playing media in seconds.""" - if self._coordinator: - return self._coordinator.media_duration - return self._media_duration @property + @soco_coordinator def media_position(self): """Position of current playing media in seconds.""" - if self._coordinator: - return self._coordinator.media_position - return self._media_position @property + @soco_coordinator def media_position_updated_at(self): - """When was the position of the current playing media valid. - - Returns value from homeassistant.util.dt.utcnow(). - """ - if self._coordinator: - return self._coordinator.media_position_updated_at - + """When was the position of the current playing media valid.""" return self._media_position_updated_at @property + @soco_coordinator def media_image_url(self): """Image url of current playing media.""" - if self._coordinator: - return self._coordinator.media_image_url - - return self._media_image_url + return self._media_image_url or None @property + @soco_coordinator def media_artist(self): """Artist of current playing media, music track only.""" - if self._coordinator: - return self._coordinator.media_artist - return self._media_artist @property + @soco_coordinator def media_album_name(self): """Album name of current playing media, music track only.""" - if self._coordinator: - return self._coordinator.media_album_name - return self._media_album_name @property + @soco_coordinator def media_title(self): """Title of current playing media.""" - if self._coordinator: - return self._coordinator.media_title - return self._media_title @property - def night_sound(self): - """Get status of Night Sound.""" - return self._night_sound - - @property - def speech_enhance(self): - """Get status of Speech Enhancement.""" - return self._speech_enhance + @soco_coordinator + def source(self): + """Name of the current input source.""" + return self._source_name @property + @soco_coordinator def supported_features(self): """Flag media player features that are supported.""" - if self._coordinator: - return self._coordinator.supported_features - - supported = SUPPORT_SONOS - - if not self._support_previous_track: - supported = supported ^ SUPPORT_PREVIOUS_TRACK - - if not self._support_next_track: - supported = supported ^ SUPPORT_NEXT_TRACK - - if not self._support_play: - supported = supported ^ SUPPORT_PLAY - if not self._support_shuffle_set: - supported = supported ^ SUPPORT_SHUFFLE_SET - if not self._support_stop: - supported = supported ^ SUPPORT_STOP - - if not self._support_pause: - supported = supported ^ SUPPORT_PAUSE - - return supported + return SUPPORT_SONOS | self._extra_features @soco_error() def volume_up(self): """Volume up media player.""" - self._player.volume += self.volume_increment + self._player.volume += self._volume_increment @soco_error() def volume_down(self): """Volume down media player.""" - self._player.volume -= self.volume_increment + self._player.volume -= self._volume_increment @soco_error() def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self._player.volume = str(int(volume * 100)) + self.soco.volume = str(int(volume * 100)) @soco_error() + @soco_coordinator def set_shuffle(self, shuffle): """Enable/Disable shuffle mode.""" - self._player.play_mode = 'SHUFFLE' if shuffle else 'NORMAL' + self.soco.play_mode = 'SHUFFLE_NOREPEAT' if shuffle else 'NORMAL' @soco_error() def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self._player.mute = mute + self.soco.mute = mute @soco_error() @soco_coordinator def select_source(self, source): """Select input source.""" - if source == SUPPORT_SOURCE_LINEIN: - self._source_name = SUPPORT_SOURCE_LINEIN - self._player.switch_to_line_in() - elif source == SUPPORT_SOURCE_TV: - self._source_name = SUPPORT_SOURCE_TV - self._player.switch_to_tv() + if source == SOURCE_LINEIN: + self.soco.switch_to_line_in() + elif source == SOURCE_TV: + self.soco.switch_to_tv() else: - fav = [fav for fav in self._favorite_sources - if fav['title'] == source] + fav = [fav for fav in self._favorites + if fav.title == source] if len(fav) == 1: src = fav.pop() - self._source_name = src['title'] - - if ('object.container.playlistContainer' in src['meta'] or - 'object.container.album.musicAlbum' in src['meta']): - self._replace_queue_with_playlist(src) - self._player.play_from_queue(0) + uri = src.reference.get_uri() + if _is_radio_uri(uri): + self.soco.play_uri(uri, title=source) else: - self._player.play_uri(src['uri'], src['meta'], - src['title']) - - def _replace_queue_with_playlist(self, src): - """Replace queue with playlist represented by src. - - Playlists can't be played directly with the self._player.play_uri - API as they are actually composed of multiple URLs. Until soco has - support for playing a playlist, we'll need to parse the playlist item - and replace the current queue in order to play it. - """ - import soco - import xml.etree.ElementTree as ET - - root = ET.fromstring(src['meta']) - namespaces = {'item': - 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/', - 'desc': 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/'} - desc = root.find('item:item', namespaces).find('desc:desc', - namespaces).text - - res = [soco.data_structures.DidlResource(uri=src['uri'], - protocol_info="DUMMY")] - didl = soco.data_structures.DidlItem(title="DUMMY", - parent_id="DUMMY", - item_id=src['uri'], - desc=desc, - resources=res) - - self._player.stop() - self._player.clear_queue() - self._player.add_to_queue(didl) + self.soco.clear_queue() + self.soco.add_to_queue(src.reference) + self.soco.play_from_queue(0) @property + @soco_coordinator def source_list(self): """List of available input sources.""" - if self._coordinator: - return self._coordinator.source_list + sources = [fav.title for fav in self._favorites] - model_name = self._speaker_info['model_name'] - sources = [] + if 'PLAY:5' in self._model or 'CONNECT' in self._model: + sources += [SOURCE_LINEIN] + elif 'PLAYBAR' in self._model: + sources += [SOURCE_LINEIN, SOURCE_TV] - if self._favorite_sources: - for fav in self._favorite_sources: - sources.append(fav['title']) - - if 'PLAY:5' in model_name: - sources += [SUPPORT_SOURCE_LINEIN] - elif 'PLAYBAR' in model_name: - sources += [SUPPORT_SOURCE_LINEIN, SUPPORT_SOURCE_TV] return sources - @property - def source(self): - """Name of the current input source.""" - if self._coordinator: - return self._coordinator.source - - return self._source_name + @soco_error() + def turn_on(self): + """Turn the media player on.""" + self.media_play() @soco_error() def turn_off(self): """Turn off media player.""" - if self._support_stop: - self.media_stop() + self.media_stop() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_play(self): """Send play command.""" - self._player.play() + self.soco.play() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_stop(self): """Send stop command.""" - self._player.stop() + self.soco.stop() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_pause(self): """Send pause command.""" - self._player.pause() + self.soco.pause() @soco_error() @soco_coordinator def media_next_track(self): """Send next track command.""" - self._player.next() + self.soco.next() @soco_error() @soco_coordinator def media_previous_track(self): """Send next track command.""" - self._player.previous() + self.soco.previous() @soco_error() @soco_coordinator def media_seek(self, position): """Send seek command.""" - self._player.seek(str(datetime.timedelta(seconds=int(position)))) + self.soco.seek(str(datetime.timedelta(seconds=int(position)))) @soco_error() @soco_coordinator def clear_playlist(self): """Clear players playlist.""" - self._player.clear_queue() - - @soco_error() - def turn_on(self): - """Turn the media player on.""" - if self.support_play: - self.media_play() + self.soco.clear_queue() @soco_error() @soco_coordinator @@ -1063,45 +890,38 @@ def play_media(self, media_type, media_id, **kwargs): if kwargs.get(ATTR_MEDIA_ENQUEUE): from soco.exceptions import SoCoUPnPException try: - self._player.add_uri_to_queue(media_id) + 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._player.play_uri(media_id) + self.soco.play_uri(media_id) @soco_error() - def join(self, master): - """Join the player to a group.""" - coord = [device for device in self.hass.data[DATA_SONOS] - if device.entity_id == master] - - if coord and master != self.entity_id: - coord = coord[0] - if coord.soco.group.coordinator != coord.soco: - coord.soco.unjoin() - self._player.join(coord.soco) - self._coordinator = coord - else: - _LOGGER.error("Master not found %s", master) + def join(self, slaves): + """Form a group with other players.""" + if self._coordinator: + self.soco.unjoin() + + for slave in slaves: + slave.soco.join(self.soco) @soco_error() def unjoin(self): """Unjoin the player from a group.""" - self._player.unjoin() - self._coordinator = None + self.soco.unjoin() @soco_error() def snapshot(self, with_group=True): """Snapshot the player.""" from soco.snapshot import Snapshot - self._soco_snapshot = Snapshot(self._player) + self._soco_snapshot = Snapshot(self.soco) self._soco_snapshot.snapshot() if with_group: - self._snapshot_group = self._player.group + self._snapshot_group = self.soco.group if self._coordinator: self._coordinator.snapshot(False) else: @@ -1121,12 +941,12 @@ def restore(self, with_group=True): # restore groups if with_group and self._snapshot_group: old = self._snapshot_group - actual = self._player.group + actual = self.soco.group ## # Master have not change, update group if old.coordinator == actual.coordinator: - if self._player is not old.coordinator: + if self.soco is not old.coordinator: # restore state of the groups self._coordinator.restore(False) remove = actual.members - old.members @@ -1144,13 +964,14 @@ def restore(self, with_group=True): ## # old is already master, rejoin if old.coordinator.group.coordinator == old.coordinator: - self._player.join(old.coordinator) + self.soco.join(old.coordinator) return ## # restore old master, update group old.coordinator.unjoin() - coordinator = _get_entity_from_soco(self.hass, old.coordinator) + coordinator = _get_entity_from_soco_uid( + self.hass, old.coordinator.uid) coordinator.restore(False) for s_dev in list(old.members): @@ -1161,45 +982,45 @@ def restore(self, with_group=True): @soco_coordinator def set_sleep_timer(self, sleep_time): """Set the timer on the player.""" - self._player.set_sleep_timer(sleep_time) + self.soco.set_sleep_timer(sleep_time) @soco_error() @soco_coordinator def clear_sleep_timer(self): """Clear the timer on the player.""" - self._player.set_sleep_timer(None) + self.soco.set_sleep_timer(None) @soco_error() @soco_coordinator def update_alarm(self, **data): """Set the alarm clock on the player.""" from soco import alarms - a = None - for alarm in alarms.get_alarms(self.soco): + alarm = None + for one_alarm in alarms.get_alarms(self.soco): # pylint: disable=protected-access - if alarm._alarm_id == str(data[ATTR_ALARM_ID]): - a = alarm - if a is None: + if one_alarm._alarm_id == str(data[ATTR_ALARM_ID]): + alarm = one_alarm + if alarm is None: _LOGGER.warning("did not find alarm with id %s", data[ATTR_ALARM_ID]) return if ATTR_TIME in data: - a.start_time = data[ATTR_TIME] + alarm.start_time = data[ATTR_TIME] if ATTR_VOLUME in data: - a.volume = int(data[ATTR_VOLUME] * 100) + alarm.volume = int(data[ATTR_VOLUME] * 100) if ATTR_ENABLED in data: - a.enabled = data[ATTR_ENABLED] + alarm.enabled = data[ATTR_ENABLED] if ATTR_INCLUDE_LINKED_ZONES in data: - a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] - a.save() + alarm.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] + alarm.save() @soco_error() def update_option(self, **data): """Modify playback options.""" - if ATTR_NIGHT_SOUND in data and self.night_sound is not None: + if ATTR_NIGHT_SOUND in data and self._night_sound is not None: self.soco.night_mode = data[ATTR_NIGHT_SOUND] - if ATTR_SPEECH_ENHANCE in data and self.speech_enhance is not None: + if ATTR_SPEECH_ENHANCE in data and self._speech_enhance is not None: self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE] @property @@ -1207,10 +1028,10 @@ def device_state_attributes(self): """Return device specific state attributes.""" attributes = {ATTR_IS_COORDINATOR: self.is_coordinator} - if self.night_sound is not None: - attributes[ATTR_NIGHT_SOUND] = self.night_sound + if self._night_sound is not None: + attributes[ATTR_NIGHT_SOUND] = self._night_sound - if self.speech_enhance is not None: - attributes[ATTR_SPEECH_ENHANCE] = self.speech_enhance + if self._speech_enhance is not None: + attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance return attributes diff --git a/requirements_all.txt b/requirements_all.txt index 30aeda2b35d11..a62f407a3857d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -43,9 +43,6 @@ PyXiaomiGateway==0.8.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.0 -# homeassistant.components.media_player.sonos -SoCo==0.13 - # homeassistant.components.sensor.travisci TravisPy==0.3.5 @@ -360,6 +357,9 @@ http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b89974819 # homeassistant.components.remember_the_milk httplib2==0.10.3 +# homeassistant.components.media_player.sonos +https://github.com/SoCo/SoCo/archive/9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.14a5 + # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4ae6d55325c7..8d3f0a68a80f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,9 +21,6 @@ asynctest>=0.11.1 # homeassistant.components.notify.html5 PyJWT==1.5.3 -# homeassistant.components.media_player.sonos -SoCo==0.13 - # homeassistant.components.device_tracker.automatic aioautomatic==0.6.4 @@ -77,6 +74,9 @@ holidays==0.9.3 # homeassistant.components.frontend home-assistant-frontend==20180130.0 +# homeassistant.components.media_player.sonos +https://github.com/SoCo/SoCo/archive/9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.14a5 + # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb influxdb==4.1.1 diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index d3ebc67931fd8..f1a0f4a82fcb5 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -41,6 +41,14 @@ def GetMediaInfo(self, _): } +class MusicLibraryMock(): + """Mock class for the music_library property on soco.SoCo object.""" + + def get_sonos_favorites(self): + """Return favorites.""" + return [] + + class SoCoMock(): """Mock class for the soco.SoCo object.""" @@ -48,6 +56,12 @@ def __init__(self, ip): """Initialize soco object.""" self.ip_address = ip self.is_visible = True + self.volume = 50 + self.mute = False + self.play_mode = 'NORMAL' + self.night_mode = False + self.dialog_mode = False + self.music_library = MusicLibraryMock() self.avTransport = AvTransportMock() def get_sonos_favorites(self): @@ -62,6 +76,7 @@ def get_speaker_info(self, force): 'zone_icon': 'x-rincon-roomicon:kitchen', 'mac_address': 'B8:E9:37:BO:OC:BA', 'zone_name': 'Kitchen', + 'model_name': 'Sonos PLAY:1', 'hardware_version': '1.8.1.2-1'} def get_current_transport_info(self): @@ -145,8 +160,9 @@ def test_ensure_setup_discovery(self, *args): 'host': '192.0.2.1' }) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -164,7 +180,7 @@ def test_ensure_setup_config_interface_addr(self, discover_mock, *args): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) self.assertEqual(discover_mock.call_count, 1) @mock.patch('soco.SoCo', new=SoCoMock) @@ -184,7 +200,7 @@ def test_ensure_setup_config_advertise_addr(self, discover_mock, assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) self.assertEqual(discover_mock.call_count, 1) self.assertEqual(soco.config.EVENT_ADVERTISE_IP, '192.0.1.1') @@ -201,8 +217,9 @@ def test_ensure_setup_config_hosts_string_single(self, *args): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -217,8 +234,9 @@ def test_ensure_setup_config_hosts_string_multiple(self, *args): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 2) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -233,8 +251,9 @@ def test_ensure_setup_config_hosts_list(self, *args): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 2) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch.object(soco, 'discover', new=socoDiscoverMock.discover) @@ -242,58 +261,9 @@ def test_ensure_setup_config_hosts_list(self, *args): def test_ensure_setup_sonos_discovery(self, *args): """Test a single device using the autodiscovery provided by Sonos.""" sonos.setup_platform(self.hass, {}, fake_add_device) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(SoCoMock, 'join') - def test_sonos_group_players(self, join_mock, *args): - """Ensuring soco methods called for sonos_group_players service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - device_master = mock.MagicMock() - device_master.entity_id = "media_player.test" - device_master.soco_device = mock.MagicMock() - self.hass.data[sonos.DATA_SONOS].append(device_master) - - join_mock.return_value = True - device.join("media_player.test") - self.assertEqual(join_mock.call_count, 1) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(SoCoMock, 'unjoin') - def test_sonos_unjoin(self, unjoinMock, *args): - """Ensuring soco methods called for sonos_unjoin service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - unjoinMock.return_value = True - device.unjoin() - self.assertEqual(unjoinMock.call_count, 1) - self.assertEqual(unjoinMock.call_args, mock.call()) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - def test_set_shuffle(self, shuffle_set_mock, *args): - """Ensuring soco methods called for sonos_snapshot service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - device.set_shuffle(True) - self.assertEqual(shuffle_set_mock.call_count, 1) - self.assertEqual(device._player.play_mode, 'SHUFFLE') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -303,7 +273,7 @@ def test_sonos_set_sleep_timer(self, set_sleep_timerMock, *args): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass device.set_sleep_timer(30) @@ -317,7 +287,7 @@ def test_sonos_clear_sleep_timer(self, set_sleep_timerMock, *args): sonos.setup_platform(self.hass, {}, mock.MagicMock(), { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass device.set_sleep_timer(None) @@ -331,7 +301,7 @@ def test_update_alarm(self, soco_mock, alarm_mock, *args): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass alarm1 = alarms.Alarm(soco_mock) alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False, @@ -361,7 +331,7 @@ def test_sonos_snapshot(self, snapshotMock, *args): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass snapshotMock.return_value = True @@ -379,7 +349,7 @@ def test_sonos_restore(self, restoreMock, *args): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass restoreMock.return_value = True @@ -389,21 +359,3 @@ def test_sonos_restore(self, restoreMock, *args): device.restore() self.assertEqual(restoreMock.call_count, 1) self.assertEqual(restoreMock.call_args, mock.call(False)) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - def test_sonos_set_option(self, option_mock, *args): - """Ensuring soco methods called for sonos_set_option service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - option_mock.return_value = True - device._snapshot_coordinator = mock.MagicMock() - device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') - - device.update_option(night_sound=True, speech_enhance=True) - - self.assertEqual(option_mock.call_count, 1) From 594772aa9065c4cfbe63fb09a6cb08d11e608a89 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 4 Feb 2018 09:09:53 +0100 Subject: [PATCH 2/7] Ignore play_mode from events where it is missing --- homeassistant/components/media_player/sonos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 854ea773d81d3..fb3ae4b50b0c5 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -460,7 +460,7 @@ def process_avtransport_event(self, event): if new_status == 'TRANSITIONING': return - self._play_mode = variables.get('current_play_mode') + self._play_mode = variables.get('current_play_mode', self._play_mode) if self.soco.is_playing_tv: self._refresh_linein(SOURCE_TV) From 3da7d5754012b72dadaf39bb42617c2e53fa404c Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 4 Feb 2018 09:10:05 +0100 Subject: [PATCH 3/7] Remove unused preload helper --- homeassistant/components/media_player/__init__.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 91bcb4d8af059..a48ab0a308d6c 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass -from homeassistant.util.async import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) _RND = SystemRandom() @@ -877,12 +876,6 @@ def state_attributes(self): return state_attr - def preload_media_image_url(self, url): - """Preload and cache a media image for future use.""" - run_coroutine_threadsafe( - _async_fetch_image(self.hass, url), self.hass.loop - ).result() - @asyncio.coroutine def _async_fetch_image(hass, url): From 03ffc3d26f0cafe4a38515c54398bc42ef3fdf05 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 5 Feb 2018 22:27:53 +0100 Subject: [PATCH 4/7] Freeze SoCo version --- homeassistant/components/media_player/sonos.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index fb3ae4b50b0c5..aeba17ece95e8 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -28,7 +28,7 @@ REQUIREMENTS = [ 'https://github.com/SoCo/SoCo/archive/' - '9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.14a5'] + '9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.12-'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a62f407a3857d..13278918bf5f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -358,7 +358,7 @@ http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b89974819 httplib2==0.10.3 # homeassistant.components.media_player.sonos -https://github.com/SoCo/SoCo/archive/9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.14a5 +https://github.com/SoCo/SoCo/archive/9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.12- # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d3f0a68a80f7..47136d7f43f8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ holidays==0.9.3 home-assistant-frontend==20180130.0 # homeassistant.components.media_player.sonos -https://github.com/SoCo/SoCo/archive/9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.14a5 +https://github.com/SoCo/SoCo/archive/9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.12- # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 3ebf899ec1418c4899ec198fc2866f21b1934b8e Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 13 Feb 2018 23:33:47 +0100 Subject: [PATCH 5/7] Updates for entity registry --- homeassistant/components/media_player/sonos.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index aeba17ece95e8..0ca18cdd38246 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -485,7 +485,8 @@ def process_avtransport_event(self, event): # Also update slaves for entity in self.hass.data[DATA_SONOS].devices: - if entity.coordinator == self: + coordinator = entity.coordinator + if coordinator and coordinator.unique_id == self.unique_id: entity.schedule_update_ha_state() def process_rendering_event(self, event): @@ -518,12 +519,11 @@ def process_zonegrouptopology_event(self, event): coordinator_uid, *slave_uids = group.split(',') else: # Use SoCo cache for existing topology - grp = self.soco.group - coordinator = grp.coordinator - coordinator_uid = coordinator.uid - slave_uids = [p.uid for p in grp.members if p != coordinator] + coordinator_uid = self.soco.group.coordinator.uid + slave_uids = [p.uid for p in self.soco.group.members + if p.uid != coordinator_uid] - if self == _get_entity_from_soco_uid(self.hass, coordinator_uid): + if self.unique_id == coordinator_uid: self._coordinator = None self.schedule_update_ha_state() From c50495c31d2ee775688a576050f7c5f1a3e443d8 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 14 Feb 2018 21:16:29 +0100 Subject: [PATCH 6/7] Add codeowner --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index 6e088a84e5d85..e29cb66461bcb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -55,6 +55,7 @@ homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/monoprice.py @etsinko +homeassistant/components/media_player/sonos.py @amelchio homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/plant.py @ChristianKuehnel homeassistant/components/sensor/airvisual.py @bachya From be75123b9c451eec23cc5e206e83c58561847889 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 18 Feb 2018 15:58:00 +0100 Subject: [PATCH 7/7] Use real soco release --- homeassistant/components/media_player/sonos.py | 4 +--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 0ca18cdd38246..0fbd88ffc546f 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -26,9 +26,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = [ - 'https://github.com/SoCo/SoCo/archive/' - '9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.12-'] +REQUIREMENTS = ['SoCo==0.14'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 45818d9926c1a..200dc9b11bf1f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -44,6 +44,9 @@ PyXiaomiGateway==0.8.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.0 +# homeassistant.components.media_player.sonos +SoCo==0.14 + # homeassistant.components.sensor.travisci TravisPy==0.3.5 @@ -355,9 +358,6 @@ http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b89974819 # homeassistant.components.remember_the_milk httplib2==0.10.3 -# homeassistant.components.media_player.sonos -https://github.com/SoCo/SoCo/archive/9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.12- - # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b56951e9aa10..75dfb5b3bc6b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,6 +21,9 @@ asynctest>=0.11.1 # homeassistant.components.notify.html5 PyJWT==1.5.3 +# homeassistant.components.media_player.sonos +SoCo==0.14 + # homeassistant.components.device_tracker.automatic aioautomatic==0.6.4 @@ -71,9 +74,6 @@ holidays==0.9.3 # homeassistant.components.frontend home-assistant-frontend==20180211.0 -# homeassistant.components.media_player.sonos -https://github.com/SoCo/SoCo/archive/9f848e7a2c73aebb0f6d0b09008b17990f4ffabc.zip#SoCo==0.12- - # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb influxdb==5.0.0