From c085f06df523314d5f772de4fe56028ed835731d Mon Sep 17 00:00:00 2001 From: abmantis Date: Sun, 30 Apr 2017 20:41:21 +0100 Subject: [PATCH] Add support for shuffle toggling on Spotify component. (#7339) * add support for shuffle toggling on Spotify component. this also required adding support for shuffle on the media_player component. * lint * Use ATTR_MEDIA_SHUFFLING for service handler param * Line too long fix * fix tests * add shuffle set to demo mediaplayer * rename shuffle attribute --- .../components/media_player/__init__.py | 48 ++++++++++++++++++- homeassistant/components/media_player/demo.py | 21 ++++++-- .../components/media_player/services.yaml | 11 +++++ .../components/media_player/spotify.py | 18 +++++-- .../components/media_player/universal.py | 28 +++++++++-- homeassistant/const.py | 1 + .../components/media_player/test_universal.py | 27 ++++++++++- 7 files changed, 139 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 511cb8208a5..bd29fe24aec 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -31,7 +31,8 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET, SERVICE_VOLUME_MUTE, SERVICE_TOGGLE, SERVICE_MEDIA_STOP, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK) + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, + SERVICE_SHUFFLE_SET) _LOGGER = logging.getLogger(__name__) _RND = SystemRandom() @@ -81,6 +82,7 @@ ATTR_APP_NAME = 'app_name' ATTR_INPUT_SOURCE = 'source' ATTR_INPUT_SOURCE_LIST = 'source_list' ATTR_MEDIA_ENQUEUE = 'enqueue' +ATTR_MEDIA_SHUFFLE = 'shuffle' MEDIA_TYPE_MUSIC = 'music' MEDIA_TYPE_TVSHOW = 'tvshow' @@ -104,6 +106,7 @@ SUPPORT_SELECT_SOURCE = 2048 SUPPORT_STOP = 4096 SUPPORT_CLEAR_PLAYLIST = 8192 SUPPORT_PLAY = 16384 +SUPPORT_SHUFFLE_SET = 32768 # Service call validation schemas MEDIA_PLAYER_SCHEMA = vol.Schema({ @@ -133,6 +136,10 @@ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ vol.Optional(ATTR_MEDIA_ENQUEUE): cv.boolean, }) +MEDIA_PLAYER_SET_SHUFFLE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean, +}) + SERVICE_TO_METHOD = { SERVICE_TURN_ON: {'method': 'async_turn_on'}, SERVICE_TURN_OFF: {'method': 'async_turn_off'}, @@ -161,6 +168,9 @@ SERVICE_TO_METHOD = { SERVICE_PLAY_MEDIA: { 'method': 'async_play_media', 'schema': MEDIA_PLAYER_PLAY_MEDIA_SCHEMA}, + SERVICE_SHUFFLE_SET: { + 'method': 'async_set_shuffle', + 'schema': MEDIA_PLAYER_SET_SHUFFLE_SCHEMA}, } ATTR_TO_PROPERTY = [ @@ -185,6 +195,7 @@ ATTR_TO_PROPERTY = [ ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_SHUFFLE, ] @@ -322,6 +333,16 @@ def clear_playlist(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_CLEAR_PLAYLIST, data) +def set_shuffle(hass, shuffle, entity_id=None): + """Send the media player the command to enable/disable shuffle mode.""" + data = {ATTR_MEDIA_SHUFFLE: shuffle} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SHUFFLE_SET, data) + + @asyncio.coroutine def async_setup(hass, config): """Track states and offer events for media_players.""" @@ -358,6 +379,9 @@ def async_setup(hass, config): params['media_id'] = service.data.get(ATTR_MEDIA_CONTENT_ID) params[ATTR_MEDIA_ENQUEUE] = \ service.data.get(ATTR_MEDIA_ENQUEUE) + elif service.service == SERVICE_SHUFFLE_SET: + params[ATTR_MEDIA_SHUFFLE] = \ + service.data.get(ATTR_MEDIA_SHUFFLE) target_players = component.async_extract_from_service(service) update_tasks = [] @@ -539,6 +563,11 @@ class MediaPlayerDevice(Entity): """List of available input sources.""" return None + @property + def shuffle(self): + """Boolean if shuffle is enabled.""" + return None + @property @deprecated_substitute('supported_media_commands') def supported_features(self): @@ -701,6 +730,18 @@ class MediaPlayerDevice(Entity): return self.hass.loop.run_in_executor( None, self.clear_playlist) + def set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + raise NotImplementedError() + + def async_set_shuffle(self, shuffle): + """Enable/disable shuffle mode. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.loop.run_in_executor( + None, self.set_shuffle, shuffle) + # No need to overwrite these. @property def support_play(self): @@ -757,6 +798,11 @@ class MediaPlayerDevice(Entity): """Boolean if clear playlist command supported.""" return bool(self.supported_features & SUPPORT_CLEAR_PLAYLIST) + @property + def support_shuffle_set(self): + """Boolean if shuffle is supported.""" + return bool(self.supported_features & SUPPORT_SHUFFLE_SET) + def async_toggle(self): """Toggle the power on the media player. diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 90253895882..0319ccf98b3 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -9,7 +9,7 @@ from homeassistant.components.media_player import ( SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_PLAY, - MediaPlayerDevice) + SUPPORT_SHUFFLE_SET, MediaPlayerDevice) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING import homeassistant.util.dt as dt_util @@ -31,15 +31,17 @@ YOUTUBE_COVER_URL_FORMAT = 'https://img.youtube.com/vi/{}/hqdefault.jpg' YOUTUBE_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | \ + SUPPORT_SHUFFLE_SET MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | SUPPORT_PLAY + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \ + SUPPORT_PLAY | SUPPORT_SHUFFLE_SET NETFLIX_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET class AbstractDemoPlayer(MediaPlayerDevice): @@ -53,6 +55,7 @@ class AbstractDemoPlayer(MediaPlayerDevice): self._player_state = STATE_PLAYING self._volume_level = 1.0 self._volume_muted = False + self._shuffle = False @property def should_poll(self): @@ -79,6 +82,11 @@ class AbstractDemoPlayer(MediaPlayerDevice): """Return boolean if volume is currently muted.""" return self._volume_muted + @property + def shuffle(self): + """Boolean if shuffling is enabled.""" + return self._shuffle + def turn_on(self): """Turn the media player on.""" self._player_state = STATE_PLAYING @@ -109,6 +117,11 @@ class AbstractDemoPlayer(MediaPlayerDevice): self._player_state = STATE_PAUSED self.schedule_update_ha_state() + def set_shuffle(self, shuffle): + """Enable/disable shuffle mode.""" + self._shuffle = shuffle + self.schedule_update_ha_state() + class DemoYoutubePlayer(AbstractDemoPlayer): """A Demo media player that only supports YouTube.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index e23616a47a9..ae90e141289 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -154,6 +154,17 @@ clear_playlist: description: Name(s) of entites to change source on example: 'media_player.living_room_chromecast' +shuffle_set: + description: Set shuffling state + + fields: + entity_id: + description: Name(s) of entities to set + example: 'media_player.spotify' + shuffle: + description: True/false for enabling/disabling shuffle + example: true + sonos_join: description: Group player together. diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index ce4230bf92c..fcb54245ead 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -16,8 +16,8 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_VOLUME_SET, SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_NEXT_TRACK, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, PLATFORM_SCHEMA, - MediaPlayerDevice) + SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, + PLATFORM_SCHEMA, MediaPlayerDevice) from homeassistant.const import ( CONF_NAME, STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv @@ -33,7 +33,7 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_SPOTIFY = SUPPORT_VOLUME_SET | SUPPORT_PAUSE | SUPPORT_PLAY |\ SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_SELECT_SOURCE |\ - SUPPORT_PLAY_MEDIA + SUPPORT_PLAY_MEDIA | SUPPORT_SHUFFLE_SET SCOPE = 'user-read-playback-state user-modify-playback-state' DEFAULT_CACHE_PATH = '.spotify-token-cache' @@ -132,6 +132,7 @@ class SpotifyMediaPlayer(MediaPlayerDevice): self._current_device = None self._devices = None self._volume = None + self._shuffle = False self._player = None self._token_info = self._oauth.get_cached_token() @@ -185,11 +186,17 @@ class SpotifyMediaPlayer(MediaPlayerDevice): self._volume = device.get('volume_percent') / 100 if device.get('name'): self._current_device = device.get('name') + if device.get('shuffle_state'): + self._shuffle = device.get('shuffle_state') def set_volume_level(self, volume): """Set the volume level.""" self._player.volume(int(volume * 100)) + def set_shuffle(self, shuffle): + """Enable/Disable shuffle mode.""" + self._player.shuffle(shuffle) + def media_next_track(self): """Skip to next track.""" self._player.next_track() @@ -245,6 +252,11 @@ class SpotifyMediaPlayer(MediaPlayerDevice): """Device volume.""" return self._volume + @property + def shuffle(self): + """Shuffling state.""" + return self._shuffle + @property def source_list(self): """Playback devices.""" diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 5dfe007976f..ba671f25f38 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -17,19 +17,19 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION, ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_POSITION_UPDATED_AT, DOMAIN, SERVICE_PLAY_MEDIA, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, - ATTR_INPUT_SOURCE, SERVICE_SELECT_SOURCE, SERVICE_CLEAR_PLAYLIST, - MediaPlayerDevice) + SUPPORT_SHUFFLE_SET, ATTR_INPUT_SOURCE, SERVICE_SELECT_SOURCE, + SERVICE_CLEAR_PLAYLIST, MediaPlayerDevice) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, CONF_NAME, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, - SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_IDLE, STATE_OFF, STATE_ON, - SERVICE_MEDIA_STOP, ATTR_SUPPORTED_FEATURES) + SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, SERVICE_SHUFFLE_SET, STATE_IDLE, + STATE_OFF, STATE_ON, SERVICE_MEDIA_STOP, ATTR_SUPPORTED_FEATURES) from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.service import async_call_from_config @@ -356,6 +356,11 @@ class UniversalMediaPlayer(MediaPlayerDevice): """List of available input sources.""" return self._override_or_child_attr(ATTR_INPUT_SOURCE_LIST) + @property + def shuffle(self): + """Boolean if shuffling is enabled.""" + return self._override_or_child_attr(ATTR_MEDIA_SHUFFLE) + @property def supported_features(self): """Flag media player features that are supported.""" @@ -383,6 +388,10 @@ class UniversalMediaPlayer(MediaPlayerDevice): if SERVICE_CLEAR_PLAYLIST in self._cmds: flags |= SUPPORT_CLEAR_PLAYLIST + if SERVICE_SHUFFLE_SET in self._cmds and \ + ATTR_MEDIA_SHUFFLE in self._attrs: + flags |= SUPPORT_SHUFFLE_SET + return flags @property @@ -524,6 +533,15 @@ class UniversalMediaPlayer(MediaPlayerDevice): """ return self._async_call_service(SERVICE_CLEAR_PLAYLIST) + def async_set_shuffle(self, shuffle): + """Enable/disable shuffling. + + This method must be run in the event loop and returns a coroutine. + """ + data = {ATTR_MEDIA_SHUFFLE: shuffle} + return self._async_call_service( + SERVICE_SHUFFLE_SET, data, allow_override=True) + @asyncio.coroutine def async_update(self): """Update state in HA.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index c6aa14417f5..5ab322fab6c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -324,6 +324,7 @@ SERVICE_MEDIA_STOP = 'media_stop' SERVICE_MEDIA_NEXT_TRACK = 'media_next_track' SERVICE_MEDIA_PREVIOUS_TRACK = 'media_previous_track' SERVICE_MEDIA_SEEK = 'media_seek' +SERVICE_SHUFFLE_SET = 'shuffle_set' SERVICE_ALARM_DISARM = 'alarm_disarm' SERVICE_ALARM_ARM_HOME = 'alarm_arm_home' diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py index 62be4aca267..d2cc874a541 100644 --- a/tests/components/media_player/test_universal.py +++ b/tests/components/media_player/test_universal.py @@ -30,6 +30,7 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): self._source = None self._tracks = 12 self._media_image_url = None + self._shuffle = False self.service_calls = { 'turn_on': mock_service( @@ -67,6 +68,9 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): 'clear_playlist': mock_service( hass, media_player.DOMAIN, media_player.SERVICE_CLEAR_PLAYLIST), + 'shuffle_set': mock_service( + hass, media_player.DOMAIN, + media_player.SERVICE_SHUFFLE_SET), } @property @@ -99,6 +103,11 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): """Image url of current playing media.""" return self._media_image_url + @property + def shuffle(self): + """Return true if the media player is shuffling.""" + return self._shuffle + def turn_on(self): """Mock turn_on function.""" self._state = STATE_UNKNOWN @@ -131,6 +140,10 @@ class MockMediaPlayer(media_player.MediaPlayerDevice): """Clear players playlist.""" self._tracks = 0 + def set_shuffle(self, shuffle): + """Clear players playlist.""" + self._shuffle = shuffle + class TestMediaPlayer(unittest.TestCase): """Test the media_player module.""" @@ -164,6 +177,9 @@ class TestMediaPlayer(unittest.TestCase): self.mock_source_id = input_select.ENTITY_ID_FORMAT.format('source') self.hass.states.set(self.mock_source_id, 'dvd') + self.mock_shuffle_switch_id = switch.ENTITY_ID_FORMAT.format('shuffle') + self.hass.states.set(self.mock_shuffle_switch_id, STATE_OFF) + self.config_children_only = { 'name': 'test', 'platform': 'universal', 'children': [media_player.ENTITY_ID_FORMAT.format('mock1'), @@ -178,7 +194,8 @@ class TestMediaPlayer(unittest.TestCase): 'volume_level': self.mock_volume_id, 'source': self.mock_source_id, 'source_list': self.mock_source_list_id, - 'state': self.mock_state_switch_id + 'state': self.mock_state_switch_id, + 'shuffle': self.mock_shuffle_switch_id } } @@ -541,6 +558,7 @@ class TestMediaPlayer(unittest.TestCase): config['commands']['volume_mute'] = 'test' config['commands']['volume_set'] = 'test' config['commands']['select_source'] = 'test' + config['commands']['shuffle_set'] = 'test' ump = universal.UniversalMediaPlayer(self.hass, **config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name']) @@ -553,7 +571,7 @@ class TestMediaPlayer(unittest.TestCase): check_flags = universal.SUPPORT_TURN_ON | universal.SUPPORT_TURN_OFF \ | universal.SUPPORT_VOLUME_STEP | universal.SUPPORT_VOLUME_MUTE \ - | universal.SUPPORT_SELECT_SOURCE + | universal.SUPPORT_SELECT_SOURCE | universal.SUPPORT_SHUFFLE_SET self.assertEqual(check_flags, ump.supported_features) @@ -674,6 +692,11 @@ class TestMediaPlayer(unittest.TestCase): self.assertEqual( 1, len(self.mock_mp_2.service_calls['clear_playlist'])) + run_coroutine_threadsafe( + ump.async_set_shuffle(True), + self.hass.loop).result() + self.assertEqual(1, len(self.mock_mp_2.service_calls['shuffle_set'])) + def test_service_call_to_command(self): """Test service call to command.""" config = self.config_children_only