/**
* Copyright 2023 Bloodtick
*
* 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.
*
* HubiThings OAuth
*
* Update: Bloodtick Jones
* Date: 2022-12-04
*
* 1.0.00 2022-12-04 First pass.
* ... Deleted
* 1.2.10 2023-01-07 Align version to Replica for next Beta release.
* 1.2.11 2023-01-11 Align version to Replica for next Beta release.
* 1.2.12 2023-01-12 Align version to Replica for next Beta release.
* 1.3.00 2023-01-13 Update to modal for OAuth redirect. UI refinement. Formal Release Candidate.
* 1.3.02 2023-01-26 Remove ST Virtual Device support and move to Replica (not completed)
* 1.3.03 2023-02-09 Support for SmartThings Virtual Devices. Major UI Button overhaul. Work to improve refresh.
* 1.3.04 2023-02-16 Support for SmartThings Scene MVP. Not released.
* 1.3.05 2023-02-18 Support for 200+ SmartThings devices. Increase OAuth maximum from 20 to 30.
* 1.3.06 2023-02-26 Natural order sorting.
* 1.3.07 2023-03-14 Bug fixes for possible Replica UI list nulls. C-8 hub migration OAuth warning.
LINE 30 MAX */
public static String version() { return "1.3.07" }
public static String copyright() { return "© 2023 ${author()}" }
public static String author() { return "Bloodtick Jones" }
import groovy.json.*
import java.util.*
import java.text.SimpleDateFormat
import java.net.URLEncoder
import hubitat.helper.RMUtils
import com.hubitat.app.ChildDeviceWrapper
import groovy.transform.CompileStatic
import groovy.transform.Field
@Field static final String sDefaultAppName="HubiThings OAuth"
@Field static final Integer iSmartAppDeviceLimit=30
@Field static final Integer iHttpSuccess=200
@Field static final Integer iHttpError=400
@Field static final Integer iRefreshInterval=0
@Field static final String sURI="https://api.smartthings.com"
@Field static final String sOauthURI="https://auth-global.api.smartthings.com"
@Field static final List lOauthScope=["r:locations:*", "x:locations:*", "r:devices:*", "x:devices:*", "r:scenes:*", "x:scenes:*"]
@Field static final String sColorDarkBlue="#1A77C9"
@Field static final String sColorLightGrey="#DDDDDD"
@Field static final String sColorDarkGrey="#696969"
@Field static final String sColorDarkRed="DarkRed"
definition(
parent: 'replica:HubiThings Replica',
name: sDefaultAppName,
namespace: "replica",
author: "bloodtick",
description: "Hubitat Child Application to manage SmartThings OAuth",
category: "Convenience",
importUrl:"https://raw.githubusercontent.com/bloodtick/Hubitat/main/hubiThingsReplica/hubiThingsOauth.groovy",
iconUrl: "",
iconX2Url: "",
singleInstance: false,
installOnOpen: true
){}
String getDefaultLabel() {
return pageMainPageAppLabel?:app.getLabel()?:sDefaultAppName
}
preferences {
page name:"pageMain"
}
mappings {
path("/callback") { action: [ POST: "callback"] }
path("/oauth/callback") { action: [ GET: "oauthCallback" ] }
}
/************************************** PARENT METHODS START *******************************************************/
def installed() {
logInfo "${getDefaultLabel()} executing 'installed()'"
state.isInstalled = now()
if(pageMainPageAppLabel) { app.updateLabel( pageMainPageAppLabel ) }
else app.updateSetting("pageMainPageAppLabel", getDefaultLabel())
initialize()
}
def initialize() {
logInfo "${getDefaultLabel()} executing 'initialize()'"
getParent()?.childInitialize( app )
}
def updated() {
logInfo "${getDefaultLabel()} executing 'updated()'"
getParent()?.childUpdated( app )
}
def uninstalled() {
logInfo "${getDefaultLabel()} executing 'uninstalled()'"
if(state.appId) deleteApp(state.appId)
getParent()?.childUninstalled( app )
}
def subscriptionDeviceListChanged(data) {
logDebug "${getDefaultLabel()} executing 'subscriptionDeviceListChanged($data)'"
getParent()?.childSubscriptionDeviceListChanged( app, data?.data )
}
def subscriptionEvent(event) {
logDebug "${getDefaultLabel()} executing 'subscriptionEvent()'"
getParent()?.childSubscriptionEvent(app, event)
}
List getOtherSubscribedDeviceIds() {
logDebug "${getDefaultLabel()} executing 'getOtherSubscribedDevices()'"
return getParent()?.childGetOtherSubscribedDeviceIds( app )
}
public Map getSmartSubscribedDevices() {
logDebug "${getDefaultLabel()} executing 'getSmartSubscribedDevices()'"
List deviceIds = getSmartSubscriptions()?.items?.findAll{ it.sourceType=="DEVICE" }?.device?.deviceId
List devices = deviceIds?.findResults{ deviceId -> getSmartDevices()?.items?.find{ it.deviceId==deviceId }?.clone() }
devices?.each{
it.oauthId=getOauthId()
it.appId=app.getId()
it.roomName = getSmartRoomName(it?.roomId)
it.locationName = getSmartLocationName(it?.locationId)
return it
}
return [items:(devices?:[])]
}
public Map getSmartDevices() {
Long appId = app.getId()
if(g_mSmartDeviceList[appId]==null) {
if(state?.installedAppId) { // can't start until I know my location
g_mSmartDeviceList[appId]=[:]
smartLocationQuery() // this will update location, rooms, devices in that order
Integer count=0
while(count<60 && g_mSmartDeviceList[appId]==[:] ) { pauseExecution(250); count++ } // wait a max of 15 seconds
if(count==60) logWarn "${getDefaultLabel()} getSmartDevices() timeout"
}
}
return g_mSmartDeviceList[appId] ?: [:]
}
public Map getSmartRooms() {
if(g_mSmartRoomList[app.getId()]==null) {
g_mSmartRoomList[app.getId()] = (state.rooms ?: [:])
getSmartDevices() // does not block
}
return (g_mSmartRoomList[app.getId()] ?: [:])
}
public Map getSmartLocations() {
if(g_mSmartLocationList[app.getId()]==null) {
g_mSmartLocationList[app.getId()] = (state.location ?: [:])
getSmartDevices() // does not block
}
return (g_mSmartLocationList[app.getId()] ?: [:])
}
public Map getSmartSubscriptions() {
if(state.installedAppId && g_mSmartSubscriptionList[app.getId()]==null) {
g_mSmartSubscriptionList[app.getId()] = (state?.subscriptions ?: [:])
getSmartSubscriptionList() // does not block
}
return (g_mSmartSubscriptionList[app.getId()] ?: [:])
}
public List getSmartDeviceSelectList() {
return pageMainSmartDevices?:[]
}
public Boolean createSmartDevice(String locationId, String deviceId, Boolean updateSubscriptionFlag=false) {
if(locationId==getLocationId()) {
if( !getSmartDevices()?.items?.find{ it?.deviceId==deviceId } ) getSmartDeviceList()
List smartDevices = getSmartDeviceSelectList()
if(updateSubscriptionFlag && !smartDevices?.contains(deviceId)) {
smartDevices << deviceId
app.updateSetting( "pageMainSmartDevices", [type:"enum", value: smartDevices] )
setSmartDeviceSubscriptions()
}
return true
}
return false
}
public Boolean deleteSmartDevice(String locationId, String deviceId) {
if(locationId==getLocationId()) {
if( getSmartDevices()?.items?.find{ it?.deviceId==deviceId } ) getSmartDeviceList()
List smartDevices = getSmartDeviceSelectList()
if(smartDevices?.contains(deviceId)) {
smartDevices.remove(deviceId)
app.updateSetting( "pageMainSmartDevices", [type:"enum", value: smartDevices] )
setSmartDeviceSubscriptions()
}
return true
}
return false
}
public String getLocationId() {
return state?.locationId
}
public String getAuthToken(Boolean usePat=false) {
return (usePat ? (getParent()?.getAuthToken() ?: userSmartThingsPAT) : ( (state.authTokenExpires>now()) ? state?.authToken : getParent()?.getAuthToken() ?: userSmartThingsPAT ))
}
public Integer getMaxDeviceLimit() {
return iSmartAppDeviceLimit
}
public String getAuthStatus() {
if(!state?.oauthCallbackUrl) state.oauthCallbackUrl = getTargetUrl() //added 1.3.07 to help with C-8 migrations
if(state.oauthCallbackUrl != getTargetUrl()) state.oauthCallback = "INVALID" //added 1.3.07 to help with C-8 migrations
String response = "UNKNOWN"
if(state?.oauthCallback=="CONFIRMED" && state?.authTokenError==false && state.authTokenExpires>now())
response = "AUTHORIZED"
if((state?.oauthCallback!="CONFIRMED" || state?.authTokenError==true) && (state.authTokenExpires>0))
response = "FAILURE"
if(!state?.authTokenExpires)
response = "PENDING"
return response
}
public void updateLocationSubscriptionSettings(Boolean value) {
app.updateSetting('enableDeviceLifecycleSubscription', value)
app.updateSetting('enableHealthSubscription', value)
app.updateSetting('enableModeSubscription', value)
app.updateSetting('enableSceneLifecycleSubscription', value)
}
/************************************** PARENT METHODS STOP ********************************************************/
String getTargetUrl() {
return "${getApiServerUrl()}/${getHubUID()}/apps/${app.id}/callback?access_token=${state.accessToken}"
}
String getRedirectUri() {
return "https://cloud.hubitat.com/oauth/stateredirect"
}
String getOauthState() {
return "${getHubUID()}/apps/${app.id}/oauth/callback?access_token=${state.accessToken}"
}
String getOauthId() {
return "${getHubUID().reverse().take(3).reverse()}-${app.getId().toString().padLeft(4,"0")}" // I just made this up
}
String getOauthAuthorizeUri() {
String clientId = state.oauthClientId
String scope = URLEncoder.encode(lOauthScope?.join(' '), "UTF-8")
String redirectUri = URLEncoder.encode(getRedirectUri(), "UTF-8")
String oauthState = URLEncoder.encode(getOauthState(), "UTF-8")
return "$sURI/oauth/authorize?client_id=${clientId}&scope=${scope}&response_type=code&redirect_uri=${redirectUri}&state=${oauthState}"
}
def pageMain(){
if(!state.accessToken) {
def install = installHelper()
if(install) return install
}
Integer refreshInterval = state.refreshInterval ?: ((state.appId && !state.installedAppId) ? 5 : 0)
String refreshTime = "${(new Date( now()+refreshInterval*1000 ).format("h:mm:ss a"))}"
return dynamicPage(name: "pageMain", install: true, uninstall: true, refreshInterval: refreshInterval) {
displayHeader()
String comments = "This application utilizes the SmartThings Cloud API to create and delete subscriptions. SmartThings enforces rates and guardrails with a maximum of 30 devices per installed application, "
comments+= "40 requests to create subscriptions per 15 minutes, and an overall rate limit of 15 requests per 15 minutes to query the subscription API for status updates. "
comments+= "Suggest taking your time when selecting devices so you do not exceed these limits. You can have up to a maximum of 100 installed applications per SmartThings location. "
comments+= "Unlike the SmartThings Personal Access Token (PAT) that is valid for 50 years from creation, the OAuth authorization token is valid for 24 hours and must be refreshed. "
comments+= "The authorization token refresh is automatically handled by the ${getDefaultLabel()} application every three hours , "
comments+= "but if your Hubitat hub is offline for an extended time period, you will need to reauthorize the token manually via the '$sSamsungIcon SmartThings OAuth Authorization' link."
comments+= "${refreshInterval ? "
Repaint: $refreshTime
" : ""}"
section() {
paragraph( getFormat("comments",comments,null,"Gray") )
}
if(!getParent()) {
section(menuHeader("${getDefaultLabel()} Configuration")) {
input(name: "userSmartThingsPAT", type: "password", title: getFormat("hyperlink","$sSamsungIcon SmartThings Personal Access Token:","https://account.smartthings.com/tokens"), description: "SmartThings UUID Token", width: 6, submitOnChange: true, newLineAfter:true)
}
}
if(state?.user=="bloodtick") { section() { input(name: "dynamic::pageMainTestButton", type: "button", width: 2, title: "$sHubitatIcon Test", style:"width:75%;") } }
if(getAuthToken()) {
section(menuHeader("SmartThings API $sHubitatIconStatic $sSamsungIconStatic")) {
if(!state.appId) {
input(name: "pageMain::createApp", type: "button", width: 2, title: "Create API", style:"width:75%; color:$sColorDarkBlue; font-weight:bold;")
paragraph( getFormat("text", "Select 'Create API' to begin initialization of SmartThings API") )
if(state.createAppError) paragraph( "SmartThings API ERROR: "+state.createAppError )
}
else {
input(name: "pageMain::deleteApp", type: "button", width: 2, title: "Delete API", style:"width:75%;")
paragraph("$sSamsungIcon SmartThings API is ${state.installedAppId ? "configured: select 'Delete API' to remove OAuth authorization" : "ready for OAuth authorization: ${getFormat("text","select link below to continue")}"}")
String status = "• SmartThings Application: ${getFormat("text", "CONFIRMED",null,sColorDarkGrey)}\n"
status += "• Hubitat Webhook Callback: ${getFormat("text", state.oauthCallback,null,(state?.oauthCallback=="CONFIRMED"?sColorDarkGrey:sColorDarkRed))}\n"
status += "• Installed Application: ${getFormat("text", state.installedAppId?"AUTHORIZED":"PENDING AUTHORIZATION",null,state.installedAppId?sColorDarkGrey:sColorDarkRed)}"
if(state?.authTokenExpires) {
status += "\n\n"
status += "• Device Count: ${getSmartDevices()?.items?.size()?:0}\n" //this needs to be first since it will fetch location, rooms, devices, in that order
status += "• Room Count: ${getSmartRooms()?.items?.size()?:0}\n"
status += "• Location: ${getSmartLocationName(state.locationId)}\n"
status += "• Token Expiration: ${(new Date(state?.authTokenExpires).format("YYYY-MM-dd h:mm:ss a z"))}"
if(state?.oauthCallback=="INVALID")
status += getFormat("text","\nAction: Callback Invalid! 'Delete API' is required to restore!",null,sColorDarkRed)
else if(getAuthStatus()=="FAILURE")
status += getFormat("text","\nAction: Token Invalid! New OAuth Authorization is required to restore!",null,sColorDarkRed)
}
paragraph(status)
if(state.installedAppId) {
//paragraph( getFormat("hyperlink","$sSamsungIcon Click here to refresh SmartThings OAuth Authorization", getOauthAuthorizeUri()) )
href url: getOauthAuthorizeUri(), style: "external", required: false, title: "$sSamsungIcon SmartThings OAuth Authorization", description: "Click to reauthorize Installed Application"
}
else {
//paragraph( getFormat("hyperlink","$sSamsungIcon 'Click Here' for SmartThings OAuth Authorization and select 'Refresh' when completed", getOauthAuthorizeUri()) )
//input(name: "pageMain::noop", type: "button", width: 2, title: "Refresh", style:"width:75%; color:$sColorDarkBlue; font-weight:bold;", newLineAfter:true)
href url: getOauthAuthorizeUri(), style: "external", required: false, title: getFormat("text","$sSamsungIcon SmartThings OAuth Authorization"), description: "Click to authorize Installed Application"
}
// this is a workaround for the form data submission on 'external' modal boxes. not sure why hubitat is failing.
paragraph (rawHtml: true, """""")
paragraph (rawHtml: true, """""")
}
}
}
if(state.installedAppId) {
section(menuHeader("SmartThings Subscriptions")) {
if(!getParent()) {
input(name: "enableDeviceLifecycleSubscription", type: "bool", title: getFormat("text","Enable SmartThings Device Lifecycle Subscription"), defaultValue: false, submitOnChange: true)
input(name: "enableHealthSubscription", type: "bool", title: getFormat("text","Enable SmartThings Health Subscription"), defaultValue: false, submitOnChange: true)
input(name: "enableModeSubscription", type: "bool", title: getFormat("text","Enable SmartThings Mode Subscription"), defaultValue: false, submitOnChange: true)
input(name: "enableSceneLifecycleSubscription", type: "bool", title: getFormat("text","Enable SmartThings Scene Lifecycle Subscription"), defaultValue: false, submitOnChange: true)
}
String controller = getParent() ? "${getParent()?.getLabel()} managed" : ""
String status = "$sSamsungIcon SmartThings Location Subscriptions: $controller "
status += getSmartSubscriptionId("DEVICE_LIFECYCLE") ? "• SmartThings Device Lifecycle is subscribed " : ""
status += getSmartSubscriptionId("DEVICE_HEALTH") ? "• SmartThings Device Health is subscribed " : ""
status += getSmartSubscriptionId("MODE") ? "• SmartThings Mode is subscribed " : ""
status += getSmartSubscriptionId("SCENE_LIFECYCLE") ? "• SmartThings Scene Lifecycle is subscribed" : ""
paragraph(status)
Map smartDevices = getSmartDevices()?.clone() // this could block up to ten seconds if we don't have devices cached
if(smartDevices?.items) {
List smartDevicesSelect = []
List removeDevices = getOtherSubscribedDeviceIds()?.clone() ?: []
try { // not sure but sort fails sometimes. worry about it another day.
//smartDevices?.items?.sort{ (it?.label?:it?.name).toString() }
smartDevices?.items?.sort{ a,b -> naturalSort((a?.label?:a?.name).toString(), (b?.label?:b?.name).toString()) }
smartDevices?.items?.sort{ a,b -> naturalSort( "${getSmartRoomName(a?.roomId.toString())?:""} : ${(a?.label?:it?.name).toString()}", "${getSmartRoomName(b?.roomId.toString())?:""} : ${(b?.label?:it?.name).toString()}") }
} catch(e) {
logWarn "${getDefaultLabel()} pageMainSmartDevices $e"
}
smartDevices?.items?.each {
Map device = [ "${it.deviceId}" : "${getSmartRoomName(it?.roomId)?:""} : ${(it?.label?:it?.name).toString()}" ]
if( !removeDevices?.find{ removeDevice -> removeDevice==it.deviceId } )
smartDevicesSelect.add(device)
}
input(name: "pageMainSmartDevices", type: "enum", title: getFormat("text", " $sSamsungIcon SmartThings Device Subscriptions (${pageMainSmartDevices?.size() ?: 0} of max ${iSmartAppDeviceLimit}):"), description: "Choose a SmartThings devices", options: smartDevicesSelect, multiple: true, submitOnChange:true, width:6, newLineAfter:true)
if(iSmartAppDeviceLimit >=pageMainSmartDevices?.size()) {
Map update = checkSmartSubscriptions()
if(update?.ready && !state.refreshInterval) {
input(name: "pageMain::configure", type: "button", width: 2, title: "Configure", style:"width:75%; color:$sColorDarkBlue; font-weight:bold;")
paragraph( getFormat("text", "Select 'Configure' to update SmartThings subscriptions") )
}
else {
if(refreshInterval)
input(name: "pageMain::noop", type: "button", width: 2, title: "Wait...", style:"width:75%;")
else
input(name: "pageMain::refreshApp", type: "button", width: 2, title: "Refresh", style:"width:75%;")
state.remove('refreshInterval')
}
}
else {
paragraph( getFormat("text","Action: Too many SmartThings devices selected! The maximum device count supported is $iSmartAppDeviceLimit per '${getDefaultLabel()}' instance!",null,sColorDarkRed) )
}
}
else {
input(name: "pageMain::refreshApp", type: "button", width: 2, title: "Refresh ", style:"width:75%;")
}
try {
smartDevicesTable()
} catch(e) { logInfo "${getDefaultLabel()} smartDevicesTable $e" }
}
}
section(menuHeader("OAuth Application Logging")) {
input(name: "appInfoDisable", type: "bool", title: "Disable info logging", required: false, defaultValue: false, submitOnChange: true)
input(name: "appDebugEnable", type: "bool", title: "Enable debug logging", required: false, defaultValue: false, submitOnChange: true)
//input(name: "appTraceEnable", type: "bool", title: "Enable trace logging", required: false, defaultValue: false, submitOnChange: true)
}
if(appDebugEnable || appTraceEnable) {
runIn(1800, updatePageMain)
} else {
unschedule('updatePageMain')
}
}
}
void updatePageMain() {
logInfo "${app.getLabel()} disabling debug and trace logs"
app.updateSetting("appDebugEnable", false)
app.updateSetting("appTraceEnable", false)
}
def smartDevicesTable(){
Map update = checkSmartSubscriptions()
List deviceIds = (update?.current + update?.select + update?.delete).unique()
List smartDevices = deviceIds?.collect{ deviceId -> getSmartDevices()?.clone().items?.find{ it.deviceId==deviceId } }
String smartDeviceList = ""
smartDeviceList += "$sSamsungIcon Room $sSamsungIcon Device $sSamsungIcon Status "
try { // not sure but sort fails sometimes
//smartDevices?.sort{ (it?.label?:it?.name).toString() }
//smartDevices?.sort{ "${getSmartRoomName(it?.roomId)?:""} : ${(it?.label?:it?.name).toString()}" }
smartDevices?.sort{ a,b -> naturalSort((a?.label?:a?.name).toString(), (b?.label?:b?.name).toString()) }
smartDevices?.sort{ a,b -> naturalSort( "${getSmartRoomName(a?.roomId.toString())?:""} : ${(a?.label?:it?.name).toString()}", "${getSmartRoomName(b?.roomId.toString())?:""} : ${(b?.label?:it?.name).toString()}") }
} catch(e) { logInfo "${getDefaultLabel()} smartDevicesTable $e" }
smartDevices?.each { device ->
String status = (update?.select?.find{it==device?.deviceId}) ? "Pending Subscribe" : (update?.delete?.find{it==device?.deviceId}) ? "Pending Unsubscribe" : "Subscribed"
smartDeviceList += "${getSmartRoomName(device?.roomId)} "
smartDeviceList += "${getSmartDeviceName(device?.deviceId)} "
smartDeviceList += "$status "
}
smartDeviceList +="
"
if (smartDevices?.size()){
paragraph( getFormat("line") )
paragraph( smartDeviceList )
paragraph("")
}
}
def installHelper() {
if(!state?.isInstalled) {
return dynamicPage(name: "pageMain", install: true, refreshInterval: 0){
displayHeader()
section(menuHeader("Complete Install $sHubitatIconStatic $sSamsungIconStatic")) {
paragraph("Please complete the install (click done) and then return to $sHubitatIcon SmartApp to continue configuration")
input(name: "pageMainPageAppLabel", type: "text", title: getFormat("text","$sHubitatIcon Change ${app.getLabel()?:sDefaultAppName} SmartApp Name:"), width: 6, defaultValue:(app.getLabel()?:sDefaultAppName), submitOnChange: true, newLineAfter:true)
}
}
}
if(!state?.accessToken){
try { createAccessToken() } catch(e) { logWarn e }
}
if(!state?.accessToken) {
return dynamicPage(name: "pageMain", install: true, uninstall: true, refreshInterval: 0){
displayHeader()
section(menuHeader("Complete OAUTH Install $sHubitatIconStatic $sSamsungIconStatic")) {
paragraph("Problem with OAUTH installation! Please remove $sHubitatIcon '${getDefaultLabel()}' and authorize OAUTH in Apps Code source code and reinstall")
}
}
}
return null
}
@Field volatile static Map g_bAppButtonHandlerLock = [:]
void appButtonHandler(String btn) {
logDebug "${app.getLabel()} executing 'appButtonHandler($btn)'"
if(g_bAppButtonHandlerLock[app.id]) return
appButtonHandlerLock()
if(btn.contains("::")) {
List items = btn.tokenize("::")
if(items && items.size() > 1 && items[1]) {
String k = (String)items[0]
String v = (String)items[1]
logTrace "Button [$k] [$v] pressed"
switch(k) {
case "pageMain":
switch(v) {
case "noop":
break
case "configure":
state.refreshInterval=5
setSmartDeviceSubscriptions()
break
case "createApp":
if(getParent()) updateLocationSubscriptionSettings(false) // do this here since we can change locations
createApp()
break
case "deleteApp":
deleteApp(state.appId)
break
case "refreshApp":
refreshApp()
break
}
break
case "dynamic":
this."$v"()
break
default:
logInfo "Not supported"
}
}
}
appButtonHandlerUnLock()
}
void appButtonHandlerLock() {
g_bAppButtonHandlerLock[app.id] = true
runIn(10,appButtonHandlerUnLock)
}
void appButtonHandlerUnLock() {
unschedule('appButtonHandlerUnLock')
g_bAppButtonHandlerLock[app.id] = false
}
def callback() {
Map response = [statusCode:iHttpError]
def event = new JsonSlurper().parseText(request.body)
logDebug "${getDefaultLabel()} ${event?.messageType}: $event"
switch(event?.messageType) {
case 'PING':
response = [statusCode:iHttpSuccess, pingData: [challenge: event?.pingData.challenge]]
break;
case 'CONFIRMATION':
response = [statusCode:iHttpSuccess, targetUrl: getTargetUrl()]
runIn(2, confirmation, [data: event?.confirmationData])
break;
case 'EVENT':
logDebug "${getDefaultLabel()} ${event?.messageType}"
subscriptionEvent(event)
response.statusCode = iHttpSuccess
break;
default:
logWarn "${getDefaultLabel()} callback() ${event?.messageType} not supported"
}
event.clear()
event = null
logDebug "RESPONSE: ${JsonOutput.toJson(response)}"
return render(status:response.statusCode, data:JsonOutput.toJson(response))
}
Map confirmation(Map confirmationData) {
logDebug "${getDefaultLabel()} executing 'confirmation()' url:${confirmationData?.confirmationUrl}"
Map response = [statusCode:iHttpError]
try {
httpGet(confirmationData?.confirmationUrl) { resp ->
logDebug "response data: ${resp?.data}"
if (resp?.data?.targetUrl == getTargetUrl()) {
logInfo "${getDefaultLabel()} callback confirmation success"
state.oauthCallback = "CONFIRMED"
state.oauthCallbackUrl = getTargetUrl() //added 1.3.07 to help with C-8 migrations
}
else {
logWarn "${getDefaultLabel()} callback confirmation failure with url:${resp?.data?.targetUrl}"
state.oauthCallback = "ERROR"
}
response.statusCode = resp.status
response['targetUrl'] = resp.data.targetUrl
}
} catch (e) {
logWarn "${getDefaultLabel()} confirmation() error: $e"
}
return response
}
String getSmartSubscriptionId(String sourceType, String deviceId=null) {
return getSmartSubscriptions()?.items?.find{ it.sourceType==sourceType && (deviceId==null || it.device.deviceId==deviceId) }?.id
}
Map checkSmartSubscriptions() {
List currentIds = getSmartSubscriptions()?.items?.findAll{ it.sourceType=="DEVICE" }?.device?.deviceId
List selectIds = pageMainSmartDevices?.clone()
List deleteIds = currentIds?.clone()
if(selectIds) { deleteIds?.intersect(selectIds)?.each{ deleteIds?.remove(it); selectIds?.remove(it) } }
Boolean deviceLifecycle = !!getSmartSubscriptionId("DEVICE_LIFECYCLE")
Boolean health = !!getSmartSubscriptionId("DEVICE_HEALTH")
Boolean mode = !!getSmartSubscriptionId("MODE")
Boolean sceneLifecycle = !!getSmartSubscriptionId("SCENE_LIFECYCLE")
Boolean ready = (selectIds?.size() || deleteIds?.size() || health!=enableHealthSubscription || mode!=enableModeSubscription
|| deviceLifecycle!=enableDeviceLifecycleSubscription || sceneLifecycle!=enableSceneLifecycleSubscription)
return ([current:(currentIds?:[]), select:(selectIds?:[]), delete:(deleteIds?:[]), ready:ready])
}
void setSmartSubscriptions() {
logDebug "${getDefaultLabel()} executing 'setSmartSubscriptions()'"
Boolean deviceLifecycle = !!getSmartSubscriptionId("DEVICE_LIFECYCLE")
if(!deviceLifecycle && enableDeviceLifecycleSubscription)
setSmartDeviceLifecycleSubscription()
else if (deviceLifecycle && !enableDeviceLifecycleSubscription)
deleteSmartSubscriptions("DEVICE_LIFECYCLE")
Boolean health = !!getSmartSubscriptionId("DEVICE_HEALTH")
if(!health && enableHealthSubscription)
setSmartHealthSubscription()
else if (health && !enableHealthSubscription)
deleteSmartSubscriptions("DEVICE_HEALTH")
Boolean mode = !!getSmartSubscriptionId("MODE")
if(!mode && enableModeSubscription)
setSmartModeSubscription()
else if (mode && !enableModeSubscription)
deleteSmartSubscriptions("MODE")
Boolean sceneLifecycle = !!getSmartSubscriptionId("SCENE_LIFECYCLE")
if(!sceneLifecycle && enableSceneLifecycleSubscription)
setSmartSceneLifecycleSubscription()
else if (sceneLifecycle && !enableSceneLifecycleSubscription)
deleteSmartSubscriptions("SCENE_LIFECYCLE")
}
void setSmartDeviceSubscriptions() {
logDebug "${getDefaultLabel()} executing 'setSmartDeviceSubscriptions()'"
setSmartSubscriptions()
Map update = checkSmartSubscriptions()
update?.select?.each{ deviceId ->
logDebug "${getDefaultLabel()} subscribed to $deviceId"
setSmartDeviceSubscription(deviceId)
}
update?.delete?.each{ deviceId ->
logDebug "${getDefaultLabel()} unsubscribe to $deviceId"
deleteSmartSubscriptions("DEVICE", deviceId)
}
if(update?.ready) {
runIn(1, subscriptionDeviceListChanged, [data: [createIds:update?.select, deleteIds:update?.delete, reason:"subscriptionListChanged"]])
runIn(2, getSmartSubscriptionList)
}
}
Map deleteSmartSubscriptions(String sourceType, String deviceId=null) {
logDebug "${getDefaultLabel()} executing 'deleteSmartSubscriptions($sourceType, $deviceId)'"
Map response = [statusCode:iHttpError]
String subscriptionId = getSmartSubscriptionId(sourceType, deviceId)
Map params = [
uri: sURI,
path: "/installedapps/${state?.installedAppId}/subscriptions/$subscriptionId",
headers: [ Authorization: "Bearer ${state?.authToken}" ]
]
try {
httpDelete(params) { resp ->
logInfo "${getDefaultLabel()} '${deviceId?getSmartDeviceName(deviceId):sourceType}' delete subscription status:${resp.status}"
response.statusCode = resp.status
}
} catch (e) {
logWarn "${getDefaultLabel()} deleteSmartSubscriptions($sourceType, $deviceId) error: $e"
}
return response
}
Map setSmartDeviceSubscription(String deviceId) {
logDebug "${getDefaultLabel()} executing 'setSmartDeviceSubscription($deviceId)'"
Map response = [statusCode:iHttpError]
Map subscription = [ sourceType: "DEVICE", device: [ deviceId: deviceId, componentId: "*", capability: "*", attribute: "*", stateChangeOnly: true, subscriptionName: deviceId, value: "*" ]]
Map data = [
uri: sURI,
path: "/installedapps/${state?.installedAppId}/subscriptions",
body: JsonOutput.toJson(subscription),
method: "setSmartDeviceSubscription",
deviceId: deviceId
]
response.statusCode = asyncHttpPostJson("asyncHttpPostCallback", data).statusCode
return response
}
Map setSmartHealthSubscription() {
logDebug "${getDefaultLabel()} executing 'setSmartHealthSubscription()'"
Map response = [statusCode:iHttpError]
Map health = [ sourceType: "DEVICE_HEALTH", deviceHealth: [ locationId: state?.locationId, subscriptionName: state?.locationId ]]
Map data = [
uri: sURI,
path: "/installedapps/${state?.installedAppId}/subscriptions",
body: JsonOutput.toJson(health),
method: "setSmartHealthSubscription",
]
response.statusCode = asyncHttpPostJson("asyncHttpPostCallback", data).statusCode
return response
}
Map setSmartDeviceLifecycleSubscription() {
logDebug "${getDefaultLabel()} executing 'setSmartHealthSubscription()'"
Map response = [statusCode:iHttpError]
Map health = [ sourceType: "DEVICE_LIFECYCLE", deviceLifecycle: [ locationId: state?.locationId, subscriptionName: state?.locationId ]]
Map data = [
uri: sURI,
path: "/installedapps/${state?.installedAppId}/subscriptions",
body: JsonOutput.toJson(health),
method: "setSmartDeviceLifecycleSubscription",
]
response.statusCode = asyncHttpPostJson("asyncHttpPostCallback", data).statusCode
return response
}
Map setSmartModeSubscription() {
logDebug "${getDefaultLabel()} executing 'setSmartModeSubscription()'"
Map response = [statusCode:iHttpError]
Map mode = [ sourceType: "MODE", mode: [ locationId: state?.locationId, subscriptionName: state?.locationId ]]
Map data = [
uri: sURI,
path: "/installedapps/${state?.installedAppId}/subscriptions",
body: JsonOutput.toJson(mode),
method: "setSmartModeSubscription",
]
response.statusCode = asyncHttpPostJson("asyncHttpPostCallback", data).statusCode
return response
}
Map setSmartSceneLifecycleSubscription() {
logDebug "${getDefaultLabel()} executing 'setSmartSceneLifecycleSubscription()'"
Map response = [statusCode:iHttpError]
Map mode = [ sourceType: "SCENE_LIFECYCLE", sceneLifecycle: [ locationId: state?.locationId, subscriptionName: state?.locationId ]]
Map data = [
uri: sURI,
path: "/installedapps/${state?.installedAppId}/subscriptions",
body: JsonOutput.toJson(mode),
method: "setSmartSceneLifecycleSubscription",
]
response.statusCode = asyncHttpPostJson("asyncHttpPostCallback", data).statusCode
return response
}
private Map asyncHttpPostJson(String callbackMethod, Map data) {
logDebug "${getDefaultLabel()} executing 'asyncHttpPostJson()'"
Map response = [statusCode:iHttpError]
Map params = [
uri: data.uri,
path: data.path,
body: data.body,
contentType: "application/json",
requestContentType: "application/json",
headers: [ Authorization: "Bearer ${getAuthToken()}" ]
]
try {
asynchttpPost(callbackMethod, params, data)
response.statusCode = iHttpSuccess
} catch (e) {
logWarn "${getDefaultLabel()} asyncHttpPostJson error: $e"
}
return response
}
void asyncHttpPostCallback(resp, data) {
logDebug "${getDefaultLabel()} executing 'asyncHttpPostCallback()' status: ${resp.status} method: ${data?.method}"
if(resp.status==iHttpSuccess) {
resp.headers.each { logTrace "${it.key} : ${it.value}" }
logTrace "response data: ${resp.data}"
switch(data?.method) {
case "setSmartDeviceCommand":
Map command = new JsonSlurper().parseText(resp.data)
logDebug "${getDefaultLabel()} successful ${data?.method}:${command}"
break
case "setSmartDeviceSubscription":
Map subscription = new JsonSlurper().parseText(resp.data)
logTrace "${getDefaultLabel()} ${data?.method}: ${subscription}"
logInfo "${getDefaultLabel()} '${getSmartDeviceName(data?.deviceId)}' DEVICE subscription status:${resp.status}"
break
case "setSmartHealthSubscription":
Map subscription = new JsonSlurper().parseText(resp.data)
logTrace "${getDefaultLabel()} ${data?.method}: ${subscription}"
logInfo "${getDefaultLabel()} HEALTH subscription status:${resp.status}"
break
case "setSmartDeviceLifecycleSubscription":
Map subscription = new JsonSlurper().parseText(resp.data)
logTrace "${getDefaultLabel()} ${data?.method}: ${subscription}"
logInfo "${getDefaultLabel()} DEVICE_LIFECYCLE subscription status:${resp.status}"
break
case "setSmartModeSubscription":
Map subscription = new JsonSlurper().parseText(resp.data)
logTrace "${getDefaultLabel()} ${data?.method}: ${subscription}"
logInfo "${getDefaultLabel()} MODE subscription status:${resp.status}"
break
case "setSmartSceneLifecycleSubscription":
Map subscription = new JsonSlurper().parseText(resp.data)
logTrace "${getDefaultLabel()} ${data?.method}: ${subscription}"
logInfo "${getDefaultLabel()} SCENE_LIFECYCLE subscription status:${resp.status}"
break
default:
logWarn "${getDefaultLabel()} asyncHttpPostCallback ${data?.method} not supported"
if (resp?.data) { logInfo resp.data }
}
}
else {
resp.headers.each { logTrace "${it.key} : ${it.value}" }
logWarn("${getDefaultLabel()} asyncHttpPostCallback ${data?.method} status:${resp.status} reason:${resp.errorMessage}")
}
}
@Field volatile static Map g_mSmartSubscriptionList = [:]
Map getSmartSubscriptionList() {
logDebug "${getDefaultLabel()} executing 'getSmartSubscriptionList()'"
Map response = [statusCode:iHttpError]
Map data = [ uri: sURI, path: "/installedapps/${state.installedAppId}/subscriptions", method: "getSmartSubscriptionList"]
response.statusCode = asyncHttpGet("asyncHttpGetCallback", data).statusCode
return response
}
@Field volatile static Map g_mSmartDeviceList = [:]
Map getSmartDeviceList() {
logDebug "${getDefaultLabel()} executing 'getSmartDeviceList()'"
Map response = [statusCode:iHttpError]
Map data = [ uri: sURI, path: "/devices", method: "getSmartDeviceList"]
response.statusCode = asyncHttpGet("asyncHttpGetCallback", data).statusCode
return response
}
void getSmartDeviceListPages( Map deviceList ) {
logDebug "${getDefaultLabel()} executing 'getSmartDeviceListPages()' size:${deviceList?.items?.size()} next:${deviceList?._links?.next}"
deviceList?._links?.next?.each{ key, href ->
Map params = [ uri: href, headers: [ Authorization: "Bearer ${getAuthToken()}" ] ]
try {
httpGet(params) { resp ->
deviceList?.items?.addAll( resp.data?.items )
getSmartDeviceListPages(resp.data) // good old recursion
}
} catch (e) {
logWarn "${getDefaultLabel()} has getSmartDeviceListPages() error: $e"
}
}
}
String getSmartDeviceName(String deviceId) {
Map smartDeviceList = g_mSmartDeviceList[app.getId()]?.clone()
Map device = smartDeviceList?.items ? smartDeviceList?.items?.find{ it.deviceId==deviceId } ?: [label:"Name Not Defined"] : [label:deviceId]
return (device?.label ?: device?.name).toString()
}
@Field volatile static Map g_mSmartRoomList = [:]
Map getSmartRoomList() {
logDebug "${getDefaultLabel()} executing 'getSmartRoomList()'"
Map response = [statusCode:iHttpError]
Map data = [ uri: sURI, path: "/locations/${state.locationId}/rooms", method: "getSmartRoomList" ]
response.statusCode = asyncHttpGet("asyncHttpGetCallback", data).statusCode
return response
}
String getSmartRoomName(String roomId) {
Map smartRoomList = g_mSmartRoomList[app.getId()]?.clone()
return smartRoomList?.items ? smartRoomList?.items?.find{ it.roomId==roomId }?.name ?: "Room Not Defined" : roomId
}
@Field volatile static Map g_mSmartLocationList = [:]
Map getSmartLocationList() {
logDebug "${getDefaultLabel()} executing 'getSmartLocationList()'"
Map response = [statusCode:iHttpError]
Map data = [ uri: sURI, path: "/locations", method: "getSmartLocationList" ]
response.statusCode = asyncHttpGet("asyncHttpGetCallback", data).statusCode
return response
}
String getSmartLocationName(String locationId) {
Map smartLocationList = g_mSmartLocationList[app.getId()]?.clone()
return smartLocationList?.items ? smartLocationList?.items?.find{ it.locationId==locationId }?.name ?: "Location Not Defined" : locationId
}
private Map asyncHttpGet(String callbackMethod, Map data) {
logDebug "${getDefaultLabel()} executing 'asyncHttpGet()'"
Map response = [statusCode:iHttpError]
Map params = [
uri: data.uri,
path: data.path,
headers: [ Authorization: "Bearer ${getAuthToken()}" ]
]
try {
asynchttpGet(callbackMethod, params, data)
response.statusCode = iHttpSuccess
} catch (e) {
logWarn "${getDefaultLabel()} asyncHttpGet error: $e"
}
return response
}
@Field volatile static Map g_bSmartLocationQueryIsRunningLock = [:]
@Field volatile static Map g_bSmartLocationQueryChanged = [:]
void smartLocationQuery() {
logDebug "${getDefaultLabel()} executing 'smartLocationQuery()'"
if(g_bSmartLocationQueryIsRunningLock[app.getId()]) {
logInfo "${getDefaultLabel()} is currently querying for location, rooms and devices. Please wait."
return
}
g_bSmartLocationQueryIsRunningLock[app.getId()] = true
getSmartLocationList()
runIn(30, clearSmartLocationQueryLock)
}
void clearSmartLocationQueryLock() {
unschedule('clearSmartLocationQueryLock')
if(g_bSmartLocationQueryChanged[app.getId()])
runIn(5, subscriptionDeviceListChanged, [data: [reason:"locationChanged"]])
g_bSmartLocationQueryChanged[app.getId()] = false
g_bSmartLocationQueryIsRunningLock[app.getId()] = false
}
void asyncHttpGetCallback(resp, data) {
logDebug "${getDefaultLabel()} executing 'asyncHttpGetCallback()' status: ${resp.status} method: ${data?.method}"
if (resp.status == iHttpSuccess) {
switch(data?.method) {
case "getSmartSubscriptionList":
Map subscriptionList = new JsonSlurper().parseText(resp.data)
subscriptionList?.items?.sort{ it?.sourceType }
subscriptionList?.items?.findAll{ it?.sourceType=="DEVICE" }?.device?.sort{ it.deviceId }
Boolean changed = !g_mSmartSubscriptionList[app.getId()]?.items?.equals( subscriptionList?.items )
if(changed) {
g_mSmartSubscriptionList[app.getId()]?.clear()
state.subscriptions = g_mSmartSubscriptionList[app.getId()] = subscriptionList
setSmartDeviceSubscriptions()
}
logInfo "${getDefaultLabel()} ${changed?"updated":"checked"} subscription list"
break
case "getSmartDeviceList":
Map deviceList = new JsonSlurper().parseText(resp.data)
getSmartDeviceListPages(deviceList)
Boolean changed = !g_mSmartDeviceList[app.getId()]?.items?.sort{ it.deviceId }?.equals( deviceList?.items?.sort{ it.deviceId } )
if(changed) {
g_mSmartDeviceList[app.getId()]?.clear()
g_mSmartDeviceList[app.getId()] = deviceList
g_bSmartLocationQueryChanged[app.getId()] = true
}
clearSmartLocationQueryLock()
logInfo "${getDefaultLabel()} ${changed?"updated":"checked"} device list"
break
case "getSmartRoomList":
Map roomList = new JsonSlurper().parseText(resp.data)
Boolean changed = !g_mSmartRoomList[app.getId()]?.items?.sort{ it.roomId }?.equals( roomList?.items?.sort{ it.roomId } )
if(changed) {
g_mSmartRoomList[app.getId()]?.clear()
state.rooms = g_mSmartRoomList[app.getId()] = roomList
g_bSmartLocationQueryChanged[app.getId()] = true
}
logInfo "${getDefaultLabel()} ${changed?"updated":"checked"} room list"
getSmartDeviceList()
break
case "getSmartLocationList":
Map locationList = new JsonSlurper().parseText(resp.data)
Boolean changed = !g_mSmartLocationList[app.getId()]?.items?.sort{ it.locationId }?.equals( locationList?.items?.sort{ it.locationId } )
if(changed) {
g_mSmartLocationList[app.getId()]?.clear()
state.location = g_mSmartLocationList[app.getId()] = locationList
state.locationId = locationList?.items?.collect{ it.locationId }?.unique()?.getAt(0)
g_bSmartLocationQueryChanged[app.getId()] = true
}
logInfo "${getDefaultLabel()} ${changed?"updated":"checked"} location list"
getSmartRoomList()
break
default:
logWarn "${getDefaultLabel()} asyncHttpGetCallback ${data?.method} not supported"
if (resp?.data) { logInfo resp.data }
}
resp.headers.each { logTrace "${it.key} : ${it.value}" }
logTrace "response data: ${resp.data}"
}
else {
logWarn("${getDefaultLabel()} asyncHttpGetCallback '${data?.method}' status:${resp.status} reason:${resp.errorMessage} - rescheduled in 15 minutes")
runIn(15*60, data?.method)
}
}
def oauthCallback() {
logDebug "${getDefaultLabel()} oauthCallback() $params"
String code = params.code
String client_id = state.oauthClientId
String client_secret = state.oauthClientSecret
String redirect_uri = getRedirectUri()
Map params = [
uri: sOauthURI,
path: "/oauth/token",
query: [ grant_type:"authorization_code", code:code, client_id:client_id, redirect_uri:redirect_uri ],
contentType: "application/x-www-form-urlencoded",
requestContentType: "application/json",
headers: [ Authorization: "Basic ${("${client_id}:${client_secret}").bytes.encodeBase64().toString()}" ]
]
try {
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
String respStr = resp.data.toString().replace("[{","{").replace("}:null]","}")
Map respStrJson = new JsonSlurper().parseText(respStr)
state.installedAppId = respStrJson.installed_app_id
state.authToken = respStrJson.access_token
state.refreshToken = respStrJson.refresh_token
state.authTokenExpires = (now() + (respStrJson.expires_in * 1000))
state.authTokenError = false
runIn(1,startApp)
}
}
}
catch (e) {
logWarn "${getDefaultLabel()} oauthCallback() error: $e"
}
if (state.authToken)
return render(status:iHttpSuccess, contentType: 'text/html', data: getHtmlResponse(true))
else
return render(status:iHttpError, contentType: 'text/html', data: getHtmlResponse(false))
}
Map oauthRefresh() {
logDebug "${getDefaultLabel()} executing 'oauthRefresh()'"
Map response = [statusCode:iHttpError]
String refresh_token = state.refreshToken
String client_id = state.oauthClientId
String client_secret = state.oauthClientSecret
Map params = [
uri: sOauthURI,
path: "/oauth/token",
query: [ grant_type:"refresh_token", client_id:client_id, refresh_token:refresh_token ],
contentType: "application/x-www-form-urlencoded",
requestContentType: "application/json",
headers: [ Authorization: "Basic ${("${client_id}:${client_secret}").bytes.encodeBase64().toString()}" ]
]
try {
httpPost(params) { resp ->
if (resp && resp.data && resp.success) {
// strange json'y response. this works good enough to solve.
String respStr = resp.data.toString().replace("[{","{").replace("}:null]","}")
Map respStrJson = new JsonSlurper().parseText(respStr)
state.installedAppId = respStrJson.installed_app_id
state.authToken = respStrJson.access_token
state.refreshToken = respStrJson.refresh_token
state.authTokenExpires = (now() + (respStrJson.expires_in * 1000))
state.authTokenError = false
response.statusCode = resp.status
logInfo "${getDefaultLabel()} updated authorization token"
}
else {
state.authTokenError = true
logWarn"${getDefaultLabel()} could not update authorization token"
}
}
} catch (e) {
state.authTokenError = true
logWarn "${getDefaultLabel()} oauthRefresh() error: $e"
}
runIn(1, refreshApp)
return response
}
void appStatus() {
if(getAuthStatus()=="AUTHORIZED") {
app.updateLabel( "$pageMainPageAppLabel ${getOauthId()} : ${getFormat("text","Authorized")}" ) // this will send updated() command
}
else if (getAuthStatus()=="FAILURE") {
app.updateLabel( "$pageMainPageAppLabel ${getOauthId()} : ${getFormat("text","Authorization Error",null,sColorDarkRed)}" ) // this will send updated() command
}
else {
app.updateLabel( "$pageMainPageAppLabel ${getOauthId()}" ) // this will send updated() command
}
getParent()?.childHealthChanged( app )
}
void startApp() { // called by oauthCallback() in runIn
logDebug "${getDefaultLabel()} executing startApp"
runEvery3Hours('oauthRefresh') // tokens are good for 24 hours, refresh every 3 hours to give up to 21 hours offline time worst case.
appStatus()
}
void refreshApp() { // called by oauthRefresh() && callback()==DEVICE_LIFECYCLE_EVENT in runIn(1)
logInfo "${getDefaultLabel()} executing refreshApp"
// these are async calls and will not block
if(state.installedAppId) {
smartLocationQuery()
getSmartSubscriptionList()
}
appStatus()
}
void stopApp() { // called by deleteApp() directly.
logDebug "${getDefaultLabel()} executing stopApp"
unschedule()
state.remove('appId')
state.remove('appName')
state.remove('authToken')
state.remove('authTokenError')
state.remove('authTokenExpires')
state.remove('installedAppId')
state.remove('location')
state.remove('locationId')
state.remove('oauthCallback')
state.remove('oauthClientId')
state.remove('oauthClientSecret')
state.remove('refreshToken')
state.remove('rooms')
state.remove('subscriptions')
app.removeSetting("hubitatQueryString")
g_mSmartSubscriptionList[app.getId()] = null
g_mSmartLocationList[app.getId()] = null
g_mSmartRoomList[app.getId()] = null
g_mSmartDeviceList[app.getId()] = null
runIn(1,appStatus)
}
def createApp() {
logInfo "${getDefaultLabel()} creating SmartThings API"
def response = [statusCode:iHttpError]
String displayName = "$sDefaultAppName ${getOauthId()}"
def app = [
appName: "${sDefaultAppName.replaceAll("\\s","").toLowerCase()}-${UUID.randomUUID().toString()}",
displayName: displayName,
description: "SmartThings Service to connect with Hubitat",
iconImage: [ url:"https://raw.githubusercontent.com/bloodtick/Hubitat/main/hubiThingsReplica/icon/replica.png" ],
appType: "API_ONLY",
classifications: ["CONNECTED_SERVICE"],
singleInstance: true,
apiOnly: [targetUrl:getTargetUrl()],
oauth: [
clientName: "HubiThings Replica Oauth",
scope: lOauthScope,
redirectUris: [getRedirectUri()]
]
]
def params = [
uri: sURI,
path: "/apps",
body: JsonOutput.toJson(app),
headers: [ Authorization: "Bearer ${getAuthToken(true)}" ]
]
try {
httpPostJson(params) { resp ->
if(resp.status==200) {
logDebug "createApp() response data: ${JsonOutput.toJson(resp.data)}"
state.appId = resp.data.app.appId
state.appName = resp.data.app.appName
state.oauthClientId = resp.data.oauthClientId
state.oauthClientSecret = resp.data.oauthClientSecret
state.oauthCallback = resp.data.app?.apiOnly?.subscription?.targetStatus
state.remove('createAppError')
logTrace resp.data
}
response.statusCode = resp.status
}
} catch (e) {
logWarn "createApp() error: $e"
state.createAppError = e.toString()
}
return response
}
def deleteApp(appNameOrId) {
logDebug "executing 'deleteApp($appNameOrId)'"
def response = [statusCode:iHttpError]
def params = [
uri: sURI,
path: "/apps/$appNameOrId",
headers: [ Authorization: "Bearer ${getAuthToken(true)}" ]
]
try {
httpDelete(params) { resp ->
logDebug "deleteApp() response data: ${JsonOutput.toJson(resp.data)}"
if(resp.status==200 && state.appId==appNameOrId) {
logInfo "${getDefaultLabel()} successfully deleted SmartThings API"
stopApp()
}
response.statusCode = resp.status
}
} catch (e) {
logWarn "deleteApp() error: $e"
}
return response
}
def getApp(appNameOrId) {
logInfo "executing 'getApp($appNameOrId)'"
def params = [
uri: sURI,
path: "/apps/$appNameOrId",
headers: [ Authorization: "Bearer ${getAuthToken(true)}" ]
]
def data = [method:"getApp"]
try {
asynchttpGet("appCallback", params, data)
} catch (e) {
logWarn "getApp() error: $e"
}
}
def listApps() {
logInfo "executing 'listApps()'"
def params = [
uri: sURI,
path: "/apps",
headers: [ Authorization: "Bearer ${getAuthToken(true)}" ]
]
def data = [method:"listApps"]
try {
asynchttpGet("appCallback", params, data)
} catch (e) {
logWarn "listApps() error: $e"
}
}
def naturalSort( def a, def b ) {
def aParts = a.replaceAll(/(\d+)/, '#$1#').split('#')
def bParts = b.replaceAll(/(\d+)/, '#$1#').split('#')
int i = 0
while(i < aParts.size() && i < bParts.size()) {
if (aParts[i] != bParts[i]) {
if (aParts[i].isNumber() && bParts[i].isNumber())
return aParts[i].toInteger() <=> bParts[i].toInteger()
else
return aParts[i] <=> bParts[i]
}
i++
}
return aParts.size() <=> bParts.size()
}
def getHtmlResponse(Boolean success=false) {
"""
${getDefaultLabel()}
${success ? "$sSamsungIconStatic $sSamsungIcon SmartThings has authorized ${getDefaultLabel()}" : "$sSamsungIconStatic $sSamsungIcon SmartThings connection could not be established!"}
${success ? statusMsg("Close this window to continue configuration") : errorMsg("Close this window and retry authorization")}
"""
}
@Field static final String sSamsungIconStatic="""
"""
@Field static final String sSamsungIcon=""" """
@Field static final String sHubitatIconStatic="""
"""
@Field static final String sHubitatIcon=""" """
// thanks to DCMeglio (Hubitat Package Manager) for a lot of formatting hints
String getFormat(type, myText="", myHyperlink="", myColor=sColorDarkBlue){
if(type == "line") return " "
if(type == "title") return "${myText} "
if(type == "text") return "${myText} "
if(type == "hyperlink") return "${myText} "
if(type == "comments") return "${myText}
"
}
String errorMsg(String msg) { getFormat("text", msg, null, sColorDarkRed) }
String statusMsg(String msg) { getFormat("text", msg, null, sColorDarkBlue) }
def displayHeader() {
section (getFormat("title", "${app.getLabel()?:sDefaultAppName}${sCodeRelease?.size() ? " : $sCodeRelease" : ""}" )) {
paragraph "Developed by: ${author()} Current Version: v${version()} - ${copyright()}
"
paragraph( getFormat("line") )
}
}
def displayFooter(){
section() {
paragraph( getFormat("line") )
paragraph "${getDefaultLabel()}
Please consider donating. This application took a lot of work to make.
If you find it valuable, I'd certainly appreciate it!
"
}
}
def menuHeader(titleText){"${titleText}
"}
private logInfo(msg) { if(!appInfoDisable) { log.info "${msg}" } }
private logDebug(msg) { if(appDebugEnable) { log.debug "${msg}" } }
private logTrace(msg) { if(appTraceEnable) { log.trace "${msg}" } }
private logWarn(msg) { log.warn "${msg}" }
private logError(msg) { log.error "${msg}" }
void pageMainTestButton() {
return
}