/** * Kodi Media Center * * Copyright 2016 Josh Lyon * * !!!IMPORTANT!!! Feel free to learn from this code, but please don't STEAL IT / COPY-PASTE parts of it * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * */ import groovy.json.JsonSlurper metadata { definition (name: "Kodi Media Center", namespace: "boshdirect", author: "Josh Lyon") { capability "Media Controller" capability "Music Player" capability "Switch" capability "Polling" capability "Refresh" attribute "destURL", "string" attribute "currentWindowID", "string" //the current window ID from Window.GetProperties attribute "playerID", "number" //the current active player ID attribute "kodiVersion", "string" //kodi app version attribute "kodiName", "string" //kodi name attribute "trackType", "string" //kodi video type (movie,tv episode,etc) //command "setupDevice", ["string", "string", "string", "number"] command "splitURL", [ "string" ] command "toggleMute" command "inputUp" command "inputDown" command "inputLeft" command "inputRight" command "inputInfo" command "inputBack" command "inputSelect" command "inputHome" command "playFile", [ "string" ] command "playPlaylist", [ "number" ] command "clearPlaylist", [ "number" ] command "addToPlaylist", [ "number", "string" ] command "getActivePlayers" command "getVideoPlayerStatus", [ "number" ] command "getAudioPlayerStatus", [ "number" ] command "showNotification", ["string", "string" ] command "executeAddon", [ "string" ] } simulator { // TODO: define status and reply messages here } tiles(scale: 2) { //Row 1 - Mult Attribute Tile multiAttributeTile(name:"kodiMulti", type:"generic", width:6, height:4) { tileAttribute("device.status", key: "PRIMARY_CONTROL") { attributeState("default", label: '--', backgroundColor:"#79b821") attributeState("paused", label:'Paused', icon: "st.sonos.pause-btn", backgroundColor:"#ffffff") attributeState("playing", label:'Playing', icon: "st.sonos.play-btn", backgroundColor:"#79b821") attributeState("stopped", label:'Stopped', icon: "st.sonos.stop-btn", backgroundColor:"#ffffff") } /* tileAttribute("device.trackDescription", key: "SECONDARY_CONTROL") { attributeState("default", label:'${currentValue}', unit:"") } */ } //Row 2 - Thin valueTile("track", "device.trackDescription", decoration: "flat", width: 6, height: 2){ state "default", label: 'Now Playing: ${currentValue}' } valueTile("activity", "device.currentActivity", decoration: "flat", width: 3){ state "default", label: '${currentValue}' } //----------------inputs------------------- //Row 3 standardTile("input.home", "device.status", inactiveLabel: false, decoration: "flat", height: 2, width: 2) { state "default", action:"inputHome", label: "Home", icon: "st.Home.home2" } valueTile("input.up", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"inputUp", label: '↑' } valueTile("input.info", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"inputInfo", label: " INFO " } //Row 4 valueTile("input.left", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"inputLeft", label: "←" } valueTile("input.select", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"inputSelect", label: "SELECT" } valueTile("input.right", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"inputRight", label: "→" } //Row 5 valueTile("input.back", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"inputBack", label: " BACK " } valueTile("input.down", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"inputDown", label: "↓" } standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", height: 2, width: 2) { state "default", action:"refresh.refresh", icon:"st.secondary.refresh" } //----------------playback controls----------------------- //Row 1 (6) standardTile("input.previous", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"previousTrack", icon:"st.sonos.previous-btn" //, label: "◀◀" } standardTile("input.playpause", "device.status", decoration: "flat", height: 2, width: 2) { state "paused", action:"play",icon: "st.sonos.play-btn", nextState:"playing"//, label: "Play" state "playing", action:"pause",icon: "st.sonos.pause-btn", nextState:"paused"//, label: "Pause" //the second character is pause ❚❚ ▶/⏸ } standardTile("input.next", "device.status", decoration: "flat", height: 2, width: 2) { state "default", action:"nextTrack", icon:"st.sonos.next-btn" //, label: "▶▶" } //--------------Volume Control--------------------- //Row 1 (7) controlTile("volume", "device.level", "slider", decoration: "flat", width:4){ state "level", action: "switch level.setLevel" } standardTile("mute", "device.mute", decoration: "flat", height: 2, width: 2){ state "default", label: '${currentValue}', action: "toggleMute" state "muted", label: "Unmute", icon: "st.custom.sonos.unmuted", action: "unmute", nextState:"unmuted" state "unmuted", label: "Mute", icon: "st.custom.sonos.muted", action: "mute", nextState:"muted" } //Row 2 (8) - extra data valueTile("destURL", "device.destURL", decoration: "flat", width: 4){ state "default", label: '${currentValue}' } //--------------TESTING ------------------- //supporting function tiles -- most will be removed when this is released to production standardTile("urlsplitter", "device.destURL", inactiveLabel: false, decoration: "flat", height: 2, width: 2) { state "default", action:"splitURL", icon:"st.Office.office12", label: "Parse URL" } standardTile("videoStatus", "device.status", inactiveLabel: false, decoration: "flat", height: 2, width: 2) { state "default", action:"getVideoStatus", label: "Get Video Status" } //For the lists... standardTile("mainOverview", "device.status", height: 1, width: 1, canChangeIcon: true) { state "default", label: '${currentValue}', action:"playPause", backgroundColor: "#ffffff", icon: "st.Electronics.electronics18" state "paused", label: 'Paused', action:"play", nextState:"playing", backgroundColor: "#ffffff", icon: "st.Electronics.electronics18" state "playing", label: 'Playing', action:"pause", nextState:"paused", backgroundColor: "#79b821", icon: "st.Electronics.electronics18" } main(["mainOverview"]) details([ //"kodiMulti", "track",// "activity", "input.home", "input.up", "input.info", "input.left", "input.select", "input.right", "input.back", "input.down", "refresh", "input.previous", "input.playpause", "input.next", "volume", "mute", "destURL" //"urlsplitter", "videoStatus" ]) } } preferences{ section("Authorization (optional)"){ input("username", "text", title: "Username", description: "eg. kodi", autoCorrect: false, capitalization: "none") input("password", "text", title: "Password", description: "eg. kodi", autoCorrect: false, capitalization: "none") } section("IMPORTANT: Only use the override if instructed to do so!"){ paragraph "Your Kodi devices should be automatically discovered using the Kodi SmartApp. Overriding the URL here may cause unexpected results. ONLY USE THE OVERRIDE IF YOU KNOW WHAT YOU ARE DOING" input("overrideURL", "text", title: "Override URL [ADVANCED]", description: "Full URL to Kodi Webserver, including port", autoCorrect: false, capitalization: "none") } } //---------------- Setup Methods ---------------- def installed(){ log.debug "installed" sendEvent(name: "mute", value: "unmuted") sendEvent(name: "level", value: 100) //We will manually call setupDevice from the Service Manager SmartApp } def updated(){ log.debug "updated" initialize() //If the user updated their preferences, we need to reinitialize things with the new settings } def initialize(){ log.debug "overriding the IP Address based on input preferences ${overrideURL}" //if the override URL is set, let's use it if(overrideURL) setURL(overrideURL) //immediately check the subscriptions runIn(5, CheckEventSubscription) //wait a few seconds since the updated seems to get hit twice?? } /** * Called from the Service Manager SmartApp to initialize the URL to control the Kodi instance * and the device information needed for UPnP subscription and eventing end points **/ def setupDevice(url, udn, udnAddress, udnPort){ state.udn = udn log.debug "Received: $udnAddress : $udnPort" state.udnAddress = udnAddress state.udnPort = udnPort log.trace "Setup device with address ${udnAddress}:${udnPort} and UDN: ${udn}" setURL(url) //get the initial full set of data from the Kodi instance refresh() //check the subscriptions immediately CheckEventSubscription() } def setURL(url){ state.destURL = url sendEvent(name: "destURL", value: url, descriptionText: "URL set to ${url}") splitURL(url) } def getURL(){ state.destURL } //-------------- parse events into attributes ---------------- def parse(String description) { //log.debug "Parsing '${description}'" def todo = [] def map = stringToMap(description) def msg = parseLanMessage(description) if(msg.headers){ //if(map.headers && map.body){ log.trace "Response Received (with Headers and Body)" log.debug "HEADER: ${msg.headers}" //Check for authorization issues if(msg.header.toLowerCase().contains("unauthorized")){ def authMessage = "UNAUTHORIZED: Edit this device to set your Kodi Username/Password" log.debug authMessage sendEvent(name: "trackDescription", value: authMessage, descriptionText: authMessage) //TODO: can we redirect to the authorization screen automatically? } def server = msg?.headers?.server //sid:xxxx-xxxxx <-- Subscriber ID? //TIMEOUT //[nts:upnp:propchange, nt:upnp:event, content-length:1243, //sid:uuid:2ac7714b-1bd6-df84-512e-ff9b4ce73bb2, host:192.168.1.118:39500, // seq:0, user-agent:Neptune/1.1.3, content-type:text/xml; charset="utf-8", // notify /notify http/1.1:null] if(msg?.headers?.sid && msg?.headers?.timeout){ log.debug "Current Event Subscriptions: ${state.transportSID}" //if the SID map is not created, let's create it if(!state.transportSID) state.transportSID = [:] //capture the SID def sid = msg?.headers?.sid.replaceAll("uuid:", "") log.debug "Event SID: $sid" //capture the timeout def timeout = msg?.headers?.timeout.replaceAll("Second-", "") log.debug "Event Subscription Timeout: $timeout" def expires = now() as long log.debug "The time now is $expires" expires += (timeout.toLong() * 1000) //multiple the expiration in seconds * 1000 to get millis log.debug "The subscription will expire at $expires" //update the item in the state map state.transportSID << ["$sid": expires] } if(msg?.headers?.nt && msg?.headers?.nt.toLowerCase().contains("upnp:event")){ //server?.contains("UPnP") && server.contains("DLNA") //UPnP Event Subscription Response log.trace "UPnP Response" //log.debug "Body: ${msg.body}" //log.debug "XML: ${msg.xml}" //<Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/"><InstanceID val="0"><TransportState val="PAUSED_PLAYBACK"/></InstanceID></Event> //as long as we have the LastChange item, let's take its HTML encoded contents and parse them as XML def transportState = "" if(!msg.xml?.property?.LastChange.isEmpty()){ // //Parse the inner content of the last change (which was HTML encoded) def event = new XmlSlurper().parseText(msg.xml?.property?.LastChange.toString()) //And if we got the TransportState, let's update the event status if(!event.InstanceID.TransportState.isEmpty()){ transportState = event.InstanceID.TransportState.@val def transportStates = [PAUSED_PLAYBACK: "paused", PLAYING: "playing", STOPPED: "stopped"] def status = transportStates."$transportState" log.debug "Current state is: ${status}" setStatus(status) if(transportState == "STOPPED") clearTrack() } //Check for first playback as a large amount of data gets sent over: CurrentTrackMetaData if(!event.InstanceID.CurrentTrackMetaData.isEmpty() && transportState != "STOPPED"){ //need to check if it's stopped as on the first subscription, KODI will send over the last track data log.debug "WE HAVE METADATA" //Parse the inner content of the CurrentTrackMetadata (which was HTML encoded) def metadata = new XmlSlurper().parseText(event.InstanceID.CurrentTrackMetaData.@val.toString()) log.debug "METADATA: $metadata" //And if we got the TransportState, let's update the event status if(!metadata.item?.title.isEmpty()){ def title = metadata.item.title log.debug "Current playing title is: ${title}" sendEvent(name:"trackDescription", value: title) } //see if we have the UPnP object class type //def itemClass = metadata.'**'.find { it.name() == 'class' } //NOT ALLOWED //def itemClass = metadata.depthFirst().findAll { it.name() == 'class' } //NOT ALLOWED def itemClass = metadata.item.children().find{ it.name() == "class" }.toString() if(!itemClass.isEmpty()){ log.debug "itemClass: $itemClass" sendEvent(name: "trackType",value: itemClass) def playerID = 1 //default to video if(itemClass.contains("video")) playerID = 1 //object.item.videoItem.movie else if(itemClass.contains("audio")) playerID = 0 //object.item.audioItem.musicTrack else if(itemClass.contains("imageItem")) playerID = 2 //object.item.imageItem.* else getActivePlayers() //if it's not a known item, fallback to getting the info via JSON-RPC sendEvent(name: "playerID", value: playerID) } else{ log.debug "item.upnp:class is empty. Falling back to Player.GetActive" getActivePlayers() } } } } //Response to KODI commands via JSON RPC else if(msg.body && msg.headers?.'content-type'?.contains("json")){ //don't try to respond to XML responses //def bodyString = new String(map.body.decodeBase64()) //log.debug "BODY: $bodyString" //log.debug "BODY: $msg.data" //def slurper = new JsonSlurper() //def response = slurper.parseText(bodyString) def response = msg.json log.debug response log.debug "Last Command: ${state.lastCommand}" //If we were previously unauthorized and now controls are working, let's clear out the warning message //log.debug "STATUS: ${msg.status}" if(state.lastCommand && device?.currentValue("trackDescription")?.contains("UNAUTHORIZED") && msg.status == 200){ clearTrack() } //if we were requesting the current active players if(state.lastCommand == "Player.GetActivePlayers"){ state.lastCommand = null //then parse the list of active players response?.result?.each { //and if we got a player, let's get the actual status def playerid = it.playerid log.debug "Player ID: ${playerid}" sendEvent(name: "playerID", value: playerid) //for audio if(it.type && it.type == "audio"){ log.trace "Audio Player Active" todo << { getAudioPlayerStatus(playerid) } //Player.GetItem } //for video if(it.type && it.type == "video"){ log.trace "Video Player Active" todo << { getVideoPlayerStatus(playerid) } //Player.GetItem } } if(!response?.result){ log.trace "NO PLAYERS Active" //clear out the track data if no players are active clearTrack() //if we don't have anything playing, let's get the application properties // (since it's normally triggered after parsing the current player data) todo << { getApplicationProperties() } } } //respond to getting the details for the current audio/video player if(state.lastCommand == "Player.GetItem"){ //Audio: item {"title", "album", "artist", "duration", "thumbnail", "file", "fanart", "streamdetails" } //Video: item { id, title, thumbnail, label, streamdetails, type(movie|tvshow), tvshowid, episode, season, showtitle } state.lastCommand = null def nowPlaying = response?.result?.item?.label ?: response?.result?.item?.title log.trace "Now Playing: ${nowPlaying}" sendEvent(name: "trackDescription", value: nowPlaying) sendEvent(name: "trackData", value: response?.result?.item) //when we are done parsing the Player.GetItem for video, let's get the other application properties todo << { getApplicationProperties() } } //if(response.result) log.debug "Result: ${response.result}" //handle the response for GUI.GetProperties if(state.lastCommand == "GUI.GetProperties"){ state.lastCommand = null //returns currentwindow { label, id } in response.result log.trace "Received GUI Properties" log.trace "Current Window: ${response?.result?.currentwindow?.label}" sendEvent(name: "currentActivity", value: response?.result?.currentwindow?.label); sendEvent(name: "currentWindowID", value: response?.result?.currentwindow?.id); } //handle the play/pause command if(state.lastCommand == "Player.PlayPause"){ state.lastCommand = null log.trace "speed: ${response?.result?.speed}" def status = response?.result?.speed > 0 ? "playing" : "paused" log.trace "Player Status is: ${status}" setStatus(status) } //handle the stop command if(state.lastCommand == "Player.Stop"){ state.lastCommand = null if(response?.result == "OK"){ log.trace "Player Status is: Stopped" setStatus("stopped") } } //Application.SetMute if(state.lastCommand == "Application.SetMute"){ state.lastCommand = null def muteStatus = "unmuted" if(response?.result){ muteStatus = "muted"}else{ muteStatus = "unmuted"} log.trace "Mute Status is: ${muteStatus}" sendEvent(name: "mute", value: muteStatus) } //Application.SetVolume if(state.lastCommand == "Application.SetVolume"){ state.lastCommand = null def level = response?.result log.trace "Volume Level is: ${level}" sendEvent(name: "level", value: level) } //TODO: Also GET the VOLUME and MUTE status if(state.lastCommand == "Application.GetProperties"){ //volume sendEvent(name: "level", value: response?.result?.volume) //muted def muteStatus = "unmuted" if( response?.result?.muted){ muteStatus = "muted"}else{ muteStatus = "unmuted"} sendEvent(name: "mute", value: muteStatus) //name sendEvent(name: "kodiName", value: response?.result?.name) //version sendEvent(name: "kodiVersion", value: response?.result?.version?.revision) //when we are done getting the volume level, let's refresh the current activity (current window) todo << { getCurrentActivity() } } //TODO: Get the Player Properties //Player.GetProperties params [ properties [ /* [ boolean canrotate = False ] [ boolean canrepeat = False ] [ integer speed = 0 ] [ boolean canshuffle = False ] [ boolean shuffled = False ] [ boolean canmove = False ] [ boolean subtitleenabled = False ] [ Player.Position.Percentage percentage = 0 ] [ Player.Type type = "video" ] [ Player.Repeat repeat = "off" ] [ boolean canseek = False ] [ Player.Subtitle currentsubtitle ] [ Player.Subtitle[] subtitles ] [ Global.Time totaltime ] [ boolean canzoom = False ] [ Player.Audio.Stream.Extended currentaudiostream ] [ Playlist.Id playlistid = -1 ] [ Player.Audio.Stream.Extended[] audiostreams ] [ boolean partymode = False ] [ Global.Time time ] [ Playlist.Position position = -1 ] [ boolean canchangespeed = False ] */ } log.debug "running todos" def toReturn = [] todo.each{ toReturn << it.call() } return toReturn // TODO: handle 'activities' attribute // TODO: handle 'status' attribute // TODO: handle 'level' attribute // TODO: handle 'mute' attribute } } def clearTrack(){ sendEvent(name: "trackDescription", value: "") sendEvent(name: "trackData", value: "") sendEvent(name: "trackType", value:"") setStatus("stopped") //formerly Inactive } def setStatus(status){ sendEvent(name: "status", value: status) //map playing/paused/stopped/inactive to on/off switch(status){ case "playing": sendEvent(name: "switch", value: "on") break; case "paused": case "stopped": case "inactive": default: sendEvent(name: "switch", value: "off") break; } } // handle commands def startActivity(activity) { log.trace "Executing 'startActivity'" def window = activity def subsection = null if(activity.contains(".")){ def values = activity.tokenize(".") window = values[0] subsection = values[1] } log.trace "Navigating to ${window} → ${subsection}" //{"jsonrpc":"2.0","method":"GUI.ActivateWindow","params":{"window":"videos", "parameters": "movietitles"},"id":"1"}} def command = "GUI.ActivateWindow" state.lastCommand = command def params = [ "window": window ] if(subsection){ params.put("parameters", [subsection] ) } sendCommand(command, params) } def getAllActivities() { log.debug "Executing 'getAllActivities'" def activities = [ "videos", "videos.movietitles", "videos.recentlyaddedmovies", "videos.tvshows", "Videos.recentlyaddedepisodes", "music", "music.genres", "music.artists", "music.albums", "music.top100", "programs.addons" ] sendEvent(name: "activities", value: activities) } def getCurrentActivity() { log.debug "Executing 'getCurrentActivity'" def command = "GUI.GetProperties" state.lastCommand = command def params = ["properties": [ "currentwindow" ] ] sendCommand(command, params) } def play() { log.debug "Executing 'play'" playPause() } def pause() { log.debug "Executing 'pause'" playPause() } def playPause(){ sendPlayerCommand("Player.PlayPause") } def stop() { sendPlayerCommand("Player.Stop") } def nextTrack() { log.debug "Executing 'nextTrack'" sendPlayerCommand("Player.GoTo", ["to": "next"]) } def previousTrack() { log.debug "Executing 'previousTrack'" sendPlayerCommand("Player.GoTo", ["to": "previous"]) } def playTrack() { log.debug "Executing 'playTrack'" // TODO: handle 'playTrack' command } def setLevel(level) { sendCommand("Application.SetVolume", ["volume": level as int]) } def playText() { log.debug "Executing 'playText'" // TODO: handle 'playText' command } def mute() { log.debug "Executing 'mute'" sendCommand("Application.SetMute", ["mute": true ]) } def unmute() { log.debug "Executing 'unmute'" sendCommand("Application.SetMute", ["mute": false ]) } def toggleMute(){ log.debug "Executing 'unmute'" sendCommand("Application.SetMute", ["mute": "toggle" ]) } def setTrack() { log.debug "Executing 'setTrack'" // TODO: handle 'setTrack' command } def resumeTrack() { log.debug "Executing 'resumeTrack'" // TODO: handle 'resumeTrack' command } def restoreTrack() { log.debug "Executing 'restoreTrack'" // TODO: handle 'restoreTrack' command } def poll() { log.debug "Executing 'poll'" refresh() } //--------------- INPUT.CONTROL --------------- def inputBack(){ sendCommand("Input.Back") } def inputHome(){ sendCommand("Input.Home") } def inputUp(){ sendCommand("Input.Up") } def inputDown(){ sendCommand("Input.Down") } def inputLeft(){ sendCommand("Input.Left") } def inputRight(){ sendCommand("Input.Right") } def inputContextMenu(){ sendCommand("Input.ContextMenu") } def inputInfo(){ sendCommand("Input.Info") } def inputSelect(){ sendCommand("Input.Select") } //def inputUp(){ sendCommand("Input.SendText") } //requires STRING (text) BOOLEAN (is whole input TRUE=closedialog) def inputShowCodec(){ sendCommand("Input.ShowCodec") } def inputShowOSD(){ sendCommand("Input.ShowOSD") } //-------------Player.Open()------------------- //Play a single video from file def playFile(fileName){ //{"jsonrpc":"2.0","id":"1","method":"Player.Open","params":{"item":{"file":"Media/Big_Buck_Bunny_1080p.mov"}}} sendCommand("Player.Open", ["item": ["file": fileName]]) } //Play a Playlist given the numeric playlist ID def playPlaylist(playlistid){ //{"jsonrpc":"2.0","id":1,"method":"Player.Open","params":{"item":{"playlistid":1},"options":{"repeat":"all"}}} sendCommand("Player.Open", ["item": [ "playlistid": playlistid ]]) //does not include the repeat option } //Clear a Playlist given the numeric playlist ID def clearPlaylist(playlistid){ //{"jsonrpc":"2.0","id":1,"method":"Playlist.Clear","params":{"playlistid":1}} sendCommand("Playlist.Clear", ["playlistid": playlistid]) } //add a file to a playlist, given the file and the numeric playlist id def addToPlaylist(playlistid, fileName){ //{"jsonrpc":"2.0","id":1,"method":"Playlist.Add","params":{"playlistid":1,"item":{"file":"Media/Big_Buck_Bunny_1080p.mov"}}} sendCommand("Playlist.Add", ["playlistid": playlistid, "item": ["file": fileName]]) } //------------Music Player - Mapped Commands----------------- def playTrack(filename){ playFile(filename) } def playText(textToSpeak){ //TODO: implement TTS } //Default playlists: music (playlistid = 0), video (1) and pictures (2) def setTrack(filename){ addToPlaylist(1, filename) } def resumeTrack(someMap){ //TODO: implement resumeTrack ("Set and play the given track and maintain queue position") } def restoreTrack(someMap){ //TODO: implement restoreTrack ("Restore the track with the given data") } //------------- Player Status ------ def getActivePlayers(){ sendCommand("Player.GetActivePlayers") } def getVideoPlayerStatus(playerID){ playerID = playerID ?: 1 //default to getting the video player info log.debug "Getting status for player id: ${playerID}" def params = [ "properties": [ "title", "season", "episode", "duration", "showtitle", "tvshowid", "thumbnail", "streamdetails" ], "playerid": playerID ?: 1 ] sendCommand("Player.GetItem", params, "VideoGetItem") } def getAudioPlayerStatus(playerID){ playerID = playerID ? playerID : 0 //default to getting the audio player info log.debug "Getting status for player id: ${playerID}" def params = [ "properties": ["title", "album", "artist", "duration", "thumbnail", "file", "fanart", "streamdetails"], "playerid": playerID ] sendCommand("Player.GetItem", params, "AudioGetItem") } def getApplicationProperties(){ //{"jsonrpc": "2.0", "method": "Application.GetProperties", "params": {"properties": ["volume"]}, "id": 1} sendCommand("Application.GetProperties", ["properties": [ "volume", "muted", "name", "version" ] ]) } //------------- Extra Super Fun Methods --------------- def showNotification(title, message, image="info"){ sendCommand("GUI.ShowNotification", [ "title": title, "message": message, "image": image ]) //TODO: Add SmartThings image to notification } def executeAddon(addonid){ sendCommand("Addons.ExecuteAddon", [ "wait": false, "addonid": addonid ]) } def refresh() { log.debug "Executing 'refresh'" log.debug "Getting status from ${state.host}:${state.port}" //CheckEventSubscription() ResetEventSubscriptions() def hubActions = [] hubActions << getActivePlayers() hubActions } //--------map Switch On/Off to usable commands def on(){ play() } def off(){ pause() } // -------------------- HTTP Command Helpers ----------------------------- def sendPlayerCommand(command, parameters=null, id=null){ def playerid = device.currentValue("playerID") playerid = (playerid != null ? playerid : 1) //don't use elvis operator here as 0 is a valid playerid def params = parameters ?: [:] params.put("playerid", playerid) log.debug "params: $params" sendCommand(command, params, id) } def sendCommand(command, parameters=null, id=null){ state.lastCommand = command def content = [ "jsonrpc":"2.0", "method":"$command", "id": 1 ] //if the parameters and id were passed in, add them to the JSON command if(parameters) content.put("params", parameters) if(id) content.put("id", id) def json = new groovy.json.JsonBuilder() def payload = json.call(content) log.trace "Sending command: ${command}" //def pretty = json.toPrettyString() //don't set the DNI here, we'll use the MAC from the Parent SmartApp //device.deviceNetworkId = getHostHexAddress() def path = "/jsonrpc" def headers = [:] headers.put("HOST", getHostAddress()) headers.put("Content-Type", "application/json") if(username){ def pair ="$username:$password" def basicAuth = pair.bytes.encodeBase64(); headers.put("Authorization", "Basic " + basicAuth ) } def method = "POST" def result = new physicalgraph.device.HubAction( method: method, path: path, body: payload, headers: headers ) result } def basicGet() { //don't set the DNI here, we'll use the MAC from the Parent SmartApp //device.deviceNetworkId = getHostHexAddress() def path = "/jsonrpc" def headers = [:] headers.put("GET", getHostAddress()) //headers.put("Content-Type", "application/x-www-form-urlencoded") def method = "GET" def result = new physicalgraph.device.HubAction( method: method, path: path, headers: headers ) result } //------------------------- UPnP Callbacks ----------------------------------- def CheckEventSubscription(){ //check to see if our UPnP Event Subscriptions are still valid def todo = [] def allTransport = state.transportSID ?: [:] //remove any of the expired or invalid subscriptions log.debug "Checking for expired subscriptions in $allTransport" def toRemove = allTransport.findAll { def expiration = it.value.toLong() def isRemovable = !expiration || expiration < now() || expiration == 0 isRemovable } log.debug "Unsubscribing from and removing: $toRemove" toRemove.each { todo << unsubscribeAction("/AVTransport/${state.udn}/event.xml", it.key) } //TODO: We really only need to keep one of each subscription type live //Refresh any of the still valid subscriptions def toKeep = allTransport - toRemove state.transportSID = toKeep log.debug "Refreshing subscription for: $toKeep" toKeep.each { todo << renewSubscription("/AVTransport/${state.udn}/event.xml", it.key) } //if we don't have any valid subscriptions left, let's subscribe so we have at least one if(toKeep.size() == 0) todo << subscribeAction("/AVTransport/${state.udn}/event.xml") //runIn(300, CheckEventSubscription) -- replaced with schedule(cron, event) def numActions = todo.size() log.debug "Returning $numActions HubActions" //sendHubCommand(todo) //force the send of the HubActions -- they don't seem to send from updated() todo.each{ sendHubCommand(it) } } def ResetEventSubscriptions(){ log.trace "Clearing existing event subscriptions and getting a new subscription." //queue up all our actions def todo = [] //unsubscribe everything state.transportSID.each { todo << unsubscribeAction("/AVTransport/${state.udn}/event.xml", it.key)} //then subscribe once todo << subscribeAction("/AVTransport/${state.udn}/event.xml") //send the queued up commands todo.each{ sendHubCommand(it) } } private subscribeAction(path, callbackPath="") { log.trace "subscribe($path, $callbackPath)" def address = getCallBackAddress() def ip = getUDNAddress() //varies from example code -- we are passing in the UDN during setup from the Service Manager def result = new physicalgraph.device.HubAction( method: "SUBSCRIBE", path: path, headers: [ HOST: ip, CALLBACK: "", NT: "upnp:event", TIMEOUT: "Second-28800" ] ) log.trace "SUBSCRIBE $ip to $path" return result } /* SUBSCRIBE publisher path HTTP/1.1 HOST: publisher host:publisher port USER-AGENT: OS/version UPnP/1.1 product/version CALLBACK: NT: upnp:event TIMEOUT: Second-requested subscription duration */ private renewSubscription(path, SID){ log.trace "renewSubscription($path, $SID)" def address = getCallBackAddress() def ip = getUDNAddress() //We are passing in the UDN during setup from the Service Manager def result = new physicalgraph.device.HubAction( method: "SUBSCRIBE", path: path, headers: [ HOST: ip, SID: "uuid:${SID}", TIMEOUT: "Second-28800" //8 hours isn't respected by Kodi ] ) log.trace "RENEW SUBSCRIPTION $ip for $SID" return result } /* SUBSCRIBE publisher path HTTP/1.1 HOST: publisher host:publisher port SID: uuid:subscription UUID TIMEOUT: Second-requested subscription duration */ private unsubscribeAction(path, SID){ log.trace "unsubscribeAction($path, $SID)" def address = getCallBackAddress() def ip = getUDNAddress() //We are passing in the UDN during setup from the Service Manager def result = new physicalgraph.device.HubAction( method: "UNSUBSCRIBE", path: path, headers: [ HOST: ip, SID: "uuid:${SID}" ] ) log.trace "UNSUBSCRIBE FROM $ip at $path" return result } // UNSUBSCRIBE /* UNSUBSCRIBE publisher path HTTP/1.1 HOST: publisher host:publisher port SID: uuid:subscription UUID */ //--------------------------- Helper Methods ----------------------------------- def splitURL(url){ log.debug "splitting atoms" url = url ?: state.destURL if(url.contains("http://")){ url = url.replaceAll("http://", "") //TODO: only pull off the last character if it's a / url = url.replaceAll("/", "") //uri = new URI(url) //host = uri?.getHost() } if(url.contains(":")){ log.debug "Splitting out the host and port" def values = url.tokenize(":") state.host = values[0] state.port = values[1] } else{ log.debug "Host ONLY provided. Defaulting port to 80" state.host = url state.port = "80" } log.debug "Host: ${state.host} Port: ${state.port}" } //----------------- IP and Port Hex Conversion Utilities ----------------------- // gets the address of the hub private getCallBackAddress() { return device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP") } def getHostHexAddress(){ def hosthex = convertIPtoHex(state.host) def porthex = convertPortToHex(state.port) return "$hosthex:$porthex" } def getHostAddress(){ return state.host + ":" + state.port } def getUDNAddress(){ return state.udnAddress + ":" + state.udnPort } private String convertIPtoHex(ipAddress) { String hex = ipAddress.tokenize( '.' ).collect { String.format( '%02X', it.toInteger() ) }.join() return hex } private String convertPortToHex(port) { String hexport = port.toString().format( '%04X', port.toInteger() ) return hexport }