Squeezebox JSON-RPC (#5084)
* Refactor of Squeezebox connection code * Refactor of Squeezebox connection code * Typos * Make Python 3.4 friendly * Addressing comments * Improving docstring * Using discovered port * Style better * Accept new disco object * Revert "Accept new disco object" * Make it obvious that port isn't discovered yet * Flake8. ;)
This commit is contained in:
parent
469aad5fc8
commit
fd50201407
1 changed files with 120 additions and 150 deletions
|
@ -5,8 +5,11 @@ For more details about this platform, please refer to the documentation at
|
||||||
https://home-assistant.io/components/media_player.squeezebox/
|
https://home-assistant.io/components/media_player.squeezebox/
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import telnetlib
|
import asyncio
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import json
|
||||||
|
import aiohttp
|
||||||
|
import async_timeout
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
@ -19,10 +22,12 @@ from homeassistant.const import (
|
||||||
CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_IDLE, STATE_OFF,
|
CONF_HOST, CONF_PASSWORD, CONF_USERNAME, STATE_IDLE, STATE_OFF,
|
||||||
STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT)
|
STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, CONF_PORT)
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_PORT = 9090
|
DEFAULT_PORT = 9000
|
||||||
|
TIMEOUT = 10
|
||||||
|
|
||||||
KNOWN_DEVICES = []
|
KNOWN_DEVICES = []
|
||||||
|
|
||||||
|
@ -38,7 +43,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
@asyncio.coroutine
|
||||||
|
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||||
"""Setup the squeezebox platform."""
|
"""Setup the squeezebox platform."""
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
|
@ -47,11 +53,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||||
|
|
||||||
if discovery_info is not None:
|
if discovery_info is not None:
|
||||||
host = discovery_info[0]
|
host = discovery_info[0]
|
||||||
port = DEFAULT_PORT
|
port = None # Port is not collected in netdisco 0.8.1
|
||||||
else:
|
else:
|
||||||
host = config.get(CONF_HOST)
|
host = config.get(CONF_HOST)
|
||||||
port = config.get(CONF_PORT)
|
port = config.get(CONF_PORT)
|
||||||
|
|
||||||
|
# In case the port is not discovered
|
||||||
|
if port is None:
|
||||||
|
port = DEFAULT_PORT
|
||||||
|
|
||||||
# Get IP of host, to prevent duplication of same host (different DNS names)
|
# Get IP of host, to prevent duplication of same host (different DNS names)
|
||||||
try:
|
try:
|
||||||
ipaddr = socket.gethostbyname(host)
|
ipaddr = socket.gethostbyname(host)
|
||||||
|
@ -68,13 +78,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||||
return False
|
return False
|
||||||
KNOWN_DEVICES.append(key)
|
KNOWN_DEVICES.append(key)
|
||||||
|
|
||||||
_LOGGER.debug("Creating LMS object for %s", key)
|
_LOGGER.debug("Creating LMS object for %s", ipaddr)
|
||||||
lms = LogitechMediaServer(host, port, username, password)
|
lms = LogitechMediaServer(hass, host, port, username, password)
|
||||||
|
if lms is False:
|
||||||
if not lms.init_success:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
add_devices(lms.create_players())
|
players = yield from lms.create_players()
|
||||||
|
yield from async_add_devices(players)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -82,107 +92,86 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||||
class LogitechMediaServer(object):
|
class LogitechMediaServer(object):
|
||||||
"""Representation of a Logitech media server."""
|
"""Representation of a Logitech media server."""
|
||||||
|
|
||||||
def __init__(self, host, port, username, password):
|
def __init__(self, hass, host, port, username, password):
|
||||||
"""Initialize the Logitech device."""
|
"""Initialize the Logitech device."""
|
||||||
|
self.hass = hass
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self._username = username
|
self._username = username
|
||||||
self._password = password
|
self._password = password
|
||||||
self.http_port = self._get_http_port()
|
|
||||||
self.init_success = True if self.http_port else False
|
|
||||||
|
|
||||||
def _get_http_port(self):
|
|
||||||
"""Get http port from media server, it is used to get cover art."""
|
|
||||||
http_port = self.query('pref', 'httpport', '?')
|
|
||||||
if not http_port:
|
|
||||||
_LOGGER.error("Failed to connect to server %s:%s",
|
|
||||||
self.host, self.port)
|
|
||||||
return http_port
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
def create_players(self):
|
def create_players(self):
|
||||||
"""Create a list of SqueezeBoxDevices connected to the LMS."""
|
"""Create a list of devices connected to LMS."""
|
||||||
players = []
|
result = []
|
||||||
count = self.query('player', 'count', '?')
|
data = yield from self.async_query('players', 'status')
|
||||||
for index in range(0, int(count)):
|
|
||||||
player_id = self.query('player', 'id', str(index), '?')
|
|
||||||
player = SqueezeBoxDevice(self, player_id)
|
|
||||||
players.append(player)
|
|
||||||
return players
|
|
||||||
|
|
||||||
def query(self, *parameters):
|
for players in data['players_loop']:
|
||||||
"""Send request and await response from server."""
|
player = SqueezeBoxDevice(
|
||||||
response = self.get(' '.join(parameters))
|
self, players['playerid'], players['name'])
|
||||||
response = response.split(' ')[-1].strip()
|
yield from player.async_update()
|
||||||
response = urllib.parse.unquote(response)
|
result.append(player)
|
||||||
|
return result
|
||||||
|
|
||||||
return response
|
@asyncio.coroutine
|
||||||
|
def async_query(self, *command, player=""):
|
||||||
|
"""Abstract out the JSON-RPC connection."""
|
||||||
|
response = None
|
||||||
|
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]
|
||||||
|
})
|
||||||
|
|
||||||
def get_player_status(self, player):
|
_LOGGER.debug("URL: %s Data: %s", url, data)
|
||||||
"""Get the status of a player."""
|
|
||||||
# (title) : Song title
|
|
||||||
# Requested Information
|
|
||||||
# a (artist): Artist name 'artist'
|
|
||||||
# d (duration): Song duration in seconds 'duration'
|
|
||||||
# K (artwork_url): URL to remote artwork
|
|
||||||
# l (album): Album, including the server's "(N of M)"
|
|
||||||
tags = 'adKl'
|
|
||||||
new_status = {}
|
|
||||||
response = self.get('{player} status - 1 tags:{tags}\n'
|
|
||||||
.format(player=player, tags=tags))
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
response = response.split(' ')
|
|
||||||
|
|
||||||
for item in response:
|
|
||||||
parts = urllib.parse.unquote(item).partition(':')
|
|
||||||
new_status[parts[0]] = parts[2]
|
|
||||||
return new_status
|
|
||||||
|
|
||||||
def get(self, command):
|
|
||||||
"""Abstract out the telnet connection."""
|
|
||||||
try:
|
try:
|
||||||
telnet = telnetlib.Telnet(self.host, self.port)
|
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)
|
||||||
|
|
||||||
if self._username and self._password:
|
if response.status == 200:
|
||||||
_LOGGER.debug("Logging in")
|
data = yield from response.json()
|
||||||
|
else:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Query failed, response code: %s Full message: %s",
|
||||||
|
response.status, response)
|
||||||
|
return False
|
||||||
|
|
||||||
telnet.write('login {username} {password}\n'.format(
|
except (asyncio.TimeoutError,
|
||||||
username=self._username,
|
aiohttp.errors.ClientError,
|
||||||
password=self._password).encode('UTF-8'))
|
aiohttp.errors.ClientDisconnectedError) as error:
|
||||||
telnet.read_until(b'\n', timeout=3)
|
_LOGGER.error("Failed communicating with LMS: %s", type(error))
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if response is not None:
|
||||||
|
yield from response.release()
|
||||||
|
|
||||||
_LOGGER.debug("About to send message: %s", command)
|
try:
|
||||||
message = '{}\n'.format(command)
|
return data['result']
|
||||||
telnet.write(message.encode('UTF-8'))
|
except AttributeError:
|
||||||
|
_LOGGER.error("Received invalid response: %s", data)
|
||||||
response = telnet.read_until(b'\n', timeout=3)\
|
return False
|
||||||
.decode('UTF-8')\
|
|
||||||
|
|
||||||
telnet.write(b'exit\n')
|
|
||||||
_LOGGER.debug("Response: %s", response)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
except (OSError, EOFError) as error:
|
|
||||||
_LOGGER.error("Could not communicate with %s:%d: %s",
|
|
||||||
self.host,
|
|
||||||
self.port,
|
|
||||||
error)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class SqueezeBoxDevice(MediaPlayerDevice):
|
class SqueezeBoxDevice(MediaPlayerDevice):
|
||||||
"""Representation of a SqueezeBox device."""
|
"""Representation of a SqueezeBox device."""
|
||||||
|
|
||||||
def __init__(self, lms, player_id):
|
def __init__(self, lms, player_id, name):
|
||||||
"""Initialize the SqueezeBox device."""
|
"""Initialize the SqueezeBox device."""
|
||||||
super(SqueezeBoxDevice, self).__init__()
|
super(SqueezeBoxDevice, self).__init__()
|
||||||
self._lms = lms
|
self._lms = lms
|
||||||
self._id = player_id
|
self._id = player_id
|
||||||
self._name = self._lms.query(self._id, 'name', '?')
|
self._status = {}
|
||||||
self._status = self._lms.get_player_status(self._id)
|
self._name = name
|
||||||
|
_LOGGER.debug("Creating SqueezeBox object: %s, %s", name, player_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
|
@ -203,9 +192,31 @@ class SqueezeBoxDevice(MediaPlayerDevice):
|
||||||
return STATE_IDLE
|
return STATE_IDLE
|
||||||
return STATE_UNKNOWN
|
return STATE_UNKNOWN
|
||||||
|
|
||||||
def update(self):
|
def async_query(self, *parameters):
|
||||||
"""Retrieve latest state."""
|
"""Send a command to the LMS.
|
||||||
self._status = self._lms.get_player_status(self._id)
|
|
||||||
|
This method must be run in the event loop and returns a coroutine.
|
||||||
|
"""
|
||||||
|
return self._lms.async_query(
|
||||||
|
*parameters, player=self._id)
|
||||||
|
|
||||||
|
def query(self, *parameters):
|
||||||
|
"""Queue up a command to send the LMS."""
|
||||||
|
self.hass.loop.create_task(self.async_query(*parameters))
|
||||||
|
|
||||||
|
@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))
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._status = response.copy()
|
||||||
|
self._status.update(response["remoteMeta"])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def volume_level(self):
|
def volume_level(self):
|
||||||
|
@ -217,7 +228,7 @@ class SqueezeBoxDevice(MediaPlayerDevice):
|
||||||
def is_volume_muted(self):
|
def is_volume_muted(self):
|
||||||
"""Return true if volume is muted."""
|
"""Return true if volume is muted."""
|
||||||
if 'mixer volume' in self._status:
|
if 'mixer volume' in self._status:
|
||||||
return self._status['mixer volume'].startswith('-')
|
return str(self._status['mixer volume']).startswith('-')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_content_id(self):
|
def media_content_id(self):
|
||||||
|
@ -254,15 +265,14 @@ class SqueezeBoxDevice(MediaPlayerDevice):
|
||||||
username=self._lms._username,
|
username=self._lms._username,
|
||||||
password=self._lms._password,
|
password=self._lms._password,
|
||||||
server=self._lms.host,
|
server=self._lms.host,
|
||||||
port=self._lms.http_port)
|
port=self._lms.port)
|
||||||
else:
|
else:
|
||||||
base_url = 'http://{server}:{port}/'.format(
|
base_url = 'http://{server}:{port}/'.format(
|
||||||
server=self._lms.host,
|
server=self._lms.host,
|
||||||
port=self._lms.http_port)
|
port=self._lms.port)
|
||||||
|
|
||||||
url = urllib.parse.urljoin(base_url, media_url)
|
url = urllib.parse.urljoin(base_url, media_url)
|
||||||
|
|
||||||
_LOGGER.debug("Media image url: %s", url)
|
|
||||||
return url
|
return url
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -284,7 +294,7 @@ class SqueezeBoxDevice(MediaPlayerDevice):
|
||||||
def media_album_name(self):
|
def media_album_name(self):
|
||||||
"""Album of current playing media."""
|
"""Album of current playing media."""
|
||||||
if 'album' in self._status:
|
if 'album' in self._status:
|
||||||
return self._status['album'].rstrip()
|
return self._status['album']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_media_commands(self):
|
def supported_media_commands(self):
|
||||||
|
@ -293,64 +303,64 @@ class SqueezeBoxDevice(MediaPlayerDevice):
|
||||||
|
|
||||||
def turn_off(self):
|
def turn_off(self):
|
||||||
"""Turn off media player."""
|
"""Turn off media player."""
|
||||||
self._lms.query(self._id, 'power', '0')
|
self.query('power', '0')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def volume_up(self):
|
def volume_up(self):
|
||||||
"""Volume up media player."""
|
"""Volume up media player."""
|
||||||
self._lms.query(self._id, 'mixer', 'volume', '+5')
|
self.query('mixer', 'volume', '+5')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def volume_down(self):
|
def volume_down(self):
|
||||||
"""Volume down media player."""
|
"""Volume down media player."""
|
||||||
self._lms.query(self._id, 'mixer', 'volume', '-5')
|
self.query('mixer', 'volume', '-5')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def set_volume_level(self, volume):
|
def set_volume_level(self, volume):
|
||||||
"""Set volume level, range 0..1."""
|
"""Set volume level, range 0..1."""
|
||||||
volume_percent = str(int(volume*100))
|
volume_percent = str(int(volume*100))
|
||||||
self._lms.query(self._id, 'mixer', 'volume', volume_percent)
|
self.query('mixer', 'volume', volume_percent)
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def mute_volume(self, mute):
|
def mute_volume(self, mute):
|
||||||
"""Mute (true) or unmute (false) media player."""
|
"""Mute (true) or unmute (false) media player."""
|
||||||
mute_numeric = '1' if mute else '0'
|
mute_numeric = '1' if mute else '0'
|
||||||
self._lms.query(self._id, 'mixer', 'muting', mute_numeric)
|
self.query('mixer', 'muting', mute_numeric)
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def media_play_pause(self):
|
def media_play_pause(self):
|
||||||
"""Send pause command to media player."""
|
"""Send pause command to media player."""
|
||||||
self._lms.query(self._id, 'pause')
|
self.query('pause')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def media_play(self):
|
def media_play(self):
|
||||||
"""Send play command to media player."""
|
"""Send play command to media player."""
|
||||||
self._lms.query(self._id, 'play')
|
self.query('play')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def media_pause(self):
|
def media_pause(self):
|
||||||
"""Send pause command to media player."""
|
"""Send pause command to media player."""
|
||||||
self._lms.query(self._id, 'pause', '1')
|
self.query('pause', '1')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def media_next_track(self):
|
def media_next_track(self):
|
||||||
"""Send next track command."""
|
"""Send next track command."""
|
||||||
self._lms.query(self._id, 'playlist', 'index', '+1')
|
self.query('playlist', 'index', '+1')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def media_previous_track(self):
|
def media_previous_track(self):
|
||||||
"""Send next track command."""
|
"""Send next track command."""
|
||||||
self._lms.query(self._id, 'playlist', 'index', '-1')
|
self.query('playlist', 'index', '-1')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def media_seek(self, position):
|
def media_seek(self, position):
|
||||||
"""Send seek command."""
|
"""Send seek command."""
|
||||||
self._lms.query(self._id, 'time', position)
|
self.query('time', position)
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def turn_on(self):
|
def turn_on(self):
|
||||||
"""Turn the media player on."""
|
"""Turn the media player on."""
|
||||||
self._lms.query(self._id, 'power', '1')
|
self.query('power', '1')
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def play_media(self, media_type, media_id, **kwargs):
|
def play_media(self, media_type, media_id, **kwargs):
|
||||||
|
@ -365,51 +375,11 @@ class SqueezeBoxDevice(MediaPlayerDevice):
|
||||||
self._play_uri(media_id)
|
self._play_uri(media_id)
|
||||||
|
|
||||||
def _play_uri(self, media_id):
|
def _play_uri(self, media_id):
|
||||||
"""
|
"""Replace the current play list with the uri."""
|
||||||
Replace the current play list with the uri.
|
self.query('playlist', 'play', media_id)
|
||||||
|
|
||||||
Telnet Command Structure:
|
|
||||||
<playerid> playlist play <item> <title> <fadeInSecs>
|
|
||||||
|
|
||||||
The "playlist play" command puts the specified song URL,
|
|
||||||
playlist or directory contents into the current playlist
|
|
||||||
and plays starting at the first item. Any songs previously
|
|
||||||
in the playlist are discarded. An optional title value may be
|
|
||||||
passed to set a title. This can be useful for remote URLs.
|
|
||||||
The "fadeInSecs" parameter may be passed to specify fade-in period.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
Request: "04:20:00:12:23:45 playlist play
|
|
||||||
/music/abba/01_Voulez_Vous.mp3<LF>"
|
|
||||||
Response: "04:20:00:12:23:45 playlist play
|
|
||||||
/music/abba/01_Voulez_Vous.mp3<LF>"
|
|
||||||
|
|
||||||
"""
|
|
||||||
self._lms.query(self._id, 'playlist', 'play', media_id)
|
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
||||||
def _add_uri_to_playlist(self, media_id):
|
def _add_uri_to_playlist(self, media_id):
|
||||||
"""
|
"""Add a items to the existing playlist."""
|
||||||
Add a items to the existing playlist.
|
self.query('playlist', 'add', media_id)
|
||||||
|
|
||||||
Telnet Command Structure:
|
|
||||||
<playerid> playlist add <item>
|
|
||||||
|
|
||||||
The "playlist add" command adds the specified song URL, playlist or
|
|
||||||
directory contents to the end of the current playlist. Songs
|
|
||||||
currently playing or already on the playlist are not affected.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
Request: "04:20:00:12:23:45 playlist add
|
|
||||||
/music/abba/01_Voulez_Vous.mp3<LF>"
|
|
||||||
Response: "04:20:00:12:23:45 playlist add
|
|
||||||
/music/abba/01_Voulez_Vous.mp3<LF>"
|
|
||||||
|
|
||||||
Request: "04:20:00:12:23:45 playlist add
|
|
||||||
/playlists/abba.m3u<LF>"
|
|
||||||
Response: "04:20:00:12:23:45 playlist add
|
|
||||||
/playlists/abba.m3u<LF>"
|
|
||||||
|
|
||||||
"""
|
|
||||||
self._lms.query(self._id, 'playlist', 'add', media_id)
|
|
||||||
self.update_ha_state()
|
self.update_ha_state()
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue