2 * Copyright 2016 David Lomas (codersaur)
4 * Name: Evohome (Connect)
6 * Author: David Lomas (codersaur)
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
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.
24 * - Additional info log messages.
27 * - Initial Beta Release
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).
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:
41 * http://www.apache.org/licenses/LICENSE-2.0
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.
49 name: "Evohome (Connect)",
50 namespace: "codersaur",
51 author: "David Lomas (codersaur)",
52 description: "Connect your Honeywell Evohome System to SmartThings.",
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",
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'
75 input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true
80 /**********************************************************************
81 * Setup and Configuration Commands:
82 **********************************************************************/
87 * Runs when the app is first installed.
92 atomicState.installedAt = now()
93 log.debug "${app.label}: Installed with settings: ${settings}"
101 * Runs when the app is uninstalled.
105 if(getChildDevices()) {
106 removeChildDevices(getChildDevices())
114 * Runs when app settings are changed.
119 if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}"
122 atomicState.debug = settings.prefDebugMode
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.
132 // Thermostat Mode Durations:
133 atomicState.thermostatModeDuration = settings.prefThermostatModeDuration
134 atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration
136 // Force Authentication:
139 // Refresh Subscriptions and Schedules:
140 manageSubscriptions()
143 // Refresh child device configuration:
145 updateChildDeviceConfig()
147 // Run a poll, but defer it so that updated() returns sooner:
153 /**********************************************************************
154 * Management Commands:
155 **********************************************************************/
160 * Check scheduled tasks have not stalled, and re-schedule if necessary.
161 * Generates a random offset (seconds) for each scheduled task.
164 * - manageAuth() - every 5 mins.
165 * - poll() - every minute.
168 void manageSchedules() {
170 if (atomicState.debug) log.debug "${app.label}: manageSchedules()"
172 // Generate a random offset (1-60):
173 Random rand = new Random(now())
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()"
180 unschedule(manageAuth)
183 //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed"
185 randomOffset = rand.nextInt(60)
186 schedule("${randomOffset} 0/5 * * * ?", "manageAuth")
190 if (1==1) { // To Do: Test if schedule has actually stalled.
191 if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()"
196 //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed"
198 randomOffset = rand.nextInt(60)
199 schedule("${randomOffset} 0/1 * * * ?", "poll")
206 * manageSubscriptions()
208 * Unsubscribe/Subscribe.
210 void manageSubscriptions() {
212 if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()"
217 // Subscribe to App Touch events:
218 subscribe(app,handleAppTouch)
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.
231 * Should be scheduled to run every 1-5 minutes.
235 if (atomicState.debug) log.debug "${app.label}: manageAuth()"
237 // Check if Auth Token is valid, if not authenticate:
238 if (!atomicState.evohomeAuth.authToken) {
240 log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..."
243 else if (atomicState.evohomeAuthFailed) {
245 log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..."
248 else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) {
250 log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..."
254 // Check if Auth Token should be refreshed:
255 def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100))
257 if (now() >= refreshAt) {
258 log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires."
262 log.info "${app.label}: manageAuth(): Auth Token is okay."
270 * poll(onlyZoneId=-1)
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.
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}.
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.
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.
285 * If onlyZoneId is not specified all zones are updated, but only if the relevent poll
286 * interval has been exceeded.
289 void poll(onlyZoneId=-1) {
291 if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})"
293 // Check if there's been an authentication failure:
294 if (atomicState.evohomeAuthFailed) {
298 if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update):
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)
306 else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded:
308 // Adjust intervals to allow for poll() execution time:
309 def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30
310 def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30
313 if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) {
317 // Get zone schedules:
318 if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) {
319 getEvohomeSchedules()
322 // Update all child devices:
329 /**********************************************************************
331 **********************************************************************/
335 * handleAppTouch(evt)
337 * App touch event handler.
338 * Used for testing and debugging.
341 void handleAppTouch(evt) {
343 if (atomicState.debug) log.debug "${app.label}: handleAppTouch()"
349 //updateChildDeviceConfig()
356 /**********************************************************************
357 * SmartApp-Child Interface Commands:
358 **********************************************************************/
361 * updateChildDeviceConfig()
363 * Add/Remove/Update Child Devices based on atomicState.evohomeConfig
364 * and update their internal state.
367 void updateChildDeviceConfig() {
369 if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()"
371 // Build list of active DNIs, any existing children with DNIs not in here will be deleted.
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 ->
380 def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId )
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
397 def d = getChildDevice(dni)
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)
404 log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}"
409 d.generateEvent(values)
416 if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}"
419 def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) }
421 if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete."
424 log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}"
426 deleteChildDevice(it.deviceNetworkId)
429 log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}"
437 * updateChildDevice(onlyZoneId=-1)
439 * Update the attributes of a child device from atomicState.evohomeStatus
440 * and atomicState.evohomeSchedules.
442 * If onlyZoneId is not specified, then all zones are updated.
444 * Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime.
447 void updateChildDevice(onlyZoneId=-1) {
449 if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})"
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.
457 def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId)
458 def d = getChildDevice(dni)
460 def schedule = atomicState.evohomeSchedules.find { it.dni == dni}
461 def currSw = getCurrentSwitchpoint(schedule.schedule)
462 def nextSw = getNextSwitchpoint(schedule.schedule)
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
476 if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}"
477 d.generateEvent(values)
479 if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist, so skipping status update."
489 /**********************************************************************
490 * Evohome API Commands:
491 **********************************************************************/
496 * Authenticate to Evohome.
499 private authenticate() {
501 if (atomicState.debug) log.debug "${app.label}: authenticate()"
503 def requestParams = [
505 uri: 'https://tccna.honeywell.com',
506 path: '/Auth/OAuth/Token',
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'
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
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
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}"
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
545 if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}"
547 // Now get User Account info:
548 getEvohomeUserAccount()
551 log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}"
552 atomicState.evohomeAuthFailed = true
555 } catch (groovyx.net.http.HttpResponseException e) {
556 log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}"
557 atomicState.evohomeAuthFailed = true
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'.
571 private refreshAuthToken() {
573 if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()"
575 def requestParams = [
577 uri: 'https://tccna.honeywell.com',
578 path: '/Auth/OAuth/Token',
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'
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
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
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}"
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
616 if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}"
618 // Now get User Account info:
619 getEvohomeUserAccount()
622 log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}"
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
638 * getEvohomeUserAccount()
640 * Gets user account info and stores in atomicState.evohomeUserAccount.
643 private getEvohomeUserAccount() {
645 log.info "${app.label}: getEvohomeUserAccount(): Getting user account information."
647 def requestParams = [
649 uri: atomicState.evohomeEndpoint,
650 path: '/WebAPI/emea/api/v1/userAccount',
651 headers: atomicState.evohomeHeaders
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}"
661 log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}"
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
677 * Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig.
680 private getEvohomeConfig() {
682 log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations."
684 def requestParams = [
686 uri: atomicState.evohomeEndpoint,
687 path: '/WebAPI/emea/api/v1/location/installationInfo',
689 'userId': atomicState.evohomeUserAccount.userId,
690 'includeTemperatureControlSystems': 'True'
692 headers: atomicState.evohomeHeaders
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()
704 log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}"
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
719 * getEvohomeStatus(onlyZoneId=-1)
721 * Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus.
722 * If onlyZoneId is not specified, all zones are updated.
725 private getEvohomeStatus(onlyZoneId=-1) {
727 if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})"
729 def newEvohomeStatus = []
731 if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location):
733 log.info "${app.label}: getEvohomeStatus(): Getting status for all zones."
735 atomicState.evohomeConfig.each { loc ->
736 def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId)
738 newEvohomeStatus << locStatus
742 if (newEvohomeStatus) {
743 // Write out newEvohomeStatus back to atomicState:
744 atomicState.evohomeStatus = newEvohomeStatus
745 atomicState.evohomeStatusUpdatedAt = now()
748 else { // Only update the specified zone:
750 log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}"
752 def newZoneStatus = getEvohomeZoneStatus(onlyZoneId)
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
772 // Write out newEvohomeStatus back to atomicState:
773 atomicState.evohomeStatus = newEvohomeStatus
774 // Note: atomicState.evohomeStatusUpdatedAt is NOT updated.
781 * getEvohomeLocationStatus(locationId)
783 * Gets the status for a specific location and returns data as a map.
785 * Called by getEvohomeStatus().
787 private getEvohomeLocationStatus(locationId) {
789 if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}"
791 def requestParams = [
793 'uri': atomicState.evohomeEndpoint,
794 'path': "/WebAPI/emea/api/v1/location/${locationId}/status",
795 'query': [ 'includeTemperatureControlSystems': 'True'],
796 'headers': atomicState.evohomeHeaders
800 httpGet(requestParams) { resp ->
801 if(resp.status == 200 && resp.data) {
802 if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}"
806 log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}"
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
821 * getEvohomeZoneStatus(zoneId)
823 * Gets the status for a specific zone and returns data as a map.
826 private getEvohomeZoneStatus(zoneId) {
828 if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})"
830 def requestParams = [
832 'uri': atomicState.evohomeEndpoint,
833 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status",
834 'headers': atomicState.evohomeHeaders
838 httpGet(requestParams) { resp ->
839 if(resp.status == 200 && resp.data) {
840 if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}"
844 log.error "${app.label}: getEvohomeZoneStatus: No Data. Response Status: ${resp.status}"
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
859 * getEvohomeSchedules()
861 * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules.
864 private getEvohomeSchedules() {
866 log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones."
868 def evohomeSchedules = []
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)
877 evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule]
884 if (evohomeSchedules) {
885 // Write out complete schedules to state:
886 atomicState.evohomeSchedules = evohomeSchedules
887 atomicState.evohomeSchedulesUpdatedAt = now()
890 return evohomeSchedules
895 * getEvohomeZoneSchedule(zoneId)
897 * Gets the schedule for a specific zone and returns data as a map.
900 private getEvohomeZoneSchedule(zoneId) {
901 if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule(${zoneId})"
903 def requestParams = [
905 'uri': atomicState.evohomeEndpoint,
906 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule",
907 'headers': atomicState.evohomeHeaders
911 httpGet(requestParams) { resp ->
912 if(resp.status == 200 && resp.data) {
913 if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule: Data: ${resp.data}"
917 log.error "${app.label}: getEvohomeZoneSchedule: No Data. Response Status: ${resp.status}"
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
932 * setThermostatMode(systemId, mode, until)
934 * Set thermostat mode for specified controller, until specified time.
936 * systemId: SystemId of temperatureControlSystem. E.g.: 123456
938 * mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom".
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.
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.
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.
962 def setThermostatMode(systemId, mode, until=-1) {
964 if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}"
966 // Clean mode (translate to index):
967 mode = mode.toLowerCase()
989 log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!"
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):
1001 else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) {
1002 until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days):
1005 // Convert to date (or 0):
1006 if ('permanent' == until || 0 == until || -1 == until) {
1009 else if (until instanceof Date) {
1010 untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
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.
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.
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.
1022 log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently."
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'))
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"
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}"
1042 def requestParams = [
1043 'uri': atomicState.evohomeEndpoint,
1044 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode",
1046 'headers': atomicState.evohomeHeaders
1051 httpPutJson(requestParams) { resp ->
1052 if(resp.status == 201 && resp.data) {
1053 if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}"
1057 log.error "${app.label}: setThermostatMode(): No Data. Response Status: ${resp.status}"
1061 } catch (groovyx.net.http.HttpResponseException e) {
1062 log.error "${app.label}: setThermostatMode(): Error: ${e}"
1063 if (e.statusCode == 401) {
1064 atomicState.evohomeAuthFailed = true
1072 * setHeatingSetpoint(zoneId, setpoint, until=-1)
1074 * Set heatingSetpoint for specified zoneId, until specified time.
1076 * zoneId: Zone ID of zone, e.g.: "123456"
1078 * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string.
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.
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.
1092 def setHeatingSetpoint(zoneId, setpoint, until=-1) {
1094 if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}"
1097 setpoint = formatTemperature(setpoint)
1101 if ('permanent' == until || 0 == until || -1 == until) {
1104 else if (until instanceof Date) {
1105 untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
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.
1111 log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently."
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"
1121 else { // Temporary:
1122 body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes]
1123 log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}"
1126 def requestParams = [
1127 'uri': atomicState.evohomeEndpoint,
1128 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint",
1130 'headers': atomicState.evohomeHeaders
1135 httpPutJson(requestParams) { resp ->
1136 if(resp.status == 201 && resp.data) {
1137 if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}"
1141 log.error "${app.label}: setHeatingSetpoint(): No Data. Response Status: ${resp.status}"
1145 } catch (groovyx.net.http.HttpResponseException e) {
1146 log.error "${app.label}: setHeatingSetpoint(): Error: ${e}"
1147 if (e.statusCode == 401) {
1148 atomicState.evohomeAuthFailed = true
1156 * clearHeatingSetpoint(zoneId)
1158 * Clear the heatingSetpoint for specified zoneId.
1159 * zoneId: Zone ID of zone, e.g.: "123456"
1161 def clearHeatingSetpoint(zoneId) {
1163 log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}"
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
1175 httpPutJson(requestParams) { resp ->
1176 if(resp.status == 201 && resp.data) {
1177 if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}"
1181 log.error "${app.label}: clearHeatingSetpoint(): No Data. Response Status: ${resp.status}"
1185 } catch (groovyx.net.http.HttpResponseException e) {
1186 log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}"
1187 if (e.statusCode == 401) {
1188 atomicState.evohomeAuthFailed = true
1195 /**********************************************************************
1197 **********************************************************************/
1200 * generateDni(locId,gatewayId,systemId,deviceId)
1202 * Generate a device Network ID.
1203 * Uses the same format as the official Evohome App, but with a prefix of "Evohome."
1205 private generateDni(locId,gatewayId,systemId,deviceId) {
1206 return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.')
1211 * formatTemperature(t)
1213 * Format temperature value to one decimal place.
1214 * t: can be string, float, bigdecimal...
1215 * Returns as string.
1217 private formatTemperature(t) {
1218 return Float.parseFloat("${t}").round(1).toString()
1223 * formatSetpointMode(mode)
1225 * Format Evohome setpointMode values to SmartThings values:
1228 private formatSetpointMode(mode) {
1231 case 'FollowSchedule':
1232 mode = 'followSchedule'
1234 case 'PermanentOverride':
1235 mode = 'permanentOverride'
1237 case 'TemporaryOverride':
1238 mode = 'temporaryOverride'
1241 log.error "${app.label}: formatSetpointMode(): Mode: ${mode} unknown!"
1242 mode = mode.toLowerCase()
1251 * formatThermostatMode(mode)
1253 * Translate Evohome thermostatMode values to SmartThings values.
1256 private formatThermostatMode(mode) {
1278 log.error "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!"
1279 mode = mode.toLowerCase()
1288 * getCurrentSwitchpoint(schedule)
1290 * Returns the current active switchpoint in the given schedule.
1291 * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"]
1294 private getCurrentSwitchpoint(schedule) {
1296 if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()"
1298 Calendar c = new GregorianCalendar()
1299 def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }
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)}
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.
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}"
1322 return currentSwitchPoint
1327 * getNextSwitchpoint(schedule)
1329 * Returns the next switchpoint in the given schedule.
1330 * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"]
1333 private getNextSwitchpoint(schedule) {
1335 if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()"
1337 Calendar c = new GregorianCalendar()
1338 def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }
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)}
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.
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}"
1359 return nextSwitchPoint