More features for the Bluesound component (#11450)

* Added support for join and unjoin

* Added support for sleep functionality

* Fixed supported features

* Removed long lines and fixed documentation strings

* Fixed D401, imperative mood

* Added shuffle support

* Removed unnecessary log row

* Removed model, modelname and brand

* Removed descriptions

* Removed polling command on method run. This change is not needed

* Fixed merge errors

* Removed unused usings

* Pylint fixes

* Hound fixes

* Remove attr Sleep and removed white space in services.xml
This commit is contained in:
thrawnarn 2018-02-18 23:59:26 +01:00 committed by Paulus Schoutsen
parent 72fa170265
commit 1143499301
2 changed files with 307 additions and 58 deletions

View File

@ -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."""

View File

@ -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'