/** * Copyright 2024 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.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. * 1.3.08 2023-04-23 Support for more SmartThings Virtual Devices. * 1.3.09 2023-06-05 Updated to support 'warning' for token refresh with still valid OAuth authorization. * 1.3.10 2023-06-17 Support SmartThings Virtual Lock, add default values to ST Virtuals, fix mirror/create flow logic (no OAuth changes) * 1.3.11 2023-07-05 Support for building your own Virtual Devices, Mute logs/Disable periodic refresh buttons on rules. Updated to support schema.oneOf.type drivers. (no OAuth changes) * 1.3.12 2023-08-06 Bug fix for dup event trigger to different command event (virtual only). GitHub issue ticket support for new devices requests. (no OAuth changes) * 1.3.13 2024-02-17 Updated refresh support to allow for device (Location Knob) execution * 1.3.14 2024-03-08 Bug fix for capability check before attribute match in smartTriggerHandler(), checkCommand() && checkTrigger() (no OAuth changes) * 1.3.15 2024-03-23 Update to OAuth to give easier callback identification. This will only take effect on new APIs, so old ones will still have generic name. * LINE 30 MAX */ public static String version() { return "1.3.15" } public static String copyright() { return "© 2024 ${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" @Field static final String sColorYellow="#8B8000" 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"] } // 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" ] } } /************************************** 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?.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}" } 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","

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) { 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 += "" 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_bAppButtonHandlerLock = [:] Boolean appButtonHandlerLock() { if(g_bAppButtonHandlerLock[app.id]) { logInfo "${app.getLabel()} appButtonHandlerLock is locked"; return false } g_bAppButtonHandlerLock[app.id] = true runIn(10,appButtonHandlerUnLock) return true } 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()} ${getOauthId()} ${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()} ${getOauthId()} ${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()} ${getOauthId()} ${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()} ${getOauthId()} ${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()=="WARNING") { app.updateLabel( "$pageMainPageAppLabel ${getOauthId()} : ${getFormat("text","Authorization Warning",null,sColorYellow)}" ) // this will send updated() command } else if (getAuthStatus()=="FAILURE") { app.updateLabel( "$pageMainPageAppLabel ${getOauthId()} : ${getFormat("text","Authorization Failure",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('oauthCallbackUrl') 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!"}

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