Update groveStreams.groovy
[smartapps.git] / third-party / evohome-connect.groovy
1 /**
2  *  Copyright 2016 David Lomas (codersaur)
3  *
4  *  Name: Evohome (Connect)
5  *
6  *  Author: David Lomas (codersaur)
7  *
8  *  Date: 2016-04-05
9  *
10  *  Version: 0.08
11  *
12  *  Description:
13  *   - Connect your Honeywell Evohome System to SmartThings.
14  *   - Requires the Evohome Heating Zone device handler.
15  *   - For latest documentation see: https://github.com/codersaur/SmartThings
16  *
17  *  Version History:
18  * 
19  *   2016-04-05: v0.08
20  *    - New 'Update Refresh Time' setting to control polling after making an update.
21  *    - poll() - If onlyZoneId is 0, this will force a status update for all zones.
22  * 
23  *   2016-04-04: v0.07
24  *    - Additional info log messages.
25  * 
26  *   2016-04-03: v0.06
27  *    - Initial Beta Release
28  * 
29  *  To Do:
30  *   - Add support for hot water zones (new device handler).
31  *   - Tidy up settings: See: http://docs.smartthings.com/en/latest/smartapp-developers-guide/preferences-and-settings.html
32  *   - Allow Evohome zones to be (de)selected as part of the setup process.
33  *   - Enable notifications if connection to Evohome cloud fails.
34  *   - Expose whether thremoStatMode is permanent or temporary, and if temporary for how long. Get from 'tcs.systemModeStatus.*'. i.e thermostatModeUntil
35  *   - Investigate if Evohome supports registing a callback so changes in Evohome are pushed to SmartThings instantly (instead of relying on polling).
36  *
37  *  License:
38  *   Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
39  *   in compliance with the License. You may obtain a copy of the License at:
40  *
41  *      http://www.apache.org/licenses/LICENSE-2.0
42  *
43  *   Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
44  *   on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
45  *   for the specific language governing permissions and limitations under the License.
46  *
47  */
48 definition(
49         name: "Evohome (Connect)",
50         namespace: "codersaur",
51         author: "David Lomas (codersaur)",
52         description: "Connect your Honeywell Evohome System to SmartThings.",
53         category: "My Apps",
54         iconUrl: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png",
55         iconX2Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png",
56         iconX3Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png",
57         singleInstance: true
58 )
59
60 preferences {
61
62         section ("Evohome:") {
63                 input "prefEvohomeUsername", "text", title: "Username", required: true, displayDuringSetup: true
64                 input "prefEvohomePassword", "password", title: "Password", required: true, displayDuringSetup: true
65                 input title: "Advanced Settings:", displayDuringSetup: true, type: "paragraph", element: "paragraph", description: "Change these only if needed"
66                 input "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true, description: "Poll Evohome every n minutes"
67                 input "prefEvohomeUpdateRefreshTime", "number", title: "Update Refresh Time (seconds)", range: "2..60", defaultValue: 3, required: true, displayDuringSetup: true, description: "Wait n seconds after an update before polling"
68                 input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5.0, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting"
69                 input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph"
70                 input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days'
71                 input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours'
72         }
73
74         section("General:") {
75                 input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true
76         }
77         
78 }
79
80 /**********************************************************************
81  *  Setup and Configuration Commands:
82  **********************************************************************/
83
84 /**
85  *  installed()
86  *
87  *  Runs when the app is first installed.
88  *
89  **/
90 def installed() {
91
92         atomicState.installedAt = now()
93         log.debug "${app.label}: Installed with settings: ${settings}"
94
95 }
96
97
98 /**
99  *  uninstalled()
100  *
101  *  Runs when the app is uninstalled.
102  *
103  **/
104 def uninstalled() {
105         if(getChildDevices()) {
106                 removeChildDevices(getChildDevices())
107         }
108 }
109
110
111 /**
112  *  updated()
113  * 
114  *  Runs when app settings are changed.
115  *
116  **/
117 void updated() {
118
119         if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}"
120
121         // General:
122         atomicState.debug = settings.prefDebugMode
123         
124         // Evohome:
125         atomicState.evohomeEndpoint = 'https://tccna.honeywell.com'
126         atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime.
127         atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes).
128         atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes).
129         atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling.
130         
131
132         // Thermostat Mode Durations:
133         atomicState.thermostatModeDuration = settings.prefThermostatModeDuration
134         atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration
135         
136         // Force Authentication:
137         authenticate()
138
139         // Refresh Subscriptions and Schedules:
140         manageSubscriptions()
141         manageSchedules()
142         
143         // Refresh child device configuration:
144         getEvohomeConfig()
145         updateChildDeviceConfig()
146
147         // Run a poll, but defer it so that updated() returns sooner:
148         runIn(5, "poll")
149
150 }
151
152
153 /**********************************************************************
154  *  Management Commands:
155  **********************************************************************/
156
157 /**
158  *  manageSchedules()
159  * 
160  *  Check scheduled tasks have not stalled, and re-schedule if necessary.
161  *  Generates a random offset (seconds) for each scheduled task.
162  *  
163  *  Schedules:
164  *   - manageAuth() - every 5 mins.
165  *   - poll() - every minute. 
166  *  
167  **/
168 void manageSchedules() {
169
170         if (atomicState.debug) log.debug "${app.label}: manageSchedules()"
171
172         // Generate a random offset (1-60):
173         Random rand = new Random(now())
174         def randomOffset = 0
175         
176         // manageAuth (every 5 mins):
177         if (1==1) { // To Do: Test if schedule has actually stalled.
178                 if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()"
179                 try {
180                         unschedule(manageAuth)
181                 }
182                 catch(e) {
183                         //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed"
184                 }
185                 randomOffset = rand.nextInt(60)
186                 schedule("${randomOffset} 0/5 * * * ?", "manageAuth")
187         }
188
189         // poll():
190         if (1==1) { // To Do: Test if schedule has actually stalled.
191                 if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()"
192                 try {
193                         unschedule(poll)
194                 }
195                 catch(e) {
196                         //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed"
197                 }
198                 randomOffset = rand.nextInt(60)
199                 schedule("${randomOffset} 0/1 * * * ?", "poll")
200         }
201
202 }
203
204
205 /**
206  *  manageSubscriptions()
207  * 
208  *  Unsubscribe/Subscribe.
209  **/
210 void manageSubscriptions() {
211
212         if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()"
213
214         // Unsubscribe:
215         unsubscribe()
216         
217         // Subscribe to App Touch events:
218         subscribe(app,handleAppTouch)
219         
220 }
221
222
223 /**
224  *  manageAuth()
225  * 
226  *  Ensures authenication token is valid. 
227  *   Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold.
228  *   Re-authenticates if Auth Token has expired completely.
229  *   Otherwise, done nothing.
230  *
231  *  Should be scheduled to run every 1-5 minutes.
232  **/
233 void manageAuth() {
234
235         if (atomicState.debug) log.debug "${app.label}: manageAuth()"
236
237         // Check if Auth Token is valid, if not authenticate:
238         if (!atomicState.evohomeAuth.authToken) {
239         
240                 log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..."
241                 authenticate()
242         }
243         else if (atomicState.evohomeAuthFailed) {
244         
245                 log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..."
246                 authenticate()
247         }
248         else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) {
249         
250                 log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..."
251                 authenticate()
252         }
253         else {          
254                 // Check if Auth Token should be refreshed:
255                 def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100))
256                 
257                 if (now() >= refreshAt) {
258                         log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires."
259                         refreshAuthToken()
260                 }
261                 else {
262                         log.info "${app.label}: manageAuth(): Auth Token is okay."              
263                 }
264         }
265
266 }
267
268
269 /**
270  *  poll(onlyZoneId=-1)
271  * 
272  *  This is the main command that co-ordinates retrieval of information from the Evohome API
273  *  and its dissemination to child devices. It should be scheduled to run every minute.
274  *
275  *  Different types of information are collected on different schedules:
276  *   - Zone status information is polled according to ${evohomeStatusPollInterval}.
277  *   - Zone schedules are polled according to ${evohomeSchedulePollInterval}.
278  *
279  *  poll() can be called by a child device when an update has been made, in which case
280  *  onlyZoneId will be specified, and only that zone will be updated.
281  * 
282  *  If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll 
283  *  interval. This should only be used after setThremostatMode() call.
284  *
285  *  If onlyZoneId is not specified all zones are updated, but only if the relevent poll
286  *  interval has been exceeded.
287  *
288  **/
289 void poll(onlyZoneId=-1) {
290
291         if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})"
292         
293         // Check if there's been an authentication failure:
294         if (atomicState.evohomeAuthFailed) {
295                 manageAuth()
296         }
297         
298         if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update):
299                 getEvohomeStatus()
300                 updateChildDevice()
301         }
302         else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device:
303                 getEvohomeStatus(onlyZoneId)
304                 updateChildDevice(onlyZoneId)
305         }
306         else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: 
307         
308                 // Adjust intervals to allow for poll() execution time:
309                 def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30
310                 def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30
311
312                 // Get zone status:
313                 if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) {
314                         getEvohomeStatus()
315                 } 
316
317                 // Get zone schedules:
318                 if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) {
319                         getEvohomeSchedules()
320                 }
321                 
322                 // Update all child devices:
323                 updateChildDevice()
324         }
325
326 }
327
328
329 /**********************************************************************
330  *  Event Handlers:
331  **********************************************************************/
332
333
334 /**
335  *  handleAppTouch(evt)
336  * 
337  *  App touch event handler.
338  *   Used for testing and debugging.
339  *
340  **/
341 void handleAppTouch(evt) {
342
343         if (atomicState.debug) log.debug "${app.label}: handleAppTouch()"
344
345         //manageAuth()
346         //manageSchedules()
347         
348         //getEvohomeConfig()
349         //updateChildDeviceConfig()
350         
351         poll()
352
353 }
354
355
356 /**********************************************************************
357  *  SmartApp-Child Interface Commands:
358  **********************************************************************/
359
360 /**
361  *  updateChildDeviceConfig()
362  * 
363  *  Add/Remove/Update Child Devices based on atomicState.evohomeConfig
364  *  and update their internal state.
365  *
366  **/
367 void updateChildDeviceConfig() {
368
369         if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()"
370         
371         // Build list of active DNIs, any existing children with DNIs not in here will be deleted.
372         def activeDnis = []
373         
374         // Iterate through evohomeConfig, adding new Evohome Heating Zone devices where necessary.
375         atomicState.evohomeConfig.each { loc ->
376                 loc.gateways.each { gateway ->
377                         gateway.temperatureControlSystems.each { tcs ->
378                                 tcs.zones.each { zone ->
379                                         
380                                         def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId )
381                                         activeDnis << dni
382                                         
383                                         def values = [
384                                                 'debug': atomicState.debug,
385                                                 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime,
386                                                 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint),
387                                                 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint),
388                                                 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution,
389                                                 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp),
390                                                 'zoneType': zone?.zoneType,
391                                                 'locationId': loc.locationInfo.locationId,
392                                                 'gatewayId': gateway.gatewayInfo.gatewayId,
393                                                 'systemId': tcs.systemId,
394                                                 'zoneId': zone.zoneId
395                                         ]
396                                         
397                                         def d = getChildDevice(dni)
398                                         if(!d) {
399                                                 try {
400                                                         values.put('label', "${zone.name} Heating Zone (Evohome)")
401                                                         log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label},  DNI: ${dni}"
402                                         d = addChildDevice(app.namespace, "Evohome Heating Zone", dni, null, values)
403                                                 } catch (e) {
404                                                         log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}"
405                                                 }
406                                         } 
407                                         
408                                         if(d) {
409                                                 d.generateEvent(values)
410                                         }
411                                 }
412                         }
413                 }
414         }
415         
416         if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}"
417         
418         // Delete Devices:
419         def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) }
420         
421         if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete."
422
423         delete.each {
424                 log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}"
425                 try {
426                         deleteChildDevice(it.deviceNetworkId)
427                 }
428                 catch(e) {
429                         log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}"
430                 }
431         }
432 }
433
434
435
436 /**
437  *  updateChildDevice(onlyZoneId=-1)
438  * 
439  *  Update the attributes of a child device from atomicState.evohomeStatus
440  *  and atomicState.evohomeSchedules.
441  *  
442  *  If onlyZoneId is not specified, then all zones are updated.
443  *
444  *  Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime.
445  *
446  **/
447 void updateChildDevice(onlyZoneId=-1) {
448
449         if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})"
450         
451         atomicState.evohomeStatus.each { loc ->
452                 loc.gateways.each { gateway ->
453                         gateway.temperatureControlSystems.each { tcs ->
454                                 tcs.zones.each { zone ->
455                                         if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified.
456                                         
457                                                 def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId)
458                                                 def d = getChildDevice(dni)
459                                                 if(d) {
460                                                         def schedule = atomicState.evohomeSchedules.find { it.dni == dni}
461                                                         def currSw = getCurrentSwitchpoint(schedule.schedule)
462                                                         def nextSw = getNextSwitchpoint(schedule.schedule)
463
464                                                         def values = [
465                                                                 'temperature': formatTemperature(zone?.temperatureStatus?.temperature),
466                                                                 //'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable,
467                                                                 'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature),
468                                                                 'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature),
469                                                                 'thermostatSetpointMode': formatSetpointMode(zone?.heatSetpointStatus?.setpointMode),
470                                                                 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until,
471                                                                 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode),
472                                                                 'scheduledSetpoint': formatTemperature(currSw.temperature),
473                                                                 'nextScheduledSetpoint': formatTemperature(nextSw.temperature),
474                                                                 'nextScheduledTime': nextSw.time
475                                                         ]
476                                                         if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}"
477                                                         d.generateEvent(values)
478                                                 } else {
479                                                         if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist, so skipping status update."
480                                                 }
481                                         }
482                                 }
483                         }
484                 }
485         }
486 }
487
488
489 /**********************************************************************
490  *  Evohome API Commands:
491  **********************************************************************/
492
493 /**
494  *  authenticate()
495  * 
496  *  Authenticate to Evohome.
497  *
498  **/
499 private authenticate() {
500
501         if (atomicState.debug) log.debug "${app.label}: authenticate()"
502         
503         def requestParams = [
504                 method: 'POST',
505                 uri: 'https://tccna.honeywell.com',
506                 path: '/Auth/OAuth/Token',
507                 headers: [
508                         'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=',
509                         'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml',
510                         'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
511                 ],
512                 body: [
513                         'grant_type':   'password',
514                         'scope':        'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account',
515                         'Username':     settings.prefEvohomeUsername,
516                         'Password':     settings.prefEvohomePassword
517                 ]
518         ]
519
520         try {
521                 httpPost(requestParams) { resp ->
522                         if(resp.status == 200 && resp.data) {
523                                 // Update evohomeAuth:
524                                 // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign.
525                                 def tmpAuth = atomicState.evohomeAuth ?: [:]
526                                 tmpAuth.put('lastUpdated' , now())
527                                         tmpAuth.put('authToken' , resp?.data?.access_token)
528                                         tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0)
529                                         tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000))
530                                         tmpAuth.put('refreshToken' , resp?.data?.refresh_token)
531                                 atomicState.evohomeAuth = tmpAuth
532                                 atomicState.evohomeAuthFailed = false
533                                 
534                                 if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}"
535                                 def exp = new Date(tmpAuth.expiresAt)
536                                 log.info "${app.label}: authenticate(): New Auth Token Expires At: ${exp}"
537
538                                 // Update evohomeHeaders:
539                                 def tmpHeaders = atomicState.evohomeHeaders ?: [:]
540                                         tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}")
541                                         tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249')
542                                         tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml')
543                                 atomicState.evohomeHeaders = tmpHeaders
544                                 
545                                 if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}"
546                                 
547                                 // Now get User Account info:
548                                 getEvohomeUserAccount()
549                         }
550                         else {
551                                 log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}"
552                                 atomicState.evohomeAuthFailed = true
553                         }
554                 }
555         } catch (groovyx.net.http.HttpResponseException e) {
556                 log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}"
557                 atomicState.evohomeAuthFailed = true
558         }
559         
560 }
561
562
563 /**
564  *  refreshAuthToken()
565  * 
566  *  Refresh Auth Token.
567  *  If token refresh fails, then authenticate() is called.
568  *  Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'.
569  *
570  **/
571 private refreshAuthToken() {
572
573         if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()"
574
575         def requestParams = [
576                 method: 'POST',
577                 uri: 'https://tccna.honeywell.com',
578                 path: '/Auth/OAuth/Token',
579                 headers: [
580                         'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=',
581                         'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml',
582                         'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
583                 ],
584                 body: [
585                         'grant_type':   'refresh_token',
586                         'scope':        'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account',
587                         'refresh_token':        atomicState.evohomeAuth.refreshToken
588                 ]
589         ]
590
591         try {
592                 httpPost(requestParams) { resp ->
593                         if(resp.status == 200 && resp.data) {
594                                 // Update evohomeAuth:
595                                 // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign.
596                                 def tmpAuth = atomicState.evohomeAuth ?: [:]
597                                 tmpAuth.put('lastUpdated' , now())
598                                         tmpAuth.put('authToken' , resp?.data?.access_token)
599                                         tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0)
600                                         tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000))
601                                         tmpAuth.put('refreshToken' , resp?.data?.refresh_token)
602                                 atomicState.evohomeAuth = tmpAuth
603                                 atomicState.evohomeAuthFailed = false
604                                 
605                                 if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}"
606                                 def exp = new Date(tmpAuth.expiresAt)
607                                 log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}"
608
609                                 // Update evohomeHeaders:
610                                 def tmpHeaders = atomicState.evohomeHeaders ?: [:]
611                                         tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}")
612                                         tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249')
613                                         tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml')
614                                 atomicState.evohomeHeaders = tmpHeaders
615                                 
616                                 if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}"
617                                 
618                                 // Now get User Account info:
619                                 getEvohomeUserAccount()
620                         }
621                         else {
622                                 log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}"
623                         }
624                 }
625         } catch (groovyx.net.http.HttpResponseException e) {
626                 log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}"
627                 // If Unauthorized (401) then re-authenticate:
628                 if (e.statusCode == 401) {
629                         atomicState.evohomeAuthFailed = true
630                         authenticate()
631                 }
632         }
633         
634 }
635
636
637 /**
638  *  getEvohomeUserAccount()
639  * 
640  *  Gets user account info and stores in atomicState.evohomeUserAccount.
641  *
642  **/
643 private getEvohomeUserAccount() {
644
645         log.info "${app.label}: getEvohomeUserAccount(): Getting user account information."
646         
647         def requestParams = [
648                 method: 'GET',
649                 uri: atomicState.evohomeEndpoint,
650                 path: '/WebAPI/emea/api/v1/userAccount',
651                 headers: atomicState.evohomeHeaders
652         ]
653
654         try {
655                 httpGet(requestParams) { resp ->
656                         if (resp.status == 200 && resp.data) {
657                                 atomicState.evohomeUserAccount = resp.data
658                                 if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}"
659                         }
660                         else {
661                                 log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}"
662                         }
663                 }
664         } catch (groovyx.net.http.HttpResponseException e) {
665                 log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}"
666                 if (e.statusCode == 401) {
667                         atomicState.evohomeAuthFailed = true
668                 }
669         }
670 }
671
672
673
674 /**
675  *  getEvohomeConfig()
676  * 
677  *  Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig.
678  *
679  **/
680 private getEvohomeConfig() {
681
682         log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations."
683
684         def requestParams = [
685                 method: 'GET',
686                 uri: atomicState.evohomeEndpoint,
687                 path: '/WebAPI/emea/api/v1/location/installationInfo',
688                 query: [
689                         'userId': atomicState.evohomeUserAccount.userId,
690                         'includeTemperatureControlSystems': 'True'
691                 ],
692                 headers: atomicState.evohomeHeaders
693         ]
694
695         try {
696                 httpGet(requestParams) { resp ->
697                         if (resp.status == 200 && resp.data) {
698                                 if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}"
699                                 atomicState.evohomeConfig = resp.data
700                                 atomicState.evohomeConfigUpdatedAt = now()
701                                 return null
702                         }
703                         else {
704                                 log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}"
705                                 return 'error'
706                         }
707                 }
708         } catch (groovyx.net.http.HttpResponseException e) {
709                 log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}"
710                 if (e.statusCode == 401) {
711                         atomicState.evohomeAuthFailed = true
712                 }
713                 return e
714         }
715 }
716
717
718 /**
719  *  getEvohomeStatus(onlyZoneId=-1)
720  * 
721  *  Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus.
722  *  If onlyZoneId is not specified, all zones are updated.
723  *
724  **/
725 private getEvohomeStatus(onlyZoneId=-1) {
726
727         if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})"
728         
729         def newEvohomeStatus = []
730         
731         if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location):
732                 
733                 log.info "${app.label}: getEvohomeStatus(): Getting status for all zones."
734                 
735                 atomicState.evohomeConfig.each { loc ->
736                         def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId)
737                         if (locStatus) {
738                                 newEvohomeStatus << locStatus
739                         }
740                 }
741
742                 if (newEvohomeStatus) {
743                         // Write out newEvohomeStatus back to atomicState:
744                         atomicState.evohomeStatus = newEvohomeStatus
745                         atomicState.evohomeStatusUpdatedAt = now()
746                 }
747         }
748         else { // Only update the specified zone:
749                 
750                 log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}"
751                 
752                 def newZoneStatus = getEvohomeZoneStatus(onlyZoneId)
753                 if (newZoneStatus) {
754                         // Get existing evohomeStatus and update only the specified zone, preserving data for other zones:
755                         // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate).
756                         // If mutiple zones are requesting updates at the same time this could cause loss of new data, but
757                         // the worse case is having out-of-date data for a few minutes...
758                         newEvohomeStatus = atomicState.evohomeStatus
759                         newEvohomeStatus.each { loc ->
760                                 loc.gateways.each { gateway ->
761                                         gateway.temperatureControlSystems.each { tcs ->
762                                                 tcs.zones.each { zone ->
763                                                         if (onlyZoneId == zone.zoneId) { // This is the zone that must be updated:
764                                                                 zone.activeFaults = newZoneStatus.activeFaults
765                                                                 zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus
766                                                                 zone.temperatureStatus = newZoneStatus.temperatureStatus
767                                                         }
768                                                 }
769                                         }
770                                 }
771                         }
772                         // Write out newEvohomeStatus back to atomicState:
773                         atomicState.evohomeStatus = newEvohomeStatus
774                         // Note: atomicState.evohomeStatusUpdatedAt is NOT updated.
775                 } 
776         }
777 }
778
779
780 /**
781  *  getEvohomeLocationStatus(locationId)
782  * 
783  *  Gets the status for a specific location and returns data as a map.
784  *
785  *  Called by getEvohomeStatus().
786  **/
787 private getEvohomeLocationStatus(locationId) {
788
789         if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}"
790         
791         def requestParams = [
792                 'method': 'GET',
793                 'uri': atomicState.evohomeEndpoint,
794                 'path': "/WebAPI/emea/api/v1/location/${locationId}/status", 
795                 'query': [ 'includeTemperatureControlSystems': 'True'],
796                 'headers': atomicState.evohomeHeaders
797         ]
798
799         try {
800                 httpGet(requestParams) { resp ->
801                         if(resp.status == 200 && resp.data) {
802                                 if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}"
803                                 return resp.data
804                         }
805                         else {
806                                 log.error "${app.label}: getEvohomeLocationStatus:  No Data. Response Status: ${resp.status}"
807                                 return false
808                         }
809                 }
810         } catch (groovyx.net.http.HttpResponseException e) {
811                 log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}"
812                 if (e.statusCode == 401) {
813                         atomicState.evohomeAuthFailed = true
814                 }
815                 return false
816         }
817 }
818
819
820 /**
821  *  getEvohomeZoneStatus(zoneId)
822  * 
823  *  Gets the status for a specific zone and returns data as a map.
824  *
825  **/
826 private getEvohomeZoneStatus(zoneId) {
827
828         if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})"
829         
830         def requestParams = [
831                 'method': 'GET',
832                 'uri': atomicState.evohomeEndpoint,
833                 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status",
834                 'headers': atomicState.evohomeHeaders
835         ]
836
837         try {
838                 httpGet(requestParams) { resp ->
839                         if(resp.status == 200 && resp.data) {
840                                 if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}"
841                                 return resp.data
842                         }
843                         else {
844                                 log.error "${app.label}: getEvohomeZoneStatus:  No Data. Response Status: ${resp.status}"
845                                 return false
846                         }
847                 }
848         } catch (groovyx.net.http.HttpResponseException e) {
849                 log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}"
850                 if (e.statusCode == 401) {
851                         atomicState.evohomeAuthFailed = true
852                 }
853                 return false
854         }
855 }
856
857
858 /**
859  *  getEvohomeSchedules()
860  * 
861  *  Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules.
862  *
863  **/
864 private getEvohomeSchedules() {
865
866         log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones."
867                         
868         def evohomeSchedules = []
869                 
870         atomicState.evohomeConfig.each { loc ->
871                 loc.gateways.each { gateway ->
872                         gateway.temperatureControlSystems.each { tcs ->
873                                 tcs.zones.each { zone ->
874                                         def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId )
875                                         def schedule = getEvohomeZoneSchedule(zone.zoneId)
876                                         if (schedule) {
877                                                 evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule]
878                                         }
879                                 }
880                         }
881                 }
882         }
883
884         if (evohomeSchedules) {
885                 // Write out complete schedules to state:
886                 atomicState.evohomeSchedules = evohomeSchedules
887                 atomicState.evohomeSchedulesUpdatedAt = now()
888         }
889
890         return evohomeSchedules
891 }
892
893
894 /**
895  *  getEvohomeZoneSchedule(zoneId)
896  * 
897  *  Gets the schedule for a specific zone and returns data as a map.
898  *
899  **/
900 private getEvohomeZoneSchedule(zoneId) {
901         if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule(${zoneId})"
902         
903         def requestParams = [
904                 'method': 'GET',
905                 'uri': atomicState.evohomeEndpoint,
906                 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule",
907                 'headers': atomicState.evohomeHeaders
908         ]
909
910         try {
911                 httpGet(requestParams) { resp ->
912                         if(resp.status == 200 && resp.data) {
913                                 if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule: Data: ${resp.data}"
914                                 return resp.data
915                         }
916                         else {
917                                 log.error "${app.label}: getEvohomeZoneSchedule:  No Data. Response Status: ${resp.status}"
918                                 return false
919                         }
920                 }
921         } catch (groovyx.net.http.HttpResponseException e) {
922                 log.error "${app.label}: getEvohomeZoneSchedule: Error: e.statusCode ${e.statusCode}"
923                 if (e.statusCode == 401) {
924                         atomicState.evohomeAuthFailed = true
925                 }
926                 return false
927         }
928 }
929
930
931 /**
932  *  setThermostatMode(systemId, mode, until)
933  * 
934  *  Set thermostat mode for specified controller, until specified time.
935  *
936  *   systemId:   SystemId of temperatureControlSystem. E.g.: 123456
937  *
938  *   mode:       String. Either: "auto", "off", "economy", "away", "dayOff", "custom".
939  *
940  *   until:      (Optional) Time to apply mode until, can be either:
941  *                - Date: date object representing when override should end.
942  *                - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z".
943  *                - String: 'permanent'.
944  *                - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'.
945  *                          Duration will be rounded down to align with Midnight in the local timezone
946  *                          (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent.
947  *                If 'until' is not specified, a default value is used from the SmartApp settings.
948  *
949  *   Notes:      'Auto' and 'Off' modes are always permanent.
950  *               Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller).
951  *               Therefore changing the thermostatMode will affect all zones associated with the same controller.
952  * 
953  * 
954  *  Example usage:
955  *   setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456.
956  *   setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456.
957  *   setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456.
958  *   setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456.
959  *   setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456.
960  *
961  **/
962 def setThermostatMode(systemId, mode, until=-1) {
963
964         if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}"
965         
966         // Clean mode (translate to index):
967         mode = mode.toLowerCase()
968         int modeIndex
969         switch (mode) {
970                 case 'auto':
971                         modeIndex = 0
972                         break
973                 case 'off':
974                         modeIndex = 1
975                         break
976                 case 'economy':
977                         modeIndex = 2
978                         break
979                 case 'away':
980                         modeIndex = 3
981                         break
982                 case 'dayoff':
983                         modeIndex = 4
984                         break
985                 case 'custom':
986                         modeIndex = 6
987                         break
988                 default:
989                         log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!"
990                         modeIndex = 999
991                         break
992         }
993         
994         // Clean until:
995         def untilRes
996         
997         // until has not been specified, so determine behaviour from settings:
998         if (-1 == until && 'economy' == mode) { 
999                 until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours):
1000         }
1001         else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) {
1002                 until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days):
1003         }
1004         
1005         // Convert to date (or 0):    
1006         if ('permanent' == until || 0 == until || -1 == until) {
1007                 untilRes = 0
1008         }
1009         else if (until instanceof Date) {
1010                 untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
1011         }
1012         else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC:
1013                 untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
1014         }
1015         else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours:
1016                 untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
1017         }
1018         else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days:
1019                 untilRes = new Date( now() + (Math.round(until) * 86400000) ).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) // Round down to midnight in the LOCAL timezone.
1020         }
1021         else {
1022                 log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently."
1023                 untilRes = 0
1024         }
1025         
1026         // If mode is away/dayOff/custom the date needs to be rounded down to midnight in the local timezone, then converted back to string again:
1027         if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { 
1028                 untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilRes).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC'))
1029         }
1030
1031         // Build request:
1032         def body
1033         if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent:
1034                 body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True']
1035                 log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True"
1036         }
1037         else { // Mode is temporary:
1038                 body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False']
1039                 log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}"
1040         }
1041         
1042         def requestParams = [
1043                 'uri': atomicState.evohomeEndpoint,
1044                 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", 
1045                 'body': body,
1046                 'headers': atomicState.evohomeHeaders
1047         ]
1048         
1049         // Make request:
1050         try {
1051                 httpPutJson(requestParams) { resp ->
1052                         if(resp.status == 201 && resp.data) {
1053                                 if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}"
1054                                 return null
1055                         }
1056                         else {
1057                                 log.error "${app.label}: setThermostatMode():  No Data. Response Status: ${resp.status}"
1058                                 return 'error'
1059                         }
1060                 }
1061         } catch (groovyx.net.http.HttpResponseException e) {
1062                 log.error "${app.label}: setThermostatMode(): Error: ${e}"
1063                 if (e.statusCode == 401) {
1064                         atomicState.evohomeAuthFailed = true
1065                 }
1066                 return e
1067         }
1068 }
1069
1070
1071  /**
1072  *  setHeatingSetpoint(zoneId, setpoint, until=-1)
1073  * 
1074  *  Set heatingSetpoint for specified zoneId, until specified time.
1075  *
1076  *   zoneId:     Zone ID of zone, e.g.: "123456"
1077  *
1078  *   setpoint:   Setpoint temperature, e.g.: "21.5". Can be a number or string.
1079  *
1080  *   until:      (Optional) Time to apply setpoint until, can be either:
1081  *                - Date: date object representing when override should end.
1082  *                - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z".
1083  *                - String: 'permanent'.
1084  *               If not specified, setpoint will be applied permanently.
1085  *
1086  *  Example usage:
1087  *   setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456.
1088  *   setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456.
1089  *   setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456.
1090  *
1091  **/
1092 def setHeatingSetpoint(zoneId, setpoint, until=-1) {
1093
1094         if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}"
1095         
1096         // Clean setpoint:
1097         setpoint = formatTemperature(setpoint)
1098         
1099         // Clean until:
1100         def untilRes
1101         if ('permanent' == until || 0 == until || -1 == until) {
1102                 untilRes = 0
1103         }
1104         else if (until instanceof Date) {
1105                 untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
1106         }
1107         else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC:
1108                 untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
1109         }
1110         else {
1111                 log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently."
1112                 untilRes = 0
1113         }
1114         
1115         // Build request:
1116         def body
1117         if (0 == untilRes) { // Permanent:
1118                 body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null]
1119                 log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent"
1120         }
1121         else { // Temporary:
1122                 body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes]
1123                 log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}"
1124         }
1125         
1126         def requestParams = [
1127                 'uri': atomicState.evohomeEndpoint,
1128                 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", 
1129                 'body': body,
1130                 'headers': atomicState.evohomeHeaders
1131         ]
1132         
1133         // Make request:
1134         try {
1135                 httpPutJson(requestParams) { resp ->
1136                         if(resp.status == 201 && resp.data) {
1137                                 if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}"
1138                                 return null
1139                         }
1140                         else {
1141                                 log.error "${app.label}: setHeatingSetpoint():  No Data. Response Status: ${resp.status}"
1142                                 return 'error'
1143                         }
1144                 }
1145         } catch (groovyx.net.http.HttpResponseException e) {
1146                 log.error "${app.label}: setHeatingSetpoint(): Error: ${e}"
1147                 if (e.statusCode == 401) {
1148                         atomicState.evohomeAuthFailed = true
1149                 }
1150                 return e
1151         }
1152 }
1153
1154
1155 /**
1156  *  clearHeatingSetpoint(zoneId)
1157  * 
1158  *  Clear the heatingSetpoint for specified zoneId.
1159  *   zoneId:     Zone ID of zone, e.g.: "123456"
1160  **/
1161 def clearHeatingSetpoint(zoneId) {
1162
1163         log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}"
1164         
1165         // Build request:
1166         def requestParams = [
1167                 'uri': atomicState.evohomeEndpoint,
1168                 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", 
1169                 'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null],
1170                 'headers': atomicState.evohomeHeaders
1171         ]
1172         
1173         // Make request:
1174         try {
1175                 httpPutJson(requestParams) { resp ->
1176                         if(resp.status == 201 && resp.data) {
1177                                 if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}"
1178                                 return null
1179                         }
1180                         else {
1181                                 log.error "${app.label}: clearHeatingSetpoint():  No Data. Response Status: ${resp.status}"
1182                                 return 'error'
1183                         }
1184                 }
1185         } catch (groovyx.net.http.HttpResponseException e) {
1186                 log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}"
1187                 if (e.statusCode == 401) {
1188                         atomicState.evohomeAuthFailed = true
1189                 }
1190                 return e
1191         }
1192 }
1193
1194
1195 /**********************************************************************
1196  *  Helper Commands:
1197  **********************************************************************/
1198  
1199  /**
1200  *  generateDni(locId,gatewayId,systemId,deviceId)
1201  * 
1202  *  Generate a device Network ID.
1203  *  Uses the same format as the official Evohome App, but with a prefix of "Evohome."
1204  **/
1205 private generateDni(locId,gatewayId,systemId,deviceId) {
1206         return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.')
1207 }
1208  
1209  
1210 /**
1211  *  formatTemperature(t)
1212  * 
1213  *  Format temperature value to one decimal place.
1214  *  t:   can be string, float, bigdecimal...
1215  *  Returns as string.
1216  **/
1217 private formatTemperature(t) {
1218         return Float.parseFloat("${t}").round(1).toString()
1219 }
1220  
1221  
1222  /**
1223  *  formatSetpointMode(mode)
1224  * 
1225  *  Format Evohome setpointMode values to SmartThings values:
1226  *   
1227  **/
1228 private formatSetpointMode(mode) {
1229  
1230         switch (mode) {
1231                 case 'FollowSchedule':
1232                         mode = 'followSchedule'
1233                         break
1234                 case 'PermanentOverride':
1235                         mode = 'permanentOverride'
1236                         break
1237                 case 'TemporaryOverride':
1238                         mode = 'temporaryOverride'
1239                         break
1240                 default:
1241                         log.error "${app.label}: formatSetpointMode(): Mode: ${mode} unknown!"
1242                         mode = mode.toLowerCase()
1243                         break
1244         }
1245
1246         return mode
1247 }
1248  
1249  
1250 /**
1251  *  formatThermostatMode(mode)
1252  * 
1253  *  Translate Evohome thermostatMode values to SmartThings values.
1254  *   
1255  **/
1256 private formatThermostatMode(mode) {
1257  
1258         switch (mode) {
1259                 case 'Auto':
1260                         mode = 'auto'
1261                         break
1262                 case 'AutoWithEco':
1263                         mode = 'economy'
1264                         break
1265                 case 'Away':
1266                         mode = 'away'
1267                         break
1268                 case 'Custom':
1269                         mode = 'custom'
1270                         break
1271                 case 'DayOff':
1272                         mode = 'dayOff'
1273                         break
1274                 case 'HeatingOff':
1275                         mode = 'off'
1276                         break
1277                 default:
1278                         log.error "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!"
1279                         mode = mode.toLowerCase()
1280                         break
1281         }
1282
1283         return mode
1284 }
1285   
1286
1287 /**
1288  *  getCurrentSwitchpoint(schedule)
1289  * 
1290  *  Returns the current active switchpoint in the given schedule.
1291  *  e.g. [timeOfDay:"23:00:00", temperature:"15.0000"]
1292  *   
1293  **/
1294 private getCurrentSwitchpoint(schedule) {
1295
1296         if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()"
1297         
1298         Calendar c = new GregorianCalendar()
1299         def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }
1300         
1301         // Sort and find next switchpoint:
1302         ScheduleToday.switchpoints.sort {it.timeOfDay}
1303         ScheduleToday.switchpoints.reverse(true)
1304         def currentSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay < c.getTime().format("HH:mm:ss", location.timeZone)}
1305         
1306         if (!currentSwitchPoint) {
1307                 // There are no current switchpoints today, so we must look for the last Switchpoint yesterday.
1308                 if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): No current switchpoints today, so must look to yesterday's schedule."
1309                 c.add(Calendar.DATE, -1 ) // Subtract one DAY.
1310                 def ScheduleYesterday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }
1311                 ScheduleYesterday.switchpoints.sort {it.timeOfDay}
1312                 ScheduleYesterday.switchpoints.reverse(true)
1313                 currentSwitchPoint = ScheduleYesterday.switchpoints[0] // There will always be one.
1314         }
1315         
1316         // Now construct the switchpoint time as a full ISO-8601 format date string in UTC:
1317         def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + currentSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone.
1318         def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone.    
1319         currentSwitchPoint << [ 'time': isoDateStr ]
1320         if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): Current Switchpoint: ${currentSwitchPoint}"
1321         
1322         return currentSwitchPoint
1323 }
1324  
1325
1326 /**
1327  *  getNextSwitchpoint(schedule)
1328  * 
1329  *  Returns the next switchpoint in the given schedule.
1330  *  e.g. [timeOfDay:"23:00:00", temperature:"15.0000"]
1331  *   
1332  **/
1333 private getNextSwitchpoint(schedule) {
1334
1335         if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()"
1336         
1337         Calendar c = new GregorianCalendar()
1338         def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }
1339         
1340         // Sort and find next switchpoint:
1341         ScheduleToday.switchpoints.sort {it.timeOfDay}
1342         def nextSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay > c.getTime().format("HH:mm:ss", location.timeZone)}
1343         
1344         if (!nextSwitchPoint) {
1345                 // There are no switchpoints left today, so we must look for the first Switchpoint tomorrow.
1346                 if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): No more switchpoints today, so must look to tomorrow's schedule."
1347                 c.add(Calendar.DATE, 1 ) // Add one DAY.
1348                 def ScheduleTmrw = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }
1349                 ScheduleTmrw.switchpoints.sort {it.timeOfDay}
1350                 nextSwitchPoint = ScheduleTmrw.switchpoints[0] // There will always be one.
1351         }
1352
1353         // Now construct the switchpoint time as a full ISO-8601 format date string in UTC:
1354         def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + nextSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone.
1355         def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone.    
1356         nextSwitchPoint << [ 'time': isoDateStr ]
1357         if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): Next Switchpoint: ${nextSwitchPoint}"
1358         
1359         return nextSwitchPoint
1360 }
1361