From cf07939792a12e331416f6433135eb69c91fcb1f Mon Sep 17 00:00:00 2001 From: Wolfgang Ettlinger Date: Wed, 17 Jun 2015 13:44:39 +0200 Subject: [PATCH 1/4] first draft of kodi plugin --- homeassistant/components/media_player/kodi.py | 171 ++++++++++++++++++ requirements.txt | 3 + 2 files changed, 174 insertions(+) create mode 100644 homeassistant/components/media_player/kodi.py diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py new file mode 100644 index 00000000000..90c1f126117 --- /dev/null +++ b/homeassistant/components/media_player/kodi.py @@ -0,0 +1,171 @@ +""" +homeassistant.components.media_player.demo +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Demo implementation of the media player. + +""" +from homeassistant.components.media_player import ( + MediaPlayerDevice, STATE_NO_APP, ATTR_MEDIA_STATE, + ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_TITLE, ATTR_MEDIA_DURATION, + ATTR_MEDIA_VOLUME, MEDIA_STATE_PLAYING, MEDIA_STATE_STOPPED, + ATTR_MEDIA_IS_VOLUME_MUTED, MEDIA_STATE_PAUSED) +from homeassistant.const import ATTR_ENTITY_PICTURE +import jsonrpc_requests + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the kodi platform. """ + + add_devices([ + KodiDevice( + config.get('name', 'Kodi'), + config.get('url'), + auth=( + config.get('user', ''), + config.get('password', ''))), + ]) + + +class KodiDevice(MediaPlayerDevice): + """ TODO. """ + + def __init__(self, name, url, auth=None): + self._name = name + self._url = url + self._server = jsonrpc_requests.Server(url, auth=auth) + + @property + def should_poll(self): + """ TODO. """ + return True + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + def _get_players(self): + return self._server.Player.GetActivePlayers() + + @property + def state(self): + """ Returns the state of the device. """ + players = self._get_players() + + if len(players) == 0: + return STATE_NO_APP + + return players[0]['type'] + + @property + def state_attributes(self): + """ Returns the state attributes. """ + + players = self._get_players() + + if len(players) == 0: + return { + ATTR_MEDIA_STATE: MEDIA_STATE_STOPPED + } + + player_id = players[0]['playerid'] + + assert isinstance(player_id, int) + + item = self._server.Player.GetItem( + player_id, + ['title', 'file', 'uniqueid', 'thumbnail', 'artist'])[ + 'item'] + + properties = self._server.Player.GetProperties( + player_id, + ['time', 'totaltime', 'speed']) + + app_properties = self._server.Application.GetProperties( + ['volume', 'muted']) + + # find a string we can use as a title + title = item.get('title', + item.get('label', + item.get('file', 'unknown'))) + + total_time = properties['totaltime'] + + return { + ATTR_MEDIA_CONTENT_ID: item['uniqueid'], + ATTR_MEDIA_TITLE: title, + ATTR_MEDIA_DURATION: + total_time['hours'] * 3600 + + total_time['minutes'] * 60 + + total_time['seconds'], + ATTR_MEDIA_VOLUME: + app_properties['volume'] / 100.0, + ATTR_MEDIA_IS_VOLUME_MUTED: + app_properties['muted'], + ATTR_ENTITY_PICTURE: + item['thumbnail'], + ATTR_MEDIA_STATE: + MEDIA_STATE_PAUSED if properties['speed'] == 0 + else MEDIA_STATE_PLAYING, + } + + def turn_off(self): + """ turn_off media player. """ + self._server.System.Shutdown() + self.update_ha_state() + + def volume_up(self): + """ volume_up media player. """ + assert self._server.Input.ExecuteAction('volumeup') == 'OK' + self.update_ha_state() + + def volume_down(self): + """ volume_down media player. """ + assert self._server.Input.ExecuteAction('volumedown') == 'OK' + self.update_ha_state() + + def volume_set(self, volume): + """ TODO. """ + self._server.Application.SetVolume(int(volume * 100)) + self.update_ha_state() + + def volume_mute(self, mute): + """ mute (true) or unmute (false) media player. """ + self._server.Application.SetMute(mute) + self.update_ha_state() + + def _set_play_state(self, state): + players = self._get_players() + + if len(players) != 0: + self._server.Player.PlayPause(players[0]['playerid'], state) + + self.update_ha_state() + + def media_play_pause(self): + """ media_play_pause media player. """ + self._set_play_state('toggle') + + def media_play(self): + """ media_play media player. """ + self._set_play_state(True) + + def media_pause(self): + """ media_pause media player. """ + self._set_play_state(False) + + def _goto(self, to): + players = self._get_players() + + if len(players) != 0: + self._server.Player.GoTo(players[0]['playerid'], to) + + self.update_ha_state() + + def media_next_track(self): + self._goto('next') + + def media_prev_track(self): + self._goto('previous') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index fd234122c03..7a0b5e1cfc6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,3 +61,6 @@ blockchain>=1.1.2 # MPD Bindings (media_player.mpd) python-mpd2>=0.5.4 + +# JSON-RPC interface +jsonrpc-requests>=0.1 \ No newline at end of file From 4355686bd68d8cba97093183fe4e71e0ccebc51f Mon Sep 17 00:00:00 2001 From: Wolfgang Ettlinger Date: Wed, 17 Jun 2015 17:12:15 +0200 Subject: [PATCH 2/4] final draft of kodi module --- README.md | 2 +- homeassistant/components/media_player/kodi.py | 270 +++++++++++++----- 2 files changed, 203 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 05fe01340bc..a50962e659f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ It offers the following functionality through built-in components: * Track if devices are home by monitoring connected devices to a wireless router (supporting [OpenWrt](https://openwrt.org/), [Tomato](http://www.polarcloud.com/tomato), [Netgear](http://netgear.com), and [DD-WRT](http://www.dd-wrt.com/site/index)) * Track and control [Philips Hue](http://meethue.com) lights, [WeMo](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/) switches, and [Tellstick](http://www.telldus.se/products/tellstick) devices and sensors - * Track and control [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast) and [Music Player Daemon](http://www.musicpd.org/) + * Track and control [Google Chromecasts](http://www.google.com/intl/en/chrome/devices/chromecast), [Music Player Daemon](http://www.musicpd.org/) and XBMC/Kodi (http://kodi.tv/) * Support for [ISY994](https://www.universal-devices.com/residential/isy994i-series/) (Insteon and X10 devices), [Z-Wave](http://www.z-wave.com/), [Nest Thermostats](https://nest.com/), and [Modbus](http://www.modbus.org/) * Track running system services and monitoring your system stats (Memory, disk usage, and more) * Control low-cost 433 MHz remote control wall-socket devices (https://github.com/r10r/rcswitch-pi) and other switches that can be turned on/off with shell commands diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 90c1f126117..cf35506d009 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -1,23 +1,70 @@ """ -homeassistant.components.media_player.demo +homeassistant.components.media_player.kodi ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Demo implementation of the media player. +Provides an interface to the XBMC/Kodi JSON-RPC API +Configuration: + +To use Kodi add something like this to your configuration: + +media_player: + platform: kodi + name: Kodi + url: http://192.168.0.123/jsonrpc + user: kodi + password: my_secure_password + +Variables: + +name +*Optional +The name of the device + +url +*Required +The URL of the XBMC/Kodi JSON-RPC API. Example: http://192.168.0.123/jsonrpc + +user +*Optional +The XBMC/Kodi HTTP username + +password +*Optional +The XBMC/Kodi HTTP password """ + +import urllib +import logging + from homeassistant.components.media_player import ( - MediaPlayerDevice, STATE_NO_APP, ATTR_MEDIA_STATE, - ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_TITLE, ATTR_MEDIA_DURATION, - ATTR_MEDIA_VOLUME, MEDIA_STATE_PLAYING, MEDIA_STATE_STOPPED, - ATTR_MEDIA_IS_VOLUME_MUTED, MEDIA_STATE_PAUSED) -from homeassistant.const import ATTR_ENTITY_PICTURE -import jsonrpc_requests + MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_SEEK, SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_MUTE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK) +from homeassistant.const import ( + STATE_IDLE, STATE_PLAYING, STATE_PAUSED, STATE_OFF) + +try: + import jsonrpc_requests +except ImportError: + jsonrpc_requests = None + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """ Sets up the kodi platform. """ + if jsonrpc_requests is None: + _LOGGER.exception( + "Unable to import jsonrpc_requests. " + "Did you maybe not install the 'jsonrpc-requests' pip module?") + + return False + add_devices([ KodiDevice( config.get('name', 'Kodi'), @@ -28,18 +75,29 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ]) +def _get_image_url(kodi_url): + """ Helper function that parses the thumbnail URLs used by Kodi """ + url_components = urllib.parse.urlparse(kodi_url) + + if url_components.scheme == 'image': + return urllib.parse.unquote(url_components.netloc) + + class KodiDevice(MediaPlayerDevice): - """ TODO. """ + """ Represents a XBMC/Kodi device. """ + + # pylint: disable=too-many-public-methods def __init__(self, name, url, auth=None): self._name = name self._url = url self._server = jsonrpc_requests.Server(url, auth=auth) + self._players = None + self._properties = None + self._item = None + self._app_properties = None - @property - def should_poll(self): - """ TODO. """ - return True + self.update() @property def name(self): @@ -47,69 +105,108 @@ class KodiDevice(MediaPlayerDevice): return self._name def _get_players(self): - return self._server.Player.GetActivePlayers() + """ Returns the active player objects or None """ + try: + return self._server.Player.GetActivePlayers() + except (OSError, ConnectionError): + # OSError may be raised for "No route to host" + return None @property def state(self): """ Returns the state of the device. """ - players = self._get_players() + if self._players is None: + return STATE_OFF - if len(players) == 0: - return STATE_NO_APP + if len(self._players) == 0: + return STATE_IDLE - return players[0]['type'] + if self._properties['speed'] == 0: + return STATE_PAUSED + else: + return STATE_PLAYING + + def update(self): + """ Retrieve latest state. """ + self._players = self._get_players() + + if self._players is not None and len(self._players) > 0: + player_id = self._players[0]['playerid'] + + assert isinstance(player_id, int) + + self._properties = self._server.Player.GetProperties( + player_id, + ['time', 'totaltime', 'speed'] + ) + + self._item = self._server.Player.GetItem( + player_id, + ['title', 'file', 'uniqueid', 'thumbnail', 'artist'] + )['item'] + + self._app_properties = self._server.Application.GetProperties( + ['volume', 'muted'] + ) @property - def state_attributes(self): - """ Returns the state attributes. """ + def volume_level(self): + """ Volume level of the media player (0..1). """ + if self._app_properties is not None: + return self._app_properties['volume'] / 100.0 - players = self._get_players() + @property + def is_volume_muted(self): + """ Boolean if volume is currently muted. """ + if self._app_properties is not None: + return self._app_properties['muted'] - if len(players) == 0: - return { - ATTR_MEDIA_STATE: MEDIA_STATE_STOPPED - } + @property + def media_content_id(self): + """ Content ID of current playing media. """ + if self._item is not None: + return self._item['uniqueid'] - player_id = players[0]['playerid'] + @property + def media_content_type(self): + """ Content type of current playing media. """ + if self._players is not None and len(self._players) > 0: + return self._players[0]['type'] - assert isinstance(player_id, int) + @property + def media_duration(self): + """ Duration of current playing media in seconds. """ + if self._properties is not None: + total_time = self._properties['totaltime'] - item = self._server.Player.GetItem( - player_id, - ['title', 'file', 'uniqueid', 'thumbnail', 'artist'])[ - 'item'] - - properties = self._server.Player.GetProperties( - player_id, - ['time', 'totaltime', 'speed']) - - app_properties = self._server.Application.GetProperties( - ['volume', 'muted']) - - # find a string we can use as a title - title = item.get('title', - item.get('label', - item.get('file', 'unknown'))) - - total_time = properties['totaltime'] - - return { - ATTR_MEDIA_CONTENT_ID: item['uniqueid'], - ATTR_MEDIA_TITLE: title, - ATTR_MEDIA_DURATION: + return ( total_time['hours'] * 3600 + total_time['minutes'] * 60 + - total_time['seconds'], - ATTR_MEDIA_VOLUME: - app_properties['volume'] / 100.0, - ATTR_MEDIA_IS_VOLUME_MUTED: - app_properties['muted'], - ATTR_ENTITY_PICTURE: - item['thumbnail'], - ATTR_MEDIA_STATE: - MEDIA_STATE_PAUSED if properties['speed'] == 0 - else MEDIA_STATE_PLAYING, - } + total_time['seconds']) + + @property + def media_image_url(self): + """ Image url of current playing media. """ + if self._item is not None: + return _get_image_url(self._item['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'))) + + @property + def supported_media_commands(self): + """ Flags of media commands that are supported. """ + return SUPPORT_KODI def turn_off(self): """ turn_off media player. """ @@ -126,17 +223,18 @@ class KodiDevice(MediaPlayerDevice): assert self._server.Input.ExecuteAction('volumedown') == 'OK' self.update_ha_state() - def volume_set(self, volume): - """ TODO. """ + def set_volume_level(self, volume): + """ set volume level, range 0..1. """ self._server.Application.SetVolume(int(volume * 100)) self.update_ha_state() - def volume_mute(self, mute): + def mute_volume(self, mute): """ mute (true) or unmute (false) media player. """ self._server.Application.SetMute(mute) self.update_ha_state() def _set_play_state(self, state): + """ Helper method for play/pause/toggle """ players = self._get_players() if len(players) != 0: @@ -156,16 +254,52 @@ class KodiDevice(MediaPlayerDevice): """ media_pause media player. """ self._set_play_state(False) - def _goto(self, to): + def _goto(self, direction): + """ Helper method used for previous/next track """ players = self._get_players() if len(players) != 0: - self._server.Player.GoTo(players[0]['playerid'], to) + self._server.Player.GoTo(players[0]['playerid'], direction) self.update_ha_state() def media_next_track(self): + """ Send next track command. """ self._goto('next') - def media_prev_track(self): - self._goto('previous') \ No newline at end of file + def media_previous_track(self): + """ Send next track command. """ + # first seek to position 0, Kodi seems to go to the beginning + # of the current track current track is not at the beginning + self.media_seek(0) + self._goto('previous') + + def media_seek(self, position): + """ Send seek command. """ + players = self._get_players() + + 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: + self._server.Player.Seek(players[0]['playerid'], time) + + self.update_ha_state() + + def turn_on(self): + """ turn the media player on. """ + raise NotImplementedError() + + def play_youtube(self, media_id): + """ Plays a YouTube media. """ + raise NotImplementedError() From 689255dec0780348cb954063ec2c0e2ce7d20ebc Mon Sep 17 00:00:00 2001 From: Wolfgang Ettlinger Date: Thu, 18 Jun 2015 11:42:35 +0200 Subject: [PATCH 3/4] fix detection of when Kodi is off/unreachable --- homeassistant/components/media_player/kodi.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index cf35506d009..80b241d0a7c 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -108,8 +108,7 @@ class KodiDevice(MediaPlayerDevice): """ Returns the active player objects or None """ try: return self._server.Player.GetActivePlayers() - except (OSError, ConnectionError): - # OSError may be raised for "No route to host" + except jsonrpc_requests.jsonrpc.TransportError: return None @property From 20172285039a7bf731dd53f44739c8dd7d96381d Mon Sep 17 00:00:00 2001 From: Wolfgang Ettlinger Date: Thu, 18 Jun 2015 11:46:02 +0200 Subject: [PATCH 4/4] clear all data when kodi is off --- homeassistant/components/media_player/kodi.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 80b241d0a7c..d31e71251dc 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -147,6 +147,10 @@ class KodiDevice(MediaPlayerDevice): self._app_properties = self._server.Application.GetProperties( ['volume', 'muted'] ) + else: + self._properties = None + self._item = None + self._app_properties = None @property def volume_level(self):