diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index d7664d68ce5..4bd3e43794f 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -16,14 +16,16 @@ import async_timeout import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, SUPPORT_CLEAR_PLAYLIST, - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, + ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_PAUSED, STATE_PLAYING) + ATTR_ENTITY_ID, CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, + STATE_OFF, STATE_PAUSED, STATE_PLAYING) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -35,10 +37,14 @@ REQUIREMENTS = ['xmltodict==0.11.0'] _LOGGER = logging.getLogger(__name__) -STATE_OFFLINE = 'offline' -ATTR_MODEL = 'model' -ATTR_MODEL_NAME = 'model_name' -ATTR_BRAND = 'brand' +STATE_GROUPED = 'grouped' + +ATTR_MASTER = 'master' + +SERVICE_JOIN = 'bluesound_join' +SERVICE_UNJOIN = 'bluesound_unjoin' +SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' +SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' DATA_BLUESOUND = 'bluesound' DEFAULT_PORT = 11000 @@ -58,6 +64,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }]) }) +BS_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +BS_JOIN_SCHEMA = BS_SCHEMA.extend({ + vol.Required(ATTR_MASTER): cv.entity_id, +}) + +SERVICE_TO_METHOD = { + SERVICE_JOIN: { + 'method': 'async_join', + 'schema': BS_JOIN_SCHEMA}, + SERVICE_UNJOIN: { + 'method': 'async_unjoin', + 'schema': BS_SCHEMA}, + SERVICE_SET_TIMER: { + 'method': 'async_increase_timer', + 'schema': BS_SCHEMA}, + SERVICE_CLEAR_TIMER: { + 'method': 'async_clear_timer', + 'schema': BS_SCHEMA} +} + def _add_player(hass, async_add_devices, host, port=None, name=None): """Add Bluesound players.""" @@ -120,6 +149,30 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass, async_add_devices, host.get(CONF_HOST), host.get(CONF_PORT), host.get(CONF_NAME)) + @asyncio.coroutine + def async_service_handler(service): + """Map services to method of Bluesound devices.""" + method = SERVICE_TO_METHOD.get(service.service) + if not method: + return + + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_players = [player for player in hass.data[DATA_BLUESOUND] + if player.entity_id in entity_ids] + else: + target_players = hass.data[DATA_BLUESOUND] + + for player in target_players: + yield from getattr(player, method['method'])(**params) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service]['schema'] + hass.services.async_register( + DOMAIN, service, async_service_handler, schema=schema) + class BluesoundPlayer(MediaPlayerDevice): """Representation of a Bluesound Player.""" @@ -128,13 +181,10 @@ class BluesoundPlayer(MediaPlayerDevice): """Initialize the media player.""" self.host = host self._hass = hass - self._port = port + self.port = port self._polling_session = async_get_clientsession(hass) self._polling_task = None # The actual polling task. self._name = name - self._brand = None - self._model = None - self._model_name = None self._icon = None self._capture_items = [] self._services_items = [] @@ -145,9 +195,13 @@ class BluesoundPlayer(MediaPlayerDevice): self._is_online = False self._retry_remove = None self._lastvol = None + self._master = None + self._is_master = False + self._group_name = None + self._init_callback = init_callback - if self._port is None: - self._port = DEFAULT_PORT + if self.port is None: + self.port = DEFAULT_PORT @staticmethod def _try_get_index(string, search_string): @@ -158,7 +212,7 @@ class BluesoundPlayer(MediaPlayerDevice): return -1 @asyncio.coroutine - def _internal_update_sync_status( + def force_update_sync_status( self, on_updated_cb=None, raise_timeout=False): """Update the internal status.""" resp = None @@ -174,14 +228,27 @@ class BluesoundPlayer(MediaPlayerDevice): if not self._name: self._name = self._sync_status.get('@name', self.host) - if not self._brand: - self._brand = self._sync_status.get('@brand', self.host) - if not self._model: - self._model = self._sync_status.get('@model', self.host) if not self._icon: self._icon = self._sync_status.get('@icon', self.host) - if not self._model_name: - self._model_name = self._sync_status.get('@modelName', self.host) + + master = self._sync_status.get('master', None) + if master is not None: + self._is_master = False + master_host = master.get('#text') + master_device = [device for device in + self._hass.data[DATA_BLUESOUND] + if device.host == master_host] + + if master_device and master_host != self.host: + self._master = master_device[0] + else: + self._master = None + _LOGGER.error("Master not found %s", master_host) + else: + if self._master is not None: + self._master = None + slaves = self._sync_status.get('slave', None) + self._is_master = slaves is not None if on_updated_cb: on_updated_cb() @@ -223,7 +290,7 @@ class BluesoundPlayer(MediaPlayerDevice): self._retry_remove() self._retry_remove = None - yield from self._internal_update_sync_status( + yield from self.force_update_sync_status( self._init_callback, True) except (asyncio.TimeoutError, ClientError): _LOGGER.info("Node %s is offline, retrying later", self.host) @@ -256,7 +323,7 @@ class BluesoundPlayer(MediaPlayerDevice): if method[0] == '/': method = method[1:] - url = "http://{}:{}/{}".format(self.host, self._port, method) + url = "http://{}:{}/{}".format(self.host, self.port, method) _LOGGER.debug("Calling URL: %s", url) response = None @@ -297,26 +364,47 @@ class BluesoundPlayer(MediaPlayerDevice): etag = self._status.get('@etag', '') if etag != '': - url = 'Status?etag={}&timeout=60.0'.format(etag) - url = "http://{}:{}/{}".format(self.host, self._port, url) + url = 'Status?etag={}&timeout=120.0'.format(etag) + url = "http://{}:{}/{}".format(self.host, self.port, url) _LOGGER.debug("Calling URL: %s", url) try: - with async_timeout.timeout(65, loop=self._hass.loop): + with async_timeout.timeout(125, loop=self._hass.loop): response = yield from self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE}) if response.status != 200: - _LOGGER.error("Error %s on %s", response.status, url) + _LOGGER.error("Error %s on %s. Trying one more time.", + response.status, url) + else: + result = yield from response.text() + self._is_online = True + self._last_status_update = dt_util.utcnow() + self._status = xmltodict.parse(result)['status'].copy() - result = yield from response.text() - self._is_online = True - self._last_status_update = dt_util.utcnow() - self._status = xmltodict.parse(result)['status'].copy() - self.schedule_update_ha_state() + group_name = self._status.get('groupName', None) + if group_name != self._group_name: + _LOGGER.debug('Group name change detected on device: %s', + self.host) + self._group_name = group_name + # the sleep is needed to make sure that the + # devices is synced + yield from asyncio.sleep(1, loop=self._hass.loop) + yield from self.async_trigger_sync_on_all() + elif self.is_grouped: + # when player is grouped we need to fetch volume from + # sync_status. We will force an update if the player is + # grouped this isn't a foolproof solution. A better + # solution would be to fetch sync_status more often when + # the device is playing. This would solve alot of + # problems. This change will be done when the + # communication is moved to a separate library + yield from self.force_update_sync_status() + + self.schedule_update_ha_state() except (asyncio.TimeoutError, ClientError): self._is_online = False @@ -327,12 +415,20 @@ class BluesoundPlayer(MediaPlayerDevice): self._name) raise + @asyncio.coroutine + def async_trigger_sync_on_all(self): + """Trigger sync status update on all devices.""" + _LOGGER.debug("Trigger sync status on all devices") + + for player in self._hass.data[DATA_BLUESOUND]: + yield from player.force_update_sync_status() + @asyncio.coroutine @Throttle(SYNC_STATUS_INTERVAL) def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False): """Update sync status.""" - yield from self._internal_update_sync_status( + yield from self.force_update_sync_status( on_updated_cb, raise_timeout=False) @asyncio.coroutine @@ -433,7 +529,10 @@ class BluesoundPlayer(MediaPlayerDevice): def state(self): """Return the state of the device.""" if self._status is None: - return STATE_OFFLINE + return STATE_OFF + + if self.is_grouped and not self.is_master: + return STATE_GROUPED status = self._status.get('state', None) if status == 'pause' or status == 'stop': @@ -445,7 +544,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_title(self): """Title of current playing media.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None return self._status.get('title1', None) @@ -456,6 +556,9 @@ class BluesoundPlayer(MediaPlayerDevice): if self._status is None: return None + if self.is_grouped and not self.is_master: + return self._group_name + artist = self._status.get('artist', None) if not artist: artist = self._status.get('title2', None) @@ -464,7 +567,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_album_name(self): """Artist of current playing media (Music track only).""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None album = self._status.get('album', None) @@ -475,21 +579,23 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_image_url(self): """Image url of current playing media.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None url = self._status.get('image', None) if not url: return if url[0] == '/': - url = "http://{}:{}{}".format(self.host, self._port, url) + url = "http://{}:{}{}".format(self.host, self.port, url) return url @property def media_position(self): """Position of current playing media in seconds.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None mediastate = self.state @@ -510,7 +616,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None duration = self._status.get('totlen', None) @@ -526,10 +633,10 @@ class BluesoundPlayer(MediaPlayerDevice): @property def volume_level(self): """Volume level of the media player (0..1).""" - if self._status is None: - return None - volume = self._status.get('volume', None) + if self.is_grouped: + volume = self._sync_status.get('@volume', None) + if volume is not None: return int(volume) / 100 return None @@ -537,9 +644,6 @@ class BluesoundPlayer(MediaPlayerDevice): @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - if not self._status: - return None - volume = self.volume_level if not volume: return None @@ -558,7 +662,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def source_list(self): """List of available input sources.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None sources = [] @@ -581,7 +686,8 @@ class BluesoundPlayer(MediaPlayerDevice): """Name of the current input source.""" from urllib import parse - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None current_service = self._status.get('service', '') @@ -649,12 +755,17 @@ class BluesoundPlayer(MediaPlayerDevice): if self._status is None: return None + if self.is_grouped and not self.is_master: + return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | \ + SUPPORT_VOLUME_MUTE + supported = SUPPORT_CLEAR_PLAYLIST if self._status.get('indexing', '0') == '0': supported = supported | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | \ - SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE + SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ + SUPPORT_SHUFFLE_SET current_vol = self.volume_level if current_vol is not None and current_vol >= 0: @@ -667,17 +778,87 @@ class BluesoundPlayer(MediaPlayerDevice): return supported @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_MODEL: self._model, - ATTR_MODEL_NAME: self._model_name, - ATTR_BRAND: self._brand, - } + def is_master(self): + """Return true if player is a coordinator.""" + return self._is_master + + @property + def is_grouped(self): + """Return true if player is a coordinator.""" + return self._master is not None or self._is_master + + @property + def shuffle(self): + """Return true if shuffle is active.""" + return True if self._status.get('shuffle', '0') == '1' else False + + @asyncio.coroutine + def async_join(self, master): + """Join the player to a group.""" + master_device = [device for device in self.hass.data[DATA_BLUESOUND] + if device.entity_id == master] + + if master_device: + _LOGGER.debug("Trying to join player: %s to master: %s", + self.host, master_device[0].host) + + yield from master_device[0].async_add_slave(self) + else: + _LOGGER.error("Master not found %s", master_device) + + @asyncio.coroutine + def async_unjoin(self): + """Unjoin the player from a group.""" + if self._master is None: + return + + _LOGGER.debug("Trying to unjoin player: %s", self.host) + yield from self._master.async_remove_slave(self) + + @asyncio.coroutine + def async_add_slave(self, slave_device): + """Add slave to master.""" + return self.send_bluesound_command('/AddSlave?slave={}&port={}' + .format(slave_device.host, + slave_device.port)) + + @asyncio.coroutine + def async_remove_slave(self, slave_device): + """Remove slave to master.""" + return self.send_bluesound_command('/RemoveSlave?slave={}&port={}' + .format(slave_device.host, + slave_device.port)) + + @asyncio.coroutine + def async_increase_timer(self): + """Increase sleep time on player.""" + sleep_time = yield from self.send_bluesound_command('/Sleep') + if sleep_time is None: + _LOGGER.error('Error while increasing sleep time on player: %s', + self.host) + return 0 + + return int(sleep_time.get('sleep', '0')) + + @asyncio.coroutine + def async_clear_timer(self): + """Clear sleep timer on player.""" + sleep = 1 + while sleep > 0: + sleep = yield from self.async_increase_timer() + + @asyncio.coroutine + def async_set_shuffle(self, shuffle): + """Enable or disable shuffle mode.""" + return self.send_bluesound_command('/Shuffle?state={}' + .format('1' if shuffle else '0')) @asyncio.coroutine def async_select_source(self, source): """Select input source.""" + if self.is_grouped and not self.is_master: + return + items = [x for x in self._preset_items if x['title'] == source] if len(items) < 1: @@ -700,11 +881,17 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_clear_playlist(self): """Clear players playlist.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Clear') @asyncio.coroutine def async_media_next_track(self): """Send media_next command to media player.""" + if self.is_grouped and not self.is_master: + return + cmd = 'Skip' if self._status and 'actions' in self._status: for action in self._status['actions']['action']: @@ -717,6 +904,9 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_media_previous_track(self): """Send media_previous command to media player.""" + if self.is_grouped and not self.is_master: + return + cmd = 'Back' if self._status and 'actions' in self._status: for action in self._status['actions']['action']: @@ -729,23 +919,52 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_media_play(self): """Send media_play command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Play') @asyncio.coroutine def async_media_pause(self): """Send media_pause command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Pause') @asyncio.coroutine def async_media_stop(self): """Send stop command.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Pause') @asyncio.coroutine def async_media_seek(self, position): """Send media_seek command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Play?seek=' + str(float(position))) + @asyncio.coroutine + def async_play_media(self, media_type, media_id, **kwargs): + """ + Send the play_media command to the media player. + + If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. + """ + if self.is_grouped and not self.is_master: + return + + url = 'Play?url={}'.format(media_id) + + if kwargs.get(ATTR_MEDIA_ENQUEUE): + return self.send_bluesound_command(url) + + return self.send_bluesound_command(url) + @asyncio.coroutine def async_volume_up(self): """Volume up the media player.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 3e5ee57cb2f..7ac250b1d30 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -323,7 +323,6 @@ squeezebox_call_method: yamaha_enable_output: description: Enable or disable an output port - fields: entity_id: description: Name(s) of entites to enable/disable port on. @@ -334,3 +333,34 @@ yamaha_enable_output: enabled: description: Boolean indicating if port should be enabled or not. example: true + +bluesound_join: + description: Group player together. + fields: + master: + description: Entity ID of the player that should become the master of the group. + example: 'media_player.bluesound_livingroom' + entity_id: + description: Name(s) of entities that will coordinate the grouping. Platform dependent. + example: 'media_player.bluesound_livingroom' + +bluesound_unjoin: + description: Unjoin the player from a group. + fields: + entity_id: + description: Name(s) of entities that will be unjoined from their group. Platform dependent. + example: 'media_player.bluesound_livingroom' + +bluesound_set_sleep_timer: + description: "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0" + fields: + entity_id: + description: Name(s) of entities that will have a timer set. + example: 'media_player.bluesound_livingroom' + +bluesound_clear_sleep_timer: + description: Clear a Bluesound timer. + fields: + entity_id: + description: Name(s) of entities that will have the timer cleared. + example: 'media_player.bluesound_livingroom'