Last active
November 28, 2020 04:32
-
-
Save nayelyzarazua-bluetrail/7fd9ff595fc898f7b8cc905267ac90b9 to your computer and use it in GitHub Desktop.
evohomeIntegration
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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: https://github.com/codersaur/SmartThings | |
* | |
* 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. | |
* https://github.com/codersaur/SmartThings/blob/master/devices/evohome/evohome-heating-zone.groovy >>>>>>>>>>>> Original | |
Se conecta con la smartapp por el nombre del DTH? | |
*/ | |
metadata { | |
definition (name: "EvohomecodesaurWchanges", namespace: "codersaur", author: "David Lomas", deviceTypeId:"Thermostat",ocfDeviceType:"oic.d.thermostat",vid:"6a738c4d-9308-378f-afbc-aa06591b6e50", mnmn:"SmartThingsCommunity") { | |
capability "Actuator" | |
capability "Sensor" | |
capability "Refresh" | |
capability "Temperature Measurement" | |
// capability "Thermostat" | |
capability "thermostatHeatingSetpoint" | |
capability "thermostatSetpoint" | |
capability "thermostatMode" | |
//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}" | |
initialize() | |
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) | |
} | |
def initialize() { | |
sendEvent(name:"temperature", value:"72", unit:"F") | |
sendEvent(name:"heatingSetpoint", value:"7", unit:"F") | |
sendEvent(name:"thermostatSetpoint", value:"7", unit:"F") | |
sendEvent(name:"thermostatMode", value:"cool") | |
sendEvent(name:"supportedThermostatModes", value:"['auto','cool','eco','rush hour','emergency heat','heat','off']") | |
} | |
/********************************************************************** | |
* 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) { | |
if(name=='temperature'){ | |
//add unit for Temperature because it is needed in Dashboard View | |
sendEvent(name: name, value: value, unit:"C", displayed: true) | |
}else{ | |
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 <device default>. | |
* setHeatingSetpoint(21.0, 'nextSwitchpoint') // Set until next scheduled switchpoint. | |
* setHeatingSetpoint(21.0, 'midnight') // Set until midnight. | |
* setHeatingSetpoint(21.0, 'permanent') // Set permanently. | |
* setHeatingSetpoint(21.0, 0) // Set permanently. | |
* setHeatingSetpoint(21.0, 6) // Set for 6 hours. | |
* setHeatingSetpoint(21.0, '2016-04-01T00:00:00Z') // Set until specific time. | |
* | |
**/ | |
def setHeatingSetpoint(setpoint, until=-1) { | |
if (state.debug) log.debug "${device.label}: setHeatingSetpoint(Setpoint: ${setpoint}, Until: ${until})" | |
// Clean setpoint: | |
setpoint = formatTemperature(setpoint) | |
if (Float.parseFloat(setpoint) < Float.parseFloat(state.minHeatingSetpoint)) { | |
log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is less than zone's minimum setpoint (${state.minHeatingSetpoint})." | |
setpoint = state.minHeatingSetpoint | |
} | |
else if (Float.parseFloat(setpoint) > Float.parseFloat(state.maxHeatingSetpoint)) { | |
log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is greater than zone's maximum setpoint (${state.maxHeatingSetpoint})." | |
setpoint = state.maxHeatingSetpoint | |
} | |
// Clean and parse until value: | |
def untilRes | |
Calendar c = new GregorianCalendar() | |
def tzOffset = location.timeZone.getOffset(new Date().getTime()) // Timezone offset to UTC in milliseconds. | |
// If until has not been specified, determine behaviour from device state.setpointMode: | |
if (-1 == until) { | |
switch (state.setpointMode) { | |
case 'Next Switchpoint': | |
until = 'nextSwitchpoint' | |
break | |
case 'Midday': | |
until = 'midday' | |
break | |
case 'Midnight': | |
until = 'midnight' | |
break | |
case 'Duration': | |
until = state.setpointDuration ?: 0 | |
break | |
case 'Time': | |
// TO DO : construct time, like we do for midnight. | |
// settings.prefSetpointTime appears to return an ISO dateformat string. | |
// However using an input of type "time" causes HTTP 500 errors in the IDE, so disabled for now. | |
// If time has passed, then need to make it the next day. | |
if (state.debug) log.debug "${device.label}: setHeatingSetpoint(): Time: ${state.SetpointTime}" | |
until = 'nextSwitchpoint' | |
break | |
case 'Permanent': | |
until = 'permanent' | |
break | |
default: | |
until = 'nextSwitchpoint' | |
break | |
} | |
} | |
if ('permanent' == until || 0 == until) { | |
untilRes = 0 | |
} | |
else if (until instanceof Date) { | |
untilRes = until | |
} | |
else if ('nextSwitchpoint' == until) { | |
untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", device.currentValue('nextScheduledTime')) | |
} | |
else if ('midday' == until) { | |
untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().format("yyyy-MM-dd'T'12:00:00XX", location.timeZone)) | |
} | |
else if ('midnight' == until) { | |
c.add(Calendar.DATE, 1 ) // Add one day to calendar and use to get midnight in local time: | |
untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", c.getTime().format("yyyy-MM-dd'T'00:00:00XX", location.timeZone)) | |
} | |
else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string, so parse: | |
untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until) | |
} | |
else if (until.isNumber()) { // until is a duration in minutes, so construct date from now(): | |
// Evohome supposedly only accepts setpoints for up to 24 hours, so we should limit minutes to 1440. | |
// For now, just pass any duration and see if Evohome accepts it... | |
untilRes = new Date( now() + (Math.round(until) * 60000) ) | |
} | |
else { | |
log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." | |
untilRes = 0 | |
} | |
log.info "${device.label}: setHeatingSetpoint(): Setting setpoint to: ${setpoint} until: ${untilRes}" | |
// Send update via parent: | |
if (!parent.setHeatingSetpoint(state.zoneId, setpoint, untilRes)) { | |
// Command was successful, but it takes a few seconds for the Evohome cloud service to update with new values. | |
// Meanwhile, we know the new setpoint and thermostatSetpointMode anyway: | |
sendEvent(name: 'heatingSetpoint', value: setpoint) | |
sendEvent(name: 'thermostatSetpoint', value: setpoint) | |
sendEvent(name: 'thermostatSetpointMode', value: (0 == untilRes) ? 'permanentOverride' : 'temporaryOverride' ) | |
sendEvent(name: 'thermostatSetpointUntil', value: (0 == untilRes) ? null : untilRes.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC'))) | |
calculateThermostatOperatingState() | |
calculateOptimisations() | |
calculateThermostatStatus() | |
sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) | |
pseudoSleep(state.updateRefreshTime * 1000) | |
parent.poll(state.zoneId) | |
return null | |
} | |
else { | |
log.error "${device.label}: setHeatingSetpoint(): Error: Unable to set heating setpoint." | |
return 'error' | |
} | |
} | |
/** | |
* clearHeatingSetpoint() | |
* | |
* Clear the heatingSetpoint. Will return heatingSetpoint to scheduled value. | |
* thermostatSetpointMode should return to "followSchedule". | |
* | |
**/ | |
def clearHeatingSetpoint() { | |
log.info "${device.label}: clearHeatingSetpoint()" | |
// Send update via parent: | |
if (!parent.clearHeatingSetpoint(state.zoneId)) { | |
// Command was successful, but it takes a few seconds for the Evohome cloud service | |
// to update the zone status with the new heatingSetpoint. | |
// Meanwhile, we know the new thermostatSetpointMode is "followSchedule". | |
sendEvent(name: 'thermostatSetpointMode', value: 'followSchedule') | |
sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) | |
// sleep command is not allowed in SmartThings, so we use psuedoSleep(). | |
pseudoSleep(state.updateRefreshTime * 1000) | |
parent.poll(state.zoneId) | |
return null | |
} | |
else { | |
log.error "${device.label}: clearHeatingSetpoint(): Error: Unable to clear heating setpoint." | |
return 'error' | |
} | |
} | |
/** | |
* raiseSetpoint() | |
* | |
* Raise heatingSetpoint and thermostatSetpoint. | |
* Increments by state.temperatureResolution (usually 0.5). | |
* | |
* Called by raiseSetpoint tile. | |
* | |
**/ | |
void raiseSetpoint() { | |
if (state.debug) log.debug "${device.label}: raiseSetpoint()" | |
def mode = device.currentValue("thermostatMode") | |
def targetSp = new BigDecimal(state.targetSetpoint) | |
def tempRes = new BigDecimal(state.temperatureResolution) // (normally 0.5) | |
def maxSp = new BigDecimal(state.maxHeatingSetpoint) | |
if ('off' == mode || 'away' == mode) { | |
log.warn "${device.label}: raiseSetpoint(): thermostat mode (${mode}) does not allow altering the temperature setpoint." | |
} | |
else { | |
targetSp += tempRes | |
if (targetSp > maxSp) { | |
targetSp = maxSp | |
} | |
state.targetSetpoint = targetSp | |
log.info "${device.label}: raiseSetpoint(): Target setpoint raised to: ${targetSp}" | |
sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) | |
runIn(3, "alterSetpoint", [overwrite: true]) // Wait three seconds in case targetSetpoint is changed again. | |
} | |
} | |
/** | |
* lowerSetpoint() | |
* | |
* Lower heatingSetpoint and thermostatSetpoint. | |
* Increments by state.temperatureResolution (usually 0.5). | |
* | |
* Called by lowerSetpoint tile. | |
* | |
**/ | |
void lowerSetpoint() { | |
if (state.debug) log.debug "${device.label}: lowerSetpoint()" | |
def mode = device.currentValue("thermostatMode") | |
def targetSp = new BigDecimal(state.targetSetpoint) | |
def tempRes = new BigDecimal(state.temperatureResolution) // (normally 0.5) | |
def minSp = new BigDecimal(state.minHeatingSetpoint) | |
if ('off' == mode || 'away' == mode) { | |
log.warn "${device.label}: lowerSetpoint(): thermostat mode (${mode}) does not allow altering the temperature setpoint." | |
} | |
else { | |
targetSp -= tempRes | |
if (targetSp < minSp) { | |
targetSp = minSp | |
} | |
state.targetSetpoint = targetSp | |
log.info "${device.label}: lowerSetpoint(): Target setpoint lowered to: ${targetSp}" | |
sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) | |
runIn(3, "alterSetpoint", [overwrite: true]) // Wait three seconds in case targetSetpoint is changed again. | |
} | |
} | |
/** | |
* alterSetpoint() | |
* | |
* Proxy command called by raiseSetpoint and lowerSetpoint, as runIn | |
* cannot pass targetSetpoint diretly to setHeatingSetpoint. | |
* | |
**/ | |
private alterSetpoint() { | |
if (state.debug) log.debug "${device.label}: alterSetpoint()" | |
setHeatingSetpoint(state.targetSetpoint) | |
} | |
/********************************************************************** | |
* Convenience Commands: | |
* These commands alias other commands with preset parameters. | |
**********************************************************************/ | |
void resume() { | |
if (state.debug) log.debug "${device.label}: resume()" | |
clearHeatingSetpoint() | |
} | |
void auto() { | |
if (state.debug) log.debug "${device.label}: auto()" | |
setThermostatMode('auto') | |
} | |
void heat() { | |
if (state.debug) log.debug "${device.label}: heat()" | |
setThermostatMode('auto') | |
} | |
void off() { | |
if (state.debug) log.debug "${device.label}: off()" | |
setThermostatMode('off') | |
} | |
void away(until=-1) { | |
if (state.debug) log.debug "${device.label}: away()" | |
setThermostatMode('away', until) | |
} | |
void custom(until=-1) { | |
if (state.debug) log.debug "${device.label}: custom()" | |
setThermostatMode('custom', until) | |
} | |
void dayOff(until=-1) { | |
if (state.debug) log.debug "${device.label}: dayOff()" | |
setThermostatMode('dayOff', until) | |
} | |
void economy(until=-1) { | |
if (state.debug) log.debug "${device.label}: economy()" | |
setThermostatMode('economy', until) | |
} | |
void boost() { | |
if (state.debug) log.debug "${device.label}: boost()" | |
setHeatingSetpoint(state.boostTemperature) | |
} | |
void suppress() { | |
if (state.debug) log.debug "${device.label}: suppress()" | |
setHeatingSetpoint(state.suppressTemperature) | |
} | |
/********************************************************************** | |
* Helper Commands: | |
**********************************************************************/ | |
/** | |
* pseudoSleep(ms) | |
* | |
* Substitute for sleep() command. | |
* | |
**/ | |
private pseudoSleep(ms) { | |
def start = now() | |
while (now() < start + ms) { | |
// Do nothing, just wait. | |
} | |
} | |
/** | |
* getInputDefaultValue(inputName) | |
* | |
* Get the default value for the specified input. | |
* | |
**/ | |
private getInputDefaultValue(inputName) { | |
if (state.debug) log.debug "${device.label}: getInputDefaultValue()" | |
def returnValue | |
properties.preferences?.sections.each { section -> | |
section.input.each { input -> | |
if (input.name == inputName) { | |
returnValue = input.defaultValue | |
} | |
} | |
} | |
return returnValue | |
} | |
/** | |
* formatTemperature(t) | |
* | |
* Format temperature value to one decimal place. | |
* t: can be string, float, bigdecimal... | |
* Returns as string. | |
**/ | |
private formatTemperature(t) { | |
//return Float.parseFloat("${t}").round(1) | |
//return String.format("%.1f", Float.parseFloat("${t}").round(1)) | |
return Float.parseFloat("${t}").round(1).toString() | |
} | |
/** | |
* formatThermostatModeForDisp(mode) | |
* | |
* Translate SmartThings values to display values. | |
* | |
**/ | |
private formatThermostatModeForDisp(mode) { | |
if (state.debug) log.debug "${device.label}: formatThermostatModeForDisp()" | |
switch (mode) { | |
case 'auto': | |
mode = 'Auto' | |
break | |
case 'economy': | |
mode = 'Economy' | |
break | |
case 'away': | |
mode = 'Away' | |
break | |
case 'custom': | |
mode = 'Custom' | |
break | |
case 'dayOff': | |
mode = 'Day Off' | |
break | |
case 'off': | |
mode = 'Off' | |
break | |
default: | |
mode = 'Unknown' | |
break | |
} | |
return mode | |
} | |
/** | |
* calculateThermostatOperatingState() | |
* | |
* Calculates thermostatOperatingState and generates event accordingly. | |
* | |
**/ | |
private calculateThermostatOperatingState() { | |
if (state.debug) log.debug "${device.label}: calculateThermostatOperatingState()" | |
def tOS | |
if ('off' == device.currentValue('thermostatMode')) { | |
tOS = 'off' | |
} | |
else if (device.currentValue("temperature") < device.currentValue("thermostatSetpoint")) { | |
tOS = 'heating' | |
} | |
else { | |
tOS = 'idle' | |
} | |
sendEvent(name: 'thermostatOperatingState', value: tOS) | |
} | |
/** | |
* calculateOptimisations() | |
* | |
* Calculates if optimisation and windowFunction are active | |
* and generates events accordingly. | |
* | |
* This isn't going to be 100% perfect, but is reasonably accurate. | |
* | |
**/ | |
private calculateOptimisations() { | |
if (state.debug) log.debug "${device.label}: calculateOptimisations()" | |
def newOptValue = 'inactive' | |
def newWdfValue = 'inactive' | |
// Convert temp values to BigDecimals for comparison: | |
def heatingSp = new BigDecimal(device.currentValue('heatingSetpoint')) | |
def scheduledSp = new BigDecimal(device.currentValue('scheduledSetpoint')) | |
def nextScheduledSp = new BigDecimal(device.currentValue('nextScheduledSetpoint')) | |
def windowTemp = new BigDecimal(state.windowFunctionTemperature) | |
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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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}" | |
//Manual update | |
//updateChildDeviceConfig() | |
//updateChildDevice() | |
} | |
/** | |
* 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' | |
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()*/ | |
//Manual update | |
updateChildDeviceConfig() | |
updateChildDevice() | |
// 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()" | |
def dni = 'networkID.test.123' | |
//activeDnis << dni | |
def values = [ | |
'debug': '', | |
'updateRefreshTime': '', | |
'minHeatingSetpoint': '', | |
'maxHeatingSetpoint': '', | |
'temperatureResolution': '', | |
'windowFunctionTemperature': '', | |
'zoneType': '', | |
'locationId': '', | |
'gatewayId': '', | |
'systemId': '', | |
'zoneId': '' | |
] | |
def d = getChildDevice(dni) | |
if(!d) { | |
try { | |
values.put('label', "test Heating Zone (Evohome)") | |
log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" | |
d = addChildDevice(app.namespace, "EvohomecodesaurWchanges", 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) | |
} | |
// 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, "EvohomecodesaurWchanges", dni, null, values)/*****check****** | |
} catch (e) { | |
log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" | |
} | |
} | |
if(d) { | |
d.generateEvent(values)/***check****que onda con este generateEvent | |
} | |
} | |
} | |
} | |
}*/ | |
/*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})" | |
def dni = 'networkID.test.123' | |
def d = getChildDevice(dni) | |
if(d) { | |
def values = [ | |
'temperature': '35', | |
'heatingSetpoint': '27', | |
'thermostatSetpoint': '38', | |
'thermostatMode': formatThermostatMode('Eco') | |
] | |
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." | |
} | |
/*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)/****check -- this is where the capabilities are updated, how they identify the device?*** | |
} 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', | |
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) | |
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')/******este ID que es?**********/ | |
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', | |
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) | |
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) {/******De donde se manda llamar********/ | |
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) { | |
log.debug 'generateDNI Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.') | |
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 = 'eco' | |
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 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment