diff --git a/.coveragerc b/.coveragerc index 074e35a073d0..dbae8437223f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -322,6 +322,7 @@ omit = homeassistant/components/media_extractor.py homeassistant/components/media_player/anthemav.py homeassistant/components/media_player/aquostv.py + homeassistant/components/media_player/bluesound.py homeassistant/components/media_player/braviatv.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/clementine.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 3a55f6559c65..5e1dc8d61664 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -60,6 +60,7 @@ 'openhome': ('media_player', 'openhome'), 'harmony': ('remote', 'harmony'), 'bose_soundtouch': ('media_player', 'soundtouch'), + 'bluesound': ('media_player', 'bluesound'), } CONF_IGNORE = 'ignore' diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py new file mode 100644 index 000000000000..c1b9bab69374 --- /dev/null +++ b/homeassistant/components/media_player/bluesound.py @@ -0,0 +1,809 @@ +""" +Bluesound. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.bluesound/ +""" +import logging +from datetime import timedelta +from asyncio.futures import CancelledError +import asyncio +import voluptuous as vol +from aiohttp.client_exceptions import ClientError +import aiohttp +import async_timeout +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.core import callback +from homeassistant.util import Throttle +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.util.dt as dt_util + +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, + SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_STEP) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + STATE_PLAYING, STATE_PAUSED, STATE_IDLE, CONF_HOSTS, + CONF_HOST, CONF_PORT, CONF_NAME) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['xmltodict==0.11.0'] + +STATE_OFFLINE = 'offline' +ATTR_MODEL = 'model' +ATTR_MODEL_NAME = 'model_name' +ATTR_BRAND = 'brand' + +DATA_BLUESOUND = 'bluesound' +DEFAULT_PORT = 11000 + +SYNC_STATUS_INTERVAL = timedelta(minutes=5) +UPDATE_CAPTURE_INTERVAL = timedelta(minutes=30) +UPDATE_SERVICES_INTERVAL = timedelta(minutes=30) +UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) +NODE_OFFLINE_CHECK_TIMEOUT = 180 +NODE_RETRY_INITIATION = timedelta(minutes=3) + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [{ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME): cv.string, + }]) +}) + + +def _add_player(hass, async_add_devices, host, port=None, name=None): + if host in [x.host for x in hass.data[DATA_BLUESOUND]]: + return + + @callback + def _init_player(event=None): + """Start polling.""" + hass.async_add_job(player.async_init()) + + @callback + def _start_polling(event=None): + """Start polling.""" + player.start_polling() + + @callback + def _stop_polling(): + """Stop polling.""" + player.stop_polling() + + @callback + def _add_player_cb(): + """Add player after first sync fetch.""" + async_add_devices([player]) + _LOGGER.info('Added Bluesound device with name: %s', player.name) + + if hass.is_running: + _start_polling() + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, + _start_polling + ) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + _stop_polling + ) + + player = BluesoundPlayer(hass, host, port, name, _add_player_cb) + hass.data[DATA_BLUESOUND].append(player) + + if hass.is_running: + _init_player() + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, + _init_player + ) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the Bluesound platforms.""" + if DATA_BLUESOUND not in hass.data: + hass.data[DATA_BLUESOUND] = [] + + if discovery_info: + _add_player(hass, async_add_devices, discovery_info.get('host'), + discovery_info.get('port', None)) + return + + hosts = config.get(CONF_HOSTS, None) + if hosts: + for host in hosts: + _add_player(hass, + async_add_devices, + host.get(CONF_HOST), + host.get(CONF_PORT, None), + host.get(CONF_NAME, None)) + + +class BluesoundPlayer(MediaPlayerDevice): + """Bluesound Player Object.""" + + def __init__(self, hass, host, port=None, name=None, init_callback=None): + """Initialize the media player.""" + self.host = host + self._hass = hass + self._port = port + self._polling_session = async_get_clientsession(hass) + self._polling_task = None # The actuall polling task. + self._name = name + self._brand = None + self._model = None + self._model_name = None + self._icon = None + self._capture_items = [] + self._services_items = [] + self._preset_items = [] + self._sync_status = {} + self._status = None + self._last_status_update = None + self._is_online = False + self._retry_remove = None + self._lastvol = None + self._init_callback = init_callback + if self._port is None: + self._port = DEFAULT_PORT + +# Internal methods + @staticmethod + def _try_get_index(string, seach_string): + try: + return string.index(seach_string) + except ValueError: + return -1 + + @asyncio.coroutine + def _internal_update_sync_status(self, on_updated_cb=None, + raise_timeout=False): + resp = None + try: + resp = yield from self.send_bluesound_command( + 'SyncStatus', + raise_timeout, raise_timeout) + except: + raise + + if not resp: + return None + self._sync_status = resp['SyncStatus'].copy() + + 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) + + if on_updated_cb: + on_updated_cb() + return True +# END Internal methods + +# Poll functionality + @asyncio.coroutine + def _start_poll_command(self): + """"Loop which polls the status of the player.""" + try: + while True: + yield from self.async_update_status() + + except (asyncio.TimeoutError, ClientError): + _LOGGER.info("Bluesound node %s is offline, retrying later", + self._name) + yield from asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT, + loop=self._hass.loop) + self.start_polling() + + except CancelledError: + _LOGGER.debug("Stopping bluesound polling of node %s", self._name) + except: + _LOGGER.exception("Unexpected error in %s", self._name) + raise + + def start_polling(self): + """Start the polling task.""" + self._polling_task = self._hass.async_add_job( + self._start_poll_command()) + + def stop_polling(self): + """Stop the polling task.""" + self._polling_task.cancel() +# END Poll functionality + +# Initiator + @asyncio.coroutine + def async_init(self): + """Initiate the player async.""" + try: + if self._retry_remove is not None: + self._retry_remove() + self._retry_remove = None + + yield from self._internal_update_sync_status(self._init_callback, + True) + except (asyncio.TimeoutError, ClientError): + _LOGGER.info("Bluesound node %s is offline, retrying later", + self.host) + self._retry_remove = async_track_time_interval( + self._hass, + self.async_init, + NODE_RETRY_INITIATION) + except: + _LOGGER.exception("Unexpected when initiating error in %s", + self.host) + raise +# END Initiator + +# Status updates fetchers + @asyncio.coroutine + def async_update(self): + """Update internal status of the entity.""" + if not self._is_online: + return + + yield from self.async_update_sync_status() + yield from self.async_update_presets() + yield from self.async_update_captures() + yield from self.async_update_services() + + @asyncio.coroutine + def send_bluesound_command(self, method, raise_timeout=False, + allow_offline=False): + """Send command to the player.""" + import xmltodict + + if not self._is_online and not allow_offline: + return + + if method[0] == '/': + method = method[1:] + url = "http://{}:{}/{}".format(self.host, self._port, method) + + _LOGGER.info("calling URL: %s", url) + response = None + try: + websession = async_get_clientsession(self._hass) + with async_timeout.timeout(10, loop=self._hass.loop): + response = yield from websession.get(url) + + if response.status == 200: + result = yield from response.text() + if len(result) < 1: + data = None + else: + data = xmltodict.parse(result) + else: + _LOGGER.error("Error %s on %s", response.status, url) + return None + + except (asyncio.TimeoutError, aiohttp.ClientError): + if raise_timeout: + _LOGGER.info("Timeout with Bluesound: %s", self.host) + raise + else: + _LOGGER.debug("Failed communicating with Bluesound: %s", + self.host) + return None + + return data + + @asyncio.coroutine + def async_update_status(self): + """Using the poll session to always get the status of the player.""" + import xmltodict + response = None + + url = 'Status' + etag = '' + if self._status is not None: + etag = self._status.get('@etag', '') + + if etag != '': + url = 'Status?etag='+etag+'&timeout=60.0' + url = "http://{}:{}/{}".format(self.host, self._port, url) + + _LOGGER.debug("calling URL: %s", url) + + try: + + with async_timeout.timeout(65, 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) + + 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() + + except (asyncio.TimeoutError, ClientError): + self._is_online = False + self._last_status_update = None + self._status = None + self.schedule_update_ha_state() + _LOGGER.info("Client connection error, marking %s as offline", + self._name) + raise + + @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(on_updated_cb, + raise_timeout=False) + + @asyncio.coroutine + @Throttle(UPDATE_CAPTURE_INTERVAL) + def async_update_captures(self): + """Update Capture cources.""" + resp = yield from self.send_bluesound_command( + 'RadioBrowse?service=Capture') + if not resp: + return + self._capture_items = [] + + def _create_capture_item(item): + self._capture_items.append({ + 'title': item.get('@text', ''), + 'name': item.get('@text', ''), + 'type': item.get('@serviceType', 'Capture'), + 'image': item.get('@image', ''), + 'url': item.get('@URL', '') + }) + + if 'radiotime' in resp and 'item' in resp['radiotime']: + if isinstance(resp['radiotime']['item'], list): + for item in resp['radiotime']['item']: + _create_capture_item(item) + else: + _create_capture_item(resp['radiotime']['item']) + + return self._capture_items + + @asyncio.coroutine + @Throttle(UPDATE_PRESETS_INTERVAL) + def async_update_presets(self): + """Update Presets.""" + resp = yield from self.send_bluesound_command('Presets') + if not resp: + return + self._preset_items = [] + + def _create_preset_item(item): + self._preset_items.append({ + 'title': item.get('@name', ''), + 'name': item.get('@name', ''), + 'type': 'preset', + 'image': item.get('@image', ''), + 'is_raw_url': True, + 'url2': item.get('@url', ''), + 'url': 'Preset?id=' + item.get('@id', '') + }) + + if 'presets' in resp and 'preset' in resp['presets']: + if isinstance(resp['presets']['preset'], list): + for item in resp['presets']['preset']: + _create_preset_item(item) + else: + _create_preset_item(resp['presets']['preset']) + + return self._preset_items + + @asyncio.coroutine + @Throttle(UPDATE_SERVICES_INTERVAL) + def async_update_services(self): + """Update Services.""" + resp = yield from self.send_bluesound_command('Services') + if not resp: + return + self._services_items = [] + + def _create_service_item(item): + self._services_items.append({ + 'title': item.get('@displayname', ''), + 'name': item.get('@name', ''), + 'type': item.get('@type', ''), + 'image': item.get('@icon', ''), + 'url': item.get('@name', '') + }) + + if 'services' in resp and 'service' in resp['services']: + if isinstance(resp['services']['service'], list): + for item in resp['services']['service']: + _create_service_item(item) + else: + _create_service_item(resp['services']['service']) + + return self._services_items +# END Status updates fetchers + +# Media player (and core) properties + @property + def should_poll(self): + """No need to poll information.""" + return True + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def state(self): + """Return the state of the device.""" + if self._status is None: + return STATE_OFFLINE + + status = self._status.get('state', None) + if status == 'pause' or status == 'stop': + return STATE_PAUSED + elif status == 'stream' or status == 'play': + return STATE_PLAYING + else: + return STATE_IDLE + + @property + def media_title(self): + """Title of current playing media.""" + if self._status is None: + return None + + return self._status.get('title1', None) + + @property + def media_artist(self): + """Artist of current playing media (Music track only).""" + if self._status is None: + return None + + artist = self._status.get('artist', None) + if not artist: + artist = self._status.get('title2', None) + return artist + + @property + def media_album_name(self): + """Artist of current playing media (Music track only).""" + if self._status is None: + return None + + album = self._status.get('album', None) + if not album: + album = self._status.get('title3', None) + return album + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self._status is None: + return None + + url = self._status.get('image', None) + if not url: + return + if url[0] == '/': + 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: + return None + + mediastate = self.state + if self._last_status_update is None or mediastate == STATE_IDLE: + return None + + position = self._status.get('secs', None) + if position is None: + return None + + position = float(position) + if mediastate == STATE_PLAYING: + position += (dt_util.utcnow() - + self._last_status_update).total_seconds() + + return position + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + if self._status is None: + return None + + duration = self._status.get('totlen', None) + if duration is None: + return None + return float(duration) + + @property + def media_position_updated_at(self): + """Last time status was updated.""" + return self._last_status_update + + @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 volume is not None: + return int(volume) / 100 + return None + + @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 + return volume < 0.001 and volume >= 0 + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def icon(self): + """Return the icon of the device.""" + return self._icon + + @property + def source_list(self): + """List of available input sources.""" + if self._status is None: + return None + + sources = [] + + for source in self._preset_items: + sources.append(source['title']) + + for source in [x for x in self._services_items + if x['type'] == 'LocalMusic' or + x['type'] == 'RadioService']: + sources.append(source['title']) + + for source in self._capture_items: + sources.append(source['title']) + + return sources + + @property + def source(self): + """Name of the current input source.""" + from urllib import parse + + if self._status is None: + return None + + current_service = self._status.get('service', '') + if current_service == '': + return '' + stream_url = self._status.get('streamUrl', '') + + if self._status.get('is_preset', '') == '1' and stream_url != '': + # this check doesn't work with all presets, for example playlists. + # But it works with radio service_items will catch playlists + items = [x for x in self._preset_items if 'url2' in x and + parse.unquote(x['url2']) == stream_url] + if len(items) > 0: + return items[0]['title'] + + # this could be a bit difficult to detect. Bluetooth could be named + # different things and there is not any way to match chooses in + # capture list to current playing. It's a bit of guesswork. + # This method will be needing some tweaking over time + title = self._status.get('title1', '').lower() + if title == 'bluetooth' or stream_url == 'Capture:hw:2,0/44100/16/2': + items = [x for x in self._capture_items + if x['url'] == "Capture%3Abluez%3Abluetooth"] + if len(items) > 0: + return items[0]['title'] + + items = [x for x in self._capture_items if x['url'] == stream_url] + if len(items) > 0: + return items[0]['title'] + + if stream_url[:8] == 'Capture:': + stream_url = stream_url[8:] + + idx = BluesoundPlayer._try_get_index(stream_url, ':') + if idx > 0: + stream_url = stream_url[:idx] + for item in self._capture_items: + url = parse.unquote(item['url']) + if url[:8] == 'Capture:': + url = url[8:] + idx = BluesoundPlayer._try_get_index(url, ':') + if idx > 0: + url = url[:idx] + if url.lower() == stream_url.lower(): + return item['title'] + + items = [x for x in self._capture_items + if x['name'] == current_service] + if len(items) > 0: + return items[0]['title'] + + items = [x for x in self._services_items + if x['name'] == current_service] + if len(items) > 0: + return items[0]['title'] + + if self._status.get('streamUrl', '') != '': + _LOGGER.debug("Couldn't find source of stream url: %s", + self._status.get('streamUrl', '')) + return None + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + if self._status is None: + return None + + 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 + + current_vol = self.volume_level + if current_vol is not None and current_vol >= 0: + supported = supported | SUPPORT_VOLUME_STEP | \ + SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + + if self._status.get('canSeek', '') == '1': + supported = supported | SUPPORT_SEEK + + 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, + } +# END Media player (and core) properties + +# Media player commands + @asyncio.coroutine + def async_select_source(self, source): + """Select input source.""" + items = [x for x in self._preset_items if x['title'] == source] + + if len(items) < 1: + items = [x for x in self._services_items if x['title'] == source] + if len(items) < 1: + items = [x for x in self._capture_items if x['title'] == source] + + if len(items) < 1: + return + + selected_source = items[0] + url = 'Play?url={}&preset_id&image={}'.format(selected_source['url'], + selected_source['image']) + + if 'is_raw_url' in selected_source and selected_source['is_raw_url']: + url = selected_source['url'] + + return self.send_bluesound_command(url) + + @asyncio.coroutine + def async_clear_playlist(self): + """Clear players playlist.""" + return self.send_bluesound_command('Clear') + + @asyncio.coroutine + def async_media_next_track(self): + """Send media_next command to media player.""" + cmd = 'Skip' + if self._status and 'actions' in self._status: + for action in self._status['actions']['action']: + if ('@name' in action and '@url' in action and + action['@name'] == 'skip'): + cmd = action['@url'] + + return self.send_bluesound_command(cmd) + + @asyncio.coroutine + def async_media_previous_track(self): + """Send media_previous command to media player.""" + cmd = 'Back' + if self._status and 'actions' in self._status: + for action in self._status['actions']['action']: + if ('@name' in action and '@url' in action and + action['@name'] == 'back'): + cmd = action['@url'] + + return self.send_bluesound_command(cmd) + + @asyncio.coroutine + def async_media_play(self): + """Send media_play command to media player.""" + return self.send_bluesound_command('Play') + + @asyncio.coroutine + def async_media_pause(self): + """Send media_pause command to media player.""" + return self.send_bluesound_command('Pause') + + @asyncio.coroutine + def async_media_stop(self): + """Send stop command.""" + return self.send_bluesound_command('Pause') + + @asyncio.coroutine + def async_media_seek(self, position): + """Send media_seek command to media player.""" + return self.send_bluesound_command('Play?seek=' + str(float(position))) + + @asyncio.coroutine + def async_volume_up(self): + """Volume up the media player.""" + current_vol = self.volume_level + if not current_vol or current_vol < 0: + return + return self.async_set_volume_level(((current_vol*100)+1)/100) + + @asyncio.coroutine + def async_volume_down(self): + """Volume down the media player.""" + current_vol = self.volume_level + if not current_vol or current_vol < 0: + return + return self.async_set_volume_level(((current_vol*100)-1)/100) + + @asyncio.coroutine + def async_set_volume_level(self, volume): + """Send volume_up command to media player.""" + if volume < 0: + volume = 0 + elif volume > 1: + volume = 1 + return self.send_bluesound_command( + 'Volume?level=' + str(float(volume) * 100)) + + @asyncio.coroutine + def async_mute_volume(self, mute): + """Send mute command to media player.""" + if mute: + volume = self.volume_level + if volume > 0: + self._lastvol = volume + return self.send_bluesound_command('Volume?level=0') + else: + return self.send_bluesound_command( + 'Volume?level=' + str(float(self._lastvol) * 100)) +# END Media player commands diff --git a/requirements_all.txt b/requirements_all.txt index fc06a89fab3b..8b43ce7dfd97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -968,6 +968,7 @@ xbee-helper==0.0.7 # homeassistant.components.sensor.xbox_live xboxapi==0.1.1 +# homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 # homeassistant.components.sensor.yr