/**
* 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 += "$sSamsungIcon Room $sSamsungIcon Device $sSamsungIcon Status "
try { // not sure but sort fails sometimes
//smartDevices?.sort{ (it?.label?:it?.name).toString() }
//smartDevices?.sort{ "${getSmartRoomName(it?.roomId)?:""} : ${(it?.label?:it?.name).toString()}" }
smartDevices?.sort{ a,b -> naturalSort((a?.label?:a?.name).toString(), (b?.label?:b?.name).toString()) }
smartDevices?.sort{ a,b -> naturalSort( "${getSmartRoomName(a?.roomId.toString())?:""} : ${(a?.label?:it?.name).toString()}", "${getSmartRoomName(b?.roomId.toString())?:""} : ${(b?.label?:it?.name).toString()}") }
} catch(e) { logInfo "${getDefaultLabel()} smartDevicesTable $e" }
smartDevices?.each { device ->
String status = (update?.select?.find{it==device?.deviceId}) ? "Pending Subscribe" : (update?.delete?.find{it==device?.deviceId}) ? "Pending Unsubscribe" : "Subscribed"
smartDeviceList += "${getSmartRoomName(device?.roomId)} "
smartDeviceList += "${getSmartDeviceName(device?.deviceId)} "
smartDeviceList += "$status "
}
smartDeviceList +="
"
if (smartDevices?.size()){
paragraph( getFormat("line") )
paragraph( smartDeviceList )
paragraph("")
}
}
def installHelper() {
if(!state?.isInstalled) {
return dynamicPage(name: "pageMain", install: true, refreshInterval: 0){
displayHeader()
section(menuHeader("Complete Install $sHubitatIconStatic $sSamsungIconStatic")) {
paragraph("Please complete the install (click done) and then return to $sHubitatIcon SmartApp to continue configuration")
input(name: "pageMainPageAppLabel", type: "text", title: getFormat("text","$sHubitatIcon Change ${app.getLabel()?:sDefaultAppName} SmartApp Name:"), width: 6, defaultValue:(app.getLabel()?:sDefaultAppName), submitOnChange: true, newLineAfter:true)
}
}
}
if(!state?.accessToken){
try { createAccessToken() } catch(e) { logWarn e }
}
if(!state?.accessToken) {
return dynamicPage(name: "pageMain", install: true, uninstall: true, refreshInterval: 0){
displayHeader()
section(menuHeader("Complete OAUTH Install $sHubitatIconStatic $sSamsungIconStatic")) {
paragraph("Problem with OAUTH installation! Please remove $sHubitatIcon '${getDefaultLabel()}' and authorize OAUTH in Apps Code source code and reinstall")
}
}
}
return null
}
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!"}
${success ? statusMsg("Close this window to continue configuration") : errorMsg("Close this window and retry authorization")}
"""
}
@Field static final String sSamsungIconStatic="""
"""
@Field static final String sSamsungIcon=""" """
@Field static final String sHubitatIconStatic="""
"""
@Field static final String sHubitatIcon=""" """
// thanks to DCMeglio (Hubitat Package Manager) for a lot of formatting hints
String getFormat(type, myText="", myHyperlink="", myColor=sColorDarkBlue){
if(type == "line") return " "
if(type == "title") return "${myText} "
if(type == "text") return "${myText} "
if(type == "hyperlink") return "${myText} "
if(type == "comments") return "${myText}
"
}
String errorMsg(String msg) { getFormat("text", msg, null, sColorDarkRed) }
String statusMsg(String msg) { getFormat("text", msg, null, sColorDarkBlue) }
def displayHeader() {
section (getFormat("title", "${app.getLabel()?:sDefaultAppName}${sCodeRelease?.size() ? " : $sCodeRelease" : ""}" )) {
paragraph "Developed by: ${author()} Current Version: v${version()} - ${copyright()}
"
paragraph( getFormat("line") )
}
}
def displayFooter(){
section() {
paragraph( getFormat("line") )
paragraph "${getDefaultLabel()}
Please consider donating. This application took a lot of work to make.
If you find it valuable, I'd certainly appreciate it!
"
}
}
def menuHeader(titleText){"${titleText}
"}
private logInfo(msg) { if(!appInfoDisable) { log.info "${msg}" } }
private logDebug(msg) { if(appDebugEnable) { log.debug "${msg}" } }
private logTrace(msg) { if(appTraceEnable) { log.trace "${msg}" } }
private logWarn(msg) { log.warn "${msg}" }
private logError(msg) { log.error "${msg}" }
void pageMainTestButton() {
logWarn getOAuthToken("pageMainTestButton")
}