Plea for Help with Unmaintained Evohome HE App

Hi,

I am working on migrating from SmartThings to Hubitat and after numerous re-installs and attempts to troubleshoot I'm struggling to get the unmaintained Evohome App working to control temperatures. It seems to connect and retrieve state information but attempts to change temperatures throw errors as in the logs below. I installed the app & driver as outlined in this thread: Honeywell Evohome

The thermostats for the individual radiators update their temperature, so I think it's a sign that Hubitat at least receives info from the thermostats.

I'm not really skilled in Groovy and wondered whether any of you smart guys out there might be able to give me some pointers to daignose and fix this issue?

It's also my first use of Hubitat community support so apologies in advance if I am posting in the wrong place. I have diligently read all previous posts on this topic!

Any help appreciated.

Simon

I'm not going to promise I can solve the issues you are seeing, but at least a link to the driver (if it is available on the web somewhere) or posting some of the code around line 898 may help me (likely others) solve the issue.

Iā€™m one of the few whoā€™s also using it. Mine is working fine with a few caveats.

The logging isnā€™t right in both the app and driver. With some help on here a few weeks back I managed to merge a lot of the spammy info logging with debug logging so that itā€™s all or nothing. That has worked ok for me. If you look back through my posts youā€™ll probably find it. Iā€™ll edit this post with a link if I can.

The capabilities donā€™t align with those for thermostat correctly. As we have heat only, no cooling, I get errors when I manually make an adjustment on ā€˜Hubitat Dashboardā€™ I made a rule for that last night so I can type a value on a variable tile and the rule triggers sending that value as heatingSetoint. Strangely it works fine with SharpTools thermostat tile and also with Alexa.

I believe the modes arenā€™t correct Auto, Economy, Day Off, Away, Custom. Again Iā€™ve gotten round this with virtual switches for the modes. I actually prefer it that way as the driver allows you to adjust the temperature in an individual room while the mode is ā€˜awayā€™ - something the Honeywell app will not allow.

All in all it works well and Iā€™ve managed to use it in rules for windows open etc - lowering and resuming.

@Simonfea I'll post my amended code for both app and driver shortly:

  • It fixes the big decimal error - this was fixed by someone else on this forum a year or two ago

  • It stops a lot of the unwanted logging

Perhaps the versions I post could be the starting point @sburke781

This was the post where I dealt with (bodged) the logging:

1 Like

Here you go @Simonfea - first here is my app code. In this I've changed much of the info logging level to debug. I did this as there's an existing switch to turn off debug logging in the app. This suppresses a lot of the logging as per the post I linked. I think it also fixed the big decimal error a long time ago.

App Code:

Summary

/**

  • Copyright 2016 David Lomas (codersaur)
  • Name: Evohome (Connect)
  • Author: David Lomas (codersaur)
  • Date: 2016-04-05
  • Version: 0.08
  • Description:
    • Connect your Honeywell Evohome System to SmartThings.
    • Requires the Evohome Heating Zone device handler.
  • Version History:
  • 2022-11-25
    • Merge info logging with debug logging
  • 2016-04-05: v0.08
    • New 'Update Refresh Time' setting to control polling after making an update.
    • poll() - If onlyZoneId is 0, this will force a status update for all zones.
  • 2016-04-04: v0.07
    • Additional info log messages.
  • 2016-04-03: v0.06
    • Initial Beta Release
  • To Do:
    • Add support for hot water zones (new device handler).
    • Allow Evohome zones to be (de)selected as part of the setup process.
    • Enable notifications if connection to Evohome cloud fails.
    • Expose whether thremoStatMode is permanent or temporary, and if temporary for how long. Get from 'tcs.systemModeStatus.*'. i.e thermostatModeUntil
    • Investigate if Evohome supports registing a callback so changes in Evohome are pushed to SmartThings instantly (instead of relying on polling).
  • License:
  • Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
  • in compliance with the License. You may obtain a copy of the License at:
  •  http://www.apache.org/licenses/LICENSE-2.0
    
  • Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
  • on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
  • for the specific language governing permissions and limitations under the License.

*/
definition(
name: "Evohome (Connect)",
namespace: "codersaur",
author: "David Lomas (codersaur)",
description: "Connect your Honeywell Evohome System to SmartThings.",
category: "My Apps",
iconUrl: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png",
iconX2Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png",
iconX3Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png",
singleInstance: true
)

preferences {

section ("Evohome:") {
	input "prefEvohomeUsername", "text", title: "Username", required: true, displayDuringSetup: true
	input "prefEvohomePassword", "password", title: "Password", required: true, displayDuringSetup: true
	input title: "Advanced Settings:", displayDuringSetup: true, type: "paragraph", element: "paragraph", description: "Change these only if needed"
	input "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true, description: "Poll Evohome every n minutes"
	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"
	input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5.0, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting"
	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"
	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'
	input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours'
}

section("General:") {
	input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true
}

}

/**********************************************************************

  • Setup and Configuration Commands:
    **********************************************************************/

/**

  • installed()
  • Runs when the app is first installed.

**/
def installed() {

atomicState.installedAt = now()
log.debug "${app.label}: Installed with settings: ${settings}"

}

/**

  • uninstalled()
  • Runs when the app is uninstalled.

**/
def uninstalled() {
if(getChildDevices()) {
removeChildDevices(getChildDevices())
}
}

/**

  • updated()
  • Runs when app settings are changed.

**/
void updated() {

if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}"

// General:
atomicState.debug = settings.prefDebugMode

// Evohome:
atomicState.evohomeEndpoint = 'https://mytotalconnectcomfort.com/WebApi'
atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime.
atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes).
atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes).
atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling.


// Thermostat Mode Durations:
atomicState.thermostatModeDuration = settings.prefThermostatModeDuration
atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration

// Force Authentication:
authenticate()

// Refresh Subscriptions and Schedules:
manageSubscriptions()
manageSchedules()

// Refresh child device configuration:
getEvohomeConfig()
updateChildDeviceConfig()

// Run a poll, but defer it so that updated() returns sooner:
runIn(5, "poll")

}

/**********************************************************************

  • Management Commands:
    **********************************************************************/

/**

  • manageSchedules()
  • Check scheduled tasks have not stalled, and re-schedule if necessary.
  • Generates a random offset (seconds) for each scheduled task.
  • Schedules:
    • manageAuth() - every 5 mins.
    • poll() - every minute.

**/
void manageSchedules() {

if (atomicState.debug) log.debug "${app.label}: manageSchedules()"

// Generate a random offset (1-60):
Random rand = new Random(now())
def randomOffset = 0

// manageAuth (every 5 mins):
if (1==1) { // To Do: Test if schedule has actually stalled.
	if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()"
	try {
		unschedule(manageAuth)
	}
	catch(e) {
		//if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed"
	}
	randomOffset = rand.nextInt(60)
	schedule("${randomOffset} 0/5 * * * ?", "manageAuth")
}

// poll():
if (1==1) { // To Do: Test if schedule has actually stalled.
	if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()"
	try {
		unschedule(poll)
	}
	catch(e) {
		//if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed"
	}
	randomOffset = rand.nextInt(60)
	schedule("${randomOffset} 0/1 * * * ?", "poll")
}

}

/**

  • manageSubscriptions()

  • Unsubscribe/Subscribe.
    **/
    void manageSubscriptions() {

    if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()"

    // Unsubscribe:
    unsubscribe()

    // Subscribe to App Touch events:
    subscribe(app,handleAppTouch)

}

/**

  • manageAuth()

  • Ensures authenication token is valid.

  • Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold.

  • Re-authenticates if Auth Token has expired completely.

  • Otherwise, done nothing.

  • Should be scheduled to run every 1-5 minutes.
    **/
    void manageAuth() {

    if (atomicState.debug) log.debug "${app.label}: manageAuth()"

    // Check if Auth Token is valid, if not authenticate:
    if (!atomicState.evohomeAuth.authToken) {

    if (atomicState.debug) log.debug "${app.label}: manageAuth(): No Auth Token. Authenticating..."
    authenticate()
    

    }
    else if (atomicState.evohomeAuthFailed) {

    if (atomicState.debug) log.debug "${app.label}: manageAuth(): Auth has failed. Authenticating..."
    authenticate()
    

    }
    else if (!atomicState.evohomeAuth.expiresAt.toString().isNumber() || now() >= atomicState.evohomeAuth.expiresAt) {

    if (atomicState.debug) log.debug "${app.label}: manageAuth(): Auth Token has expired. Authenticating..."
    authenticate()
    

    }
    else {
    // Check if Auth Token should be refreshed:
    def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100))

    if (now() >= refreshAt) {
    	if (atomicState.debug) log.debug "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires."
    	refreshAuthToken()
    }
    else {
    	if (atomicState.debug) log.debug "${app.label}: manageAuth(): Auth Token is okay."		
    }
    

    }

}

/**

  • poll(onlyZoneId=-1)
  • This is the main command that co-ordinates retrieval of information from the Evohome API
  • and its dissemination to child devices. It should be scheduled to run every minute.
  • Different types of information are collected on different schedules:
    • Zone status information is polled according to ${evohomeStatusPollInterval}.
    • Zone schedules are polled according to ${evohomeSchedulePollInterval}.
  • poll() can be called by a child device when an update has been made, in which case
  • onlyZoneId will be specified, and only that zone will be updated.
  • If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll
  • interval. This should only be used after setThremostatMode() call.
  • If onlyZoneId is not specified all zones are updated, but only if the relevent poll
  • interval has been exceeded.

**/
void poll(onlyZoneId=-1) {

if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})"

// Check if there's been an authentication failure:
if (atomicState.evohomeAuthFailed) {
	manageAuth()
}

if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update):
	getEvohomeStatus()
	updateChildDevice()
}
else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device:
	getEvohomeStatus(onlyZoneId)
	updateChildDevice(onlyZoneId)
}
else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: 

	// Adjust intervals to allow for poll() execution time:
	def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30
	def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30

	// Get zone status:
	if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) {
		getEvohomeStatus()
	} 

	// Get zone schedules:
	if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) {
		getEvohomeSchedules()
	}
	
	// Update all child devices:
	updateChildDevice()
}

}

/**********************************************************************

  • Event Handlers:
    **********************************************************************/

/**

  • handleAppTouch(evt)
  • App touch event handler.
  • Used for testing and debugging.

**/
void handleAppTouch(evt) {

if (atomicState.debug) log.debug "${app.label}: handleAppTouch()"

//manageAuth()
//manageSchedules()

//getEvohomeConfig()
//updateChildDeviceConfig()

poll()

}

/**********************************************************************

  • SmartApp-Child Interface Commands:
    **********************************************************************/

/**

  • updateChildDeviceConfig()
  • Add/Remove/Update Child Devices based on atomicState.evohomeConfig
  • and update their internal state.

**/
void updateChildDeviceConfig() {

if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()"

// Build list of active DNIs, any existing children with DNIs not in here will be deleted.
def activeDnis = []

// Iterate through evohomeConfig, adding new Evohome Heating Zone devices where necessary.
atomicState.evohomeConfig.each { loc ->
	loc.gateways.each { gateway ->
		gateway.temperatureControlSystems.each { tcs ->
			tcs.zones.each { zone ->
				
				def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId )
				activeDnis << dni
				
				def values = [
					'debug': atomicState.debug,
					'updateRefreshTime': atomicState.evohomeUpdateRefreshTime,
					'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint),
					'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint),
					'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution,
					'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp),
					'zoneType': zone?.zoneType,
					'locationId': loc.locationInfo.locationId,
					'gatewayId': gateway.gatewayInfo.gatewayId,
					'systemId': tcs.systemId,
					'zoneId': zone.zoneId
				]
				
				def d = getChildDevice(dni)
				if(!d) {
					try {
						values.put('label', "${zone.name} Heating Zone (Evohome)")
						if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label},  DNI: ${dni}"
	                   	d = addChildDevice("codersaur", "Evohome Heating Zone", dni, values)
					} catch (e) {
						log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}"
					}
				} 
				
				if(d) {
					d.generateEvent(values)
				}
			}
		}
	}
}

if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}"

// Delete Devices:
def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) }

if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete."

delete.each {
	if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}"
	try {
		deleteChildDevice(it.deviceNetworkId)
	}
	catch(e) {
		log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}"
	}
}

}

/**

  • updateChildDevice(onlyZoneId=-1)
  • Update the attributes of a child device from atomicState.evohomeStatus
  • and atomicState.evohomeSchedules.
  • If onlyZoneId is not specified, then all zones are updated.
  • Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime.

**/
void updateChildDevice(onlyZoneId=-1) {

if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})"

atomicState.evohomeStatus.each { loc ->
	loc.gateways.each { gateway ->
		gateway.temperatureControlSystems.each { tcs ->
			tcs.zones.each { zone ->
				if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified.
				
					def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId)
					def d = getChildDevice(dni)
					if(d) {
						def schedule = atomicState.evohomeSchedules.find { it.dni == dni}
						def currSw = getCurrentSwitchpoint(schedule.schedule)
						def nextSw = getNextSwitchpoint(schedule.schedule)

						def values = [
							'temperature': formatTemperature(zone?.temperatureStatus?.temperature),
							//'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable,
							'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature),
							'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature),
							'thermostatSetpointMode': formatSetpointMode(zone?.heatSetpointStatus?.setpointMode),
							'thermostatSetpointUntil': zone?.heatSetpointStatus?.until,
							'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode),
							'scheduledSetpoint': formatTemperature(currSw.temperature),
							'nextScheduledSetpoint': formatTemperature(nextSw.temperature),
							'nextScheduledTime': nextSw.time
						]
						if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}"
						d.generateEvent(values)
					} else {
						if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist, so skipping status update."
					}
				}
			}
		}
	}
}

}

/**********************************************************************

  • Evohome API Commands:
    **********************************************************************/

/**

  • authenticate()
  • Authenticate to Evohome.

**/
private authenticate() {

if (atomicState.debug) log.debug "${app.label}: authenticate()"

def requestParams = [
	//method: 'POST',
	uri: 'https://mytotalconnectcomfort.com/WebApi',
	path: '/Auth/OAuth/Token',
	headers: [
		'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=',
		'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml',
		'Content-Type':	'application/x-www-form-urlencoded; charset=utf-8'
	],
	body: [
		'grant_type':	'password',
		'scope':	'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account EMEA-V1-Get-Location-Installation-Info-By-UserId',
		'Username':	settings.prefEvohomeUsername,
		'Password':	settings.prefEvohomePassword
	]
]

try {
	httpPost(requestParams) { resp ->
		if(resp.status == 200 && resp.data) {
			// Update evohomeAuth:
			// We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign.
			def tmpAuth = atomicState.evohomeAuth ?: [:]
		    	tmpAuth.put('lastUpdated' , now())
				tmpAuth.put('authToken' , resp?.data?.access_token)
				tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0)
				tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000))
				tmpAuth.put('refreshToken' , resp?.data?.refresh_token)
			atomicState.evohomeAuth = tmpAuth
			atomicState.evohomeAuthFailed = false
			
			if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}"
			def exp = new Date(tmpAuth.expiresAt)
			if (atomicState.debug) log.debug "${app.label}: authenticate(): New Auth Token Expires At: ${exp}"

			// Update evohomeHeaders:
			def tmpHeaders = atomicState.evohomeHeaders ?: [:]
				tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}")
				tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249')
				tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml')
			atomicState.evohomeHeaders = tmpHeaders
			
			if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}"
			
			// Now get User Account info:
			getEvohomeUserAccount()
		}
		else {
			log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}"
			atomicState.evohomeAuthFailed = true
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}"
	atomicState.evohomeAuthFailed = true
}

}

/**

  • refreshAuthToken()
  • Refresh Auth Token.
  • If token refresh fails, then authenticate() is called.
  • Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'.

**/
private refreshAuthToken() {

if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()"

def requestParams = [
	//method: 'POST',
	uri: 'https://mytotalconnectcomfort.com/WebApi',
	path: '/Auth/OAuth/Token',
	headers: [
		'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=',
		'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml',
		'Content-Type':	'application/x-www-form-urlencoded; charset=utf-8'
	],
	body: [
		'grant_type':	'refresh_token',
		'scope':	'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account EMEA-V1-Get-Location-Installation-Info-By-UserId',
		'refresh_token':	atomicState.evohomeAuth.refreshToken
	]
]

try {
	httpPost(requestParams) { resp ->
		if(resp.status == 200 && resp.data) {
			// Update evohomeAuth:
			// We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign.
			def tmpAuth = atomicState.evohomeAuth ?: [:]
		    	tmpAuth.put('lastUpdated' , now())
				tmpAuth.put('authToken' , resp?.data?.access_token)
				tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0)
				tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000))
				tmpAuth.put('refreshToken' , resp?.data?.refresh_token)
			atomicState.evohomeAuth = tmpAuth
			atomicState.evohomeAuthFailed = false
			
			if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}"
			def exp = new Date(tmpAuth.expiresAt)
			if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}"

			// Update evohomeHeaders:
			def tmpHeaders = atomicState.evohomeHeaders ?: [:]
				tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}")
				tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249')
				tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml')
			atomicState.evohomeHeaders = tmpHeaders
			
			if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}"
			
			// Now get User Account info:
			getEvohomeUserAccount()
		}
		else {
			log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}"
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}"
	// If Unauthorized (401) then re-authenticate:
	if (e.statusCode == 401) {
		atomicState.evohomeAuthFailed = true
		authenticate()
	}
}

}

/**

  • getEvohomeUserAccount()
  • Gets user account info and stores in atomicState.evohomeUserAccount.

**/
private getEvohomeUserAccount() {

if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Getting user account information."

def requestParams = [
	//method: 'GET',
	uri: atomicState.evohomeEndpoint,
	path: '/WebAPI/emea/api/v1/userAccount',
	headers: atomicState.evohomeHeaders
]

try {
	httpGet(requestParams) { resp ->
		if (resp.status == 200 && resp.data) {
			atomicState.evohomeUserAccount = resp.data
			if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}"
		}
		else {
			log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}"
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}"
	if (e.statusCode == 401) {
		atomicState.evohomeAuthFailed = true
	}
}

}

/**

  • getEvohomeConfig()
  • Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig.

**/
private getEvohomeConfig() {

if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Getting configuration for all locations."

def requestParams = [
	//method: 'GET',
	uri: atomicState.evohomeEndpoint,
	path: '/WebAPI/emea/api/v1/location/installationInfo',
	query: [
		'userId': atomicState.evohomeUserAccount.userId,
		'includeTemperatureControlSystems': 'True'
	],
	headers: atomicState.evohomeHeaders
]

try {
	httpGet(requestParams) { resp ->
		if (resp.status == 200 && resp.data) {
			if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}"
			atomicState.evohomeConfig = resp.data
			atomicState.evohomeConfigUpdatedAt = now()
			return null
		}
		else {
			log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}"
			return 'error'
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}"
	if (e.statusCode == 401) {
		atomicState.evohomeAuthFailed = true
	}
	return e
}

}

/**

  • getEvohomeStatus(onlyZoneId=-1)
  • Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus.
  • If onlyZoneId is not specified, all zones are updated.

**/
private getEvohomeStatus(onlyZoneId=-1) {

if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})"

def newEvohomeStatus = []

if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location):
	
	if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(): Getting status for all zones."
	
	atomicState.evohomeConfig.each { loc ->
		def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId)
		if (locStatus) {
			newEvohomeStatus << locStatus
		}
	}

	if (newEvohomeStatus) {
		// Write out newEvohomeStatus back to atomicState:
		atomicState.evohomeStatus = newEvohomeStatus
		atomicState.evohomeStatusUpdatedAt = now()
	}
}
else { // Only update the specified zone:
	
	if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}"
	
	def newZoneStatus = getEvohomeZoneStatus(onlyZoneId)
	if (newZoneStatus) {
		// Get existing evohomeStatus and update only the specified zone, preserving data for other zones:
		// Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate).
		// If mutiple zones are requesting updates at the same time this could cause loss of new data, but
		// the worse case is having out-of-date data for a few minutes...
		newEvohomeStatus = atomicState.evohomeStatus
		newEvohomeStatus.each { loc ->
			loc.gateways.each { gateway ->
				gateway.temperatureControlSystems.each { tcs ->
					tcs.zones.each { zone ->
						if (onlyZoneId == zone.zoneId) { // This is the zone that must be updated:
							zone.activeFaults = newZoneStatus.activeFaults
							zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus
							zone.temperatureStatus = newZoneStatus.temperatureStatus
						}
					}
				}
			}
		}
		// Write out newEvohomeStatus back to atomicState:
		atomicState.evohomeStatus = newEvohomeStatus
		// Note: atomicState.evohomeStatusUpdatedAt is NOT updated.
	} 
}

}

/**

  • getEvohomeLocationStatus(locationId)

  • Gets the status for a specific location and returns data as a map.

  • Called by getEvohomeStatus().
    **/
    private getEvohomeLocationStatus(locationId) {

    if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}"

    def requestParams = [
    //'method': 'GET',
    'uri': atomicState.evohomeEndpoint,
    'path': "/WebAPI/emea/api/v1/location/${locationId}/status",
    'query': [ 'includeTemperatureControlSystems': 'True'],
    'headers': atomicState.evohomeHeaders
    ]

    try {
    httpGet(requestParams) { resp ->
    if(resp.status == 200 && resp.data) {
    if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}"
    return resp.data
    }
    else {
    log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}"
    return false
    }
    }
    } catch (groovyx.net.http.HttpResponseException e) {
    log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}"
    if (e.statusCode == 401) {
    atomicState.evohomeAuthFailed = true
    }
    return false
    }
    }

/**

  • getEvohomeZoneStatus(zoneId)
  • Gets the status for a specific zone and returns data as a map.

**/
private getEvohomeZoneStatus(zoneId) {

if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})"

def requestParams = [
	//'method': 'GET',
	'uri': atomicState.evohomeEndpoint,
	'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status",
	'headers': atomicState.evohomeHeaders
]

try {
	httpGet(requestParams) { resp ->
		if(resp.status == 200 && resp.data) {
			if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}"
			return resp.data
		}
		else {
			log.error "${app.label}: getEvohomeZoneStatus:  No Data. Response Status: ${resp.status}"
			return false
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}"
	if (e.statusCode == 401) {
		atomicState.evohomeAuthFailed = true
	}
	return false
}

}

/**

  • getEvohomeSchedules()
  • Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules.

**/
private getEvohomeSchedules() {

if (atomicState.debug) log.debug "${app.label}: getEvohomeSchedules(): Getting schedules for all zones."
		
def evohomeSchedules = []
	
atomicState.evohomeConfig.each { loc ->
	loc.gateways.each { gateway ->
		gateway.temperatureControlSystems.each { tcs ->
			tcs.zones.each { zone ->
				def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId )
				def schedule = getEvohomeZoneSchedule(zone.zoneId)
				if (schedule) {
					evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule]
				}
			}
		}
	}
}

if (evohomeSchedules) {
	// Write out complete schedules to state:
	atomicState.evohomeSchedules = evohomeSchedules
	atomicState.evohomeSchedulesUpdatedAt = now()
}

return evohomeSchedules

}

/**

  • getEvohomeZoneSchedule(zoneId)
  • Gets the schedule for a specific zone and returns data as a map.

**/
private getEvohomeZoneSchedule(zoneId) {
if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule(${zoneId})"

def requestParams = [
	//'method': 'GET',
	'uri': atomicState.evohomeEndpoint,
	'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule",
	'headers': atomicState.evohomeHeaders
]

try {
	httpGet(requestParams) { resp ->
		if(resp.status == 200 && resp.data) {
			if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule: Data: ${resp.data}"
			return resp.data
		}
		else {
			log.error "${app.label}: getEvohomeZoneSchedule:  No Data. Response Status: ${resp.status}"
			return false
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: getEvohomeZoneSchedule: Error: e.statusCode ${e.statusCode}"
	if (e.statusCode == 401) {
		atomicState.evohomeAuthFailed = true
	}
	return false
}

}

/**

  • setThermostatMode(systemId, mode, until)
  • Set thermostat mode for specified controller, until specified time.
  • systemId: SystemId of temperatureControlSystem. E.g.: 123456
  • mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom".
  • until: (Optional) Time to apply mode until, can be either:
  •            - Date: date object representing when override should end.
    
  •            - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z".
    
  •            - String: 'permanent'.
    
  •            - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'.
    
  •                      Duration will be rounded down to align with Midnight in the local timezone
    
  •                      (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent.
    
  •            If 'until' is not specified, a default value is used from the SmartApp settings.
    
  • Notes: 'Auto' and 'Off' modes are always permanent.
  •           Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller).
    
  •           Therefore changing the thermostatMode will affect all zones associated with the same controller.
    
  • Example usage:
  • setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456.
  • setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456.
  • setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456.
  • setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456.
  • setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456.

**/
def setThermostatMode(systemId, mode, until=-1) {

if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}"

// Clean mode (translate to index):
mode = mode.toLowerCase()
int modeIndex
switch (mode) {
	case 'auto':
		modeIndex = 0
		break
	case 'off':
		modeIndex = 1
		break
	case 'economy':
		modeIndex = 2
		break
	case 'away':
		modeIndex = 3
		break
	case 'dayoff':
		modeIndex = 4
		break
	case 'custom':
		modeIndex = 6
		break
	default:
		log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!"
		modeIndex = 999
		break
}

// Clean until:
def untilRes

// until has not been specified, so determine behaviour from settings:
if (-1 == until && 'economy' == mode) { 
	until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours):
}
else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) {
	until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days):
}

// Convert to date (or 0):    
if ('permanent' == until || 0 == until || -1 == until) {
	untilRes = 0
}
else if (until instanceof Date) {
	untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
}
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:
	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.
}
else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours:
	untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
}
else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days:
	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.
}
else {
	log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently."
	untilRes = 0
}

// 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:
if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { 
	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'))
}

// Build request:
def body
if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent:
	body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True']
	if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True"
}
else { // Mode is temporary:
	body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False']
	if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}"
}

def requestParams = [
	'uri': atomicState.evohomeEndpoint,
	'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", 
	'body': body,
	'headers': atomicState.evohomeHeaders
]

// Make request:
try {
	httpPutJson(requestParams) { resp ->
		if(resp.status == 201 && resp.data) {
			if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}"
			return null
		}
		else {
			log.error "${app.label}: setThermostatMode():  No Data. Response Status: ${resp.status}"
			return 'error'
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: setThermostatMode(): Error: ${e}"
	if (e.statusCode == 401) {
		atomicState.evohomeAuthFailed = true
	}
	return e
}

}

/**

  • setHeatingSetpoint(zoneId, setpoint, until=-1)
  • Set heatingSetpoint for specified zoneId, until specified time.
  • zoneId: Zone ID of zone, e.g.: "123456"
  • setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string.
  • until: (Optional) Time to apply setpoint until, can be either:
  •            - Date: date object representing when override should end.
    
  •            - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z".
    
  •            - String: 'permanent'.
    
  •           If not specified, setpoint will be applied permanently.
    
  • Example usage:
  • setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456.
  • setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456.
  • setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456.

**/
def setHeatingSetpoint(zoneId, setpoint, until=-1) {

if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}"

// Clean setpoint:
setpoint = formatTemperature(setpoint)

// Clean until:
def untilRes
if ('permanent' == until || 0 == until || -1 == until) {
	untilRes = 0
}
else if (until instanceof Date) {
	untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute.
}
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:
	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.
}
else {
	log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently."
	untilRes = 0
}

// Build request:
def body
if (0 == untilRes) { // Permanent:
	body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null]
	if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent"
}
else { // Temporary:
	body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes]
	if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}"
}

def requestParams = [
	'uri': atomicState.evohomeEndpoint,
	'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", 
	'body': body,
	'headers': atomicState.evohomeHeaders
]

// Make request:
try {
	httpPutJson(requestParams) { resp ->
		if(resp.status == 201 && resp.data) {
			if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}"
			return null
		}
		else {
			log.error "${app.label}: setHeatingSetpoint():  No Data. Response Status: ${resp.status}"
			return 'error'
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: setHeatingSetpoint(): Error: ${e}"
	if (e.statusCode == 401) {
		atomicState.evohomeAuthFailed = true
	}
	return e
}

}

/**

  • clearHeatingSetpoint(zoneId)
  • Clear the heatingSetpoint for specified zoneId.
  • zoneId: Zone ID of zone, e.g.: "123456"
    **/
    def clearHeatingSetpoint(zoneId) {
if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}"

// Build request:
def requestParams = [
	'uri': atomicState.evohomeEndpoint,
	'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", 
	'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null],
	'headers': atomicState.evohomeHeaders
]

// Make request:
try {
	httpPutJson(requestParams) { resp ->
		if(resp.status == 201 && resp.data) {
			if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}"
			return null
		}
		else {
			log.error "${app.label}: clearHeatingSetpoint():  No Data. Response Status: ${resp.status}"
			return 'error'
		}
	}
} catch (groovyx.net.http.HttpResponseException e) {
	log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}"
	if (e.statusCode == 401) {
		atomicState.evohomeAuthFailed = true
	}
	return e
}

}

/**********************************************************************

  • Helper Commands:
    **********************************************************************/

/**

  • generateDni(locId,gatewayId,systemId,deviceId)
  • Generate a device Network ID.
  • Uses the same format as the official Evohome App, but with a prefix of "Evohome."
    **/
    private generateDni(locId,gatewayId,systemId,deviceId) {
    return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.')
    }

/**

  • formatTemperature(t)
  • Format temperature value to one decimal place.
  • t: can be string, float, bigdecimal...
  • Returns as string.
    **/
    private formatTemperature(t) {
    return Float.parseFloat("${t}").round(1).toString()
    }

/**

  • formatSetpointMode(mode)
  • Format Evohome setpointMode values to SmartThings values:

**/
private formatSetpointMode(mode) {

switch (mode) {
	case 'FollowSchedule':
		mode = 'followSchedule'
		break
	case 'PermanentOverride':
		mode = 'permanentOverride'
		break
	case 'TemporaryOverride':
		mode = 'temporaryOverride'
		break
	default:
		log.error "${app.label}: formatSetpointMode(): Mode: ${mode} unknown!"
		mode = mode.toLowerCase()
		break
}

return mode

}

/**

  • formatThermostatMode(mode)
  • Translate Evohome thermostatMode values to SmartThings values.

**/
private formatThermostatMode(mode) {

switch (mode) {
	case 'Auto':
		mode = 'auto'
		break
	case 'AutoWithEco':
		mode = 'economy'
		break
	case 'Away':
		mode = 'away'
		break
	case 'Custom':
		mode = 'custom'
		break
	case 'DayOff':
		mode = 'dayOff'
		break
	case 'HeatingOff':
		mode = 'off'
		break
	default:
		log.error "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!"
		mode = mode.toLowerCase()
		break
}

return mode

}

/**

  • getCurrentSwitchpoint(schedule)
  • Returns the current active switchpoint in the given schedule.
  • e.g. [timeOfDay:"23:00:00", temperature:"15.0000"]

**/
private getCurrentSwitchpoint(schedule) {

if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()"

Calendar c = new GregorianCalendar()
def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }

// Sort and find next switchpoint:
ScheduleToday.switchpoints.sort {it.timeOfDay}
ScheduleToday.switchpoints.reverse(true)
def currentSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay < c.getTime().format("HH:mm:ss", location.timeZone)}

if (!currentSwitchPoint) {
	// There are no current switchpoints today, so we must look for the last Switchpoint yesterday.
	if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): No current switchpoints today, so must look to yesterday's schedule."
	c.add(Calendar.DATE, -1 ) // Subtract one DAY.
	def ScheduleYesterday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }
	ScheduleYesterday.switchpoints.sort {it.timeOfDay}
	ScheduleYesterday.switchpoints.reverse(true)
	currentSwitchPoint = ScheduleYesterday.switchpoints[0] // There will always be one.
}

// Now construct the switchpoint time as a full ISO-8601 format date string in UTC:
def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + currentSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone.
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.    
currentSwitchPoint << [ 'time': isoDateStr ]
if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): Current Switchpoint: ${currentSwitchPoint}"

return currentSwitchPoint

}

/**

  • getNextSwitchpoint(schedule)
  • Returns the next switchpoint in the given schedule.
  • e.g. [timeOfDay:"23:00:00", temperature:"15.0000"]

**/
private getNextSwitchpoint(schedule) {

if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()"

Calendar c = new GregorianCalendar()
def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }

// Sort and find next switchpoint:
ScheduleToday.switchpoints.sort {it.timeOfDay}
def nextSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay > c.getTime().format("HH:mm:ss", location.timeZone)}

if (!nextSwitchPoint) {
	// There are no switchpoints left today, so we must look for the first Switchpoint tomorrow.
	if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): No more switchpoints today, so must look to tomorrow's schedule."
	c.add(Calendar.DATE, 1 ) // Add one DAY.
	def ScheduleTmrw = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) }
	ScheduleTmrw.switchpoints.sort {it.timeOfDay}
	nextSwitchPoint = ScheduleTmrw.switchpoints[0] // There will always be one.
}

// Now construct the switchpoint time as a full ISO-8601 format date string in UTC:
def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + nextSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone.
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.    
nextSwitchPoint << [ 'time': isoDateStr ]
if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): Next Switchpoint: ${nextSwitchPoint}"

return nextSwitchPoint

}

1 Like

I'm not sure if there were any changes in here. Ideally it needs preferences adding for logging. All I've done is comment out some of the logging. That will save 2 lines of logs per heating zone per minute (which was filling my logs and preventing me finding info for other devices)

Heating Zone Driver

Summary

/**

  • Copyright 2016 David Lomas (codersaur)
  • Name: Evohome Heating Zone
  • Author: David Lomas (codersaur)
  • Date: 2016-04-08
  • Version: 0.09
  • Description:
    • This device handler is a child device for the Evohome (Connect) SmartApp.
  • Version History:
  • 2016-04-08: v0.09
    • calculateOptimisations(): Fixed comparison of temperature values.
  • 2016-04-05: v0.08
    • New 'Update Refresh Time' setting from parent to control polling after making an update.
    • setThermostatMode(): Forces poll for all zones to ensure new thermostatMode is updated.
  • 2016-04-04: v0.07
    • generateEvent(): hides events if name or value are null.
    • generateEvent(): log.info message for new values.
  • 2016-04-03: v0.06
    • Initial Beta Release
  • To Do:
    • Clean up device settings (preferences). Hide/Show prefSetpointDuration input dynamically depending on prefSetpointMode. - If supported for devices???
    • When thermostat mode is away or off, heatingSetpoint overrides should not allowed (although setting while away actually works). Should warn at least.
  • License:
  • Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
  • in compliance with the License. You may obtain a copy of the License at:
  •  http://www.apache.org/licenses/LICENSE-2.0
    
  • Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
  • on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
  • for the specific language governing permissions and limitations under the License.

*/
metadata {
definition (name: "Evohome Heating Zone", namespace: "codersaur", author: "David Lomas") {
capability "Actuator"
capability "Sensor"
capability "Refresh"
capability "Temperature Measurement"
// capability "Thermostat"
capability "Thermostat Heating Setpoint"
capability "Thermostat Setpoint"
capability "Thermostat Mode"
capability "Thermostat Operating State"

	//command "poll" // Polling
	command "refresh" // Refresh
	command "setHeatingSetpoint" // Thermostat
	command "raiseSetpoint" // Custom
	command "lowerSetpoint" // Custom
	command "setThermostatMode" // Thermostat
	command "cycleThermostatMode" // Custom
	command "off" // Thermostat
	command "heat" // Thermostat
	command "auto" // Custom
	command "away" // Custom
	command "economy" // Custom
	command "dayOff" // Custom
	command "custom" // Custom
	command "resume" // Custom
	command "boost" // Custom
	command "suppress" // Custom
	command "generateEvent" // Custom
	command "test" // Custom

	attribute "temperature","number" // Temperature Measurement
	attribute "heatingSetpoint","number" // Thermostat
	attribute "thermostatSetpoint","number" // Thermostat
	attribute "thermostatSetpointMode", "string" // Custom
	attribute "thermostatSetpointUntil", "string" // Custom
	attribute "thermostatSetpointStatus", "string" // Custom
	attribute "thermostatMode", "string" // Thermostat
	attribute "thermostatOperatingState", "string" // Thermostat
	attribute "thermostatStatus", "string" // Custom
	attribute "scheduledSetpoint", "number" // Custom
	attribute "nextScheduledSetpoint", "number" // Custom
	attribute "nextScheduledTime", "string" // Custom
	attribute "optimisation", "string" // Custom
	attribute "windowFunction", "string" // Custom
	
}

tiles(scale: 2) {

	// Main multi
	multiAttributeTile(name:"multi", type:"thermostat", width:6, height:4) {
		tileAttribute("device.temperature", key: "PRIMARY_CONTROL") {
			attributeState("default", label:'${currentValue}Ā°', unit:"C")
		}
		// Up and Down buttons:
		//tileAttribute("device.temperature", key: "VALUE_CONTROL") {
		//	attributeState("VALUE_UP", action: "raiseSetpoint")
		//	attributeState("VALUE_DOWN", action: "lowerSetpoint")
		//}
		// Operating State - used to get background colour when type is 'thermostat'.
		tileAttribute("device.thermostatStatus", key: "OPERATING_STATE") {
			attributeState("Heating", backgroundColor:"#ffa81e", defaultState: true)
			attributeState("Idle (Auto)", backgroundColor:"#44b621")
			attributeState("Idle (Custom)", backgroundColor:"#44b621")
			attributeState("Idle (Day Off)", backgroundColor:"#44b621")
			attributeState("Idle (Economy)", backgroundColor:"#44b621")
			attributeState("Idle (Away)", backgroundColor:"#44b621")
			attributeState("Off", backgroundColor:"#269bd2")
		}
		//tileAttribute("device.thermostatMode", key: "THERMOSTAT_MODE") {
		//	attributeState("off", label:'${name}')
		//	attributeState("away", label:'${name}')
		//	attributeState("auto", label:'${name}')
		//	attributeState("economy", label:'${name}')
		//	attributeState("dayOff", label:'${name}')
		//	attributeState("custom", label:'${name}')
		//}
		//tileAttribute("device.heatingSetpoint", key: "HEATING_SETPOINT") {
		//	attributeState("default", label:'${currentValue}', unit:"C")
		//}
		//tileAttribute("device.coolingSetpoint", key: "COOLING_SETPOINT") {
		//	attributeState("default", label:'${currentValue}', unit:"C")
		//}
	}

	// temperature tile:
	valueTile("temperature", "device.temperature", width: 2, height: 2, canChangeIcon: true) {
		state("temperature", label:'${currentValue}Ā°', unit:"C", icon:"st.Weather.weather2",
				backgroundColors:[
						// Celsius
						[value: 0, color: "#153591"],
						[value: 7, color: "#1e9cbb"],
						[value: 15, color: "#90d2a7"],
						[value: 23, color: "#44b621"],
						[value: 28, color: "#f1d801"],
						[value: 35, color: "#d04e00"],
						[value: 37, color: "#bc2323"]
				]
		)
	}
	
	// thermostatSetpoint tiles:
	valueTile("thermostatSetpoint", "device.thermostatSetpoint", width: 3, height: 1) {
		state "thermostatSetpoint", label:'Setpoint: ${currentValue}Ā°', unit:"C"
	}
	valueTile("thermostatSetpointStatus", "device.thermostatSetpointStatus", width: 3, height: 1, decoration: "flat") {
		state "thermostatSetpointStatus", label:'${currentValue}', backgroundColor:"#ffffff"
	}
	standardTile("raiseSetpoint", "device.thermostatSetpoint", width: 1, height: 1, decoration: "flat") {
		state "setpoint", action:"raiseSetpoint", icon:"st.thermostat.thermostat-up"
	}
	standardTile("lowerSetpoint", "device.thermostatSetpoint", width: 1, height: 1, decoration: "flat") {
		state "setpoint", action:"lowerSetpoint", icon:"st.thermostat.thermostat-down"
	}
	standardTile("resume", "device.resume", width: 1, height: 1, decoration: "flat") {
		state "default", action:"resume", label:'Resume', icon:"st.samsung.da.oven_ic_send"
	}
	standardTile("boost", "device.boost", inactiveLabel: false, decoration: "flat", width: 1, height: 1) {
		state "default", action:"boost", label:'Boost' // icon TBC
	}
	standardTile("suppress", "device.suppress", inactiveLabel: false, decoration: "flat", width: 1, height: 1) {
		state "default", action:"suppress", label:'Suppress' // icon TBC
	}
	
	
	// thermostatMode/Status Tiles:
	
	// thermostatStatus (also incorporated into the multi tile).
	valueTile("thermostatStatus", "device.thermostatStatus", height: 1, width: 6, decoration: "flat") {
		state "thermostatStatus", label:'${currentValue}', backgroundColor:"#ffffff"
	}
	// Single thermostatMode tile that cycles between all modes (too slow).
	// To Do: Update with Evohome-specific modes:
	standardTile("thermostatMode", "device.thermostatMode", inactiveLabel: false, decoration: "flat") {
		state "off", action:"cycleMode", nextState: "updating", icon: "st.thermostat.heating-cooling-off"
		state "heat", action:"cycleMode",  nextState: "updating", icon: "st.thermostat.heat"
		state "cool", action:"cycleMode",  nextState: "updating", icon: "st.thermostat.cool"
		state "auto", action:"cycleMode",  nextState: "updating", icon: "st.thermostat.auto"
		state "auxHeatOnly", action:"cycleMode", icon: "st.thermostat.emergency-heat"
		state "updating", label:"Working", icon: "st.secondary.secondary"
	}
	// Individual Mode tiles:
	standardTile("auto", "device.auto", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
		state "default", action:"auto", icon: "st.thermostat.auto"
	}
	standardTile("away", "device.away", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
		state "default", action:"away", label:'Away' // icon TBC
	}
	standardTile("custom", "device.custom", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
		state "default", action:"custom", label:'Custom' // icon TBC
	}
	standardTile("dayOff", "device.dayOff", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
		state "default", action:"dayOff", label:'Day Off' // icon TBC
	}
	standardTile("economy", "device.economy", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
		state "default", action:"economy", label:'Economy' // icon TBC
	}
	standardTile("off", "device.off", inactiveLabel: false, decoration: "flat", width: 2, height: 2) {
		state "default", action:"off", icon:"st.thermostat.heating-cooling-off"
	}
	// Other tiles:
	standardTile("refresh", "device.thermostatMode", inactiveLabel: false, decoration: "flat") {
		state "default", action:"refresh.refresh", icon:"st.secondary.refresh"
	}
	standardTile("test", "device.test", width: 1, height: 1, decoration: "flat") {
		state "default", label:'Test', action:"test"
	}
	
	main "temperature"
	details(
			[
			"multi",
			"thermostatSetpoint","raiseSetpoint","boost","resume",
			"thermostatSetpointStatus","lowerSetpoint","suppress","refresh",
			"auto","away","custom","dayOff","economy","off"
			]
	)
}

preferences {
	section { // Setpoint Adjustments:
		input title: "Setpoint Duration", description: "Configure how long setpoint adjustments are applied for.", displayDuringSetup: true, type: "paragraph", element: "paragraph"
		input 'prefSetpointMode', 'enum', title: 'Until', description: '', options: ["Next Switchpoint", "Midday", "Midnight", "Duration", "Permanent"], defaultValue: "Next Switchpoint", required: true, displayDuringSetup: true
		input 'prefSetpointDuration', 'number', title: 'Duration (minutes)', description: 'Apply setpoint for this many minutes', range: "1..1440", defaultValue: 60, required: true, displayDuringSetup: true
		//input 'prefSetpointTime', 'time', title: 'Time', description: 'Apply setpoint until this time', required: true, displayDuringSetup: true
		input title: "Setpoint Temperatures", description: "Configure preset temperatures for the 'Boost' and 'Suppress' buttons.", displayDuringSetup: true, type: "paragraph", element: "paragraph"
		input "prefBoostTemperature", "string", title: "'Boost' Temperature", defaultValue: "21.5", required: true, displayDuringSetup: true // use of 'decimal' input type in devices is currently broken.
		input "prefSuppressTemperature", "string", title: "'Suppress' Temperature", defaultValue: "15.0", required: true, displayDuringSetup: true // use of 'decimal' input type in devices is currently broken.
	}
			
}

}

/**********************************************************************

  • Test Commands:
    **********************************************************************/

/**

  • test()

  • Test method, called from test tile.
    **/
    def test() {

    //log.debug "$device.displayName: test(): Properties: ${properties}"
    //log.debug "$device.displayName: test(): Settings: ${settings}"
    //log.debug "$device.displayName: test(): State: ${state}"

}

/**********************************************************************

  • Setup and Configuration Commands:
    **********************************************************************/

/**

  • installed()
  • Runs when the app is first installed.
  • When a device is created by a SmartApp, settings are not populated
  • with the defaultValues configured for each input. Therefore, we
  • populate the corresponding state.* variables with the input defaultValues.

**/
def installed() {

log.debug "${app.label}: Installed with settings: ${settings}"

state.installedAt = now()

// These default values will be overwritten by the Evohome SmartApp almost immediately:
state.debug = false
state.updateRefreshTime = 5 // Wait this many seconds after an update before polling.
state.zoneType = 'RadiatorZone'
state.minHeatingSetpoint = formatTemperature(5.0)
state.maxHeatingSetpoint = formatTemperature(35.0)
state.temperatureResolution = formatTemperature(0.5)
state.windowFunctionTemperature = formatTemperature(5.0)
state.targetSetpoint = state.minHeatingSetpoint

// Populate state.* with default values for each preference/input:
state.setpointMode = getInputDefaultValue('prefSetpointMode')
state.setpointDuration = getInputDefaultValue('prefSetpointDuration')
state.boostTemperature = getInputDefaultValue('prefBoostTemperature')
state.suppressTemperature = getInputDefaultValue('prefSuppressTemperature')

}

/**

  • updated()

  • Runs when device settings are changed.
    **/
    def updated() {

    if (state.debug) log.debug "${device.label}: Updating with settings: ${settings}"

    // Copy input values to state:
    state.setpointMode = settings.prefSetpointMode
    state.setpointDuration = settings.prefSetpointDuration
    state.boostTemperature = formatTemperature(settings.prefBoostTemperature)
    state.suppressTemperature = formatTemperature(settings.prefSuppressTemperature)

}

/**********************************************************************

  • SmartApp-Child Interface Commands:
    **********************************************************************/

/**

  • generateEvent(values)
  • Called by parent to update the state of this child device.

**/
void generateEvent(values) {

//log.info "${device.label}: generateEvent(): New values: ${values}"

if(values) {
	values.each { name, value ->
		if ( name == 'minHeatingSetpoint' 
			|| name == 'maxHeatingSetpoint' 
			|| name == 'temperatureResolution' 
			|| name == 'windowFunctionTemperature'
			|| name == 'zoneType'
			|| name == 'locationId'
			|| name == 'gatewayId'
			|| name == 'systemId'
			|| name == 'zoneId'
			|| name == 'schedule'
			|| name == 'debug'
            || name == 'updateRefreshTime'
			) {
			// Internal state only.
			state."${name}" = value
		}
		else { // Attribute value, so generate an event:
			if (name != null && value != null) {
				sendEvent(name: name, value: value, displayed: true)
			}
			else { // If name or value is null, set displayed to false,
				   // otherwise the 'Recently' view on smartphone app clogs 
				   // up with empty events.
				sendEvent(name: name, value: value, displayed: false)
			}
			
			// Reset targetSetpoint (used by raiseSetpoint/lowerSetpoint) if heatingSetpoint has changed:
			if (name == 'heatingSetpoint') {
				state.targetSetpoint = value
			}
		}
	}
}

// Calculate derived attributes (order is important here):
calculateThermostatOperatingState()
calculateOptimisations()
calculateThermostatStatus()
calculateThermostatSetpointStatus()

}

/**********************************************************************

  • Capability-related Commands:
    **********************************************************************/

/**

  • poll()

  • Polls the device. Required for the "Polling" capability
    **/
    void poll() {

    if (state.debug) log.debug "${device.label}: poll()"
    parent.poll(state.zoneId)
    }

/**

  • refresh()

  • Refreshes values from the device. Required for the "Refresh" capability.
    **/
    void refresh() {

    if (state.debug) log.debug "${device.label}: refresh()"
    sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false)
    parent.poll(state.zoneId)
    }

/**

  • setThermostatMode(mode, until=-1)
  • Set thermostat mode until specified time.
  • mode: Possible values: 'auto','off','away','dayOff','custom', or 'economy'.
  • until: (Optional) Time to apply mode until, can be either:
  •         - Date: Date object representing when override should end.
    
  •         - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z".
    
  •         - String: 'permanent'.
    
  •         - Number: Duration in hours if mode is 'economy', or days if mode is 'away'/'dayOff'/'custom'.
    
  •                   Duration will be rounded down to align with Midnight i nthe local timezone
    
  •                   (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent.
    
  •                   If duration is not specified, a default value is used from the Evohome SmartApp settings.
    
  • Notes: 'Auto' and 'Off' modes are always permanent.
  •        Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller).
    
  •        Therefore changing the thermostatMode will affect all zones associated with the same controller.
    
  • Example usage:
  • setThermostatMode('off', 0) // Set off mode permanently.
  • setThermostatMode('away', 1) // Set away mode for one day (i.e. until midnight tonight).
  • setThermostatMode('dayOff', 2) // Set dayOff mode for two days (ends tomorrow night).
  • setThermostatMode('economy', 2) // Set economy mode for two hours.

**/
def setThermostatMode(String mode, until=-1) {

log.info "${device.label}: setThermostatMode(Mode: ${mode}, Until: ${until})"

// Send update via parent:
if (!parent.setThermostatMode(state.systemId, mode, until)) {
	sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false)
	// Wait a few seconds as it takes a while for Evohome to update setpoints in response to a mode change.
	pseudoSleep(state.updateRefreshTime * 1000)
	parent.poll(0) // Force poll for all zones as thermostatMode is a property of the temperatureControlSystem.
	return null
}
else {
	log.error "${device.label}: setThermostatMode(): Error: Unable to set thermostat mode."
	return 'error'
}

}

/**

  • setHeatingSetpoint(setpoint, until=-1)
  • Set heatingSetpoint until specified time.
  • setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string.
  •           If setpoint is outside allowed range (i.e. minHeatingSetpoint to 
    
  •           maxHeatingSetpoint) it will be re-written to the appropriate limit.
    
  • until: (Optional) Time to apply setpoint until, can be either:
  •            - Date: date object representing when override should end.
    
  •            - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z".
    
  •            - String: 'nextSwitchpoint', 'midnight', 'midday', or 'permanent'.
    
  •            - Number: duration in minutes (from now). 0 = permanent.
    
  •           If not specified, setpoint duration will default to the
    
  •           behaviour defined in the device settings.
    
  • Example usage:
  • setHeatingSetpoint(21.0) // Set until .
  • setHeatingSetpoint(21.0, 'nextSwitchpoint') // Set until next scheduled switchpoint.
  • setHeatingSetpoint(21.0, 'midnight') // Set until midnight.
  • setHeatingSetpoint(21.0, 'permanent') // Set permanently.
  • setHeatingSetpoint(21.0, 0) // Set permanently.
  • setHeatingSetpoint(21.0, 6) // Set for 6 hours.
  • setHeatingSetpoint(21.0, '2016-04-01T00:00:00Z') // Set until specific time.

**/
def setHeatingSetpoint(setpoint, until=-1) {

if (state.debug) log.debug "${device.label}: setHeatingSetpoint(Setpoint: ${setpoint}, Until: ${until})"

// Clean setpoint:
setpoint = formatTemperature(setpoint)
if (Float.parseFloat(setpoint) < Float.parseFloat(state.minHeatingSetpoint)) {
	log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is less than zone's minimum setpoint (${state.minHeatingSetpoint})."
	setpoint = state.minHeatingSetpoint
}
else if (Float.parseFloat(setpoint) > Float.parseFloat(state.maxHeatingSetpoint)) {
	log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is greater than zone's maximum setpoint (${state.maxHeatingSetpoint})."
	setpoint = state.maxHeatingSetpoint
}

// Clean and parse until value:
def untilRes
Calendar c = new GregorianCalendar()
def tzOffset = location.timeZone.getOffset(new Date().getTime()) // Timezone offset to UTC in milliseconds.

// If until has not been specified, determine behaviour from device state.setpointMode:
if (-1 == until) {
	switch (state.setpointMode) {
    	case 'Next Switchpoint':
        	until = 'nextSwitchpoint'
            break
    	case 'Midday':
        	until = 'midday'
            break
    	case 'Midnight':
        	until = 'midnight'
            break
    	case 'Duration':
        	until = state.setpointDuration ?: 0
            break
    	case 'Time':
			// TO DO : construct time, like we do for midnight.
			// settings.prefSetpointTime appears to return an ISO dateformat string.
			// However using an input of type "time" causes HTTP 500 errors in the IDE, so disabled for now.
			// If time has passed, then need to make it the next day.
			if (state.debug) log.debug "${device.label}: setHeatingSetpoint(): Time: ${state.SetpointTime}"
        	until = 'nextSwitchpoint'
            break
    	case 'Permanent':
        	until = 'permanent'
            break
    	default:
        	until = 'nextSwitchpoint'
            break
	}
}

if ('permanent' == until || 0 == until) {
	untilRes = 0
}
else if (until instanceof Date) {
	untilRes = until
}
else if ('nextSwitchpoint' == until) {
	untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", device.currentValue('nextScheduledTime'))
}
else if ('midday' == until) {
	untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().format("yyyy-MM-dd'T'12:00:00XX", location.timeZone)) 
}
else if ('midnight' == until) {
	c.add(Calendar.DATE, 1 ) // Add one day to calendar and use to get midnight in local time:
	untilRes =  new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", c.getTime().format("yyyy-MM-dd'T'00:00:00XX", location.timeZone))
}
else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string, so parse:
	untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until)
}
else if (until.isNumber()) { // until is a duration in minutes, so construct date from now():
	// Evohome supposedly only accepts setpoints for up to 24 hours, so we should limit minutes to 1440.
	// For now, just pass any duration and see if Evohome accepts it...
	untilRes = new Date( now() + (Math.round(until) * 60000) )
}
else {
	log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently."
	untilRes = 0
}

log.info "${device.label}: setHeatingSetpoint(): Setting setpoint to: ${setpoint} until: ${untilRes}"

// Send update via parent:
if (!parent.setHeatingSetpoint(state.zoneId, setpoint, untilRes)) {
	// Command was successful, but it takes a few seconds for the Evohome cloud service to update with new values.
	// Meanwhile, we know the new setpoint and thermostatSetpointMode anyway:
	sendEvent(name: 'heatingSetpoint', value: setpoint)
	sendEvent(name: 'thermostatSetpoint', value: setpoint)
	sendEvent(name: 'thermostatSetpointMode', value: (0 == untilRes) ? 'permanentOverride' : 'temporaryOverride' )
	sendEvent(name: 'thermostatSetpointUntil', value: (0 == untilRes) ? null : untilRes.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')))
	calculateThermostatOperatingState()
	calculateOptimisations()
	calculateThermostatStatus()
	sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false)
	pseudoSleep(state.updateRefreshTime * 1000)
	parent.poll(state.zoneId)
	return null
}
else {
	log.error "${device.label}: setHeatingSetpoint(): Error: Unable to set heating setpoint."
	return 'error'
}

}

/**

  • clearHeatingSetpoint()
  • Clear the heatingSetpoint. Will return heatingSetpoint to scheduled value.
  • thermostatSetpointMode should return to "followSchedule".

**/
def clearHeatingSetpoint() {

log.info "${device.label}: clearHeatingSetpoint()"

// Send update via parent:
if (!parent.clearHeatingSetpoint(state.zoneId)) {
	// Command was successful, but it takes a few seconds for the Evohome cloud service
	// to update the zone status with the new heatingSetpoint.
	// Meanwhile, we know the new thermostatSetpointMode is "followSchedule".
	sendEvent(name: 'thermostatSetpointMode', value: 'followSchedule')
	sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false)
	// sleep command is not allowed in SmartThings, so we use psuedoSleep().
	pseudoSleep(state.updateRefreshTime * 1000)
	parent.poll(state.zoneId)
	return null
}
else {
	log.error "${device.label}: clearHeatingSetpoint(): Error: Unable to clear heating setpoint."
	return 'error'
}

}

/**

  • raiseSetpoint()
  • Raise heatingSetpoint and thermostatSetpoint.
  • Increments by state.temperatureResolution (usually 0.5).
  • Called by raiseSetpoint tile.

**/
void raiseSetpoint() {

if (state.debug) log.debug "${device.label}: raiseSetpoint()"

def mode = device.currentValue("thermostatMode")
def targetSp = new BigDecimal(state.targetSetpoint)
def tempRes = new BigDecimal(state.temperatureResolution) // (normally 0.5)
def maxSp = new BigDecimal(state.maxHeatingSetpoint)

if ('off' == mode || 'away' == mode) {
	log.warn "${device.label}: raiseSetpoint(): thermostat mode (${mode}) does not allow altering the temperature setpoint."
}
else {
	targetSp += tempRes

	if (targetSp > maxSp) {
		targetSp = maxSp
	}
	
	state.targetSetpoint = targetSp
	log.info "${device.label}: raiseSetpoint(): Target setpoint raised to: ${targetSp}"
	sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false)
	runIn(3, "alterSetpoint", [overwrite: true]) // Wait three seconds in case targetSetpoint is changed again.
}

}

/**

  • lowerSetpoint()
  • Lower heatingSetpoint and thermostatSetpoint.
  • Increments by state.temperatureResolution (usually 0.5).
  • Called by lowerSetpoint tile.

**/
void lowerSetpoint() {

if (state.debug) log.debug "${device.label}: lowerSetpoint()"

def mode = device.currentValue("thermostatMode")
def targetSp = new BigDecimal(state.targetSetpoint)
def tempRes = new BigDecimal(state.temperatureResolution) // (normally 0.5)
def minSp = new BigDecimal(state.minHeatingSetpoint)

if ('off' == mode || 'away' == mode) {
	log.warn "${device.label}: lowerSetpoint(): thermostat mode (${mode}) does not allow altering the temperature setpoint."
}
else {
	targetSp -= tempRes 

	if (targetSp < minSp) {
		targetSp = minSp
	}
	
	state.targetSetpoint = targetSp
	log.info "${device.label}: lowerSetpoint(): Target setpoint lowered to: ${targetSp}"
	sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false)
	runIn(3, "alterSetpoint", [overwrite: true]) // Wait three seconds in case targetSetpoint is changed again.
}

}

/**

  • alterSetpoint()
  • Proxy command called by raiseSetpoint and lowerSetpoint, as runIn
  • cannot pass targetSetpoint diretly to setHeatingSetpoint.

**/
private alterSetpoint() {

if (state.debug) log.debug "${device.label}: alterSetpoint()"

setHeatingSetpoint(state.targetSetpoint)

}

/**********************************************************************

  • Convenience Commands:
  • These commands alias other commands with preset parameters.
    **********************************************************************/

void resume() {
if (state.debug) log.debug "${device.label}: resume()"
clearHeatingSetpoint()
}

void auto() {
if (state.debug) log.debug "${device.label}: auto()"
setThermostatMode('auto')
}

void heat() {
if (state.debug) log.debug "${device.label}: heat()"
setThermostatMode('auto')
}

void off() {
if (state.debug) log.debug "${device.label}: off()"
setThermostatMode('off')
}

void away(until=-1) {
if (state.debug) log.debug "${device.label}: away()"
setThermostatMode('away', until)
}

void custom(until=-1) {
if (state.debug) log.debug "${device.label}: custom()"
setThermostatMode('custom', until)
}

void dayOff(until=-1) {
if (state.debug) log.debug "${device.label}: dayOff()"
setThermostatMode('dayOff', until)
}

void economy(until=-1) {
if (state.debug) log.debug "${device.label}: economy()"
setThermostatMode('economy', until)
}

void boost() {
if (state.debug) log.debug "${device.label}: boost()"
setHeatingSetpoint(state.boostTemperature)
}

void suppress() {
if (state.debug) log.debug "${device.label}: suppress()"
setHeatingSetpoint(state.suppressTemperature)
}

/**********************************************************************

  • Helper Commands:
    **********************************************************************/

/**

  • pseudoSleep(ms)
  • Substitute for sleep() command.

**/
private pseudoSleep(ms) {
def start = now()
while (now() < start + ms) {
// Do nothing, just wait.
}
}

/**

  • getInputDefaultValue(inputName)
  • Get the default value for the specified input.

**/
private getInputDefaultValue(inputName) {

if (state.debug) log.debug "${device.label}: getInputDefaultValue()"

def returnValue
properties.preferences?.sections.each { section ->
	section.input.each { input ->
		if (input.name == inputName) {
			returnValue = input.defaultValue
		}
	}
}

return returnValue

}

/**

  • formatTemperature(t)
  • Format temperature value to one decimal place.
  • t: can be string, float, bigdecimal...
  • Returns as string.
    **/
    private formatTemperature(t) {
    //return Float.parseFloat("${t}").round(1)
    //return String.format("%.1f", Float.parseFloat("${t}").round(1))
    return Float.parseFloat("${t}").round(1).toString()
    }

/**

  • formatThermostatModeForDisp(mode)
  • Translate SmartThings values to display values.

**/
private formatThermostatModeForDisp(mode) {

if (state.debug) log.debug "${device.label}: formatThermostatModeForDisp()"

switch (mode) {
	case 'auto':
		mode = 'Auto'
		break
	case 'economy':
		mode = 'Economy'
		break
	case 'away':
		mode = 'Away'
		break
	case 'custom':
		mode = 'Custom'
		break
	case 'dayOff':
		mode = 'Day Off'
		break
	case 'off':
		mode = 'Off'
		break
	default:
		mode = 'Unknown'
		break
}

return mode

}

/**

  • calculateThermostatOperatingState()
  • Calculates thermostatOperatingState and generates event accordingly.

**/
private calculateThermostatOperatingState() {

if (state.debug) log.debug "${device.label}: calculateThermostatOperatingState()"

def tOS
if ('off' == device.currentValue('thermostatMode')) {
	tOS = 'off'
}
else if (device.currentValue("temperature") < device.currentValue("thermostatSetpoint")) {
	tOS = 'heating'
}
else {
	tOS = 'idle'
}

sendEvent(name: 'thermostatOperatingState', value: tOS)

}

/**

  • calculateOptimisations()
  • Calculates if optimisation and windowFunction are active
  • and generates events accordingly.
  • This isn't going to be 100% perfect, but is reasonably accurate.

**/
private calculateOptimisations() {

if (state.debug) log.debug "${device.label}: calculateOptimisations()"

def newOptValue = 'inactive'
def newWdfValue = 'inactive'

// Convert temp values to BigDecimals for comparison:
def heatingSp = new BigDecimal(device.currentValue('heatingSetpoint'))
def scheduledSp = new BigDecimal(device.currentValue('scheduledSetpoint'))
def nextScheduledSp = new BigDecimal(device.currentValue('nextScheduledSetpoint'))
// def windowTemp = new BigDecimal(state.windowFunctionTemperature)
def windowTemp = new BigDecimal(state.windowFunctionTemperature ?: formatTemperature(5.0))

if ('auto' != device.currentValue('thermostatMode')) {
	// Optimisations cannot be active if thermostatMode is not 'auto'.
}
else if ('followSchedule' != device.currentValue('thermostatSetpointMode')) {
	// Optimisations cannot be active if thermostatSetpointMode is not 'followSchedule'.
	// There must be a manual override.
}
else if (heatingSp == scheduledSp) {
	// heatingSetpoint is what it should be, so no reason to suspect that optimisations are active.
}
else if (heatingSp == nextScheduledSp) {
	// heatingSetpoint is the nextScheduledSetpoint, so optimisation is likely active:
	newOptValue = 'active'
}
else if (heatingSp == windowTemp) {
	// heatingSetpoint is the windowFunctionTemp, so windowFunction is likely active:
	newWdfValue = 'active'
}

sendEvent(name: 'optimisation', value: newOptValue)
sendEvent(name: 'windowFunction', value: newWdfValue)

}

/**

  • calculateThermostatStatus()
  • Calculates thermostatStatus and generates event accordingly.
  • thermostatStatus is a text summary of thermostatMode and thermostatOperatingState.

**/
private calculateThermostatStatus() {

if (state.debug) log.debug "${device.label}: calculateThermostatStatus()"

def newThermostatStatus = ''
def thermostatModeDisp = formatThermostatModeForDisp(device.currentValue('thermostatMode'))
def setpoint = device.currentValue('thermostatSetpoint')

if ('Off' == thermostatModeDisp) {
	newThermostatStatus = 'Off'
}
else if('heating' == device.currentValue('thermostatOperatingState')) {
	newThermostatStatus = "Heating to ${setpoint}Ā° (${thermostatModeDisp})"
}
else {
	newThermostatStatus = "Idle (${thermostatModeDisp})"
}

sendEvent(name: 'thermostatStatus', value: newThermostatStatus)

}

/**

  • calculateThermostatSetpointStatus()
  • Calculates thermostatSetpointStatus and generates event accordingly.
  • thermostatSetpointStatus is a text summary of thermostatSetpointMode and thermostatSetpointUntil.
  • It also indicates if 'optimisation' or 'windowFunction' is active.

**/
private calculateThermostatSetpointStatus() {

if (state.debug) log.debug "${device.label}: calculateThermostatSetpointStatus()"

def newThermostatSetpointStatus = ''
def setpointMode = device.currentValue('thermostatSetpointMode')

if ('off' == device.currentValue('thermostatMode')) {
	newThermostatSetpointStatus = 'Off'
}
else if ('away' == device.currentValue('thermostatMode')) {
	newThermostatSetpointStatus = 'Away'
}
else if ('active' == device.currentValue('optimisation')) {
	newThermostatSetpointStatus = 'Optimisation Active'
}
else if ('active' == device.currentValue('windowFunction')) {
	newThermostatSetpointStatus = 'Window Function Active'
}
else if ('followSchedule' == setpointMode) {
	newThermostatSetpointStatus = 'Following Schedule'
}
else if ('permanentOverride' == setpointMode) {
	newThermostatSetpointStatus = 'Permanent'
}
else {
	def untilStr = device.currentValue('thermostatSetpointUntil')
	if (untilStr) {
	
		//def nowDate = new Date()
		
		// thermostatSetpointUntil is an ISO-8601 date format in UTC, and parse() seems to assume date is in UTC.
		def untilDate = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilStr) 
		def untilDisp = ''
		
		if (untilDate.format("u") == new Date().format("u")) { // Compare day of week to current day of week (today).
			untilDisp = untilDate.format("HH:mm", location.timeZone) // Same day, so just show time.
		}
		else {
			untilDisp = untilDate.format("HH:mm 'on' EEEE", location.timeZone) // Different day, so include name of day.
		}
		newThermostatSetpointStatus = "Temporary Until ${untilDisp}"
	}
	else {
		newThermostatSetpointStatus = "Temporary"
	}
}

sendEvent(name: 'thermostatSetpointStatus', value: newThermostatSetpointStatus)

}

Hopefully that'll be a start and get things working for you.

In drivers code, I opened the original, deleted the code from the window, pasted the revised code into the same window and saved. I did the same with the app code (opened the app code page deleted the code (not the app) pasted the revised in and saved)

1 Like

Of course - apologies. Iā€™m using the code in ApriliaEddā€™s GitHub and have tried both the Beta version of the driver (which I think is meant to fix the Bigdecimal error) and the original. For some reason I canā€™t post the direct link in this reply but this will help find it: github com ApriliaEdd Hubitat

Many thanks for replying.

Simon

1 Like

Thanks @johnwill1, thatā€™s really helpful. Iā€™ll have a go with that code. Iā€™m in the UK so no cooling needed either and Iā€™m not that worried about the modes.

I had it working really well with ST and triggered temperature changes based on which rooms were occupied which saves a lot on fuel bills!

Iā€™ll let you know how I get on.

Simon

1 Like

The main issue is dashboards. It will work perfectly with SharpTools but not with Hubitat Dashboard which is an issue with the app and driver code. It would be great if it was fully compatible but it's a big ask on here as there are only a few users I'm aware of using this UK version. ApriliaEdd made changes just to get it working for himself initially (he's not a dev) and moved over to Home Assistant I believe so doesn't use it anymore.

Iā€™m early on my HE journey but my initial plan is to use ActionTiles which combines some of my existing ST controls with HE. Do you know if it works with ActionTiles? @johnwill1

If you join the hub owners group, youā€™ll gain regular forum priviliges like the ability to post links.

2 Likes

I don't - I only used ActionTiles briefly so I'm not sure. The issue something to do with the capabilities in the driver. The standard thermostat tile, when you click the up/down arrow sends a different command to the one the driver needs (I think maybe the tile sends setHeatingSetpoint and raiseSetpoint is needed or something like that)

If you're new to HE I'd highly recommend Hubitat Dashboard for Android by Joe Page. It's totally local and very customisable. It's Android only but works fine on fire tablets

Thanks Mark - I've done that now.

1 Like

@johnwill1 - thanks for posting your app and driver code. I tried updating the code as you dexcribed and still got errors, so cleaned everything down, removed the old devices, app and driver code to start fresh. I still seem to be getting errors when I try to change temperatures. I've posted the logs below - any ideas on how to diagnose further?

1 Like

So is that when youā€™re trying to adjust the temperature in Action Tiles or directly from the panel on the device page? If itā€™s from Action Tiles itā€™s probably what I said about the wrong capabilityā€™s in the driver.

Thanks for you reply @johnwill1 . It happens when using the app. I havenā€™t added devices to ActionTiles yet. Are there any actions on the app that should definitely work? It does successfully retrieve device states.

Yes. Not all commands work in the device panel but Iā€™m able to set a temperature override, resume a mode following an override etc.

Iā€™m away from home for a few days at the moment but when I get back Iā€™ll post what does/doesnā€™t work. I might be able to get at my hub and paste a couple of rules that work for me.

Some of my rules:

Door open and stays, set heatingSetpoint to 5, wait until door closes, resume.

I have 4 virtual switches (set to auto off) titled away, economy, auto, day off. 1 rule maps those to the modes.

In rule actions Iā€™m using Custom Command, device type Temperature (not thermostat), selecting the zone, then picking the heatingSetpoint command and entering a value.

@Simonfea - I managed to VPN home and take a quick look at the panel.

From memory:

  • raiseSetpoint, lowerSetpoint don't work.
  • setHeatingSetpoint, auto, away, dayOff, resume do work.

Example RM Rule for setting the modes via virtual buttons:

In the above you could pick ANY of your zones. Setting a mode on one zone applies the mode to the entire system (Bathroom was first in the list alphabetically and so was picked when I set the rule up)

Example window mode rule:

This works great but there's a caveat... As it completes with a 'resume' it will go back to whatever the scheduled temperature is. That can be a pain if before the door was opened you had overridden the scheduled temperature manually.

Thanks for all the help. I gave up on this last year but have come back to have another go! Everything works except setting heating setpoints which is really frustrating. I'm still getting an error as below. I'm just not sure how to go about debugging this further so any ideas welcome!

That should be easy to fix, give me a link to the current code you are using.
If its your own tweaked code not public anywhere post it on pastebin.com or Github gists https://gist.github.com/ and post a link for it.