/** * Copyright 2026 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.5.00 2024-12-20 Updates to use the OAuth token as much as possible. See here: https://community.smartthings.com/t/changes-to-personal-access-tokens-pat/292019 * 1.5.01 2025-01-06 OAuth patch to set status and json correctly for external application use of the OAuth token. (no Replcia changes) * 1.5.02 2025-03-01 Set refresh waits in Replica and OAuth to reduce excessive message traffic and lower Hubitat overhead * 1.5.03 2025-03-03 Move startup to 30 seconds after hub is ready. Fix app to show real time events. (no OAuth changes) * 1.5.04 2025-03-09 More fixes to improve hub startup performance and excessive message traffic notifications * 1.5.05 2025-04-01 SmartThings fixed API to allow for virtual device creation using the OAuth token. (no OAuth changes) * 1.5.06 2025-04-01 More fixes to improve hub startup performance. Added 'update' to deviceTriggerHandler for use with drivers. (no OAuth changes) * 1.5.07 2025-04-13 Allow user to directly set attributes and function parameters in rules. Updates have #tagRuleOverride. (no OAuth changes) * 1.5.08 2025-04-18 Updates to virtual device configurations and use locationId finding OAuth and not PAT. (no OAuth changes) * 1.5.09 2026-01-17 Minor update on checkCommand() to correct logic showing if SmartThings command is valid. Happy New Year. (no OAuth changes) * LINE 30 MAX */ public static String version() { return "1.5.09" } public static String copyright() { return "© 2026 ${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 String sSTNamespace="replica" @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:deviceprofiles","i:deviceprofiles:*","r:customcapability","r:hubs:*","r:installedapps:*","w:installedapps:*", "r:locations:*","w:locations:*","x:locations:*", "r:devices:*","w:devices:*","x:devices:*", "r:scenes:*","x:scenes:*", "r:rules:*","w:rules:*","x:rules:*", "x:notifications:*"] @Field static final String sColorDarkBlue="#1A77C9" @Field static final String sColorLightGrey="#DDDDDD" @Field static final String sColorDarkGrey="#696969" @Field static final String sColorDarkRed="DarkRed" @Field static final String sColorYellow="#8B8000" @Field static final Integer iRescheduled=15*60 definition( parent: 'replica:HubiThings Replica', name: sDefaultAppName, namespace: sSTNamespace, 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 ? (getApiId()?"$pageMainPageAppLabel ${getApiId()}":pageMainPageAppLabel) : app.getLabel()?:sDefaultAppName } preferences { page name:"pageMain" } mappings { path("/callback") { action: [ POST: "callback"] } // orginal callback, deprecated in 1.3.15 to give indication of 'who' owns this. path("/replicaCallback") { action: [ POST: "callback"] } // new callback path("/oauth/callback") { action: [ GET: "oauthCallback" ] } path("/oauthToken") { action: [ GET: "oauthToken" ] } } /************************************** 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.apiId=getApiId() it.appId=app.getId() it.roomName = getSmartRoomName(it?.roomId) it.namespace = sSTNamespace // added 1.4.00 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 getOAuthToken(String method, Boolean forcePat=false, Boolean noWarning=false) { String token = ((forcePat || now()>state.authTokenExpires) ? (getParent()?.getOAuthToken("$sDefaultAppName:$method",getLocationId()?:"location not set",true,noWarning) ?: userSmartThingsPAT) : state?.authToken ?: userSmartThingsPAT ) return token } public String getAuthToken(String method="getAuthToken") { return getOAuthToken(method) } public Integer getMaxDeviceLimit() { return iSmartAppDeviceLimit } public String getAuthStatus() { if(state?.oauthClientId && !state?.oauthCallbackUrl) state.oauthCallbackUrl = getTargetUrl() //added 1.3.07 to help with C-8 migrations if(state?.oauthClientId && state.oauthCallbackUrl != getTargetUrl() && state.oauthCallbackUrl != getTargetUrlOrginal()) state.oauthCallback = "INVALID" //added 1.3.07 to help with C-8 migrations, update 1.3.15 String response = "UNKNOWN" if(state?.oauthCallback=="CONFIRMED" && state?.authTokenError==false && state?.authTokenExpires>now()) response = "AUTHORIZED" else if((state?.oauthCallback!="CONFIRMED" || state?.authTokenError==true) && state?.authTokenExpires>now()) response = "WARNING" else 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 getTargetUrlOrginal() { return "${getApiServerUrl()}/${getHubUID()}/apps/${app.id}/callback?access_token=${state.accessToken}" } String getTargetUrl() { return "${getApiServerUrl()}/${getHubUID()}/apps/${app.id}/replicaCallback?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}" } public String getApiId() { return "${getHubUID().reverse().take(3).reverse()}-${app.getId().toString().padLeft(4,"0")}" // I just made this up } public String getUri() { return "http://${location.hub.getDataValue("localIP")}/installedapp/configure/$app.id" } public String getLocationName() { return getSmartLocationName(state.locationId) } 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), which was valid for 50 years from creation but is changing to 24 hours as of January 2025 and will need to be manually updated to change the API, the OAuth authorization token is valid for 24 hours and must be refreshed. " comments+= "The authorization token refresh is automatically handled by the $sDefaultAppName application every three hours., " comments+= "However, if your Hubitat hub is offline for an extended 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(getOAuthToken("pageMain",false,true)) { 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"))}\n" //String restInternal = "${getFullLocalApiServerUrl()}/oauthToken?access_token=${state.accessToken}" String restInternal = "http://${location.hub.localIP}:8080/apps/api/${app.id}/oauthToken?access_token=${state.accessToken}" String restExternal = "${getFullApiServerUrl()}/oauthToken?access_token=${state.accessToken}" status += "• Token REST API: ${getFormat("hyperlink","Internal",restInternal)}${getFormat("hyperlink"," (JSON)",restInternal+"&json=true")} - ${getFormat("hyperlink","External",restExternal)}${getFormat("hyperlink"," (JSON)",restExternal+"&json=true")}" status += ( getFormat("comments","\nThe HTTP/S GET REST API links are provided for using the $sSamsungIcon OAuth token in external applications. Note that the local Hubitat web port 80 server cannot be used for internal access. You must use port 8080, such as: 'http://127.0.0.1:8080'.",null,"Gray") ) if(state?.oauthCallback=="INVALID") status += getFormat("text","

Action: Callback Invalid! 'Delete API' is required to restore!",null,sColorDarkRed) else if(getAuthStatus()=="WARNING") status += getFormat("text","

Warning: Authorization Token did not refresh and will automatically retry within three hours.",null,sColorYellow) else if(getAuthStatus()=="FAILURE") status += getFormat("text","

Action: Token Invalid! New OAuth Authorization or 'Delete API' 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) { logDebug "${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 += "" 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 += "" smartDeviceList += "" smartDeviceList += "" } smartDeviceList +="
$sSamsungIcon Room$sSamsungIcon Device$sSamsungIcon Status
${getSmartRoomName(device?.roomId)}${getSmartDeviceName(device?.deviceId)}$status
" 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 } void appButtonHandler(String btn) { logDebug "${app.getLabel()} executing 'appButtonHandler($btn)'" if(!appButtonHandlerLock()) return 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": if(getAuthStatus()!="AUTHORIZED") oauthRefresh() // calls refreshApp() else refreshApp() break } break case "dynamic": this."$v"() break default: logInfo "Not supported" } } } appButtonHandlerUnLock() } @Field volatile static Map g_lAppButtonHandlerIsRunningLock = [:] Boolean appButtonHandlerLock() { if(g_lAppButtonHandlerIsRunningLock[app.id]!=null && g_lAppButtonHandlerIsRunningLock[app.getId()] > now() - 10*1000 ) { logInfo "${app.getLabel()} appButtonHandlerLock is locked"; return false } g_lAppButtonHandlerIsRunningLock[app.getId()] = now() return true } void appButtonHandlerUnLock() { g_lAppButtonHandlerIsRunningLock[app.getId()] = 0L } 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()'" if(!asyncHttpCheck("setSmartDeviceSubscriptions")) return setSmartSubscriptions() Map update = checkSmartSubscriptions() update?.select?.each{ deviceId -> logDebug "${getDefaultLabel()} subscribed to $deviceId" setSmartDeviceSubscription(deviceId) pauseExecution(100) } update?.delete?.each{ deviceId -> logDebug "${getDefaultLabel()} unsubscribe to $deviceId" deleteSmartSubscriptions("DEVICE", deviceId) pauseExecution(100) } if(update?.ready) { runIn(2, getSmartSubscriptionList) runIn(3, subscriptionDeviceListChanged, [data: [createIds:update?.select, deleteIds:update?.delete, reason:"subscriptionListChanged"]]) } } 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 Boolean asyncHttpCheck(String method) { if(!state.containsKey('authTokenError') || getAuthStatus()=="FAILURE") { if(state[method]!=true) { logError "${getDefaultLabel()} does not have a valid authToken. Rejecting method:$method" state[method]= true } return false } else if(state.containsKey(method)) state.remove(method) if(state.containsKey('rescheduled') && state.rescheduled>now()) { logWarn "${getDefaultLabel()} had too many requests now waiting ${Math.ceil((state.rescheduled - now())/(1000*60)) as Integer} minutes. Rejecting method:$method" return false } else if(state.containsKey('rescheduled')) state.remove('rescheduled') return true } private Map asyncHttpPostJson(String callbackMethod, Map data) { logDebug "${getDefaultLabel()} executing 'asyncHttpPostJson()'" Map response = [statusCode:iHttpError] if(!asyncHttpCheck(data.method)) return response Map params = [ uri: data.uri, path: data.path, body: data.body, contentType: "application/json", requestContentType: "application/json", headers: [ Authorization: "Bearer ${getOAuthToken("asyncHttpPostJson:$data.method")}" ] ] 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} ${data?.deviceId?"device:$data.deviceId":""}") } } @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 ${getOAuthToken("getSmartDeviceListPages")}" ] ] 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] if(!asyncHttpCheck(data.method)) return response Map params = [ uri: data.uri, path: data.path, headers: [ Authorization: "Bearer ${getOAuthToken("asyncHttpGet:$data.method")}" ] ] try { asynchttpGet(callbackMethod, params, data) response.statusCode = iHttpSuccess } catch (e) { logWarn "${getDefaultLabel()} asyncHttpGet error: $e" } return response } @Field volatile static Map g_lSmartLocationQueryIsRunningLock = [:] @Field volatile static Map g_bSmartLocationQueryChanged = [:] void smartLocationQuery() { if(g_lSmartLocationQueryIsRunningLock[app.getId()]!=null && g_lSmartLocationQueryIsRunningLock[app.getId()] > now() - 30*1000) { // only allow this once per 30 seconds logInfo "${getDefaultLabel()} is currently querying for location, rooms and devices. Please wait ${30-((now() - g_lSmartLocationQueryIsRunningLock[app.getId()])/1000 as Integer)} seconds." return } else logDebug "${getDefaultLabel()} executing 'smartLocationQuery()'" g_lSmartLocationQueryIsRunningLock[app.getId()] = now() 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_lSmartLocationQueryIsRunningLock[app.getId()] = now() } 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 ${iRescheduled/60} minutes") state.rescheduled = (now() + iRescheduled*1000) - 1000 runIn(iRescheduled, data?.method) } } def oauthToken() { logDebug"${getDefaultLabel()} oauthToken() $params" String authToken = (state?.authToken!=null && state?.authTokenExpires>now()) ? state?.authToken : "Not Valid" if(params?.json) { Map data = [ authToken: authToken, timestamp: ((new Date()).format("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", TimeZone.getTimeZone("UTC"))) as String ] if(authToken==state.authToken) { data.scope = lOauthScope?.sort() data.expiration = (new Date(state?.authTokenExpires).format("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", TimeZone.getTimeZone("UTC"))) as String data.locationId = getLocationId() as String data.locationName = getLocation() as String } return render(contentType: "application/json", data: JsonOutput.toJson(data.sort()), status: (authToken==state?.authToken)?200:404) } return render(contentType: "text/plain", data: authToken, status: (authToken==state.authToken)?200:404) } 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( "${getDefaultLabel()} : ${getFormat("text","Authorized")}" ) // this will send updated() command } else if (getAuthStatus()=="WARNING") { app.updateLabel( "${getDefaultLabel()} : ${getFormat("text","Authorization Warning",null,sColorYellow)}" ) // this will send updated() command } else if (getAuthStatus()=="FAILURE") { app.updateLabel( "${getDefaultLabel()} : ${getFormat("text","Authorization Failure",null,sColorDarkRed)}" ) // this will send updated() command } else { app.updateLabel( "${getDefaultLabel()}" ) // 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('oauthCallbackUrl') state.remove('oauthClientId') state.remove('oauthClientSecret') state.remove('refreshToken') state.remove('rooms') state.remove('subscriptions') state.remove('rescheduled') app.removeSetting("hubitatQueryString") g_mSmartSubscriptionList[app.getId()] = null g_mSmartLocationList[app.getId()] = null g_mSmartRoomList[app.getId()] = null g_mSmartDeviceList[app.getId()] = null clearSmartLocationQueryLock() runIn(1,appStatus) } def createApp() { logInfo "${getDefaultLabel()} creating SmartThings API" def response = [statusCode:iHttpError] String displayName = "$sDefaultAppName ${getApiId()}" 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 ${getOAuthToken("createApp",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 ${getOAuthToken("deleteApp",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 ${getOAuthToken("getApp",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 ${getOAuthToken("listApps",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!"}

""" } @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()}

PayPal Logo

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() { logWarn getOAuthToken("pageMainTestButton") }