From 5dd47e67f1789788921cacd305b9dc5a37caa5dc Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Fri, 29 Jan 2016 22:32:52 -0600 Subject: [PATCH 01/27] Checkpoint before next wave of changes... --- .../ecobee-connect.src/ecobee-connect.groovy | 425 ++++++++++-------- 1 file changed, 245 insertions(+), 180 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index a385a43d0e6..10f55fbfe1f 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -21,8 +21,7 @@ * 10-28-2015 DVCSMP-604 - accessory sensor, DVCSMP-1174, DVCSMP-1111 - not respond to routines * StrykerSKS - 12-11-2015 - Make it work (better) with the Ecobee 3 * - * Current Version: 0.8.0-RC - * Release Date: 2016-01-26 + * Current Version: 0.8.9-RC * See separate Changelog for change history * */ @@ -56,15 +55,15 @@ mappings { def authPage() { LOG("=====> authPage() Entered", 5) - if(!atomicState.accessToken) { //this is an access token for the 3rd party to make a call to the connect app - atomicState.accessToken = createAccessToken() + if(!state.accessToken) { //this is an access token for the 3rd party to make a call to the connect app + state.accessToken = createAccessToken() } def description = "Click to enter Ecobee Credentials" def uninstallAllowed = false def oauthTokenProvided = false - if(atomicState.authToken) { + if(state.authToken) { description = "You are connected. Click Next above." uninstallAllowed = true oauthTokenProvided = true @@ -72,7 +71,7 @@ def authPage() { description = "Click to enter Ecobee Credentials" } - def redirectUrl = buildRedirectUrl //"${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}" + def redirectUrl = buildRedirectUrl //"${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}" LOG("authPage() --> RedirectUrl = ${redirectUrl}") // get rid of next button until the user is actually auth'd @@ -85,7 +84,7 @@ def authPage() { } } } else { - LOG("authPage() --> in else for oauthTokenProvided - ${atomicState.authToken}.") + LOG("authPage() --> in else for oauthTokenProvided - ${state.authToken}.") return dynamicPage(name: "auth", title: "ecobee Setup", nextPage: "therms", uninstall: uninstallAllowed) { section() { paragraph "Continue on to select thermostats." @@ -149,14 +148,14 @@ def sensorsPage() { def oauthInitUrl() { LOG("oauthInitUrl with callback: ${callbackUrl}", 5) - atomicState.oauthInitState = UUID.randomUUID().toString() + state.oauthInitState = UUID.randomUUID().toString() def oauthParams = [ response_type: "code", client_id: smartThingsClientId, scope: "smartRead,smartWrite", redirect_uri: callbackUrl, //"https://graph.api.smartthings.com/oauth/callback" - state: atomicState.oauthInitState + state: state.oauthInitState ] LOG("oauthInitUrl - Before redirect: location: ${apiEndpoint}/authorize?${toQueryString(oauthParams)}", 4) @@ -165,13 +164,13 @@ def oauthInitUrl() { // OAuth Callback URL and helpers def callback() { - LOG("callback()>> params: $params, params.code ${params.code}, params.state ${params.state}, atomicState.oauthInitState ${atomicState.oauthInitState}", 4) + LOG("callback()>> params: $params, params.code ${params.code}, params.state ${params.state}, state.oauthInitState ${state.oauthInitState}", 4) def code = params.code def oauthState = params.state - //verify oauthState == atomicState.oauthInitState, so the callback corresponds to the authentication request - if (oauthState == atomicState.oauthInitState){ + //verify oauthState == state.oauthInitState, so the callback corresponds to the authentication request + if (oauthState == state.oauthInitState){ LOG("callback() --> States matched!", 4) def tokenParams = [ grant_type: "authorization_code", @@ -185,22 +184,22 @@ def callback() { LOG("callback()-->tokenURL: ${tokenUrl}", 2) httpPost(uri: tokenUrl) { resp -> - atomicState.refreshToken = resp.data.refresh_token - atomicState.authToken = resp.data.access_token + state.refreshToken = resp.data.refresh_token + state.authToken = resp.data.access_token LOG("Expires in ${resp?.data?.expires_in} seconds") - atomicState.authTokenExpires = now() + (resp.data.expires_in * 1000) - LOG("swapped token: $resp.data; atomicState.refreshToken: ${atomicState.refreshToken}; atomicState.authToken: ${atomicState.authToken}", 2) + state.authTokenExpires = now() + (resp.data.expires_in * 1000) + LOG("swapped token: $resp.data; state.refreshToken: ${state.refreshToken}; state.authToken: ${state.authToken}", 2) } - if (atomicState.authToken) { + if (state.authToken) { success() } else { fail() } } else { - LOG("callback() failed oauthState != atomicState.oauthInitState", 1) + LOG("callback() failed oauthState != state.oauthInitState", 1) } } @@ -302,7 +301,7 @@ def getEcobeeThermostats() { def deviceListParams = [ uri: apiEndpoint, path: "/1/thermostat", - headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.authToken}"], query: [format: 'json', body: requestBody] ] @@ -313,7 +312,7 @@ def getEcobeeThermostats() { LOG("httpGet() response: ${resp.data}") // Initialize the Thermostat Data. Will reuse for the Sensor List intialization - atomicState.thermostatData = resp.data + state.thermostatData = resp.data if (resp.status == 200) { @@ -327,7 +326,7 @@ def getEcobeeThermostats() { //refresh the auth token if (resp.status == 500 && resp.data.status.code == 14) { LOG("Storing the failed action to try later") - atomicState.action = "getEcobeeThermostats" + state.action = "getEcobeeThermostats" LOG("Refreshing your auth_token!", 1) refreshAuthToken() } else { @@ -339,8 +338,8 @@ def getEcobeeThermostats() { LOG("___exception getEcobeeThermostats(): ${e}", 1, null, "error") refreshAuthToken() } - atomicState.thermostatsWithNames = stats - LOG("atomicState.thermostatsWithNames == ${atomicState.thermostatsWithNames}", 4) + state.thermostatsWithNames = stats + LOG("state.thermostatsWithNames == ${state.thermostatsWithNames}", 4) return stats } @@ -351,9 +350,9 @@ Map getEcobeeSensors() { def sensorMap = [:] def foundThermo = null // TODO: Is this needed? - atomicState.remoteSensors = [:] + state.remoteSensors = [:] - atomicState.thermostatData.thermostatList.each { singleStat -> + state.thermostatData.thermostatList.each { singleStat -> LOG("thermostat loop: singleStat == ${singleStat} singleStat.identifier == ${singleStat.identifier}", 4) if (!settings.thermostats.findAll{ it.contains(singleStat.identifier) } ) { @@ -362,21 +361,21 @@ Map getEcobeeSensors() { } else { LOG("getEcobeeSensors() --> Entering the else... we found a match. singleStat == ${singleStat.name}", 4) - atomicState.remoteSensors = atomicState.remoteSensors ? (atomicState.remoteSensors + singleStat.remoteSensors) : singleStat.remoteSensors - LOG("After atomicState.remoteSensors setup...", 5) + state.remoteSensors = state.remoteSensors ? (state.remoteSensors + singleStat.remoteSensors) : singleStat.remoteSensors + LOG("After state.remoteSensors setup...", 5) // WORKAROUND: Iterate over remoteSensors list and add in the thermostat DNI // This is needed to work around the dynamic enum "bug" which prevents proper deletion // TODO: Check to see if this is still needed. Seem to use elibleSensors now instead // singleStat.remoteSensors.each { tempSensor -> // tempSensor.thermDNI = "${thermostat}" - // atomicState.remoteSensors = atomicState.remoteSensors + tempSensor + // state.remoteSensors = state.remoteSensors + tempSensor //} LOG("getEcobeeSensors() - singleStat.remoteSensors: ${singleStat.remoteSensors}", 4) - LOG("getEcobeeSensors() - atomicState.remoteSensors: ${atomicState.remoteSensors}", 4) + LOG("getEcobeeSensors() - state.remoteSensors: ${state.remoteSensors}", 4) } - atomicState.remoteSensors.each { + state.remoteSensors.each { if (it.type != "thermostat") { def value = "${it?.name}" def key = "ecobee_sensor-"+ it?.id + "-" + it?.code @@ -386,7 +385,7 @@ Map getEcobeeSensors() { } // end thermostats.each loop LOG("getEcobeeSensors() - remote sensor list: ${sensorMap}", 4) - atomicState.eligibleSensors = sensorMap + state.eligibleSensors = sensorMap return sensorMap } @@ -416,15 +415,15 @@ def updated() { def initialize() { LOG("=====> initialize()", 4) - atomicState.connected = "full" + state.connected = "full" unschedule() - atomicState.reAttempt = 0 + state.reAttempt = 0 // Create the child Thermostat Devices def devices = thermostats.collect { dni -> def d = getChildDevice(dni) if(!d) { - d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"Ecobee Thermostat:${atomicState.thermostatsWithNames[dni]}"]) + d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"Ecobee Thermostat:${state.thermostatsWithNames[dni]}"]) LOG("created ${d.displayName} with id $dni", 4) } else { LOG("found ${d.displayName} with id $dni already exists", 4) @@ -437,7 +436,7 @@ def initialize() { def sensors = settings.ecobeesensors.collect { dni -> def d = getChildDevice(dni) if(!d) { - d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"Ecobee Sensor:${atomicState.eligibleSensors[dni]}"]) + d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"Ecobee Sensor:${state.eligibleSensors[dni]}"]) LOG("created ${d.displayName} with id $dni", 4) } else { LOG("found ${d.displayName} with id $dni already exists", 4) @@ -448,20 +447,20 @@ def initialize() { LOG("created ${devices.size()} thermostats and ${sensors.size()} sensors.") - // WORKAROUND: settings.ecobeesensors may contain leftover sensors in the dynamic enum bug scenario, use info in atomicState.eligibleSensors instead + // WORKAROUND: settings.ecobeesensors may contain leftover sensors in the dynamic enum bug scenario, use info in state.eligibleSensors instead // TODO: Need to deal with individual sensors from remaining thermostats that might be excluded... // TODO: Cleanup this code now that it is working! - def sensorList = atomicState.eligibleSensors.keySet() + def sensorList = state.eligibleSensors.keySet() - // atomicState.eligibleSensorsAsList = sensorList + // state.eligibleSensorsAsList = sensorList def reducedSensorList = settings.ecobeesensors.findAll { sensorList.contains(it) } LOG("**** reducedSensorList = ${reducedSensorList} *****", 4, null, "warn") - atomicState.activeSensors = reducedSensorList + state.activeSensors = reducedSensorList - LOG("sensorList based on keys: ${sensorList} from atomicState.sensors: ${atomicState.eligibleSensors}", 4) + LOG("sensorList based on keys: ${sensorList} from state.sensors: ${state.eligibleSensors}", 4) - def combined = settings.thermostats + atomicState.activeSensors + def combined = settings.thermostats + state.activeSensors LOG("Combined devices == ${combined}", 4) // Delete any that are no longer in settings @@ -476,12 +475,12 @@ def initialize() { LOG("delete: ${delete}, deleting ${delete.size()} thermostat(s) and/or sensor(s)", 4, null, "warn") delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) - atomicState.thermostatData = [:] //reset Map to store thermostat data + state.thermostatData = [:] //reset Map to store thermostat data //send activity feeds to tell that device is connected def notificationMessage = "is connected to SmartThings" sendActivityFeeds(notificationMessage) - atomicState.timeSendPush = null + state.timeSendPush = null pollHandler() //first time polling data from thermostat @@ -499,7 +498,7 @@ def initialize() { // Called during initialization to get the inital poll def pollHandler() { LOG("pollHandler()", 5) - atomicState.lastPoll = 0 // Initialize the variable and force a poll even if there was one recently + state.lastPoll = 0 // Initialize the variable and force a poll even if there was one recently pollChildren(null) // Hit the ecobee API for update on all thermostats } @@ -514,8 +513,8 @@ def pollChildren(child = null) { // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children - def timeSinceLastPoll = (atomicState.lastPoll == 0) ? 0 : ((now() - atomicState.lastPoll?.toDouble()) / 1000 / 60) - LOG("Time since last poll? ${timeSinceLastPoll} -- atomicState.lastPoll == ${atomicState.lastPoll}", 3, child, "info") + def timeSinceLastPoll = (state.lastPoll == 0) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) + LOG("Time since last poll? ${timeSinceLastPoll} -- state.lastPoll == ${state.lastPoll}", 3, child, "info") // Reschedule polling if it has been a while since the previous poll def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 @@ -528,10 +527,10 @@ def pollChildren(child = null) { runEvery15Minutes("refreshAuthToken") } - if ( (atomicState.lastPoll == 0) || ( timeSinceLastPoll > getMinMinBtwPolls().toDouble() ) ) { + if ( (state.lastPoll == 0) || ( timeSinceLastPoll > getMinMinBtwPolls().toDouble() ) ) { // It has been longer than the minimum delay LOG("Calling the Ecobee API to fetch the latest data...", 4, child) - pollEcobeeAPI(getChildThermostatDeviceIdsString()) // This will update the values saved in the atomicState which can then be used to send the updates + pollEcobeeAPI(getChildThermostatDeviceIdsString()) // This will update the values saved in the state which can then be used to send the updates } else { LOG("pollChildren() - Not time to call the API yet. It has been ${timeSinceLastPoll} minutes since last full poll.", 4, child) generateEventLocalParams() // Update any local parameters and send @@ -546,11 +545,11 @@ def pollChildren(child = null) { if( oneChild.hasCapability("Thermostat") ) { // We found a Thermostat, send all of its events LOG("pollChildren() - We found a Thermostat!", 5) - oneChild.generateEvent(atomicState.thermostats[oneChild.device.deviceNetworkId].data) + oneChild.generateEvent(state.thermostats[oneChild.device.deviceNetworkId].data) } else { // We must have a remote sensor - LOG("pollChildren() - Updating sensor data: ${oneChild.device.deviceNetworkId} data: ${atomicState.remoteSensorsData[oneChild.device.deviceNetworkId].data}", 4) - oneChild.generateEvent(atomicState.remoteSensorsData[oneChild.device.deviceNetworkId].data) + LOG("pollChildren() - Updating sensor data: ${oneChild.device.deviceNetworkId} data: ${state.remoteSensorsData[oneChild.device.deviceNetworkId].data}", 4) + oneChild.generateEvent(state.remoteSensorsData[oneChild.device.deviceNetworkId].data) } } } @@ -569,7 +568,7 @@ private def generateEventLocalParams() { apiConnected: apiConnected() ] - atomicState.thermostats[oneChild.device.deviceNetworkId].data.apiConnected = apiConnected() + state.thermostats[oneChild.device.deviceNetworkId].data.apiConnected = apiConnected() oneChild.generateEvent(data) } else { // We must have a remote sensor @@ -597,7 +596,7 @@ private def pollEcobeeAPI(thermostatIdsString = "") { def pollParams = [ uri: apiEndpoint, path: "/1/thermostat", - headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.authToken}"], query: [format: 'json', body: jsonRequestBody] ] @@ -610,8 +609,8 @@ private def pollEcobeeAPI(thermostatIdsString = "") { if(resp.status == 200) { LOG("poll results returned resp.data ${resp.data}", 2) - atomicState.remoteSensors = resp.data.thermostatList.remoteSensors - atomicState.thermostatData = resp.data + state.remoteSensors = resp.data.thermostatList.remoteSensors + state.thermostatData = resp.data // Create the list of sensors and related data updateSensorData() @@ -620,12 +619,12 @@ private def pollEcobeeAPI(thermostatIdsString = "") { result = true - if (atomicState.connected != "full") { - atomicState.connected = "full" + if (state.connected != "full") { + state.connected = "full" generateEventLocalParams() // Update the connection status } - atomicState.lastPoll = now(); - LOG("httpGet: updated ${atomicState.thermostats?.size()} stats: ${atomicState.thermostats}") + state.lastPoll = now(); + LOG("httpGet: updated ${state.thermostats?.size()} stats: ${state.thermostats}") } else { LOG("pollEcobeeAPI() - polling children & got http status ${resp.status}", 1, null, "error") @@ -650,14 +649,14 @@ private def pollEcobeeAPI(thermostatIdsString = "") { apiLost("pollEcobeeAPI() - In HttpResponseException: Received data.stat.code of 14") } else if (e.statusCode != 401) { //this issue might comes from exceed 20sec app execution, connectivity issue etc. LOG("In HttpResponseException - statusCode != 401 (${e.statusCode})", 1, null, "warn") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices runIn(reAttemptPeriod, "pollEcobeeAPI") // retry to poll } else if (e.statusCode == 401) { // Status.code other than 14 - atomicState.reAttemptPoll = atomicState.reAttemptPoll + 1 - LOG("statusCode == 401: reAttempt refreshAuthToken to try = ${atomicState.reAttemptPoll}", 1, null, "warn") - if (atomicState.reAttemptPoll <= 3) { - atomicState.connected = "warn" + state.reAttemptPoll = state.reAttemptPoll + 1 + LOG("statusCode == 401: reAttempt refreshAuthToken to try = ${state.reAttemptPoll}", 1, null, "warn") + if (state.reAttemptPoll <= 3) { + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices runIn(reAttemptPeriod, "pollEcobeeAPI") } else { @@ -696,8 +695,8 @@ void poll() { def availableModes(child) { - def tData = atomicState.thermostats[child.device.deviceNetworkId] - LOG("atomicState.thermostats = ${atomicState.thermostats}", 3, child) + def tData = state.thermostats[child.device.deviceNetworkId] + LOG("state.thermostats = ${state.thermostats}", 3, child) LOG("Child DNI = ${child.device.deviceNetworkId}", 3, child) LOG("Data = ${tData}", 3, child) @@ -726,8 +725,8 @@ def availableModes(child) { } def currentMode(child) { - def tData = atomicState.thermostats[child.device.deviceNetworkId] - LOG("atomicState.thermostats = ${atomicState.thermostats}", 3, child) + def tData = state.thermostats[child.device.deviceNetworkId] + LOG("state.thermostats = ${state.thermostats}", 3, child) LOG("Child DNI = ${child.device.deviceNetworkId}", 3, child) LOG("Data = ${tData}", 3, child) @@ -750,7 +749,7 @@ def updateSensorData() { LOG("Entered updateSensorData() ", 5) def sensorCollector = [:] - atomicState.remoteSensors.each { + state.remoteSensors.each { it.each { if ( it.type == "ecobee3_remote_sensor" ) { // Add this sensor to the list @@ -802,26 +801,27 @@ def updateSensorData() { } // end thermostat else if } // End it.each loop } // End remoteSensors.each loop - atomicState.remoteSensorsData = sensorCollector + state.remoteSensorsData = sensorCollector LOG("updateSensorData(): found these remoteSensors: ${sensorCollector}", 4) } def updateThermostatData() { // Create the list of thermostats and related data - atomicState.thermostats = atomicState.thermostatData.thermostatList.inject([:]) { collector, stat -> + state.thermostats = state.thermostatData.thermostatList.inject([:]) { collector, stat -> def dni = [ app.id, stat.identifier ].join('.') LOG("Updating dni $dni, Got weather? ${stat.weather.forecasts[0].weatherSymbol.toString()}") + // TODO: Put a wrapper here based on the thermostat brand def thermSensor = stat.remoteSensors.find { it.type == "thermostat" } LOG("updateThermostatData() - thermSensor == ${thermSensor}" ) - def occupancyCap = thermSensor.capability.find { it.type == "occupancy" } + def occupancyCap = thermSensor?.capability.find { it.type == "occupancy" } LOG("updateThermostatData() - occupancyCap = ${occupancyCap} value = ${occupancyCap.value}") - // Check to see if there is even a value - def occupancy = occupancyCap.value + // Check to see if there is even a value, not all types have a sensor + def occupancy = occupancyCap.value ?: "not support" LOG("Program data: ${stat.program} Current climate (ref): ${stat.program?.currentClimateRef}", 4) @@ -879,7 +879,7 @@ def updateThermostatData() { if (runningEvent) { - currentFanMode = runningEvent.fan + currentFanMode = circulateFanModeOn ? "circulate" : runningEvent.fan } else { currentFanMode = stat.runtime.desiredFanMode } @@ -955,7 +955,7 @@ def toQueryString(Map m) { private refreshAuthToken() { - if(!atomicState.refreshToken) { + if(!state.refreshToken) { LOG("refreshing auth token", 2) LOG("refreshAuthToken() - There is no refreshToken stored! Unable to refresh OAuth token.", 1, null, "error") apiLost("refreshAuthToken() - No refreshToken") @@ -970,7 +970,7 @@ private refreshAuthToken() { method: 'POST', uri : apiEndpoint, path : "/token", - query : [grant_type: 'refresh_token', code: "${atomicState.refreshToken}", client_id: smartThingsClientId], + query : [grant_type: 'refresh_token', code: "${state.refreshToken}", client_id: smartThingsClientId], ] LOG("refreshParams = ${refreshParams}") @@ -982,54 +982,54 @@ private refreshAuthToken() { if(resp.status == 200) { LOG("refreshAuthToken() - 200 Response received - Extracting info." ) - jsonMap = resp.data // Needed to work around strange bug that wasn't updating atomicState when accessing resp.data directly + jsonMap = resp.data // Needed to work around strange bug that wasn't updating state when accessing resp.data directly LOG("resp.data = ${resp.data} -- jsonMap is? ${jsonMap}") if(jsonMap) { LOG("resp.data == ${resp.data}, jsonMap == ${jsonMap}") - atomicState.refreshToken = jsonMap.refresh_token + state.refreshToken = jsonMap.refresh_token - // TODO - Platform BUG: This was not updating the atomicState values for some reason if we use resp.data directly??? + // TODO - Platform BUG: This was not updating the state values for some reason if we use resp.data directly??? // Workaround using jsonMap for authToken - LOG("atomicState.authToken before: ${atomicState.authToken}") - def oldAuthToken = atomicState.authToken - atomicState.authToken = jsonMap?.access_token - LOG("atomicState.authToken after: ${atomicState.authToken}") - if (oldAuthToken == atomicState.authToken) { - LOG("WARN: atomicState.authToken did NOT update properly! This is likely a transient problem.", 1, null, "warn") - atomicState.connected = "warn" + LOG("state.authToken before: ${state.authToken}") + def oldAuthToken = state.authToken + state.authToken = jsonMap?.access_token + LOG("state.authToken after: ${state.authToken}") + if (oldAuthToken == state.authToken) { + LOG("WARN: state.authToken did NOT update properly! This is likely a transient problem.", 1, null, "warn") + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices } // Save the expiry time to optimize the refresh LOG("Expires in ${resp?.data?.expires_in} seconds") - atomicState.authTokenExpires = (resp?.data?.expires_in * 1000) + now() + state.authTokenExpires = (resp?.data?.expires_in * 1000) + now() - LOG("Refresh Token = atomicState =${atomicState.refreshToken} == in: ${resp?.data?.refresh_token}") - LOG("OAUTH Token = atomicState ${atomicState.authToken} == in: ${resp?.data?.access_token}") + LOG("Refresh Token = state =${state.refreshToken} == in: ${resp?.data?.refresh_token}") + LOG("OAUTH Token = state ${state.authToken} == in: ${resp?.data?.access_token}") - if(atomicState.action && atomicState.action != "") { - LOG("Token refreshed. Executing next action: ${atomicState.action}") + if(state.action && state.action != "") { + LOG("Token refreshed. Executing next action: ${state.action}") - "${atomicState.action}"() + "${state.action}"() // Reset saved action - atomicState.action = "" + state.action = "" } } else { LOG("No jsonMap??? ${jsonMap}", 2) } - atomicState.action = "" - atomicState.connected = "full" + state.action = "" + state.connected = "full" generateEventLocalParams() // Update the connected state at the thermostat devices } else { LOG("Refresh failed ${resp.status} : ${resp.status.code}!", 1, null, "error") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices } } @@ -1041,14 +1041,14 @@ private refreshAuthToken() { apiLost("refreshAuthToken() - Received data.status.code = 14" ) } else if (e.statusCode != 401) { //this issue might comes from exceed 20sec app execution, connectivity issue etc. LOG("refreshAuthToken() - e.statusCode: ${e.statusCode}", 1, null, "warn") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices runIn(reAttemptPeriod, "refreshAuthToken") } else if (e.statusCode == 401) { // status.code other than 14 - atomicState.reAttempt = atomicState.reAttempt + 1 - LOG("reAttempt refreshAuthToken to try = ${atomicState.reAttempt}", 1, null, "warn") - if (atomicState.reAttempt <= 3) { - atomicState.connected = "warn" + state.reAttempt = state.reAttempt + 1 + LOG("reAttempt refreshAuthToken to try = ${state.reAttempt}", 1, null, "warn") + if (state.reAttempt <= 3) { + state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices runIn(reAttemptPeriod, "refreshAuthToken") } else { @@ -1082,23 +1082,26 @@ def resumeProgram(child, deviceId) { return result } -def setHold(child, heating, cooling, deviceId, sendHoldType=null, fanMode="") { +def setHold(child, heating, cooling, deviceId, sendHoldType=null, fanMode="", extraParams=[]) { int h = (getTemperatureScale() == "C") ? (cToF(heating) * 10) : (heating * 10) int c = (getTemperatureScale() == "C") ? (cToF(cooling) * 10) : (cooling * 10) LOG("setHold(): setpoints____ - h: ${heating} - ${h}, c: ${cooling} - ${c}, setHoldType: ${sendHoldType}", 3, child) - def tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? - [coolHoldTemp:"${c}", heatHoldTemp: "${h}", holdType:"${sendHoldType}" - ] : - [coolHoldTemp:"${c}", heatHoldTemp: "${h}" - ] - - - if (fanMode != "") { tstatSettings << [fan:"${fanMode}"] } - - + def tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? + [coolHoldTemp:"${c}", heatHoldTemp: "${h}", holdType:"${sendHoldType}" + ] : + [coolHoldTemp:"${c}", heatHoldTemp: "${h}" + ] + + if (fanMode != "") { + tstatSettings << [fan:"${fanMode}"] + } + + if (extraParams != []) { + tstatSettings << extraParams + } //def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '","includeRuntime":true},"thermostat": {"settings":{"hvacMode":"'+"${mode}"+'"}}}' def jsonRequestBody = buildBodyRequest('setHold',null,deviceId,tstatSettings,null).toString() @@ -1127,7 +1130,7 @@ def setMode(child, mode, deviceId) { def result = sendJson(jsonRequestBody) LOG("setMode to ${mode} with result ${result}", 4, child) if (result) { - generateQuickEvent("thermostatMode", mode, 15) + child.generateQuickEvent("thermostatMode", mode, 15) } else { LOG("Unable to set new mode (${mode})", 1, child, "warn") } @@ -1136,28 +1139,84 @@ def setMode(child, mode, deviceId) { } -def setFanMinOnTime(child, time, deviceId) { - LOG("setFanMinOnTime() - Not yet implemented!", 1, chile, "warn") +def setFanMinOnTime(child, min, deviceId, temp=true) { + LOG("setFanMinOnTime() - Setting for ${min} minutes", 1, child, "warn") + def h = "690" + def c = "770" + + def tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? + [coolHoldTemp:"${c}", heatHoldTemp: "${h}", holdType:"${sendHoldType}" + ] : + [coolHoldTemp:"${c}", heatHoldTemp: "${h}" + ] + + + def jsonRequestBody + // Determine if this is setting the minimum time temperarily or permanment basis + if(temp) { + // temporarily set the fanMinOnTime + LOG("setFanMinOnTime() in 'temp' if", 4, child) + def currentHeatingSetpoint = h ?: child.device.currentValue("heatingSetpoint") + def currentCoolingSetpoint = child.device.currentValue("coolingSetpoint") + def holdType = whatHoldType() + def tstatParams = tstatSettings + tstatParams << [fanMinOnTime:"${min}", isTemperatureRelative: "false", isTemperatureAbsolute: "false"] + // jsonRequestBody = buildBodyRequest('setHold',null,deviceId,tstatParams,null).toString() + jsonRequestBody = '{"functions":[{"type":"setHold","params":{"coolHoldTemp":"730","heatHoldTemp":"690","holdType":"nextTransition","fan":"auto","fanMinOnTime":"15","isTemperatureRelative":"false","isTemperatureAbsolute":"false"}}],"selection":{"selectionType":"thermostats","selectionMatch":"312989153500"},"thermostat":{"settings":{"fanMinOnTime":"15"}}}' + //jsonRequestBody = '{"functions":[{"type":"setHold","params":{"fanMinOnTime":"15","coolHoldTemp":"730","heatHoldTemp":"690","holdType":"nextTransition","fan":"auto"}}],"selection":{"selectionType":"thermostats","selectionMatch":"312989153500"}}' + //jsonRequestBody = '{"functions":[{"type":"setHold","event":[{"type":"hold","name":"auto","fanMinOnTime":"15","isTemperatureRelative":"false","isTemperatureAbsolute":"false"}]}],"selection":{"selectionType":"thermostats","selectionMatch":"312989153500"}}' + LOG("setFanMinOnTime Request Body (temp) = ${jsonRequestBody}", 4, child) + } else { + // Change the value in settings until changed again + + tstatSettings << [fanMinOnTime:"${min}"] + + jsonRequestBody = buildBodyRequest('setThermostatSettings',null,deviceId,null,tstatSettings).toString() + LOG("setFanMinOnTime Request Body = ${jsonRequestBody}", 4, child) + } + + + + def result = sendJson(child, jsonRequestBody) + LOG("seFanMinOnTime to ${min} with result ${result}", 4, child) + if (result) { + child.generateQuickEvent("fanMinOnTime", min, 15) + } else { + LOG("Unable to set fanMinOnTime (${min})", 1, child, "warn") + } + } + +// TODO: Pull the learnings from the setFanMinOnTime to be able to set just the fan settings without impacting temperature. +// TODO: Be sure to take into consideration any existing running Event? def setFanMode(child, fanMode, deviceId, sendHoldType=null) { LOG("setFanMode() to ${fanMode} with DeviceID: ${deviceId}", 5, child) - - - def tstatSettings - // TODO: Handle Circulate as a special case? + def extraParams = [isTemperatureRelative: "false", isTemperatureAbsolute: "false"] + // TODO: Set the fan mode to circulate in the events data sent to the device if (fanMode == "circulate") { fanMode = "auto" - // Add a minimum circulate time here + // Add a minimum circulate time here + extraParams << [fanMinOnTime: "15"] + child.state.circulateFanModeOn = true + return true + } else if (fanMode == "off") { + child.state.circulateFanModeOn = false + fanMode = "auto" + extraParams << [fanMinOnTime: "0"] + } else { + child.state.circulateFanModeOn = false } + // TODO Check to see if there is an existing event and use that to overwrite? def currentHeatingSetpoint = child.device.currentValue("heatingSetpoint") def currentCoolingSetpoint = child.device.currentValue("coolingSetpoint") def holdType = sendHoldType ?: whatHoldType() - return setHold(child, currentHeatingSetpoint, currentCoolingSetpoint, deviceId, holdType, fanMode) + return setHold(child, currentHeatingSetpoint, currentCoolingSetpoint, deviceId, holdType, fanMode, extraParams) + } @@ -1195,7 +1254,7 @@ def sendJson(child = null, String jsonBody) { def cmdParams = [ uri: apiEndpoint, path: "/1/thermostat", - headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${state.authToken}"], body: jsonBody ] @@ -1204,54 +1263,53 @@ def sendJson(child = null, String jsonBody) { int j=0 while ( (statusCode) && (j++ < 2) ) { // only retry once - httpPost(cmdParams) { resp -> - statusCode = resp.data.status.code - - LOG("sendJson() resp.status ${resp.status}, resp.data: ${resp.data}, statusCode: ${statusCode}", 2, child) - - // TODO: Perhaps add at least two tries incase the first one fails? - if(resp.status == 200) { - LOG("Updated ${resp.data}", 4) - returnStatus = resp.data.status.code - if (resp.data.status.code == 0) { - LOG("Successful call to ecobee API.", 2, child) - atomicState.connected = "full" - generateEventLocalParams() - statusCode=false - } else { - LOG("Error return code = ${resp.data.status.code}", 1, child, "error") - } - } else { - LOG("Sent Json & got http status ${resp.status} - ${resp.status.code}", 2, child, "warn") - - //refresh the auth token - if (resp.status == 500 && resp.status.code == 14) { - LOG("Refreshing your auth_token!") - refreshAuthToken() - return false // No way to recover from a status.code 14 + httpPost(cmdParams) { resp -> + statusCode = resp.data.status.code + + LOG("sendJson() resp.status ${resp.status}, resp.data: ${resp.data}, statusCode: ${statusCode}", 2, child) + + // TODO: Perhaps add at least two tries incase the first one fails? + if(resp.status == 200) { + LOG("Updated ${resp.data}", 4) + returnStatus = resp.data.status.code + if (resp.data.status.code == 0) { + LOG("Successful call to ecobee API.", 2, child) + state.connected = "full" + generateEventLocalParams() + statusCode=false + } else { + LOG("Error return code = ${resp.data.status.code}", 1, child, "error") + } } else { - LOG("Possible Authentication error, invalid authentication method, lack of credentials, etc. Status: ${resp.status} - ${resp.status.code} ", 2, child, "error") - atomicState.connected = "warn" - generateEventLocalParams() - if (j == 2) { // Go ahead and refresh on the second pass through - refreshAuthToken() - return false - } - - } - } // resp.status if/else - } // HttpPost + LOG("Sent Json & got http status ${resp.status} - ${resp.status.code}", 2, child, "warn") + + //refresh the auth token + if (resp.status == 500 && resp.status.code == 14) { + LOG("Refreshing your auth_token!") + refreshAuthToken() + return false // No way to recover from a status.code 14 + } else { + LOG("Possible Authentication error, invalid authentication method, lack of credentials, etc. Status: ${resp.status} - ${resp.status.code} ", 2, child, "error") + state.connected = "warn" + generateEventLocalParams() + if (j == 2) { // Go ahead and refresh on the second pass through + refreshAuthToken() + return false + } + } + } // resp.status if/else + } // HttpPost } // While loop } catch (groovyx.net.http.HttpResponseException e) { LOG("sendJson() >> HttpResponseException occured. Exception info: ${e} StatusCode: ${e.statusCode} response? data: ${e.getResponse()?.getData()}", 1, child, "error") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() refreshAuthToken() return false } catch(Exception e) { // Might need to further break down LOG("sendJson() - Exception Sending Json: " + e, 1, child, "error") - atomicState.connected = "warn" + state.connected = "warn" generateEventLocalParams() if (j == 2) { // Go ahead and refresh on the second pass through refreshAuthToken() @@ -1270,7 +1328,7 @@ def getChildSensorName() { return "Ecobee Sensor" } def getServerUrl() { return "https://graph.api.smartthings.com" } def getShardUrl() { return getApiServerUrl() } def getCallbackUrl() { return "${serverUrl}/oauth/callback" } -def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" } +def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${shardUrl}" } def getApiEndpoint() { return "https://api.ecobee.com" } // This is the API Key from the Ecobee developer page. Can be provided by the app provider or use the appSettings @@ -1323,20 +1381,20 @@ private def debugEventFromParent(child, message) { //send both push notification and mobile activity feeds def sendPushAndFeeds(notificationMessage) { LOG("sendPushAndFeeds >> notificationMessage: ${notificationMessage}", 1, null, "warn") - LOG("sendPushAndFeeds >> atomicState.timeSendPush: ${atomicState.timeSendPush}", 1, null, "warn") + LOG("sendPushAndFeeds >> state.timeSendPush: ${state.timeSendPush}", 1, null, "warn") - if (atomicState.timeSendPush) { - if ( (now() - atomicState.timeSendPush) >= (1000 * 60 * 60 * 1)){ // notification is sent to remind user no more than once per hour + if (state.timeSendPush) { + if ( (now() - state.timeSendPush) >= (1000 * 60 * 60 * 1)){ // notification is sent to remind user no more than once per hour sendPush("Your Ecobee thermostat " + notificationMessage) sendActivityFeeds(notificationMessage) - atomicState.timeSendPush = now() + state.timeSendPush = now() } } else { sendPush("Your Ecobee thermostat " + notificationMessage) sendActivityFeeds(notificationMessage) - atomicState.timeSendPush = now() + state.timeSendPush = now() } - // atomicState.authToken = null + // state.authToken = null } def sendActivityFeeds(notificationMessage) { @@ -1416,8 +1474,8 @@ private def getMinMinBtwPolls() { // Are we connected with the Ecobee service? private String apiConnected() { // values can be "full", "warn", "lost" - if (atomicState.connected == null) atomicState.connected = "lost" - return atomicState.connected?.toString() ?: "lost" + if (state.connected == null) state.connected = "lost" + return state.connected?.toString() ?: "lost" } private def apiLost(where = "not specified") { @@ -1425,8 +1483,8 @@ private def apiLost(where = "not specified") { // provide cleanup steps when API Connection is lost def notificationMessage = "is disconnected from SmartThings/Ecobee, because the access credential changed or was lost. Please go to the Ecobee (Connect) SmartApp and re-enter your account login credentials." - atomicState.connected = "lost" - atomicState.authToken = null + state.connected = "lost" + state.authToken = null sendPushAndFeeds(notificationMessage) generateEventLocalParams() @@ -1448,7 +1506,7 @@ private def apiLost(where = "not specified") { def notifyApiLost() { def notificationMessage = "is disconnected from SmartThings/Ecobee, because the access credential changed or was lost. Please go to the Ecobee (Connect) SmartApp and re-enter your account login credentials." - if ( atomicState.connected == "lost" ) { + if ( state.connected == "lost" ) { generateEventLocalParams() sendPushAndFeeds(notificationMessage) LOG("notifyApiLost() - API Connection Previously Lost. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 0, null, "error", true, true) @@ -1470,7 +1528,7 @@ private Boolean readyForAuthRefresh() { LOG("Entered readyForAuthRefresh() ", 5) def timeLeft - timeLeft = atomicState.authTokenExpires ? ((atomicState.authTokenExpires - now()) / 1000 / 60) : 0 + timeLeft = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 LOG("timeLeft until expiry (in min): ${timeLeft}", 3) @@ -1500,7 +1558,7 @@ private debugLevel(level=3) { // Mark the poll data as "dirty" to allow a new API call to take place private def dirtyPollData() { LOG("dirtyPollData() called to reset poll state", 5) - atomicState.lastPoll = 0 + state.lastPoll = 0 } @@ -1514,7 +1572,7 @@ private def dirtyPollData() { // may be set to null if not relevant for the given method // thermostatId may be a list of serial# separated by ",", no spaces (ex. '123456789012,123456789013') private def buildBodyRequest(method, tstatType="registered", thermostatId, tstatParams = [], - tstatSettings = []) { + tstatSettings = [], tstatEvents = []) { LOG("Entered buildBodyRequest()", 5) def selectionJson = null @@ -1556,6 +1614,7 @@ private def buildBodyRequest(method, tstatType="registered", thermostatId, tstat selection = [selectionType: 'thermostats', selectionMatch: thermostatId] } selectionJson = new groovy.json.JsonBuilder(selection) + if ((method != 'setThermostatSettings') && (tstatSettings != null) && (tstatSettings != [])) { def function_clause = ((tstatParams != null) && (tsatParams != [])) ? [type:method, params: tstatParams] : @@ -1588,6 +1647,7 @@ private def buildBodyRequest(method, tstatType="registered", thermostatId, tstat // tstatType =managementSet or registered (no spaces). May also be set to a specific locationSet (ex./Toronto/Campus/BuildingA) // settings can be anything supported by ecobee // at https://www.ecobee.com/home/developer/api/documentation/v1/objects/Settings.shtml +/* void iterateSetThermostatSettings(tstatType, tstatSettings = []) { Integer MAX_TSTAT_BATCH = get_MAX_TSTAT_BATCH() def tstatlist = null @@ -1629,6 +1689,9 @@ void iterateSetThermostatSettings(tstatType, tstatSettings = []) { } } +*/ + +/* // thermostatId may be a list of serial# separated by ",", no spaces (ex. '123456789012,123456789013') // if no thermostatId is provided, it is defaulted to the current thermostatId // settings can be anything supported by ecobee at https://www.ecobee.com/home/developer/api/documentation/v1/objects/Settings.shtml @@ -1658,7 +1721,9 @@ void setThermostatSettings(thermostatId,tstatSettings = []) { def cmd= [] cmd << "delay 1000" cmd - } /* end if statusCode */ - } /* end api call */ - } /* end for */ -} \ No newline at end of file + } // end if statusCode + } // end api call + } // end for +} + +*/ \ No newline at end of file From ff4fd8db3bd6570dcc7f51b8a21b7b6aab806c8c Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Sun, 31 Jan 2016 15:38:04 -0600 Subject: [PATCH 02/27] Checkpoint. Able to create devices now and use the Things Tiles --- .../ecobee-connect.src/ecobee-connect.groovy | 630 ++++++++++++------ 1 file changed, 408 insertions(+), 222 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index 10f55fbfe1f..5ffd3292eae 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -21,15 +21,20 @@ * 10-28-2015 DVCSMP-604 - accessory sensor, DVCSMP-1174, DVCSMP-1111 - not respond to routines * StrykerSKS - 12-11-2015 - Make it work (better) with the Ecobee 3 * - * Current Version: 0.8.9-RC - * See separate Changelog for change history + * See Changelog for change history * - */ + */ +private def getVersion() { return "ecobee (Connect) Version 0.9.0-RC" } +private def getHelperSmartApps() { + return [ "ecobee Routines": [multiple: true, description: "Example Description"], + "ecobee ABC": [multiple: false, description: "Example Description for ecobee ABC"] + ] +} definition( name: "Ecobee (Connect)", namespace: "smartthings", - author: "SmartThings", + author: "Sean Kendall Schneyer", description: "Connect your Ecobee thermostat to SmartThings.", category: "My Apps", iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", @@ -40,9 +45,16 @@ definition( } preferences { - page(name: "auth", title: "ecobee3 Auth", nextPage: "therms", content: "authPage", uninstall: true) - page(name: "therms", title: "Select Thermostats", nextPage: "sensors", content: "thermsPage") - page(name: "sensors", title: "Select Sensors", nextPage: "", content: "sensorsPage", install:true) + page(name: "mainPage") + page(name: "removePage") + page(name: "authPage") + page(name: "thermsPage") + page(name: "sensorsPage") + page(name: "preferencesPage") + page(name: "helperSmartAppsPage") + // Part of debug Dashboard + page(name: "debugDashboardPage") + page(name: "pollChildrenPage") } mappings { @@ -52,6 +64,65 @@ mappings { // Begin Preference Pages +def mainPage() { + dynamicPage(name: "mainPage", title: "Welcome to ecobee (Connect)", install: true, uninstall: false, submitOnChange: true) { + def ecoAuthDesc = (state.authToken != null) ? "[Connected]\n" :"[Not Connected]\n" + + if(state.initialized && !state.authToken) { + section() { + paragraph "WARNING!\n\nYou are no longer connected to the ecobee API. Please re-Authorize below." + } + } + + if(state.authToken != null) { + section("Devices") { + def howManyThermsSel = settings.thermostats?.size() ?: 0 + def howManyTherms = state.numAvailTherms ?: "?" + def howManySensors = state.numAvailSensors ?: "?" + + // Thermostats + state.settingsCurrentTherms = settings.thermostats ?: [] + href ("thermsPage", title: "Thermostats", description: "Tap to select Thermostats [${howManyThermsSel}/${howManyTherms}]") + + // Sensors + if (settings.thermostats?.size() > 0) { + state.settingsCurrentSensors = settings.ecobeesensors ?: [] + def howManySensorsSel = settings.ecobeesensors?.size() ?: 0 + href ("sensorsPage", title: "Sensors", description: "Tap to select Sensors [${howManySensorsSel}/${howManySensors}]") + } + } + section("Preferences") { + href ("preferencesPage", title: "Preferences", description: "Tap to review SmartApp settings.") + LOG("In Preferences page section after preferences line", 5, null, "trace") + } + if ( debugLevel(5) ) { + section ("Debug Dashboard") { + href ("debugDashboardPage", description: "Tap to enter the Debug Dashboard", title: "") + } + } + section("Remove ecobee (Connect)") { + href ("removePage", description: "Tap to remove ecobee (Connect) ", title: "") + } + } // End if(state.authToken) + + // Setup our API Tokens + section("Ecobee Authentication") { + href ("authPage", title: "ecobee Authorization", description: "${ecoAuthDesc}Tap for ecobee Credentials") + } + + section (getVersion()) + } +} + + +def removePage() { + dynamicPage(name: "removePage", title: "Remove ecobee (Connect) and All Devices", install: false, uninstall: true) { + section ("WARNING!\n\nRemoving ecobee (Connect) also removes all Devices\n") { + } + } +} + +// Setup OAuth between SmartThings and Ecobee clouds def authPage() { LOG("=====> authPage() Entered", 5) @@ -59,16 +130,17 @@ def authPage() { state.accessToken = createAccessToken() } - def description = "Click to enter Ecobee Credentials" + def description = "Click to enter ecobee Credentials" def uninstallAllowed = false def oauthTokenProvided = false if(state.authToken) { - description = "You are connected. Click Next above." + description = "You are connected. Tap Done above." uninstallAllowed = true oauthTokenProvided = true + state.connected = "full" } else { - description = "Click to enter Ecobee Credentials" + description = "Tap to enter ecobee Credentials" } def redirectUrl = buildRedirectUrl //"${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}" @@ -77,77 +149,142 @@ def authPage() { // get rid of next button until the user is actually auth'd if (!oauthTokenProvided) { LOG("authPage() --> in !oauthTokenProvided") - return dynamicPage(name: "auth", title: "ecobee Setup", nextPage: "", uninstall: uninstallAllowed) { + return dynamicPage(name: "authPage", title: "ecobee Setup", nextPage: "", uninstall: uninstallAllowed) { section() { - paragraph "Tap below to log in to the ecobee service and authorize SmartThings access. Be sure to scroll down on page 2 and press the 'Allow' button." - href url:redirectUrl, style:"embedded", required:true, title: "ecobee Account Login", description:description + paragraph "Tap below to log in to the ecobee service and authorize SmartThings access. Be sure to press the 'Allow' button on the 2nd page." + href url:redirectUrl, style:"embedded", required:true, title: "ecobee Account Authorization", description:description } } } else { LOG("authPage() --> in else for oauthTokenProvided - ${state.authToken}.") - return dynamicPage(name: "auth", title: "ecobee Setup", nextPage: "therms", uninstall: uninstallAllowed) { + return dynamicPage(name: "authPage", title: "ecobee Setup", nextPage: "mainPage", uninstall: uninstallAllowed) { section() { - paragraph "Continue on to select thermostats." - href url:redirectUrl, style: "embedded", state: "complete", title: "ecobee Account Login", description: description + paragraph "Return to main menu." + href url:redirectUrl, style: "embedded", state: "complete", title: "ecobee Account Authorization", description: description } } } } -def thermsPage() { +// Select which Thermostats are to be used +def thermsPage(params) { LOG("=====> thermsPage() entered", 5) + state.thermsPageVisited = true def stats = getEcobeeThermostats() - LOG("thermsPage() -> thermostat list: $stats") + LOG("thermsPage() -> thermostat list: ${stats}") LOG("thermsPage() starting settings: ${settings}") - - dynamicPage(name: "therms", title: "Select Thermostats", nextPage: "sensors", content: "thermsPage", uninstall: true) { + LOG("thermsPage() params passed? ${params}", 4, null, "trace") + + dynamicPage(name: "thermsPage", title: "Select Thermostats", params: params, nextPage: "", content: "thermsPage", uninstall: false) { section("Units") { paragraph "NOTE: The units type (F or C) is determined by your Hub Location settings automatically. Please update your Hub settings (under My Locations) to change the units used. Current value is ${getTemperatureScale()}." } section("Select Thermostats") { + LOG("thersPage(): state.settingsCurrentTherms=${state.settingsCurrentTherms} settings.thermostats=${settings.thermostats}", 4, null, "trace") + if (state.settingsCurrentTherms != settings.thermostats) { + LOG("state.settingsCurrentTherms != settings.thermostats determined!!!", 4, null, "trace") + } else { LOG("state.settingsCurrentTherms == settings.thermostats: No changes detected!", 4, null, "trace") } paragraph "Tap below to see the list of ecobee thermostats available in your ecobee account and select the ones you want to connect to SmartThings." - input(name: "thermostats", title:"Select Thermostats", type: "enum", required:true, multiple:true, description: "Tap to choose", metadata:[values:stats]) - } - section("Optional Settings") { - input(name: "holdType", title:"Select Hold Type", type: "enum", required:false, multiple:false, description: "Until I Change", metadata:[values:["Until I Change", "Until Next Program"]]) - input(name: "smartAuto", title:"Use Smart Auto Temperature Adjust?", type: "bool", required:false, description: false) - input(name: "pollingInterval", title:"Polling Interval (in Minutes)", type: "enum", required:false, multiple:false, description: "5", options:["5", "10", "15", "30"]) - input(name: "debugLevel", title:"Debugging Level (higher # is more data reported)", type: "enum", required:false, multiple:false, description: "3", metadata:[values:["5", "4", "3", "2", "1", "0"]]) - } - } + input(name: "thermostats", title:"Select Thermostats", type: "enum", required:false, multiple:true, description: "Tap to choose", params: params, metadata:[values:stats], , submitOnChange: true) + } + } } def sensorsPage() { // Only show sensors that are part of the chosen thermostat(s) + // Refactor to show the sensors under their corresponding Thermostats. Use Thermostat name as section header? LOG("=====> sensorsPage() entered. settings: ${settings}", 5) + state.sensorsPageVisited = true def options = getEcobeeSensors() ?: [] def numFound = options.size() ?: 0 - + LOG("options = getEcobeeSensors == ${options}") - dynamicPage(name: "sensors", title: "Select Sensors", nextPage: "") { + dynamicPage(name: "sensorsPage", title: "Select Sensors", nextPage: "") { if (numFound > 0) { - section(""){ + section("Select Sensors"){ + LOG("sensorsPage(): state.settingsCurrentSensors=${state.settingsCurrentSensors} settings.ecobeesensors=${settings.ecobeesensors}", 4, null, "trace") + if (state.settingsCurrentSensors != settings.ecobeesensors) { + LOG("state.settingsCurrentSensors != settings.ecobeesensors determined!!!", 4, null, "trace") + } else { LOG("state.settingsCurrentSensors == settings.ecobeesensors: No changes detected!", 4, null, "trace") } paragraph "Tap below to see the list of ecobee sensors available for the selected thermostat(s) and select the ones you want to connect to SmartThings." + if (settings.showThermsAsSensor) { paragraph "NOTE: Also showing Thermostats as an available sensor to allow for actual temperature values to be used." } input(name: "ecobeesensors", title:"Select Ecobee Sensors (${numFound} found)", type: "enum", required:false, description: "Tap to choose", multiple:true, metadata:[values:options]) } } else { - // Must not have any sensors associated with this Thermostat + // No sensors associated with this set of Thermostats was found LOG("sensorsPage(): No sensors found.", 4) section(""){ paragraph "No associated sensors were found. Click Done above." } - } + } + } +} + +def preferencesPage() { + LOG("=====> preferencesPage() entered. settings: ${settings}", 5) + + dynamicPage(name: "preferencesPage", title: "Update SmartApp Preferences", nextPage: "") { + section("SmartApp Preferences") { + input(name: "holdType", title:"Select Hold Type", type: "enum", required:false, multiple:false, description: "Until I Change", metadata:[values:["Until I Change", "Until Next Program"]]) + paragraph "The 'Smart Auto Temperature Adjust' feature determines if you want to allow the thermostat setpoint to be changed using the arrow buttons in the Tile when the thermostat is in 'auto' mode." + input(name: "smartAuto", title:"Use Smart Auto Temperature Adjust?", type: "bool", required:false, description: false) + input(name: "pollingInterval", title:"Polling Interval (in Minutes)", type: "enum", required:false, multiple:false, description: "5", options:["5", "10", "15", "30"]) + input(name: "debugLevel", title:"Debugging Level (higher # for more information)", type: "enum", required:false, multiple:false, description: "3", metadata:[values:["5", "4", "3", "2", "1", "0"]]) + paragraph "Showing a Thermostat as a Remote Sensor is useful if you need to access the actual temperature in the room where the Thermostat is located and not just the (average) temperature displayed on the Thermostat" + input(name: "showThermsAsSensor", title:"Include Thermostats as a Remote Sensor?", type: "bool", required:false, description: false) + } } +} + +def debugDashboardPage() { + LOG("=====> debugDashboardPage() entered.", 5) + + + dynamicPage(name: "debugDashboardPage", title: "") { + section (getVersion()) + section("Commands") { + href(name: "pollChildrenPage", title: "", required: false, page: "pollChildrenPage", description: "Tap to execute command: pollChildren()") + } + section("Settings Information") { + paragraph "debugLevel: ${settings.debugLevel} (default=3 if null)" + paragraph "holdType: ${settings.holdType} (default='Until I Change' if null)" + paragraph "pollingInterval: ${settings.pollingInterval} {default=5 if null)" + paragraph "showThermsAsSensor: ${settings.showThermsAsSensor} {default=false if null)" + paragraph "smartAuto: ${settings.smartAuto} (default=false if null)" + paragraph "Selected Thermostats: ${settings.thermostats}" + } + section("Commands") { + href(name: "pollChildrenPage", title: "", required: false, page: "pollChildrenPage", description: "Tap to execute command: pollChildren()") + href ("removePage", description: "Tap to remove ecobee (Connect) ", title: "") + } + } +} + +// pages that are part of Debug Dashboard +def pollChildrenPage() { + LOG("=====> pollChildrenPage() entered.", 5) + pollChildren(null) + + dynamicPage(name: "pollChildrenPage", title: "") { + section() { + paragraph "pollChildren() was called" + } + } +} + + +def helperSmartAppsPage() { + + } // End Prefernce Pages // OAuth Init URL def oauthInitUrl() { LOG("oauthInitUrl with callback: ${callbackUrl}", 5) - state.oauthInitState = UUID.randomUUID().toString() def oauthParams = [ @@ -165,7 +302,6 @@ def oauthInitUrl() { // OAuth Callback URL and helpers def callback() { LOG("callback()>> params: $params, params.code ${params.code}, params.state ${params.state}, state.oauthInitState ${state.oauthInitState}", 4) - def code = params.code def oauthState = params.state @@ -193,13 +329,14 @@ def callback() { } if (state.authToken) { - success() + state.lastTokenRefresh = now() + success() } else { fail() } } else { - LOG("callback() failed oauthState != state.oauthInitState", 1) + LOG("callback() failed oauthState != state.oauthInitState", 1, null, "warn") } } @@ -317,6 +454,8 @@ def getEcobeeThermostats() { if (resp.status == 200) { LOG("httpGet() in 200 Response") + state.numAvailTherms = resp.data.thermostatList?.size() ?: 0 + resp.data.thermostatList.each { stat -> def dni = [app.id, stat.identifier].join('.') stats[dni] = getThermostatDisplayName(stat) @@ -344,6 +483,7 @@ def getEcobeeThermostats() { } // Get the list of Ecobee Sensors for use in the settings pages (Only include the sensors that are tied to a thermostat that was selected) +// NOTE: getEcobeeThermostats() should be called prior to getEcobeeSensors to refresh the full data of all thermostats Map getEcobeeSensors() { LOG("====> getEcobeeSensors() entered. thermostats: ${thermostats}", 5) @@ -352,8 +492,12 @@ Map getEcobeeSensors() { // TODO: Is this needed? state.remoteSensors = [:] + // Need to query to get full list of Thermostats (need to pull this here as we can call getEcobeeSensors out of sequence after initial setup + // TODO: Check on possible race conditions. Leave to update and initialize procedures to call in sequence? + // getEcobeeThermostats() + state.thermostatData.thermostatList.each { singleStat -> - LOG("thermostat loop: singleStat == ${singleStat} singleStat.identifier == ${singleStat.identifier}", 4) + LOG("thermostat loop: singleStat.identifier == ${singleStat.identifier} -- singleStat.remoteSensors == ${singleStat.remoteSensors} ", 4) if (!settings.thermostats.findAll{ it.contains(singleStat.identifier) } ) { // We can skip this thermostat as it was not selected by the user @@ -375,17 +519,29 @@ Map getEcobeeSensors() { LOG("getEcobeeSensors() - state.remoteSensors: ${state.remoteSensors}", 4) } + LOG("remoteSensors all before each loop: ${state.remoteSensors}", 5, null, "trace") state.remoteSensors.each { - if (it.type != "thermostat") { + LOG("Looping through each remoteSensor. Current remoteSensor: ${it}", 5, null, "trace") + if (it.type == "ecobee3_remote_sensor") { + LOG("Adding an ecobee3_remote_sensor: ${it}", 4, null, "trace") def value = "${it?.name}" def key = "ecobee_sensor-"+ it?.id + "-" + it?.code sensorMap["${key}"] = value - } + } else if ( (it.type == "thermostat") && (settings.showThermsAsSensor == true) ) { + LOG("Adding a Thermostat as a Sensor: ${it}", 4, null, "trace") + def value = "${it?.name}" + def key = "ecobee_sensor_thermostat-"+ it?.id + "-" + singleStat.identifier + LOG("Adding a Thermostat as a Sensor: ${it}, key: ${key} value: ${value}", 4, null, "trace") + sensorMap["${key}"] = value + " (Thermostat)" + } else { + LOG("Did NOT add: ${it}. settings.showThermsAsSensor=${settings.showThermsAsSensor}", 4, null, "trace") + } } } // end thermostats.each loop LOG("getEcobeeSensors() - remote sensor list: ${sensorMap}", 4) state.eligibleSensors = sensorMap + state.numAvailSensors = sensorMap.size() ?: 0 return sensorMap } @@ -402,41 +558,109 @@ def getThermostatTypeName(stat) { } def installed() { - LOG("Installed with settings: ${settings}", 5) + LOG("Installed with settings: ${settings}", 4) initialize() } def updated() { LOG("Updated with settings: ${settings}", 4) - unsubscribe() - initialize() + unsubscribe() // Do we really need/want to unsubscribe to anything? + + // refresh Thermostats and Sensor full lists + getEcobeeThermostats() + getEcobeeSensors() + + // Children + if (settings.thermostats?.size() > 0) { + createChildrenThermostats() + if (settings.ecobeesensors?.size() > 0) { createChildrenSensors() } + } + deleteUnusedChildren() + + + // Force the rescheduling + state.lastScheduledPoll = 0 + state.lastPoll = 0 + state.lastTokenRefresh = 0 + scheduleHandlers() } def initialize() { LOG("=====> initialize()", 4) - - state.connected = "full" - unschedule() + if (state.initialized) { + LOG("initialized() called more than once. Please contact the developer of this app.", 1, null, "error") + return false + } + state.connected = "full" + unschedule() // Shouldn't be needed as we are only calling intialize once now, but just in case state.reAttempt = 0 + // Setup initial polling and determine polling intervals + state.pollingInterval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 + state.tokenGrace = 16 // Anything more than this then we have a possible failed + + // Children + def aOK = true + if (settings.thermostats?.size() > 0) { aOK = aOK && createChildrenThermostats() } + if (settings.ecobeesensors?.size() > 0) { aOK = aOK && createChildrenSensors() } + deleteUnusedChildren() + + // Schedule the various handlers + scheduleHandlers() + + // Add subscriptions as little "daemons" that will check on our health + subscribe(location, "routineExecuted", scheduleHandlers) + subscribe(location, "sunset", scheduleHandlers) + subscribe(location, "sunrise", scheduleHandlers) + + // TODO: Add ability to add additional physical (or virtual) items to subscribe to that have events generated that could heal our app + + //send activity feeds to tell that device is connected + def notificationMessage = aOK ? "is connected to SmartThings" : "had an error during setup of devices" + sendActivityFeeds(notificationMessage) + state.timeSendPush = null + + state.initialized = true + return aOK +} + +private def createChildrenThermostats() { + LOG("createChildrenThermostats() entered: thermostats=${settings.thermostats}", 5) // Create the child Thermostat Devices - def devices = thermostats.collect { dni -> + def devices = settings.thermostats.collect { dni -> def d = getChildDevice(dni) if(!d) { - d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"Ecobee Thermostat:${state.thermostatsWithNames[dni]}"]) + // TODO: Place in a try block and check for this exception: physicalgraph.app.exception.UnknownDeviceTypeException + try { + d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"Ecobee Thermostat:${state.thermostatsWithNames[dni]}"]) + } catch (physicalgraph.app.exception.UnknownDeviceTypeException e) { + LOG("You MUST add the ${getChildSensorName()} Device Handler to the IDE BEFORE running the setup.", 1, null, "error") + return false + } LOG("created ${d.displayName} with id $dni", 4) } else { - LOG("found ${d.displayName} with id $dni already exists", 4) - + LOG("found ${d.displayName} with id $dni already exists", 4) } return d - } + } + + LOG("Created/Updated ${devices.size()} thermostats") + return true +} +private def createChildrenSensors() { + LOG("createChildrenSensors() entered: ecobeesensors=${settings.ecobeesensors}", 5) // Create the child Ecobee Sensor Devices def sensors = settings.ecobeesensors.collect { dni -> def d = getChildDevice(dni) if(!d) { - d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"Ecobee Sensor:${state.eligibleSensors[dni]}"]) + // TODO: Place in a try block and check for this exception: physicalgraph.app.exception.UnknownDeviceTypeException + try { + d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"Ecobee Sensor:${state.eligibleSensors[dni]}"]) + } catch (physicalgraph.app.exception.UnknownDeviceTypeException e) { + LOG("You MUST add the ${getChildSensorName()} Device Handler to the IDE BEFORE running the setup.", 1, null, "error") + return false + } LOG("created ${d.displayName} with id $dni", 4) } else { LOG("found ${d.displayName} with id $dni already exists", 4) @@ -444,61 +668,87 @@ def initialize() { return d } - LOG("created ${devices.size()} thermostats and ${sensors.size()} sensors.") - + LOG("Created/Updated ${sensors.size()} sensors.") + return true +} - // WORKAROUND: settings.ecobeesensors may contain leftover sensors in the dynamic enum bug scenario, use info in state.eligibleSensors instead - // TODO: Need to deal with individual sensors from remaining thermostats that might be excluded... - // TODO: Cleanup this code now that it is working! - def sensorList = state.eligibleSensors.keySet() - - // state.eligibleSensorsAsList = sensorList +// NOTE: For this to work effectively getEcobeeThermostats() and getEcobeeSensors() should be called prior +private def deleteUnusedChildren() { + LOG("deleteUnusedChildren() entered", 5) - def reducedSensorList = settings.ecobeesensors.findAll { sensorList.contains(it) } - LOG("**** reducedSensorList = ${reducedSensorList} *****", 4, null, "warn") - state.activeSensors = reducedSensorList + if (settings.thermostats?.size() == 0) { + // No thermostats, need to delete all children + LOG("Deleting All My Children!", 2, null, "warn") + getAllChildDevices().each { deleteChildDevice(it.deviceNetworkId) } + } else { + // Only delete those that are no longer in the list + // This should be a combination of any removed thermostats and any removed sensors + def allMyChildren = getAllChildDevices() + LOG("These are currently all of my childred: ${allMyChildren}", 5, null, "debug") + + // Update list of "eligibleSensors" + def childrenToKeep = thermostats + (state.eligibleSensors?.keySet() ?: []) + LOG("These are the children to keep around: ${childrenToKeep}", 4, null, "trace") + + def childrenToDelete = allMyChildren.findAll { !childrenToKeep.contains(it.deviceNetworkId) } - LOG("sensorList based on keys: ${sensorList} from state.sensors: ${state.eligibleSensors}", 4) + LOG("Ready to delete these devices. ${childrenToDelete}", 4, null, "trace") + childrenToDelete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) + } +} + + +def scheduleHandlers() { + if(state.connected == "lost") { + LOG("Unable to schedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, null, "error") + return + } - def combined = settings.thermostats + state.activeSensors - LOG("Combined devices == ${combined}", 4) + // state.lastScheduledPoll == last time the poll() was run via the scheduler + // state.lastScheduledTokenRefresh == last time the refreshAuthToken was run via the scheduler + //automatically update devices status every 5 mins + def timeSinceLastScheduledPoll = (state.lastScheduledPoll == 0 || !state.lastScheduledPoll) ? 0 : ((now() - state.lastScheduledPoll?.toDouble()) / 1000 / 60) + def timeSinceLastScheduledRefresh = (state.lastScheduledTokenRefresh == 0 || !state.lastScheduledTokenRefresh) ? 0 : ((now() - state.lastScheduledTokenRefresh?.toDouble()) / 1000 / 60) + def timeBeforeExpiry = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 - // Delete any that are no longer in settings - def delete + LOG("Time since last poll? ${timeSinceLastScheduledPoll} -- state.lastScheduledPoll == ${state.lastScheduledPoll}", 4, null, "info") + LOG("Time since last token refresh? ${timeSinceLastScheduledRefresh} -- state.lastScheduledTokenRefresh == ${state.lastScheduledTokenRefresh}", 4, null, "info") + LOG("timeLeft until expiry (in min): ${timeBeforeExpiry}", 4, null, "info") - if (combined) { - delete = getChildDevices().findAll { !combined.contains(it.deviceNetworkId) } - } else { - delete = getAllChildDevices() // inherits from SmartApp (data-management) - } + // Reschedule polling if it has been a while since the previous poll + def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 + if ( (timeSinceLastScheduledPoll == 0) || (timeSinceLastPoll >= (interval * 2)) || (!state.initialized) ) { + // automatically update devices status every ${interval} mins + // re-establish polling + LOG("pollChildren() - Rescheduling handlers due to delays!", 1, child, "warn") + unschedule("poll") + "runEvery${interval}Minutes"("poll") + pollScheduled() + } - LOG("delete: ${delete}, deleting ${delete.size()} thermostat(s) and/or sensor(s)", 4, null, "warn") - delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) - - state.thermostatData = [:] //reset Map to store thermostat data - - //send activity feeds to tell that device is connected - def notificationMessage = "is connected to SmartThings" - sendActivityFeeds(notificationMessage) - state.timeSendPush = null - - pollHandler() //first time polling data from thermostat + // Reschedule Authrefresh if we are over the grace period + if ( (timeSinceLastScheduledRefresh == 0) || (timeBeforeExpiry >= state.tokenGrace) || (!state.initialized) ) { + unschedule("refreshAuthTokenScheduled") + runEvery15Minutes("refreshAuthTokenScheduled") + refreshAuthTokenScheduled() + } +} - //automatically update devices status every 5 mins - def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 - "runEvery${interval}Minutes"("poll") - // runEvery5Minutes("poll") +def pollScheduled() { + state.lastScheduledPoll = now() + poll() +} - // Auth Token expires every hour - // Run as part of the poll() procedure since it runs every 5 minutes. Only runs if the time is close enough though to avoid API calls - runEvery15Minutes("refreshAuthToken") +def refreshAuthTokenScheduled() { + state.lastScheduledTokenRefresh = now() + refreshAuthToken() } // Called during initialization to get the inital poll def pollHandler() { LOG("pollHandler()", 5) - state.lastPoll = 0 // Initialize the variable and force a poll even if there was one recently + state.lastPoll = 0 // Initialize the variable and force a poll even if there was one recently pollChildren(null) // Hit the ecobee API for update on all thermostats } @@ -511,11 +761,16 @@ def pollChildren(child = null) { return } + if (settings.thermostats?.size() < 1) { + LOG("pollChildren() - Nothing to poll as there are no thermostats currently selected", 1, child, "warn") + return + } // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children def timeSinceLastPoll = (state.lastPoll == 0) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) LOG("Time since last poll? ${timeSinceLastPoll} -- state.lastPoll == ${state.lastPoll}", 3, child, "info") + // TODO: Let the scheduleHandlers() function do this instead // Reschedule polling if it has been a while since the previous poll def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 if ( timeSinceLastPoll >= (interval * 2) ) { @@ -545,11 +800,11 @@ def pollChildren(child = null) { if( oneChild.hasCapability("Thermostat") ) { // We found a Thermostat, send all of its events LOG("pollChildren() - We found a Thermostat!", 5) - oneChild.generateEvent(state.thermostats[oneChild.device.deviceNetworkId].data) + oneChild.generateEvent(state.thermostats[oneChild.device.deviceNetworkId]?.data) } else { // We must have a remote sensor - LOG("pollChildren() - Updating sensor data: ${oneChild.device.deviceNetworkId} data: ${state.remoteSensorsData[oneChild.device.deviceNetworkId].data}", 4) - oneChild.generateEvent(state.remoteSensorsData[oneChild.device.deviceNetworkId].data) + LOG("pollChildren() - Updating sensor data: ${oneChild.device.deviceNetworkId} data: ${state.remoteSensorsData[oneChild.device.deviceNetworkId]?.data}", 4) + oneChild.generateEvent(state.remoteSensorsData[oneChild.device.deviceNetworkId]?.data) } } } @@ -568,7 +823,7 @@ private def generateEventLocalParams() { apiConnected: apiConnected() ] - state.thermostats[oneChild.device.deviceNetworkId].data.apiConnected = apiConnected() + state.thermostats[oneChild.device.deviceNetworkId].data?.apiConnected = apiConnected() oneChild.generateEvent(data) } else { // We must have a remote sensor @@ -678,11 +933,7 @@ private def pollEcobeeAPI(thermostatIdsString = "") { // poll() will be called on a regular interval using a runEveryX command -void poll() { - // def devices = getChildDevices() - // devices.each {pollChildren(it)} - // TODO: if ( readyForAuthRefresh() ) { refreshAuthToken() } // Use runIn to make this feasible? - +void poll() { // Check to see if we are connected to the API or not if (apiConnected() == "lost") { LOG("poll() - Skipping poll() due to lost API Connection", 1, null, "warn") @@ -693,8 +944,7 @@ void poll() { } -def availableModes(child) { - +def availableModes(child) { def tData = state.thermostats[child.device.deviceNetworkId] LOG("state.thermostats = ${state.thermostats}", 3, child) LOG("Child DNI = ${child.device.deviceNetworkId}", 3, child) @@ -751,9 +1001,11 @@ def updateSensorData() { state.remoteSensors.each { it.each { - if ( it.type == "ecobee3_remote_sensor" ) { + if ( ( it.type == "ecobee3_remote_sensor" ) || ((it.type == "thermostat") && (settings.showThermsAsSensor)) ) { // Add this sensor to the list - def sensorDNI = "ecobee_sensor-" + it?.id + "-" + it?.code + def sensorDNI + if (it.type == "ecobee3_remote_sensor") { sensorDNI = "ecobee_sensor-" + it?.id + "-" + it?.code } + else { sensorDNI = "ecobee_sensor_thermostat-" + it?.id + "-" + it?.code } LOG("sensorDNI == ${sensorDNI}", 4) def temperature = "" @@ -795,8 +1047,9 @@ def updateSensorData() { sensorCollector[sensorDNI] = [data:sensorData] LOG("sensorCollector being updated with sensorData: ${sensorData}", 4) - } else if (it.type == "thermostat") { - // Extract the occupancy status + } else if ( (it.type == "thermostat") && (settings.showThermsAsSensor) ) { + // Also update the thermostat based Remote Sensor + } // end thermostat else if } // End it.each loop @@ -832,23 +1085,19 @@ def updateThermostatData() { runningEvent = stat.events.find { LOG("Checking event: ${it}", 5) it.running == true - } - - } - + } + } def usingMetric = wantMetric() // cache the value to save the function calls - def tempTemperature = myConvertTemperatureIfNeeded( (stat.runtime.actualTemperature / 10), "F", (usingMetric ? 1 : 0)) - def tempHeatingSetpoint = myConvertTemperatureIfNeeded( (stat.runtime.desiredHeat / 10), "F", (usingMetric ? 1 : 0)) - def tempCoolingSetpoint = myConvertTemperatureIfNeeded( (stat.runtime.desiredCool / 10), "F", (usingMetric ? 1 : 0)) - def tempWeatherTemperature = myConvertTemperatureIfNeeded( ((stat.weather.forecasts[0].temperature / 10)), "F", (usingMetric ? 1 : 0)) - - + def tempTemperature = myConvertTemperatureIfNeeded( (stat.runtime.actualTemperature.toDouble() / 10), "F", (usingMetric ? 1 : 0)) + def tempHeatingSetpoint = myConvertTemperatureIfNeeded( (stat.runtime.desiredHeat.toDouble() / 10), "F", (usingMetric ? 1 : 0)) + def tempCoolingSetpoint = myConvertTemperatureIfNeeded( (stat.runtime.desiredCool.toDouble() / 10), "F", (usingMetric ? 1 : 0)) + def tempWeatherTemperature = myConvertTemperatureIfNeeded( ((stat.weather.forecasts[0].temperature.toDouble() / 10)), "F", (usingMetric ? 1 : 0)) + def currentClimateName = "" def currentClimateId = "" def currentFanMode = "" - - + if (runningEvent) { LOG("Found a running Event: ${runningEvent}", 4) def tempClimateRef = runningEvent.holdClimateRef ?: "" @@ -935,19 +1184,21 @@ def getThermostatOperatingState(stat) { def getChildThermostatDeviceIdsString(singleStat = null) { if(!singleStat) { - LOG("getChildThermostatDeviceIdsString() - !singleStat", 2, null, "warn") + LOG("getChildThermostatDeviceIdsString() - !singleStat returning the list for all thermostats", 4, null, "info") return thermostats.collect { it.split(/\./).last() }.join(',') } else { // Only return the single thermostat def ecobeeDevId = singleStat.device.deviceNetworkID.split(/\./).last() - LOG("Received a single thermostat, returning the Ecobee Device ID as a String: ${ecobeeDevId}", 4) + LOG("Received a single thermostat, returning the Ecobee Device ID as a String: ${ecobeeDevId}", 4, null, "info") return ecobeeDevId } } +/* def toJson(Map m) { return new org.json.JSONObject(m).toString() } +*/ // Pending delete if not used anywhere def toQueryString(Map m) { return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") @@ -1138,76 +1389,25 @@ def setMode(child, mode, deviceId) { return result } - -def setFanMinOnTime(child, min, deviceId, temp=true) { - LOG("setFanMinOnTime() - Setting for ${min} minutes", 1, child, "warn") - def h = "690" - def c = "770" - - def tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? - [coolHoldTemp:"${c}", heatHoldTemp: "${h}", holdType:"${sendHoldType}" - ] : - [coolHoldTemp:"${c}", heatHoldTemp: "${h}" - ] - - - def jsonRequestBody - // Determine if this is setting the minimum time temperarily or permanment basis - if(temp) { - // temporarily set the fanMinOnTime - LOG("setFanMinOnTime() in 'temp' if", 4, child) - def currentHeatingSetpoint = h ?: child.device.currentValue("heatingSetpoint") - def currentCoolingSetpoint = child.device.currentValue("coolingSetpoint") - def holdType = whatHoldType() - def tstatParams = tstatSettings - tstatParams << [fanMinOnTime:"${min}", isTemperatureRelative: "false", isTemperatureAbsolute: "false"] - // jsonRequestBody = buildBodyRequest('setHold',null,deviceId,tstatParams,null).toString() - jsonRequestBody = '{"functions":[{"type":"setHold","params":{"coolHoldTemp":"730","heatHoldTemp":"690","holdType":"nextTransition","fan":"auto","fanMinOnTime":"15","isTemperatureRelative":"false","isTemperatureAbsolute":"false"}}],"selection":{"selectionType":"thermostats","selectionMatch":"312989153500"},"thermostat":{"settings":{"fanMinOnTime":"15"}}}' - //jsonRequestBody = '{"functions":[{"type":"setHold","params":{"fanMinOnTime":"15","coolHoldTemp":"730","heatHoldTemp":"690","holdType":"nextTransition","fan":"auto"}}],"selection":{"selectionType":"thermostats","selectionMatch":"312989153500"}}' - //jsonRequestBody = '{"functions":[{"type":"setHold","event":[{"type":"hold","name":"auto","fanMinOnTime":"15","isTemperatureRelative":"false","isTemperatureAbsolute":"false"}]}],"selection":{"selectionType":"thermostats","selectionMatch":"312989153500"}}' - LOG("setFanMinOnTime Request Body (temp) = ${jsonRequestBody}", 4, child) - } else { - // Change the value in settings until changed again - - tstatSettings << [fanMinOnTime:"${min}"] - - jsonRequestBody = buildBodyRequest('setThermostatSettings',null,deviceId,null,tstatSettings).toString() - LOG("setFanMinOnTime Request Body = ${jsonRequestBody}", 4, child) - } - - - - def result = sendJson(child, jsonRequestBody) - LOG("seFanMinOnTime to ${min} with result ${result}", 4, child) - if (result) { - child.generateQuickEvent("fanMinOnTime", min, 15) - } else { - LOG("Unable to set fanMinOnTime (${min})", 1, child, "warn") - } - - -} - - -// TODO: Pull the learnings from the setFanMinOnTime to be able to set just the fan settings without impacting temperature. -// TODO: Be sure to take into consideration any existing running Event? def setFanMode(child, fanMode, deviceId, sendHoldType=null) { LOG("setFanMode() to ${fanMode} with DeviceID: ${deviceId}", 5, child) def extraParams = [isTemperatureRelative: "false", isTemperatureAbsolute: "false"] // TODO: Set the fan mode to circulate in the events data sent to the device - if (fanMode == "circulate") { - fanMode = "auto" + if (fanMode == "circulate") { + fanMode = "auto" + LOG("fanMode == 'circulate'", 5, child, "trace") // Add a minimum circulate time here - extraParams << [fanMinOnTime: "15"] - child.state.circulateFanModeOn = true - return true + // NOTE: This is not currently honored by the Ecobee + extraParams << [fanMinOnTime:15] + child.circulateFanModeOn = true } else if (fanMode == "off") { - child.state.circulateFanModeOn = false + child.circulateFanModeOn = false fanMode = "auto" + // NOTE: This is not currently honored by the Ecobee extraParams << [fanMinOnTime: "0"] } else { - child.state.circulateFanModeOn = false + child.circulateFanModeOn = false } // TODO Check to see if there is an existing event and use that to overwrite? @@ -1215,11 +1415,11 @@ def setFanMode(child, fanMode, deviceId, sendHoldType=null) { def currentCoolingSetpoint = child.device.currentValue("coolingSetpoint") def holdType = sendHoldType ?: whatHoldType() + LOG("about to call setHold: ${currentHeatingSetpoint}, ${currentCoolingSetpoint}, ${deviceId}, ${holdType}, ${fanMode}, ${extraParams}", 5, child, "trace") return setHold(child, currentHeatingSetpoint, currentCoolingSetpoint, deviceId, holdType, fanMode, extraParams) } - def setProgram(child, program, deviceId, sendHoldType=null) { LOG("setProgram() to ${program} with DeviceID: ${deviceId}", 5, child) @@ -1231,12 +1431,7 @@ def setProgram(child, program, deviceId, sendHoldType=null) { ] def jsonRequestBody = buildBodyRequest('setHold',null,deviceId,tstatSettings,null).toString() - //def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '","includeRuntime":true},"functions": [{ "type": "setHold", "params": { "holdClimateRef": '+ program + '", "coolHoldTemp": '+c+',"heatHoldTemp": '+h+', "holdType": '+sendHoldType+' } } ]}' - // def jsonRequestBody = '{"selection":{"selectionType":"thermostats","selectionMatch":"' + deviceId + '","includeRuntime":true},"functions": [{ "type": "setHold", "params": { "holdClimateRef": "'+program+'", "holdType": '+sendHoldType+' } } ]}' - - LOG("about to sendJson with jsonRequestBody (${jsonRequestBody}", 4, child) - - + LOG("about to sendJson with jsonRequestBody (${jsonRequestBody}", 4, child) def result = sendJson(child, jsonRequestBody) LOG("setProgram with result ${result}", 3, child) dirtyPollData() @@ -1244,9 +1439,8 @@ def setProgram(child, program, deviceId, sendHoldType=null) { } - - -def sendJson(child = null, String jsonBody) { +// API Helper Functions +private def sendJson(child = null, String jsonBody) { // Reset the poll timer to allow for an immediate refresh dirtyPollData() @@ -1323,16 +1517,16 @@ def sendJson(child = null, String jsonBody) { return false } -def getChildThermostatName() { return "Ecobee Thermostat" } -def getChildSensorName() { return "Ecobee Sensor" } -def getServerUrl() { return "https://graph.api.smartthings.com" } -def getShardUrl() { return getApiServerUrl() } -def getCallbackUrl() { return "${serverUrl}/oauth/callback" } -def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${shardUrl}" } -def getApiEndpoint() { return "https://api.ecobee.com" } +private def getChildThermostatName() { return "Ecobee Thermostat" } +private def getChildSensorName() { return "Ecobee Sensor" } +private def getServerUrl() { return "https://graph.api.smartthings.com" } +private def getShardUrl() { return getApiServerUrl() } +private def getCallbackUrl() { return "${serverUrl}/oauth/callback" } +private def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${shardUrl}" } +private def getApiEndpoint() { return "https://api.ecobee.com" } // This is the API Key from the Ecobee developer page. Can be provided by the app provider or use the appSettings -def getSmartThingsClientId() { +private def getSmartThingsClientId() { if(!appSettings.clientId) { return "obvlTjUuuR2zKpHR6nZMxHWugoi5eVtS" } else { @@ -1342,7 +1536,7 @@ def getSmartThingsClientId() { -private def LOG(message, level=3, child=null, logType="debug", event=true, displayEvent=false) { +private def LOG(message, level=3, child=null, logType="debug", event=true, displayEvent=true) { def prefix = "" if ( settings.debugLevel?.toInteger() == 5 ) { prefix = "LOG: " } if ( debugLevel(level) ) { @@ -1355,7 +1549,6 @@ private def LOG(message, level=3, child=null, logType="debug", event=true, displ private def debugEvent(message, displayEvent = false) { - def results = [ name: "appdebug", descriptionText: message, @@ -1371,15 +1564,10 @@ private def debugEventFromParent(child, message) { debugEventFromParent: message ] if (child) { child.generateEvent(data) } - /* - if (child != null) { - child.sendEvent("name":"debugEventFromParent", "value":message, "description":message, displayed: true, isStateChange: true) - } - */ } //send both push notification and mobile activity feeds -def sendPushAndFeeds(notificationMessage) { +private def sendPushAndFeeds(notificationMessage) { LOG("sendPushAndFeeds >> notificationMessage: ${notificationMessage}", 1, null, "warn") LOG("sendPushAndFeeds >> state.timeSendPush: ${state.timeSendPush}", 1, null, "warn") @@ -1397,7 +1585,7 @@ def sendPushAndFeeds(notificationMessage) { // state.authToken = null } -def sendActivityFeeds(notificationMessage) { +private def sendActivityFeeds(notificationMessage) { def devices = getChildDevices() devices.each { child -> child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent @@ -1416,34 +1604,32 @@ def sendActivityFeeds(notificationMessage) { -// Helper Apps - -// Built in functions from SmartThings for temperature unit handling? -// getTemperatureScale() -// fahrenheitToCelsius() -// celsiusToFahrenheit() -// convertTemperatureIfNeeded() - +// Helper Functions // Creating my own as it seems that the built-in version only works for a device, NOT a SmartApp def myConvertTemperatureIfNeeded(scaledSensorValue, cmdScale, precision) { if ( (cmdScale != "C") && (cmdScale != "F") && (cmdScale != "dC") && (cmdScale != "dF") ) { // We do not have a valid Scale input, throw a debug error into the logs and just return the passed in value + LOG("Invalid temp scale used: ${cmdScale}", 2, null, "error") return scaledSensorValue } + def returnSensorValue + // Normalize the input if (cmdScale == "dF") { cmdScale = "F" } if (cmdScale == "dC") { cmdScale = "C" } + LOG("About to convert/scale temp: ${scaledSensorValue}", 5, null, "trace", false) if (cmdScale == getTemperatureScale() ) { // The platform scale is the same as the current value scale - return scaledSensorValue - } else if (cmdScale == "F") { - return fToC(scaledSensorValue).round(precision) + returnSensorValue = scaledSensorValue.round(precision) + } else if (cmdScale == "F") { + returnSensorValue = fToC(scaledSensorValue).round(precision) } else { - return cToF(scaledSensorValue).round(precision) + returnSensorValue = cToF(scaledSensorValue).round(precision) } - + LOG("returnSensorValue == ${returnSensorValue}", 5, null, "trace", false) + return returnSensorValue } def wantMetric() { From e6151338a1490f778c165a4ffbc9ec0c8adaa9ea Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Sun, 31 Jan 2016 15:38:24 -0600 Subject: [PATCH 03/27] Checkpoint. Able to create devices now and use the Things Tiles --- .../ecobee-thermostat.groovy | 112 ++++++++++-------- 1 file changed, 63 insertions(+), 49 deletions(-) diff --git a/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy b/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy index 6b2645ed774..ea7b1a5ebfb 100644 --- a/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy +++ b/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy @@ -20,11 +20,12 @@ * Incorporate additional device capabilities, some based on code by Yves Racine * * - * Current Version: 0.8.0-RC - * Release Date: 2016-01-26 - * See separate Changelog for change history + * See Changelog for change history * */ + +private def getVersion() { return "ecobee (Connect) Version 0.9.0-RC" } + metadata { definition (name: "Ecobee Thermostat", namespace: "smartthings", author: "SmartThings") { capability "Actuator" @@ -258,7 +259,7 @@ metadata { tiles(scale: 2) { - multiAttributeTile(name:"iOSsummary", type:"thermostat", width:6, height:4) { + multiAttributeTile(name:"tempSummary", type:"thermostat", width:6, height:4) { tileAttribute("device.temperature", key: "PRIMARY_CONTROL") { attributeState("default", label:'${currentValue}', unit:"dF") } @@ -349,18 +350,18 @@ metadata { ) } standardTile("mode", "device.thermostatMode", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { - state "off", action:"switchMode", nextState: "updating", icon: "st.thermostat.heating-cooling-off" - state "heat", action:"switchMode", nextState: "updating", icon: "st.thermostat.heat" - state "cool", action:"switchMode", nextState: "updating", icon: "st.thermostat.cool" - state "auto", action:"switchMode", nextState: "updating", icon: "st.thermostat.auto" - state "auxHeatOnly", action:"switchMode", icon: "st.thermostat.emergency-heat" + state "off", action:"thermostat.heat", nextState: "updating", icon: "st.thermostat.heating-cooling-off" + state "heat", action:"thermostat.cool", nextState: "updating", icon: "st.thermostat.heat" + state "cool", action:"thermostat.auto", nextState: "updating", icon: "st.thermostat.cool" + state "auto", action:"thermostat.off", nextState: "updating", icon: "st.thermostat.auto" + // Not included in the button loop, but if already in "auxHeatOnly" pressing button will go to "auto" + state "auxHeatOnly", action:"thermostat.auto", icon: "st.thermostat.emergency-heat" state "updating", label:"Working", icon: "st.secondary.secondary" } standardTile("fanMode", "device.thermostatFanMode", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { - state "auto", label:'Fan: ${currentValue}', action:"thermostat.fanOn", nextState: "updating", icon: "st.Appliances.appliances11" state "on", label:'Fan: ${currentValue}', action:"thermostat.fanAuto", nextState: "updating", icon: "st.Appliances.appliances11" - // state "off", label:'Fan: ${currentValue}', action:"switchFanMode", nextState: "updating", icon: "st.Appliances.appliances11" - // state "circulate", label:'Fan: ${currentValue}', action:"switchFanMode", nextState: "updating", icon: "st.Appliances.appliances11" + state "auto", label:'Fan: ${currentValue}', action:"thermostat.fanCirculate", nextState: "updating", icon: "st.Appliances.appliances11" + state "circulate", label:'Fan: ${currentValue}', action:"thermostat.fanOn", nextState: "updating", icon: "st.Appliances.appliances11" state "updating", label:"Working", icon: "st.secondary.secondary" } standardTile("upButtonControl", "device.thermostatSetpoint", width: 2, height: 1, inactiveLabel: false, decoration: "flat") { @@ -505,10 +506,10 @@ metadata { - main(["temperature", "summary"]) + main(["temperature", "tempSummary"]) // details(["summary","temperature", "upButtonControl", "thermostatSetpoint", "currentStatus", "downButtonControl", "mode", "weatherIcon", "resumeProgram", "refresh"]) // details(["summary","apiStatus", "upButtonControl", "thermostatSetpoint", "currentStatus", "downButtonControl", "mode", "weatherIcon", "resumeProgram", "refresh"]) - details(["summary", + details(["tempSummary", "operatingState", "weatherIcon", "weatherTemperature", "motion", "resumeProgram", "mode", "coolSliderControl", "coolingSetpoint", @@ -792,15 +793,16 @@ def modes() { } def fanModes() { - ["on", "auto", "circulate"] + ["off", "on", "auto", "circulate"] } // TODO Add a delay (like in the setTemperature case) to capture multiple clicks on the UI +/* def switchMode() { LOG("in switchMode()", 5) def currentMode = device.currentState("thermostatMode")?.value - def lastTriedMode = state.lastTriedMode ?: currentMode ?: "off" + def lastTriedMode = state.lastTriedMode ?: currentMode ?: "auto" def modeOrder = modes() def next = { modeOrder[modeOrder.indexOf(it) + 1] ?: modeOrder[0] } def nextMode = next(lastTriedMode) @@ -817,6 +819,7 @@ def switchToMode(nextMode) { } } + def switchFanMode() { LOG("switchFanMode()", 5) def currentFanMode = device.currentState("thermostatFanMode")?.value @@ -864,14 +867,18 @@ def switchToFanMode(nextMode) { } returnCommand } +*/ + def getDataByName(String name) { state[name] ?: device.getDataValue(name) } +def generateQuickEvent(name, value) { + generateQuickEvent(name, value, 0) +} - -def generateQuickEvent(name, value, pollIn=0) { +def generateQuickEvent(name, value, pollIn) { sendEvent(name: name, value: value, displayed: true) if (pollIn > 0) { runIn(pollIn, "poll") } } @@ -886,16 +893,18 @@ def generateOperatingStateEvent(operatingState) { } -def setThermostatMode(String value, holdType=null) { +def setThermostatMode(String value) { // "emergencyHeat" "heat" "cool" "off" "auto" if (value=="emergency" || value=="emergencyHeat") { value = "auxHeatOnly" } LOG("setThermostatMode(${value})", 5) - + generateQuickEvent("thermostatMode", value) + + def deviceId = getDeviceId() - if (parent.setMode(this, value, deviceId, holdType)) - generateQuickEvent("thermostatMode", value, 15) - else { + if (parent.setMode(this, value, deviceId)) { + // generateQuickEvent("thermostatMode", value, 15) + } else { LOG("Error setting new mode to ${value}.", 1, null, "error") def currentMode = device.currentState("thermostatMode")?.value generateQuickEvent("thermostatMode", currentMode) // reset the tile back @@ -1007,8 +1016,22 @@ def generateProgramEvent(program, failedProgram=null) { def setThermostatFanMode(value, holdType=null) { LOG("setThermostatFanMode(${value})", 4) // "auto" "on" "circulate" "off" + + // This is to work around a bug in some SmartApps that are using fanOn and fanAuto as inputs here, which is wrong + if (value == "fanOn" || value == "on" ) { value = "on" } + else if (value == "fanAuto" || value == "auto" ) { value = "auto" } + else if (value == "fanCirculate" || value == "circulate") { value == "circulate" } + else if (value == "fanOff" || value == "off") { value = "off" } + else { + LOG("setThermostatFanMode() - Unrecognized Fan Mode: ${value}. Setting to 'auto'", 1, null, "error") + value = "auto" + } + + // Change the state now to quickly refresh the UI + generateQuickEvent("thermostatFanMode", value, 0) + if ( parent.setFanMode(this, value, getDeviceId()) ) { - generateQuickEvent("thermostatFanMode", value, 15) + LOG("parent.setFanMode() returned successfully!", 5) } else { generateQuickEvent("thermostatFanMode", device.currentValue("thermostatFanMode")) } @@ -1018,7 +1041,7 @@ def setThermostatFanMode(value, holdType=null) { } def fanOn() { - LOG("fanON()", 5) + LOG("fanOn()", 5) setThermostatFanMode("on") } @@ -1039,17 +1062,15 @@ def fanOff() { } def generateSetpointEvent() { - LOG("Generate SetPoint Event", 5) def mode = device.currentValue("thermostatMode") def heatingSetpoint = device.currentValue("heatingSetpoint") def coolingSetpoint = device.currentValue("coolingSetpoint") - LOG("Current Mode = ${mode}") - LOG("Heating Setpoint = ${heatingSetpoint}") - LOG("Cooling Setpoint = ${coolingSetpoint}") - + LOG("Current Mode = ${mode}") + LOG("Heating Setpoint = ${heatingSetpoint}") + LOG("Cooling Setpoint = ${coolingSetpoint}") if (mode == "heat") { sendEvent("name":"thermostatSetpoint", "value":heatingSetpoint.toString()) @@ -1108,13 +1129,12 @@ void lowerSetpoint() { def targetvalue if (mode == "off" || (mode == "auto" && !usingSmartAuto() )) { - log.warn "lowerSetpoint(): this mode: $mode does not allow lowerSetpoint" + LOG("lowerSetpoint(): this mode: $mode does not allow lowerSetpoint", 2, null, "warn") } else { - def heatingSetpoint = device.currentValue("heatingSetpoint") def coolingSetpoint = device.currentValue("coolingSetpoint") def thermostatSetpoint = device.currentValue("thermostatSetpoint").toDouble() - log.debug "lowerSetpoint() mode = ${mode}, heatingSetpoint: ${heatingSetpoint}, coolingSetpoint:${coolingSetpoint}, thermostatSetpoint:${thermostatSetpoint}" + LOG("lowerSetpoint() mode = ${mode}, heatingSetpoint: ${heatingSetpoint}, coolingSetpoint:${coolingSetpoint}, thermostatSetpoint:${thermostatSetpoint}", 4) if (thermostatSetpoint) { targetvalue = thermostatSetpoint @@ -1139,7 +1159,6 @@ void lowerSetpoint() { //called by raiseSetpoint() and lowerSetpoint() void alterSetpoint(temp) { - def mode = device.currentValue("thermostatMode") def heatingSetpoint = device.currentValue("heatingSetpoint") def coolingSetpoint = device.currentValue("coolingSetpoint") @@ -1202,7 +1221,7 @@ void alterSetpoint(temp) { sendEvent("name": "thermostatSetpoint", "value": temp.value.toString(), displayed: false) sendEvent("name": "heatingSetpoint", "value": targetHeatingSetpoint) sendEvent("name": "coolingSetpoint", "value": targetCoolingSetpoint) - log.debug "alterSetpoint in mode $mode succeed change setpoint to= ${temp.value}" + LOG("alterSetpoint in mode $mode succeed change setpoint to= ${temp.value}", 4) } else { LOG("WARN: alterSetpoint() - setHold failed. Could be an intermittent problem.", 1, null, "error") sendEvent("name": "thermostatSetpoint", "value": saveThermostatSetpoint.toString(), displayed: false) @@ -1219,32 +1238,25 @@ def generateStatusEvent() { def coolingSetpoint = device.currentValue("coolingSetpoint") def temperature = device.currentValue("temperature") - def statusText - - - LOG("Generate Status Event for Mode = ${mode}") - LOG("Temperature = ${temperature}") - LOG("Heating set point = ${heatingSetpoint}") - LOG("Cooling set point = ${coolingSetpoint}") - LOG("HVAC Mode = ${mode}") - + def statusText + LOG("Generate Status Event for Mode = ${mode}", 4) + LOG("Temperature = ${temperature}", 4) + LOG("Heating setpoint = ${heatingSetpoint}", 4) + LOG("Cooling setpoint = ${coolingSetpoint}", 4) + LOG("HVAC Mode = ${mode}", 4) if (mode == "heat") { - if (temperature >= heatingSetpoint) { statusText = "Right Now: Idle" } else { statusText = "Heating to ${heatingSetpoint}°" } - } else if (mode == "cool") { - if (temperature <= coolingSetpoint) { statusText = "Right Now: Idle" } else { statusText = "Cooling to ${coolingSetpoint}°" } - } else if (mode == "auto") { statusText = "Right Now: Auto (Heat: ${heatingSetpoint}/Cool: ${coolingSetpoint})" } else if (mode == "off") { @@ -1294,7 +1306,9 @@ private def get_MAX_TSTAT_BATCH() { private def getDeviceId() { - return device.deviceNetworkId.split(/\./).last() + def deviceId = device.deviceNetworkId.split(/\./).last() + LOG("getDeviceId() returning ${deviceId}", 4) + return deviceId } private def usingSmartAuto() { From 9570a3f06f51ae3371fa9ce1acb8880d2f57ac71 Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Sun, 31 Jan 2016 18:55:14 -0600 Subject: [PATCH 04/27] Checkpoint before adding first child SmartApps --- .../ecobee-connect.src/ecobee-connect.groovy | 89 +++++++++++++++---- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index 5ffd3292eae..2a16093d3a1 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -65,16 +65,29 @@ mappings { // Begin Preference Pages def mainPage() { - dynamicPage(name: "mainPage", title: "Welcome to ecobee (Connect)", install: true, uninstall: false, submitOnChange: true) { - def ecoAuthDesc = (state.authToken != null) ? "[Connected]\n" :"[Not Connected]\n" + + def deviceHandlersInstalled = testForDeviceHandlers() + def readyToInstall = deviceHandlersInstalled + + dynamicPage(name: "mainPage", title: "Welcome to ecobee (Connect)", install: readyToInstall, uninstall: false, submitOnChange: true) { + def ecoAuthDesc = (state.authToken != null) ? "[Connected]\n" :"[Not Connected]\n" + // If not device Handlers we cannot proceed + if(!deviceHandlersInstalled) { + section() { + paragraph "ERROR!\n\nYou MUST add the ${getChildThermostatName()} and ${getChildSensorName()} Device Handlers to the IDE BEFORE running the setup." + } + } else { + readyToInstall = true + } + if(state.initialized && !state.authToken) { section() { paragraph "WARNING!\n\nYou are no longer connected to the ecobee API. Please re-Authorize below." } } - if(state.authToken != null) { + if(state.authToken != null && deviceHandlersInstalled) { section("Devices") { def howManyThermsSel = settings.thermostats?.size() ?: 0 def howManyTherms = state.numAvailTherms ?: "?" @@ -88,6 +101,7 @@ def mainPage() { if (settings.thermostats?.size() > 0) { state.settingsCurrentSensors = settings.ecobeesensors ?: [] def howManySensorsSel = settings.ecobeesensors?.size() ?: 0 + if (howManySensorsSel > howManySensors) { howManySensorsSel = howManySensors } // This is due to the fact that you can remove alread selected hiden items href ("sensorsPage", title: "Sensors", description: "Tap to select Sensors [${howManySensorsSel}/${howManySensors}]") } } @@ -97,11 +111,11 @@ def mainPage() { } if ( debugLevel(5) ) { section ("Debug Dashboard") { - href ("debugDashboardPage", description: "Tap to enter the Debug Dashboard", title: "") + href ("debugDashboardPage", description: "Tap to enter the Debug Dashboard", title: "Debug Dashboard") } } section("Remove ecobee (Connect)") { - href ("removePage", description: "Tap to remove ecobee (Connect) ", title: "") + href ("removePage", description: "Tap to remove ecobee (Connect) ", title: "Remove ecobee (Connect)") } } // End if(state.authToken) @@ -266,6 +280,7 @@ def debugDashboardPage() { // pages that are part of Debug Dashboard def pollChildrenPage() { LOG("=====> pollChildrenPage() entered.", 5) + state.lastPoll = 0 // Reset to force the poll to happen pollChildren(null) dynamicPage(name: "pollChildrenPage", title: "") { @@ -282,6 +297,34 @@ def helperSmartAppsPage() { } // End Prefernce Pages + +// Preference Pages Helpers +private def Boolean testForDeviceHandlers() { + if (state.runTestOnce != null) { return state.runTestOnce } + + def DNIAdder = now().toString() + def d1 + def d2 + def success = true + + try { + d1 = addChildDevice(app.namespace, getChildThermostatName(), "dummyThermDNI-${DNIAdder}", null, ["label":"Ecobee Thermostat:TestingForInstall"]) + d2 = addChildDevice(app.namespace, getChildSensorName(), "dummySensorDNI-${DNIAdder}", null, ["label":"Ecobee Sensor:TestingForInstall"]) + } catch (physicalgraph.app.exception.UnknownDeviceTypeException e) { + LOG("You MUST add the ${getChildThermostatName()} and ${getChildSensorName()} Device Handlers to the IDE BEFORE running the setup.", 1, null, "error") + success = false + } + + state.runTestOnce = success + + if (d1) deleteChildDevice("dummyThermDNI-${DNIAdder}") + if (d2) deleteChildDevice("dummySensorDNI-${DNIAdder}") + + return success +} + +// End Preference Pages Helpers + // OAuth Init URL def oauthInitUrl() { LOG("oauthInitUrl with callback: ${callbackUrl}", 5) @@ -530,7 +573,7 @@ Map getEcobeeSensors() { } else if ( (it.type == "thermostat") && (settings.showThermsAsSensor == true) ) { LOG("Adding a Thermostat as a Sensor: ${it}", 4, null, "trace") def value = "${it?.name}" - def key = "ecobee_sensor_thermostat-"+ it?.id + "-" + singleStat.identifier + def key = "ecobee_sensor_thermostat-"+ it?.id + "-" + it?.name LOG("Adding a Thermostat as a Sensor: ${it}, key: ${key} value: ${value}", 4, null, "trace") sensorMap["${key}"] = value + " (Thermostat)" } else { @@ -559,7 +602,7 @@ def getThermostatTypeName(stat) { def installed() { LOG("Installed with settings: ${settings}", 4) - initialize() + return initialize() } def updated() { @@ -580,9 +623,15 @@ def updated() { // Force the rescheduling state.lastScheduledPoll = 0 + state.lastScheduledTokenRefresh = 0 state.lastPoll = 0 state.lastTokenRefresh = 0 - scheduleHandlers() + scheduleHandlers() + + // Add subscriptions as little "daemons" that will check on our health + subscribe(location, "routineExecuted", scheduleHandlers) + subscribe(location, "sunset", scheduleHandlers) + subscribe(location, "sunrise", scheduleHandlers) } def initialize() { @@ -595,6 +644,12 @@ def initialize() { unschedule() // Shouldn't be needed as we are only calling intialize once now, but just in case state.reAttempt = 0 + // Initialize several variables + state.lastScheduledPoll = 0 + state.lastScheduledTokenRefresh = 0 + state.lastPoll = 0 + state.lastTokenRefresh = 0 + // Setup initial polling and determine polling intervals state.pollingInterval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 state.tokenGrace = 16 // Anything more than this then we have a possible failed @@ -618,9 +673,9 @@ def initialize() { //send activity feeds to tell that device is connected def notificationMessage = aOK ? "is connected to SmartThings" : "had an error during setup of devices" sendActivityFeeds(notificationMessage) - state.timeSendPush = null - + state.timeSendPush = null state.initialized = true + return aOK } @@ -723,7 +778,7 @@ def scheduleHandlers() { LOG("pollChildren() - Rescheduling handlers due to delays!", 1, child, "warn") unschedule("poll") "runEvery${interval}Minutes"("poll") - pollScheduled() + runIn(15, "pollScheduled") } // Reschedule Authrefresh if we are over the grace period @@ -767,7 +822,7 @@ def pollChildren(child = null) { } // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children - def timeSinceLastPoll = (state.lastPoll == 0) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) + def timeSinceLastPoll = (state.lastPoll == 0) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) LOG("Time since last poll? ${timeSinceLastPoll} -- state.lastPoll == ${state.lastPoll}", 3, child, "info") // TODO: Let the scheduleHandlers() function do this instead @@ -803,7 +858,7 @@ def pollChildren(child = null) { oneChild.generateEvent(state.thermostats[oneChild.device.deviceNetworkId]?.data) } else { // We must have a remote sensor - LOG("pollChildren() - Updating sensor data: ${oneChild.device.deviceNetworkId} data: ${state.remoteSensorsData[oneChild.device.deviceNetworkId]?.data}", 4) + LOG("pollChildren() - Updating sensor data for ${oneChild}: ${oneChild.device.deviceNetworkId} data: ${state.remoteSensorsData[oneChild.device.deviceNetworkId]?.data}", 4) oneChild.generateEvent(state.remoteSensorsData[oneChild.device.deviceNetworkId]?.data) } } @@ -1004,8 +1059,12 @@ def updateSensorData() { if ( ( it.type == "ecobee3_remote_sensor" ) || ((it.type == "thermostat") && (settings.showThermsAsSensor)) ) { // Add this sensor to the list def sensorDNI - if (it.type == "ecobee3_remote_sensor") { sensorDNI = "ecobee_sensor-" + it?.id + "-" + it?.code } - else { sensorDNI = "ecobee_sensor_thermostat-" + it?.id + "-" + it?.code } + if (it.type == "ecobee3_remote_sensor") { + sensorDNI = "ecobee_sensor-" + it?.id + "-" + it?.code + } else { + LOG("We have a Thermostat based Sensor! it=${it}", 4, null, "trace") + sensorDNI = "ecobee_sensor_thermostat-"+ it?.id + "-" + it?.name + } LOG("sensorDNI == ${sensorDNI}", 4) def temperature = "" From 42002848b43a336273e6cec15b8f107538b57a34 Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Mon, 1 Feb 2016 10:19:15 -0600 Subject: [PATCH 05/27] First releasae of the ecobee Routines SmartApp (Child) --- .../ecobee-connect.src/ecobee-connect.groovy | 146 +++++----------- .../ecobee-routines.groovy | 157 ++++++++++++++++++ 2 files changed, 194 insertions(+), 109 deletions(-) create mode 100644 smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index 2a16093d3a1..a969dc97c5d 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -26,8 +26,10 @@ */ private def getVersion() { return "ecobee (Connect) Version 0.9.0-RC" } private def getHelperSmartApps() { - return [ "ecobee Routines": [multiple: true, description: "Example Description"], - "ecobee ABC": [multiple: false, description: "Example Description for ecobee ABC"] + return [ + [name: "ecobeeRoutinesChild", appName: "ecobee Routines", + namespace: "smartthings", multiple: true, + title: "Create new Routines Handler..."] ] } @@ -88,6 +90,11 @@ def mainPage() { } if(state.authToken != null && deviceHandlersInstalled) { + if (settings.thermostats?.size() > 0) { + section("Helper SmartApps") { + href ("helperSmartAppsPage", title: "Helper SmartApps", description: "Tap to manage Helper SmartApps") + } + } section("Devices") { def howManyThermsSel = settings.thermostats?.size() ?: 0 def howManyTherms = state.numAvailTherms ?: "?" @@ -200,7 +207,7 @@ def thermsPage(params) { LOG("state.settingsCurrentTherms != settings.thermostats determined!!!", 4, null, "trace") } else { LOG("state.settingsCurrentTherms == settings.thermostats: No changes detected!", 4, null, "trace") } paragraph "Tap below to see the list of ecobee thermostats available in your ecobee account and select the ones you want to connect to SmartThings." - input(name: "thermostats", title:"Select Thermostats", type: "enum", required:false, multiple:true, description: "Tap to choose", params: params, metadata:[values:stats], , submitOnChange: true) + input(name: "thermostats", title:"Select Thermostats", type: "enum", required:false, multiple:true, description: "Tap to choose", params: params, metadata:[values:stats], submitOnChange: true) } } } @@ -292,8 +299,20 @@ def pollChildrenPage() { def helperSmartAppsPage() { - - + LOG("helperSmartAppsPage() entered", 5) + + LOG("SmartApps available are ${getHelperSmartApps()}", 5, null, "info") + //getHelperSmartApps() { + dynamicPage(name: "helperSmartAppsPage", title: "Helper Smart Apps", install: true, uninstall: false, submitOnChange: true) { + getHelperSmartApps().each { oneApp -> + LOG("Processing the app: ${oneApp}", 4, null, "trace") + def allowMultiple = oneApp.multiple.value + section ("${oneApp.appName.value}") { + app(name:"${oneApp.name.value}", appName:"${oneApp.appName.value}", namespace:"${oneApp.namespace.value}", title:"${oneApp.title.value}", multiple: allowMultiple) + //app(name: "${oneApp.name.value}", appName: "ecobee Routines", namespace: "smartthings", title: "Create new ecobee Routine Handler...", multiple: true) + } + } + } } // End Prefernce Pages @@ -753,7 +772,7 @@ private def deleteUnusedChildren() { } -def scheduleHandlers() { +def scheduleHandlers(evt=null) { if(state.connected == "lost") { LOG("Unable to schedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, null, "error") return @@ -1123,7 +1142,10 @@ def updateThermostatData() { state.thermostats = state.thermostatData.thermostatList.inject([:]) { collector, stat -> def dni = [ app.id, stat.identifier ].join('.') - LOG("Updating dni $dni, Got weather? ${stat.weather.forecasts[0].weatherSymbol.toString()}") + LOG("Updating dni $dni, Got weather? ${stat.weather.forecasts[0].weatherSymbol.toString()}", 4) + LOG("Climates available: ${stat.program?.climates}", 4) + // Extract Climates + def climateData = stat.program?.climates // TODO: Put a wrapper here based on the thermostat brand def thermSensor = stat.remoteSensors.find { it.type == "thermostat" } @@ -1222,12 +1244,13 @@ def updateThermostatData() { LOG("Event Data = ${data}", 4) - collector[dni] = [data:data] + collector[dni] = [data:data,climateData:climateData] return collector } } + def getThermostatOperatingState(stat) { def equipStatus = (stat.equipmentStatus.size() > 0) ? stat.equipmentStatus : 'Idle' @@ -1459,17 +1482,16 @@ def setFanMode(child, fanMode, deviceId, sendHoldType=null) { // Add a minimum circulate time here // NOTE: This is not currently honored by the Ecobee extraParams << [fanMinOnTime:15] - child.circulateFanModeOn = true + state.circulateFanModeOn = true } else if (fanMode == "off") { - child.circulateFanModeOn = false + state.circulateFanModeOn = false fanMode = "auto" // NOTE: This is not currently honored by the Ecobee extraParams << [fanMinOnTime: "0"] } else { - child.circulateFanModeOn = false + state.circulateFanModeOn = false } - - // TODO Check to see if there is an existing event and use that to overwrite? + def currentHeatingSetpoint = child.device.currentValue("heatingSetpoint") def currentCoolingSetpoint = child.device.currentValue("coolingSetpoint") def holdType = sendHoldType ?: whatHoldType() @@ -1481,6 +1503,7 @@ def setFanMode(child, fanMode, deviceId, sendHoldType=null) { def setProgram(child, program, deviceId, sendHoldType=null) { LOG("setProgram() to ${program} with DeviceID: ${deviceId}", 5, child) + program = program.toLower() def tstatSettings tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? @@ -1594,7 +1617,6 @@ private def getSmartThingsClientId() { } - private def LOG(message, level=3, child=null, logType="debug", event=true, displayEvent=true) { def prefix = "" if ( settings.debugLevel?.toInteger() == 5 ) { prefix = "LOG: " } @@ -1618,13 +1640,13 @@ private def debugEvent(message, displayEvent = false) { } private def debugEventFromParent(child, message) { - def data = [ debugEventFromParent: message ] if (child) { child.generateEvent(data) } } +// TODO: Create a more generic push capability to send notifications //send both push notification and mobile activity feeds private def sendPushAndFeeds(notificationMessage) { LOG("sendPushAndFeeds >> notificationMessage: ${notificationMessage}", 1, null, "warn") @@ -1654,15 +1676,6 @@ private def sendActivityFeeds(notificationMessage) { - - - - - - - - - // Helper Functions // Creating my own as it seems that the built-in version only works for a device, NOT a SmartApp def myConvertTemperatureIfNeeded(scaledSensorValue, cmdScale, precision) { @@ -1887,88 +1900,3 @@ private def buildBodyRequest(method, tstatType="registered", thermostatId, tstat } } - -// iterateSetThermostatSettings: iterate thru all the thermostats under a specific account and set the desired settings -// tstatType =managementSet or registered (no spaces). May also be set to a specific locationSet (ex./Toronto/Campus/BuildingA) -// settings can be anything supported by ecobee -// at https://www.ecobee.com/home/developer/api/documentation/v1/objects/Settings.shtml -/* -void iterateSetThermostatSettings(tstatType, tstatSettings = []) { - Integer MAX_TSTAT_BATCH = get_MAX_TSTAT_BATCH() - def tstatlist = null - Integer nTstats = 0 - - def ecobeeType = determine_ecobee_type_or_location(tstatType) - getThermostatSummary(ecobeeType) - if (settings.trace) { - log.debug - "iterateSetThermostatSettings>ecobeeType=${ecobeeType},about to loop ${data.thermostatCount} thermostat(s)" - sendEvent name: "verboseTrace", value: - "iterateSetThermostatSettings>ecobeeType=${ecobeeType},about to loop ${data.thermostatCount} thermostat(s)" - } - for (i in 0..data.thermostatCount - 1) { - def thermostatDetails = data.revisionList[i].split(':') - def Id = thermostatDetails[0] - def thermostatName = thermostatDetails[1] - def connected = thermostatDetails[2] - if (connected == 'true') { - if (nTstats == 0) { - tstatlist = Id - nTstats = 1 - } - if ((nTstats > MAX_TSTAT_BATCH) || (i == (data.thermostatCount - 1))) { - // process a batch of maximum 25 thermostats according to API doc - if (settings.trace) { - sendEvent name: "verboseTrace", value: - "iterateSetThermostatSettings>about to call setThermostatSettings for ${tstatlist}" - log.debug "iterateSetThermostatSettings> about to call setThermostatSettings for ${tstatlist}" - } - setThermostatSettings("${tstatlist}",tstatSettings) - tstatlist = Id - nTstats = 1 - } else { - tstatlist = tstatlist + "," + Id - nTstats++ - } - } - } -} - -*/ - -/* -// thermostatId may be a list of serial# separated by ",", no spaces (ex. '123456789012,123456789013') -// if no thermostatId is provided, it is defaulted to the current thermostatId -// settings can be anything supported by ecobee at https://www.ecobee.com/home/developer/api/documentation/v1/objects/Settings.shtml -void setThermostatSettings(thermostatId,tstatSettings = []) { - thermostatId= determine_tstat_id(thermostatId) - if ( debugLevel(5) ) { - log.debug "setThermostatSettings>called with values ${tstatSettings} for ${thermostatId}" - } - - def bodyReq = buildBodyRequest('setThermostatSettings',null,thermostatId,null,tstatSettings) - def statusCode=true - int j=0 - while ((statusCode) && (j++ <2)) { // retries once if api call fails - apiHelper('setThermostatSettings', bodyReq) {resp -> - statusCode = resp.data.status.code - def message = resp.data.status.message - if (!statusCode) { - if ( debugLevel(3) ) { log.debug "setThermostatSettings() successful for ${thermostatId} with settings ${tstatSettings}" } - - } else { - if ( debugLevel(1) ) { - log.error "setThermostatSettings() error=${statusCode.toString()},message=${message} for ${thermostatId}" - debugEvent( "setThermostatSettings() error=${statusCode.toString()},message=${message} for ${thermostatId}" ) - } - - // introduce a 1 second delay before re-attempting any other command - def cmd= [] - cmd << "delay 1000" - cmd - } // end if statusCode - } // end api call - } // end for -} - -*/ \ No newline at end of file diff --git a/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy b/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy new file mode 100644 index 00000000000..ccc033a42ba --- /dev/null +++ b/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy @@ -0,0 +1,157 @@ +/** + * ecobee Routines + * + * Copyright 2015 Sean Kendall Schneyer + * + * + * 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. + * + */ +private def getVersion() { return "ecobee Routines Version 0.1.0-RC" } + + +definition( + name: "ecobee Routines", + namespace: "smartthings", + author: "Sean Kendall Schneyer (smartthings at linuxbox dot org)", + description: "Rule", + category: "Convenience", + parent: "smartthings:Ecobee (Connect)", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png", + singleInstance: false +) + +preferences { + page(name: "mainPage") +} + +// Preferences Pages +def mainPage() { + dynamicPage(name: "mainPage", title: "Setup Routines", uninstall: true, install: true) { + section(title: "Name for Routine Handler") { + label title: "Name this Routine Handler", required: true + + } + + section(title: "Select Conditions") { + if(settings.tempDisable == true) paragraph "WARNING: Temporarily Disabled as requested. Turn back on to activate handler." + input ("myThermostats", "capability.Thermostat", title: "Pick Ecobee Thermostat(s)", required: true, multiple: true, submitOnChange: true) + + if (myThermostats?.size() > 0) { + // Start defining which Routine(s) to allow the SmartApp to execute in + mode(title: "Select mode(s) to use: ", required: true) + input(name: "whichProgram", title: "Select Program to use: ", type: "enum", required: true, multiple:false, description: "Tap to choose...", metadata:[values:["Away", "Home", "Sleep", "Resume Program"]], submitOnChange: true) + input(name: "fanMode", title: "Select a Fan Mode to use\n(Optional) ", type: "enum", required: false, multiple: false, description: "Tap to choose...", metadata:[values:["On", "Auto", "default"]], submitOnChange: true) + if(settings.whichProgram != "Resume Program") input(name: "holdType", title: "Select the Hold Type to use\n(Optional) ", type: "enum", required: false, multiple: false, description: "Tap to choose...", metadata:[values:["Until I Change", "Until Next Program", "default"]], submitOnChange: true) + input(name: "useSunriseSunset", title: "Also at Sunrise or Sunset?\n(Optional) ", type: "enum", required: false, multiple: true, description: "Tap to choose...", metadata:[values:["Sunrise", "Sunset"]], submitOnChange: true) + } + input(name: "tempDisable", title: "Temporarily Disable Handler? ", type: "bool", required: false, description: false, submitOnChange: true) + } + } +} + + +// Main functions +def installed() { + LOG("installed() entered", 5) + initialize() +} + +def updated() { + LOG("updated() entered", 5) + unsubscribe() + initialize() + +} + +def initialize() { + LOG("initialize() entered") + if(tempDisable == true) { + LOG("Teporarily Disapabled as per request.", 2, null, "warn") + return true + } + + subscribe(location, "mode", changeProgramHandler) + // subscribe(app, changeProgramHandler) + + if(useSunriseSunset?.size() > 0) { + // Setup subscriptions for sunrise and/or sunset as well + if( useSunriseSunset.contains("Sunrise") ) subscribe(location, "sunrise", changeProgramHandler) + if( useSunriseSunset.contains("Sunset") ) subscribe(location, "sunset", changeProgramHandler) + } + + // Normalize settings data + normalizeSettings() + LOG("initialize() exiting") +} + +private def normalizeSettings() { + // whichProgram + state.programParam = "" + if (whichProgram != null && whichProgram != "") { + if (whichProgram == "Resume Program") { + state.doResumeProgram = true + } else { + state.programParam = whichProgram.toLowerCase() + } + } + + // fanMode + state.fanCommand = "" + if (fanMode != null && fanMode != "") { + if (fanMode == "On") { + state.fanCommand = "fanOn" + } else if (fanMode == "Auto") { + state.fanCommand = "fanAuto" + } else { + state.fanCommand = "" + } + } + + // holdType + state.holdTypeParam = null + if (holdType != null && holdType != "") { + if (holdType == "Until I Change") { + state.holdTypeParam = "indefinite" + } else if (holdType == "Until Next Program") { + state.holdTypeParam = "nextTransition" + } else { + state.holdTypeParam = null + } + } +} + +def changeProgramHandler(evt) { + LOG("changeProgramHander() entered with evt: ${evt}", 5) + + settings.myThermostats.each { stat -> + LOG("In each loop: Working on stat: ${stat}", 4, null, "trace") + // First let's change the Thermostat Program + if(state.doResumeProgram == true) { + LOG("Resuming Program for ${stat}", 4, null, "trace") + stat.resumeProgram() + } else { + LOG("Setting Thermostat Program to programParam: ${state.programParam} and holdType: ${state.holdTypeParam}", 4, null, "trace") + stat.setThermostatProgram(state.programParam, state.holdTypeParam) + } + if (state.fanCommand != "" && state.fanCommand != null) stat."${state.fanCommand}"() + } + return true +} + + + +// Helper Functions +private def LOG(message, level=3, child=null, logType="debug", event=true, displayEvent=true) { + message = "${app.label} ${message}" + parent.LOG(message, level, child, logType, event, displayEvent) +} + From ff039f0adeadd2cb9c45c0003b5b4c7250bd000d Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Mon, 1 Feb 2016 14:34:35 -0600 Subject: [PATCH 06/27] Fixed the missing version number. Updates to version number handling --- .../smartthings/ecobee-connect.src/ecobee-connect.groovy | 7 ++++--- .../ecobee-routines.src/ecobee-routines.groovy | 8 ++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index a969dc97c5d..dc1c6f553de 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -24,7 +24,8 @@ * See Changelog for change history * */ -private def getVersion() { return "ecobee (Connect) Version 0.9.0-RC" } +def getVersionNum() { return "0.9.0" } +private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC2" } private def getHelperSmartApps() { return [ [name: "ecobeeRoutinesChild", appName: "ecobee Routines", @@ -131,7 +132,7 @@ def mainPage() { href ("authPage", title: "ecobee Authorization", description: "${ecoAuthDesc}Tap for ecobee Credentials") } - section (getVersion()) + section (getVersionLabel()) } } @@ -265,7 +266,7 @@ def debugDashboardPage() { dynamicPage(name: "debugDashboardPage", title: "") { - section (getVersion()) + section (getVersionLabel()) section("Commands") { href(name: "pollChildrenPage", title: "", required: false, page: "pollChildrenPage", description: "Tap to execute command: pollChildren()") } diff --git a/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy b/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy index ccc033a42ba..2d32e5584a6 100644 --- a/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy +++ b/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy @@ -14,7 +14,9 @@ * for the specific language governing permissions and limitations under the License. * */ -private def getVersion() { return "ecobee Routines Version 0.1.0-RC" } +def getVersionNum() { return "0.1.0" } +private def getVersionLabel() { return "ecobee Routines Version ${getVersionNum()}-RC2" } + definition( @@ -54,7 +56,9 @@ def mainPage() { input(name: "useSunriseSunset", title: "Also at Sunrise or Sunset?\n(Optional) ", type: "enum", required: false, multiple: true, description: "Tap to choose...", metadata:[values:["Sunrise", "Sunset"]], submitOnChange: true) } input(name: "tempDisable", title: "Temporarily Disable Handler? ", type: "bool", required: false, description: false, submitOnChange: true) - } + } + + section (getVersionLabel()) } } From 58f72cffeb66fcb286ba7bf913566e79ce351385 Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Mon, 1 Feb 2016 14:34:45 -0600 Subject: [PATCH 07/27] Fixed the missing version number. Updates to version number handling --- .../ecobee-sensor.src/ecobee-sensor.groovy | 11 ++++++----- .../ecobee-thermostat.src/ecobee-thermostat.groovy | 6 ++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/devicetypes/smartthings/ecobee-sensor.src/ecobee-sensor.groovy b/devicetypes/smartthings/ecobee-sensor.src/ecobee-sensor.groovy index 2691aad3099..4a6d8e0c467 100644 --- a/devicetypes/smartthings/ecobee-sensor.src/ecobee-sensor.groovy +++ b/devicetypes/smartthings/ecobee-sensor.src/ecobee-sensor.groovy @@ -12,12 +12,13 @@ * 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. * - * Current Version: 0.8.0-RC - * Release Date: 2016-01-26 - * See separate Changelog for change history + * See Changelog for change history * */ - + +def getVersionNum() { return "0.9.0" } +private def getVersionLabel() { return "Ecobee Sensor Version 0.9.0-RC2" } + metadata { definition (name: "Ecobee Sensor", namespace: "smartthings", author: "SmartThings") { capability "Sensor" @@ -175,4 +176,4 @@ private def debugEvent(message, displayEvent = false) { ] if ( debugLevel(4) ) { log.debug "Generating AppDebug Event: ${results}" } sendEvent (results) -} +} \ No newline at end of file diff --git a/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy b/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy index ea7b1a5ebfb..09267c95267 100644 --- a/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy +++ b/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy @@ -23,8 +23,10 @@ * See Changelog for change history * */ - -private def getVersion() { return "ecobee (Connect) Version 0.9.0-RC" } + +def getVersionNum() { return "0.9.0" } +private def getVersionLabel() { return "Ecobee Thermostat Version 0.9.0-RC2" } + metadata { definition (name: "Ecobee Thermostat", namespace: "smartthings", author: "SmartThings") { From 078bbb01ab267d74517d9a97ce98e8943d768672 Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Mon, 1 Feb 2016 19:11:47 -0600 Subject: [PATCH 08/27] Fix toLower() to toLowerCase() --- smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index dc1c6f553de..4de8fbbc9fd 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -1504,7 +1504,7 @@ def setFanMode(child, fanMode, deviceId, sendHoldType=null) { def setProgram(child, program, deviceId, sendHoldType=null) { LOG("setProgram() to ${program} with DeviceID: ${deviceId}", 5, child) - program = program.toLower() + program = program.toLowerCase() def tstatSettings tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? From 3c2ab0639f91906cc8263d24588e7c4be8166d4b Mon Sep 17 00:00:00 2001 From: Sean Kendall Schneyer Date: Mon, 1 Feb 2016 20:53:07 -0600 Subject: [PATCH 09/27] Adding a few images to test custom icons --- smartapp-icons/ecobee/ecobee_motion.png | Bin 0 -> 7255 bytes smartapp-icons/ecobee/ecobee_nomotion.png | Bin 0 -> 6717 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 smartapp-icons/ecobee/ecobee_motion.png create mode 100644 smartapp-icons/ecobee/ecobee_nomotion.png diff --git a/smartapp-icons/ecobee/ecobee_motion.png b/smartapp-icons/ecobee/ecobee_motion.png new file mode 100644 index 0000000000000000000000000000000000000000..8b306b22b45d91a44a42ca01f7b3c40a03c903cf GIT binary patch literal 7255 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 literal 0 HcmV?d00001 diff --git a/smartapp-icons/ecobee/ecobee_nomotion.png b/smartapp-icons/ecobee/ecobee_nomotion.png new file mode 100644 index 0000000000000000000000000000000000000000..0b11734b05fafbd7b651d31578eb3766b5016c7e GIT binary patch literal 6717 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 literal 0 HcmV?d00001 From 84877abdc958fcbdb2089667c89aac0253236c5e Mon Sep 17 00:00:00 2001 From: Sean Kendall Schneyer Date: Mon, 1 Feb 2016 21:08:34 -0600 Subject: [PATCH 10/27] Updated logos with border --- smartapp-icons/ecobee/ecobee_motion.png | Bin 7255 -> 8162 bytes smartapp-icons/ecobee/ecobee_nomotion.png | Bin 6717 -> 7574 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_motion.png b/smartapp-icons/ecobee/ecobee_motion.png index 8b306b22b45d91a44a42ca01f7b3c40a03c903cf..bd97fc19ee82449ca460ba4cc5d76be65f1e77db 100644 GIT binary patch literal 8162 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 diff --git a/smartapp-icons/ecobee/ecobee_nomotion.png b/smartapp-icons/ecobee/ecobee_nomotion.png index 0b11734b05fafbd7b651d31578eb3766b5016c7e..76f29352e1199a6efb16b847e130737c5db3c658 100644 GIT binary patch literal 7574 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 From 8eb6ebb958eb7e9592349cf8b62b6fcc4d22e827 Mon Sep 17 00:00:00 2001 From: Sean Kendall Schneyer Date: Mon, 1 Feb 2016 21:14:19 -0600 Subject: [PATCH 11/27] Updated border --- smartapp-icons/ecobee/ecobee_motion.png | Bin 8162 -> 8305 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_motion.png b/smartapp-icons/ecobee/ecobee_motion.png index bd97fc19ee82449ca460ba4cc5d76be65f1e77db..c4a96881512be4b42e0cce4ffa26cca709d80354 100644 GIT binary patch literal 8305 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 Date: Mon, 1 Feb 2016 21:16:59 -0600 Subject: [PATCH 12/27] trying 150x150 --- smartapp-icons/ecobee/ecobee_nomotion.png | Bin 7574 -> 8001 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_nomotion.png b/smartapp-icons/ecobee/ecobee_nomotion.png index 76f29352e1199a6efb16b847e130737c5db3c658..99d17f235d36ce7dbeffd5f47cbe00de925df3a8 100644 GIT binary patch literal 8001 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 WV_a{AGBtRnicnS5P Date: Mon, 1 Feb 2016 21:19:17 -0600 Subject: [PATCH 13/27] Raise the icon --- smartapp-icons/ecobee/ecobee_nomotion.png | Bin 8001 -> 7967 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_nomotion.png b/smartapp-icons/ecobee/ecobee_nomotion.png index 99d17f235d36ce7dbeffd5f47cbe00de925df3a8..1e214e5b5d73369aa1326fe87f49986d396db878 100644 GIT binary patch literal 7967 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 literal 8001 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 Date: Mon, 1 Feb 2016 21:20:20 -0600 Subject: [PATCH 14/27] resave --- smartapp-icons/ecobee/ecobee_nomotion.png | Bin 7967 -> 7967 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_nomotion.png b/smartapp-icons/ecobee/ecobee_nomotion.png index 1e214e5b5d73369aa1326fe87f49986d396db878..231d3ac240da8fec72f48aa95948b2fd5160e3ec 100644 GIT binary patch delta 17 ZcmbPlH{Wi8Gn)vD;+^(88$GYd0RTF}2KoR1 delta 17 YcmbPlH{Wi8Gn+71r>c$2M$c<<05huvr2qf` From af650180908c8b1dd3bc990efb147b301b376b23 Mon Sep 17 00:00:00 2001 From: Sean Kendall Schneyer Date: Mon, 1 Feb 2016 21:32:30 -0600 Subject: [PATCH 15/27] resave --- smartapp-icons/ecobee/ecobee_nomotion.png | Bin 7967 -> 3938 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_nomotion.png b/smartapp-icons/ecobee/ecobee_nomotion.png index 231d3ac240da8fec72f48aa95948b2fd5160e3ec..85687f18c430331811a4c70d853f236ad56d8619 100644 GIT binary patch literal 3938 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 BQgQ$Q literal 7967 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 Date: Mon, 1 Feb 2016 21:49:52 -0600 Subject: [PATCH 16/27] added heat circle --- smartapp-icons/ecobee/ecobee_heat_circle.png | Bin 0 -> 3169 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 smartapp-icons/ecobee/ecobee_heat_circle.png diff --git a/smartapp-icons/ecobee/ecobee_heat_circle.png b/smartapp-icons/ecobee/ecobee_heat_circle.png new file mode 100644 index 0000000000000000000000000000000000000000..ef75375f6bb9121c1ea8c47ca0fae923c861a4f3 GIT binary patch literal 3169 zcmV-n44(6eP)pF2XskIMF-#l0s}D>qn~%l0000TX;fHrLvL+uWo~o;00000Lvm$d zbY)~9cWHEJAV*0}P-HG;2LJ#I*GWV{RCwC$oquo?VM?Et`p_ zV!9rKS`*K;s$5NZKBM&`!Fk7qqI0zrd<58HM|#DEe7<|pg+>+%de|cC5@v(5f%ol5 zja=vUuSzxT9z@`Wz)r;mEi1RFIv3x7K=QJq;h~b$0U*VWg6{)@3%Jcn?ILeB@7{Tw zjFlZucAndkL|5|Pf$y2AwR%AwX%kYodHolfQ~1or6aptshMB`PxbKC@ox;z9HaFkRF-;8D;Mc3xPc)n&6eg);+X|GRF(LG*Y^1HShDprZHGRy zBGLm4Yk4#Ab`*3y8&RXc6A4i42_xQ&f=A=1sG``Tc3u9Rz?Csl>I2z9iJ8lcnae;BI;7*=$lV`Jo(a;pl<}4fk%In%S)ASa? z8{?E)5{vdRx!6&|5;f%->`EX#Vrn1 zZmk_jhnHvbqvtOQ{#FvaHn7(0+^V@C_hxm51@RZRCR0;xyZ0gjE9?l^SJOFe#kK7~ z7Zu*qc6D1J0uNiQJz76PMyZVEQ;l9cflQ9Q5~4RpJYdb7j*e(qA`3 z{*%KzyYo`jU1D)l4x|+B*C$W2sXCX1MQ)Opr4SfC#nFJkg(=1#>2k_@+_=j7<%h~RPKlc||_ynW`h!MOXmlx#v#*NWt2FENs zNXe2LfFA=>Vj%Mv@D9>Wpf|CYOSaUL9VYKLvZ?(b0pt39;Lm2QDUsw|hmK9B>!sAJ$!bQZN4w}Z&D_o4$kFcRC*X}lT zyJ$n>#E=p4b_4gvTjST{{f;*k^wM89L|e_IM3Q(vI*~&)^4v(lvda^p?Pq~+0eI}a z>|nmKziuen;XEgCxy*+<8hL`e+{sLornePKxs!K~3=$YVMOkAv)hz`y&G#9K13QFF znHSwK*+(L|uK)Js)~k7svY8kX7d%{@QR|6%qD1M+G1z@$7j+G>f)9M!!OgiM6@#GK|iomt;c+!;2H2Uj?NKZXZMyiuoqDZw z5$ObrqmzR-tCQ-M0?Xsk8@v4Ydvj>0XxAlULsc$SB|et67E;#OjlVaC*KaxG)T@oU zkPZt>b?OPX6r5Ql*uf=F^)RP6pI2}0hIfi5x*fc6plj(DoGTSVmyO)T-j=VFMD7GEgN^ydSEPKe(u0xtq5oO*#5 zcl86Xc;*FhC2mgfWabu1K?bwO%njfK@S+kJNJ7fs#~gtZCpkEf&*Rr0BK^Wk*wQ}W zIB`=l)A;CKX~LMduFHu>JOR8Hnms#)&gaNtVF|5w9AS??&+zxr>@58pd`>6wHjz-r z=~l-FdpR;ZT0au=xLlDpoBma)G|iWmehxNJkf+GJ?P6@6-ZHwzZC;ea+(I9L!F2Ba zXQt`pnnKv`%RRQ`n7}PXaSKsR$xP#ct1srh*`EZj`FnFX^ra^(dXkqm zMnU=*a|?Z8<(9wWcA78+wZLyfFKF!;SV9a=UD~zI!kq61e}6M_TJtDY9IzBA(vA`G zK>9S-7XFVLfURmbjOzn88!0K3iWDk41q=cMK#v^#IRqR;ih>_al=A-td$57W7-5BA00000NkvXX Hu0mjf6DIeh literal 0 HcmV?d00001 From 76ae1f3b6d154c484690d50facd2b8517a583554 Mon Sep 17 00:00:00 2001 From: Sean Kendall Schneyer Date: Mon, 1 Feb 2016 22:04:42 -0600 Subject: [PATCH 17/27] heat square --- smartapp-icons/ecobee/ecobee_heat_square.png | Bin 0 -> 1867 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 smartapp-icons/ecobee/ecobee_heat_square.png diff --git a/smartapp-icons/ecobee/ecobee_heat_square.png b/smartapp-icons/ecobee/ecobee_heat_square.png new file mode 100644 index 0000000000000000000000000000000000000000..8c698af4b041fe9301891b5df53cf3ef7195fd2d GIT binary patch literal 1867 zcmV-R2ekN!P)pF2XskIMF-#l0t5sN7mYit0000TX;fHrLvL+uWo~o;00000Lvm$d zbY)~9cWHEJAV*0}P-HG;2LJ#D!AV3xRCwC$TwhF6R~Y|MTSiltrm-cBfpXhUYLk*o zCR=0Bn88R;9)K7~ScWmp9^7g+7lJP&$YNr24P>!TbHu zj3rTTEMV1`5rZ=1qR-?_Q6slEa)5EQN0huq9VJf=qtE1pprbIcMnOj*x{j#R(CV#y zyY+LH`#R}Ir~S!kRj4oD9l>f!3LiP$5OfqKr8KU>fPx1O7XW~^D*zy3R3)Xb>xepH z+)~9$0DuP7^O$H^_i}ATDQ-?LGv{!%M}#t&ID&>~%JO3zz9yEk4Hd>SHP*d6VJv~( zY)f6?YL5ugZg+Z`+}o^Tq%5JrFMeDIeij$)006XCE7>UwpPp9FtpNZA#}?yGv@sPP z{DzA7UM3S`!dMb9PiDuK<29?dS(RRdI!cZidWD^(%yn)$PXlerxXQ?UxLp>hoNqA! z_}T(A)~J(q>a)gR{eT)}GI3ggrp=1O_qj}cjab4@Rl$fs39GXJf{uld-^C&)fWqtn z)KPNWKJg6F?#0|QlHO(&4y$8#;asO2Z(Wc@41s|*W#$3uC^>c&ZNU87e9U{O;-%n5 zI3O;!1YeD>F$T%lNJb1w9PLlst>U5`82Zq^OYgh$BDiIZ7BosLz<^&pIL2=5F|}@1 z)686_9JN>0BD#_3RFp9z*>iCP4;(J|Oy1y*B@wWuDvB|JDx;ed!dTKqp;Cv=8q+PYC$5Z?P$4%L1RO!pM;HF1j_qIoXwDeNJuMn(fU6UGueSra5o542Y+(cfrg6)R?K z5PGu>qcjgblQ(MVO>IRfd?qhGdE3VdSW3)=FIkrx93ca~=av@dex6TBFNx0)a0DCy zN5BzqdALI4y6^}EgginXA&-zpgpjx0$g8a=CFHS{)9>hpuf=?@lpmt-WF-9 zqR>`p#ijARA^G0Hu|?Dms8Of2GK%zPT6XaGg0r~*q`g{+?~l#mhD8%KAH86IiVc1_ zIuG5$uXHJFPo9yH2l!Jn{f$-_hBa~1GsFM*4?gTK6%w=UZB_-V7^GdogeOV@rksxI zn{)_2TPE7yXobuD7}72wTpn9e|I?T620&w!=50^%<~rq|imhn5TFy9S+oCH9uEgPz z=mdIRn{D9Cs6_M((k>xm;+m=`oH}q1J-4(`Nt&ToFk(=0>R59UhA)$easP}1F1G~g z^PZ^naLN3C=&mMiW!UD(_P<}pFsxyHGB0OF-pv#K)sYQx08mN!t8gbDCx$z2^FM2f zR-wPqil!8 zod#kbv0vmAPsYvmuSdKsJ{q?YaHbzQ-T6u0cay6CfRp>*;FP%9ic-{R{Xqt%$INpk zFyQ*{IcA({eEGpFBs=%u7mG9F#Ffd!xbvxh!D#q)Iluk9z(^Q9K94C{mDJ1DQr-Gx z`Z=7Ew>5(D4z++w<9kD1=a#AjUX>yiIazJ}@`Aj8j2M)t(^}zjOYreeGFFY%QF2_U z5vHss@5tLaKYey^7fv0x7yOO3D=_;+7!o~Ei8r8;Y@kkS4Y{xL_aaUS%PH~_hoJoO zuXmmEs7YRvm+L~xEeLspJVG8Jj|d@;kVnWP Date: Mon, 1 Feb 2016 22:44:55 -0600 Subject: [PATCH 18/27] heat square --- smartapp-icons/ecobee/ecobee_heat_square.png | Bin 1867 -> 660 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_heat_square.png b/smartapp-icons/ecobee/ecobee_heat_square.png index 8c698af4b041fe9301891b5df53cf3ef7195fd2d..7ba5e3ba0a47bf5cd1e6de05fbddc9d61b8fdbee 100644 GIT binary patch delta 600 zcmV-e0;m1U4wMBUiBL{Q4GJ0x0000DNk~Le00017000172nGNE0hH+C&XFM^1}rQF zxrJDfNh^N>3Q0skRCwC$oJ+35APhxgtNY(FZ&WF&UYid#_>yy`RfxG5e;Sf)TNpEI zxg+P@0akXfe9r{Dt)$PKxtU)b#LQaQEPf4?a+6T&0f>klv88Ot^$|C@6tvRh7*bz% zXQKZG2$SgO;_1@d8Xz=(rnw^k06=h7&CXLcq8xu{t(lDC*+tVU!(bVOk5nzG_>s!L z3j3aw@e!_ZWm4t3^mm9XQQG&$>#04-UE|9%QfCc`%BnVU5>oLU000000H91i>8i%N zs`6=uOE$(M{;hJwjhW^upKenA@qhY(h|v)w!*EwLI!x&+hDS}rGdAOh$m3Zq^6mHc zBzu32_t9!g)GD9qzV}1KWIbD%PvfIj=rv+lGu+8HQMwJEm0)~^3rvsWcMK_Y!s6_+ z%gB>M7HRyMdOS*L%TRxr9uxoo000000000004}h~mqkobaOFoartIO0!b=`I*dHEI z3pKLmJoetLB)Y_w9DOd}(H?#zs9GF%WwC!1OGjHl=>0X-J^rOGv|7@1b+KuDI>f#s z^qGaKw3xQK*x$m=lJ7!PPIQvz+h6u5n-W1^0Z1GmnlM(O2sN}^+-XgeR;cFc8?IK^7GHKd1!TbHu2O8|fd)$^EW zS@(Z(ZAB?=PA@a(aJ5H-GMPAnhG@$2V;sIFma+{M#xph6y*yzof!=IOUEykv2-0qM zdYatZtYV}rp~5eITnK&^7wrH5v{x(HDGZ;UR?n>g00zev<4&|O6(0PCiuhh86Jx?y z5;0F^$Cl$YtG8K|UW7VIjv0D|ou$lmZaRNY18vH<%E)}UT^6aFZ!rP*+5$AzsFQZ; zv&LZkfEr~oaaw_<&5FbKxlDbHSi(U5`82Zq^OYgh$BDiIZ7BosLz<^&pIL2=5F|}@1)686_9JN>0 zBD#_3RFp9z*>iCP4;(J|Oy1y*B@wWuDvB|g0+X8F8(tio&1ESz^vuqsGu(P0W8}(d7mQ zu20e-4dD!2U%ds#hhoQ5`~k;J*=JU;0?1>-+UZek%436cICw%tM3ZqaNV{lWninU9Waf8sCZLD0P z5rYyfSIaZstwVP;af=>;a0Mgut$#u7YP6C_Lzv{x(9-)LnOD`ssFdb16q zG!H(LH)`okZAB@3CNDmD+s6u6O3Z~XS(h6eAp^eWmKNuJo=-_HiO+u#a0DCyN5Bzq zdALI4y6^}EgginXA&-zpgpjx0$g8a=CFHS{)9>hpuf=?@lpmt-WF-9qR>`p z#ijARA^G0Hu|?Dms8N5XwK9tIXIggf_=2;!0HnQIiSLii;)X>NH6OiTe~Jx$IXVyB z!>@EHY)_t%kq7uwGyRQL7=|@*(=)^W_zynpFBKBA?QK>Cs~DtR!h|PE0;Zgf>YH>3 zKU*f+-)M!){TR|NAzU6?QvcJJ?*>3)l;&+u^5#0_po*<%xmthDIAzE( zztM`ODk^`;Yp;J+f+}8uVOYb8H~_dlNrSd4&{uDXOGe-@;OS8<^ky4qy8_o*XY+T# zXw_`)5>k;p7gtbQp~er#9JtmRy-4$h1+P|bf6>X;QzG=$`&a2yPgCZMtHRp+r5(D4z++w<9kD1=a#AjUX>yiIazJ}@`Aj8j2M)t(^}zjOYreeGFFY%QF2_U z5vHss@5p4^IzN4Oa2HM;xEK75wkt6EL>Ll1QHeL8k!+w&YYn-t^YmBjgbYlm7v3+MbNFM99Vf0000 Date: Mon, 1 Feb 2016 22:46:58 -0600 Subject: [PATCH 19/27] heat square --- smartapp-icons/ecobee/ecobee_heat_square.png | Bin 660 -> 1867 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_heat_square.png b/smartapp-icons/ecobee/ecobee_heat_square.png index 7ba5e3ba0a47bf5cd1e6de05fbddc9d61b8fdbee..218841c489a424c338ba8c9ed7e8ccaa635103d6 100644 GIT binary patch delta 1816 zcmV+z2j}>d1!TbHu2O8|fd)$^EW zS@(Z(ZAB?=PA@a(aJ5H-GMPAnhG@$2V;sIFma+{M#xph6y*yzof!=IOUEykv2-0qM zdYatZtYV}rp~5eITnK&^7wrH5v{x(HDGZ;UR?n>g00zev<4&|O6(0PCiuhh86Jx?y z5;0F^$Cl$YtG8K|UW7VIjv0D|ou$lmZaRNY18vH<%E)}UT^6aFZ!rP*+5$AzsFQZ; zv&LZkfEr~oaaw_<&5FbKxlDbHSi(U5`82Zq^OYgh$BDiIZ7BosLz<^&pIL2=5F|}@1)686_9JN>0 zBD#_3RFp9z*>iCP4;(J|Oy1y*B@wWuDvB|g0+X8F8(tio&1ESz^vuqsGu(P0W8}(d7mQ zu20e-4dD!2U%ds#hhoQ5`~k;J*=JU;0?1>-+UZek%436cICw%tM3ZqaNV{lWninU9Waf8sCZLD0P z5rYyfSIaZstwVP;af=>;a0Mgut$#u7YP6C_Lzv{x(9-)LnOD`ssFdb16q zG!H(LH)`okZAB@3CNDmD+s6u6O3Z~XS(h6eAp^eWmKNuJo=-_HiO+u#a0DCyN5Bzq zdALI4y6^}EgginXA&-zpgpjx0$g8a=CFHS{)9>hpuf=?@lpmt-WF-9qR>`p z#ijARA^G0Hu|?Dms8N5XwK9tIXIggf_=2;!0HnQIiSLii;)X>NH6OiTe~Jx$IXVyB z!>@EHY)_t%kq7uwGyRQL7=|@*(=)^W_zynpFBKBA?QK>Cs~DtR!h|PE0;Zgf>YH>3 zKU*f+-)M!){TR|NAzU6?QvcJJ?*>3)l;&+u^5#0_po*<%xmthDIAzE( zztM`ODk^`;Yp;J+f+}8uVOYb8H~_dlNrSd4&{uDXOGe-@;OS8<^ky4qy8_o*XY+T# zXw_`)5>k;p7gtbQp~er#9JtmRy-4$h1+P|bf6>X;QzG=$`&a2yPgCZMtHRp+r5(D4z++w<9kD1=a#AjUX>yiIazJ}@`Aj8j2M)t(^}zjOYreeGFFY%QF2_U z5vHss@5p4^IzN4Oa2HM;xEK75wkt6EL>Ll1QHeL8k!+w&YYn-t^YmBjgbYlm7v3+MbNFM99Vf00003Q0skRCwC$oJ+35APhxgtNY(FZ&WF&UYid#_>yy`RfxG5e;Sf)TNpEI zxg+P@0akXfe9r{Dt)$PKxtU)b#LQaQEPf4?a+6T&0f>klv88Ot^$|C@6tvRh7*bz% zXQKZG2$SgO;_1@d8Xz=(rnw^k06=h7&CXLcq8xu{t(lDC*+tVU!(bVOk5nzG_>s!L z3j3aw@e!_ZWm4t3^mm9XQQG&$>#04-UE|9%QfCc`%BnVU5>oLU000000H91i>8i%N zs`6=uOE$(M{;hJwjhW^upKenA@qhY(h|v)w!*EwLI!x&+hDS}rGdAOh$m3Zq^6mHc zBzu32_t9!g)GD9qzV}1KWIbD%PvfIj=rv+lGu+8HQMwJEm0)~^3rvsWcMK_Y!s6_+ z%gB>M7HRyMdOS*L%TRxr9uxoo000000000004}h~mqkobaOFoartIO0!b=`I*dHEI z3pKLmJoetLB)Y_w9DOd}(H?#zs9GF%WwC!1OGjHl=>0X-J^rOGv|7@1b+KuDI>f#s z^qGaKw3xQK*x$m=lJ7!PPIQvz+h6u5n-W1^0Z1GmnlM(O2sN}^+-XgeR;cFc8?IK^7GHK Date: Mon, 1 Feb 2016 22:51:56 -0600 Subject: [PATCH 20/27] cool square --- smartapp-icons/ecobee/ecobee_cool_square.png | Bin 0 -> 2541 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 smartapp-icons/ecobee/ecobee_cool_square.png diff --git a/smartapp-icons/ecobee/ecobee_cool_square.png b/smartapp-icons/ecobee/ecobee_cool_square.png new file mode 100644 index 0000000000000000000000000000000000000000..79c6df080d40c11ff7064669c371e11768d8e49f GIT binary patch literal 2541 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 Date: Mon, 1 Feb 2016 22:55:06 -0600 Subject: [PATCH 21/27] cool square --- smartapp-icons/ecobee/ecobee_cool_square.png | Bin 2541 -> 4903 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/smartapp-icons/ecobee/ecobee_cool_square.png b/smartapp-icons/ecobee/ecobee_cool_square.png index 79c6df080d40c11ff7064669c371e11768d8e49f..6410c0b0f9b795c4b2652759e650930a25ae80e5 100644 GIT binary patch delta 4881 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 D{h4^8 delta 2500 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 O0000 Date: Tue, 2 Feb 2016 00:19:38 -0600 Subject: [PATCH 22/27] Tweeks to the watchdog handling. Fixed the schedule call to the wrong function (needed to use scheduled wrapper to update state variables) --- .../ecobee-connect.src/ecobee-connect.groovy | 96 +++++++++---------- 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index dc1c6f553de..0d2ba6f8770 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -25,7 +25,7 @@ * */ def getVersionNum() { return "0.9.0" } -private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC2" } +private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC3" } private def getHelperSmartApps() { return [ [name: "ecobeeRoutinesChild", appName: "ecobee Routines", @@ -160,7 +160,7 @@ def authPage() { description = "You are connected. Tap Done above." uninstallAllowed = true oauthTokenProvided = true - state.connected = "full" + apiRestored() } else { description = "Tap to enter ecobee Credentials" } @@ -391,8 +391,7 @@ def callback() { LOG("swapped token: $resp.data; state.refreshToken: ${state.refreshToken}; state.authToken: ${state.authToken}", 2) } - if (state.authToken) { - state.lastTokenRefresh = now() + if (state.authToken) { success() } else { fail() @@ -644,8 +643,7 @@ def updated() { // Force the rescheduling state.lastScheduledPoll = 0 state.lastScheduledTokenRefresh = 0 - state.lastPoll = 0 - state.lastTokenRefresh = 0 + state.lastPoll = 0 scheduleHandlers() // Add subscriptions as little "daemons" that will check on our health @@ -667,8 +665,7 @@ def initialize() { // Initialize several variables state.lastScheduledPoll = 0 state.lastScheduledTokenRefresh = 0 - state.lastPoll = 0 - state.lastTokenRefresh = 0 + state.lastPoll = 0 // Setup initial polling and determine polling intervals state.pollingInterval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 @@ -681,6 +678,7 @@ def initialize() { deleteUnusedChildren() // Schedule the various handlers + if(canSchedule()) runEvery15Minutes(scheduleHandlers) scheduleHandlers() // Add subscriptions as little "daemons" that will check on our health @@ -707,7 +705,7 @@ private def createChildrenThermostats() { if(!d) { // TODO: Place in a try block and check for this exception: physicalgraph.app.exception.UnknownDeviceTypeException try { - d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"Ecobee Thermostat:${state.thermostatsWithNames[dni]}"]) + d = addChildDevice(app.namespace, getChildThermostatName(), dni, null, ["label":"EcoTherm: ${state.thermostatsWithNames[dni]}"]) } catch (physicalgraph.app.exception.UnknownDeviceTypeException e) { LOG("You MUST add the ${getChildSensorName()} Device Handler to the IDE BEFORE running the setup.", 1, null, "error") return false @@ -731,7 +729,7 @@ private def createChildrenSensors() { if(!d) { // TODO: Place in a try block and check for this exception: physicalgraph.app.exception.UnknownDeviceTypeException try { - d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"Ecobee Sensor:${state.eligibleSensors[dni]}"]) + d = addChildDevice(app.namespace, getChildSensorName(), dni, null, ["label":"EcoSensor: ${state.eligibleSensors[dni]}"]) } catch (physicalgraph.app.exception.UnknownDeviceTypeException e) { LOG("You MUST add the ${getChildSensorName()} Device Handler to the IDE BEFORE running the setup.", 1, null, "error") return false @@ -781,7 +779,6 @@ def scheduleHandlers(evt=null) { // state.lastScheduledPoll == last time the poll() was run via the scheduler // state.lastScheduledTokenRefresh == last time the refreshAuthToken was run via the scheduler - //automatically update devices status every 5 mins def timeSinceLastScheduledPoll = (state.lastScheduledPoll == 0 || !state.lastScheduledPoll) ? 0 : ((now() - state.lastScheduledPoll?.toDouble()) / 1000 / 60) def timeSinceLastScheduledRefresh = (state.lastScheduledTokenRefresh == 0 || !state.lastScheduledTokenRefresh) ? 0 : ((now() - state.lastScheduledTokenRefresh?.toDouble()) / 1000 / 60) def timeBeforeExpiry = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 @@ -795,10 +792,9 @@ def scheduleHandlers(evt=null) { if ( (timeSinceLastScheduledPoll == 0) || (timeSinceLastPoll >= (interval * 2)) || (!state.initialized) ) { // automatically update devices status every ${interval} mins // re-establish polling - LOG("pollChildren() - Rescheduling handlers due to delays!", 1, child, "warn") - unschedule("poll") - "runEvery${interval}Minutes"("poll") - runIn(15, "pollScheduled") + LOG("scheduleHandlers() - Rescheduling handlers due to delays!", 1, child, "warn") + unschedule("pollScheduled") + "runEvery${interval}Minutes"("pollScheduled") } // Reschedule Authrefresh if we are over the grace period @@ -811,11 +807,13 @@ def scheduleHandlers(evt=null) { def pollScheduled() { state.lastScheduledPoll = now() + scheduleHandlers() poll() } def refreshAuthTokenScheduled() { state.lastScheduledTokenRefresh = now() + scheduleHandlers() refreshAuthToken() } @@ -840,23 +838,14 @@ def pollChildren(child = null) { LOG("pollChildren() - Nothing to poll as there are no thermostats currently selected", 1, child, "warn") return } - - // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children + + // Run a watchdog checker here + scheduleHandlers() + + // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children def timeSinceLastPoll = (state.lastPoll == 0) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) LOG("Time since last poll? ${timeSinceLastPoll} -- state.lastPoll == ${state.lastPoll}", 3, child, "info") - // TODO: Let the scheduleHandlers() function do this instead - // Reschedule polling if it has been a while since the previous poll - def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 - if ( timeSinceLastPoll >= (interval * 2) ) { - // automatically update devices status every ${interval} mins - // re-establish polling - LOG("pollChildren() - Rescheduling handlers due to delays!", 1, child, "warn") - unschedule() - "runEvery${interval}Minutes"("poll") - runEvery15Minutes("refreshAuthToken") - } - if ( (state.lastPoll == 0) || ( timeSinceLastPoll > getMinMinBtwPolls().toDouble() ) ) { // It has been longer than the minimum delay LOG("Calling the Ecobee API to fetch the latest data...", 4, child) @@ -865,7 +854,6 @@ def pollChildren(child = null) { LOG("pollChildren() - Not time to call the API yet. It has been ${timeSinceLastPoll} minutes since last full poll.", 4, child) generateEventLocalParams() // Update any local parameters and send } - // Iterate over all the children def d = getChildDevices() @@ -932,11 +920,6 @@ private def pollEcobeeAPI(thermostatIdsString = "") { try{ httpGet(pollParams) { resp -> - -// if (resp.data) { -// debugEventFromParent(child, "pollChildren(child) >> resp.status = ${resp.status}, resp.data = ${resp.data}") -// } - if(resp.status == 200) { LOG("poll results returned resp.data ${resp.data}", 2) state.remoteSensors = resp.data.thermostatList.remoteSensors @@ -945,12 +928,11 @@ private def pollEcobeeAPI(thermostatIdsString = "") { // Create the list of sensors and related data updateSensorData() // Create the list of thermostats and related data - updateThermostatData() - + updateThermostatData() result = true if (state.connected != "full") { - state.connected = "full" + apiRestored() generateEventLocalParams() // Update the connection status } state.lastPoll = now(); @@ -961,7 +943,8 @@ private def pollEcobeeAPI(thermostatIdsString = "") { //refresh the auth token if (resp.status == 500 && resp.data.status.code == 14) { LOG("Resp.status: ${resp.status} Status Code: ${resp.data.status.code}. Unable to recover", 1, null, "error") - // Not possible to recover from a code 14 + // Should not possible to recover from a code 14 but try anyway? + apiLost("pollEcobeeAPI() - Resp.status: ${resp.status} Status Code: ${resp.data.status.code}. Unable to recover.") } else { @@ -971,24 +954,26 @@ private def pollEcobeeAPI(thermostatIdsString = "") { } } catch (groovyx.net.http.HttpResponseException e) { LOG("pollEcobeeAPI() >> HttpResponseException occured. Exception info: ${e} StatusCode: ${e.statusCode} response? data: ${e.getResponse()?.getData()}", 1, null, "error") + result = false def reAttemptPeriod = 45 // in sec if ( (e.statusCode == 500 && e.getResponse()?.data.status.code == 14) || (e.statusCode == 401 && e.getResponse()?.data.status.code == 14) ) { // Not possible to recover from status.code == 14 + if ( refreshAuthToken() ) { LOG("We have recovered the token a from the code 14.", 2, null, "warn") } LOG("In HttpResponseException: Received data.stat.code of 14", 1, null, "error") apiLost("pollEcobeeAPI() - In HttpResponseException: Received data.stat.code of 14") } else if (e.statusCode != 401) { //this issue might comes from exceed 20sec app execution, connectivity issue etc. LOG("In HttpResponseException - statusCode != 401 (${e.statusCode})", 1, null, "warn") state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - runIn(reAttemptPeriod, "pollEcobeeAPI") // retry to poll + if(canSchedule()) { runIn(reAttemptPeriod, "pollChildren") } else { pollChildren() } } else if (e.statusCode == 401) { // Status.code other than 14 state.reAttemptPoll = state.reAttemptPoll + 1 LOG("statusCode == 401: reAttempt refreshAuthToken to try = ${state.reAttemptPoll}", 1, null, "warn") if (state.reAttemptPoll <= 3) { state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - runIn(reAttemptPeriod, "pollEcobeeAPI") + if(canSchedule()) { runIn(reAttemptPeriod, "pollChildren") } else { pollChildren() } } else { LOG("Unable to poll EcobeeAPI after three attempts. Will try to refresh authtoken.", 1, null, "error") debugEvent( "Unable to poll EcobeeAPI after three attempts. Will try to refresh authtoken." ) @@ -1293,11 +1278,11 @@ private refreshAuthToken() { LOG("refreshing auth token", 2) LOG("refreshAuthToken() - There is no refreshToken stored! Unable to refresh OAuth token.", 1, null, "error") apiLost("refreshAuthToken() - No refreshToken") - return + return false } else if ( !readyForAuthRefresh() ) { // Not ready to refresh yet LOG("refreshAuthToken() - Not time to refresh yet, there is still time left before expiration.") - return + return true } else { def refreshParams = [ @@ -1358,13 +1343,14 @@ private refreshAuthToken() { LOG("No jsonMap??? ${jsonMap}", 2) } state.action = "" - state.connected = "full" + apiRestored() generateEventLocalParams() // Update the connected state at the thermostat devices - + return true } else { LOG("Refresh failed ${resp.status} : ${resp.status.code}!", 1, null, "error") state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices + return false } } } catch (groovyx.net.http.HttpResponseException e) { @@ -1377,14 +1363,14 @@ private refreshAuthToken() { LOG("refreshAuthToken() - e.statusCode: ${e.statusCode}", 1, null, "warn") state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - runIn(reAttemptPeriod, "refreshAuthToken") + if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthTokens() } } else if (e.statusCode == 401) { // status.code other than 14 state.reAttempt = state.reAttempt + 1 LOG("reAttempt refreshAuthToken to try = ${state.reAttempt}", 1, null, "warn") if (state.reAttempt <= 3) { state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - runIn(reAttemptPeriod, "refreshAuthToken") + if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthToken() } } else { // More than 3 attempts, time to give up and notify the end user LOG("More than 3 attempts to refresh tokens. Giving up", 1, null, "error") @@ -1395,7 +1381,7 @@ private refreshAuthToken() { } catch (java.util.concurrent.TimeoutException e) { LOG("refreshAuthToken(), TimeoutException: ${e}.", 1, null, "error") // Likely bad luck and network overload, move on and let it try again - runIn(300, "refreshAuthToken") + if(canSchedule()) { runIn(300, "refreshAuthToken") } else { refreshAuth() } } catch (Exception e) { LOG("refreshAuthToken(), General Exception: ${e}.", 1, null, "error") } @@ -1504,7 +1490,7 @@ def setFanMode(child, fanMode, deviceId, sendHoldType=null) { def setProgram(child, program, deviceId, sendHoldType=null) { LOG("setProgram() to ${program} with DeviceID: ${deviceId}", 5, child) - program = program.toLower() + program = program.toLowerCase() def tstatSettings tstatSettings = ((sendHoldType != null) && (sendHoldType != "")) ? @@ -1551,7 +1537,7 @@ private def sendJson(child = null, String jsonBody) { returnStatus = resp.data.status.code if (resp.data.status.code == 0) { LOG("Successful call to ecobee API.", 2, child) - state.connected = "full" + apiRestored() generateEventLocalParams() statusCode=false } else { @@ -1563,7 +1549,9 @@ private def sendJson(child = null, String jsonBody) { //refresh the auth token if (resp.status == 500 && resp.status.code == 14) { LOG("Refreshing your auth_token!") - refreshAuthToken() + if(refreshAuthToken()) { + LOG("Successfully performed a refreshAuthToken() after a Code 14!", 2, child, "warn") + } return false // No way to recover from a status.code 14 } else { LOG("Possible Authentication error, invalid authentication method, lack of credentials, etc. Status: ${resp.status} - ${resp.status.code} ", 2, child, "error") @@ -1737,6 +1725,12 @@ private String apiConnected() { return state.connected?.toString() ?: "lost" } + +private def apiRestored() { + state.connected = "full" + unschedule("notifyApiLost") +} + private def apiLost(where = "not specified") { LOG("apiLost() - ${where}: Lost connection with APIs. unscheduling Polling and refreshAuthToken. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 1, null, "error", true, true) From f185aec61cd32ddd010181e4caebf1de18dba22d Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Tue, 2 Feb 2016 00:40:38 -0600 Subject: [PATCH 23/27] Fixed even more issues with the watchdog handling, including more typos of old function names --- .../ecobee-connect.src/ecobee-connect.groovy | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index 0d2ba6f8770..ddcaee49518 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -25,7 +25,7 @@ * */ def getVersionNum() { return "0.9.0" } -private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC3" } +private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC4" } private def getHelperSmartApps() { return [ [name: "ecobeeRoutinesChild", appName: "ecobee Routines", @@ -783,13 +783,13 @@ def scheduleHandlers(evt=null) { def timeSinceLastScheduledRefresh = (state.lastScheduledTokenRefresh == 0 || !state.lastScheduledTokenRefresh) ? 0 : ((now() - state.lastScheduledTokenRefresh?.toDouble()) / 1000 / 60) def timeBeforeExpiry = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 - LOG("Time since last poll? ${timeSinceLastScheduledPoll} -- state.lastScheduledPoll == ${state.lastScheduledPoll}", 4, null, "info") - LOG("Time since last token refresh? ${timeSinceLastScheduledRefresh} -- state.lastScheduledTokenRefresh == ${state.lastScheduledTokenRefresh}", 4, null, "info") - LOG("timeLeft until expiry (in min): ${timeBeforeExpiry}", 4, null, "info") + LOG("scheduleHandlers() - Time since last poll? ${timeSinceLastScheduledPoll} -- state.lastScheduledPoll == ${state.lastScheduledPoll}", 4, null, "info") + LOG("scheduleHandlers() - Time since last token refresh? ${timeSinceLastScheduledRefresh} -- state.lastScheduledTokenRefresh == ${state.lastScheduledTokenRefresh}", 4, null, "info") + LOG("scheduleHandlers() - timeLeft until expiry (in min): ${timeBeforeExpiry}", 4, null, "info") // Reschedule polling if it has been a while since the previous poll def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 - if ( (timeSinceLastScheduledPoll == 0) || (timeSinceLastPoll >= (interval * 2)) || (!state.initialized) ) { + if ( (timeSinceLastScheduledPoll == 0) || (timeSinceLastScheduledPoll >= (interval * 2)) || (!state.initialized) ) { // automatically update devices status every ${interval} mins // re-establish polling LOG("scheduleHandlers() - Rescheduling handlers due to delays!", 1, child, "warn") @@ -798,10 +798,10 @@ def scheduleHandlers(evt=null) { } // Reschedule Authrefresh if we are over the grace period - if ( (timeSinceLastScheduledRefresh == 0) || (timeBeforeExpiry >= state.tokenGrace) || (!state.initialized) ) { + if ( (timeSinceLastScheduledRefresh == 0) || (timeBeforeExpiry <= state.tokenGrace) || (!state.initialized) ) { unschedule("refreshAuthTokenScheduled") runEvery15Minutes("refreshAuthTokenScheduled") - refreshAuthTokenScheduled() + refreshAuthTokenScheduled() } } @@ -1752,8 +1752,8 @@ private def apiLost(where = "not specified") { } } - unschedule("poll") - unschedule("refreshAuthToken") + // unschedule("pollScheduled") + // unschedule("refreshAuthTokenScheduled") runEvery3Hours("notifyApiLost") } From a758bbd64eacd4ce7113b9ca7dbecc7ace831cee Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Tue, 2 Feb 2016 00:43:40 -0600 Subject: [PATCH 24/27] Proper check using grace period state variable --- .../smartthings/ecobee-connect.src/ecobee-connect.groovy | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index ddcaee49518..9a95dade1b9 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -798,10 +798,10 @@ def scheduleHandlers(evt=null) { } // Reschedule Authrefresh if we are over the grace period - if ( (timeSinceLastScheduledRefresh == 0) || (timeBeforeExpiry <= state.tokenGrace) || (!state.initialized) ) { + if ( (timeSinceLastScheduledRefresh == 0) || (timeSinceLastScheduledRefresh >= state.tokenGrace) || (!state.initialized) ) { unschedule("refreshAuthTokenScheduled") - runEvery15Minutes("refreshAuthTokenScheduled") - refreshAuthTokenScheduled() + runEvery15Minutes("refreshAuthTokenScheduled") + if ( readyForAuthRefresh() ) refreshAuthTokenScheduled() } } From 16ccd82a505dd0450173e94d227eae8683131b33 Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Wed, 3 Feb 2016 02:57:18 -0600 Subject: [PATCH 25/27] Overhaul of watchdog handling --- .../ecobee-connect.src/ecobee-connect.groovy | 397 ++++++++++++------ .../ecobee-routines.groovy | 4 +- 2 files changed, 275 insertions(+), 126 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index 9a95dade1b9..3252d1993ef 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -25,7 +25,7 @@ * */ def getVersionNum() { return "0.9.0" } -private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC4" } +private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC5" } private def getHelperSmartApps() { return [ [name: "ecobeeRoutinesChild", appName: "ecobee Routines", @@ -91,7 +91,7 @@ def mainPage() { } if(state.authToken != null && deviceHandlersInstalled) { - if (settings.thermostats?.size() > 0) { + if (settings.thermostats?.size() > 0 && state.initialized) { section("Helper SmartApps") { href ("helperSmartAppsPage", title: "Helper SmartApps", description: "Tap to manage Helper SmartApps") } @@ -262,8 +262,7 @@ def preferencesPage() { } def debugDashboardPage() { - LOG("=====> debugDashboardPage() entered.", 5) - + LOG("=====> debugDashboardPage() entered.", 5) dynamicPage(name: "debugDashboardPage", title: "") { section (getVersionLabel()) @@ -288,7 +287,7 @@ def debugDashboardPage() { // pages that are part of Debug Dashboard def pollChildrenPage() { LOG("=====> pollChildrenPage() entered.", 5) - state.lastPoll = 0 // Reset to force the poll to happen + state.forcePoll = true // Reset to force the poll to happen pollChildren(null) dynamicPage(name: "pollChildrenPage", title: "") { @@ -493,10 +492,8 @@ def connectionStatus(message, redirectUrl = null) { // Get the list of Ecobee Thermostats for use in the settings pages def getEcobeeThermostats() { - LOG("====> getEcobeeThermostats() entered", 5) - + LOG("====> getEcobeeThermostats() entered", 5) def requestBody = '{"selection":{"selectionType":"registered","selectionMatch":"","includeRuntime":true,"includeSensors":true,"includeProgram":true}}' - def deviceListParams = [ uri: apiEndpoint, path: "/1/thermostat", @@ -507,12 +504,10 @@ def getEcobeeThermostats() { def stats = [:] try { httpGet(deviceListParams) { resp -> - LOG("httpGet() response: ${resp.data}") // Initialize the Thermostat Data. Will reuse for the Sensor List intialization - state.thermostatData = resp.data - + state.thermostatData = resp.data if (resp.status == 200) { LOG("httpGet() in 200 Response") @@ -570,17 +565,12 @@ Map getEcobeeSensors() { state.remoteSensors = state.remoteSensors ? (state.remoteSensors + singleStat.remoteSensors) : singleStat.remoteSensors LOG("After state.remoteSensors setup...", 5) - // WORKAROUND: Iterate over remoteSensors list and add in the thermostat DNI - // This is needed to work around the dynamic enum "bug" which prevents proper deletion - // TODO: Check to see if this is still needed. Seem to use elibleSensors now instead - // singleStat.remoteSensors.each { tempSensor -> - // tempSensor.thermDNI = "${thermostat}" - // state.remoteSensors = state.remoteSensors + tempSensor - //} LOG("getEcobeeSensors() - singleStat.remoteSensors: ${singleStat.remoteSensors}", 4) LOG("getEcobeeSensors() - state.remoteSensors: ${state.remoteSensors}", 4) } + // WORKAROUND: Iterate over remoteSensors list and add in the thermostat DNI + // This is needed to work around the dynamic enum "bug" which prevents proper deletion LOG("remoteSensors all before each loop: ${state.remoteSensors}", 5, null, "trace") state.remoteSensors.each { LOG("Looping through each remoteSensor. Current remoteSensor: ${it}", 5, null, "trace") @@ -621,70 +611,66 @@ def getThermostatTypeName(stat) { def installed() { LOG("Installed with settings: ${settings}", 4) - return initialize() + initialize() } def updated() { - LOG("Updated with settings: ${settings}", 4) - unsubscribe() // Do we really need/want to unsubscribe to anything? - - // refresh Thermostats and Sensor full lists - getEcobeeThermostats() - getEcobeeSensors() - - // Children - if (settings.thermostats?.size() > 0) { - createChildrenThermostats() - if (settings.ecobeesensors?.size() > 0) { createChildrenSensors() } - } - deleteUnusedChildren() - - - // Force the rescheduling - state.lastScheduledPoll = 0 - state.lastScheduledTokenRefresh = 0 - state.lastPoll = 0 - scheduleHandlers() - - // Add subscriptions as little "daemons" that will check on our health - subscribe(location, "routineExecuted", scheduleHandlers) - subscribe(location, "sunset", scheduleHandlers) - subscribe(location, "sunrise", scheduleHandlers) + LOG("Updated with settings: ${settings}", 4) + initialize() } def initialize() { LOG("=====> initialize()", 4) - if (state.initialized) { - LOG("initialized() called more than once. Please contact the developer of this app.", 1, null, "error") - return false - } - state.connected = "full" - unschedule() // Shouldn't be needed as we are only calling intialize once now, but just in case + + state.connected = "full" state.reAttempt = 0 - // Initialize several variables - state.lastScheduledPoll = 0 - state.lastScheduledTokenRefresh = 0 - state.lastPoll = 0 + try { + unsubscribe() + unschedule() // reset all the schedules + } catch (Exception e) { + LOG("updated() - Exception encountered trying to unschedule(). Exception: ${e}", 2, null, "error") + } + // Initialize several variables + state.lastScheduledPoll = now() + state.lastScheduledTokenRefresh = now() + state.lastScheduledWatchdog = now() + state.lastPoll = now() + // Setup initial polling and determine polling intervals - state.pollingInterval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 - state.tokenGrace = 16 // Anything more than this then we have a possible failed + state.pollingInterval = getPollingInterval() + state.tokenGrace = 18 // Anything more than this then we have a possible failed + state.watchdogInterval = 15 + if (state.initialized) { + // refresh Thermostats and Sensor full lists + getEcobeeThermostats() + getEcobeeSensors() + } + + // getEcobeeThermostats() + // getEcobeeSensors() + // Children def aOK = true if (settings.thermostats?.size() > 0) { aOK = aOK && createChildrenThermostats() } - if (settings.ecobeesensors?.size() > 0) { aOK = aOK && createChildrenSensors() } + if (settings.ecobeesensors?.size() > 0) { aOK = aOK && createChildrenSensors() } deleteUnusedChildren() + + // Initial poll() + // if (settings.thermostats?.size() > 0) { if (canSchedule()) { runIn(10, pollInit) } else { pollInit() } } + if (settings.thermostats?.size() > 0) { pollInit() } + + // Add subscriptions as little "daemons" that will check on our health + subscribe(location, "routineExecuted", scheduleWatchdog) + subscribe(location, "sunset", scheduleWatchdog) + subscribe(location, "sunrise", scheduleWatchdog) // Schedule the various handlers - if(canSchedule()) runEvery15Minutes(scheduleHandlers) - scheduleHandlers() - - // Add subscriptions as little "daemons" that will check on our health - subscribe(location, "routineExecuted", scheduleHandlers) - subscribe(location, "sunset", scheduleHandlers) - subscribe(location, "sunrise", scheduleHandlers) + if (settings.thermostats?.size() > 0) { spawnDaemon("poll") } + spawnDaemon("watchdog") + spawnDaemon("auth") // TODO: Add ability to add additional physical (or virtual) items to subscribe to that have events generated that could heal our app @@ -760,7 +746,7 @@ private def deleteUnusedChildren() { LOG("These are currently all of my childred: ${allMyChildren}", 5, null, "debug") // Update list of "eligibleSensors" - def childrenToKeep = thermostats + (state.eligibleSensors?.keySet() ?: []) + def childrenToKeep = (thermostats ?: []) + (state.eligibleSensors?.keySet() ?: []) LOG("These are the children to keep around: ${childrenToKeep}", 4, null, "trace") def childrenToDelete = allMyChildren.findAll { !childrenToKeep.contains(it.deviceNetworkId) } @@ -771,82 +757,227 @@ private def deleteUnusedChildren() { } -def scheduleHandlers(evt=null) { - if(state.connected == "lost") { - LOG("Unable to schedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, null, "error") - return - } +def scheduleWatchdog(evt=null) { + def results = true + if(apiConnected() == "lost") { + // Possibly a false alarm? Check if we can update the token with one last fleeting try... + if( refreshAuthToken() ) { + // We are back in business! + LOG("scheduleWatchdog() - Was able to recover the lost connection. Please ignore any notifications received.", 1, null, "error") + } else { + LOG("scheduleWatchdog() - Unable toschedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, null, "error") + return false + } + } + + def pollAlive = isDaemonAlive("poll") + def authAlive = isDaemonAlive("auth") + def watchdogAlive = isDaemonAlive("watchdog") + + LOG("scheduleWatchdog() --> pollAlive==${pollAlive} authAlive==${authAlive} watchdogAlive==${watchdogAlive}", 4, null, "debug") - // state.lastScheduledPoll == last time the poll() was run via the scheduler - // state.lastScheduledTokenRefresh == last time the refreshAuthToken was run via the scheduler - def timeSinceLastScheduledPoll = (state.lastScheduledPoll == 0 || !state.lastScheduledPoll) ? 0 : ((now() - state.lastScheduledPoll?.toDouble()) / 1000 / 60) - def timeSinceLastScheduledRefresh = (state.lastScheduledTokenRefresh == 0 || !state.lastScheduledTokenRefresh) ? 0 : ((now() - state.lastScheduledTokenRefresh?.toDouble()) / 1000 / 60) + // Reschedule polling if it has been a while since the previous poll + if (!pollAlive) { spawnDaemon("poll") } + if (!authAlive) { spawnDaemon("auth") } + if (!watchdogAlive) { spawnDaemon("watchdog") } + + return +} + +// Watchdog Handler +private def Boolean isDaemonAlive(daemon="all") { + // Daemon options: "poll", "auth", "watchdog", "all" + def daemonList = ["poll", "auth", "watchdog", "all"] + + daemon = daemon.toLowerCase() + def result = true + + def timeSinceLastScheduledPoll = (state.lastScheduledPoll == 0 || state.lastScheduledPoll == null) ? 0 : ((now() - state.lastScheduledPoll?.toDouble()) / 1000 / 60) + def timeSinceLastScheduledRefresh = (state.lastScheduledTokenRefresh == 0 || state.lastScheduledTokenRefresh == null) ? 0 : ((now() - state.lastScheduledTokenRefresh?.toDouble()) / 1000 / 60) + def timeSinceLastScheduledWatchdog = (state.lastScheduledWatchdog == 0 || state.lastScheduledWatchdog == null) ? 0 : ((now() - state.lastScheduledWatchdog?.toDouble()) / 1000 / 60) def timeBeforeExpiry = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 - LOG("scheduleHandlers() - Time since last poll? ${timeSinceLastScheduledPoll} -- state.lastScheduledPoll == ${state.lastScheduledPoll}", 4, null, "info") - LOG("scheduleHandlers() - Time since last token refresh? ${timeSinceLastScheduledRefresh} -- state.lastScheduledTokenRefresh == ${state.lastScheduledTokenRefresh}", 4, null, "info") - LOG("scheduleHandlers() - timeLeft until expiry (in min): ${timeBeforeExpiry}", 4, null, "info") - - // Reschedule polling if it has been a while since the previous poll - def interval = (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 - if ( (timeSinceLastScheduledPoll == 0) || (timeSinceLastScheduledPoll >= (interval * 2)) || (!state.initialized) ) { - // automatically update devices status every ${interval} mins - // re-establish polling - LOG("scheduleHandlers() - Rescheduling handlers due to delays!", 1, child, "warn") - unschedule("pollScheduled") - "runEvery${interval}Minutes"("pollScheduled") - } + LOG("isDaemonAlive() - Time since last poll? ${timeSinceLastScheduledPoll} -- state.lastScheduledPoll == ${state.lastScheduledPoll}", 4, null, "info") + LOG("isDaemonAlive() - Time since last token refresh? ${timeSinceLastScheduledRefresh} -- state.lastScheduledTokenRefresh == ${state.lastScheduledTokenRefresh}", 4, null, "info") + LOG("isDaemonAlive() - Time left (timeBeforeExpiry) until expiry (in min): ${timeBeforeExpiry}", 4, null, "info") + + if (daemon == "poll" || daemon == "all") { + LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'poll'", 4, null, "trace") + def maxInterval = state.pollingInterval + 3 + // if ( (timeSinceLastScheduledPoll == 0) || (timeSinceLastScheduledPoll >= maxInterval) || (state.initialized != true) ) { result = false } + if ( timeSinceLastScheduledPoll >= maxInterval ) { result = false } + } + + if (daemon == "auth" || daemon == "all") { + LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'auth'", 4, null, "trace") + // if ( (timeSinceLastScheduledRefresh == 0) || (timeSinceLastScheduledRefresh >= state.tokenGrace) || (state.initialized != true) ) { result = false } + if ( timeSinceLastScheduledRefresh >= state.tokenGrace ) { result = false } + } + + if (daemon == "watchdog" || daemon == "all") { + LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'watchdog'", 4, null, "trace") + def maxInterval = state.watchdogInterval + 3 + // if ( (timeSinceLastScheduledWatchdog == 0) || (timeSinceLastScheduledWatchdog >= (maxInterval)) || (state.initialized != true) ) { result = false } + if ( timeSinceLastScheduledWatchdog >= maxInterval ) { result = false } + } - // Reschedule Authrefresh if we are over the grace period - if ( (timeSinceLastScheduledRefresh == 0) || (timeSinceLastScheduledRefresh >= state.tokenGrace) || (!state.initialized) ) { - unschedule("refreshAuthTokenScheduled") - runEvery15Minutes("refreshAuthTokenScheduled") - if ( readyForAuthRefresh() ) refreshAuthTokenScheduled() - } + if (!daemonList.contains(daemon) ) { + // Unkown option passed in, gotta punt + LOG("isDaemonAlive() - Unknown daemon: ${daemon} received. Do not know how to check this daemon.", 1, null, "error") + result = false + } + return result +} + +private def Boolean spawnDaemon(daemon="all") { + // Daemon options: "poll", "auth", "watchdog", "all" + def daemonList = ["poll", "auth", "watchdog", "all"] + + daemon = daemon.toLowerCase() + def result = true + + if (daemon == "poll" || daemon == "all") { + LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'poll'", 4, null, "trace") + // Reschedule the daemon + try { + result = result && unschedule("pollScheduled") + if ( canSchedule() ) { + "runEvery${state.pollingInterval}Minutes"("pollScheduled") + // if ( canSchedule() ) { runIn(30, "pollScheduled") } // Don't count this against the results + pollScheduled() + result = result && true + } else { + LOG("canSchedule() is NOT allowed! Unable to schedule daemon!", 1, null, "error") + result = false + } + } catch (Exception e) { + LOG("spawnDaemon() - Exception when performing unschedule() of ${daemon}. Exception: ${e}", 1, null, "error") + result = result && false + } + } + + if (daemon == "auth" || daemon == "all") { + LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'auth'", 4, null, "trace") + // Reschedule the daemon + try { + result = result && unschedule("refreshAuthTokenScheduled") + if ( canSchedule() ) { + runEvery15Minutes("refreshAuthTokenScheduled") + // if ( canSchedule() ) { runIn(30, "refreshAuthTokenScheduled") } // Don't count this against the results + refreshAuthTokenScheduled() + result = result && true + } else { + LOG("canSchedule() is NOT allowed! Unable to schedule daemon!", 1, null, "error") + result = false + } + } catch (Exception e) { + LOG("spawnDaemon() - Exception when performing unschedule() of ${daemon}. Exception: ${e}", 1, null, "error") + result = result && false + } + } + + if (daemon == "watchdog" || daemon == "all") { + LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'watchdog'", 4, null, "trace") + // Reschedule the daemon + try { + result = result && unschedule("scheduleWatchdog") + if ( canSchedule() ) { + runEvery15Minutes("scheduleWatchdog") + result = result && true + } else { + LOG("canSchedule() is NOT allowed! Unable to schedule daemon!", 1, null, "error") + result = false + } + } catch (Exception e) { + LOG("spawnDaemon() - Exception when performing unschedule() of ${daemon}. Exception: ${e}", 1, null, "error") + result = result && false + } + } + + if (!daemonList.contains(daemon) ) { + // Unkown option passed in, gotta punt + LOG("isDaemonAlive() - Unknown daemon: ${daemon} received. Do not know how to check this daemon.", 1, null, "error") + result = false + } + return result +} + + +def updateLastPoll(Boolean isScheduled=false) { + if (isScheduled) { + state.lastScheduledPoll = now() + state.lastScheduledPollDate = new Date().toString() + } else { + state.lastPoll = now() + state.lastPollDate = new Date().toString() + } } +// Called by scheduled() event handler def pollScheduled() { - state.lastScheduledPoll = now() - scheduleHandlers() - poll() + updateLastPoll(true) + LOG("pollScheduled() - Running at ${state.lastScheduledPollDate} (epic: ${state.lastScheduledPoll})", 3, null, "trace") + return poll() +} + + +def updateLastTokenRefresh(Boolean isScheduled=false) { + if (isScheduled) { + state.lastScheduledTokenRefresh = now() + state.lastScheduledTokenRefreshDate = new Date().toString() + } else { + state.lastTokenRefresh = now() + state.lastTokenRefreshDate = new Date().toString() + } } +// Called by scheduled() event handler def refreshAuthTokenScheduled() { - state.lastScheduledTokenRefresh = now() - scheduleHandlers() - refreshAuthToken() + updateLastTokenRefresh(true) + LOG("refreshAuthTokenScheduled() - Running at ${state.lastScheduledTokenRefreshDate} (epic: ${state.lastScheduledTokenRefresh})", 3, null, "trace") + scheduleWatchdog() + return refreshAuthToken() } // Called during initialization to get the inital poll -def pollHandler() { - LOG("pollHandler()", 5) - state.lastPoll = 0 // Initialize the variable and force a poll even if there was one recently +def pollInit() { + LOG("pollInit()", 5) + state.forcePoll = true // Initialize the variable and force a poll even if there was one recently pollChildren(null) // Hit the ecobee API for update on all thermostats } def pollChildren(child = null) { + def results = true + LOG("=====> pollChildren()", 4) - if (apiConnected() == "lost") { - LOG("pollChildren() - Unable to pollChildren() due to API not being connected", 1, child) - return - } + if(apiConnected() == "lost") { + // Possibly a false alarm? Check if we can update the token with one last fleeting try... + if( refreshAuthToken() ) { + // We are back in business! + LOG("pollChildren() - Was able to recover the lost connection. Please ignore any notifications received.", 1, child, "error") + } else { + LOG("pollChildren() - Unable toschedule handlers do to loss of API Connection. Please ensure you are authorized.", 1, child, "error") + return false + } + } + if (settings.thermostats?.size() < 1) { LOG("pollChildren() - Nothing to poll as there are no thermostats currently selected", 1, child, "warn") - return + return true } // Run a watchdog checker here - scheduleHandlers() + scheduleWatchdog() // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children - def timeSinceLastPoll = (state.lastPoll == 0) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) + def timeSinceLastPoll = (state.forcePoll == true) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) LOG("Time since last poll? ${timeSinceLastPoll} -- state.lastPoll == ${state.lastPoll}", 3, child, "info") - if ( (state.lastPoll == 0) || ( timeSinceLastPoll > getMinMinBtwPolls().toDouble() ) ) { + if ( (state.forcePoll == true) || ( timeSinceLastPoll > getMinMinBtwPolls().toDouble() ) ) { // It has been longer than the minimum delay LOG("Calling the Ecobee API to fetch the latest data...", 4, child) pollEcobeeAPI(getChildThermostatDeviceIdsString()) // This will update the values saved in the state which can then be used to send the updates @@ -870,6 +1001,7 @@ def pollChildren(child = null) { oneChild.generateEvent(state.remoteSensorsData[oneChild.device.deviceNetworkId]?.data) } } + return results } private def generateEventLocalParams() { @@ -899,6 +1031,7 @@ private def generateEventLocalParams() { private def pollEcobeeAPI(thermostatIdsString = "") { LOG("=====> pollEcobeeAPI() entered - thermostatIdsString = ${thermostatIdsString}", 2, null, "info") + state.forcePoll = false // TODO: Check on any running EVENTs on thermostat @@ -931,7 +1064,7 @@ private def pollEcobeeAPI(thermostatIdsString = "") { updateThermostatData() result = true - if (state.connected != "full") { + if (apiConnected() != "full") { apiRestored() generateEventLocalParams() // Update the connection status } @@ -993,14 +1126,27 @@ private def pollEcobeeAPI(thermostatIdsString = "") { // poll() will be called on a regular interval using a runEveryX command -void poll() { +def poll() { + LOG("poll() - Running at ${state.lastPollDate} (epic: ${state.lastPoll})", 3, null, "trace") + // Check to see if we are connected to the API or not if (apiConnected() == "lost") { - LOG("poll() - Skipping poll() due to lost API Connection", 1, null, "warn") - } else { - LOG("poll() - Polling children with pollChildren(null)", 4) - pollChildren(null) // Poll ALL the children at the same time for efficiency + LOG("poll() - Attempting to recover poll() due to lost API Connection", 1, null, "warn") + scheduleWatchdog() + if ( refreshAuthToken() ) { + // We were able to recover + apiRestored() + LOG("poll() - we were able to recover the connection!", 1, null, "info") + } else { + LOG("poll() - we were unable to recover the connection.", 2, null, "error") + return false + } } + + state.lastPoll = now() + state.lastPollDate = new Date().toString() + LOG("poll() - Polling children with pollChildren(null)", 4) + return pollChildren(null) // Poll ALL the children at the same time for efficiency } @@ -1272,11 +1418,11 @@ def toQueryString(Map m) { return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") } -private refreshAuthToken() { +private def Boolean refreshAuthToken() { if(!state.refreshToken) { LOG("refreshing auth token", 2) - LOG("refreshAuthToken() - There is no refreshToken stored! Unable to refresh OAuth token.", 1, null, "error") + LOG("refreshAuthToken() - There is no refreshToken stored! Unable to refresh OAuth token.", 1, null, "error") apiLost("refreshAuthToken() - No refreshToken") return false } else if ( !readyForAuthRefresh() ) { @@ -1325,6 +1471,7 @@ private refreshAuthToken() { // Save the expiry time to optimize the refresh LOG("Expires in ${resp?.data?.expires_in} seconds") state.authTokenExpires = (resp?.data?.expires_in * 1000) + now() + LOG("Updated state.authTokenExpires = ${state.authTokenExpires}", 4, null, "trace") LOG("Refresh Token = state =${state.refreshToken} == in: ${resp?.data?.refresh_token}") LOG("OAUTH Token = state ${state.authToken} == in: ${resp?.data?.access_token}") @@ -1717,11 +1864,15 @@ private def getMinMinBtwPolls() { return 1 } +private def getPollingInterval() { + return (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 +} + // Are we connected with the Ecobee service? private String apiConnected() { // values can be "full", "warn", "lost" - if (state.connected == null) state.connected = "lost" + if (state.connected == null) state.connected = "warn" return state.connected?.toString() ?: "lost" } @@ -1733,6 +1884,8 @@ private def apiRestored() { private def apiLost(where = "not specified") { LOG("apiLost() - ${where}: Lost connection with APIs. unscheduling Polling and refreshAuthToken. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 1, null, "error", true, true) + // TODO: Add a state.apiLostDump variable and populate it with useful troubleshooting information to make it easier to grab everything in one place. Then also add this to the Debug Dashboard + // state.apiLostDump = [blah:blah, yup:yup] // provide cleanup steps when API Connection is lost def notificationMessage = "is disconnected from SmartThings/Ecobee, because the access credential changed or was lost. Please go to the Ecobee (Connect) SmartApp and re-enter your account login credentials." @@ -1783,11 +1936,7 @@ private Boolean readyForAuthRefresh() { timeLeft = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 LOG("timeLeft until expiry (in min): ${timeLeft}", 3) - - // Since this runs as part of poll() we can be a bit more conservative on the time before renewing the token - // def pollInterval = settings.pollingInterval ?: 5 - // def ready = timeLeft <= ((pollInterval * 3) + 2) def ready = timeLeft <= 29 LOG("Ready for authRefresh? ${ready}", 4) return ready @@ -1811,7 +1960,7 @@ private debugLevel(level=3) { // Mark the poll data as "dirty" to allow a new API call to take place private def dirtyPollData() { LOG("dirtyPollData() called to reset poll state", 5) - state.lastPoll = 0 + state.forcePoll = true } diff --git a/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy b/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy index 2d32e5584a6..3abcca51f7d 100644 --- a/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy +++ b/smartapps/smartthings/ecobee-routines.src/ecobee-routines.groovy @@ -15,7 +15,7 @@ * */ def getVersionNum() { return "0.1.0" } -private def getVersionLabel() { return "ecobee Routines Version ${getVersionNum()}-RC2" } +private def getVersionLabel() { return "ecobee Routines Version ${getVersionNum()}-RC5" } @@ -23,7 +23,7 @@ definition( name: "ecobee Routines", namespace: "smartthings", author: "Sean Kendall Schneyer (smartthings at linuxbox dot org)", - description: "Rule", + description: "Support for changing ecobee Programs based on SmartThings Mode changes", category: "Convenience", parent: "smartthings:Ecobee (Connect)", iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", From 39c3feee225b1b0f913d9f6e8b56081fda6344e8 Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Fri, 5 Feb 2016 02:47:51 -0600 Subject: [PATCH 26/27] Updates to the watchdog handling. Hopefully this version is stable. --- .../ecobee-connect.src/ecobee-connect.groovy | 234 +++++++++++++----- 1 file changed, 168 insertions(+), 66 deletions(-) diff --git a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy index 3252d1993ef..9c7a4a51641 100644 --- a/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy +++ b/smartapps/smartthings/ecobee-connect.src/ecobee-connect.groovy @@ -25,7 +25,7 @@ * */ def getVersionNum() { return "0.9.0" } -private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC5" } +private def getVersionLabel() { return "ecobee (Connect) Version ${getVersionNum()}-RC6" } private def getHelperSmartApps() { return [ [name: "ecobeeRoutinesChild", appName: "ecobee Routines", @@ -69,14 +69,21 @@ mappings { // Begin Preference Pages def mainPage() { - def deviceHandlersInstalled = testForDeviceHandlers() - def readyToInstall = deviceHandlersInstalled + def deviceHandlersInstalled + def readyToInstall + + // Only create the dummy devices if we aren't initialized yet + if (!state.initialized) { + deviceHandlersInstalled = testForDeviceHandlers() + readyToInstall = deviceHandlersInstalled + } + if (state.initialized) { readyToInstall = true } dynamicPage(name: "mainPage", title: "Welcome to ecobee (Connect)", install: readyToInstall, uninstall: false, submitOnChange: true) { def ecoAuthDesc = (state.authToken != null) ? "[Connected]\n" :"[Not Connected]\n" // If not device Handlers we cannot proceed - if(!deviceHandlersInstalled) { + if(!state.initialized && !deviceHandlersInstalled) { section() { paragraph "ERROR!\n\nYou MUST add the ${getChildThermostatName()} and ${getChildSensorName()} Device Handlers to the IDE BEFORE running the setup." } @@ -90,7 +97,14 @@ def mainPage() { } } - if(state.authToken != null && deviceHandlersInstalled) { + if(state.authToken != null && state.initialized != true) { + section() { + paragraph "Please click 'Done' to save your credentials. Then re-open the SmartApp to continue the setup." + } + } + + // Need to save the initial login to setup the device without timeouts + if(state.authToken != null && state.initialized) { if (settings.thermostats?.size() > 0 && state.initialized) { section("Helper SmartApps") { href ("helperSmartAppsPage", title: "Helper SmartApps", description: "Tap to manage Helper SmartApps") @@ -272,11 +286,22 @@ def debugDashboardPage() { section("Settings Information") { paragraph "debugLevel: ${settings.debugLevel} (default=3 if null)" paragraph "holdType: ${settings.holdType} (default='Until I Change' if null)" - paragraph "pollingInterval: ${settings.pollingInterval} {default=5 if null)" - paragraph "showThermsAsSensor: ${settings.showThermsAsSensor} {default=false if null)" + paragraph "pollingInterval: ${settings.pollingInterval} (default=5 if null)" + paragraph "showThermsAsSensor: ${settings.showThermsAsSensor} (default=false if null)" paragraph "smartAuto: ${settings.smartAuto} (default=false if null)" paragraph "Selected Thermostats: ${settings.thermostats}" } + section("Dump of Debug Variables") { + def debugParamList = getDebugDump() + LOG("debugParamList: ${debugParamList}", 4, null, "debug") + //if ( debugParamList?.size() > 0 ) { + if ( debugParamList != null ) { + debugParamList.each { key, value -> + LOG("Adding paragraph: key:${key} value:${value}", 5, null, "trace") + paragraph "${key}: ${value}" + } + } + } section("Commands") { href(name: "pollChildrenPage", title: "", required: false, page: "pollChildrenPage", description: "Tap to execute command: pollChildren()") href ("removePage", description: "Tap to remove ecobee (Connect) ", title: "") @@ -302,7 +327,8 @@ def helperSmartAppsPage() { LOG("helperSmartAppsPage() entered", 5) LOG("SmartApps available are ${getHelperSmartApps()}", 5, null, "info") - //getHelperSmartApps() { + + //getHelperSmartApps() { dynamicPage(name: "helperSmartAppsPage", title: "Helper Smart Apps", install: true, uninstall: false, submitOnChange: true) { getHelperSmartApps().each { oneApp -> LOG("Processing the app: ${oneApp}", 4, null, "trace") @@ -524,7 +550,7 @@ def getEcobeeThermostats() { LOG("Storing the failed action to try later") state.action = "getEcobeeThermostats" LOG("Refreshing your auth_token!", 1) - refreshAuthToken() + refreshAuthToken(true) } else { LOG("Other error. Status: ${resp.status} Response data: ${resp.data} ", 1) } @@ -532,7 +558,7 @@ def getEcobeeThermostats() { } } catch(Exception e) { LOG("___exception getEcobeeThermostats(): ${e}", 1, null, "error") - refreshAuthToken() + refreshAuthToken(true) } state.thermostatsWithNames = stats LOG("state.thermostatsWithNames == ${state.thermostatsWithNames}", 4) @@ -632,11 +658,18 @@ def initialize() { LOG("updated() - Exception encountered trying to unschedule(). Exception: ${e}", 2, null, "error") } - // Initialize several variables - state.lastScheduledPoll = now() - state.lastScheduledTokenRefresh = now() - state.lastScheduledWatchdog = now() - state.lastPoll = now() + def nowTime = now() + def nowDate = getTimestamp() + + // Initialize several variables + state.lastScheduledPoll = nowTime + state.lastScheduledPollDate = nowDate + state.lastScheduledTokenRefresh = nowTime + state.lastScheduledTokenRefreshDate = nowDate + state.lastScheduledWatchdog = nowTime + state.lastScheduledWatchdogDate = nowDate + state.lastPoll = nowTime + state.lastPollDate = nowDate // Setup initial polling and determine polling intervals state.pollingInterval = getPollingInterval() @@ -677,8 +710,12 @@ def initialize() { //send activity feeds to tell that device is connected def notificationMessage = aOK ? "is connected to SmartThings" : "had an error during setup of devices" sendActivityFeeds(notificationMessage) - state.timeSendPush = null - state.initialized = true + state.timeSendPush = null + if (!state.initialized) { + state.initialized = true + state.initializedEpic = nowTime + state.initializedDate = nowDate + } return aOK } @@ -752,16 +789,27 @@ private def deleteUnusedChildren() { def childrenToDelete = allMyChildren.findAll { !childrenToKeep.contains(it.deviceNetworkId) } LOG("Ready to delete these devices. ${childrenToDelete}", 4, null, "trace") - childrenToDelete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) + if (childrenToDelete.size() > 0) childrenToDelete?.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) } } -def scheduleWatchdog(evt=null) { +def scheduleWatchdog(evt=null, local=false) { def results = true + LOG("scheduleWhatdog() called with: evt (${evt}) & local (${local})", 4, null, "trace") + // Only update the Scheduled timestamp if it is not a local action or from a subscription + if ( (evt == null) && (local==false) ) { + state.lastScheduledWatchdog = now() + state.lastScheduledWatchdogDate = getTimestamp() + } + + state.lastWatchdog = now() + state.lastWatchdogDate = getTimestamp() + + LOG("After watchdog tagging") if(apiConnected() == "lost") { // Possibly a false alarm? Check if we can update the token with one last fleeting try... - if( refreshAuthToken() ) { + if( refreshAuthToken(true) ) { // We are back in business! LOG("scheduleWatchdog() - Was able to recover the lost connection. Please ignore any notifications received.", 1, null, "error") } else { @@ -792,13 +840,15 @@ private def Boolean isDaemonAlive(daemon="all") { daemon = daemon.toLowerCase() def result = true - def timeSinceLastScheduledPoll = (state.lastScheduledPoll == 0 || state.lastScheduledPoll == null) ? 0 : ((now() - state.lastScheduledPoll?.toDouble()) / 1000 / 60) - def timeSinceLastScheduledRefresh = (state.lastScheduledTokenRefresh == 0 || state.lastScheduledTokenRefresh == null) ? 0 : ((now() - state.lastScheduledTokenRefresh?.toDouble()) / 1000 / 60) - def timeSinceLastScheduledWatchdog = (state.lastScheduledWatchdog == 0 || state.lastScheduledWatchdog == null) ? 0 : ((now() - state.lastScheduledWatchdog?.toDouble()) / 1000 / 60) - def timeBeforeExpiry = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 + def timeSinceLastScheduledPoll = (state.lastScheduledPoll == 0 || state.lastScheduledPoll == null) ? 0 : ((now() - state.lastScheduledPoll) / 1000 / 60) // TODO: Removed toDouble() will this impact? + def timeSinceLastScheduledRefresh = (state.lastScheduledTokenRefresh == 0 || state.lastScheduledTokenRefresh == null) ? 0 : ((now() - state.lastScheduledTokenRefresh) / 1000 / 60) + def timeSinceLastScheduledWatchdog = (state.lastScheduledWatchdog == 0 || state.lastScheduledWatchdog == null) ? 0 : ((now() - state.lastScheduledWatchdog) / 1000 / 60) + def timeBeforeExpiry = state.authTokenExpires ? ((state.authTokenExpires - now()) / 1000 / 60) : 0 + LOG("isDaemonAlive() - now() == ${now()} for daemon (${daemon})", 5, null, "trace") LOG("isDaemonAlive() - Time since last poll? ${timeSinceLastScheduledPoll} -- state.lastScheduledPoll == ${state.lastScheduledPoll}", 4, null, "info") LOG("isDaemonAlive() - Time since last token refresh? ${timeSinceLastScheduledRefresh} -- state.lastScheduledTokenRefresh == ${state.lastScheduledTokenRefresh}", 4, null, "info") + LOG("isDaemonAlive() - Time since watchdog activation? ${timeSinceLastScheduledWatchdog} -- state.lastScheduledWatchdog == ${state.lastScheduledWatchdog}", 4, null, "info") LOG("isDaemonAlive() - Time left (timeBeforeExpiry) until expiry (in min): ${timeBeforeExpiry}", 4, null, "info") if (daemon == "poll" || daemon == "all") { @@ -816,8 +866,9 @@ private def Boolean isDaemonAlive(daemon="all") { if (daemon == "watchdog" || daemon == "all") { LOG("isDaemonAlive() - Checking daemon (${daemon}) in 'watchdog'", 4, null, "trace") - def maxInterval = state.watchdogInterval + 3 - // if ( (timeSinceLastScheduledWatchdog == 0) || (timeSinceLastScheduledWatchdog >= (maxInterval)) || (state.initialized != true) ) { result = false } + def maxInterval = state.watchdogInterval + 6 + //if ( (timeSinceLastScheduledWatchdog == 0) || (timeSinceLastScheduledWatchdog >= (maxInterval)) || (state.initialized != true) ) { result = false } + LOG("isDaemonAlive(watchdog) - timeSinceLastScheduledWatchdog=(${timeSinceLastScheduledWatchdog}) Timestamps: (${state.lastScheduledWatchdogDate}) (epic: ${state.lastScheduledWatchdog}) now-(${now()})", 4, null, "trace") if ( timeSinceLastScheduledWatchdog >= maxInterval ) { result = false } } @@ -826,6 +877,7 @@ private def Boolean isDaemonAlive(daemon="all") { LOG("isDaemonAlive() - Unknown daemon: ${daemon} received. Do not know how to check this daemon.", 1, null, "error") result = false } + LOG("isDaemonAlive() - result is ${result}", 4, null, "trace") return result } @@ -840,12 +892,13 @@ private def Boolean spawnDaemon(daemon="all") { LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'poll'", 4, null, "trace") // Reschedule the daemon try { - result = result && unschedule("pollScheduled") + // result = result && unschedule("pollScheduled") if ( canSchedule() ) { "runEvery${state.pollingInterval}Minutes"("pollScheduled") - // if ( canSchedule() ) { runIn(30, "pollScheduled") } // Don't count this against the results - pollScheduled() - result = result && true + // if ( canSchedule() ) { runIn(30, "pollScheduled") } // This will wipe out the existing scheduler! + // Web Services taking too long. Go ahead and only schedule here for now + + result = result && pollScheduled() } else { LOG("canSchedule() is NOT allowed! Unable to schedule daemon!", 1, null, "error") result = false @@ -860,12 +913,13 @@ private def Boolean spawnDaemon(daemon="all") { LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'auth'", 4, null, "trace") // Reschedule the daemon try { - result = result && unschedule("refreshAuthTokenScheduled") + // result = result && unschedule("refreshAuthTokenScheduled") if ( canSchedule() ) { runEvery15Minutes("refreshAuthTokenScheduled") // if ( canSchedule() ) { runIn(30, "refreshAuthTokenScheduled") } // Don't count this against the results - refreshAuthTokenScheduled() - result = result && true + // Web Services taking too long. Go ahead and only schedule here for now + + result = result && refreshAuthTokenScheduled() } else { LOG("canSchedule() is NOT allowed! Unable to schedule daemon!", 1, null, "error") result = false @@ -880,7 +934,7 @@ private def Boolean spawnDaemon(daemon="all") { LOG("spawnDaemon() - Performing seance for daemon (${daemon}) in 'watchdog'", 4, null, "trace") // Reschedule the daemon try { - result = result && unschedule("scheduleWatchdog") + // result = result && unschedule("scheduleWatchdog") if ( canSchedule() ) { runEvery15Minutes("scheduleWatchdog") result = result && true @@ -906,10 +960,10 @@ private def Boolean spawnDaemon(daemon="all") { def updateLastPoll(Boolean isScheduled=false) { if (isScheduled) { state.lastScheduledPoll = now() - state.lastScheduledPollDate = new Date().toString() + state.lastScheduledPollDate = getTimestamp() } else { state.lastPoll = now() - state.lastPollDate = new Date().toString() + state.lastPollDate = getTimestamp() } } @@ -921,22 +975,24 @@ def pollScheduled() { } -def updateLastTokenRefresh(Boolean isScheduled=false) { - if (isScheduled) { +def updateLastTokenRefresh(isScheduled=false) { + if (isScheduled) { state.lastScheduledTokenRefresh = now() - state.lastScheduledTokenRefreshDate = new Date().toString() + state.lastScheduledTokenRefreshDate = getTimestamp() + LOG("updateLastTokenRefresh(true) - Updated timestamps: state.lastScheduledTokenRefreshDate=(${state.lastScheduledTokenRefreshDate}) state.lastScheduledTokenRefresh=${state.lastScheduledTokenRefresh} ", 5, null, "trace") } else { state.lastTokenRefresh = now() - state.lastTokenRefreshDate = new Date().toString() + state.lastTokenRefreshDate = getTimestamp() } } // Called by scheduled() event handler def refreshAuthTokenScheduled() { updateLastTokenRefresh(true) - LOG("refreshAuthTokenScheduled() - Running at ${state.lastScheduledTokenRefreshDate} (epic: ${state.lastScheduledTokenRefresh})", 3, null, "trace") - scheduleWatchdog() - return refreshAuthToken() + LOG("refreshAuthTokenScheduled() - Running at ${state.lastScheduledTokenRefreshDate} (epic: ${state.lastScheduledTokenRefresh})", 3, null, "trace") + + def result = refreshAuthToken() + return result } @@ -964,14 +1020,14 @@ def pollChildren(child = null) { } } + // Run a watchdog checker here + scheduleWatchdog(null, true) if (settings.thermostats?.size() < 1) { LOG("pollChildren() - Nothing to poll as there are no thermostats currently selected", 1, child, "warn") return true } - // Run a watchdog checker here - scheduleWatchdog() // Check to see if it is time to do an full poll to the Ecobee servers. If so, execute the API call and update ALL children def timeSinceLastPoll = (state.forcePoll == true) ? 0 : ((now() - state.lastPoll?.toDouble()) / 1000 / 60) @@ -1092,7 +1148,7 @@ private def pollEcobeeAPI(thermostatIdsString = "") { def reAttemptPeriod = 45 // in sec if ( (e.statusCode == 500 && e.getResponse()?.data.status.code == 14) || (e.statusCode == 401 && e.getResponse()?.data.status.code == 14) ) { // Not possible to recover from status.code == 14 - if ( refreshAuthToken() ) { LOG("We have recovered the token a from the code 14.", 2, null, "warn") } + // if ( refreshAuthToken() ) { LOG("We have recovered the token a from the code 14.", 2, null, "warn") } LOG("In HttpResponseException: Received data.stat.code of 14", 1, null, "error") apiLost("pollEcobeeAPI() - In HttpResponseException: Received data.stat.code of 14") } else if (e.statusCode != 401) { //this issue might comes from exceed 20sec app execution, connectivity issue etc. @@ -1132,7 +1188,7 @@ def poll() { // Check to see if we are connected to the API or not if (apiConnected() == "lost") { LOG("poll() - Attempting to recover poll() due to lost API Connection", 1, null, "warn") - scheduleWatchdog() + scheduleWatchdog(null, true) if ( refreshAuthToken() ) { // We were able to recover apiRestored() @@ -1144,7 +1200,7 @@ def poll() { } state.lastPoll = now() - state.lastPollDate = new Date().toString() + state.lastPollDate = getTimestamp() LOG("poll() - Polling children with pollChildren(null)", 4) return pollChildren(null) // Poll ALL the children at the same time for efficiency } @@ -1418,14 +1474,16 @@ def toQueryString(Map m) { return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") } -private def Boolean refreshAuthToken() { - +private def Boolean refreshAuthToken(force=false) { + // Update the timestamp + updateLastTokenRefresh() + if(!state.refreshToken) { LOG("refreshing auth token", 2) LOG("refreshAuthToken() - There is no refreshToken stored! Unable to refresh OAuth token.", 1, null, "error") apiLost("refreshAuthToken() - No refreshToken") return false - } else if ( !readyForAuthRefresh() ) { + } else if ( (force != true) && !readyForAuthRefresh() ) { // Not ready to refresh yet LOG("refreshAuthToken() - Not time to refresh yet, there is still time left before expiration.") return true @@ -1506,31 +1564,44 @@ private def Boolean refreshAuthToken() { if ( (e.statusCode == 500 && e.getResponse()?.data.status.code == 14) || (e.statusCode == 401 && e.getResponse()?.data.status.code == 14) ) { LOG("refreshAuthToken() - Received data.status.code = 14", 1, null, "error") apiLost("refreshAuthToken() - Received data.status.code = 14" ) + return false } else if (e.statusCode != 401) { //this issue might comes from exceed 20sec app execution, connectivity issue etc. LOG("refreshAuthToken() - e.statusCode: ${e.statusCode}", 1, null, "warn") state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthTokens() } + if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthTokens(true) } + return false } else if (e.statusCode == 401) { // status.code other than 14 state.reAttempt = state.reAttempt + 1 LOG("reAttempt refreshAuthToken to try = ${state.reAttempt}", 1, null, "warn") if (state.reAttempt <= 3) { state.connected = "warn" generateEventLocalParams() // Update the connected state at the thermostat devices - if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthToken() } + if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthToken(true) } + return false } else { // More than 3 attempts, time to give up and notify the end user LOG("More than 3 attempts to refresh tokens. Giving up", 1, null, "error") debugEvent("More than 3 attempts to refresh tokens. Giving up") apiLost("refreshAuthToken() - More than 3 attempts to refresh token. Have to give up") + return false } } } catch (java.util.concurrent.TimeoutException e) { LOG("refreshAuthToken(), TimeoutException: ${e}.", 1, null, "error") // Likely bad luck and network overload, move on and let it try again - if(canSchedule()) { runIn(300, "refreshAuthToken") } else { refreshAuth() } + state.connected = "warn" + generateEventLocalParams() // Update the connected state at the thermostat devices + def reAttemptPeriod = 300 // in sec + if(canSchedule()) { runIn(reAttemptPeriod, "refreshAuthToken") } else { refreshAuthTokens(true) } + return false + } catch (groovy.lang.StringWriterIOException e) { + LOG("refreshAuthToken(), groovy.lang.StringWriterIOException encountered: ${e}", 1, null, "error") + apiLost("refreshAuthToken(), groovy.lang.StringWriterIOException encountered: ${e}") + return false } catch (Exception e) { LOG("refreshAuthToken(), General Exception: ${e}.", 1, null, "error") + return false } } } @@ -1696,7 +1767,7 @@ private def sendJson(child = null, String jsonBody) { //refresh the auth token if (resp.status == 500 && resp.status.code == 14) { LOG("Refreshing your auth_token!") - if(refreshAuthToken()) { + if(refreshAuthToken(true)) { LOG("Successfully performed a refreshAuthToken() after a Code 14!", 2, child, "warn") } return false // No way to recover from a status.code 14 @@ -1705,7 +1776,7 @@ private def sendJson(child = null, String jsonBody) { state.connected = "warn" generateEventLocalParams() if (j == 2) { // Go ahead and refresh on the second pass through - refreshAuthToken() + refreshAuthToken(true) return false } } @@ -1716,7 +1787,7 @@ private def sendJson(child = null, String jsonBody) { LOG("sendJson() >> HttpResponseException occured. Exception info: ${e} StatusCode: ${e.statusCode} response? data: ${e.getResponse()?.getData()}", 1, child, "error") state.connected = "warn" generateEventLocalParams() - refreshAuthToken() + refreshAuthToken(true) return false } catch(Exception e) { // Might need to further break down @@ -1724,7 +1795,7 @@ private def sendJson(child = null, String jsonBody) { state.connected = "warn" generateEventLocalParams() if (j == 2) { // Go ahead and refresh on the second pass through - refreshAuthToken() + refreshAuthToken(true) return false } } @@ -1753,14 +1824,22 @@ private def getSmartThingsClientId() { } -private def LOG(message, level=3, child=null, logType="debug", event=true, displayEvent=true) { +private def LOG(message, level=3, child=null, logType="debug", event=false, displayEvent=true) { def prefix = "" + def logTypes = ["error", "debug", "info", "trace"] + + if(!logTypes.contains(logType)) { + log.error "LOG() - Received logType ${logType} which is not in the list of allowed types." + if (event && child) { debugEventFromParent(child, "LOG() - Received logType ${logType} which is not in the list of allowed types.") } + logType = "debug" + } + + if ( logType == "error" ) { state.lastLOGerror = message } if ( settings.debugLevel?.toInteger() == 5 ) { prefix = "LOG: " } if ( debugLevel(level) ) { log."${logType}" "${prefix}${message}" - // log.debug message if (event) { debugEvent(message, displayEvent) } - if (event && child) { debugEventFromParent(child, message) } + if (child) { debugEventFromParent(child, message) } } } @@ -1868,6 +1947,10 @@ private def getPollingInterval() { return (settings.pollingInterval?.toInteger() >= 5) ? settings.pollingInterval.toInteger() : 5 } +private def String getTimestamp() { + return new Date().format("yyyy-MM-dd HH:mm:ss z", location.timeZone) +} + // Are we connected with the Ecobee service? private String apiConnected() { @@ -1882,10 +1965,29 @@ private def apiRestored() { unschedule("notifyApiLost") } + +private def getDebugDump() { + def debugParams = [when:"${getTimestamp()}", whenEpic:"${now()}", + lastPollDate:"${state.lastPollDate}", lastScheduledPollDate:"${state.lastScheduledPollDate}", + lastScheduledTokenRefreshDate:"${state.lastScheduledTokenRefreshDate}", lastScheduledWatchdogDate:"${state.lastScheduledWatchdogDate}", + lastTokenRefreshDate:"${state.lastTokenRefreshDate}", initializedEpic:"${state.initializedEpic}", initializedDate:"${state.initializedDate}", + lastLOGerror:"${state.lastLOGerror}" + ] + return debugParams +} + private def apiLost(where = "not specified") { - LOG("apiLost() - ${where}: Lost connection with APIs. unscheduling Polling and refreshAuthToken. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 1, null, "error", true, true) + LOG("apiLost() - ${where}: Lost connection with APIs. unscheduling Polling and refreshAuthToken. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 1, null, "error") // TODO: Add a state.apiLostDump variable and populate it with useful troubleshooting information to make it easier to grab everything in one place. Then also add this to the Debug Dashboard - // state.apiLostDump = [blah:blah, yup:yup] + state.apiLostDump = getDebugDump() + + // Has out token really expired yet? + if ( !readyForAuthRefresh() ) { + LOG("apiLost() - Still time left on expiry of Auth Token. Gonna wait until full expiry to actually declare the api as fully lost. Setting it to warn instead.", 1, null, "error") + state.connected = "warn" + generateEventLocalParams() + return + } // provide cleanup steps when API Connection is lost def notificationMessage = "is disconnected from SmartThings/Ecobee, because the access credential changed or was lost. Please go to the Ecobee (Connect) SmartApp and re-enter your account login credentials." @@ -1895,13 +1997,13 @@ private def apiLost(where = "not specified") { sendPushAndFeeds(notificationMessage) generateEventLocalParams() - LOG("Unscheduling Polling and refreshAuthToken. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 0, null, "error", true, true) + LOG("Unscheduling Polling and refreshAuthToken. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 0, null, "error") // Notify each child that we lost so it gets logged if ( debugLevel(3) ) { def d = getChildDevices() d?.each { oneChild -> - LOG("apiLost() - notifying each child: ${oneChild} of loss", 0, child, "error", true, true) + LOG("apiLost() - notifying each child: ${oneChild} of loss", 0, child, "error") } } @@ -1915,7 +2017,7 @@ def notifyApiLost() { if ( state.connected == "lost" ) { generateEventLocalParams() sendPushAndFeeds(notificationMessage) - LOG("notifyApiLost() - API Connection Previously Lost. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 0, null, "error", true, true) + LOG("notifyApiLost() - API Connection Previously Lost. User MUST reintialize the connection with Ecobee by running the SmartApp and logging in again", 0, null, "error") } else { // Must have restored connection unschedule("notifyApiLost") From 1605eb8963a398706a7cffce041e4f50f63c7879 Mon Sep 17 00:00:00 2001 From: Sean Schneyer Date: Fri, 5 Feb 2016 02:48:06 -0600 Subject: [PATCH 27/27] Updates to the watchdog handling. Hopefully this version is stable. --- .../ecobee-thermostat.groovy | 350 ++++++------------ 1 file changed, 122 insertions(+), 228 deletions(-) diff --git a/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy b/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy index 09267c95267..747a9ffc193 100644 --- a/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy +++ b/devicetypes/smartthings/ecobee-thermostat.src/ecobee-thermostat.groovy @@ -25,7 +25,7 @@ */ def getVersionNum() { return "0.9.0" } -private def getVersionLabel() { return "Ecobee Thermostat Version 0.9.0-RC2" } +private def getVersionLabel() { return "Ecobee Thermostat Version 0.9.0-RC6" } metadata { @@ -64,203 +64,57 @@ metadata { attribute "thermostatStatus","string" attribute "apiConnected","string" attribute "averagedTemperature","number" - attribute "currentProgram","string" - attribute "currentProgramId","string" - - attribute "weatherSymbol", "string" - + attribute "currentProgramId","string" + attribute "weatherSymbol", "string" attribute "debugEventFromParent","string" - - - /* - attribute "thermostatName", "string" - attribute "temperatureDisplay", "string" - attribute "coolingSetpointDisplay", "string" - attribute "heatingSetpointDisplay", "string" - attribute "heatLevelUp", "string" - attribute "heatLevelDown", "string" - attribute "coolLevelUp", "string" - attribute "coolLevelDown", "string" - attribute "verboseTrace", "string" - attribute "fanMinOnTime", "string" - attribute "humidifierMode", "string" - attribute "dehumidifierMode", "string" - attribute "humidifierLevel", "string" - attribute "dehumidifierLevel", "string" - attribute "condensationAvoid", "string" - attribute "groups", "string" - attribute "equipmentStatus", "string" - attribute "alerts", "string" - attribute "programScheduleName", "string" - attribute "programFanMode", "string" - attribute "programType", "string" - attribute "programCoolTemp", "string" - attribute "programHeatTemp", "string" - attribute "programCoolTempDisplay", "string" - attribute "programHeatTempDisplay", "string" - attribute "programEndTimeMsg", "string" - - attribute "weatherDateTime", "string" - attribute "weatherSymbol", "string" - attribute "weatherStation", "string" - attribute "weatherCondition", "string" - attribute "weatherTemperatureDisplay", "string" - attribute "weatherPressure", "string" - attribute "weatherRelativeHumidity", "string" - attribute "weatherWindSpeed", "string" - attribute "weatherWindDirection", "string" - attribute "weatherPop", "string" - attribute "weatherTempHigh", "string" - attribute "weatherTempLow", "string" - attribute "weatherTempHighDisplay", "string" - attribute "weatherTempLowDisplay", "string" - - attribute "plugName", "string" - attribute "plugState", "string" - attribute "plugSettings", "string" - attribute "hasHumidifier", "string" - attribute "hasDehumidifier", "string" - attribute "hasErv", "string" - attribute "hasHrv", "string" - attribute "ventilatorMinOnTime", "string" - attribute "ventilatorMode", "string" - attribute "programNameForUI", "string" - // Passed in via the SmartApp - // attribute "thermostatOperatingState", "string" - attribute "climateList", "string" - attribute "modelNumber", "string" - attribute "followMeComfort", "string" - attribute "autoAway", "string" - attribute "intervalRevision", "string" - attribute "runtimeRevision", "string" - attribute "thermostatRevision", "string" - attribute "heatStages", "string" - attribute "coolStages", "string" - attribute "climateName", "string" - attribute "setClimate", "string" - - // Report Runtime events - attribute "auxHeat1RuntimeInPeriod", "string" - attribute "auxHeat2RuntimeInPeriod", "string" - attribute "auxHeat3RuntimeInPeriod", "string" - attribute "compCool1RuntimeInPeriod", "string" - attribute "compCool2RuntimeInPeriod", "string" - attribute "dehumidifierRuntimeInPeriod", "string" - attribute "humidifierRuntimeInPeriod", "string" - attribute "ventilatorRuntimeInPeriod", "string" - attribute "fanRuntimeInPeriod", "string" - - attribute "auxHeat1RuntimeDaily", "string" - attribute "auxHeat2RuntimeDaily", "string" - attribute "auxHeat3RuntimeDaily", "string" - attribute "compCool1RuntimeDaily", "string" - attribute "compCool2RuntimeDaily", "string" - attribute "dehumidifierRuntimeDaily", "string" - attribute "humidifierRuntimeDaily", "string" - attribute "ventilatorRuntimeDaily", "string" - attribute "fanRuntimeDaily", "string" - attribute "reportData", "string" - - // Report Sensor Data & Stats - attribute "reportSensorMetadata", "string" - attribute "reportSensorData", "string" - attribute "reportSensorAvgInPeriod", "string" - attribute "reportSensorMinInPeriod", "string" - attribute "reportSensorMaxInPeriod", "string" - attribute "reportSensorTotalInPeriod", "string" - - // Remote Sensor Data & Stats - attribute "remoteSensorData", "string" - attribute "remoteSensorTmpData", "string" - attribute "remoteSensorHumData", "string" - attribute "remoteSensorOccData", "string" - attribute "remoteSensorAvgTemp", "string" - attribute "remoteSensorAvgHumidity", "string" - attribute "remoteSensorMinTemp", "string" - attribute "remoteSensorMinHumidity", "string" - attribute "remoteSensorMaxTemp", "string" - attribute "remoteSensorMaxHumidity", "string" - */ + } + simulator { } + tiles(scale: 2) { + + multiAttributeTile(name:"tempSummaryBlack", type:"thermostat", width:6, height:4) { + tileAttribute("device.temperature", key: "PRIMARY_CONTROL") { + attributeState("default", label:'${currentValue}', unit:"dF") + } - /* - command "setFanMinOnTime" - command "setCondensationAvoid" - command "createVacation" - command "deleteVacation" - command "getEcobeePinAndAuth" - command "getThermostatInfo" - command "getThermostatSummary" - command "iterateCreateVacation" - command "iterateDeleteVacation" - command "iterateResumeProgram" - command "iterateSetHold" - command "resumeProgram" - command "resumeThisTstat" - command "setAuthTokens" - command "setHold" - command "setHoldExtraParams" - command "heatLevelUp" - command "heatLevelDown" - command "coolLevelUp" - command "coolLevelDown" - command "auxHeatOnly" - command "setThermostatFanMode" - command "dehumidifierOff" - command "dehumidifierOn" - command "humidifierOff" - command "humidifierAuto" - command "humidifierManual" - command "setHumidifierLevel" - command "setDehumidifierLevel" - command "updateGroup" - command "getGroups" - command "iterateUpdateGroup" - command "createGroup" - command "deleteGroup" - command "updateClimate" - command "iterateUpdateClimate" - command "createClimate" - command "deleteClimate" - command "setClimate" - command "iterateSetClimate" - command "controlPlug" - command "ventilatorOn" - command "ventilatorAuto" - command "ventilatorOff" - command "ventilatorAuto" - command "setVentilatorMinOnTime" - command "awake" - command "away" - command "present" - command "home" - command "asleep" - command "quickSave" - command "setThisTstatClimate" - command "setThermostatSettings" - command "iterateSetThermostatSettings" - command "getEquipmentStatus" - command "refreshChildTokens" - command "autoAway" - command "followMeComfort" - command "getReportData" - command "generateReportRuntimeEvents" - command "generateReportSensorStatsEvents" - command "getThermostatRevision" - command "generateRemoteSensorEvents" - */ - + tileAttribute("device.temperature", key: "VALUE_CONTROL") { + attributeState("default", action: "setTemperature") + } + tileAttribute("device.humidity", key: "SECONDARY_CONTROL") { + attributeState("default", label:'${currentValue}%', unit:"%") + } - } + + // Use this one if you want ALL BLACK + tileAttribute("device.thermostatOperatingState", key: "OPERATING_STATE") { + // TODO: Change this to a preference so the use can select green over grey from within the app + // Uncomment the below if you prefer green for idle + attributeState("idle", backgroundColor:"#000000") + // Or uncomment this one if you prefer grey for idle + // attributeState("idle", backgroundColor:"#C0C0C0") + attributeState("heating", backgroundColor:"#000000") + attributeState("cooling", backgroundColor:"#000000") + } - simulator { } - tiles(scale: 2) { + tileAttribute("device.thermostatMode", key: "THERMOSTAT_MODE") { + attributeState("off", label:'${name}') + attributeState("heat", label:'${name}') + attributeState("cool", label:'${name}') + attributeState("auto", label:'${name}') + } + tileAttribute("device.heatingSetpoint", key: "HEATING_SETPOINT") { + attributeState("default", label:'${currentValue}', unit:"F") + } + tileAttribute("device.coolingSetpoint", key: "COOLING_SETPOINT") { + attributeState("default", label:'${currentValue}', unit:"F") + } - + } // End multiAttributeTile + multiAttributeTile(name:"tempSummary", type:"thermostat", width:6, height:4) { tileAttribute("device.temperature", key: "PRIMARY_CONTROL") { attributeState("default", label:'${currentValue}', unit:"dF") @@ -273,6 +127,7 @@ metadata { attributeState("default", label:'${currentValue}%', unit:"%") } + // Use this one if you want colors for the multiAttributeTile tileAttribute("device.thermostatOperatingState", key: "OPERATING_STATE") { // TODO: Change this to a preference so the use can select green over grey from within the app // Uncomment the below if you prefer green for idle @@ -282,7 +137,6 @@ metadata { attributeState("heating", backgroundColor:"#ffa81e") attributeState("cooling", backgroundColor:"#269bd2") } - tileAttribute("device.thermostatMode", key: "THERMOSTAT_MODE") { attributeState("off", label:'${name}') attributeState("heat", label:'${name}') @@ -393,10 +247,16 @@ metadata { standardTile("refresh", "device.thermostatMode", width: 2, height: 2,inactiveLabel: false, decoration: "flat") { state "default", action:"refresh.refresh", icon:"st.secondary.refresh" } + standardTile("resumeProgram", "device.resumeProgram", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { - state "resume", action:"resumeProgram", nextState: "updating", label:'Resume Schedule', icon:"st.Office.office7" + state "resume", action:"resumeProgram", nextState: "updating", label:'Resume Program', icon:"st.Office.office7" state "updating", label:"Working", icon: "st.samsung.da.oven_ic_send" } + + standardTile("resumeProgramBlack", "device.resumeProgram", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { + state "resume", action:"resumeProgram", nextState: "updating", label:'Resume Program', icon:"https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_sched_square.png" + state "updating", label:"Working...", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_blank_square.png" + } valueTile("currentProgram", "device.currentProgramName", height: 2, width: 4, inactiveLabel: false, decoration: "flat") { state "default", label:'Comfort Setting:\n${currentValue}' } @@ -416,6 +276,15 @@ metadata { state "updating", label:"Working...", icon: "st.samsung.da.oven_ic_send" } + standardTile("operatingStateBlack", "device.thermostatOperatingState", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { + state "idle", label: "Idle", backgroundColor:"#c0c0c0", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_idle_square.png" + state "fan only", backgroundColor:"#87ceeb", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_fanonly_square.png" + state "heating", backgroundColor:"#ffa81e", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_heat_square.png" + state "cooling", backgroundColor:"#269bd2", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_cool_square.png" + // Issue reported that the label overlaps. Need to remove the icon + state "default", label: '${currentValue}', backgroundColor:"c0c0c0", icon: "st.nest.empty" + } + standardTile("operatingState", "device.thermostatOperatingState", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { //state "idle", label: "Idle", backgroundColor:"#44b621", icon: "st.nest.empty" state "idle", label: "Idle", backgroundColor:"#c0c0c0", icon: "st.nest.empty" @@ -431,12 +300,19 @@ metadata { } + standardTile("motionBlack", "device.motion", width: 2, height: 2) { + state("active", label:'motion', backgroundColor:"#53a7c0", icon:"https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_sensor_motion.png") + state("inactive", label:'no motion', backgroundColor:"#ffffff", icon:"https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_sensor_nomotion.png") + } + + standardTile("motion", "device.motion", width: 2, height: 2) { state("active", label:'motion', icon:"st.motion.motion.active", backgroundColor:"#53a7c0") state("inactive", label:'no motion', icon:"st.motion.motion.inactive", backgroundColor:"#ffffff") } + // Additional tiles based on Yves Racine's device type // Weather Tiles and other Forecast related tiles standardTile("weatherIcon", "device.weatherSymbol", inactiveLabel: false, width: 2, height: 2, @@ -445,7 +321,7 @@ metadata { state "0", label: 'Sunny', icon: "st.Weather.weather14" state "1", label: 'Few Clouds', icon: "st.Weather.weather11" state "2", label: 'Partly Cloudy', icon: "st.Weather.weather11" - state "3", label: 'Mostly Cloudy', icon: "st.Weather.weather13" + state "3", label: 'Mostly Cloudy', icon: "st.Weather.weather11" state "4", label: 'Overcast', icon: "st.Weather.weather13" state "5", label: 'Drizzle', icon: "st.Weather.weather10" state "6", label: 'Rain', icon: "st.Weather.weather10" @@ -465,53 +341,65 @@ metadata { state "20", label: 'Smoke', icon: "st.Weather.weather13" state "21", label: 'Dust', icon: "st.Weather.weather13" } - valueTile("weatherDateTime", "device.weatherDateTime", inactiveLabel: false, - width: 3, height: 2, decoration: "flat") { - state "default", label: '${currentValue}' + + standardTile("weatherIconBlack", "device.weatherSymbol", inactiveLabel: false, width: 2, height: 2, + decoration: "flat") { + state "-2", label: 'updating...', icon: "st.unknown.unknown.unknown" + state "0", label: '', icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/e3_weather_0.png" + state "1", label: 'Few Clouds', icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/light/e3_weather_1.png" + state "2", label: 'Partly Cloudy', icon: "st.Weather.weather11" + state "3", label: 'Mostly Cloudy', icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/e3_weather_3.png" + state "4", label: 'Overcast', icon: "st.Weather.weather13" + state "5", label: 'Drizzle', icon: "st.Weather.weather10" + state "6", label: 'Rain', icon: "st.Weather.weather10" + state "7", label: 'Freezing Rain', icon: "st.Weather.weather6" + state "8", label: 'Showers', icon: "st.Weather.weather10" + state "9", label: 'Hail', icon: "st.custom.wuk.sleet" + state "10", label: 'Snow', icon: "st.Weather.weather6" + state "11", label: 'Flurries', icon: "st.Weather.weather6" + state "12", label: 'Sleet', icon: "st.Weather.weather6" + state "13", label: 'Blizzard', icon: "st.Weather.weather7" + state "14", label: 'Pellets', icon: "st.custom.wuk.sleet" + state "15", label: 'Thunder Storms',icon: "st.custom.wuk.tstorms" + state "16", label: 'Windy', icon: "st.Transportation.transportation5" + state "17", label: 'Tornado', icon: "st.Weather.weather1" + state "18", label: 'Fog', icon: "st.Weather.weather13" + state "19", label: 'Hazy', icon: "st.Weather.weather13" + state "20", label: 'Smoke', icon: "st.Weather.weather13" + state "21", label: 'Dust', icon: "st.Weather.weather13" } - valueTile("weatherConditions", "device.weatherCondition", - inactiveLabel: false, width: 3, height: 2, decoration: "flat") { - state "default", label: 'Forecast\n${currentValue}' + + standardTile("weatherTemperatureBlack", "device.weatherTemperature", width: 2, height: 2, decoration: "flat") { + state "default", label: 'Outside: ${currentValue}°', unit: "dF", icon: "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/ecobee_thermometer_square.png" } - standardTile("weatherTemperature", "device.weatherTemperature", inactiveLabel: - false, width: 2, height: 2, decoration: "flat") { + + standardTile("weatherTemperature", "device.weatherTemperature",width: 2, height: 2, decoration: "flat") { state "default", label: 'Outside: ${currentValue}°', unit: "dF", icon: "st.Weather.weather2" } - valueTile("weatherRelativeHumidity", "device.weatherRelativeHumidity", - inactiveLabel: false, width: 2, height: 2,decoration: "flat") { - state "default", label: 'Out Hum\n${currentValue}%', unit: "humidity" - } - valueTile("weatherTempHigh", "device.weatherTempHigh", inactiveLabel: false, - width: 2, height: 2, decoration: "flat") { - state "default", label: 'ForecastH\n${currentValue}°', unit: "dF" - } - valueTile("weatherTempLow", "device.weatherTempLow", inactiveLabel: false, - width: 2, height: 2, decoration: "flat") { - state "default", label: 'ForecastL\n${currentValue}°', unit: "dF" - } - valueTile("weatherPressure", "device.weatherPressure", inactiveLabel: false, - width: 2, height: 2, decoration: "flat") { - state "default", label: 'Pressure\n${currentValue}', unit: "hpa" - } - valueTile("weatherWindDirection", "device.weatherWindDirection", - inactiveLabel: false, width: 2, height: 2, decoration: "flat") { - state "default", label: 'W.Dir\n${currentValue}' - } - valueTile("weatherWindSpeed", "device.weatherWindSpeed", inactiveLabel: false, - width: 2, height: 2, decoration: "flat") { - state "default", label: 'W.Speed\n${currentValue}' - } - valueTile("weatherPop", "device.weatherPop", inactiveLabel: false, width: 2, - height: 2, decoration: "flat") { - state "default", label: 'PoP\n${currentValue}%', unit: "%" + + + standardTile("ecoLogo", "device.motion", width: 2, height: 2) { + state "default", icon:"https://s3.amazonaws.com/smartapp-icons/MiscHacking/ecobee-smartapp-icn@2x.png" } + main(["temperature", "tempSummary"]) // details(["summary","temperature", "upButtonControl", "thermostatSetpoint", "currentStatus", "downButtonControl", "mode", "weatherIcon", "resumeProgram", "refresh"]) // details(["summary","apiStatus", "upButtonControl", "thermostatSetpoint", "currentStatus", "downButtonControl", "mode", "weatherIcon", "resumeProgram", "refresh"]) - details(["tempSummary", + /* details(["tempSummaryBlack", + "operatingStateBlack", "weatherIconBlack", "weatherTemperatureBlack", + "motion", "resumeProgram", "mode", + "coolSliderControl", "coolingSetpoint", + "heatSliderControl", "heatingSetpoint", + "currentStatus", "apiStatus", + "currentProgram", "fanMode", + "setHome", "setAway", "setSleep", + "refresh", "ecoLogo" + ]) +*/ + details(["tempSummary", "operatingState", "weatherIcon", "weatherTemperature", "motion", "resumeProgram", "mode", "coolSliderControl", "coolingSetpoint", @@ -519,8 +407,10 @@ metadata { "currentStatus", "apiStatus", "currentProgram", "fanMode", "setHome", "setAway", "setSleep", - "refresh" + "refresh", "ecoLogo" ]) + + } preferences { @@ -1301,6 +1191,10 @@ private def milesToKm(distance) { private def get_URI_ROOT() { return "https://api.ecobee.com" } + +private def getImageURLRoot() { + return "https://raw.githubusercontent.com/StrykerSKS/SmartThings/master/smartapp-icons/ecobee/dark/" +} // Maximum tstat batch size (25 thermostats max may be processed in batch) private def get_MAX_TSTAT_BATCH() { return 25