Checking in all the SmartThings apps; both official and third-party.
[smartapps.git] / official / ecobee-connect.groovy
diff --git a/official/ecobee-connect.groovy b/official/ecobee-connect.groovy
new file mode 100755 (executable)
index 0000000..cc26358
--- /dev/null
@@ -0,0 +1,890 @@
+/**
+ *  Copyright 2015 SmartThings
+ *
+ *  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.
+ *
+ *     Ecobee Service Manager
+ *
+ *     Author: scott
+ *     Date: 2013-08-07
+ *
+ *  Last Modification:
+ *      JLH - 01-23-2014 - Update for Correct SmartApp URL Format
+ *      JLH - 02-15-2014 - Fuller use of ecobee API
+ *      10-28-2015 DVCSMP-604 - accessory sensor, DVCSMP-1174, DVCSMP-1111 - not respond to routines
+ */
+
+include 'localization'
+
+definition(
+               name: "Ecobee (Connect)",
+               namespace: "smartthings",
+               author: "SmartThings",
+               description: "Connect your Ecobee thermostat to SmartThings.",
+               category: "SmartThings Labs",
+               iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png",
+               iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png",
+               singleInstance: true
+) {
+       appSetting "clientId"
+}
+
+preferences {
+       page(name: "auth", title: "ecobee", nextPage:"", content:"authPage", uninstall: true, install:true)
+}
+
+mappings {
+       path("/oauth/initialize") {action: [GET: "oauthInitUrl"]}
+       path("/oauth/callback") {action: [GET: "callback"]}
+}
+
+def authPage() {
+       log.debug "authPage()"
+
+       if(!atomicState.accessToken) { //this is to access token for 3rd party to make a call to connect app
+               atomicState.accessToken = createAccessToken()
+       }
+
+       def description
+       def uninstallAllowed = false
+       def oauthTokenProvided = false
+
+       if(atomicState.authToken) {
+               description = "You are connected."
+               uninstallAllowed = true
+               oauthTokenProvided = true
+       } else {
+               description = "Click to enter Ecobee Credentials"
+       }
+
+       def redirectUrl = buildRedirectUrl
+       log.debug "RedirectUrl = ${redirectUrl}"
+       // get rid of next button until the user is actually auth'd
+       if (!oauthTokenProvided) {
+               return dynamicPage(name: "auth", title: "Login", 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", description:description
+                       }
+               }
+       } else {
+               def stats = getEcobeeThermostats()
+               log.debug "thermostat list: $stats"
+               log.debug "sensor list: ${sensorsDiscovered()}"
+               return dynamicPage(name: "auth", title: "Select Your Thermostats", uninstall: true) {
+                       section("") {
+                               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 Your Thermostats", type: "enum", required:true, multiple:true, description: "Tap to choose", metadata:[values:stats])
+                       }
+
+                       def options = sensorsDiscovered() ?: []
+                       def numFound = options.size() ?: 0
+                       if (numFound > 0)  {
+                               section("") {
+                                       paragraph "Tap below to see the list of ecobee sensors available in your ecobee account and select the ones you want to connect to SmartThings."
+                                       input(name: "ecobeesensors", title: "Select Ecobee Sensors ({{numFound}} found)", messageArgs: [numFound: numFound], type: "enum", required:false, description: "Tap to choose", multiple:true, options:options)
+                               }
+                       }
+               }
+       }
+}
+
+def oauthInitUrl() {
+       log.debug "oauthInitUrl with callback: ${callbackUrl}"
+
+       atomicState.oauthInitState = UUID.randomUUID().toString()
+
+       def oauthParams = [
+                       response_type: "code",
+                       scope: "smartRead,smartWrite",
+                       client_id: smartThingsClientId,
+                       state: atomicState.oauthInitState,
+                       redirect_uri: callbackUrl
+       ]
+
+       redirect(location: "${apiEndpoint}/authorize?${toQueryString(oauthParams)}")
+}
+
+def callback() {
+       log.debug "callback()>> params: $params, params.code ${params.code}"
+
+       def code = params.code
+       def oauthState = params.state
+
+       if (oauthState == atomicState.oauthInitState) {
+               def tokenParams = [
+                       grant_type: "authorization_code",
+                       code      : code,
+                       client_id : smartThingsClientId,
+                       redirect_uri: callbackUrl
+               ]
+
+               def tokenUrl = "https://www.ecobee.com/home/token?${toQueryString(tokenParams)}"
+
+               httpPost(uri: tokenUrl) { resp ->
+                       atomicState.refreshToken = resp.data.refresh_token
+                       atomicState.authToken = resp.data.access_token
+               }
+
+               if (atomicState.authToken) {
+                       success()
+               } else {
+                       fail()
+               }
+
+       } else {
+               log.error "callback() failed oauthState != atomicState.oauthInitState"
+       }
+
+}
+
+def success() {
+       def message = """
+        <p>Your ecobee Account is now connected to SmartThings!</p>
+        <p>Click 'Done' to finish setup.</p>
+    """
+       connectionStatus(message)
+}
+
+def fail() {
+       def message = """
+        <p>The connection could not be established!</p>
+        <p>Click 'Done' to return to the menu.</p>
+    """
+       connectionStatus(message)
+}
+
+def connectionStatus(message, redirectUrl = null) {
+       def redirectHtml = ""
+       if (redirectUrl) {
+               redirectHtml = """
+                       <meta http-equiv="refresh" content="3; url=${redirectUrl}" />
+               """
+       }
+
+       def html = """
+        <!DOCTYPE html>
+        <html>
+            <head>
+                <meta name="viewport" content="width=640">
+                <title>Ecobee & SmartThings connection</title>
+                <style type="text/css">
+                    @font-face {
+                        font-family: 'Swiss 721 W01 Thin';
+                        src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
+                        src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
+                        url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
+                        url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
+                        url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
+                        font-weight: normal;
+                        font-style: normal;
+                    }
+                    @font-face {
+                        font-family: 'Swiss 721 W01 Light';
+                        src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
+                        src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
+                        url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
+                        url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
+                        url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
+                        font-weight: normal;
+                        font-style: normal;
+                    }
+                    .container {
+                        width: 90%;
+                        padding: 4%;
+                        text-align: center;
+                    }
+                    img {
+                        vertical-align: middle;
+                    }
+                    p {
+                        font-size: 2.2em;
+                        font-family: 'Swiss 721 W01 Thin';
+                        text-align: center;
+                        color: #666666;
+                        padding: 0 40px;
+                        margin-bottom: 0;
+                    }
+                    span {
+                        font-family: 'Swiss 721 W01 Light';
+                    }
+                </style>
+            </head>
+        <body>
+            <div class="container">
+                <img src="https://s3.amazonaws.com/smartapp-icons/Partner/ecobee%402x.png" alt="ecobee icon" />
+                <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
+                <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
+                ${message}
+            </div>
+        </body>
+    </html>
+    """
+
+       render contentType: 'text/html', data: html
+}
+
+def getEcobeeThermostats() {
+       log.debug "getting device list"
+       atomicState.remoteSensors = []
+
+    def bodyParams = [
+        selection: [
+            selectionType: "registered",
+            selectionMatch: "",
+            includeRuntime: true,
+            includeSensors: true
+        ]
+    ]
+       def deviceListParams = [
+               uri: apiEndpoint,
+               path: "/1/thermostat",
+               headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"],
+        // TODO - the query string below is not consistent with the Ecobee docs:
+        // https://www.ecobee.com/home/developer/api/documentation/v1/operations/get-thermostats.shtml
+               query: [format: 'json', body: toJson(bodyParams)]
+       ]
+
+       def stats = [:]
+       try {
+               httpGet(deviceListParams) { resp ->
+                       if (resp.status == 200) {
+                               resp.data.thermostatList.each { stat ->
+                                       atomicState.remoteSensors = atomicState.remoteSensors == null ? stat.remoteSensors : atomicState.remoteSensors <<  stat.remoteSensors
+                                       def dni = [app.id, stat.identifier].join('.')
+                                       stats[dni] = getThermostatDisplayName(stat)
+                               }
+                       } else {
+                               log.debug "http status: ${resp.status}"
+                       }
+               }
+       } catch (groovyx.net.http.HttpResponseException e) {
+        log.trace "Exception polling children: " + e.response.data.status
+        if (e.response.data.status.code == 14) {
+            atomicState.action = "getEcobeeThermostats"
+            log.debug "Refreshing your auth_token!"
+            refreshAuthToken()
+        }
+    }
+       atomicState.thermostats = stats
+       return stats
+}
+
+Map sensorsDiscovered() {
+       def map = [:]
+       log.info "list ${atomicState.remoteSensors}"
+       atomicState.remoteSensors.each { sensors ->
+               sensors.each {
+                       if (it.type != "thermostat") {
+                               def value = "${it?.name}"
+                               def key = "ecobee_sensor-"+ it?.id + "-" + it?.code
+                               map["${key}"] = value
+                       }
+               }
+       }
+       atomicState.sensors = map
+       return map
+}
+
+def getThermostatDisplayName(stat) {
+    if(stat?.name) {
+        return stat.name.toString()
+    }
+    return (getThermostatTypeName(stat) + " (${stat.identifier})").toString()
+}
+
+def getThermostatTypeName(stat) {
+       return stat.modelNumber == "siSmart" ? "Smart Si" : "Smart"
+}
+
+def installed() {
+       log.debug "Installed with settings: ${settings}"
+       initialize()
+}
+
+def updated() {
+       log.debug "Updated with settings: ${settings}"
+       unsubscribe()
+       initialize()
+}
+
+def initialize() {
+       log.debug "initialize"
+       def devices = thermostats.collect { dni ->
+               def d = getChildDevice(dni)
+               if(!d) {
+                       d = addChildDevice(app.namespace, getChildName(), dni, null, ["label":"${atomicState.thermostats[dni]}" ?: "Ecobee Thermostat"])
+                       log.debug "created ${d.displayName} with id $dni"
+               } else {
+                       log.debug "found ${d.displayName} with id $dni already exists"
+               }
+               return d
+       }
+
+       def sensors = ecobeesensors.collect { dni ->
+               def d = getChildDevice(dni)
+               if(!d) {
+                       d = addChildDevice(app.namespace, getSensorChildName(), dni, null, ["label":"${atomicState.sensors[dni]}" ?:"Ecobee Sensor"])
+                       log.debug "created ${d.displayName} with id $dni"
+               } else {
+                       log.debug "found ${d.displayName} with id $dni already exists"
+               }
+               return d
+       }
+       log.debug "created ${devices.size()} thermostats and ${sensors.size()} sensors."
+
+       def delete  // Delete any that are no longer in settings
+       if(!thermostats && !ecobeesensors) {
+               log.debug "delete thermostats ands sensors"
+               delete = getAllChildDevices() //inherits from SmartApp (data-management)
+       } else { //delete only thermostat
+               log.debug "delete individual thermostat and sensor"
+               if (!ecobeesensors) {
+                       delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) }
+               } else {
+                       delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) && !ecobeesensors.contains(it.deviceNetworkId)}
+               }
+       }
+       log.warn "delete: ${delete}, deleting ${delete.size()} thermostats"
+       delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management)
+
+       //send activity feeds to tell that device is connected
+       def notificationMessage = "is connected to SmartThings"
+       sendActivityFeeds(notificationMessage)
+       atomicState.timeSendPush = null
+       atomicState.reAttempt = 0
+
+       pollHandler() //first time polling data data from thermostat
+
+       //automatically update devices status every 5 mins
+       runEvery5Minutes("poll")
+
+}
+
+def pollHandler() {
+       log.debug "pollHandler()"
+       pollChildren(null) // Hit the ecobee API for update on all thermostats
+
+       atomicState.thermostats.each {stat ->
+               def dni = stat.key
+               log.debug ("DNI = ${dni}")
+               def d = getChildDevice(dni)
+               if(d) {
+                       log.debug ("Found Child Device.")
+                       d.generateEvent(atomicState.thermostats[dni].data)
+               }
+       }
+}
+
+def pollChildren(child = null) {
+    def thermostatIdsString = getChildDeviceIdsString()
+    log.debug "polling children: $thermostatIdsString"
+
+    def requestBody = [
+        selection: [
+            selectionType: "thermostats",
+            selectionMatch: thermostatIdsString,
+            includeExtendedRuntime: true,
+            includeSettings: true,
+            includeRuntime: true,
+            includeSensors: true
+        ]
+    ]
+
+       def result = false
+
+       def pollParams = [
+        uri: apiEndpoint,
+        path: "/1/thermostat",
+        headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"],
+        // TODO - the query string below is not consistent with the Ecobee docs:
+        // https://www.ecobee.com/home/developer/api/documentation/v1/operations/get-thermostats.shtml
+        query: [format: 'json', body: toJson(requestBody)]
+    ]
+
+       try{
+               httpGet(pollParams) { resp ->
+                       if(resp.status == 200) {
+                log.debug "poll results returned resp.data ${resp.data}"
+                atomicState.remoteSensors = resp.data.thermostatList.remoteSensors
+                updateSensorData()
+                storeThermostatData(resp.data.thermostatList)
+                result = true
+                log.debug "updated ${atomicState.thermostats?.size()} stats: ${atomicState.thermostats}"
+            }
+               }
+       } catch (groovyx.net.http.HttpResponseException e) {
+               log.trace "Exception polling children: " + e.response.data.status
+        if (e.response.data.status.code == 14) {
+            atomicState.action = "pollChildren"
+            log.debug "Refreshing your auth_token!"
+            refreshAuthToken()
+        }
+       }
+       return result
+}
+
+// Poll Child is invoked from the Child Device itself as part of the Poll Capability
+def pollChild() {
+       def devices = getChildDevices()
+
+       if (pollChildren()) {
+               devices.each { child ->
+                       if (!child.device.deviceNetworkId.startsWith("ecobee_sensor")) {
+                               if(atomicState.thermostats[child.device.deviceNetworkId] != null) {
+                                       def tData = atomicState.thermostats[child.device.deviceNetworkId]
+                                       log.info "pollChild(child)>> data for ${child.device.deviceNetworkId} : ${tData.data}"
+                                       child.generateEvent(tData.data) //parse received message from parent
+                               } else if(atomicState.thermostats[child.device.deviceNetworkId] == null) {
+                                       log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId}"
+                                       return null
+                               }
+                       }
+               }
+       } else {
+               log.info "ERROR: pollChildren()"
+               return null
+       }
+
+}
+
+void poll() {
+       pollChild()
+}
+
+def availableModes(child) {
+       debugEvent ("atomicState.thermostats = ${atomicState.thermostats}")
+       debugEvent ("Child DNI = ${child.device.deviceNetworkId}")
+
+       def tData = atomicState.thermostats[child.device.deviceNetworkId]
+
+       debugEvent("Data = ${tData}")
+
+       if(!tData) {
+               log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling"
+               return null
+       }
+
+       def modes = ["off"]
+
+    if (tData.data.heatMode) {
+        modes.add("heat")
+    }
+    if (tData.data.coolMode) {
+        modes.add("cool")
+    }
+    if (tData.data.autoMode) {
+        modes.add("auto")
+    }
+    if (tData.data.auxHeatMode) {
+        modes.add("auxHeatOnly")
+    }
+
+    return modes
+}
+
+def currentMode(child) {
+       debugEvent ("atomicState.Thermos = ${atomicState.thermostats}")
+       debugEvent ("Child DNI = ${child.device.deviceNetworkId}")
+
+       def tData = atomicState.thermostats[child.device.deviceNetworkId]
+
+       debugEvent("Data = ${tData}")
+
+       if(!tData) {
+               log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling"
+               return null
+       }
+
+       def mode = tData.data.thermostatMode
+       return mode
+}
+
+def updateSensorData() {
+       atomicState.remoteSensors.each {
+               it.each {
+                       if (it.type != "thermostat") {
+                               def temperature = ""
+                               def occupancy = ""
+                               it.capability.each {
+                                       if (it.type == "temperature") {
+                                               if (it.value == "unknown") {
+                                                       temperature = "--"
+                                               } else {
+                                                       if (location.temperatureScale == "F") {
+                                                               temperature = Math.round(it.value.toDouble() / 10)
+                                                       } else {
+                                                               temperature = convertFtoC(it.value.toDouble() / 10)
+                                                       }
+
+                                               }
+                                       } else if (it.type == "occupancy") {
+                                               if(it.value == "true") {
+                            occupancy = "active"
+                        } else {
+                                                       occupancy = "inactive"
+                        }
+                                       }
+                               }
+                               def dni = "ecobee_sensor-"+ it?.id + "-" + it?.code
+                               def d = getChildDevice(dni)
+                               if(d) {
+                                       d.sendEvent(name:"temperature", value: temperature)
+                                       d.sendEvent(name:"motion", value: occupancy)
+                               }
+                       }
+               }
+       }
+}
+
+def getChildDeviceIdsString() {
+       return thermostats.collect { it.split(/\./).last() }.join(',')
+}
+
+def toJson(Map m) {
+    return groovy.json.JsonOutput.toJson(m)
+}
+
+def toQueryString(Map m) {
+       return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
+}
+
+private refreshAuthToken() {
+       log.debug "refreshing auth token"
+
+       if(!atomicState.refreshToken) {
+               log.warn "Can not refresh OAuth token since there is no refreshToken stored"
+       } else {
+               def refreshParams = [
+                       method: 'POST',
+                       uri   : apiEndpoint,
+                       path  : "/token",
+                       query : [grant_type: 'refresh_token', code: "${atomicState.refreshToken}", client_id: smartThingsClientId],
+               ]
+
+               def notificationMessage = "is disconnected from SmartThings, because the access credential changed or was lost. Please go to the Ecobee (Connect) SmartApp and re-enter your account login credentials."
+               //changed to httpPost
+               try {
+                       def jsonMap
+                       httpPost(refreshParams) { resp ->
+                               if(resp.status == 200) {
+                                       log.debug "Token refreshed...calling saved RestAction now!"
+                                       debugEvent("Token refreshed ... calling saved RestAction now!")
+                                       saveTokenAndResumeAction(resp.data)
+                           }
+            }
+               } catch (groovyx.net.http.HttpResponseException e) {
+                       log.error "refreshAuthToken() >> Error: e.statusCode ${e.statusCode}"
+                       def reAttemptPeriod = 300 // in sec
+                       if (e.statusCode != 401) { // this issue might comes from exceed 20sec app execution, connectivity issue etc.
+                               runIn(reAttemptPeriod, "refreshAuthToken")
+                       } else if (e.statusCode == 401) { // unauthorized
+                               atomicState.reAttempt = atomicState.reAttempt + 1
+                               log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}"
+                               if (atomicState.reAttempt <= 3) {
+                                       runIn(reAttemptPeriod, "refreshAuthToken")
+                               } else {
+                                       sendPushAndFeeds(notificationMessage)
+                                       atomicState.reAttempt = 0
+                               }
+                       }
+               }
+       }
+}
+
+/**
+ * Saves the refresh and auth token from the passed-in JSON object,
+ * and invokes any previously executing action that did not complete due to
+ * an expired token.
+ *
+ * @param json - an object representing the parsed JSON response from Ecobee
+ */
+private void saveTokenAndResumeAction(json) {
+    log.debug "token response json: $json"
+    if (json) {
+        debugEvent("Response = $json")
+        atomicState.refreshToken = json?.refresh_token
+        atomicState.authToken = json?.access_token
+        if (atomicState.action) {
+            log.debug "got refresh token, executing next action: ${atomicState.action}"
+            "${atomicState.action}"()
+        }
+    } else {
+        log.warn "did not get response body from refresh token response"
+    }
+    atomicState.action = ""
+}
+
+/**
+ * Executes the resume program command on the Ecobee thermostat
+ * @param deviceId - the ID of the device
+ *
+ * @retrun true if the command was successful, false otherwise.
+ */
+boolean resumeProgram(deviceId) {
+    def payload = [
+        selection: [
+            selectionType: "thermostats",
+            selectionMatch: deviceId,
+            includeRuntime: true
+        ],
+        functions: [
+            [
+                type: "resumeProgram"
+            ]
+        ]
+    ]
+    return sendCommandToEcobee(payload)
+}
+
+/**
+ * Executes the set hold command on the Ecobee thermostat
+ * @param heating - The heating temperature to set in fahrenheit
+ * @param cooling - the cooling temperature to set in fahrenheit
+ * @param deviceId - the ID of the device
+ * @param sendHoldType - the hold type to execute
+ *
+ * @return true if the command was successful, false otherwise
+ */
+boolean setHold(heating, cooling, deviceId, sendHoldType) {
+    // Ecobee requires that temp values be in fahrenheit multiplied by 10.
+    int h = heating * 10
+    int c = cooling * 10
+
+    def payload = [
+        selection: [
+            selectionType: "thermostats",
+            selectionMatch: deviceId,
+            includeRuntime: true
+        ],
+        functions: [
+            [
+                type: "setHold",
+                params: [
+                    coolHoldTemp: c,
+                    heatHoldTemp: h,
+                    holdType: sendHoldType
+                ]
+            ]
+        ]
+    ]
+
+    return sendCommandToEcobee(payload)
+}
+
+/**
+ * Executes the set fan mode command on the Ecobee thermostat
+ * @param heating - The heating temperature to set in fahrenheit
+ * @param cooling - the cooling temperature to set in fahrenheit
+ * @param deviceId - the ID of the device
+ * @param sendHoldType - the hold type to execute
+ * @param fanMode - the fan mode to set to
+ *
+ * @return true if the command was successful, false otherwise
+ */
+boolean setFanMode(heating, cooling, deviceId, sendHoldType, fanMode) {
+    // Ecobee requires that temp values be in fahrenheit multiplied by 10.
+    int h = heating * 10
+    int c = cooling * 10
+
+    def payload = [
+        selection: [
+            selectionType: "thermostats",
+            selectionMatch: deviceId,
+            includeRuntime: true
+        ],
+        functions: [
+            [
+                type: "setHold",
+                params: [
+                    coolHoldTemp: c,
+                    heatHoldTemp: h,
+                    holdType: sendHoldType,
+                    fan: fanMode
+                ]
+            ]
+        ]
+    ]
+
+       return sendCommandToEcobee(payload)
+}
+
+/**
+ * Sets the mode of the Ecobee thermostat
+ * @param mode - the mode to set to
+ * @param deviceId - the ID of the device
+ *
+ * @return true if the command was successful, false otherwise
+ */
+boolean setMode(mode, deviceId) {
+    def payload = [
+        selection: [
+            selectionType: "thermostats",
+            selectionMatch: deviceId,
+            includeRuntime: true
+        ],
+        thermostat: [
+            settings: [
+                hvacMode: mode
+            ]
+        ]
+    ]
+       return sendCommandToEcobee(payload)
+}
+
+/**
+ * Makes a request to the Ecobee API to actuate the thermostat.
+ * Used by command methods to send commands to Ecobee.
+ *
+ * @param bodyParams - a map of request parameters to send to Ecobee.
+ *
+ * @return true if the command was accepted by Ecobee without error, false otherwise.
+ */
+private boolean sendCommandToEcobee(Map bodyParams) {
+       def isSuccess = false
+       def cmdParams = [
+               uri: apiEndpoint,
+               path: "/1/thermostat",
+               headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
+               body: toJson(bodyParams)
+       ]
+
+       try{
+        httpPost(cmdParams) { resp ->
+            if(resp.status == 200) {
+                log.debug "updated ${resp.data}"
+                def returnStatus = resp.data.status.code
+                if (returnStatus == 0) {
+                    log.debug "Successful call to ecobee API."
+                    isSuccess = true
+                } else {
+                    log.debug "Error return code = ${returnStatus}"
+                    debugEvent("Error return code = ${returnStatus}")
+                }
+            }
+        }
+       } catch (groovyx.net.http.HttpResponseException e) {
+        log.trace "Exception Sending Json: " + e.response.data.status
+        debugEvent ("sent Json & got http status ${e.statusCode} - ${e.response.data.status.code}")
+        if (e.response.data.status.code == 14) {
+            // TODO - figure out why we're setting the next action to be pollChildren
+            // after refreshing auth token. Is it to keep UI in sync, or just copy/paste error?
+            atomicState.action = "pollChildren"
+            log.debug "Refreshing your auth_token!"
+            refreshAuthToken()
+        } else {
+            debugEvent("Authentication error, invalid authentication method, lack of credentials, etc.")
+            log.error "Authentication error, invalid authentication method, lack of credentials, etc."
+        }
+    }
+
+    return isSuccess
+}
+
+def getChildName()           { return "Ecobee Thermostat" }
+def getSensorChildName()     { return "Ecobee Sensor" }
+def getServerUrl()           { return "https://graph.api.smartthings.com" }
+def getShardUrl()            { return getApiServerUrl() }
+def getCallbackUrl()         { return "https://graph.api.smartthings.com/oauth/callback" }
+def getBuildRedirectUrl()    { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" }
+def getApiEndpoint()         { return "https://api.ecobee.com" }
+def getSmartThingsClientId() { return appSettings.clientId }
+
+def debugEvent(message, displayEvent = false) {
+       def results = [
+               name: "appdebug",
+               descriptionText: message,
+               displayed: displayEvent
+       ]
+       log.debug "Generating AppDebug Event: ${results}"
+       sendEvent (results)
+}
+
+//send both push notification and mobile activity feeds
+def sendPushAndFeeds(notificationMessage) {
+       log.warn "sendPushAndFeeds >> notificationMessage: ${notificationMessage}"
+       log.warn "sendPushAndFeeds >> atomicState.timeSendPush: ${atomicState.timeSendPush}"
+       if (atomicState.timeSendPush) {
+               if (now() - atomicState.timeSendPush > 86400000) { // notification is sent to remind user once a day
+                       sendPush("Your Ecobee thermostat " + notificationMessage)
+                       sendActivityFeeds(notificationMessage)
+                       atomicState.timeSendPush = now()
+               }
+       } else {
+               sendPush("Your Ecobee thermostat " + notificationMessage)
+               sendActivityFeeds(notificationMessage)
+               atomicState.timeSendPush = now()
+       }
+       atomicState.authToken = null
+}
+
+/**
+ * Stores data about the thermostats in atomicState.
+ * @param thermostats - a list of thermostats as returned from the Ecobee API
+ */
+private void storeThermostatData(thermostats) {
+    log.trace "Storing thermostat data: $thermostats"
+    def data
+    atomicState.thermostats = thermostats.inject([:]) { collector, stat ->
+        def dni = [ app.id, stat.identifier ].join('.')
+        log.debug "updating dni $dni"
+
+        data = [
+            coolMode: (stat.settings.coolStages > 0),
+            heatMode: (stat.settings.heatStages > 0),
+            deviceTemperatureUnit: stat.settings.useCelsius,
+            minHeatingSetpoint: (stat.settings.heatRangeLow / 10),
+            maxHeatingSetpoint: (stat.settings.heatRangeHigh / 10),
+            minCoolingSetpoint: (stat.settings.coolRangeLow / 10),
+            maxCoolingSetpoint: (stat.settings.coolRangeHigh / 10),
+            autoMode: stat.settings.autoHeatCoolFeatureEnabled,
+            deviceAlive: stat.runtime.connected == true ? "true" : "false",
+            auxHeatMode: (stat.settings.hasHeatPump) && (stat.settings.hasForcedAir || stat.settings.hasElectric || stat.settings.hasBoiler),
+            temperature: (stat.runtime.actualTemperature / 10),
+            heatingSetpoint: stat.runtime.desiredHeat / 10,
+            coolingSetpoint: stat.runtime.desiredCool / 10,
+            thermostatMode: stat.settings.hvacMode,
+            humidity: stat.runtime.actualHumidity,
+            thermostatFanMode: stat.runtime.desiredFanMode
+        ]
+        if (location.temperatureScale == "F") {
+            data["temperature"] = data["temperature"] ? Math.round(data["temperature"].toDouble()) : data["temperature"]
+            data["heatingSetpoint"] = data["heatingSetpoint"] ? Math.round(data["heatingSetpoint"].toDouble()) : data["heatingSetpoint"]
+            data["coolingSetpoint"] = data["coolingSetpoint"] ? Math.round(data["coolingSetpoint"].toDouble()) : data["coolingSetpoint"]
+            data["minHeatingSetpoint"] = data["minHeatingSetpoint"] ? Math.round(data["minHeatingSetpoint"].toDouble()) : data["minHeatingSetpoint"]
+            data["maxHeatingSetpoint"] = data["maxHeatingSetpoint"] ? Math.round(data["maxHeatingSetpoint"].toDouble()) : data["maxHeatingSetpoint"]
+            data["minCoolingSetpoint"] = data["minCoolingSetpoint"] ? Math.round(data["minCoolingSetpoint"].toDouble()) : data["minCoolingSetpoint"]
+            data["maxCoolingSetpoint"] = data["maxCoolingSetpoint"] ? Math.round(data["maxCoolingSetpoint"].toDouble()) : data["maxCoolingSetpoint"]
+
+        }
+
+        if (data?.deviceTemperatureUnit == false && location.temperatureScale == "F") {
+            data["deviceTemperatureUnit"] = "F"
+
+        } else {
+            data["deviceTemperatureUnit"] = "C"
+        }
+
+        collector[dni] = [data:data]
+        return collector
+    }
+    log.debug "updated ${atomicState.thermostats?.size()} thermostats: ${atomicState.thermostats}"
+}
+
+def sendActivityFeeds(notificationMessage) {
+       def devices = getChildDevices()
+       devices.each { child ->
+               child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent
+       }
+}
+
+def convertFtoC (tempF) {
+       return String.format("%.1f", (Math.round(((tempF - 32)*(5/9)) * 2))/2)
+}