Skip to content

Instantly share code, notes, and snippets.

@jtp10181
Last active October 16, 2023 12:33
Show Gist options
  • Save jtp10181/2b921f88ff860ac474abd108ef6bb467 to your computer and use it in GitHub Desktop.
Save jtp10181/2b921f88ff860ac474abd108ef6bb467 to your computer and use it in GitHub Desktop.
Evohome (Connect)
/**
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: GitHub - codersaur/SmartThings: Samsung SmartThings SmartApps and Device Handlers 1
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).
Tidy up settings: See: One simple home system. A world of possibilities. | SmartThings
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
}
/**
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.
For latest documentation see: GitHub - codersaur/SmartThings: Samsung SmartThings SmartApps and Device Handlers
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) { setHeatingSetpoint(setpoint, -1) }
def setHeatingSetpoint(setpoint, until) {
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)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment