2015-08-04 15:22:56 +00:00
|
|
|
"""
|
2016-03-08 09:34:33 +00:00
|
|
|
Support for interfacing to the Logitech SqueezeBox API.
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2015-10-23 16:10:32 +00:00
|
|
|
For more details about this platform, please refer to the documentation at
|
2015-11-09 12:12:18 +00:00
|
|
|
https://home-assistant.io/components/media_player.squeezebox/
|
2015-08-04 15:22:56 +00:00
|
|
|
"""
|
|
|
|
import logging
|
2017-01-08 13:32:15 +00:00
|
|
|
import asyncio
|
2015-08-04 15:22:56 +00:00
|
|
|
import urllib.parse
|
2017-01-08 13:32:15 +00:00
|
|
|
import json
|
|
|
|
import aiohttp
|
|
|
|
import async_timeout
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2016-09-05 17:27:06 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
2015-08-04 15:22:56 +00:00
|
|
|
from homeassistant.components.media_player import (
|
2016-09-23 07:05:33 +00:00
|
|
|
ATTR_MEDIA_ENQUEUE, SUPPORT_PLAY_MEDIA,
|
2016-09-05 17:27:06 +00:00
|
|
|
MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, PLATFORM_SCHEMA,
|
2016-02-19 05:27:50 +00:00
|
|
|
SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
|
2017-01-09 00:09:30 +00:00
|
|
|
SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_PLAY, MediaPlayerDevice)
|
2015-08-04 15:22:56 +00:00
|
|
|
from homeassistant.const import (
|
2016-02-19 05:27:50 +00:00
|
|
|
CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_IDLE, STATE_OFF,
|
2016-09-05 17:27:06 +00:00
|
|
|
STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT)
|
|
|
|
import homeassistant.helpers.config_validation as cv
|
2017-01-08 13:32:15 +00:00
|
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
2015-08-04 15:22:56 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
DEFAULT_PORT = 9000
|
|
|
|
TIMEOUT = 10
|
2016-09-05 17:27:06 +00:00
|
|
|
|
2016-01-13 05:53:27 +00:00
|
|
|
SUPPORT_SQUEEZEBOX = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \
|
|
|
|
SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
|
2017-01-09 00:09:30 +00:00
|
|
|
SUPPORT_SEEK | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \
|
|
|
|
SUPPORT_PLAY
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2016-09-05 17:27:06 +00:00
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
|
|
|
vol.Required(CONF_HOST): cv.string,
|
|
|
|
vol.Optional(CONF_PASSWORD): cv.string,
|
|
|
|
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
|
|
|
vol.Optional(CONF_USERNAME): cv.string,
|
|
|
|
})
|
2016-03-17 13:38:56 +00:00
|
|
|
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
@asyncio.coroutine
|
|
|
|
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Setup the squeezebox platform."""
|
2016-10-22 05:37:35 +00:00
|
|
|
import socket
|
|
|
|
|
2016-09-05 17:27:06 +00:00
|
|
|
username = config.get(CONF_USERNAME)
|
|
|
|
password = config.get(CONF_PASSWORD)
|
|
|
|
|
2016-03-17 13:38:56 +00:00
|
|
|
if discovery_info is not None:
|
2017-03-11 18:39:26 +00:00
|
|
|
host = discovery_info.get("host")
|
|
|
|
port = discovery_info.get("port")
|
2016-03-17 13:38:56 +00:00
|
|
|
else:
|
|
|
|
host = config.get(CONF_HOST)
|
2016-09-05 17:27:06 +00:00
|
|
|
port = config.get(CONF_PORT)
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
# In case the port is not discovered
|
|
|
|
if port is None:
|
|
|
|
port = DEFAULT_PORT
|
|
|
|
|
2016-10-22 05:37:35 +00:00
|
|
|
# Get IP of host, to prevent duplication of same host (different DNS names)
|
|
|
|
try:
|
|
|
|
ipaddr = socket.gethostbyname(host)
|
|
|
|
except (OSError) as error:
|
|
|
|
_LOGGER.error("Could not communicate with %s:%d: %s",
|
|
|
|
host, port, error)
|
|
|
|
return False
|
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
_LOGGER.debug("Creating LMS object for %s", ipaddr)
|
|
|
|
lms = LogitechMediaServer(hass, host, port, username, password)
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
players = yield from lms.create_players()
|
2017-03-01 04:33:19 +00:00
|
|
|
async_add_devices(players)
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2015-08-05 11:49:45 +00:00
|
|
|
return True
|
2015-08-04 15:22:56 +00:00
|
|
|
|
|
|
|
|
2015-08-05 11:49:45 +00:00
|
|
|
class LogitechMediaServer(object):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Representation of a Logitech media server."""
|
2015-08-05 18:09:20 +00:00
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
def __init__(self, hass, host, port, username, password):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Initialize the Logitech device."""
|
2017-01-08 13:32:15 +00:00
|
|
|
self.hass = hass
|
2015-08-05 11:49:45 +00:00
|
|
|
self.host = host
|
|
|
|
self.port = port
|
|
|
|
self._username = username
|
|
|
|
self._password = password
|
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
@asyncio.coroutine
|
2015-08-05 11:49:45 +00:00
|
|
|
def create_players(self):
|
2017-01-08 13:32:15 +00:00
|
|
|
"""Create a list of devices connected to LMS."""
|
|
|
|
result = []
|
|
|
|
data = yield from self.async_query('players', 'status')
|
|
|
|
|
|
|
|
for players in data['players_loop']:
|
|
|
|
player = SqueezeBoxDevice(
|
|
|
|
self, players['playerid'], players['name'])
|
|
|
|
yield from player.async_update()
|
|
|
|
result.append(player)
|
|
|
|
return result
|
|
|
|
|
|
|
|
@asyncio.coroutine
|
|
|
|
def async_query(self, *command, player=""):
|
|
|
|
"""Abstract out the JSON-RPC connection."""
|
|
|
|
auth = None if self._username is None else aiohttp.BasicAuth(
|
|
|
|
self._username, self._password)
|
|
|
|
url = "http://{}:{}/jsonrpc.js".format(
|
|
|
|
self.host, self.port)
|
|
|
|
data = json.dumps({
|
|
|
|
"id": "1",
|
|
|
|
"method": "slim.request",
|
|
|
|
"params": [player, command]
|
|
|
|
})
|
|
|
|
|
|
|
|
_LOGGER.debug("URL: %s Data: %s", url, data)
|
2016-10-22 05:37:35 +00:00
|
|
|
|
2016-10-08 00:16:35 +00:00
|
|
|
try:
|
2017-01-08 13:32:15 +00:00
|
|
|
websession = async_get_clientsession(self.hass)
|
|
|
|
with async_timeout.timeout(TIMEOUT, loop=self.hass.loop):
|
|
|
|
response = yield from websession.post(
|
|
|
|
url,
|
|
|
|
data=data,
|
|
|
|
auth=auth)
|
|
|
|
|
2017-03-30 07:50:53 +00:00
|
|
|
if response.status != 200:
|
2017-01-08 13:32:15 +00:00
|
|
|
_LOGGER.error(
|
|
|
|
"Query failed, response code: %s Full message: %s",
|
|
|
|
response.status, response)
|
|
|
|
return False
|
|
|
|
|
2017-03-30 07:50:53 +00:00
|
|
|
data = yield from response.json()
|
|
|
|
|
|
|
|
except (asyncio.TimeoutError, aiohttp.ClientError) as error:
|
2017-01-08 13:32:15 +00:00
|
|
|
_LOGGER.error("Failed communicating with LMS: %s", type(error))
|
|
|
|
return False
|
2016-10-22 05:37:35 +00:00
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
try:
|
|
|
|
return data['result']
|
|
|
|
except AttributeError:
|
|
|
|
_LOGGER.error("Received invalid response: %s", data)
|
|
|
|
return False
|
2015-08-05 11:49:45 +00:00
|
|
|
|
|
|
|
|
|
|
|
class SqueezeBoxDevice(MediaPlayerDevice):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Representation of a SqueezeBox device."""
|
2015-08-05 11:49:45 +00:00
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
def __init__(self, lms, player_id, name):
|
2016-12-28 07:12:10 +00:00
|
|
|
"""Initialize the SqueezeBox device."""
|
2015-08-05 11:49:45 +00:00
|
|
|
super(SqueezeBoxDevice, self).__init__()
|
|
|
|
self._lms = lms
|
|
|
|
self._id = player_id
|
2017-01-08 13:32:15 +00:00
|
|
|
self._status = {}
|
|
|
|
self._name = name
|
|
|
|
_LOGGER.debug("Creating SqueezeBox object: %s, %s", name, player_id)
|
2015-08-05 11:49:45 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def name(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Return the name of the device."""
|
2015-08-05 11:49:45 +00:00
|
|
|
return self._name
|
|
|
|
|
2017-04-18 22:20:52 +00:00
|
|
|
@property
|
|
|
|
def unique_id(self):
|
|
|
|
"""Return an unique ID."""
|
|
|
|
return self._id
|
|
|
|
|
2015-08-05 11:49:45 +00:00
|
|
|
@property
|
|
|
|
def state(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Return the state of the device."""
|
2017-01-26 23:30:42 +00:00
|
|
|
if 'power' in self._status and self._status['power'] == 0:
|
2015-08-05 11:49:45 +00:00
|
|
|
return STATE_OFF
|
|
|
|
if 'mode' in self._status:
|
|
|
|
if self._status['mode'] == 'pause':
|
|
|
|
return STATE_PAUSED
|
|
|
|
if self._status['mode'] == 'play':
|
|
|
|
return STATE_PLAYING
|
|
|
|
if self._status['mode'] == 'stop':
|
|
|
|
return STATE_IDLE
|
|
|
|
return STATE_UNKNOWN
|
|
|
|
|
2017-01-08 13:32:15 +00:00
|
|
|
def async_query(self, *parameters):
|
|
|
|
"""Send a command to the LMS.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self._lms.async_query(
|
|
|
|
*parameters, player=self._id)
|
|
|
|
|
|
|
|
@asyncio.coroutine
|
|
|
|
def async_update(self):
|
|
|
|
"""Retrieve the current state of the player."""
|
|
|
|
tags = 'adKl'
|
|
|
|
response = yield from self.async_query(
|
|
|
|
"status", "-", "1", "tags:{tags}"
|
|
|
|
.format(tags=tags))
|
|
|
|
|
2017-01-26 23:30:42 +00:00
|
|
|
if response is False:
|
|
|
|
return
|
|
|
|
|
|
|
|
self._status = response.copy()
|
|
|
|
|
|
|
|
try:
|
|
|
|
self._status.update(response["playlist_loop"][0])
|
|
|
|
except KeyError:
|
|
|
|
pass
|
2017-01-08 13:32:15 +00:00
|
|
|
try:
|
|
|
|
self._status.update(response["remoteMeta"])
|
|
|
|
except KeyError:
|
|
|
|
pass
|
2015-08-04 15:22:56 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def volume_level(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Volume level of the media player (0..1)."""
|
2015-08-04 17:35:53 +00:00
|
|
|
if 'mixer volume' in self._status:
|
2015-11-11 23:21:42 +00:00
|
|
|
return int(float(self._status['mixer volume'])) / 100.0
|
2015-08-04 15:22:56 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def is_volume_muted(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Return true if volume is muted."""
|
2015-08-04 17:35:53 +00:00
|
|
|
if 'mixer volume' in self._status:
|
2017-01-08 13:32:15 +00:00
|
|
|
return str(self._status['mixer volume']).startswith('-')
|
2015-08-04 15:22:56 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def media_content_id(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Content ID of current playing media."""
|
2015-08-04 17:35:53 +00:00
|
|
|
if 'current_title' in self._status:
|
2015-08-04 15:22:56 +00:00
|
|
|
return self._status['current_title']
|
|
|
|
|
|
|
|
@property
|
|
|
|
def media_content_type(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Content type of current playing media."""
|
2015-08-04 15:22:56 +00:00
|
|
|
return MEDIA_TYPE_MUSIC
|
|
|
|
|
|
|
|
@property
|
|
|
|
def media_duration(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Duration of current playing media in seconds."""
|
2015-08-04 17:35:53 +00:00
|
|
|
if 'duration' in self._status:
|
2015-08-04 15:22:56 +00:00
|
|
|
return int(float(self._status['duration']))
|
|
|
|
|
|
|
|
@property
|
|
|
|
def media_image_url(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Image url of current playing media."""
|
2015-08-04 17:35:53 +00:00
|
|
|
if 'artwork_url' in self._status:
|
2016-01-20 09:57:39 +00:00
|
|
|
media_url = self._status['artwork_url']
|
2016-01-23 17:08:54 +00:00
|
|
|
elif 'id' in self._status:
|
2016-01-20 15:31:51 +00:00
|
|
|
media_url = ('/music/{track_id}/cover.jpg').format(
|
2016-01-23 17:14:03 +00:00
|
|
|
track_id=self._status['id'])
|
2016-01-23 16:48:14 +00:00
|
|
|
else:
|
|
|
|
media_url = ('/music/current/cover.jpg?player={player}').format(
|
2016-01-23 17:55:43 +00:00
|
|
|
player=self._id)
|
2016-01-20 15:31:51 +00:00
|
|
|
|
2016-10-22 05:37:35 +00:00
|
|
|
# pylint: disable=protected-access
|
|
|
|
if self._lms._username:
|
|
|
|
base_url = 'http://{username}:{password}@{server}:{port}/'.format(
|
|
|
|
username=self._lms._username,
|
|
|
|
password=self._lms._password,
|
|
|
|
server=self._lms.host,
|
2017-01-08 13:32:15 +00:00
|
|
|
port=self._lms.port)
|
2016-10-22 05:37:35 +00:00
|
|
|
else:
|
|
|
|
base_url = 'http://{server}:{port}/'.format(
|
|
|
|
server=self._lms.host,
|
2017-01-08 13:32:15 +00:00
|
|
|
port=self._lms.port)
|
2016-10-22 05:37:35 +00:00
|
|
|
|
|
|
|
url = urllib.parse.urljoin(base_url, media_url)
|
2016-01-20 09:57:39 +00:00
|
|
|
|
2016-10-22 05:37:35 +00:00
|
|
|
return url
|
2015-08-04 15:22:56 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def media_title(self):
|
2016-03-08 09:34:33 +00:00
|
|
|
"""Title of current playing media."""
|
2016-10-13 16:07:10 +00:00
|
|
|
if 'title' in self._status:
|
|
|
|
return self._status['title']
|
|
|
|
|
2015-08-04 17:35:53 +00:00
|
|
|
if 'current_title' in self._status:
|
2015-08-04 15:22:56 +00:00
|
|
|
return self._status['current_title']
|
|
|
|
|
2016-10-13 16:07:10 +00:00
|
|
|
@property
|
|
|
|
def media_artist(self):
|
|
|
|
"""Artist of current playing media."""
|
|
|
|
if 'artist' in self._status:
|
|
|
|
return self._status['artist']
|
|
|
|
|
|
|
|
@property
|
|
|
|
def media_album_name(self):
|
|
|
|
"""Album of current playing media."""
|
|
|
|
if 'album' in self._status:
|
2017-01-08 13:32:15 +00:00
|
|
|
return self._status['album']
|
2016-10-13 16:07:10 +00:00
|
|
|
|
2015-08-04 15:22:56 +00:00
|
|
|
@property
|
2017-02-08 04:42:45 +00:00
|
|
|
def supported_features(self):
|
|
|
|
"""Flag media player features that are supported."""
|
2015-08-04 15:22:56 +00:00
|
|
|
return SUPPORT_SQUEEZEBOX
|
|
|
|
|
2017-02-02 05:45:19 +00:00
|
|
|
def async_turn_off(self):
|
|
|
|
"""Turn off media player.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('power', '0')
|
|
|
|
|
|
|
|
def async_volume_up(self):
|
|
|
|
"""Volume up media player.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('mixer', 'volume', '+5')
|
|
|
|
|
|
|
|
def async_volume_down(self):
|
|
|
|
"""Volume down media player.
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2017-02-02 05:45:19 +00:00
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('mixer', 'volume', '-5')
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2017-02-02 05:45:19 +00:00
|
|
|
def async_set_volume_level(self, volume):
|
|
|
|
"""Set volume level, range 0..1.
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2017-02-02 05:45:19 +00:00
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
2015-08-04 15:22:56 +00:00
|
|
|
volume_percent = str(int(volume*100))
|
2017-02-02 05:45:19 +00:00
|
|
|
return self.async_query('mixer', 'volume', volume_percent)
|
|
|
|
|
|
|
|
def async_mute_volume(self, mute):
|
|
|
|
"""Mute (true) or unmute (false) media player.
|
2015-08-04 15:22:56 +00:00
|
|
|
|
2017-02-02 05:45:19 +00:00
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
2015-08-04 15:22:56 +00:00
|
|
|
mute_numeric = '1' if mute else '0'
|
2017-02-02 05:45:19 +00:00
|
|
|
return self.async_query('mixer', 'muting', mute_numeric)
|
|
|
|
|
|
|
|
def async_media_play_pause(self):
|
|
|
|
"""Send pause command to media player.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('pause')
|
|
|
|
|
|
|
|
def async_media_play(self):
|
|
|
|
"""Send play command to media player.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('play')
|
|
|
|
|
|
|
|
def async_media_pause(self):
|
|
|
|
"""Send pause command to media player.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('pause', '1')
|
|
|
|
|
|
|
|
def async_media_next_track(self):
|
|
|
|
"""Send next track command.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('playlist', 'index', '+1')
|
|
|
|
|
|
|
|
def async_media_previous_track(self):
|
|
|
|
"""Send next track command.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('playlist', 'index', '-1')
|
|
|
|
|
|
|
|
def async_media_seek(self, position):
|
|
|
|
"""Send seek command.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('time', position)
|
|
|
|
|
|
|
|
def async_turn_on(self):
|
|
|
|
"""Turn the media player on.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
return self.async_query('power', '1')
|
|
|
|
|
|
|
|
def async_play_media(self, media_type, media_id, **kwargs):
|
2016-09-23 07:05:33 +00:00
|
|
|
"""
|
|
|
|
Send the play_media command to the media player.
|
|
|
|
|
|
|
|
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the current playlist.
|
2017-02-02 05:45:19 +00:00
|
|
|
This method must be run in the event loop and returns a coroutine.
|
2016-09-23 07:05:33 +00:00
|
|
|
"""
|
|
|
|
if kwargs.get(ATTR_MEDIA_ENQUEUE):
|
2017-02-02 05:45:19 +00:00
|
|
|
return self._add_uri_to_playlist(media_id)
|
|
|
|
|
|
|
|
return self._play_uri(media_id)
|
2016-09-23 07:05:33 +00:00
|
|
|
|
|
|
|
def _play_uri(self, media_id):
|
2017-01-08 13:32:15 +00:00
|
|
|
"""Replace the current play list with the uri."""
|
2017-02-02 05:45:19 +00:00
|
|
|
return self.async_query('playlist', 'play', media_id)
|
2016-09-23 07:05:33 +00:00
|
|
|
|
|
|
|
def _add_uri_to_playlist(self, media_id):
|
2017-01-08 13:32:15 +00:00
|
|
|
"""Add a items to the existing playlist."""
|
2017-02-02 05:45:19 +00:00
|
|
|
return self.async_query('playlist', 'add', media_id)
|