From 86001557e7a9f56c272af01e3d77aa7610411bb7 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Mon, 5 Dec 2022 16:38:02 +0200 Subject: [PATCH 01/26] Add files via upload --- ...ten-solar-system-1-2022_12_05_14_37_13.tar.gz | Bin 0 -> 2563 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 devicetypes/enlighten-solar-system-1-2022_12_05_14_37_13.tar.gz diff --git a/devicetypes/enlighten-solar-system-1-2022_12_05_14_37_13.tar.gz b/devicetypes/enlighten-solar-system-1-2022_12_05_14_37_13.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..76e203c13fe2e24358ebc1a3d609a52be4d4759e GIT binary patch literal 2563 zcmV+e3jFmSiwFP!00000|Lt1;PuoZq-(SgJG3(_-QYDTPAV5~!i306LB~TICY7bRt zVvm!#`rFNn2`S6}e(%jV#tsfpU2l7L!rB$doA-S_@6DUIEDHE!s#w&LaX>{&&ZS~u zt7A_@9AD3W`W}zdah%@P7IDn8^taR7+{xDry|dkOHh&_{cd`D&lS)O!PtLz>YdN^@ z;M$+@v|qidkynHqFK|FchyyaxIDjR+dKf2jf$O9Tn?pW?Oisy&3(03RATzF}#El~* z_&8NOj)*VfketU64LtHTP6IDuVxjAVyDXB-BWdI@LDZCygM_;9CtGQfj|?<}ny&4T zIx<+S)#NN9)Uw)5^x&1%s8m-E!gDuOi4Ue z6nH6cC-BWLauKTf3#aj!DwXux?b&Q*Q|+)Fi%B~$%(8tlI6OWb9k;;gh17>AU{Vsn z{+V(C5s&ADCg7Ay$KY;2XCxMcP6UHB6{FKL!4;1tO(J7o&8T3+ofBruFd+vDr+ys>* zw>P)#;cZSlweT%E8GZ*=v`EFGUK^Wni2f(J6^YLffe?ZKeBaG{_IEW>{=VkT6; zHH=+D5lKiC+~V>bku`|dFw{ptgIkctq$_xWu`iMp#1q?N<8%T;Ij|xZ)OmsNZo@)j zsEbFC$RJ=|9i?k)1=)dFLUDcDVIEw+hB0)97%khjZJA_c!PFZ%0@Ru&aG9#od3?j% zRB4Ygo{daN8cDeSdMN;>k*Q6iV`zBu&i0T;7<@f5biY1`>TvqqlQZ+U_4P$I?!C)a zbAo25b}r-DK#Z8JJ1?rydY0&qXfvD_I9!`>QQQqZvR`PTYyvyuY_fFQ_EUYU#iZNQ zl+GB}-88ul<&2FbSFBB?WJd=}5b}^SL3K(fXnW3C;OYCw3ZWAyrAGt-x(@`iqt4uxD@4l&Y;A3A4W-QQ zmm~hQY)%5=7SOTtJf;@A8D19@M+&9l9PvtmsexM>?8VSw}`J2vf{ z8)3Iv;?QJ@7>J!f6AwVLj4Kn>!yq)m7GdC*2aK4LR3CG>>LR2c@aNFU(h485l*0dT z(hPipiGbM)M*_I!cm%r25|-fnLCES^1?8~nm(T{tOoM>@@yC+r&-*!gu|2FUh0T<1 zk0qJ?WmnfEw5Ve|46|Wnqr?yMYBojHb5k0T7~d)QjebByF2c%?r8!FI+b2n$U7cKH zDNpF7FQDF>z}jshq6du! zst~*1s7@ocpr{PxEY)jkm7;k*uL4YHK}&V!mNuaAsP=M$S%yKjtKXpEN-FWBhh^Q5 zDHkk>Ax|E(qk`7ODS@Ju8&Ex3X7I}jH|Ky!WT`#s&v%PKXTsD+E>jxtuli_#Q-{{` zSrRI8tu&hfyS@S-oBjcQJ?Di=M1^12{59OuaBK+~m8oF%`fbjXb;VU0&A`4-*7XO_ zXmsKb_&RmJz%&DkdhIf^^BH;CZ5Z1TbJ&RR2YvI>$ZU*JVNP!pFDUz)Enx$0xw1jq z9n4QKU;OsvyYIR>=SNfBKS3?4usyZ`5O_3dVKIqI`7Ac26R{?=AbPE z)hH^GFdwoq7&3_JE?w|5=Op?~2o1~z_3ti z8Eq)SZn%VP<$nF=G8W7MQl^2$%RZo_+T~W^MFGCq^YOO>TOSI`QQXy5giU?ubp*Dy z4LeC(8o2n10EhERMRz~?-RpnDGd#XHXbn!^w1)?$gEz;c3w!wHd)s*Y{lDXMd)=k? z|J}_k=jHwXpYaT71apQ|t?Zq9pLA@eX0F5dmb#x^E7Uw;vhWijTlMcoq;PAsQ+=+k z Date: Thu, 8 Dec 2022 15:35:41 +0200 Subject: [PATCH 02/26] update --- .../smartthings/hue-bulb.src/hue-bulb.groovy | 197 +++++++--------- .../hue-lux-bulb.src/hue-lux-bulb.groovy | 84 +++---- .../enlighten-solar-system-1.groovy | 213 ++++++++++++++++++ 3 files changed, 317 insertions(+), 177 deletions(-) create mode 100644 devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy diff --git a/devicetypes/smartthings/hue-bulb.src/hue-bulb.groovy b/devicetypes/smartthings/hue-bulb.src/hue-bulb.groovy index 6cb751357fc..59daf9b16d6 100644 --- a/devicetypes/smartthings/hue-bulb.src/hue-bulb.groovy +++ b/devicetypes/smartthings/hue-bulb.src/hue-bulb.groovy @@ -1,13 +1,8 @@ -//DEPRECATED. INTEGRATION MOVED TO SUPER LAN CONNECT - /** * Hue Bulb * - * Philips Hue Type "Extended Color Light" - * * Author: SmartThings */ - // for the UI metadata { // Automatically generated. Make future change here. @@ -15,15 +10,12 @@ metadata { capability "Switch Level" capability "Actuator" capability "Color Control" - capability "Color Temperature" capability "Switch" capability "Refresh" capability "Sensor" - capability "Health Check" - capability "Light" command "setAdjustedColor" - command "reset" + command "reset" command "refresh" } @@ -32,67 +24,43 @@ metadata { } tiles (scale: 2){ - multiAttributeTile(name:"rich-control", type: "lighting", width: 6, height: 4, canChangeIcon: true){ + multiAttributeTile(name:"switch", type: "lighting", width: 6, height: 4, canChangeIcon: true){ tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { - attributeState "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#00A0DC", nextState:"turningOff" + attributeState "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff" attributeState "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" - attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#00A0DC", nextState:"turningOff" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff" attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" } tileAttribute ("device.level", key: "SLIDER_CONTROL") { - attributeState "level", action:"switch level.setLevel", range:"(0..100)" - } + attributeState "level", action:"switch level.setLevel" + } tileAttribute ("device.color", key: "COLOR_CONTROL") { attributeState "color", action:"setAdjustedColor" } } - controlTile("colorTempSliderControl", "device.colorTemperature", "slider", width: 4, height: 2, inactiveLabel: false, range:"(2000..6500)") { - state "colorTemperature", action:"color temperature.setColorTemperature" - } - - valueTile("colorTemp", "device.colorTemperature", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { - state "colorTemperature", label: 'WHITES' - } - - standardTile("reset", "device.reset", height: 2, width: 2, inactiveLabel: false, decoration: "flat") { - state "default", label:"Reset To White", action:"reset", icon:"st.lights.philips.hue-single" + standardTile("reset", "device.reset", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label:"Reset Color", action:"reset", icon:"st.lights.philips.hue-single" } - - standardTile("refresh", "device.refresh", height: 2, width: 2, inactiveLabel: false, decoration: "flat") { + standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" } - - main(["rich-control"]) - details(["rich-control", "colorTempSliderControl", "colorTemp", "reset", "refresh"]) } -} -def initialize() { - sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${device.hub.hardwareID}\"}", displayed: false) -} + main(["switch"]) + details(["switch", "levelSliderControl", "rgbSelector", "refresh", "reset"]) -void installed() { - log.debug "installed()" - initialize() -} - -def updated() { - log.debug "updated()" - initialize() } // parse events into attributes def parse(description) { log.debug "parse() - $description" def results = [] - def map = description if (description instanceof String) { log.debug "Hue Bulb stringToMap - ${map}" map = stringToMap(description) } - if (map?.name && map?.value) { results << createEvent(name: "${map?.name}", value: "${map?.value}") } @@ -100,104 +68,91 @@ def parse(description) { } // handle commands -void on() { - log.trace parent.on(this) +def on(transition = "4") { + log.trace parent.on(this,transition) + sendEvent(name: "switch", value: "on") } -void off() { - log.trace parent.off(this) +def off(transition = "4") { + log.trace parent.off(this,transition) + sendEvent(name: "switch", value: "off") } -void setLevel(percent, rate = null) { - log.debug "Executing 'setLevel'" - if (verifyPercent(percent)) { - log.trace parent.setLevel(this, percent) - } +def nextLevel() { + def level = device.latestValue("level") as Integer ?: 0 + if (level <= 100) { + level = Math.min(25 * (Math.round(level / 25) + 1), 100) as Integer + } + else { + level = 25 + } + setLevel(level) } -void setSaturation(percent) { - log.debug "Executing 'setSaturation'" - if (verifyPercent(percent)) { - log.trace parent.setSaturation(this, percent) - } +def setLevel(percent) { + log.debug "Executing 'setLevel'" + parent.setLevel(this, percent) + sendEvent(name: "level", value: percent) } -void setHue(percent) { - log.debug "Executing 'setHue'" - if (verifyPercent(percent)) { - log.trace parent.setHue(this, percent) - } +def setSaturation(percent) { + log.debug "Executing 'setSaturation'" + parent.setSaturation(this, percent) + sendEvent(name: "saturation", value: percent) } -void setColor(value) { - def events = [] - def validValues = [:] +def setHue(percent) { + log.debug "Executing 'setHue'" + parent.setHue(this, percent) + sendEvent(name: "hue", value: percent) +} - if (verifyPercent(value.hue)) { - validValues.hue = value.hue - } - if (verifyPercent(value.saturation)) { - validValues.saturation = value.saturation - } - if (value.hex != null) { - if (value.hex ==~ /^\#([A-Fa-f0-9]){6}$/) { - validValues.hex = value.hex - } else { - log.warn "$value.hex is not a valid color" - } - } - if (verifyPercent(value.level)) { - validValues.level = value.level - } - if (value.switch == "off" || (value.level != null && value.level <= 0)) { - validValues.switch = "off" - } else { - validValues.switch = "on" - } - if (!validValues.isEmpty()) { - log.trace parent.setColor(this, validValues) - } +def setColor(value,alert = "none",transition = 4) { + log.debug "setColor: ${value}, $this" + parent.setColor(this, value, alert, transition) + if (value.hue) { sendEvent(name: "hue", value: value.hue)} + if (value.saturation) { sendEvent(name: "saturation", value: value.saturation)} + if (value.hex) { sendEvent(name: "color", value: value.hex)} + if (value.level) { sendEvent(name: "level", value: value.level)} + if (value.switch) { sendEvent(name: "switch", value: value.switch)} } -void reset() { - log.debug "Executing 'reset'" - setColorTemperature(4000) +def reset() { + log.debug "Executing 'reset'" + def value = [level:100, hex:"#90C638", saturation:56, hue:23] + setAdjustedColor(value) + parent.poll() } -void setAdjustedColor(value) { - if (value) { +def setAdjustedColor(value) { + if (value) { log.trace "setAdjustedColor: ${value}" def adjusted = value + [:] + adjusted.hue = adjustOutgoingHue(value.hue) // Needed because color picker always sends 100 - adjusted.level = null - setColor(adjusted) - } else { - log.warn "Invalid color input $value" + adjusted.level = null + setColor(adjusted) } } -void setColorTemperature(value) { - if (value) { - log.trace "setColorTemperature: ${value}k" - log.trace parent.setColorTemperature(this, value) - } else { - log.warn "Invalid color temperature $value" - } -} - -void refresh() { - log.debug "Executing 'refresh'" - parent?.manualRefresh() -} - -def verifyPercent(percent) { - if (percent == null) - return false - else if (percent >= 0 && percent <= 100) { - return true - } else { - log.warn "$percent is not 0-100" - return false - } +def refresh() { + log.debug "Executing 'refresh'" + parent.manualRefresh() } +def adjustOutgoingHue(percent) { + def adjusted = percent + if (percent > 31) { + if (percent < 63.0) { + adjusted = percent + (7 * (percent -30 ) / 32) + } + else if (percent < 73.0) { + adjusted = 69 + (5 * (percent - 62) / 10) + } + else { + adjusted = percent + (2 * (100 - percent) / 28) + } + } + log.info "percent: $percent, adjusted: $adjusted" + adjusted +} \ No newline at end of file diff --git a/devicetypes/smartthings/hue-lux-bulb.src/hue-lux-bulb.groovy b/devicetypes/smartthings/hue-lux-bulb.src/hue-lux-bulb.groovy index f646480212d..b50d33ca380 100644 --- a/devicetypes/smartthings/hue-lux-bulb.src/hue-lux-bulb.groovy +++ b/devicetypes/smartthings/hue-lux-bulb.src/hue-lux-bulb.groovy @@ -1,10 +1,6 @@ -//DEPRECATED. INTEGRATION MOVED TO SUPER LAN CONNECT - /** * Hue Lux Bulb * - * Philips Hue Type "Dimmable Light" - * * Author: SmartThings */ // for the UI @@ -16,53 +12,31 @@ metadata { capability "Switch" capability "Refresh" capability "Sensor" - capability "Health Check" - capability "Light" - - command "refresh" + + command "refresh" } simulator { // TODO: define status and reply messages here } - tiles(scale: 2) { - multiAttributeTile(name:"rich-control", type: "lighting", canChangeIcon: true){ - tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { - attributeState "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#00A0DC", nextState:"turningOff" - attributeState "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" - attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#00A0DC", nextState:"turningOff" - attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" - } - tileAttribute ("device.level", key: "SLIDER_CONTROL") { - attributeState "level", action:"switch level.setLevel", range:"(0..100)" - } - } - - controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 2, inactiveLabel: false, range:"(0..100)") { - state "level", action:"switch level.setLevel" - } - - standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { - state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" - } - - main(["rich-control"]) - details(["rich-control", "refresh"]) - } -} - -def initialize() { - sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${device.hub.hardwareID}\"}", displayed: false) -} + standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { + state "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821" + state "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff" + } + standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 2, inactiveLabel: false, range:"(0..100)") { + state "level", action:"switch level.setLevel" + } + valueTile("level", "device.level", inactiveLabel: false, decoration: "flat") { + state "level", label: 'Level ${currentValue}%' + } -void installed() { - log.debug "installed()" - initialize() -} + main(["switch"]) + details(["switch", "levelSliderControl", "refresh"]) -def updated() { - initialize() } // parse events into attributes @@ -83,25 +57,23 @@ def parse(description) { } // handle commands -void on() { - log.trace parent.on(this) +def on() { + parent.on(this) + sendEvent(name: "switch", value: "on") } -void off() { - log.trace parent.off(this) +def off() { + parent.off(this) + sendEvent(name: "switch", value: "off") } -void setLevel(percent, rate = null) { +def setLevel(percent) { log.debug "Executing 'setLevel'" - if (percent != null && percent >= 0 && percent <= 100) { - parent.setLevel(this, percent) - } else { - log.warn "$percent is not 0-100" - } + parent.setLevel(this, percent) + sendEvent(name: "level", value: percent) } -void refresh() { +def refresh() { log.debug "Executing 'refresh'" parent.manualRefresh() -} - +} \ No newline at end of file diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy new file mode 100644 index 00000000000..81956ea5769 --- /dev/null +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -0,0 +1,213 @@ +/** + * Enlighten Solar System + * + * Copyright 2015 Umesh Sirsiwal with contribution from Ronald Gouldner + * + * 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: "Enlighten Solar System 1", namespace: "usirsiwal", author: "Umesh Sirsiwal", ocfDeviceType: "x.com.st.d.energymeter") { + capability "Power Meter" + capability "Refresh" + capability "Energy Meter" + capability "Polling" + + + attribute "energy_today", "STRING" + attribute "energy_life", "STRING" + + } + + simulator { + // TODO: define status and reply messages here + } + + tiles { + valueTile("energy", "device.energy", width: 1, height: 1, canChangeIcon: true) { + state("energy_today", label: '${currentValue}KWh', unit:"KWh", + //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", + backgroundColors: [ + [value: 2, color: "#bc2323"], + [value: 5, color: "#d04e00"], + [value: 10, color: "#f1d801"], + [value: 20, color: "#90d2a7"], + [value: 30, color: "#44b621"], + [value: 40, color: "#1e9cbb"], + [value: 50, color: "#153591"], + ], + ) + } + valueTile("energy_life", "device.energy_life", width: 1, height: 1, canChangeIcon: true) { + state("energy_life", label: '${currentValue}MWh', unit:"MWh", backgroundColors: [ + [value: 2, color: "#bc2323"], + [value: 5, color: "#d04e00"], + [value: 10, color: "#f1d801"], + [value: 20, color: "#90d2a7"], + [value: 30, color: "#44b621"], + [value: 40, color: "#1e9cbb"], + [value: 50, color: "#153591"], + ] + ) + } + valueTile("power", "device.inverter_power", width: 1, height: 1) { + state("power", label: '${currentValue}W', unit:"W", + //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", + backgroundColors: [ + [value: 600, color: "#bc2323"], + [value: 1200, color: "#d04e00"], + [value: 1800, color: "#1e9cbb"], + [value: 2900, color: "#153591"] + ], + ) + } + + chartTile(name: "powerChart", attribute: "power") + + standardTile("refresh", "device.energy", inactiveLabel: false, decoration: "flat") { + state "default", action:"polling.poll", icon:"st.secondary.refresh" + } + + main (["power"]) + details(["power", "energy", "energy_life", "refresh"]) + } + +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + +} +def installed() { + + log.debug "Installing Solaredge Monitoring..." + + refresh() + +} + +def updated() { + + log.debug "Executing 'updated'" + + unschedule() + + runEvery15Minutes(refresh) + + runIn(2, refresh) + +} + +def poll() { + refresh() +} + +def refresh() { + log.debug "Executing 'refresh'" + energyRefresh() +} + + +def energyRefresh() { + log.debug "Executing 'energyToday'" + + def cmd = "https://region03eu5.fusionsolar.huawei.com/pvmswebsite/assets/build/index.html#/monitorLayout/station/overview"; + log.debug "Sending request cmd[${cmd}]" + + httpGet(cmd) {resp -> + if (resp.data) { + log.debug "${resp.data}" + def energy = resp.data.result.yieldtoday + def energyLife = resp.data.result.yieldtotal + def currentPower = resp.data.result.inverter_power + def systemSize = resp.data.size_w + def systemId = resp.data.system_id + def now=new Date() + def tz = location.timeZone + def todayDay = now.format("dd",tz) + def today_max_day = device.currentValue("today_max_day") + def today_max_prod = device.currentValue("today_max_prod") + def todayMaxProd=today_max_prod + log.debug "todayMaxProd was ${todayMaxProd}" + + + log.debug "System Id ${system_id}" + log.debug "Energy today ${energy}" + log.debug "Energy life ${energyLife}" + log.debug "Current Power Level ${inverter_power}" + log.debug "System Size ${systemSize}" + log.debug "Production Level ${currentPower}" + log.debug "todayDay ${todayDay}" + + // If day has changed set today_max_day to new value + if (today_max_day == null || today_max_day != todayDay) { + log.debug "Setting today_max_day=${todayDay}" + sendEvent(name: 'today_max_day', value: (todayDay)) + // New day reset todayMaxProd + todayMaxProd = productionLevel + } + + // String.format("%5.2f", energyToday) + delayBetween([sendEvent(name: 'energy', value: (energy)) + ,sendEvent(name: 'energy_life', value: (energyLife)) + ,sendEvent(name: 'power', value: (inverter_power)) + ,sendEvent(name: 'production_level', value: (String.format("%5.2f",productionLevel))) + ,sendEvent(name: 'today_max_prod', value: (todayMaxProd)) + ,sendEvent(name: 'today_max_prod_str', value: (String.format("%5.2f",todayMaxProd))) + ,sendEvent(name: 'reported_id', value: (systemId)) + ]) + + + + +} + + + } +} + +def getVisualizationData(attribute) { + log.debug "getChartData for $attribute" + def keyBase = "measure.${attribute}" + log.debug "getChartData state = $state" + + def dateBuckets = state[keyBase] + + //convert to the right format + def results = dateBuckets?.sort{it.key}.collect {[ + date: Date.parse("yyyy-MM-dd", it.key), + average: it.value.average, + min: it.value.min, + max: it.value.max + ]} + + log.debug "getChartData results = $results" + results +} + +private getKeyFromDate(date = new Date()){ + date.format("yyyy-MM-dd") +} + +private storeData(attribute, value) { + log.debug "storeData initial state: $state" + def keyBase = "measure.${attribute} ${value}" + + // create bucket if it doesn't exist + if(!state[keyBase]) { + state[keyBase] = [:] + log.debug "storeData - attribute not found. New state: $state" + } + + log.debug "storeData after min/max calculations. New state: $state" +} \ No newline at end of file From 7829a36b899c6297802267e7ec2d8b2dd2a83f27 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Thu, 8 Dec 2022 15:42:23 +0200 Subject: [PATCH 03/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 81956ea5769..95aa5868878 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -121,7 +121,7 @@ def refresh() { def energyRefresh() { log.debug "Executing 'energyToday'" - def cmd = "https://region03eu5.fusionsolar.huawei.com/pvmswebsite/assets/build/index.html#/monitorLayout/station/overview"; + def cmd = "https://region03eu5.fusionsolar.huawei.com/pvmswebsite/nologin/assets/build/index.html#/kiosk?kk=Qz3HaPPObuzs49yHcvvjhBzBw6PK0ayD"; log.debug "Sending request cmd[${cmd}]" httpGet(cmd) {resp -> @@ -210,4 +210,4 @@ private storeData(attribute, value) { } log.debug "storeData after min/max calculations. New state: $state" -} \ No newline at end of file +} From 02e21505c8b9c4335047e23dd896b21489153c8b Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Thu, 8 Dec 2022 16:06:42 +0200 Subject: [PATCH 04/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 95aa5868878..26883f8f7de 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -59,7 +59,7 @@ metadata { ] ) } - valueTile("power", "device.inverter_power", width: 1, height: 1) { + valueTile("power", "device.curPower", width: 1, height: 1) { state("power", label: '${currentValue}W', unit:"W", //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", backgroundColors: [ @@ -129,7 +129,7 @@ def energyRefresh() { log.debug "${resp.data}" def energy = resp.data.result.yieldtoday def energyLife = resp.data.result.yieldtotal - def currentPower = resp.data.result.inverter_power + def currentPower = resp.data.result.curPower def systemSize = resp.data.size_w def systemId = resp.data.system_id def now=new Date() @@ -144,9 +144,9 @@ def energyRefresh() { log.debug "System Id ${system_id}" log.debug "Energy today ${energy}" log.debug "Energy life ${energyLife}" - log.debug "Current Power Level ${inverter_power}" + log.debug "Current Power Level ${curPower}" log.debug "System Size ${systemSize}" - log.debug "Production Level ${currentPower}" + log.debug "Production Level ${curPower}" log.debug "todayDay ${todayDay}" // If day has changed set today_max_day to new value @@ -160,7 +160,7 @@ def energyRefresh() { // String.format("%5.2f", energyToday) delayBetween([sendEvent(name: 'energy', value: (energy)) ,sendEvent(name: 'energy_life', value: (energyLife)) - ,sendEvent(name: 'power', value: (inverter_power)) + ,sendEvent(name: 'power', value: (curPower)) ,sendEvent(name: 'production_level', value: (String.format("%5.2f",productionLevel))) ,sendEvent(name: 'today_max_prod', value: (todayMaxProd)) ,sendEvent(name: 'today_max_prod_str', value: (String.format("%5.2f",todayMaxProd))) From 453751c497883728dac4f56c68daae6903982603 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 09:04:25 +0200 Subject: [PATCH 05/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 26883f8f7de..a1c97bb734d 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -129,7 +129,7 @@ def energyRefresh() { log.debug "${resp.data}" def energy = resp.data.result.yieldtoday def energyLife = resp.data.result.yieldtotal - def currentPower = resp.data.result.curPower + def currentPower = resp.neteco.pvms.partials.main.io.realTimeStatus.stationStatus.curPower def systemSize = resp.data.size_w def systemId = resp.data.system_id def now=new Date() From d90d6b0968afe2687daf02158279ba5d9a1ab097 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 09:08:47 +0200 Subject: [PATCH 06/26] Add files via upload --- devicetypes/fusion_solar_py-0.0.7.tar.gz | Bin 0 -> 8008 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 devicetypes/fusion_solar_py-0.0.7.tar.gz diff --git a/devicetypes/fusion_solar_py-0.0.7.tar.gz b/devicetypes/fusion_solar_py-0.0.7.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..83173eb49c59874508fb3cc22b17f0310967b608 GIT binary patch literal 8008 zcmaKxWlS6Z^QUpQ;@0BsR;;+g;uI+EP~6?!VPSE1D6WO#?oM%cEiQ}f-ur*=a$hdF zWb%BObS|I24rYZhCV#sFjD?PbWuXHz#{jS7T=%HcoaKgSGIUkWF<{< zAAOt2s&FwD0J8v9FV$y&*JLH>-=O@Qf^8SKq3-LzjB5J2??HC0Te5ebudgq8vmdWl zE3WhR&_4Pe3c(d$4hs%{^_n&JW#5M7b$4%#RBvs4l;8f^uHk#~YQ2uV;0Q{P+HuVb zxn3{l&*Wg{E3vxNDaeODKj*x7S@-_Xgf6FBz}||f?QMJ2fOFrWlNJFTQTb2>@XibA zVNih656Cs)9rVXxkVrZ-L8spH+3TV{UXKdg>jDm15VS4u+>pIt@}`FDY}nPLKp*wY z83v)#$D(7P*=?3z3~C4>8AWHVW;ZhlyU|5g>Csat?$<7seEP zyXE3+?Msf9lzH~{Z;l)G3t7jY^GIGFMniX_rIeUrKZvKB#qM-tX)HRY%}NlXKqhH5 zj{va6Y4YV6ewLIvp1V1>=yPHN_PqH<_KzmS8kK`*J{ZzhPPj|!tm#c4-$-w6Bp-IY z!(>7F+L+|riYW|#K$qYl)^ezemSGZjvyCs{eHF&&;BkmyZXdV9S1%D;4q*WNdeDm1Rx^J$b`+7r z@>$b;TyufY=#_anh3DRHBP$A_ZZGQ$5hOZxELA_T=4SrOn+NNs>zOl`tXAmtd-2v) zQ!{$)J>zN4aDb_ZRk3!ZJ0`Voyi`$6rtb>dkzAk^U z^Bg;;usDParN?sOnx8E(=TZ4)P*9eH9ncx0ZPT+qF^2YXbY2lt3OiEd!_=^Ak5I`* zB$&m#D;di+LNza;tXI&kI=-ii2!fmGbLa^)P(LK%=JM<%C;_swxnZ~fwexG0ga##E zh+t0vNix$4#K{m9Z5sbHmgHsgY@3m+&+Su7)9Uf(P6M3g;XEdOTeG3z^+dy60U}DOI(y> zUk#d~Fn6s?kTUIiM$7^x9UF}grNT_!-A3e!%(s4Qa!nPPBCU6v6cZYDbP4=yq!>iK z@HxJT^gEgs)`;+Zdz+Ba_m!~cv`-3f|O#r1)>Pw!RUp(Pv)bH%H@A+A2;V1!U}llsJ$Rg5bb-d7lh z>JD_q?lwVBI945G`q&!kV1)MJNKrI-cupT#_T;pC;Iwk4A=ZN(%sfKzz|zBf;6j}s zvN`UQ3Nf(>tg;DFpZ97U+9O<)yQ3Q({pNwqH}#?m=Y1%q+8f+44lU3j^~bNDhW+a4 z?Wo#cF>HD=^^2oS4g%g^KHZ4^JzoCClx9lEC{@T2P$+Fm7~6q%ixd_U+5a;2u$eq1 zN!-e#6y9!n>f=0##IqeTv$tv(6&8cRqk4}DLdeC(Vf-PG{eAXx!~*4L zMF_gca?`9?Qw(#`R0MBMiBPaw!x&Ng&Ilh^K8$1pS6Uc}N-nuXHhOY*I9VEzapb2i zDYwR-D-|_uRMSXW{h}>+`%k*bm?DW&NS+N&#F6x{Uswm3&zgr}Dp%l3h=oP!jXw}+lxjKJTh*PbF$Eno%=aN88#)S9}g93%uy3b^z$ip2gUWy z)yNPiI&)lY6Fw2B&9mkpX%@zhYdxTrKDVB1_p7xK!R(_)V(jy6oxvNLnuh>mh^gR1 zvHTN6i!kRiVX={=x#9hBUg>^uKPQg@Au@EYhNKsA%&={%*1op#6DiJ)|NgB_Biji5 zTB0Gvb0=vR4|I_!7X6WsFMh<;7huy^$%LB$*La`PBHR6kzUj#ZcfdjdFaL5`Fo z%>!1tfHt`#H&WVP^1;S`UqjFe{-vW2OT9urvLVxn+tbOcQb1W!claL`K-0x<`3hID(fp~cX#*40OUX0^jxUrQhk`g zDYWNU;RIFMIt9>uyLsRsO!2jgy+(Cej~=}QRIsMe?kI@<)9Erqu+cAFxJk`d|L^JnQ2-HUO4 z;~oEt8)#*~Is5_JVKT57<BSsB^ND^smc+iLOmU4mFMv!%tWRRrx-UUzy-N+r+ z-?$zcu5&s|6#0B$9@VTDdUU!JDBu}g89tBn)y{(fY z!PbJqHLaHxhGh4|)jvg8S8e*KQ`qbrVE;o7gqx3sAw{XH-%=Ag0f96Rlh6U=JWNodj}bit+=DhGs?B|{_L zISzwdK0e)v*%f`Oc;DF>eVLVn1kv2}3P3*&4tsqe8=H1Z+4XVvJCcx-F=&6EYmEsf z=W!f|NrJ~ouhLzwQ9a*(n?!d|!6UIVsBk^$e?t@BGrlAwr9-gCp<7nnz((kWu-_v! zbh6ptPaQhc&PsnJ|BP*aDRh5zItdQBu&`V~&Uqsbz-o>A5kKpD=mcUIC?HRAywN$7 z`0|n1HJwK#h%J+J{mt!*6nJNnBn_N1N*ZeRIGX?5a| z=CqE4Iy5;u^0-cyB0R8+A{Hm8vj5Bd;PmG}%~ReR!g1`b;1fwR@dGZ0v-YK= zTe3-Nd@o94wAx@{VUN%GDaxO17P%cJ-_zbSB-s+Q5Ih8=@)H9FkF}=IztxfieDjv7 zl^swv!-@%Ko*Vkbr8G69HB7t!ccs~oPUi=yW~K;tS5S-WD=~KA1>Q7Hqom7i9lCykBO;HqC_tZeaw@MThx>jTQXc=+EKjMos zRFAa{4iIzn#8bAN|JbM}6Uib&o#@c!!klOV?fR2%S+t3Ix!ERS;n9(Ub4!^pQDR%I zcti&f*1w)PZBq3sU(h2aRUurNc9F13#UdrA+cw%nhi2Tk=(P#}B~(lEECtw7xWPPh z^RYrdm}F#&tSRCj$4t-NM4PU4h9Y#NMkUAoZsajPkp3yp(431Qdk>pc0#(D0Q!aSu z&h)l;h#)?S{`}EjHsr|8{6QPc%~Fr)X}Uj_dDh@fV7{yi)HYviB1qR#hlFBQ_NqGeY{*M4xykp#Nz$p;`)K7k>-@Xn`$$;IN{W%e zDHEvxj2NFA3YWAyWBM!SZ$o8}Ih)6e`fV6c?H&n9geG%h|H8r+e{4TkIaLwxuz;jJ~F&Ae%_4oVMpv*@>ih->?G1v$$Po z9aw0v|3Vmf58Oy{I4n(zTMK_1#m1e~QGk-&_JRb>xNj=}Qo_-r6^vq$QM@9-tuirS zm339#S>0gN{Rq(=#%gxw5Npc!g{QO`^$KCQE!FE27Edjh14;Apy#|ZGL@ImrkY|Xr zv}d`N@Fn{oe#w}SRCcoGP<$C3kb8Lt#~n1J@r?#rDnrXVeYbA){PSnZ006%faFNKT z$HS${YIGZbmNHZ=e(*hw7x?`M_j}sYG}>M@b}#}HhYTt|EXF&dLo>yyO0D*W%J_6^ zOOurk00r)+%C+$s&-3d1n~&~;!D$8$bJ(jXW<}+OsUlJ)zm_lgN>!J`vsnNPk3o1( zik@`M-^^}Uew6oT|ukMV-NdElY+rV@G5 zd7X8nN^f#e$K&O}3x=u8-={xXifb(XPQis?L*adY&SVR(V#GH=4)0YVq|H=i=+vP< z)#UXizon&m70wuB+co>~BXg}vua0peNL^RB06XJSku4CaM~jt7T$cNF)%}QPBmn~M zlNl0>CF$=KSb-umBR+g)>@93!@{K$COuAf5$hux*LJHim(J_VDC3X9}%%)5eEwqyc zp7IV13T+6wN$^OV6P)p|=4Ssf;S}QaP=xbck;+sFU5? zC?&H7^iHc|7HRUCdM_G;g5Hi+-}{Y6{iAg;<$Q6F2d{`LEB(Fz|F$h2taUi$G~oX0 z5>5H8iZ9(GR2@=l(#!pd##AR*PVM~MAqdNr0d&=v%)hC7WD%`B`j+p#d^+YqUieiM z_l~c6ieucE%OckbMCh%8h&K~ec9rWJgHd<#?a`3qG#IuJ`wS4gThHie@1ypweM@A5 z#k5muaiqu1+Iel7xF^S!@>bI%9Ja}*&H(9g+a;`hD(zoXi!kW>O@wEi%}R`I9pk~an?*cF}NGxa0_h^1sT)Cuo2c@IjtA=dz@SM2u zf-RAc`jO+_s$IdUj2yAwx!m&_`Kn^ul68jp`-`z{*>B0_W~zdz=3Fm_;z7h{mg}TH z2EMmQStl$@<#$N=yM-np`E)t+eQX`0tVn2O>En^*DHo~`#4coC)kJi5P9S>s{&yUJf zo=j?hGc%o>+vJaugiX=xC{?9&%i6R{a#1QoEce_wSYPQJgg#Zn zO>BL8&41FHY`r`LmZH?5J>%GqNcQ5y_%un^Rwsk2iRN1lD#Qdd{oE_jlLjw!Q> zCQQn~DbfmPlpnjDrqm^EU^g|sy%pMm)G7Me7_ktB*~RhTSJ380;h_?1dx6|}gF&gR zL|oL-AvT*6A{ceicYfYPq1WA!{COtEUQYdovCL6pWTpJ$)z0L|q~hpP1vN_(NxOKI2@%;S3cCLc~uj67(n^^H6)gKlL zNR+O4oCZwVK$`$ILaXituC)ogZCjo4@$#SZ6OZp!UTh`@jAuj{Q;Vs)EWWbg9rWL_ zmBRQ_dFi(c=1%ZNtlq5IC#yvRFPv=PgcayIc;O^BMfwcA=7awrb%gF-D0P5pm z_-MRsv1c}^3_Fc?nP+Y$DKG6hrXFpWv~4B)h@07-uV5w=hAd%T&HpJ0fZ;vvcV;e4 z45_7LQd8Qt6>P(@5Y@10$WmByccNwsuyD16jVH(Z`5X23PSa9dOUAj2s~91rAbrOZ z!9|lbke}Rg(^gQu#qoJ@)7wfC+<@1RwImnfQ~SuGC9%D{*jWizu*6aM78M% z50MZ4Enxj8V72WyY;v!BIa(lEh6`5@DpIIwmE>h=Ys5G7`POVbhsQacKa0jH`upB(WbI#| zSBGV#l~7kSC0Otz&f07*dMM1_f$jG0wS zQFGa%{3OHyz=ZZ!A^V4DH-MQvB3*RXHGIQFKW#!=`y)kcD*BLaJ_isTPuDpE@$7q0 zlrgrNbB`0@&>{ulEK$2Wuk|xWyFNvTBgAeo#Z|Jb4OYJd2Ko8qRMH9{iv8xnPi@;i zpwXTDv3?eFNy(gd|CR9WaMxQJMX?+4xJ?MD0WDH9|3r6wGE`cPPGr4k{x?CpQXtW} zr~JflzN~a788K|68LM3Iumea2I1V5liM6A=`X}r6BDHxAjM(mfGoZ}bBDoftltBJlYg`vtP1)B5TNgcglKlT)D*sKo&mSkX~; z(95fpW^hop3?#~W655w&K(?jstb-2%dS9zbFS{Cdt$puF(8TxF#9j zOSg9{wr{s7O`s(`uc=GLQG_CoDO8u|wM*k}-HNhJTw1*DlEaNvVMH*Lx^i3E`B`x$ z-)1LJpkRZLk}mOfI5~>3Me8cy%jRGBAL>FufS{ts-ZALwXf0F*vSRkJWAVZNVxiFw z-*QgWI8t)gyNn1W0vn^h)h9z|t)Rnqy$w(_aIY++0Q%-o=JtAgS#{Zay!~$`8_=$Pzbs@v6+s(Qw)YPq3 zuFLr%d7FEAqQ@VwSvNO1s)yaU6J8LSZ>q>L-1hT?iExAsNM>Vr!RZC2si9&fOX zg=ckDOvPM?=X8fu%#d^)3kFR|Df0i-yPIH~Z8NP?q1=}s_@(`gt1~{^7OtZIk-K+W zw)^g`|8=`;m^%#E-UYU9cKM(QPIM5}T*hf?JW4`}E*gVn<|&LZuk`{rp7>FNur0}~ zAQFd&A#BM2St!bU)ElR7e5ni!s%Q}e6P4;<^|8l^^T%7PG|k@SmQA{=5U<1xP%mVW z9{mHk?+k{ZyT;=tlxJ`&XmO{VE+hjN9K2G(m8izGhn{B14OK+=l2S~Gvk|--Fse&u z2|*w^4xV>XhJ&>cZIxNyD?(xVZJ7bL&%if;npakFm(_8oq%%(MLLP~29fK<0 zBj{83uPKqkTSj%{o39Y|gh!e*82w8g5yOcIpIJvy@kRBr{1gljmf%5b2biqkiMQ9+ zQKl@Gmis}nw!wH#o#Ngt#5;M(Li%i_4Ur;s*X&q6yjX)J2IGEdilOnjJD%;cE%%-Q zoo(o}IHBIDI%Pp2gH#GWWwsYBA<5Tw2D4w$;rj-kva{nP=*k2t5SQYtZZO9OX1{tV z-risN#ZL2E?=5k-#WBGA2wSv=ymtK{~A?j zn-P~{6a+(#GUd z$3g6ng+T;ArEn}}K;t_I9;Ac!5^a#6_|1Qkvj1M83ypj1ZSQ$~6jODCls{4gS}jAZ z_Mp!Io&NKkr|&h;;aB7T0W|v2xHU9?2!b3qTLVqdEBPOT>N0%o$f{fP-;Do*YSqr) q;N4f_*;gpP=My?~R?G~7KEqMB&;0+bS9Aj?_Ck5C7t9R|%>M!{1rWRd literal 0 HcmV?d00001 From 5a39018b763dec2c3812a237e96dbfd2fe1720cf Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 09:11:54 +0200 Subject: [PATCH 07/26] Add files via upload --- .../fusion_solar_py-0.0.7.tar.gz | Bin 0 -> 8008 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.tar.gz diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.tar.gz b/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..83173eb49c59874508fb3cc22b17f0310967b608 GIT binary patch literal 8008 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 literal 0 HcmV?d00001 From 01c611b767cef1064406a797991d363b9ca74371 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 09:15:12 +0200 Subject: [PATCH 08/26] Add files via upload --- .../fusion_solar_py-0.0.7.groovy | Bin 0 -> 8008 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.groovy diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/fusion_solar_py-0.0.7.groovy new file mode 100644 index 0000000000000000000000000000000000000000..83173eb49c59874508fb3cc22b17f0310967b608 GIT binary patch literal 8008 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 literal 0 HcmV?d00001 From afcab4457c8a9e3b2a9ab957eba758912ace41f0 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 10:52:02 +0200 Subject: [PATCH 09/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index a1c97bb734d..0be1b2f27e4 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -129,7 +129,7 @@ def energyRefresh() { log.debug "${resp.data}" def energy = resp.data.result.yieldtoday def energyLife = resp.data.result.yieldtotal - def currentPower = resp.neteco.pvms.partials.main.io.realTimeStatus.stationStatus.curPower + def currentPower = resp.resp.data.result.curPower def systemSize = resp.data.size_w def systemId = resp.data.system_id def now=new Date() @@ -144,7 +144,8 @@ def energyRefresh() { log.debug "System Id ${system_id}" log.debug "Energy today ${energy}" log.debug "Energy life ${energyLife}" - log.debug "Current Power Level ${curPower}" + log.debug "Current + Level ${curPower}" log.debug "System Size ${systemSize}" log.debug "Production Level ${curPower}" log.debug "todayDay ${todayDay}" From 89b5c5db9f958e4ec2d1edc5038d5b9fdbacc6d9 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 10:54:04 +0200 Subject: [PATCH 10/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 0be1b2f27e4..758653c48af 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -129,7 +129,7 @@ def energyRefresh() { log.debug "${resp.data}" def energy = resp.data.result.yieldtoday def energyLife = resp.data.result.yieldtotal - def currentPower = resp.resp.data.result.curPower + def currentPower = resp.data.result.curPower def systemSize = resp.data.size_w def systemId = resp.data.system_id def now=new Date() From 2f0e3dcc35f94ffcbff38b396833a819bedf0797 Mon Sep 17 00:00:00 2001 From: Mihai Berindei Date: Fri, 9 Dec 2022 11:00:15 +0200 Subject: [PATCH 11/26] grid --- .../enlighten-solar-system-1-grid.groovy | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 devicetypes/usirsiwal/enlighten-solar-system-1-grid.src/enlighten-solar-system-1-grid.groovy diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1-grid.src/enlighten-solar-system-1-grid.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1-grid.src/enlighten-solar-system-1-grid.groovy new file mode 100644 index 00000000000..6b5406530eb --- /dev/null +++ b/devicetypes/usirsiwal/enlighten-solar-system-1-grid.src/enlighten-solar-system-1-grid.groovy @@ -0,0 +1,213 @@ +/** + * Enlighten Solar System + * + * Copyright 2015 Umesh Sirsiwal with contribution from Ronald Gouldner + * + * 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: "Enlighten Solar System 1 grid", namespace: "usirsiwal", author: "Umesh Sirsiwal") { + capability "Power Meter" + capability "Refresh" + capability "Energy Meter" + capability "Polling" + + + attribute "energy_today", "STRING" + attribute "energy_life", "STRING" + + } + + simulator { + // TODO: define status and reply messages here + } + + tiles { + valueTile("energy", "device.energy", width: 1, height: 1, canChangeIcon: true) { + state("energy_today", label: '${currentValue}KWh', unit:"KWh", + //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", + backgroundColors: [ + [value: 2, color: "#bc2323"], + [value: 5, color: "#d04e00"], + [value: 10, color: "#f1d801"], + [value: 20, color: "#90d2a7"], + [value: 30, color: "#44b621"], + [value: 40, color: "#1e9cbb"], + [value: 50, color: "#153591"], + ], + ) + } + valueTile("energy_life", "device.energy_life", width: 1, height: 1, canChangeIcon: true) { + state("energy_life", label: '${currentValue}MWh', unit:"MWh", backgroundColors: [ + [value: 2, color: "#bc2323"], + [value: 5, color: "#d04e00"], + [value: 10, color: "#f1d801"], + [value: 20, color: "#90d2a7"], + [value: 30, color: "#44b621"], + [value: 40, color: "#1e9cbb"], + [value: 50, color: "#153591"], + ] + ) + } + valueTile("power", "device.power", width: 1, height: 1) { + state("power", label: '${currentValue}W', unit:"W", + //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", + backgroundColors: [ + [value: 600, color: "#bc2323"], + [value: 1200, color: "#d04e00"], + [value: 1800, color: "#1e9cbb"], + [value: 2900, color: "#153591"] + ], + ) + } + + chartTile(name: "powerChart", attribute: "power") + + standardTile("refresh", "device.energy", inactiveLabel: false, decoration: "flat") { + state "default", action:"polling.poll", icon:"st.secondary.refresh" + } + + main (["power"]) + details(["power", "energy", "energy_life", "refresh"]) + } + +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + +} +def installed() { + + log.debug "Installing Solaredge Monitoring..." + + refresh() + +} + +def updated() { + + log.debug "Executing 'updated'" + + unschedule() + + runEvery15Minutes(refresh) + + runIn(2, refresh) + +} + +def poll() { + refresh() +} + +def refresh() { + log.debug "Executing 'refresh'" + energyRefresh() +} + + +def energyRefresh() { + log.debug "Executing 'energyToday'" + + def cmd = "https://www.eu.solaxcloud.com:9443/proxy/api/getRealtimeInfo.do?tokenId=20210319151048132313611&sn=SWFJWDMRHU"; + log.debug "Sending request cmd[${cmd}]" + + httpGet(cmd) {resp -> + if (resp.data) { + log.debug "${resp.data}" + def energy = resp.data.result.consumeenergy + def energyLife = resp.data.result.yieldtotal + def currentPower = resp.data.result.feedinpower + def systemSize = resp.data.size_w + def systemId = resp.data.system_id + def now=new Date() + def tz = location.timeZone + def todayDay = now.format("dd",tz) + def today_max_day = device.currentValue("today_max_day") + def today_max_prod = device.currentValue("today_max_prod") + def todayMaxProd=today_max_prod + log.debug "todayMaxProd was ${todayMaxProd}" + + + log.debug "System Id ${system_id}" + log.debug "Energy today ${energy}" + log.debug "Energy life ${energyLife}" + log.debug "Current Power Level ${currentPower}" + log.debug "System Size ${systemSize}" + log.debug "Production Level ${currentPower}" + log.debug "todayDay ${todayDay}" + + // If day has changed set today_max_day to new value + if (today_max_day == null || today_max_day != todayDay) { + log.debug "Setting today_max_day=${todayDay}" + sendEvent(name: 'today_max_day', value: (todayDay)) + // New day reset todayMaxProd + todayMaxProd = productionLevel + } + + // String.format("%5.2f", energyToday) + delayBetween([sendEvent(name: 'energy', value: (energy)) + ,sendEvent(name: 'energy_life', value: (energyLife)) + ,sendEvent(name: 'power', value: (currentPower)) + ,sendEvent(name: 'production_level', value: (String.format("%5.2f",productionLevel))) + ,sendEvent(name: 'today_max_prod', value: (todayMaxProd)) + ,sendEvent(name: 'today_max_prod_str', value: (String.format("%5.2f",todayMaxProd))) + ,sendEvent(name: 'reported_id', value: (systemId)) + ]) + + + + +} + + + } +} + +def getVisualizationData(attribute) { + log.debug "getChartData for $attribute" + def keyBase = "measure.${attribute}" + log.debug "getChartData state = $state" + + def dateBuckets = state[keyBase] + + //convert to the right format + def results = dateBuckets?.sort{it.key}.collect {[ + date: Date.parse("yyyy-MM-dd", it.key), + average: it.value.average, + min: it.value.min, + max: it.value.max + ]} + + log.debug "getChartData results = $results" + results +} + +private getKeyFromDate(date = new Date()){ + date.format("yyyy-MM-dd") +} + +private storeData(attribute, value) { + log.debug "storeData initial state: $state" + def keyBase = "measure.${attribute} ${value}" + + // create bucket if it doesn't exist + if(!state[keyBase]) { + state[keyBase] = [:] + log.debug "storeData - attribute not found. New state: $state" + } + + log.debug "storeData after min/max calculations. New state: $state" +} \ No newline at end of file From 19b3790309ce0ae4b8413c1603f3f8b2704bab83 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 11:10:01 +0200 Subject: [PATCH 12/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 758653c48af..5128863d06f 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -141,14 +141,13 @@ def energyRefresh() { log.debug "todayMaxProd was ${todayMaxProd}" - log.debug "System Id ${system_id}" + log.debug "System Id ${system_id}" log.debug "Energy today ${energy}" log.debug "Energy life ${energyLife}" - log.debug "Current - Level ${curPower}" - log.debug "System Size ${systemSize}" - log.debug "Production Level ${curPower}" - log.debug "todayDay ${todayDay}" + log.debug "Current Power Level ${curPower}" + log.debug "System Size ${systemSize}" + log.debug "Production Level ${curPower}" + log.debug "todayDay ${todayDay}" // If day has changed set today_max_day to new value if (today_max_day == null || today_max_day != todayDay) { From ea784c0b73ace31150eb6d22c77138982f181070 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 11:18:50 +0200 Subject: [PATCH 13/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 5128863d06f..3ae9ce9372b 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -129,7 +129,7 @@ def energyRefresh() { log.debug "${resp.data}" def energy = resp.data.result.yieldtoday def energyLife = resp.data.result.yieldtotal - def currentPower = resp.data.result.curPower + def currentPower = resp.data.result.nco-kiosk-power def systemSize = resp.data.size_w def systemId = resp.data.system_id def now=new Date() From c5691335d5ccb2c7a69f637f513b24bc630a3781 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 11:40:03 +0200 Subject: [PATCH 14/26] Create Fusion solar huawei --- devicetypes/Fusion solar huawei | 214 ++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 devicetypes/Fusion solar huawei diff --git a/devicetypes/Fusion solar huawei b/devicetypes/Fusion solar huawei new file mode 100644 index 00000000000..621b293e4ae --- /dev/null +++ b/devicetypes/Fusion solar huawei @@ -0,0 +1,214 @@ +''' +Before running this source file make sure you follow the instructions +from https://forum.huawei.com/enterprise/en/communicate-with-fusionsolar-through-an-openapi-account/thread/591478-100027?page=3 + +You can't access Fusion Solar devices without an OpenAPI account previously created. +''' + +import requests +import json +import sys + +''' +OpenAPI URLs +@login_url : Login url for POST method. +@logout_url : Logout url for POST method. +@get_station_list_url : Get stations list url for POST method. +@real_time_data_url : Power station info url for POST method. +''' +login_url = 'https://eu5.fusionsolar.huawei.com/thirdData/login' +logout_url = 'https://eu5.fusionsolar.huawei.com/thirdData/logout' +get_station_list_url = 'https://eu5.fusionsolar.huawei.com/thirdData/getStationList' +real_time_data_url = 'https://eu5.fusionsolar.huawei.com/thirdData/getStationRealKpi' + + +''' +OpenAPI variables. + +@username : OpenAPI username. +@password : OpenAPI password. +@xsrf_token : Session token returned by login method. +@plant_name : Plant name to be interrogated. +@station_code : Station code returned by get statil list method +''' +username = '' +password = '' +xsrf_token = '' +plant_name = '' +station_code = '' + +def read_credentials(): + """ + Function to read OpenAPI credentials (username/password). + """ + global username + global password + + print ("Enter OpenAPI Credentials") + username = input("Username: ") + password = input("Password: ") + + +def openapi_login(): + """ + Perform login to OpenAPI account. + + Requires username and passowrd and return session token in response cookie. + """ + + global xsrf_token + login_obj = { + "userName" : username, + "systemCode" : password + } + + # Send login request + response = requests.post( + login_url, + json = login_obj, + cookies = {"web-auth" : "true", "Cookie_1" : "value"}, + timeout = 3600 + ) + + # Inspect login response + try: + json_status = json.loads(response.content) + if json_status['success'] == False: + print ("ERROR: Login Failed") + sys.exit() + + print ("INFO: Login Successfully") + + except ValueError: + print ("ERROR: Login unexpected response from server") + + # Get session cookie (xsrf-token) + cookies_dict = response.cookies.get_dict() + if "XSRF-TOKEN" not in cookies_dict: + print ("ERROR: XSRF-TOKEN not found in cookies") + sys.exit() + + xsrf_token = cookies_dict.get("XSRF-TOKEN") + print ("XSRF-TOKEN: %s" % xsrf_token) + +def openapi_get_station_list(): + """ + Read station list for current user. + + Require session token. + """ + global station_code + global plant_name + plant_obj = {} + + # Send get station list request + response = requests.post( + get_station_list_url, + json = plant_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token}, + timeout = 3600 + ) + + # Inspect response + try: + json_plant = json.loads(response.content) + if json_plant['success'] == False: + print ("ERROR: Get Station List Failed") + openapi_logout() + sys.exit() + + except ValueError: + openapi_logout() + print ("ERROR: Get station list unexpected response from server") + sys.exit() + + # read plant name + plant_name = input("Enter plant name: ") + + print ("INFO: Stations list:") + # plant name lookup inside plants list + for station in json_plant['data']: + if "stationName" not in station: + print ("ERROR: Unknown format in get station list response") + openapi_logout() + sys.exit() + + print ("INFO: Station name : %s; Station code : %s" % (station.get('stationName'), station.get('stationCode'))) + if station.get('stationName') == plant_name: + station_code = station.get('stationCode') + + if station_code == '': + print ("ERROR: Plant name %s not found in station list" % plant_name) + + +# OpenAPI Read Station Real TimeData +def openapi_real_time_data(): + rtime_obj = { "stationCodes" : station_code } + + # Send real time data request + response = requests.post( + real_time_data_url, + json = rtime_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token}, + timeout = 3600 + ) + + # Evaluate real time response + try: + json_rtime = json.loads(response.content) + if json_rtime['success'] == False: + print ("ERROR: Real Time Information Failed") + openapi_logout() + sys.exit() + + except ValueError: + openapi_logout() + print ("ERROR: Plant list unexpected response from server") + + + print ("INFO: Real time data for %s station:" % plant_name) + # Print values + for data_obj in json_rtime['data']: + map_obj = data_obj.get('dataItemMap') + print ("Day power : %s" % map_obj.get('day_power')) + print ("Month power : %s" % map_obj.get('month_power')) + print ("Total power : %s" % map_obj.get('total_power')) + print ("Health State : %s" % map_obj.get('real_health_state')) + + +def openapi_logout(): + """ + Perform logout to OpenAPI account. + + Requires session token. + """ + logout_obj = { "xsrfToken" : xsrf_token } + + # Send logout request + response = requests.post( + logout_url, + json = logout_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token} + ) + + # Inspect logout response + try: + json_status = json.loads(response.content) + if json_status['success'] == False: + print ("ERROR: Logout Failed") + sys.exit() + + print ("INFO: Logout Successfully") + except ValueError: + print ("ERROR: Logout unexpected response from server") + + +if __name__ == "__main__": + read_credentials() + openapi_login() + openapi_get_station_list() + openapi_real_time_data() + openapi_logout() From 5a1f0dada966e3a37c5a0885489f2b2ce2976e01 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 11:43:19 +0200 Subject: [PATCH 15/26] Create Huawei --- devicetypes/usirsiwal/Huawei | 214 +++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 devicetypes/usirsiwal/Huawei diff --git a/devicetypes/usirsiwal/Huawei b/devicetypes/usirsiwal/Huawei new file mode 100644 index 00000000000..621b293e4ae --- /dev/null +++ b/devicetypes/usirsiwal/Huawei @@ -0,0 +1,214 @@ +''' +Before running this source file make sure you follow the instructions +from https://forum.huawei.com/enterprise/en/communicate-with-fusionsolar-through-an-openapi-account/thread/591478-100027?page=3 + +You can't access Fusion Solar devices without an OpenAPI account previously created. +''' + +import requests +import json +import sys + +''' +OpenAPI URLs +@login_url : Login url for POST method. +@logout_url : Logout url for POST method. +@get_station_list_url : Get stations list url for POST method. +@real_time_data_url : Power station info url for POST method. +''' +login_url = 'https://eu5.fusionsolar.huawei.com/thirdData/login' +logout_url = 'https://eu5.fusionsolar.huawei.com/thirdData/logout' +get_station_list_url = 'https://eu5.fusionsolar.huawei.com/thirdData/getStationList' +real_time_data_url = 'https://eu5.fusionsolar.huawei.com/thirdData/getStationRealKpi' + + +''' +OpenAPI variables. + +@username : OpenAPI username. +@password : OpenAPI password. +@xsrf_token : Session token returned by login method. +@plant_name : Plant name to be interrogated. +@station_code : Station code returned by get statil list method +''' +username = '' +password = '' +xsrf_token = '' +plant_name = '' +station_code = '' + +def read_credentials(): + """ + Function to read OpenAPI credentials (username/password). + """ + global username + global password + + print ("Enter OpenAPI Credentials") + username = input("Username: ") + password = input("Password: ") + + +def openapi_login(): + """ + Perform login to OpenAPI account. + + Requires username and passowrd and return session token in response cookie. + """ + + global xsrf_token + login_obj = { + "userName" : username, + "systemCode" : password + } + + # Send login request + response = requests.post( + login_url, + json = login_obj, + cookies = {"web-auth" : "true", "Cookie_1" : "value"}, + timeout = 3600 + ) + + # Inspect login response + try: + json_status = json.loads(response.content) + if json_status['success'] == False: + print ("ERROR: Login Failed") + sys.exit() + + print ("INFO: Login Successfully") + + except ValueError: + print ("ERROR: Login unexpected response from server") + + # Get session cookie (xsrf-token) + cookies_dict = response.cookies.get_dict() + if "XSRF-TOKEN" not in cookies_dict: + print ("ERROR: XSRF-TOKEN not found in cookies") + sys.exit() + + xsrf_token = cookies_dict.get("XSRF-TOKEN") + print ("XSRF-TOKEN: %s" % xsrf_token) + +def openapi_get_station_list(): + """ + Read station list for current user. + + Require session token. + """ + global station_code + global plant_name + plant_obj = {} + + # Send get station list request + response = requests.post( + get_station_list_url, + json = plant_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token}, + timeout = 3600 + ) + + # Inspect response + try: + json_plant = json.loads(response.content) + if json_plant['success'] == False: + print ("ERROR: Get Station List Failed") + openapi_logout() + sys.exit() + + except ValueError: + openapi_logout() + print ("ERROR: Get station list unexpected response from server") + sys.exit() + + # read plant name + plant_name = input("Enter plant name: ") + + print ("INFO: Stations list:") + # plant name lookup inside plants list + for station in json_plant['data']: + if "stationName" not in station: + print ("ERROR: Unknown format in get station list response") + openapi_logout() + sys.exit() + + print ("INFO: Station name : %s; Station code : %s" % (station.get('stationName'), station.get('stationCode'))) + if station.get('stationName') == plant_name: + station_code = station.get('stationCode') + + if station_code == '': + print ("ERROR: Plant name %s not found in station list" % plant_name) + + +# OpenAPI Read Station Real TimeData +def openapi_real_time_data(): + rtime_obj = { "stationCodes" : station_code } + + # Send real time data request + response = requests.post( + real_time_data_url, + json = rtime_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token}, + timeout = 3600 + ) + + # Evaluate real time response + try: + json_rtime = json.loads(response.content) + if json_rtime['success'] == False: + print ("ERROR: Real Time Information Failed") + openapi_logout() + sys.exit() + + except ValueError: + openapi_logout() + print ("ERROR: Plant list unexpected response from server") + + + print ("INFO: Real time data for %s station:" % plant_name) + # Print values + for data_obj in json_rtime['data']: + map_obj = data_obj.get('dataItemMap') + print ("Day power : %s" % map_obj.get('day_power')) + print ("Month power : %s" % map_obj.get('month_power')) + print ("Total power : %s" % map_obj.get('total_power')) + print ("Health State : %s" % map_obj.get('real_health_state')) + + +def openapi_logout(): + """ + Perform logout to OpenAPI account. + + Requires session token. + """ + logout_obj = { "xsrfToken" : xsrf_token } + + # Send logout request + response = requests.post( + logout_url, + json = logout_obj, + cookies = {"XSRF-TOKEN" : xsrf_token, "web-auth" : "true"}, + headers = {"XSRF-TOKEN": xsrf_token} + ) + + # Inspect logout response + try: + json_status = json.loads(response.content) + if json_status['success'] == False: + print ("ERROR: Logout Failed") + sys.exit() + + print ("INFO: Logout Successfully") + except ValueError: + print ("ERROR: Logout unexpected response from server") + + +if __name__ == "__main__": + read_credentials() + openapi_login() + openapi_get_station_list() + openapi_real_time_data() + openapi_logout() From 35fdd5e9f86c31f121189f9275030968f3172333 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 11:46:56 +0200 Subject: [PATCH 16/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 3ae9ce9372b..20549bacbe0 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -129,7 +129,7 @@ def energyRefresh() { log.debug "${resp.data}" def energy = resp.data.result.yieldtoday def energyLife = resp.data.result.yieldtotal - def currentPower = resp.data.result.nco-kiosk-power + def currentPower = resp.data.result.total_power def systemSize = resp.data.size_w def systemId = resp.data.system_id def now=new Date() From 32098fad109123765f837ea59816b1f5a0ee4b53 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Fri, 9 Dec 2022 18:31:39 +0200 Subject: [PATCH 17/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 20549bacbe0..d6b7284eb96 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -59,7 +59,7 @@ metadata { ] ) } - valueTile("power", "device.curPower", width: 1, height: 1) { + valueTile("power", "device.power", width: 1, height: 1) { state("power", label: '${currentValue}W', unit:"W", //icon: "https://raw.githubusercontent.com/usirsiwal/smartthings-enlighten/master/enphase.jpg", backgroundColors: [ @@ -129,7 +129,7 @@ def energyRefresh() { log.debug "${resp.data}" def energy = resp.data.result.yieldtoday def energyLife = resp.data.result.yieldtotal - def currentPower = resp.data.result.total_power + def currentPower = resp.data.result.power def systemSize = resp.data.size_w def systemId = resp.data.system_id def now=new Date() @@ -146,7 +146,7 @@ def energyRefresh() { log.debug "Energy life ${energyLife}" log.debug "Current Power Level ${curPower}" log.debug "System Size ${systemSize}" - log.debug "Production Level ${curPower}" + log.debug "Production Level ${power}" log.debug "todayDay ${todayDay}" // If day has changed set today_max_day to new value From 573b0cba499c907309af33ce768ce12d2a3ec6dd Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Sat, 10 Dec 2022 09:35:48 +0200 Subject: [PATCH 18/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index d6b7284eb96..5d1880ad2b4 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -121,7 +121,7 @@ def refresh() { def energyRefresh() { log.debug "Executing 'energyToday'" - def cmd = "https://region03eu5.fusionsolar.huawei.com/pvmswebsite/nologin/assets/build/index.html#/kiosk?kk=Qz3HaPPObuzs49yHcvvjhBzBw6PK0ayD"; + def cmd = "https://region03eu5.fusionsolar.huawei.com/pvmswebsite/assets/build/index.html#/view/device/NE=35088645/inverter/details"; log.debug "Sending request cmd[${cmd}]" httpGet(cmd) {resp -> From 73fa33aabd9ccc11eec936659aaa71be75916402 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Sat, 10 Dec 2022 09:58:47 +0200 Subject: [PATCH 19/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 5d1880ad2b4..0aabb828c27 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -129,7 +129,7 @@ def energyRefresh() { log.debug "${resp.data}" def energy = resp.data.result.yieldtoday def energyLife = resp.data.result.yieldtotal - def currentPower = resp.data.result.power + def currentPower = resp.data.result.realTimePower def systemSize = resp.data.size_w def systemId = resp.data.system_id def now=new Date() @@ -160,7 +160,7 @@ def energyRefresh() { // String.format("%5.2f", energyToday) delayBetween([sendEvent(name: 'energy', value: (energy)) ,sendEvent(name: 'energy_life', value: (energyLife)) - ,sendEvent(name: 'power', value: (curPower)) + ,sendEvent(name: 'power', value: (realTimePower)) ,sendEvent(name: 'production_level', value: (String.format("%5.2f",productionLevel))) ,sendEvent(name: 'today_max_prod', value: (todayMaxProd)) ,sendEvent(name: 'today_max_prod_str', value: (String.format("%5.2f",todayMaxProd))) From 8e1990978478bfe72ffb6e60e2ef90347a584aa4 Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Sat, 10 Dec 2022 10:05:43 +0200 Subject: [PATCH 20/26] Update enlighten-solar-system-1.groovy --- .../enlighten-solar-system-1.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy index 0aabb828c27..69e42740406 100644 --- a/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy +++ b/devicetypes/usirsiwal/enlighten-solar-system-1.src/enlighten-solar-system-1.groovy @@ -121,7 +121,7 @@ def refresh() { def energyRefresh() { log.debug "Executing 'energyToday'" - def cmd = "https://region03eu5.fusionsolar.huawei.com/pvmswebsite/assets/build/index.html#/view/device/NE=35088645/inverter/details"; + def cmd = "https://region03eu5.fusionsolar.huawei.com/pvmswebsite/nologin/assets/build/index.html#/kiosk?kk=Qz3HaPPObuzs49yHcvvjhBzBw6PK0ayD"; log.debug "Sending request cmd[${cmd}]" httpGet(cmd) {resp -> From 848bc8c5e2eca91668fe8a0aad1b4466a6a930bb Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Tue, 17 Jan 2023 12:05:29 +0200 Subject: [PATCH 21/26] Evohome (Connect) 2.3 --- devicetypes/Evohome (Connect) 2.3 | 1680 +++++++++++++++++++++++++++++ 1 file changed, 1680 insertions(+) create mode 100644 devicetypes/Evohome (Connect) 2.3 diff --git a/devicetypes/Evohome (Connect) 2.3 b/devicetypes/Evohome (Connect) 2.3 new file mode 100644 index 00000000000..eeb552762fd --- /dev/null +++ b/devicetypes/Evohome (Connect) 2.3 @@ -0,0 +1,1680 @@ +/** + * Copyright 2021 Andreas Christodoulou (Andremain) + * + * Name: Evohome (Connect) + * + * Author: Andreas Christodoulou (Andremain) + * + * Date: 28/01/2021 + * + * Version: 2.3 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/andremain/EvohomeSmartthingsNew + + * Version History: + * + * 2021-02-16: v3.2 + * Added a clause to exit the loop when checking the integration for hot water, as if there was none, the connection would result in error. + * + * 2020-12-22: v2.1 Removed Deprecated set temperature option at the automation actions + * + * 2020-12-22: v2.0 First release of the new integration. + * + * 2020-12-20: v0.21 Removed the cooling option when creating an automation in the IF statement (DO BE DONE!!!) + * + * 2020-12-18: v0.20 Changed the links for the documentation and images to work. + * + * 2020-12-16: v0.19 Changed the owner of the smartapp and dht to Andremain (mine) + * + * 2020-12-14: v0.18 Managed to get all the modes to work with the new app's presentation + * + * 2020-12-13: v0.17 Managed to get the Auto, away, custom and off modes to work with the new app's presentation. Some list items appear wierd but this is a Smartthings issue + * + * 2020-12-11: v0.16 Managed to get the correct thermostat modes to show app in the modes list in the new app presentation + * + * 2020-12-9: v0.15 Changed the default value for window function temperature from 5.0 to 5 as it would throw a bad request error + * + * 2020-12-8: v0.14 Added an initialize function for the new thermostat capabilities + * + * 2020-12-6: v0.13 Removed the old custom and depricated capabilited from the old code + * + * 2020-12-3: v0.12 Generated the Presentation file required by the new smartthings app for correctly displaying the options for the inside the new app + * + * 2020-11-30: v0.11 Fixed the Checking Status on the dashboard tile for use with the new Smartthings app + * + * 2020-11-28: v0.10 Added The new capababilities for the depricated thermostat capability used by the new smartthings app + * + * 2020-11-02: v0.09 Fixed the API endpoint to be the new one that Honeywell Evohome uses. + * + * 2016-04-19: v0.10 + * - formatTemperature() - Improved error handling. + * - Mid-development of DHW zone support. + * - To Do: Add parsing of DHW schedules. - NEED SAMPLE DATA + * + * 2016-04-17: v0.09 + * - updateChildDevice() - Sends two new attribute values to child devices: + * thermostatModeMode: 'temporary' or 'permanent'. + * thermostatModeUntil: Contains date string if thermostatModeMode is temporary. + * + * 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 + * + * 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: "worldhouse47531", + author: "Andreas Christodoulou", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX2Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX3Url: "https://i.ibb.co/SwyYJWb/Evohome.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, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input "prefEvohomeDHWTemp", "decimal", title: "Hot Water Target Temperature", range: "0..100", defaultValue: 55, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + + + + + + + + + + + + + + + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://mytotalconnectcomfort.com/WebApi' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + 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() + + getEvohomeSchedules() + + //poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update child devices based on atomicState.evohomeConfig. + * + **/ +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' and 'Evohome Hot Water Zone' devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + // Heating Zones: + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating Zone R3.4", dni, null, values) //Change the name here to change the name of the device + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + + + // Hot Water Zone: + if (tcs.dhw) { + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, tcs.dhw.dhwId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'dhwTemperature': formatTemperature(settings.prefEvohomeDHWTemp), + 'zoneType': 'DHW', + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': tcs.dhw.dhwId + ] + + log.info "${app.label}: updateChildDeviceConfig(): Found a hot water zone! Values: ${values}" + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "Hot Water (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating Hot Water Zone: DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Hot Water Zone R3.4", 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 -> + // Heating Zones: + 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': decapitalise(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'thermostatModeMode': (tcs?.systemModeStatus?.isPermanent) ? 'permanent' : 'temporary', + 'thermostatModeUntil': tcs?.systemModeStatus?.timeUntil, + '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." + } + } + } + + // Hot Water Zone: + if (tcs.dhw && (onlyZoneId == -1 || onlyZoneId == tcs.dhw.dhwId)) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, tcs.dhw.dhwId) + 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(tcs.dhw?.temperatureStatus?.temperature), + //'isTemperatureAvailable': tcs.dhw?.temperatureStatus?.isAvailable, + 'switch': tcs.dhw?.stateStatus?.state.toLowerCase(), + 'switchStateMode': decapitalise(tcs.dhw?.stateStatus?.mode), + 'switchStateUntil': tcs.dhw?.stateStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'thermostatModeMode': (tcs?.systemModeStatus?.isPermanent) ? 'permanent' : 'temporary', + 'thermostatModeUntil': tcs?.systemModeStatus?.timeUntil + // 'scheduledSwitchState': ?? + // 'nextScheduledSwitchState': ?? + // 'nextScheduledTime': ?? + ] + 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." + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + '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://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + '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 in all locations are updated. + * + * Calls getEvohomeLocationStatus() and getEvohomeZoneStatus(). + * + **/ +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 worst 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) { + 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 the schedules for all hot water and temperature (heating) zones + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * and stores in atomicState.evohomeSchedules. + * + * Calls getEvohomeTempZoneSchedule() and getEvohomeDHWSchedule(). + * + **/ +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 -> + // Heating Zones: + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeTempZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + // Hot Water Zone: + if (tcs.dhw) { + if (tcs.dhw.dhwId) { + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, tcs.dhw.dhwId ) + def schedule = getEvohomeDHWSchedule(tcs.dhw.dhwId) + if (schedule) { + evohomeSchedules << ['zoneId': tcs.dhw.dhwId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeTempZoneSchedule(zoneId) + * + * Gets the schedule for the specified temperature (heating) zone and returns data as a map. + * + **/ +private getEvohomeTempZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeTempZoneSchedule(${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}: getEvohomeTempZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeTempZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeTempZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeDHWSchedule(zoneId) + * + * Gets the schedule for the specified hot water zone and returns data as a map. + * + **/ +private getEvohomeDHWSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeDHWSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeDHWSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeDHWSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeDHWSchedule: 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 'eco': + 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 override for specified heating zone, 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 any override for the specified heating zone. + * 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 + } +} + + +/** + * setDHWSwitchState(zoneId, switchState, until=-1) + * + * Set state override for specified hot water zone, until specified time. + * + * zoneId: Zone ID of hot water zone, e.g.: "123456" + * + * switchState: 'on' or 'off'. + * + * 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: + * setDHWSwitchState(123456, 'on') // Turn on hot water zone (123456) permanently. + * setDHWSwitchState(123456, 'off', 'permanent') // Turn off hot water zone (123456) permanently. + * setDHWSwitchState(123456, 'on', '2016-04-01T00:00:00Z') // Turn on hot water zone (123456) until specific time. + * + **/ +def setDHWSwitchState(zoneId, switchState, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, switchState: ${switchState}, Until: ${until}" + + // Clean switchState: + def stateRes = ('on' == switchState.toLowerCase()) ? 1 : 0 + + // 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}: setDHWSwitchState(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + // Note, DHW uses the parameter 'UntilTime' whereas heating zones use 'TimeUntil'. Go figure! + def body + if (0 == untilRes) { // Permanent: + body = ['State': stateRes, 'Mode': 1, 'UntilTime': null] + log.info "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, State: ${switchState}, Until: Permanent" + } + else { // Temporary: + body = ['State': stateRes, 'Mode': 2, 'UntilTime': untilRes] + log.info "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, State: ${switchState}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/state", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setDHWSwitchState(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setDHWSwitchState(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setDHWSwitchState(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearDHWSwitchState(zoneId) + * + * Clear any override for the specific hot water zone. + * zoneId: Zone ID of hot water zone, e.g.: "123456" + * + **/ +def clearDHWSwitchState(zoneId) { + + log.info "${app.label}: clearDHWSwitchState(): Hot Water Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/state", + 'body': ['State': 0, 'Mode': 0, 'UntilTime': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearDHWSwitchState(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearDHWSwitchState(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearDHWSwitchState(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * pseudoSleep(ms) + * + * Substitute for sleep() command. + * + **/ +private pseudoSleep(ms) { + def start = now() + while (now() < start + ms) { + // Do nothing, just wait. + } +} + + +/** + * 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 string. + * + **/ +private formatTemperature(t) { + try { + return Float.parseFloat("${t}").round(1).toString() + } + catch (NumberFormatException e) { + log.warn "${app.label}: formatTemperature(): could not parse value: ${t}" + return null + } +} + + +/** + * decapitalise(string) + * + * + * Decapitalise first letter of string. + * + * + **/ +private decapitalise(string) { + + if ( string == null || 0 == string.length() ) { + return string + } + else { + return string[0].toLowerCase() + string.substring(1) + + } + +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'HeatingOff': + mode = 'off' + break + case 'AutoWithEco': + mode = 'eco' + break + case 'Away': + mode = 'away' + break + case 'DayOff': + mode = 'dayoff' + break + case 'Custom': + mode = 'custom' + break + default: + log.warn "${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 +} From 77eda219a59e67f6835b695b67a22cf9065314b9 Mon Sep 17 00:00:00 2001 From: Mihai Berindei Date: Tue, 17 Jan 2023 12:07:17 +0200 Subject: [PATCH 22/26] new --- .../evohome-connect.groovy | 1382 +++++++++++++++++ 1 file changed, 1382 insertions(+) create mode 100644 smartapps/andremain/evohome-connect.src/evohome-connect.groovy diff --git a/smartapps/andremain/evohome-connect.src/evohome-connect.groovy b/smartapps/andremain/evohome-connect.src/evohome-connect.groovy new file mode 100644 index 00000000000..f00472dbc11 --- /dev/null +++ b/smartapps/andremain/evohome-connect.src/evohome-connect.groovy @@ -0,0 +1,1382 @@ +/** + * Copyright 2020 Andreas Christodoulou (Andremain) + * + * Name: Evohome (Connect) + * + * Author: Andreas Christodoulou (Andremain) + * + * Date: 18/12/2020 + * + * Version: 2.1 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/andremain/EvohomeSmartthingsNew + + * Version History: + * + * 2020-12-22: v2.1 Removed Depricated set temperature option at the automation actions + * + * 2020-12-22: v2.0 First release of the new integration. + * + * 2020-12-20: v0.21 Removed the cooling option when creating an automation in the IF statement (DO BE DONE!!!) + * + * 2020-12-18: v0.20 Changed the links for the documentation and images to work. + * + * 2020-12-16: v0.19 Changed the owner of the smartapp and dht to Andremain (mine) + * + * 2020-12-14: v0.18 Managed to get all the modes to work with the new app's presentation + * + * 2020-12-13: v0.17 Managed to get the Auto, away, custom and off modes to work with the new app's presentation. Some list items appear wierd but this is a Smartthings issue + * + * 2020-12-11: v0.16 Managed to get the correct thermostat modes to show app in the modes list in the new app presentation + * + * 2020-12-9: v0.15 Changed the default value for window function temperature from 5.0 to 5 as it would throw a bad request error + * + * 2020-12-8: v0.14 Added an initialize function for the new thermostat capabilities + * + * 2020-12-6: v0.13 Removed the old custom and depricated capabilited from the old code + * + * 2020-12-3: v0.12 Generated the Presentation file required by the new smartthings app for correctly displaying the options for the inside the new app + * + * 2020-11-30: v0.11 Fixed the Checking Status on the dashboard tile for use with the new Smartthings app + * + * 2020-11-28: v0.10 Added The new capababilities for the depricated thermostat capability used by the new smartthings app + * + * 2020-11-02: v0.09 Fixed the API endpoint to be the new one that Honeywell Evohome uses. + * + * 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 + * + * 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: "Andremain", + author: "Andreas Christodoulou", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX2Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX3Url: "https://i.ibb.co/SwyYJWb/Evohome.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, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://mytotalconnectcomfort.com/WebApi' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..." + authenticate() + } + else if (atomicState.evohomeAuthFailed) { + + log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..." + authenticate() + } + else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) { + + log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..." + authenticate() + } + else { + // Check if Auth Token should be refreshed: + def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100)) + + if (now() >= refreshAt) { + log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires." + refreshAuthToken() + } + else { + log.info "${app.label}: manageAuth(): Auth Token is okay." + } + } + +} + + +/** + * poll(onlyZoneId=-1) + * + * This is the main command that co-ordinates retrieval of information from the Evohome API + * and its dissemination to child devices. It should be scheduled to run every minute. + * + * Different types of information are collected on different schedules: + * - Zone status information is polled according to ${evohomeStatusPollInterval}. + * - Zone schedules are polled according to ${evohomeSchedulePollInterval}. + * + * poll() can be called by a child device when an update has been made, in which case + * onlyZoneId will be specified, and only that zone will be updated. + * + * If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll + * interval. This should only be used after setThremostatMode() call. + * + * If onlyZoneId is not specified all zones are updated, but only if the relevent poll + * interval has been exceeded. + * + **/ +void poll(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})" + + // Check if there's been an authentication failure: + if (atomicState.evohomeAuthFailed) { + manageAuth() + } + + if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update): + getEvohomeStatus() + updateChildDevice() + } + else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device: + getEvohomeStatus(onlyZoneId) + updateChildDevice(onlyZoneId) + } + else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: + + // Adjust intervals to allow for poll() execution time: + def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30 + def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30 + + // Get zone status: + if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) { + getEvohomeStatus() + } + + // Get zone schedules: + if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) { + getEvohomeSchedules() + } + + // Update all child devices: + updateChildDevice() + } + +} + + +/********************************************************************** + * Event Handlers: + **********************************************************************/ + + +/** + * handleAppTouch(evt) + * + * App touch event handler. + * Used for testing and debugging. + * + **/ +void handleAppTouch(evt) { + + if (atomicState.debug) log.debug "${app.label}: handleAppTouch()" + + //manageAuth() + //manageSchedules() + + //getEvohomeConfig() + //updateChildDeviceConfig() + + poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update Child Devices based on atomicState.evohomeConfig + * and update their internal state. + * + **/ +void updateChildDeviceConfig() { + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()" + + // Build list of active DNIs, any existing children with DNIs not in here will be deleted. + def activeDnis = [] + + // Iterate through evohomeConfig, adding new Evohome Heating Zone devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating R1.1", dni, null, values) //Chnage the name here to change the name of the device + } 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://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + '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://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'refresh_token': atomicState.evohomeAuth.refreshToken + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}" + // If Unauthorized (401) then re-authenticate: + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + authenticate() + } + } + +} + + +/** + * getEvohomeUserAccount() + * + * Gets user account info and stores in atomicState.evohomeUserAccount. + * + **/ +private getEvohomeUserAccount() { + + log.info "${app.label}: getEvohomeUserAccount(): Getting user account information." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/userAccount', + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + atomicState.evohomeUserAccount = resp.data + if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}" + } + else { + log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + } +} + + + +/** + * getEvohomeConfig() + * + * Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig. + * + **/ +private getEvohomeConfig() { + + log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/location/installationInfo', + query: [ + 'userId': atomicState.evohomeUserAccount.userId, + 'includeTemperatureControlSystems': 'True' + ], + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}" + atomicState.evohomeConfig = resp.data + atomicState.evohomeConfigUpdatedAt = now() + return null + } + else { + log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * getEvohomeStatus(onlyZoneId=-1) + * + * Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus. + * If onlyZoneId is not specified, all zones are updated. + * + **/ +private getEvohomeStatus(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})" + + def newEvohomeStatus = [] + + if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location): + + log.info "${app.label}: getEvohomeStatus(): Getting status for all zones." + + atomicState.evohomeConfig.each { loc -> + def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId) + if (locStatus) { + newEvohomeStatus << locStatus + } + } + + if (newEvohomeStatus) { + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + atomicState.evohomeStatusUpdatedAt = now() + } + } + else { // Only update the specified zone: + + log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}" + + def newZoneStatus = getEvohomeZoneStatus(onlyZoneId) + if (newZoneStatus) { + // Get existing evohomeStatus and update only the specified zone, preserving data for other zones: + // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate). + // If mutiple zones are requesting updates at the same time this could cause loss of new data, but + // the worse case is having out-of-date data for a few minutes... + newEvohomeStatus = atomicState.evohomeStatus + newEvohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == zone.zoneId) { // This is the zone that must be updated: + zone.activeFaults = newZoneStatus.activeFaults + zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus + zone.temperatureStatus = newZoneStatus.temperatureStatus + } + } + } + } + } + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + // Note: atomicState.evohomeStatusUpdatedAt is NOT updated. + } + } +} + + +/** + * getEvohomeLocationStatus(locationId) + * + * Gets the status for a specific location and returns data as a map. + * + * Called by getEvohomeStatus(). + **/ +private getEvohomeLocationStatus(locationId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/location/${locationId}/status", + 'query': [ 'includeTemperatureControlSystems': 'True'], + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeZoneStatus(zoneId) + * + * Gets the status for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneStatus(zoneId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeSchedules() + * + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * + **/ +private getEvohomeSchedules() { + + log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones." + + def evohomeSchedules = [] + + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeZoneSchedule(zoneId) + * + * Gets the schedule for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * setThermostatMode(systemId, mode, until) + * + * Set thermostat mode for specified controller, until specified time. + * + * systemId: SystemId of temperatureControlSystem. E.g.: 123456 + * + * mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom". + * + * until: (Optional) Time to apply mode until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'. + * Duration will be rounded down to align with Midnight in the local timezone + * (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent. + * If 'until' is not specified, a default value is used from the SmartApp settings. + * + * Notes: 'Auto' and 'Off' modes are always permanent. + * Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller). + * Therefore changing the thermostatMode will affect all zones associated with the same controller. + * + * + * Example usage: + * setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456. + * setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456. + * setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456. + * setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456. + * setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456. + * + **/ +def setThermostatMode(systemId, mode, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}" + + // Clean mode (translate to index): + mode = mode.toLowerCase() + int modeIndex + switch (mode) { + case 'auto': + modeIndex = 0 + break + case 'off': + modeIndex = 1 + break + case 'eco': + modeIndex = 2 + break + case 'away': + modeIndex = 3 + break + case 'dayoff': + modeIndex = 4 + break + case 'custom': + modeIndex = 6 + break + default: + log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!" + modeIndex = 999 + break + } + + // Clean until: + def untilRes + + // until has not been specified, so determine behaviour from settings: + if (-1 == until && 'economy' == mode) { + until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours): + } + else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) { + until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days): + } + + // Convert to date (or 0): + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours: + untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days: + untilRes = new Date( now() + (Math.round(until) * 86400000) ).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) // Round down to midnight in the LOCAL timezone. + } + else { + log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently." + untilRes = 0 + } + + // If mode is away/dayOff/custom the date needs to be rounded down to midnight in the local timezone, then converted back to string again: + if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilRes).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) + } + + // Build request: + def body + if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent: + body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True" + } + else { // Mode is temporary: + body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setThermostatMode(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setThermostatMode(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + /** + * setHeatingSetpoint(zoneId, setpoint, until=-1) + * + * Set heatingSetpoint for specified zoneId, until specified time. + * + * zoneId: Zone ID of zone, e.g.: "123456" + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456. + * + **/ +def setHeatingSetpoint(zoneId, setpoint, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + def body + if (0 == untilRes) { // Permanent: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent" + } + else { // Temporary: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearHeatingSetpoint(zoneId) + * + * Clear the heatingSetpoint for specified zoneId. + * zoneId: Zone ID of zone, e.g.: "123456" + **/ +def clearHeatingSetpoint(zoneId) { + + log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * generateDni(locId,gatewayId,systemId,deviceId) + * + * Generate a device Network ID. + * Uses the same format as the official Evohome App, but with a prefix of "Evohome." + **/ +private generateDni(locId,gatewayId,systemId,deviceId) { + return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.') +} + + +/** + * formatTemperature(t) + * + * Format temperature value to one decimal place. + * t: can be string, float, bigdecimal... + * Returns as string. + **/ +private formatTemperature(t) { + return Float.parseFloat("${t}").round(1).toString() +} + + + /** + * formatSetpointMode(mode) + * + * Format Evohome setpointMode values to SmartThings values: + * + **/ +private formatSetpointMode(mode) { + + switch (mode) { + case 'FollowSchedule': + mode = 'followSchedule' + break + case 'PermanentOverride': + mode = 'permanentOverride' + break + case 'TemporaryOverride': + mode = 'temporaryOverride' + break + default: + log.error "${app.label}: formatSetpointMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'HeatingOff': + mode = 'off' + break + case 'AutoWithEco': + mode = 'eco' + break + case 'Away': + mode = 'away' + break + case 'DayOff': + mode = 'dayoff' + break + case 'Custom': + mode = 'custom' + 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 From 1440c358f6c2f5f52dc5776d6a141d54c6e25e2d Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Tue, 17 Jan 2023 12:14:20 +0200 Subject: [PATCH 23/26] Create Evohome (Connect) 2.3 --- .../evohome-connect.src/Evohome (Connect) 2.3 | 1680 +++++++++++++++++ 1 file changed, 1680 insertions(+) create mode 100644 smartapps/andremain/evohome-connect.src/Evohome (Connect) 2.3 diff --git a/smartapps/andremain/evohome-connect.src/Evohome (Connect) 2.3 b/smartapps/andremain/evohome-connect.src/Evohome (Connect) 2.3 new file mode 100644 index 00000000000..eeb552762fd --- /dev/null +++ b/smartapps/andremain/evohome-connect.src/Evohome (Connect) 2.3 @@ -0,0 +1,1680 @@ +/** + * Copyright 2021 Andreas Christodoulou (Andremain) + * + * Name: Evohome (Connect) + * + * Author: Andreas Christodoulou (Andremain) + * + * Date: 28/01/2021 + * + * Version: 2.3 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/andremain/EvohomeSmartthingsNew + + * Version History: + * + * 2021-02-16: v3.2 + * Added a clause to exit the loop when checking the integration for hot water, as if there was none, the connection would result in error. + * + * 2020-12-22: v2.1 Removed Deprecated set temperature option at the automation actions + * + * 2020-12-22: v2.0 First release of the new integration. + * + * 2020-12-20: v0.21 Removed the cooling option when creating an automation in the IF statement (DO BE DONE!!!) + * + * 2020-12-18: v0.20 Changed the links for the documentation and images to work. + * + * 2020-12-16: v0.19 Changed the owner of the smartapp and dht to Andremain (mine) + * + * 2020-12-14: v0.18 Managed to get all the modes to work with the new app's presentation + * + * 2020-12-13: v0.17 Managed to get the Auto, away, custom and off modes to work with the new app's presentation. Some list items appear wierd but this is a Smartthings issue + * + * 2020-12-11: v0.16 Managed to get the correct thermostat modes to show app in the modes list in the new app presentation + * + * 2020-12-9: v0.15 Changed the default value for window function temperature from 5.0 to 5 as it would throw a bad request error + * + * 2020-12-8: v0.14 Added an initialize function for the new thermostat capabilities + * + * 2020-12-6: v0.13 Removed the old custom and depricated capabilited from the old code + * + * 2020-12-3: v0.12 Generated the Presentation file required by the new smartthings app for correctly displaying the options for the inside the new app + * + * 2020-11-30: v0.11 Fixed the Checking Status on the dashboard tile for use with the new Smartthings app + * + * 2020-11-28: v0.10 Added The new capababilities for the depricated thermostat capability used by the new smartthings app + * + * 2020-11-02: v0.09 Fixed the API endpoint to be the new one that Honeywell Evohome uses. + * + * 2016-04-19: v0.10 + * - formatTemperature() - Improved error handling. + * - Mid-development of DHW zone support. + * - To Do: Add parsing of DHW schedules. - NEED SAMPLE DATA + * + * 2016-04-17: v0.09 + * - updateChildDevice() - Sends two new attribute values to child devices: + * thermostatModeMode: 'temporary' or 'permanent'. + * thermostatModeUntil: Contains date string if thermostatModeMode is temporary. + * + * 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 + * + * 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: "worldhouse47531", + author: "Andreas Christodoulou", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX2Url: "https://i.ibb.co/SwyYJWb/Evohome.png", + iconX3Url: "https://i.ibb.co/SwyYJWb/Evohome.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, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input "prefEvohomeDHWTemp", "decimal", title: "Hot Water Target Temperature", range: "0..100", defaultValue: 55, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + + + + + + + + + + + + + + + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://mytotalconnectcomfort.com/WebApi' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + 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() + + getEvohomeSchedules() + + //poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update child devices based on atomicState.evohomeConfig. + * + **/ +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' and 'Evohome Hot Water Zone' devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + // Heating Zones: + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating Zone R3.4", dni, null, values) //Change the name here to change the name of the device + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + + + // Hot Water Zone: + if (tcs.dhw) { + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, tcs.dhw.dhwId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'dhwTemperature': formatTemperature(settings.prefEvohomeDHWTemp), + 'zoneType': 'DHW', + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': tcs.dhw.dhwId + ] + + log.info "${app.label}: updateChildDeviceConfig(): Found a hot water zone! Values: ${values}" + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "Hot Water (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating Hot Water Zone: DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Hot Water Zone R3.4", 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 -> + // Heating Zones: + 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': decapitalise(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'thermostatModeMode': (tcs?.systemModeStatus?.isPermanent) ? 'permanent' : 'temporary', + 'thermostatModeUntil': tcs?.systemModeStatus?.timeUntil, + '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." + } + } + } + + // Hot Water Zone: + if (tcs.dhw && (onlyZoneId == -1 || onlyZoneId == tcs.dhw.dhwId)) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, tcs.dhw.dhwId) + 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(tcs.dhw?.temperatureStatus?.temperature), + //'isTemperatureAvailable': tcs.dhw?.temperatureStatus?.isAvailable, + 'switch': tcs.dhw?.stateStatus?.state.toLowerCase(), + 'switchStateMode': decapitalise(tcs.dhw?.stateStatus?.mode), + 'switchStateUntil': tcs.dhw?.stateStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'thermostatModeMode': (tcs?.systemModeStatus?.isPermanent) ? 'permanent' : 'temporary', + 'thermostatModeUntil': tcs?.systemModeStatus?.timeUntil + // 'scheduledSwitchState': ?? + // 'nextScheduledSwitchState': ?? + // 'nextScheduledTime': ?? + ] + 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." + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + '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://mytotalconnectcomfort.com/WebApi', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + '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 in all locations are updated. + * + * Calls getEvohomeLocationStatus() and getEvohomeZoneStatus(). + * + **/ +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 worst 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) { + 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 the schedules for all hot water and temperature (heating) zones + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * and stores in atomicState.evohomeSchedules. + * + * Calls getEvohomeTempZoneSchedule() and getEvohomeDHWSchedule(). + * + **/ +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 -> + // Heating Zones: + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeTempZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + // Hot Water Zone: + if (tcs.dhw) { + if (tcs.dhw.dhwId) { + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, tcs.dhw.dhwId ) + def schedule = getEvohomeDHWSchedule(tcs.dhw.dhwId) + if (schedule) { + evohomeSchedules << ['zoneId': tcs.dhw.dhwId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeTempZoneSchedule(zoneId) + * + * Gets the schedule for the specified temperature (heating) zone and returns data as a map. + * + **/ +private getEvohomeTempZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeTempZoneSchedule(${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}: getEvohomeTempZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeTempZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeTempZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeDHWSchedule(zoneId) + * + * Gets the schedule for the specified hot water zone and returns data as a map. + * + **/ +private getEvohomeDHWSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeDHWSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeDHWSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeDHWSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeDHWSchedule: 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 'eco': + 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 override for specified heating zone, 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 any override for the specified heating zone. + * 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 + } +} + + +/** + * setDHWSwitchState(zoneId, switchState, until=-1) + * + * Set state override for specified hot water zone, until specified time. + * + * zoneId: Zone ID of hot water zone, e.g.: "123456" + * + * switchState: 'on' or 'off'. + * + * 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: + * setDHWSwitchState(123456, 'on') // Turn on hot water zone (123456) permanently. + * setDHWSwitchState(123456, 'off', 'permanent') // Turn off hot water zone (123456) permanently. + * setDHWSwitchState(123456, 'on', '2016-04-01T00:00:00Z') // Turn on hot water zone (123456) until specific time. + * + **/ +def setDHWSwitchState(zoneId, switchState, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, switchState: ${switchState}, Until: ${until}" + + // Clean switchState: + def stateRes = ('on' == switchState.toLowerCase()) ? 1 : 0 + + // 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}: setDHWSwitchState(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + // Note, DHW uses the parameter 'UntilTime' whereas heating zones use 'TimeUntil'. Go figure! + def body + if (0 == untilRes) { // Permanent: + body = ['State': stateRes, 'Mode': 1, 'UntilTime': null] + log.info "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, State: ${switchState}, Until: Permanent" + } + else { // Temporary: + body = ['State': stateRes, 'Mode': 2, 'UntilTime': untilRes] + log.info "${app.label}: setDHWSwitchState(): Hot Water Zone ID: ${zoneId}, State: ${switchState}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/state", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setDHWSwitchState(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setDHWSwitchState(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setDHWSwitchState(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearDHWSwitchState(zoneId) + * + * Clear any override for the specific hot water zone. + * zoneId: Zone ID of hot water zone, e.g.: "123456" + * + **/ +def clearDHWSwitchState(zoneId) { + + log.info "${app.label}: clearDHWSwitchState(): Hot Water Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/domesticHotWater/${zoneId}/state", + 'body': ['State': 0, 'Mode': 0, 'UntilTime': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearDHWSwitchState(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearDHWSwitchState(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearDHWSwitchState(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * pseudoSleep(ms) + * + * Substitute for sleep() command. + * + **/ +private pseudoSleep(ms) { + def start = now() + while (now() < start + ms) { + // Do nothing, just wait. + } +} + + +/** + * 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 string. + * + **/ +private formatTemperature(t) { + try { + return Float.parseFloat("${t}").round(1).toString() + } + catch (NumberFormatException e) { + log.warn "${app.label}: formatTemperature(): could not parse value: ${t}" + return null + } +} + + +/** + * decapitalise(string) + * + * + * Decapitalise first letter of string. + * + * + **/ +private decapitalise(string) { + + if ( string == null || 0 == string.length() ) { + return string + } + else { + return string[0].toLowerCase() + string.substring(1) + + } + +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'HeatingOff': + mode = 'off' + break + case 'AutoWithEco': + mode = 'eco' + break + case 'Away': + mode = 'away' + break + case 'DayOff': + mode = 'dayoff' + break + case 'Custom': + mode = 'custom' + break + default: + log.warn "${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 +} From 88200a1ece0c293eb019b89f1a2169dca13ef11a Mon Sep 17 00:00:00 2001 From: Mihai Berindei Date: Tue, 17 Jan 2023 12:16:54 +0200 Subject: [PATCH 24/26] new --- .../evohome-connect.groovy | 1360 +++++++++++++++++ 1 file changed, 1360 insertions(+) create mode 100644 smartapps/codersaur/evohome-connect.src/evohome-connect.groovy diff --git a/smartapps/codersaur/evohome-connect.src/evohome-connect.groovy b/smartapps/codersaur/evohome-connect.src/evohome-connect.groovy new file mode 100644 index 00000000000..2b941f76707 --- /dev/null +++ b/smartapps/codersaur/evohome-connect.src/evohome-connect.groovy @@ -0,0 +1,1360 @@ +/** + * Copyright 2016 David Lomas (codersaur) + * + * Name: Evohome (Connect) + * + * Author: David Lomas (codersaur) + * + * Date: 2016-04-05 + * + * Version: 0.08 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/codersaur/SmartThings + * + * Version History: + * + * 2016-04-05: v0.08 + * - New 'Update Refresh Time' setting to control polling after making an update. + * - poll() - If onlyZoneId is 0, this will force a status update for all zones. + * + * 2016-04-04: v0.07 + * - Additional info log messages. + * + * 2016-04-03: v0.06 + * - Initial Beta Release + * + * To Do: + * - Add support for hot water zones (new device handler). + * - Tidy up settings: See: http://docs.smartthings.com/en/latest/smartapp-developers-guide/preferences-and-settings.html + * - Allow Evohome zones to be (de)selected as part of the setup process. + * - Enable notifications if connection to Evohome cloud fails. + * - Expose whether thremoStatMode is permanent or temporary, and if temporary for how long. Get from 'tcs.systemModeStatus.*'. i.e thermostatModeUntil + * - Investigate if Evohome supports registing a callback so changes in Evohome are pushed to SmartThings instantly (instead of relying on polling). + * + * License: + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ +definition( + name: "Evohome (Connect)", + namespace: "codersaur", + author: "David Lomas (codersaur)", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + iconX2Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + iconX3Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + singleInstance: true +) + +preferences { + + section ("Evohome:") { + input "prefEvohomeUsername", "text", title: "Username", required: true, displayDuringSetup: true + input "prefEvohomePassword", "password", title: "Password", required: true, displayDuringSetup: true + input title: "Advanced Settings:", displayDuringSetup: true, type: "paragraph", element: "paragraph", description: "Change these only if needed" + input "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true, description: "Poll Evohome every n minutes" + input "prefEvohomeUpdateRefreshTime", "number", title: "Update Refresh Time (seconds)", range: "2..60", defaultValue: 3, required: true, displayDuringSetup: true, description: "Wait n seconds after an update before polling" + input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5.0, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://tccna.honeywell.com' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..." + authenticate() + } + else if (atomicState.evohomeAuthFailed) { + + log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..." + authenticate() + } + else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) { + + log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..." + authenticate() + } + else { + // Check if Auth Token should be refreshed: + def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100)) + + if (now() >= refreshAt) { + log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires." + refreshAuthToken() + } + else { + log.info "${app.label}: manageAuth(): Auth Token is okay." + } + } + +} + + +/** + * poll(onlyZoneId=-1) + * + * This is the main command that co-ordinates retrieval of information from the Evohome API + * and its dissemination to child devices. It should be scheduled to run every minute. + * + * Different types of information are collected on different schedules: + * - Zone status information is polled according to ${evohomeStatusPollInterval}. + * - Zone schedules are polled according to ${evohomeSchedulePollInterval}. + * + * poll() can be called by a child device when an update has been made, in which case + * onlyZoneId will be specified, and only that zone will be updated. + * + * If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll + * interval. This should only be used after setThremostatMode() call. + * + * If onlyZoneId is not specified all zones are updated, but only if the relevent poll + * interval has been exceeded. + * + **/ +void poll(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})" + + // Check if there's been an authentication failure: + if (atomicState.evohomeAuthFailed) { + manageAuth() + } + + if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update): + getEvohomeStatus() + updateChildDevice() + } + else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device: + getEvohomeStatus(onlyZoneId) + updateChildDevice(onlyZoneId) + } + else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: + + // Adjust intervals to allow for poll() execution time: + def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30 + def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30 + + // Get zone status: + if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) { + getEvohomeStatus() + } + + // Get zone schedules: + if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) { + getEvohomeSchedules() + } + + // Update all child devices: + updateChildDevice() + } + +} + + +/********************************************************************** + * Event Handlers: + **********************************************************************/ + + +/** + * handleAppTouch(evt) + * + * App touch event handler. + * Used for testing and debugging. + * + **/ +void handleAppTouch(evt) { + + if (atomicState.debug) log.debug "${app.label}: handleAppTouch()" + + //manageAuth() + //manageSchedules() + + //getEvohomeConfig() + //updateChildDeviceConfig() + + poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update Child Devices based on atomicState.evohomeConfig + * and update their internal state. + * + **/ +void updateChildDeviceConfig() { + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()" + + // Build list of active DNIs, any existing children with DNIs not in here will be deleted. + def activeDnis = [] + + // Iterate through evohomeConfig, adding new Evohome Heating Zone devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating Zone", dni, null, values) + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + } + } + } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}" + + // Delete Devices: + def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete." + + delete.each { + log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}" + try { + deleteChildDevice(it.deviceNetworkId) + } + catch(e) { + log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}" + } + } +} + + + +/** + * updateChildDevice(onlyZoneId=-1) + * + * Update the attributes of a child device from atomicState.evohomeStatus + * and atomicState.evohomeSchedules. + * + * If onlyZoneId is not specified, then all zones are updated. + * + * Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime. + * + **/ +void updateChildDevice(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})" + + atomicState.evohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId) + def d = getChildDevice(dni) + if(d) { + def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + def currSw = getCurrentSwitchpoint(schedule.schedule) + def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(zone?.temperatureStatus?.temperature), + //'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable, + 'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpointMode': formatSetpointMode(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'scheduledSetpoint': formatTemperature(currSw.temperature), + 'nextScheduledSetpoint': formatTemperature(nextSw.temperature), + 'nextScheduledTime': nextSw.time + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist, so skipping status update." + } + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://tccna.honeywell.com', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'Username': settings.prefEvohomeUsername, + 'Password': settings.prefEvohomePassword + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: authenticate(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}" + atomicState.evohomeAuthFailed = true + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}" + atomicState.evohomeAuthFailed = true + } + +} + + +/** + * refreshAuthToken() + * + * Refresh Auth Token. + * If token refresh fails, then authenticate() is called. + * Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'. + * + **/ +private refreshAuthToken() { + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()" + + def requestParams = [ + method: 'POST', + uri: 'https://tccna.honeywell.com', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'refresh_token': atomicState.evohomeAuth.refreshToken + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}" + // If Unauthorized (401) then re-authenticate: + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + authenticate() + } + } + +} + + +/** + * getEvohomeUserAccount() + * + * Gets user account info and stores in atomicState.evohomeUserAccount. + * + **/ +private getEvohomeUserAccount() { + + log.info "${app.label}: getEvohomeUserAccount(): Getting user account information." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/userAccount', + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + atomicState.evohomeUserAccount = resp.data + if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}" + } + else { + log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + } +} + + + +/** + * getEvohomeConfig() + * + * Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig. + * + **/ +private getEvohomeConfig() { + + log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/location/installationInfo', + query: [ + 'userId': atomicState.evohomeUserAccount.userId, + 'includeTemperatureControlSystems': 'True' + ], + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}" + atomicState.evohomeConfig = resp.data + atomicState.evohomeConfigUpdatedAt = now() + return null + } + else { + log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * getEvohomeStatus(onlyZoneId=-1) + * + * Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus. + * If onlyZoneId is not specified, all zones are updated. + * + **/ +private getEvohomeStatus(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})" + + def newEvohomeStatus = [] + + if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location): + + log.info "${app.label}: getEvohomeStatus(): Getting status for all zones." + + atomicState.evohomeConfig.each { loc -> + def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId) + if (locStatus) { + newEvohomeStatus << locStatus + } + } + + if (newEvohomeStatus) { + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + atomicState.evohomeStatusUpdatedAt = now() + } + } + else { // Only update the specified zone: + + log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}" + + def newZoneStatus = getEvohomeZoneStatus(onlyZoneId) + if (newZoneStatus) { + // Get existing evohomeStatus and update only the specified zone, preserving data for other zones: + // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate). + // If mutiple zones are requesting updates at the same time this could cause loss of new data, but + // the worse case is having out-of-date data for a few minutes... + newEvohomeStatus = atomicState.evohomeStatus + newEvohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == zone.zoneId) { // This is the zone that must be updated: + zone.activeFaults = newZoneStatus.activeFaults + zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus + zone.temperatureStatus = newZoneStatus.temperatureStatus + } + } + } + } + } + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + // Note: atomicState.evohomeStatusUpdatedAt is NOT updated. + } + } +} + + +/** + * getEvohomeLocationStatus(locationId) + * + * Gets the status for a specific location and returns data as a map. + * + * Called by getEvohomeStatus(). + **/ +private getEvohomeLocationStatus(locationId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/location/${locationId}/status", + 'query': [ 'includeTemperatureControlSystems': 'True'], + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeZoneStatus(zoneId) + * + * Gets the status for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneStatus(zoneId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeSchedules() + * + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * + **/ +private getEvohomeSchedules() { + + log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones." + + def evohomeSchedules = [] + + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeZoneSchedule(zoneId) + * + * Gets the schedule for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * setThermostatMode(systemId, mode, until) + * + * Set thermostat mode for specified controller, until specified time. + * + * systemId: SystemId of temperatureControlSystem. E.g.: 123456 + * + * mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom". + * + * until: (Optional) Time to apply mode until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'. + * Duration will be rounded down to align with Midnight in the local timezone + * (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent. + * If 'until' is not specified, a default value is used from the SmartApp settings. + * + * Notes: 'Auto' and 'Off' modes are always permanent. + * Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller). + * Therefore changing the thermostatMode will affect all zones associated with the same controller. + * + * + * Example usage: + * setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456. + * setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456. + * setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456. + * setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456. + * setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456. + * + **/ +def setThermostatMode(systemId, mode, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}" + + // Clean mode (translate to index): + mode = mode.toLowerCase() + int modeIndex + switch (mode) { + case 'auto': + modeIndex = 0 + break + case 'off': + modeIndex = 1 + break + case 'economy': + modeIndex = 2 + break + case 'away': + modeIndex = 3 + break + case 'dayoff': + modeIndex = 4 + break + case 'custom': + modeIndex = 6 + break + default: + log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!" + modeIndex = 999 + break + } + + // Clean until: + def untilRes + + // until has not been specified, so determine behaviour from settings: + if (-1 == until && 'economy' == mode) { + until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours): + } + else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) { + until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days): + } + + // Convert to date (or 0): + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours: + untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days: + untilRes = new Date( now() + (Math.round(until) * 86400000) ).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) // Round down to midnight in the LOCAL timezone. + } + else { + log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently." + untilRes = 0 + } + + // If mode is away/dayOff/custom the date needs to be rounded down to midnight in the local timezone, then converted back to string again: + if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilRes).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) + } + + // Build request: + def body + if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent: + body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True" + } + else { // Mode is temporary: + body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setThermostatMode(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setThermostatMode(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + /** + * setHeatingSetpoint(zoneId, setpoint, until=-1) + * + * Set heatingSetpoint for specified zoneId, until specified time. + * + * zoneId: Zone ID of zone, e.g.: "123456" + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456. + * + **/ +def setHeatingSetpoint(zoneId, setpoint, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + def body + if (0 == untilRes) { // Permanent: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent" + } + else { // Temporary: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearHeatingSetpoint(zoneId) + * + * Clear the heatingSetpoint for specified zoneId. + * zoneId: Zone ID of zone, e.g.: "123456" + **/ +def clearHeatingSetpoint(zoneId) { + + log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * generateDni(locId,gatewayId,systemId,deviceId) + * + * Generate a device Network ID. + * Uses the same format as the official Evohome App, but with a prefix of "Evohome." + **/ +private generateDni(locId,gatewayId,systemId,deviceId) { + return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.') +} + + +/** + * formatTemperature(t) + * + * Format temperature value to one decimal place. + * t: can be string, float, bigdecimal... + * Returns as string. + **/ +private formatTemperature(t) { + return Float.parseFloat("${t}").round(1).toString() +} + + + /** + * formatSetpointMode(mode) + * + * Format Evohome setpointMode values to SmartThings values: + * + **/ +private formatSetpointMode(mode) { + + switch (mode) { + case 'FollowSchedule': + mode = 'followSchedule' + break + case 'PermanentOverride': + mode = 'permanentOverride' + break + case 'TemporaryOverride': + mode = 'temporaryOverride' + break + default: + log.error "${app.label}: formatSetpointMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'AutoWithEco': + mode = 'economy' + break + case 'Away': + mode = 'away' + break + case 'Custom': + mode = 'custom' + break + case 'DayOff': + mode = 'dayOff' + break + case 'HeatingOff': + mode = 'off' + break + default: + log.error "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * getCurrentSwitchpoint(schedule) + * + * Returns the current active switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getCurrentSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + ScheduleToday.switchpoints.reverse(true) + def currentSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay < c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!currentSwitchPoint) { + // There are no current switchpoints today, so we must look for the last Switchpoint yesterday. + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): No current switchpoints today, so must look to yesterday's schedule." + c.add(Calendar.DATE, -1 ) // Subtract one DAY. + def ScheduleYesterday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleYesterday.switchpoints.sort {it.timeOfDay} + ScheduleYesterday.switchpoints.reverse(true) + currentSwitchPoint = ScheduleYesterday.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + currentSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + currentSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): Current Switchpoint: ${currentSwitchPoint}" + + return currentSwitchPoint +} + + +/** + * getNextSwitchpoint(schedule) + * + * Returns the next switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getNextSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + def nextSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay > c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!nextSwitchPoint) { + // There are no switchpoints left today, so we must look for the first Switchpoint tomorrow. + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): No more switchpoints today, so must look to tomorrow's schedule." + c.add(Calendar.DATE, 1 ) // Add one DAY. + def ScheduleTmrw = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleTmrw.switchpoints.sort {it.timeOfDay} + nextSwitchPoint = ScheduleTmrw.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + nextSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + nextSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): Next Switchpoint: ${nextSwitchPoint}" + + return nextSwitchPoint +} \ No newline at end of file From 0c8de8fcab96ced2510420c4280950d5917a358c Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Wed, 18 Jan 2023 09:43:06 +0200 Subject: [PATCH 25/26] Rename Evohome (Connect) 2.3 to evohome-connect1.groovy --- .../{Evohome (Connect) 2.3 => evohome-connect1.groovy} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename smartapps/andremain/evohome-connect.src/{Evohome (Connect) 2.3 => evohome-connect1.groovy} (100%) diff --git a/smartapps/andremain/evohome-connect.src/Evohome (Connect) 2.3 b/smartapps/andremain/evohome-connect.src/evohome-connect1.groovy similarity index 100% rename from smartapps/andremain/evohome-connect.src/Evohome (Connect) 2.3 rename to smartapps/andremain/evohome-connect.src/evohome-connect1.groovy From 1e08cd20880f1e77edf24af93f56ca2111e25e9e Mon Sep 17 00:00:00 2001 From: mihaiberindei <42766577+mihaiberindei@users.noreply.github.com> Date: Wed, 18 Jan 2023 12:14:03 +0200 Subject: [PATCH 26/26] Create evohome-connect.groovy --- devicetypes/evohome-connect.groovy | 707 +++++++++++++++++++++++++++++ 1 file changed, 707 insertions(+) create mode 100644 devicetypes/evohome-connect.groovy diff --git a/devicetypes/evohome-connect.groovy b/devicetypes/evohome-connect.groovy new file mode 100644 index 00000000000..4e679a1dba6 --- /dev/null +++ b/devicetypes/evohome-connect.groovy @@ -0,0 +1,707 @@ +/** + * Copyright 2020 Andreas Christodoulou (Andremain) + * + * Name: Evohome Heating Zone + * + * Author: Andreas Christodoulou (Andremain) + * + * Date: 2020 + * + * Version: 2.1 + * + * Description: + * - This device handler is a child device for the Evohome (Connect) SmartApp. + * - For latest documentation see: https://github.com/andremain/EvohomeSmartthingsNew + * + * 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??? + * + * 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 R1.1", namespace: "Andremain", author: "Andreas Christodoulou", deviceTypeId:"Thermostat",ocfDeviceType:"oic.d.thermostat", vid: "3c7f0c66-08c2-37e5-b3f0-6c5199eb2701", mnmn:"SmartThingsCommunity") { + capability "Refresh" + capability "Temperature Measurement" + capability 'Health Check' + capability "thermostatOperatingState" + capability "Thermostat" + + //New Smartthings Capabilities + capability "Thermostat Heating Setpoint" + capability "Thermostat Setpoint" + capability "Thermostat Mode" + + + command "refresh" // Refresh + command "setHeatingSetpoint" // Thermostat + command "setThermostatMode" // Thermostat + command "off" // Thermostat + command "heat" // Thermostat + + + attribute "temperature","number" // Temperature Measurement + attribute "heatingSetpoint","number" // Thermostat + attribute "thermostatSetpoint","number" // Thermostat + 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 + + } + + 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. + } + + } + +} + + +/********************************************************************** + * 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() { + + initialize() + + log.debug "${app.label}: Installed with settings: ${settings}" + + state.installedAt = now() + + // These default values will be overwritten by the Evohome SmartApp almost immediately: + state.debug = false + state.updateRefreshTime = 5 // Wait this many seconds after an update before polling. + state.zoneType = 'RadiatorZone' + state.minHeatingSetpoint = formatTemperature(5.0) + state.maxHeatingSetpoint = formatTemperature(35.0) + state.temperatureResolution = formatTemperature(0.5) + state.windowFunctionTemperature = formatTemperature(5.0) + state.targetSetpoint = state.minHeatingSetpoint + + // Populate state.* with default values for each preference/input: + state.setpointMode = getInputDefaultValue('prefSetpointMode') + state.setpointDuration = getInputDefaultValue('prefSetpointDuration') + +} + + +/** + * 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:"5", unit:"C") + sendEvent(name:"heatingSetpoint", value:"5", unit:"C") + sendEvent(name:"setHeatingSetpoint", value:"5", unit:"C") + sendEvent(name:"thermostatMode", value:"auto") + sendEvent(name:"supportedThermostatModes", value:["auto","off","eco","away","dayoff","custom"]) +} +/********************************************************************** + * 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) +} + + + +def setThermostatMode(String mode, until=-1) { + + log.info "${device.label}: setThermostatMode(Mode: ${mode}, Until: ${until})" + + // Send update via parent: + if (!parent.setThermostatMode(state.systemId, mode, until)) { + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + // Wait a few seconds as it takes a while for Evohome to update setpoints in response to a mode change. + pseudoSleep(state.updateRefreshTime * 1000) + parent.poll(0) // Force poll for all zones as thermostatMode is a property of the temperatureControlSystem. + return null + } + else { + log.error "${device.label}: setThermostatMode(): Error: Unable to set thermostat mode." + return 'error' + } +} + + +/** + * setHeatingSetpoint(setpoint, until=-1) + * + * Set heatingSetpoint until specified time. + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * If setpoint is outside allowed range (i.e. minHeatingSetpoint to + * maxHeatingSetpoint) it will be re-written to the appropriate limit. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'nextSwitchpoint', 'midnight', 'midday', or 'permanent'. + * - Number: duration in minutes (from now). 0 = permanent. + * If not specified, setpoint duration will default to the + * behaviour defined in the device settings. + * + * Example usage: + * setHeatingSetpoint(21.0) // Set until . + * setHeatingSetpoint(21.0, 'nextSwitchpoint') // Set until next scheduled switchpoint. + * setHeatingSetpoint(21.0, 'midnight') // Set until midnight. + * setHeatingSetpoint(21.0, 'permanent') // Set permanently. + * setHeatingSetpoint(21.0, 0) // Set permanently. + * setHeatingSetpoint(21.0, 6) // Set for 6 hours. + * setHeatingSetpoint(21.0, '2016-04-01T00:00:00Z') // Set until specific time. + * + **/ +def setHeatingSetpoint(setpoint, until=-1) { + + if (state.debug) log.debug "${device.label}: setHeatingSetpoint(Setpoint: ${setpoint}, Until: ${until})" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + if (Float.parseFloat(setpoint) < Float.parseFloat(state.minHeatingSetpoint)) { + log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is less than zone's minimum setpoint (${state.minHeatingSetpoint})." + setpoint = state.minHeatingSetpoint + } + else if (Float.parseFloat(setpoint) > Float.parseFloat(state.maxHeatingSetpoint)) { + log.warn "${device.label}: setHeatingSetpoint(): Specified setpoint (${setpoint}) is greater than zone's maximum setpoint (${state.maxHeatingSetpoint})." + setpoint = state.maxHeatingSetpoint + } + + // Clean and parse until value: + def untilRes + Calendar c = new GregorianCalendar() + def tzOffset = location.timeZone.getOffset(new Date().getTime()) // Timezone offset to UTC in milliseconds. + + // If until has not been specified, determine behaviour from device state.setpointMode: + if (-1 == until) { + switch (state.setpointMode) { + case 'Next Switchpoint': + until = 'nextSwitchpoint' + break + case 'Midday': + until = 'midday' + break + case 'Midnight': + until = 'midnight' + break + case 'Duration': + until = state.setpointDuration ?: 0 + break + case 'Time': + // TO DO : construct time, like we do for midnight. + // settings.prefSetpointTime appears to return an ISO dateformat string. + // However using an input of type "time" causes HTTP 500 errors in the IDE, so disabled for now. + // If time has passed, then need to make it the next day. + if (state.debug) log.debug "${device.label}: setHeatingSetpoint(): Time: ${state.SetpointTime}" + until = 'nextSwitchpoint' + break + case 'Permanent': + until = 'permanent' + break + default: + until = 'nextSwitchpoint' + break + } + } + + if ('permanent' == until || 0 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until + } + else if ('nextSwitchpoint' == until) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", device.currentValue('nextScheduledTime')) + } + else if ('midday' == until) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().format("yyyy-MM-dd'T'12:00:00XX", location.timeZone)) + } + else if ('midnight' == until) { + c.add(Calendar.DATE, 1 ) // Add one day to calendar and use to get midnight in local time: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", c.getTime().format("yyyy-MM-dd'T'00:00:00XX", location.timeZone)) + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string, so parse: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until) + } + else if (until.isNumber()) { // until is a duration in minutes, so construct date from now(): + // Evohome supposedly only accepts setpoints for up to 24 hours, so we should limit minutes to 1440. + // For now, just pass any duration and see if Evohome accepts it... + untilRes = new Date( now() + (Math.round(until) * 60000) ) + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + log.info "${device.label}: setHeatingSetpoint(): Setting setpoint to: ${setpoint} until: ${untilRes}" + + // Send update via parent: + if (!parent.setHeatingSetpoint(state.zoneId, setpoint, untilRes)) { + // Command was successful, but it takes a few seconds for the Evohome cloud service to update with new values. + // Meanwhile, we know the new setpoint and thermostatSetpointMode anyway: + sendEvent(name: 'heatingSetpoint', value: setpoint) + sendEvent(name: 'thermostatSetpoint', value: setpoint) + sendEvent(name: 'thermostatSetpointMode', value: (0 == untilRes) ? 'permanentOverride' : 'temporaryOverride' ) + sendEvent(name: 'thermostatSetpointUntil', value: (0 == untilRes) ? null : untilRes.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC'))) + calculateThermostatOperatingState() + calculateOptimisations() + calculateThermostatStatus() + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + pseudoSleep(state.updateRefreshTime * 1000) + parent.poll(state.zoneId) + return null + } + else { + log.error "${device.label}: setHeatingSetpoint(): Error: Unable to set heating setpoint." + return 'error' + } +} + + + +/** + * clearHeatingSetpoint() + * + * Clear the heatingSetpoint. Will return heatingSetpoint to scheduled value. + * thermostatSetpointMode should return to "followSchedule". + * + **/ +def clearHeatingSetpoint() { + + log.info "${device.label}: clearHeatingSetpoint()" + + // Send update via parent: + if (!parent.clearHeatingSetpoint(state.zoneId)) { + // Command was successful, but it takes a few seconds for the Evohome cloud service + // to update the zone status with the new heatingSetpoint. + // Meanwhile, we know the new thermostatSetpointMode is "followSchedule". + sendEvent(name: 'thermostatSetpointMode', value: 'followSchedule') + sendEvent(name: 'thermostatSetpointStatus', value: 'Updating', displayed: false) + // sleep command is not allowed in SmartThings, so we use psuedoSleep(). + pseudoSleep(state.updateRefreshTime * 1000) + parent.poll(state.zoneId) + return null + } + else { + log.error "${device.label}: clearHeatingSetpoint(): Error: Unable to clear heating setpoint." + return 'error' + } +} + + +/** + * 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 heat() { + if (state.debug) log.debug "${device.label}: heat()" + setThermostatMode('auto') +} + +void off() { + if (state.debug) log.debug "${device.label}: off()" + setThermostatMode('off') +} + + +/********************************************************************** + * 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 '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 ('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) +}