Update notify-me-with-hue.groovy
[smartapps.git] / official / ecobee-connect.groovy
1 /**
2  *  Copyright 2015 SmartThings
3  *
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:
6  *
7  *      http://www.apache.org/licenses/LICENSE-2.0
8  *
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.
12  *
13  *      Ecobee Service Manager
14  *
15  *      Author: scott
16  *      Date: 2013-08-07
17  *
18  *  Last Modification:
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
22  */
23
24 include 'localization'
25
26 definition(
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",
34                 singleInstance: true
35 ) {
36         appSetting "clientId"
37 }
38
39 preferences {
40         page(name: "auth", title: "ecobee", nextPage:"", content:"authPage", uninstall: true, install:true)
41 }
42
43 mappings {
44         path("/oauth/initialize") {action: [GET: "oauthInitUrl"]}
45         path("/oauth/callback") {action: [GET: "callback"]}
46 }
47
48 def authPage() {
49         log.debug "authPage()"
50
51         if(!atomicState.accessToken) { //this is to access token for 3rd party to make a call to connect app
52                 atomicState.accessToken = createAccessToken()
53         }
54
55         def description
56         def uninstallAllowed = false
57         def oauthTokenProvided = false
58
59         if(atomicState.authToken) {
60                 description = "You are connected."
61                 uninstallAllowed = true
62                 oauthTokenProvided = true
63         } else {
64                 description = "Click to enter Ecobee Credentials"
65         }
66
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) {
72                         section() {
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
75                         }
76                 }
77         } else {
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) {
82                         section("") {
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])
85                         }
86
87                         def options = sensorsDiscovered() ?: []
88                         def numFound = options.size() ?: 0
89                         if (numFound > 0)  {
90                                 section("") {
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)
93                                 }
94                         }
95                 }
96         }
97 }
98
99 def oauthInitUrl() {
100         log.debug "oauthInitUrl with callback: ${callbackUrl}"
101
102         atomicState.oauthInitState = UUID.randomUUID().toString()
103
104         def oauthParams = [
105                         response_type: "code",
106                         scope: "smartRead,smartWrite",
107                         client_id: smartThingsClientId,
108                         state: atomicState.oauthInitState,
109                         redirect_uri: callbackUrl
110         ]
111
112         redirect(location: "${apiEndpoint}/authorize?${toQueryString(oauthParams)}")
113 }
114
115 def callback() {
116         log.debug "callback()>> params: $params, params.code ${params.code}"
117
118         def code = params.code
119         def oauthState = params.state
120
121         if (oauthState == atomicState.oauthInitState) {
122                 def tokenParams = [
123                         grant_type: "authorization_code",
124                         code      : code,
125                         client_id : smartThingsClientId,
126                         redirect_uri: callbackUrl
127                 ]
128
129                 def tokenUrl = "https://www.ecobee.com/home/token?${toQueryString(tokenParams)}"
130
131                 httpPost(uri: tokenUrl) { resp ->
132                         atomicState.refreshToken = resp.data.refresh_token
133                         atomicState.authToken = resp.data.access_token
134                 }
135
136                 if (atomicState.authToken) {
137                         success()
138                 } else {
139                         fail()
140                 }
141
142         } else {
143                 log.error "callback() failed oauthState != atomicState.oauthInitState"
144         }
145
146 }
147
148 def success() {
149         def message = """
150         <p>Your ecobee Account is now connected to SmartThings!</p>
151         <p>Click 'Done' to finish setup.</p>
152     """
153         connectionStatus(message)
154 }
155
156 def fail() {
157         def message = """
158         <p>The connection could not be established!</p>
159         <p>Click 'Done' to return to the menu.</p>
160     """
161         connectionStatus(message)
162 }
163
164 def connectionStatus(message, redirectUrl = null) {
165         def redirectHtml = ""
166         if (redirectUrl) {
167                 redirectHtml = """
168                         <meta http-equiv="refresh" content="3; url=${redirectUrl}" />
169                 """
170         }
171
172         def html = """
173         <!DOCTYPE html>
174         <html>
175             <head>
176                 <meta name="viewport" content="width=640">
177                 <title>Ecobee & SmartThings connection</title>
178                 <style type="text/css">
179                     @font-face {
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');
186                         font-weight: normal;
187                         font-style: normal;
188                     }
189                     @font-face {
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');
196                         font-weight: normal;
197                         font-style: normal;
198                     }
199                     .container {
200                         width: 90%;
201                         padding: 4%;
202                         text-align: center;
203                     }
204                     img {
205                         vertical-align: middle;
206                     }
207                     p {
208                         font-size: 2.2em;
209                         font-family: 'Swiss 721 W01 Thin';
210                         text-align: center;
211                         color: #666666;
212                         padding: 0 40px;
213                         margin-bottom: 0;
214                     }
215                     span {
216                         font-family: 'Swiss 721 W01 Light';
217                     }
218                 </style>
219             </head>
220         <body>
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" />
225                 ${message}
226             </div>
227         </body>
228     </html>
229     """
230
231         render contentType: 'text/html', data: html
232 }
233
234 def getEcobeeThermostats() {
235         log.debug "getting device list"
236         atomicState.remoteSensors = []
237
238     def bodyParams = [
239         selection: [
240             selectionType: "registered",
241             selectionMatch: "",
242             includeRuntime: true,
243             includeSensors: true
244         ]
245     ]
246         def deviceListParams = [
247                 uri: apiEndpoint,
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)]
253         ]
254
255         def stats = [:]
256         try {
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)
263                                 }
264                         } else {
265                                 log.debug "http status: ${resp.status}"
266                         }
267                 }
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!"
273             refreshAuthToken()
274         }
275     }
276         atomicState.thermostats = stats
277         return stats
278 }
279
280 Map sensorsDiscovered() {
281         def map = [:]
282         log.info "list ${atomicState.remoteSensors}"
283         atomicState.remoteSensors.each { sensors ->
284                 sensors.each {
285                         if (it.type != "thermostat") {
286                                 def value = "${it?.name}"
287                                 def key = "ecobee_sensor-"+ it?.id + "-" + it?.code
288                                 map["${key}"] = value
289                         }
290                 }
291         }
292         atomicState.sensors = map
293         return map
294 }
295
296 def getThermostatDisplayName(stat) {
297     if(stat?.name) {
298         return stat.name.toString()
299     }
300     return (getThermostatTypeName(stat) + " (${stat.identifier})").toString()
301 }
302
303 def getThermostatTypeName(stat) {
304         return stat.modelNumber == "siSmart" ? "Smart Si" : "Smart"
305 }
306
307 def installed() {
308         log.debug "Installed with settings: ${settings}"
309         initialize()
310 }
311
312 def updated() {
313         log.debug "Updated with settings: ${settings}"
314         unsubscribe()
315         initialize()
316 }
317
318 def initialize() {
319         log.debug "initialize"
320         def devices = thermostats.collect { dni ->
321                 def d = getChildDevice(dni)
322                 if(!d) {
323                         d = addChildDevice(app.namespace, getChildName(), dni, null, ["label":"${atomicState.thermostats[dni]}" ?: "Ecobee Thermostat"])
324                         log.debug "created ${d.displayName} with id $dni"
325                 } else {
326                         log.debug "found ${d.displayName} with id $dni already exists"
327                 }
328                 return d
329         }
330
331         def sensors = ecobeesensors.collect { dni ->
332                 def d = getChildDevice(dni)
333                 if(!d) {
334                         d = addChildDevice(app.namespace, getSensorChildName(), dni, null, ["label":"${atomicState.sensors[dni]}" ?:"Ecobee Sensor"])
335                         log.debug "created ${d.displayName} with id $dni"
336                 } else {
337                         log.debug "found ${d.displayName} with id $dni already exists"
338                 }
339                 return d
340         }
341         log.debug "created ${devices.size()} thermostats and ${sensors.size()} sensors."
342
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) }
351                 } else {
352                         delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) && !ecobeesensors.contains(it.deviceNetworkId)}
353                 }
354         }
355         log.warn "delete: ${delete}, deleting ${delete.size()} thermostats"
356         delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management)
357
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
363
364         pollHandler() //first time polling data data from thermostat
365
366         //automatically update devices status every 5 mins
367         runEvery5Minutes("poll")
368
369 }
370
371 def pollHandler() {
372         log.debug "pollHandler()"
373         pollChildren(null) // Hit the ecobee API for update on all thermostats
374
375         atomicState.thermostats.each {stat ->
376                 def dni = stat.key
377                 log.debug ("DNI = ${dni}")
378                 def d = getChildDevice(dni)
379                 if(d) {
380                         log.debug ("Found Child Device.")
381                         d.generateEvent(atomicState.thermostats[dni].data)
382                 }
383         }
384 }
385
386 def pollChildren(child = null) {
387     def thermostatIdsString = getChildDeviceIdsString()
388     log.debug "polling children: $thermostatIdsString"
389
390     def requestBody = [
391         selection: [
392             selectionType: "thermostats",
393             selectionMatch: thermostatIdsString,
394             includeExtendedRuntime: true,
395             includeSettings: true,
396             includeRuntime: true,
397             includeSensors: true
398         ]
399     ]
400
401         def result = false
402
403         def pollParams = [
404         uri: apiEndpoint,
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)]
410     ]
411
412         try{
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
417                 updateSensorData()
418                 storeThermostatData(resp.data.thermostatList)
419                 result = true
420                 log.debug "updated ${atomicState.thermostats?.size()} stats: ${atomicState.thermostats}"
421             }
422                 }
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!"
428             refreshAuthToken()
429         }
430         }
431         return result
432 }
433
434 // Poll Child is invoked from the Child Device itself as part of the Poll Capability
435 def pollChild() {
436         def devices = getChildDevices()
437
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}"
447                                         return null
448                                 }
449                         }
450                 }
451         } else {
452                 log.info "ERROR: pollChildren()"
453                 return null
454         }
455
456 }
457
458 void poll() {
459         pollChild()
460 }
461
462 def availableModes(child) {
463         debugEvent ("atomicState.thermostats = ${atomicState.thermostats}")
464         debugEvent ("Child DNI = ${child.device.deviceNetworkId}")
465
466         def tData = atomicState.thermostats[child.device.deviceNetworkId]
467
468         debugEvent("Data = ${tData}")
469
470         if(!tData) {
471                 log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling"
472                 return null
473         }
474
475         def modes = ["off"]
476
477     if (tData.data.heatMode) {
478         modes.add("heat")
479     }
480     if (tData.data.coolMode) {
481         modes.add("cool")
482     }
483     if (tData.data.autoMode) {
484         modes.add("auto")
485     }
486     if (tData.data.auxHeatMode) {
487         modes.add("auxHeatOnly")
488     }
489
490     return modes
491 }
492
493 def currentMode(child) {
494         debugEvent ("atomicState.Thermos = ${atomicState.thermostats}")
495         debugEvent ("Child DNI = ${child.device.deviceNetworkId}")
496
497         def tData = atomicState.thermostats[child.device.deviceNetworkId]
498
499         debugEvent("Data = ${tData}")
500
501         if(!tData) {
502                 log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling"
503                 return null
504         }
505
506         def mode = tData.data.thermostatMode
507         return mode
508 }
509
510 def updateSensorData() {
511         atomicState.remoteSensors.each {
512                 it.each {
513                         if (it.type != "thermostat") {
514                                 def temperature = ""
515                                 def occupancy = ""
516                                 it.capability.each {
517                                         if (it.type == "temperature") {
518                                                 if (it.value == "unknown") {
519                                                         temperature = "--"
520                                                 } else {
521                                                         if (location.temperatureScale == "F") {
522                                                                 temperature = Math.round(it.value.toDouble() / 10)
523                                                         } else {
524                                                                 temperature = convertFtoC(it.value.toDouble() / 10)
525                                                         }
526
527                                                 }
528                                         } else if (it.type == "occupancy") {
529                                                 if(it.value == "true") {
530                             occupancy = "active"
531                         } else {
532                                                         occupancy = "inactive"
533                         }
534                                         }
535                                 }
536                                 def dni = "ecobee_sensor-"+ it?.id + "-" + it?.code
537                                 def d = getChildDevice(dni)
538                                 if(d) {
539                                         d.sendEvent(name:"temperature", value: temperature)
540                                         d.sendEvent(name:"motion", value: occupancy)
541                                 }
542                         }
543                 }
544         }
545 }
546
547 def getChildDeviceIdsString() {
548         return thermostats.collect { it.split(/\./).last() }.join(',')
549 }
550
551 def toJson(Map m) {
552     return groovy.json.JsonOutput.toJson(m)
553 }
554
555 def toQueryString(Map m) {
556         return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
557 }
558
559 private refreshAuthToken() {
560         log.debug "refreshing auth token"
561
562         if(!atomicState.refreshToken) {
563                 log.warn "Can not refresh OAuth token since there is no refreshToken stored"
564         } else {
565                 def refreshParams = [
566                         method: 'POST',
567                         uri   : apiEndpoint,
568                         path  : "/token",
569                         query : [grant_type: 'refresh_token', code: "${atomicState.refreshToken}", client_id: smartThingsClientId],
570                 ]
571
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
574                 try {
575                         def jsonMap
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)
581                             }
582             }
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")
593                                 } else {
594                                         sendPushAndFeeds(notificationMessage)
595                                         atomicState.reAttempt = 0
596                                 }
597                         }
598                 }
599         }
600 }
601
602 /**
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
605  * an expired token.
606  *
607  * @param json - an object representing the parsed JSON response from Ecobee
608  */
609 private void saveTokenAndResumeAction(json) {
610     log.debug "token response json: $json"
611     if (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}"()
618         }
619     } else {
620         log.warn "did not get response body from refresh token response"
621     }
622     atomicState.action = ""
623 }
624
625 /**
626  * Executes the resume program command on the Ecobee thermostat
627  * @param deviceId - the ID of the device
628  *
629  * @retrun true if the command was successful, false otherwise.
630  */
631 boolean resumeProgram(deviceId) {
632     def payload = [
633         selection: [
634             selectionType: "thermostats",
635             selectionMatch: deviceId,
636             includeRuntime: true
637         ],
638         functions: [
639             [
640                 type: "resumeProgram"
641             ]
642         ]
643     ]
644     return sendCommandToEcobee(payload)
645 }
646
647 /**
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
653  *
654  * @return true if the command was successful, false otherwise
655  */
656 boolean setHold(heating, cooling, deviceId, sendHoldType) {
657     // Ecobee requires that temp values be in fahrenheit multiplied by 10.
658     int h = heating * 10
659     int c = cooling * 10
660
661     def payload = [
662         selection: [
663             selectionType: "thermostats",
664             selectionMatch: deviceId,
665             includeRuntime: true
666         ],
667         functions: [
668             [
669                 type: "setHold",
670                 params: [
671                     coolHoldTemp: c,
672                     heatHoldTemp: h,
673                     holdType: sendHoldType
674                 ]
675             ]
676         ]
677     ]
678
679     return sendCommandToEcobee(payload)
680 }
681
682 /**
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
689  *
690  * @return true if the command was successful, false otherwise
691  */
692 boolean setFanMode(heating, cooling, deviceId, sendHoldType, fanMode) {
693     // Ecobee requires that temp values be in fahrenheit multiplied by 10.
694     int h = heating * 10
695     int c = cooling * 10
696
697     def payload = [
698         selection: [
699             selectionType: "thermostats",
700             selectionMatch: deviceId,
701             includeRuntime: true
702         ],
703         functions: [
704             [
705                 type: "setHold",
706                 params: [
707                     coolHoldTemp: c,
708                     heatHoldTemp: h,
709                     holdType: sendHoldType,
710                     fan: fanMode
711                 ]
712             ]
713         ]
714     ]
715
716         return sendCommandToEcobee(payload)
717 }
718
719 /**
720  * Sets the mode of the Ecobee thermostat
721  * @param mode - the mode to set to
722  * @param deviceId - the ID of the device
723  *
724  * @return true if the command was successful, false otherwise
725  */
726 boolean setMode(mode, deviceId) {
727     def payload = [
728         selection: [
729             selectionType: "thermostats",
730             selectionMatch: deviceId,
731             includeRuntime: true
732         ],
733         thermostat: [
734             settings: [
735                 hvacMode: mode
736             ]
737         ]
738     ]
739         return sendCommandToEcobee(payload)
740 }
741
742 /**
743  * Makes a request to the Ecobee API to actuate the thermostat.
744  * Used by command methods to send commands to Ecobee.
745  *
746  * @param bodyParams - a map of request parameters to send to Ecobee.
747  *
748  * @return true if the command was accepted by Ecobee without error, false otherwise.
749  */
750 private boolean sendCommandToEcobee(Map bodyParams) {
751         def isSuccess = false
752         def cmdParams = [
753                 uri: apiEndpoint,
754                 path: "/1/thermostat",
755                 headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"],
756                 body: toJson(bodyParams)
757         ]
758
759         try{
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."
766                     isSuccess = true
767                 } else {
768                     log.debug "Error return code = ${returnStatus}"
769                     debugEvent("Error return code = ${returnStatus}")
770                 }
771             }
772         }
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!"
781             refreshAuthToken()
782         } else {
783             debugEvent("Authentication error, invalid authentication method, lack of credentials, etc.")
784             log.error "Authentication error, invalid authentication method, lack of credentials, etc."
785         }
786     }
787
788     return isSuccess
789 }
790
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 }
799
800 def debugEvent(message, displayEvent = false) {
801         def results = [
802                 name: "appdebug",
803                 descriptionText: message,
804                 displayed: displayEvent
805         ]
806         log.debug "Generating AppDebug Event: ${results}"
807         sendEvent (results)
808 }
809
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()
819                 }
820         } else {
821                 sendPush("Your Ecobee thermostat " + notificationMessage)
822                 sendActivityFeeds(notificationMessage)
823                 atomicState.timeSendPush = now()
824         }
825         atomicState.authToken = null
826 }
827
828 /**
829  * Stores data about the thermostats in atomicState.
830  * @param thermostats - a list of thermostats as returned from the Ecobee API
831  */
832 private void storeThermostatData(thermostats) {
833     log.trace "Storing thermostat data: $thermostats"
834     def data
835     atomicState.thermostats = thermostats.inject([:]) { collector, stat ->
836         def dni = [ app.id, stat.identifier ].join('.')
837         log.debug "updating dni $dni"
838
839         data = [
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
856         ]
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"]
865
866         }
867
868         if (data?.deviceTemperatureUnit == false && location.temperatureScale == "F") {
869             data["deviceTemperatureUnit"] = "F"
870
871         } else {
872             data["deviceTemperatureUnit"] = "C"
873         }
874
875         collector[dni] = [data:data]
876         return collector
877     }
878     log.debug "updated ${atomicState.thermostats?.size()} thermostats: ${atomicState.thermostats}"
879 }
880
881 def sendActivityFeeds(notificationMessage) {
882         def devices = getChildDevices()
883         devices.each { child ->
884                 child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent
885         }
886 }
887
888 def convertFtoC (tempF) {
889         return String.format("%.1f", (Math.round(((tempF - 32)*(5/9)) * 2))/2)
890 }