diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 75a54c6fac2cc8c855d1bd93bd527bcfc1f2a593..aadef5a9a926a45371735e29ffa1ec952ddf222e 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -18,20 +18,26 @@ from homeassistant.components.media_player import ( PLATFORM_SCHEMA) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, - CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_PASSWORD) + CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['jsonrpc-async==0.2'] +REQUIREMENTS = ['jsonrpc-async==0.4', 'jsonrpc-websocket==0.2'] _LOGGER = logging.getLogger(__name__) +CONF_TCP_PORT = 'tcp_port' CONF_TURN_OFF_ACTION = 'turn_off_action' +CONF_ENABLE_WEBSOCKET = 'enable_websocket' DEFAULT_NAME = 'Kodi' DEFAULT_PORT = 8080 +DEFAULT_TCP_PORT = 9090 DEFAULT_TIMEOUT = 5 DEFAULT_SSL = False +DEFAULT_ENABLE_WEBSOCKET = True TURN_OFF_ACTION = [None, 'quit', 'hibernate', 'suspend', 'reboot', 'shutdown'] @@ -43,10 +49,13 @@ 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_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_TURN_OFF_ACTION, default=None): vol.In(TURN_OFF_ACTION), 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, }) @@ -56,7 +65,9 @@ def async_setup_platform(hass, config, async_add_entities, """Setup the Kodi platform.""" host = config.get(CONF_HOST) port = config.get(CONF_PORT) - use_encryption = config.get(CONF_SSL) + tcp_port = config.get(CONF_TCP_PORT) + encryption = config.get(CONF_SSL) + websocket = config.get(CONF_ENABLE_WEBSOCKET) if host.startswith('http://') or host.startswith('https://'): host = host.lstrip('http://').lstrip('https://') @@ -68,10 +79,10 @@ def async_setup_platform(hass, config, async_add_entities, entity = KodiDevice( hass, name=config.get(CONF_NAME), - host=host, port=port, encryption=use_encryption, + 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)) + turn_off_action=config.get(CONF_TURN_OFF_ACTION), websocket=websocket) yield from async_add_entities([entity], update_before_add=True) @@ -79,10 +90,12 @@ def async_setup_platform(hass, config, async_add_entities, class KodiDevice(MediaPlayerDevice): """Representation of a XBMC/Kodi device.""" - def __init__(self, hass, name, host, port, encryption=False, username=None, - password=None, turn_off_action=None): + def __init__(self, hass, name, host, port, tcp_port, encryption=False, + username=None, password=None, turn_off_action=None, + websocket=True): """Initialize the Kodi device.""" import jsonrpc_async + import jsonrpc_websocket self.hass = hass self._name = name @@ -97,32 +110,92 @@ class KodiDevice(MediaPlayerDevice): else: image_auth_string = "" - protocol = 'https' if encryption else 'http' + http_protocol = 'https' if encryption else 'http' + ws_protocol = 'wss' if encryption else 'ws' - self._http_url = '{}://{}:{}/jsonrpc'.format(protocol, host, port) + self._http_url = '{}://{}:{}/jsonrpc'.format(http_protocol, host, port) self._image_url = '{}://{}{}:{}/image'.format( - protocol, image_auth_string, host, port) - - self._server = jsonrpc_async.Server(self._http_url, **kwargs) + 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 self._turn_off_action = turn_off_action + self._enable_websocket = websocket self._players = list() - self._properties = None - self._item = None - self._app_properties = None + self._properties = {} + self._item = {} + self._app_properties = {} + self._ws_connected = False - @property - def name(self): - """Return the name of the device.""" - return self._name + @callback + def async_on_speed_event(self, sender, data): + """Called when player changes between playing and paused.""" + self._properties['speed'] = data['player']['speed'] + + # If a new item is playing, force a complete refresh + new_item = data['item']['id'] != self._item.get('id') + + self.hass.async_add_job(self.async_update_ha_state(new_item)) + + @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()) @asyncio.coroutine def _get_players(self): """Return the active player objects or None.""" - import jsonrpc_async + import jsonrpc_base try: - return (yield from self._server.Player.GetActivePlayers()) - except jsonrpc_async.jsonrpc.TransportError: + return (yield from self.server.Player.GetActivePlayers()) + except jsonrpc_base.jsonrpc.TransportError: if self._players is not None: _LOGGER.info('Unable to fetch kodi data') _LOGGER.debug('Unable to fetch kodi data', exc_info=True) @@ -142,52 +215,106 @@ class KodiDevice(MediaPlayerDevice): else: return STATE_PLAYING + @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.loop.create_task(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): """Retrieve latest state.""" self._players = yield from self._get_players() - if self._players is not None and len(self._players) > 0: + if self._players is None: + self._properties = {} + self._item = {} + self._app_properties = {} + return + + if self._enable_websocket and not self._ws_connected: + self.hass.loop.create_task(self.async_ws_connect()) + + self._app_properties = \ + yield from self.server.Application.GetProperties( + ['volume', 'muted'] + ) + + if len(self._players) > 0: player_id = self._players[0]['playerid'] assert isinstance(player_id, int) - self._properties = yield from self._server.Player.GetProperties( + self._properties = yield from self.server.Player.GetProperties( player_id, ['time', 'totaltime', 'speed', 'live'] ) - self._item = (yield from self._server.Player.GetItem( + self._item = (yield from self.server.Player.GetItem( player_id, ['title', 'file', 'uniqueid', 'thumbnail', 'artist'] ))['item'] + else: + self._properties = {} + self._item = {} + self._app_properties = {} - self._app_properties = \ - yield from self._server.Application.GetProperties( - ['volume', 'muted'] - ) + @property + def server(self): + """Active server for json-rpc requests.""" + if self._ws_connected: + return self._ws_server else: - self._properties = None - self._item = None - self._app_properties = None + 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 @property def volume_level(self): """Volume level of the media player (0..1).""" - if self._app_properties is not None: + if 'volume' in self._app_properties: return self._app_properties['volume'] / 100.0 @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - if self._app_properties is not None: - return self._app_properties['muted'] + return self._app_properties.get('muted') @property def media_content_id(self): """Content ID of current playing media.""" - if self._item is not None: - return self._item.get('uniqueid', None) + return self._item.get('uniqueid', None) @property def media_content_type(self): @@ -198,34 +325,38 @@ class KodiDevice(MediaPlayerDevice): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._properties is not None and not self._properties['live']: - total_time = self._properties['totaltime'] + if self._properties.get('live'): + return None + + total_time = self._properties.get('totaltime') + + if total_time is None: + return None - return ( - total_time['hours'] * 3600 + - total_time['minutes'] * 60 + - total_time['seconds']) + return ( + total_time['hours'] * 3600 + + total_time['minutes'] * 60 + + total_time['seconds']) @property def media_image_url(self): """Image url of current playing media.""" - if self._item is None: + thumbnail = self._item.get('thumbnail') + if thumbnail is None: return None - url_components = urllib.parse.urlparse(self._item['thumbnail']) + url_components = urllib.parse.urlparse(thumbnail) if url_components.scheme == 'image': return '{}/{}'.format( self._image_url, - urllib.parse.quote_plus(self._item['thumbnail'])) + urllib.parse.quote_plus(thumbnail)) @property def media_title(self): """Title of current playing media.""" # find a string we can use as a title - if self._item is not None: - return self._item.get( - 'title', - self._item.get('label', self._item.get('file', 'unknown'))) + return self._item.get( + 'title', self._item.get('label', self._item.get('file'))) @property def supported_features(self): @@ -241,15 +372,15 @@ class KodiDevice(MediaPlayerDevice): 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() + yield from self.server.Application.Quit() elif self._turn_off_action == 'hibernate': - yield from self._server.System.Hibernate() + yield from self.server.System.Hibernate() elif self._turn_off_action == 'suspend': - yield from self._server.System.Suspend() + yield from self.server.System.Suspend() elif self._turn_off_action == 'reboot': - yield from self._server.System.Reboot() + yield from self.server.System.Reboot() elif self._turn_off_action == 'shutdown': - yield from self._server.System.Shutdown() + yield from self.server.System.Shutdown() else: _LOGGER.warning('turn_off requested but turn_off_action is none') @@ -257,27 +388,27 @@ class KodiDevice(MediaPlayerDevice): def async_volume_up(self): """Volume up the media player.""" assert ( - yield from self._server.Input.ExecuteAction('volumeup')) == 'OK' + yield from self.server.Input.ExecuteAction('volumeup')) == 'OK' @asyncio.coroutine def async_volume_down(self): """Volume down the media player.""" assert ( - yield from self._server.Input.ExecuteAction('volumedown')) == 'OK' + yield from self.server.Input.ExecuteAction('volumedown')) == 'OK' 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)) + return self.server.Application.SetVolume(int(volume * 100)) def async_mute_volume(self, mute): """Mute (true) or unmute (false) media player. This method must be run in the event loop and returns a coroutine. """ - return self._server.Application.SetMute(mute) + return self.server.Application.SetMute(mute) @asyncio.coroutine def async_set_play_state(self, state): @@ -285,7 +416,7 @@ class KodiDevice(MediaPlayerDevice): players = yield from self._get_players() if len(players) != 0: - yield from self._server.Player.PlayPause( + yield from self.server.Player.PlayPause( players[0]['playerid'], state) def async_media_play_pause(self): @@ -315,7 +446,7 @@ class KodiDevice(MediaPlayerDevice): players = yield from self._get_players() if len(players) != 0: - yield from self._server.Player.Stop(players[0]['playerid']) + yield from self.server.Player.Stop(players[0]['playerid']) @asyncio.coroutine def _goto(self, direction): @@ -326,9 +457,9 @@ class KodiDevice(MediaPlayerDevice): 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.Seek(players[0]['playerid'], 0) - yield from self._server.Player.GoTo( + yield from self.server.Player.GoTo( players[0]['playerid'], direction) def async_media_next_track(self): @@ -364,7 +495,7 @@ class KodiDevice(MediaPlayerDevice): time['hours'] = int(position) if len(players) != 0: - yield from self._server.Player.Seek(players[0]['playerid'], time) + yield from self.server.Player.Seek(players[0]['playerid'], time) def async_play_media(self, media_type, media_id, **kwargs): """Send the play_media command to the media player. @@ -372,8 +503,8 @@ class KodiDevice(MediaPlayerDevice): This method must be run in the event loop and returns a coroutine. """ if media_type == "CHANNEL": - return self._server.Player.Open( + return self.server.Player.Open( {"item": {"channelid": int(media_id)}}) else: - return self._server.Player.Open( + return self.server.Player.Open( {"item": {"file": str(media_id)}}) diff --git a/requirements_all.txt b/requirements_all.txt index c501b5e29a5f306ee15db7ca3e007c0e5b57222a..bfdeabfe51fce3b3b79ef9b99a0c4450f885e7ad 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -298,11 +298,14 @@ insteon_hub==0.4.5 insteonlocal==0.39 # homeassistant.components.media_player.kodi -jsonrpc-async==0.2 +jsonrpc-async==0.4 # homeassistant.components.notify.kodi jsonrpc-requests==0.3 +# homeassistant.components.media_player.kodi +jsonrpc-websocket==0.2 + # homeassistant.scripts.keyring keyring>=9.3,<10.0