diff --git a/devices/Evohome/Evohome Heating Zone.groovy b/devices/Evohome/Evohome Heating Zone.groovy new file mode 100644 index 0000000..0c0b737 --- /dev/null +++ b/devices/Evohome/Evohome Heating Zone.groovy @@ -0,0 +1,989 @@ +/** + * Copyright 2016 David Lomas (codersaur) + * + * Name: Evohome Heating Zone + * + * Author: David Lomas (codersaur) + * + * Date: 2016-04-03 + * + * Version: 0.06 + * + * 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-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" + + //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.thermostatOperatingState", key: "OPERATING_STATE") { + // attributeState("idle", backgroundColor:"#44b621") + // attributeState("heating", backgroundColor:"#ffa81e") + // attributeState("off", backgroundColor:"#269bd2") + //} + 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 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() { + + state.installedAt = now() + + // These default values will be overwritten by the Evohome SmartApp almost immediately: + state.debug = false + state.zoneType = 'RadiatorZone' + state.minHeatingSetpoint = formatTemperature(5.0) + state.maxHeatingSetpoint = formatTemperature(35.0) + state.temperatureResolution = formatTemperature(0.5) + 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}" + + // Inputs: + 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) { + + if (state.debug) log.debug "${device.label}: generateEvent(): 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' + ) { + // Internal device state only. + state."${name}" = value + } + else { + sendEvent(name: name, value: value) + + // update internal targetSetpoint too: + 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, any 'until' value will be ignored. + * 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 until midnight tonight. + * setThermostatMode('dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night). + * setThermostatMode('economy', 2) // Set economy mode for 2 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)) { + // Wait a few seconds as it takes a while for Evohome to update setpoints in response to a mode change. + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + pseudoSleep(4000) + parent.poll(state.zoneId) + return null + } + else { + log.error "${device.label}: setThermostatMode(): Error: Unable to set thermostat mode." + return 'error' + } +} + + +/** + * setHeatingSetpoint(setpoint, until=-1) + * + * Set heatingSetpoint until specified time. + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * If setpoint is outside allowed range (i.e. minHeatingSetpoint to + * maxHeatingSetpoint) it will be re-written to the appropriate limit. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'nextSwitchpoint', 'midnight', 'midday', or 'permanent'. + * - Number: duration in minutes (from now). 0 = permanent. + * If not specified, setpoint duration will default to the + * behaviour defined in the device settings. + * + * Example usage: + * setHeatingSetpoint(21.0) // Set until . + * setHeatingSetpoint(21.0, 'nextSwitchpoint') // Set until next scheduled switchpoint. + * setHeatingSetpoint(21.0, 'midnight') // Set until midnight. + * setHeatingSetpoint(21.0, 'permanent') // Set permanently. + * setHeatingSetpoint(21.0, 0) // Set permanently. + * setHeatingSetpoint(21.0, 6) // Set for 6 hours. + * setHeatingSetpoint(21.0, '2016-04-01T00:00:00Z') // Set until specific time. + * + **/ +def setHeatingSetpoint(setpoint, until=-1) { + + if (state.debug) log.debug "${device.label}: setHeatingSetpoint(Setpoint: ${setpoint}, Until: ${until})" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + if (Float.parseFloat(setpoint) < Float.parseFloat(state.minHeatingSetpoint)) { + log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is less than zone's minimum setpoint (${state.minHeatingSetpoint})." + setpoint = state.minHeatingSetpoint + } + else if (Float.parseFloat(setpoint) > Float.parseFloat(state.maxHeatingSetpoint)) { + log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is greater than zone's maximum setpoint (${state.maxHeatingSetpoint})." + setpoint = state.maxHeatingSetpoint + } + + // Clean and parse until value: + def untilRes + Calendar c = new GregorianCalendar() + def tzOffset = location.timeZone.getOffset(new Date().getTime()) // Timezone offset to UTC in milliseconds. + + // If until has not been specified, determine behaviour from device state.setpointMode: + if (-1 == until) { + switch (state.setpointMode) { + case 'Next Switchpoint': + until = 'nextSwitchpoint' + break + case 'Midday': + until = 'midday' + break + case 'Midnight': + until = 'midnight' + break + case 'Duration': + until = state.setpointDuration ?: 0 + break + case 'Time': + // TO DO : construct time, like we do for midnight. + // settings.prefSetpointTime appears to return an ISO dateformat string. + // However using an input of type "time" causes HTTP 500 errors in the IDE, so disabled for now. + // If time has passed, then need to make it the next day. + if (state.debug) log.debug "${device.label}: setHeatingSetpoint(): Time: ${state.SetpointTime}" + until = 'nextSwitchpoint' + break + case 'Permanent': + until = 'permanent' + break + default: + until = 'nextSwitchpoint' + break + } + } + + if ('permanent' == until || 0 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until + } + else if ('nextSwitchpoint' == until) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", device.currentValue('nextScheduledTime')) + } + else if ('midday' == until) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().format("yyyy-MM-dd'T'12:00:00XX", location.timeZone)) + } + else if ('midnight' == until) { + c.add(Calendar.DATE, 1 ) // Add one day to calendar and use to get midnight in local time: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", c.getTime().format("yyyy-MM-dd'T'00:00:00XX", location.timeZone)) + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string, so parse: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until) + } + else if (until.isNumber()) { // until is a duration in minutes, so construct date from now(): + // Evohome supposedly only accepts setpoints for up to 24 hours, so we should limit minutes to 1440. + // For now, just pass any duration and see if Evohome accepts it... + untilRes = new Date( now() + (Math.round(until) * 60000) ) + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + log.info "${device.label}: setHeatingSetpoint(): Setting setpoint to: ${setpoint} until: ${untilRes}" + + // Send update via parent: + if (!parent.setHeatingSetpoint(state.zoneId, setpoint, untilRes)) { + // Command was successful, but it takes a few seconds for the Evohome cloud service to update with new values. + // Meanwhile, we know the new setpoint and thermostatSetpointMode anyway: + sendEvent(name: 'heatingSetpoint', value: setpoint) + sendEvent(name: 'thermostatSetpoint', value: setpoint) + sendEvent(name: 'thermostatSetpointMode', value: (0 == untilRes) ? 'permanentOverride' : 'temporaryOverride' ) + sendEvent(name: 'thermostatSetpointUntil', value: (0 == untilRes) ? null : untilRes.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC'))) + calculateThermostatOperatingState() + calculateOptimisations() + calculateThermostatStatus() + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + pseudoSleep(3000) + 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(3000) + 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]) + } + +} + + +/** + * 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]) + } + +} + + +/** + * 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' + + 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 (device.currentValue('heatingSetpoint') == device.currentValue('scheduledSetpoint')) { + // heatingSetpoint is what it should be, so no reason to suspect that optimisations are active. + } + else if (device.currentValue('heatingSetpoint') == device.currentValue('nextScheduledSetpoint')) { + // heatingSetpoint is the nextScheduledSetpoint, so optimisation is likely active: + newOptValue = 'active' + } + else if (device.currentValue('heatingSetpoint') == (state.windowFunctionTemperature ?: 5.0)) { + // 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) +} \ No newline at end of file diff --git a/smartapps/Evohome/Evohome (Connect).groovy b/smartapps/Evohome/Evohome (Connect).groovy new file mode 100644 index 0000000..ea2ab30 --- /dev/null +++ b/smartapps/Evohome/Evohome (Connect).groovy @@ -0,0 +1,1327 @@ +/** + * Copyright 2016 David Lomas (codersaur) + * + * Name: Evohome (Connect) + * + * Author: David Lomas (codersaur) + * + * Date: 2016-04-03 + * + * Version: 0.06 + * + * 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-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 "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true + input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5.0, required: true, displayDuringSetup: true + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for. Specify 0 to apply modes permanently. 'Auto' and 'Off' modes are always permanent.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', description: 'Apply thermostat modes for this many days', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', description: 'Apply economy mode for this many hours', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + **/ +def installed() { + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://tccna.honeywell.com' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + + // 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()" + randomOffset = rand.nextInt(60) + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + 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()" + randomOffset = rand.nextInt(60) + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + 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." + } + } + + log.debug "${app.label}: manageAuth(): Finish" +} + + +/** + * 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. + * + * 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 not specified all zones are updated. + * + * Different types of information are collected on different schedules: + * - Zone status information is polled according to ${evohomeStatusPollInterval}. + * - Zone schedules are polled according to ${evohomeSchedulePollInterval}. + * + **/ +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 != -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, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating Zone", dni, null, values) + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + } + } + } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}" + + // Delete Devices: + def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete." + + delete.each { + log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}" + try { + deleteChildDevice(it.deviceNetworkId) + } + catch(e) { + log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}" + } + } +} + + + +/** + * updateChildDevice(onlyZoneId=-1) + * + * Update the attributes of a child device from atomicState.evohomeStatus + * and atomicState.evohomeSchedules. + * + * If onlyZoneId is not specified, then all zones are updated. + * + * Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime. + * + **/ +void updateChildDevice(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})" + + atomicState.evohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId) + def d = getChildDevice(dni) + if(d) { + def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + def currSw = getCurrentSwitchpoint(schedule.schedule) + def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(zone?.temperatureStatus?.temperature), + //'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable, + 'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpointMode': formatSetpointMode(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'scheduledSetpoint': formatTemperature(currSw.temperature), + 'nextScheduledSetpoint': formatTemperature(nextSw.temperature), + 'nextScheduledTime': nextSw.time + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist, so skipping status update." + } + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://tccna.honeywell.com', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'Username': settings.prefEvohomeUsername, + 'Password': settings.prefEvohomePassword + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: authenticate(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}" + atomicState.evohomeAuthFailed = true + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}" + atomicState.evohomeAuthFailed = true + } + +} + + +/** + * refreshAuthToken() + * + * Refresh Auth Token. + * If token refresh fails, then authenticate() is called. + * Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'. + * + **/ +private refreshAuthToken() { + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()" + + def requestParams = [ + method: 'POST', + uri: 'https://tccna.honeywell.com', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'refresh_token': atomicState.evohomeAuth.refreshToken + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}" + // If Unauthorized (401) then re-authenticate: + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + authenticate() + } + } + +} + + +/** + * getEvohomeUserAccount() + * + * Gets user account info and stores in atomicState.evohomeUserAccount. + * + **/ +private getEvohomeUserAccount() { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount()" + + 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()" + + 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): + + 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: + + def newZoneStatus = getEvohomeZoneStatus(onlyZoneId) + if (newZoneStatus) { + // Get existing evohomeStatus and update only the specified zone, preservign data for other zones: + // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate). + 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()" + + 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'] + } + else { // Mode is temporary: + body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False'] + } + + 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] + } + else { // Temporary: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': 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 +} + \ No newline at end of file