diff --git a/devicetypes/alyc100/auto-charge-resume.png b/devicetypes/alyc100/auto-charge-resume.png new file mode 100644 index 00000000000..ce262f7a531 Binary files /dev/null and b/devicetypes/alyc100/auto-charge-resume.png differ diff --git a/devicetypes/alyc100/best-pet-hair-cleaning.png b/devicetypes/alyc100/best-pet-hair-cleaning.png new file mode 100644 index 00000000000..7eea78ca81f Binary files /dev/null and b/devicetypes/alyc100/best-pet-hair-cleaning.png differ diff --git a/devicetypes/alyc100/eight-sleep-mattress.src/eight-sleep-mattress.groovy b/devicetypes/alyc100/eight-sleep-mattress.src/eight-sleep-mattress.groovy new file mode 100644 index 00000000000..1a08625cb15 --- /dev/null +++ b/devicetypes/alyc100/eight-sleep-mattress.src/eight-sleep-mattress.groovy @@ -0,0 +1,790 @@ +/** + * Eight Sleep Mattress + * + * Copyright 2017 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 13.11.2017: 1.1 - Add back up method for determining sleep event if presence values from API become unreliable. + * 26.01.2017: 1.0 - Remove BETA label. + * + * 25.01.2017: 1.0 BETA Release 8c - Stop Infinite Loop / Divide by Errors. + * 20.01.2017: 1.0 BETA Release 8b - Ensure one sleep score notification per day. + * 19.01.2017: 1.0 BETA Release 8 - Sleep score stored as 'battery' capability for rule building. + * - Sleep score notifications via Eight Sleep (Connect) app. + * - Tweaks to 8slp bed event frequency. + * - Tile display changes. + * 17.01.2017: 1.0 BETA Release 7f - Bug fix. Out of Bed detection. + * 17.01.2017: 1.0 BETA Release 7e - Bug fix. Mark device as Connected after offline event has finished. More tweaks to bed presence logic. + * 15.01.2017: 1.0 BETA Release 7d - Further tweaks to bed presence logic. Fix to chart when missing sleep data. + * 15.01.2017: 1.0 BETA Release 7c - Bug Fix. Time zone support. Added device handler version information. + * 15.01.2017: 1.0 BETA Release 7b - Bug Fix. Broken heat duration being sent on 'ON' command. Tweak to 'out of bed' detection. + * 12.01.2017: 1.0 BETA Release 7 - Tweaks to bed presence logic. + * 13.01.2017: 1.0 BETA Release 6c - 8Slp Event minor fixes. Not important to functionality. + * 13.01.2017: 1.0 BETA Release 6b - Bug fix. Stop timer being reset when 'on' command is sent while device is already on. + * 13.01.2017: 1.0 BETA Release 6 - Changes to bed presence contact behaviour. + * - Handle scenario of no partner credentials. + * 13.01.2017: 1.0 BETA Release 5 - Historical sleep chart improvements showing SleepScore. + * 12.01.2017: 1.0 BETA Release 4c - Better 'Offline' detection and handling. + * 12.01.2017: 1.0 BETA Release 4b - Minor event messaging improvements. + * 12.01.2017: 1.0 BETA Release 4 - Further refinements to bed presence contact behaviour. + * 11.01.2017: 1.0 BETA Release 3c - Use Google Chart image API for Android support + * 11.01.2017: 1.0 BETA Release 3b - Further Chart formatting update + * 11.01.2017: 1.0 BETA Release 3 - Chart formatting update + - Attempt to improve bed detection + * 11.01.2017: 1.0 BETA Release 2 - Change set level behaviour + * - Support partner sleep trend data + * - Timer display changes + * - Add slider control on main tile + * - Many bug fixes + * 11.01.2017: 1.0 BETA Release 1 - Initial Release + */ +preferences { + input title: "", description: "${textVersion()}\n${textCopyright()}", displayDuringSetup: false, type: "paragraph", element: "paragraph" +} + +metadata { + definition (name: "Eight Sleep Mattress", namespace: "alyc100", author: "Alex Lee Yuk Cheung") { + capability "Actuator" + capability "Contact Sensor" + capability "Polling" + capability "Refresh" + capability "Sensor" + capability "Switch" + capability "Switch Level" + capability "Battery" + + command "setHeatDuration" + command "heatingDurationDown" + command "heatingDurationUp" + command "levelUp" + command "levelDown" + command "setNewLevelValue" + } + + + simulator { + // TODO: define status and reply messages here + } + + tiles (scale: 2){ + multiAttributeTile(name:"switch", type: "generic", width: 6, height: 6, canChangeIcon: true){ + tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { + attributeState "on", label:'${name}', action:"switch.off", icon:"st.Bedroom.bedroom12", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "off", label:'${name}', action:"switch.on", icon:"st.Bedroom.bedroom12", backgroundColor:"#ffffff", nextState:"turningOn" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.Bedroom.bedroom12", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.Bedroom.bedroom12", backgroundColor:"#ffffff", nextState:"turningOn" + attributeState "offline", label:'${name}', icon:"st.Bedroom.bedroom12", backgroundColor:"#ff0000" + } + tileAttribute ("device.desiredLevel", key: "SLIDER_CONTROL") { + attributeState "desiredLevel", action:"setNewLevelValue", range:"(10..100)" + } + tileAttribute ("timer", key: "SECONDARY_CONTROL") { + attributeState "timer", label:'${currentValue} left' + } + } + + standardTile("presence", "device.contact", width: 2, height: 2) { + state("closed", label:'In Bed', icon:"st.Bedroom.bedroom2", backgroundColor:"#79b821") + state("open", label:'Out Of Bed', icon:"st.Bedroom.bedroom6", backgroundColor:"#ffa81e") + } + + standardTile("switch_mini", "device.switch", width: 2, height: 2) { + state( "on", label:'${name}', action:"switch.off", icon:"st.Bedroom.bedroom12", backgroundColor:"#79b821") + state( "off", label:'${name}', action:"switch.on", icon:"st.Bedroom.bedroom12", backgroundColor:"#ffffff") + state( "offline", label:'${name}', icon:"st.Bedroom.bedroom12", backgroundColor:"#ff0000") + } + + valueTile("currentHeatLevel", "device.currentHeatLevel", width: 2, height: 2){ + state "default", label: '${currentValue}', unit:"%", + backgroundColors:[ + [value: 0, color: "#153591"], + [value: 10, color: "#44b621"], + [value: 30, color: "#f1d801"], + [value: 60, color: "#d04e00"], + [value: 100, color: "#bc2323"] + ] + } + + standardTile("network", "device.network", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("default", label:'unknown', icon: "st.unknown.unknown.unknown") + state ("Connected", label:'Online', icon: "st.Health & Wellness.health9", backgroundColor: "#79b821") + state ("Not Connected", label:'Offline', icon: "st.Health & Wellness.health9", backgroundColor: "#bc2323") + } + + standardTile("refresh", "device.switch", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false, decoration: "flat") { + state("default", label:'refresh', action:"refresh.refresh", icon:"st.secondary.refresh-icon") + } + + valueTile("heatingDuration", "device.heatingDuration", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state("default", label:'${currentValue}') + } + + valueTile("level", "device.desiredLevel", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label: '${currentValue}%', unit:"%", + backgroundColors:[ + [value: 0, color: "#153591"], + [value: 10, color: "#44b621"], + [value: 30, color: "#f1d801"], + [value: 60, color: "#d04e00"], + [value: 100, color: "#bc2323"] + ] + } + + standardTile("levelUp", "device.levelUp", width: 1, height: 1, canChangeIcon: false, inactiveLabel: false, decoration: "flat") { + state "levelUp", label:' ', action:"levelUp", icon:"st.thermostat.thermostat-up", backgroundColor:"#ffffff" + } + + standardTile("levelDown", "device.levelDown", width: 1, height: 1, canChangeIcon: false, inactiveLabel: false, decoration: "flat") { + state "levelDown", label:' ', action:"levelDown", icon:"st.thermostat.thermostat-down", backgroundColor:"#ffffff" + } + + valueTile("status", "device.status", inactiveLabel: false, decoration: "flat", width: 4, height: 1) { + state("default", label:'${currentValue}') + } + + valueTile("version", "device.version", inactiveLabel: false, decoration: "flat", width: 4, height: 1) { + state("default", label:'${currentValue}') + } + + standardTile("heatingDurationUp", "device.heatingDurationUp", width: 1, height: 1, canChangeIcon: false, inactiveLabel: false, decoration: "flat") { + state "heatingDurationUp", label:' ', action:"heatingDurationUp", icon:"st.thermostat.thermostat-up", backgroundColor:"#ffffff" + } + + standardTile("heatingDurationDown", "device.heatingDurationDown", width: 1, height: 1, canChangeIcon: false, inactiveLabel: false, decoration: "flat") { + state "heatingDurationDown", label:' ', action:"heatingDurationDown", icon:"st.thermostat.thermostat-down", backgroundColor:"#ffffff" + } + + valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) { + state "battery", label:'${currentValue}', unit:"", + backgroundColors:[ + [value: 0, color: "#dddddd"], + [value: 1, color: "#fd5e53"], + [value: 65, color: "#fd5e53"], + [value: 66, color: "#d1e231"], + [value: 82, color: "#d1e231"], + [value: 83, color: "#00e2b1"], + [value: 100, color: "#00e2b1"] + ] + } + + htmlTile(name:"chartHTML", action: "getImageChartHTML", width: 6, height: 4, whiteList: ["www.gstatic.com", "raw.githubusercontent.com"]) + htmlTile(name:"sleepScoreHTML", action: "getSleepScoreHTML", width: 2, height: 2, whiteList: ["fonts.gstatic.com", "fonts.googleapis.com", "www.gstatic.com", "raw.githubusercontent.com"]) + + main(["switch"]) + details(["switch", "levelUp", "level", "heatingDuration", "heatingDurationUp", "levelDown", "heatingDurationDown", "currentHeatLevel", "presence", "sleepScoreHTML", "chartHTML", "network", "refresh", "status" ]) + + } +} + +mappings { + path("/getImageChartHTML") {action: [GET: "getImageChartHTML"]} + path("/getSleepScoreHTML") {action: [GET: "getSleepScoreHTML"]} +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + +} + +// handle commands +def poll() { + log.debug "Executing 'poll'" + def resp = parent.apiGET("/devices/${device.deviceNetworkId.tokenize("/")[0]}?offlineView=true") + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + setOffline() + return [] + } + if ((!resp.data.result.online) || (!resp.data.result.sensorInfo.connected)) { + setOffline() + return [] + } + sendEvent(name: 'network', value: "Connected" as String) + def currentHeatLevel = 0 + def nowHeating = false + def targetHeatingLevel = 0 + def presenceStart = 0 + def presenceEnd = 0 + def timer = 0 + state.isOwner = (device.deviceNetworkId.tokenize("/")[1] == resp.data.result.ownerId) + if (device.deviceNetworkId.tokenize("/")[1] == resp.data.result.leftUserId) { + state.bedSide = "left" + nowHeating = resp.data.result.leftNowHeating ? true : false + currentHeatLevel = resp.data.result.leftHeatingLevel as Integer + if (nowHeating) { + timer = resp.data.result.leftHeatingDuration + } + targetHeatingLevel = resp.data.result.leftTargetHeatingLevel as Integer + presenceStart = resp.data.result.leftPresenceStart as Integer + presenceEnd = resp.data.result.leftPresenceEnd as Integer + + } else { + state.bedSide = "right" + nowHeating = resp.data.result.rightNowHeating ? true : false + currentHeatLevel = resp.data.result.rightHeatingLevel as Integer + if (nowHeating) { + timer = resp.data.result.rightHeatingDuration + } + targetHeatingLevel = resp.data.result.rightTargetHeatingLevel as Integer + presenceStart = resp.data.result.rightPresenceStart as Integer + presenceEnd = resp.data.result.rightPresenceEnd as Integer + } + sendEvent(name: "switch", value: nowHeating ? "on" : "off") + sendEvent(name: "level", value: targetHeatingLevel) + + state.desiredLevel = targetHeatingLevel as Integer + sendEvent(name: "desiredLevel", "value": state.desiredLevel, unit: "%", displayed: false) + + sendEvent(name: "currentHeatLevel", value: currentHeatLevel) + addCurrentHeatLevelToHistoricalArray(currentHeatLevel) + def formattedTime = convertSecondsToString(timer) + sendEvent(name: "timer", value: formattedTime, descriptionText: "Heating Timer ${formattedTime}", displayed: true) + if (!state.heatingDuration) { + state.heatingDuration = 180 + sendEvent("name":"heatingDuration", "value": convertSecondsToString(state.heatingDuration * 60), displayed: false) + } + def df = new java.text.SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss") + if (getTimeZone()) { df.setTimeZone(location.timeZone) } + sendEvent(name: "status", value: "Last update:\n" + df.format(Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", resp.data.result.lastHeard)), displayed: false ) + sendEvent(name: "version", value: textVersion(), displayed: false) + + //BED PRESENCE LOGIC + def contactState = device.currentState("contact").getValue() + def currSwitchState = device.currentState("switch").getValue() + def heatDelta + if (currSwitchState == "on") { + heatDelta = currentHeatLevel - state.desiredLevel + if (heatDelta >= 0) { + sendEvent(name: "desiredHeatLevelReached", value: "true", displayed: false, descriptionText: "Desired heat level has been reached.") + } + } else { + heatDelta = currentHeatLevel - 10 + sendEvent(name: "desiredHeatLevelReached", value: "false", displayed: false) + } + + //If 8slp flags bed sleep event, start sleep analysis process. + if (((state.lastPresenceStartValue) && (presenceStart != state.lastPresenceStartValue) && (!state.analyzeSleep) && (contactState == "open") && (!state.analyzeSleep)) || ((heatDelta > 0) && (contactState == "open") && (!state.analyzeSleep)) && ((state.heatLevelHistory[0] > state.heatLevelHistory[1]) && (state.heatLevelHistory[1] > state.heatLevelHistory[2]))) { + sendEvent(name: "8slp Event", value: "${app.label}", displayed: true, isStateChange: true, descriptionText: "Presence start event received from 8Slp.") + //Set recorded heat level on sleep + state.heatLevelOnSleep = currentHeatLevel + //Start sleep analysis to detect 'in bed' patterns. + startSleepAnalysis() + } + + //If 8slp flags bed left event, start wake up analysis process in 7 minutes time. + if (((state.lastPresenceEndValue) && (presenceEnd != state.lastPresenceEndValue) && (!state.analyzeWakeUp) && (contactState == "closed") && (!state.analyzeWakeUp)) || ((state.heatLevelHistory[1] - state.heatLevelHistory[0] >= 5) && (contactState == "closed") && (!state.analyzeWakeUp))) { + sendEvent(name: "8slp Event", value: "${app.label}", displayed: true, isStateChange: true, descriptionText: "Presence end event received from 8Slp.") + //Set recorded heat level on wake up event. + state.heatLevelOnWakeUp = currentHeatLevel + //Start wake up analysis to detect 'out of bed' patterns. + runIn(7*60, startWakeUpAnalysis) + } + + //Sleep analysis logic. When does device know someone is in bed? + if (state.analyzeSleep) { + //If bed temperature has risen or bed heat is above set level, likely someone is lying in bed. + if ((currentHeatLevel - state.heatLevelOnSleep >= 5) || ((heatDelta >= 8) && (currentHeatLevel >= 25))) { + setInBed() + stopSleepAnalysis() + unschedule('stopSleepAnalysis') + } + } + + //Wake up analysis logic. When does device know someone is actually out of bed? + if (state.analyzeWakeUp) { + //Has desired heat level been altered during sleep? + if (lastDesiredLevel != state.desiredLevel) { + state.desiredLevelChange = true + } + + //Does heatlevel changes find any patterns? + if (state.lastCurrentHeatLevel) { + //Check for substantial bed heat loss, assume this warm body has left bed. + if ((state.heatLevelHistory[0] < state.heatLevelHistory[1]) && (state.heatLevelHistory[1] < state.heatLevelHistory[2])) { + if ((contactState == "closed") && ((state.heatLevelOnWakeUp - currentHeatLevel) >= (currentHeatLevel * 0.15))) { + setOutOfBed() + stopWakeUpAnalysis() + unschedule('stopWakeUpAnalysis') + } + } + } + + //If bed is this cool, then someone must have left + if ((currSwitchState == "off") && (currentHeatLevel <= 15)) { + setOutOfBed() + stopWakeUpAnalysis() + unschedule('stopWakeUpAnalysis') + } + } else { + //Fast heat loss or current heat level matches desired heat level, assume nobody is present + if (contactState == "closed") { + if (((state.heatLevelHistory[0] < state.heatLevelHistory[1]) && (state.heatLevelHistory[1] < state.heatLevelHistory[2]) && (state.heatLevelHistory[2] < state.heatLevelHistory[3])) && ((currentHeatLevel < 25) || ((heatDelta <= 5) && (heatDelta > -5)) || ((state.heatLevelHistory[3] - state.heatLevelHistory[0]) >= ((state.heatLevelHistory[0] * 0.15) as Integer)))) { + setOutOfBed() + } + } + } + + state.lastCurrentHeatLevel = currentHeatLevel + state.lastDesiredLevel = state.desiredLevel + state.lastPresenceStartValue = presenceStart + state.lastPresenceEndValue = presenceEnd + addHistoricalSleepToChartData() +} + +def installed() { + sendEvent(name: "contact", value: "open") +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} + +def setInBed() { + sendEvent(name: "contact", value: "closed", descriptionText: "Is In Bed", displayed: true) + unschedule('getLatestSleepScore') +} + +def setOutOfBed() { + sendEvent(name: "contact", value: "open", descriptionText: "Is Out Of Bed", displayed: true) + state.desiredLevelChange = false + runIn(1800, getLatestSleepScore) +} + +// Start wake up analysis logic over the next 37 minutes +def startWakeUpAnalysis() { + log.debug "Starting wake up analysis" + state.analyzeWakeUp = true + unschedule('stopWakeUpAnalysis') + runIn(37*60, stopWakeUpAnalysis) +} + +def stopWakeUpAnalysis() { + log.debug "Stopping wake up analysis" + state.analyzeWakeUp = false +} + +// Start sleep analysis logic over the next 17 minutes +def startSleepAnalysis() { + log.debug "Starting sleep analysis" + state.analyzeSleep = true + unschedule('stopSleepAnalysis') + runIn(17*60, stopSleepAnalysis) +} + +def stopSleepAnalysis() { + log.debug "Stopping sleep analysis" + state.analyzeSleep = false +} + +def on() { + log.debug "Executing 'on'" + // TODO: handle 'on' command + def body + def currSwitchState = device.currentState("switch").getValue() + def duration = state.heatingDuration * 60 + if (currSwitchState == "off") { + if (state.bedSide && state.bedSide == "left") { + body = [ + "leftHeatingDuration": "${duration}" + ] + } else { + body = [ + "rightHeatingDuration": "${duration}" + ] + } + } + parent.apiPUT("/devices/${device.deviceNetworkId.tokenize("/")[0]}", body) + runIn(3, refresh) +} + +def off() { + log.debug "Executing 'off'" + // TODO: handle 'off' command + def body + def currSwitchState = device.currentState("switch").getValue() + if (currSwitchState == "on") { + if (state.bedSide && state.bedSide == "left") { + body = [ + "leftHeatingDuration": 0 + ] + } else { + body = [ + "rightHeatingDuration": 0 + ] + } + } + parent.apiPUT("/devices/${device.deviceNetworkId.tokenize("/")[0]}", body) + runIn(3, refresh) +} + +def setLevel(percent) { + log.debug "Executing 'setLevel' with percent $percent" + // TODO: handle 'setLevel' command + if (percent < 10) { + percent = 10 + } + if (percent > 100) { + percent = 100 + } + def currSwitchState = device.currentState("switch").getValue() + def body + if (state.bedSide && state.bedSide == "left") { + if (currSwitchState == "on") { + body = [ + "leftTargetHeatingLevel": percent + ] + } else { + body = [ + "leftTargetHeatingLevel": percent, + "leftHeatingDuration": "${state.heatingDuration * 60}" + ] + } + } else { + if (currSwitchState == "on") { + body = [ + "rightTargetHeatingLevel": percent + ] + } else { + body = [ + "rightTargetHeatingLevel": percent, + "rightHeatingDuration": "${state.heatingDuration * 60}" + ] + } + } + parent.apiPUT("/devices/${device.deviceNetworkId.tokenize("/")[0]}", body) + sendEvent(name: "level", value: percent) + runIn(4, refresh) +} + +def levelUp() { + log.debug "Executing 'levelUp'" + def currentLevel = getLevel() as Integer + def newLevel = (currentLevel + 10) - (currentLevel % 10) + if (newLevel > 100) { + newLevel = 100 + } + setNewLevelValue(newLevel) +} + +def levelDown() { + log.debug "Executing 'levelDown'" + def currentLevel = getLevel() as Integer + def newLevel = (currentLevel - 10) - (currentLevel % 10) + if (newLevel < 10) { + newLevel = 10 + } + setNewLevelValue(newLevel) +} + +def getLevel() { + return state.desiredLevel == null ? device.currentValue("level") : state.desiredLevel +} + +def setLevelToDesired() { + setLevel(state.newLevel) +} + +def setNewLevelValue(newLevelValue) { + log.debug "Executing 'setNewLevelValue' with value $newLevelValue" + unschedule('setLevelToDesired') + state.newLevel = newLevelValue + state.desiredLevel = state.newLevel + sendEvent("name":"desiredLevel", "value": state.desiredLevel, displayed: true) + log.debug "Setting level up to: ${state.newLevel}" + runIn(3, setLevelToDesired) +} + +//Commands +def setHeatDuration(minutes) { + log.debug "Executing 'setHeatDuration with length $minutes minutes'" + if (minutes < 10) { + minutes = 10 + } + if (minutes > 600) { + minutes = 600 + } + state.heatingDuration = minutes + sendEvent("name":"heatingDuration", "value": convertSecondsToString(state.heatingDuration * 60), displayed: false) +} + +def heatingDurationDown() { + log.debug "Executing 'heatingDurationDown'" + //Round down result + def newHeatDurationLength = (state.heatingDuration - 15) - (state.heatingDuration % 15) + setHeatDuration(newHeatDurationLength) +} + +def heatingDurationUp() { + log.debug "Executing 'heatingDurationUp'" + //Round down result + def newHeatDurationLength = (state.heatingDuration + 15) - (state.heatingDuration % 15) + setHeatDuration(newHeatDurationLength) +} + +def setOffline() { + sendEvent(name: 'network', value: "Not Connected" as String) + sendEvent(name: "switch", value: "offline") +} + +//Helper methods +def convertSecondsToString(seconds) { + def hour = (seconds / 3600) as Integer + def minute = (seconds - (hour * 3600)) / 60 as Integer + + def hourString = (hour < 10) ? "0$hour" : "$hour" + def minuteString = (minute < 10) ? "0$minute" : "$minute" + + return "${hourString}hr:${minuteString}mins" +} + +def getTimeZone() { + def tz = null + if(location?.timeZone) { tz = location?.timeZone } + if(!tz) { log.warn "No time zone has been retrieved from SmartThings. Please try to open your ST location and press Save." } + return tz +} + +def addCurrentHeatLevelToHistoricalArray(heatLevel) { + if (!state.heatLevelHistory) state.heatLevelHistory = [heatLevel, heatLevel, heatLevel, heatLevel, heatLevel] + state.heatLevelHistory.add(0, heatLevel) + state.heatLevelHistory.pop() +} + +//Chart data rendering +def getHistoricalSleepData(fromDate, toDate) { + def result = "" + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (getTimeZone()) { df.setTimeZone(location.timeZone) } + if (state.isOwner) { + result = parent.apiGET("/users/${device.deviceNetworkId.tokenize("/")[1]}/trends?tz=${URLEncoder.encode(getTimeZone().getID())}&from=${df.format(fromDate)}&to=${df.format(toDate)}") + } else if (parent.partnerAuthenticated()) { + result = parent.apiGETWithPartner("/users/${device.deviceNetworkId.tokenize("/")[1]}/trends?tz=${URLEncoder.encode(getTimeZone().getID())}&from=${df.format(fromDate)}&to=${df.format(toDate)}") + } + result +} + +def getLatestSleepScore() { + def sleepScore = 0 + log.debug "Executing 'getLatestSleepScore'" + def date = new Date() + def resp = getHistoricalSleepData((date - 1), date) + if (resp == "" || resp.status == 403) { + log.error("Cannot access sleep data for partner.") + } else if (resp.status != 200) { + log.error("Unexpected result in addHistoricalSleepToChartData(): [${resp.status}] ${resp.data}") + } + else { + def days = resp.data.days + if (days.size() > 0) { + sleepScore = days[days.size()-1].score as Integer + } + } + sendEvent(name: 'battery', value: sleepScore, displayed: true, descriptionText: "Your sleep score is ${sleepScore}.") +} + +def addHistoricalSleepToChartData() { + def date = new Date() + def resp = getHistoricalSleepData((date - 6), date) + if (resp == "" || resp.status == 403) { + log.error("Cannot access sleep data for partner.") + state.chartData = "UNAVAILABLE" + } else if (resp.status != 200) { + log.error("Unexpected result in addHistoricalSleepToChartData(): [${resp.status}] ${resp.data}") + } + else { + def days = resp.data.days + state.chartData = [0, 0, 0, 0, 0, 0, 0] + state.chartData2 = [0, 0, 0, 0, 0, 0, 0] + state.chartData3 = [0, 0, 0, 0, 0, 0, 0] + if (days.size() > 0) { + 0.upto(days.size() - 1, { + def dayIndex = (date - Date.parse("yyyy-MM-dd", days[it].day)) + state.chartData[dayIndex] = days[it].sleepDuration / 3600 + state.chartData2[dayIndex] = days[it].presenceDuration / 3600 + state.chartData3[dayIndex] = days[it].score + }) + } + } +} + +def getImageChartHTML() { + try { + def date = new Date() + if (state.chartData == null) { + state.chartData = [0, 0, 0, 0, 0, 0, 0] + } + def hData + if (state.chartData == "UNAVAILABLE") { + hData = """ +
+

Sleep data unavailable for partner user.

+

Open the Eight Sleep (Connect) app to enter partner side credentials.

+
+ """ + } + else { + if (state.chartData2 == null) { + state.chartData2 = [0, 0, 0, 0, 0, 0, 0] + } + if (state.chartData3 == null) { + state.chartData3 = [0, 0, 0, 0, 0, 0, 0] + } + def a = state.chartData.max() + def b = state.chartData2.max() + def topValue = a > b ? a : b + hData = """ +

Historical Data


+
+ +
+ """ + } + def mainHtml = """ + + + + + + + + + + + + + + ${hData} + + + """ + render contentType: "text/html", data: mainHtml, status: 200 + } + catch (ex) { + log.error "getImageChartHTML Exception:", ex + } +} + +def getSleepScoreHTML() { + try { + if (device.currentState("battery") == null) { getLatestSleepScore() } + def sleepScore = device.currentState("battery").getValue() as Integer + def backgroundColor = "ddd" + if (sleepScore > 0) { backgroundColor = "fd5e53" } + if (sleepScore > 66) { backgroundColor = "d1e231" } + if (sleepScore > 83) { backgroundColor = "00e2b1" } + + def mainHtml = """ + + + + + + + + + + + + + + + +
+
Sleep Score
${sleepScore}
+
+ + + """ + render contentType: "text/html", data: mainHtml, status: 200 + } + catch (ex) { + log.error "getSleepScoreHTML Exception:", ex + } +} + +def getCssData() { + def cssData = null + def htmlInfo + state.cssData = null + + if(htmlInfo?.cssUrl && htmlInfo?.cssVer) { + if(state?.cssData) { + if (state?.cssVer?.toInteger() == htmlInfo?.cssVer?.toInteger()) { + //LogAction("getCssData: CSS Data is Current | Loading Data from State...") + cssData = state?.cssData + } else if (state?.cssVer?.toInteger() < htmlInfo?.cssVer?.toInteger()) { + //LogAction("getCssData: CSS Data is Outdated | Loading Data from Source...") + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + //LogAction("getCssData: CSS Data is Missing | Loading Data from Source...") + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state?.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + //LogAction("getCssData: No Stored CSS Info Data Found for Device... Loading for Static URL...") + cssData = getFileBase64(cssUrl(), "text", "css") + } + return cssData +} + +def getFileBase64(url, preType, fileType) { + try { + def params = [ + uri: url, + contentType: '$preType/$fileType' + ] + httpGet(params) { resp -> + if(resp.data) { + def respData = resp?.data + ByteArrayOutputStream bos = new ByteArrayOutputStream() + int len + int size = 4096 + byte[] buf = new byte[size] + while ((len = respData.read(buf, 0, size)) != -1) + bos.write(buf, 0, len) + buf = bos.toByteArray() + //LogAction("buf: $buf") + String s = buf?.encodeBase64() + //LogAction("resp: ${s}") + return s ? "data:${preType}/${fileType};base64,${s.toString()}" : null + } + } + } + catch (ex) { + log.error "getFileBase64 Exception:", ex + } +} + +def cssUrl() { return "https://raw.githubusercontent.com/desertblade/ST-HTMLTile-Framework/master/css/smartthings.css" } + +private def textVersion() { + def text = "Eight Sleep Mattress\nVersion: 1.0\nDate: 26012017(1130)" +} + +private def textCopyright() { + def text = "Copyright © 2017 Alex Lee Yuk Cheung" +} + diff --git a/devicetypes/alyc100/empty.png b/devicetypes/alyc100/empty.png new file mode 100644 index 00000000000..50a672ff516 Binary files /dev/null and b/devicetypes/alyc100/empty.png differ diff --git a/devicetypes/alyc100/hive-active-light-colour-tuneable.src/hive-active-light-colour-tuneable.groovy b/devicetypes/alyc100/hive-active-light-colour-tuneable.src/hive-active-light-colour-tuneable.groovy new file mode 100644 index 00000000000..74db7703251 --- /dev/null +++ b/devicetypes/alyc100/hive-active-light-colour-tuneable.src/hive-active-light-colour-tuneable.groovy @@ -0,0 +1,289 @@ +/** + * Hive Active Light Colour Tunable + * + * Copyright 2016 Tom Beech / Alex Lee Yuk Cheung + * + * 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. + * + * 30.10.2017 - v3.0 Update Hive Colour Bulb device to support new Hive Beekeeper API + * 1.11.2017 - v3.0b Bug fix. Typo for colour change HTTP request. + */ + +metadata { + definition (name: "Hive Active Light Colour Tuneable", namespace: "alyc100", author: "Tom Beech") { + capability "Polling" + capability "Switch" + capability "Switch Level" + capability "Refresh" + capability "Color Control" + capability "Color Temperature" + capability "Actuator" + capability "Sensor" + + attribute "colorName", "string" + } + + simulator { + } + + tiles (scale: 2){ + 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:"http://hosted.lifx.co/smartthings/v1/196xOn.png", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "off", label:'${name}', action:"switch.on", icon:"http://hosted.lifx.co/smartthings/v1/196xOff.png", backgroundColor:"#ffffff", nextState:"turningOn" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"http://hosted.lifx.co/smartthings/v1/196xOn.png", backgroundColor:"#fffA62", nextState:"turningOff" + attributeState "turningOff", label:'${name}', action:"switch.on", icon:"http://hosted.lifx.co/smartthings/v1/196xOff.png", backgroundColor:"#fffA62", nextState:"turningOn" + } + tileAttribute ("device.level", key: "SLIDER_CONTROL") { + attributeState "level", action:"switch level.setLevel" + } + tileAttribute ("device.group", key: "SECONDARY_CONTROL") { + attributeState "group", label: '${currentValue}' + } + tileAttribute ("device.color", key: "COLOR_CONTROL") { + attributeState "color", action:"setColor" + } + } + + standardTile("refresh", "device.switch", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + + main(["switch"]) + details(["switch", "refresh"]) + } +} + +//parse events into attributes +def parse(value) { + log.debug "Parsing '${value}' for ${device.deviceNetworkId}" +} + +def setColorTemperature(value) { + log.debug "Executing 'setColorTemperature' to ${value}" + + def args = ["colourMode":"WHITE","colourTemperature":value] + def resp = parent.apiPOST("/nodes/colourtuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: "colorTemperature", value: value) + sendEvent(name: "hue", value: 0) + sendEvent(name: "saturation", value: 0) + } +} + +def setLevel(double value) { + + def val = String.format("%.0f", value) + + log.debug "Setting level to $val" + + def onOff = "ON" + if(val == 0) { + onOff = "OFF" + } + + def args = [status: onOff, brightness: val] + def resp = parent.apiPOST("/nodes/colourtuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: 'level', value: val) + sendEvent(name: 'switch', value: onOff.toLowerCase()) + log.debug "Level set" + } +} + +def setSaturation(percent) { + log.debug "setSaturation($percent)" + setColor(saturation: percent) +} + +def setHue(value) { + log.debug "setHue($value)" + setColor(hue: value) +} + +def setColor(value) { + def result = [] + log.debug "setColor: ${value}" + + def hue = 0 + def sat = 0 + + // If a HEX value was passed, just convert it and set the HSV values + if (value.hex) { + def c = value.hex.findAll(/[0-9a-fA-F]{2}/).collect { Integer.parseInt(it, 16) } + + // Set the desired colours + def res = rgbToHSV(c[0],c[1],c[2]) + + hue = res.hue + sat = res.saturation + + // Otherwise, determine which values have been passed and set up the HSV values + } else { + hue = value.hue ?: device.currentValue("hue") + saturation = value.saturation ?: device.currentValue("saturation") + if(hue == null) hue = 13 + if(saturation == null) saturation = 13 + } + + log.debug "Hue: ${hue}" + log.debug "Sat: ${sat}" + + // SEND HTTP COMMAND TO SET COLOUR + def args = ["colourMode":"COLOUR","hue":hue, "saturation": sat ] + def resp = parent.apiPOST("/nodes/colourtuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + if(value.hex) sendEvent(name: "color", value: value.hex) + if(value.hue) sendEvent(name: "hue", value: value.hue) + if(value.saturation) sendEvent(name: "saturation", value: value.saturation) + if(value.switch) sendEvent(name: "switch", value: value.switch) + } +} + +def on() { + def args = [status: "ON"] + def resp = parent.apiPOST("/nodes/colourtuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: 'switch', value: "on") + } +} + +def off() { + + def args = [status: "OFF"] + def resp = parent.apiPOST("/nodes/colourtuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: 'switch', value: "off") + } +} + +def installed() { + log.debug "Executing 'installed'" +} + +def refresh() { + poll() +} + +def poll() { + log.debug "Executing 'poll'" + def currentDevice = parent.getDeviceStatus(device.deviceNetworkId) + if (currentDevice == []) { + return [] + } + log.debug "$device.name status: $currentDevice" + def state = currentDevice.state.status + def temperature = currentDevice.state.colourTemperature + def brightness = currentDevice.state.brightness + def presence = currentDevice.props.online + def hsvHue = currentDevice.state.hue + def hsvSat = currentDevice.state.saturation + def hsvValue = currentDevice.state.value + + + //brightness = String.format("%.0f", brightness) + //temperature = String.format("%.0f", temperature) + + log.debug "State: $state" + log.debug "Temperature: $temperature" + log.debug "Brightness: $brightness" + log.debug "Presence: $presence" + log.debug "HSV (Hue): $hsvHue" + log.debug "HSV (Sat): $hsvSat" + + if(presence == "ABSENT") { + // Bulb is not present (i.e. turned off at the switch or removed) + sendEvent(name: 'switch', value: "off") + } else { + sendEvent(name: 'switch', value: state.toLowerCase()) + } + + sendEvent(name: 'level', value: brightness) + sendEvent(name: "hue", value: hsvHue) + sendEvent(name: "saturation", value: hsvSat) + + return; +} + +def rgbToHSV(r, g, b) { + + double h, s, v; + + double min, max, delta; + + min = Math.min(Math.min(r, g), b); + max = Math.max(Math.max(r, g), b); + + // V + v = max; + + delta = max - min; + + // S + if( max != 0 ) + s = delta / max; + else { + s = 0; + h = -1; + return [hue: h, saturation: s * 100, value: v] + } + + // H + if( r == max ) + h = ( g - b ) / delta; // between yellow & magenta + else if( g == max ) + h = 2 + ( b - r ) / delta; // between cyan & yellow + else + h = 4 + ( r - g ) / delta; // between magenta & cyan + + h *= 60; // degrees + + if( h < 0 ) + h += 360; + + [hue: h, saturation: s * 100, value: v] +} + +def huesatToRGB(float hue, float sat) { + while(hue >= 100) hue -= 100 + int h = (int)(hue / 100 * 6) + float f = hue / 100 * 6 - h + int p = Math.round(255 * (1 - (sat / 100))) + int q = Math.round(255 * (1 - (sat / 100) * f)) + int t = Math.round(255 * (1 - (sat / 100) * (1 - f))) + switch (h) { + case 0: return [255, t, p] + case 1: return [q, 255, p] + case 2: return [p, 255, t] + case 3: return [p, q, 255] + case 4: return [t, p, 255] + case 5: return [255, p, q] + } +} \ No newline at end of file diff --git a/devicetypes/alyc100/hive-active-light-tuneable.src/hive-active-light-tuneable.groovy b/devicetypes/alyc100/hive-active-light-tuneable.src/hive-active-light-tuneable.groovy new file mode 100644 index 00000000000..07b45035b7c --- /dev/null +++ b/devicetypes/alyc100/hive-active-light-tuneable.src/hive-active-light-tuneable.groovy @@ -0,0 +1,206 @@ +/** + * Hive Active Light Tuneable + * + * Copyright 2016 Tom Beech + * + * 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. + * + * 23.11.16 - Made change to ensure that setting the brightness higher than 1 also sends the 'ON' command. Some smartapps turn bulbs on by setting the brightness to >0 + * 23.11.16 - Fixed setLevel so that it updates the devices switch state if it turned the light on or off + * 24.11.16 - Added support for when a bulb is physically powered off + * 30.05.17 - Updated for Hive Beekeeper API + * 31.05.17 - Bug fix. Refresh bug prevents installation of Hive devices + * + * 30.10.2017 + * v3.0 - Support for Hive Active Light Colour Tuneable device. + */ + +metadata { + definition (name: "Hive Active Light Tuneable", namespace: "alyc100", author: "Tom Beech") { + capability "Polling" + capability "Switch" + capability "Switch Level" + capability "Refresh" + capability "Color Temperature" + capability "Actuator" + capability "Sensor" + + attribute "colorName", "string" + } + + simulator { + } + + tiles (scale: 2){ + 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:"http://hosted.lifx.co/smartthings/v1/196xOn.png", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "off", label:'${name}', action:"switch.on", icon:"http://hosted.lifx.co/smartthings/v1/196xOff.png", backgroundColor:"#ffffff", nextState:"turningOn" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"http://hosted.lifx.co/smartthings/v1/196xOn.png", backgroundColor:"#fffA62", nextState:"turningOff" + attributeState "turningOff", label:'${name}', action:"switch.on", icon:"http://hosted.lifx.co/smartthings/v1/196xOff.png", backgroundColor:"#fffA62", nextState:"turningOn" + } + tileAttribute ("device.level", key: "SLIDER_CONTROL") { + attributeState "level", action:"switch level.setLevel" + } + tileAttribute ("device.group", key: "SECONDARY_CONTROL") { + attributeState "group", label: '${currentValue}' + } + } + + valueTile("colorName", "device.colorName", height: 2, width: 4, inactiveLabel: false, decoration: "flat") { + state "colorName", label: '${currentValue}' + } + controlTile("colorTempSliderControl", "device.colorTemperature", "slider", height: 1, width: 6, inactiveLabel: false, range:"(2700..6500)") { + state "colorTemp", action:"color temperature.setColorTemperature" + } + valueTile("colorTemp", "device.colorTemperature", inactiveLabel: false, decoration: "flat", height: 2, width: 2) { + state "colorTemp", label: '${currentValue}K' + } + standardTile("refresh", "device.switch", width: 1, height: 1, inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + + main(["switch"]) + details(["switch", "levelSliderControl", "colorTempSliderControl", "colorTemp", "colorName", "refresh"]) + } +} + +//parse events into attributes +def parse(value) { + log.debug "Parsing '${value}' for ${device.deviceNetworkId}" +} + +private getGenericName(value){ + def genericName = "Warm White" + if(value < 2750){ + genericName = "Extra Warm White" + } else if(value < 3300){ + genericName = "Warm White" + } else if(value < 4150){ + genericName = "Moonlight" + } else if(value < 5000){ + genericName = "Daylight" + } else if(value < 6500){ + genericName = "Cool Light" + } + + genericName +} + +def setColorTemperature(value) { + log.debug "Executing 'setColorTemperature' to ${value}" + + def genericName = getGenericName(value) + + def args = ["colourMode":"WHITE","colourTemperature":value] + def resp = parent.apiPOST("/nodes/tuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: "colorTemperature", value: value) + sendEvent(name: "colorName", value: genericName) + } +} + +def setLevel(double value) { + + def val = String.format("%.0f", value) + + log.debug "Setting level to $val" + + def onOff = "ON" + if(val == 0) { + onOff = "OFF" + } + + def args = [status: onOff, brightness: val] + def resp = parent.apiPOST("/nodes/tuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: 'level', value: val) + sendEvent(name: 'switch', value: onOff.toLowerCase()) + log.debug "Level set" + } +} + +def on() { + def args = [status: "ON"] + def resp = parent.apiPOST("/nodes/tuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: 'switch', value: "on") + } +} + +def off() { + + def args = [status: "OFF"] + def resp = parent.apiPOST("/nodes/tuneablelight/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: 'switch', value: "off") + } +} + +def installed() { + log.debug "Executing 'installed'" +} + +def refresh() { + poll() +} + +def poll() { + log.debug "Executing 'poll'" + def currentDevice = parent.getDeviceStatus(device.deviceNetworkId) + if (currentDevice == []) { + return [] + } + log.debug "$device.name status: $currentDevice" + + def state = currentDevice.state.status + def temperature = currentDevice.state.colourTemperature + def brightness = currentDevice.state.brightness + def presence = currentDevice.props.online + + //brightness = String.format("%.0f", brightness) + //temperature = String.format("%.0f", temperature) + + log.debug "State: $state" + log.debug "Temperature: $temperature" + log.debug "Brightness: $brightness" + log.debug "Presence: $presence" + + if(presence == "ABSENT") { + // Bulb is not present (i.e. turned off at the switch or removed) + sendEvent(name: 'switch', value: "off") + } else { + sendEvent(name: 'switch', value: state.toLowerCase()) + } + + sendEvent(name: 'level', value: brightness) + + def genericName = getGenericName(value) + sendEvent(name: "colorName", value: genericName) + sendEvent(name: "colorTemperature", value: temperature) + + return; +} \ No newline at end of file diff --git a/devicetypes/alyc100/hive-active-light.src/hive-active-light.groovy b/devicetypes/alyc100/hive-active-light.src/hive-active-light.groovy new file mode 100644 index 00000000000..818a72a9f5a --- /dev/null +++ b/devicetypes/alyc100/hive-active-light.src/hive-active-light.groovy @@ -0,0 +1,147 @@ +/** + * Hive Active Light V1.1b + * + * Copyright 2016 Tom Beech + * + * 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. + * + * 23.11.16 - Made change to ensure that setting the brightness higher than 1 also sends the 'ON' command. Some smartapps turn bulbs on by setting the brightness to >0 + * 23.11.16 - Fixed setLevel so that it updates the devices switch state if it turned the light on or off + * 24.11.16 - Added support for when a bulb is physicall powered off + * 30.05.17 - Update for Hive Beekeeper API + * 31.05.17 - Bug fix. Refresh bug prevents installation of Hive devices + + * 30.10.2017 + * v3.0 - Version refactor to reflect BeeKeeper API update. + */ + +metadata { + definition (name: "Hive Active Light", namespace: "alyc100", author: "Tom Beech") { + capability "Polling" + capability "Switch" + capability "Switch Level" + capability "Refresh" + capability "Actuator" + capability "Sensor" + } + + simulator { + } + + tiles (scale: 2){ + 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:"http://hosted.lifx.co/smartthings/v1/196xOn.png", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "off", label:'${name}', action:"switch.on", icon:"http://hosted.lifx.co/smartthings/v1/196xOff.png", backgroundColor:"#ffffff", nextState:"turningOn" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"http://hosted.lifx.co/smartthings/v1/196xOn.png", backgroundColor:"#fffA62", nextState:"turningOff" + attributeState "turningOff", label:'${name}', action:"switch.on", icon:"http://hosted.lifx.co/smartthings/v1/196xOff.png", backgroundColor:"#fffA62", nextState:"turningOn" + } + tileAttribute ("device.level", key: "SLIDER_CONTROL") { + attributeState "level", action:"switch level.setLevel" + } + tileAttribute ("device.group", key: "SECONDARY_CONTROL") { + attributeState "group", label: '${currentValue}' + } + } + + standardTile("refresh", "device.switch", width: 1, height: 1, inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + + main(["switch"]) + details(["switch", "levelSliderControl", "refresh"]) + } +} + +//parse events into attributes +def parse(value) { + log.debug "Parsing '${value}' for ${device.deviceNetworkId}" +} + +def setLevel(double value) { + + def val = String.format("%.0f", value) + + log.debug "Setting level to $val" + + def resp = parent.apiPOST("/nodes/warmwhitelight/${device.deviceNetworkId}", [brightness: val]) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + + sendEvent(name: 'level', value: val) + } +} + +def on() { + def resp = parent.apiPOST("/nodes/warmwhitelight/${device.deviceNetworkId}", [status: "ON"]) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: 'switch', value: "on") + } +} + +def off() { + + def resp = parent.apiPOST("/nodes/warmwhitelight/${device.deviceNetworkId}", [status: "OFF"]) + + if(resp.status == 404) { + // Bulb has reported it is offline, poll for more details + poll() + } else { + sendEvent(name: 'switch', value: "off") + } +} + +def installed() { + log.debug "Executing 'installed'" +} + +def refresh() { + poll() +} + +def poll() { + log.debug "Executing 'poll'" + def currentDevice = parent.getDeviceStatus(device.deviceNetworkId) + if (currentDevice == []) { + return [] + } + log.debug "$device.name status: $currentDevice" + + def state = currentDevice.state.status + def brightness = currentDevice.state.brightness + def presence = currentDevice.props.online + + //brightness = String.format("%.0f", brightness) + //temperature = String.format("%.0f", temperature) + + log.debug "State: $state" + log.debug "Brightness: $brightness" + log.debug "Presence: $presence" + + if(presence == "ABSENT") { + // Bulb is not present (i.e. turned off at the switch or removed) + sendEvent(name: 'switch', value: "off") + } else { + sendEvent(name: 'switch', value: state.toLowerCase()) + } + + if(brightness) { + sendEvent(name: 'level', value: brightness) + } + + return; +} \ No newline at end of file diff --git a/devicetypes/alyc100/hive-active-plug.src/hive-active-plug.groovy b/devicetypes/alyc100/hive-active-plug.src/hive-active-plug.groovy new file mode 100644 index 00000000000..3bfc8275555 --- /dev/null +++ b/devicetypes/alyc100/hive-active-plug.src/hive-active-plug.groovy @@ -0,0 +1,150 @@ +/** + * Hive Active Plug v1.1b + * + * Copyright 2016 Tom Beech + * + * 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. + * + * VERSION HISTORY + * + * 30.05.2017 v1.1 - Update for Hive Beekeeper API + * 31.05.2017 v1.1b - Bug fix. Refresh bug prevents installation of Hive devices. + * 30.10.2017 v3.0 Update Hive Colour Bulb device to support new Hive Beekeeper API + */ + +metadata { + definition (name: "Hive Active Plug", namespace: "alyc100", author: "Tom Beech") { + capability "Switch" + capability "Power Meter" + capability "Refresh" + capability "Polling" + + command "changeSwitchState", ["string"] + } + + simulator { + // TODO: define status and reply messages here + } + + tiles { + + standardTile("switch", "device.switch", width: 1, height: 1, canChangeIcon: true) { + state "on", label:'${name}', action:"switch.off", icon:"st.switches.switch.on", backgroundColor:"#79b821" + state "off", label:'${name}', action:"switch.on", icon:"st.switches.switch.off", backgroundColor:"#ffffff" + } + + valueTile("power", "device.power", decoration: "flat") { + state "default", label:'${currentValue} W' + } + + standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 1, height: 1) { + state("default", label:'refresh', action:"polling.poll", icon:"st.secondary.refresh-icon") + } + + valueTile("battery", "device.battery", inactiveLabel: false, decoration: "flat") { + state "battery", label:'${currentValue}% battery', unit:"" + } + + main (["switch", "power"]) + details (["switch", "power", "refresh"]) + } +} + +// parse events into attributes +def parse(String description) { + log.debug "parsing '${description}'" +} + + +def on() { + log.debug "Executing 'on'" + + def args = [status: "ON"] + def resp = parent.apiPOST("/nodes/activeplug/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Plug has reported it is offline, poll for more details + poll() + return + } + + sendEvent(name: "switch", value: "on"); + + runIn(5, "poll") +} + +def off() { + log.debug "Executing 'off'" + + def args = [status: "OFF"] + def resp = parent.apiPOST("/nodes/activeplug/${device.deviceNetworkId}", args) + + if(resp.status == 404) { + // Plug has reported it is offline, poll for more details + poll() + return + } + + sendEvent(name: "power", value: 0, unit: "W") + sendEvent(name: "switch", value: "off"); +} + +def changeSwitchState(newState) { + + log.trace "Received update that this switch is now $newState" + switch(newState) { + case 1: + sendEvent(name: "switch", value: "on") + runIn(5, "poll") + break; + case 0: + sendEvent(name: "switch", value: "off") + sendEvent(name: "power", value: 0, unit: "W") + break; + } +} + +def poll() { + log.debug "Executing 'poll'" + def currentDevice = parent.getDeviceStatus(device.deviceNetworkId) + if (currentDevice == []) { + return [] + } + log.debug "$device.name status: $currentDevice" + + def state = currentDevice.state.status + def powerConsumption = currentDevice.props.powerConsumption + def presence = currentDevice.props.presence + + log.debug "State: $state" + log.debug "Power Consumption: $powerConsumption" + log.debug "Presence: $presence" + + if(presence == "ABSENT") { + // Bulb is not present (i.e. turned off at the switch or removed) + sendEvent(name: 'switch', value: "off") + } else { + sendEvent(name: 'switch', value: state.toLowerCase()) + } + + // Set power consumption + log.debug "Setting power" + if(state == "OFF") { + sendEvent(name: "power", value: 0, unit: "W") + } else { + sendEvent(name: "power", value: powerConsumption, unit: "W") + } + log.debug "Power set" +} + +def refresh() { + log.debug "Executing 'refresh'" + poll(); +} \ No newline at end of file diff --git a/devicetypes/alyc100/hive-heating.src/hive-heating.groovy b/devicetypes/alyc100/hive-heating.src/hive-heating.groovy new file mode 100644 index 00000000000..9af1681daa8 --- /dev/null +++ b/devicetypes/alyc100/hive-heating.src/hive-heating.groovy @@ -0,0 +1,402 @@ +/** + * Hive Heating + * + * Copyright 2015 Alex Lee Yuk Cheung + * + * 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. + * + * + * VERSION HISTORY + * 25.02.2016 + * v2.0 BETA - Initial Release + * v2.1 - Introducing button temperature control via improved thermostat multi attribute tile. More responsive temperature control. + * Improve Boost button behaviour and look. + * v2.1.1 - Tweaks to temperature control responsiveness + * v2.1.2 - Minor tweaks to main display + * v2.1.3 - Allow changing of boost interval amount in device settings. + * v2.1.4 - Allow changing of boost temperature in device settings. + * v2.1.5 - Option to disable Hive Heating Device for summer. Disable mode stops any automation commands from other smart apps reactivating Hive Heating. + * v2.1.5b - Bug fix when desired heat set point is null, control stops working. + * v2.1.5c - Fix multitile button behaviour that has changed since ST app 2.1.0. Add colour code to temperature reporting in activity feed. + * v2.1.5d - Fix blank temperature readings on Android ST app + * v2.1.5e - Another attempt to fix blank temperature reading on Android. + * v2.1.5f - Allow decimal value for boost temperature. Changes to VALUE_CONTROL method to match latest ST docs. + * v2.1.5g - Changes to tile display for iOS app v2.1.2 + * + * 10.09.2016 + * v2.1.6 - Allow a maximum temperature threshold to be set. + * v2.1.6b - Added event for maximum temperature threshold breach. + * + * 30.05.2017 + * v2.2 - Updated to use new Beekeeper API - Huge thanks to Tom Beech! + * v2.2b - Bug fix. Refresh bug prevents installation of Hive devices. + * + * 30.10.2017 + * v3.0 - Version refactor to reflect BeeKeeper API update. + * + * 08.10.2017 + * v3.1 - New Smartthing App compatability + * + * 12.10.2020 + * v3.2 - Update Hive API URL + * + * 08.12.2020 + * v3.3 - Update UI for new SmartThings app + * v3.3a - Add missing set boost length command for WebCore. + * v3.3b - Tweak to boost length command. + */ +preferences +{ + input( "boostInterval", "number", title: "Boost Interval (minutes)", description: "Boost interval amount in minutes", required: false, defaultValue: 10 ) + input( "boostTemp", "decimal", title: "Boost Temperature (°C)", description: "Boost interval amount in Centigrade", required: false, defaultValue: 22, range: "5..32" ) + input( "maxTempThreshold", "decimal", title: "Max Temperature Threshold (°C)", description: "Set the maximum temperature threshold in Centigrade", required: false, defaultValue: 32, range: "5..32" ) + input( "disableDevice", "bool", title: "Disable Hive Heating?", required: false, defaultValue: false ) +} + +metadata { + definition (name: "Hive Heating", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.thermostat", mnmn: "fBZA", vid: "de5c3738-3ae6-38b7-adfd-bbd98cba41d1") { + capability "Actuator" + capability "Polling" + capability "Refresh" + capability "Temperature Measurement" + capability "Thermostat" + capability "Thermostat Heating Setpoint" + capability "Thermostat Mode" + capability "Thermostat Operating State" + capability "Health Check" + capability "tigerdrum36561.boostLabel" + capability "tigerdrum36561.boostLength" + + command "heatingSetpointUp" + command "heatingSetpointDown" + command "boostTimeUp" + command "boostTimeDown" + command "setThermostatMode" + command "setHeatingSetpoint" + command "setTemperatureForSlider" + command "setBoostLength" + } + + simulator { + // TODO: define status and reply messages here + } +} +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'temperature' attribute + // TODO: handle 'heatingSetpoint' attribute + // TODO: handle 'thermostatSetpoint' attribute + // TODO: handle 'thermostatMode' attribute + // TODO: handle 'thermostatOperatingState' attribute +} + +def installed() { + log.debug "Executing 'installed'" + state.boostLength = 60 + state.desiredHeatSetpoint = 7 + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +void updated() { + log.debug "Executing 'updated'" + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +// handle commands +def setHeatingSetpoint(temp) { + log.debug "Executing 'setHeatingSetpoint with temp $temp'" + def latestThermostatMode = device.latestState('thermostatMode') + + if (temp < 5) { + temp = 5 + } + if (temp > 32) { + temp = 32 + } + + if (settings.disableDevice == null || settings.disableDevice == false) { + //if thermostat is off, set to manual + def args + if (latestThermostatMode.stringValue == 'off') { + args = [ + mode: "SCHEDULE", target: temp + ] + + } + else { + // {"target":7.5} + args = [ + target: temp + ] + } + //def type = device.type; + def resp = parent.apiPOST("/nodes/heating/${device.deviceNetworkId}", args) + } + runIn(4, refresh) +} + +def setBoostLength(minutes) { + log.debug "Executing 'setBoostLength with length $minutes minutes'" + if (minutes < 5) { + minutes = 5 + } + if (minutes > 300) { + minutes = 300 + } + state.boostLength = minutes + sendEvent("name":"boostLength", "value": minutes, "unit": "minutes", displayed: true) +} + +def getBoostIntervalValue() { + if (settings.boostInterval == null) { + return 10 + } + return settings.boostInterval.toInteger() +} + +def getBoostTempValue() { + if (settings.boostTemp == null) { + return "22" + } + return settings.boostTemp +} + +def getMaxTempThreshold() { + if (settings.maxTempThreshold == null) { + return "32" + } + return settings.maxTempThreshold +} + +def boostTimeUp() { + log.debug "Executing 'boostTimeUp'" + //Round down result + int boostIntervalValue = getBoostIntervalValue() + def newBoostLength = (state.boostLength + boostIntervalValue) - (state.boostLength % boostIntervalValue) + setBoostLength(newBoostLength) +} + +def boostTimeDown() { + log.debug "Executing 'boostTimeDown'" + //Round down result + int boostIntervalValue = getBoostIntervalValue() + def newBoostLength = (state.boostLength - boostIntervalValue) - (state.boostLength % boostIntervalValue) + setBoostLength(newBoostLength) +} + +def boostButton() { + log.debug "Executing 'boostButton'" + setThermostatMode('emergency heat') +} + +def setHeatingSetpointToDesired() { + setHeatingSetpoint(state.newSetpoint) +} + +def setNewSetPointValue(newSetPointValue) { + log.debug "Executing 'setNewSetPointValue' with value $newSetPointValue" + unschedule('setHeatingSetpointToDesired') + state.newSetpoint = newSetPointValue + state.desiredHeatSetpoint = state.newSetpoint + sendEvent("name":"desiredHeatSetpoint", "value": state.desiredHeatSetpoint, displayed: false) + log.debug "Setting heat set point up to: ${state.newSetpoint}" + runIn(3, setHeatingSetpointToDesired) +} + +def heatingSetpointUp(){ + log.debug "Executing 'heatingSetpointUp'" + setNewSetPointValue(getHeatTemp().toInteger() + 1) +} + +def heatingSetpointDown(){ + log.debug "Executing 'heatingSetpointDown'" + setNewSetPointValue(getHeatTemp().toInteger() - 1) +} + +def setTemperatureForSlider(value) { + log.debug "Executing 'setTemperatureForSlider with $value'" + setNewSetPointValue(value) +} + +def getHeatTemp() { + return state.desiredHeatSetpoint == null ? device.currentValue("heatingSetpoint") : state.desiredHeatSetpoint +} + +def off() { + setThermostatMode('off') +} + +def heat() { + setThermostatMode('heat') +} + +def emergencyHeat() { + log.debug "Executing 'boost'" + + def latestThermostatMode = device.latestState('thermostatMode') + + //Don't do if already in BOOST mode. + if (latestThermostatMode.stringValue != 'emergency heat') { + setThermostatMode('emergency heat') + } + else { + log.debug "Already in boost mode." + } + +} + +def auto() { + setThermostatMode('auto') +} + +def setThermostatMode(mode) { + if (settings.disableDevice == null || settings.disableDevice == false) { + mode = mode == 'cool' ? 'heat' : mode + log.debug "Executing 'setThermostatMode with mode $mode'" + def args = [ + mode: "SCHEDULE" + ] + if (mode == 'off') { + args = [ + mode: "OFF" + ] + } else if (mode == 'heat') { + //mode": "MANUAL", "target": 20 + args = [ + mode: "MANUAL", + target: 20 + ] + } else if (mode == 'emergency heat') { + if (state.boostLength == null || state.boostLength == '') + { + state.boostLength = 60 + sendEvent("name":"boostLength", "value": 60, "unit": "minutes", displayed: true) + } + //"mode": "BOOST","boost": 60,"target": 22 + args = [ + mode: "BOOST", + boost: state.boostLength, + target: getBoostTempValue() + ] + } + + def resp = parent.apiPOST("/nodes/heating/${device.deviceNetworkId}", args) + mode = mode == 'range' ? 'auto' : mode + } + runIn(4, refresh) +} + +def poll() { + log.debug "Executing 'poll'" + def currentDevice = parent.getDeviceStatus(device.deviceNetworkId) + if (currentDevice == []) { + return [] + } + log.debug "$device.name status: $currentDevice" + //Construct status message + def statusMsg = "" + + //Boost button label + def boostLabel = "OFF" + + // get temperature status + def temperature = currentDevice.props.temperature + def heatingSetpoint = currentDevice.state.target as Double + + log.debug "Got Temperature ${temperature} on device ${currentDevice.state.name}" + log.debug "Got Setpoint ${heatingSetpoint} on device ${currentDevice.state.name}" + + //Check heating set point against maximum threshold value. + log.debug "Maximum temperature threshold set to: " + getMaxTempThreshold() + if ((getMaxTempThreshold() as BigDecimal) < (heatingSetpoint as BigDecimal)) + { + log.debug "Maximum temperature threshold exceeded. " + heatingSetpoint + " is higher than " + getMaxTempThreshold() + sendEvent(name: 'maxtempthresholdbreach', value: heatingSetpoint, unit: "C", displayed: false) + //Force temperature threshold to Hive API. + def args = [ + target: getMaxTempThreshold() + ] + + parent.apiPOST("/nodes/heating/${device.deviceNetworkId}", args) + heatingSetpoint = String.format("%2.1f", getMaxTempThreshold()) + } + + // convert temperature reading of 1 degree to 7 as Hive app does + if (heatingSetpoint == "1.0") { + heatingSetpoint = "7.0" + } + sendEvent(name: 'temperature', value: temperature, unit: "C", state: "heat") + sendEvent(name: 'heatingSetpoint', value: heatingSetpoint, unit: "C", state: "heat") + sendEvent(name: 'coolingSetpoint', value: heatingSetpoint, unit: "C", state: "heat") + sendEvent(name: 'thermostatSetpoint', value: heatingSetpoint, unit: "C", state: "heat", displayed: false) + sendEvent(name: 'thermostatFanMode', value: "off", displayed: false) + + state.desiredHeatSetpoint = heatingSetpoint + sendEvent("name":"desiredHeatSetpoint", "value": state.desiredHeatSetpoint, unit: "C", displayed: false) + + // determine hive operating mode + def mode = currentDevice.state.mode.toLowerCase() + + //If Hive heating device is set to disabled, then force off if not already off. + if (settings.disableDevice != null && settings.disableDevice == true && mode != "off") { + def args = [ + mode: "OFF" + ] + parent.apiPOST("/nodes/heating/${device.deviceNetworkId}", args) + mode = 'off' + } + else if (mode == "boost") { + mode = 'emergency heat' + def boostTime = currentDevice.state.boost + statusMsg = "Boost " + boostTime + " min" + boostLabel = boostTime + " min remaining" + sendEvent("name":"boostTimeRemaining", "value": boostTime + " mins") + } + else if (mode == "manual") { + mode = 'heat' + statusMsg = statusMsg + " Manual" + } + else if (mode == "off") { + mode = 'off' + statusMsg = statusMsg + " Off" + } + else { + mode = 'auto' + statusMsg = statusMsg + " Schedule" + } + + if (settings.disableDevice != null && settings.disableDevice == true) { + statusMsg = "DISABLED" + } + + sendEvent(name: 'thermostatMode', value: mode) + + // determine if Hive heating relay is on + def stateHeatingRelay = (heatingSetpoint as BigDecimal) > (temperature as BigDecimal) + + log.debug "stateHeatingRelay: $stateHeatingRelay" + + if (stateHeatingRelay) { + sendEvent(name: 'thermostatOperatingState', value: "heating") + } + else { + sendEvent(name: 'thermostatOperatingState', value: "idle") + } + + sendEvent("name":"hiveHeating", "value": statusMsg, displayed: false) + sendEvent("name":"boostLabel", "value": boostLabel, displayed: false) + +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} \ No newline at end of file diff --git a/devicetypes/alyc100/hive-hot-water.src/hive-hot-water.groovy b/devicetypes/alyc100/hive-hot-water.src/hive-hot-water.groovy new file mode 100644 index 00000000000..c23b84821d1 --- /dev/null +++ b/devicetypes/alyc100/hive-hot-water.src/hive-hot-water.groovy @@ -0,0 +1,241 @@ +/** + * Hive Hot Water + * + * Copyright 2017 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 25.02.2016 + * v2.0 BETA - Initial Release + * v2.0b - Fix blank temperature readings on Android ST app + * + * 10.03.2017 + * v2.0c - Fix to boost mode. + * + * 30.05.2017 + * v2.1 - Updated to use Hive Beekeeper API. + * v2.1b - Bug fix. Refresh bug prevents installation of Hive devices. + * + * 30.10.2017 + * v3.0 - Version refactor to reflect BeeKeeper API update. + * + * 08.10.2018 + * v3.1 - First attempt at New Smartthings App compatibility. + * + * 08.12.2020 + * v3.1 - New Smartthings App UI. + * v3.1a - Add missing set boost length command for WebCore. + * v3.1b - Tweak to boost length command. + */ + +metadata { + definition (name: "Hive Hot Water", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.thermostat", mnmn: "fBZA", vid: "e1ac13ba-6325-3cd8-8908-46b6ab04a313") { + capability "Actuator" + capability "Polling" + capability "Refresh" + capability "Thermostat" + capability "Thermostat Mode" + capability "Thermostat Operating State" + capability "Health Check" + capability "tigerdrum36561.boostLabel" + capability "tigerdrum36561.boostLength" + + command "setThermostatMode" + command "setBoostLength" + } + + simulator { + // TODO: define status and reply messages here + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'switch' attribute + // TODO: handle 'thermostatMode' attribute + +} + +def installed() { + log.debug "Executing 'installed'" + state.boostLength = 60 + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +void updated() { + log.debug "Executing 'updated'" + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +// handle commands +def setHeatingSetpoint(temp) { + //Not implemented +} + +def setBoostLength(minutes) { + log.debug "Executing 'setBoostLength with length $minutes minutes'" + if (minutes < 5) { + minutes = 5 + } + if (minutes > 300) { + minutes = 300 + } + state.boostLength = minutes + sendEvent("name":"boostLength", "value": minutes, "units":"minutes", displayed: true) + + def latestThermostatMode = device.latestState('thermostatMode') +} + +def heatingSetpointUp(){ + //Not implemented +} + +def heatingSetpointDown(){ + //Not implemented +} + +def on() { + log.debug "Executing 'on'" + setThermostatMode('heat') +} + +def off() { + setThermostatMode('off') +} + +def heat() { + setThermostatMode('heat') +} + +def emergencyHeat() { + log.debug "Executing 'boost'" + + def latestThermostatMode = device.latestState('thermostatMode') + + //Don't do if already in BOOST mode. + if (latestThermostatMode.stringValue != 'emergency heat') { + setThermostatMode('emergency heat') + } + else { + log.debug "Already in boost mode." + } + + +} + +def auto() { + setThermostatMode('auto') +} + +def setThermostatMode(mode) { + mode = mode == 'cool' ? 'heat' : mode + def args = [ + mode: "SCHEDULE" + ] + if (mode == 'off') { + args = [ + mode: "OFF" + ] + } else if (mode == 'heat') { + //{"nodes":[{"attributes":{"activeHeatCoolMode":{"targetValue":"HEAT"},"activeScheduleLock":{"targetValue":false}}}]} + args = [ + mode: "MANUAL" + ] + } else if (mode == 'emergency heat') { + if (state.boostLength == null || state.boostLength == '') + { + state.boostLength = 60 + sendEvent("name":"boostLength", "value": 60, "units":"minutes", displayed: true) + } + args = [ + mode: "BOOST", + boost: state.boostLength + ] + } + + def resp = parent.apiPOST("/nodes/hotwater/${device.deviceNetworkId}", args) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + return [] + } + else { + mode = mode == 'range' ? 'auto' : mode + runIn(3, refresh) + } +} + +def poll() { + log.debug "Executing 'poll'" + def currentDevice = parent.getDeviceStatus(device.deviceNetworkId) + if (currentDevice == []) { + return [] + } + log.debug "$device.name status: $currentDevice" + + //Construct status message + def statusMsg = "Currently" + + //Boost button label + def boostLabel = "OFF" + + // determine hive hot water operating mode + def mode = currentDevice.state.mode.toLowerCase() + + if (mode == "off") { + statusMsg = statusMsg + " set to OFF" + } + else if (mode == "boost") { + mode = 'emergency heat' + statusMsg = statusMsg + " set to BOOST" + def boostTime = currentDevice.state.boost + boostLabel = boostTime + " min remaining" + sendEvent("name":"boostTimeRemaining", "value": boostTime + " mins") + } + else if (mode == "manual") { + mode = 'heat' + statusMsg = statusMsg + " set to ON" + } + else { + mode = 'auto' + statusMsg = statusMsg + " set to SCHEDULE" + } + + sendEvent(name: 'thermostatMode', value: mode) + + // determine if Hive hot water relay is on + def stateHotWaterRelay = currentDevice.state.status + + log.debug "stateHotWaterRelay: $stateHotWaterRelay" + + if (stateHotWaterRelay == "ON") { + sendEvent(name: 'temperature', value: 99, unit: "C", state: "heat", displayed: false) + sendEvent(name: 'heatingSetpoint', value: 99, unit: "C", state: "heat", displayed: false) + sendEvent(name: 'coolingSetpoint', value: 99, unit: "C", state: "heat", displayed: false) + sendEvent(name: 'thermostatOperatingState', value: "heating") + statusMsg = statusMsg + " and is HEATING" + } + else { + sendEvent(name: 'temperature', value: 0, unit: "C", state: "heat", displayed: false) + sendEvent(name: 'heatingSetpoint', value: 0, unit: "C", state: "heat", displayed: false) + sendEvent(name: 'coolingSetpoint', value: 0, unit: "C", state: "heat", displayed: false) + sendEvent(name: 'thermostatOperatingState', value: "idle") + statusMsg = statusMsg + " and is IDLE" + } + sendEvent("name":"hiveHotWater", "value":statusMsg, displayed: false) + sendEvent("name":"boostLabel", "value": boostLabel, displayed: false) + +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} \ No newline at end of file diff --git a/devicetypes/alyc100/hive-trv.src/hive-trv.groovy b/devicetypes/alyc100/hive-trv.src/hive-trv.groovy new file mode 100644 index 00000000000..5308393031b --- /dev/null +++ b/devicetypes/alyc100/hive-trv.src/hive-trv.groovy @@ -0,0 +1,417 @@ +/** + * Hive TRV + * + * Initial Copyright 2015 Alex Lee Yuk Cheung (Hive Thermostat DH) + * Modified for use wiht Hive TRV - Ben Lee @Bibbleq + * + * 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. + * + * + * VERSION HISTORY + * + * 31.10.2019 + * v1 based on Hive Heating DH code with modification for TRVs + * + * 06.09.2019 + * v1.1 display calibration information + * + * 13.09.2019 + * v1.2 Updated to make more efficient (parent caches product information for 2 minutes) + * + * 18.09.2019 + * v1.3 Added refresh capabilities into DH instead of relying on parent SA (causes timeouts if too many TRVs) + * + * 8.11.2019 + * v1.3.1 fixed typo + * + * 8.12.2020 + * v1.4 Update UI for new SmartThings app + * v1.4a - Add missing set boost length command for WebCore. + * v1.4b - Tweak to boost length command. + */ + +preferences +{ + input( "boostInterval", "number", title: "Boost Interval (minutes)", description: "Boost interval amount in minutes", required: false, defaultValue: 60 ) + input( "boostTemp", "decimal", title: "Boost Temperature (°C)", description: "Boost interval amount in Centigrade", required: false, defaultValue: 22, range: "5..32" ) + input( "maxTempThreshold", "decimal", title: "Max Temperature Threshold (°C)", description: "Set the maximum temperature threshold in Centigrade", required: false, defaultValue: 32, range: "5..32" ) + input( "disableDevice", "bool", title: "Disable Hive TRV?", required: false, defaultValue: false ) +} + +metadata { + definition (name: "Hive TRV", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.thermostat", mnmn: "fBZA", vid: "de5c3738-3ae6-38b7-adfd-bbd98cba41d1") { + capability "Actuator" + capability "Polling" + capability "Refresh" + capability "Temperature Measurement" + capability "Thermostat" + capability "Thermostat Heating Setpoint" + capability "Thermostat Mode" + capability "Thermostat Operating State" + capability "Health Check" + capability "Battery" + capability "tigerdrum36561.boostLabel" + capability "tigerdrum36561.boostLength" + + command "heatingSetpointUp" + command "heatingSetpointDown" + command "boostTimeUp" + command "boostTimeDown" + command "setThermostatMode" + command "setHeatingSetpoint" + command "setTemperatureForSlider" + command "setBoostLength" + command "boostButton" + } + + simulator { + // TODO: define status and reply messages here + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'temperature' attribute + // TODO: handle 'heatingSetpoint' attribute + // TODO: handle 'thermostatSetpoint' attribute + // TODO: handle 'thermostatMode' attribute + // TODO: handle 'thermostatOperatingState' attribute +} + +def installed() { + log.debug "Executing 'installed'" + state.boostLength = 60 + state.desiredHeatSetpoint = 7 + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +void updated() { + log.debug "Executing 'updated'" + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +// handle commands +def setHeatingSetpoint(temp) { + log.debug "Executing 'setHeatingSetpoint with temp $temp'" + def latestThermostatMode = device.latestState('thermostatMode') + + if (temp < 5) { + temp = 5 + } + if (temp > 32) { + temp = 32 + } + + if (settings.disableDevice == null || settings.disableDevice == false) { + //if thermostat is off, set to manual + def args + if (latestThermostatMode.stringValue == 'off') { + args = [ + mode: "MANUAL", + target: temp + ] + + } + else { + // {"target":7.5} + args = [ + target: temp + ] + } + def resp = parent.apiPOST("/nodes/trvcontrol/${device.deviceNetworkId}", args) + } + runIn(4, refresh) +} + +def setBoostLength(minutes) { + log.debug "Executing 'setBoostLength with length $minutes minutes'" + //modified minimum boost to be 15 minutes as TRV can take 15 minutes to pick up new set points + if (minutes < 5) { + minutes = 5 + } + if (minutes > 300) { + minutes = 300 + } + state.boostLength = minutes + sendEvent("name":"boostLength", "value": minutes, "unit": "minutes", displayed: true) +} + +def getBoostIntervalValue() { + if (settings.boostInterval == null) { + return 10 + } + return settings.boostInterval.toInteger() +} + +def getBoostTempValue() { + if (settings.boostTemp == null) { + return "22" + } + return settings.boostTemp +} + +def getMaxTempThreshold() { + if (settings.maxTempThreshold == null) { + return "32" + } + return settings.maxTempThreshold +} + +def boostTimeUp() { + log.debug "Executing 'boostTimeUp'" + //Round down result + int boostIntervalValue = getBoostIntervalValue() + def newBoostLength = (state.boostLength + boostIntervalValue) - (state.boostLength % boostIntervalValue) + setBoostLength(newBoostLength) +} + +def boostTimeDown() { + log.debug "Executing 'boostTimeDown'" + //Round down result + int boostIntervalValue = getBoostIntervalValue() + def newBoostLength = (state.boostLength - boostIntervalValue) - (state.boostLength % boostIntervalValue) + setBoostLength(newBoostLength) +} + +def boostButton() { + log.debug "Executing 'boostButton'" + setThermostatMode('emergency heat') +} + +def setHeatingSetpointToDesired() { + setHeatingSetpoint(state.newSetpoint) +} + +def setNewSetPointValue(newSetPointValue) { + log.debug "Executing 'setNewSetPointValue' with value $newSetPointValue" + unschedule('setHeatingSetpointToDesired') + state.newSetpoint = newSetPointValue + state.desiredHeatSetpoint = state.newSetpoint + sendEvent("name":"desiredHeatSetpoint", "value": state.desiredHeatSetpoint, displayed: false) + log.debug "Setting heat set point up to: ${state.newSetpoint}" + runIn(3, setHeatingSetpointToDesired) +} + +def heatingSetpointUp(){ + log.debug "Executing 'heatingSetpointUp'" + setNewSetPointValue(getHeatTemp().toInteger() + 1) +} + +def heatingSetpointDown(){ + log.debug "Executing 'heatingSetpointDown'" + setNewSetPointValue(getHeatTemp().toInteger() - 1) +} + +def setTemperatureForSlider(value) { + log.debug "Executing 'setTemperatureForSlider with $value'" + setNewSetPointValue(value) +} + +def getHeatTemp() { + return state.desiredHeatSetpoint == null ? device.currentValue("heatingSetpoint") : state.desiredHeatSetpoint +} + +def off() { + setThermostatMode('off') +} + +def heat() { + setThermostatMode('heat') +} + +def emergencyHeat() { + log.debug "Executing 'boost'" + + def latestThermostatMode = device.latestState('thermostatMode') + + //Don't do if already in BOOST mode. + if (latestThermostatMode.stringValue != 'emergency heat') { + setThermostatMode('emergency heat') + } + else { + log.debug "Already in boost mode." + } + +} + +def auto() { + setThermostatMode('auto') +} + +def setThermostatMode(mode) { + if (settings.disableDevice == null || settings.disableDevice == false) { + mode = mode == 'cool' ? 'heat' : mode + log.debug "Executing 'setThermostatMode with mode $mode'" + def args = [ + mode: "SCHEDULE" + ] + if (mode == 'off') { + args = [ + mode: "OFF" + ] + } else if (mode == 'heat') { + //mode": "MANUAL", "target": 20 + args = [ + target: 20 + ] + } else if (mode == 'emergency heat') { + if (state.boostLength == null || state.boostLength == '') + { + state.boostLength = 60 + sendEvent("name":"boostLength", "value": 60, "unit": "minutes", displayed: true) + } + //"mode": "BOOST","boost": 60,"target": 22 + args = [ + mode: "BOOST", + boost: state.boostLength, + target: getBoostTempValue() + ] + } + + def resp = parent.apiPOST("/nodes/trvcontrol/${device.deviceNetworkId}", args) + mode = mode == 'range' ? 'auto' : mode + } + runIn(4, refresh) +} + +def setDeviceID() { + def DeviceID = parent.getDeviceID(device.deviceNetworkId) + state.DeviceID = DeviceID +} + +def poll() { + log.debug "Executing 'poll' for $device.deviceNetworkId" + def currentDevice = parent.getDeviceTRVStatus(device.deviceNetworkId) + if (currentDevice == []) { + return [] + } + log.debug "${device.name} status: ${currentDevice}" + + if (state.DeviceID == null) { + log.debug "Device ID Null" + setDeviceID() + } + + //Get Device info (Product & Device API requests return differnt sets of info) + def currentDeviceDetails = parent.getDeviceInfo(state.DeviceID) + //log.debug "${device.name} details: ${currentDeviceDetails}" + + //update battery + sendEvent("name": "battery", "value": currentDeviceDetails.props.battery, displayed: true) + //log.debug "Battery: ${currentDeviceDetails.props.battery}" + + //update signal + sendEvent("name": "signal", "value": currentDeviceDetails.props.signal, displayed: true) + //log.debug "Signal: ${currentDeviceDetails.props.signal}" + + //update calibration status + sendEvent("name": "calibrationstatus", "value": currentDeviceDetails.state.calibrationStatus, displayed: true) + //log.debug "Calibrationstatus: ${currentDeviceDetails.state.calibrationStatus}" + + //update calibration timestamp + sendEvent("name": "calibrationtimestamp", "value": currentDeviceDetails.props.calibration, displayed: true) + //log.debug "Calibration Time: ${currentDeviceDetails.props.calibration}" + + //Construct status message + def statusMsg = "" + + //Boost button label + def boostLabel = "OFF" + + // get temperature status + def temperature = currentDevice.props.temperature + def heatingSetpoint = currentDevice.state.target as Double + + //Check heating set point against maximum threshold value. + log.debug "Maximum temperature threshold set to: " + getMaxTempThreshold() + if ((getMaxTempThreshold() as BigDecimal) < (heatingSetpoint as BigDecimal)) { + log.debug "Maximum temperature threshold exceeded. " + heatingSetpoint + " is higher than " + getMaxTempThreshold() + sendEvent(name: 'maxtempthresholdbreach', value: heatingSetpoint, unit: "C", displayed: false) + //Force temperature threshold to Hive API. + def args = [ + target: getMaxTempThreshold() + ] + parent.apiPOST("/nodes/trvcontrol/${device.deviceNetworkId}", args) + heatingSetpoint = String.format("%2.1f", getMaxTempThreshold()) + } + + // convert temperature reading of 1 degree to 7 as Hive app does + if (heatingSetpoint == "1.0") { + heatingSetpoint = "7.0" + } + sendEvent(name: 'temperature', value: temperature, unit: "C", state: "heat") + sendEvent(name: 'heatingSetpoint', value: heatingSetpoint, unit: "C", state: "heat") + //sendEvent(name: 'coolingSetpoint', value: heatingSetpoint, unit: "C", state: "heat") + sendEvent(name: 'thermostatSetpoint', value: heatingSetpoint, unit: "C", state: "heat", displayed: false) + //sendEvent(name: 'thermostatFanMode', value: "off", displayed: false) + + state.desiredHeatSetpoint = heatingSetpoint + sendEvent("name":"desiredHeatSetpoint", "value": state.desiredHeatSetpoint, unit: "C", displayed: false) + + // determine hive operating mode + def mode = currentDevice.state.mode.toLowerCase() + + //If Hive heating device is set to disabled, then force off if not already off. + if (settings.disableDevice != null && settings.disableDevice == true && mode != "off") { + def args = [ + mode: "OFF" + ] + parent.apiPOST("/nodes/trvcontrol/${device.deviceNetworkId}", args) + mode = 'off' + } + + switch (mode) { + case "boost": + mode = 'emergency heat' + def boostTime = currentDevice.state.boost + statusMsg = "Boost " + boostTime + "min" + boostLabel = boostTime + " min remaining" + sendEvent("name":"boostTimeRemaining", "value": boostTime + " mins") + case "manual": + mode = 'heat' + statusMsg = statusMsg + " Manual" + case "auto": + statusMsg = statusMsg + " Schedule" + default: + log.debug "default" + } + + if (settings.disableDevice != null && settings.disableDevice == true) { + statusMsg = "DISABLED" + } + + sendEvent(name: 'thermostatMode', value: mode) + + // determine if Hive heating relay is on + def stateHeatingRelay = (heatingSetpoint as BigDecimal) > (temperature as BigDecimal) + + //log.debug "stateHeatingRelay: $stateHeatingRelay" + //log.debug "Working status: ${currentDevice.props.working}" + + if (stateHeatingRelay && currentDevice.props.working.toBoolean() == true) { + //log.debug "heating" + sendEvent(name: 'thermostatOperatingState', value: "heating") + } + else { + //log.debug "idle" + sendEvent(name: 'thermostatOperatingState', value: "idle") + } + + sendEvent("name":"hiveHeating", "value": statusMsg, displayed: false) + sendEvent("name":"boostLabel", "value": boostLabel, displayed: false) + +} + +def refresh() { + log.debug "Executing 'refresh'" + unschedule('poll') + runEvery5Minutes('poll') +} \ No newline at end of file diff --git a/devicetypes/alyc100/laser-guided-navigation.png b/devicetypes/alyc100/laser-guided-navigation.png new file mode 100644 index 00000000000..5da62efd193 Binary files /dev/null and b/devicetypes/alyc100/laser-guided-navigation.png differ diff --git a/devicetypes/alyc100/mihome-adapter-plus.src/mihome-adapter-plus.groovy b/devicetypes/alyc100/mihome-adapter-plus.src/mihome-adapter-plus.groovy new file mode 100644 index 00000000000..5c43b4c14c4 --- /dev/null +++ b/devicetypes/alyc100/mihome-adapter-plus.src/mihome-adapter-plus.groovy @@ -0,0 +1,313 @@ +/** + * MiHome Adapter Plus + * + * Copyright 2016 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 10.10.2018: 2.1 - Compatibility with New Smartthings App. + * 17.09.2017: 2.0.1a - Disable setting device to Offline on unexpected API response. + * 12.12.2017: 2.0.1 - Resolve Android chart display issue. + * 23.11.2016: 2.0 - Remove extra logging. + * + * 09.11.2016: 2.0 BETA Release 5.2 - Stop executeAction() bug when adding device. + * 09.11.2016: 2.0 BETA Release 5.1 - Shift from data to state to hold variables. + * 09.11.2016: 2.0 BETA Release 5 - Try to fix chart Android compatibility. + * 08.11.2016: 2.0 BETA Release 4 - Added historical power chart data. RENAME TO ADAPTER PLUS. + * 08.11.2016: 2.0 BETA Release 3 - Added ON and OFF buttons for devices that do not report state. + * 06.11.2016: 2.0 BETA Release 2 - Various tile and formatting updates for Android. + * 06.11.2016: 2.0 BETA Release 1 - Support for MiHome (Connect) v2.0. Inital version of device. + */ +metadata { + definition (name: "MiHome Adapter Plus", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.switch", mnmn: "SmartThings", vid: "generic-switch-power") { + capability "Actuator" + capability "Polling" + capability "Refresh" + capability "Switch" + capability "Sensor" + capability "Power Meter" + capability "Health Check" + + command "on" + command "off" + } + + + simulator { + // TODO: define status and reply messages here + } + + tiles(scale: 2) { + multiAttributeTile(name:"rich-control", type:"lighting", width:6, height:4, canChangeIcon: true){ + tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { + attributeState "on", label:'${name}', action:"switch.off", icon:"st.Appliances.appliances17", backgroundColor:"#79b821", nextState:"on" + attributeState "off", label:'${name}', action:"switch.on", icon:"st.Appliances.appliances17", backgroundColor:"#ffffff", nextState:"off" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.Appliances.appliances17", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.Appliances.appliances17", backgroundColor:"#ffffff", nextState:"turningOn" + attributeState "offline", label:'${name}', icon:"st.switches.switch.off", backgroundColor:"#ff0000" + } + tileAttribute("device.power", key: "SECONDARY_CONTROL") { + attributeState("default", label:'${currentValue} Wh', unit:"Wh") + } + } + + standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { + state "on", label:'${name}', action:"switch.off", icon:"st.Appliances.appliances17", backgroundColor:"#79b821", nextState:"off" + state "off", label:'${name}', action:"switch.on", icon:"st.Appliances.appliances17", backgroundColor:"#ffffff", nextState:"on" + state "turningOn", label:'${name}', action:"switch.off", icon:"st.Appliances.appliances17", backgroundColor:"#79b821", nextState:"turningOff" + state "turningOff", label:'${name}', action:"switch.on", icon:"st.Appliances.appliances17", backgroundColor:"#ffffff", nextState:"turningOn" + state "offline", label:'${name}', icon:"st.switches.switch.off", backgroundColor:"#ff0000" + } + + standardTile("refresh", "device.switch", inactiveLabel: false, height: 2, width: 2, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + + valueTile("totalPower", "device.totalPower", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Today Total Power:\n${currentValue} Wh' + } + + valueTile("yesterdayTotalPower", "device.yesterdayTotalPower", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Yesterday Total Power:\n${currentValue} Wh' + } + + standardTile("onButton", "device.onButton", inactiveLabel: false, width: 2, height: 2) { + state("default", label:'On', action:"on") + } + + standardTile("offButton", "device.offButton", inactiveLabel: false, width: 2, height: 2) { + state("default", label:'Off', action:"off") + } + + htmlTile(name:"chartHTML", action: "getChartHTML", width: 6, height: 4, whiteList: ["www.gstatic.com", "raw.githubusercontent.com"]) + + main(["switch"]) + details(["rich-control", "onButton", "offButton", "refresh", "totalPower", "yesterdayTotalPower", "chartHTML"]) + } +} + +mappings { + path("/getChartHTML") {action: [GET: "getChartHTML"]} +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'switch' attribute + +} + +// handle commands +def installed() { + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def poll() { + log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}" + + def resp = parent.apiGET("/subdevices/show?params=" + URLEncoder.encode(new groovy.json.JsonBuilder([id: device.deviceNetworkId.toInteger()]).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + //sendEvent(name: "switch", value: "offline", descriptionText: "The device is offline") + return [] + } + def power_state = resp.data.data.power_state + //def power_state = 1 + if (power_state != null) { + sendEvent(name: "switch", value: power_state == 0 ? "off" : "on") + } + def real_power = resp.data.data.real_power + //def real_power = Math.abs(new Random().nextInt() % 600 + 1) + if (real_power != null) { + sendEvent(name: "power", value: real_power as BigDecimal, unit: "Wh") + } + def today_wh = resp.data.data.today_wh + //def today_wh = Math.abs(new Random().nextInt() % 3000 + 1) + if (today_wh != null) { + + //Calculate change of day + def df = new java.text.SimpleDateFormat("D") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("Europe/London")) + } + def currentDay = df.format(new Date()).toInteger() + def changeOfDay = false + if ((state.day != null) && (currentDay != state.day)) { + changeOfDay = true + } + state.day = currentDay + + if (state.last_wh_reading != null) { + //Determine when the days total wh reading has been reset and store as chart data. + if (((today_wh as BigDecimal) < state.last_wh_reading) || (today_wh == 0 && changeOfDay)) { + addYesterdayTotalToChartData(state.last_wh_reading) + sendEvent(name: "yesterdayTotalPower", value: state.last_wh_reading, unit: "Wh") + } + } + + state.last_wh_reading = today_wh as BigDecimal + sendEvent(name: "totalPower", value: today_wh as BigDecimal, unit: "Wh") + addCurrentTotalToChartData(today_wh as BigDecimal) + } +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} + +def on() { + log.debug "Executing 'on'" + def resp = parent.apiGET("/subdevices/power_on?params=" + URLEncoder.encode(new groovy.json.JsonBuilder([id: device.deviceNetworkId.toInteger()]).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + } + else { + refresh() + } +} + +def off() { + log.debug "Executing 'off'" + def resp = parent.apiGET("/subdevices/power_off?params=" + URLEncoder.encode(new groovy.json.JsonBuilder([id: device.deviceNetworkId.toInteger()]).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + } + else { + refresh() + } +} + +def addYesterdayTotalToChartData(total) { + if (state.chartData == null) { + state.chartData = [0, total, 0, 0, 0, 0, 0] + } + else { + state.chartData.putAt(0, total) + state.chartData.add(0, 0) + state.chartData.pop() + } +} + +def addCurrentTotalToChartData(total) { + if (state.chartData == null) { + state.chartData = [total, 0, 0, 0, 0, 0, 0] + } + state.chartData.putAt(0, total) +} + +def getChartHTML() { + try { + def date = new Date() + if (state.chartData == null) { + state.chartData = [0, 0, 0, 0, 0, 0, 0] + } + def topValue = state.chartData.max() + def hData = "" + if (state.last_wh_reading != null && state.last_wh_reading > 0) { + hData = """ +

Historical Usage


+
+ """ + } else { + hData = """ +
+

Not enough data to create power history chart.

+

Currently collecting data. Come back in a couple of hours.

+
+ """ + } + + def mainHtml = """ + + + + + + + + + + + + + + ${hData} + + + """ + render contentType: "text/html", data: mainHtml, status: 200 + } + catch (ex) { + log.error "getChartHTML Exception:", ex + } +} + + +def getCssData() { + def cssData = null + def htmlInfo + state.cssData = null + + if(htmlInfo?.cssUrl && htmlInfo?.cssVer) { + if(state?.cssData) { + if (state?.cssVer?.toInteger() == htmlInfo?.cssVer?.toInteger()) { + cssData = state?.cssData + } else if (state?.cssVer?.toInteger() < htmlInfo?.cssVer?.toInteger()) { + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state?.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + cssData = getFileBase64(cssUrl(), "text", "css") + } + return cssData +} + +def getFileBase64(url, preType, fileType) { + try { + def params = [ + uri: url, + contentType: '$preType/$fileType' + ] + httpGet(params) { resp -> + if(resp.data) { + def respData = resp?.data + ByteArrayOutputStream bos = new ByteArrayOutputStream() + int len + int size = 4096 + byte[] buf = new byte[size] + while ((len = respData.read(buf, 0, size)) != -1) + bos.write(buf, 0, len) + buf = bos.toByteArray() + String s = buf?.encodeBase64() + return s ? "data:${preType}/${fileType};base64,${s.toString()}" : null + } + } + } + catch (ex) { + log.error "getFileBase64 Exception:", ex + } +} + +def cssUrl() { return "https://raw.githubusercontent.com/desertblade/ST-HTMLTile-Framework/master/css/smartthings.css" } \ No newline at end of file diff --git a/devicetypes/alyc100/mihome-adapter.src/mihome-adapter.groovy b/devicetypes/alyc100/mihome-adapter.src/mihome-adapter.groovy new file mode 100644 index 00000000000..b2a3ed4fcc5 --- /dev/null +++ b/devicetypes/alyc100/mihome-adapter.src/mihome-adapter.groovy @@ -0,0 +1,149 @@ +/** + * MiHome Adapter + * + * Copyright 2016 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY - FORMER VERSION NOW RENAMED AS ADAPTER PLUS + * 10.10.2018: 2.1 - Compatibility with New Smartthings App. + * 17.09.2017: 2.0a - Disable setting device to Offline on unexpected API response. + * 23.11.2016: 2.0 - Remove extra logging. + * + * 10.11.2016: 2.0 BETA Release 3 - Merge Light Switch and Adapter functionality into one device type. + * 10.11.2016: 2.0 BETA Release 2.1 - Bug fix. Stop NumberFormatException when creating body object. + * 09.11.2016: 2.0 BETA Release 2 - Added support for MiHome multiple gangway devices. + * + * 08.11.2016: 2.0 BETA Release 1 - Support for MiHome (Connect) v2.0. Inital version of device. + */ +metadata { + definition (name: "MiHome Adapter", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.switch", mnmn: "SmartThings", vid: "generic-switch") { + capability "Actuator" + capability "Polling" + capability "Refresh" + capability "Switch" + capability "Health Check" + + command "on" + command "off" + } + + tiles(scale: 2) { + multiAttributeTile(name:"rich-control", type:"lighting", width:6, height:4, canChangeIcon: true){ + tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { + attributeState "on", label:'${name}', action:"switch.off", icon:"st.Home.home30", backgroundColor:"#79b821", nextState:"on" + attributeState "off", label:'${name}', action:"switch.on", icon:"st.Home.home30", backgroundColor:"#ffffff", nextState:"off" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.Home.home30", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.Home.home30", backgroundColor:"#ffffff", nextState:"turningOn" + attributeState "offline", label:'${name}', icon:"st.switches.switch.off", backgroundColor:"#ff0000" + } + } + + standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { + state "on", label:'${name}', action:"switch.off", icon:"st.Home.home30", backgroundColor:"#79b821", nextState:"off" + state "off", label:'${name}', action:"switch.on", icon:"st.Home.home30", backgroundColor:"#ffffff", nextState:"on" + state "turningOn", label:'${name}', action:"switch.off", icon:"st.Home.home30", backgroundColor:"#79b821", nextState:"turningOff" + state "turningOff", label:'${name}', action:"switch.on", icon:"st.Home.home30", backgroundColor:"#ffffff", nextState:"turningOn" + state "offline", label:'${name}', icon:"st.switches.switch.off", backgroundColor:"#ff0000" + } + + standardTile("refresh", "device.switch", inactiveLabel: false, height: 2, width: 2, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + + standardTile("onButton", "device.onButton", inactiveLabel: false, width: 2, height: 2) { + state("default", label:'On', action:"on") + } + + standardTile("offButton", "device.offButton", inactiveLabel: false, width: 2, height: 2) { + state("default", label:'Off', action:"off") + } + + main(["switch"]) + details(["rich-control", "onButton", "offButton", "refresh"]) + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'switch' attribute + +} + +// handle commands +def installed() { + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def poll() { + log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}" + def body = [] + if (device.deviceNetworkId.contains("/")) { + body = [id: (device.deviceNetworkId.tokenize("/")[0].toInteger()), socket: (device.deviceNetworkId.tokenize("/")[1].toInteger())] + } else { + body = [id: device.deviceNetworkId.toInteger()] + } + def resp = parent.apiGET("/subdevices/show?params=" + URLEncoder.encode(new groovy.json.JsonBuilder(body).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + //sendEvent(name: "switch", value: "offline", descriptionText: "The device is offline") + return [] + } + def power_state = resp.data.data.power_state + if (power_state != null) { + sendEvent(name: "switch", value: power_state == 0 ? "off" : "on") + } +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} + +def on() { + log.debug "Executing 'on'" + def body = [] + if (device.deviceNetworkId.contains("/")) { + body = [id: (device.deviceNetworkId.tokenize("/")[0].toInteger()), socket: (device.deviceNetworkId.tokenize("/")[1].toInteger())] + } else { + body = [id: device.deviceNetworkId.toInteger()] + } + def resp = parent.apiGET("/subdevices/power_on?params=" + URLEncoder.encode(new groovy.json.JsonBuilder(body).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + } + else { + refresh() + } +} + +def off() { + log.debug "Executing 'off'" + def body = [] + if (device.deviceNetworkId.contains("/")) { + body = [id: (device.deviceNetworkId.tokenize("/")[0].toInteger()), socket: (device.deviceNetworkId.tokenize("/")[1].toInteger())] + } else { + body = [id: device.deviceNetworkId.toInteger()] + } + def resp = parent.apiGET("/subdevices/power_off?params=" + URLEncoder.encode(new groovy.json.JsonBuilder(body).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + } + else { + refresh() + } +} + + diff --git a/devicetypes/alyc100/mihome-etrv.src/mihome-etrv.groovy b/devicetypes/alyc100/mihome-etrv.src/mihome-etrv.groovy new file mode 100644 index 00000000000..c4793bffa93 --- /dev/null +++ b/devicetypes/alyc100/mihome-etrv.src/mihome-etrv.groovy @@ -0,0 +1,348 @@ +/** + * MiHome eTRV + * + * Copyright 2016 Alex Lee Yuk Cheung + * + * 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. + * + * + * VERSION HISTORY + * 10.10.2018: 2.1 - Add support for New Smartthings App. + * 23.11.2016: 2.0 - Remove BETA status. + * + * 07.11.2016: 2.0 BETA Release 1.1 - Allow icon to be changed. + * 07.11.2016: 2.0 BETA Release 1 - Version number update to match Smartapp. + * + * 10.01.2016: 1.1.2 - Bug fix to Boost mode not executing. + * + * 10.01.2016: 1.1.1 - Fixed stopBoost always returning to 'on' mode. + * + * 09.01.2016: 1.1 - Added BETA Boost Capability + * + * 09.01.2016: 1.0 - Initial Release + * + */ + +metadata { + definition (name: "MiHome eTRV", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.thermostat", mnmn: "SmartThings", vid: "SmartThings-smartthings-Z-Wave_Thermostat") { + capability "Actuator" + capability "Polling" + capability "Refresh" + capability "Temperature Measurement" + capability "Thermostat" + capability "Thermostat Mode" + capability "Thermostat Heating Setpoint" + capability "Switch" + capability "Health Check" + + command "heatingSetpointUp" + command "heatingSetpointDown" + command "setHeatingSetpoint" + command "setBoostLength" + } + + simulator { + // TODO: define status and reply messages here + } + + tiles(scale: 2) { + + multiAttributeTile(name: "thermostat", width: 6, height: 4, type:"lighting") { + tileAttribute("device.temperature", key:"PRIMARY_CONTROL", canChangeBackground: true){ + attributeState "default", label: '${currentValue}°', unit:"C", + backgroundColors:[ + [value: 0, color: "#153591"], + [value: 10, color: "#1e9cbb"], + [value: 13, color: "#90d2a7"], + [value: 17, color: "#44b621"], + [value: 20, color: "#f1d801"], + [value: 25, color: "#d04e00"], + [value: 29, color: "#bc2323"] + ] + } + tileAttribute ("batteryVoltage", key: "SECONDARY_CONTROL") { + attributeState "batteryVoltage", label:'Battery Voltage Is ${currentValue}' + } + } + + valueTile("thermostat_small", "device.temperature", width: 2, height: 2, canChangeIcon: true) { + state "default", label:'${currentValue}°', unit:"C", + backgroundColors:[ + [value: 0, color: "#153591"], + [value: 10, color: "#1e9cbb"], + [value: 13, color: "#90d2a7"], + [value: 17, color: "#44b621"], + [value: 20, color: "#f1d801"], + [value: 25, color: "#d04e00"], + [value: 29, color: "#bc2323"] + ] + } + + valueTile("heatingSetpoint", "device.heatingSetpoint", width: 2, height: 2) { + state "default", label:'${currentValue}°', unit:"C", + backgroundColors:[ + [value: 0, color: "#153591"], + [value: 10, color: "#1e9cbb"], + [value: 13, color: "#90d2a7"], + [value: 17, color: "#44b621"], + [value: 20, color: "#f1d801"], + [value: 25, color: "#d04e00"], + [value: 29, color: "#bc2323"] + ] + } + + standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state("default", label:'refresh', action:"polling.poll", icon:"st.secondary.refresh-icon") + } + + controlTile("heatSliderControl", "device.heatingSetpoint", "slider", height: 2, width: 4, inactiveLabel: false, range:"(12..30)") { + state "setHeatingSetpoint", label:'Set temperature to', action:"setHeatingSetpoint" + } + + controlTile("boostSliderControl", "device.boostLength", "slider", height: 2, width: 4, inactiveLabel: false, range:"(60..300)") { + state ("setBoostLength", label:'Set boost length to', action:"setBoostLength") + } + + standardTile("switch", "device.switch", decoration: "flat", height: 2, width: 2, inactiveLabel: false) { + state "on", label:'${name}', action:"switch.off", icon:"st.Home.home1", backgroundColor:"#f1d801" + state "off", label:'${name}', action:"switch.on", icon:"st.Home.home1", backgroundColor:"#ffffff" + } + + standardTile("thermostatMode", "device.thermostatMode", inactiveLabel: true, decoration: "flat", width: 2, height: 2) { + state("auto", label: "SCHEDULED", icon:"st.Office.office7") + state("off", icon:"st.thermostat.heating-cooling-off") + state("heat", label: "MANUAL", icon:"st.Weather.weather2") + state("emergency heat", label: "BOOST", icon:"st.Health & Wellness.health7") + } + + valueTile("boost", "device.boostLabel", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state("default", label:'${currentValue}', action:"emergencyHeat") + } + + main(["thermostat_small"]) + details(["thermostat", "heatingSetpoint", "heatSliderControl", "boost", "boostSliderControl", "switch", "refresh"]) + } +} + +def installed() { + log.debug "Executing 'installed'" + state.boostLength = 60 + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +void updated() { + log.debug "Executing 'updated'" + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def uninstalled() { + unschedule() +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'temperature' attribute + // TODO: handle 'heatingSetpoint' attribute + // TODO: handle 'thermostatSetpoint' attribute + // TODO: handle 'thermostatMode' attribute + // TODO: handle 'thermostatOperatingState' attribute +} + +// handle commands +def setHeatingSetpoint(temp) { + log.debug "Executing 'setHeatingSetpoint with temp $temp'" + def latestThermostatMode = device.latestState('thermostatMode') + + if (temp < 12) { + temp = 12 + } + if (temp > 30) { + temp = 30 + } + sendEvent(name: "boostSwitch", value: "off", displayed: false) + def resp = parent.apiGET("/subdevices/set_target_temperature?params=" + URLEncoder.encode(new groovy.json.JsonBuilder([id: device.deviceNetworkId.toInteger(), temperature: temp]).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + } + else { + runIn(1, refresh) + } +} + +def setBoostLength(minutes) { + log.debug "Executing 'setBoostLength with length $minutes minutes'" + if (minutes < 60) { + minutes = 60 + } + if (minutes > 300) { + minutes = 300 + } + state.boostLength = minutes + sendEvent(name:"boostLength", value: state.boostLength, displayed: true) + + def latestThermostatMode = device.latestState('thermostatMode') + + //If already in BOOST mode, send updated boost length. + if (latestThermostatMode.stringValue == 'emergency heat') { + setThermostatMode('emergency heat') + } + else { + refresh() + } +} + +def stopBoost() { + log.debug "Executing 'stopBoost'" + sendEvent(name: "boostSwitch", value: "off", displayed: false) + if (state.lastThermostatMode == 'off') { + off() + } + else { + on() + } +} + +def heatingSetpointUp(){ + log.debug "Executing 'heatingSetpointUp'" + int newSetpoint = device.currentValue("heatingSetpoint") + 1 + log.debug "Setting heat set point up to: ${newSetpoint}" + setHeatingSetpoint(newSetpoint) +} + +def heatingSetpointDown(){ + log.debug "Executing 'heatingSetpointDown'" + int newSetpoint = device.currentValue("heatingSetpoint") - 1 + log.debug "Setting heat set point down to: ${newSetpoint}" + setHeatingSetpoint(newSetpoint) +} + +def setLastHeatingSetpoint(temp) { + //Don't store set point if it is 12. + if (temp > 12) { + state.lastHeatingSetPoint = temp + } +} + +def off() { + setThermostatMode('off') + +} + +def on() { + setThermostatMode('heat') + +} + +def heat() { + setThermostatMode('heat') +} + +def emergencyHeat() { + log.debug "Executing 'boost'" + + def latestThermostatMode = device.latestState('thermostatMode') + + //Don't do if already in BOOST mode. + if (latestThermostatMode.stringValue != 'emergency heat') { + setThermostatMode('emergency heat') + } + else { + log.debug "Already in boost mode." + } +} + +def auto() { + setThermostatMode('heat') +} + +def setThermostatMode(mode) { + mode = mode == 'cool' ? 'heat' : mode + log.debug "Executing 'setThermostatMode with mode $mode'" + + if (mode == 'off') { + unschedule(stopBoost) + sendEvent(name: "boostSwitch", value: "off", displayed: false) + setLastHeatingSetpoint(device.currentValue('heatingSetpoint')) + setHeatingSetpoint(12) + } else if (mode == 'emergency heat') { + if (state.boostLength == null || state.boostLength == '') + { + state.boostLength = 60 + sendEvent("name":"boostLength", "value": 60, displayed: true) + } + state.lastThermostatMode = device.latestState('thermostatMode') + setLastHeatingSetpoint(device.currentValue('heatingSetpoint')) + sendEvent(name: "boostSwitch", value: "on", displayed: false) + def resp = parent.apiGET("/subdevices/set_target_temperature?params=" + URLEncoder.encode(new groovy.json.JsonBuilder([id: device.deviceNetworkId.toInteger(), temperature: 22]).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + } + else { + refresh() + } + //Schedule boost switch off + schedule(now() + (state.boostLength * 60000), stopBoost) + } else { + unschedule(stopBoost) + sendEvent(name: "boostSwitch", value: "off", displayed: false) + def lastHeatingSetPoint = 21 + if (state.lastHeatingSetPoint != null && state.lastHeatingSetPoint > 12) + { + lastHeatingSetPoint = state.lastHeatingSetPoint + } + setHeatingSetpoint(lastHeatingSetPoint) + } + +} + +def poll() { + log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}" + + def resp = parent.apiGET("/subdevices/show?params=" + URLEncoder.encode(new groovy.json.JsonBuilder([id: device.deviceNetworkId.toInteger()]).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + return [] + } + + //Boost button label + if (state.boostLength == null || state.boostLength == '') + { + state.boostLength = 60 + sendEvent("name":"boostLength", "value": 60, displayed: true) + } + def boostLabel = "" + + sendEvent(name: "temperature", value: resp.data.data.last_temperature, unit: "C", state: "heat") + sendEvent(name: "heatingSetpoint", value: resp.data.data.target_temperature, unit: "C", state: "heat") + def boostSwitch = device.currentValue("boostSwitch") + log.debug "boostSwitch: $boostSwitch" + if (boostSwitch != null && boostSwitch == "on") { + sendEvent(name: "thermostatMode", value: "emergency heat") + boostLabel = "Boosting" + } + else { + sendEvent(name: "thermostatMode", value: resp.data.data.target_temperature == 12 ? "off" : "heat") + boostLabel = "Start\n$state.boostLength Min Boost" + } + sendEvent(name: 'thermostatOperatingState', value: resp.data.data.target_temperature == 12 ? "idle" : "heating") + sendEvent(name: 'thermostatFanMode', value: "off", displayed: false) + sendEvent(name: "switch", value: resp.data.data.target_temperature == 12 ? "off" : "on") + sendEvent(name: "batteryVoltage", value: resp.data.data.voltage == null ? "Not Available" : resp.data.data.voltage + "V") + sendEvent(name: "boostLabel", value: boostLabel, displayed: false) + + return [] + +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} \ No newline at end of file diff --git a/devicetypes/alyc100/mihome-monitor.src/mihome-monitor.groovy b/devicetypes/alyc100/mihome-monitor.src/mihome-monitor.groovy new file mode 100644 index 00000000000..f9470eaadc8 --- /dev/null +++ b/devicetypes/alyc100/mihome-monitor.src/mihome-monitor.groovy @@ -0,0 +1,250 @@ +/** + * MiHome Monitor + * + * Copyright 2016 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 10.10.2018: 2.1 - Compatibility with New Smartthings App. + * 12.12.2017: 2.0.1 - Resolve Android chart display issue. + * 23.11.2016: 2.0 - Remove extra logging. + * + * 10.11.2016: 2.0 BETA Release 2.2 - Stop potential executeAction() errors. + * 10.11.2016: 2.0 BETA Release 2.1 - Shift from data to state to hold variables. + * 09.11.2016: 2.0 BETA Release 2 - Try to fix chart Android compatibility. + * 09.11.2016: 2.0 BETA Release 1 - Support for MiHome (Connect) v2.0. Inital version of device. + */ +metadata { + definition (name: "MiHome Monitor", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.switch", mnmn: "SmartThings", vid: "generic-switch-power") { + capability "Polling" + capability "Power Meter" + capability "Refresh" + capability "Sensor" + capability "Health Check" + } + + tiles(scale: 2) { + multiAttributeTile(name:"power", type:"generic", width:6, height:4, canChangeIcon: true) { + tileAttribute("device.power", key: "PRIMARY_CONTROL") { + attributeState "default", label: '${currentValue} W', icon:"st.Appliances.appliances17", backgroundColor:"#69b62c" + } + } + + valueTile("totalPower", "device.totalPower", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Today Total Power:\n${currentValue} Wh' + } + + valueTile("yesterdayTotalPower", "device.yesterdayTotalPower", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Yesterday Total Power:\n${currentValue} Wh' + } + + standardTile("refresh", "device.power", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh" + } + + htmlTile(name:"chartHTML", action: "getChartHTML", width: 6, height: 4, whiteList: ["www.gstatic.com", "raw.githubusercontent.com"]) + + main (["power"]) + details(["power", "totalPower", "yesterdayTotalPower", "chartHTML", "refresh"]) + } +} + +mappings { + path("/getChartHTML") {action: [GET: "getChartHTML"]} +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'power' attribute + +} + +// handle commands +def installed() { + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def poll() { + log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}" + + def resp = parent.apiGET("/subdevices/show?params=" + URLEncoder.encode(new groovy.json.JsonBuilder([id: device.deviceNetworkId.toInteger()]).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + return [] + } + + def real_power = resp.data.data.real_power + //def real_power = Math.abs(new Random().nextInt() % 600 + 1) + if (real_power != null) { + sendEvent(name: "power", value: real_power as BigDecimal, unit: "Wh") + } + + def today_wh = resp.data.data.today_wh + // def today_wh = Math.abs(new Random().nextInt() % 3000 + 1) + if (today_wh != null) { + + //Calculate change of day + def df = new java.text.SimpleDateFormat("D") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("Europe/London")) + } + def currentDay = df.format(new Date()).toInteger() + def changeOfDay = false + if ((state.day != null) && (currentDay != state.day)) { + changeOfDay = true + } + state.day = currentDay + + if (state.last_wh_reading != null) { + //Determine when the days total wh reading has been reset and store as chart data. + if (((today_wh as BigDecimal) < state.last_wh_reading) || (today_wh == 0 && changeOfDay)) { + addYesterdayTotalToChartData(state.last_wh_reading) + sendEvent(name: "yesterdayTotalPower", value: state.last_wh_reading, unit: "Wh") + } + } + state.last_wh_reading = today_wh as BigDecimal + sendEvent(name: "totalPower", value: today_wh as BigDecimal, unit: "Wh") + addCurrentTotalToChartData(today_wh as BigDecimal) + } +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} + +def addYesterdayTotalToChartData(total) { + if (state.chartData == null) { + state.chartData = [0, total, 0, 0, 0, 0, 0] + } + else { + state.chartData.putAt(0, total) + state.chartData.add(0, 0) + state.chartData.pop() + } +} + +def addCurrentTotalToChartData(total) { + if (state.chartData == null) { + state.chartData = [total, 0, 0, 0, 0, 0, 0] + } + state.chartData.putAt(0, total) +} + +def getChartHTML() { + try { + def date = new Date() + if (state.chartData == null) { + state.chartData = [0, 0, 0, 0, 0, 0, 0] + } + def topValue = state.chartData.max() + def hData = "" + if (state.last_wh_reading != null && state.last_wh_reading > 0) { + hData = """ +

Historical Usage


+
+ """ + } else { + hData = """ +
+

Not enough data to create power history chart.

+

Currently collecting data. Come back in a couple of hours.

+
+ """ + } + + def mainHtml = """ + + + + + + + + + + + + + + + + ${hData} + + + """ + render contentType: "text/html", data: mainHtml, status: 200 + } + catch (ex) { + log.error "getChartHTML Exception:", ex + } +} + +def getCssData() { + def cssData = null + def htmlInfo + state.cssData = null + + if(htmlInfo?.cssUrl && htmlInfo?.cssVer) { + if(state?.cssData) { + if (state?.cssVer?.toInteger() == htmlInfo?.cssVer?.toInteger()) { + cssData = state?.cssData + } else if (state?.cssVer?.toInteger() < htmlInfo?.cssVer?.toInteger()) { + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state?.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + cssData = getFileBase64(cssUrl(), "text", "css") + } + return cssData +} + +def getFileBase64(url, preType, fileType) { + try { + def params = [ + uri: url, + contentType: '$preType/$fileType' + ] + httpGet(params) { resp -> + if(resp.data) { + def respData = resp?.data + ByteArrayOutputStream bos = new ByteArrayOutputStream() + int len + int size = 4096 + byte[] buf = new byte[size] + while ((len = respData.read(buf, 0, size)) != -1) + bos.write(buf, 0, len) + buf = bos.toByteArray() + String s = buf?.encodeBase64() + return s ? "data:${preType}/${fileType};base64,${s.toString()}" : null + } + } + } + catch (ex) { + log.error "getFileBase64 Exception:", ex + } +} + +def cssUrl() { return "https://raw.githubusercontent.com/desertblade/ST-HTMLTile-Framework/master/css/smartthings.css" } \ No newline at end of file diff --git a/devicetypes/alyc100/mihome-motion-sensor.src/mihome-motion-sensor.groovy b/devicetypes/alyc100/mihome-motion-sensor.src/mihome-motion-sensor.groovy new file mode 100644 index 00000000000..4d26bb4229d --- /dev/null +++ b/devicetypes/alyc100/mihome-motion-sensor.src/mihome-motion-sensor.groovy @@ -0,0 +1,82 @@ +/** + * MiHome Motion Sensor + * + * Copyright 2016 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 10.10.2018: 2.1 - Compatibility with New Smartthings App. + * 23.11.2016: 2.0 - Remove BETA status. + * + * 08.11.2016: 2.0 BETA Release 1 - Support for MiHome (Connect) v2.0. Initial version of device. + * + */ +metadata { + definition (name: "MiHome Motion Sensor", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.switch", mnmn: "SmartThings", vid: "generic-motion") { + capability "Motion Sensor" + capability "Polling" + capability "Refresh" + capability "Sensor" + capability "Health Check" + } + + tiles(scale: 2) { + multiAttributeTile(name:"motion", type: "generic", width: 6, height: 4){ + tileAttribute ("device.motion", key: "PRIMARY_CONTROL") { + attributeState "active", label:'motion', icon:"st.motion.motion.active", backgroundColor:"#53a7c0" + attributeState "inactive", label:'no motion', icon:"st.motion.motion.inactive", backgroundColor:"#ffffff" + } + } + + standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + + main "motion" + details(["motion", "refresh"]) + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'motion' attribute + +} + +// handle commands +def installed() { + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def poll() { + log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}" + + def resp = parent.apiGET("/subdevices/show?params=" + URLEncoder.encode(new groovy.json.JsonBuilder([id: device.deviceNetworkId.toInteger()]).toString())) + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + return [] + } + //log.debug resp.data.data + def sensor_state = resp.data.data.sensor_state + if (sensor_state != null) { + sendEvent(name: "motion", value: sensor_state == 0 ? "inactive" : "active") + } +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} \ No newline at end of file diff --git a/devicetypes/alyc100/mihome_etrv_icon.png b/devicetypes/alyc100/mihome_etrv_icon.png new file mode 100644 index 00000000000..937eb23b0c0 Binary files /dev/null and b/devicetypes/alyc100/mihome_etrv_icon.png differ diff --git a/devicetypes/alyc100/neato-botvac-connected-series.src/neato-botvac-connected-series.groovy b/devicetypes/alyc100/neato-botvac-connected-series.src/neato-botvac-connected-series.groovy new file mode 100644 index 00000000000..44b378eb684 --- /dev/null +++ b/devicetypes/alyc100/neato-botvac-connected-series.src/neato-botvac-connected-series.groovy @@ -0,0 +1,936 @@ +/** + * Neato Botvac Connected Series + * + * Copyright 2017,2018,2019,2020 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * + * 09-12-2020: 1.15 - New Smartthings UI. Thanks to @cscheiene for the work. + * 19-10-2020: 1.14b - Update VID to device handler for battery status in new Smartthings app. + * 13-10-2020: 1.14 - Move persistent map and turbo mode tiles to device settings. + * 07-04-2020: 1.13b - Handle regularly changing secret key from Neato API. + * 05-09-2019: 1.13 - Handle new Long Secret Key format for future Neato Botvac firmware. + * 23-12-2018: 1.12b - Fix to better support future D3/D5 firmware updates. + * 20-12-2018: 1.12 - UI improvements. Support for persistent map for D3 firmware v4.3+. + * 11-11-2018: 1.11 - Add support for turbo clean for D5. + * 01-11-2018: 1.10b - Bug fix. Stop double update of dock status on poll. + * 08-10-2018: 1.10 - Initial compatibility with New Smartthings App. + * 23-09-2018: 1.9.2b - Reduce the CAPS on Android tile labels. + * 21-09-2018: 1.9.2 - Support for D4 and D6 models. Replace Neato logo with empty icon for unsupport feature tiles. + * 18-04-2018: 1.9.1 - Enable methods to enable WebCORE to set botvac mode. + * 18-04-2018: 1.9b - Incorrect Persistent Map mode label fix. + * 18-04-2018: 1.9 - Support for D7 persistent map cleaning and deep cleaning mode. + * 15-04-2018: 1.8.1 - Fix support for D7 with houseCleaning basic-3 support. + * 21-12-2017: 1.8 - Add map support for D3 and D5 models with firmware V4x + * 06-09-2017: 1.7b - D7 remove navigation mode it's not supported. + * 06-09-2017: 1.7a - Fix support for D7 Eco/Turbo. + * 06-09-2017: 1.7 - Add support for D5 Extra Care. Add support for D7 Eco/Turbo. + * 06-09-2017: 1.6 - Add support for D7 including Maps and Find Me. + * + * 31-03-2017: 1.5.1b - Add actuator capability for ACTION TILES compatability. + * 24-01-2017: 1.5.1 - Sq ft display on maps and stats. + * + * 24-01-2017: 1.5b - Better error handling for maps. + * 17-01-2017: 1.5 - Find Me support and stats reporting for D5. Minor tweaks to stats table formatting. + * + * 12-01-2017: 1.4b - Time zones!. + * 12-01-2017: 1.4 - Cleaning map view functionality. + * + * 13-12-2016: 1.3b - Attempt to stop Null Pointer on 1.3b. + * 13-12-2016: 1.3 - Added compatability with newer Botvac models with firmware 3.x. + * + * 12-12-2016: 1.2.2c - Bug fix. Prevent NULL error on result.error string. + * + * 01-11-2016: 1.2.2b - Bug fix. Stop disabling Neato Schedule even when SmartSchedule is off. + * 26-10-2016: 1.2.2 - Turn off 'searching' status when Botvac is idle. More information for activity feed. + * + * 25-10-2016: 1.2.1 - New device tile to change cleaning mode. Icon refactor. + * + * 25-10-2016: 1.2b - Very silly bug fix. Clean mode always reporting as Eco. Added display cleaning mode in Device Handler. + * 23-10-2016: 1.2 - Add option to select Turbo or Eco clean modes + * + * 20-10-2016: 1.1b - Minor display tweak for offline condition. + * 20-10-2016: 1.1 - Added smart schedule and force clean status messages. Added smart schedule reset button. + * Disable Neato Robot Schedule if SmartSchedule is enabled. + * + * 14-10-2016: 1.0 - Initial Version + * + */ +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.InvalidKeyException; + +preferences +{ + input( "prefCleaningMode", "enum", options: ["turbo", "eco", "webcore"], title: "Cleaning Mode", description: "Only supported on certain models", required: true, defaultValue: "turbo" ) + input( "prefNavigationMode", "enum", options: ["standard", "extraCare", "deep", "webcore"], title: "Navigation Mode", description: "Only supported on certain models", required: true, defaultValue: "standard" ) + input( "prefPersistentMapMode", "enum", options: ["on", "off", "webcore"], title: "Use Persistent Map", description: "Only supported on certain models", required: false, defaultValue: false ) +} + +metadata { + definition (name: "Neato Botvac Connected Series", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.robotcleaner", mnmn: "SmartThingsCommunity", vid: "1b47ad78-269e-3c5c-a1a9-8c84d2a2ef05") { + capability "Battery" + capability "Polling" + capability "Refresh" + capability "Switch" + capability "Actuator" + capability "Health Check" + capability "islandtravel33177.neatoBin" + capability "islandtravel33177.neatoNetwork" + capability "islandtravel33177.neatoStatus" + capability "islandtravel33177.neatoDockStatus" + capability "islandtravel33177.neatoCharging" + capability "islandtravel33177.neatoStatusMsg" + + command "refresh" + command "dock" + command "enableSchedule" + command "disableSchedule" + command "resetSmartSchedule" + command "setCleaningMode", ["string"] + command "setNavigationMode", ["string"] + command "setPersistentMapMode", ["string"] + command "setSecretKey", ["string"] + command "findMe" + } + + simulator { + // TODO: define status and reply messages here + } + + tiles(scale: 2) { + multiAttributeTile(name: "clean", width: 6, height: 4, type:"lighting") { + tileAttribute("device.status", key:"PRIMARY_CONTROL", canChangeBackground: true){ + attributeState("off", label: 'PAUSED', action: "switch.on", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png", backgroundColor: "#ffffff", nextState:"on") + attributeState("docked", label: 'DOCKED', action: "switch.on", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/auto-charge-resume.png", backgroundColor: "#ffffff", nextState:"on") + attributeState("docking", label: 'DOCKING', action: "switch.on", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png", backgroundColor: "#ffffff", nextState:"on") + attributeState("cleaning", label: 'CLEANING', action: "switch.off", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/best-pet-hair-cleaning.png", backgroundColor: "#79b821", nextState:"off") + attributeState("error", label:'ERROR', icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png", backgroundColor: "#FF0000") + attributeState("offline", label:'${name}', icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png", backgroundColor:"#bc2323") + } + tileAttribute ("statusMsg", key: "SECONDARY_CONTROL") { + attributeState "statusMsg", label:'${currentValue}' + } + } + valueTile("smartScheduleStatusMessage", "device.smartScheduleStatusMessage", decoration: "flat", width: 3, height: 1) { + state "default", label: '${currentValue}' + } + + valueTile("forceCleanStatusMessage", "device.forceCleanStatusMessage", decoration: "flat", width: 3, height: 1) { + state "default", label: '${currentValue}' + } + valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) { + state "battery", label:'${currentValue}% battery', unit:"" + } + standardTile("charging", "device.charging", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("true", label:'Charging', icon: "st.samsung.da.RC_ic_charge", backgroundColor: "#E5E500") + state ("false", label:'', icon: "st.samsung.da.RC_ic_charge") + } + standardTile("bin", "device.bin", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("default", label:'unknown', icon: "st.unknown.unknown.unknown") + state ("empty", label:'Bin Empty', icon: "st.Office.office10",backgroundColor: "#79b821") + state ("full", label:'Bin Full', icon: "st.Office.office10", backgroundColor: "#bc2323") + } + standardTile("network", "device.network", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("default", label:'unknown', icon: "st.unknown.unknown.unknown") + state ("Connected", label:'Online', icon: "st.Health & Wellness.health9", backgroundColor: "#79b821") + state ("Not Connected", label:'Offline', icon: "st.Health & Wellness.health9", backgroundColor: "#bc2323") + } + standardTile("refresh", "device.switch", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false, decoration: "flat") { + state("default", label:'refresh', action:"refresh.refresh", icon:"st.secondary.refresh-icon") + } + standardTile("status", "device.status", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("unknown", label:'Unknown', icon: "st.unknown.unknown.unknown") + state ("cleaning", label:'Cleaning', icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/best-pet-hair-cleaning.png") + state ("ready", label:'Ready', icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png") + state ("error", label:'Error', icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png", backgroundColor: "#bc2323") + state ("paused", label:'Paused', icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png") + } + + standardTile("dockStatus", "device.dockStatus", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false, decoration: "flat") { + state ("docked", label:'Docked', icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/auto-charge-resume.png") + state ("dockable", label:'Dock', action: "dock", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato_staub.png") + state ("undocked", label:'Undocked', icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png") + } + + standardTile("scheduled", "device.scheduled", width: 2, height: 2, decoration: "flat") { + state ("true", label:'Neato Sched On', action: "disableSchedule", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato_schedule_icon.png") + state ("false", label:'Neato Sched Off', action: "enableSchedule", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato_no_schedule_icon.png") + } + + standardTile("dockHasBeenSeen", "device.dockHasBeenSeen", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("true", label:'Seen', backgroundColor: "#79b821", icon:"st.Transportation.transportation13") + state ("false", label:'Searching', backgroundColor: "#E5E500", icon:"st.Transportation.transportation13") + state ("idle", label:'', icon:"st.Transportation.transportation13") + } + + standardTile("resetSmartSchedule", "device.resetSmartSchedule", inactiveLabel: false, width: 2, height: 2, decoration: "flat") { + state("default", label:'reset schedule', action:"resetSmartSchedule", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/reset_schedule_icon.png") + } + + standardTile("switch", "device.switch", width: 2, height: 2, decoration: "flat") { + state("off", label: 'STOPPED', action: "switch.on", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png", backgroundColor: "#ffffff", nextState:"on") + state("on", label: 'CLEANING', action: "switch.off", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/best-pet-hair-cleaning.png", backgroundColor: "#79b821", nextState:"off") + state("offline", label:'${name}', icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/laser-guided-navigation.png", backgroundColor:"#bc2323") + } + + htmlTile(name:"mapHTML", action: "getMapHTML", width: 6, height: 9, whiteList: ["neatorobotics.s3.amazonaws.com", "raw.githubusercontent.com"]) + + main("switch") + details(["clean","smartScheduleStatusMessage", "forceCleanStatusMessage", "status", "battery", "charging", "bin", "dockStatus", "dockHasBeenSeen", "scheduled", "resetSmartSchedule", "network", "refresh", "mapHTML"]) + } +} + +mappings { + path("/getMapHTML") {action: [GET: "getMapHTML"]} +} + +// handle commands + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + log.debug "Updated with settings: ${settings}" + initialize() + sendEvent(name: "checkInterval", value: 10 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def initialize() { + if (state.startCleaningMode == null) { + state.startCleaningMode = "turbo" + } + if (state.startNavigationMode == null) { + state.startNavigationMode = "standard" + } + if (state.startPersistentMapMode == null) { + state.startPersistentMapMode = "off" + } + poll() +} + +def on() { + log.debug "Executing 'on'" + def currentState = device.latestState('status').stringValue + if (currentState == 'paused') { + nucleoPOST("/messages", '{"reqId":"1", "cmd":"resumeCleaning"}') + } + else if (currentState != 'error') { + def modeParam = 1 + def navParam = 1 + def catParam = 2 + if (isTurboCleanMode()) modeParam = 2 + if (isExtraCareNavigationMode()) navParam = 2 + if (isDeepNavigationMode()) { + modeParam = 2 + navParam = 3 + } + if (isPersistentMapMode()) catParam = 4 + switch (state.houseCleaning) { + case "basic-1": + nucleoPOST("/messages", '{"reqId":"1", "cmd":"startCleaning", "params":{"category": 2, "mode": ' + modeParam + ', "modifier": 1}}') + break; + case "minimal-2": + nucleoPOST("/messages", '{"reqId":"1", "cmd":"startCleaning", "params":{"category": 2, "navigationMode": ' + navParam + '}}') + break; + default: + nucleoPOST("/messages", '{"reqId":"1", "cmd":"startCleaning", "params":{"category": ' + catParam + ', "mode": ' + modeParam + ', "navigationMode": ' + navParam + '}}') + break; + } + } + runIn(2, refresh) +} + +def off() { + log.debug "Executing 'off'" + def currentState = device.latestState('status').stringValue + if (currentState == 'cleaning' || currentState == 'error') { + nucleoPOST("/messages", '{"reqId":"1", "cmd":"pauseCleaning"}') + } + runIn(2, refresh) +} + +def dock() { + log.debug "Executing 'dock'" + if (device.latestState('status').stringValue == 'paused') { + nucleoPOST("/messages", '{"reqId":"1", "cmd":"sendToBase"}') + } + runIn(2, refresh) +} + +def enableSchedule() { + log.debug "Executing 'enableSchedule'" + nucleoPOST("/messages", '{"reqId":"1", "cmd":"enableSchedule"}') + runIn(2, refresh) +} + +def disableSchedule() { + log.debug "Executing 'disableSchedule'" + nucleoPOST("/messages", '{"reqId":"1", "cmd":"disableSchedule"}') + runIn(2, refresh) +} + +def findMe() { + log.debug "Executing 'findMe'" + nucleoPOST("/messages", '{"reqId":"1", "cmd": "findMe"}') +} + +def setOffline() { + sendEvent(name: 'network', value: "Not Connected" as String) + sendEvent(name: "switch", value: "offline") +} + +def resetSmartSchedule() { + log.debug "Executing 'resetSmartSchedule'" + parent.resetSmartScheduleForDevice(device.deviceNetworkId) + runIn(2, refresh) +} + +//Methods to support WebCORE mode set +def setCleaningMode(mode) { + if ( mode == "eco" || mode == "turbo" ) { + state.startCleaningMode = mode + } else { + log.error("Unsupported cleaning mode: [${mode}]") + } +} + +def setNavigationMode(mode) { + if ( mode == "deep" || mode == "extraCare" || mode == "standard") { + state.startNavigationMode = mode + } else { + log.error("Unsupported navigation mode: [${mode}]") + } +} + +def setPersistentMapMode(mode) { + if ( mode == "on" || mode == "off" ) { + state.startPersistentMapMode = mode + } else { + log.error("Unsupported persistent map mode: [${mode}]") + } +} + +def poll() { + log.debug "Executing 'poll'" + def resp = nucleoPOST("/messages", '{"reqId":"1", "cmd":"getRobotState"}') + def result = resp.data + def statusMsg = "" + def binFullFlag = false + if (resp.status != 200) { + if (result.containsKey("message")) { + switch (result.message) { + case "Could not find robot_serial for specified vendor_name": + statusMsg += 'Robot serial and/or secret is not correct.\n' + break; + } + } + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + setOffline() + sendEvent(name: 'status', value: "error" as String) + statusMsg += 'Not Connected To Neato' + log.debug headerString + } + else { + if (result.containsKey("meta")) { + state.firmware = result.meta.firmware + state.modelName = result.meta.modelName + } + if (result.containsKey("availableServices")) { + state.houseCleaning = result.availableServices.houseCleaning + } + if (result.containsKey("state")) { + sendEvent(name: 'network', value: "Connected" as String) + //state 1 - Ready to clean + //state 2 - Cleaning + //state 3 - Paused + //state 4 - Error + switch (result.state) { + case "1": + sendEvent(name: "status", value: "ready") + sendEvent(name: "switch", value: "off") + if (state.firmware.startsWith("2")) { + statusMsg += "READY TO ${isTurboCleanMode() ? "TURBO" : "ECO"} CLEAN" + } else { + statusMsg += "READY TO CLEAN" + } + break; + case "2": + sendEvent(name: "status", value: "cleaning") + sendEvent(name: "switch", value: "on") + if (state.firmware.startsWith("2")) { + statusMsg += "CURRENTLY ${isTurboCleanMode() ? "TURBO" : "ECO"} CLEANING" + } else { + statusMsg += "CURRENTLY CLEANING" + } + break; + case "3": + sendEvent(name: "status", value: "paused") + sendEvent(name: "switch", value: "off") + statusMsg += 'PAUSED' + def t = parent.autoDockDelayValue() + if (t != -1) { statusMsg += " - Auto dock set to $t seconds." } + break; + case "4": + sendEvent(name: "status", value: "error") + statusMsg += 'HAS A PROBLEM' + break; + default: + sendEvent(name: "status", value: "unknown") + statusMsg += 'UNKNOWN' + break; + } + } + if (state.firmware.startsWith("2") && result.containsKey("error")) { + switch (result.error) { + case "ui_alert_dust_bin_full": + binFullFlag = true + break; + case "ui_alert_return_to_base": + statusMsg += ' - Returning to base' + break; + case "ui_alert_return_to_start": + statusMsg += ' - Returning to start' + break; + case "ui_alert_return_to_charge": + statusMsg += ' - Returning to charge' + break; + case "ui_alert_busy_charging": + statusMsg += ' - Busy charging' + break; + case "ui_alert_recovering_location": + statusMsg += ' - Recovering Location' + break; + case "ui_error_dust_bin_full": + binFullFlag = true + statusMsg += ' - Dust bin full!' + break; + case "ui_error_picked_up": + statusMsg += ' - Picked up!' + break; + case "ui_error_brush_stuck": + statusMsg += ' - Brush stuck!' + break; + case "ui_error_stuck": + statusMsg += ' - I\'m stuck!' + break; + case "ui_error_dust_bin_missing": + statusMsg += ' - Dust Bin is missing!' + break + case "ui_error_navigation_falling": + statusMsg += ' - Please clear my path!' + break + case "ui_error_navigation_noprogress": + statusMsg += ' - Please clear my path!' + break + case "ui_error_battery_overtemp": + statusMsg += ' - Battery is overheating!' + break + case "ui_error_unable_to_return_to_base": + statusMsg += ' - Unable to return to base!' + break + case "ui_error_bumper_stuck": + statusMsg += ' - Bumper stuck!' + break + case "ui_error_lwheel_stuck": + statusMsg += ' - Left wheel stuck!' + break + case "ui_error_rwheel_stuck": + statusMsg += ' - Right wheel stuck!' + break + case "ui_error_lds_jammed": + statusMsg += ' - LIDAR jammed!' + break + case "ui_error_brush_overload": + statusMsg += ' - Brush overloaded!' + break + case "ui_error_hardware_failure": + statusMsg += ' - Hardware Failure!' + break + case "ui_error_unable_to_see": + statusMsg += ' - Unable to see!' + break + case "ui_error_rdrop_stuck": + statusMsg += ' - Right drop stuck!' + break + case "ui_error_ldrop_stuck": + statusMsg += ' - Left drop stuck!' + break + default: + if ("ui_alert_invalid" != result.error) { + statusMsg += ' - ' + result.error.replaceAll('ui_error_', '').replaceAll('ui_alert_', '').replaceAll('_',' ').capitalize() + } + break; + //More error detail messages here as discovered + } + } + if (state.firmware.startsWith("3") && result.containsKey("error")) { + if (result.error) { + if (result.error == "dustbin_full") { + binFullFlag = true + statusMsg += ' - Dust bin full!' + } + else { + statusMsg += ' - ' + result.error.replaceAll('_',' ').capitalize() + } + } + } + if (state.firmware.startsWith("3") && result.containsKey("alert")) { + if (result.alert) { + if (result.alert == "dustbin_full") { + binFullFlag = true + } + } + } + + //Tile configuration for models + if (state.modelName == "BotVacD7Connected" || state.modelName == "BotVacD6Connected" || (state.modelName == "BotVacD5Connected" && isD3D5SupportedFirmwareVersion(state.firmware)) || state.modelName == "BotVacD4Connected" || (state.modelName == "BotVacD3Connected" && isD3D5SupportedFirmwareVersion(state.firmware)) ) { + //Neato Botvac D7, D6, D5 (firmware 4.3 and above), D4, D3 (firmware 4.3 and above) + //Leave empty for future feature support + } else if (state.modelName == "BotVacD5Connected") { + //Neato Botvac D5 + state.startCleaningMode = "unsupported" + state.startPersistentMapMode = "unsupported" + } else if (state.modelName == "BotVacD3Connected") { + //Neato Botvac D3 + state.startCleaningMode = "unsupported" + state.startPersistentMapMode = "unsupported" + } else { + //Neato Botvac Connected + state.startNavigationMode = "unsupported" + state.startPersistentMapMode = "unsupported" + } + + if (result.containsKey("details")) { + if (result.details.isDocked) { + sendEvent(name: 'dockStatus', value: "docked" as String) + } else { + sendEvent(name: 'dockStatus', value: "undocked" as String) + } + sendEvent(name: 'charging', value: result.details.isCharging as String) + sendEvent(name: 'scheduled', value: result.details.isScheduleEnabled as String) + //If Botvac is idle, set dock has been seen status to idle and ignore API result + if (result.state as String == "1") { + sendEvent(name: 'dockHasBeenSeen', value: "idle", displayed: false) + } else { + sendEvent(name: 'dockHasBeenSeen', value: result.details.dockHasBeenSeen as String) + } + sendEvent(name: 'battery', value: result.details.charge as Integer) + } + if (result.containsKey("availableCommands") && (device.currentState("switch").getValue() == "off")) { + if (result.availableCommands.goToBase) { + sendEvent(name: 'dockStatus', value: "dockable") + } + } + if (binFullFlag) { + sendEvent(name: 'bin', value: "full" as String) + } else { + sendEvent(name: 'bin', value: "empty" as String) + } + def smartScheduleStatus = "" + def t = parent.timeToSmartScheduleClean(device.deviceNetworkId) + if (t != -1) { + if (t >= 86400000) { + smartScheduleStatus += "SmartSchedule activating in ${Math.round(new BigDecimal(t/86400000)).toString()} days." + } else if ((t >= 0) && (t <= 86400000)) { + smartScheduleStatus += "SmartSchedule activating in ${Math.round(new BigDecimal(t/3600000)).toString()} hours." + } else { + smartScheduleStatus += "SmartSchedule waiting for configured trigger." + } + } else { + smartScheduleStatus += "SmartSchedule is disabled. Configure in Neato (Connect) smart app." + } + def forceCleanStatus = "" + t = parent.timeToForceClean(device.deviceNetworkId) + if (t != -1) { + if (t >= 86400000) { + forceCleanStatus += "Force clean due in ${Math.round(new BigDecimal(t/86400000)).toString()} days." + } else if ((t >= 0) && (t <= 86400000)) { + forceCleanStatus += "Force clean due in ${Math.round(new BigDecimal(t/3600000)).toString()} hours." + } else { + forceCleanStatus += "Force clean imminent." + } + } else { + forceCleanStatus += "Force clean is disabled. Configure in Neato (Connect) smart app." + } + sendEvent(name: 'smartScheduleStatusMessage', value: smartScheduleStatus, displayed: false) + sendEvent(name: 'forceCleanStatusMessage', value: forceCleanStatus, displayed: false) + } + sendEvent(name: 'statusMsg', value: statusMsg, displayed: false) + + //Create verbose updates on activity feed. + def statusMsgTokenized = statusMsg.tokenize('-') + if (statusMsgTokenized.size() > 1) { + def infoMsg = statusMsg.tokenize('-')[statusMsgTokenized.size() - 1].substring(1) + sendEvent(name: "botvacInfo", value: "${infoMsg}", displayed: true, linkText: "${device.displayName}", descriptionText: "${infoMsg}") + } + + //If smart schedule is enabled, disable Neato schedule to avoid conflict + if (parent.isSmartScheduleEnabled() && result.details.isScheduleEnabled) { + log.debug "Disable Neato scheduling system as SmartSchedule is enabled" + nucleoPOST("/messages", '{"reqId":"1", "cmd":"disableSchedule"}') + sendEvent(name: 'scheduled', value: "false") + } +} + +def refresh() { + log.debug "Executing 'refresh'" + if (parent.getSecretKey(device.deviceNetworkId) == null) { + //Issue notification + parent.messageHandler("IMPORTANT: Update to Neato(Connect) and device handler required for new Neato firmware:\n1. Remove all Neato device smart app dependencies.\n2.Remove Neato devices.\n3. Update SmartApp to Neato (Connect) v1.2.5 or later and Device Handler to Neato Connected Series v1.13 or later from Github.\n4. Re-add your Neato Botvacs.\n5. Reconfigure your SmartSchedules as they will probably be deleted." ,true) + } + poll() +} + +private def isTurboCleanMode() { + def result = true + if ((state.startCleaningMode == "unsupported") || (state.startCleaningMode != null && state.startCleaningMode == "eco" && settings.prefCleaningMode == "webcore") || (settings.prefCleaningMode == "eco")) { + result = false + } + result +} + +private def isExtraCareNavigationMode() { + def result = false + if ((state.startNavigationMode == "unsupported") || (state.startNavigationMode != null && state.startNavigationMode == "extraCare" && settings.prefCleaningMode == "webcore") || (settings.prefNavigationMode == "extraCare")) { + result = true + } + result +} + +private def isDeepNavigationMode() { + def result = false + if ((state.startNavigationMode == "unsupported") || (state.startNavigationMode != null && state.startNavigationMode == "deep" && settings.prefCleaningMode == "webcore") || (settings.prefNavigationMode == "deep")) { + result = true + } + result +} + +private def isPersistentMapMode() { + def result = false + if ((state.startPersistentMapMode == "unsupported") || (settings.prefPersistentMapMode == "on") || (state.startPersistentMapMode != null && state.startPersistentMapMode == "on")) { + result = true + } + result +} + +private def isD3D5SupportedFirmwareVersion(firmware) { + def result = false + def value = firmware.substring(0,3) as BigDecimal + if (value >= 4.3) { + result = true + } + result +} + +def nucleoPOST(path, body) { + try { + log.debug("Beginning API POST: ${nucleoURL(path)}, ${body}") + def date = new Date().format("EEE, dd MMM yyyy HH:mm:ss z", TimeZone.getTimeZone('GMT')) + httpPostJson(uri: nucleoURL(path), body: body, headers: nucleoRequestHeaders(date, getHMACSignature(date, body)) ) {response -> + parent.logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + parent.logResponse(e.response) + return e.response + } +} + +def getHMACSignature(date, body) { + //request params + def robot_serial = device.deviceNetworkId + //Format date should be "Fri, 03 Apr 2015 09:12:31 GMT" + + def robot_secret_key = parent.getSecretKey(device.deviceNetworkId) + // build string to be signed + def string_to_sign = "${robot_serial.toLowerCase()}\n${date}\n${body}" + + // create signature with SHA256 + //signature = OpenSSL::HMAC.hexdigest('sha256', robot_secret_key, string_to_sign) + try { + Mac mac = Mac.getInstance("HmacSHA256") + SecretKeySpec secretKeySpec = new SecretKeySpec(robot_secret_key.getBytes(), "HmacSHA256") + mac.init(secretKeySpec) + byte[] digest = mac.doFinal(string_to_sign.getBytes()) + return digest.encodeHex() + } catch (InvalidKeyException e) { + throw new RuntimeException("Invalid key exception while converting to HMac SHA256") + } +} + +Map nucleoRequestHeaders(date, HMACsignature) { + return [ + 'X-Date': "${date}", + 'Accept': 'application/vnd.neato.nucleo.v1', + 'Content-Type': 'application/*+json', + 'X-Agent': '0.11.3-142', + 'Authorization': "NEATOAPP ${HMACsignature}" + ] +} + +def getMapHTML() { + try { + def df = new java.text.SimpleDateFormat("MMM d, yyyy h:mm a") + if (parent.getTimeZone()) { df.setTimeZone(location.timeZone) } + def resp + def hData = "" + if ((state.modelName == "BotVacD7Connected") || (state.firmware.startsWith("2.2")) || (state.firmware.startsWith("4"))) { + resp = parent.beehiveGET("/users/me/robots/${device.deviceNetworkId.tokenize("|")[0]}/maps") + if (resp.status == 403) { + hData = """ +
+

Neato (Connect) not authorised for Map access.

+

You may need to reauthorize your Neato credentials. Open the Neato(Connect) smart app in the ST mobile app, scroll to the bottom and tap the reauthorize item.

+
+ """ + } else if (resp.status != 200) { + hData = """ +
+

Neato map retrieval failed.

+

Please try again later.

+
+ """ + } else if ((resp.data.maps) && (resp.data.maps.size() > 0)) { + def mapUrl = resp.data.maps[0].url + def generated_at = Date.parse("yyyy-MM-dd'T'HH:mm:ss'Z'", resp.data.maps[0].generated_at) + def cleaned_area = resp.data.maps[0].cleaned_area + def start_at = Date.parse("yyyy-MM-dd'T'HH:mm:ss'Z'", resp.data.maps[0].start_at) + def end_at = Date.parse("yyyy-MM-dd'T'HH:mm:ss'Z'", resp.data.maps[0].end_at) + hData = """ +

Cleaning Map ${df.format(generated_at)}

+
+ + + + + + + + + + + + + +
Area CleanedCleaning Time
${Math.round(cleaned_area * 100) / 100} m² / ${Math.round(cleaned_area * 1076.39) / 100} ft²${getCleaningTime(start_at, end_at)} hours
+ + + + + + + + + + + + + +
StatusLaunched From
${resp.data.maps[0].status.capitalize()}${resp.data.maps[0].launched_from.capitalize()}
+
+ """ + } else { + hData = """ +
+

No map available yet.

+

Complete at least one house cleaning to view maps

+
+ """ + } + } else if (state.modelName == "BotVacD5Connected"){ + resp = nucleoPOST("/messages", '{"reqId":"1", "cmd":"getLocalStats"}') + def cleaned_area = resp.data.data.houseCleaning.history[0].area + def start_at = Date.parse("yyyy-MM-dd'T'HH:mm:ss'Z'", resp.data.data.houseCleaning.history[0].start) + def end_at = Date.parse("yyyy-MM-dd'T'HH:mm:ss'Z'", resp.data.data.houseCleaning.history[0].end) + hData = """ +

Statistics ${df.format(end_at)}

+
+

Please update your Botvac firmware via the Neato mobile app to get cleaning map functionality.

+
+ + + + + + + + + + + + + +
Area CleanedCleaning Time
${Math.round(cleaned_area * 100) / 100} m² / ${Math.round(cleaned_area * 1076.39) / 100} ft²${getCleaningTime(start_at, end_at)} hours
+ + + + + + + + + + + + + +
CompletedLaunched From
${resp.data.data.houseCleaning.history[0].completed ? "Yes" : "No"}${resp.data.data.houseCleaning.history[0].launchedFrom.capitalize()}

+

Lifetime Statistics

+ + + + + + + + + + + + + +
Total Cleaned AreaAverage Cleaned Area
${Math.round(resp.data.data.houseCleaning.totalCleanedArea * 100) / 100} m² / ${Math.round(resp.data.data.houseCleaning.totalCleanedArea * 1076.39) / 100} ft²${Math.round(resp.data.data.houseCleaning.averageCleanedArea * 100) / 100} m² / ${Math.round(resp.data.data.houseCleaning.averageCleanedArea * 1076.39) / 100} ft²
+ + + + + + + + + + + + + +
Total Cleaning TimeAverage Cleaning Time
${convertSecondsToTime(resp.data.data.houseCleaning.totalCleaningTime)} hours${convertSecondsToTime(resp.data.data.houseCleaning.averageCleaningTime)} hours
+ """ + } else if (state.firmware.startsWith("2")) { + hData = """ +

Update Firmware

+
+

Cleaning maps only supported on Neato Botvac Connected models with firmware v2.2.0 or later.

+

If you have Neato Botvac Connected, ensure you update firmware to v2.2.0 or later.

+
+ """ + } else { + hData = """ +

Neato Botvac D3 Connected

+
+

Please update your Botvac firmware via the Neato mobile app to get cleaning map functionality.

+
+ """ + } + + def mainHtml = """ + + + + + + + + + + + + + ${hData} + + + """ + render contentType: "text/html", data: mainHtml, status: 200 + } + catch (ex) { + log.error "getMapHTML Exception:", ex + } +} + +//Helper methods +private def getCleaningTime(start_at, end_at) { + def diff = end_at.getTime() - start_at.getTime() + def hour = (diff / 3600000) as Integer + def minute = Math.round((diff - (hour * 3600000)) / 60000) as Integer + + def hourString = (hour < 10) ? "0$hour" : "$hour" + def minuteString = (minute < 10) ? "0$minute" : "$minute" + + return "${hourString}:${minuteString}" +} + +private def convertSecondsToTime(seconds) { + def hour = (seconds / 3600) as Integer + def minute = (seconds - (hour * 3600)) / 60 as Integer + + def hourString = (hour < 10) ? "0$hour" : "$hour" + def minuteString = (minute < 10) ? "0$minute" : "$minute" + + return "${hourString}:${minuteString}" +} + +private def getCssData() { + def cssData = null + def htmlInfo + state.cssData = null + + if(htmlInfo?.cssUrl && htmlInfo?.cssVer) { + if(state?.cssData) { + if (state?.cssVer?.toInteger() == htmlInfo?.cssVer?.toInteger()) { + cssData = state?.cssData + } else if (state?.cssVer?.toInteger() < htmlInfo?.cssVer?.toInteger()) { + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state?.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + cssData = getFileBase64(cssUrl(), "text", "css") + } + return cssData +} + +private def getFileBase64(url, preType, fileType) { + try { + def params = [ + uri: url, + contentType: '$preType/$fileType' + ] + httpGet(params) { resp -> + if(resp.data) { + def respData = resp?.data + ByteArrayOutputStream bos = new ByteArrayOutputStream() + int len + int size = 4096 + byte[] buf = new byte[size] + while ((len = respData.read(buf, 0, size)) != -1) + bos.write(buf, 0, len) + buf = bos.toByteArray() + String s = buf?.encodeBase64() + return s ? "data:${preType}/${fileType};base64,${s.toString()}" : null + } + } + } + catch (ex) { + log.error "getFileBase64 Exception:", ex + } +} + +def cssUrl() { return "https://raw.githubusercontent.com/desertblade/ST-HTMLTile-Framework/master/css/smartthings.css" } +def nucleoURL(path = '/') { return "https://nucleo.neatocloud.com:4443/vendors/neato/robots/${device.deviceNetworkId.tokenize("|")[0]}${path}" } \ No newline at end of file diff --git a/devicetypes/alyc100/neato-icons_1x.png b/devicetypes/alyc100/neato-icons_1x.png new file mode 100644 index 00000000000..a7b225237a8 Binary files /dev/null and b/devicetypes/alyc100/neato-icons_1x.png differ diff --git a/devicetypes/alyc100/neato_botvac_image.png b/devicetypes/alyc100/neato_botvac_image.png new file mode 100644 index 00000000000..e97fca2dfec Binary files /dev/null and b/devicetypes/alyc100/neato_botvac_image.png differ diff --git a/devicetypes/alyc100/neato_charging.png b/devicetypes/alyc100/neato_charging.png new file mode 100644 index 00000000000..0bfb8ddecbc Binary files /dev/null and b/devicetypes/alyc100/neato_charging.png differ diff --git a/devicetypes/alyc100/neato_eco_icon.png b/devicetypes/alyc100/neato_eco_icon.png new file mode 100644 index 00000000000..11fd1177095 Binary files /dev/null and b/devicetypes/alyc100/neato_eco_icon.png differ diff --git a/devicetypes/alyc100/neato_findme_icon.png b/devicetypes/alyc100/neato_findme_icon.png new file mode 100644 index 00000000000..f773239dbbb Binary files /dev/null and b/devicetypes/alyc100/neato_findme_icon.png differ diff --git a/devicetypes/alyc100/neato_floor_icon.png b/devicetypes/alyc100/neato_floor_icon.png new file mode 100644 index 00000000000..37e7231baf5 Binary files /dev/null and b/devicetypes/alyc100/neato_floor_icon.png differ diff --git a/devicetypes/alyc100/neato_logo.png b/devicetypes/alyc100/neato_logo.png new file mode 100644 index 00000000000..d14347a59de Binary files /dev/null and b/devicetypes/alyc100/neato_logo.png differ diff --git a/devicetypes/alyc100/neato_no_schedule_icon.png b/devicetypes/alyc100/neato_no_schedule_icon.png new file mode 100644 index 00000000000..1f3ec81cd91 Binary files /dev/null and b/devicetypes/alyc100/neato_no_schedule_icon.png differ diff --git a/devicetypes/alyc100/neato_notcharging.png b/devicetypes/alyc100/neato_notcharging.png new file mode 100644 index 00000000000..13c959ccd14 Binary files /dev/null and b/devicetypes/alyc100/neato_notcharging.png differ diff --git a/devicetypes/alyc100/neato_schedule_icon.png b/devicetypes/alyc100/neato_schedule_icon.png new file mode 100644 index 00000000000..d3988098aef Binary files /dev/null and b/devicetypes/alyc100/neato_schedule_icon.png differ diff --git a/devicetypes/alyc100/neato_staub.png b/devicetypes/alyc100/neato_staub.png new file mode 100644 index 00000000000..4afea65aa61 Binary files /dev/null and b/devicetypes/alyc100/neato_staub.png differ diff --git a/devicetypes/alyc100/neato_turbo_icon.png b/devicetypes/alyc100/neato_turbo_icon.png new file mode 100644 index 00000000000..891037fd3f1 Binary files /dev/null and b/devicetypes/alyc100/neato_turbo_icon.png differ diff --git a/devicetypes/alyc100/ovo-energy-meter-v2-0.src/ovo-energy-meter-v2-0.groovy b/devicetypes/alyc100/ovo-energy-meter-v2-0.src/ovo-energy-meter-v2-0.groovy new file mode 100644 index 00000000000..11609e85c4a --- /dev/null +++ b/devicetypes/alyc100/ovo-energy-meter-v2-0.src/ovo-energy-meter-v2-0.groovy @@ -0,0 +1,344 @@ +/** + * OVO Energy Meter V2.0 + * + * Copyright 2015,2016,2017,2018 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * v2.0 - Initial V2.0 Release with OVO Energy (Connect) app + * + * v2.1 - Improve pricing calculations using contract info from OVO. Notification framework for high costs. + * Enable alert for specified daily cost level breach. + * v2.1b - Allow cost alert level to be decimal + * + * v2.2 - Percentage comparison from previous cost values added into display + * v2.2.1 - Add current consumption price based on unit price from OVO account API not OVO live API + * v2.2.1b - Remove double negative on percentage values. + * v2.2.2 - Change current hour logic to accommodate GMT/BST. + * v2.2.2b - Alter Simple Date Format hour string + * + * 10.11.2016: v2.3 - Added historical power chart for the last 5 days. + * 10.11.2016: v2.3.1 - Fix chart Android compatibility. + * 11.11.2016: v2.3.2 - Move chart data into state variable + * 11.11.2016: v2.3.3 - Prevent potential executeAction() error when adding device. + * 11.11.2016: v2.3.4 - Migrate variable from data to state. + * 11.11.2016: v2.3.5 - Bug Fix. Silly state variable not initialised on first run. + * 11.11.2016: v2.3.6 - Reduce number of calls to account API. + * 12.11.2016: v2.3.7 - Stop yesterday cost comparison being 0%. + * + * 06.12.2016: v2.4 - Better API failure handling and recovery. Historical and yesterday power feed from OVO API. + * 06.12.2016: v2.4.1 - Relax setting offline mode to 60 minute down time. + * 07.12.2016: v2.4.1b - Handle when OVO API hasn't generated yesterday's total figures at midnight. + * 07.12.2016: v2.4.1c - Add 'Pending' connection status for short API issues + * 11.01.2017: v2.4.2 - Resolve Android issues for Chart data. + * 18.02.2018: v2.4.3 - Enable chart display when API returns corrupt historic data. + * + * 01.06.2018: v2.5 - Remove all live feed tiles as they are now unsupported on OVO. + * - Add yesterday's total power consumption into main tile. + * - Update chart data with historical values only. + * + * 10.10.2018: v2.6 - Compatibility with New Smartthings App + */ + +preferences +{ + input( "costAlertLevel", "decimal", title: "Set cost alert level (£)", description: "Send alert when daily cost reaches amount", required: false, defaultValue: 10.00 ) +} + +metadata { + definition (name: "OVO Energy Meter V2.0", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.switch", mnmn: "SmartThings", vid: "generic-switch-power") { + capability "Polling" + capability "Power Meter" + capability "Refresh" + capability "Sensor" + capability "Health Check" + + attribute "network","string" + } + + tiles(scale: 2) { + multiAttributeTile(name:"power", type:"generic", width:6, height:4, canChangeIcon: true) { + tileAttribute("device.power", key: "PRIMARY_CONTROL") { + attributeState "default", label: '${currentValue} Wh', icon:"st.Appliances.appliances17", backgroundColor:"#0a9928" + } + } + + valueTile("consumptionPrice", "device.consumptionPrice", decoration: "flat", width: 3, height: 2) { + state "default", label: 'Curr. Cost:\n${currentValue}/h' + } + valueTile("unitPrice", "device.unitPrice", decoration: "flat", width: 3, height: 2) { + state "default", label: 'Unit Price:\n${currentValue}' + } + + valueTile("totalDemand", "device.averageDailyTotalPower", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Total Power:\n${currentValue} kWh' + } + valueTile("totalConsumptionPrice", "device.currentDailyTotalPowerCost", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Total Cost:\n${currentValue}' + } + + valueTile("yesterdayTotalPower", "device.yesterdayTotalPower", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Yesterday Total Power :\n${currentValue} kWh' + } + valueTile("yesterdayTotalPowerCost", "device.yesterdayTotalPowerCost", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Yesterday Total Cost:\n${currentValue}' + } + + standardTile("network", "device.network", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("default", label:'unknown', icon: "st.unknown.unknown.unknown") + state ("Connected", label:'Online', icon: "st.Health & Wellness.health9", backgroundColor: "#79b821") + state ("Pending", label:'Pending', icon: "st.Health & Wellness.health9", backgroundColor: "#ffa500") + state ("Not Connected", label:'Offline', icon: "st.Health & Wellness.health9", backgroundColor: "#bc2323") + } + + standardTile("refresh", "device.power", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh" + } + + htmlTile(name:"chartHTML", action: "getImageChartHTML", width: 6, height: 5, whiteList: ["www.gstatic.com", "raw.githubusercontent.com"]) + + main (["power"]) + details(["power", "unitPrice", "yesterdayTotalPower", "yesterdayTotalPowerCost", "chartHTML", "refresh"]) + } +} + +mappings { + path("/getImageChartHTML") {action: [GET: "getImageChartHTML"]} +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'power' attribute + +} + +// handle commands +def installed() { + sendEvent(name: "checkInterval", value: 48 * 60 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + sendEvent(name: "checkInterval", value: 48 * 60 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def poll() { + log.debug "Executing 'poll'" + refreshLiveData() +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} + +def refreshLiveData() { + //Get current hour + //state.hour = null + def df = new java.text.SimpleDateFormat("HH") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("Europe/London")) + } + def currentHour = df.format(new Date()).toInteger() + + if ((state.hour == null) || (state.hour != currentHour)) { + //Add historical figures to chart data object + addHistoricalPowerToChartData() + setYesterdayPowerValues() + + //Reset at midnight or initial call + if ((state.hour == null) || (currentHour == 0)) { + sendEvent(name: 'costAlertLevelPassed', value: "false", displayed: false) + } + + state.hour = currentHour + } +} + +private def calculatePercentChange(current, previous) { + def delta = current - previous + if (previous != 0) { + return Math.round((delta / previous) * 100) + } else { + if (delta > 0) return 1000 + else if (delta == 0) return 0 + else return -1000 + } +} + +def getCostAlertLevelValue() { + if (settings.costAlertLevel == null) { + return "10" + } + return settings.costAlertLevel +} + +def getAggregatePower(fromDate, toDate) { + return parent.apiGET("https://live.ovoenergy.com/api/live/meters/${device.deviceNetworkId}/consumptions/aggregated?from=${fromDate.format("yyyy-MM-dd")}T00%3A00%3A00.000Z&to=${toDate.format("yyyy-MM-dd")}T00%3A00%3A00.000Z&granularity=DAY") +} + +def setYesterdayPowerValues() { + //Store the day's power info as yesterdays + def date = new Date() + def resp = getAggregatePower((date - 2), date) + if (resp.status != 200) { + log.error("Unexpected result in setYesterdayPowerValues(): [${resp.status}] ${resp.data}") + } else { + def consumptions = resp.data.consumptions + if (consumptions[1].dataError != "NotFound") { + //consumptions[1].price, consumptions[0].price + def yesterdayTotalPower = Math.round((consumptions[1].consumption as BigDecimal) * 1000) / 1000 + sendEvent(name: 'yesterdayTotalPower', value: "$yesterdayTotalPower", unit: "KWh", displayed: false) + def yesterdayTotalPowerInWh = Math.round((consumptions[1].consumption as BigDecimal) * 1000) + sendEvent(name: 'power', value: "$yesterdayTotalPowerInWh", unit: "Wh", displayed: true) + + def yesterdayTotalPowerCost = (Math.round((consumptions[1].price as BigDecimal) * 100))/100 + + def formattedCostYesterdayComparison = 0 + //Calculate cost difference between days + def costYesterdayComparison = calculatePercentChange(consumptions[1].price as BigDecimal, consumptions[0].price as BigDecimal) + formattedCostYesterdayComparison = costYesterdayComparison + if (costYesterdayComparison >= 0) { + formattedCostYesterdayComparison = "+" + formattedCostYesterdayComparison + } + + yesterdayTotalPowerCost = String.format("%1.2f",yesterdayTotalPowerCost) + sendEvent(name: 'yesterdayTotalPowerCost', value: "£$yesterdayTotalPowerCost (" + formattedCostYesterdayComparison + "%)", displayed: false) + + def unitPrice = (Math.round((consumptions[1].tariff as BigDecimal) * 100))/100 + sendEvent(name: 'unitPrice', value: "£$unitPrice", displayed: false) + + //Send event to raise notification on high cost + if ((yesterdayTotalPowerCost as BigDecimal) > (getCostAlertLevelValue() as BigDecimal)) { + sendEvent(name: 'costAlertLevelPassed', value: "£${getCostAlertLevelValue()}") + } else { + sendEvent(name: 'costAlertLevelPassed', value: "false", displayed: false) + } + + } else { + sendEvent(name: 'yesterdayTotalPower', value: "TBD", unit: "KWh", displayed: false) + sendEvent(name: 'yesterdayTotalPowerCost', value: "being calculated...", displayed: false) + sendEvent(name: 'costAlertLevelPassed', value: "false", displayed: false) + } + } +} + +def addHistoricalPowerToChartData() { + def date = new Date() + def resp = getAggregatePower((date - 7), date) + if (resp.status != 200) { + log.error("Unexpected result in addHistoricalPowerToChartData(): [${resp.status}] ${resp.data}") + } + else { + def consumptions = resp.data.consumptions + if (consumptions[6]) { + state.chartData = [consumptions[6].price, consumptions[5].price, consumptions[4].price, consumptions[3].price, consumptions[2].price, consumptions[1].price, consumptions[0].price] + } else { + state.chartData = [0, consumptions[5].price, consumptions[4].price, consumptions[3].price, consumptions[2].price, consumptions[1].price, consumptions[0].price] + } + } +} + +def getImageChartHTML() { + try { + def date = new Date() + if (state.chartData == null) { + state.chartData = [0, 0, 0, 0, 0, 0, 0] + } else { + def removeNull = state.chartData.collect { it == null ? it = 0 : it } + state.chartData = removeNull + } + def topValue = state.chartData.max() + def hData = """ +

Historical Costs


+
+ """ + + def mainHtml = """ + + + + + + + + + + + + + + ${hData} + + + """ + render contentType: "text/html", data: mainHtml, status: 200 + } + catch (ex) { + log.error "getImageChartHTML Exception:", ex + } +} + +def getCssData() { + def cssData = null + def htmlInfo + state.cssData = null + + if(htmlInfo?.cssUrl && htmlInfo?.cssVer) { + if(state?.cssData) { + if (state?.cssVer?.toInteger() == htmlInfo?.cssVer?.toInteger()) { + cssData = state?.cssData + } else if (state?.cssVer?.toInteger() < htmlInfo?.cssVer?.toInteger()) { + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + cssData = getFileBase64(htmlInfo.cssUrl, "text", "css") + state?.cssData = cssData + state?.cssVer = htmlInfo?.cssVer + } + } else { + cssData = getFileBase64(cssUrl(), "text", "css") + } + return cssData +} + +def getFileBase64(url, preType, fileType) { + try { + def params = [ + uri: url, + contentType: '$preType/$fileType' + ] + httpGet(params) { resp -> + if(resp.data) { + def respData = resp?.data + ByteArrayOutputStream bos = new ByteArrayOutputStream() + int len + int size = 4096 + byte[] buf = new byte[size] + while ((len = respData.read(buf, 0, size)) != -1) + bos.write(buf, 0, len) + buf = bos.toByteArray() + String s = buf?.encodeBase64() + return s ? "data:${preType}/${fileType};base64,${s.toString()}" : null + } + } + } + catch (ex) { + log.error "getFileBase64 Exception:", ex + } +} + +def cssUrl() { return "https://raw.githubusercontent.com/desertblade/ST-HTMLTile-Framework/master/css/smartthings.css" } \ No newline at end of file diff --git a/devicetypes/alyc100/reset_schedule_icon.png b/devicetypes/alyc100/reset_schedule_icon.png new file mode 100644 index 00000000000..87367c33032 Binary files /dev/null and b/devicetypes/alyc100/reset_schedule_icon.png differ diff --git a/devicetypes/alyc100/sure-petcare-hub.src/sure-petcare-hub.groovy b/devicetypes/alyc100/sure-petcare-hub.src/sure-petcare-hub.groovy new file mode 100644 index 00000000000..d950ccdc5cd --- /dev/null +++ b/devicetypes/alyc100/sure-petcare-hub.src/sure-petcare-hub.groovy @@ -0,0 +1,181 @@ +/** + * Sure PetCare Hub + * + * Copyright 2019 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 10.09.2019 - v1.1b - Improve API call efficiency + * 06.09.2019 - v1.0 - Initial Version + */ + + +metadata { + definition (name: "Sure PetCare Hub", namespace: "alyc100", author: "Alex Lee Yuk Cheung") { + capability "Polling" + capability "Refresh" + capability "Sensor" + capability "Health Check" + + attribute "network","string" + + command "toggleLedMode" + command "setLedMode", ["string"] + } + + tiles(scale: 2) { + standardTile("hubname", "device.hubname", width: 6, height: 4) { + state "default", label:"PetCare Hub", inactivelabel:true, icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/surepetcare-hub.png", backgroundColor: "#cccccc" + } + valueTile("serial_number", "device.serial_number", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Serial Number:\n${currentValue}' + } + + valueTile("mac_address", "device.mac_address", decoration: "flat", width: 3, height: 1) { + state "default", label: 'MAC Address:\n${currentValue}' + } + valueTile("created_at", "device.created_at", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Created at:\n${currentValue}' + } + valueTile("updated_at", "device.updated_at", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Updated at:\n${currentValue}' + } + valueTile("firmware", "device.firmware", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Firmware Version:\n${currentValue}' + } + valueTile("hardware", "device.hardware", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Hardware Version:\n${currentValue}' + } + + standardTile("network", "device.network", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("default", label:'unknown', icon: "st.unknown.unknown.unknown") + state ("Connected", label:'Online', icon: "st.Health & Wellness.health9", backgroundColor: "#79b821") + state ("Pending", label:'Pending', icon: "st.Health & Wellness.health9", backgroundColor: "#ffa500") + state ("Not Connected", label:'Offline', icon: "st.Health & Wellness.health9", backgroundColor: "#bc2323") + } + + standardTile("ledMode", "device.ledMode", inactiveLabel: false, width: 2, height: 2, decoration: "flat") { + state("bright", label:'LED Bright', action:"toggleLedMode", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-hub-bright.png", nextState: "off") + state("dim", label:'LED Dim', action:"toggleLedMode", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-hub-dim.png", nextState: "bright") + state("off", label:'LED Off', action:"toggleLedMode", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-hub-off.png", nextState: "dim") + + } + + standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh" + } + + + main (["hubname"]) + details(["hubname", "serial_number", "mac_address", "created_at", "updated_at", "hardware", "firmware", "ledMode", "network", "refresh"]) + } +} + +// handle commands +def installed() { + sendEvent(name: "checkInterval", value: 48 * 60 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + sendEvent(name: "checkInterval", value: 48 * 60 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def poll() { + log.debug "Executing 'poll'" + + if (!state.statusRespCode || state.statusRespCode != 200) { + log.error("Unexpected result in poll(): [${state.statusRespCode}] ${state.statusResponse}") + return [] + } + def response = state.statusResponse.data.devices + def hub = response.find{device.deviceNetworkId.toInteger() == it.id} + sendEvent(name: "hubname", value: hub.name) + sendEvent(name: "serial_number", value: hub.serial_number) + sendEvent(name: "mac_address", value: hub.mac_address) + sendEvent(name: "created_at", value: hub.created_at) + sendEvent(name: "updated_at", value: hub.updated_at) + def ledMode + switch (hub.status.led_mode) { + case 1: + ledMode = "bright" + break; + case 4: + ledMode = "dim" + break; + default: + ledMode = "off" + break; + } + sendEvent(name: "ledMode", value: ledMode) + if (hub.status.online) { + sendEvent(name: 'network', value: "Connected" as String) + } else { + sendEvent(name: 'network', value: "Pending" as String) + } + sendEvent(name: "hardware", value: hub.status.version.device.hardware) + sendEvent(name: "firmware", value: hub.status.version.device.firmware) +} + +def toggleLedMode() { + log.debug "Executing 'toggleLedMode'" + def ledMode + if (device.currentState("ledMode").getValue() == "off") { + ledMode = "dim" + } else if (device.currentState("ledMode").getValue() == "dim") { + ledMode = "bright" + } else { + ledMode = "off" + } + setLedMode(ledMode) + sendEvent(name: "ledMode", value: ledMode) + runIn(2, "updateStatusAndRefresh") +} + +def setLedMode(mode) { + log.debug "Executing 'setLedMode' with mode ${mode}" + def modeValue + switch (mode) { + case "bright": + modeValue = 1 + break; + case "dim": + modeValue = 4 + break; + default: + modeValue = 0 + break; + } + def body = [ + led_mode: modeValue + ] + def resp = parent.apiPUT("/api/device/" + device.deviceNetworkId + "/control", body) +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} + +def updateStatusAndRefresh() { + log.debug "Executing 'updateStatusAndRefresh'" + def resp = parent.apiGET("/api/me/start") + setStatusRespCode(resp.status) + setStatusResponse(resp.data) + refresh() +} + +def setStatusRespCode(respCode) { + state.statusRespCode = respCode +} + +def setStatusResponse(respBody) { + state.statusResponse = respBody +} \ No newline at end of file diff --git a/devicetypes/alyc100/sure-petcare-pet-door-connect.src/sure-petcare-pet-door-connect.groovy b/devicetypes/alyc100/sure-petcare-pet-door-connect.src/sure-petcare-pet-door-connect.groovy new file mode 100644 index 00000000000..0ff096a9f5c --- /dev/null +++ b/devicetypes/alyc100/sure-petcare-pet-door-connect.src/sure-petcare-pet-door-connect.groovy @@ -0,0 +1,285 @@ +/** + * Sure PetCare Pet Door Connect + * + * Copyright 2019 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 11.12.2019 - v1.2.2 - Thanks for @solarfusion, new battery calculations. + * 08.10.2019 - v1.2.1b - Change lock behaviour to 'Pet In' rather than lock both ways + * 13.09.2019 - v1.2.1 - Add curfew status tile + * 10.09.2019 - v1.2 - Add button controls to change lock status + * 10.09.2019 - v1.1b - Improve API call efficiency + * 09.09.2019 - v1.1 - Added Keep Pet In option for Dual Scan devices + * 06.09.2019 - v1.0 - Initial Version + */ + + +metadata { + definition (name: "Sure PetCare Pet Door Connect", namespace: "alyc100", author: "Alex Lee Yuk Cheung") { + capability "Polling" + capability "Refresh" + capability "Actuator" + capability "Health Check" + capability "Battery" + capability "Lock" + capability "Sensor" + + attribute "network","string" + + command "toggleLockMode" + command "setLockMode", ["string"] + command "setLockModeToBoth" + command "setLockModeToIn" + command "setLockModeToOut" + command "setLockModeToNone" + } + + tiles(scale: 2) { + multiAttributeTile(name: "flap", width: 6, height: 4, type:"generic") { + tileAttribute("device.lockMode", key:"PRIMARY_CONTROL", canChangeBackground: true){ + attributeState("both", label: 'LOCKED', action: "toggleLockMode", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-flap-lock.png", backgroundColor: "#ef6d6a", nextState:"waiting") + attributeState("out", label: 'PETS OUT', action: "toggleLockMode", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-flap-out.png", backgroundColor: "#f88e4c", nextState:"waiting") + attributeState("in", label: 'PETS IN', action: "toggleLockMode", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-flap-in.png", backgroundColor: "#81cb65", nextState:"waiting") + attributeState("none", label: 'UNLOCKED', action: "toggleLockMode", icon: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-flap-unlock.png", backgroundColor: "#33a1ff", nextState:"waiting") + attributeState("waiting", label:'Please Wait...', backgroundColor:"#ffffff") + } + } + + standardTile("both", "device.switch", width: 1, height: 1, inactiveLabel: false, canChangeIcon: false) { + state("default", action:"setLockModeToBoth", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-flap-lock.png") + } + standardTile("out", "device.switch", width: 1, height: 1, inactiveLabel: false, canChangeIcon: false) { + state("default", action:"setLockModeToOut", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-flap-out.png") + } + standardTile("in", "device.switch", width: 1, height: 1, inactiveLabel: false, canChangeIcon: false) { + state("default", action:"setLockModeToIn", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-flap-in.png") + } + standardTile("none", "device.switch", width: 1, height: 1, inactiveLabel: false, canChangeIcon: false) { + state("default", action:"setLockModeToNone", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/surepetcare-flap-unlock.png") + } + valueTile("serial_number", "device.serial_number", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Serial Number:\n${currentValue}' + } + valueTile("mac_address", "device.mac_address", decoration: "flat", width: 3, height: 1) { + state "default", label: 'MAC Address:\n${currentValue}' + } + valueTile("created_at", "device.created_at", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Created at:\n${currentValue}' + } + valueTile("updated_at", "device.updated_at", decoration: "flat", width: 3, height: 1) { + state "default", label: 'Updated at:\n${currentValue}' + } + valueTile("curfewStatus", "device.curfewStatus", decoration: "flat", width: 5, height: 1) { + state "default", label: '${currentValue}' + } + standardTile("empty", "device.empty", decoration: "flat", inactiveLabel: false, width: 1, height: 1) { + state "default", label: '' + } + valueTile("battery", "device.battery", decoration: "flat", inactiveLabel: false, width: 2, height: 2) { + state "battery", label:'${currentValue}%\nbattery', unit:"" + } + valueTile("device_rssi", "device.device_rssi", canChangeBackground: true, width: 2, height: 2){ + state "default", label: 'RSSI ${currentValue}', + backgroundColors:[ + [value: -50, color: "#33cc33"], + [value: -60, color: "#ffff99"], + [value: -70, color: "#ff9933"], + [value: -100, color: "#ff0000"] + ] + } + standardTile("network", "device.network", width: 2, height: 2, inactiveLabel: false, canChangeIcon: false) { + state ("default", label:'unknown', icon: "st.unknown.unknown.unknown") + state ("Connected", label:'Online', icon: "st.Health & Wellness.health9", backgroundColor: "#79b821") + state ("Pending", label:'Pending', icon: "st.Health & Wellness.health9", backgroundColor: "#ffa500") + state ("Not Connected", label:'Offline', icon: "st.Health & Wellness.health9", backgroundColor: "#bc2323") + } + standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label:'', action:"refresh.refresh", icon:"st.secondary.refresh" + } + + main (["flap"]) + details(["flap", "curfewStatus", "empty", "both", "out", "battery", "device_rssi", "in", "none", "serial_number", "mac_address", "created_at", "updated_at", "network", "refresh"]) + } +} + +// handle commands +def installed() { + sendEvent(name: "checkInterval", value: 1 * 60 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + sendEvent(name: "checkInterval", value: 1 * 60 * 60, data: [protocol: "cloud"], displayed: false) +} + +def poll() { + log.debug "Executing 'poll'" + + if (!state.statusRespCode || state.statusRespCode != 200) { + log.error("Unexpected result in poll(): [${state.statusRespCode}] ${state.statusResponse}") + return [] + } + def response = state.statusResponse.data.devices + def flap = response.find{device.deviceNetworkId.toInteger() == it.id} + sendEvent(name: 'product_id', value: flap.product_id) + sendEvent(name: "serial_number", value: flap.serial_number) + sendEvent(name: "mac_address", value: flap.mac_address) + sendEvent(name: "created_at", value: flap.created_at) + sendEvent(name: "updated_at", value: flap.updated_at) + def curfewStatus + if (flap.control.curfew && !flap.control.curfew.isEmpty()) { + curfewStatus = "A curfew is activated on this device between ${flap.control.curfew[0].lock_time} and ${flap.control.curfew[0].unlock_time}." + } else { + curfewStatus = "A curfew is not enabled on this device." + } + sendEvent(name: "curfewStatus", value: curfewStatus) + def lockMode + switch (flap.status.locking.mode) { + case 0: + lockMode = "none" + break; + case 1: + lockMode = "in" + break; + case 2: + lockMode = "out" + break; + default: + lockMode = "both" + break; + } + if (lockMode == "none" || lockMode == "out") { + sendEvent(name: "lock", value: "unlocked") + } else { + sendEvent(name: "lock", value: "locked") + } + sendEvent(name: "lockMode", value: lockMode) + if (flap.status.online) { + sendEvent(name: 'network', value: "Connected" as String) + } else { + sendEvent(name: 'network', value: "Pending" as String) + } + def batteryPercent = getBatteryPercent(flap.status.battery) + sendEvent(name: 'battery', value: batteryPercent) + sendEvent(name: 'device_rssi', value: flap.status.signal.device_rssi) + sendEvent(name: 'hub_rssi', value: flap.status.signal.hub_rssi) +} + +def toggleLockMode() { + log.debug "Executing 'toggleLockMode'" + def lockMode + if (device.currentState("lockMode").getValue() == "both") { + lockMode = "in" + } else if (device.currentState("lockMode").getValue() == "in") { + lockMode = "out" + } else if (device.currentState("lockMode").getValue() == "out") { + lockMode = "none" + } else { + lockMode = "both" + } + setLockMode(lockMode) +} + +def setLockMode(mode) { + log.debug "Executing 'setLockMode' with mode ${mode}" + def modeValue + switch (mode) { + case "none": + modeValue = 0 + break; + case "in": + modeValue = 1 + break; + case "out": + modeValue = 2 + break; + case "both": + modeValue = 3 + break; + default: + log.error("Unsupported lock mode: [${mode}]") + return [] + } + def body = [ + locking: modeValue + ] + def resp = parent.apiPUT("/api/device/" + device.deviceNetworkId + "/control", body) + sendEvent(name: "lockMode", value: mode) + runIn(2, "updateStatusAndRefresh") +} + +def lock() { + log.debug "Executing 'lock'" + setLockMode("in") +} + +def unlock() { + log.debug "Executing 'unlock'" + setLockMode("none") +} + +def getBatteryPercent(voltage) { + log.debug "Executing 'getBatteryPercent'" + log.debug "Battery voltage " + voltage + " volts" + /* Battery voltages + 6.27v = new batteries + 5.2v = flap starts misbehaving i.e. batteries dead + 0.27v increments for each 25% = 6.27, 6, 5.8, 5.5, 5.2 + */ + def percentage = 100 + if (voltage < 5.2) { + percentage = 0 + } else if (voltage < 5.5) { + percentage = 25 + } else if (voltage < 5.8) { + percentage = 50 + } else if (voltage < 6) { + percentage = 75 + } + return percentage +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} + +def updateStatusAndRefresh() { + log.debug "Executing 'updateStatusAndRefresh'" + def resp = parent.apiGET("/api/me/start") + setStatusRespCode(resp.status) + setStatusResponse(resp.data) + refresh() +} + +def setStatusRespCode(respCode) { + state.statusRespCode = respCode +} + +def setStatusResponse(respBody) { + state.statusResponse = respBody +} + +def setLockModeToBoth() { + setLockMode("both") +} + +def setLockModeToIn() { + setLockMode("in") +} + +def setLockModeToOut() { + setLockMode("out") +} + +def setLockModeToNone() { + setLockMode("none") +} \ No newline at end of file diff --git a/devicetypes/alyc100/sure-petcare-pet.src/sure-petcare-pet.groovy b/devicetypes/alyc100/sure-petcare-pet.src/sure-petcare-pet.groovy new file mode 100644 index 00000000000..46db727c228 --- /dev/null +++ b/devicetypes/alyc100/sure-petcare-pet.src/sure-petcare-pet.groovy @@ -0,0 +1,187 @@ +/** + * Sure PetCare Pet + * + * Copyright 2019 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 10.09.2019 - v1.1c - Expose setIndoors method to external smart app. + * 10.09.2019 - v1.1b - Improve API call efficiency + * 09.09.2019 - v1.1 - Added Keep Pet In option on Pet device for Dual Scan PetCare cat flaps + * 08.09.2019 - v1.0c - Bug fix. Fix tag id comparison for generating look through events. + * 07.09.2019 - v1.0b - Bug fix. Change method of finding 'look through' events. + - Add Tag ID to tiles + * 06.09.2019 - v1.0 - Initial Version + */ +metadata { + definition (name: "Sure PetCare Pet", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.r.sensor.presence", mnmn: "SmartThings", vid: "surepet-pet-presence") { + capability "Sensor" + capability "Polling" + capability "Refresh" + capability "Presence Sensor" + + command "setIndoorsOnly", ["string"] + command "toggleIndoorsOnly" + command "refresh" + } + + simulator { + // TODO: define status and reply messages here + } + + tiles(scale: 2) { + standardTile("presence", "device.presence", width: 4, height: 4, canChangeIcon: false, canChangeBackground: true) { + state("present", labelIcon:"st.presence.tile.mobile-present", backgroundColor:"#00a0dc") + state("not present",labelIcon:"st.presence.tile.mobile-not-present",backgroundColor:"#cccccc") + } + + valueTile("tag", "device.tag", decoration: "flat", width: 4, height: 1) { + state "default", label: 'Tag Number:\n${currentValue}' + } + + valueTile("petInfo", "device.petInfo", decoration: "flat", width: 4, height: 1) { + state "default", label: '${currentValue}' + } + + standardTile("indoorsOnly", "device.indoorsOnly", width: 2, height: 2, decoration: "flat", inactiveLabel: false, canChangeIcon: false) { + state ("true", label:'Keep In', action: "toggleIndoorsOnly", icon: "st.Home.home2", backgroundColor: "#5cb85c", nextState:"false") + state ("false", label:'Let Out', action: "toggleIndoorsOnly", icon: "st.Outdoor.outdoor15", backgroundColor: "#f88e4c", nextState:"true") + state ("empty", icon:"https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/empty.png") + } + + standardTile("refresh", "device.refresh", width:2, height:2, decoration: "flat") { + state("default", label:'refresh', action:"refresh.refresh", icon:"st.secondary.refresh-icon") + } + + main(["presence"]) + details(["presence", "tag", "petInfo", "indoorsOnly", "refresh"]) + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + // TODO: handle 'switch' attribute + +} + +// handle commands +def installed() { + log.debug "Executing 'installed'" +} + +def updated() { + log.debug "Executing 'updated'" +} + +def poll() { + log.debug "Executing 'poll' for ${device} ${this} ${device.deviceNetworkId}" + + if (!state.statusRespCode || state.statusRespCode != 200) { + log.error("Unexpected result in poll(): [${state.statusRespCode}] ${state.statusResponse}") + return [] + } + def response = state.statusResponse.data.pets + def pet = response.find{device.deviceNetworkId.toInteger() == it.id} + def presence = pet.position.where + def pres = (presence == 1) ? "present" : "not present" + sendEvent(name: 'presence', value: pres, descriptionText: "${device.name} is ${pres.toLowerCase()}", displayed: true) + if (pet.photo) { + state.photoURL = pet.photo.location + } + def tagStatus = parent.getTagStatus(device.currentState("tag_id").getValue().toInteger()) + log.debug "Cat indoors only status is ${tagStatus}" + sendEvent(name: 'indoorsOnly', value: tagStatus, displayed: true) + def tag_id = pet.tag_id + response = state.statusResponse.data.tags + def tag = response.find{tag_id == it.id} + sendEvent(name: 'tag_id', value: tag_id, displayed: true) + sendEvent(name: 'tag', value: tag.tag.toString() + ".", displayed: true) + + //Pick up look through flap events + def resp = parent.apiGET("/api/timeline/household/" + parent.getHouseholdID() + "/pet") + if (resp.status != 200) { + log.error("Unexpected result in poll(): [${resp.status}] ${resp.data}") + return [] + } + + if (!state.lastTimePetLooked) { + state.lastTimePetLooked = now() + } + + for(def entry : resp.data.data) { + if (entry.movements) { + if ((entry.movements[0].tag_id == device.currentState("tag_id").getValue().toInteger()) && entry.movements[0].direction == 0) { + def movementDate = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXXX", entry.movements[0].created_at) + if (state.lastTimePetLooked < movementDate.getTime()) { + state.lastTimePetLooked = movementDate.getTime() + //Notify pet peek event. + def entryDeviceName = "flap" + if (entry.devices) { + entryDeviceName = entry.devices[0].name + } + def msg = "${device.name} looked through ${entryDeviceName} at ${movementDate.format("hh:mm aaa dd MMM yyyy", parent.getTimeZone())}" + sendEvent(name: "petInfo", value: msg, displayed: true, linkText: "${device.displayName}", descriptionText: msg) + break + } + } + } + } +} + +def toggleIndoorsOnly() { + log.debug "Executing 'toggleIndoorsOnly'" + if (device.currentState("indoorsOnly").getValue() == "false") { + setIndoorsOnly("true") + } else { + setIndoorsOnly("false") + } + +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} + +def updateStatusAndRefresh() { + log.debug "Executing 'updateStatusAndRefresh'" + def resp = parent.apiGET("/api/me/start") + setStatusRespCode(resp.status) + setStatusResponse(resp.data) + refresh() +} + +def getPhotoURL() { + return state.photoURL +} + +def setStatusRespCode(respCode) { + state.statusRespCode = respCode +} + +def setStatusResponse(respBody) { + state.statusResponse = respBody +} + +def setIndoorsOnly(mode) { + log.debug "Executing 'setIndoorsOnly' with mode ${mode}" + if (mode == "true") { + parent.setTagToIndoorsOnly(device.currentState("tag_id").getValue().toInteger()) + sendEvent(name: 'indoorsOnly', value: mode, displayed: true) + } else if (mode == "false") { + parent.setTagToOutdoors(device.currentState("tag_id").getValue().toInteger()) + sendEvent(name: 'indoorsOnly', value: mode, displayed: true) + } else { + log.error("Unsupported indoorsOnly mode: [${mode}]") + } + runIn(2, "updateStatusAndRefresh") +} diff --git a/devicetypes/alyc100/surepetcare-flap-in.png b/devicetypes/alyc100/surepetcare-flap-in.png new file mode 100644 index 00000000000..43d21fdd075 Binary files /dev/null and b/devicetypes/alyc100/surepetcare-flap-in.png differ diff --git a/devicetypes/alyc100/surepetcare-flap-lock.png b/devicetypes/alyc100/surepetcare-flap-lock.png new file mode 100644 index 00000000000..35600375e3e Binary files /dev/null and b/devicetypes/alyc100/surepetcare-flap-lock.png differ diff --git a/devicetypes/alyc100/surepetcare-flap-out.png b/devicetypes/alyc100/surepetcare-flap-out.png new file mode 100644 index 00000000000..233f989a86b Binary files /dev/null and b/devicetypes/alyc100/surepetcare-flap-out.png differ diff --git a/devicetypes/alyc100/surepetcare-flap-unlock.png b/devicetypes/alyc100/surepetcare-flap-unlock.png new file mode 100644 index 00000000000..be1a18839d6 Binary files /dev/null and b/devicetypes/alyc100/surepetcare-flap-unlock.png differ diff --git a/devicetypes/alyc100/surepetcare-hub-bright.png b/devicetypes/alyc100/surepetcare-hub-bright.png new file mode 100644 index 00000000000..46b728fea77 Binary files /dev/null and b/devicetypes/alyc100/surepetcare-hub-bright.png differ diff --git a/devicetypes/alyc100/surepetcare-hub-dim.png b/devicetypes/alyc100/surepetcare-hub-dim.png new file mode 100644 index 00000000000..8a402d16410 Binary files /dev/null and b/devicetypes/alyc100/surepetcare-hub-dim.png differ diff --git a/devicetypes/alyc100/surepetcare-hub-off.png b/devicetypes/alyc100/surepetcare-hub-off.png new file mode 100644 index 00000000000..e91f3580461 Binary files /dev/null and b/devicetypes/alyc100/surepetcare-hub-off.png differ diff --git a/devicetypes/alyc100/surepetcare-inside-location.png b/devicetypes/alyc100/surepetcare-inside-location.png new file mode 100644 index 00000000000..bad0c4b114c Binary files /dev/null and b/devicetypes/alyc100/surepetcare-inside-location.png differ diff --git a/devicetypes/alyc100/surepetcare-outside-location.png b/devicetypes/alyc100/surepetcare-outside-location.png new file mode 100644 index 00000000000..50e1c01c5f8 Binary files /dev/null and b/devicetypes/alyc100/surepetcare-outside-location.png differ diff --git a/devicetypes/alyc100/warmup-4ie.src/warmup-4ie.groovy b/devicetypes/alyc100/warmup-4ie.src/warmup-4ie.groovy new file mode 100644 index 00000000000..1d1171fd11d --- /dev/null +++ b/devicetypes/alyc100/warmup-4ie.src/warmup-4ie.groovy @@ -0,0 +1,359 @@ +/** + * Warmup 4ie + * + * Copyright 2015,2016,2017,2018 Alex Lee Yuk Cheung + * + * 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. + * + * + * VERSION HISTORY + * 09.12.2020 v1.0 - New SmartThings App UI. + * 08.10.2018 v1.0 BETA Release 5 - Compatibility for New Smartthings App. + * 10.12.2017 v1.0 BETA Release 4 - Fix to boost functionality when thermostat is off. + * 10.12.2017 v1.0 BETA Release 3 - Add boost functionality. + * 05.01.2017 v1.0 BETA Release 2 - Minor fix that prevented 'Manual' mode being selected and activated. + * 14.12.2016 v1.0 BETA - Initial Release + */ +import groovy.time.TimeCategory + +preferences +{ + input( "boostInterval", "number", title: "Boost Interval (minutes)", description: "Boost interval amount in minutes", required: false, defaultValue: 10 ) + input( "boostTemp", "decimal", title: "Boost Temperature (°C)", description: "Boost interval amount in Centigrade", required: false, defaultValue: 22, range: "5..32" ) + input( "disableDevice", "bool", title: "Disable Warmup Heating Device?", required: false, defaultValue: false ) +} + +metadata { + definition (name: "Warmup 4IE", namespace: "alyc100", author: "Alex Lee Yuk Cheung", ocfDeviceType: "oic.d.thermostat", mnmn: "fBZA", vid: "36a9d325-53b2-37e8-9376-ee404ac3259d") { + capability "Actuator" + capability "Polling" + capability "Refresh" + capability "Temperature Measurement" + capability "Thermostat" + capability "Thermostat Heating Setpoint" + capability "Thermostat Mode" + capability "Thermostat Operating State" + capability "Health Check" + capability "tigerdrum36561.boostLabel" + capability "tigerdrum36561.boostLength" + capability "tigerdrum36561.airTemperatureMeasurement" + + command "heatingSetpointUp" + command "heatingSetpointDown" + command "setThermostatMode" + command "setHeatingSetpoint" + command "setTemperatureForSlider" + command "setTemperature" + command "boostButton" + command "boostTimeUp" + command "boostTimeDown" + command "setBoostLength" + } + + simulator { + // TODO: define status and reply messages here + } +} + +def installed() { + log.debug "Executing 'installed'" + state.desiredHeatSetpoint = 7 + // execute handlerMethod every 10 minutes. + runEvery10Minutes(poll) + sendEvent(name: "checkInterval", value: 20 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def updated() { + log.debug "Executing 'updated'" + // execute handlerMethod every 10 minutes. + unschedule() + runEvery10Minutes(poll) + sendEvent(name: "checkInterval", value: 20 * 60 + 2 * 60, data: [protocol: "cloud"], displayed: false) +} + +def uninstalled() { + log.debug "Executing 'uninstalled'" + unschedule() +} + +// parse events into attributes +def parse(String description) { +} + +// handle commands + +def off() { + setThermostatMode('off') +} + +def on() { + setThermostatMode('auto') + +} + +def heat() { + setThermostatMode('heat') +} + +def auto() { + setThermostatMode('auto') +} + +def setHeatingSetpoint(temp) { + log.debug "Executing 'setHeatingSetpoint with temp $temp'" + def latestThermostatMode = device.latestState('thermostatMode') + + if (temp < 5) { + temp = 5 + } + if (temp > 32) { + temp = 32 + } + def args + if (settings.disableDevice == null || settings.disableDevice == false) { + //if thermostat is off, set to manual + if (latestThermostatMode.stringValue == 'off') { + args = [ + method: "setProgramme", roomId: device.deviceNetworkId, roomMode: "fixed" + ] + parent.apiPOSTByChild(args) + } + args = [ + method: "setProgramme", roomId: device.deviceNetworkId, roomMode: "fixed", fixed: [fixedTemp: "${(temp * 10) as Integer}"] + ] + parent.apiPOSTByChild(args) + + } + runIn(3, refresh) +} + +def setHeatingSetpointToDesired() { + setHeatingSetpoint(state.newSetpoint) +} + +def setNewSetPointValue(newSetPointValue) { + log.debug "Executing 'setNewSetPointValue' with value $newSetPointValue" + state.newSetpoint = newSetPointValue + state.desiredHeatSetpoint = state.newSetpoint + sendEvent("name":"desiredHeatSetpoint", "value": state.desiredHeatSetpoint, displayed: false) + log.debug "Setting heat set point up to: ${state.newSetpoint}" + setHeatingSetpointToDesired() +} + +def heatingSetpointUp(){ + log.debug "Executing 'heatingSetpointUp'" + setNewSetPointValue(getHeatTemp().toInteger() + 1) +} + +def heatingSetpointDown(){ + log.debug "Executing 'heatingSetpointDown'" + setNewSetPointValue(getHeatTemp().toInteger() - 1) +} + +def setTemperature(value) { + log.debug "Executing 'setTemperature with $value'" + def currentTemp = device.currentState("temperature").doubleValue + (value < currentTemp) ? (setNewSetPointValue(getHeatTemp().toInteger() - 1)) : (setNewSetPointValue(getHeatTemp().toInteger() + 1)) +} + +def setTemperatureForSlider(value) { + log.debug "Executing 'setTemperatureForSlider with $value'" + setNewSetPointValue(value) +} + +def getHeatTemp() { + return state.desiredHeatSetpoint == null ? device.currentValue("heatingSetpoint") : state.desiredHeatSetpoint +} + +def emergencyHeat() { + log.debug "Executing 'boost'" + + def latestThermostatMode = device.latestState('thermostatMode') + + //Don't do if already in BOOST mode. + if (latestThermostatMode.stringValue != 'emergency heat') { + setThermostatMode('emergency heat') + } + else { + log.debug "Already in boost mode." + } +} + + +def setThermostatMode(mode) { + if (settings.disableDevice == null || settings.disableDevice == false) { + mode = mode == 'cool' ? 'heat' : mode + log.debug "Executing 'setThermostatMode with mode $mode'" + def args + if (mode == 'off') { + //Sets whole location to off instead of individual thermostat. Awaiting Warmup API update. + + /*args = [ + method: "setRunModeByRoomIdArray", roomIdArray: [device.deviceNetworkId as Integer], values: [runMode: "frost"] + ] + parent.apiPOSTByChild(args) + */ + parent.setLocationToFrost() + } else if (mode == 'heat') { + args = [ + method: "setProgramme", roomId: device.deviceNetworkId, roomMode: "fixed" + ] + parent.apiPOSTByChild(args) + } else if (mode == 'emergency heat') { + if (state.boostLength == null || state.boostLength == '') + { + state.boostLength = 60 + sendEvent("name":"boostLength", "value": 60, displayed: true) + } + args = [ + method: "setOverride", rooms: ["$device.deviceNetworkId"], type: 3, temp: getBoostTempValue(), until: getBoostEndTime() + ] + parent.apiPOSTByChild(args) + } else { + args = [ + method: "setProgramme", roomId: device.deviceNetworkId, roomMode: "prog" + ] + parent.apiPOSTByChild(args) + } + mode = mode == 'range' ? 'auto' : mode + } + runIn(3, refresh) +} + +def setBoostLength(minutes) { + log.debug "Executing 'setBoostLength with length $minutes minutes'" + if (minutes < 5) { + minutes = 5 + } + if (minutes > 300) { + minutes = 300 + } + state.boostLength = minutes + sendEvent("name":"boostLength", "value": state.boostLength, "unit":"minutes", displayed: true) +} + +def getBoostIntervalValue() { + if (settings.boostInterval == null) { + return 10 + } + return settings.boostInterval.toInteger() +} + +def getBoostTempValue() { + log.debug "Executing 'getBoostTempValue'" + def retVal + if (settings.boostTemp == null) { + retVal = "220" + } + retVal = ((settings.boostTemp as Integer) * 10) as String + return retVal +} + +def getBoostEndTime() { + log.debug "Executing 'getBoostEndTime'" + use( TimeCategory ) { + def endDate = new Date() + state.boostLength.minutes + return endDate.format("HH:mm") + } +} + +def boostTimeUp() { + log.debug "Executing 'boostTimeUp'" + //Round down results + int boostIntervalValue = getBoostIntervalValue() + def newBoostLength = (state.boostLength + boostIntervalValue) - (state.boostLength % boostIntervalValue) + setBoostLength(newBoostLength) +} + +def boostTimeDown() { + log.debug "Executing 'boostTimeDown'" + //Round down result + int boostIntervalValue = getBoostIntervalValue() + def newBoostLength = (state.boostLength - boostIntervalValue) - (state.boostLength % boostIntervalValue) + setBoostLength(newBoostLength) +} + +def boostButton() { + log.debug "Executing 'boostButton'" + setThermostatMode('emergency heat') +} + +def poll() { + log.debug "Executing 'poll'" + def room = parent.getStatus(device.deviceNetworkId) + if (room == []) { + log.error("Unexpected result in parent.getStatus()") + return [] + } + log.debug room + def modeMsg = "" + def airTempMsg = "" + def mode = room.runMode[0] + if (mode == "fixed") mode = "heat" + else if (mode == "off" || mode == "frost") mode = "off" + else if (mode == "prog" || mode == "schedule") mode = "auto" + else if (mode == "override") mode = "emergency heat" + sendEvent(name: 'thermostatMode', value: mode) + modeMsg = "Mode: " + mode.toUpperCase() + "." + + //Boost button label + def boostLabel = "OFF" + + //If Warmup heating device is set to disabled, then force off if not already off. + if (settings.disableDevice != null && settings.disableDevice == true && activeHeatCoolMode != "OFF") { + //Sets whole location to off instead of individual thermostat. Awaiting Warmup API update. + /*args = [ + method: "setRunModeByRoomIdArray", roomIdArray: [device.deviceNetworkId as Integer], values: [runMode: "frost"] + ] + parent.apiPOSTByChild(args) + */ + parent.setLocationToFrost() + mode = 'off' + } else if (mode == "emergency heat") { + def boostTime = room.overrideDur + boostLabel = boostTime + "min remaining" + sendEvent("name":"boostTimeRemaining", "value": boostTime + " mins") + } + + if (settings.disableDevice != null && settings.disableDevice == true) { + modeMsg = "DISABLED" + } + + def temperature = String.format("%2.1f",(room.currentTemp[0] as BigDecimal)/ 10) + sendEvent(name: 'temperature', value: temperature, unit: "C", state: "heat") + + def heatingSetpoint = String.format("%2.1f",(room.targetTemp[0] as BigDecimal) / 10) + sendEvent(name: 'heatingSetpoint', value: heatingSetpoint, unit: "C", state: "heat") + sendEvent(name: 'coolingSetpoint', value: heatingSetpoint, unit: "C", state: "heat") + sendEvent(name: 'thermostatSetpoint', value: heatingSetpoint, unit: "C", state: "heat", displayed: false) + + if ((room.targetTemp[0] as BigDecimal) > (room.currentTemp[0] as BigDecimal)) { + sendEvent(name: 'thermostatOperatingState', value: "heating") + } + else { + sendEvent(name: 'thermostatOperatingState', value: "idle") + } + + sendEvent(name: 'thermostatFanMode', value: "off", displayed: false) + + def airTemperature = String.format("%2.1f",(room.airTemp[0] as BigDecimal) / 10) + sendEvent("name": "airTemperature", "value": airTemperature, unit: "C") + sendEvent("name":"statusMsg", "value": modeMsg + " " + airTempMsg, displayed: false) + + state.desiredHeatSetpoint = (int) Double.parseDouble(heatingSetpoint) + sendEvent("name":"desiredHeatSetpoint", "value": state.desiredHeatSetpoint, unit: "C", displayed: false) + + sendEvent("name":"boostLabel", "value": boostLabel, displayed: false) +} + +def refresh() { + log.debug "Executing 'refresh'" + poll() +} \ No newline at end of file diff --git a/smartapps/alyc100/10457773_334250273417145_3395772416845089626_n.png b/smartapps/alyc100/10457773_334250273417145_3395772416845089626_n.png new file mode 100644 index 00000000000..abe895a3d42 Binary files /dev/null and b/smartapps/alyc100/10457773_334250273417145_3395772416845089626_n.png differ diff --git a/smartapps/alyc100/8slp-icon.png b/smartapps/alyc100/8slp-icon.png new file mode 100644 index 00000000000..bf47230bfc4 Binary files /dev/null and b/smartapps/alyc100/8slp-icon.png differ diff --git a/smartapps/alyc100/eight-sleep-connect.src/eight-sleep-connect.groovy b/smartapps/alyc100/eight-sleep-connect.src/eight-sleep-connect.groovy new file mode 100644 index 00000000000..238e05532bb --- /dev/null +++ b/smartapps/alyc100/eight-sleep-connect.src/eight-sleep-connect.groovy @@ -0,0 +1,664 @@ +/** + * Eight Sleep (Connect) + * + * Copyright 2017 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 26.01.2017: v1.0b - Token renew error fix. + * 26.01.2017: v1.0 - Remove BETA label. + * + * 19.01.2017: 1.0 BETA Release 6 - Added notification framework with option screen. + * 12.01.2017: 1.0 BETA Release 5 - Stop partner credentials being mandatory. Change device creation based on whether partner credentials are present. + * 12.01.2017: 1.0 BETA Release 4 - Enable changing of SmartApp name. + * 12.01.2017: 1.0 BETA Release 3b - Remove single instance lock for users with multiple mattresses. + * 12.01.2017: 1.0 BETA Release 3 - Better messaging within smart app on login errors. + * 11.01.2017: 1.0 BETA Release 2 - Support partner account authentication and session management. + * 11.01.2017: 1.0 BETA Release 1 - Initial Release + */ +definition( + name: "Eight Sleep (Connect)", + namespace: "alyc100", + author: "Alex Lee Yuk Cheung", + description: "Connect your Eight Sleep device to SmartThings", + iconUrl: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/8slp-icon.png", + iconX2Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/8slp-icon.png", + iconX3Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/8slp-icon.png" +) + +preferences { + page(name:"firstPage", title:"Eight Sleep Device Setup", content:"firstPage", install: true) + page(name: "loginPAGE") + page(name: "partnerLoginPAGE") + page(name: "selectDevicePAGE") + page(name: "notificationsPAGE") +} + +def apiURL(path = '/') { return "https://client-api.8slp.net/v1${path}" } + +def firstPage() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter Eight Sleep account crednentials", state: authenticated()) + } + } + } + else + { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter Eight Sleep account crednentials", state: authenticated()) + } + if (stateTokenPresent()) { + section ("Add your partner's credentials (optional):") { + href("partnerLoginPAGE", title: null, description: partnerAuthenticated() ? "Authenticated as " + partnerUsername : "Tap to enter Eight Sleep partner account crednentials", state: partnerAuthenticated()) + } + section ("Choose your Eight Sleep devices:") { + href("selectDevicePAGE", title: null, description: devicesSelected() ? getDevicesSelectedString() : "Tap to select Eight Sleep devices", state: devicesSelected()) + } + section ("Notifications:") { + href("notificationsPAGE", title: null, description: notificationsSelected() ? getNotificationsString() : "Tap to configure notifications", state: notificationsSelected()) + } + section () { + label name: "name", title: "Assign a Name", required: true, state: (name ? "complete" : null), defaultValue: app.name + } + } else { + section { + paragraph "There was a problem connecting to Eight Sleep. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def loginPAGE() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your Eight Sleep account credentials below to enable SmartThings and Eight Sleep integration." } + section { + input("username", "text", title: "Username", description: "Your Eight Sleep username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your Eight Sleep password", required: true, submitOnChange: true) + } + } + } + else { + getEightSleepAccessToken() + dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your Eight Sleep account credentials below to enable SmartThings and Eight Sleep integration." } + section("Eight Sleep Credentials:") { + input("username", "text", title: "Username", description: "Your Eight Sleep username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your Eight Sleep password", required: true, submitOnChange: true) + } + + if (stateTokenPresent()) { + section { + paragraph "You have successfully connected to Eight Sleep. Click 'Done' to select your Eight Sleep devices." + } + } + else { + section { + paragraph "There was a problem connecting to Eight Sleep. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def partnerLoginPAGE() { + if (partnerUsername == null || partnerUsername == '' || partnerPassword == null || partnerPassword == '') { + return dynamicPage(name: "partnerLoginPAGE", title: "Partner Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your Eight Sleep partner account credentials below." } + section { + input("partnerUsername", "text", title: "Username", description: "Your Eight Sleep partner username (usually an email address)", required: false) + input("partnerPassword", "password", title: "Password", description: "Your Eight Sleep partner password", required: true, submitOnChange: false) + } + } + } + else { + getEightSleepPartnerAccessToken() + dynamicPage(name: "partnerLoginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your Eight Sleep partner account credentials below." } + section("Eight Sleep Partner Credentials:") { + input("partnerUsername", "text", title: "Username", description: "Your Eight Sleep partner username (usually an email address)", required: false) + input("partnerPassword", "password", title: "Password", description: "Your Eight Sleep partner password", required: true, submitOnChange: false) + } + + if (statePartnerTokenPresent()) { + section { + paragraph "You have successfully added your partner's credentials.." + } + } + else { + section { + paragraph "There was a problem adding your partner's Eight Sleep credentials. Check your partner user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def selectDevicePAGE() { + updateDevices() + dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) { + section { headerSECTION() } + section("Select your devices:") { + input "selectedEightSleep", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/eightsleep-device.png", required:false, title:"Select Eight Sleep Device \n(${state.eightSleepDevices.size() ?: 0} found)", multiple:true, options:state.eightSleepDevices + } + } +} + +def notificationsPAGE() { + dynamicPage(name: "notificationsPAGE", title: "Preferences", uninstall: false, install: false) { + section { + input("recipients", "contact", title: "Send notifications to", required: false, submitOnChange: true) { + input "sendPush", "bool", title: "Send notifications via Push?", required: false, defaultValue: false, submitOnChange: true + } + input "sendSMS", "phone", title: "Send notifications via SMS?", required: false, defaultValue: null, submitOnChange: true + if ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) { + input "onNotification", "bool", title: "Notify when Eight Sleep heat is on ", required: false, defaultValue: false + input "offNotification", "bool", title: "Notify when Eight Sleep heat is off ", required: false, defaultValue: false + input "inBedNotification", "bool", title: "Notify when 'In Bed' event occurs", required: false, defaultValue: false + input "outOfBedNotification", "bool", title: "Notify when 'Out Of Bed' event occurs", required: false, defaultValue: false + input "heatLevelReachedNotification", "bool", title: "Notify when desired heat level reached", required: false, defaultValue: false + input "sleepScoreNotification", "bool", title: "Notify when latest sleep score is updated", required: false, defaultValue: false + } + } + } +} + +def headerSECTION() { + return paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/8slp-icon.png", + "${textVersion()}") +} + +def stateTokenPresent() { + return state.eightSleepAccessToken != null && state.eightSleepAccessToken != '' +} + +def statePartnerTokenPresent() { + return state.eightSleepPartnerAccessToken != null && state.eightSleepPartnerAccessToken != '' +} + +def authenticated() { + return (state.eightSleepAccessToken != null && state.eightSleepAccessToken != '') ? "complete" : null +} + +def partnerAuthenticated() { + return (state.eightSleepPartnerAccessToken != null && state.eightSleepPartnerAccessToken != '') ? "complete" : null +} + +def devicesSelected() { + return (selectedEightSleep) ? "complete" : null +} + +def getDevicesSelectedString() { + if (state.eightSleepDevices == null) { + updateDevices() + } + + def listString = "" + selectedEightSleep.each { childDevice -> + if (state.eightSleepDevices[childDevice] != null) listString += state.eightSleepDevices[childDevice] + "\n" + } + return listString +} + +def notificationsSelected() { + return ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) && (settings.onNotification || settings.offNotification || settings.inBedNotification || settings.outOfBedNotification || settings.heatLevelReachedNotification || settings.sleepScoreNotification) ? "complete" : null +} + +def getNotificationsString() { + def listString = "" + if (location.contactBookEnabled && settings.recipients) { + listString += "Send the following notifications to " + settings.recipients + } + else if (settings.sendPush) { + listString += "Send the following notifications" + } + + if (!settings.recipients && !settings.sendPush && settings.sendSMS != null) { + listString += "Send the following SMS to ${settings.sendSMS}" + } + else if (settings.sendSMS != null) { + listString += " and SMS to ${settings.sendSMS}" + } + + if ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) { + listString += ":\n" + if (settings.onNotification) listString += "• Eight Sleep On\n" + if (settings.offNotification) listString += "• Eight Sleep Off\n" + if (settings.inBedNotification) listString += "• In Bed\n" + if (settings.outOfBedNotification) listString += "• Out Of Bed\n" + if (settings.heatLevelReachedNotification) listString += "• Desired Heat Level Reached\n" + if (settings.sleepScoreNotification) listString += "• Sleep Score\n" + } + if (listString != "") listString = listString.substring(0, listString.length() - 1) + return listString +} + +// App lifecycle hooks + +def installed() { + log.debug "installed" + initialize() + // Check for new devices and remove old ones every 3 hours + runEvery3Hours('updateDevices') + // execute refresh method every minute + runEvery5Minutes('refreshDevices') +} + +// called after settings are changed +def updated() { + log.debug "updated" + initialize() + unschedule('refreshDevices') + runEvery5Minutes('refreshDevices') +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule() + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(devices) { + devices.each { + deleteChildDevice(it.deviceNetworkId) // 'it' is default + } +} + +// Implement event handlers +def eventHandler(evt) { + log.debug "Executing 'eventHandler' for ${evt.displayName}" + def msg + if (evt.value == "open") { + msg = "${evt.displayName} is out of bed." + if (settings.outOfBedNotification) { + messageHandler(msg, false) + } + } + else if (evt.value == "closed") { + msg = "${evt.displayName} is in bed." + if (settings.inBedNotification) { + messageHandler(msg, false) + } + } + else if (evt.value == "on") { + msg = "${evt.displayName} is on." + if (settings.onNotification) { + messageHandler(msg, false) + } + } + else if (evt.value == "off") { + msg = "${evt.displayName} is off." + if (settings.offNotification) { + messageHandler(msg, false) + } + } + else if (evt.value == "true") { + msg = "${evt.displayName} has reached desired temperature." + if (settings.heatLevelReachedNotification) { + messageHandler(msg, false) + } + } + else if (evt.name == "battery") { + msg = "${evt.displayName} sleep score is ${evt.value}." + if (settings.sleepScoreNotification) { + messageHandler(msg, false) + } + } +} + +// called after Done is hit after selecting a Location +def initialize() { + log.debug "initialize" + if (selectedEightSleep) { + addEightSleep() + } + + def devices = getChildDevices() + devices.each { + if (notificationsSelected() == "complete") { + subscribe(it, "switch", eventHandler, [filterEvents: false]) + subscribe(it, "contact", eventHandler, [filterEvents: false]) + subscribe(it, "desiredHeatLevelReached", eventHandler, [filterEvents: false]) + subscribe(it, "battery", eventHandler, [filterEvents: false]) + } + log.debug "Refreshing device $it.name" + it.refresh() + } +} + +def updateDevices() { + if (!state.devices) { + state.devices = [:] + } + def devices = devicesList() + state.eightSleepDevices = [:] + + def selectors = [] + + devices.each { device -> + log.debug "Identified: device ${device}" + def value = "Eight Sleep ${device.reverse().take(4).reverse()}" + def key = device + state.eightSleepDevices["${key}"] = value + def resp = apiGET("/devices/${device}?filter=ownerId,leftUserId,rightUserId") + if (resp.status == 200) { + def leftUserId = resp.data.result.leftUserId + def rightUserId = resp.data.result.rightUserId + selectors.add("${device}/${leftUserId}") + selectors.add("${device}/${rightUserId}") + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } + log.debug selectors + + //Remove devices if does not exist on the Eight Sleep platform + getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { + log.info("Deleting ${it.deviceNetworkId}") + try { + deleteChildDevice(it.deviceNetworkId) + } catch (physicalgraph.exception.NotFoundException e) { + log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.") + } catch (physicalgraph.exception.ConflictException ce) { + log.info("Device ${it.deviceNetworkId} in use. Please manually delete.") + } + } +} + +def addEightSleep() { + updateDevices() + + selectedEightSleep.each { device -> + def resp = apiGET("/devices/${device}?filter=ownerId,leftUserId,rightUserId") + if (resp.status == 200) { + //Add left side of mattress as device + def ownerId = resp.data.result.ownerId + def leftUserId = resp.data.result.leftUserId + def rightUserId = resp.data.result.rightUserId + def childDevice + if ((leftUserId == ownerId) || (partnerAuthenticated())) { + childDevice = getChildDevice("${device}/${leftUserId}") + if (!childDevice && state.eightSleepDevices[device] != null) { + log.info("Adding device ${device}/${leftUserId}: ${state.eightSleepDevices[device]} [Left]") + def data = [ + name: "${state.eightSleepDevices[device]} [Left]", + label: "${state.eightSleepDevices[device]} [Left]" + ] + childDevice = addChildDevice(app.namespace, "Eight Sleep Mattress", "${device}/${leftUserId}", null, data) + log.debug "Created ${state.eightSleepDevices[device]} [Left] with id: ${device}/${leftUserId}" + } else { + log.debug "found ${state.eightSleepDevices[device]} [Left] with id ${device}/${leftUserId} already exists" + } + } + + //Add right side of mattress as device + if ((rightUserId == ownerId) || (partnerAuthenticated())) { + childDevice = getChildDevice("${device}/${rightUserId}") + if (!childDevice && state.eightSleepDevices[device] != null) { + log.info("Adding device ${device}/${rightUserId}: ${state.eightSleepDevices[device]} [Right]") + def data = [ + name: "${state.eightSleepDevices[device]} [Right]", + label: "${state.eightSleepDevices[device]} [Right]" + ] + childDevice = addChildDevice(app.namespace, "Eight Sleep Mattress", "${device}/${rightUserId}", null, data) + log.debug "Created ${state.eightSleepDevices[device]} [Right] with id: ${device}/${rightUserId}" + } else { + log.debug "found ${state.eightSleepDevices[device]} [Right] with id ${device}/${rightUserId} already exists" + } + } + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def refreshDevices() { + log.info("Executing refreshDevices...") + atomicState.renewAttempt = 0 + atomicState.renewAttemptPartner = 0 + getChildDevices().each { device -> + log.info("Refreshing device ${device.name} ...") + device.refresh() + } +} + +def devicesList() { + logErrors([]) { + def resp = apiGET("/users/me") + if (resp.status == 200) { + return resp.data.user.devices + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def getEightSleepAccessToken() { + def body = [ + "email": "${username}", + "password" : "${password}" + ] + def resp = apiPOST("/login", body) + state.eightSleepAccessToken = null + if (resp.status == 200) { + state.eightSleepAccessToken = resp.data.session.token + state.userId = resp.data.session.userId + atomicState.expirationDate = resp.data.session.expirationDate + log.debug "eightSleepAccessToken: $resp.data.session.token" + log.debug "eightSleepUserId: $resp.data.session.userId" + log.debug "eightSleepTokenExpirationDate: $resp.data.session.expirationDate" + state.loginerrors = null + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + state.loginerrors = "Error: ${resp.status}: ${resp.data}" + return [] + } +} + +def getEightSleepPartnerAccessToken() { + def body = [ + "email": "${partnerUsername}", + "password" : "${partnerPassword}" + ] + def resp = apiPOST("/login", body) + state.eightSleepPartnerAccessToken = null + if (resp.status == 200) { + state.eightSleepPartnerAccessToken = resp.data.session.token + state.partnerUserId = resp.data.session.userId + atomicState.partnerExpirationDate = resp.data.session.expirationDate + log.debug "eightSleepPartnerAccessToken: $resp.data.session.token" + log.debug "partnerUserId: $resp.data.session.userId" + log.debug "partnerExpirationDate: $resp.data.session.expirationDate" + state.loginerrors = null + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + state.loginerrors = "Error: ${resp.status}: ${resp.data}" + return [] + } +} + +def apiGET(path) { + try { + httpGet(uri: apiURL(path), headers: apiRequestHeaders()) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +def apiGETWithPartner(path) { + def result + if (partnerUsername != null && partnerUsername != '' && partnerPassword != null && partnerPassword != '') { + try { + httpGet(uri: apiURL(path), headers: apiPartnerRequestHeaders()) {response -> + logResponse(response) + result = response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + result = e.response + } + } else { + result = "" + } + result +} + +def apiPOST(path, body = [:]) { + try { + log.debug("Beginning API POST: ${path}, ${body}") + httpPost(uri: apiURL(path), body: new groovy.json.JsonBuilder(body).toString(), headers: apiRequestHeaders() ) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +def apiPUT(path, body = [:]) { + try { + log.debug("Beginning API POST: ${path}, ${body}") + httpPut(uri: apiURL(path), body: new groovy.json.JsonBuilder(body).toString(), headers: apiRequestHeaders() ) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +Map apiRequestHeaders() { + //Check token expiry + if (state.eightSleepAccessToken) { + def now = new Date().getTime() + def sessionExpiryTime = Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", atomicState.expirationDate).getTime() + if (now > sessionExpiryTime) { + if (!atomicState.renewAttempt) {atomicState.renewAttempt = 0} + log.debug "Renewing Access Token Attempt ${atomicState.renewAttempt}" + if (atomicState.renewAttempt < 5) { + atomicState.renewAttempt = atomicState.renewAttempt +1 + getEightSleepAccessToken() + } else { + log.error "Renew attempt limit reached" + } + } else { + atomicState.renewAttempt = 0 + } + } + + return [ "Host": "client-api.8slp.net", + "Content-Type": "application/json", + "API-Key": "api-key", + "Application-Id": "morphy-app-id", + "Connection": "keep-alive", + "User-Agent" : "Eight%20AppStore/11 CFNetwork/808.2.16 Darwin/16.3.0", + "Accept-Language": "en-gb", + "Accept-Encoding": "gzip, deflate", + "Accept": "*/*", + "app-Version": "1.10.0", + "Session-Token": "${state.eightSleepAccessToken}" + + ] +} + +Map apiPartnerRequestHeaders() { + //Check token expiry + if (state.eightSleepPartnerAccessToken) { + def now = new Date().getTime() + def sessionExpiryTime = Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", atomicState.partnerExpirationDate).getTime() + if (now > sessionExpiryTime) { + if (!atomicState.renewAttemptPartner) {atomicState.renewAttemptPartner = 0} + log.debug "Renewing Partner Access Token Attempt ${atomicState.renewAttempt}" + if (atomicState.renewAttemptPartner < 5) { + atomicState.renewAttemptPartner = atomicState.renewAttemptPartner +1 + getEightSleepPartnerAccessToken() + } else { + log.error "Renew attempt limit reached" + } + } else { + atomicState.renewAttemptPartner = 0 + } + } + + return [ "Host": "client-api.8slp.net", + "Content-Type": "application/json", + "API-Key": "api-key", + "Application-Id": "morphy-app-id", + "Connection": "keep-alive", + "User-Agent" : "Eight%20AppStore/11 CFNetwork/808.2.16 Darwin/16.3.0", + "Accept-Language": "en-gb", + "Accept-Encoding": "gzip, deflate", + "Accept": "*/*", + "app-Version": "1.10.0", + "Session-Token": "${state.eightSleepPartnerAccessToken}" + + ] +} + +def logResponse(response) { + log.info("Status: ${response.status}") + log.info("Body: ${response.data}") +} + +def logErrors(options = [errorReturn: null, logObject: log], Closure c) { + try { + return c() + } catch (groovyx.net.http.HttpResponseException e) { + options.logObject.error("got error: ${e}, body: ${e.getResponse().getData()}") + if (e.statusCode == 401) { // token is expired + state.remove("eightSleepAccessToken") + options.logObject.warn "Access token is not valid" + } + return options.errorReturn + } catch (java.net.SocketTimeoutException e) { + options.logObject.warn "Connection timed out, not much we can do here" + return options.errorReturn + } +} + +def messageHandler(msg, forceFlag) { + log.debug "Executing 'messageHandler for $msg. Forcing is $forceFlag'" + if (settings.sendSMS != null && !forceFlag) { + sendSms(settings.sendSMS, msg) + } + if (location.contactBookEnabled && settings.recipients) { + sendNotificationToContacts(msg, settings.recipients) + } else if (settings.sendPush || forceFlag) { + sendPush(msg) + } +} + +private def textVersion() { + def text = "Eight Sleep (Connect)\nVersion: 1.0b\nDate: 26012017(2015)" +} + +private def textCopyright() { + def text = "Copyright © 2017 Alex Lee Yuk Cheung" +} \ No newline at end of file diff --git a/smartapps/alyc100/eightsleep-device.png b/smartapps/alyc100/eightsleep-device.png new file mode 100644 index 00000000000..1746ff504ef Binary files /dev/null and b/smartapps/alyc100/eightsleep-device.png differ diff --git a/smartapps/alyc100/hive-activeplug.jpg b/smartapps/alyc100/hive-activeplug.jpg new file mode 100644 index 00000000000..0649d7bfda3 Binary files /dev/null and b/smartapps/alyc100/hive-activeplug.jpg differ diff --git a/smartapps/alyc100/hive-bulb.jpg b/smartapps/alyc100/hive-bulb.jpg new file mode 100644 index 00000000000..c1b9246d5cd Binary files /dev/null and b/smartapps/alyc100/hive-bulb.jpg differ diff --git a/smartapps/alyc100/hive-colouredbulb.jpg b/smartapps/alyc100/hive-colouredbulb.jpg new file mode 100644 index 00000000000..5f32882fbf4 Binary files /dev/null and b/smartapps/alyc100/hive-colouredbulb.jpg differ diff --git a/smartapps/alyc100/hive-colouredbulb.png b/smartapps/alyc100/hive-colouredbulb.png new file mode 100644 index 00000000000..f80556ca02d Binary files /dev/null and b/smartapps/alyc100/hive-colouredbulb.png differ diff --git a/smartapps/alyc100/hive-connect.src/hive-connect.groovy b/smartapps/alyc100/hive-connect.src/hive-connect.groovy new file mode 100644 index 00000000000..6a5645424ec --- /dev/null +++ b/smartapps/alyc100/hive-connect.src/hive-connect.groovy @@ -0,0 +1,1364 @@ +/** + * Hive (Connect) + * + * Copyright 2015,2016 Alex Lee Yuk Cheung + * Hive Contact Sensor code portions contributed by Simon Green + * Hive Active Bulb code portions contributed by Tom Beech + * + * 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. + * + * VERSION HISTORY + * + * 24.02.2016 + * v2.0 BETA - New Hive Connect App + * v2.0.1 BETA - Fix bug for accounts that do not have capabilities attribute against thermostat. + * v2.1 - Improved authentication process and overhaul to UI. Added notification capability. + * v2.1.1 - Bug fix when initially selecting devices for the first time. + * v2.1.2 - Move external icon references into Github\ + * + * 17.08.2016 + * v2.1.3 - Fix null pointer on state variable corruption + * v2.1.3b - Fix device failure on API timeout + * + * 01.09.2016 + * v2.2 - Integrate auto mode functionality from Auto Mode for Thermostat smart app + * + * 04.09.2016 + * v2.3 - Added support for Hive Contact Sensor - Author: Simon Green + * + * 06.09.2016 + * v2.3.1 - Improve device detection + * + * 10.09.2016 + * v2.3.2 - Added notification option for maximum temperature threshold breach for Hive heating devices. + * + * 23.1.2016 + * v2.4 - Added support for Hive Active Warm White and Hive Active Tunable Lights - Author: Tom Beech + * v2.4b - Minor UI fixes for bulb devices. + * + * 28.11.2016 + * v2.5 - Added support for Hive Active Plugs - Author: Tom Beech + * - Refactor of device selecting string - Author: Tom Beech + * - Review device naming and text consistency. + * + * v2.5b - Shortern some device names. + * + * 02.12.2016 + * v2.6 - Added support for Hive Active Colour Bulb - Author: Tom Beech + * + * 28.05.2017 + * v2.7 - Support for new Hive Beekeeper API - Authors: Tom Beech, Alex Lee Yuk Cheung + * - Removed support for Hive Contact Sensor. Zigbee integration by Simon Green is preferred option. + * v2.7b - Bug fix. Refresh bug prevents installation of Hive devices. + * + * 30.10.2017 + * v3.0 - Support for Hive Active Light Colour Tuneable device. + * + * 4.10.2019 + * v3.1 - Support for Hive Radiator TRV - Authors: Ben Lee + * + * 13.10.2020 + * v3.1.1b - Fix device suffix being set within deviceId + * + * 05.12.2020 + * v3.2 - Change authentication method to use Tokens generated from amazon-user-pool-srp-client + * + * 06.12.2020 + * v3.2a - Reduce token refresh frequency + * v3.2b - Add Bearer token on API call + * + * 07.12.2020 + * v3.2c - Remove username and password references that are now redundant + */ +definition( + name: "Hive (Connect)", + namespace: "alyc100", + author: "Alex Lee Yuk Cheung", + description: "Connect your Hive devices to SmartThings.", + iconUrl: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/10457773_334250273417145_3395772416845089626_n.png", + iconX2Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/10457773_334250273417145_3395772416845089626_n.png", + singleInstance: true +) + +preferences { + //startPage + page(name: "startPage") + + //Connect Pages + page(name:"mainPage", title:"Hive Device Setup", content:"mainPage", install: true) + page(name: "loginPAGE") + page(name: "selectDevicePAGE") + page(name: "preferencesPAGE") + page(name: "tmaPAGE") + + //Thermostat Mode Automation Pages + page(name: "tmaConfigurePAGE") +} + +def apiBeekeeperUKURL(path = '/') { return "https://beekeeper-uk.hivehome.com:443/1.0${path}" } +def apiBeekeeperURL(path = '/') { return "https://beekeeper.hivehome.com:443/1.0${path}" } + +def initTokens() { + def hiveTokenString = 'PASTE HiveToken.json content into here' + return new groovy.json.JsonSlurper().parseText(hiveTokenString) +} + +def startPage() { + if (parent) { + atomicState?.isParent = false + tmaConfigurePAGE() + } else { + atomicState?.isParent = true + mainPage() + } +} + +//Hive Connect App Pages + +def mainPage() { + log.debug "mainPage" + if (!state.beekeeperToken || state.beekeeperToken == '') { + return dynamicPage(name: "mainPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "AUTHENTICATED. Tap to refresh authentication" : "Tap to refresh authentication", state: authenticated()) + } + } + } else { + log.debug "next phase" + return dynamicPage(name: "mainPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: "Authentication", description: authenticated() ? "AUTHENTICATED. Tap to refresh authentication" : "Tap to refresh authentication", state: authenticated()) + } + if (stateTokenPresent()) { + section ("Choose your devices:") { + href("selectDevicePAGE", title: "Devices", description: devicesSelected() ? getDevicesSelectedString() : "Tap to select devices", state: devicesSelected()) + } + section("Hive Mode Automations:") { + href "tmaPAGE", title: "Hive Mode Automations...", description: (tmaDescription() ? tmaDescription() : "Tap to Configure..."), state: (tmaDescription() ? "complete" : null) + } + section ("Notifications:") { + href("preferencesPAGE", title: null, description: preferencesSelected() ? getPreferencesString() : "Tap to configure notifications", state: preferencesSelected()) + } + } else { + section { + paragraph "NOT AUTHENTICATED. There was a problem connecting to Hive. Check your generated token and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def headerSECTION() { + return paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/10457773_334250273417145_3395772416845089626_n.png", + "Hive (Connect)\nVersion: v3.2c\nDate: 07122020(1500)") +} + +def stateTokenPresent() { + return state.beekeeperToken != null && state.beekeeperToken != '' +} + +def authenticated() { + return (state.beekeeperToken != null && state.beekeeperToken != '') ? "complete" : null +} + +def devicesSelected() { + return (selectedHeating || selectedHotWater || selectedBulb || selectedTunableBulb || selectedActivePlug || selectedColourBulb) ? "complete" : null +} + +def preferencesSelected() { + return (sendPush || sendSMS != null) && (maxtemp != null || mintemp != null || sendBoost || sendOff || sendManual || sendSchedule || sendMaxThresholdBreach) ? "complete" : null +} + +def tmaDescription() { + def tmaApp = findChildAppByName( appName() ) + if(tmaApp) { + def str = "" + str += "Thermostat Automations:" + + childApps?.each { a -> + def name = a?.getLabel() + str += "\n• $name" + } + + return str + } + return null +} + + +def getDevicesSelectedString() { + if (state.hiveHeatingDevices == null || + state.hiveHotWaterDevices == null || + state.hiveTunableBulbDevices == null || + state.hiveBulbDevices == null || + state.hiveActivePlugDevices == null || + state.hiveColourBulb == null) { + updateDevices() + } + def listString = "" + + selectedHeating.each { childDevice -> + if (null != state.hiveHeatingDevices) + listString += "${state.hiveHeatingDevices[childDevice]}\n" + } + + selectedHotWater.each { childDevice -> + if (null != state.hiveHotWaterDevices) + listString += "${state.hiveHotWaterDevices[childDevice]}\n" + } + + selectedBulb.each { childDevice -> + if (null != state.hiveBulbDevices) + listString += "${state.hiveBulbDevices[childDevice]}\n" + } + + selectedTunableBulb.each { childDevice -> + if (null != state.hiveTunableBulbDevices) + listString += "${state.hiveTunableBulbDevices[childDevice]}\n" + } + selectedActivePlug.each { childDevice -> + if (null != state.hiveActivePlugDevices) + listString += "${state.hiveActivePlugDevices[childDevice]}\n" + } + selectedColourBulb.each { childDevice -> + if (null != state.selectedColourBulb) + listString += "${state.selectedColourBulb[childDevice]}\n" + } + + // Returns the completed list, and trims the last carrige return + return listString.trim() +} + + +def getPreferencesString() { + def listString = "" + if (sendPush) listString += "Send Push, " + if (sendSMS != null) listString += "Send SMS, " + if (maxtemp != null) listString += "Max Temp: ${maxtemp}, " + if (mintemp != null) listString += "Min Temp: ${mintemp}, " + if (sendBoost) listString += "Boost, " + if (sendOff) listString += "Off, " + if (sendManual) listString += "Manual, " + if (sendSchedule) listString += "Schedule, " + if (sendMaxThresholdBreach) listString += "Max Temp Threshold Breach, " + if (listString != "") listString = listString.substring(0, listString.length() - 2) + return listString +} + +def loginPAGE() { + getBeekeeperAccessToken() + if (!state.beekeeperToken || state.beekeeperToken == '') { + return dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "NOT AUTHENTICATED. There was a problem connecting to Hive. Check your generated token and error logs in SmartThings web console.\n\n${state.loginerrors}" } + } + } else { + dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + if (stateTokenPresent()) { + section { + paragraph "AUTHENTICATED. You have successfully connected to Hive. Click 'Next' to go back to the main screen and choose your Hive devices." + } + } else { + section { + paragraph "NOT AUTHENTICATED. There was a problem connecting to Hive. Check your generated token and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def selectDevicePAGE() { + updateDevices() + dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) { + section { headerSECTION() } + section("Select your devices:") { + input "selectedHeating", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/thermostat-frame-6c75d5394d102f52cb8cf73704855446.png", required:false, title:"Select Hive Heating Devices \n(${state.hiveHeatingDevices.size() ?: 0} found)", multiple:true, options:state.hiveHeatingDevices + input "selectedHotWater", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/thermostat-frame-6c75d5394d102f52cb8cf73704855446.png", required:false, title:"Select Hive Hot Water Devices \n(${state.hiveHotWaterDevices.size() ?: 0} found)", multiple:true, options:state.hiveHotWaterDevices + input "selectedBulb", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/hive-bulb.jpg", required:false, title:"Select Hive Light Dimmable Devices \n(${state.hiveBulbDevices.size() ?: 0} found)", multiple:true, options:state.hiveBulbDevices + input "selectedTunableBulb", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/hive-tunablebulb.jpg", required:false, title:"Select Hive Light Tuneable Devices \n(${state.hiveTunableBulbDevices.size() ?: 0} found)", multiple:true, options:state.hiveTunableBulbDevices + input "selectedColourBulb", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/hive-colouredbulb.jpg", required:false, title:"Select Hive Light Colour Devices \n(${state.hiveColourBulb.size() ?: 0} found)", multiple:true, options:state.hiveColourBulb + input "selectedActivePlug", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/hive-activeplug.jpg", required:false, title:"Select Hive Plug Devices \n(${state.hiveActivePlugDevices.size() ?: 0} found)", multiple:true, options:state.hiveActivePlugDevices + } + } +} + +def preferencesPAGE() { + dynamicPage(name: "preferencesPAGE", title: "Preferences", uninstall: false, install: false) { + section { + input "sendPush", "bool", title: "Send as Push?", required: false, defaultValue: false + input "sendSMS", "phone", title: "Send as SMS?", required: false, defaultValue: null + } + section("Thermostat Notifications:") { + input "sendBoost", "bool", title: "Notify when mode is Boosting?", required: false, defaultValue: false + input "sendOff", "bool", title: "Notify when mode is Off?", required: false, defaultValue: false + input "sendManual", "bool", title: "Notify when mode is Manual?", required: false, defaultValue: false + input "sendSchedule", "bool", title: "Notify when mode is Schedule?", required: false, defaultValue: false + } + section("Thermostat Max Temperature") { + input ("maxtemp", "number", title: "Alert when temperature is above this value", required: false, defaultValue: 25) + } + section("Thermostat Min Temperature") { + input ("mintemp", "number", title: "Alert when temperature is below this value", required: false, defaultValue: 10) + } + section("Thermostat Max Threshold Breach") { + input "sendMaxThresholdBreach", "bool", title: "Notify when max temp threshold has been breached?", required: false, defaultValue: false + } + } +} + +def tmaPAGE() { + dynamicPage(name: "tmaPAGE", title: "", nextPage: !parent ? "startPage" : "tmaPAGE", install: false) { + def tmaApp = findChildAppByName( appName() ) + if(tmaApp) { + section("Configured Hive Mode Automations...") { } + } else { + section("") { + paragraph "Create New Hive Mode Automation to get Started..." + } + } + section("Add a new Automation:") { + app(name: "tmaApp", appName: appName(), namespace: "alyc100", multiple: true, title: "Create New Mode Automation...") + def rText = "NOTICE:\nIntegrated Hive Mode Automations is in BETA\n" + paragraph "${rText}"//, required: true, state: null + } + } +} + +//Auto Thermostat Mode Pages +def tmaConfigurePAGE() { + dynamicPage(name: "tmaConfigurePAGE", title: "Hive Mode Automation", install: true, uninstall: true) { + section { + input ("thermostats", "capability.thermostat", title: "For these thermostats", multiple: true, required: true) + } + section { + input(name: "modeTrigger", title: "Set the trigger to", + description: null, multiple: false, required: true, submitOnChange: true, type: "enum", + options: ["true": "Mode Change", "false": "Switches"]) + } + if (modeTrigger == "true") { + // Do something here like update a message on the screen, + // or introduce more inputs. submitOnChange will refresh + // the page and allow the user to see the changes immediately. + // For example, you could prompt for the level of the dimmers + // if dimmers have been selected: + section { + input ("modes", "mode", title:"When SmartThings enters these modes", multiple: true, required: true) + } + } else if (modeTrigger == "false") { + section { + input ("theSwitch", "capability.switch", title:"When this switch is activated", multiple: false, required: true) + } + } + section { + input ("alteredThermostatMode", "enum", multiple: false, title: "Set thermostats to this mode", + options: ["Set To Schedule", "Boost", "Turn Off", "Set to Manual"], required: true, defaultValue: 'Turn Off') + } + section { + input ("resetThermostats", "enum", title: "Reset thermostats after trigger turns off?", + options: ["true": "Yes","false": "No"], required: true, submitOnChange: true) + } + if (resetThermostats == "true") { + section { + input ("resumedThermostatMode", "enum", multiple: false, title: "Reset thermostats back to this mode", submitOnChange: true, + options: ["Set To Schedule", "Boost", "Turn Off", "Set to Manual"], required: true, defaultValue: 'Set To Schedule') + } + if (resumedThermostatMode == "Boost") { + section { + input ("thermostatModeAfterBoost", "enum", multiple: false, title: "What to do when Boost has finished", + options: ["Set To Schedule", "Turn Off", "Set to Manual"], required: true, defaultValue: 'Set To Schedule') + } + } + } + section( "Additional configuration" ) { + input ("days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]) + href "timeIntervalInput", title: "Only during a certain time", description: getTimeLabel(starting, ending), state: greyedOutTime(starting, ending), refreshAfterSelection:true + input ("temp", "decimal", title: "If setting to Manual, set the temperature to this", required: false, defaultValue: 21) + } + section( "Notifications" ) { + input ("sendPushMessage", "enum", title: "Send a push notification?", + options: ["Yes", "No"], required: true) + input ("phone", "phone", title: "Send a Text Message?", required: false) + } + section { + label title: "Assign a name", required: true + } + } +} + +page(name: "timeIntervalInput", title: "Only during a certain time", refreshAfterSelection:true) { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } +} + + +// App lifecycle hooks + +def installed() { + if(parent) { installedChild() } // This will handle all of the install functions when the child app is installed + else { installedParent() } // This will handle all of the install functions when the parent app is installed +} + +def updated() { + if(parent) { updatedChild() } // This will handle all of the install functions when the child app is updated + else { updatedParent() } // This will handle all of the install functions when the parent app is updated +} + +def uninstalled() { + if(parent) { } // This will handle all of the install functions when the child app is uninstalled + else { uninstalledParent() } // This will handle all of the install functions when the parent app is uninstalled +} + +def installedParent() { + log.debug "installed" + initialize() + // Check for new devices every 3 hours + runEvery3Hours('updateDevices') + // execute handlerMethod every 10 minutes. + runEvery10Minutes('refreshDevices') +} + +// called after settings are changed +def updatedParent() { + log.debug "updated" + unsubscribe() + initialize() + unschedule('refreshDevices') + runEvery10Minutes('refreshDevices') +} + +def uninstalledParent() { + log.info("Uninstalling, removing child devices...") + unschedule() + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(devices) { + devices.each { + deleteChildDevice(it.deviceNetworkId) // 'it' is default + } +} + +def installedChild() { + log.debug "Installed with settings: ${settings}" + //set up initial thermostat state and force thermostat into correct mode + state.thermostatAltered = false + state.boostingReset = false + + //Flags to stop possible infinite loop scenarios when handlers create events + state.internalThermostatEvent = false + state.internalSwitchEvent = false + + subscribe(thermostats, "thermostatMode", thermostateventHandlerForTMA, [filterEvents: false]) + //Check if mode or switch is the trigger and run initialisation + if (modeTrigger == "true") { + def currentMode = location.mode + log.debug "currentMode = $currentMode" + if (currentMode in modes) { + takeActionForMode(currentMode) + } + subscribe(location, "mode", modeeventHandlerForTMA, [filterEvents: false]) + } + else { + if (theSwitch.currentSwitch == "on") { + takeActionForSwitch(theSwitch.currentSwitch) + } + subscribe(theSwitch, "switch", switcheventHandlerForTMA, [filterEvents: false]) + } +} + +def updatedChild() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + //set up initial thermostat state and force thermostat into correct mode + state.thermostatAltered = false + state.boostingReset = false + state.internalThermostatEvent = false + state.internalSwitchEvent = false + subscribe(thermostats, "thermostatMode", thermostateventHandlerForTMA, [filterEvents: false]) + //Check if mode or switch is the trigger and run initialisation + if (modeTrigger == "true") { + def currentMode = location.mode + log.debug "currentMode = $currentMode" + if (currentMode in modes) { + takeActionForMode(currentMode) + } + subscribe(location, "mode", modeeventHandlerForTMA, [filterEvents: false]) + } else { + if (theSwitch.currentSwitch == "on") { + takeActionForSwitch(theSwitch.currentSwitch) + } + subscribe(theSwitch, "switch", switcheventHandlerForTMA, [filterEvents: false]) + } +} + +// called after Done is hit after selecting a Location +def initialize() { + if (parent) { } + else { + log.debug "initialize" + + if (selectedHeating) { + addHeating() + } + if (selectedHotWater) { + addHotWater() + } + if(selectedBulb) { + addBulb() + } + if(selectedTunableBulb) { + addTunableBulb() + } + if(selectedActivePlug) { + addActivePlug() + } + if(selectedColourBulb) { + addColourBulb() + } + runIn(10, 'refreshDevices') // Asynchronously refresh devices so we don't block + + //subscribe to events for notifications if activated + if (preferencesSelected() == "complete") { + getChildDevices().each { childDevice -> + if (childDevice.typeName == "Hive Heating V2.0" || childDevice.typeName == "Hive Hot Water V2.0") { + subscribe(childDevice, "thermostatMode", modeHandler, [filterEvents: false]) + } + if (childDevice.typeName == "Hive Heating V2.0") { + subscribe(childDevice, "temperature", tempHandler, [filterEvents: false]) + subscribe(childDevice, "maxtempthresholdbreach", evtHandler, [filterEvents: false]) + } + } + } + state.maxNotificationSent = false + state.minNotificationSent = false + } +} + +//Event Handler for Connect App +def evtHandler(evt) { + def msg + if (evt.name == "maxtempthresholdbreach") { + msg = "Auto adjusting set temperature of ${evt.displayName} as current set temperature of ${evt.value}°C is above maximum threshold." + if (settings.sendMaxThresholdBreach) generateNotification(msg) + } +} + +def tempHandler(evt) { + def msg + log.trace "temperature: $evt.value, $evt" + + if (settings.maxtemp != null) { + def maxTemp = settings.maxtemp + if (evt.doubleValue >= maxTemp) { + msg = "${evt.displayName} temperature reading is very hot." + if (state.maxNotificationSent == null || state.maxNotificationSent == false) { + generateNotification(msg) + //Avoid constant messages + state.maxNotificationSent = true + } + } + else { + //Reset if temperature falls back to normal levels + state.maxNotificationSent = false + } + } + else if (settings.mintemp != null) { + def minTemp = settings.mintemp + if (evt.doubleValue <= minTemp) { + msg = "${evt.displayName} temperature reading is very cold." + if (state.minNotificationSent == null || state.minNotificationSent == false) { + generateNotification(msg) + //Avoid constant messages + state.minNotificationSent = true + } + } + else { + //Reset if temperature falls back to normal levels + state.minNotificationSent = false + } + } +} + +def modeHandler(evt) { + def msg + if (evt.value == "heat") { + msg = "${evt.displayName} is set to Manual" + if (settings.sendSchedule) generateNotification(msg) + } + else if (evt.value == "off") { + msg = "${evt.displayName} is turned Off" + if (settings.sendOff) generateNotification(msg) + } + else if (evt.value == "auto") { + msg = "${evt.displayName} is set to Schedule" + if (settings.sendManual) generateNotification(msg) + } + else if (evt.value == "emergency heat") { + msg = "${evt.displayName} is in Boost mode" + if (settings.sendBoost) generateNotification(msg) + } + +} + +//Event Handlers for Thermostat Mode Automation + +def modeeventHandlerForTMA(evt) { + if(allOk) { + log.debug "evt.value: $evt.value" + takeActionForMode(evt.value) + } +} + +//Handler and action for switch detection +def switcheventHandlerForTMA(evt) { + if(allOk) { + log.debug "evt.value: $evt.value" + log.debug "state.internalSwitchEvent: $state.internalSwitchEvent" + if (state.internalSwitchEvent == false) { + takeActionForSwitch(evt.value) + } + state.internalSwitchEvent = false + } +} + +def thermostateventHandlerForTMA(evt) { + log.debug "evt.name: $evt.value" + log.debug "state.thermostatAltered: $state.thermostatAltered" + log.debug "alteredThermostatMode: $alteredThermostatMode" + log.debug "state.boostingReset: $state.boostingReset" + //If boost mode is selected as the trigger, turn switch off if boost mode finishes... + if (state.internalThermostatEvent == false) { + if (modeTrigger == "false") { + //if the switch is currently on, check the new mode of the thermostat and set switch to off if necessary + if (alteredThermostatMode == "Boost") { + state.internalSwitchEvent = true + if (evt.value != "emergency heat") { + //Switching the switch to off should trigger an event that resets app state + theSwitch.off() + } + else { + //Switching the switch to on so it can't be boost again + theSwitch.on() + } + } + } + + //If boost mode is selected as resumed state, need to set thermostat mode as per preference + if (state.boostingReset) { + if (evt.value != "emergency heat") { + state.internalThermostatEvent = true + changeAllThermostatsModes(thermostats, thermostatModeAfterBoost, "Boost has now finished") + //Reset boosting reset flag + state.boostingReset = false + } + } + } + state.internalThermostatEvent = false +} + +// Thermostat Auto Mode Methods +def takeActionForSwitch(switchState) { + // Is incoming switch is on + if (switchState == "on") + { + //Check thermostat is not already altered + if (!state.thermostatAltered) + { + //Turn selected thermostats into selected mode + + //Add detail to push message if set to Manual is specified + log.debug "$theSwitch.label is on, turning thermostats to $alteredThermostatMode" + state.internalThermostatEvent = true + changeAllThermostatsModes(thermostats, alteredThermostatMode, "$theSwitch.label has turned on") + //Only if reset action is specified, set the thermostatAltered state. + if (resetThermostats == "true") + { + state.thermostatAltered = true + } + } + } + else { + log.debug "$theSwitch.label is off" + //Check if thermostats have previously been altered + if (state.thermostatAltered) + { + //Check if user wants to reset thermostats + if (resetThermostats == "true") + { + log.debug "Thermostats have been altered, turning back to $resumedThermostatMode" + //Turn selected thermostats into selected mode + state.internalThermostatEvent = true + changeAllThermostatsModes(thermostats, resumedThermostatMode, "$theSwitch.label has turned off") + + //Set flag if boost mode is selected as reset state so it can be set back to desired mode in 'thermostatModeAfterBoost' + if (resumedThermostatMode == "Boost") { + state.boostingReset = true + } + + } + //Reset app state + state.thermostatAltered = false + } + else + { + log.debug "Thermostats were not altered. No action taken." + } + } +} + +def takeActionForMode(mode) { + // Is incoming mode in the event input enumeration + if (mode in modes) + { + //Check thermostat is not already altered + if (!state.thermostatAltered) + { + //Turn selected thermostats into selected mode + + //Add detail to push message if set to Manual is specified + log.debug "$mode in selected modes, turning thermostats to $alteredThermostatMode" + state.internalThermostatEvent = true + changeAllThermostatsModes(thermostats, alteredThermostatMode, "mode has changed to $mode") + + //Only if reset action is specified, set the thermostatAltered state. + if (resetThermostats == "true") + { + state.thermostatAltered = true + } + } + } + else { + log.debug "$mode is not in select modes" + //Check if thermostats have previously been altered + if (state.thermostatAltered) + { + //Check if user wants to reset thermostats + if (resetThermostats == "true") + { + log.debug "Thermostats have been altered, turning back to $resumedThermostatMode" + + //Turn each thermostat to selected mode + state.internalThermostatEvent = true + changeAllThermostatsModes(thermostats, resumedThermostatMode, "mode has changed to $mode") + + //Set flag if boost mode is selected as reset state so it can be set back to desired mode in 'thermostatModeAfterBoost' + if (resumedThermostatMode == "Boost") { + state.boostingReset = true + } + + } + //Reset app state + state.thermostatAltered = false + } + else + { + log.debug "Thermostats were not altered. No action taken." + } + } +} + +//Helper method for thermostat mode change +private changeAllThermostatsModes(thermostats, newThermostatMode, reason) { + //Add detail to push message if set to Manual is specified + def thermostatModeDetail = newThermostatMode + if (newThermostatMode == "Set to Manual") { + thermostatModeDetail = thermostatModeDetail + " at $temp°C" + } + for (thermostat in thermostats) { + def message = '' + message = "SmartThings has reset $thermostat.label to $thermostatModeDetail because $reason." + log.info message + send(message) + log.debug "Setting $thermostat.label to $thermostatModeDetail" + if (newThermostatMode == "Set to Manual") { + thermostat.heat() + thermostat.setHeatingSetpoint(temp) + } + else if (newThermostatMode == "Turn Off") { + thermostat.off() + } + else if (newThermostatMode == "Boost") { + thermostat.emergencyHeat() + } + else { + thermostat.auto() + } + } +} + +private send(msg) { + if ( sendPushMessage != "No" ) { + log.debug( "sending push message" ) + sendPush( msg ) + } + + if ( phone ) { + log.debug( "sending text message" ) + sendSms( phone, msg ) + } + + log.debug msg +} + +private getAllOk() { + daysOk && timeOk +} + +private getDaysOk() { + def result = true + if (days) { + def df = new java.text.SimpleDateFormat("EEEE") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("Europe/London")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + log.trace "daysOk = $result" + result +} + +private getTimeOk() { + def result = true + if (starting && ending) { + def currTime = now() + def start = timeToday(starting).time + def stop = timeToday(ending).time + result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start + } + log.trace "timeOk = $result" + result +} + +private hhmm(time, fmt = "h:mm a") { + def t = timeToday(time, location.timeZone) + def f = new java.text.SimpleDateFormat(fmt) + f.setTimeZone(location.timeZone ?: timeZone(time)) + f.format(t) +} + +def getTimeLabel(starting, ending){ + + def timeLabel = "Tap to set" + + if(starting && ending){ + timeLabel = "Between" + " " + hhmm(starting) + " " + "and" + " " + hhmm(ending) + } + else if (starting) { + timeLabel = "Start at" + " " + hhmm(starting) + } + else if(ending){ + timeLabel = "End at" + hhmm(ending) + } + timeLabel +} + +private hideOptionsSection() { + (starting || ending || days || modes) ? false : true +} + +def greyedOutSettings(){ + def result = "" + if (starting || ending || days || falseAlarmThreshold) { + result = "complete" + } + result +} + +def greyedOutTime(starting, ending){ + def result = "" + if (starting || ending) { + result = "complete" + } + result +} + +def generateNotification(msg) { + if (settings.sendSMS != null) { + sendSms(sendSMS, msg) + } + if (settings.sendPush == true) { + sendPush(msg) + } +} + +def updateDevices() { + if (!state.devices) { + state.devices = [:] + } + def devices = devicesList() + state.hiveHeatingDevices = [:] + state.hiveHotWaterDevices = [:] + state.hiveBulbDevices = [:] + state.hiveTunableBulbDevices = [:] + state.hiveActivePlugDevices = [:] + state.hiveColourBulb = [:] + + def selectors = [] + devices.each { device -> + selectors.add("${device.id}") + //Heating + if (device.type == "heating" || device.type == "trvcontrol") { + def suffix = device.type == "heating" ? "Hive Heating" : "Hive TRV" + //Heating Control + log.debug "Identified: ${device.state.name} ${suffix}" + def value = "${device.state.name} ${suffix}" + def key = device.id + selectors.add("${key}") + state.hiveHeatingDevices["${key}"] = value + + //Update names of devices with Hive + def childDevice = getChildDevice("${key}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.state.name + " ${suffix}") { + childDevice.name = device.state.name + " ${suffix}" + log.debug "Device's name has changed." + } + } + // Water Control + } else if (device.type == "hotwater") { + log.debug "Identified: ${device.state.name} Hive Hot Water" + def value = "${device.state.name} Hive Hot Water" + def key = device.id + state.hiveHotWaterDevices["${key}"] = value + + //Update names of devices + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.state.name + " Hive Hot Water") { + childDevice.name = device.state.name + " Hive Hot Water" + log.debug "Device's name has changed." + } + } + //Dimmable Bulb + } else if (device.type == "tuneablelight") { + log.debug "Identified: ${device.state.name} Hive Light Tunable" + def value = "${device.state.name} Hive Light Tunable" + def key = device.id + state.hiveTunableBulbDevices["${key}"] = value + //Update names of devices + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.state.name) { + childDevice.name = device.state.name + log.debug "Device's name has changed." + } + } + //Colour Bulb + } else if (device.type == "colourtuneablelight") { + log.debug "Identified: ${device.state.name} Hive Colour Bulb" + def value = "${device.state.name} Hive Colour Bulb" + def key = device.id + state.hiveColourBulb["${key}"] = value + //Update names of devices + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.state.name) { + childDevice.name = device.state.name + log.debug "Device's name has changed." + } + } + //White Active Light Bulb + } else if (device.type == "warmwhitelight") { + log.debug "Identified: ${device.state.name} Hive Light Dimmable" + def value = "${device.state.name} Hive Light Dimmable" + def key = device.id + state.hiveBulbDevices["${key}"] = value + //Update names of devices + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.state.name) { + childDevice.name = device.state.name + log.debug "Device's name has changed." + } + } + // Active Plug + } else if (device.type == "activeplug") { + log.debug "Identified: ${device.state.name} Hive Plug" + def value = "${device.state.name} Hive Plug" + def key = device.id + state.hiveActivePlugDevices["${key}"] = value + //Update names of devices + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.state.name) { + childDevice.name = device.state.name + log.debug "Device's name has changed." + } + } + } + } + //Remove devices if does not exist on the Hive platform + getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { + log.info("Deleting ${it.deviceNetworkId}") + try { + deleteChildDevice(it.deviceNetworkId) + } catch (physicalgraph.exception.NotFoundException e) { + log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.") + } catch (physicalgraph.exception.ConflictException ce) { + log.info("Device ${it.deviceNetworkId} in use. Please manually delete.") + } + } +} + +def addHeating() { + updateDevices() + + selectedHeating.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice) { + log.info("Adding Hive Heating device ${device}: ${state.hiveHeatingDevices[device]}") + + def data = [ + name: state.hiveHeatingDevices[device], + label: state.hiveHeatingDevices[device], + ] + childDevice = addChildDevice(app.namespace, "Hive Heating", "$device", null, data) + childDevice.refresh() + + log.debug "Created ${state.hiveHeatingDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.hiveHeatingDevices[device]} with id ${device} already exists" + } + + } +} + +def addHotWater() { + updateDevices() + + selectedHotWater.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice) { + log.info("Adding Hive Hot Water device ${device}: ${state.hiveHotWaterDevices[device]}") + + def data = [ + name: state.hiveHotWaterDevices[device], + label: state.hiveHotWaterDevices[device], + ] + childDevice = addChildDevice(app.namespace, "Hive Hot Water", "$device", null, data) + childDevice.refresh() + log.debug "Created ${state.hiveHotWaterDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.hiveHotWaterDevices[device]} with id ${device} already exists" + } + + } +} + +def addBulb() { + updateDevices() + + selectedBulb.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice) { + log.debug "Adding Hive Light Dimmable device ${device}: ${state.hiveBulbDevices[device]}" + + def data = [ + name: state.hiveBulbDevices[device], + label: state.hiveBulbDevices[device], + ] + + log.debug data + + childDevice = addChildDevice(app.namespace, "Hive Active Light", "$device", null, data) + childDevice.refresh() + + log.debug "Created ${state.hiveBulbDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.hiveBulbDevices[device]} with id ${device} already exists" + } + + } +} + +def addTunableBulb() { + updateDevices() + + selectedTunableBulb.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice) { + log.debug "Adding Hive Light Tuneable device ${device}: ${state.hiveTunableBulbDevices[device]}" + + def data = [ + name: state.hiveTunableBulbDevices[device], + label: state.hiveTunableBulbDevices[device], + ] + + log.debug data + + childDevice = addChildDevice(app.namespace, "Hive Active Light Tuneable", "$device", null, data) + childDevice.refresh() + + log.debug "Created ${state.hiveTunableBulbDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.hiveTunableBulbDevices[device]} with id ${device} already exists" + } + + } +} + +def addColourBulb() { + updateDevices() + + selectedColourBulb.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice) { + log.debug "Adding Hive Light Colour device ${device}: ${state.hiveBulbDevices[device]}" + + def data = [ + name: state.hiveColourBulb[device], + label: state.hiveColourBulb[device], + ] + + log.debug data + + childDevice = addChildDevice(app.namespace, "Hive Active Light Colour Tuneable", "$device", null, data) + childDevice.refresh() + + log.debug "Created ${state.hiveColourBulb[device]} with id: ${device}" + + } else { + log.debug "found ${state.hiveColourBulb[device]} with id ${device} already exists" + } + + } +} + +def addActivePlug() { + updateDevices() + + selectedActivePlug.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice) { + log.debug "Adding Hive Plug device ${device}: ${state.hiveActivePlugDevices[device]}" + + def data = [ + name: state.hiveActivePlugDevices[device], + label: state.hiveActivePlugDevices[device], + ] + + log.debug data + + childDevice = addChildDevice(app.namespace, "Hive Active Plug", "$device", null, data) + childDevice.refresh() + + log.debug "Created ${state.hiveActivePlugDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.hiveActivePlugDevices[device]} with id ${device} already exists" + } + + } +} + +def refreshDevices() { + log.info("Refreshing all devices...") + getChildDevices().each { device -> + device.refresh() + } +} + +def devicesList() { + logErrors([]) { + def resp = apiGET("/products") + if (resp.status == 200) { + return resp.data + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def getDeviceStatus(id) { + def retVal = [] + def resp = apiGET("/products") + if (resp.status == 200) { + resp.data.eachWithIndex { currentDevice, i -> + if(currentDevice.id == id || (currentDevice.type + "/" + currentDevice.id) == id) { + retVal = resp.data[i] + } + } + + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + } + return retVal +} + +def apiGET(path, body = [:]) { + try { + if(!isLoggedIn()) { + log.debug "Need to login" + getBeekeeperAccessToken() + } + log.debug("Beginning API GET: ${apiBeekeeperUKURL(path)}, ${apiRequestHeaders()}") + + httpGet(uri: apiBeekeeperUKURL(path), contentType: 'application/json', headers: apiRequestHeaders()) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +def apiPOST(path, body = [:]) { + try { + if(!isLoggedIn()) { + log.debug "Need to login" + getBeekeeperAccessToken() + } + log.debug("Beginning API POST: ${path}, ${body}") + + httpPostJson(uri: apiBeekeeperUKURL(path), body: body, headers: apiRequestHeaders() ) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +def getBeekeeperAccessToken() { + try { + def tokens + if (state.beekeeperToken) { + tokens = [ "token": state.beekeeperToken, + "refreshToken": state.beekeeperRefreshToken, + "accessToken": state.beekeeperAccessToken ] + } else { + tokens = initTokens() + } + def params = [ + uri: apiBeekeeperURL('/cognito/refresh-token'), + contentType: 'application/json', + body: tokens + ] + + state.cookie = '' + + httpPostJson(params) {response -> + log.debug "Request was successful, $response.status" + log.debug response.headers + + state.cookie = response?.headers?.'Set-Cookie'?.split(";")?.getAt(0) + log.debug "Adding cookie to collection: $cookie" + log.debug "auth: $response.data" + log.debug "cookie: $state.cookie" + log.debug "token: ${response.data.token}" + log.debug "refreshToken: ${response.data.refreshToken}" + log.debug "accessToken: ${response.data.accessToken}" + + + state.beekeeperToken = response.data.token + state.beekeeperRefreshToken = response.data.refreshToken + state.beekeeperAccessToken = response.data.accessToken + // set the expiration to 20 minutes + state.beekeeperToken_expires_at = new Date().getTime() + 1200000 + state.loginerrors = null + } + + } catch (groovyx.net.http.HttpResponseException e) { + if (e.response.status == 401) { + state.remove("beekeeperToken") + state.remove("beekeeperRefreshToken") + state.remove("beekeeperAccessToken") + state.remove("beekeeperToken_expires_at") + } + state.loginerrors = "Error: ${e.response.status}: ${e.response.data}" + logResponse(e.response) + return e.response + } +} + +def apiRequestHeaders() { + return [ + 'Authorization': "Bearer ${state.beekeeperToken}" + ] +} + +def isLoggedIn() { + state.remove("hiveAccessToken") + log.debug "Calling isLoggedIn()" + log.debug "isLoggedIn state $state.beekeeperToken" + if(!state.beekeeperToken) { + log.debug "No state.beekeeperToken" + return false + } + + def now = new Date().getTime() + return state.beekeeperToken_expires_at > now +} + + +def isTmaAppInst() { + def chldCnt = 0 + childApps?.each { cApp -> +// if(cApp?.name != getWatchdogAppChildName()) { chldCnt = chldCnt + 1 } + chldCnt = chldCnt + 1 + } + return (chldCnt > 0) ? true : false +} + +def logResponse(response) { + log.info("Status: ${response.status}") + log.info("Body: ${response.data}") +} + +def logErrors(options = [errorReturn: null, logObject: log], Closure c) { + try { + return c() + } catch (groovyx.net.http.HttpResponseException e) { + log.error("got error: ${e}, body: ${e.getResponse().getData()}") + if (e.statusCode == 401) { // token is expired + state.remove("beekeeperToken") + state.remove("beekeeperRefreshToken") + state.remove("beekeeperAccessToken") + state.remove("beekeeperToken_expires_at") + log.warn "Access token is not valid" + } + return options.errorReturn + } catch (java.net.SocketTimeoutException e) { + log.warn "Connection timed out, not much we can do here" + return options.errorReturn + } +} + +def appName() { return "${parent ? "Hive Mode Automation" : "Hive (Connect)"}" } \ No newline at end of file diff --git a/smartapps/alyc100/hive-tunablebulb.jpg b/smartapps/alyc100/hive-tunablebulb.jpg new file mode 100644 index 00000000000..2b19f789d7a Binary files /dev/null and b/smartapps/alyc100/hive-tunablebulb.jpg differ diff --git a/smartapps/alyc100/hive-window-door-sensor-815702baa8f484d342f2ebf3eb38ab971acecba02586d0ec485c588f2646c935.jpg b/smartapps/alyc100/hive-window-door-sensor-815702baa8f484d342f2ebf3eb38ab971acecba02586d0ec485c588f2646c935.jpg new file mode 100644 index 00000000000..0c57b6bc16e Binary files /dev/null and b/smartapps/alyc100/hive-window-door-sensor-815702baa8f484d342f2ebf3eb38ab971acecba02586d0ec485c588f2646c935.jpg differ diff --git a/smartapps/alyc100/icon175x175.jpeg b/smartapps/alyc100/icon175x175.jpeg new file mode 100644 index 00000000000..09946907f7e Binary files /dev/null and b/smartapps/alyc100/icon175x175.jpeg differ diff --git a/smartapps/alyc100/light-switch-force-sync.src/light-switch-force-sync.groovy b/smartapps/alyc100/light-switch-force-sync.src/light-switch-force-sync.groovy new file mode 100644 index 00000000000..f8756285331 --- /dev/null +++ b/smartapps/alyc100/light-switch-force-sync.src/light-switch-force-sync.groovy @@ -0,0 +1,90 @@ +/** + * Light Switch Force Sync + * + * Copyright 2015 Alex Lee Yuk Cheung + * + * Created because of Osram bulbs turning on randomly. If Osram bulbs are grouped and controlled via a master virtual switch, + * this smart app enables the individual bulbs to be checked and the bulb state (on/off) synchronised with the state of the master virtual switch. + * + * 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. + * + * VERSION HISTORY + * 20.01.2016 + * v1.0 - Initial Release + */ + +definition( + name: "Light Switch Force Sync", + namespace: "alyc100", + author: "Alex Lee Yuk Cheung", + description: "If you have a set of lights activated on a master switch. Poll to ensure states are updated.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + +preferences { + page(name: "configurePage") + +} + +def configurePage() { + dynamicPage(name: "configurePage", title:"Setup", install: true, uninstall: true) { + section { + input("lights", "capability.switch", title: "For these lights", multiple: true, required: true) + } + + section { + input("masterSwitch", "capability.switch", title: "Synchronise to this master switch", multiple: false, required: true) + } + } +} + +// App lifecycle hooks + +def installed() { + // Check for new devices and remove old ones every 3 hours + // execute handlerMethod every 10 minutes. + schedule("0 0/5 * * * ?", syncLightsToSwitch) +} + +// called after settings are changed +def updated() { + log.debug "Executing 'updated()'" + unschedule(syncLightsToSwitch) + schedule("0 0/5 * * * ?", syncLightsToSwitch) +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule(syncLightsToSwitch) +} + +def syncLightsToSwitch() { + log.debug "Executing 'syncLightsToSwitch()'" + lights.refresh() + runIn(10, updateLightState) +} + +def updateLightState() { + log.debug "masterSwitchState: $masterSwitch.currentSwitch" + for (light in lights) { + if (masterSwitch.currentSwitch == "on") { + if (light.currentSwitch == "off") { + light.on() + } + } else { + if (light.currentSwitch == "on") { + light.off() + } + } + } +} \ No newline at end of file diff --git a/smartapps/alyc100/mihome-adapter.png b/smartapps/alyc100/mihome-adapter.png new file mode 100644 index 00000000000..721c195ab2d Binary files /dev/null and b/smartapps/alyc100/mihome-adapter.png differ diff --git a/smartapps/alyc100/mihome-connect.src/mihome-connect.groovy b/smartapps/alyc100/mihome-connect.src/mihome-connect.groovy new file mode 100644 index 00000000000..ce4ed528121 --- /dev/null +++ b/smartapps/alyc100/mihome-connect.src/mihome-connect.groovy @@ -0,0 +1,755 @@ +/** + * MiHome (Connect) + * + * Copyright 2016 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * + * 06.08.2018: 2.0.2b - Double light switch support. + * 04.08.2018: 2.0.2 - Updated supported devices. Remove CRON schedule in place of runEveryXMinute command. + * 31.07.2018: 2.0.1c - Bug fix. Stop SSL exception on API call. + * 16.01.2017: 2.0.1b - Bug fix. Wrong implementation of double wall socket fixed. + * 16.01.2017: 2.0.1 - Added support for MiHome Double Wall Socket + * 09.01.2017: 2.0c - Added support for MiHome House Monitor + * 12.12.2016: 2.0b - Null issues when a device has been forced removed. + * 23.11.2016: 2.0 - Remove extra logging. + * + * 10.11.2016: 2.0 BETA Release 6 - Merge Light Switch and Adapter functionality into one device type. + * 10.11.2016: 2.0 BETA Release 5.4 - Remove unecessary catch blocks for fixed executeAction() errors. + * 10.11.2016: 2.0 BETA Release 5.3 - Suppress random executeAction() errors. + * + * 09.11.2016: 2.0 BETA Release 5.2 - 4 gang device detection fix. + * 09.11.2016: 2.0 BETA Release 5.1 - Try and reduce chances of executeAction() errors. + * 09.11.2016: 2.0 BETA Release 5 - Add 4 Gang Extension compatibility. + * + * 08.11.2016: 2.0 BETA Release 4 - Add Energy Monitor Device compatibility. Separate Adapter and Adapter Plus devices. + * 08.11.2016: 2.0 BETA Release 3 - Add Motion Sensor Device compatibility. Detect standard MiHome adapters. + * + * 06.11.2016: 2.0 BETA Release 2 - Fix issue identifying MiHome adapters. + * 06.11.2016: 2.0 BETA Release 1 - Enable MiHome Connect to manage other MiHome devices. Update framework to match other alyc100 connect apps. + * + * 31.01.2016: 1.0.4 - Move external icon references into Github + * 31.01.2016: 1.0.3b - Added icons to MiHome device list. + * 31.01.2016: 1.0.3 - Bug fix to refresh schedule job. + * + * 17.01.2016: 1.0.2 - Bug fix when device has been manually deleted. + * + * 10.01.2016: 1.0.1 - Improve messaging for connection process. + * + * 09.01.2016: 1.0 - Initial Release + * + */ +definition( + name: "MiHome (Connect)", + namespace: "alyc100", + author: "Alex Lee Yuk Cheung", + description: "Connect your MiHome devices to SmartThings.", + iconUrl: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome-icon-89db7a9bfb5c8b066ffb4e50c8d68235.png", + iconX2Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome-icon-89db7a9bfb5c8b066ffb4e50c8d68235.png", + singleInstance: true +) + +preferences { + page(name:"firstPage", title:"MiHome Device Setup", content:"firstPage", install: true) + page(name: "loginPAGE") + page(name: "selectDevicePAGE") +} + +def apiURL(path = '/') { return "https://mihome4u.co.uk/api/v1${path}" } + +def firstPage() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter MiHome account crednentials", state: authenticated()) + } + } + } + else + { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username + ". Tap to reset authentication" : "Tap to enter MiHome account crednentials", state: authenticated()) + } + if (stateTokenPresent()) { + section ("Choose your MiHome devices:") { + href("selectDevicePAGE", title: null, description: devicesSelected() ? getDevicesSelectedString() : "Tap to select MiHome devices", state: devicesSelected()) + } + section () { + label name: "name", title: "Assign a Name", required: true, state: (name ? "complete" : null), defaultValue: app.name + } + } else { + section { + paragraph "There was a problem connecting to MiHome. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def loginPAGE() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your MiHome account credentials below to enable SmartThings and MiHome integration." } + section { + input("username", "text", title: "Username", description: "Your MiHome username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your MiHome password", required: true, submitOnChange: true) + } + } + } + else { + resetMiHomeAccessToken() + dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your MiHome account credentials below to enable SmartThings and MiHome integration." } + section("MiHome Credentials:") { + input("username", "text", title: "Username", description: "Your MiHome username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your MiHome password", required: true, submitOnChange: true) + } + + if (stateTokenPresent()) { + section { + paragraph "You have successfully connected to MiHome. Click 'Done' to select your MiHome devices." + } + } + else { + section { + paragraph "There was a problem connecting to MiHome. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def selectDevicePAGE() { + updateDevices() + dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) { + section { headerSECTION() } + section("Select your devices:") { + input "selectedETRVs", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome4-01bc8a0e478b385df3248b55cc2df7ca.png", required:false, title:"Select MiHome eTRV Devices \n(${state.miETRVDevices.size() ?: 0} found)", multiple:true, options:state.miETRVDevices + input "selectedLights", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome3_switch.png", required:false, title:"Select MiHome Light Devices \n(${state.miLightDevices.size() ?: 0} found)", multiple:true, options:state.miLightDevices + input "selectedAdapters", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome-adapter.png", required:false, title:"Select MiHome Adapter Devices \n(${state.miAdapterDevices.size() ?: 0} found)", multiple:true, options:state.miAdapterDevices + input "selectedAdapterPluses", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome-monitor.png", required:false, title:"Select MiHome Adapter Plus Devices \n(${state.miAdapterPlusDevices.size() ?: 0} found)", multiple:true, options:state.miAdapterPlusDevices + input "selected4GangExtensions", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome-extension.png", required:false, title:"Select MiHome 4 Gang Extension Devices \n(${state.mi4GangExtensionDevices.size() ?: 0} found)", multiple:true, options:state.mi4GangExtensionDevices + input "selectedSockets", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome2-socket.png", required:false, title:"Select MiHome Wall Socket Devices \n(${state.miSocketDevices.size() ?: 0} found)", multiple:true, options:state.miSocketDevices + input "selectedMonitors", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome5-adapter.png", required:false, title:"Select MiHome Monitor Devices \n(${state.miMonitorDevices.size() ?: 0} found)", multiple:true, options:state.miMonitorDevices + input "selectedMotions", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome-motion-sensor-ir.png", required:false, title:"Select MiHome Motion Sensors \n(${state.miMotionSensors.size() ?: 0} found)", multiple:true, options:state.miMotionSensors + } + } +} + +def headerSECTION() { + return paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/mihome-icon-89db7a9bfb5c8b066ffb4e50c8d68235.png", + "${textVersion()}") +} + +def stateTokenPresent() { + return state.miHomeAccessToken != null && state.miHomeAccessToken != '' +} + +def authenticated() { + return (state.miHomeAccessToken != null && state.miHomeAccessToken != '') ? "complete" : null +} + +def devicesSelected() { + return (selectedETRVs || selectedLights || selectedAdapters || selectedAdapterPluses || selected4GangExtensions || selectedSockets || selectedMonitors || selectedMotions) ? "complete" : null +} + +def getDevicesSelectedString() { + if (state.miETRVDevices == null || state.miLightDevices == null || state.miAdapterDevices == null || state.miAdapterPlusDevices == null || state.mi4GangExtensionDevices == null || state.miSocketDevices == null || state.miMonitorDevices == null || state.miMotionSensors == null) { + updateDevices() + } + + def listString = "" + selectedETRVs.each { childDevice -> + if (state.miETRVDevices[childDevice] != null) listString += state.miETRVDevices[childDevice] + "\n" + } + selectedLights.each { childDevice -> + if (state.miLightDevices[childDevice] != null) listString += state.miLightDevices[childDevice] + "\n" + } + selectedAdapters.each { childDevice -> + if (state.miAdapterDevices[childDevice] != null) listString += state.miAdapterDevices[childDevice] + "\n" + } + selectedAdapterPluses.each { childDevice -> + if (state.miAdapterPlusDevices[childDevice] != null) listString += state.miAdapterPlusDevices[childDevice] + "\n" + } + selected4GangExtensions.each { childDevice -> + if (state.mi4GangExtensionDevices[childDevice] != null) listString += state.mi4GangExtensionDevices[childDevice] + "\n" + } + selectedSockets.each { childDevice -> + if (state.miSocketDevices[childDevice] != null) listString += state.miSocketDevices[childDevice] + "\n" + } + selectedMonitors.each { childDevice -> + if (state.miMonitorDevices[childDevice] != null) listString += state.miMonitorDevices[childDevice] + "\n" + } + selectedMotions.each { childDevice -> + if (state.miMotionSensors[childDevice] != null) listString += state.miMotionSensors[childDevice] + "\n" + } + return listString +} + +// App lifecycle hooks + +def installed() { + log.debug "installed" + initialize() + // Check for new devices and remove old ones every 3 hours + runEvery3Hours('updateDevices') + // execute refresh method every 5 minutes + runEvery1Minute('refreshDevices') +} + +// called after settings are changed +def updated() { + log.debug "updated" + initialize() + unschedule('refreshDevices') + runEvery1Minute('refreshDevices') +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule() + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(devices) { + devices.each { + deleteChildDevice(it.deviceNetworkId) // 'it' is default + } +} + +// called after Done is hit after selecting a Location +def initialize() { + log.debug "initialize" + if (selectedETRVs) { + addETRV() + } + if (selectedLights) { + addLight() + } + if (selectedAdapters) { + addAdapter() + } + if (selectedAdapterPluses) { + addAdapterPlus() + } + if (selected4GangExtensions) { + add4GangExtension() + } + if (selectedSockets) { + addSocket() + } + if (selectedMonitors) { + addMonitor() + } + if (selectedMotions) { + addMotion() + } + + def devices = getChildDevices() + devices.each { + log.debug "Refreshing device $it.name" + it.refresh() + } +} + + +def updateDevices() { + if (!state.devices) { + state.devices = [:] + } + def devices = devicesList() + state.miETRVDevices = [:] + state.miLightDevices = [:] + state.miAdapterDevices = [:] + state.miAdapterPlusDevices = [:] + state.mi4GangExtensionDevices = [:] + state.miSocketDevices = [:] + state.miMotionSensors = [:] + state.miMonitorDevices = [:] + + def selectors = [] + devices.each { device -> + if (device.device_type == 'etrv') { + log.debug "Identified: device ${device.id}: ${device.device_type}: ${device.label}: ${device.target_temperature}: ${device.last_temperature}: ${device.voltage}" + selectors.add("${device.id}") + def value = "${device.label} eTRV" + def key = device.id + state.miETRVDevices["${key}"] = value + + //Update names of devices with MiHome + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.label + " eTRV") { + childDevice.name = device.label + " eTRV" + log.debug "Device's name has changed." + } + } + } + else if (device.device_type == 'light' || device.device_type == 'double_light') { + log.debug "Identified: device ${device.id}: ${device.device_type}: ${device.label}" + selectors.add("${device.id}") + def value = "${device.label} Light Switch" + def key = device.id + state.miLightDevices["${key}"] = value + + //Update names of devices with MiHome + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.label + " Light Switch") { + childDevice.name = device.label + " Light Switch" + log.debug "Device's name has changed." + } + } + } + else if (device.device_type == 'legacy') { + log.debug "Identified: device ${device.id}: ${device.device_type}: ${device.label}" + selectors.add("${device.id}") + def value = "${device.label} Adapter" + def key = device.id + state.miAdapterDevices["${key}"] = value + + //Update names of devices with MiHome + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.label + " Adapter") { + childDevice.name = device.label + " Adapter" + log.debug "Device's name has changed." + } + } + } + else if (device.device_type == 'socket') { + log.debug "Identified: device ${device.id}: ${device.device_type}: ${device.label}" + selectors.add("${device.id}") + def value = "${device.label} Wall Socket" + def key = device.id + state.miSocketDevices["${key}"] = value + + //Update names of devices with MiHome + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.label + " Wall Socket") { + childDevice.name = device.label + " Wall Socket" + log.debug "Device's name has changed." + } + } + } + else if (device.device_type == 'control') { + log.debug "Identified: device ${device.id}: ${device.device_type}: ${device.label}" + selectors.add("${device.id}") + def value = "${device.label} Adapter Plus" + def key = device.id + state.miAdapterPlusDevices["${key}"] = value + + //Update names of devices with MiHome + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.label + " Adapter Plus") { + childDevice.name = device.label + " Adapter Plus" + log.debug "Device's name has changed." + } + } + } + else if (device.device_type == 'fourgang') { + log.debug "Identified: device ${device.id}: ${device.device_type}: ${device.label}" + def value = "${device.label} 4 Gang Extension" + def key = device.id + state.mi4GangExtensionDevices["${key}"] = value + + //Update names of devices with MiHome + 0.upto(3, { + selectors.add("${device.id}/${it}") + def childDevice = getChildDevice("${device.id}/${it}") + + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.label + " 4 Gang Extension [Socket ${it + 1}]") { + childDevice.name = device.label + " 4 Gang Extension [Socket ${it + 1}]" + log.debug "Device's name has changed." + } + } + }) + } + else if (device.device_type == 'monitor' || device.device_type == 'house' || device.device_type == 'home') { + log.debug "Identified: device ${device.id}: ${device.device_type}: ${device.label}" + selectors.add("${device.id}") + def value = "${device.label} Monitor" + def key = device.id + state.miMonitorDevices["${key}"] = value + + //Update names of devices with MiHome + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.label + " Monitor") { + childDevice.name = device.label + " Monitor" + log.debug "Device's name has changed." + } + } + } + else if (device.device_type == 'motion' || device.device_type == 'open' ) { + log.debug "Identified: device ${device.id}: ${device.device_type}: ${device.label}" + selectors.add("${device.id}") + def value = "${device.label} Motion Sensor" + def key = device.id + state.miMotionSensors["${key}"] = value + + //Update names of devices with MiHome + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.label + " Motion Sensor") { + childDevice.name = device.label + " Motion Sensor" + log.debug "Device's name has changed." + } + } + } + else { + log.debug "Unsupported device: device ${device.id}: ${device.device_type}. Contact alyc100 with copy of this log." + } + } + log.debug selectors + //Remove devices if does not exist on the MiHome platform + getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { + log.info("Deleting ${it.deviceNetworkId}") + try { + deleteChildDevice(it.deviceNetworkId) + } catch (physicalgraph.exception.NotFoundException e) { + log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.") + } catch (physicalgraph.exception.ConflictException ce) { + log.info("Device ${it.deviceNetworkId} in use. Please manually delete.") + } + } +} + +def addETRV() { + updateDevices() + + selectedETRVs.each { device -> + + def childDevice = getChildDevice("${device}") + if (!childDevice && state.miETRVDevices[device] != null) { + log.info("Adding device ${device}: ${state.miETRVDevices[device]}") + + def data = [ + name: state.miETRVDevices[device], + label: state.miETRVDevices[device] + ] + childDevice = addChildDevice(app.namespace, "MiHome eTRV", "$device", null, data) + + log.debug "Created ${state.miETRVDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.miETRVDevices[device]} with id ${device} already exists" + } + + } +} + +def addLight() { + updateDevices() + + selectedLights.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice && state.miLightDevices[device] != null) { + log.info("Adding device ${device}: ${state.miLightDevices[device]}") + def data = [ + name: state.miLightDevices[device], + label: state.miLightDevices[device] + ] + childDevice = addChildDevice(app.namespace, "MiHome Adapter", "$device", null, data) + + log.debug "Created ${state.miLightDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.miLightDevices[device]} with id ${device} already exists" + } + + } +} + +def addAdapter() { + updateDevices() + + selectedAdapters.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice && state.miAdapterDevices[device] != null) { + log.info("Adding device ${device}: ${state.miAdapterDevices[device]}") + + def data = [ + name: state.miAdapterDevices[device], + label: state.miAdapterDevices[device] + ] + childDevice = addChildDevice(app.namespace, "MiHome Adapter", "$device", null, data) + + log.debug "Created ${state.miAdapterDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.miAdapterDevices[device]} with id ${device} already exists" + } + + } +} + +def addAdapterPlus() { + updateDevices() + + selectedAdapterPluses.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice && state.miAdapterPlusDevices[device] != null) { + log.info("Adding device ${device}: ${state.miAdapterPlusDevices[device]}") + + def data = [ + name: state.miAdapterPlusDevices[device], + label: state.miAdapterPlusDevices[device] + ] + childDevice = addChildDevice(app.namespace, "MiHome Adapter Plus", "$device", null, data) + + log.debug "Created ${state.miAdapterPlusDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.miAdapterPlusDevices[device]} with id ${device} already exists" + } + + } +} + +def add4GangExtension() { + updateDevices() + + selected4GangExtensions.each { device -> + 0.upto(3, { + def childDevice = getChildDevice("${device}/${it}") + + if (!childDevice && state.mi4GangExtensionDevices[device] != null) { + log.info("Adding device ${device}/${it}: ${state.mi4GangExtensionDevices[device]} [Socket ${it + 1}]") + + def data = [ + name: "${state.mi4GangExtensionDevices[device]} [Socket ${it + 1}]", + label: "${state.mi4GangExtensionDevices[device]} [Socket ${it + 1}]" + ] + childDevice = addChildDevice(app.namespace, "MiHome Adapter", "${device}/${it}", null, data) + + log.debug "Created ${state.mi4GangExtensionDevices[device]} [Socket ${it + 1}] with id: ${device}/${it}" + } else { + log.debug "found ${state.mi4GangExtensionDevices[device]} [Socket ${it + 1}] with id ${device}/${it} already exists" + } + }) + } +} + +def addSocket() { + updateDevices() + + selectedSockets.each { device -> + def childDevice = getChildDevice("${device}") + + if (!childDevice && state.miSocketDevices[device] != null) { + log.info("Adding device ${device}: ${state.miSocketDevices[device]}") + + def data = [ + name: "${state.miSocketDevices[device]}", + label: "${state.miSocketDevices[device]}" + ] + childDevice = addChildDevice(app.namespace, "MiHome Adapter", "${device}", null, data) + + log.debug "Created ${state.miSocketDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.miSocketDevices[device]} with id ${device}} already exists" + } + } +} + +def addMonitor() { + updateDevices() + + selectedMonitors.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice && state.miMonitorDevices[device] != null) { + log.info("Adding device ${device}: ${state.miMonitorDevices[device]}") + + def data = [ + name: state.miMonitorDevices[device], + label: state.miMonitorDevices[device] + ] + childDevice = addChildDevice(app.namespace, "MiHome Monitor", "$device", null, data) + + log.debug "Created ${state.miMonitorDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.miMonitorDevices[device]} with id ${device} already exists" + } + + } +} + +def addMotion() { + updateDevices() + + selectedMotions.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice && state.miMotionSensors[device] != null) { + log.info("Adding device ${device}: ${state.miMotionSensors[device]}") + + def data = [ + name: state.miMotionSensors[device], + label: state.miMotionSensors[device] + ] + childDevice = addChildDevice(app.namespace, "MiHome Motion Sensor", "$device", null, data) + + log.debug "Created ${state.miMotionSensors[device]} with id: ${device}" + } else { + log.debug "found ${state.miMotionSensors[device]} with id ${device} already exists" + } + + } +} + +def refreshDevices() { + log.info("Executing refreshDevices...") + if (atomicState.refreshCounter == null || atomicState.refreshCounter >= 5) { + atomicState.refreshCounter = 0 + } else { + atomicState.refreshCounter = atomicState.refreshCounter + 1 + } + getChildDevices().each { device -> + if (atomicState.refreshCounter == 5) { + log.info("Low Freq Refreshing device ${device.name} ...") + try { + device.refresh() + } catch (e) { + //WORKAROUND - Catch unexplained exception when refreshing devices. + logResponse(e.response) + } + } else if (device.name.contains("Monitor") || device.name.contains("Motion Sensor") || device.name.contains("Adapter Plus")|| device.name.contains("eTRV")) { + log.info("Hight Freq Refreshing device ${device.name}...") + device.refresh() + } + } +} + +def devicesList() { + logErrors([]) { + def resp = apiGET("/subdevices/list") + if (resp.status == 200) { + + return resp.data.data + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def getMiHomeAccessToken() { + def resp = apiGET("/users/profile") + if (resp.status == 200) { + state.miHomeAccessToken = resp.data.data.api_key + log.debug "miHomeAccessToken: $resp.data.data.api_key" + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } +} + +def resetMiHomeAccessToken() { + log.debug("Resetting token...") + state.miHomeAccessToken = '' + getMiHomeAccessToken() +} + +def apiGET(path) { + try { + log.debug("Beginning API GET: ${path}") + httpGet(uri: apiURL(path), headers: apiRequestHeaders()) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + return e.response + } catch (javax.net.ssl.SSLException ssle) { + log.error ssle + } catch (Exception ex) { + return ex.response + } +} + +def apiPOST(path, body = [:]) { + try { + log.debug("Beginning API POST: ${path}, ${body}") + httpGet(uri: apiURL(path), body: new groovy.json.JsonBuilder(body).toString(), headers: apiRequestHeaders()) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + return e.response + } catch (javax.net.ssl.SSLException ssle) { + log.error ssle + } catch (Exception ex) { + return ex.response + } +} + +Map apiRequestHeaders() { + def userpassascii = "${username}:${password}" + if (state.miHomeAccessToken && state.miHomeAccessToken != '') { + userpassascii = "${username}:${state.miHomeAccessToken}" + } + def userpass = "Basic " + userpassascii.encodeAsBase64().toString() + + return ["User-Agent": "SmartThings Integration", + "Authorization": "$userpass" + ] +} + +def logResponse(response) { + log.info("Status: ${response.status}") + //log.info("Body: ${response.data}") +} + +def logErrors(options = [errorReturn: null, logObject: log], Closure c) { + try { + return c() + } catch (groovyx.net.http.HttpResponseException e) { + options.logObject.error("got error: ${e}, body: ${e.getResponse().getData()}") + if (e.statusCode == 401) { // token is expired + state.remove("miHomeAccessToken") + options.logObject.warn "Access token is not valid" + } + return options.errorReturn + } catch (java.net.SocketTimeoutException e) { + options.logObject.warn "Connection timed out, not much we can do here" + return options.errorReturn + } +} + +private def textVersion() { + def text = "MiHome (Connect)\nVersion: 2.0.3\nDate: 23092021(2015)" +} + +private def textCopyright() { + def text = "Copyright © 2017,2018 Alex Lee Yuk Cheung" +} diff --git a/smartapps/alyc100/mihome-extension.png b/smartapps/alyc100/mihome-extension.png new file mode 100644 index 00000000000..64b61839a6f Binary files /dev/null and b/smartapps/alyc100/mihome-extension.png differ diff --git a/smartapps/alyc100/mihome-icon-89db7a9bfb5c8b066ffb4e50c8d68235.png b/smartapps/alyc100/mihome-icon-89db7a9bfb5c8b066ffb4e50c8d68235.png new file mode 100644 index 00000000000..daebf198ac4 Binary files /dev/null and b/smartapps/alyc100/mihome-icon-89db7a9bfb5c8b066ffb4e50c8d68235.png differ diff --git a/smartapps/alyc100/mihome-monitor.png b/smartapps/alyc100/mihome-monitor.png new file mode 100644 index 00000000000..276ae264114 Binary files /dev/null and b/smartapps/alyc100/mihome-monitor.png differ diff --git a/smartapps/alyc100/mihome-motion-sensor-ir.png b/smartapps/alyc100/mihome-motion-sensor-ir.png new file mode 100644 index 00000000000..e48865768d4 Binary files /dev/null and b/smartapps/alyc100/mihome-motion-sensor-ir.png differ diff --git a/smartapps/alyc100/mihome2-socket.png b/smartapps/alyc100/mihome2-socket.png new file mode 100644 index 00000000000..683c0747466 Binary files /dev/null and b/smartapps/alyc100/mihome2-socket.png differ diff --git a/smartapps/alyc100/mihome3_switch.png b/smartapps/alyc100/mihome3_switch.png new file mode 100644 index 00000000000..b00caa99e23 Binary files /dev/null and b/smartapps/alyc100/mihome3_switch.png differ diff --git a/smartapps/alyc100/mihome4-01bc8a0e478b385df3248b55cc2df7ca.png b/smartapps/alyc100/mihome4-01bc8a0e478b385df3248b55cc2df7ca.png new file mode 100644 index 00000000000..255e7879f73 Binary files /dev/null and b/smartapps/alyc100/mihome4-01bc8a0e478b385df3248b55cc2df7ca.png differ diff --git a/smartapps/alyc100/mihome5-adapter.png b/smartapps/alyc100/mihome5-adapter.png new file mode 100644 index 00000000000..bb6ffa51a57 Binary files /dev/null and b/smartapps/alyc100/mihome5-adapter.png differ diff --git a/smartapps/alyc100/neato-connect.src/neato-connect.groovy b/smartapps/alyc100/neato-connect.src/neato-connect.groovy new file mode 100644 index 00000000000..d4a47336f2f --- /dev/null +++ b/smartapps/alyc100/neato-connect.src/neato-connect.groovy @@ -0,0 +1,1425 @@ +/** + * Neato (Connect) + * + * Copyright 2016,2017,2018,2019,2020 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 17-04-2020: 1.2.6c - Notification workaround to fix issue caused by change in ST platform. + * 07-04-2020: 1.2.6b - Handle regularly changing secret key from Neato API. + * 05-09-2019: 1.2.6 - Option to delay cleaning if bin is full. + * 05-09-2019: 1.2.5 - Handle new Long Secret Key format for future Neato Botvac firmware. + * 28-06-2018: 1.2.4b - Bug fix. Stop nullPointerException on reschedule method. + * 18-04-2018: 1.2.4 - Show restriction summary text in app when contact sensor restrictions are configured. + * 19-01-2018: 1.2.3 - Allow contact sensors to trigger clean if conditions are met. + * 17-01-2018: 1.2.2 - Allow contact sensors to restrict Botvac start. + * 06-01-2018: 1.2.1e - Fix null pointer exception on new installations. + * 05-01-2018: 1.2.1d - Another attempt to remove null reference when Botvac is removed. + * 05-01-2018: 1.2.1c - Attempt to remove null reference when Botvac is removed. + * 14-10-2017: 1.2.1b - Fix to setting Smart Home Monitor. + * 20-09-2017: 1.2.1 BETA - Allow option for a SmartSchedule 'day' be measured from midnight rather than last cleaning time. + * 06-07-2017: 1.2h - Bug fix. Fix to smart schedule event handler typo preventing SHM mode changing. Fix to allow delayed start for multiple botvacs. + * 30-05-2017: 1.2g - Bug fix. Null botvac ID generated when no trigger smart schedule is set. + * 23-03-2017: 1.2f - Bug fix. Neato Botvac null pointer when start delay is set. + * 16-03-2017: 1.2e - Bug fix. Enforce single instance of app. + * 16-03-2017: 1.2d - Bug fix. Schedule not reset automatically when clean starts in some scenarios. + * - Bug fix. Switch triggers not working. + * 06-03-2017: 1.2c - Bug fix. Schedule ignored when SS notifications are turned off for mode and switch triggers. + * 02-03-2017: 1.2b - Critical error fix that stopped cleaning completely. + * 23-02-2017: 1.2 - Add delay option for clean when using Mode as trigger. Add option to disable notification before scheduled clean. + * 27-01-2017: 1.2 BETA Release 2 - Fix to scheduler. + * 25-01-2017: 1.2 BETA Release 1b - Minor fix to SmartSchedule menus. + * 24-01-2017: 1.2 BETA Release 1 - Individual SmartSchedule for each Botvac. (Loses SmartSchedule from earlier versions). + * + * 17-01-2017: 1.1.7b - Clean up display and formatting for multiple Botvacs. + * 12-01-2017: 1.1.7 - Add authentication scope for Maps. Added reauthentication option. + * + * 26-11-2016: 1.1.6 - Enforce SHM mode if SHM is changed during a clean. + * + * 01-11-2016: 1.1.5 - Improved handling of lost credentials to Neato. Better time zone handling. + * + * 24-10-2016: 1.1.4b - Bug fix. Override switch handler fix to prevent false negatives. + * 23-10-2016: 1.1.4 - Improve error notification from device status. + * + * 21-10-2016: 1.1.3b - Force poll on settings update. + * 20-10-2016: 1.1.3 - Allow device handler to display smart scheduling information. + * + * 20-10-2016: 1.1.2b - Bug fix. SmartSchedule does not operate if force clean option is disabled. + * 19-10-2016: 1.1.2 - Option to specify "no trigger" in SmartSchedule. Notification when Force clean is due in 24 hours. + Separate Smart schedule time markers from force clean time markers. + * + * 19-10-2016: 1.1.1b - Unschedule auto dock if cleaning is resumed. + * 18-10-2016: 1.1.1 - Allow smart schedule to also be triggered on presence and switch events. Add option to specify how override switches work (all or any). + * + * 18-10-2016: 1.1d - Bug fix. Custom state validation errors and error saving page message when upgrading from 1.0 to 1.1. + * 18-10-2016: 1.1c - Bug fix. Smart schedule was not updating last clean time properly when Botvac was activated. + * 17-10-2016: 1.1b - Set last clean value to new devices for smart schedule. + * 17-10-2016: 1.1 - SmartSchedule functionality and minor fixes + * + * 15-10-2016: 1.0c - Fix to auto SHM mode not triggering + * 14-10-2016: 1.0b - Minor fix to preference list + * 14-10-2016: 1.0 - Initial Version + */ +definition( + name: "Neato (Connect)", + namespace: "alyc100", + author: "Alex Lee Yuk Cheung", + description: "Integration to Neato Robotics Connected Series robot vacuums", + category: "", + iconUrl: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png", + iconX2Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png", + iconX3Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png", + oauth: true, + singleInstance: true) + +{ + appSetting "clientId" + appSetting "clientSecret" +} + + +preferences { + page(name: "auth", title: "Neato", nextPage:"", content:"authPage", uninstall: true, install:true) + page(name: "selectDevicePAGE") + page(name: "preferencesPAGE") + page(name: "notificationsPAGE") + page(name: "smartSchedulePAGE") + page(name: "timeIntervalPAGE") +} + +mappings { + path("/oauth/initialize") {action: [GET: "oauthInitUrl"]} + path("/oauth/callback") {action: [GET: "callback"]} +} + +def authPage() { + log.debug "authPage()" + + if(!atomicState.accessToken) { //this is to access token for 3rd party to make a call to connect app + atomicState.accessToken = createAccessToken() + } + + def description + def uninstallAllowed = false + def oauthTokenProvided = false + + if(atomicState.authToken) { + description = "You are connected." + uninstallAllowed = true + oauthTokenProvided = true + } else { + description = "Click to enter Neato Credentials" + } + + def redirectUrl = buildRedirectUrl + log.debug "RedirectUrl = ${redirectUrl}" + // get rid of next button until the user is actually auth'd + if (!oauthTokenProvided) { + return dynamicPage(name: "auth", title: "Login", nextPage: "", uninstall:uninstallAllowed) { + section { headerSECTION() } + section() { + paragraph "Tap below to log in to the Neato service and authorize SmartThings access." + href url:redirectUrl, style:"embedded", required:true, title:"Neato", description:description + } + } + } else { + updateDevices() + //Disable push option if contact book is enabled + if (location.contactBookEnabled) { + settings.sendPush = false + } + + dynamicPage(name: "auth", uninstall: false, install: false) { + section { headerSECTION() } + + section ("Choose your Neato Botvacs:") { + href("selectDevicePAGE", title: null, description: devicesSelected() ? "Devices:" + getDevicesSelectedString() : "Tap to select your Neato Botvacs", state: devicesSelected()) + } + if (devicesSelected() == "complete") { + section ("SmartSchedule Configuration:") { + if (selectedBotvacs.size() > 0) { + selectedBotvacs.each() { + //Migrate settings from v1.1 and earlier to v1.1.1 + if (settings["smartScheduleEnabled#$it"] && settings["ssScheduleTrigger#$it"] == null) { + settings["ssScheduleTrigger#$it"] = "mode" + } + def ssEnabled = smartScheduleSelected(it) + href("smartSchedulePAGE", params: ["botvacId": it], title: "SmartSchedule for ${state.botvacDevices[it]}", description: settings["smartScheduleEnabled#$it"] ? "${getSmartScheduleString(it)}" : "Tap to configure SmartSchedule for ${state.botvacDevices[it]}", state: ssEnabled, required: false, submitOnChange: false) + } + } + } + section ("Preferences:") { + href("preferencesPAGE", title: null, description: preferencesSelected() ? getPreferencesString() : "Tap to configure preferences", state: preferencesSelected()) + } + section ("Notifications:") { + href("notificationsPAGE", title: null, description: notificationsSelected() ? getNotificationsString() : "Tap to configure notifications", state: notificationsSelected()) + } + + def botvacList = "" + section("Botvac Status:") { + getChildDevices().each { childDevice -> + try { + paragraph image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato_botvac_image.png", "${childDevice.displayName} is ${childDevice.currentStatus}. Battery is ${childDevice.currentBattery}%" + } + catch (e) { + log.trace "Error checking status." + log.trace e + } + } + } + } + section() { + paragraph "Tap below to reauthenticate to the Neato service and reauthorize SmartThings access." + href url:redirectUrl, style:"embedded", required:false, title:"Neato", description:description + } + } + } +} + +def selectDevicePAGE() { + updateDevices() + dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) { + section { headerSECTION() } + section() { + paragraph "Tap below to see the list of Neato Botvacs available in your Neato account and select the ones you want to connect to SmartThings." + input "selectedBotvacs", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato_botvac_image.png", required:false, title:"Select Neato Devices \n(${state.botvacDevices.size() ?: 0} found)", multiple:true, options:state.botvacDevices + } + } +} + +def smartSchedulePAGE(params) { + log.debug "PARAMS: $params" + if (params.containsKey("botvacId")) state.configBotvacId = params?.botvacId + def botvacId = state.configBotvacId + return dynamicPage(name: "smartSchedulePAGE", title: "SmartSchedule for ${state.botvacDevices[botvacId]}", install: false, uninstall: false) { + section() { + paragraph "Configure a dymanic schedule for your Botvac so that it can clean on a regular interval but based on mode, presence sensor or switch triggers." + input "smartScheduleEnabled#$botvacId", "bool", title: "Enable SmartSchedule?", required: false, defaultValue: false, submitOnChange: true + } + if (settings["smartScheduleEnabled#$botvacId"]) { + section() { + input ("ssEnableWarning#$botvacId", "bool", title: "Enable schedule notification before cleaning", required: false, defaultValue: true) + } + section("Configure your cleaning interval and schedule triggers:") { + //SmartSchedule configuration options. + //Configure regular cleaning interval in days + input ("ssCleaningInterval#$botvacId", "number", title: "Set your ideal cleaning interval in days", required: true, defaultValue: 3) + + //Define when day should be mesaured + paragraph "[BETA] If enabled then a day is calculated from midnight before the last clean." + input ("ssIntervalFromMidnight#$botvacId", "bool", title: "Measure day interval from midnight before last clean?", required: false, defaultValue: false) + input ("ssStopCleanBinFull#$botvacId", "bool", title: "Postpone cleaning if Botvac bin is full?", required: false, defaultValue: false) + //Define smart schedule trigger + input("ssScheduleTrigger#$botvacId", "enum", title: "How do you want to trigger the schedule?", multiple: false, required: true, submitOnChange: true, options: ["mode": "Away Modes", "switch": "Switches", "presence": "Presence", "none": "No Triggers"]) + + //Define your away modes + if (settings["ssScheduleTrigger#$botvacId"] == "mode") { + input ("ssAwayModes#$botvacId", "mode", title:"Specify your away modes:", multiple: true, required: true) + } + if (settings["ssScheduleTrigger#$botvacId"] == "switch") { + input ("ssSwitchTrigger#$botvacId", "capability.switch", title:"Which switches?", multiple: true, required: true) + input ("ssSwitchTriggerCondition#$botvacId", "enum", title:"Trigger schedule when:", multiple: false, required: true, options: ["any": "Any switch turns on", "all": "All switches are on"], defaultValue: "any") + } + if (settings["ssScheduleTrigger#$botvacId"] == "presence") { + input ("ssPeopleAway#$botvacId", "capability.presenceSensor", title:"Which presence sensors?", multiple: true, required: true) + input ("ssPeopleAwayCondition#$botvacId", "enum", title:"Trigger schedule when:", multiple: false, required: true, options: ["any": "Someone leaves", "all": "Everyone is away"], defaultValue: "all") + } + + if (settings["ssScheduleTrigger#$botvacId"] != "none") { + input ("ssStartDelay#$botvacId", "number", title:"Set start delay time (minutes):", required: true, defaultValue: 0) + } + } + section("SmartSchedule restrictions:") { + //Define time of day + paragraph "Set SmartSchedule restrictions so that your Botvacs don't start unless below conditions are met." + def greyedOutTime = greyedOutTime(settings["starting#$botvacId"], settings["ending#$botvacId"]) + def timeLabel = getTimeLabel(settings["starting#$botvacId"], settings["ending#$botvacId"]) + href ("timeIntervalPAGE", params: ["botvacId": botvacId], title: "Operate Botvac only during a certain time", description: timeLabel, state: greyedOutTime, refreshAfterSelection:true) + //Define allowed days of operation + input ("days#$botvacId", "enum", title: "Operate Botvac only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]) + //Define contact sensors + input ("ssRestrictContactSensors#$botvacId", "capability.contactSensor", title:"Set SmartSchedule restriction contact sensors", multiple: true, required: false, submitOnChange: true) + if (settings["ssRestrictContactSensors#$botvacId"]) { + input ("ssRestrictContactSensorsCondition#$botvacId", "enum", title:"Start Botvac only when:", multiple: false, required: true, options: ["allclosed": "All selected contacts are closed", "anyclosed": "Any selected contacts are closed", "allopen": "All selected contacts are open", "anyopen": "Any selected contacts are open"], defaultValue: "allclosed") + } + + } + section("SmartSchedule overrides:") { + //Define override switches to restart SmartSchedule countdown + paragraph "Routine override switches/buttons will cancel the next scheduled clean and reset the interval countdown when switched on." + input ("ssOverrideSwitch#$botvacId", "capability.switch", title:"Set SmartSchedule override switches", multiple: true, required: false, submitOnChange: true) + if (settings["ssOverrideSwitch#$botvacId"]) { + input ("ssOverrideSwitchCondition#$botvacId", "enum", title:"Override schedule when:", multiple: false, required: true, options: ["any": "Any selected switch turns on", "all": "All selected switches are on"], defaultValue: "any") + } + } + section("Notifications:") { + paragraph "Turn on SmartSchedule notifications. You can configure specific recipients via Notification settings section." + input "ssNotification", "bool", title: "Enable SmartSchedule notifications?", required: false, defaultValue: true + } + } + } + +} + +def timeIntervalPAGE(params) { + def botvacId = params.botvacId + return dynamicPage(name: "timeIntervalPAGE", title: "Only during a certain time", refreshAfterSelection:true) { + section { + input "starting#$botvacId", "time", title: "Starting", required: false + input "ending#$botvacId", "time", title: "Ending", required: false + } + } +} + +def notificationsPAGE() { + return dynamicPage(name: "notificationsPAGE", title: "Notifications", install: false, uninstall: false) { + section(){ + input("recipients", "contact", title: "Send notifications to", required: false, submitOnChange: true) { + input "sendPush", "bool", title: "Send notifications via Push?", required: false, defaultValue: false, submitOnChange: true + } + input "sendSMS", "phone", title: "Send notifications via SMS?", required: false, defaultValue: null, submitOnChange: true + if ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) { + input "sendBotvacOn", "bool", title: "Notify when Botvacs are on?", required: false, defaultValue: false + input "sendBotvacOff", "bool", title: "Notify when Botvacs are off?", required: false, defaultValue: false + input "sendBotvacError", "bool", title: "Notify on Botvacs have an error?", required: false, defaultValue: true + input "sendBotvacBin", "bool", title: "Notify when Botvacs have a full bin?", required: false, defaultValue: true + def smartScheduleEnabled = false + if (selectedBotvacs.size() > 0) { + selectedBotvacs.each() { + if (settings["smartScheduleEnabled#$it"]) smartScheduleEnabled = true + } + } + if (smartScheduleEnabled) { + input "ssNotification", "bool", title: "Enable SmartSchedule notifications?", required: false, defaultValue: true + } + } + } + } +} + +def preferencesPAGE() { + return dynamicPage(name: "preferencesPAGE", title: "Preferences", install: false, uninstall: false) { + + section("Force Clean"){ + paragraph "If Botvac has been inactive for a number of days specified, then force a clean." + input "forceClean", "bool", title: "Force clean after elapsed time?", required: false, defaultValue: false, submitOnChange: true + if (forceClean != false) { + input ("forceCleanDelay", "number", title: "Number of days before force clean (in days)", required: false, defaultValue: 7) + } + } + section("Auto Dock") { + paragraph "When Botvac is paused, automatically send to base after a specified number of seconds." + input "autoDock", "bool", title: "Auto dock Botvac after pause?", required: false, defaultValue: true, submitOnChange: true + if (autoDock != false) { + input ("autoDockDelay", "number", title: "Auto dock delay after pause (in seconds)", required: false, defaultValue: 60) + } + } + section("Auto Smart Home Monitor..."){ + paragraph "If Smart Home Monitor is set to Arm(Away), auto Set Smart Home Monitor to Arm(Stay) when cleaning and reset when done. If Smart Home Monitor is Disarmed during cleaning, then this will not reactivate SHM." + input "autoSHM", "bool", title: "Auto set Smart Home Monitor?", required: false, defaultValue: false, submitOnChange: true + + } + } +} + +def headerSECTION() { + return paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/neato_icon.png", + "${textVersion()}") +} + +def oauthInitUrl() { + log.debug "oauthInitUrl with callback: ${callbackUrl}" + + atomicState.oauthInitState = UUID.randomUUID().toString() + + def oauthParams = [ + response_type: "code", + scope: "public_profile control_robots maps", + client_id: clientId(), + state: atomicState.oauthInitState, + redirect_uri: callbackUrl + ] + + redirect(location: "${apiEndpoint}/oauth2/authorize?${toQueryString(oauthParams)}") +} + +// The toQueryString implementation simply gathers everything in the passed in map and converts them to a string joined with the "&" character. +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +def callback() { + log.debug "callback()>> params: $params, params.code ${params.code}" + + def code = params.code + def oauthState = params.state + + if (oauthState == atomicState.oauthInitState) { + def tokenParams = [ + grant_type: "authorization_code", + code : code, + client_id : clientId(), + client_secret: clientSecret(), + redirect_uri: callbackUrl + ] + + def tokenUrl = "https://beehive.neatocloud.com/oauth2/token?${toQueryString(tokenParams)}" + + httpPost(uri: tokenUrl) { resp -> + atomicState.refreshToken = resp.data.refresh_token + atomicState.authToken = resp.data.access_token + } + + if (atomicState.authToken) { + success() + } else { + fail() + } + + } else { + log.error "callback() failed oauthState != atomicState.oauthInitState" + } + +} + +// Example success method +def success() { + def message = """ +

Your Neato Account is now connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + displayMessageAsHtml(message) +} + +def fail() { + def message = """ +

The connection could not be established!

+

Click 'Done' to return to the menu.

+ """ + displayMessageAsHtml(message) +} + +def displayMessageAsHtml(message) { + def redirectHtml = "" + if (redirectUrl) { redirectHtml = """""" } + + def html = """ + + + + + SmartThings & Neato connection + + + +
+ SmartThings logo + connected device icon + neato icon + ${message} +
+ + + """ + render contentType: 'text/html', data: html +} + +private refreshAuthToken() { + log.debug "refreshing auth token" + + if(!atomicState.refreshToken) { + log.warn "Can not refresh OAuth token since there is no refreshToken stored" + } else { + def refreshParams = [ + method: 'POST', + uri : "https://beehive.neatocloud.com", + path : "/oauth2/token", + query : [grant_type: 'refresh_token', refresh_token: "${atomicState.refreshToken}"], + ] + + def notificationMessage = "Neato is disconnected from SmartThings, because the access credential changed or was lost. Please go to the Neato (Connect) SmartApp and re-enter your account login credentials." + //changed to httpPost + try { + def jsonMap + httpPost(refreshParams) { resp -> + if(resp.status == 200) { + log.debug "Token refreshed...calling saved RestAction now!" + saveTokenAndResumeAction(resp.data) + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "refreshAuthToken() >> Error: e.statusCode ${e.statusCode}" + def reAttemptPeriod = 300 // in sec + if (e.statusCode != 401) { // this issue might comes from exceed 20sec app execution, connectivity issue etc. + runIn(reAttemptPeriod, "refreshAuthToken") + } else if (e.statusCode == 401) { // unauthorized + if (!atomicState.reAttempt) atomicState.reAttempt = 0 + atomicState.reAttempt = atomicState.reAttempt + 1 + log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}" + if (atomicState.reAttempt <= 3) { + runIn(reAttemptPeriod, "refreshAuthToken") + } else { + messageHandler(notificationMessage, true) + atomicState.authToken = null + atomicState.reAttempt = 0 + + } + } + } + } +} + +private void saveTokenAndResumeAction(json) { + log.debug "saveTokenAndResumeAction: token response json: $json" + if (json) { + atomicState.refreshToken = json?.refresh_token + atomicState.authToken = json?.access_token + if (atomicState.action) { + log.debug "got refresh token, executing next action: ${atomicState.action}" + "${atomicState.action}"() + } + } else { + log.warn "did not get response body from refresh token response" + } + atomicState.action = "" +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + unschedule() + //Initialise variables + if (state.lastClean == null) { + state.lastClean = [:] + } + if (state.smartSchedule == null) { + state.smartSchedule = [:] + } + if (state.forceCleanNotificationSent == null) { + state.forceCleanNotificationSent = [:] + } + if (state.botvacOnTimeMarker == null) { + state.botvacOnTimeMarker = [:] + } + state.remove("taskStartTimes") + if (selectedBotvacs) addBotvacs() + + getChildDevices().each { childDevice -> + def botvacId = childDevice.deviceNetworkId + //subscribe to events for smartSchedule + if (settings["smartScheduleEnabled#$botvacId"]) { + //store last mode selected + if ((!state.lastTriggerMode) || (state.lastTriggerMode instanceof String)) state.lastTriggerMode = [:] + + if (settings["ssScheduleTrigger#$botvacId"] == "mode") { subscribe(location, "mode", smartScheduleHandler, [filterEvents: false]) } + else if (settings["ssScheduleTrigger#$botvacId"] == "switch") { subscribe(settings["ssSwitchTrigger#$botvacId"], "switch.on", smartScheduleHandler, [filterEvents: false]) } + else if (settings["ssScheduleTrigger#$botvacId"] == "presence") { subscribe(settings["ssPeopleAway#$botvacId"], "presence", smartScheduleHandler, [filterEvents: false]) } + + subscribe(settings["ssOverrideSwitch#$botvacId"], "switch.on", smartScheduleHandler, [filterEvents: false]) + subscribe(settings["ssRestrictContactSensors#$botvacId"], "contact", smartScheduleHandler, [filterEvents: false]) + } + + if (state.botvacOnTimeMarker[botvacId] == null) state.botvacOnTimeMarker[botvacId] = now() + //subscribe to events for notifications if activated + if (settings["smartScheduleEnabled#$botvacId"] || preferencesSelected() == "complete" || notificationsSelected() == "complete") { + subscribe(childDevice, "status.cleaning", eventHandler, [filterEvents: false]) + } + if (preferencesSelected() == "complete" || notificationsSelected() == "complete") { + subscribe(childDevice, "status.ready", eventHandler, [filterEvents: false]) + subscribe(childDevice, "status.error", eventHandler, [filterEvents: false]) + subscribe(childDevice, "status.paused", eventHandler, [filterEvents: false]) + subscribe(childDevice, "bin.full", eventHandler, [filterEvents: false]) + } + //initialise force clean flags + if (settings.forceClean) { + if (state.forceCleanNotificationSent[botvacId] == null) state.forceCleanNotificationSent[botvacId] = false + } + //subscribe to events for smartSchedule + if (settings["smartScheduleEnabled#$botvacId"]) { + //Initialize flags for Smart Schedule + if (state.smartSchedule[botvacId] == null) state.smartSchedule[botvacId] = false + if (state.lastClean[botvacId] == null) { + if (settings["ssIntervalFromMidnight#$botvacId"]) { + state.lastClean[botvacId] = (new Date()).clearTime().getTime() + } else { + state.lastClean[botvacId] = now() + } + } + //Trigger has changed so reset all smart schedule flags + if ((state.lastTriggerMode.containsKey(botvacId)) && (state.lastTriggerMode[botvacId] != settings["ssScheduleTrigger#$botvacId"])) { + log.debug "Smart schedule trigger mode has changed. Resetting smart schedule flag." + state.smartSchedule[botvacId] = false + state.lastTriggerMode[botvacId] = settings["ssScheduleTrigger#$botvacId"] + } + } + childDevice.poll() + } + def nextTimeInSeconds = getNextTimeInSeconds() + if (nextTimeInSeconds >= 0) { + runIn(nextTimeInSeconds, timeHandler) + } + else { + log.warn "No time has been scheduled. Check that you have Botvacs added under the Neato (Connect) app." + } + runEvery5Minutes('pollOn') // Asynchronously refresh devices so we don't block + +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule() + removeChildDevices(getChildDevices()) +} + +def updateDevices() { + log.debug "Executing 'updateDevices'" + if (!state.devices) { + state.devices = [:] + } + def devices = devicesList() + state.botvacDevices = [:] + state.secretKeys = [:] + def selectors = [] + devices.each { device -> + if (device.serial != null) { + selectors.add("${device.serial}") + state.secretKeys["${device.serial}"] = device.secret_key + state.botvacDevices["${device.serial}"] = "Neato Botvac - " + device.name + } + } + log.debug "selectors: $selectors" + //Remove devices if does not exist on the Neato platform + getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { + log.info("Deleting ${it.deviceNetworkId}") + try { + deleteChildDevice(it.deviceNetworkId) + } catch (physicalgraph.exception.NotFoundException e) { + log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.") + } catch (physicalgraph.exception.ConflictException ce) { + log.info("Device ${it.deviceNetworkId} in use. Please manually delete.") + } + } + if (selectedBotvacs) { + selectedBotvacs.retainAll(selectors as Object[]) + } +} + +def addBotvacs() { + log.debug "Executing 'addBotvacs'" + updateDevices() + + selectedBotvacs.each { device -> + + def childDevice = getChildDevice(device) + + if (!childDevice) { + log.info("Adding Neato Botvac device ${device}: ${state.botvacDevices[device]}") + + def data = [ + name: state.botvacDevices[device], + label: state.botvacDevices[device] + ] + childDevice = addChildDevice(app.namespace, "Neato Botvac Connected Series", device, null, data) + childDevice.refresh() + + log.debug "Created ${state.botvacDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.botvacDevices[device]} with id ${device} already exists" + } + } +} + +def getSecretKey(deviceSerial) { + return state.secretKeys[deviceSerial] +} + +private removeChildDevices(devices) { + devices.each { + deleteChildDevice(it.deviceNetworkId) // 'it' is default + } +} + +def devicesList() { + logErrors([]) { + def resp = beehiveGET("/users/me/robots") + def notificationMessage = "Neato is disconnected from SmartThings, because the access credential changed or was lost. Please go to the Neato (Connect) SmartApp and re-enter your account login credentials." + if (resp.status == 200) { + return resp.data + } else if (resp.status == 401) { + atomicState.action = "updateDevices" + if (!atomicState.reAttempt) atomicState.reAttempt = 0 + atomicState.reAttempt = atomicState.reAttempt + 1 + log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}" + if (atomicState.reAttempt <= 3) { + runIn(reAttemptPeriod, "refreshAuthToken") + } else { + messageHandler(notificationMessage, true) + atomicState.authToken = null + atomicState.reAttempt = 0 + } + } + else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + runIn(reAttemptPeriod, "refreshAuthToken") + return [] + } + } +} + +def devicesSelected() { + return (selectedBotvacs) ? "complete" : null +} + +def getDevicesSelectedString() { + updateDevices() + def listString = "" + selectedBotvacs.each { childDevice -> + if (null != state.botvacDevices) { + listString += "\n• " + state.botvacDevices[childDevice] + } + } + return listString +} + +def smartScheduleSelected(botvacId) { + return settings["smartScheduleEnabled#$botvacId"] ? "complete" : null +} + +def getSmartScheduleString(botvacId) { + def listString = "" + if (settings["smartScheduleEnabled#$botvacId"]) { + listString += "SmartSchedule set for every ${settings["ssCleaningInterval#$botvacId"]} days " + if (settings["ssScheduleTrigger#$botvacId"] == "mode") {listString += "when mode is ${settings["ssAwayModes#$botvacId"]}."} + else if (settings["ssScheduleTrigger#$botvacId"] == "switch") { + if (settings["ssSwitchTriggerCondition#$botvacId"] == "any") { + listString += "when any of ${settings["ssSwitchTrigger#$botvacId"]} turns on." + } else { + listString += "when ${settings["ssSwitchTrigger#$botvacId"]} are all on." + } + } + + else if (settings["ssScheduleTrigger#$botvacId"] == "presence") { + if (settings["ssPeopleAwayCondition#$botvacId"] == "any") { + listString += "when one of ${settings["ssPeopleAway#$botvacId"]} leaves." + } else { + listString += "when ${settings["ssPeopleAway#$botvacId"]} are all away." + } + } + + listString += "\n\nThe following restrictions apply:\n" + if (settings["starting#$botvacId"]) listString += "• ${getTimeLabel(settings["starting#$botvacId"], settings["ending#$botvacId"])}\n" + if (settings["days#$botvacId"]) listString += "• Only on ${settings["days#$botvacId"]}.\n" + if (settings["ssRestrictContactSensors#$botvacId"]) { + if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "allclosed") { + listString += "• When ${settings["ssRestrictContactSensors#$botvacId"]} are all closed.\n" + } else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "anyclosed") { + listString += "• When any one of ${settings["ssRestrictContactSensors#$botvacId"]} are closed.\n" + } else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "allopen") { + listString += "• When ${settings["ssRestrictContactSensors#$botvacId"]} are all open.\n" + } else { + listString += "• When any one of ${settings["ssRestrictContactSensors#$botvacId"]} are closed.\n" + } + } + if (settings["ssOverrideSwitch#$botvacId"]) { + if (settings["ssOverrideSwitchCondition#$botvacId"] == "any") { + listString += "• Override schedule if any of ${settings["ssOverrideSwitch#$botvacId"]} turns on.\n" + } else { + listString += "• Override schedule if ${settings["ssOverrideSwitch#$botvacId"]} are all on.\n" + } + } + } + return listString +} + +def preferencesSelected() { + return (settings.forceClean || settings.autoDock || settings.autoSHM) ? "complete" : null +} + +def getPreferencesString() { + def listString = "" + if (settings.forceClean) listString += "• Force clean after ${settings.forceCleanDelay} days\n" + if (settings.autoDock) listString += "• Auto Dock after ${settings.autoDockDelay} seconds\n" + if (settings.autoSHM) listString += "• Automatically set Smart Home Monitor\n" + + if (listString != "") listString = listString.substring(0, listString.length() - 1) + return listString +} + +def notificationsSelected() { + return ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) && (settings.sendBotvacOn || settings.sendBotvacOff || settings.sendBotvacError || settings.sendBotvacBin || settings.ssNotification) ? "complete" : null +} + +def getNotificationsString() { + def listString = "" + if (location.contactBookEnabled && settings.recipients) { + listString += "Send the following notifications to " + settings.recipients + } + else if (settings.sendPush) { + listString += "Send the following notifications" + } + + if (!settings.recipients && !settings.sendPush && settings.sendSMS != null) { + listString += "Send the following SMS to ${settings.sendSMS}" + } + else if (settings.sendSMS != null) { + listString += " and SMS to ${settings.sendSMS}" + } + + if ((location.contactBookEnabled && settings.recipients) || settings.sendPush || settings.sendSMS != null) { + listString += ":\n" + if (settings.sendBotvacOn) listString += "• Botvac On\n" + if (settings.sendBotvacOff) listString += "• Botvac Off\n" + if (settings.sendBotvacError) listString += "• Botvac Error\n" + if (settings.sendBotvacBin) listString += "• Bin Full\n" + if (settings.ssNotification) listString += "• SmartSchedule\n" + } + if (listString != "") listString = listString.substring(0, listString.length() - 1) + return listString +} + +//Beehive API Access +def beehiveGET(path, body = [:]) { + try { + log.debug("Beginning API GET: ${beehiveURL(path)}, ${beehiveRequestHeaders()}") + + httpGet(uri: beehiveURL(path), contentType: 'application/json', headers: beehiveRequestHeaders()) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +Map beehiveRequestHeaders() { + return [ + 'Accept': 'application/vnd.neato.nucleo.v1', + 'Content-Type': 'application/*+json', + 'X-Agent': '0.11.3-142', + 'Authorization': "Bearer ${atomicState.authToken}" + ] +} + +def logResponse(response) { + log.info("Status: ${response.status}") + log.info("Body: ${response.data}") +} + +def logErrors(options = [errorReturn: null, logObject: log], Closure c) { + try { + return c() + } catch (groovyx.net.http.HttpResponseException e) { + log.error("got error: ${e}, body: ${e.getResponse().getData()}") + return options.errorReturn + } catch (java.net.SocketTimeoutException e) { + log.warn "Connection timed out, not much we can do here" + return options.errorReturn + } +} + +// Implement event handlers +def eventHandler(evt) { + log.debug "Executing 'eventHandler' for ${evt.displayName}" + def msg + if (evt.isStateChange == 'true') { + if (evt.value == "paused") { + log.trace "Setting auto dock for ${evt.displayName}" + //If configured, set to dock automatically after one minute. + if (settings.autoDock) { + runIn(settings.autoDockDelay, scheduleAutoDock) + } + } + else if (evt.value == "error") { + unschedule(pollOn) + unschedule(scheduleAutoDock) + runEvery5Minutes('pollOn') + sendEvent(linkText:app.label, name:"${evt.displayName}", value:"error",descriptionText:"${evt.displayName} has an error", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "${evt.displayName} has an error" + msg = "${evt.displayName} has an error: " + evt.device.latestState('statusMsg').stringValue.minus('HAS A PROBLEM - ') + if (settings.sendBotvacError) { + messageHandler(msg, false) + } + } + else if (evt.value == "cleaning") { + unschedule(pollOn) + unschedule(scheduleAutoDock) + //Increase poll interval during cleaning + schedule("0 0/1 * * * ?", pollOn) + //Record last cleaning time for device + log.debug "$evt.device.deviceNetworkId has started cleaning" + if (settings["ssIntervalFromMidnight#$evt.device.deviceNetworkId"]) { + state.lastClean[evt.device.deviceNetworkId] = (new Date()).clearTime().getTime() + } else { + state.lastClean[evt.device.deviceNetworkId] = now() + } + state.botvacOnTimeMarker[evt.device.deviceNetworkId] = now() + log.debug "$evt.device.deviceNetworkId has started cleaning" + if (settings.forceClean) { state.forceCleanNotificationSent[evt.device.deviceNetworkId] = false } + //Remove SmartSchedule flag + state.smartSchedule[evt.device.deviceNetworkId] = false + sendEvent(linkText:app.label, name:"${evt.displayName}", value:"on",descriptionText:"${evt.displayName} is on", eventType:"SOLUTION_EVENT", displayed: true) + msg = "${evt.displayName} is on" + if (settings.sendBotvacOn) { + messageHandler(msg, false) + } + setSHMToStay() + } + else if (evt.value == "full") { + unschedule(pollOn) + runEvery5Minutes('pollOn') + sendEvent(linkText:app.label, name:"${evt.displayName}", value:"bin full",descriptionText:"${evt.displayName} bin is full", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "${evt.displayName} bin is full" + msg = "${evt.displayName} bin is full" + if (settings.sendBotvacBin) { + messageHandler(msg, false) + } + } + else if (evt.value == "ready") { + unschedule(pollOn) + unschedule(scheduleAutoDock) + runEvery5Minutes('pollOn') + sendEvent(linkText:app.label, name:"${evt.displayName}", value:"off",descriptionText:"${evt.displayName} is off", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "${evt.displayName} is off" + msg = "${evt.displayName} is off" + if (settings.sendBotvacOff) { + messageHandler(msg, false) + } + } + } +} + +def timeHandler(evt) { + smartScheduleHandler(evt) +} + +def smartScheduleHandler(evt) { + if (evt != null) { + log.debug "Executing 'smartScheduleHandler' for ${evt.displayName}" + } else { + log.debug "Executing 'smartScheduleHandler' for scheduled event" + } + //Update scheduler + def nextTimeInSeconds = getNextTimeInSeconds() + if (nextTimeInSeconds >= 0) { + runIn(nextTimeInSeconds, timeHandler) + } + else { + log.warn "No time has been scheduled. Check that you have Botvacs added under the Neato (Connect) app." + } + getChildDevices().each { childDevice -> + def botvacId = childDevice.deviceNetworkId + //If switch on for override event + if (evt != null && evt.name == "switch") { + def switchInList = false + for (switchName in settings["ssOverrideSwitch#$botvacId"].name) { + if (switchName == evt.device.name) { + switchInList = true + break + } + } + log.debug "Swtich found in override switch list: $switchInList" + if (switchInList) { + def executeOverride = true + //If override switch condition is ALL... + if (settings["ssOverrideSwitchCondition#$botvacId"] == "all") { + //Check all switches in override switch settings are on + for (switchVal in settings["ssOverrideSwitch#$botvacId"].currentSwitch) { + if (switchVal == "off") { + executeOverride = false + break + } + } + } + + if (executeOverride) { + //Reset last clean date to current time + resetSmartScheduleForDevice(botvacId) + childDevice.poll() + } + if (settings.ssNotification) { + messageHandler("Neato SmartSchedule has reset schedule for ${childDevice.name} as override switch ${evt.displayName} is on.", false) + } + } + } + //If mode change event, schedule trigger, contact sensor or presence trigger + //Check conditions, time and day have been met and execute clean. If no trigger is specified rely on pollOn method to start clean. + if (settings["ssScheduleTrigger#$botvacId"] != "none") { + def delay = 0 + if (settings["ssStartDelay#$botvacId"]) delay = settings["ssStartDelay#$botvacId"] * 60 + if (delay > 0) { + runIn(delay, startConditionalClean, [overwrite: false, data: [botvacId: botvacId]]) + } else { + startConditionalClean([botvacId: botvacId]) + } + } + + } +} + +def scheduleAutoDock() { + log.debug "Executing 'scheduleAutoDock'" + getChildDevices().each { childDevice -> + if (childDevice.latestState('status').stringValue == 'paused') { + childDevice.dock() + } + } +} + +def pollOn() { + log.debug "Executing 'pollOn'" + + def activeCleaners = false + log.debug "Last clean states: ${state.lastClean}" + log.debug "Smart schedule states: ${state.smartSchedule}" + log.debug "Botvac ON time markers: ${state.botvacOnTimeMarker}" + getChildDevices().each { childDevice -> + def botvacId = childDevice.deviceNetworkId + state.pollState = now() + childDevice.poll() + if (childDevice.currentSwitch == "off") { + //Update smart schedule state. Create notification when clean is due. + if (settings["smartScheduleEnabled#$botvacId"] && state.lastClean != null && state.lastClean[botvacId] != null) { + def t = now() - state.lastClean[botvacId] + log.debug "$childDevice.displayName schedule marker at " + state.lastClean[botvacId] + ". ${t/86400000} days has elapsed since. ${settings["ssCleaningInterval#$botvacId"] - (t/86400000)} days to scheduled clean." + + //Set SmartSchedule flag if SmartSchedule has not been set already, interval has elapsed and trigger conditions are not met + if ((settings["ssScheduleTrigger#$botvacId"] == "none") && ((settings["ssCleaningInterval#$botvacId"] - (t/86400000)) < 1) && (!state.smartSchedule[botvacId]) && (settings["ssEnableWarning#$botvacId"])) { + //hour calculation for notification of next clean + state.smartSchedule[botvacId] = true + if (settings.ssNotification) { + messageHandler("Neato SmartSchedule has scheduled ${childDevice.displayName} for a clean in 24 hours (date and time restrictions permitting). Please clear obstacles and leave internal doors open ready for the clean.", false) + } + } else if ((!getTriggerConditionsOk(botvacId)) && (t > (settings["ssCleaningInterval#$botvacId"] * 86400000)) && (!state.smartSchedule[botvacId]) && (settings["ssEnableWarning#$botvacId"])) { + state.smartSchedule[botvacId] = true + if (settings.ssNotification) { + def reason = "you're next away" + if (settings["ssScheduleTrigger#$botvacId"] == "switch") { reason = "your selected switches turn on" } + else if (settings["ssScheduleTrigger#$botvacId"] == "presence") { reason = "your selected presence sensors leave"} + messageHandler("Neato SmartSchedule has scheduled ${childDevice.displayName} for a clean when " + reason + " (date and time restrictions permitting). Please clear obstacles and leave internal doors open ready for the clean.", false) + } + } + //If no trigger has been set for smart schedule, execute clean when interval time has elapsed + if ((settings["ssScheduleTrigger#$botvacId"] == "none") && (state.smartSchedule[botvacId] || (!settings["ssEnableWarning#$botvacId"])) && (t > (settings["ssCleaningInterval#$botvacId"] * 86400000))) { + startConditionalClean([botvacId: botvacId]) + } + } + //Update force clean state and create notification when clean is due. + if (settings.forceClean && state.botvacOnTimeMarker != null && state.botvacOnTimeMarker[botvacId] != null) { + def t = now() - state.botvacOnTimeMarker[botvacId] + log.debug "$childDevice.displayName ON time marker at " + state.botvacOnTimeMarker[botvacId] + ". ${t/86400000} days has elapsed since. ${settings.forceCleanDelay - (t/86400000)} days to force clean." + + //Create 24 hour warning for force clean. + if ((state.forceCleanNotificationSent != null) && (!state.forceCleanNotificationSent[botvacId]) && ((settings.forceCleanDelay - (t/86400000)) < 1)) { + //Send notification when force clean is due + log.debug "Force clean due within 24 hours" + messageHandler(childDevice.displayName + " has not cleaned for " + (settings.forceCleanDelay - 1) + " days. Forcing a clean in 24 hours. Please clear obstacles and leave internal doors open ready for the clean.", true) + state.forceCleanNotificationSent[botvacId] = true + } + + //Execute force clean (no conditions need checking) + if (t > (settings.forceCleanDelay * 86400000)) { + log.debug "Force clean activated as ${t/86400000} days has elapsed" + messageHandler(childDevice.displayName + " has not cleaned for " + settings.forceCleanDelay + " days. Forcing a clean.", true) + resetSmartScheduleForDevice(botvacId) + childDevice.on() + } + } + } + if (childDevice.currentStatus == "cleaning") { + //Search for active cleaners + activeCleaners = true + } + } + + //Set SHM mode depending on whether there are active cleaners. + if (activeCleaners) { + setSHMToStay() + } else { + setSHMToAway() + } + + //If SHM is disarmed because of external event, then disable auto SHM mode + if (location.currentState("alarmSystemStatus")?.value == "off") { + state.autoSHMchange = "n" + } +} + +//Access methods for device type +def isSmartScheduleEnabled(botvacId) { + return settings["smartScheduleEnabled#$botvacId"] +} + +def timeToSmartScheduleClean(botvacId) { + log.debug "Executing 'timeToSmartScheduleClean' with device $botvacId" + def result = -1 + if (settings["smartScheduleEnabled#$botvacId"] && state.lastClean != null && state.lastClean[botvacId] != null) { + result = (state.lastClean[botvacId] + (settings["ssCleaningInterval#$botvacId"] * 86400000)) - now() + } + log.debug "Time to smart schedule clean: $result milliseconds" + result +} + +def timeToForceClean(botvacId) { + log.debug "Executing 'timeToForceClean' with device $botvacId" + def result = -1 + if (settings.forceClean && state.botvacOnTimeMarker != null && state.botvacOnTimeMarker[botvacId] != null) { + result = (state.botvacOnTimeMarker[botvacId] + (settings.forceCleanDelay * 86400000)) - now() + } + log.debug "Time to force clean: $result milliseconds" + result +} + +def autoDockDelayValue() { + log.debug "Executing 'autoDockDelayValue'" + def result = -1 + if (settings.autoDock) { + result = settings.autoDockDelay + } + log.debug "Auto dock delay: $result seconds" + result +} + +def resetSmartScheduleForDevice(botvacId) { + log.debug "Executing 'resetSmartScheduleForDevice' with device $botvacId" + if (settings["smartScheduleEnabled#$botvacId"] && state.lastClean != null && state.smartSchedule != null) { + //Reset last clean date to current time + state.lastClean[botvacId] = now() + if (settings["ssIntervalFromMidnight#$botvacId"]) { + state.lastClean[botvacId] = (new Date()).clearTime().getTime() + } else { + state.lastClean[botvacId] = now() + } + //Remove existing SmartSchedule flag + state.smartSchedule[botvacId] = false + } + /** + //DEBUG PURPOSES ONLY. FAKE TIME ON OVERRIDE SWITCH AND INCREASE POLL + //state.lastClean[deviceNetworkId] = Date.parseToStringDate("Thu Oct 13 01:23:45 UTC 2016").getTime() + state.lastClean[botvacId] = 1476868627993 + state.botvacOnTimeMarker[botvacId] = 1476889942741 + unschedule(pollOn) + schedule("0 0/1 * * * ?", pollOn) + log.debug "Fake data loaded.... " + (now() - state.lastClean[botvacId])/86400000 + **/ + +} + +//Helper methods +def setSHMToStay() { + if (settings.autoSHM) { + if (location.currentState("alarmSystemStatus")?.value == "away") { + sendEvent(linkText:app.label, name:"Smart Home Monitor", value:"stay",descriptionText:"Smart Home Monitor was set to stay", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "Smart Home Monitor is set to stay" + sendLocationEvent(name: "alarmSystemStatus", value: "stay") + state.autoSHMchange = "y" + messageHandler("Smart Home Monitor is set to stay as a Neato Botvac is cleaning", true) + } + } +} + +def setSHMToAway() { + if (settings.autoSHM) { + if (location.currentState("alarmSystemStatus")?.value == "stay" && state.autoSHMchange == "y") { + sendEvent(linkText:app.label, name:"Smart Home Monitor", value:"away",descriptionText:"Smart Home Monitor was set back to away", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "Smart Home Monitor is set back to away" + sendLocationEvent(name: "alarmSystemStatus", value: "away") + state.autoSHMchange = "n" + messageHandler("Smart Home Monitor is set to away as all Neato Botvacs are off", true) + } + } +} + +def startConditionalClean(data) { + def botvacId = data.botvacId + log.debug "Executing 'startConditionalClean for $botvacId'" + if (getAllOk(botvacId)) { + def botvacDevice = getChildDevice(botvacId) + //If smartSchedule flag has been set, start clean. + if ((state.smartSchedule[botvacId]) || (!settings["ssEnableWarning#$botvacId"])) { + if (settings.ssNotification) { + messageHandler("Neato SmartSchedule has started ${botvacDevice.displayName} cleaning.", false) + } + resetSmartScheduleForDevice(botvacId) + botvacDevice.on() + } + } +} + +def adjustTimeforTimeZone(originalTime) { + if (getTimeZone()) { + def adjustedTime = timeToday(originalTime, location.timeZone) + def timeNow = now() + (2*1000) + if (adjustedTime.time < timeNow) { + adjustedTime = adjustedTime + 1 + } + return adjustedTime + } + return originalTime +} + +def getNextTimeInSeconds() { + def nextTime = null + getChildDevices().each { childDevice -> + def time + def botvacId = childDevice.deviceNetworkId + if (settings["starting#$botvacId"]) { + time = adjustTimeforTimeZone(settings["starting#$botvacId"]) + } else { + time = timeToday("00:01", location.timeZone) + } + def t = timeTodayAfter(new Date(), time.format("HH:mm", getTimeZone()), getTimeZone()) + if (nextTime) { + nextTime = (nextTime > t.getTime()) ? t.getTime() : nextTime + } else { + nextTime = t.getTime() + } + } + if (nextTime) { + def seconds = Math.ceil((nextTime - now()) / 1000) + log.debug "Scheduling ST job to run in ${seconds}s, at ${nextTime}" + return seconds as Integer + } + else return -1 +} + +def messageHandler(msg, forceFlag) { + log.debug "Executing 'messageHandler for $msg. Forcing is $forceFlag'" + if (settings.sendSMS != null && !forceFlag) { + sendSms(settings.sendSMS, msg) + } + if (location.contactBookEnabled && settings.recipients) { + sendNotificationToContacts(msg, settings.recipients) + } else if (settings.sendPush || forceFlag) { + sendPush(msg) + } +} + +private getAllOk(botvacId) { + getTriggerConditionsOk(botvacId) && getDaysOk(botvacId) && getTimeOk(botvacId) && getScheduleOk(botvacId) && getContactSensorsOk(botvacId) && getBinOK(botvacId) +} + +private getScheduleOk(botvacId) { + def t = (now() - state.lastClean[botvacId]) + 2 + def result = t > (settings["ssCleaningInterval#$botvacId"] * 86400000) + log.trace "scheduleOk for $botvacId = $result" + result +} + +private getTriggerConditionsOk(botvacId) { + //Calculate, depending on smart schedule trigger mode, whether conditions currently match + def result = true + + if (settings["ssScheduleTrigger#$botvacId"] == "mode") { + result = location.mode in settings["ssAwayModes#$botvacId"] + } else if (settings["ssScheduleTrigger#$botvacId"] == "switch") { + if (settings["ssSwitchTriggerCondition#$botvacId"] == "any") { + result = "on" in settings["ssSwitchTrigger#$botvacId"].currentSwitch + } else { + for (switchVal in settings["ssSwitchTrigger#$botvacId"].currentSwitch) { + if (switchVal == "off") { + result = false + break + } + } + } + } else if (settings["ssScheduleTrigger#$botvacId"] == "presence") { + if (settings["ssPeopleAwayCondition#$botvacId"] == "any") { + result = "not present" in settings["ssPeopleAway#$botvacId"].currentPresence + } else { + for (person in settings["ssPeopleAway#$botvacId"]) { + if (person.currentPresence == "present") { + result = false + break + } + } + } + } + + log.trace "triggerConditionsOk for $botvacId = $result" + result +} + +private getDaysOk(botvacId) { + def result = true + if (settings["days#$botvacId"]) { + def df = new java.text.SimpleDateFormat("EEEE") + if (getTimeZone()) { df.setTimeZone(location.timeZone) } + def day = df.format(new Date()) + result = settings["days#$botvacId"].contains(day) + } + log.trace "daysOk for $botvacId = $result" + result +} + +private getTimeOk(botvacId) { + def result = true + if (settings["starting#$botvacId"] && settings["ending#$botvacId"]) { + def currTime = now() + def start = timeToday(settings["starting#$botvacId"], location.timeZone).time + def stop = timeToday(settings["ending#$botvacId"], location.timeZone).time + result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start + } + log.trace "timeOk for $botvacId = $result" + result +} + +private getContactSensorsOk(botvacId) { + def result = true + def currContacts = settings["ssRestrictContactSensors#$botvacId"]?.currentContact + if (currContacts) { + if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "allclosed") { + if (currContacts.contains("open")) { result = false } + } + else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "anyclosed") { + result = currContacts.findAll {contactVal -> contactVal == "closed" ? true : false} + } + else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "allopen") { + if (currContacts.contains("closed")) { result = false } + } + else if (settings["ssRestrictContactSensorsCondition#$botvacId"] == "anyopen") { + result = currContacts.findAll {contactVal -> contactVal == "open" ? true : false} + } + } + log.trace "contactSesnorsOk for $botvacId = $result" + result +} + +private getBinOK(botvacId) { + def result = true + if (settings["ssStopCleanBinFull#$botvacId"]) { + def botvacDevice = getChildDevice(botvacId) + def binState = botvacDevice.currentState("bin").getValue() + if (binState == "full") result = false + } + log.trace "binOK for $botvacId = $result" + result +} + +private hhmm(time, fmt = "h:mm a z") { + def t = timeToday(time, location.timeZone) + def f = new java.text.SimpleDateFormat(fmt) + if (getTimeZone()) { f.setTimeZone(location.timeZone ?: timeZone(time)) } + f.format(t) +} + +def getTimeLabel(starting, ending){ + def timeLabel = "Tap to set" + + if(starting && ending){ + timeLabel = "Between" + " " + hhmm(starting) + " " + "and" + " " + hhmm(ending) + } + else if (starting) { + timeLabel = "Start at" + " " + hhmm(starting) + } + else if(ending){ + timeLabel = "End at" + hhmm(ending) + } + timeLabel +} + +def greyedOutTime(starting, ending){ + def result = "" + if (starting || ending) { + result = "complete" + } + result +} + +def getTimeZone() { + def tz = null + if(location?.timeZone) { tz = location?.timeZone } + if(!tz) { log.warn "No time zone has been retrieved from SmartThings. Please try to open your ST location and press Save." } + return tz +} + +def getChildName() { return "Neato BotVac" } +def getServerUrl() { return "https://graph.api.smartthings.com" } +def getShardUrl() { return getApiServerUrl() } +def getCallbackUrl() { return "https://graph.api.smartthings.com/oauth/callback" } +def getBuildRedirectUrl() { return "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" } +def getApiEndpoint() { return "https://apps.neatorobotics.com" } +def getSmartThingsClientId() { return appSettings.clientId } +def beehiveURL(path = '/') { return "https://beehive.neatocloud.com${path}" } +private def textVersion() { + def text = "Neato (Connect)\nVersion: 1.2.6c\nDate: 17042020(1200)" +} + +private def textCopyright() { + def text = "Copyright © 2016-2020 Alex Lee Yuk Cheung" +} + +def clientId() { + if(!appSettings.clientId) { + return "3ba64237d07f43e2e6ecff97de60916b73c4b06df71e9ad35ec02d7b3b513881" + } else { + return appSettings.clientId + } +} + +def clientSecret() { + if(!appSettings.clientSecret) { + return "e7fd560dab04efdd38488f918a2a8b0c097157d765e19003360fc458f5119bde" + } else { + return appSettings.clientSecret + } +} \ No newline at end of file diff --git a/smartapps/alyc100/neato_icon.png b/smartapps/alyc100/neato_icon.png new file mode 100644 index 00000000000..f18079915f4 Binary files /dev/null and b/smartapps/alyc100/neato_icon.png differ diff --git a/smartapps/alyc100/ovo-energy-connect.src/ovo-energy-connect.groovy b/smartapps/alyc100/ovo-energy-connect.src/ovo-energy-connect.groovy new file mode 100644 index 00000000000..d79849db13a --- /dev/null +++ b/smartapps/alyc100/ovo-energy-connect.src/ovo-energy-connect.groovy @@ -0,0 +1,504 @@ +/** + * OVO Energy (Connect) + * + * Copyright 2017,2018 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 09.03.2016 + * v2.0 - New OVO Connect App + * v2.1 - Send notification for daily cost summary and daily usage summary + * v2.2 - Support fetching latest unit prices and standing charge from OVO account. + * Send notification for specified daily cost level breach. + * v2.2.1 - Move external icon references to Github + * + * 17.08.2016 + * v2.2.2 - Fix device failure on API timeout. + * + * 11.11.2016 + * v2.2.3 - Reduce number of calls to accounts API. + * + * 06.12.2016 + * v2.2.4 - Add offline/online API notification. + * + * 10.01.2017 + * v2.2.4b - Stop null pointer on update latest price failiure. + * + * 25.01.2017 + * v2.2.5 - Stop notifications when total power and cost values are TBD from OVO API + * + * 01.06.2018 + * v2.5 - OVO have stopped the live API. Update to remove unecessary polling. + * + * 28.10.2018 + * v3.0 - Support for new My OVO platform V2. + */ +definition( + name: "OVO Energy (Connect)", + namespace: "alyc100", + author: "Alex Lee Yuk Cheung", + description: "Connect your OVO Energy Account to SmartThings. (Requires OVO Smart Gateway)", + iconUrl: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/icon175x175.jpeg", + iconX2Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/icon175x175.jpeg", + singleInstance: true +) + +preferences { + page(name:"firstPage", title:"OVO Account Setup", content:"firstPage", install: true) + page(name: "loginPAGE") + page(name: "selectDevicePAGE") + page(name: "preferencesPAGE") + page(name: "accountDetailsPAGE") +} + +def firstPage() { + log.debug "firstPage" + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter OVO Energy account crednentials", state: authenticated()) + } + } + } + else + { + log.debug "next phase" + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter OVO Energy account crednentials", state: authenticated()) + } + if (stateTokenPresent()) { + section ("Choose your Smart Meters:") { + href("selectDevicePAGE", title: null, description: devicesSelected() ? "Devices: " + getDevicesSelectedString() : "Tap to select smart meters", state: devicesSelected()) + } + section ("Notifications:") { + href("preferencesPAGE", title: null, description: preferencesSelected() ? getPreferencesString() : "Tap to configure notifications", state: preferencesSelected()) + } + section ("Account Details:") { + href("accountDetailsPAGE", title: null, description: "Tap to view OVO Energy Account Details") + } + } else { + section { + paragraph "There was a problem connecting to OVO Energy. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def headerSECTION() { + return paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/icon175x175.jpeg", + "OVO Energy (Connect)\nVersion: 2.5\nDate: 01062018(1420)") +} + +def stateTokenPresent() { + return state.ovoAccessToken != null && state.ovoAccessToken != '' +} + +def authenticated() { + return (state.ovoAccessToken != null && state.ovoAccessToken != '') ? "complete" : null +} + +def devicesSelected() { + return (selectedMeters) ? "complete" : null +} + +def getDevicesSelectedString() { + if (state.smartMeterDevices == null) { + updateDevices() + } + def listString = "" + selectedMeters.each { childDevice -> + if (listString == "") { + if (null != state.smartMeterDevices) { + listString += state.smartMeterDevices[childDevice] + } + } + else { + if (null != state.smartMeterDevices) { + listString += "\n" + state.smartMeterDevices[childDevice] + } + } + } + return listString +} + +def preferencesSelected() { + return (sendPush || sendSMS != null) && (sendDailyCostSummary || sendDailyUsageSummary || costAlertLevel) ? "complete" : null +} + +def getPreferencesString() { + def listString = "" + if (sendPush) listString += "Send Push, " + if (sendSMS != null) listString += "Send SMS, " + if (sendDailyCostSummary) listString += "Daily Cost Summary, " + if (sendDailyUsageSummary) listString += "Daily Usage Summary, " + if (costAlertLevel) listString += "Daily Cost Level Alert, " + if (listString != "") listString = listString.substring(0, listString.length() - 2) + return listString +} + +def loginPAGE() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your OVO Energy account credentials below to enable SmartThings and OVO Energy integration." } + section("OVO Energy Credentials:") { + input("username", "text", title: "Username", description: "Your OVO username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your OVO password", required: true, submitOnChange: true) + } + } + } + else { + getOVOAccessToken() + dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your OVO Energy account credentials below to enable SmartThings and OVO Energy integration." } + section("OVO Energy Credentials:") { + input("username", "text", title: "Username", description: "Your OVO Energy username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your OVO Energy password", required: true, submitOnChange: true) + } + + if (stateTokenPresent()) { + section { + paragraph "You have successfully connected to OVO Energy. Click 'Done' to select your OVO Smart Meter devices." + } + } + else { + section { + paragraph "There was a problem connecting to OVO Energy. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def selectDevicePAGE() { + updateDevices() + dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) { + section { headerSECTION() } + section("Select your devices:") { + input "selectedMeters", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/ovowebsitessuite-carousel.png", required:false, title:"Select Smart Meter Devices \n(${state.smartMeterDevices.size() ?: 0} found)", multiple:true, options:state.smartMeterDevices + } + } +} + +def preferencesPAGE() { + dynamicPage(name: "preferencesPAGE", title: "Preferences", uninstall: false, install: false) { + section { + input "sendPush", "bool", title: "Send as Push?", required: false, defaultValue: false + input "sendSMS", "phone", title: "Send as SMS?", required: false, defaultValue: null + } + section("OVO Smart Meter Notifications:") { + input "sendDailyCostSummary", "bool", title: "Send daily cost information?", required: false, defaultValue: false + input "sendDailyUsageSummary", "bool", title: "Send daily usage information?", required: false, defaultValue: false + input "costAlertLevel", "bool", title: "Alert when daily cost exceeds amount?", required: false, defaultValue: false + } + } +} + +def accountDetailsPAGE() { + def accountData = updateAccountDetails() + dynamicPage(name: "accountDetailsPAGE", title: "OVO Energy Account Details", uninstall: false, install: false) { + section("Account Holder") { + paragraph "Account ID:\n${accountData.id}" + paragraph "Name:\n${accountData.accountHolder}" + paragraph "Address:\n${accountData.homeAddress.line1}\n${accountData.homeAddress.line2}\n${accountData.homeAddress.town}\n${accountData.homeAddress.county}\n${accountData.homeAddress.postcode}" + } + section("Balance") { + paragraph "${String.format("%1.2f", accountData.balance.amount as BigDecimal)} ${accountData.balance.currency}" + } + section("Direct Debit") { + paragraph "${String.format("%1.2f", accountData.directDebit.payment.amount as BigDecimal)} ${accountData.directDebit.payment.currency}" + paragraph "Next Payment Date: ${accountData.directDebit.nextPaymentDate}" + } + } +} + +// App lifecycle hooks + +def installed() { + log.debug "installed" + initialize() + // Check for new devices every 3 hours + runEvery3Hours('updateDevices') + // execute handlerMethod every 1 hour. + runEvery1Hour('refreshDevices') +} + +// called after settings are changed +def updated() { + log.debug "updated" + unsubscribe() + initialize() + unschedule('refreshDevices') + // execute handlerMethod every 1 hour. + runEvery1Hour('refreshDevices') +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule() + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(devices) { + devices.each { + deleteChildDevice(it.deviceNetworkId) // 'it' is default + } +} + +// called after Done is hit after selecting a Location +def initialize() { + log.debug "initialize" + if (selectedMeters) + addMeters() + + runIn(10, 'refreshDevices') // Asynchronously refresh devices so we don't block + + //subscribe to events for notifications if activated + if (preferencesSelected() == "complete") { + getChildDevices().each { childDevice -> + subscribe(childDevice, "yesterdayTotalPower", evtHandler) + subscribe(childDevice, "yesterdayTotalPowerCost", evtHandler) + subscribe(childDevice, "costAlertLevelPassed", evtHandler) + } + } +} + +def evtHandler(evt) { + def msg + if ((evt.name == "yesterdayTotalPowerCost") && (evt.value != "being calculated...")) { + msg = "${evt.displayName} total daily cost was ${evt.value}" + if (settings.sendDailyCostSummary) generateNotification(msg) + } + else if ((evt.name == "yesterdayTotalPower") && (evt.value != "TBD")) { + msg = "${evt.displayName} total daily usage was ${evt.value} ${evt.unit} " + if (settings.sendDailyUsageSummary) generateNotification(msg) + } + else if (evt.name == "costAlertLevelPassed" && evt.value == "offline") { + msg = "${evt.displayName} is currently offline" + generateNotification(msg) + } + else if (evt.name == "costAlertLevelPassed" && evt.value == "online") { + msg = "${evt.displayName} is back online" + generateNotification(msg) + } + else if (evt.name == "costAlertLevelPassed" && evt.value != "false") { + msg = "WARNING: ${evt.displayName} daily cost has exceeded ${evt.value}" + if (settings.costAlertLevel) generateNotification(msg) + } +} + +def generateNotification(msg) { + if (settings.sendSMS != null) { + sendSms(sendSMS, msg) + } + if (settings.sendPush) { + sendPush(msg) + } +} + +def updateDevices() { + log.debug "Executing 'updateDevices'" + if (!state.devices) { + state.devices = [:] + } + def devices = devicesList() + state.smartMeterDevices = [:] + def selectors = [] + devices.each { device -> + if (device.mpan != null) { + selectors.add("${device.mpan}") + def value + value = (device.utilityType == "GAS") ? "OVO Gas Smart Meter" : "OVO Electricity Smart Meter" + def key = device.mpan + state.smartMeterDevices["${key}"] = value + } + } + log.debug selectors + //Remove devices if does not exist on the OVO platform + getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { + log.info("Deleting ${it.deviceNetworkId}") + try { + deleteChildDevice(it.deviceNetworkId) + } catch (physicalgraph.exception.NotFoundException e) { + log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.") + } catch (physicalgraph.exception.ConflictException ce) { + log.info("Device ${it.deviceNetworkId} in use. Please manually delete.") + } + } +} + +def addMeters() { + updateDevices() + + selectedMeters.each { device -> + + def childDevice = getChildDevice("${device}") + + if (!childDevice) { + log.info("Adding Smart Meter device ${device}: ${state.smartMeterDevices[device]}") + + def data = [ + name: state.smartMeterDevices[device], + label: state.smartMeterDevices[device], + ] + childDevice = addChildDevice(app.namespace, "OVO Energy Meter V2.0", "$device", null, data) + childDevice.refresh() + + log.debug "Created ${state.smartMeterDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.smartMeterDevices[device]} with id ${device} already exists" + } + + } +} + +def refreshDevices() { + log.info("Refreshing all devices...") + getChildDevices().each { device -> + device.refresh() + } +} + +def devicesList() { + logErrors([]) { + def resp = apiGET("https://paym.ovoenergy.com/api/paym/accounts") + if (resp.status == 200) { + return resp.data.consumers[0] + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def updateAccountDetails() { + logErrors([]) { + def resp = apiGET("https://smartpaym.ovoenergy.com/api/customer-and-account-ids") + if (resp.status == 200) { + return resp.data[0] + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def apiGET(path, body = [:]) { + try { + if(!isLoggedIn()) { + log.debug "Need to login" + getOVOAccessToken() + } + log.debug("Beginning API GET: ${path}, ${apiRequestHeaders()}") + + httpGet(uri: path, contentType: 'application/json', headers: apiRequestHeaders()) {response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +def getOVOAccessToken() { + try { + def params = [ + uri: 'https://my.ovoenergy.com/api/v2/auth/login', + contentType: 'application/json;charset=UTF-8', + headers: [ + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json;charset=UTF-8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.86 Safari/537.36', + 'Origin': 'https://my.ovoenergy.com' + ], + body: [ + username: settings.username, + rememberMe: true, + password: settings.password, + ] + ] + + state.cookie = '' + + httpPostJson(params) {response -> + log.debug "Request was successful, $response.status" + log.debug response.headers + + state.cookie = response?.headers?.'Set-Cookie'?.split(";")?.getAt(0) + log.debug "Adding cookie to collection: $cookie" + log.debug "auth: $response.data" + log.debug "cookie: $state.cookie" + log.debug "sessionid: ${response.data.userId}" + + state.ovoAccessToken = response.data.userId + // set the expiration to 5 minutes + state.ovoAccessToken_expires_at = new Date().getTime() + 600000 + state.loginerrors = null + } + + } catch (groovyx.net.http.HttpResponseException e) { + state.ovoAccessToken = null + state.ovoAccessToken_expires_at = null + state.loginerrors = "Error: ${e.response.status}: ${e.response.data}" + logResponse(e.response) + return e.response + } +} + +Map apiRequestHeaders() { + return [ + 'Cookie': "${state.cookie}", + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json;charset=UTF-8', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.86 Safari/537.36', + 'Origin': 'https://my.ovoenergy.com', + 'Authorization': "${state.ovoAccessToken}" + ] +} + +def isLoggedIn() { + log.debug "Calling isLoggedIn()" + log.debug "isLoggedIn state $state.ovoAccessToken" + if(!state.ovoAccessToken) { + log.debug "No state.ovoAccessToken" + return false + } + + def now = new Date().getTime(); + return state.ovoAccessToken_expires_at > now +} + +def logResponse(response) { + log.info("Status: ${response.status}") + log.info("Body: ${response.data}") +} + +def logErrors(options = [errorReturn: null, logObject: log], Closure c) { + try { + return c() + } catch (groovyx.net.http.HttpResponseException e) { + log.error("got error: ${e}, body: ${e.getResponse().getData()}") + if (e.statusCode == 401) { // token is expired + state.remove("ovoAccessToken") + log.warn "Access token is not valid" + } + return options.errorReturn + } catch (java.net.SocketTimeoutException e) { + log.warn "Connection timed out, not much we can do here" + return options.errorReturn + } +} \ No newline at end of file diff --git a/smartapps/alyc100/ovowebsitessuite-carousel.png b/smartapps/alyc100/ovowebsitessuite-carousel.png new file mode 100644 index 00000000000..100eb113407 Binary files /dev/null and b/smartapps/alyc100/ovowebsitessuite-carousel.png differ diff --git a/smartapps/alyc100/sure-petcare-connect.src/sure-petcare-connect.groovy b/smartapps/alyc100/sure-petcare-connect.src/sure-petcare-connect.groovy new file mode 100644 index 00000000000..f9418025c92 --- /dev/null +++ b/smartapps/alyc100/sure-petcare-connect.src/sure-petcare-connect.groovy @@ -0,0 +1,931 @@ +/** + * Sure Petcare (Connect) + * + * Copyright 2020 Alex Lee Yuk Cheung + * + * 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. + * + * VERSION HISTORY + * 17.04.2020 - v1.2c - Notification workaround based on change on ST platform. + * 08.10.2019 - v1.2b - Rename lock mode labels. + * 13.09.2019 - v1.2 - Curfew option on PetCare doors + * 10.09.2019 - v1.1b - Improve API call efficiency + * 09.09.2019 - v1.1 - Added Keep Pet In option on Pet device for Dual Scan PetCare cat flaps + * - Added Pet Status with photo. + * 07.09.2019 - v1.0.1 - Added Notification Framework + * 06.09.2019 - v1.0 - Initial Version + */ +definition( + name: "Sure PetCare (Connect)", + namespace: "alyc100", + author: "Alex Lee Yuk Cheung", + description: "Connect your Sure PetCare devices to SmartThings.", + category: "", + iconUrl: "https://www.surepetcare.io/assets/images/onboarding/Sure_Petcare_Logo.png", + iconX2Url: "https://www.surepetcare.io/assets/images/onboarding/Sure_Petcare_Logo.png", + iconX3Url: "https://www.surepetcare.io/assets/images/onboarding/Sure_Petcare_Logo.png") + singleInstance: true + + +preferences { + page(name:"firstPage", title:"Sure PetCare Device Setup", content:"firstPage", install: true) + page(name: "loginPAGE") + page(name: "selectDevicePAGE") + page(name: "curfewPAGE") + page(name: "preferencesPAGE") + page(name: "timeIntervalPAGE") +} + +def apiURL(path = '/') { return "https://app.api.surehub.io${path}" } +def deviceId() { return (Math.abs(new Random().nextInt() % 9999999999) + 1000000000).toString() } + +def firstPage() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter Sure PetCare account credentials", state: authenticated()) + } + } + } + else + { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter Sure PetCare account credentials", state: authenticated()) + } + if (stateTokenPresent()) { + section ("Choose your Sure PetCare devices and pets:") { + href("selectDevicePAGE", title: null, description: devicesSelected() ? getDevicesSelectedString() : "Tap to select Sure PetCare devices", state: devicesSelected()) + } + if (devicesSelected() == "complete") { + section ("Curfew Configuration:") { + if (selectedPetDoorConnect && selectedPetDoorConnect.size() > 0) { + selectedPetDoorConnect.each() { + def curfewEnabled = curfewSelected(it) + href("curfewPAGE", params: ["deviceId": it], title: "Curfew for ${state.surePetCarePetDoorConnectDevices[it]}", description: settings["curfewEnabled#$it"] ? "${getSmartScheduleString(it)}" : "Tap to configure curfew for ${state.surePetCarePetDoorConnectDevices[it]}", state: curfewEnabled, required: false, submitOnChange: false) + } + } + if (selectedDualScanCatFlapConnect && selectedDualScanCatFlapConnect.size() > 0) { + settings.selectedDualScanCatFlapConnect.each() { + def curfewEnabled = curfewSelected(it) + href("curfewPAGE", params: ["deviceId": it], title: "Curfew for ${state.surePetCareDualScanCatFlapConnectDevices[it]}", description: settings["curfewEnabled#$it"] ? "${getCurfewString(it)}" : "Tap to configure curfew for ${state.surePetCareDualScanCatFlapConnectDevices[it]}", state: curfewEnabled, required: false, submitOnChange: false) + } + } + } + section ("Notifications:") { + href("preferencesPAGE", title: null, description: preferencesSelected() ? getPreferencesString() : "Tap to configure notifications", state: preferencesSelected()) + } + section("Pets:") { + getChildDevices().findAll { it.typeName == "Sure PetCare Pet" }.each { childDevice -> + try { + paragraph image: "${childDevice.getPhotoURL()}", "${childDevice.displayName} is ${childDevice.currentPresence}." + } + catch (e) { + log.trace "Error checking status." + log.trace e + } + } + } + } + } else { + section { + paragraph "There was a problem connecting to Sure PetCare. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def loginPAGE() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your Sure PetCare account credentials below to enable SmartThings and Sure PetCare integration." } + section { + input("username", "text", title: "Username", description: "Your Sure PetCare username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your Sure PetCare password", required: true, submitOnChange: true) + } + } + } + else { + getSurePetCareAccessToken() + dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your Sure PetCare account credentials below to enable SmartThings and Sure PetCare integration." } + section("Sure PetCare Credentials:") { + input("username", "text", title: "Username", description: "Your Sure PetCare username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your Sure PetCare password", required: true, submitOnChange: true) + } + + if (stateTokenPresent()) { + section { + paragraph "You have successfully connected to Sure PetCare. Click 'Done' to select your Sure PetCare devices." + } + } + else { + section { + paragraph "There was a problem connecting to Sure PetCare. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def selectDevicePAGE() { + updateLocations() + dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) { + section { headerSECTION() } + if (devicesSelected() == null) { + section("Select your Location:") { + input "selectedLocation", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-location.png", required:false, title:"Select a Location \n(${state.surePetCareLocations.size() ?: 0} found)", multiple:false, options:state.surePetCareLocations, submitOnChange: true + } + } + else { + section("Your location:") { + paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-location.png", + "Location: ${state.surePetCareLocations[selectedLocation]}\n(Remove all devices to change)") + } + } + if (selectedLocation) { + updateDevices() + section("Select your devices:") { + input "selectedHub", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/surepetcare-hub.png", required:false, title:"Select PetCare Hub Devices \n(${state.surePetCareHubDevices.size() ?: 0} found)", multiple:true, options:state.surePetCareHubDevices + input "selectedPetDoorConnect", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/surepetcare-petdoor.png", required:false, title:"Select Pet Door Connect Devices \n(${state.surePetCarePetDoorConnectDevices.size() ?: 0} found)", multiple:true, options:state.surePetCarePetDoorConnectDevices + input "selectedDualScanCatFlapConnect", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/surepetcare-catflap.png", required:false, title:"Select Dual Scan Cat Flap Connect Devices \n(${state.surePetCareDualScanCatFlapConnectDevices.size() ?: 0} found)", multiple:true, options:state.surePetCareDualScanCatFlapConnectDevices + } + + section("Select your pets:") { + input "selectedPet", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/surepetcare-pet.png", required:false, title:"Select Your Pet \n(${state.surePetPets.size() ?: 0} found)", multiple:true, options:state.surePetPets + } + } + } +} + +def curfewPAGE(params) { + log.debug "PARAMS: $params" + if (params.containsKey("deviceId")) state.configDeviceId = params?.deviceId + def deviceId = state.configDeviceId + return dynamicPage(name: "curfewPAGE", title: "Curfew Settings", install: false, uninstall: false) { + section() { + paragraph "Configure a curfew schedule for your Pet Door / Cat Flap." + input "curfewEnabled#$deviceId", "bool", title: "Enable Curfew?", required: false, defaultValue: false, submitOnChange: true + } + if (settings["curfewEnabled#$deviceId"]) { + + section("Curfew Time:") { + //Define time of day + paragraph "Set the time of your curfew." + def greyedOutTime = greyedOutTime(settings["starting#$deviceId"], settings["ending#$deviceId"]) + def timeLabel = getTimeLabel(settings["starting#$deviceId"], settings["ending#$deviceId"]) + href ("timeIntervalPAGE", params: ["deviceId": deviceId], title: "Set curfew during a certain time", description: timeLabel, state: greyedOutTime, refreshAfterSelection:true) + } + /*section("Notifications:") { + paragraph "Turn on SmartSchedule notifications. You can configure specific recipients via Notification settings section." + input "ssNotification", "bool", title: "Enable SmartSchedule notifications?", required: false, defaultValue: true + } */ + } + } + +} + +def timeIntervalPAGE(params) { + def deviceId = params.deviceId + return dynamicPage(name: "timeIntervalPAGE", title: "Only during a certain time", refreshAfterSelection:true) { + section { + input "starting#$deviceId", "time", title: "Starting", required: false + input "ending#$deviceId", "time", title: "Ending", required: false + } + } +} + +def preferencesPAGE() { + dynamicPage(name: "preferencesPAGE", title: "Preferences", uninstall: false, install: false) { + section { + input "sendPush", "bool", title: "Send as Push?", required: false, defaultValue: false + input "sendSMS", "phone", title: "Send as SMS?", required: false, defaultValue: null + } + section("Sure PetCare Notifications:") { + input "sendPetDoorLock", "bool", title: "Notify when pet doors are lock and unlocked?", required: false, defaultValue: false + input "sendPetPresence", "bool", title: "Notify when pets arrive and leave?", required: false, defaultValue: false + input "sendPetIndoors", "bool", title: "Notify when pets are set to indoors only or allowed outdoors?", required: false, defaultValue: false + input "sendPetLooked", "bool", title: "Notify when pets look through door?", required: false, defaultValue: false + input "sendDoorConnection", "bool", title: "Notify when pet doors lose connection?", required: false, defaultValue: false + } + } +} + +def headerSECTION() { + return paragraph (image: "https://www.surepetcare.io/assets/images/onboarding/Sure_Petcare_Logo.png", + "${textVersion()}") +} + +def preferencesSelected() { + return (sendPush || sendSMS != null) && (sendPetDoorLock || sendPetPresence || sendPetLooked || sendDoorConnection) ? "complete" : null +} + +def getPreferencesString() { + def listString = "" + if (sendPush) listString += "Send Push, " + if (sendSMS != null) listString += "Send SMS, " + if (sendPetDoorLock) listString += "Pet Door Lock/Unlock, " + if (sendPetPresence) listString += "Pet Arrival/Leaving, " + if (sendPetLooked) listString += "Pet Looked, " + if (sendDoorConnection) listString += "Pet Door Offline, " + if (listString != "") listString = listString.substring(0, listString.length() - 2) + return listString +} + +def stateTokenPresent() { + return state.surePetCareAccessToken != null && state.surePetCareAccessToken != '' +} + +def authenticated() { + return (state.surePetCareAccessToken != null && state.surePetCareAccessToken != '') ? "complete" : null +} + +def devicesSelected() { + return (selectedHub || selectedPetDoorConnect || selectedDualScanCatFlapConnect || selectedPet) ? "complete" : null +} + +def getDevicesSelectedString() { + if (state.surePetCareHubDevices == null || + state.surePetCarePetDoorConnectDevices == null || + state.surePetCareDualScanCatFlapConnectDevices == null || + state.surePetPets == null) { + updateDevices() + } + def listString = "" + + selectedHub.each { childDevice -> + if (null != state.surePetCareHubDevices) + listString += "\n• " + state.surePetCareHubDevices[childDevice] + } + + selectedPetDoorConnect.each { childDevice -> + if (null != state.surePetCarePetDoorConnectDevices) + listString += "\n• " + state.surePetCarePetDoorConnectDevices[childDevice] + } + + selectedDualScanCatFlapConnect.each { childDevice -> + if (null != state.surePetCareDualScanCatFlapConnectDevices) + listString += "\n• " + state.surePetCareDualScanCatFlapConnectDevices[childDevice] + } + + selectedPet.each { childDevice -> + if (null != state.surePetPets) + listString += "\n• " + state.surePetPets[childDevice] + } + // Returns the completed list, and trims the last carrige return + return listString.trim() +} + +def curfewSelected(deviceId) { + return settings["curfewEnabled#$deviceId"] ? "complete" : null +} + +def getCurfewString(deviceId) { + def listString = "" + listString += "The following curfew applies:\n" + if (settings["curfewEnabled#$deviceId"]) listString += "• ${getTimeLabel(settings["starting#$deviceId"], settings["ending#$deviceId"])}\n" + return listString +} + +private hhmm(time, fmt = "HH:mm") { + def t = timeToday(time, location.timeZone) + def f = new java.text.SimpleDateFormat(fmt) + if (getTimeZone()) { f.setTimeZone(location.timeZone ?: timeZone(time)) } + f.format(t) +} + +private timeToString(time, fmt) { + def t = timeToday(time, location.timeZone) + def f = new java.text.SimpleDateFormat(fmt) + if (getTimeZone()) { f.setTimeZone(location.timeZone ?: timeZone(time)) } + f.format(t) +} + +def getTimeLabel(starting, ending){ + def timeLabel = "Tap to set" + + if(starting && ending){ + timeLabel = "Between" + " " + hhmm(starting) + " " + "and" + " " + hhmm(ending) + } + else if (starting) { + timeLabel = "Start at" + " " + hhmm(starting) + } + else if(ending){ + timeLabel = "End at" + hhmm(ending) + } + timeLabel +} + +def greyedOutTime(starting, ending){ + def result = "" + if (starting || ending) { + result = "complete" + } + result +} + +// App lifecycle hooks + +def installed() { + log.debug "installed" + initialize() + // Check for new devices and remove old ones every 3 hours + runEvery3Hours('updateDevices') + // execute refresh method every minute + runEvery1Minute('refreshDevices') +} + +// called after settings are changed +def updated() { + log.debug "updated" + unsubscribe() + initialize() + unschedule('refreshDevices') + runEvery1Minute('refreshDevices') +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule() + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(devices) { + devices.each { + deleteChildDevice(it.deviceNetworkId) // 'it' is default + } +} + +// called after Done is hit after selecting a Location +def initialize() { + log.debug "initialize" + if (selectedHub) { + addHub() + } + if (selectedPetDoorConnect) { + addPetDoorConnect() + } + if(selectedDualScanCatFlapConnect) { + addDualScanCatFlapConnect() + } + + if (selectedPet) { + addPet() + } + runIn(10, 'refreshDevices') // Asynchronously refresh devices so we don't block + + //subscribe to events for notifications if activated + if (preferencesSelected() == "complete") { + getChildDevices().each { childDevice -> + if (childDevice.typeName == "Sure PetCare Pet") { + subscribe(childDevice, "petInfo", evtHandler, [filterEvents: false]) + subscribe(childDevice, "presence", evtHandler, [filterEvents: false]) + subscribe(childDevice, "indoorsOnly", evtHandler, [filterEvents: false]) + } + if (childDevice.typeName == "Sure PetCare Pet Door Connect") { + subscribe(childDevice, "lockMode", evtHandler, [filterEvents: false]) + subscribe(childDevice, "network", evtHandler, [filterEvents: false]) + //enable/disable curfew for pet door devices + runIn(1, syncCurfewSettings, [data: [deviceId: childDevice.deviceNetworkId]]) + } + } + } +} + +//enable/disable curfew for pet door devices +def syncCurfewSettings(data) { + def deviceId = data.deviceId + def body + if (settings["curfewEnabled#$deviceId"]) { + def curfew = [ + enabled: true, + lock_time: "${hhmm(settings["starting#$deviceId"])}", + unlock_time: "${hhmm(settings["ending#$deviceId"])}" + ] + def curfewList = [] + curfewList.add(curfew) + body = [ + curfew: curfewList + ] + } else { + def curfew = [ + enabled: false, + lock_time: "${hhmm(settings["starting#$deviceId"])}", + unlock_time: "${hhmm(settings["ending#$deviceId"])}" + ] + def curfewList = [] + curfewList.add(curfew) + body = [ + curfew: curfewList + ] + } + apiPUT("/api/device/" + deviceId + "/control", body) +} + +//Event Handler for Connect App +def evtHandler(evt) { + def msg + if (evt.isStateChange == 'true') { + if (evt.name == "petInfo") { + msg = evt.value + if (settings.sendPetLooked) messageHandler(msg, false) + } else if (evt.name == "presence") { + msg = (evt.value == "present") ? "${evt.displayName} has arrived." : "${evt.displayName} is leaving." + if (settings.sendPetPresence) messageHandler(msg, false) + } else if (evt.name == "indoorsOnly") { + if (evt.value != "empty") { + msg = (evt.value == "true") ? "${evt.displayName} is set to indoors only." : "${evt.displayName} is allowed outdoors." + if (settings.sendPetIndoors) messageHandler(msg, false) + } + } else if (evt.name == "lockMode") { + msg = (evt.value == "both" || evt.value == "in") ? "${evt.displayName} is locked." : "${evt.displayName} is unlocked." + if (settings.sendPetDoorLock) messageHandler(msg, false) + } else if (evt.name == "network") { + msg = (evt.value == "Connected") ? "${evt.displayName} is online." : "${evt.displayName} is offline." + if (settings.sendDoorConnection) messageHandler(msg, false) + } + } +} + +def updateDevices() { + if (!state.devices) { + state.devices = [:] + } + def devices = devicesList() + state.surePetCareHubDevices = [:] + state.surePetCarePetDoorConnectDevices = [:] + state.surePetCareDualScanCatFlapConnectDevices = [:] + + def selectors = [] + devices.each { device -> + log.debug "Identified: device ${device.id}: ${device.product_id}: ${device.household_id}: ${device.name}: ${device.serial_number}: ${device.mac_address}" + selectors.add("${device.id}") + + //Hub + if (device.product_id == 1) { + log.debug "Identified: ${device.name} Pet Care Hub" + def value = "${device.name} PetCare Hub" + def key = device.id + state.surePetCareHubDevices["${key}"] = value + + //Update names of devices with PetCare + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.name + " PetCare Hub") { + childDevice.name = device.name + " PetCare Hub" + log.debug "Device's name has changed." + } + } + } + //Pet Door Connect + else if (device.product_id == 3) { + log.debug "Identified: ${device.name} Pet Door Connect" + def value = "${device.name} Pet Door Connect" + def key = device.id + state.surePetCarePetDoorConnectDevices["${key}"] = value + + //Update names of devices with PetCare + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.name + " Pet Door Connect") { + childDevice.name = device.name + " Pet Door Connect" + log.debug "Device's name has changed." + } + } + } + //Dual Scan Cat Flap Connect + else if (device.product_id == 6) { + log.debug "Identified: ${device.name} Dual Scan Cat Flap Connect" + def value = "${device.name} Dual Scan Cat Flap Connect" + def key = device.id + state.surePetCareDualScanCatFlapConnectDevices["${key}"] = value + + //Update names of devices with PetCare + def childDevice = getChildDevice("${device.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.name + " Dual Scan Cat Flap Connect") { + childDevice.name = device.name + " Dual Scan Cat Flap Connect" + log.debug "Device's name has changed." + } + } + } + } + + if (!state.pets) { + state.pets = [:] + } + def pets = petsList() + state.surePetPets = [:] + + pets.each {pet -> + def species = (pet.species_id == 2) ? "dog" : "cat" + log.debug "Identified: ${pet.name} the ${species}" + selectors.add("${pet.id}") + def value = "${pet.name} the ${species}" + def key = pet.id + state.surePetPets["${key}"] = value + + //Update names of pets with PetCare + def childDevice = getChildDevice("${pet.id}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != pet.name + " the ${species}") { + childDevice.name = pet.name + " the ${species}" + log.debug "Pet's name has changed." + } + } + } + + //Remove devices if does not exist on the Sure PetCare platform + getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { + log.info("Deleting ${it.deviceNetworkId}") + try { + deleteChildDevice(it.deviceNetworkId) + } catch (physicalgraph.exception.NotFoundException e) { + log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.") + } catch (physicalgraph.exception.ConflictException ce) { + log.info("Device ${it.deviceNetworkId} in use. Please manually delete.") + } + } +} + +def updateLocations() { + def locations = locationsList() + state.surePetCareLocations = [:] + + def selectors = [] + locations.each { location -> + log.debug "Identified: location ${location.id}: ${location.name}" + selectors.add("${location.id}") + def value = "${location.name}" + def key = location.id + state.surePetCareLocations["${key}"] = value + } +} + +def addHub() { + updateDevices() + + selectedHub.each { device -> + def childDevice = getChildDevice("${device}") + if (!childDevice && state.surePetCareHubDevices[device] != null) { + log.info("Adding device ${device}: ${state.surePetCareHubDevices[device]}") + + def data = [ + name: state.surePetCareHubDevices[device], + label: state.surePetCareHubDevices[device] + ] + childDevice = addChildDevice(app.namespace, "Sure PetCare Hub", "$device", null, data) + + log.debug "Created ${state.surePetCareHubDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.surePetCareHubDevices[device]} with id ${device} already exists" + } + } +} + +def addPetDoorConnect() { + updateDevices() + + selectedPetDoorConnect.each { device -> + def childDevice = getChildDevice("${device}") + if (!childDevice && state.surePetCarePetDoorConnectDevices[device] != null) { + log.info("Adding device ${device}: ${state.surePetCarePetDoorConnectDevices[device]}") + + def data = [ + name: state.surePetCarePetDoorConnectDevices[device], + label: state.surePetCarePetDoorConnectDevices[device] + ] + childDevice = addChildDevice(app.namespace, "Sure PetCare Pet Door Connect", "$device", null, data) + + log.debug "Created ${state.surePetCarePetDoorConnectDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.surePetCarePetDoorConnectDevices[device]} with id ${device} already exists" + } + } +} + +def addDualScanCatFlapConnect() { + updateDevices() + + selectedDualScanCatFlapConnect.each { device -> + def childDevice = getChildDevice("${device}") + if (!childDevice && state.surePetCareDualScanCatFlapConnectDevices[device] != null) { + log.info("Adding device ${device}: ${state.surePetCareDualScanCatFlapConnectDevices[device]}") + + def data = [ + name: state.surePetCareDualScanCatFlapConnectDevices[device], + label: state.surePetCareDualScanCatFlapConnectDevices[device] + ] + childDevice = addChildDevice(app.namespace, "Sure PetCare Pet Door Connect", "$device", null, data) + + log.debug "Created ${state.surePetCareDualScanCatFlapConnectDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.surePetCareDualScanCatFlapConnectDevices[device]} with id ${device} already exists" + } + } +} + +def addPet() { + updateDevices() + + selectedPet.each { device -> + def childDevice = getChildDevice("${device}") + if (!childDevice && state.surePetPets[device] != null) { + log.info("Adding pet ${device}: ${state.surePetPets[device]}") + + def data = [ + name: state.surePetPets[device], + label: state.surePetPets[device] + ] + childDevice = addChildDevice(app.namespace, "Sure PetCare Pet", "$device", null, data) + + log.debug "Created ${state.surePetPets[device]} with id: ${device}" + } else { + log.debug "found ${state.surePetPets[device]} with id ${device} already exists" + } + } +} + +def refreshDevices() { + log.info("Executing refreshDevices...") + if (atomicState.refreshCounter == null || atomicState.refreshCounter >= 10) { + atomicState.refreshCounter = 0 + } else { + atomicState.refreshCounter = atomicState.refreshCounter + 1 + } + def resp = apiGET("/api/me/start") + getChildDevices().each { device -> + device.setStatusRespCode(resp.status) + device.setStatusResponse(resp.data) + if (device.typeName == "Sure PetCare Pet") { + log.info("High Freq Refreshing device ${device.typeName}...") + device.refresh() + } else if (device.typeName == "Sure PetCare Pet Door Connect") { + log.info("High Freq Refreshing device ${device.typeName}...") + device.refresh() + //Update curfew status + def flap = resp.data.data.devices.find{device.deviceNetworkId.toInteger() == it.id} + if (flap.control.curfew && !flap.control.curfew.isEmpty()) { + app.updateSetting("curfewEnabled#${device.deviceNetworkId}", [type: "bool", value: true]) + app.updateSetting("starting#${device.deviceNetworkId}", [type: "time", value: timeToString(flap.control.curfew[0].lock_time, "yyyy-MM-dd'T'HH:mm:ss.SSSXX")]) + app.updateSetting("ending#${device.deviceNetworkId}", [type: "time", value: timeToString(flap.control.curfew[0].unlock_time, "yyyy-MM-dd'T'HH:mm:ss.SSSXX")]) + } else { + app.updateSetting("curfewEnabled#${device.deviceNetworkId}", [type: bool, value: false]) + } + } else if (atomicState.refreshCounter == 10) { + log.info("Low Freq Refreshing device ${device.name} ...") + try { + device.refresh() + } catch (e) { + //WORKAROUND - Catch unexplained exception when refreshing devices. + logResponse(e.response) + } + } + } +} + +def devicesList() { + logErrors([]) { + def resp = apiGET('/api/household/' + selectedLocation + '/device') + if (resp.status == 200) { + return resp.data.data + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def petsList() { + logErrors([]) { + + def body = [:] + def resp = apiGET('/api/household/' + selectedLocation + '/pet') + if (resp.status == 200) { + log.debug resp.data.data + return resp.data.data + } else { + log.error("Non-200 from location list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def locationsList() { + logErrors([]) { + + def body = [:] + def resp = apiGET('/api/household') + if (resp.status == 200) { + log.debug resp.data.data + return resp.data.data + } else { + log.error("Non-200 from location list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def getSurePetCareAccessToken() { + try { + def params = [ + uri: apiURL('/api/auth/login'), + contentType: 'application/json', + headers: [ + 'Content-Type': 'application/json' + ], + body: [ + email_address: settings.username, + password: settings.password, + device_id: deviceId() + ] + ] + + state.cookie = '' + + httpPostJson(params) {response -> + log.debug "Request was successful, $response.status" + log.debug response.headers + + state.cookie = response?.headers?.'Set-Cookie'?.split(";")?.getAt(0) + log.debug "Adding cookie to collection: $cookie" + log.debug "auth: $response.data" + log.debug "cookie: $state.cookie" + log.debug "sessionid: ${response.data.data.token}" + + state.surePetCareAccessToken = response.data.data.token + // set the expiration to 5 minutes + state.surePetCareAccessToken_expires_at = new Date().getTime() + 300000 + state.loginerrors = null + } + } catch (groovyx.net.http.HttpResponseException e) { + state.surePetCareAccessToken = null + state.surePetCareAccessToken_expires_at = null + state.loginerrors = "Error: ${e.response.status}: ${e.response.data}" + logResponse(e.response) + return e.response + } +} + +def apiPOST(path, body = [:]) { + def bodyString = new groovy.json.JsonBuilder(body).toString() + log.debug("Beginning API POST: ${apiURL(path)}, ${bodyString}") + try { + httpPost(uri: apiURL(path), body: bodyString, headers: apiRequestHeaders() ) { + response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +def apiPUT(path, body = [:]) { + def bodyString = new groovy.json.JsonBuilder(body).toString() + log.debug("Beginning API PUT: ${apiURL(path)}, ${bodyString}") + try { + httpPut(uri: apiURL(path), body: bodyString, headers: apiRequestHeaders() ) { + response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } catch (Exception e) { + logResponse(e.response) + return e.response + } +} + +def apiGET(path) { + log.debug("Beginning API GET: ${apiURL(path)}") + try { + httpGet(uri: apiURL(path), headers: apiRequestHeaders() ) { + response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +//Used by Pet device to get tagID indoors only status for household +def getTagStatus(tagID) { + log.debug "Executing 'getTagStatus'" + def result = "empty" + def profileList = [] + getChildDevices().findAll { it.typeName == "Sure PetCare Pet Door Connect" }.each { childDevice -> + if (childDevice.currentState("product_id").getValue().toInteger() == 6) { + def resp = apiGET("/api/device/" + childDevice.deviceNetworkId + "/tag/" + tagID.toString()) + profileList.add(resp.data.data.profile) + } + } + log.debug "Profile List of child devices ${profileList}" + result = (profileList.size() > 0 && profileList.contains(2)) ? "false" : "true" + return result +} + +//Used by Pet device to set tag ID to indoors only for household +def setTagToIndoorsOnly(tagID) { + log.debug "Executing 'setTagToIndoorsOnly'" + getChildDevices().findAll { it.typeName == "Sure PetCare Pet Door Connect" }.each { childDevice -> + if (childDevice.currentState("product_id").getValue().toInteger() == 6) { + def body = [ + profile: 3 + ] + def resp = apiPUT("/api/device/" + childDevice.deviceNetworkId + "/tag/" + tagID, body) + } + } +} + +//Used by Pet device to set tag ID to allow outdoors for household +def setTagToOutdoors(tagID) { + log.debug "Executing 'setTagToOutdoors'" + getChildDevices().findAll { it.typeName == "Sure PetCare Pet Door Connect" }.each { childDevice -> + if (childDevice.currentState("product_id").getValue().toInteger() == 6) { + def body = [ + profile: 2 + ] + def resp = apiPUT("/api/device/" + childDevice.deviceNetworkId + "/tag/" + tagID, body) + } + } +} + +def getHouseholdID() { + return selectedLocation +} + +def messageHandler(msg, forceFlag) { + log.debug "Executing 'messageHandler for $msg. Forcing is $forceFlag'" + if (settings.sendSMS != null && !forceFlag) { + sendSms(settings.sendSMS, msg) + } + if (settings.sendPush || forceFlag) { + sendPush(msg) + } +} + +def getTimeZone() { + def tz = null + if(location?.timeZone) { tz = location?.timeZone } + if(!tz) { log.warn "No time zone has been retrieved from SmartThings. Please try to open your ST location and press Save." } + return tz +} + +Map apiRequestHeaders() { + return [ "Authorization": "Bearer $state.surePetCareAccessToken", + "Content-Type": "application/json" + ] +} + +def logResponse(response) { + log.info("Status: ${response.status}") + log.info("Body: ${response.data}") +} + +def logErrors(options = [errorReturn: null, logObject: log], Closure c) { + try { + return c() + } catch (groovyx.net.http.HttpResponseException e) { + options.logObject.error("got error: ${e}, body: ${e.getResponse().getData()}") + if (e.statusCode == 401) { // token is expired + state.remove("surePetCareAccessToken") + options.logObject.warn "Access token is not valid" + } + return options.errorReturn + } catch (java.net.SocketTimeoutException e) { + options.logObject.warn "Connection timed out, not much we can do here" + return options.errorReturn + } +} + + + +private def textVersion() { + def text = "Sure PetCare (Connect)\nVersion: 1.2c\nDate: 17042020(1200)" +} + +private def textCopyright() { + def text = "Copyright © 2020 Alex Lee Yuk Cheung" +} \ No newline at end of file diff --git a/smartapps/alyc100/surepetcare-catflap.png b/smartapps/alyc100/surepetcare-catflap.png new file mode 100644 index 00000000000..d121e9c209d Binary files /dev/null and b/smartapps/alyc100/surepetcare-catflap.png differ diff --git a/smartapps/alyc100/surepetcare-hub.png b/smartapps/alyc100/surepetcare-hub.png new file mode 100644 index 00000000000..efe86cb5bdf Binary files /dev/null and b/smartapps/alyc100/surepetcare-hub.png differ diff --git a/smartapps/alyc100/surepetcare-pet.png b/smartapps/alyc100/surepetcare-pet.png new file mode 100644 index 00000000000..bbd6bd541dc Binary files /dev/null and b/smartapps/alyc100/surepetcare-pet.png differ diff --git a/smartapps/alyc100/surepetcare-petdoor.png b/smartapps/alyc100/surepetcare-petdoor.png new file mode 100644 index 00000000000..cb5d4127044 Binary files /dev/null and b/smartapps/alyc100/surepetcare-petdoor.png differ diff --git a/smartapps/alyc100/thermostat-frame-6c75d5394d102f52cb8cf73704855446.png b/smartapps/alyc100/thermostat-frame-6c75d5394d102f52cb8cf73704855446.png new file mode 100644 index 00000000000..8c221d40fb3 Binary files /dev/null and b/smartapps/alyc100/thermostat-frame-6c75d5394d102f52cb8cf73704855446.png differ diff --git a/smartapps/alyc100/warmup-4ie.png b/smartapps/alyc100/warmup-4ie.png new file mode 100644 index 00000000000..17d35dcad89 Binary files /dev/null and b/smartapps/alyc100/warmup-4ie.png differ diff --git a/smartapps/alyc100/warmup-connect.src/warmup-connect.groovy b/smartapps/alyc100/warmup-connect.src/warmup-connect.groovy new file mode 100644 index 00000000000..121edbe1d03 --- /dev/null +++ b/smartapps/alyc100/warmup-connect.src/warmup-connect.groovy @@ -0,0 +1,455 @@ +/** + * Warmup (Connect) + * + * Copyright 2016 Alex Lee Yuk Cheung + * + * 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: "Warmup (Connect)", + namespace: "alyc100", + author: "Alex Lee Yuk Cheung", + description: "Connect your Warmup devices to SmartThings.", + category: "", + iconUrl: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-icon.png", + iconX2Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-icon.png", + iconX3Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-icon.png") + singleInstance: true + + +preferences { + page(name:"firstPage", title:"Warmup Device Setup", content:"firstPage", install: true) + page(name: "loginPAGE") + page(name: "selectDevicePAGE") +} + +def apiURL() { return "https://api.warmup.com/apps/app/v1" } + +def firstPage() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter Warmup account credentials", state: authenticated()) + } + } + } + else + { + return dynamicPage(name: "firstPage", title: "", install: true, uninstall: true) { + section { + headerSECTION() + href("loginPAGE", title: null, description: authenticated() ? "Authenticated as " +username : "Tap to enter Warmup account credentials", state: authenticated()) + } + if (stateTokenPresent()) { + section ("Choose your Warmup devices:") { + href("selectDevicePAGE", title: null, description: devicesSelected() ? getDevicesSelectedString() : "Tap to select Warmup devices", state: devicesSelected()) + } + } else { + section { + paragraph "There was a problem connecting to Warmup. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def loginPAGE() { + if (username == null || username == '' || password == null || password == '') { + return dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your Warmup account credentials below to enable SmartThings and Warmup integration." } + section { + input("username", "text", title: "Username", description: "Your Warmup username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your Warmup password", required: true, submitOnChange: true) + } + } + } + else { + getWarmupAccessToken() + dynamicPage(name: "loginPAGE", title: "Login", uninstall: false, install: false) { + section { headerSECTION() } + section { paragraph "Enter your Warmup account credentials below to enable SmartThings and Warmup integration." } + section("Warmup Credentials:") { + input("username", "text", title: "Username", description: "Your Warmup username (usually an email address)", required: true) + input("password", "password", title: "Password", description: "Your Warmup password", required: true, submitOnChange: true) + } + + if (stateTokenPresent()) { + section { + paragraph "You have successfully connected to Warmup. Click 'Done' to select your Warmup devices." + } + } + else { + section { + paragraph "There was a problem connecting to Warmup. Check your user credentials and error logs in SmartThings web console.\n\n${state.loginerrors}" + } + } + } + } +} + +def selectDevicePAGE() { + updateLocations() + dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) { + section { headerSECTION() } + if (devicesSelected() == null) { + section("Select your Location:") { + input "selectedLocation", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-location.png", required:false, title:"Select a Location \n(${state.warmupLocations.size() ?: 0} found)", multiple:false, options:state.warmupLocations, submitOnChange: true + } + } + else { + section("Your location:") { + paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-location.png", + "Location: ${state.warmupLocations[selectedLocation]}\n(Remove all devices to change)") + } + } + if (selectedLocation) { + updateDevices() + + section("Select your devices:") { + input "selectedWarmup4IEs", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-4ie.png", required:false, title:"Select Warmup 4IE Devices \n(${state.warmup4IEDevices.size() ?: 0} found)", multiple:true, options:state.warmup4IEDevices + } + } + } +} + +def headerSECTION() { + return paragraph (image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/smartapps/alyc100/warmup-icon.png", + "${textVersion()}") +} + +def stateTokenPresent() { + return state.warmupAccessToken != null && state.warmupAccessToken != '' +} + +def authenticated() { + return (state.warmupAccessToken != null && state.warmupAccessToken != '') ? "complete" : null +} + +def devicesSelected() { + return (selectedWarmup4IEs) ? "complete" : null +} + +def getDevicesSelectedString() { + if (state.warmup4IEDevices == null) { + updateDevices() + } + + def listString = "" + selectedWarmup4IEs.each { childDevice -> + if (state.warmup4IEDevices[childDevice] != null) listString += state.warmup4IEDevices[childDevice] + "\n" + } + return listString +} + +// App lifecycle hooks + +def installed() { + log.debug "installed" + initialize() + // Check for new devices and remove old ones every 3 hours + runEvery3Hours('updateDevices') + // execute refresh method every minute + schedule("0 0/1 * * * ?", refreshDevices) +} + +// called after settings are changed +def updated() { + log.debug "updated" + initialize() + unschedule('refreshDevices') + schedule("0 0/10 * * * ?", refreshDevices) +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule() + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(devices) { + devices.each { + deleteChildDevice(it.deviceNetworkId) // 'it' is default + } +} + +// called after Done is hit after selecting a Location +def initialize() { + log.debug "initialize" + if (selectedWarmup4IEs) { + addWarmup4IE() + } + + def devices = getChildDevices() + devices.each { + log.debug "Refreshing device $it.name" + it.refresh() + } +} + +def updateDevices() { + if (!state.devices) { + state.devices = [:] + } + def devices = devicesList() + state.warmup4IEDevices = [:] + + def selectors = [] + devices.each { device -> + log.debug "Identified: device ${device.roomId}: ${device.roomName}: ${device.targetTemp}: ${device.currentTemp}: ${device.energy}" + selectors.add("${device.roomId}") + def value = "${device.roomName} Warmup" + def key = device.roomId + state.warmup4IEDevices["${key}"] = value + + //Update names of devices with MiHome + def childDevice = getChildDevice("${device.roomId}") + if (childDevice) { + //Update name of device if different. + if(childDevice.name != device.roomName + " Warmup") { + childDevice.name = device.roomName + " Warmup" + log.debug "Device's name has changed." + } + } + } + log.debug selectors + //Remove devices if does not exist on the Warmup platform + getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { + log.info("Deleting ${it.deviceNetworkId}") + try { + deleteChildDevice(it.deviceNetworkId) + } catch (physicalgraph.exception.NotFoundException e) { + log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.") + } catch (physicalgraph.exception.ConflictException ce) { + log.info("Device ${it.deviceNetworkId} in use. Please manually delete.") + } + } +} + +def updateLocations() { + def locations = locationsList() + state.warmupLocations = [:] + + def selectors = [] + locations.each { location -> + log.debug "Identified: location ${location.id}: ${location.name}" + selectors.add("${location.id}") + def value = "${location.name}" + def key = location.id + state.warmupLocations["${key}"] = value + } + log.debug selectors +} + +def addWarmup4IE() { + updateDevices() + + selectedWarmup4IEs.each { device -> + + def childDevice = getChildDevice("${device}") + if (!childDevice && state.warmup4IEDevices[device] != null) { + log.info("Adding device ${device}: ${state.warmup4IEDevices[device]}") + + def data = [ + name: state.warmup4IEDevices[device], + label: state.warmup4IEDevices[device] + ] + childDevice = addChildDevice(app.namespace, "Warmup 4IE", "$device", null, data) + + log.debug "Created ${state.warmup4IEDevices[device]} with id: ${device}" + } else { + log.debug "found ${state.warmup4IEDevices[device]} with id ${device} already exists" + } + + } +} + +def refreshDevices() { + log.info("Executing refreshDevices...") + getChildDevices().each { device -> + log.info("Refreshing device ${device.name} ...") + device.refresh() + } +} + +def devicesList() { + logErrors([]) { + def body = [ + account: [ + "email": "${username}", + "token" : "${state.warmupAccessToken}" + ], + request: [ + "method" : "getRooms", + "locId" : "${selectedLocation}" + ] + ] + def resp = apiPOST(body) + if (resp.status == 200 && resp.data.status.result == "success") { + return resp.data.response.rooms + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def getStatus(roomId) { + logErrors([]) { + def body = [ + account: [ + "email": "${username}", + "token" : "${state.warmupAccessToken}" + ], + request: [ + "method" : "getRooms", + "locId" : "${selectedLocation}" + ] + ] + def resp = apiPOST(body) + if (resp.status == 200 && resp.data.status.result == "success") { + resp.data.response.rooms.each { room -> + if (room.roomId == roomId) return room + } + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def locationsList() { + logErrors([]) { + def body = [ + account: [ + "email": "${username}", + "token" : "${state.warmupAccessToken}" + ], + request: [ + "method" : "getLocations" + ] + ] + def resp = apiPOST(body) + if (resp.status == 200 && resp.data.status.result == "success") { + return resp.data.response.locations + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def getWarmupAccessToken() { + def body = [ + request: [ + "email": "${username}", + "password" : "${password}", + "method" : "userLogin", + "appId" : "${app.id.toUpperCase()}" + ] + ] + def resp = apiPOST(body) + if (resp.status == 200) { + if (resp.data.status.result == "success" && resp.data.status.result == "success") { + state.warmupAccessToken = resp.data.response.token + log.debug "warmupAccessToken: $resp.data.response.token" + } else { + log.error("Non-200 from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +def apiPOST(body = [:]) { + def bodyString = new groovy.json.JsonBuilder(body).toString() + log.debug("Beginning API POST: ${apiURL()}, ${bodyString}") + try { + httpPost(uri: apiURL(), body: bodyString, headers: apiRequestHeaders() ) { + response -> + logResponse(response) + return response + } + } catch (groovyx.net.http.HttpResponseException e) { + logResponse(e.response) + return e.response + } +} + +def apiPOSTByChild(args = [:]) { + def body = [ + account: [ + "email": "${username}", + "token" : "${state.warmupAccessToken}" + ], + request: args + ] + return apiPOST(body) +} + +def setLocationToFrost() { + def body = [ + account: [ + "email": "${username}", + "token" : "${state.warmupAccessToken}" + ], + request: [ + method: "setModes", values: [holEnd:"-", fixedTemp: "",holStart:"-",geoMode:"0",holTemp:"-",locId:"${selectedLocation}",locMode:"frost"] + ] + ] + return apiPOST(body) +} + + +Map apiRequestHeaders() { + return [ "Host": "api.warmup.com", + "Content-Type": "application/json", + "APP-Token": "M=;He_iyhs+vA\"4lic{6-LqNM:", + "Connection": "keep-alive", + "User-Agent" : "WARMUP_APP", + "Accept-Language": "en-gb", + "Accept-Encoding": "gzip, deflate", + "Accept": "*/*", + "APP-Version": "1.4.2", + + ] +} + +def logResponse(response) { + log.info("Status: ${response.status}") + log.info("Body: ${response.data}") +} + +def logErrors(options = [errorReturn: null, logObject: log], Closure c) { + try { + return c() + } catch (groovyx.net.http.HttpResponseException e) { + options.logObject.error("got error: ${e}, body: ${e.getResponse().getData()}") + if (e.statusCode == 401) { // token is expired + state.remove("warmupAccessToken") + options.logObject.warn "Access token is not valid" + } + return options.errorReturn + } catch (java.net.SocketTimeoutException e) { + options.logObject.warn "Connection timed out, not much we can do here" + return options.errorReturn + } +} + + + +private def textVersion() { + def text = "Warmup (Connect)\nVersion: 1.0 BETA\nDate: 14122016(1500)" +} + +private def textCopyright() { + def text = "Copyright © 2016 Alex Lee Yuk Cheung" +} \ No newline at end of file diff --git a/smartapps/alyc100/warmup-icon.png b/smartapps/alyc100/warmup-icon.png new file mode 100644 index 00000000000..591fa77f8f6 Binary files /dev/null and b/smartapps/alyc100/warmup-icon.png differ diff --git a/smartapps/alyc100/warmup-location.png b/smartapps/alyc100/warmup-location.png new file mode 100644 index 00000000000..6b46d8aabb9 Binary files /dev/null and b/smartapps/alyc100/warmup-location.png differ diff --git a/smartapps/sidjohn1/thinking-cleanerer-neato-botvac-edition.src/thinking-cleanerer-neato-botvac-edition.groovy b/smartapps/sidjohn1/thinking-cleanerer-neato-botvac-edition.src/thinking-cleanerer-neato-botvac-edition.groovy new file mode 100644 index 00000000000..55d2bb23902 --- /dev/null +++ b/smartapps/sidjohn1/thinking-cleanerer-neato-botvac-edition.src/thinking-cleanerer-neato-botvac-edition.groovy @@ -0,0 +1,264 @@ +/** + * Thinking Cleanerer Neato Botvac Edition + * Smartthings SmartApp + * Copyright 2014 Sidney Johnson + * If you like this app, please support the developer via PayPal: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XKDRYZ3RUNR9Y + * + * 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. + * + * Version: 1.0 - Initial Version + * Version: 1.2 - Added error push notifcation, and better icons + * Version: 1.3 - New interface, better polling, and logging. Added sms notifcations + * Version: 1.4 - Added bin full notifcations + * Version: 1.4.1 - Fixed SMS send issue + * Version: 1.4.2 - Fixed No such property: currentSwitch issue, added poll on initialize, locked to single instance + * Version: 1.5 - More robust polling, auto set Smart Home Monitor + * + * Modified by Alex Lee Yuk Cheung for Neato Botvac Devices + * Version: 1.0 - Initial Version - Added Auto Dock On Pause, Force Clean Option, Changes to Polling behaviour + * Version: 1.1 - Fix to setting SHM state when error has occured whilst cleaning + */ + +definition( + name: "Thinking Cleanerer Neato Botvac Edition", + namespace: "sidjohn1", + author: "Sidney Johnson", + description: "Handles polling and job notification for Neato Botvac", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato-icons_1x.png", + iconX2Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato-icons_1x.png", + iconX3Url: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato-icons_1x.png", + singleInstance: true) + +preferences { + page name:"pageInfo" +} + +def pageInfo() { + return dynamicPage(name: "pageInfo", title: "Thinking Cleanerer Neato Botvac Edition", install: true, uninstall: true) { + section("About") { + paragraph "Thinking Cleaner(Botvac) smartapp for Smartthings. This app monitors your Botvac and provides job notifacation" + paragraph "${textVersion()}\n${textCopyright()}" + } + section("Select Botvac(s) to monitor..."){ + input "switch1", "device.NeatoBotvacConnected", title: "Monitored Botvac", required: true, multiple: true, submitOnChange: true + } + def roombaList = "" + settings.switch1.each() { + try { + roombaList += "$it.displayName is $it.currentStatus. Battery is $it.currentBattery%\n" + } + catch (e) { + log.trace "Error checking status." + log.trace e + } + } + if (roombaList) { + section("Botvac Status:") { + paragraph roombaList.trim() + } + } + section("Preferences"){ + input "forceClean", "bool", title: "Force clean after elapsed time?", required: false, defaultValue: false, submitOnChange: true + if (forceClean) { + input ("forceCleanDelay", "number", title: "Number of days before force clean (in days)", required: false, defaultValue: 7) + } + input "autoDock", "bool", title: "Auto dock Botvac after pause?", required: false, defaultValue: true, submitOnChange: true + if (autoDock) { + input ("autoDockDelay", "number", title: "Auto dock delay after pause (in seconds)", required: false, defaultValue: 60) + } + } + section(hideable: true, hidden: true, "Auto Smart Home Monitor..."){ + input "autoSHM", "bool", title: "Auto Set Smart Home Monitor?", required: true, multiple: true, defaultValue: false, submitOnChange: true + paragraph"Auto Set Smart Home Monitor to Arm(Stay) when cleaning and Arm(Away) when done." + } + section(hideable: true, hidden: true, "Event Notifications..."){ + input "sendPush", "bool", title: "Send as Push?", required: false, defaultValue: true + input "sendSMS", "phone", title: "Send as SMS?", required: false, defaultValue: null + input "sendRoombaOn", "bool", title: "Notify when on?", required: false, defaultValue: false + input "sendRoombaOff", "bool", title: "Notify when off?", required: false, defaultValue: false + input "sendRoombaError", "bool", title: "Notify on error?", required: false, defaultValue: true + input "sendRoombaBin", "bool", title: "Notify on full bin?", required: false, defaultValue: true + } + } +} + +def installed() { + log.trace "Installed with settings: ${settings}" + runEvery5Minutes('pollOn') + state.lastClean = [:] + initialize() +} + +def updated() { + log.trace "Updated with settings: ${settings}" + unschedule() + unsubscribe() + runEvery5Minutes('pollOn') + initialize() +} + +def initialize() { + log.info "Thinking Cleanerer Neato Botvac Edition ${textVersion()} ${textCopyright()}" + subscribe(switch1, "status.cleaning", eventHandler) + subscribe(switch1, "status.ready", eventHandler) + subscribe(switch1, "status.error", eventHandler) + subscribe(switch1, "status.paused", eventHandler) + subscribe(switch1, "bin.full", eventHandler) +} + +def uninstalled() { + unschedule() +} + +def eventHandler(evt) { + def msg + if (evt.value == "paused") { + log.trace "Setting auto dock for ${evt.displayName}" + //If configured, set to dock automatically after one minute. + if (autoDock == true) { + runIn(autoDockDelay, scheduleAutoDock) + } + } + else if (evt.value == "error") { + unschedule() + runEvery5Minutes('pollOn') + sendEvent(linkText:app.label, name:"${evt.displayName}", value:"error",descriptionText:"${evt.displayName} has an error", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "${evt.displayName} has an error" + msg = "${evt.displayName} has an error" + if (sendRoombaError == true) { + if (settings.sendSMS != null) { + sendSms(sendSMS, msg) + } + if (settings.sendPush == true) { + sendPush(msg) + } + } + } + else if (evt.value == "cleaning") { + unschedule() + //Increase poll interval during cleaning + schedule("0 0/1 * * * ?", pollOn) + if (state.lastClean == null) { + state.lastClean = [:] + } + //Record last cleaning time for device + state.lastClean[evt.displayName] = now() + sendEvent(linkText:app.label, name:"${evt.displayName}", value:"on",descriptionText:"${evt.displayName} is on", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "${evt.displayName} is on" + msg = "${evt.displayName} is on" + if (sendRoombaOn == true) { + if (settings.sendSMS != null) { + sendSms(sendSMS, msg) + } + if (settings.sendPush == true) { + sendPush(msg) + } + } + if (settings.autoSHM.contains('true') ) { + if (location.currentState("alarmSystemStatus")?.value == "away") { + sendEvent(linkText:app.label, name:"Smart Home Monitor", value:"stay",descriptionText:"Smart Home Monitor was set to stay", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "Smart Home Monitor is set to stay" + sendLocationEvent(name: "alarmSystemStatus", value: "stay") + state.autoSHMchange = "y" + sendPush("Smart Home Monitor is set to stay as ${evt.displayName} is on") + } + } + } + else if (evt.value == "full") { + unschedule('pollOn') + runEvery5Minutes('pollOn') + sendEvent(linkText:app.label, name:"${evt.displayName}", value:"bin full",descriptionText:"${evt.displayName} bin is full", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "${evt.displayName} bin is full" + msg = "${evt.displayName} bin is full" + if (sendRoombaBin == true) { + if (settings.sendSMS != null) { + sendSms(sendSMS, msg) + } + if (settings.sendPush == true) { + sendPush(msg) + } + } + } + else if (evt.value == "ready") { + unschedule() + runEvery5Minutes('pollOn') + sendEvent(linkText:app.label, name:"${evt.displayName}", value:"off",descriptionText:"${evt.displayName} is off", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "${evt.displayName} is off" + msg = "${evt.displayName} is off" + if (sendRoombaOff == true) { + if (settings.sendSMS != null) { + sendSms(sendSMS, msg) + } + if (settings.sendPush == true) { + sendPush(msg) + } + } + } +} + +def scheduleAutoDock() { + settings.switch1.each() { + if (it.latestState('status').stringValue == 'paused') { + it.dock() + } + } +} + +def pollOn() { + log.debug "Executing 'pollOn'" + settings.switch1.each() { + state.pollState = now() + it.poll() + //Force on if last clean was a long time ago + if (it.currentSwitch == "off" && forceClean && state.lastClean != null && state.lastClean[it.displayName] != null) { + def t = now() - state.lastClean[it.displayName] + log.debug "$it.displayName last cleaned at " + state.lastClean[it.displayName] + ". $t milliseconds has elapsed since." + if (t > (forceCleanDelay * 86400000)) { + log.debug "Force clean activated as $t milliseconds has elapsed" + sendPush(it.displayName + " has not cleaned for " + forceCleanDelay + " days. Forcing a clean.") + it.on() + } + } + } + + def activeCleaners = false + + for (cleaner in switch1) { + if (cleaner.latestState('status').stringValue == 'cleaning') { + activeCleaners = true + } + } + + if (!activeCleaners) { + if (settings.autoSHM.contains('true') ) { + if (location.currentState("alarmSystemStatus")?.value == "stay" && state.autoSHMchange == "y"){ + sendEvent(linkText:app.label, name:"Smart Home Monitor", value:"away",descriptionText:"Smart Home Monitor was set back to away", eventType:"SOLUTION_EVENT", displayed: true) + log.trace "Smart Home Monitor is set back to away" + sendLocationEvent(name: "alarmSystemStatus", value: "away") + state.autoSHMchange = "n" + sendPush("Smart Home Monitor is set to away as all cleaners are off") + } + } + } + + //If SHM is disarmed because of external event, then disable auto SHM mode + if (location.currentState("alarmSystemStatus")?.value == "off") { + state.autoSHMchange = "n" + } +} + +private def textVersion() { + def text = "Version 1.5" +} + +private def textCopyright() { + def text = "Copyright © 2015 Sidjohn1. Modified by Alex Lee Yuk Cheung" +} \ No newline at end of file