diff --git a/PlexManager/PlexHomeTheatre.groovy b/PlexManager/PlexHomeTheatre.groovy index 1359247..1d370d6 100644 --- a/PlexManager/PlexHomeTheatre.groovy +++ b/PlexManager/PlexHomeTheatre.groovy @@ -1,7 +1,8 @@ /** - * Pi Relay Control + * Plex Home Theatre * * Copyright 2016 Tom Beech + * Modified by Ph4r * * 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: @@ -28,13 +29,31 @@ metadata { definition (name: "Plex Home Theatre", namespace: "ibeech", author: "ibeech") { capability "Switch" - capability "musicPlayer" + capability "musicPlayer" + capability "Sensor" + capability "Actuator" command "scanNewClients" command "setPlaybackIcon", ["string"] command "setPlaybackTitle", ["string"] - command "setVolumeLevel", ["number"] - } + command "setVolumeLevel", ["number"] + command "setPlaybackPosition", ["number"] + command "setPlaybackDuration", ["number"] + command "playbackType", ["string"] + command "stepBack" + command "stepForward" + command "home" + command "moveUp" + command "music" + command "moveLeft" + command "select" + command "moveRight" + command "back" + command "moveDown" + + input name: "CommandTarget", type: "enum", title: "Command Target", options: ["Server", "Client", "ServerProxy"], description: "Select Command Target", required: true, defaultValue: "Client" + input name: "TimelineStatus", type: "enum", title: "Timeline Status", options: ["None", "Subscribe", "Poll", "ServerSubscribe"], description: "Select How To Get Status", required: true, defaultValue: "Subscribe" + } simulator { // TODO: define status and reply messages here @@ -44,9 +63,9 @@ metadata { multiAttributeTile(name:"status", type: "generic", width: 6, height: 4, canChangeIcon: true){ tileAttribute ("device.status", key: "PRIMARY_CONTROL") { - attributeState "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#79b821" + attributeState "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#00a0dc" attributeState "stopped", label:'Stopped', action:"music Player.play", icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" - attributeState "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#FFA500" + attributeState "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#e86d13" } tileAttribute ("device.trackDescription", key: "SECONDARY_CONTROL") { attributeState "trackDescription", label:'${currentValue}' @@ -57,36 +76,81 @@ metadata { } } - standardTile("next", "device.status", width: 2, height: 2, decoration: "flat") { - state "next", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn", backgroundColor:"#ffffff" + standardTile("previous", "device.status", width: 1, height: 1, decoration: "flat") { + state "previous", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn", backgroundColor:"#ffffff" + } + + standardTile("stepBack", "device.status", width: 1, height: 1, decoration: "flat") { + state "stepBack", label:'<10', action:"stepBack", icon:"", backgroundColor:"#ffffff" + } + + standardTile("stop", "device.status", width: 2, height: 1, decoration: "flat") { + state "default", label:'', action:"music Player.stop", icon:"st.sonos.stop-btn", backgroundColor:"#ffffff" } - standardTile("previous", "device.status", width: 2, height: 2, decoration: "flat") { - state "previous", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn", backgroundColor:"#ffffff" + standardTile("stepForward", "device.status", width: 1, height: 1, decoration: "flat") { + state "stepForward", label:'>30', action:"stepForward", icon:"", backgroundColor:"#ffffff" } - standardTile("fillerTile", "device.status", width: 5, height: 1, decoration: "flat") { - state "default", label:'', action:"", icon:"", backgroundColor:"#ffffff" + standardTile("next", "device.status", width: 1, height: 1, decoration: "flat") { + state "next", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn", backgroundColor:"#ffffff" } - standardTile("stop", "device.status", width: 2, height: 2, decoration: "flat") { - state "default", label:'', action:"music Player.stop", icon:"st.sonos.stop-btn", backgroundColor:"#ffffff" - state "grouped", label:'', action:"music Player.stop", icon:"st.sonos.stop-btn", backgroundColor:"#ffffff" + valueTile("playbackType", "device.playbackType", decoration: "flat", width: 6, height: 1) { + state "playbackType", label:'Playing: ${currentValue}', defaultState: true + } + + controlTile("playbackPosition", "device.playbackPosition", "slider", width: 5, height: 1, range:"(0..100)") { + state "playbackPosition", label:'Position', action:"setPlaybackPosition" + } + + valueTile("playbackDuration", "device.playbackDuration", width: 1, height: 1) { + state "playbackDuration", label:'${currentValue}', defaultState: 0 + } + + standardTile("home", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'', action:"home", icon:"st.Home.home2", backgroundColor:"#ffffff" + } + + standardTile("moveUp", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'', action:"moveUp", icon:"st.thermostat.thermostat-up", backgroundColor:"#ffffff" + } + + standardTile("music", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'music', action:"music", icon:"", backgroundColor:"#ffffff" } - standardTile("scanNewClients", "device.status", width: 5, height: 1, decoration: "flat") { - state "default", label:'Scan New Clients', action:"scanNewClients", icon:"state.icon", backgroundColor:"#ffffff" - state "grouped", label:'', action:"scanNewClients", icon:"state.icon", backgroundColor:"#ffffff" + standardTile("moveLeft", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'', action:"moveLeft", icon:"st.thermostat.thermostat-left", backgroundColor:"#ffffff" + } + + standardTile("select", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'select', action:"select", icon:"", backgroundColor:"#ffffff" } - main "status" - details (["status", "previous", "stop", "next", "currentSong"]) + standardTile("moveRight", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'', action:"moveRight", icon:"st.thermostat.thermostat-right", backgroundColor:"#ffffff" + } + + standardTile("back", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'back', action:"back", icon:"", backgroundColor:"#ffffff" + } + + standardTile("moveDown", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'', action:"moveDown", icon:"st.thermostat.thermostat-down", backgroundColor:"#ffffff" + } + + standardTile("scanNewClients", "device.status", width: 2, height: 2, decoration: "flat") { + state "default", label:'New Clients', action:"scanNewClients", icon:"st.secondary.refresh", backgroundColor:"#ffffff" + } + + main "status" } } // parse events into attributes def parse(String description) { - log.debug "Virtual siwtch parsing '${description}'" + log.debug "Virtual switch parsing '${description}'" } def play() { @@ -112,13 +176,13 @@ def stop() { sendEvent(name: "switch", value: device.deviceNetworkId + ".stop"); sendEvent(name: "switch", value: "off"); sendEvent(name: "status", value: "stopped"); - setPlaybackTitle("Stopped"); + //setPlaybackTitle("Stopped"); } def previousTrack() { log.debug "Executing 'previous': " - setPlaybackTitle("Skipping previous"); + //setPlaybackTitle("Skipping previous"); sendCommand("previous"); } @@ -140,6 +204,79 @@ def setVolumeLevel(level) { sendCommand("setVolume." + level); } +def setPlaybackPosition(level) { + log.debug "Executing 'setPlaybackPosition(" + level + ")'" + sendEvent(name: "playbackPosition", value: level); + sendCommand("setPosition." + level); +} + +def setPlaybackDuration(level) { + log.debug "Executing 'setPlaybackDuration(" + level + ")'" + sendEvent(name: "playbackDuration", value: level); +} + +def stepBack() { + log.debug "Executing 'stepBack'" + + //setPlaybackTitle("Jumping back 10s"); + sendCommand("stepBack"); +} + +def stepForward() { + log.debug "Executing 'stepForward'" + + //setPlaybackTitle("Jumping up 30s"); + sendCommand("stepForward"); +} + +def home() { + log.debug "Executing 'home'" + + sendCommand("home"); +} + +def moveUp() { + log.debug "Executing 'moveUp'" + + sendCommand("moveUp"); +} + +def music() { + log.debug "Executing 'music'" + + sendCommand("music"); +} + +def moveLeft() { + log.debug "Executing 'moveLeft'" + + sendCommand("moveLeft"); +} + +def select() { + log.debug "Executing 'select'" + + sendCommand("select"); +} + +def moveRight() { + log.debug "Executing 'moveRight'" + + sendCommand("moveRight"); +} + +def back() { + log.debug "Executing 'back'" + + sendCommand("back"); +} + +def moveDown() { + log.debug "Executing 'moveDown'" + + sendCommand("moveDown"); +} + def sendCommand(command) { def lastState = device.currentState('switch').getValue(); @@ -149,7 +286,7 @@ def sendCommand(command) { def setPlaybackState(state) { - log.debug "Executing 'setPlaybackState'" + log.debug "Executing 'setPlaybackState' to state $state" switch(state) { case "stopped": sendEvent(name: "switch", value: "off"); @@ -186,4 +323,16 @@ def setPlaybackIcon(iconUrl) { def playbackType(type) { sendEvent(name: "playbackType", value: type); -} \ No newline at end of file +} + +def volumeLevelIn(level) { + sendEvent(name: "level", value: level); +} + +def playbackPositionIn(level) { + sendEvent(name: "playbackPosition", value: level); +} + +def playbackDurationIn(level) { + sendEvent(name: "playbackDuration", value: level); +} diff --git a/PlexManager/PlexManager.groovy b/PlexManager/PlexManager.groovy index 33b5756..f6cdd8b 100644 --- a/PlexManager/PlexManager.groovy +++ b/PlexManager/PlexManager.groovy @@ -2,6 +2,7 @@ * Plex Manager * * Copyright 2016 iBeech + * Modified by Ph4r 2017 * * 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: @@ -27,12 +28,13 @@ definition( name: "Plex Manager", namespace: "ibeech", - author: "ibeech", + author: "ibeech & Ph4r", description: "Add and Manage Plex Home Theatre endpoints", category: "Safety & Security", iconUrl: "http://download.easyicon.net/png/1126483/64/", iconX2Url: "http://download.easyicon.net/png/1126483/128/", - iconX3Url: "http://download.easyicon.net/png/1126483/128/") + iconX3Url: "http://download.easyicon.net/png/1126483/128/", + oauth: [displayName: "PlexManager", displayLink: ""]) preferences { page(name: "startPage") @@ -41,7 +43,7 @@ preferences { } def startPage() { - if (state?.authenticationToken) { return clientPage() } + if (atomicState?.authenticationToken) { return clientPage() } else { return authPage() } } @@ -58,16 +60,15 @@ def authPage() { } def clientPage() { - if (!state.authenticationToken) { getAuthenticationToken() } - - def showUninstall = state.appInstalled - def devs = getClientList() - //log.debug "devs: ${devs}" - return dynamicPage(name: "clientPage", uninstall: true, install: true) { + if (!atomicState.authenticationToken) { getAuthenticationToken() } + def showUninstall = atomicState.appInstalled + def clnts = getClientList() ? getClientList() : getAuthenticationToken() + def clntDesc = clnts.size() ? "Found (${clnts.size()}) Clients..." : "Tap to Choose" + return dynamicPage(name: "clientPage", uninstall: true, install: true) { section("Client Selection Page") { - paragraph("Devices with [status] on the end only provide status reporting and not control of the Plex Client via the ST App") - input "selectedClients", "enum", title: "Select Your Clients...", options: devs, multiple: true, required: false, submitOnChange: true + input "clients", "enum", title: "Select Your Clients...", description: clntDesc, metadata: [values:clnts], multiple: true, required: false, submitOnChange: true href "authPage", title:"Go Back to Auth Page", description: "Tap to edit..." + input "showAllDevs", "bool", title: "Show All Devices Regardless of Capability", defaultValue: "false", submitOnChange: true } } } @@ -77,193 +78,349 @@ def clientListOpt() { } def getClientList() { - def devs = [] + def devs = [:] log.debug "Executing 'getClientList'" - def params = [ - uri: "https://plex.tv/devices.xml", - contentType: 'application/xml', - headers: [ - 'X-Plex-Token': state.authenticationToken - ] - ] - httpGet(params) { resp -> - log.debug "Parsing plex.tv/devices.xml" - def devices = resp.data.Device - devices.each { thing -> - thing.@provides.text().tokenize(',').each { provider -> - if(provider == "player") { - thing.Connection.each { con -> + try{ + def params = [ + uri: "https://plex.tv/devices.xml", + contentType: 'application/xml', + headers: [ + 'X-Plex-Token': atomicState.authenticationToken + ] + ] + // GET 3rd level IP of Plex server + def plexServerIPShort = settings.plexServerIP.substring(0 , plexServerIP.lastIndexOf(".")) + + httpGet(params) { resp -> + log.debug "Parsing plex.tv/devices.xml" + def devices = resp.data.Device + + def deviceNames = [] + devices.each { thing -> + + def capabilities = thing.@provides.text() + + // If these capabilities + if(capabilities.contains("player")||capabilities.contains("client")||settings.showAllDevs){ + + //Define name based on name unless blank then use device name + def whatToCallMe = "Unknown" + if(thing.@name.text() != "") {whatToCallMe = "${thing.@name.text()}-${thing.@product.text()}"} + else if(thing.@device.text()!="") {whatToCallMe = "${thing.@device.text()}-${thing.@product.text()}"} + + // Create alternative name if same name + def tempName = whatToCallMe + for (int i = 2; i < 100; i++) { + if(deviceNames.contains(tempName)){ + tempName = "${whatToCallMe} #$i" + }else{ + whatToCallMe = tempName + break + } + } + + deviceNames << whatToCallMe + + def addressVal = "0.0.0.0" + def portVal = "0" + def listName = whatToCallMe + + // Get IP Address for those with an IP in the same range as your Plex Server if connection IP available (will only return a single entry for the local device) + thing.Connection.each { con -> + def uri = con.@uri.text() - def address = (uri =~ 'https?://([^:]+)')[0][1] - devs << ["${thing.@name.text()}|${thing.@clientIdentifier.text()}|${address}":"${thing.@name.text()}"] - } + def address = (uri =~ 'https?://([^:]+)')[0][1] + def port = uri.split(":")[2].replaceAll("/","") + + //Check if IP on same range + if(plexServerIPShort == address.substring(0 , address.lastIndexOf("."))){ + addressVal = address + portVal = port + } + } + + + // Add to list + if(devs.findIndexValues { it =~ /${thing.@clientIdentifier.text()}/ } == []){ + if(portVal == "0"){ listName = listName + "*" } + devs << ["${whatToCallMe}|${thing.@clientIdentifier.text()}|${addressVal}|${portVal}": "$listName"] + } } - } - - if(thing.@device.text() == "Xbox One") { - devs << ["${thing.@name.text()}[status]|${thing.@clientIdentifier.text()}|0.0.0.0":"${thing.@name.text()}[status]"] - } - - if(thing.@provides.text() == "client") { - devs << ["${thing.@device.text()}[status]|${thing.@clientIdentifier.text()}|0.0.0.0":"${thing.@device.text()}[status]"] - } + } } + return devs.sort { a, b -> a.value.toLowerCase() <=> b.value.toLowerCase() } } - return devs.sort() + catch (ex) { + if (ex instanceof groovyx.net.http.HttpResponseException) { + if(ex.message.contains("unauthorized")) { + log.debug "The current Authentication Token has expired... Re-authenicating..." + return null + } + } + else { + log.warn "getClientList Exception: ${ex}" + return null + } + } } def installed() { - state.appInstalled = true + atomicState.appInstalled = true log.debug "Installed with settings: ${settings}" initialize() } def initialize() { - - state.poll = true; - regularPolling(); + atomicState.commandID = -1; + atomicState.stateCommandSent = now(); + schedule("9 0/1 * 1/1 * ?", regularPolling); } def updated() { log.debug "Updated with settings: ${settings}" + atomicState.accessToken = createAccessToken() + log.debug "URL FOR USE IN PLEX WEBHOOK:\n${getApiServerUrl()}/api/smartapps/installations/${app.id}/pwh?access_token=${atomicState.accessToken}" unsubscribe() - if(selectedClients) { + if(clients) { - selectedClients.each { client -> - def item = client.tokenize('|') - def name = item[0] - def address = item[1] - def uniqueIdentifier = item[2] + def children = getChildDevices() + def clnts = clients.collect { client -> + def item = client.tokenize('|') + def phtName = item[0] + def phtIP = item[1] + def phtIdentifier = item[2] + def port = item[3] + log.info "Updating PHT: " + phtName + " with IP: " + phtIP + ":" + port + " and machine identifier: " + phtIdentifier + + def child_deviceNetworkID = childDeviceID(phtIP, phtIdentifier, port); + + //def pht = getChildDevice(client) + def pht = children.find{ d -> d.deviceNetworkId.contains(phtIP) } - updatePHT(name, address, uniqueIdentifier); + if(!pht) { + // The PHT does not exist, create it + log.debug "creating ${phtName} with id $client" + pht = addChildDevice("ibeech", "Plex Home Theatre", child_deviceNetworkID, theHub.id, [label:phtName, name:phtName]) + pht.take(); + pht.setPlaybackState("stopped"); + log.debug "created ${pht.displayName} with id $client" + } + else { + log.debug "found ${pht.displayName} with id $client already exists" + // Update the network device ID + if(pht.deviceNetworkId != child_deviceNetworkID) { + log.trace "Updating this devices network ID, so that it is consistant" + pht.deviceNetworkId = childDeviceID(phtIP, phtIdentifier, port); + } + } + + return pht } + + log.debug "created ${clnts?.size()} clients" + + unschedule() + + def deleter // Delete any that are no longer in settings + if(!clnts) { + deleter = getAllChildDevices() + log.warn "found empty clnts list" + } + else { //delete unselected clients + deleter = getChildDevices().findAll { !clnts.contains(it.deviceNetworkId) } + } + def listC = deleter - clnts.intersect(deleter) //Text looks good, but intersect of the lists returns empty set? + log.warn "clients: ${clients}, clnts: ${clnts}, deleter: ${deleter}, listC: ${listC}, deleting ${listC.size()} devices like ${listC[0]}" + //deleter.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) + //listC.each { deleteChildDevice(it) } //inherits from SmartApp (data-management) + + + initialize() + } - //state.authenticationToken = null; - //state.tokenUserName = null; - state.poll = false; - //getClients(); + atomicState.commandID = -1; - if (!state.authenticationToken) { + if (!atomicState.authenticationToken) { getAuthenticationToken() } - if(!state.poll){ - state.poll = true; - regularPolling(); - } + regularPolling(); - subscribe(location, null, response, [filterEvents:false]) + // Renew the subscriptions + subscribe(location, null, response, [filterEvents:false]) + subscribe(location, "playbackDuration", response) + subscribe(location, "switch", switchChange) + //subscribe(app, onAppTouch) } def uninstalled() { - //unsubscribe() - state.poll = false; - //removeChildDevices(getChildDevices()) + removeChildDevices(getChildDevices()) + //unschedule() //This is called automatically by the Cloud +} + +def onAppTouch(event) { + regularPolling() } private removeChildDevices(delete) { try { delete.each { - deleteChildDevice(it.deviceNetworkId) + def phtIP = getPHTAddress(it.deviceNetworkId); + def phtPort = getPHTPort(it.deviceNetworkId); + def phtID = getPHTIdentifier(it.deviceNetworkId); + executeClientRequest("/timeline/unsubscribe", phtID, phtIP, phtPort , "GET"); + deleteChildDevice(it.deviceNetworkId) log.info "Successfully Removed Child Device: ${it.displayName} (${it.deviceNetworkId})" } } catch (e) { log.error "There was an error (${e}) when trying to delete the child device" } } -def response(evt) { +mappings { + path("/pwh") { action: [ POST: "plexWebHookHandler", GET: "plexWebHookHandler", PUT: "plexWebHookHandler"] } +} -log.trace "in response(evt)"; +def plexWebHookHandler() { + def jsonSlurper = new groovy.json.JsonSlurper() + def plexJSON = jsonSlurper.parseText(params.payload) + //log.debug "WebHooks data\nServer ${plexJSON.Server}\nPlayer: ${plexJSON.Player}\nFull: ${plexJSON}" + def command = "" + def playerID = plexJSON.Player.uuid + def mediaType = plexJSON.Metadata.type + // change command to right format + switch(plexJSON.event) { + case ["media.play","media.resume"]: command = "playing"; break; + case "media.pause": command = "paused"; break; + case "media.stop": command = "stopped"; break; + return + } + def children = getChildDevices() + def pht = children.find{ d -> d.deviceNetworkId.contains(playerID) } + def timelineStatus = "Subscribe" + if (pht?.settings?.TimelineStatus.toString() != "") { + timelineStatus = pht.settings.TimelineStatus.toString() + } + if (timelineStatus != "None") { return } + pht.setPlaybackState(command); + pht.playbackType(mediaType); + pht.setPlaybackTitle(plexJSON.Metadata.title); + + def playingTime = 0L + if (plexJSON.Metadata?.viewOffset) {playingTime = plexJSON.Metadata?.viewOffset.toLong();} + def playingDuration = playingTime.toLong(); + if (plexJSON.Metadata?.duration) {playingDuration = plexJSON.Metadata?.duration.toLong();} + def playingPosition = (( playingTime / playingDuration ) * 100).toLong() + + log.trace "Determined that $pht Media is: $command is $playingPosition% Complete @ $playingTime/$playingDuration" + + pht.playbackPositionIn(playingPosition); + pht.playbackDurationIn(playingDuration); +} + +def response(evt) { + def msg = parseLanMessage(evt.description); - if(msg && msg.body && msg.body.startsWith(" - log.debug "Checking $pht for updates" - - // Convert the devices full network id to just the IP address of the device - //def address = getPHTAddress(pht.deviceNetworkId); - def identifier = getPHTIdentifier(pht.deviceNetworkId); - - // Look at all the current content playing, and determine if anything is playing on this device - def currentPlayback = mediaContainer.Video.find { d -> d.Player.@machineIdentifier.text() == identifier } - - // If there is no content playing on this device, then the state is stopped - def playbackState = "stopped"; - - // If we found active content on this device, look up its current state (i.e. playing or paused) - if(currentPlayback) { - - playbackState = currentPlayback.Player.@state.text(); - } - - log.trace "Determined that $pht is: " + playbackState - - pht.setPlaybackState(playbackState); - - log.trace "Current playback type:" + currentPlayback.@type.text() - pht.playbackType(currentPlayback.@type.text()) - switch(currentPlayback.@type.text()) { - case "movie": - pht.setPlaybackTitle(currentPlayback.@title.text()); - break; - - case "": - pht.setPlaybackTitle("..."); - break; - - case "clip": - pht.setPlaybackTitle("Trailer"); - break; - - case "episode": - pht.setPlaybackTitle(currentPlayback.@grandparentTitle.text() + ": " + currentPlayback.@title.text()); - } - } - - } -} - -def updatePHT(phtName, phtIP, phtIdentifier){ - - if(phtName && phtIP && phtIdentifier) { - - log.info "Updating PHT: " + phtName + " with IP: " + phtIP + " and machine identifier: " + phtIdentifier + if (atomicState?.commandID == null || (msg.body.contains("commandID") && mediaContainer?.@commandID.text().toInteger() >= 0)) { + if (mediaContainer?.@commandID.text().toInteger() >= atomicState?.commandID.toInteger()) { atomicState.commandID = mediaContainer.@commandID.text().toInteger(); } + + def children = getChildDevices() - def children = getChildDevices() - def child_deviceNetworkID = childDeviceID(phtIP, phtIdentifier); + def thisID = msg.headers["X-Plex-Client-Identifier"] - def pht = children.find{ d -> d.deviceNetworkId.contains(phtIP) } + def pht = children.find{ d -> d.deviceNetworkId.contains(thisID) } - if(!pht){ - // The PHT does not exist, create it - log.debug "This PHT does not exist, creating a new one now" - pht = addChildDevice("ibeech", "Plex Home Theatre", child_deviceNetworkID, theHub.id, [label:phtName, name:phtName]) - } else { + if (msg.body.contains("Timeline") && (now() >= (atomicState.stateCommandSent + 900L) )){ + def playbackState = "stopped"; + def playingType = "NONE"; + def playingLevel = 0; + def playinglocation = "none"; + def indexName = "nav"; + def mediaLocation; - // Update the network device ID - if(pht.deviceNetworkId != child_deviceNetworkID) { - log.trace "Updating this devices network ID, so that it is consistant" - pht.deviceNetworkId = childDeviceID(phtIP, phtIdentifier); + // location=[navigation,fullScreenVideo,fullScreenPhoto,fullScreenMusic] + playinglocation = mediaContainer?.@location.text(); + + switch(playinglocation) { + case "navigation": + indexName = "nav"; + break; + case "fullScreenVideo": + indexName = "video"; + break; + case "fullScreenPhoto": + indexName = "photo"; + break; + case "fullScreenMusic": + indexName = "music"; + break; + } + + if (indexName != "nav") + { + mediaLocation = mediaContainer.'*'.find { node-> node.@type == indexName } + playbackState = mediaLocation.@state.text(); + playingType = mediaLocation.@type.text(); + playingLevel = mediaLocation.@volume.text(); + + log.trace "Determined that $pht is: $playbackState of type $playingType @ $playinglocation:$playinglevel" + + pht.setPlaybackState(playbackState); + pht.playbackType(playingType); + pht.volumeLevelIn(playingLevel); + pht.setPlaybackTitle(playinglocation); + + if (indexName != "photo") + { + def playingDuration = mediaLocation.@duration.text().toLong(); + def playingTime = mediaLocation.@time.text().toLong(); + def playingPosition = (( playingTime / playingDuration ) * 100).toLong() + + log.trace "Determined that $pht Media is: $playbackState is $playingPosition% Complete @ $playingTime/$playingDuration" + + pht.playbackPositionIn(playingPosition); + pht.playbackDurationIn(playingDuration); + } +/* + def inKey = mediaLocation.@key.text(); + def inMachineIdentifier = mediaLocation.@machineIdentifier.text(); + def inAddress = mediaLocation.@address.text(); + def inPort = mediaLocation.@port.text(); + def inProtocol = mediaLocation.@protocol.text(); + + def phtIP = getPHTAddress(pht.deviceNetworkId); + def phtPort = getPHTPort(pht.deviceNetworkId); + def phtID = getPHTIdentifier(pht.deviceNetworkId); + + + executeClientRequest("/mirror/details?key=$inKey&machineIdentifier=$inMachineIdentifier&address=$inAddress&port=$inPort&protocol=$inProtocol", phtID, phtIP, phtPort, "GET"); +*/ + } + else { + log.trace "No status available in $playinglocation message" + pht.setPlaybackState(playbackState); + pht.playbackType(playingType); + pht.volumeLevelIn(playinglevel); + pht.setPlaybackTitle(playinglocation); + } } - } - - // Renew the subscription - subscribe(pht, "switch", switchChange) - } + } + } } -def String childDeviceID(phtIP, identifier) { +def String childDeviceID(phtIP, identifier, port) { - def id = "pht." + settings.plexServerIP + "." + phtIP + "." + identifier + def id = "pht." + settings.plexServerIP + "." + phtIP + "." + identifier + "." + port //log.trace "childDeviceID: $id"; return id; } @@ -286,7 +443,7 @@ def String getPHTIdentifier(deviceNetworkId) { def String getPHTCommand(deviceNetworkId) { def parts = deviceNetworkId.tokenize('.'); - def part = parts[10]; + def part = parts[11]; //log.trace "PHTCommand: $part" return part @@ -294,10 +451,18 @@ def String getPHTCommand(deviceNetworkId) { def String getPHTAttribute(deviceNetworkId) { def parts = deviceNetworkId.tokenize('.'); - def part = parts[11]; + def part = parts[12]; //log.trace "PHTAttribute: $part" - return parts[11]; + return part; +} +def String getPHTPort(deviceNetworkId) { + + def parts = deviceNetworkId.tokenize('.'); + def part = parts[10]; + //log.trace "PHTPort: $part" + + return part } def switchChange(evt) { @@ -315,109 +480,208 @@ def switchChange(evt) { // Parse out the new switch state from the event data def command = getPHTCommand(evt.value); + // Parse out the PHT clientIdentifier from the event data + def phtID = getPHTIdentifier(evt.value); + + // Parse out the PHT IP address from the event data + def phtPort = getPHTPort(evt.value); + //log.debug "phtIP: " + phtIP log.debug "Command: $command" switch(command) { case "next": - log.debug "Sending command 'next' to $phtIP" - next(phtIP); + log.debug "Executing 'next'" + executeClientRequest("/playback/skipNext", phtID, phtIP, phtPort , "GET"); break; case "previous": - log.debug "Sending command 'previous' to $phtIP" - previous(phtIP); + log.debug "Executing 'next'" + executeClientRequest("/playback/skipPrevious", phtID, phtIP, phtPort , "GET"); break; case "play": + log.debug "Executing 'play'" + atomicState.stateCommandSent = now(); + executeClientRequest("/playback/play", phtID, phtIP, phtPort , "GET"); + break; + case "pause": - // Toggle the play / pause button for this PHT - playpause(phtIP); - break; + log.debug "Executing 'pause'" + atomicState.stateCommandSent = now(); + executeClientRequest("/playback/pause", phtID, phtIP, phtPort , "GET"); + break; - case "stop": - stop(phtIP); + case "stop": + log.debug "Executing 'stop'" + atomicState.stateCommandSent = now(); + executeClientRequest("/playback/stop", phtID, phtIP, phtPort , "GET"); break; case "scanNewClients": - getClients(); + getClientList() ? getClientList() : getAuthenticationToken() + break; case "setVolume": - setVolume(phtIP, getPHTAttribute(evt.value)); + setVolume(phtID, phtIP, phtPort, getPHTAttribute(evt.value)); break; + + case "setPosition": + setPosition(phtID, phtIP, phtPort, getPHTAttribute(evt.value)); + break; + + case "stepBack": + log.debug "Executing 'stepBack'" + executeClientRequest("/playback/stepBack", phtID, phtIP, phtPort , "GET"); + break; + + case "stepForward": + log.debug "Executing 'stepForward'" + executeClientRequest("/playback/stepForward", phtID, phtIP, phtPort , "GET"); + break; + + case "moveLeft": + log.debug "Executing 'moveLeft'" + executeClientRequest("/navigation/moveLeft", phtID, phtIP, phtPort , "GET"); + break; + + case "moveRight": + log.debug "Executing 'moveRight'" + executeClientRequest("/navigation/moveRight", phtID, phtIP, phtPort , "GET"); + break; + + case "moveDown": + log.debug "Executing 'moveDown'" + executeClientRequest("/navigation/moveDown", phtID, phtIP, phtPort , "GET"); + break; + + case "moveUp": + log.debug "Executing 'moveUp'" + executeClientRequest("/navigation/moveUp", phtID, phtIP, phtPort , "GET"); + break; + + case "select": + log.debug "Executing 'select'" + executeClientRequest("/navigation/select", phtID, phtIP, phtPort , "GET"); + break; + + case "back": + log.debug "Executing 'moveRight'" + executeClientRequest("/navigation/back", phtID, phtIP, phtPort , "GET"); + break; + + case "home": + log.debug "Executing 'home'" + executeClientRequest("/navigation/home", phtID, phtIP, phtPort , "GET"); + break; + + case "music": + log.debug "Executing 'music'" + executeClientRequest("/navigation/music", phtID, phtIP, phtPort , "GET"); + break; + } return; } -def setVolume(phtIP, level) { +def setVolume(phtID, phtIP, phtPort, level) { log.debug "Executing 'setVolume'" - executeRequest("/system/players/$phtIP/playback/setParameters?volume=$level", "GET"); + executeClientRequest("/playback/setParameters?volume=$level", phtID, phtIP, phtPort, "GET"); } -def regularPolling() { - - if(!state.poll) return; - - log.debug "Polling for PHT state" - - if(state.authenticationToken) { - updateClientStatus(); - } +def setPosition(phtID, phtIP, phtPort, level) { + log.debug "Executing 'setPosition'" + def children = getChildDevices() + def pht = children.find{ d -> d.deviceNetworkId.contains(phtIP) } - runIn(10, regularPolling); -} + if (pht) { + def duration = pht.currentValue("playbackDuration") + def selectedLevel = ( duration.toLong() * (level.toLong() / 100L) ).toLong() -def updateClientStatus(){ - log.debug "Executing 'updateClientStatus'" - - executeRequest("/status/sessions", "GET") + executeClientRequest("/playback/seekTo?offset=$selectedLevel", phtID, phtIP, phtPort, "GET"); + } } -def playpause(phtIP) { - log.debug "Executing 'playpause'" - - executeRequest("/system/players/" + phtIP + "/playback/play", "GET"); -} +def regularPolling() { -def stop(phtIP) { - log.debug "Executing 'stop'" - - executeRequest("/system/players/" + phtIP + "/playback/stop", "GET"); + initiateClientRequest() + + runOnce( new Date(now() + 30000L), initiateClientRequest); } -def next(phtIP) { - log.debug "Executing 'next'" +def executeClientRequest(Path, phtID, phtIP, phtPort, method) { + // We don't have an authentication token + if(!atomicState.authenticationToken) { + getAuthenticationToken() + } - executeRequest("/system/players/" + phtIP + "/playback/skipNext", "GET"); -} - -def previous(phtIP) { - log.debug "Executing 'next'" + // We don't have an active subscription + if(atomicState?.commandID.toInteger() < 0 || atomicState?.commandID.toInteger() == null) { + initiateClientRequest() + } - executeRequest("/system/players/" + phtIP + "/playback/skipPrevious", "GET"); -} - -def executeRequest(Path, method) { - - log.debug "The " + method + " path is: " + Path; + atomicState.commandID = atomicState.commandID.toInteger() + 1; + if ( atomicState.commandID.toInteger() == Integer.MAX_VALUE ) atomicState.commandID = 0; + + def connStyle = "Client" + def children = getChildDevices() + def pht = children.find{ d -> d.deviceNetworkId.contains(phtIP) } + if (pht?.settings.CommandTarget.toString() != "") { + connStyle = pht.settings.CommandTarget.toString() + } - // We don't have an authentication token - if(!state.authenticationToken) { - getAuthenticationToken() + log.trace "ExecuteClientRequest - The $method path is: $Path and the ID is: $atomicState.commandID from: $phtID|$phtIP:$phtPort to $connStyle"; + + def sendPath = Path + if (!Path.contains("?")) { sendPath = Path + "?" } else { sendPath = Path + "&" } + def headers = [:] + def pathTarget ="" + if (connStyle == "Server") { + headers.put("HOST", "$settings.plexServerIP:32400") + //headers.put("X-Plex-Token", atomicState.authenticationToken) + //headers.put("X-Plex-Target-Client-Identifier", phtID) + pathTarget = "/system/players/" + phtIP + sendPath + "X-Plex-Token=$atomicState.authenticationToken&X-Plex-Target-Client-Identifier=$phtID" } - - def headers = [:] - headers.put("HOST", "$settings.plexServerIP:32400") - headers.put("X-Plex-Token", state.authenticationToken) - - try { + else if (connStyle == "ServerProxy") { + headers.put("HOST", "$settings.plexServerIP:32400") + headers.put("X-Plex-Device-Name", "STHub") + headers.put("X-Plex-Client-Identifier", "PlexManager") + headers.put("X-Plex-Client-Platform", "SmartThings") + headers.put("X-Plex-Version", "5.3.4.759") + headers.put("X-Plex-Platform", "SmartThings") + headers.put("X-Plex-Platform-Version", "4.4.4") + headers.put("X-Plex-Provides", "controller,sync-target") + headers.put("X-Plex-Product", "PlexManager for ST") + headers.put("X-Plex-Device", "STHub") + headers.put("X-Plex-Model", "v2") + headers.put("X-Plex-Device-Vendor", "Samsung") + headers.put("X-Plex-Device-Screen-Resolution", "1024x768 (Mobile)") + headers.put("X-Plex-Device-Screen-Density", "160") + headers.put("X-Plex-Username", atomicState.tokenUserName) + headers.put("Accept-Language", "en-us") + headers.put("Connection", "Keep-Alive") + headers.put("Accept-Encoding", "gzip") + headers.put("X-Plex-Target-Client-Identifier", phtID) + pathTarget = "/player" + sendPath + "X-Plex-Token=$atomicState.authenticationToken&commandID=$atomicState.commandID" + } + else { + headers.put("HOST", "$phtIP:$phtPort") + headers.put("X-Plex-Device-Name", "STHub") + headers.put("X-Plex-Client-Identifier", "PlexManager") + headers.put("X-Plex-Target-Client-Identifier", phtID) + pathTarget = "/player" + sendPath + "X-Plex-Client-Identifier=PlexManager&X-Plex-Device-Name=STHub&X-Plex-Token=$atomicState.authenticationToken&X-Plex-Target-Client-Identifier=$phtID&commandID=$atomicState.commandID" + } + + try { def actualAction = new physicalgraph.device.HubAction( method: method, - path: Path, + path: pathTarget, headers: headers) + log.debug"${actualAction}" + sendHubCommand(actualAction) } catch (Exception e) { @@ -425,6 +689,88 @@ def executeRequest(Path, method) { } } +def initiateClientRequest() { + def hub = location.hubs[0] + def localPort = hub.localSrvPortTCP + def localIP = hub.localIP + log.trace "initiateClientRequest"; + + // We don't have an authentication token + if(!atomicState.authenticationToken) { + getAuthenticationToken() + } + + getChildDevices().each { pht -> + + def timelineStatus = "Subscribe" + if (pht?.settings?.TimelineStatus && pht?.settings?.TimelineStatus.toString() != "") { + timelineStatus = pht.settings.TimelineStatus.toString() + } + log.trace "Initiating ${pht.deviceNetworkId} with $timelineStatus" + if (timelineStatus == "None") { return } + + def phtIP = getPHTAddress(pht.deviceNetworkId); + def phtPort = getPHTPort(pht.deviceNetworkId); + def phtID = getPHTIdentifier(pht.deviceNetworkId); + def headers = [:] + headers.put("X-Plex-Device-Name", "STHub") + headers.put("X-Plex-Client-Identifier", "PlexManager") + + headers.put("X-Plex-Client-Platform", "SmartThings") + headers.put("X-Plex-Version", "5.3.4.759") + headers.put("X-Plex-Platform", "SmartThings") + headers.put("X-Plex-Platform-Version", "4.4.4") + headers.put("X-Plex-Provides", "controller,sync-target") + headers.put("X-Plex-Product", "PlexManager for ST") + headers.put("X-Plex-Device", "STHub") + headers.put("X-Plex-Model", "v2") + headers.put("X-Plex-Device-Vendor", "Samsung") + headers.put("X-Plex-Device-Screen-Resolution", "1024x768 (Mobile)") + headers.put("X-Plex-Device-Screen-Density", "160") + headers.put("X-Plex-Username", atomicState.tokenUserName) + headers.put("Accept-Language", "en-us") + headers.put("Connection", "Keep-Alive") + headers.put("Accept-Encoding", "gzip") + headers.put("X-Plex-Target-Client-Identifier", phtID) + //headers.put("X-Plex-Token", atomicState.authenticationToken) + def sendCommandID = 0 + def pathToSend = "" + + if (timelineStatus == "Poll") { + headers.put("HOST", "$phtIP:$phtPort") + if (atomicState.commandID > sendCommandID) { + atomicState.commandID = atomicState.commandID.toInteger() + 1; + sendCommandID = atomicState.commandID + } + pathToSend = "/player/timeline/poll?wait=0&commandID=$sendCommandID&X-Plex-Token=$atomicState.authenticationToken" + } + else if (timelineStatus == "ServerSubscribe") { + headers.put("HOST", "$settings.plexServerIP:32400") + if (atomicState.commandID > sendCommandID) { sendCommandID = atomicState.commandID } + pathToSend = "/player/timeline/subscribe?port=$localPort&protocol=http&commandID=$sendCommandID&X-Plex-Token=$atomicState.authenticationToken" + } + else { + headers.put("HOST", "$phtIP:$phtPort") + if (atomicState.commandID > sendCommandID) { sendCommandID = atomicState.commandID } + pathToSend = "/player/timeline/subscribe?port=$localPort&protocol=http&commandID=$sendCommandID&X-Plex-Token=$atomicState.authenticationToken" + } + + try { + def actualAction = new physicalgraph.device.HubAction( + method: "GET", + path: pathToSend, + headers: headers) + + //log.debug"${actualAction}" + + sendHubCommand(actualAction) + } + catch (Exception e) { + log.debug "Hit Exception $e on $hubAction" + } + } +} + def getAuthenticationToken() { log.debug "Getting authentication token for Plex Server " + settings.plexServerIP @@ -432,17 +778,27 @@ def getAuthenticationToken() { def params = [ uri: "https://plex.tv/users/sign_in.json?user%5Blogin%5D=" + settings.plexUserName + "&user%5Bpassword%5D=" + URLEncoder.encode(settings.plexPassword), headers: [ - 'X-Plex-Client-Identifier': 'Plex', - 'X-Plex-Product': 'Device', - 'X-Plex-Version': '1.0' + 'X-Plex-Client-Identifier': 'PlexManager', + 'X-Plex-Product': 'PlexManager for ST', + 'X-Plex-Version': '5.3.4.759', + 'X-Plex-Device-Name': 'STHub', + 'X-Plex-Client-Platform': 'SmartThings', + 'X-Plex-Platform': 'SmartThings', + 'X-Plex-Platform-Version': '4.4.4', + 'X-Plex-Provides': 'controller,sync-target', + 'X-Plex-Device': 'STHub', + 'X-Plex-Model': 'v2', + 'X-Plex-Device-Vendor': 'Samsung', + 'X-Plex-Device-Screen-Resolution': '1024x768 (Mobile)', + 'X-Plex-Device-Screen-Density': '160' ] ] try { httpPostJson(params) { resp -> - state.tokenUserName = settings.plexUserName - state.authenticationToken = resp.data.user.authentication_token; - log.debug "Token is: " + state.authenticationToken + atomicState.tokenUserName = resp.data.user.username; + atomicState.authenticationToken = resp.data.user.authentication_token; + log.debug "Token is: " + atomicState.authenticationToken + " UserName is: " + atomicState.tokenUserName //+ " data is: " + resp.data } } catch (Exception e) {