core/homeassistant/components/media_player/kodi.py

603 lines
20 KiB
Python
Raw Normal View History

2015-06-17 11:44:39 +00:00
"""
2016-03-08 09:34:33 +00:00
Support for interfacing with the XBMC/Kodi JSON-RPC API.
2015-06-17 11:44:39 +00:00
2015-10-23 16:15:12 +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.kodi/
2015-06-17 11:44:39 +00:00
"""
import asyncio
2017-03-10 00:52:45 +00:00
from functools import wraps
2015-06-17 15:12:15 +00:00
import logging
2016-02-19 05:27:50 +00:00
import urllib
2015-06-17 15:12:15 +00:00
import aiohttp
2016-09-05 15:46:57 +00:00
import voluptuous as vol
2015-06-17 11:44:39 +00:00
from homeassistant.components.media_player import (
2016-02-19 05:27:50 +00:00
SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP,
2017-01-20 19:52:55 +00:00
SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_VOLUME_STEP, MediaPlayerDevice,
PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO,
MEDIA_TYPE_PLAYLIST)
2015-06-17 15:12:15 +00:00
from homeassistant.const import (
2016-09-05 15:46:57 +00:00
STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME,
CONF_PORT, CONF_SSL, CONF_PROXY_SSL, CONF_USERNAME, CONF_PASSWORD,
EVENT_HOMEASSISTANT_STOP)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2016-09-05 15:46:57 +00:00
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.deprecation import get_deprecated
2015-06-17 15:12:15 +00:00
REQUIREMENTS = ['jsonrpc-async==0.4', 'jsonrpc-websocket==0.2']
2015-06-17 15:12:15 +00:00
2016-09-05 15:46:57 +00:00
_LOGGER = logging.getLogger(__name__)
CONF_TCP_PORT = 'tcp_port'
2016-09-05 15:46:57 +00:00
CONF_TURN_OFF_ACTION = 'turn_off_action'
CONF_ENABLE_WEBSOCKET = 'enable_websocket'
2016-09-05 15:46:57 +00:00
DEFAULT_NAME = 'Kodi'
DEFAULT_PORT = 8080
DEFAULT_TCP_PORT = 9090
DEFAULT_TIMEOUT = 5
DEFAULT_PROXY_SSL = False
DEFAULT_ENABLE_WEBSOCKET = True
2016-09-05 15:46:57 +00:00
TURN_OFF_ACTION = [None, 'quit', 'hibernate', 'suspend', 'reboot', 'shutdown']
# https://github.com/xbmc/xbmc/blob/master/xbmc/media/MediaType.h
MEDIA_TYPES = {
"music": MEDIA_TYPE_MUSIC,
"artist": MEDIA_TYPE_MUSIC,
"album": MEDIA_TYPE_MUSIC,
"song": MEDIA_TYPE_MUSIC,
"video": MEDIA_TYPE_VIDEO,
"set": MEDIA_TYPE_PLAYLIST,
"musicvideo": MEDIA_TYPE_VIDEO,
"movie": MEDIA_TYPE_VIDEO,
"tvshow": MEDIA_TYPE_TVSHOW,
"season": MEDIA_TYPE_TVSHOW,
"episode": MEDIA_TYPE_TVSHOW,
}
2015-06-17 15:12:15 +00:00
SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \
2017-01-20 19:52:55 +00:00
SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_VOLUME_STEP
2015-06-17 11:44:39 +00:00
2016-09-05 15:46:57 +00:00
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port,
vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean,
2016-09-05 15:46:57 +00:00
vol.Optional(CONF_TURN_OFF_ACTION, default=None): vol.In(TURN_OFF_ACTION),
2016-12-06 15:43:11 +00:00
vol.Inclusive(CONF_USERNAME, 'auth'): cv.string,
vol.Inclusive(CONF_PASSWORD, 'auth'): cv.string,
vol.Optional(CONF_ENABLE_WEBSOCKET, default=DEFAULT_ENABLE_WEBSOCKET):
cv.boolean,
2016-09-05 15:46:57 +00:00
})
2015-06-17 11:44:39 +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 Kodi platform."""
2017-01-03 20:41:42 +00:00
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
tcp_port = config.get(CONF_TCP_PORT)
encryption = get_deprecated(config, CONF_PROXY_SSL, CONF_SSL)
websocket = config.get(CONF_ENABLE_WEBSOCKET)
2016-02-11 21:07:47 +00:00
2017-01-03 20:41:42 +00:00
if host.startswith('http://') or host.startswith('https://'):
host = host.lstrip('http://').lstrip('https://')
_LOGGER.warning(
"Kodi host name should no longer conatin http:// See updated "
"definitions here: "
"https://home-assistant.io/components/media_player.kodi/")
2016-02-11 21:07:47 +00:00
entity = KodiDevice(
hass,
name=config.get(CONF_NAME),
host=host, port=port, tcp_port=tcp_port, encryption=encryption,
username=config.get(CONF_USERNAME),
password=config.get(CONF_PASSWORD),
turn_off_action=config.get(CONF_TURN_OFF_ACTION), websocket=websocket)
async_add_devices([entity], update_before_add=True)
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
def cmd(func):
"""Decorator to catch command exceptions."""
2017-03-10 00:52:45 +00:00
@wraps(func)
2017-03-08 20:54:51 +00:00
@asyncio.coroutine
def wrapper(obj, *args, **kwargs):
"""Wrapper for all command methods."""
import jsonrpc_base
try:
yield from func(obj, *args, **kwargs)
except jsonrpc_base.jsonrpc.TransportError as exc:
# If Kodi is off, we expect calls to fail.
if obj.state == STATE_OFF:
log_function = _LOGGER.info
else:
log_function = _LOGGER.error
log_function("Error calling %s on entity %s: %r",
func.__name__, obj.entity_id, exc)
return wrapper
2015-06-17 11:44:39 +00:00
class KodiDevice(MediaPlayerDevice):
2016-03-08 09:34:33 +00:00
"""Representation of a XBMC/Kodi device."""
2015-06-17 15:12:15 +00:00
def __init__(self, hass, name, host, port, tcp_port, encryption=False,
username=None, password=None, turn_off_action=None,
websocket=True):
2016-03-08 09:34:33 +00:00
"""Initialize the Kodi device."""
import jsonrpc_async
import jsonrpc_websocket
self.hass = hass
2015-06-17 11:44:39 +00:00
self._name = name
2016-12-06 15:43:11 +00:00
kwargs = {
'timeout': DEFAULT_TIMEOUT,
'session': async_get_clientsession(hass),
}
2016-12-06 15:43:11 +00:00
2017-01-03 20:41:42 +00:00
if username is not None:
kwargs['auth'] = aiohttp.BasicAuth(username, password)
2017-01-03 20:41:42 +00:00
image_auth_string = "{}:{}@".format(username, password)
else:
image_auth_string = ""
http_protocol = 'https' if encryption else 'http'
ws_protocol = 'wss' if encryption else 'ws'
self._http_url = '{}://{}:{}/jsonrpc'.format(http_protocol, host, port)
self._image_url = '{}://{}{}:{}/image'.format(
http_protocol, image_auth_string, host, port)
self._ws_url = '{}://{}:{}/jsonrpc'.format(ws_protocol, host, tcp_port)
self._http_server = jsonrpc_async.Server(self._http_url, **kwargs)
if websocket:
# Setup websocket connection
self._ws_server = jsonrpc_websocket.Server(self._ws_url, **kwargs)
# Register notification listeners
self._ws_server.Player.OnPause = self.async_on_speed_event
self._ws_server.Player.OnPlay = self.async_on_speed_event
self._ws_server.Player.OnSpeedChanged = self.async_on_speed_event
self._ws_server.Player.OnStop = self.async_on_stop
self._ws_server.Application.OnVolumeChanged = \
self.async_on_volume_changed
self._ws_server.System.OnQuit = self.async_on_quit
self._ws_server.System.OnRestart = self.async_on_quit
self._ws_server.System.OnSleep = self.async_on_quit
def on_hass_stop(event):
"""Close websocket connection when hass stops."""
self.hass.async_add_job(self._ws_server.close())
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, on_hass_stop)
else:
self._ws_server = None
2016-12-06 15:43:11 +00:00
self._turn_off_action = turn_off_action
self._enable_websocket = websocket
self._players = list()
self._properties = {}
self._item = {}
self._app_properties = {}
self._ws_connected = False
2015-06-17 11:44:39 +00:00
@callback
def async_on_speed_event(self, sender, data):
"""Called when player changes between playing and paused."""
self._properties['speed'] = data['player']['speed']
if not hasattr(data['item'], 'id'):
# If no item id is given, perform a full update
force_refresh = True
else:
# If a new item is playing, force a complete refresh
force_refresh = data['item']['id'] != self._item.get('id')
self.hass.async_add_job(self.async_update_ha_state(force_refresh))
@callback
def async_on_stop(self, sender, data):
"""Called when the player stops playback."""
# Prevent stop notifications which are sent after quit notification
if self._players is None:
return
self._players = []
self._properties = {}
self._item = {}
self.hass.async_add_job(self.async_update_ha_state())
@callback
def async_on_volume_changed(self, sender, data):
"""Called when the volume is changed."""
self._app_properties['volume'] = data['volume']
self._app_properties['muted'] = data['muted']
self.hass.async_add_job(self.async_update_ha_state())
@callback
def async_on_quit(self, sender, data):
"""Called when the volume is changed."""
self._players = None
self._properties = {}
self._item = {}
self._app_properties = {}
self.hass.async_add_job(self.async_update_ha_state())
2015-06-17 11:44:39 +00:00
@asyncio.coroutine
2015-06-17 11:44:39 +00:00
def _get_players(self):
2016-03-08 09:34:33 +00:00
"""Return the active player objects or None."""
import jsonrpc_base
2015-06-17 15:12:15 +00:00
try:
return (yield from self.server.Player.GetActivePlayers())
except jsonrpc_base.jsonrpc.TransportError:
if self._players is not None:
2017-01-03 20:41:42 +00:00
_LOGGER.info('Unable to fetch kodi data')
_LOGGER.debug('Unable to fetch kodi data', exc_info=True)
2015-06-17 15:12:15 +00:00
return None
2015-06-17 11:44:39 +00:00
@property
def state(self):
2016-03-08 09:34:33 +00:00
"""Return the state of the device."""
2015-06-17 15:12:15 +00:00
if self._players is None:
return STATE_OFF
2015-06-17 11:44:39 +00:00
2015-06-17 15:12:15 +00:00
if len(self._players) == 0:
return STATE_IDLE
2015-06-17 11:44:39 +00:00
if self._properties['speed'] == 0 and not self._properties['live']:
2015-06-17 15:12:15 +00:00
return STATE_PAUSED
else:
return STATE_PLAYING
2015-06-17 11:44:39 +00:00
@asyncio.coroutine
def async_ws_connect(self):
"""Connect to Kodi via websocket protocol."""
import jsonrpc_base
try:
yield from self._ws_server.ws_connect()
except jsonrpc_base.jsonrpc.TransportError:
_LOGGER.info("Unable to connect to Kodi via websocket")
_LOGGER.debug(
"Unable to connect to Kodi via websocket", exc_info=True)
# Websocket connection is not required. Just return.
return
self.hass.async_add_job(self.async_ws_loop())
self._ws_connected = True
@asyncio.coroutine
def async_ws_loop(self):
"""Run the websocket asyncio message loop."""
import jsonrpc_base
try:
yield from self._ws_server.ws_loop()
except jsonrpc_base.jsonrpc.TransportError:
# Kodi abruptly ends ws connection when exiting. We only need to
# know that it was closed.
pass
finally:
yield from self._ws_server.close()
self._ws_connected = False
@asyncio.coroutine
def async_update(self):
2016-03-08 09:34:33 +00:00
"""Retrieve latest state."""
self._players = yield from self._get_players()
2015-06-17 11:44:39 +00:00
if self._players is None:
self._properties = {}
self._item = {}
self._app_properties = {}
return
if self._enable_websocket and not self._ws_connected:
self.hass.async_add_job(self.async_ws_connect())
self._app_properties = \
yield from self.server.Application.GetProperties(
['volume', 'muted']
)
if len(self._players) > 0:
2015-06-17 15:12:15 +00:00
player_id = self._players[0]['playerid']
2015-06-17 11:44:39 +00:00
2015-06-17 15:12:15 +00:00
assert isinstance(player_id, int)
2015-06-17 11:44:39 +00:00
self._properties = yield from self.server.Player.GetProperties(
2015-06-17 15:12:15 +00:00
player_id,
['time', 'totaltime', 'speed', 'live']
2015-06-17 15:12:15 +00:00
)
2015-06-17 11:44:39 +00:00
self._item = (yield from self.server.Player.GetItem(
2015-06-17 15:12:15 +00:00
player_id,
['title', 'file', 'uniqueid', 'thumbnail', 'artist',
'albumartist', 'showtitle', 'album', 'season', 'episode']
))['item']
else:
self._properties = {}
self._item = {}
self._app_properties = {}
2015-06-17 11:44:39 +00:00
@property
def server(self):
"""Active server for json-rpc requests."""
if self._ws_connected:
return self._ws_server
2015-06-18 09:46:02 +00:00
else:
return self._http_server
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def should_poll(self):
"""Return True if entity has to be polled for state."""
return not self._ws_connected
2015-06-17 11:44:39 +00:00
2015-06-17 15:12:15 +00:00
@property
def volume_level(self):
2016-03-08 09:34:33 +00:00
"""Volume level of the media player (0..1)."""
if 'volume' in self._app_properties:
2015-06-17 15:12:15 +00:00
return self._app_properties['volume'] / 100.0
2015-06-17 11:44:39 +00:00
2015-06-17 15:12:15 +00:00
@property
def is_volume_muted(self):
2016-03-08 09:34:33 +00:00
"""Boolean if volume is currently muted."""
return self._app_properties.get('muted')
2015-06-17 11:44:39 +00:00
2015-06-17 15:12:15 +00:00
@property
def media_content_id(self):
2016-03-08 09:34:33 +00:00
"""Content ID of current playing media."""
return self._item.get('uniqueid', None)
2015-06-17 15:12:15 +00:00
@property
def media_content_type(self):
2016-03-08 09:34:33 +00:00
"""Content type of current playing media."""
return MEDIA_TYPES.get(self._item.get('type'))
2015-06-17 11:44:39 +00:00
2015-06-17 15:12:15 +00:00
@property
def media_duration(self):
2016-03-08 09:34:33 +00:00
"""Duration of current playing media in seconds."""
if self._properties.get('live'):
return None
total_time = self._properties.get('totaltime')
if total_time is None:
return None
2015-06-17 11:44:39 +00:00
return (
total_time['hours'] * 3600 +
total_time['minutes'] * 60 +
total_time['seconds'])
2015-06-17 15:12:15 +00:00
@property
def media_image_url(self):
2016-03-08 09:34:33 +00:00
"""Image url of current playing media."""
thumbnail = self._item.get('thumbnail')
if thumbnail is None:
2017-01-03 20:41:42 +00:00
return None
2016-02-10 19:48:41 +00:00
url_components = urllib.parse.urlparse(thumbnail)
2016-02-10 19:48:41 +00:00
if url_components.scheme == 'image':
2017-01-03 20:41:42 +00:00
return '{}/{}'.format(
self._image_url,
urllib.parse.quote_plus(thumbnail))
2015-06-17 15:12:15 +00:00
@property
def media_title(self):
2016-03-08 09:34:33 +00:00
"""Title of current playing media."""
2015-06-17 15:12:15 +00:00
# find a string we can use as a title
return self._item.get(
'title', self._item.get('label', self._item.get('file')))
2015-06-17 15:12:15 +00:00
@property
def media_series_title(self):
"""Title of series of current playing media, TV show only."""
return self._item.get('showtitle')
@property
def media_season(self):
"""Season of current playing media, TV show only."""
return self._item.get('season')
@property
def media_episode(self):
"""Episode of current playing media, TV show only."""
return self._item.get('episode')
@property
def media_album_name(self):
"""Album name of current playing media, music track only."""
return self._item.get('album')
@property
def media_artist(self):
"""Artist of current playing media, music track only."""
artists = self._item.get('artist', [])
if len(artists) > 0:
return artists[0]
else:
return None
@property
def media_album_artist(self):
"""Album artist of current playing media, music track only."""
artists = self._item.get('albumartist', [])
if len(artists) > 0:
return artists[0]
else:
return None
2015-06-17 15:12:15 +00:00
@property
def supported_features(self):
"""Flag media player features that are supported."""
supported_features = SUPPORT_KODI
2016-09-05 15:46:57 +00:00
if self._turn_off_action in TURN_OFF_ACTION:
supported_features |= SUPPORT_TURN_OFF
return supported_features
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
@cmd
@asyncio.coroutine
def async_turn_off(self):
"""Execute turn_off_action to turn off media player."""
if self._turn_off_action == 'quit':
yield from self.server.Application.Quit()
elif self._turn_off_action == 'hibernate':
yield from self.server.System.Hibernate()
elif self._turn_off_action == 'suspend':
yield from self.server.System.Suspend()
elif self._turn_off_action == 'reboot':
yield from self.server.System.Reboot()
elif self._turn_off_action == 'shutdown':
yield from self.server.System.Shutdown()
else:
_LOGGER.warning('turn_off requested but turn_off_action is none')
2017-03-08 20:54:51 +00:00
@cmd
@asyncio.coroutine
def async_volume_up(self):
2016-03-08 09:34:33 +00:00
"""Volume up the media player."""
assert (
yield from self.server.Input.ExecuteAction('volumeup')) == 'OK'
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
@cmd
@asyncio.coroutine
def async_volume_down(self):
2016-03-08 09:34:33 +00:00
"""Volume down the media player."""
assert (
yield from self.server.Input.ExecuteAction('volumedown')) == 'OK'
2017-03-08 20:54:51 +00:00
@cmd
def async_set_volume_level(self, volume):
"""Set volume level, range 0..1.
This method must be run in the event loop and returns a coroutine.
"""
return self.server.Application.SetVolume(int(volume * 100))
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
@cmd
def async_mute_volume(self, mute):
"""Mute (true) or unmute (false) media player.
2015-06-17 11:44:39 +00:00
This method must be run in the event loop and returns a coroutine.
"""
return self.server.Application.SetMute(mute)
2015-06-17 11:44:39 +00:00
@asyncio.coroutine
def async_set_play_state(self, state):
2016-03-08 09:34:33 +00:00
"""Helper method for play/pause/toggle."""
players = yield from self._get_players()
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
if players is not None and len(players) != 0:
yield from self.server.Player.PlayPause(
players[0]['playerid'], state)
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
@cmd
def async_media_play_pause(self):
"""Pause media on media player.
2015-06-17 11:44:39 +00:00
This method must be run in the event loop and returns a coroutine.
"""
return self.async_set_play_state('toggle')
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
@cmd
def async_media_play(self):
"""Play media.
2015-06-17 11:44:39 +00:00
This method must be run in the event loop and returns a coroutine.
"""
return self.async_set_play_state(True)
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
@cmd
def async_media_pause(self):
"""Pause the media player.
This method must be run in the event loop and returns a coroutine.
"""
return self.async_set_play_state(False)
2017-03-08 20:54:51 +00:00
@cmd
@asyncio.coroutine
def async_media_stop(self):
"""Stop the media player."""
players = yield from self._get_players()
if len(players) != 0:
yield from self.server.Player.Stop(players[0]['playerid'])
@asyncio.coroutine
2015-06-17 15:12:15 +00:00
def _goto(self, direction):
2016-03-08 09:34:33 +00:00
"""Helper method used for previous/next track."""
players = yield from self._get_players()
2015-06-17 11:44:39 +00:00
if len(players) != 0:
if direction == 'previous':
# first seek to position 0. Kodi goes to the beginning of the
# current track if the current track is not at the beginning.
yield from self.server.Player.Seek(players[0]['playerid'], 0)
yield from self.server.Player.GoTo(
players[0]['playerid'], direction)
2017-03-08 20:54:51 +00:00
@cmd
def async_media_next_track(self):
"""Send next track command.
2015-06-17 11:44:39 +00:00
This method must be run in the event loop and returns a coroutine.
"""
return self._goto('next')
2015-06-17 11:44:39 +00:00
2017-03-08 20:54:51 +00:00
@cmd
def async_media_previous_track(self):
"""Send next track command.
2015-06-17 11:44:39 +00:00
This method must be run in the event loop and returns a coroutine.
"""
return self._goto('previous')
2015-06-17 15:12:15 +00:00
2017-03-08 20:54:51 +00:00
@cmd
@asyncio.coroutine
def async_media_seek(self, position):
2016-03-08 09:34:33 +00:00
"""Send seek command."""
players = yield from self._get_players()
2015-06-17 15:12:15 +00:00
time = {}
time['milliseconds'] = int((position % 1) * 1000)
position = int(position)
time['seconds'] = int(position % 60)
position /= 60
time['minutes'] = int(position % 60)
position /= 60
time['hours'] = int(position)
if len(players) != 0:
yield from self.server.Player.Seek(players[0]['playerid'], time)
2015-06-17 15:12:15 +00:00
2017-03-08 20:54:51 +00:00
@cmd
def async_play_media(self, media_type, media_id, **kwargs):
"""Send the play_media command to the media player.
This method must be run in the event loop and returns a coroutine.
"""
if media_type == "CHANNEL":
return self.server.Player.Open(
{"item": {"channelid": int(media_id)}})
else:
return self.server.Player.Open(
{"item": {"file": str(media_id)}})