/**
 *  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.
 *   - For latest documentation see: https://github.com/codersaur/SmartThings
 *
 *  Version History:
 * 
 *   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).
 *   - Tidy up settings: See: http://docs.smartthings.com/en/latest/smartapp-developers-guide/preferences-and-settings.html
 *   - 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://tccna.honeywell.com'
	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) {
	
		log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..."
		authenticate()
	}
	else if (atomicState.evohomeAuthFailed) {
	
		log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..."
		authenticate()
	}
	else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) {
	
		log.info "${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) {
			log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires."
			refreshAuthToken()
		}
		else {
			log.info "${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)")
							log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label},  DNI: ${dni}"
		                   	d = addChildDevice(app.namespace, "Evohome Heating Zone", dni, null, 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 {
		log.info "${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://tccna.honeywell.com',
		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',
			'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)
				log.info "${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://tccna.honeywell.com',
		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',
			'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)
				log.info "${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() {

	log.info "${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() {

	log.info "${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):
		
		log.info "${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:
		
		log.info "${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() {

	log.info "${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']
		log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True"
	}
	else { // Mode is temporary:
		body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False']
		log.info "${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]
		log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent"
	}
	else { // Temporary:
		body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes]
		log.info "${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) {

	log.info "${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
}
  