2 * Copyright 2015 SmartThings
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at:
7 * http://www.apache.org/licenses/LICENSE-2.0
9 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11 * for the specific language governing permissions and limitations under the License.
13 * Ecobee Service Manager
19 * JLH - 01-23-2014 - Update for Correct SmartApp URL Format
20 * JLH - 02-15-2014 - Fuller use of ecobee API
21 * 10-28-2015 DVCSMP-604 - accessory sensor, DVCSMP-1174, DVCSMP-1111 - not respond to routines
24 include 'localization'
27 name: "Ecobee (Connect)",
28 namespace: "smartthings",
29 author: "SmartThings",
30 description: "Connect your Ecobee thermostat to SmartThings.",
31 category: "SmartThings Labs",
32 iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png",
33 iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png",
40 page(name: "auth", title: "ecobee", nextPage:"", content:"authPage", uninstall: true, install:true)
44 path("/oauth/initialize") {action: [GET: "oauthInitUrl"]}
45 path("/oauth/callback") {action: [GET: "callback"]}
49 log.debug "authPage()"
51 if(!atomicState.accessToken) { //this is to access token for 3rd party to make a call to connect app
52 atomicState.accessToken = createAccessToken()
56 def uninstallAllowed = false
57 def oauthTokenProvided = false
59 if(atomicState.authToken) {
60 description = "You are connected."
61 uninstallAllowed = true
62 oauthTokenProvided = true
64 description = "Click to enter Ecobee Credentials"
67 def redirectUrl = buildRedirectUrl
68 log.debug "RedirectUrl = ${redirectUrl}"
69 // get rid of next button until the user is actually auth'd
70 if (!oauthTokenProvided) {
71 return dynamicPage(name: "auth", title: "Login", nextPage: "", uninstall:uninstallAllowed) {
73 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."
74 href url:redirectUrl, style:"embedded", required:true, title:"ecobee", description:description
78 def stats = getEcobeeThermostats()
79 log.debug "thermostat list: $stats"
80 log.debug "sensor list: ${sensorsDiscovered()}"
81 return dynamicPage(name: "auth", title: "Select Your Thermostats", uninstall: true) {
83 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."
84 input(name: "thermostats", title:"Select Your Thermostats", type: "enum", required:true, multiple:true, description: "Tap to choose", metadata:[values:stats])
87 def options = sensorsDiscovered() ?: []
88 def numFound = options.size() ?: 0
91 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."
92 input(name: "ecobeesensors", title: "Select Ecobee Sensors ({{numFound}} found)", messageArgs: [numFound: numFound], type: "enum", required:false, description: "Tap to choose", multiple:true, options:options)
100 log.debug "oauthInitUrl with callback: ${callbackUrl}"
102 atomicState.oauthInitState = UUID.randomUUID().toString()
105 response_type: "code",
106 scope: "smartRead,smartWrite",
107 client_id: smartThingsClientId,
108 state: atomicState.oauthInitState,
109 redirect_uri: callbackUrl
112 redirect(location: "${apiEndpoint}/authorize?${toQueryString(oauthParams)}")
116 log.debug "callback()>> params: $params, params.code ${params.code}"
118 def code = params.code
119 def oauthState = params.state
121 if (oauthState == atomicState.oauthInitState) {
123 grant_type: "authorization_code",
125 client_id : smartThingsClientId,
126 redirect_uri: callbackUrl
129 def tokenUrl = "https://www.ecobee.com/home/token?${toQueryString(tokenParams)}"
131 httpPost(uri: tokenUrl) { resp ->
132 atomicState.refreshToken = resp.data.refresh_token
133 atomicState.authToken = resp.data.access_token
136 if (atomicState.authToken) {
143 log.error "callback() failed oauthState != atomicState.oauthInitState"
150 <p>Your ecobee Account is now connected to SmartThings!</p>
151 <p>Click 'Done' to finish setup.</p>
153 connectionStatus(message)
158 <p>The connection could not be established!</p>
159 <p>Click 'Done' to return to the menu.</p>
161 connectionStatus(message)
164 def connectionStatus(message, redirectUrl = null) {
165 def redirectHtml = ""
168 <meta http-equiv="refresh" content="3; url=${redirectUrl}" />
176 <meta name="viewport" content="width=640">
177 <title>Ecobee & SmartThings connection</title>
178 <style type="text/css">
180 font-family: 'Swiss 721 W01 Thin';
181 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot');
182 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.eot?#iefix') format('embedded-opentype'),
183 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.woff') format('woff'),
184 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.ttf') format('truetype'),
185 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-thin-webfont.svg#swis721_th_btthin') format('svg');
190 font-family: 'Swiss 721 W01 Light';
191 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot');
192 src: url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.eot?#iefix') format('embedded-opentype'),
193 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.woff') format('woff'),
194 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.ttf') format('truetype'),
195 url('https://s3.amazonaws.com/smartapp-icons/Partner/fonts/swiss-721-light-webfont.svg#swis721_lt_btlight') format('svg');
205 vertical-align: middle;
209 font-family: 'Swiss 721 W01 Thin';
216 font-family: 'Swiss 721 W01 Light';
221 <div class="container">
222 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/ecobee%402x.png" alt="ecobee icon" />
223 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/connected-device-icn%402x.png" alt="connected device icon" />
224 <img src="https://s3.amazonaws.com/smartapp-icons/Partner/support/st-logo%402x.png" alt="SmartThings logo" />
231 render contentType: 'text/html', data: html
234 def getEcobeeThermostats() {
235 log.debug "getting device list"
236 atomicState.remoteSensors = []
240 selectionType: "registered",
242 includeRuntime: true,
246 def deviceListParams = [
248 path: "/1/thermostat",
249 headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"],
250 // TODO - the query string below is not consistent with the Ecobee docs:
251 // https://www.ecobee.com/home/developer/api/documentation/v1/operations/get-thermostats.shtml
252 query: [format: 'json', body: toJson(bodyParams)]
257 httpGet(deviceListParams) { resp ->
258 if (resp.status == 200) {
259 resp.data.thermostatList.each { stat ->
260 atomicState.remoteSensors = atomicState.remoteSensors == null ? stat.remoteSensors : atomicState.remoteSensors << stat.remoteSensors
261 def dni = [app.id, stat.identifier].join('.')
262 stats[dni] = getThermostatDisplayName(stat)
265 log.debug "http status: ${resp.status}"
268 } catch (groovyx.net.http.HttpResponseException e) {
269 log.trace "Exception polling children: " + e.response.data.status
270 if (e.response.data.status.code == 14) {
271 atomicState.action = "getEcobeeThermostats"
272 log.debug "Refreshing your auth_token!"
276 atomicState.thermostats = stats
280 Map sensorsDiscovered() {
282 log.info "list ${atomicState.remoteSensors}"
283 atomicState.remoteSensors.each { sensors ->
285 if (it.type != "thermostat") {
286 def value = "${it?.name}"
287 def key = "ecobee_sensor-"+ it?.id + "-" + it?.code
288 map["${key}"] = value
292 atomicState.sensors = map
296 def getThermostatDisplayName(stat) {
298 return stat.name.toString()
300 return (getThermostatTypeName(stat) + " (${stat.identifier})").toString()
303 def getThermostatTypeName(stat) {
304 return stat.modelNumber == "siSmart" ? "Smart Si" : "Smart"
308 log.debug "Installed with settings: ${settings}"
313 log.debug "Updated with settings: ${settings}"
319 log.debug "initialize"
320 def devices = thermostats.collect { dni ->
321 def d = getChildDevice(dni)
323 d = addChildDevice(app.namespace, getChildName(), dni, null, ["label":"${atomicState.thermostats[dni]}" ?: "Ecobee Thermostat"])
324 log.debug "created ${d.displayName} with id $dni"
326 log.debug "found ${d.displayName} with id $dni already exists"
331 def sensors = ecobeesensors.collect { dni ->
332 def d = getChildDevice(dni)
334 d = addChildDevice(app.namespace, getSensorChildName(), dni, null, ["label":"${atomicState.sensors[dni]}" ?:"Ecobee Sensor"])
335 log.debug "created ${d.displayName} with id $dni"
337 log.debug "found ${d.displayName} with id $dni already exists"
341 log.debug "created ${devices.size()} thermostats and ${sensors.size()} sensors."
343 def delete // Delete any that are no longer in settings
344 if(!thermostats && !ecobeesensors) {
345 log.debug "delete thermostats ands sensors"
346 delete = getAllChildDevices() //inherits from SmartApp (data-management)
347 } else { //delete only thermostat
348 log.debug "delete individual thermostat and sensor"
349 if (!ecobeesensors) {
350 delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) }
352 delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) && !ecobeesensors.contains(it.deviceNetworkId)}
355 log.warn "delete: ${delete}, deleting ${delete.size()} thermostats"
356 delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management)
358 //send activity feeds to tell that device is connected
359 def notificationMessage = "is connected to SmartThings"
360 sendActivityFeeds(notificationMessage)
361 atomicState.timeSendPush = null
362 atomicState.reAttempt = 0
364 pollHandler() //first time polling data data from thermostat
366 //automatically update devices status every 5 mins
367 runEvery5Minutes("poll")
372 log.debug "pollHandler()"
373 pollChildren(null) // Hit the ecobee API for update on all thermostats
375 atomicState.thermostats.each {stat ->
377 log.debug ("DNI = ${dni}")
378 def d = getChildDevice(dni)
380 log.debug ("Found Child Device.")
381 d.generateEvent(atomicState.thermostats[dni].data)
386 def pollChildren(child = null) {
387 def thermostatIdsString = getChildDeviceIdsString()
388 log.debug "polling children: $thermostatIdsString"
392 selectionType: "thermostats",
393 selectionMatch: thermostatIdsString,
394 includeExtendedRuntime: true,
395 includeSettings: true,
396 includeRuntime: true,
405 path: "/1/thermostat",
406 headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"],
407 // TODO - the query string below is not consistent with the Ecobee docs:
408 // https://www.ecobee.com/home/developer/api/documentation/v1/operations/get-thermostats.shtml
409 query: [format: 'json', body: toJson(requestBody)]
413 httpGet(pollParams) { resp ->
414 if(resp.status == 200) {
415 log.debug "poll results returned resp.data ${resp.data}"
416 atomicState.remoteSensors = resp.data.thermostatList.remoteSensors
418 storeThermostatData(resp.data.thermostatList)
420 log.debug "updated ${atomicState.thermostats?.size()} stats: ${atomicState.thermostats}"
423 } catch (groovyx.net.http.HttpResponseException e) {
424 log.trace "Exception polling children: " + e.response.data.status
425 if (e.response.data.status.code == 14) {
426 atomicState.action = "pollChildren"
427 log.debug "Refreshing your auth_token!"
434 // Poll Child is invoked from the Child Device itself as part of the Poll Capability
436 def devices = getChildDevices()
438 if (pollChildren()) {
439 devices.each { child ->
440 if (!child.device.deviceNetworkId.startsWith("ecobee_sensor")) {
441 if(atomicState.thermostats[child.device.deviceNetworkId] != null) {
442 def tData = atomicState.thermostats[child.device.deviceNetworkId]
443 log.info "pollChild(child)>> data for ${child.device.deviceNetworkId} : ${tData.data}"
444 child.generateEvent(tData.data) //parse received message from parent
445 } else if(atomicState.thermostats[child.device.deviceNetworkId] == null) {
446 log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId}"
452 log.info "ERROR: pollChildren()"
462 def availableModes(child) {
463 debugEvent ("atomicState.thermostats = ${atomicState.thermostats}")
464 debugEvent ("Child DNI = ${child.device.deviceNetworkId}")
466 def tData = atomicState.thermostats[child.device.deviceNetworkId]
468 debugEvent("Data = ${tData}")
471 log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling"
477 if (tData.data.heatMode) {
480 if (tData.data.coolMode) {
483 if (tData.data.autoMode) {
486 if (tData.data.auxHeatMode) {
487 modes.add("auxHeatOnly")
493 def currentMode(child) {
494 debugEvent ("atomicState.Thermos = ${atomicState.thermostats}")
495 debugEvent ("Child DNI = ${child.device.deviceNetworkId}")
497 def tData = atomicState.thermostats[child.device.deviceNetworkId]
499 debugEvent("Data = ${tData}")
502 log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling"
506 def mode = tData.data.thermostatMode
510 def updateSensorData() {
511 atomicState.remoteSensors.each {
513 if (it.type != "thermostat") {
517 if (it.type == "temperature") {
518 if (it.value == "unknown") {
521 if (location.temperatureScale == "F") {
522 temperature = Math.round(it.value.toDouble() / 10)
524 temperature = convertFtoC(it.value.toDouble() / 10)
528 } else if (it.type == "occupancy") {
529 if(it.value == "true") {
532 occupancy = "inactive"
536 def dni = "ecobee_sensor-"+ it?.id + "-" + it?.code
537 def d = getChildDevice(dni)
539 d.sendEvent(name:"temperature", value: temperature)
540 d.sendEvent(name:"motion", value: occupancy)
547 def getChildDeviceIdsString() {
548 return thermostats.collect { it.split(/\./).last() }.join(',')
552 return groovy.json.JsonOutput.toJson(m)
555 def toQueryString(Map m) {
556 return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
559 private refreshAuthToken() {
560 log.debug "refreshing auth token"
562 if(!atomicState.refreshToken) {
563 log.warn "Can not refresh OAuth token since there is no refreshToken stored"
565 def refreshParams = [
569 query : [grant_type: 'refresh_token', code: "${atomicState.refreshToken}", client_id: smartThingsClientId],
572 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."
573 //changed to httpPost
576 httpPost(refreshParams) { resp ->
577 if(resp.status == 200) {
578 log.debug "Token refreshed...calling saved RestAction now!"
579 debugEvent("Token refreshed ... calling saved RestAction now!")
580 saveTokenAndResumeAction(resp.data)
583 } catch (groovyx.net.http.HttpResponseException e) {
584 log.error "refreshAuthToken() >> Error: e.statusCode ${e.statusCode}"
585 def reAttemptPeriod = 300 // in sec
586 if (e.statusCode != 401) { // this issue might comes from exceed 20sec app execution, connectivity issue etc.
587 runIn(reAttemptPeriod, "refreshAuthToken")
588 } else if (e.statusCode == 401) { // unauthorized
589 atomicState.reAttempt = atomicState.reAttempt + 1
590 log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}"
591 if (atomicState.reAttempt <= 3) {
592 runIn(reAttemptPeriod, "refreshAuthToken")
594 sendPushAndFeeds(notificationMessage)
595 atomicState.reAttempt = 0
603 * Saves the refresh and auth token from the passed-in JSON object,
604 * and invokes any previously executing action that did not complete due to
607 * @param json - an object representing the parsed JSON response from Ecobee
609 private void saveTokenAndResumeAction(json) {
610 log.debug "token response json: $json"
612 debugEvent("Response = $json")
613 atomicState.refreshToken = json?.refresh_token
614 atomicState.authToken = json?.access_token
615 if (atomicState.action) {
616 log.debug "got refresh token, executing next action: ${atomicState.action}"
617 "${atomicState.action}"()
620 log.warn "did not get response body from refresh token response"
622 atomicState.action = ""
626 * Executes the resume program command on the Ecobee thermostat
627 * @param deviceId - the ID of the device
629 * @retrun true if the command was successful, false otherwise.
631 boolean resumeProgram(deviceId) {
634 selectionType: "thermostats",
635 selectionMatch: deviceId,
640 type: "resumeProgram"
644 return sendCommandToEcobee(payload)
648 * Executes the set hold command on the Ecobee thermostat
649 * @param heating - The heating temperature to set in fahrenheit
650 * @param cooling - the cooling temperature to set in fahrenheit
651 * @param deviceId - the ID of the device
652 * @param sendHoldType - the hold type to execute
654 * @return true if the command was successful, false otherwise
656 boolean setHold(heating, cooling, deviceId, sendHoldType) {
657 // Ecobee requires that temp values be in fahrenheit multiplied by 10.
663 selectionType: "thermostats",
664 selectionMatch: deviceId,
673 holdType: sendHoldType
679 return sendCommandToEcobee(payload)
683 * Executes the set fan mode command on the Ecobee thermostat
684 * @param heating - The heating temperature to set in fahrenheit
685 * @param cooling - the cooling temperature to set in fahrenheit
686 * @param deviceId - the ID of the device
687 * @param sendHoldType - the hold type to execute
688 * @param fanMode - the fan mode to set to
690 * @return true if the command was successful, false otherwise
692 boolean setFanMode(heating, cooling, deviceId, sendHoldType, fanMode) {
693 // Ecobee requires that temp values be in fahrenheit multiplied by 10.
699 selectionType: "thermostats",
700 selectionMatch: deviceId,
709 holdType: sendHoldType,
716 return sendCommandToEcobee(payload)
720 * Sets the mode of the Ecobee thermostat
721 * @param mode - the mode to set to
722 * @param deviceId - the ID of the device
724 * @return true if the command was successful, false otherwise
726 boolean setMode(mode, deviceId) {
729 selectionType: "thermostats",
730 selectionMatch: deviceId,
739 return sendCommandToEcobee(payload)
743 * Makes a request to the Ecobee API to actuate the thermostat.
744 * Used by command methods to send commands to Ecobee.
746 * @param bodyParams - a map of request parameters to send to Ecobee.
748 * @return true if the command was accepted by Ecobee without error, false otherwise.
750 private boolean sendCommandToEcobee(Map bodyParams) {
751 def isSuccess = false
754 path: "/1/thermostat",
755 headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
756 body: toJson(bodyParams)
760 httpPost(cmdParams) { resp ->
761 if(resp.status == 200) {
762 log.debug "updated ${resp.data}"
763 def returnStatus = resp.data.status.code
764 if (returnStatus == 0) {
765 log.debug "Successful call to ecobee API."
768 log.debug "Error return code = ${returnStatus}"
769 debugEvent("Error return code = ${returnStatus}")
773 } catch (groovyx.net.http.HttpResponseException e) {
774 log.trace "Exception Sending Json: " + e.response.data.status
775 debugEvent ("sent Json & got http status ${e.statusCode} - ${e.response.data.status.code}")
776 if (e.response.data.status.code == 14) {
777 // TODO - figure out why we're setting the next action to be pollChildren
778 // after refreshing auth token. Is it to keep UI in sync, or just copy/paste error?
779 atomicState.action = "pollChildren"
780 log.debug "Refreshing your auth_token!"
783 debugEvent("Authentication error, invalid authentication method, lack of credentials, etc.")
784 log.error "Authentication error, invalid authentication method, lack of credentials, etc."
791 def getChildName() { return "Ecobee Thermostat" }
792 def getSensorChildName() { return "Ecobee Sensor" }
793 def getServerUrl() { return "https://graph.api.smartthings.com" }
794 def getShardUrl() { return getApiServerUrl() }
795 def getCallbackUrl() { return "https://graph.api.smartthings.com/oauth/callback" }
796 def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" }
797 def getApiEndpoint() { return "https://api.ecobee.com" }
798 def getSmartThingsClientId() { return appSettings.clientId }
800 def debugEvent(message, displayEvent = false) {
803 descriptionText: message,
804 displayed: displayEvent
806 log.debug "Generating AppDebug Event: ${results}"
810 //send both push notification and mobile activity feeds
811 def sendPushAndFeeds(notificationMessage) {
812 log.warn "sendPushAndFeeds >> notificationMessage: ${notificationMessage}"
813 log.warn "sendPushAndFeeds >> atomicState.timeSendPush: ${atomicState.timeSendPush}"
814 if (atomicState.timeSendPush) {
815 if (now() - atomicState.timeSendPush > 86400000) { // notification is sent to remind user once a day
816 sendPush("Your Ecobee thermostat " + notificationMessage)
817 sendActivityFeeds(notificationMessage)
818 atomicState.timeSendPush = now()
821 sendPush("Your Ecobee thermostat " + notificationMessage)
822 sendActivityFeeds(notificationMessage)
823 atomicState.timeSendPush = now()
825 atomicState.authToken = null
829 * Stores data about the thermostats in atomicState.
830 * @param thermostats - a list of thermostats as returned from the Ecobee API
832 private void storeThermostatData(thermostats) {
833 log.trace "Storing thermostat data: $thermostats"
835 atomicState.thermostats = thermostats.inject([:]) { collector, stat ->
836 def dni = [ app.id, stat.identifier ].join('.')
837 log.debug "updating dni $dni"
840 coolMode: (stat.settings.coolStages > 0),
841 heatMode: (stat.settings.heatStages > 0),
842 deviceTemperatureUnit: stat.settings.useCelsius,
843 minHeatingSetpoint: (stat.settings.heatRangeLow / 10),
844 maxHeatingSetpoint: (stat.settings.heatRangeHigh / 10),
845 minCoolingSetpoint: (stat.settings.coolRangeLow / 10),
846 maxCoolingSetpoint: (stat.settings.coolRangeHigh / 10),
847 autoMode: stat.settings.autoHeatCoolFeatureEnabled,
848 deviceAlive: stat.runtime.connected == true ? "true" : "false",
849 auxHeatMode: (stat.settings.hasHeatPump) && (stat.settings.hasForcedAir || stat.settings.hasElectric || stat.settings.hasBoiler),
850 temperature: (stat.runtime.actualTemperature / 10),
851 heatingSetpoint: stat.runtime.desiredHeat / 10,
852 coolingSetpoint: stat.runtime.desiredCool / 10,
853 thermostatMode: stat.settings.hvacMode,
854 humidity: stat.runtime.actualHumidity,
855 thermostatFanMode: stat.runtime.desiredFanMode
857 if (location.temperatureScale == "F") {
858 data["temperature"] = data["temperature"] ? Math.round(data["temperature"].toDouble()) : data["temperature"]
859 data["heatingSetpoint"] = data["heatingSetpoint"] ? Math.round(data["heatingSetpoint"].toDouble()) : data["heatingSetpoint"]
860 data["coolingSetpoint"] = data["coolingSetpoint"] ? Math.round(data["coolingSetpoint"].toDouble()) : data["coolingSetpoint"]
861 data["minHeatingSetpoint"] = data["minHeatingSetpoint"] ? Math.round(data["minHeatingSetpoint"].toDouble()) : data["minHeatingSetpoint"]
862 data["maxHeatingSetpoint"] = data["maxHeatingSetpoint"] ? Math.round(data["maxHeatingSetpoint"].toDouble()) : data["maxHeatingSetpoint"]
863 data["minCoolingSetpoint"] = data["minCoolingSetpoint"] ? Math.round(data["minCoolingSetpoint"].toDouble()) : data["minCoolingSetpoint"]
864 data["maxCoolingSetpoint"] = data["maxCoolingSetpoint"] ? Math.round(data["maxCoolingSetpoint"].toDouble()) : data["maxCoolingSetpoint"]
868 if (data?.deviceTemperatureUnit == false && location.temperatureScale == "F") {
869 data["deviceTemperatureUnit"] = "F"
872 data["deviceTemperatureUnit"] = "C"
875 collector[dni] = [data:data]
878 log.debug "updated ${atomicState.thermostats?.size()} thermostats: ${atomicState.thermostats}"
881 def sendActivityFeeds(notificationMessage) {
882 def devices = getChildDevices()
883 devices.each { child ->
884 child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent
888 def convertFtoC (tempF) {
889 return String.format("%.1f", (Math.round(((tempF - 32)*(5/9)) * 2))/2)