From: rtrimana Date: Tue, 10 Jul 2018 20:28:04 +0000 (-0700) Subject: Checking in all the SmartThings apps; both official and third-party. X-Git-Url: http://plrg.eecs.uci.edu/git/?p=smartapps.git;a=commitdiff_plain;h=3325c1b0cc49b9fbbc497cb3612f7aeff5263eca;ds=sidebyside Checking in all the SmartThings apps; both official and third-party. --- diff --git a/README.md b/README.md index facac0d..59fbd9d 100644 --- a/README.md +++ b/README.md @@ -1 +1,5 @@ -# smartthings_app \ No newline at end of file +# smartthings_app + +Official and third-party SmartThings applications downloaded/copied from IoTBench. + +https://github.com/IoTBench/IoTBench-test-suite diff --git a/official/alfred-workflow.groovy b/official/alfred-workflow.groovy new file mode 100755 index 0000000..3170ee5 --- /dev/null +++ b/official/alfred-workflow.groovy @@ -0,0 +1,194 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Alfred Workflow + * + * Author: SmartThings + */ + +definition( + name: "Alfred Workflow", + namespace: "vlaminck", + author: "SmartThings", + description: "This SmartApp allows you to interact with the things in your physical graph through Alfred.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/alfred-app.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/alfred-app@2x.png", + oauth: [displayName: "SmartThings Alfred Workflow", displayLink: ""] +) + +preferences { + section("Allow Alfred to Control These Things...") { + input "switches", "capability.switch", title: "Which Switches?", multiple: true, required: false + input "locks", "capability.lock", title: "Which Locks?", multiple: true, required: false + } +} + +mappings { + path("/switches") { + action: [ + GET: "listSwitches", + PUT: "updateSwitches" + ] + } + path("/switches/:id") { + action: [ + GET: "showSwitch", + PUT: "updateSwitch" + ] + } + path("/locks") { + action: [ + GET: "listLocks", + PUT: "updateLocks" + ] + } + path("/locks/:id") { + action: [ + GET: "showLock", + PUT: "updateLock" + ] + } +} + +def installed() {} + +def updated() {} + +def listSwitches() { + switches.collect { device(it,"switch") } +} +void updateSwitches() { + updateAll(switches) +} +def showSwitch() { + show(switches, "switch") +} +void updateSwitch() { + update(switches) +} + +def listLocks() { + locks.collect { device(it, "lock") } +} +void updateLocks() { + updateAll(locks) +} +def showLock() { + show(locks, "lock") +} +void updateLock() { + update(locks) +} + +private void updateAll(devices) { + def command = request.JSON?.command + def type = params.param1 + if (!devices) { + httpError(404, "Devices not found") + } + + if (command){ + devices.each { device -> + executeCommand(device, type, command) + } + } +} + +private void update(devices) { + log.debug "update, request: ${request.JSON}, params: ${params}, devices: $devices.id" + def command = request.JSON?.command + def type = params.param1 + def device = devices?.find { it.id == params.id } + + if (!device) { + httpError(404, "Device not found") + } + + if (command) { + executeCommand(device, type, command) + } +} + +/** + * Validating the command passed by the user based on capability. + * @return boolean + */ +def validateCommand(device, deviceType, command) { + def capabilityCommands = getDeviceCapabilityCommands(device.capabilities) + def currentDeviceCapability = getCapabilityName(deviceType) + if (capabilityCommands[currentDeviceCapability]) { + return command in capabilityCommands[currentDeviceCapability] ? true : false + } else { + // Handling other device types here, which don't accept commands + httpError(400, "Bad request.") + } +} + +/** + * Need to get the attribute name to do the lookup. Only + * doing it for the device types which accept commands + * @return attribute name of the device type + */ +def getCapabilityName(type) { + switch(type) { + case "switches": + return "Switch" + case "locks": + return "Lock" + default: + return type + } +} + +/** + * Constructing the map over here of + * supported commands by device capability + * @return a map of device capability -> supported commands + */ +def getDeviceCapabilityCommands(deviceCapabilities) { + def map = [:] + deviceCapabilities.collect { + map[it.name] = it.commands.collect{ it.name.toString() } + } + return map +} + +/** + * Validates and executes the command + * on the device or devices + */ +def executeCommand(device, type, command) { + if (validateCommand(device, type, command)) { + device."$command"() + } else { + httpError(403, "Access denied. This command is not supported by current capability.") + } +} + +private show(devices, name) { + def device = devices.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } + else { + def s = device.currentState(name) + [id: device.id, label: device.displayName, name: device.displayName, state: s] + } +} + +private device(it, name) { + if (it) { + def s = it.currentState(name) + [id: it.id, label: it.displayName, name: it.displayName, state: s] + } +} diff --git a/official/auto-humidity-vent.groovy b/official/auto-humidity-vent.groovy new file mode 100755 index 0000000..a0d4544 --- /dev/null +++ b/official/auto-humidity-vent.groovy @@ -0,0 +1,277 @@ +/** + * Auto Humidity Vent + * + * Copyright 2014 Jonathan Andersson + * + * 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: "Auto Humidity Vent", + namespace: "jonathan-a", + author: "Jonathan Andersson", + description: "When the humidity reaches a specified level, activate one or more vent fans until the humidity is reduced to a specified level.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartthings-device-icons/Appliances/appliances11-icn.png", + iconX2Url: "https://s3.amazonaws.com/smartthings-device-icons/Appliances/appliances11-icn@2x.png" + +) + + +preferences { + + section("Enable / Disable the following functionality:") { + input "app_enabled", "bool", title: "Auto Humidity Vent", required:true, defaultValue:true + input "fan_control_enabled", "bool", title: "Vent Fan Control", required:true, defaultValue:true + } + + section("Choose a humidity sensor...") { + input "humidity_sensor", "capability.relativeHumidityMeasurement", title: "Humidity Sensor", required: true + } + section("Enter the relative humudity level (%) above which the vent fans will activate:") { + input "humidity_a", "number", title: "Humidity Activation Level", required: true, defaultValue:70 + } + section("Enter the relative humudity level (%) below which the vent fans will deactivate:") { + input "humidity_d", "number", title: "Humidity Deactivation Level", required: true, defaultValue:65 + } + + section("Select the vent fans to control...") { + input "fans", "capability.switch", title: "Vent Fans", multiple: true, required: true + } + + section("Select the vent fan energy meters to monitor...") { + input "emeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false + input "price_kwh", "decimal", title: "Cost in cents per kWh (12 is US avg)", required: true, defaultValue:12 + } + + section("Set notification options:") { + input "sendPushMessage", "bool", title: "Push notifications", required:true, defaultValue:false + input "phone", "phone", title: "Send text messages to", required: false + } + +} + + +def installed() { + + log.debug "${app.label} installed with settings: ${settings}" + + state.app_enabled = false + state.fan_control_enabled = false + + state.fansOn = false + state.fansOnTime = now() + state.fansLastRunTime = 0 + + initialize() + +} + + +def uninstalled() +{ + + send("${app.label} uninstalled.") + + state.app_enabled = false + + set_fans(false) + + state.fan_control_enabled = false + +} + + +def updated() { + + log.debug "${app.label} updated with settings: ${settings}" + + unsubscribe() + + initialize() + +} + + +def initialize() { + + if (settings.fan_control_enabled) { + if(state.fan_control_enabled == false) { + send("Vent Fan Control Enabled.") + } else { + log.debug "Vent Fan Control Enabled." + } + + state.fan_control_enabled = true + } else { + if(state.fan_control_enabled == true) { + send("Vent Fan Control Disabled.") + } else { + log.debug "Vent Fan Control Disabled." + } + + state.fan_control_enabled = false + } + + if (settings.app_enabled) { + if(state.app_enabled == false) { + send("${app.label} Enabled.") + } else { + log.debug "${app.label} Enabled." + } + + subscribe(humidity_sensor, "humidity", "handleThings") + + state.app_enabled = true + } else { + if(state.app_enabled == true) { + send("${app.label} Disabled.") + } else { + log.debug "${app.label} Disabled." + } + + state.app_enabled = false + } + + handleThings() + +} + + +def handleThings(evt) { + + + log.debug "handleThings()" + + if(evt) { + log.debug "$evt.descriptionText" + } + + def h = 0.0 as BigDecimal + if (settings.app_enabled) { + h = settings.humidity_sensor.currentValue('humidity') +/* + //Simulator is broken and requires this work around for testing. + if (settings.humidity_sensor.latestState('humidity')) { + log.debug settings.humidity_sensor.latestState('humidity').stringValue[0..-2] + h = settings.humidity_sensor.latestState('humidity').stringValue[0..-2].toBigDecimal() + } else { + h = 20 + } +*/ + } + + log.debug "Humidity: $h%, Activate: $humidity_a%, Deactivate: $humidity_d%" + + def activateFans = false + def deactivateFans = false + + if (settings.app_enabled) { + + if (state.fansOn) { + if (h > humidity_d) { + log.debug "Humidity not sufficient to deactivate vent fans: $h > $humidity_d" + } else { + log.debug "Humidity sufficient to deactivate vent fans: $h <= $humidity_d" + deactivateFans = true + } + } else { + if (h < humidity_a) { + log.debug "Humidity not sufficient to activate vent fans: $h < $humidity_a" + } else { + log.debug "Humidity sufficient to activate vent fans: $h >= $humidity_a" + activateFans = true + } + } + } + + if(activateFans) { + set_fans(true) + } + if(deactivateFans) { + set_fans(false) + } + +} + + +def set_fans(fan_state) { + + if (fan_state) { + if (state.fansOn == false) { + send("${app.label} fans On.") + state.fansOnTime = now() + if (settings.fan_control_enabled) { + if (emeters) { + emeters.reset() + } + fans.on() + } else { + send("${app.label} fan control is disabled.") + } + state.fansOn = true + } else { + log.debug "${app.label} fans already On." + } + } else { + if (state.fansOn == true) { + send("${app.label} fans Off.") + state.fansLastRunTime = (now() - state.fansOnTime) + + BigInteger ms = new java.math.BigInteger(state.fansLastRunTime) + int seconds = (BigInteger) (((BigInteger) ms / (1000I)) % 60I) + int minutes = (BigInteger) (((BigInteger) ms / (1000I * 60I)) % 60I) + int hours = (BigInteger) (((BigInteger) ms / (1000I * 60I * 60I)) % 24I) + int days = (BigInteger) ((BigInteger) ms / (1000I * 60I * 60I * 24I)) + + def sb = String.format("${app.label} cycle: %d:%02d:%02d:%02d", days, hours, minutes, seconds) + + send(sb) + + if (settings.fan_control_enabled) { + fans.off() + if (emeters) { + log.debug emeters.currentValue('energy') + //TODO: How to ensure latest (most accurate) energy reading? + emeters.poll() //[configure, refresh, on, off, poll, reset] +// emeters.refresh() //[configure, refresh, on, off, poll, reset] + state.fansLastRunEnergy = emeters.currentValue('energy').sum() + state.fansLastRunCost = ((state.fansLastRunEnergy * price_kwh) / 100.0) + send("${app.label} cycle: ${state.fansLastRunEnergy}kWh @ \$${state.fansLastRunCost}") + } + } else { + send("${app.label} fan control is disabled.") + } + state.fansOn = false + state.fansHoldoff = now() + } else { + log.debug "${app.label} fans already Off." + } + } + +} + + +private send(msg) { + + if (sendPushMessage) { + sendPush(msg) + } + + if (phone) { + sendSms(phone, msg) + } + + log.debug(msg) +} + diff --git a/official/beacon-control.groovy b/official/beacon-control.groovy new file mode 100755 index 0000000..007b455 --- /dev/null +++ b/official/beacon-control.groovy @@ -0,0 +1,317 @@ +/** + * Beacon Control + * + * Copyright 2014 Physical Graph Corporation + * + * 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: "Beacon Control", + category: "SmartThings Internal", + namespace: "smartthings", + author: "SmartThings", + description: "Execute a Hello, Home phrase, turn on or off some lights, and/or lock or unlock your door when you enter or leave a monitored region", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/MiscHacking/mindcontrol.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/MiscHacking/mindcontrol@2x.png" +) + +preferences { + page(name: "mainPage") + + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def mainPage() { + dynamicPage(name: "mainPage", install: true, uninstall: true) { + + section("Where do you want to watch?") { + input name: "beacons", type: "capability.beacon", title: "Select your beacon(s)", + multiple: true, required: true + } + + section("Who do you want to watch for?") { + input name: "phones", type: "device.mobilePresence", title: "Select your phone(s)", + multiple: true, required: true + } + + section("What do you want to do on arrival?") { + input name: "arrivalPhrase", type: "enum", title: "Execute a phrase", + options: listPhrases(), required: false + input "arrivalOnSwitches", "capability.switch", title: "Turn on some switches", + multiple: true, required: false + input "arrivalOffSwitches", "capability.switch", title: "Turn off some switches", + multiple: true, required: false + input "arrivalLocks", "capability.lock", title: "Unlock the door", + multiple: true, required: false + } + + section("What do you want to do on departure?") { + input name: "departPhrase", type: "enum", title: "Execute a phrase", + options: listPhrases(), required: false + input "departOnSwitches", "capability.switch", title: "Turn on some switches", + multiple: true, required: false + input "departOffSwitches", "capability.switch", title: "Turn off some switches", + multiple: true, required: false + input "departLocks", "capability.lock", title: "Lock the door", + multiple: true, required: false + } + + section("Do you want to be notified?") { + input "pushNotification", "bool", title: "Send a push notification" + input "phone", "phone", title: "Send a text message", description: "Tap to enter phone number", + required: false + } + + section { + label title: "Give your automation a name", description: "e.g. Goodnight Home, Wake Up" + } + + def timeLabel = timeIntervalLabel() + section(title: "More options", hidden: hideOptionsSection(), hideable: true) { + href "timeIntervalInput", title: "Only during a certain time", + description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + } +} + +// Lifecycle management +def installed() { + log.debug " Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug " Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + subscribe(beacons, "presence", beaconHandler) +} + +// Event handlers +def beaconHandler(evt) { + log.debug " beaconHandler: $evt" + + if (allOk) { + def data = new groovy.json.JsonSlurper().parseText(evt.data) + // removed logging of device names. can be added back for debugging + //log.debug " data: $data - phones: " + phones*.deviceNetworkId + + def beaconName = getBeaconName(evt) + // removed logging of device names. can be added back for debugging + //log.debug " beaconName: $beaconName" + + def phoneName = getPhoneName(data) + // removed logging of device names. can be added back for debugging + //log.debug " phoneName: $phoneName" + if (phoneName != null) { + def action = data.presence == "1" ? "arrived" : "left" + def msg = "$phoneName has $action ${action == 'arrived' ? 'at ' : ''}the $beaconName" + + if (action == "arrived") { + msg = arriveActions(msg) + } + else if (action == "left") { + msg = departActions(msg) + } + log.debug " msg: $msg" + + if (pushNotification || phone) { + def options = [ + method: (pushNotification && phone) ? "both" : (pushNotification ? "push" : "sms"), + phone: phone + ] + sendNotification(msg, options) + } + } + } +} + +// Helpers +private arriveActions(msg) { + if (arrivalPhrase || arrivalOnSwitches || arrivalOffSwitches || arrivalLocks) msg += ", so" + + if (arrivalPhrase) { + log.debug " executing: $arrivalPhrase" + executePhrase(arrivalPhrase) + msg += " ${prefix('executed')} $arrivalPhrase." + } + if (arrivalOnSwitches) { + log.debug " turning on: $arrivalOnSwitches" + arrivalOnSwitches.on() + msg += " ${prefix('turned')} ${list(arrivalOnSwitches)} on." + } + if (arrivalOffSwitches) { + log.debug " turning off: $arrivalOffSwitches" + arrivalOffSwitches.off() + msg += " ${prefix('turned')} ${list(arrivalOffSwitches)} off." + } + if (arrivalLocks) { + log.debug " unlocking: $arrivalLocks" + arrivalLocks.unlock() + msg += " ${prefix('unlocked')} ${list(arrivalLocks)}." + } + msg +} + +private departActions(msg) { + if (departPhrase || departOnSwitches || departOffSwitches || departLocks) msg += ", so" + + if (departPhrase) { + log.debug " executing: $departPhrase" + executePhrase(departPhrase) + msg += " ${prefix('executed')} $departPhrase." + } + if (departOnSwitches) { + log.debug " turning on: $departOnSwitches" + departOnSwitches.on() + msg += " ${prefix('turned')} ${list(departOnSwitches)} on." + } + if (departOffSwitches) { + log.debug " turning off: $departOffSwitches" + departOffSwitches.off() + msg += " ${prefix('turned')} ${list(departOffSwitches)} off." + } + if (departLocks) { + log.debug " unlocking: $departLocks" + departLocks.lock() + msg += " ${prefix('locked')} ${list(departLocks)}." + } + msg +} + +private prefix(word) { + def result + def index = settings.prefixIndex == null ? 0 : settings.prefixIndex + 1 + switch (index) { + case 0: + result = "I $word" + break + case 1: + result = "I also $word" + break + case 2: + result = "And I $word" + break + default: + result = "And $word" + break + } + + settings.prefixIndex = index + log.trace "prefix($word'): $result" + result +} + +private listPhrases() { + location.helloHome.getPhrases().label +} + +private executePhrase(phraseName) { + if (phraseName) { + location.helloHome.execute(phraseName) + log.debug " executed phrase: $phraseName" + } +} + +private getBeaconName(evt) { + def beaconName = beacons.find { b -> b.id == evt.deviceId } + return beaconName +} + +private getPhoneName(data) { + def phoneName = phones.find { phone -> + // Work around DNI bug in data + def pParts = phone.deviceNetworkId.split('\\|') + def dParts = data.dni.split('\\|') + pParts[0] == dParts[0] + } + return phoneName +} + +private hideOptionsSection() { + (starting || ending || days || modes) ? false : true +} + +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace " modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private timeIntervalLabel() { + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} + +private list(List names) { + switch (names.size()) { + case 0: + return null + case 1: + return names[0] + case 2: + return "${names[0]} and ${names[1]}" + default: + return "${names[0..-2].join(', ')}, and ${names[-1]}" + } +} diff --git a/official/beaconthings-manager.groovy b/official/beaconthings-manager.groovy new file mode 100755 index 0000000..3254f32 --- /dev/null +++ b/official/beaconthings-manager.groovy @@ -0,0 +1,147 @@ +/** + * BeaconThing Manager + * + * Copyright 2015 obycode + * + * 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: "BeaconThings Manager", + namespace: "com.obycode", + author: "obycode", + description: "SmartApp to interact with the BeaconThings iOS app. Use this app to integrate iBeacons into your smart home.", + category: "Convenience", + iconUrl: "http://beaconthingsapp.com/images/Icon-60.png", + iconX2Url: "http://beaconthingsapp.com/images/Icon-60@2x.png", + iconX3Url: "http://beaconthingsapp.com/images/Icon-60@3x.png", + oauth: true) + + +preferences { + section("Allow BeaconThings to talk to your home") { + + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def initialize() { +} + +def uninstalled() { + removeChildDevices(getChildDevices()) +} + +mappings { + path("/beacons") { + action: [ + DELETE: "clearBeacons", + POST: "addBeacon" + ] + } + + path("/beacons/:id") { + action: [ + PUT: "updateBeacon", + DELETE: "deleteBeacon" + ] + } +} + +void clearBeacons() { + removeChildDevices(getChildDevices()) +} + +void addBeacon() { + def beacon = request.JSON?.beacon + if (beacon) { + def beaconId = "BeaconThings" + if (beacon.major) { + beaconId = "$beaconId-${beacon.major}" + if (beacon.minor) { + beaconId = "$beaconId-${beacon.minor}" + } + } + log.debug "adding beacon $beaconId" + def d = addChildDevice("com.obycode", "BeaconThing", beaconId, null, [label:beacon.name, name:"BeaconThing", completedSetup: true]) + log.debug "addChildDevice returned $d" + + if (beacon.present) { + d.arrive(beacon.present) + } + else if (beacon.presence) { + d.setPresence(beacon.presence) + } + } +} + +void updateBeacon() { + log.debug "updating beacon ${params.id}" + def beaconDevice = getChildDevice(params.id) + // def children = getChildDevices() + // def beaconDevice = children.find{ d -> d.deviceNetworkId == "${params.id}" } + if (!beaconDevice) { + log.debug "Beacon not found directly" + def children = getChildDevices() + beaconDevice = children.find{ d -> d.deviceNetworkId == "${params.id}" } + if (!beaconDevice) { + log.debug "Beacon not found in list either" + return + } + } + + // This could be just updating the presence + def presence = request.JSON?.presence + if (presence) { + log.debug "Setting ${beaconDevice.label} to $presence" + beaconDevice.setPresence(presence) + } + + // It could be someone arriving + def arrived = request.JSON?.arrived + if (arrived) { + log.debug "$arrived arrived at ${beaconDevice.label}" + beaconDevice.arrived(arrived) + } + + // It could be someone left + def left = request.JSON?.left + if (left) { + log.debug "$left left ${beaconDevice.label}" + beaconDevice.left(left) + } + + // or it could be updating the name + def beacon = request.JSON?.beacon + if (beacon) { + beaconDevice.label = beacon.name + } +} + +void deleteBeacon() { + log.debug "deleting beacon ${params.id}" + deleteChildDevice(params.id) + // def children = getChildDevices() + // def beaconDevice = children.find{ d -> d.deviceNetworkId == "${params.id}" } + // if (beaconDevice) { + // deleteChildDevice(beaconDevice.deviceNetworkId) + // } +} + +private removeChildDevices(delete) { + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} diff --git a/official/big-turn-off.groovy b/official/big-turn-off.groovy new file mode 100755 index 0000000..f5fae99 --- /dev/null +++ b/official/big-turn-off.groovy @@ -0,0 +1,54 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Big Turn OFF + * + * Author: SmartThings + */ +definition( + name: "Big Turn OFF", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights off when the SmartApp is tapped or activated", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" +) + +preferences { + section("When I touch the app, turn off...") { + input "switches", "capability.switch", multiple: true + } +} + +def installed() +{ + subscribe(location, changedLocationMode) + subscribe(app, appTouch) +} + +def updated() +{ + unsubscribe() + subscribe(location, changedLocationMode) + subscribe(app, appTouch) +} + +def changedLocationMode(evt) { + log.debug "changedLocationMode: $evt" + switches?.off() +} + +def appTouch(evt) { + log.debug "appTouch: $evt" + switches?.off() +} diff --git a/official/big-turn-on.groovy b/official/big-turn-on.groovy new file mode 100755 index 0000000..f395ab3 --- /dev/null +++ b/official/big-turn-on.groovy @@ -0,0 +1,55 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Big Turn ON + * + * Author: SmartThings + */ + +definition( + name: "Big Turn ON", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights on when the SmartApp is tapped or activated.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" +) + +preferences { + section("When I touch the app, turn on...") { + input "switches", "capability.switch", multiple: true + } +} + +def installed() +{ + subscribe(location, changedLocationMode) + subscribe(app, appTouch) +} + +def updated() +{ + unsubscribe() + subscribe(location, changedLocationMode) + subscribe(app, appTouch) +} + +def changedLocationMode(evt) { + log.debug "changedLocationMode: $evt" + switches?.on() +} + +def appTouch(evt) { + log.debug "appTouch: $evt" + switches?.on() +} diff --git a/official/bon-voyage.groovy b/official/bon-voyage.groovy new file mode 100755 index 0000000..cb9593d --- /dev/null +++ b/official/bon-voyage.groovy @@ -0,0 +1,147 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Bon Voyage + * + * Author: SmartThings + * Date: 2013-03-07 + * + * Monitors a set of presence detectors and triggers a mode change when everyone has left. + */ + +definition( + name: "Bon Voyage", + namespace: "smartthings", + author: "SmartThings", + description: "Monitors a set of SmartSense Presence tags or smartphones and triggers a mode change when everyone has left. Used in conjunction with Big Turn Off or Make It So to turn off lights, appliances, adjust the thermostat, turn on security apps, and more.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld@2x.png" +) + +preferences { + section("When all of these people leave home") { + input "people", "capability.presenceSensor", multiple: true + } + section("Change to this mode") { + input "newMode", "mode", title: "Mode?" + } + section("False alarm threshold (defaults to 10 min)") { + input "falseAlarmThreshold", "decimal", title: "Number of minutes", required: false + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to", required: false) { + input "sendPushMessage", "enum", title: "Send a push notification?", options: ["Yes", "No"], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + // commented out log statement because presence sensor label could contain user's name + //log.debug "Current mode = ${location.mode}, people = ${people.collect{it.label + ': ' + it.currentPresence}}" + subscribe(people, "presence", presence) +} + +def updated() { + log.debug "Updated with settings: ${settings}" + // commented out log statement because presence sensor label could contain user's name + //log.debug "Current mode = ${location.mode}, people = ${people.collect{it.label + ': ' + it.currentPresence}}" + unsubscribe() + subscribe(people, "presence", presence) +} + +def presence(evt) +{ + log.debug "evt.name: $evt.value" + if (evt.value == "not present") { + if (location.mode != newMode) { + log.debug "checking if everyone is away" + if (everyoneIsAway()) { + log.debug "starting sequence" + runIn(findFalseAlarmThreshold() * 60, "takeAction", [overwrite: false]) + } + } + else { + log.debug "mode is the same, not evaluating" + } + } + else { + log.debug "present; doing nothing" + } +} + +def takeAction() +{ + if (everyoneIsAway()) { + def threshold = 1000 * 60 * findFalseAlarmThreshold() - 1000 + def awayLongEnough = people.findAll { person -> + def presenceState = person.currentState("presence") + if (!presenceState) { + // This device has yet to check in and has no presence state, treat it as not away long enough + return false + } + def elapsed = now() - presenceState.rawDateCreated.time + elapsed >= threshold + } + log.debug "Found ${awayLongEnough.size()} out of ${people.size()} person(s) who were away long enough" + if (awayLongEnough.size() == people.size()) { + // TODO -- uncomment when app label is available + def message = "SmartThings changed your mode to '${newMode}' because everyone left home" + log.info message + send(message) + setLocationMode(newMode) + } else { + log.debug "not everyone has been away long enough; doing nothing" + } + } else { + log.debug "not everyone is away; doing nothing" + } +} + +private everyoneIsAway() +{ + def result = true + for (person in people) { + if (person.currentPresence == "present") { + result = false + break + } + } + log.debug "everyoneIsAway: $result" + return result +} + +private send(msg) { + if (location.contactBookEnabled) { + log.debug("sending notifications to: ${recipients?.size()}") + sendNotificationToContacts(msg, recipients) + } + else { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phone) { + log.debug("sending text message") + sendSms(phone, msg) + } + } + log.debug msg +} + +private findFalseAlarmThreshold() { + (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold : 10 +} diff --git a/official/bose-soundtouch-connect.groovy b/official/bose-soundtouch-connect.groovy new file mode 100755 index 0000000..883c6e8 --- /dev/null +++ b/official/bose-soundtouch-connect.groovy @@ -0,0 +1,609 @@ +/** + * Bose SoundTouch (Connect) + * + * Copyright 2015 SmartThings + * + * 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: "Bose SoundTouch (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Control your Bose SoundTouch speakers", + category: "SmartThings Labs", + iconUrl: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon.png", + iconX2Url: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon@2x.png", + iconX3Url: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon@2x-1.png", + singleInstance: true +) + +preferences { + page(name:"deviceDiscovery", title:"Device Setup", content:"deviceDiscovery", refreshTimeout:5) +} + +/** + * Get the urn that we're looking for + * + * @return URN which we are looking for + * + * @todo This + getUSNQualifier should be one and should use regular expressions + */ +def getDeviceType() { + return "urn:schemas-upnp-org:device:MediaRenderer:1" // Bose +} + +/** + * If not null, returns an additional qualifier for ssdUSN + * to avoid spamming the network + * + * @return Additional qualifier OR null if not needed + */ +def getUSNQualifier() { + return "uuid:BO5EBO5E-F00D-F00D-FEED-" +} + +/** + * Get the name of the new device to instantiate in the user's smartapps + * This must be an app owned by the namespace (see #getNameSpace). + * + * @return name + */ +def getDeviceName() { + return "Bose SoundTouch" +} + +/** + * Returns the namespace this app and siblings use + * + * @return namespace + */ +def getNameSpace() { + return "smartthings" +} + +/** + * The deviceDiscovery page used by preferences. Will automatically + * make calls to the underlying discovery mechanisms as well as update + * whenever new devices are discovered AND verified. + * + * @return a dynamicPage() object + */ +def deviceDiscovery() +{ + if(canInstallLabs()) + { + def refreshInterval = 3 // Number of seconds between refresh + int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int + state.deviceRefreshCount = deviceRefreshCount + refreshInterval + + def devices = getSelectableDevice() + def numFound = devices.size() ?: 0 + + // Make sure we get location updates (contains LAN data such as SSDP results, etc) + subscribeNetworkEvents() + + //device discovery request every 15s + if((deviceRefreshCount % 15) == 0) { + discoverDevices() + } + + // Verify request every 3 seconds except on discoveries + if(((deviceRefreshCount % 3) == 0) && ((deviceRefreshCount % 15) != 0)) { + verifyDevices() + } + + log.trace "Discovered devices: ${devices}" + + return dynamicPage(name:"deviceDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) { + section("Please wait while we discover your ${getDeviceName()}. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selecteddevice", "enum", required:false, title:"Select ${getDeviceName()} (${numFound} found)", multiple:true, options:devices, submitOnChange: true + } + } + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"deviceDiscovery", title:"Upgrade needed!", nextPage:"", install:true, uninstall: true) { + section("Upgrade") { + paragraph "$upgradeNeeded" + } + } + } +} + +/** + * Called by SmartThings Cloud when user has selected device(s) and + * pressed "Install". + */ +def installed() { + log.trace "Installed with settings: ${settings}" + initialize() +} + +/** + * Called by SmartThings Cloud when app has been updated + */ +def updated() { + log.trace "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +/** + * Called by SmartThings Cloud when user uninstalls the app + * + * We don't need to manually do anything here because any children + * are automatically removed upon the removal of the parent. + * + * Only time to do anything here is when you need to notify + * the remote end. And even then you're discouraged from removing + * the children manually. + */ +def uninstalled() { +} + +/** + * If user has selected devices, will start monitoring devices + * for changes (new address, port, etc...) + */ +def initialize() { + log.trace "initialize()" + state.subscribe = false + if (selecteddevice) { + addDevice() + refreshDevices() + subscribeNetworkEvents(true) + } +} + +/** + * Adds the child devices based on the user's selection + * + * Uses selecteddevice defined in the deviceDiscovery() page + */ +def addDevice(){ + def devices = getVerifiedDevices() + def devlist + log.trace "Adding childs" + + // If only one device is selected, we don't get a list (when using simulator) + if (!(selecteddevice instanceof List)) { + devlist = [selecteddevice] + } else { + devlist = selecteddevice + } + + log.trace "These are being installed: ${devlist}" + + devlist.each { dni -> + def d = getChildDevice(dni) + if(!d) { + def newDevice = devices.find { (it.value.mac) == dni } + def deviceName = newDevice?.value.name + if (!deviceName) + deviceName = getDeviceName() + "[${newDevice?.value.name}]" + d = addChildDevice(getNameSpace(), getDeviceName(), dni, newDevice?.value.hub, [label:"${deviceName}"]) + d.boseSetDeviceID(newDevice.value.deviceID) + log.trace "Created ${d.displayName} with id $dni" + // sync DTH with device, done here as it currently don't work from the DTH's installed() method + d.refresh() + } else { + log.trace "${d.displayName} with id $dni already exists" + } + } +} + +/** + * Resolves a DeviceNetworkId to an address. Primarily used by children + * + * @param dni Device Network id + * @return address or null + */ +def resolveDNI2Address(dni) { + def device = getVerifiedDevices().find { (it.value.mac) == dni } + if (device) { + return convertHexToIP(device.value.networkAddress) + } + return null +} + +/** + * Joins a child to the "Play Everywhere" zone + * + * @param child The speaker joining the zone + * @return A list of maps with POST data + */ +def boseZoneJoin(child) { + log = child.log // So we can debug this function + + def results = [] + def result = [:] + + // Find the master (if any) + def server = getChildDevices().find{ it.boseGetZone() == "server" } + + if (server) { + log.debug "boseJoinZone() We have a server already, so lets add the new speaker" + child.boseSetZone("client") + + result['endpoint'] = "/setZone" + result['host'] = server.getDeviceIP() + ":8090" + result['body'] = "" + getChildDevices().each{ it -> + log.trace "child: " + child + log.trace "zone : " + it.boseGetZone() + if (it.boseGetZone() || it.boseGetDeviceID() == child.boseGetDeviceID()) + result['body'] = result['body'] + "${it.boseGetDeviceID()}" + } + result['body'] = result['body'] + '' + } else { + log.debug "boseJoinZone() No server, add it!" + result['endpoint'] = "/setZone" + result['host'] = child.getDeviceIP() + ":8090" + result['body'] = "" + result['body'] = result['body'] + "${child.boseGetDeviceID()}" + result['body'] = result['body'] + '' + child.boseSetZone("server") + } + results << result + return results +} + +def boseZoneReset() { + getChildDevices().each{ it.boseSetZone(null) } +} + +def boseZoneHasMaster() { + return getChildDevices().find{ it.boseGetZone() == "server" } != null +} + +/** + * Removes a speaker from the play everywhere zone. + * + * @param child Which speaker is leaving + * @return a list of maps with POST data + */ +def boseZoneLeave(child) { + log = child.log // So we can debug this function + + def results = [] + def result = [:] + + // First, tag us as a non-member + child.boseSetZone(null) + + // Find the master (if any) + def server = getChildDevices().find{ it.boseGetZone() == "server" } + + if (server && server.boseGetDeviceID() != child.boseGetDeviceID()) { + log.debug "boseLeaveZone() We have a server, so tell him we're leaving" + result['endpoint'] = "/removeZoneSlave" + result['host'] = server.getDeviceIP() + ":8090" + result['body'] = "" + result['body'] = result['body'] + "${child.boseGetDeviceID()}" + result['body'] = result['body'] + '' + results << result + } else { + log.debug "boseLeaveZone() No server, then...uhm, we probably were it!" + // Dismantle the entire thing, first send this to master + result['endpoint'] = "/removeZoneSlave" + result['host'] = child.getDeviceIP() + ":8090" + result['body'] = "" + getChildDevices().each{ dev -> + if (dev.boseGetZone() || dev.boseGetDeviceID() == child.boseGetDeviceID()) + result['body'] = result['body'] + "${dev.boseGetDeviceID()}" + } + result['body'] = result['body'] + '' + results << result + + // Also issue this to each individual client + getChildDevices().each{ dev -> + if (dev.boseGetZone() && dev.boseGetDeviceID() != child.boseGetDeviceID()) { + log.trace "Additional device: " + dev + result['host'] = dev.getDeviceIP() + ":8090" + results << result + } + } + } + + return results +} + +/** + * Define our XML parsers + * + * @return mapping of root-node <-> parser function + */ +def getParsers() { + [ + "root" : "parseDESC", + "info" : "parseINFO" + ] +} + +/** + * Called when location has changed, contains information from + * network transactions. See deviceDiscovery() for where it is + * registered. + * + * @param evt Holds event information + */ +def onLocation(evt) { + // Convert the event into something we can use + def lanEvent = parseLanMessage(evt.description, true) + lanEvent << ["hub":evt?.hubId] + + // Determine what we need to do... + if (lanEvent?.ssdpTerm?.contains(getDeviceType()) && + (getUSNQualifier() == null || + lanEvent?.ssdpUSN?.contains(getUSNQualifier()) + ) + ) + { + parseSSDP(lanEvent) + } + else if ( + lanEvent.headers && lanEvent.body && + lanEvent.headers."content-type"?.contains("xml") + ) + { + def parsers = getParsers() + def xmlData = new XmlSlurper().parseText(lanEvent.body) + + // Let each parser take a stab at it + parsers.each { node,func -> + if (xmlData.name() == node) + "$func"(xmlData) + } + } +} + +/** + * Handles SSDP description file. + * + * @param xmlData + */ +private def parseDESC(xmlData) { + log.info "parseDESC()" + + def devicetype = getDeviceType().toLowerCase() + def devicetxml = body.device.deviceType.text().toLowerCase() + + // Make sure it's the type we want + if (devicetxml == devicetype) { + def devices = getDevices() + def device = devices.find {it?.key?.contains(xmlData?.device?.UDN?.text())} + if (device && !device.value?.verified) { + // Unlike regular DESC, we cannot trust this just yet, parseINFO() decides all + device.value << [name:xmlData?.device?.friendlyName?.text(),model:xmlData?.device?.modelName?.text(), serialNumber:xmlData?.device?.serialNum?.text()] + } else { + log.error "parseDESC(): The xml file returned a device that didn't exist" + } + } +} + +/** + * Handle BOSE result. This is an alternative to + * using the SSDP description standard. Some of the speakers do + * not support SSDP description, so we need this as well. + * + * @param xmlData + */ +private def parseINFO(xmlData) { + log.info "parseINFO()" + def devicetype = getDeviceType().toLowerCase() + + def deviceID = xmlData.attributes()['deviceID'] + def device = getDevices().find {it?.key?.contains(deviceID)} + if (device && !device.value?.verified) { + device.value << [name:xmlData?.name?.text(),model:xmlData?.type?.text(), serialNumber:xmlData?.serialNumber?.text(), "deviceID":deviceID, verified: true] + } +} + +/** + * Handles SSDP discovery messages and adds them to the list + * of discovered devices. If it already exists, it will update + * the port and location (in case it was moved). + * + * @param lanEvent + */ +def parseSSDP(lanEvent) { + //SSDP DISCOVERY EVENTS + def USN = lanEvent.ssdpUSN.toString() + def devices = getDevices() + + if (!(devices."${USN}")) { + //device does not exist + log.trace "parseSDDP() Adding Device \"${USN}\" to known list" + devices << ["${USN}":lanEvent] + } else { + // update the values + def d = devices."${USN}" + if (d.networkAddress != lanEvent.networkAddress || d.deviceAddress != lanEvent.deviceAddress) { + log.trace "parseSSDP() Updating device location (ip & port)" + d.networkAddress = lanEvent.networkAddress + d.deviceAddress = lanEvent.deviceAddress + } + } +} + +/** + * Generates a Map object which can be used with a preference page + * to represent a list of devices detected and verified. + * + * @return Map with zero or more devices + */ +Map getSelectableDevice() { + def devices = getVerifiedDevices() + def map = [:] + devices.each { + def value = "${it.value.name}" + def key = it.value.mac + map["${key}"] = value + } + map +} + +/** + * Starts the refresh loop, making sure to keep us up-to-date with changes + * + */ +private refreshDevices() { + discoverDevices() + verifyDevices() + runIn(300, "refreshDevices") +} + +/** + * Starts a subscription for network events + * + * @param force If true, will unsubscribe and subscribe if necessary (Optional, default false) + */ +private subscribeNetworkEvents(force=false) { + if (force) { + unsubscribe() + state.subscribe = false + } + + if(!state.subscribe) { + subscribe(location, null, onLocation, [filterEvents:false]) + state.subscribe = true + } +} + +/** + * Issues a SSDP M-SEARCH over the LAN for a specific type (see getDeviceType()) + */ +private discoverDevices() { + log.trace "discoverDevice() Issuing SSDP request" + sendHubCommand(new physicalgraph.device.HubAction("lan discovery ${getDeviceType()}", physicalgraph.device.Protocol.LAN)) +} + +/** + * Walks through the list of unverified devices and issues a verification + * request for each of them (basically calling verifyDevice() per unverified) + */ +private verifyDevices() { + def devices = getDevices().findAll { it?.value?.verified != true } + + devices.each { + verifyDevice( + it?.value?.mac, + convertHexToIP(it?.value?.networkAddress), + convertHexToInt(it?.value?.deviceAddress), + it?.value?.ssdpPath + ) + } +} + +/** + * Verify the device, in this case, we need to obtain the info block which + * holds information such as the actual mac to use in certain scenarios. + * + * Without this mac (henceforth referred to as deviceID), we can't do multi-speaker + * functions. + * + * @param deviceNetworkId The DNI of the device + * @param ip The address of the device on the network (not the same as DNI) + * @param port The port to use (0 will be treated as invalid and will use 80) + * @param devicessdpPath The URL path (for example, /desc) + * + * @note Result is captured in locationHandler() + */ +private verifyDevice(String deviceNetworkId, String ip, int port, String devicessdpPath) { + if(ip) { + def address = ip + ":8090" + sendHubCommand(new physicalgraph.device.HubAction([ + method: "GET", + path: "/info", + headers: [ + HOST: address, + ]])) + } else { + log.warn("verifyDevice() IP address was empty") + } +} + +/** + * Returns an array of devices which have been verified + * + * @return array of verified devices + */ +def getVerifiedDevices() { + getDevices().findAll{ it?.value?.verified == true } +} + +/** + * Returns all discovered devices or an empty array if none + * + * @return array of devices + */ +def getDevices() { + state.devices = state.devices ?: [:] +} + +/** + * Converts a hexadecimal string to an integer + * + * @param hex The string with a hexadecimal value + * @return An integer + */ +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +/** + * Converts an IP address represented as 0xAABBCCDD to AAA.BBB.CCC.DDD + * + * @param hex Address represented in hex + * @return String containing normal IPv4 dot notation + */ +private String convertHexToIP(hex) { + if (hex) + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") + else + hex +} + +/** + * Tests if this setup can support SmarthThing Labs items + * + * @return true if it supports it. + */ +private Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +/** + * Tests if the firmwares on all hubs owned by user match or exceed the + * provided version number. + * + * @param desiredFirmware The version that must match or exceed + * @return true if hub has same or newer + */ +private Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +/** + * Creates a list of firmware version for every hub the user has + * + * @return List of firmwares + */ +private List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} \ No newline at end of file diff --git a/official/bose-soundtouch-control.groovy b/official/bose-soundtouch-control.groovy new file mode 100755 index 0000000..442200b --- /dev/null +++ b/official/bose-soundtouch-control.groovy @@ -0,0 +1,341 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Bose® SoundTouch® Control + * + * Author: SmartThings & Joe Geiger + * + * Date: 2015-30-09 + */ +definition( + name: "Bose® SoundTouch® Control", + namespace: "smartthings", + author: "SmartThings & Joe Geiger", + description: "Control your Bose® SoundTouch® when certain actions take place in your home.", + category: "SmartThings Labs", + iconUrl: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon.png", + iconX2Url: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon@2x.png", + iconX3Url: "https://d3azp77rte0gip.cloudfront.net/smartapps/fcf1d93a-ba0b-4324-b96f-e5b5487dfaf5/images/BoseST_icon@2x-1.png" +) + +preferences { + page(name: "mainPage", title: "Control your Bose® SoundTouch® when something happens", install: true, uninstall: true) + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def mainPage() { + dynamicPage(name: "mainPage") { + def anythingSet = anythingSet() + if (anythingSet) { + section("When..."){ + ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + } + section(anythingSet ? "Select additional triggers" : "When...", hideable: anythingSet, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + section("Perform this action"){ + input "actionType", "enum", title: "Action?", required: true, defaultValue: "play", options: [ + "Turn On & Play", + "Turn Off", + "Toggle Play/Pause", + "Skip to Next Track", + "Skip to Beginning/Previous Track", + "Play Preset 1", + "Play Preset 2", + "Play Preset 3", + "Play Preset 4", + "Play Preset 5", + "Play Preset 6" + ] + } + section { + input "bose", "capability.musicPlayer", title: "Bose® SoundTouch® music player", required: true + } + section("More options", hideable: true, hidden: true) { + input "volume", "number", title: "Set the volume volume", description: "0-100%", required: false + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + if (settings.modes) { + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)" + } + } +} + +private anythingSet() { + for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","triggerModes","timeOfDay"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace "subscribeToEvents()" + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } +} + +def eventHandler(evt) { + if (allOk) { + def lastTime = state[frequencyKey(evt)] + if (oncePerDayOk(lastTime)) { + if (frequency) { + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + else { + log.debug "Not taking action because $frequency minutes have not elapsed since last action" + } + } + else { + takeAction(evt) + } + } + else { + log.debug "Not taking action because it was already taken today" + } + } +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + takeAction(evt) +} + +private takeAction(evt) { + log.debug "takeAction($actionType)" + def options = [:] + if (volume) { + bose.setLevel(volume as Integer) + options.delay = 1000 + } + + switch (actionType) { + case "Turn On & Play": + options ? bose.on(options) : bose.on() + break + case "Turn Off": + options ? bose.off(options) : bose.off() + break + case "Toggle Play/Pause": + def currentStatus = bose.currentValue("playpause") + if (currentStatus == "play") { + options ? bose.pause(options) : bose.pause() + } + else if (currentStatus == "pause") { + options ? bose.play(options) : bose.play() + } + break + case "Skip to Next Track": + options ? bose.nextTrack(options) : bose.nextTrack() + break + case "Skip to Beginning/Previous Track": + options ? bose.previousTrack(options) : bose.previousTrack() + break + case "Play Preset 1": + options ? bose.preset1(options) : bose.preset1() + break + case "Play Preset 2": + options ? bose.preset2(options) : bose.preset2() + break + case "Play Preset 3": + options ? bose.preset3(options) : bose.preset3() + break + case "Play Preset 4": + options ? bose.preset4(options) : bose.preset4() + break + case "Play Preset 5": + options ? bose.preset5(options) : bose.preset5() + break + case "Play Preset 6": + options ? bose.preset6(options) : bose.preset6() + break + default: + log.error "Action type '$actionType' not defined" + } + + if (frequency) { + state.lastActionTimeStamp = now() + } +} + +private frequencyKey(evt) { + //evt.deviceId ?: evt.value + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = true + if (oncePerDay) { + result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + } + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private timeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} +// TODO - End Centralize diff --git a/official/bright-when-dark-and-or-bright-after-sunset.groovy b/official/bright-when-dark-and-or-bright-after-sunset.groovy new file mode 100755 index 0000000..79764a8 --- /dev/null +++ b/official/bright-when-dark-and-or-bright-after-sunset.groovy @@ -0,0 +1,762 @@ +definition( + name: "Bright When Dark And/Or Bright After Sunset", + namespace: "Arno", + author: "Arnaud", + description: "Turn ON light(s) and/or dimmer(s) when there's movement and the room is dark with illuminance threshold and/or between sunset and sunrise. Then turn OFF after X minute(s) when the brightness of the room is above the illuminance threshold or turn OFF after X minute(s) when there is no movement.", + category: "Convenience", + iconUrl: "http://neiloseman.com/wp-content/uploads/2013/08/stockvault-bulb128619.jpg", + iconX2Url: "http://neiloseman.com/wp-content/uploads/2013/08/stockvault-bulb128619.jpg" +) + +preferences +{ + page(name: "configurations") + page(name: "options") + + page(name: "timeIntervalInput", title: "Only during a certain time...") + { + section + { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def configurations() +{ + dynamicPage(name: "configurations", title: "Configurations...", uninstall: true, nextPage: "options") + { + section(title: "Turn ON lights on movement when...") + { + input "dark", "bool", title: "It is dark?", required: true + input "sun", "bool", title: "Between sunset and surise?", required: true + } + section(title: "More options...", hidden: hideOptionsSection(), hideable: true) + { + def timeLabel = timeIntervalLabel() + href "timeIntervalInput", title: "Only during a certain time:", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : null + input "days", "enum", title: "Only on certain days of the week:", multiple: true, required: false, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "modes", "mode", title: "Only when mode is:", multiple: true, required: false + } + section ("Assign a name") + { + label title: "Assign a name", required: false + } + } +} + +def options() +{ + if (dark == true && sun == true) + { + dynamicPage(name: "options", title: "Lights will turn ON on movement when it is dark and between sunset and sunrise...", install: true, uninstall: true) + { + section("Control these light(s)...") + { + input "lights", "capability.switch", title: "Light(s)?", multiple: true, required: false + } + section("Control these dimmer(s)...") + { + input "dimmers", "capability.switchLevel", title: "Dimmer(s)?", multiple: true, required:false + input "level", "number", title: "How bright?", required:false, description: "0% to 100%" + } + section("Turning ON when it's dark and there's movement...") + { + input "motionSensor", "capability.motionSensor", title: "Where?", multiple: true, required: true + } + section("And then OFF when it's light or there's been no movement for...") + { + input "delayMinutes", "number", title: "Minutes?", required: false + } + section("Using this light sensor...") + { + input "lightSensor", "capability.illuminanceMeasurement",title: "Light Sensor?", multiple: false, required: true + input "luxLevel", "number", title: "Illuminance threshold? (default 50 lux)",defaultValue: "50", required: false + } + section ("And between sunset and sunrise...") + { + input "sunriseOffsetValue", "text", title: "Sunrise offset", required: false, description: "00:00" + input "sunriseOffsetDir", "enum", title: "Before or After", required: false, metadata: [values: ["Before","After"]] + input "sunsetOffsetValue", "text", title: "Sunset offset", required: false, description: "00:00" + input "sunsetOffsetDir", "enum", title: "Before or After", required: false, metadata: [values: ["Before","After"]] + } + section ("Zip code (optional, defaults to location coordinates when location services are enabled)...") + { + input "zipCode", "text", title: "Zip Code?", required: false, description: "Local Zip Code" + } + } + } + else if (dark == true && sun == false) + { + dynamicPage(name: "options", title: "Lights will turn ON on movement when it is dark...", install: true, uninstall: true) + { + section("Control these light(s)...") + { + input "lights", "capability.switch", title: "Light(s)?", multiple: true, required: false + } + section("Control these dimmer(s)...") + { + input "dimmers", "capability.switchLevel", title: "Dimmer(s)?", multiple: true, required:false + input "level", "number", title: "How bright?", required:false, description: "0% to 100%" + } + section("Turning ON when it's dark and there's movement...") + { + input "motionSensor", "capability.motionSensor", title: "Where?", multiple: true, required: true + } + section("And then OFF when it's light or there's been no movement for...") + { + input "delayMinutes", "number", title: "Minutes?", required: false + } + section("Using this light sensor...") + { + input "lightSensor", "capability.illuminanceMeasurement",title: "Light Sensor?", multiple: false, required: true + input "luxLevel", "number", title: "Illuminance threshold? (default 50 lux)",defaultValue: "50", required: false + } + } + } + else if (sun == true && dark == false) + { + dynamicPage(name: "options", title: "Lights will turn ON on movement between sunset and sunrise...", install: true, uninstall: true) + { + section("Control these light(s)...") + { + input "lights", "capability.switch", title: "Light(s)?", multiple: true, required: false + } + section("Control these dimmer(s)...") + { + input "dimmers", "capability.switchLevel", title: "Dimmer(s)?", multiple: true, required:false + input "level", "number", title: "How bright?", required:false, description: "0% to 100%" + } + section("Turning ON there's movement...") + { + input "motionSensor", "capability.motionSensor", title: "Where?", multiple: true, required: true + } + section("And then OFF there's been no movement for...") + { + input "delayMinutes", "number", title: "Minutes?", required: false + } + section ("Between sunset and sunrise...") + { + input "sunriseOffsetValue", "text", title: "Sunrise offset", required: false, description: "00:00" + input "sunriseOffsetDir", "enum", title: "Before or After", required: false, metadata: [values: ["Before","After"]] + input "sunsetOffsetValue", "text", title: "Sunset offset", required: false, description: "00:00" + input "sunsetOffsetDir", "enum", title: "Before or After", required: false, metadata: [values: ["Before","After"]] + } + section ("Zip code (optional, defaults to location coordinates when location services are enabled)...") + { + input "zipCode", "text", title: "Zip Code?", required: false, description: "Local Zip Code" + } + } + } + else + { + dynamicPage(name: "options", title: "Lights will turn ON on movement...", install: true, uninstall: true) + { + section("Control these light(s)...") + { + input "lights", "capability.switch", title: "Light(s)?", multiple: true, required: false + } + section("Control these dimmer(s)...") + { + input "dimmers", "capability.switchLevel", title: "Dimmer(s)?", multiple: true, required:false + input "level", "number", title: "How bright?", required:false, description: "0% to 100%" + } + section("Turning ON when there's movement...") + { + input "motionSensor", "capability.motionSensor", title: "Where?", multiple: true, required: true + } + section("And then OFF when there's been no movement for...") + { + input "delayMinutes", "number", title: "Minutes?", required: false + } + } + } +} + +def installed() +{ + log.debug "Installed with settings: ${settings}." + initialize() +} + +def updated() +{ + log.debug "Updated with settings: ${settings}." + unsubscribe() + unschedule() + initialize() +} + +def initialize() +{ + subscribe(motionSensor, "motion", motionHandler) + if (lights != null && lights != "" && dimmers != null && dimmers != "") + { + log.debug "$lights subscribing..." + subscribe(lights, "switch", lightsHandler) + log.debug "$dimmers subscribing..." + subscribe(dimmers, "switch", dimmersHandler) + if (dark == true && lightSensor != null && lightSensor != "") + { + log.debug "$lights and $dimmers will turn ON when movement detected and when it is dark..." + subscribe(lightSensor, "illuminance", illuminanceHandler, [filterEvents: false]) + } + if (sun == true) + { + log.debug "$lights and $dimmers will turn ON when movement detected between sunset and sunrise..." + astroCheck() + subscribe(location, "position", locationPositionChange) + subscribe(location, "sunriseTime", sunriseSunsetTimeHandler) + subscribe(location, "sunsetTime", sunriseSunsetTimeHandler) + } + else if (dark != true && sun != true) + { + log.debug "$lights and $dimmers will turn ON when movement detected..." + } + } + else if (lights != null && lights != "") + { + log.debug "$lights subscribing..." + subscribe(lights, "switch", lightsHandler) + if (dark == true && lightSensor != null && lightSensor != "") + { + log.debug "$lights will turn ON when movement detected and when it is dark..." + subscribe(lightSensor, "illuminance", illuminanceHandler, [filterEvents: false]) + } + if (sun == true) + { + log.debug "$lights will turn ON when movement detected between sunset and sunrise..." + astroCheck() + subscribe(location, "position", locationPositionChange) + subscribe(location, "sunriseTime", sunriseSunsetTimeHandler) + subscribe(location, "sunsetTime", sunriseSunsetTimeHandler) + } + else if (dark != true && sun != true) + { + log.debug "$lights will turn ON when movement detected..." + } + } + else if (dimmers != null && dimmers != "") + { + log.debug "$dimmers subscribing..." + subscribe(dimmers, "switch", dimmersHandler) + if (dark == true && lightSensor != null && lightSensor != "") + { + log.debug "$dimmers will turn ON when movement detected and when it is dark..." + subscribe(lightSensor, "illuminance", illuminanceHandler, [filterEvents: false]) + } + if (sun == true) + { + log.debug "$dimmers will turn ON when movement detected between sunset and sunrise..." + astroCheck() + subscribe(location, "position", locationPositionChange) + subscribe(location, "sunriseTime", sunriseSunsetTimeHandler) + subscribe(location, "sunsetTime", sunriseSunsetTimeHandler) + } + else if (dark != true && sun != true) + { + log.debug "$dimmers will turn ON when movement detected..." + } + } + log.debug "Determinating lights and dimmers current value..." + if (lights != null && lights != "") + { + if (lights.currentValue("switch").toString().contains("on")) + { + state.lightsState = "on" + log.debug "Lights $state.lightsState." + } + else if (lights.currentValue("switch").toString().contains("off")) + { + state.lightsState = "off" + log.debug "Lights $state.lightsState." + } + else + { + log.debug "ERROR!" + } + } + if (dimmers != null && dimmers != "") + { + if (dimmers.currentValue("switch").toString().contains("on")) + { + state.dimmersState = "on" + log.debug "Dimmers $state.dimmersState." + } + else if (dimmers.currentValue("switch").toString().contains("off")) + { + state.dimmersState = "off" + log.debug "Dimmers $state.dimmersState." + } + else + { + log.debug "ERROR!" + } + } +} + +def locationPositionChange(evt) +{ + log.trace "locationChange()" + astroCheck() +} + +def sunriseSunsetTimeHandler(evt) +{ + state.lastSunriseSunsetEvent = now() + log.debug "SmartNightlight.sunriseSunsetTimeHandler($app.id)" + astroCheck() +} + +def motionHandler(evt) +{ + log.debug "$evt.name: $evt.value" + if (evt.value == "active") + { + unschedule(turnOffLights) + unschedule(turnOffDimmers) + if (dark == true && sun == true) + { + if (darkOk == true && sunOk == true) + { + log.debug "Lights and Dimmers will turn ON because $motionSensor detected motion and $lightSensor was dark or because $motionSensor detected motion between sunset and sunrise..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn ON..." + turnOnLights() + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn ON..." + turnOnDimmers() + } + } + else if (darkOk == true && sunOk != true) + { + log.debug "Lights and Dimmers will turn ON because $motionSensor detected motion and $lightSensor was dark..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn ON..." + turnOnLights() + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn ON..." + turnOnDimmers() + } + } + else if (darkOk != true && sunOk == true) + { + log.debug "Lights and dimmers will turn ON because $motionSensor detected motion between sunset and sunrise..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn ON..." + turnOnLights() + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn ON..." + turnOnDimmers() + } + } + else + { + log.debug "Lights and dimmers will not turn ON because $lightSensor is too bright or because time not between sunset and surise." + } + } + else if (dark == true && sun != true) + { + if (darkOk == true) + { + log.debug "Lights and dimmers will turn ON because $motionSensor detected motion and $lightSensor was dark..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn ON..." + turnOnLights() + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn ON..." + turnOnDimmers() + } + } + else + { + log.debug "Lights and dimmers will not turn ON because $lightSensor is too bright." + } + } + else if (dark != true && sun == true) + { + if (sunOk == true) + { + log.debug "Lights and dimmers will turn ON because $motionSensor detected motion between sunset and sunrise..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn ON..." + turnOnLights() + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn ON..." + turnOnDimmers() + } + } + else + { + log.debug "Lights and dimmers will not turn ON because time not between sunset and surise." + } + } + else if (dark != true && sun != true) + { + log.debug "Lights and dimmers will turn ON because $motionSensor detected motion..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn ON..." + turnOnLights() + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn ON..." + turnOnDimmers() + } + } + } + else if (evt.value == "inactive") + { + unschedule(turnOffLights) + unschedule(turnOffDimmers) + if (state.lightsState != "off" || state.dimmersState != "off") + { + log.debug "Lights and/or dimmers are not OFF." + if (delayMinutes) + { + def delay = delayMinutes * 60 + if (dark == true && sun == true) + { + log.debug "Lights and dimmers will turn OFF in $delayMinutes minute(s) after turning ON when dark or between sunset and sunrise..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn OFF in $delayMinutes minute(s)..." + runIn(delay, turnOffLights) + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn OFF in $delayMinutes minute(s)..." + runIn(delay, turnOffDimmers) + } + } + else if (dark == true && sun != true) + { + log.debug "Lights and dimmers will turn OFF in $delayMinutes minute(s) after turning ON when dark..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn OFF in $delayMinutes minute(s)..." + runIn(delay, turnOffLights) + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn OFF in $delayMinutes minute(s)..." + runIn(delay, turnOffDimmers) + } + } + else if (dark != true && sun == true) + { + log.debug "Lights and dimmers will turn OFF in $delayMinutes minute(s) between sunset and sunrise..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn OFF in $delayMinutes minute(s)..." + runIn(delay, turnOffLights) + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn OFF in $delayMinutes minute(s)..." + runIn(delay, turnOffDimmers) + } + } + else if (dark != true && sun != true) + { + log.debug "Lights and dimmers will turn OFF in $delayMinutes minute(s)..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn OFF in $delayMinutes minute(s)..." + runIn(delay, turnOffLights) + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn OFF in $delayMinutes minute(s)..." + runIn(delay, turnOffDimmers) + } + } + } + else + { + log.debug "Lights and dimmers will stay ON because no turn OFF delay was set..." + } + } + else if (state.lightsState == "off" && state.dimmersState == "off") + { + log.debug "Lights and dimmers are already OFF and will not turn OFF in $delayMinutes minute(s)." + } + } +} + +def lightsHandler(evt) +{ + log.debug "Lights Handler $evt.name: $evt.value" + if (evt.value == "on") + { + log.debug "Lights: $lights now ON." + unschedule(turnOffLights) + state.lightsState = "on" + } + else if (evt.value == "off") + { + log.debug "Lights: $lights now OFF." + unschedule(turnOffLights) + state.lightsState = "off" + } +} + +def dimmersHandler(evt) +{ + log.debug "Dimmer Handler $evt.name: $evt.value" + if (evt.value == "on") + { + log.debug "Dimmers: $dimmers now ON." + unschedule(turnOffDimmers) + state.dimmersState = "on" + } + else if (evt.value == "off") + { + log.debug "Dimmers: $dimmers now OFF." + unschedule(turnOffDimmers) + state.dimmersState = "off" + } +} + +def illuminanceHandler(evt) +{ + log.debug "$evt.name: $evt.value, lastStatus lights: $state.lightsState, lastStatus dimmers: $state.dimmersState, motionStopTime: $state.motionStopTime" + unschedule(turnOffLights) + unschedule(turnOffDimmers) + if (evt.integerValue > 999) + { + log.debug "Lights and dimmers will turn OFF because illuminance is superior to 999 lux..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn OFF..." + turnOffLights() + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn OFF..." + turnOffDimmers() + } + } + else if (evt.integerValue > ((luxLevel != null && luxLevel != "") ? luxLevel : 50)) + { + log.debug "Lights and dimmers will turn OFF because illuminance is superior to $luxLevel lux..." + if (lights != null && lights != "") + { + log.debug "Lights: $lights will turn OFF..." + turnOffLights() + } + if (dimmers != null && dimmers != "") + { + log.debug "Dimmers: $dimmers will turn OFF..." + turnOffDimmers() + } + } +} + +def turnOnLights() +{ + if (allOk) + { + if (state.lightsState != "on") + { + log.debug "Turning ON lights: $lights..." + lights?.on() + state.lightsState = "on" + } + else + { + log.debug "Lights: $lights already ON." + } + } + else + { + log.debug "Time, days of the week or mode out of range! $lights will not turn ON." + } +} + +def turnOnDimmers() +{ + if (allOk) + { + if (state.dimmersState != "on") + { + log.debug "Turning ON dimmers: $dimmers..." + settings.dimmers?.setLevel(level) + state.dimmersState = "on" + } + else + { + log.debug "Dimmers: $dimmers already ON." + } + } + else + { + log.debug "Time, days of the week or mode out of range! $dimmers will not turn ON." + } +} + + +def turnOffLights() +{ + if (allOk) + { + if (state.lightsState != "off") + { + log.debug "Turning OFF lights: $lights..." + lights?.off() + state.lightsState = "on" + } + else + { + log.debug "Lights: $lights already OFF." + } + } + else + { + log.debug "Time, day of the week or mode out of range! $lights will not turn OFF." + } +} + +def turnOffDimmers() +{ + if (allOk) + { + if (state.dimmersState != "off") + { + log.debug "Turning OFF dimmers: $dimmers..." + dimmers?.off() + state.dimmersState = "off" + } + else + { + log.debug "Dimmers: $dimmers already OFF." + } + } + else + { + log.debug "Time, day of the week or mode out of range! $dimmers will not turn OFF." + } +} + +def astroCheck() +{ + def s = getSunriseAndSunset(zipCode: zipCode, sunriseOffset: sunriseOffset, sunsetOffset: sunsetOffset) + state.riseTime = s.sunrise.time + state.setTime = s.sunset.time + log.debug "Sunrise: ${new Date(state.riseTime)}($state.riseTime), Sunset: ${new Date(state.setTime)}($state.setTime)" +} + +private getDarkOk() +{ + def result + if (dark == true && lightSensor != null && lightSensor != "") + { + result = lightSensor.currentIlluminance < ((luxLevel != null && luxLevel != "") ? luxLevel : 50) + } + log.trace "darkOk = $result" + result +} + +private getSunOk() +{ + def result + if (sun == true) + { + def t = now() + result = t < state.riseTime || t > state.setTime + } + log.trace "sunOk = $result" + result +} + +private getSunriseOffset() +{ + sunriseOffsetValue ? (sunriseOffsetDir == "Before" ? "-$sunriseOffsetValue" : sunriseOffsetValue) : null +} + +private getSunsetOffset() +{ + sunsetOffsetValue ? (sunsetOffsetDir == "Before" ? "-$sunsetOffsetValue" : sunsetOffsetValue) : null +} + +private getAllOk() +{ + modeOk && daysOk && timeOk +} + +private getModeOk() +{ + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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) +} + +private hideOptionsSection() +{ + (starting || ending || days || modes) ? false : true +} + +private timeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} diff --git a/official/brighten-dark-places.groovy b/official/brighten-dark-places.groovy new file mode 100755 index 0000000..c8ab1d0 --- /dev/null +++ b/official/brighten-dark-places.groovy @@ -0,0 +1,57 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Brighten Dark Places + * + * Author: SmartThings + */ +definition( + name: "Brighten Dark Places", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights on when a open/close sensor opens and the space is dark.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet-luminance.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet-luminance@2x.png" +) + +preferences { + section("When the door opens...") { + input "contact1", "capability.contactSensor", title: "Where?" + } + section("And it's dark...") { + input "luminance1", "capability.illuminanceMeasurement", title: "Where?" + } + section("Turn on a light...") { + input "switch1", "capability.switch" + } +} + +def installed() +{ + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def updated() +{ + unsubscribe() + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def contactOpenHandler(evt) { + def lightSensorState = luminance1.currentIlluminance + log.debug "SENSOR = $lightSensorState" + if (lightSensorState != null && lightSensorState < 10) { + log.trace "light.on() ... [luminance: ${lightSensorState}]" + switch1.on() + } +} diff --git a/official/brighten-my-path.groovy b/official/brighten-my-path.groovy new file mode 100755 index 0000000..8f3eac8 --- /dev/null +++ b/official/brighten-my-path.groovy @@ -0,0 +1,49 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Brighten My Path + * + * Author: SmartThings + */ +definition( + name: "Brighten My Path", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights on when motion is detected.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_motion-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_motion-outlet@2x.png" +) + +preferences { + section("When there's movement...") { + input "motion1", "capability.motionSensor", title: "Where?", multiple: true + } + section("Turn on a light...") { + input "switch1", "capability.switch", multiple: true + } +} + +def installed() +{ + subscribe(motion1, "motion.active", motionActiveHandler) +} + +def updated() +{ + unsubscribe() + subscribe(motion1, "motion.active", motionActiveHandler) +} + +def motionActiveHandler(evt) { + switch1.on() +} diff --git a/official/button-controller.groovy b/official/button-controller.groovy new file mode 100755 index 0000000..283a4e3 --- /dev/null +++ b/official/button-controller.groovy @@ -0,0 +1,322 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Button Controller + * + * Author: SmartThings + * Date: 2014-5-21 + */ +definition( + name: "Button Controller", + namespace: "smartthings", + author: "SmartThings", + description: "Control devices with buttons like the Aeon Labs Minimote", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/MyApps/Cat-MyApps.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/MyApps/Cat-MyApps@2x.png" +) + +preferences { + page(name: "selectButton") + page(name: "configureButton1") + page(name: "configureButton2") + page(name: "configureButton3") + page(name: "configureButton4") + + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def selectButton() { + dynamicPage(name: "selectButton", title: "First, select your button device", nextPage: "configureButton1", uninstall: configured()) { + section { + input "buttonDevice", "capability.button", title: "Button", multiple: false, required: true + } + + section(title: "More options", hidden: hideOptionsSection(), hideable: true) { + + def timeLabel = timeIntervalLabel() + + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : null + + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + } +} + +def configureButton1() { + dynamicPage(name: "configureButton1", title: "Now let's decide how to use the first button", + nextPage: "configureButton2", uninstall: configured(), getButtonSections(1)) +} +def configureButton2() { + dynamicPage(name: "configureButton2", title: "If you have a second button, set it up here", + nextPage: "configureButton3", uninstall: configured(), getButtonSections(2)) +} + +def configureButton3() { + dynamicPage(name: "configureButton3", title: "If you have a third button, you can do even more here", + nextPage: "configureButton4", uninstall: configured(), getButtonSections(3)) +} +def configureButton4() { + dynamicPage(name: "configureButton4", title: "If you have a fourth button, you rule, and can set it up here", + install: true, uninstall: true, getButtonSections(4)) +} + +def getButtonSections(buttonNumber) { + return { + section("Lights") { + input "lights_${buttonNumber}_pushed", "capability.switch", title: "Pushed", multiple: true, required: false + input "lights_${buttonNumber}_held", "capability.switch", title: "Held", multiple: true, required: false + } + section("Locks") { + input "locks_${buttonNumber}_pushed", "capability.lock", title: "Pushed", multiple: true, required: false + input "locks_${buttonNumber}_held", "capability.lock", title: "Held", multiple: true, required: false + } + section("Sonos") { + input "sonos_${buttonNumber}_pushed", "capability.musicPlayer", title: "Pushed", multiple: true, required: false + input "sonos_${buttonNumber}_held", "capability.musicPlayer", title: "Held", multiple: true, required: false + } + section("Modes") { + input "mode_${buttonNumber}_pushed", "mode", title: "Pushed", required: false + input "mode_${buttonNumber}_held", "mode", title: "Held", required: false + } + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + section("Hello Home Actions") { + log.trace phrases + input "phrase_${buttonNumber}_pushed", "enum", title: "Pushed", required: false, options: phrases + input "phrase_${buttonNumber}_held", "enum", title: "Held", required: false, options: phrases + } + } + section("Sirens") { + input "sirens_${buttonNumber}_pushed","capability.alarm" ,title: "Pushed", multiple: true, required: false + input "sirens_${buttonNumber}_held", "capability.alarm", title: "Held", multiple: true, required: false + } + + section("Custom Message") { + input "textMessage_${buttonNumber}", "text", title: "Message", required: false + } + + section("Push Notifications") { + input "notifications_${buttonNumber}_pushed","bool" ,title: "Pushed", required: false, defaultValue: false + input "notifications_${buttonNumber}_held", "bool", title: "Held", required: false, defaultValue: false + } + + section("Sms Notifications") { + input "phone_${buttonNumber}_pushed","phone" ,title: "Pushed", required: false + input "phone_${buttonNumber}_held", "phone", title: "Held", required: false + } + } +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + initialize() +} + +def initialize() { + subscribe(buttonDevice, "button", buttonEvent) +} + +def configured() { + return buttonDevice || buttonConfigured(1) || buttonConfigured(2) || buttonConfigured(3) || buttonConfigured(4) +} + +def buttonConfigured(idx) { + return settings["lights_$idx_pushed"] || + settings["locks_$idx_pushed"] || + settings["sonos_$idx_pushed"] || + settings["mode_$idx_pushed"] || + settings["notifications_$idx_pushed"] || + settings["sirens_$idx_pushed"] || + settings["notifications_$idx_pushed"] || + settings["phone_$idx_pushed"] +} + +def buttonEvent(evt){ + if(allOk) { + def buttonNumber = evt.data // why doesn't jsonData work? always returning [:] + def value = evt.value + log.debug "buttonEvent: $evt.name = $evt.value ($evt.data)" + log.debug "button: $buttonNumber, value: $value" + + def recentEvents = buttonDevice.eventsSince(new Date(now() - 3000)).findAll{it.value == evt.value && it.data == evt.data} + log.debug "Found ${recentEvents.size()?:0} events in past 3 seconds" + + if(recentEvents.size <= 1){ + switch(buttonNumber) { + case ~/.*1.*/: + executeHandlers(1, value) + break + case ~/.*2.*/: + executeHandlers(2, value) + break + case ~/.*3.*/: + executeHandlers(3, value) + break + case ~/.*4.*/: + executeHandlers(4, value) + break + } + } else { + log.debug "Found recent button press events for $buttonNumber with value $value" + } + } +} + +def executeHandlers(buttonNumber, value) { + log.debug "executeHandlers: $buttonNumber - $value" + + def lights = find('lights', buttonNumber, value) + if (lights != null) toggle(lights) + + def locks = find('locks', buttonNumber, value) + if (locks != null) toggle(locks) + + def sonos = find('sonos', buttonNumber, value) + if (sonos != null) toggle(sonos) + + def mode = find('mode', buttonNumber, value) + if (mode != null) changeMode(mode) + + def phrase = find('phrase', buttonNumber, value) + if (phrase != null) location.helloHome.execute(phrase) + + def textMessage = findMsg('textMessage', buttonNumber) + + def notifications = find('notifications', buttonNumber, value) + if (notifications?.toBoolean()) sendPush(textMessage ?: "Button $buttonNumber was pressed" ) + + def phone = find('phone', buttonNumber, value) + if (phone != null) sendSms(phone, textMessage ?:"Button $buttonNumber was pressed") + + def sirens = find('sirens', buttonNumber, value) + if (sirens != null) toggle(sirens) +} + +def find(type, buttonNumber, value) { + def preferenceName = type + "_" + buttonNumber + "_" + value + def pref = settings[preferenceName] + if(pref != null) { + log.debug "Found: $pref for $preferenceName" + } + + return pref +} + +def findMsg(type, buttonNumber) { + def preferenceName = type + "_" + buttonNumber + def pref = settings[preferenceName] + if(pref != null) { + log.debug "Found: $pref for $preferenceName" + } + + return pref +} + +def toggle(devices) { + log.debug "toggle: $devices = ${devices*.currentValue('switch')}" + + if (devices*.currentValue('switch').contains('on')) { + devices.off() + } + else if (devices*.currentValue('switch').contains('off')) { + devices.on() + } + else if (devices*.currentValue('lock').contains('locked')) { + devices.unlock() + } + else if (devices*.currentValue('lock').contains('unlocked')) { + devices.lock() + } + else if (devices*.currentValue('alarm').contains('off')) { + devices.siren() + } + else { + devices.on() + } +} + +def changeMode(mode) { + log.debug "changeMode: $mode, location.mode = $location.mode, location.modes = $location.modes" + + if (location.mode != mode && location.modes?.find { it.name == mode }) { + setLocationMode(mode) + } +} + +// execution filter methods +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location.timeZone).time + def stop = timeToday(ending, location.timeZone).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) +} + +private hideOptionsSection() { + (starting || ending || days || modes) ? false : true +} + +private timeIntervalLabel() { + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} diff --git a/official/camera-power-scheduler.groovy b/official/camera-power-scheduler.groovy new file mode 100755 index 0000000..40fe521 --- /dev/null +++ b/official/camera-power-scheduler.groovy @@ -0,0 +1,88 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Schedule the Camera Power + * + * Author: danny@smartthings.com + * Date: 2013-10-07 + */ + +definition( + name: "Camera Power Scheduler", + namespace: "smartthings", + author: "SmartThings", + description: "Turn the power on and off at a specific time. ", + category: "Available Beta Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/dropcam-on-off-schedule.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/dropcam-on-off-schedule@2x.png" +) + +preferences { + section("Camera power..."){ + input "switch1", "capability.switch", multiple: true + } + section("Turn the Camera On at..."){ + input "startTime", "time", title: "Start Time", required:false + } + section("Turn the Camera Off at..."){ + input "endTime", "time", title: "End Time", required:false + } +} + +def installed() +{ + initialize() +} + +def updated() +{ + unschedule() + initialize() +} + +def initialize() { + /* + def tz = location.timeZone + + //if it's after the startTime but before the end time, turn it on + if(startTime && timeToday(startTime,tz).time > timeToday(now,tz).time){ + + if(endTime && timeToday(endTime,tz).time < timeToday(now,tz).time){ + switch1.on() + } + else{ + switch1.off() + } + } + else if(endTime && timeToday(endtime,tz).time > timeToday(now,tz).time) + { + switch1.off() + } + */ + + if(startTime) + runDaily(startTime, turnOnCamera) + if(endTime) + runDaily(endTime,turnOffCamera) +} + +def turnOnCamera() +{ + log.info "turned on camera" + switch1.on() +} + +def turnOffCamera() +{ + log.info "turned off camera" + switch1.off() +} diff --git a/official/cameras-on-when-im-away.groovy b/official/cameras-on-when-im-away.groovy new file mode 100755 index 0000000..42051ba --- /dev/null +++ b/official/cameras-on-when-im-away.groovy @@ -0,0 +1,101 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Cameras On When I'm Away + * + * Author: danny@smartthings.com + * Date: 2013-10-07 + */ + +definition( + name: "Cameras On When I'm Away", + namespace: "smartthings", + author: "SmartThings", + description: "Turn cameras on when I'm away", + category: "Available Beta Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/dropcam-on-off-presence.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/dropcam-on-off-presence@2x.png" +) + +preferences { + section("When all of these people are home...") { + input "people", "capability.presenceSensor", multiple: true + } + section("Turn off camera power..."){ + input "switches1", "capability.switch", multiple: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + log.debug "Current people = ${people.collect{it.label + ': ' + it.currentPresence}}" + subscribe(people, "presence", presence) +} + +def updated() { + log.debug "Updated with settings: ${settings}" + log.debug "Current people = ${people.collect{it.label + ': ' + it.currentPresence}}" + unsubscribe() + subscribe(people, "presence", presence) +} + +def presence(evt) +{ + log.debug "evt.name: $evt.value" + if (evt.value == "not present") { + + log.debug "checking if everyone is away" + if (everyoneIsAway()) { + log.debug "starting on Sequence" + + runIn(60*2, "turnOn") //two minute delay after everyone has left + } + } + else { + if (!everyoneIsAway()) { + turnOff() + } + } +} + +def turnOff() +{ + log.debug "canceling On requests" + unschedule("turnOn") + + log.info "turning off the camera" + switches1.off() +} + +def turnOn() +{ + + log.info "turned on the camera" + switches1.on() + + unschedule("turnOn") // Temporary work-around to scheduling bug +} + +private everyoneIsAway() +{ + def result = true + for (person in people) { + if (person.currentPresence == "present") { + result = false + break + } + } + log.debug "everyoneIsAway: $result" + return result +} + + diff --git a/official/carpool-notifier.groovy b/official/carpool-notifier.groovy new file mode 100755 index 0000000..32a215f --- /dev/null +++ b/official/carpool-notifier.groovy @@ -0,0 +1,114 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * title: Carpool Notifier + * + * description: + * Do you carpool to work with your spouse? Do you pick your children up from school? Have they been waiting in doors for you? Let them know you've arrived with Carpool Notifier. + * + * This SmartApp is designed to send notifications to your carpooling buddies when you arrive to pick them up. What separates this SmartApp from other notification SmartApps is that it will only send a notification if your carpool buddy is not with you. + * + * category: Family + + * icon: https://s3.amazonaws.com/smartapp-icons/Family/App-IMadeIt.png + * icon2X: https://s3.amazonaws.com/smartapp-icons/Family/App-IMadeIt%402x.png + * + * Author: steve + * Date: 2013-11-19 + */ + +definition( + name: "Carpool Notifier", + namespace: "smartthings", + author: "SmartThings", + description: "Send notifications to your carpooling buddies when you arrive to pick them up. If the person you are picking up is home, and has been for 5 minutes or more, they will get a notification when you arrive.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Family/App-IMadeIt.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Family/App-IMadeIt@2x.png" +) + +preferences { + section() { + input(name: "driver", type: "capability.presenceSensor", required: true, multiple: false, title: "When this person arrives", description: "Who's driving?") + input("recipients", "contact", title: "Notify", description: "Send notifications to") { + input(name: "phoneNumber", type: "phone", required: true, multiple: false, title: "Send a text to", description: "Phone number") + } + input(name: "message", type: "text", required: false, multiple: false, title: "With the message:", description: "Your ride is here!") + input(name: "rider", type: "capability.presenceSensor", required: true, multiple: false, title: "But only when this person is not with you", description: "Who are you picking up?") + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(driver, "presence.present", presence) +} + +def presence(evt) { + + if (evt.value == "present" && riderIsHome()) + { +// log.debug "Rider Is Home; Send A Text" + sendText() + } + +} + +def riderIsHome() { + +// log.debug "rider presence: ${rider.currentPresence}" + + if (rider.currentPresence != "present") + { + return false + } + + def riderState = rider.currentState("presence") +// log.debug "riderState: ${riderState}" + if (!riderState) + { + return true + } + + def latestState = rider.latestState("presence") + + def now = new Date() + def minusFive = new Date(minutes: now.minutes - 5) + + + if (minusFive > latestState.date) + { + return true + } + + return false +} + +def sendText() { + if (location.contactBookEnabled) { + sendNotificationToContacts(message ?: "Your ride is here!", recipients) + } + else { + sendSms(phoneNumber, message ?: "Your ride is here!") + } +} diff --git a/official/close-the-valve.groovy b/official/close-the-valve.groovy new file mode 100755 index 0000000..0e6cc6f --- /dev/null +++ b/official/close-the-valve.groovy @@ -0,0 +1,87 @@ +/** + * Close a valve if moisture is detected + * + * Copyright 2014 SmartThings + * + * Author: Juan Risso + * + * 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: "Close The Valve", + namespace: "smartthings", + author: "SmartThings", + description: "Close a selected valve if moisture is detected, and get notified by SMS and push notification.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Developers/dry-the-wet-spot.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Developers/dry-the-wet-spot@2x.png" +) + +preferences { + section("When water is sensed...") { + input "sensor", "capability.waterSensor", title: "Where?", required: true, multiple: true + } + section("Close the valve...") { + input "valve", "capability.valve", title: "Which?", required: true, multiple: false + } + section("Send this message (optional, sends standard status message if not specified)"){ + input "messageText", "text", title: "Message Text", required: false + } + section("Via a push notification and/or an SMS message"){ + input "phone", "phone", title: "Phone Number (for SMS, optional)", required: false + input "pushAndPhone", "enum", title: "Both Push and SMS?", required: false, options: ["Yes","No"] + } + section("Minimum time between messages (optional)") { + input "frequency", "decimal", title: "Minutes", required: false + } +} + +def installed() { + subscribe(sensor, "water", waterHandler) +} + +def updated() { + unsubscribe() + subscribe(sensor, "water", waterHandler) +} + +def waterHandler(evt) { + log.debug "Sensor says ${evt.value}" + if (evt.value == "wet") { + valve.close() + } + if (frequency) { + def lastTime = state[evt.deviceId] + if (lastTime == null || now() - lastTime >= frequency * 60000) { + sendMessage(evt) + } + } + else { + sendMessage(evt) + } +} + +private sendMessage(evt) { + def msg = messageText ?: "We closed the valve because moisture was detected" + log.debug "$evt.name:$evt.value, pushAndPhone:$pushAndPhone, '$msg'" + + if (!phone || pushAndPhone != "No") { + log.debug "sending push" + sendPush(msg) + } + if (phone) { + log.debug "sending SMS" + sendSms(phone, msg) + } + if (frequency) { + state[evt.deviceId] = now() + } +} \ No newline at end of file diff --git a/official/coffee-after-shower.groovy b/official/coffee-after-shower.groovy new file mode 100755 index 0000000..599759e --- /dev/null +++ b/official/coffee-after-shower.groovy @@ -0,0 +1,55 @@ +/** + * Coffee After Shower + * + * + * 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: "Coffee After Shower", + namespace: "hwustrack", + author: "Hans Wustrack", + description: "This app is designed simply to turn on your coffee machine while you are taking a shower.", + 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") + + +preferences { + section("About") { + paragraph "This app is designed simply to turn on your coffee machine " + + "while you are taking a shower." + } + section("Bathroom humidity sensor") { + input "bathroom", "capability.relativeHumidityMeasurement", title: "Which humidity sensor?" + } + section("Coffee maker to turn on") { + input "coffee", "capability.switch", title: "Which switch?" + } + section("Humidity level to switch coffee on at") { + input "relHum", "number", title: "Humidity level?", defaultValue: 50 + } +} + +def installed() { + subscribe(bathroom, "humidity", coffeeMaker) +} + +def updated() { + unsubscribe() + subscribe(bathroom, "humidity", coffeeMaker) +} + +def coffeeMaker(shower) { + log.info "Humidity value: $shower.value" + if (shower.value.toInteger() > relHum) { + coffee.on() + } +} diff --git a/official/color-coordinator.groovy b/official/color-coordinator.groovy new file mode 100755 index 0000000..dc5dfb7 --- /dev/null +++ b/official/color-coordinator.groovy @@ -0,0 +1,176 @@ +/** + * Color Coordinator + * Version 1.1.1 - 11/9/16 + * By Michael Struck + * + * 1.0.0 - Initial release + * 1.1.0 - Fixed issue where master can be part of slaves. This causes a loop that impacts SmartThings. + * 1.1.1 - Fix NPE being thrown for slave/master inputs being empty. + * + * + * 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: "Color Coordinator", + namespace: "MichaelStruck", + author: "Michael Struck", + description: "Ties multiple colored lights to one specific light's settings", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/ColorCoordinator/CC.png", + iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/ColorCoordinator/CC@2x.png" +) + +preferences { + page name: "mainPage" +} + +def mainPage() { + dynamicPage(name: "mainPage", title: "", install: true, uninstall: false) { + def masterInList = slaves?.id?.find{it==master?.id} + if (masterInList) { + section ("**WARNING**"){ + paragraph "You have included the Master Light in the Slave Group. This will cause a loop in execution. Please remove this device from the Slave Group.", image: "https://raw.githubusercontent.com/MichaelStruck/SmartThingsPublic/master/img/caution.png" + } + } + section("Master Light") { + input "master", "capability.colorControl", title: "Colored Light", required: true + } + section("Lights that follow the master settings") { + input "slaves", "capability.colorControl", title: "Colored Lights", multiple: true, required: true, submitOnChange: true + } + section([mobileOnly:true], "Options") { + input "randomYes", "bool",title: "When Master Turned On, Randomize Color", defaultValue: false + href "pageAbout", title: "About ${textAppName()}", description: "Tap to get application version, license and instructions" + } + } +} + +page(name: "pageAbout", title: "About ${textAppName()}", uninstall: true) { + section { + paragraph "${textVersion()}\n${textCopyright()}\n\n${textLicense()}\n" + } + section("Instructions") { + paragraph textHelp() + } + section("Tap button below to remove application"){ + } +} + +def installed() { + init() +} + +def updated(){ + unsubscribe() + init() +} + +def init() { + subscribe(master, "switch", onOffHandler) + subscribe(master, "level", colorHandler) + subscribe(master, "hue", colorHandler) + subscribe(master, "saturation", colorHandler) + subscribe(master, "colorTemperature", tempHandler) +} +//----------------------------------- +def onOffHandler(evt){ + if (slaves && master) { + if (!slaves?.id.find{it==master?.id}){ + if (master?.currentValue("switch") == "on"){ + if (randomYes) getRandomColorMaster() + else slaves?.on() + } + else { + slaves?.off() + } + } + } +} + +def colorHandler(evt) { + if (slaves && master) { + if (!slaves?.id?.find{it==master?.id} && master?.currentValue("switch") == "on"){ + log.debug "Changing Slave units H,S,L" + def dimLevel = master?.currentValue("level") + def hueLevel = master?.currentValue("hue") + def saturationLevel = master.currentValue("saturation") + def newValue = [hue: hueLevel, saturation: saturationLevel, level: dimLevel as Integer] + slaves?.setColor(newValue) + try { + log.debug "Changing Slave color temp" + def tempLevel = master?.currentValue("colorTemperature") + slaves?.setColorTemperature(tempLevel) + } + catch (e){ + log.debug "Color temp for master --" + } + } + } +} + +def getRandomColorMaster(){ + def hueLevel = Math.floor(Math.random() *1000) + def saturationLevel = Math.floor(Math.random() * 100) + def dimLevel = master?.currentValue("level") + def newValue = [hue: hueLevel, saturation: saturationLevel, level: dimLevel as Integer] + log.debug hueLevel + log.debug saturationLevel + master.setColor(newValue) + slaves?.setColor(newValue) +} + +def tempHandler(evt){ + if (slaves && master) { + if (!slaves?.id?.find{it==master?.id} && master?.currentValue("switch") == "on"){ + if (evt.value != "--") { + log.debug "Changing Slave color temp based on Master change" + def tempLevel = master.currentValue("colorTemperature") + slaves?.setColorTemperature(tempLevel) + } + } + } +} + +//Version/Copyright/Information/Help + +private def textAppName() { + def text = "Color Coordinator" +} + +private def textVersion() { + def text = "Version 1.1.1 (12/13/2016)" +} + +private def textCopyright() { + def text = "Copyright © 2016 Michael Struck" +} + +private def textLicense() { + def text = + "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"+ + "\n\n"+ + " http://www.apache.org/licenses/LICENSE-2.0"+ + "\n\n"+ + "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." +} + +private def textHelp() { + def text = + "This application will allow you to control the settings of multiple colored lights with one control. " + + "Simply choose a master control light, and then choose the lights that will follow the settings of the master, "+ + "including on/off conditions, hue, saturation, level and color temperature. Also includes a random color feature." +} diff --git a/official/curb-control.groovy b/official/curb-control.groovy new file mode 100755 index 0000000..3bd1ec4 --- /dev/null +++ b/official/curb-control.groovy @@ -0,0 +1,118 @@ +/** + * Curb Control + * + * Author: Curb + */ + +definition( + name: "Curb Control", + namespace: "Curb", + author: "Curb", + description: "This SmartApp allows you to interact with the switches in your physical graph through Curb.", + category: "Convenience", + iconUrl: "http://energycurb.com/images/logo.png", + iconX2Url: "http://energycurb.com/images/logo.png", + oauth: [displayName: "SmartThings Curb Control", displayLink: "energycurb.com"] +) + +preferences { + section("Allow Curb to Control These Things...") { + input "switches", "capability.switch", title: "Which Switches?", multiple: true, required: false + } +} + +mappings { + path("/") { + action: [ + GET: "index" + ] + } + path("/switches") { + action: [ + GET: "listSwitches", + PUT: "updateSwitches" + ] + } + path("/switches/:id") { + action: [ + GET: "showSwitch", + PUT: "updateSwitch" + ] + } +} + +def installed() {} + +def updated() {} + +def index(){ + [[url: "/switches"]] +} + +def listSwitches() { + switches.collect { device(it,"switch") } +} +void updateSwitches() { + updateAll(switches) +} +def showSwitch() { + show(switches, "switch") +} +void updateSwitch() { + update(switches) +} + +private void updateAll(devices) { + def command = request.JSON?.command + if (command) { + switch(command) { + case "on": + devices.on() + break + case "off": + devices.off() + break + default: + httpError(403, "Access denied. This command is not supported by current capability.") + } + } +} + +private void update(devices) { + log.debug "update, request: ${request.JSON}, params: ${params}, devices: $devices.id" + def command = request.JSON?.command + if (command) { + def device = devices.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + switch(command) { + case "on": + device.on() + break + case "off": + device.off() + break + default: + httpError(403, "Access denied. This command is not supported by current capability.") + } + } + } +} + +private show(devices, name) { + def d = devices.find { it.id == params.id } + if (!d) { + httpError(404, "Device not found") + } + else { + device(d, name) + } +} + +private device(it, name){ + if(it) { + def s = it.currentState(name) + [id: it.id, label: it.displayName, name: it.displayName, state: s] + } +} diff --git a/official/curling-iron.groovy b/official/curling-iron.groovy new file mode 100755 index 0000000..8f9203f --- /dev/null +++ b/official/curling-iron.groovy @@ -0,0 +1,114 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Curling Iron + * + * Author: SmartThings + * Date: 2013-03-20 + */ +definition( + name: "Curling Iron", + namespace: "smartthings", + author: "SmartThings", + description: "Turns on an outlet when the user is present and off after a period of time", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch@2x.png" +) + +preferences { + section("When someone's around because of...") { + input name: "motionSensors", title: "Motion here", type: "capability.motionSensor", multiple: true, required: false + input name: "presenceSensors", title: "And (optionally) these sensors being present", type: "capability.presenceSensor", multiple: true, required: false + } + section("Turn on these outlet(s)") { + input name: "outlets", title: "Which?", type: "capability.switch", multiple: true + } + section("For this amount of time") { + input name: "minutes", title: "Minutes?", type: "number", multiple: false + } +} + +def installed() { + subscribeToEvents() +} + +def updated() { + unsubscribe() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(motionSensors, "motion.active", motionActive) + subscribe(motionSensors, "motion.inactive", motionInactive) + subscribe(presenceSensors, "presence.not present", notPresent) +} + +def motionActive(evt) { + log.debug "$evt.name: $evt.value" + if (anyHere()) { + outletsOn() + } +} + +def motionInactive(evt) { + log.debug "$evt.name: $evt.value" + if (allQuiet()) { + outletsOff() + } +} + +def notPresent(evt) { + log.debug "$evt.name: $evt.value" + if (!anyHere()) { + outletsOff() + } +} + +def allQuiet() { + def result = true + for (it in motionSensors) { + if (it.currentMotion == "active") { + result = false + break + } + } + return result +} + +def anyHere() { + def result = true + for (it in presenceSensors) { + if (it.currentPresence == "not present") { + result = false + break + } + } + return result +} + +def outletsOn() { + outlets.on() + unschedule("scheduledTurnOff") +} + +def outletsOff() { + def delay = minutes * 60 + runIn(delay, "scheduledTurnOff") +} + +def scheduledTurnOff() { + outlets.off() + unschedule("scheduledTurnOff") // Temporary work-around to scheduling bug +} + + diff --git a/official/darken-behind-me.groovy b/official/darken-behind-me.groovy new file mode 100755 index 0000000..e5576bf --- /dev/null +++ b/official/darken-behind-me.groovy @@ -0,0 +1,49 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Darken Behind Me + * + * Author: SmartThings + */ +definition( + name: "Darken Behind Me", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights off after a period of no motion being observed.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_motion-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_motion-outlet@2x.png" +) + +preferences { + section("When there's no movement...") { + input "motion1", "capability.motionSensor", title: "Where?" + } + section("Turn off a light...") { + input "switch1", "capability.switch", multiple: true + } +} + +def installed() +{ + subscribe(motion1, "motion.inactive", motionInactiveHandler) +} + +def updated() +{ + unsubscribe() + subscribe(motion1, "motion.inactive", motionInactiveHandler) +} + +def motionInactiveHandler(evt) { + switch1.off() +} diff --git a/official/device-tile-controller.groovy b/official/device-tile-controller.groovy new file mode 100755 index 0000000..fbc7195 --- /dev/null +++ b/official/device-tile-controller.groovy @@ -0,0 +1,107 @@ +/** + * Device Tile Controller + * + * Copyright 2016 SmartThings + * + * 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: "Device Tile Controller", + namespace: "smartthings/tile-ux", + author: "SmartThings", + description: "A controller SmartApp to install virtual devices into your location in order to simulate various native Device Tiles.", + category: "SmartThings Internal", + 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", + singleInstance: true) + + +preferences { + // landing page + page(name: "defaultPage") +} + +def defaultPage() { + dynamicPage(name: "defaultPage", install: true, uninstall: true) { + section { + paragraph "Select on Unselect the devices that you want to install" + } + section(title: "Multi Attribute Tile Types") { + input(type: "bool", name: "genericDeviceTile", title: "generic", description: "A device that showcases the various use of generic multi-attribute-tiles.", defaultValue: "false") + input(type: "bool", name: "lightingDeviceTile", title: "lighting", description: "A device that showcases the various use of lighting multi-attribute-tiles.", defaultValue: "false") + input(type: "bool", name: "thermostatDeviceTile", title: "thermostat", description: "A device that showcases the various use of thermostat multi-attribute-tiles.", defaultValue: "true") + input(type: "bool", name: "mediaPlayerDeviceTile", title: "media player", description: "A device that showcases the various use of mediaPlayer multi-attribute-tiles.", defaultValue: "false") + input(type: "bool", name: "videoPlayerDeviceTile", title: "video player", description: "A device that showcases the various use of videoPlayer multi-attribute-tiles.", defaultValue: "false") + } + section(title: "Device Tile Types") { + input(type: "bool", name: "standardDeviceTile", title: "standard device tiles", description: "A device that showcases the various use of standard device tiles.", defaultValue: "false") + input(type: "bool", name: "valueDeviceTile", title: "value device tiles", description: "A device that showcases the various use of value device tiles.", defaultValue: "false") + input(type: "bool", name: "presenceDeviceTile", title: "presence device tiles", description: "A device that showcases the various use of color control device tile.", defaultValue: "false") + } + section(title: "Other Tile Types") { + input(type: "bool", name: "carouselDeviceTile", title: "image carousel", description: "A device that showcases the various use of carousel device tile.", defaultValue: "false") + input(type: "bool", name: "sliderDeviceTile", title: "slider", description: "A device that showcases the various use of slider device tile.", defaultValue: "false") + input(type: "bool", name: "colorWheelDeviceTile", title: "color wheel", description: "A device that showcases the various use of color wheel device tile.", defaultValue: "false") + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" +} + +def uninstalled() { + getChildDevices().each { + deleteChildDevice(it.deviceNetworkId) + } +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initializeDevices() +} + +def initializeDevices() { + settings.each { key, value -> + log.debug "$key : $value" + def existingDevice = getChildDevices().find { it.name == key } + log.debug "$existingDevice" + if (existingDevice && !value) { + deleteChildDevice(existingDevice.deviceNetworkId) + } else if (!existingDevice && value) { + String dni = UUID.randomUUID() + log.debug "$dni" + addChildDevice(app.namespace, key, dni, null, [ + label: labelMap()[key] ?: key, + completedSetup: true + ]) + } + } +} + +// Map the name of the Device to a proper Label +def labelMap() { + [ + genericDeviceTile: "Tile Multiattribute Generic", + lightingDeviceTile: "Tile Multiattribute Lighting", + thermostatDeviceTile: "Tile Multiattribute Thermostat", + mediaPlayerDeviceTile: "Tile Multiattribute Media Player", + videoPlayerDeviceTile: "Tile Multiattribute Video Player", + standardDeviceTile: "Tile Device Standard", + valueDeviceTile: "Tile Device Value", + presenceDeviceTile: "Tile Device Presence", + carouselDeviceTile: "Tile Device Carousel", + sliderDeviceTile: "Tile Device Slider", + colorWheelDeviceTile: "Tile Device Color Wheel" + ] +} diff --git a/official/door-jammed-notification.groovy b/official/door-jammed-notification.groovy new file mode 100755 index 0000000..b44ddcf --- /dev/null +++ b/official/door-jammed-notification.groovy @@ -0,0 +1,66 @@ +/** + * Door Jammed Notification + * + * Copyright 2015 John Rucker + * + * 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: "Door Jammed Notification", + namespace: "JohnRucker", + author: "John.Rucker@Solar-current.com", + description: "Sends a SmartThings notification and text messages when your CoopBoss detects a door jam.", + category: "My Apps", + iconUrl: "http://coopboss.com/images/SmartThingsIcons/coopbossLogo.png", + iconX2Url: "http://coopboss.com/images/SmartThingsIcons/coopbossLogo2x.png", + iconX3Url: "http://coopboss.com/images/SmartThingsIcons/coopbossLogo3x.png") + +preferences { + section("When the door state changes") { + paragraph "Send a SmartThings notification when the coop's door jammed and did not close." + input "doorSensor", "capability.doorControl", title: "Select CoopBoss", required: true, multiple: false + input("recipients", "contact", title: "Recipients", description: "Send notifications to") { + input "phone", "phone", title: "Phone number?", required: true} + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + subscribe(doorSensor, "doorState", coopDoorStateHandler) +} + +def coopDoorStateHandler(evt) { + if (evt.value == "jammed"){ + def msg = "WARNING ${doorSensor.displayName} door is jammed and did not close!" + log.debug "WARNING ${doorSensor.displayName} door is jammed and did not close, texting $phone" + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + sendPush(msg) + if (phone) { + sendSms(phone, msg) + } + } + } +} \ No newline at end of file diff --git a/official/door-knocker.groovy b/official/door-knocker.groovy new file mode 100755 index 0000000..53ca7d9 --- /dev/null +++ b/official/door-knocker.groovy @@ -0,0 +1,88 @@ +/** + * Door Knocker + * + * Author: brian@bevey.org + * Date: 9/10/13 + * + * Let me know when someone knocks on the door, but ignore + * when someone is opening the door. + */ + +definition( + name: "Door Knocker", + namespace: "imbrianj", + author: "brian@bevey.org", + description: "Alert if door is knocked, but not opened.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("When Someone Knocks?") { + input name: "knockSensor", type: "capability.accelerationSensor", title: "Where?" + } + + section("But not when they open this door?") { + input name: "openSensor", type: "capability.contactSensor", title: "Where?" + } + + section("Knock Delay (defaults to 5s)?") { + input name: "knockDelay", type: "number", title: "How Long?", required: false + } + + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } +} + +def installed() { + init() +} + +def updated() { + unsubscribe() + init() +} + +def init() { + state.lastClosed = 0 + subscribe(knockSensor, "acceleration.active", handleEvent) + subscribe(openSensor, "contact.closed", doorClosed) +} + +def doorClosed(evt) { + state.lastClosed = now() +} + +def doorKnock() { + if((openSensor.latestValue("contact") == "closed") && + (now() - (60 * 1000) > state.lastClosed)) { + log.debug("${knockSensor.label ?: knockSensor.name} detected a knock.") + send("${knockSensor.label ?: knockSensor.name} detected a knock.") + } + + else { + log.debug("${knockSensor.label ?: knockSensor.name} knocked, but looks like it was just someone opening the door.") + } +} + +def handleEvent(evt) { + def delay = knockDelay ?: 5 + runIn(delay, "doorKnock") +} + +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) +} diff --git a/official/door-state-to-color-light-hue-bulb.groovy b/official/door-state-to-color-light-hue-bulb.groovy new file mode 100755 index 0000000..db42954 --- /dev/null +++ b/official/door-state-to-color-light-hue-bulb.groovy @@ -0,0 +1,141 @@ +/** + * CoopBoss Door Status to color + * + * Copyright 2015 John Rucker + * + * 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: "Door State to Color Light (Hue Bulb)", + namespace: "JohnRucker", + author: "John Rucker", + description: "Change the color of your Hue bulbs based on your coop's door status.", + category: "My Apps", + iconUrl: "http://coopboss.com/images/SmartThingsIcons/coopbossLogo.png", + iconX2Url: "http://coopboss.com/images/SmartThingsIcons/coopbossLogo2x.png", + iconX3Url: "http://coopboss.com/images/SmartThingsIcons/coopbossLogo3x.png") + + +preferences { + section("When the door opens/closese...") { + paragraph "Sets a Hue bulb or bulbs to a color based on your coop's door status:\r unknown = white\r open = blue\r opening = purple\r closed = green\r closing = pink\r jammed = red\r forced close = orange." + input "doorSensor", "capability.doorControl", title: "Select CoopBoss", required: true, multiple: false + input "bulbs", "capability.colorControl", title: "pick a bulb", required: true, multiple: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + subscribe(doorSensor, "doorState", coopDoorStateHandler) +} + +def coopDoorStateHandler(evt) { + log.debug "${evt.descriptionText}, $evt.value" + def color = "White" + def hueColor = 100 + def saturation = 100 + Map hClr = [:] + hClr.hex = "#FFFFFF" + + switch(evt.value) { + case "open": + color = "Blue" + break; + case "opening": + color = "Purple" + break; + case "closed": + color = "Green" + break; + case "closing": + color = "Pink" + break; + case "jammed": + color = "Red" + break; + case "forced close": + color = "Orange" + break; + case "unknown": + color = "White" + break; + } + + switch(color) { + case "White": + hueColor = 52 + saturation = 19 + break; + case "Daylight": + hueColor = 53 + saturation = 91 + break; + case "Soft White": + hueColor = 23 + saturation = 56 + break; + case "Warm White": + hueColor = 20 + saturation = 80 //83 + break; + case "Blue": + hueColor = 70 + hClr.hex = "#0000FF" + break; + case "Green": + hueColor = 39 + hClr.hex = "#00FF00" + break; + case "Yellow": + hueColor = 25 + hClr.hex = "#FFFF00" + break; + case "Orange": + hueColor = 10 + hClr.hex = "#FF6000" + break; + case "Purple": + hueColor = 75 + hClr.hex = "#BF7FBF" + break; + case "Pink": + hueColor = 83 + hClr.hex = "#FF5F5F" + break; + case "Red": + hueColor = 100 + hClr.hex = "#FF0000" + break; + } + + //bulbs*.on() + bulbs*.setHue(hueColor) + bulbs*.setSaturation(saturation) + bulbs*.setColor(hClr) + + //bulbs.each{ + //it.on() // Turn the bulb on when open (this method does not come directly from the colorControl capability) + //it.setLevel(100) // Make sure the light brightness is 100% + //it.setHue(hueColor) + //it.setSaturation(saturation) + //} +} \ No newline at end of file diff --git a/official/double-tap.groovy b/official/double-tap.groovy new file mode 100755 index 0000000..596d494 --- /dev/null +++ b/official/double-tap.groovy @@ -0,0 +1,100 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Double Tap + * + * Author: SmartThings + */ +definition( + name: "Double Tap", + namespace: "smartthings", + author: "SmartThings", + description: "Turn on or off any number of switches when an existing switch is tapped twice in a row.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" +) + +preferences { + section("When this switch is double-tapped...") { + input "master", "capability.switch", title: "Where?" + } + section("Turn on or off all of these switches as well") { + input "switches", "capability.switch", multiple: true, required: false + } + section("And turn off but not on all of these switches") { + input "offSwitches", "capability.switch", multiple: true, required: false + } + section("And turn on but not off all of these switches") { + input "onSwitches", "capability.switch", multiple: true, required: false + } +} + +def installed() +{ + subscribe(master, "switch", switchHandler, [filterEvents: false]) +} + +def updated() +{ + unsubscribe() + subscribe(master, "switch", switchHandler, [filterEvents: false]) +} + +def switchHandler(evt) { + log.info evt.value + + // use Event rather than DeviceState because we may be changing DeviceState to only store changed values + def recentStates = master.eventsSince(new Date(now() - 4000), [all:true, max: 10]).findAll{it.name == "switch"} + log.debug "${recentStates?.size()} STATES FOUND, LAST AT ${recentStates ? recentStates[0].dateCreated : ''}" + + if (evt.physical) { + if (evt.value == "on" && lastTwoStatesWere("on", recentStates, evt)) { + log.debug "detected two taps, turn on other light(s)" + onSwitches()*.on() + } else if (evt.value == "off" && lastTwoStatesWere("off", recentStates, evt)) { + log.debug "detected two taps, turn off other light(s)" + offSwitches()*.off() + } + } + else { + log.trace "Skipping digital on/off event" + } +} + +private onSwitches() { + (switches + onSwitches).findAll{it} +} + +private offSwitches() { + (switches + offSwitches).findAll{it} +} + +private lastTwoStatesWere(value, states, evt) { + def result = false + if (states) { + + log.trace "unfiltered: [${states.collect{it.dateCreated + ':' + it.value}.join(', ')}]" + def onOff = states.findAll { it.physical || !it.type } + log.trace "filtered: [${onOff.collect{it.dateCreated + ':' + it.value}.join(', ')}]" + + // This test was needed before the change to use Event rather than DeviceState. It should never pass now. + if (onOff[0].date.before(evt.date)) { + log.warn "Last state does not reflect current event, evt.date: ${evt.dateCreated}, state.date: ${onOff[0].dateCreated}" + result = evt.value == value && onOff[0].value == value + } + else { + result = onOff.size() > 1 && onOff[0].value == value && onOff[1].value == value + } + } + result +} diff --git a/official/dry-the-wetspot.groovy b/official/dry-the-wetspot.groovy new file mode 100755 index 0000000..17cf709 --- /dev/null +++ b/official/dry-the-wetspot.groovy @@ -0,0 +1,55 @@ +/** + * Dry the Wetspot + * + * Copyright 2014 Scottin Pollock + * + * 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: "Dry the Wetspot", + namespace: "smartthings", + author: "Scottin Pollock", + description: "Turns switch on and off based on moisture sensor input.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Developers/dry-the-wet-spot.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Developers/dry-the-wet-spot@2x.png" +) + + +preferences { + section("When water is sensed...") { + input "sensor", "capability.waterSensor", title: "Where?", required: true + } + section("Turn on a pump...") { + input "pump", "capability.switch", title: "Which?", required: true + } +} + +def installed() { + subscribe(sensor, "water.dry", waterHandler) + subscribe(sensor, "water.wet", waterHandler) +} + +def updated() { + unsubscribe() + subscribe(sensor, "water.dry", waterHandler) + subscribe(sensor, "water.wet", waterHandler) +} + +def waterHandler(evt) { + log.debug "Sensor says ${evt.value}" + if (evt.value == "wet") { + pump.on() + } else if (evt.value == "dry") { + pump.off() + } +} + diff --git a/official/ecobee-connect.groovy b/official/ecobee-connect.groovy new file mode 100755 index 0000000..cc26358 --- /dev/null +++ b/official/ecobee-connect.groovy @@ -0,0 +1,890 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Ecobee Service Manager + * + * Author: scott + * Date: 2013-08-07 + * + * Last Modification: + * JLH - 01-23-2014 - Update for Correct SmartApp URL Format + * JLH - 02-15-2014 - Fuller use of ecobee API + * 10-28-2015 DVCSMP-604 - accessory sensor, DVCSMP-1174, DVCSMP-1111 - not respond to routines + */ + +include 'localization' + +definition( + name: "Ecobee (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Connect your Ecobee thermostat to SmartThings.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png", + singleInstance: true +) { + appSetting "clientId" +} + +preferences { + page(name: "auth", title: "ecobee", nextPage:"", content:"authPage", uninstall: true, install:true) +} + +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 Ecobee 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() { + paragraph "Tap below to log in to the ecobee service and authorize SmartThings access. Be sure to scroll down on page 2 and press the 'Allow' button." + href url:redirectUrl, style:"embedded", required:true, title:"ecobee", description:description + } + } + } else { + def stats = getEcobeeThermostats() + log.debug "thermostat list: $stats" + log.debug "sensor list: ${sensorsDiscovered()}" + return dynamicPage(name: "auth", title: "Select Your Thermostats", uninstall: true) { + section("") { + paragraph "Tap below to see the list of ecobee thermostats available in your ecobee account and select the ones you want to connect to SmartThings." + input(name: "thermostats", title:"Select Your Thermostats", type: "enum", required:true, multiple:true, description: "Tap to choose", metadata:[values:stats]) + } + + def options = sensorsDiscovered() ?: [] + def numFound = options.size() ?: 0 + if (numFound > 0) { + section("") { + paragraph "Tap below to see the list of ecobee sensors available in your ecobee account and select the ones you want to connect to SmartThings." + input(name: "ecobeesensors", title: "Select Ecobee Sensors ({{numFound}} found)", messageArgs: [numFound: numFound], type: "enum", required:false, description: "Tap to choose", multiple:true, options:options) + } + } + } + } +} + +def oauthInitUrl() { + log.debug "oauthInitUrl with callback: ${callbackUrl}" + + atomicState.oauthInitState = UUID.randomUUID().toString() + + def oauthParams = [ + response_type: "code", + scope: "smartRead,smartWrite", + client_id: smartThingsClientId, + state: atomicState.oauthInitState, + redirect_uri: callbackUrl + ] + + redirect(location: "${apiEndpoint}/authorize?${toQueryString(oauthParams)}") +} + +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 : smartThingsClientId, + redirect_uri: callbackUrl + ] + + def tokenUrl = "https://www.ecobee.com/home/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" + } + +} + +def success() { + def message = """ +

Your ecobee Account is now connected to SmartThings!

+

Click 'Done' to finish setup.

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

The connection could not be established!

+

Click 'Done' to return to the menu.

+ """ + connectionStatus(message) +} + +def connectionStatus(message, redirectUrl = null) { + def redirectHtml = "" + if (redirectUrl) { + redirectHtml = """ + + """ + } + + def html = """ + + + + + Ecobee & SmartThings connection + + + +
+ ecobee icon + connected device icon + SmartThings logo + ${message} +
+ + + """ + + render contentType: 'text/html', data: html +} + +def getEcobeeThermostats() { + log.debug "getting device list" + atomicState.remoteSensors = [] + + def bodyParams = [ + selection: [ + selectionType: "registered", + selectionMatch: "", + includeRuntime: true, + includeSensors: true + ] + ] + def deviceListParams = [ + uri: apiEndpoint, + path: "/1/thermostat", + headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"], + // TODO - the query string below is not consistent with the Ecobee docs: + // https://www.ecobee.com/home/developer/api/documentation/v1/operations/get-thermostats.shtml + query: [format: 'json', body: toJson(bodyParams)] + ] + + def stats = [:] + try { + httpGet(deviceListParams) { resp -> + if (resp.status == 200) { + resp.data.thermostatList.each { stat -> + atomicState.remoteSensors = atomicState.remoteSensors == null ? stat.remoteSensors : atomicState.remoteSensors << stat.remoteSensors + def dni = [app.id, stat.identifier].join('.') + stats[dni] = getThermostatDisplayName(stat) + } + } else { + log.debug "http status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.trace "Exception polling children: " + e.response.data.status + if (e.response.data.status.code == 14) { + atomicState.action = "getEcobeeThermostats" + log.debug "Refreshing your auth_token!" + refreshAuthToken() + } + } + atomicState.thermostats = stats + return stats +} + +Map sensorsDiscovered() { + def map = [:] + log.info "list ${atomicState.remoteSensors}" + atomicState.remoteSensors.each { sensors -> + sensors.each { + if (it.type != "thermostat") { + def value = "${it?.name}" + def key = "ecobee_sensor-"+ it?.id + "-" + it?.code + map["${key}"] = value + } + } + } + atomicState.sensors = map + return map +} + +def getThermostatDisplayName(stat) { + if(stat?.name) { + return stat.name.toString() + } + return (getThermostatTypeName(stat) + " (${stat.identifier})").toString() +} + +def getThermostatTypeName(stat) { + return stat.modelNumber == "siSmart" ? "Smart Si" : "Smart" +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + log.debug "initialize" + def devices = thermostats.collect { dni -> + def d = getChildDevice(dni) + if(!d) { + d = addChildDevice(app.namespace, getChildName(), dni, null, ["label":"${atomicState.thermostats[dni]}" ?: "Ecobee Thermostat"]) + log.debug "created ${d.displayName} with id $dni" + } else { + log.debug "found ${d.displayName} with id $dni already exists" + } + return d + } + + def sensors = ecobeesensors.collect { dni -> + def d = getChildDevice(dni) + if(!d) { + d = addChildDevice(app.namespace, getSensorChildName(), dni, null, ["label":"${atomicState.sensors[dni]}" ?:"Ecobee Sensor"]) + log.debug "created ${d.displayName} with id $dni" + } else { + log.debug "found ${d.displayName} with id $dni already exists" + } + return d + } + log.debug "created ${devices.size()} thermostats and ${sensors.size()} sensors." + + def delete // Delete any that are no longer in settings + if(!thermostats && !ecobeesensors) { + log.debug "delete thermostats ands sensors" + delete = getAllChildDevices() //inherits from SmartApp (data-management) + } else { //delete only thermostat + log.debug "delete individual thermostat and sensor" + if (!ecobeesensors) { + delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) } + } else { + delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) && !ecobeesensors.contains(it.deviceNetworkId)} + } + } + log.warn "delete: ${delete}, deleting ${delete.size()} thermostats" + delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management) + + //send activity feeds to tell that device is connected + def notificationMessage = "is connected to SmartThings" + sendActivityFeeds(notificationMessage) + atomicState.timeSendPush = null + atomicState.reAttempt = 0 + + pollHandler() //first time polling data data from thermostat + + //automatically update devices status every 5 mins + runEvery5Minutes("poll") + +} + +def pollHandler() { + log.debug "pollHandler()" + pollChildren(null) // Hit the ecobee API for update on all thermostats + + atomicState.thermostats.each {stat -> + def dni = stat.key + log.debug ("DNI = ${dni}") + def d = getChildDevice(dni) + if(d) { + log.debug ("Found Child Device.") + d.generateEvent(atomicState.thermostats[dni].data) + } + } +} + +def pollChildren(child = null) { + def thermostatIdsString = getChildDeviceIdsString() + log.debug "polling children: $thermostatIdsString" + + def requestBody = [ + selection: [ + selectionType: "thermostats", + selectionMatch: thermostatIdsString, + includeExtendedRuntime: true, + includeSettings: true, + includeRuntime: true, + includeSensors: true + ] + ] + + def result = false + + def pollParams = [ + uri: apiEndpoint, + path: "/1/thermostat", + headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"], + // TODO - the query string below is not consistent with the Ecobee docs: + // https://www.ecobee.com/home/developer/api/documentation/v1/operations/get-thermostats.shtml + query: [format: 'json', body: toJson(requestBody)] + ] + + try{ + httpGet(pollParams) { resp -> + if(resp.status == 200) { + log.debug "poll results returned resp.data ${resp.data}" + atomicState.remoteSensors = resp.data.thermostatList.remoteSensors + updateSensorData() + storeThermostatData(resp.data.thermostatList) + result = true + log.debug "updated ${atomicState.thermostats?.size()} stats: ${atomicState.thermostats}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.trace "Exception polling children: " + e.response.data.status + if (e.response.data.status.code == 14) { + atomicState.action = "pollChildren" + log.debug "Refreshing your auth_token!" + refreshAuthToken() + } + } + return result +} + +// Poll Child is invoked from the Child Device itself as part of the Poll Capability +def pollChild() { + def devices = getChildDevices() + + if (pollChildren()) { + devices.each { child -> + if (!child.device.deviceNetworkId.startsWith("ecobee_sensor")) { + if(atomicState.thermostats[child.device.deviceNetworkId] != null) { + def tData = atomicState.thermostats[child.device.deviceNetworkId] + log.info "pollChild(child)>> data for ${child.device.deviceNetworkId} : ${tData.data}" + child.generateEvent(tData.data) //parse received message from parent + } else if(atomicState.thermostats[child.device.deviceNetworkId] == null) { + log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId}" + return null + } + } + } + } else { + log.info "ERROR: pollChildren()" + return null + } + +} + +void poll() { + pollChild() +} + +def availableModes(child) { + debugEvent ("atomicState.thermostats = ${atomicState.thermostats}") + debugEvent ("Child DNI = ${child.device.deviceNetworkId}") + + def tData = atomicState.thermostats[child.device.deviceNetworkId] + + debugEvent("Data = ${tData}") + + if(!tData) { + log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling" + return null + } + + def modes = ["off"] + + if (tData.data.heatMode) { + modes.add("heat") + } + if (tData.data.coolMode) { + modes.add("cool") + } + if (tData.data.autoMode) { + modes.add("auto") + } + if (tData.data.auxHeatMode) { + modes.add("auxHeatOnly") + } + + return modes +} + +def currentMode(child) { + debugEvent ("atomicState.Thermos = ${atomicState.thermostats}") + debugEvent ("Child DNI = ${child.device.deviceNetworkId}") + + def tData = atomicState.thermostats[child.device.deviceNetworkId] + + debugEvent("Data = ${tData}") + + if(!tData) { + log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling" + return null + } + + def mode = tData.data.thermostatMode + return mode +} + +def updateSensorData() { + atomicState.remoteSensors.each { + it.each { + if (it.type != "thermostat") { + def temperature = "" + def occupancy = "" + it.capability.each { + if (it.type == "temperature") { + if (it.value == "unknown") { + temperature = "--" + } else { + if (location.temperatureScale == "F") { + temperature = Math.round(it.value.toDouble() / 10) + } else { + temperature = convertFtoC(it.value.toDouble() / 10) + } + + } + } else if (it.type == "occupancy") { + if(it.value == "true") { + occupancy = "active" + } else { + occupancy = "inactive" + } + } + } + def dni = "ecobee_sensor-"+ it?.id + "-" + it?.code + def d = getChildDevice(dni) + if(d) { + d.sendEvent(name:"temperature", value: temperature) + d.sendEvent(name:"motion", value: occupancy) + } + } + } + } +} + +def getChildDeviceIdsString() { + return thermostats.collect { it.split(/\./).last() }.join(',') +} + +def toJson(Map m) { + return groovy.json.JsonOutput.toJson(m) +} + +def toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +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 : apiEndpoint, + path : "/token", + query : [grant_type: 'refresh_token', code: "${atomicState.refreshToken}", client_id: smartThingsClientId], + ] + + def notificationMessage = "is disconnected from SmartThings, because the access credential changed or was lost. Please go to the Ecobee (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!" + debugEvent("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 + atomicState.reAttempt = atomicState.reAttempt + 1 + log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}" + if (atomicState.reAttempt <= 3) { + runIn(reAttemptPeriod, "refreshAuthToken") + } else { + sendPushAndFeeds(notificationMessage) + atomicState.reAttempt = 0 + } + } + } + } +} + +/** + * Saves the refresh and auth token from the passed-in JSON object, + * and invokes any previously executing action that did not complete due to + * an expired token. + * + * @param json - an object representing the parsed JSON response from Ecobee + */ +private void saveTokenAndResumeAction(json) { + log.debug "token response json: $json" + if (json) { + debugEvent("Response = $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 = "" +} + +/** + * Executes the resume program command on the Ecobee thermostat + * @param deviceId - the ID of the device + * + * @retrun true if the command was successful, false otherwise. + */ +boolean resumeProgram(deviceId) { + def payload = [ + selection: [ + selectionType: "thermostats", + selectionMatch: deviceId, + includeRuntime: true + ], + functions: [ + [ + type: "resumeProgram" + ] + ] + ] + return sendCommandToEcobee(payload) +} + +/** + * Executes the set hold command on the Ecobee thermostat + * @param heating - The heating temperature to set in fahrenheit + * @param cooling - the cooling temperature to set in fahrenheit + * @param deviceId - the ID of the device + * @param sendHoldType - the hold type to execute + * + * @return true if the command was successful, false otherwise + */ +boolean setHold(heating, cooling, deviceId, sendHoldType) { + // Ecobee requires that temp values be in fahrenheit multiplied by 10. + int h = heating * 10 + int c = cooling * 10 + + def payload = [ + selection: [ + selectionType: "thermostats", + selectionMatch: deviceId, + includeRuntime: true + ], + functions: [ + [ + type: "setHold", + params: [ + coolHoldTemp: c, + heatHoldTemp: h, + holdType: sendHoldType + ] + ] + ] + ] + + return sendCommandToEcobee(payload) +} + +/** + * Executes the set fan mode command on the Ecobee thermostat + * @param heating - The heating temperature to set in fahrenheit + * @param cooling - the cooling temperature to set in fahrenheit + * @param deviceId - the ID of the device + * @param sendHoldType - the hold type to execute + * @param fanMode - the fan mode to set to + * + * @return true if the command was successful, false otherwise + */ +boolean setFanMode(heating, cooling, deviceId, sendHoldType, fanMode) { + // Ecobee requires that temp values be in fahrenheit multiplied by 10. + int h = heating * 10 + int c = cooling * 10 + + def payload = [ + selection: [ + selectionType: "thermostats", + selectionMatch: deviceId, + includeRuntime: true + ], + functions: [ + [ + type: "setHold", + params: [ + coolHoldTemp: c, + heatHoldTemp: h, + holdType: sendHoldType, + fan: fanMode + ] + ] + ] + ] + + return sendCommandToEcobee(payload) +} + +/** + * Sets the mode of the Ecobee thermostat + * @param mode - the mode to set to + * @param deviceId - the ID of the device + * + * @return true if the command was successful, false otherwise + */ +boolean setMode(mode, deviceId) { + def payload = [ + selection: [ + selectionType: "thermostats", + selectionMatch: deviceId, + includeRuntime: true + ], + thermostat: [ + settings: [ + hvacMode: mode + ] + ] + ] + return sendCommandToEcobee(payload) +} + +/** + * Makes a request to the Ecobee API to actuate the thermostat. + * Used by command methods to send commands to Ecobee. + * + * @param bodyParams - a map of request parameters to send to Ecobee. + * + * @return true if the command was accepted by Ecobee without error, false otherwise. + */ +private boolean sendCommandToEcobee(Map bodyParams) { + def isSuccess = false + def cmdParams = [ + uri: apiEndpoint, + path: "/1/thermostat", + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + body: toJson(bodyParams) + ] + + try{ + httpPost(cmdParams) { resp -> + if(resp.status == 200) { + log.debug "updated ${resp.data}" + def returnStatus = resp.data.status.code + if (returnStatus == 0) { + log.debug "Successful call to ecobee API." + isSuccess = true + } else { + log.debug "Error return code = ${returnStatus}" + debugEvent("Error return code = ${returnStatus}") + } + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.trace "Exception Sending Json: " + e.response.data.status + debugEvent ("sent Json & got http status ${e.statusCode} - ${e.response.data.status.code}") + if (e.response.data.status.code == 14) { + // TODO - figure out why we're setting the next action to be pollChildren + // after refreshing auth token. Is it to keep UI in sync, or just copy/paste error? + atomicState.action = "pollChildren" + log.debug "Refreshing your auth_token!" + refreshAuthToken() + } else { + debugEvent("Authentication error, invalid authentication method, lack of credentials, etc.") + log.error "Authentication error, invalid authentication method, lack of credentials, etc." + } + } + + return isSuccess +} + +def getChildName() { return "Ecobee Thermostat" } +def getSensorChildName() { return "Ecobee Sensor" } +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://api.ecobee.com" } +def getSmartThingsClientId() { return appSettings.clientId } + +def debugEvent(message, displayEvent = false) { + def results = [ + name: "appdebug", + descriptionText: message, + displayed: displayEvent + ] + log.debug "Generating AppDebug Event: ${results}" + sendEvent (results) +} + +//send both push notification and mobile activity feeds +def sendPushAndFeeds(notificationMessage) { + log.warn "sendPushAndFeeds >> notificationMessage: ${notificationMessage}" + log.warn "sendPushAndFeeds >> atomicState.timeSendPush: ${atomicState.timeSendPush}" + if (atomicState.timeSendPush) { + if (now() - atomicState.timeSendPush > 86400000) { // notification is sent to remind user once a day + sendPush("Your Ecobee thermostat " + notificationMessage) + sendActivityFeeds(notificationMessage) + atomicState.timeSendPush = now() + } + } else { + sendPush("Your Ecobee thermostat " + notificationMessage) + sendActivityFeeds(notificationMessage) + atomicState.timeSendPush = now() + } + atomicState.authToken = null +} + +/** + * Stores data about the thermostats in atomicState. + * @param thermostats - a list of thermostats as returned from the Ecobee API + */ +private void storeThermostatData(thermostats) { + log.trace "Storing thermostat data: $thermostats" + def data + atomicState.thermostats = thermostats.inject([:]) { collector, stat -> + def dni = [ app.id, stat.identifier ].join('.') + log.debug "updating dni $dni" + + data = [ + coolMode: (stat.settings.coolStages > 0), + heatMode: (stat.settings.heatStages > 0), + deviceTemperatureUnit: stat.settings.useCelsius, + minHeatingSetpoint: (stat.settings.heatRangeLow / 10), + maxHeatingSetpoint: (stat.settings.heatRangeHigh / 10), + minCoolingSetpoint: (stat.settings.coolRangeLow / 10), + maxCoolingSetpoint: (stat.settings.coolRangeHigh / 10), + autoMode: stat.settings.autoHeatCoolFeatureEnabled, + deviceAlive: stat.runtime.connected == true ? "true" : "false", + auxHeatMode: (stat.settings.hasHeatPump) && (stat.settings.hasForcedAir || stat.settings.hasElectric || stat.settings.hasBoiler), + temperature: (stat.runtime.actualTemperature / 10), + heatingSetpoint: stat.runtime.desiredHeat / 10, + coolingSetpoint: stat.runtime.desiredCool / 10, + thermostatMode: stat.settings.hvacMode, + humidity: stat.runtime.actualHumidity, + thermostatFanMode: stat.runtime.desiredFanMode + ] + if (location.temperatureScale == "F") { + data["temperature"] = data["temperature"] ? Math.round(data["temperature"].toDouble()) : data["temperature"] + data["heatingSetpoint"] = data["heatingSetpoint"] ? Math.round(data["heatingSetpoint"].toDouble()) : data["heatingSetpoint"] + data["coolingSetpoint"] = data["coolingSetpoint"] ? Math.round(data["coolingSetpoint"].toDouble()) : data["coolingSetpoint"] + data["minHeatingSetpoint"] = data["minHeatingSetpoint"] ? Math.round(data["minHeatingSetpoint"].toDouble()) : data["minHeatingSetpoint"] + data["maxHeatingSetpoint"] = data["maxHeatingSetpoint"] ? Math.round(data["maxHeatingSetpoint"].toDouble()) : data["maxHeatingSetpoint"] + data["minCoolingSetpoint"] = data["minCoolingSetpoint"] ? Math.round(data["minCoolingSetpoint"].toDouble()) : data["minCoolingSetpoint"] + data["maxCoolingSetpoint"] = data["maxCoolingSetpoint"] ? Math.round(data["maxCoolingSetpoint"].toDouble()) : data["maxCoolingSetpoint"] + + } + + if (data?.deviceTemperatureUnit == false && location.temperatureScale == "F") { + data["deviceTemperatureUnit"] = "F" + + } else { + data["deviceTemperatureUnit"] = "C" + } + + collector[dni] = [data:data] + return collector + } + log.debug "updated ${atomicState.thermostats?.size()} thermostats: ${atomicState.thermostats}" +} + +def sendActivityFeeds(notificationMessage) { + def devices = getChildDevices() + devices.each { child -> + child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent + } +} + +def convertFtoC (tempF) { + return String.format("%.1f", (Math.round(((tempF - 32)*(5/9)) * 2))/2) +} diff --git a/official/elder-care-daily-routine.groovy b/official/elder-care-daily-routine.groovy new file mode 100755 index 0000000..e032420 --- /dev/null +++ b/official/elder-care-daily-routine.groovy @@ -0,0 +1,143 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Elder Care + * + * Author: SmartThings + * Date: 2013-03-06 + * + * Stay connected to your loved ones. Get notified if they are not up and moving around + * by a specified time and/or if they have not opened a cabinet or door according to a set schedule. + */ + +definition( + name: "Elder Care: Daily Routine", + namespace: "smartthings", + author: "SmartThings", + description: "Stay connected to your loved ones. Get notified if they are not up and moving around by a specified time and/or if they have not opened a cabinet or door according to a set schedule.", + category: "Family", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/calendar_contact-accelerometer.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/calendar_contact-accelerometer@2x.png" +) + +preferences { + section("Who are you checking on?") { + input "person1", "text", title: "Name?" + } + section("If there's no movement (optional, leave blank to not require)...") { + input "motion1", "capability.motionSensor", title: "Where?", required: false + } + section("or a door or cabinet hasn't been opened (optional, leave blank to not require)...") { + input "contact1", "capability.contactSensor", required: false + } + section("between these times...") { + input "time0", "time", title: "From what time?" + input "time1", "time", title: "Until what time?" + } + section("then alert the following people...") { + input("recipients", "contact", title: "People to notify", description: "Send notifications to") { + input "phone1", "phone", title: "Phone number?", required: false + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + schedule(time1, "scheduleCheck") +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() //TODO no longer subscribe like we used to - clean this up after all apps updated + unschedule() + schedule(time1, "scheduleCheck") +} + +def scheduleCheck() +{ + if(noRecentContact() && noRecentMotion()) { + def person = person1 ?: "your elder" + def msg = "Alert! There has been no activity at ${person}'s place ${timePhrase}" + log.debug msg + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + if (phone1) { + sendSms(phone1, msg) + } else { + sendPush(msg) + } + } + } else { + log.debug "There has been activity ${timePhrase}, not sending alert" + } +} + +private noRecentMotion() +{ + if(motion1) { + def motionEvents = motion1.eventsSince(sinceTime) + log.trace "Found ${motionEvents?.size() ?: 0} motion events" + if (motionEvents.find { it.value == "active" }) { + log.debug "There have been recent 'active' events" + return false + } else { + log.debug "There have not been any recent 'active' events" + return true + } + } else { + log.debug "Motion sensor not enabled" + return true + } +} + +private noRecentContact() +{ + if(contact1) { + def contactEvents = contact1.eventsSince(sinceTime) + log.trace "Found ${contactEvents?.size() ?: 0} door events" + if (contactEvents.find { it.value == "open" }) { + log.debug "There have been recent 'open' events" + return false + } else { + log.debug "There have not been any recent 'open' events" + return true + } + } else { + log.debug "Contact sensor not enabled" + return true + } +} + +private getSinceTime() { + if (time0) { + return timeToday(time0, location?.timeZone) + } + else { + return new Date(now() - 21600000) + } +} + +private getTimePhrase() { + def interval = now() - sinceTime.time + if (interval < 3600000) { + return "in the past ${Math.round(interval/60000)} minutes" + } + else if (interval < 7200000) { + return "in the past hour" + } + else { + return "in the past ${Math.round(interval/3600000)} hours" + } +} diff --git a/official/elder-care-slip-fall.groovy b/official/elder-care-slip-fall.groovy new file mode 100755 index 0000000..2c893ae --- /dev/null +++ b/official/elder-care-slip-fall.groovy @@ -0,0 +1,124 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Elder Care: Slip & Fall + * + * Author: SmartThings + * Date: 2013-04-07 + * + */ + +definition( + name: "Elder Care: Slip & Fall", + namespace: "smartthings", + author: "SmartThings", + description: "Monitors motion sensors in bedroom and bathroom during the night and detects if occupant does not return from the bathroom after a specified period of time.", + category: "Family", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/calendar_contact-accelerometer.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/calendar_contact-accelerometer@2x.png" +) + +preferences { + section("Bedroom motion detector(s)") { + input "bedroomMotion", "capability.motionSensor", multiple: true + } + section("Bathroom motion detector") { + input "bathroomMotion", "capability.motionSensor" + } + section("Active between these times") { + input "startTime", "time", title: "Start Time" + input "stopTime", "time", title: "Stop Time" + } + section("Send message when no return within specified time period") { + input "warnMessage", "text", title: "Warning Message" + input "threshold", "number", title: "Minutes" + } + section("To these contacts") { + input("recipients", "contact", title: "Recipients", description: "Send notifications to") { + input "phone1", "phone", required: false + input "phone2", "phone", required: false + input "phone3", "phone", required: false + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + state.active = 0 + subscribe(bedroomMotion, "motion.active", bedroomActive) + subscribe(bathroomMotion, "motion.active", bathroomActive) +} + +def bedroomActive(evt) { + def start = timeToday(startTime, location?.timeZone) + def stop = timeToday(stopTime, location?.timeZone) + def now = new Date() + log.debug "bedroomActive, status: $state.ststus, start: $start, stop: $stop, now: $now" + if (state.status == "waiting") { + log.debug "motion detected in bedroom, disarming" + unschedule("sendMessage") + state.status = null + } + else { + if (start.before(now) && stop.after(now)) { + log.debug "motion in bedroom, look for bathroom motion" + state.status = "pending" + } + else { + log.debug "Not in time window" + } + } +} + +def bathroomActive(evt) { + log.debug "bathroomActive, status: $state.status" + if (state.status == "pending") { + def delay = threshold.toInteger() * 60 + state.status = "waiting" + log.debug "runIn($delay)" + runIn(delay, sendMessage) + } +} + +def sendMessage() { + log.debug "sendMessage" + def msg = warnMessage + log.info msg + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + sendPush msg + if (phone1) { + sendSms phone1, msg + } + if (phone2) { + sendSms phone2, msg + } + if (phone3) { + sendSms phone3, msg + } + } + state.status = null +} diff --git a/official/energy-alerts.groovy b/official/energy-alerts.groovy new file mode 100755 index 0000000..bd394a1 --- /dev/null +++ b/official/energy-alerts.groovy @@ -0,0 +1,103 @@ +/** + * Energy Saver + * + * Copyright 2014 SmartThings + * + * 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: "Energy Alerts", + namespace: "smartthings", + author: "SmartThings", + description: "Get notified if you're using too much energy", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/text.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text@2x.png" +) + +preferences { + section { + input(name: "meter", type: "capability.powerMeter", title: "When This Power Meter...", required: true, multiple: false, description: null) + input(name: "aboveThreshold", type: "number", title: "Reports Above...", required: true, description: "in either watts or kw.") + input(name: "belowThreshold", type: "number", title: "Or Reports Below...", required: true, description: "in either watts or kw.") + } + section { + input("recipients", "contact", title: "Send notifications to") { + input(name: "sms", type: "phone", title: "Send A Text To", description: null, required: false) + input(name: "pushNotification", type: "bool", title: "Send a push notification", description: null, defaultValue: true) + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + subscribe(meter, "power", meterHandler) +} + +def meterHandler(evt) { + + def meterValue = evt.value as double + + if (!atomicState.lastValue) { + atomicState.lastValue = meterValue + } + + def lastValue = atomicState.lastValue as double + atomicState.lastValue = meterValue + + def dUnit = evt.unit ?: "Watts" + + def aboveThresholdValue = aboveThreshold as int + if (meterValue > aboveThresholdValue) { + if (lastValue < aboveThresholdValue) { // only send notifications when crossing the threshold + def msg = "${meter} reported ${evt.value} ${dUnit} which is above your threshold of ${aboveThreshold}." + sendMessage(msg) + } else { +// log.debug "not sending notification for ${evt.description} because the threshold (${aboveThreshold}) has already been crossed" + } + } + + + def belowThresholdValue = belowThreshold as int + if (meterValue < belowThresholdValue) { + if (lastValue > belowThresholdValue) { // only send notifications when crossing the threshold + def msg = "${meter} reported ${evt.value} ${dUnit} which is below your threshold of ${belowThreshold}." + sendMessage(msg) + } else { +// log.debug "not sending notification for ${evt.description} because the threshold (${belowThreshold}) has already been crossed" + } + } +} + +def sendMessage(msg) { + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + if (sms) { + sendSms(sms, msg) + } + if (pushNotification) { + sendPush(msg) + } + } +} diff --git a/official/energy-saver.groovy b/official/energy-saver.groovy new file mode 100755 index 0000000..7f7d536 --- /dev/null +++ b/official/energy-saver.groovy @@ -0,0 +1,59 @@ +/** + * Energy Saver + * + * Copyright 2014 SmartThings + * + * 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: "Energy Saver", + namespace: "smartthings", + author: "SmartThings", + description: "Turn things off if you're using too much energy", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" +) + +preferences { + section { + input(name: "meter", type: "capability.powerMeter", title: "When This Power Meter...", required: true, multiple: false, description: null) + input(name: "threshold", type: "number", title: "Reports Above...", required: true, description: "in either watts or kw.") + } + section { + input(name: "switches", type: "capability.switch", title: "Turn Off These Switches", required: true, multiple: true, description: null) + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + subscribe(meter, "power", meterHandler) +} + +def meterHandler(evt) { + def meterValue = evt.value as double + def thresholdValue = threshold as int + if (meterValue > thresholdValue) { + log.debug "${meter} reported energy consumption above ${threshold}. Turning of switches." + switches.off() + } +} diff --git a/official/enhanced-auto-lock-door.groovy b/official/enhanced-auto-lock-door.groovy new file mode 100755 index 0000000..81bbdc9 --- /dev/null +++ b/official/enhanced-auto-lock-door.groovy @@ -0,0 +1,115 @@ +definition( + name: "Enhanced Auto Lock Door", + namespace: "Lock Auto Super Enhanced", + author: "Arnaud", + description: "Automatically locks a specific door after X minutes when closed and unlocks it when open after X seconds.", + category: "Safety & Security", + iconUrl: "http://www.gharexpert.com/mid/4142010105208.jpg", + iconX2Url: "http://www.gharexpert.com/mid/4142010105208.jpg" +) + +preferences{ + section("Select the door lock:") { + input "lock1", "capability.lock", required: true + } + section("Select the door contact sensor:") { + input "contact", "capability.contactSensor", required: true + } + section("Automatically lock the door when closed...") { + input "minutesLater", "number", title: "Delay (in minutes):", required: true + } + section("Automatically unlock the door when open...") { + input "secondsLater", "number", title: "Delay (in seconds):", required: true + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to", required: false) { + input "phoneNumber", "phone", title: "Warn with text message (optional)", description: "Phone Number", required: false + } + } +} + +def installed(){ + initialize() +} + +def updated(){ + unsubscribe() + unschedule() + initialize() +} + +def initialize(){ + log.debug "Settings: ${settings}" + subscribe(lock1, "lock", doorHandler, [filterEvents: false]) + subscribe(lock1, "unlock", doorHandler, [filterEvents: false]) + subscribe(contact, "contact.open", doorHandler) + subscribe(contact, "contact.closed", doorHandler) +} + +def lockDoor(){ + log.debug "Locking the door." + lock1.lock() + if(location.contactBookEnabled) { + if ( recipients ) { + log.debug ( "Sending Push Notification..." ) + sendNotificationToContacts( "${lock1} locked after ${contact} was closed for ${minutesLater} minutes!", recipients) + } + } + if (phoneNumber) { + log.debug("Sending text message...") + sendSms( phoneNumber, "${lock1} locked after ${contact} was closed for ${minutesLater} minutes!") + } +} + +def unlockDoor(){ + log.debug "Unlocking the door." + lock1.unlock() + if(location.contactBookEnabled) { + if ( recipients ) { + log.debug ( "Sending Push Notification..." ) + sendNotificationToContacts( "${lock1} unlocked after ${contact} was opened for ${secondsLater} seconds!", recipients) + } + } + if ( phoneNumber ) { + log.debug("Sending text message...") + sendSms( phoneNumber, "${lock1} unlocked after ${contact} was opened for ${secondsLater} seconds!") + } +} + +def doorHandler(evt){ + if ((contact.latestValue("contact") == "open") && (evt.value == "locked")) { // If the door is open and a person locks the door then... + //def delay = (secondsLater) // runIn uses seconds + runIn( secondsLater, unlockDoor ) // ...schedule (in minutes) to unlock... We don't want the door to be closed while the lock is engaged. + } + else if ((contact.latestValue("contact") == "open") && (evt.value == "unlocked")) { // If the door is open and a person unlocks it then... + unschedule( unlockDoor ) // ...we don't need to unlock it later. + } + else if ((contact.latestValue("contact") == "closed") && (evt.value == "locked")) { // If the door is closed and a person manually locks it then... + unschedule( lockDoor ) // ...we don't need to lock it later. + } + else if ((contact.latestValue("contact") == "closed") && (evt.value == "unlocked")) { // If the door is closed and a person unlocks it then... + //def delay = (minutesLater * 60) // runIn uses seconds + runIn( (minutesLater * 60), lockDoor ) // ...schedule (in minutes) to lock. + } + else if ((lock1.latestValue("lock") == "unlocked") && (evt.value == "open")) { // If a person opens an unlocked door... + unschedule( lockDoor ) // ...we don't need to lock it later. + } + else if ((lock1.latestValue("lock") == "unlocked") && (evt.value == "closed")) { // If a person closes an unlocked door... + //def delay = (minutesLater * 60) // runIn uses seconds + runIn( (minutesLater * 60), lockDoor ) // ...schedule (in minutes) to lock. + } + else { //Opening or Closing door when locked (in case you have a handle lock) + log.debug "Unlocking the door." + lock1.unlock() + if(location.contactBookEnabled) { + if ( recipients ) { + log.debug ( "Sending Push Notification..." ) + sendNotificationToContacts( "${lock1} unlocked after ${contact} was opened or closed when ${lock1} was locked!", recipients) + } + } + if ( phoneNumber ) { + log.debug("Sending text message...") + sendSms( phoneNumber, "${lock1} unlocked after ${contact} was opened or closed when ${lock1} was locked!") + } + } +} \ No newline at end of file diff --git a/official/every-element.groovy b/official/every-element.groovy new file mode 100755 index 0000000..417ae1c --- /dev/null +++ b/official/every-element.groovy @@ -0,0 +1,568 @@ +/** + * Every Element + * + * Copyright 2015 SmartThings + * + * 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: "Every Element", + namespace: "smartthings/examples", + author: "SmartThings", + description: "Every element demonstration app", + category: "SmartThings Internal", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + +preferences { + // landing page + page(name: "firstPage") + + // PageKit + page(name: "buttonsPage") + page(name: "imagePage") + page(name: "inputPage") + page(name: "inputBooleanPage") + page(name: "inputIconPage") + page(name: "inputImagePage") + page(name: "inputDevicePage") + page(name: "inputCapabilityPage") + page(name: "inputRoomPage") + page(name: "inputModePage") + page(name: "inputSelectionPage") + page(name: "inputHubPage") + page(name: "inputContactBookPage") + page(name: "inputTextPage") + page(name: "inputTimePage") + page(name: "appPage") + page(name: "hrefPage") + page(name: "paragraphPage") + page(name: "videoPage") + page(name: "labelPage") + page(name: "modePage") + + // Every element helper pages + page(name: "deadEnd", title: "Nothing to see here, move along.", content: "foo") + page(name: "flattenedPage") +} + +def firstPage() { + dynamicPage(name: "firstPage", title: "Where to first?", install: true, uninstall: true) { + section { + href(page: "appPage", title: "Element: 'app'") + href(page: "buttonsPage", title: "Element: 'buttons'") + href(page: "hrefPage", title: "Element: 'href'") + href(page: "imagePage", title: "Element: 'image'") + href(page: "inputPage", title: "Element: 'input'") + href(page: "labelPage", title: "Element: 'label'") + href(page: "modePage", title: "Element: 'mode'") + href(page: "paragraphPage", title: "Element: 'paragraph'") + href(page: "videoPage", title: "Element: 'video'") + } + section { + href(page: "flattenedPage", title: "All of the above elements on a single page") + } + } +} + +def inputPage() { + dynamicPage(name: "inputPage", title: "Links to every 'input' element") { + section { + href(page: "inputBooleanPage", title: "to boolean page") + href(page: "inputIconPage", title: "to icon page") + href(page: "inputImagePage", title: "to image page") + href(page: "inputSelectionPage", title: "to selection page") + href(page: "inputTextPage", title: "to text page") + href(page: "inputTimePage", title: "to time page") + } + section("subsets of selection input") { + href(page: "inputDevicePage", title: "to device selection page") + href(page: "inputCapabilityPage", title: "to capability selection page") + href(page: "inputRoomPage", title: "to room selection page") + href(page: "inputModePage", title: "to mode selection page") + href(page: "inputHubPage", title: "to hub selection page") + href(page: "inputContactBookPage", title: "to contact-book selection page") + } + } +} + +def inputBooleanPage() { + dynamicPage(name: "inputBooleanPage") { + section { + paragraph "The `required` and `multiple` attributes have no effect because the value will always be either `true` or `false`" + } + section { + input(type: "boolean", name: "booleanWithoutDescription", title: "without description", description: null) + input(type: "boolean", name: "booleanWithDescription", title: "with description", description: "This has a description") + } + section("defaultValue: 'true'") { + input(type: "boolean", name: "booleanWithDefaultValue", title: "", description: "", defaultValue: "true") + } + section("with image") { + input(type: "boolean", name: "booleanWithoutDescriptionWithImage", title: "without description", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", description: null) + input(type: "boolean", name: "booleanWithDescriptionWithImage", title: "with description", description: "This has a description", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} +def inputIconPage() { + dynamicPage(name: "inputIconPage") { + section { + paragraph "`description` is not displayed for icon elements" + paragraph "`multiple` has no effect because you can only choose a single icon" + } + section("required: true") { + input(type: "icon", name: "iconRequired", title: "without description", required: true) + input(type: "icon", name: "iconRequiredWithDescription", title: "with description", description: "this is a description", required: true) + } + section("with image") { + paragraph "The image specified will be replaced after an icon is selected" + input(type: "icon", name: "iconwithImage", title: "without description", required: false, image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} +def inputImagePage() { + dynamicPage(name: "inputImagePage") { + section { + paragraph "This only exists in DeviceTypes. Someone should do something about that. (glares at MikeDave)" + paragraph "Go to the device preferences of a Mobile Presence device to see it in action" + paragraph "If you try to set the value of this, it will not behave as it would in Device Preferences" + input(type: "image", title: "This is kind of what it looks like", required: false) + } + } +} + + +def optionsGroup(List groups, String title) { + def group = [values:[], order: groups.size()] + group.title = title ?: "" + groups << group + return groups +} +def addValues(List groups, String key, String value) { + def lastGroup = groups[-1] + lastGroup["values"] << [ + key: key, + value: value, + order: lastGroup["values"].size() + ] + return groups +} +def listToMap(List original) { + original.inject([:]) { result, v -> + result[v] = v + return result + } +} +def addGroup(List groups, String title, values) { + if (values instanceof List) { + values = listToMap(values) + } + + values.inject(optionsGroup(groups, title)) { result, k, v -> + return addValues(result, k, v) + } + return groups +} +def addGroup(values) { + addGroup([], null, values) +} +/* Example usage of options builder + +// Creating grouped options + def newGroups = [] + addGroup(newGroups, "first group", ["foo", "bar", "baz"]) + addGroup(newGroups, "second group", [zero: "zero", one: "uno", two: "dos", three: "tres"]) + +// simple list + addGroup(["a", "b", "c"]) + +// simple map + addGroup(["a": "yes", "b": "no", "c": "maybe"])​​​​ +*/ + + +def inputSelectionPage() { + + def englishOptions = ["One", "Two", "Three"] + def spanishOptions = ["Uno", "Dos", "Tres"] + def groupedOptions = [] + addGroup(groupedOptions, "English", englishOptions) + addGroup(groupedOptions, "Spanish", spanishOptions) + + dynamicPage(name: "inputSelectionPage") { + + section("options variations") { + paragraph "tap these elements and look at the differences when selecting an option" + input(type: "enum", name: "selectionSimple", title: "Simple options", description: "no separators in the selectable options", groupedOptions: addGroup(englishOptions + spanishOptions)) + input(type: "enum", name: "selectionGrouped", title: "Grouped options", description: "separate groups of options with headers", groupedOptions: groupedOptions) + } + + section("list vs map") { + paragraph "These should be identical in UI, but are different in code and will produce different settings" + input(type: "enum", name: "selectionList", title: "Choose a device", description: "settings will be something like ['Device1 Label']", groupedOptions: addGroup(["Device1 Label", "Device2 Label"])) + input(type: "enum", name: "selectionMap", title: "Choose a device", description: "settings will be something like ['device1-id']", groupedOptions: addGroup(["device1-id": "Device1 Label", "device2-id": "Device2 Label"])) + } + + section("segmented") { + paragraph "segmented should only work if there are either 2 or 3 options to choose from" + input(type: "enum", name: "selectionSegmented1", style: "segmented", title: "1 option", groupedOptions: addGroup(["One"])) + input(type: "enum", name: "selectionSegmented4", style: "segmented", title: "4 options", groupedOptions: addGroup(["One", "Two", "Three", "Four"])) + + paragraph "multiple and required will have no effect on segmented selection elements. There will always be exactly 1 option selected" + input(type: "enum", name: "selectionSegmented2", style: "segmented", title: "2 options", options: ["One", "Two"]) + input(type: "enum", name: "selectionSegmented3", style: "segmented", title: "3 options", options: ["One", "Two", "Three"]) + + paragraph "specifying defaultValue still works with segmented selection elements" + input(type: "enum", name: "selectionSegmentedWithDefault", title: "defaulted to 'two'", groupedOptions: addGroup(["One", "Two", "Three"]), defaultValue: "Two") + } + + section("required: true") { + input(type: "enum", name: "selectionRequired", title: "This is required", description: "It should look different when nothing is selected", groupedOptions: addGroup(["only option"]), required: true) + } + + section("multiple: true") { + input(type: "enum", name: "selectionMultiple", title: "This allows multiple selections", description: "It should look different when nothing is selected", groupedOptions: addGroup(["an option", "another option", "no way, one more?"]), multiple: true) + } + + section("with image") { + input(type: "enum", name: "selectionWithImage", title: "This has an image", description: "and a description", groupedOptions: addGroup(["an option", "another option", "no way, one more?"]), image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} +def inputTextPage() { + dynamicPage(name: "inputTextPage", title: "Every 'text' variation") { + section("style and functional differences") { + input(type: "text", name: "textRequired", title: "required: true", description: "This should look different when nothing has been entered", required: true) + input(type: "text", name: "textWithImage", title: "with image", description: "This should look different when nothing has been entered", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", required: false) + } + section("text") { + input(type: "text", name: "text", title: "This has an alpha-numeric keyboard", description: "no special formatting", required: false) + } + section("password") { + input(type: "password", name: "password", title: "This has an alpha-numeric keyboard", description: "masks value", required: false) + } + section("email") { + input(type: "email", name: "email", title: "This has an email-specific keyboard", description: "no special formatting", required: false) + } + section("phone") { + input(type: "phone", name: "phone", title: "This has a numeric keyboard", description: "formatted for phone numbers", required: false) + } + section("decimal") { + input(type: "decimal", name: "decimal", title: "This has an numeric keyboard with decimal point", description: "no special formatting", required: false) + } + section("number") { + input(type: "number", name: "number", title: "This has an numeric keyboard without decimal point", description: "no special formatting", required: false) + } + + section("specified ranges") { + paragraph "You can limit number and decimal inputs to a specific range." + input(range: "50..150", type: "decimal", name: "decimalRange50..150", title: "only values between 50 and 150 will pass validation", description: "no special formatting", required: false) + paragraph "Negative limits will add a negative symbol to the keyboard." + input(range: "-50..50", type: "number", name: "numberRange-50..50", title: "only values between -50 and 50 will pass validation", description: "no special formatting", required: false) + paragraph "Specify * to not limit one side or the other." + input(range: "*..0", type: "decimal", name: "decimalRange*..0", title: "only negative values will pass validation", description: "no special formatting", required: false) + input(range: "*..*", type: "number", name: "numberRange*..*", title: "only positive values will pass validation", description: "no special formatting", required: false) + paragraph "If you don't specify a range, it defaults to 0..*" + } + } +} +def inputTimePage() { + dynamicPage(name: "inputTimePage") { + section { + input(type: "time", name: "timeWithDescription", title: "a time picker", description: "with a description", required: false) + input(type: "time", name: "timeWithoutDescription", title: "without a description", description: null, required: false) + input(type: "time", name: "timeRequired", title: "required: true", required: true) + } + } +} + +/// selection subsets +def inputDevicePage() { + + dynamicPage(name: "inputDevicePage") { + + section("required: true") { + input(type: "device.switch", name: "deviceRequired", title: "This is required", description: "It should look different when nothing is selected") + } + + section("multiple: true") { + input(type: "device.switch", name: "deviceMultiple", title: "This is required", description: "It should look different when nothing is selected", multiple: true) + } + + section("with image") { + input(type: "device.switch", name: "deviceRequired", title: "This has an image", description: "and a description", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} +def inputCapabilityPage() { + + dynamicPage(name: "inputCapabilityPage") { + + section("required: true") { + input(type: "capability.switch", name: "capabilityRequired", title: "This is required", description: "It should look different when nothing is selected") + } + + section("multiple: true") { + input(type: "capability.switch", name: "capabilityMultiple", title: "This is required", description: "It should look different when nothing is selected", multiple: true) + } + + section("with image") { + input(type: "capability.switch", name: "capabilityRequired", title: "This has an image", description: "and a description", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} +def inputRoomPage() { + + dynamicPage(name: "inputRoomPage") { + + section("required: true") { + input(type: "room", name: "roomRequired", title: "This is required", description: "It should look different when nothing is selected") + } + + section("multiple: true") { + input(type: "room", name: "roomMultiple", title: "This is required", description: "It should look different when nothing is selected", multiple: true) + } + + section("with image") { + input(type: "room", name: "roomRequired", title: "This has an image", description: "and a description", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} +def inputModePage() { + + dynamicPage(name: "inputModePage") { + + section("required: true") { + input(type: "mode", name: "modeRequired", title: "This is required", description: "It should look different when nothing is selected") + } + + section("multiple: true") { + input(type: "mode", name: "modeMultiple", title: "This is required", description: "It should look different when nothing is selected", multiple: true) + } + + section("with image") { + input(type: "mode", name: "modeRequired", title: "This has an image", description: "and a description", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} +def inputHubPage() { + + dynamicPage(name: "inputHubPage") { + + section("required: true") { + input(type: "hub", name: "hubRequired", title: "This is required", description: "It should look different when nothing is selected") + } + + section("multiple: true") { + input(type: "hub", name: "hubMultiple", title: "This is required", description: "It should look different when nothing is selected", multiple: true) + } + + section("with image") { + input(type: "hub", name: "hubRequired", title: "This has an image", description: "and a description", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} +def inputContactBookPage() { + + dynamicPage(name: "inputContactBookPage") { + + section("required: true") { + input(type: "contact", name: "contactRequired", title: "This is required", description: "It should look different when nothing is selected") + } + + section("multiple: true") { + input(type: "contact", name: "contactMultiple", title: "This is required", description: "It should look different when nothing is selected", multiple: true) + } + + section("with image") { + input(type: "contact", name: "contactRequired", title: "This has an image", description: "and a description", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} + +def appPage() { + dynamicPage(name: "appPage", title: "Every 'app' type") { + section { + paragraph "These won't work unless you create a child SmartApp to link to... Sorry." + } + section("app") { + app( + name: "app", + title: "required:false, multiple:false", + required: false, + multiple: false, + namespace: "Steve", + appName: "Child SmartApp" + ) + app(name: "appRequired", title: "required:true", required: true, multiple: false, namespace: "Steve", appName: "Child SmartApp") + app(name: "appComplete", title: "state:complete", required: false, multiple: false, namespace: "Steve", appName: "Child SmartApp", state: "complete") + app(name: "appWithImage", title: "This element has an image and a long title.", description: "I am setting long title and descriptions to test the offset", required: false, multiple: false, image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", namespace: "Steve", appName: "Child SmartApp") + } + section("multiple:true") { + app(name: "appMultiple", title: "multiple:true", required: false, multiple: true, namespace: "Steve", appName: "Child SmartApp") + } + section("multiple:true with image") { + app(name: "appMultipleWithImage", title: "This element has an image and a long title.", description: "I am setting long title and descriptions to test the offset", required: false, multiple: true, image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", namespace: "Steve", appName: "Child SmartApp") + } + } +} + +def labelPage() { + dynamicPage(name: "labelPage", title: "Every 'Label' type") { + section("label") { + paragraph "The difference between a label element and a text input element is that the label element will effect the SmartApp directly by setting the label. An input element will place the set value in the SmartApp's settings." + paragraph "There are 3 here as an example. Never use more than 1 label element on a page." + label(name: "label", title: "required:false, multiple:false", required: false, multiple: false) + label(name: "labelRequired", title: "required:true", required: true, multiple: false) + label(name: "labelWithImage", title: "This element has an image and a long title.", description: "I am setting long title and descriptions to test the offset", required: false, image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} + +def modePage() { + dynamicPage(name: "modePage", title: "Every 'mode' type") { // TODO: finish this + section("mode") { + paragraph "The difference between a mode element and a mode input element is that the mode element will effect the SmartApp directly by setting the modes it executes in. A mode input element will place the set value in the SmartApp's settings." + paragraph "Another difference is that you can select 'All Modes' when choosing which mode the SmartApp should execute in. This is the same as selecting no modes. When a SmartApp does not have modes specified, it will execute in all modes." + paragraph "There are 4 here as an example. Never use more than 1 mode element on a page." + mode(name: "mode", title: "required:false, multiple:false", required: false, multiple: false) + mode(name: "modeRequired", title: "required:true", required: true, multiple: false) + mode(name: "modeMultiple", title: "multiple:true", required: false, multiple: true) + mode(name: "modeWithImage", title: "This element has an image and a long title.", description: "I am setting long title and descriptions to test the offset", required: false, multiple: true, image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + } + } +} + +def paragraphPage() { + dynamicPage(name: "paragraphPage", title: "Every 'paragraph' type") { + section("paragraph") { + paragraph "This is how you should make a paragraph element" + paragraph image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", "This is a long description, blah, blah, blah." + } + } +} + +def hrefPage() { + dynamicPage(name: "hrefPage", title: "Every 'href' variation") { + section("stylistic differences") { + href(page: "deadEnd", title: "state: 'complete'", description: "gives the appearance of an input that has been filled out", state: "complete") + href(page: "deadEnd", title: "with image", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png") + href(page: "deadEnd", title: "with image and description", description: "and state: 'complete'", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", state: "complete") + } + section("functional differences") { + href(page: "deadEnd", title: "to a page within the app") + href(url: "http://www.google.com", title: "to a url using all defaults") + href(url: "http://www.google.com", title: "external: true", description: "takes you outside the app", external: true) + } + } +} + +def buttonsPage() { + dynamicPage(name: "buttonsPage", title: "Every 'button' type") { + section("Simple Buttons") { + paragraph "If there are an odd number of buttons, the last button will span the entire view area." + buttons(name: "buttons1", title: "1 button", buttons: [ + [label: "foo", action: "foo"] + ]) + buttons(name: "buttons2", title: "2 buttons", buttons: [ + [label: "foo", action: "foo"], + [label: "bar", action: "bar"] + ]) + buttons(name: "buttons3", title: "3 buttons", buttons: [ + [label: "foo", action: "foo"], + [label: "bar", action: "bar"], + [label: "baz", action: "baz"] + ]) + buttons(name: "buttonsWithImage", title: "This element has an image and a long title.", description: "I am setting long title and descriptions to test the offset", image: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", buttons: [ + [label: "foo", action: "foo"], + [label: "bar", action: "bar"] + ]) + } + section("Colored Buttons") { + buttons(name: "buttonsColoredSpecial", title: "special strings", description: "SmartThings highly recommends using these colors", buttons: [ + [label: "complete", action: "bar", backgroundColor: "complete"], + [label: "required", action: "bar", backgroundColor: "required"] + ]) + buttons(name: "buttonsColoredHex", title: "hex values work", buttons: [ + [label: "bg: #000dff", action: "foo", backgroundColor: "#000dff"], + [label: "fg: #ffac00", action: "foo", color: "#ffac00"], + [label: "both fg and bg", action: "foo", color: "#ffac00", backgroundColor: "#000dff"] + ]) + buttons(name: "buttonsColoredString", title: "strings work too", buttons: [ + [label: "green", action: "foo", backgroundColor: "green"], + [label: "red", action: "foo", backgroundColor: "red"], + [label: "both fg and bg", action: "foo", color: "red", backgroundColor: "green"] + ]) + } + } + +} + +def imagePage() { + dynamicPage(name: "imagePage", title: "Every 'image' type") { // TODO: finish thise + section("image") { + image "http://f.cl.ly/items/1k1S0A0m3805402o3O12/20130915-191127.jpg" + image(name: "imageWithMultipleImages", title: "This element has an image and a long title.", description: "I am setting long title and descriptions to test the offset", required: false, images: ["https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", "http://f.cl.ly/items/1k1S0A0m3805402o3O12/20130915-191127.jpg"]) + } + } +} + +def videoPage() { + dynamicPage(name: "videoPage", title: "Every 'video' type") { // TODO: finish this + section("video") { + // TODO: update this when there is a videoElement method + element(name: "videoElement", element: "video", type: "video", title: "this is a video!", description: "I am setting long title and descriptions to test the offset", required: false, image: "http://f.cl.ly/items/0w0D1p0K2D0d190F3H3N/Image%202015-12-14%20at%207.57.27%20AM.jpg", video: "http://f.cl.ly/items/3O2L03471l2K3E3l3K1r/Zombie%20Kid%20Likes%20Turtles.mp4") + } + } +} + +def flattenedPage() { + def allSections = [] + firstPage().sections[0].body.each { hrefElement -> + if (hrefElement.name != "inputPage") { + // inputPage is a bunch of hrefs + allSections += "${hrefElement.page}"().sections + } + } + // collect the input elements + inputPage().sections.each { section -> + section.body.each { hrefElement -> + allSections += "${hrefElement.page}"().sections + } + } + def flattenedPage = dynamicPage(name: "flattenedPage", title: "All elements in one page!") {} + flattenedPage.sections = allSections + return flattenedPage +} + +def foo() { + dynamicPage(name: "deadEnd") { + section { } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. +} diff --git a/official/feed-my-pet.groovy b/official/feed-my-pet.groovy new file mode 100755 index 0000000..f35092b --- /dev/null +++ b/official/feed-my-pet.groovy @@ -0,0 +1,51 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Feed My Pet + * + * Author: SmartThings + */ +definition( + name: "Feed My Pet", + namespace: "smartthings", + author: "SmartThings", + description: "Setup a schedule for when your pet is fed. Purchase any SmartThings certified pet food feeder and install the Feed My Pet app, and set the time. You and your pet are ready to go. Your life just got smarter.", + category: "Pets", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/dogfood_feeder.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/dogfood_feeder@2x.png" +) + +preferences { + section("Choose your pet feeder...") { + input "feeder", "device.PetFeederShield", title: "Where?" + } + section("Feed my pet at...") { + input "time1", "time", title: "When?" + } +} + +def installed() +{ + schedule(time1, "scheduleCheck") +} + +def updated() +{ + unschedule() + schedule(time1, "scheduleCheck") +} + +def scheduleCheck() +{ + log.trace "scheduledFeeding" + feeder?.feed() +} diff --git a/official/flood-alert.groovy b/official/flood-alert.groovy new file mode 100755 index 0000000..f44aa48 --- /dev/null +++ b/official/flood-alert.groovy @@ -0,0 +1,73 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Flood Alert + * + * Author: SmartThings + */ +definition( + name: "Flood Alert!", + namespace: "smartthings", + author: "SmartThings", + description: "Get a push notification or text message when water is detected where it doesn't belong.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/water_moisture.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/water_moisture@2x.png" +) + +preferences { + section("When there's water detected...") { + input "alarm", "capability.waterSensor", title: "Where?" + } + section("Send a notification to...") { + input("recipients", "contact", title: "Recipients", description: "Send notifications to") { + input "phone", "phone", title: "Phone number?", required: false + } + } +} + +def installed() { + subscribe(alarm, "water.wet", waterWetHandler) +} + +def updated() { + unsubscribe() + subscribe(alarm, "water.wet", waterWetHandler) +} + +def waterWetHandler(evt) { + def deltaSeconds = 60 + + def timeAgo = new Date(now() - (1000 * deltaSeconds)) + def recentEvents = alarm.eventsSince(timeAgo) + log.debug "Found ${recentEvents?.size() ?: 0} events in the last $deltaSeconds seconds" + + def alreadySentSms = recentEvents.count { it.value && it.value == "wet" } > 1 + + if (alreadySentSms) { + log.debug "SMS already sent within the last $deltaSeconds seconds" + } else { + def msg = "${alarm.displayName} is wet!" + log.debug "$alarm is wet, texting phone number" + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + sendPush(msg) + if (phone) { + sendSms(phone, msg) + } + } + } +} + diff --git a/official/forgiving-security.groovy b/official/forgiving-security.groovy new file mode 100755 index 0000000..b8630c1 --- /dev/null +++ b/official/forgiving-security.groovy @@ -0,0 +1,119 @@ +/** + * Forgiving Security + * + * Author: brian@bevey.org + * Date: 10/25/13 + * + * Arm a simple security system based on mode. Has a grace period to allow an + * ever present lag in presence detection. + */ + +definition( + name: "Forgiving Security", + namespace: "imbrianj", + author: "brian@bevey.org", + description: "Alerts you if something happens while you're away. Has a settable grace period to compensate for presence sensors that may take a few seconds to be noticed.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("Things to secure?") { + input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false + input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + } + + section("Alarms to go off?") { + input "alarms", "capability.alarm", title: "Which Alarms?", multiple: true, required: false + input "lights", "capability.switch", title: "Turn on which lights?", multiple: true, required: false + } + + section("Delay for presence lag?") { + input name: "presenceDelay", type: "number", title: "Seconds (defaults to 15s)", required: false + } + + section("Notifications?") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + + section("Message interval?") { + input name: "messageDelay", type: "number", title: "Minutes (default to every message)", required: false + } +} + +def installed() { + init() +} + +def updated() { + unsubscribe() + init() +} + +def init() { + state.lastTrigger = now() + state.deviceTriggers = [] + subscribe(contacts, "contact.open", triggerAlarm) + subscribe(motions, "motion.active", triggerAlarm) +} + +def triggerAlarm(evt) { + def presenceDelay = presenceDelay ?: 15 + + if(now() - (presenceDelay * 1000) > state.lastTrigger) { + log.warn("Stale event - ignoring") + + state.deviceTriggers = [] + } + + state.deviceTriggers.add(evt.displayName) + state.triggerMode = location.mode + state.lastTrigger = now() + + log.info(evt.displayName + " triggered an alarm. Waiting for presence lag.") + runIn(presenceDelay, "fireAlarm") +} + +def fireAlarm() { + if(state.deviceTriggers.size() > 0) { + def devices = state.deviceTriggers.unique().join(", ") + + if(location.mode == state.triggerMode) { + log.info(devices + " alarm triggered and mode hasn't changed.") + send(devices + " alarm has been triggered!") + lights?.on() + alarms?.both() + } + + else { + log.info(devices + " alarm triggered, but it looks like you were just coming home. Ignoring.") + } + } + + state.deviceTriggers = [] +} + +private send(msg) { + def delay = (messageDelay != null && messageDelay != "") ? messageDelay * 60 * 1000 : 0 + + if(now() - delay > state.lastMessage) { + state.lastMessage = now() + if(sendPushMessage == "Yes") { + log.debug("Sending push message.") + sendPush(msg) + } + + if(phone) { + log.debug("Sending text message.") + sendSms(phone, msg) + } + + log.debug(msg) + } + + else { + log.info("Have a message to send, but user requested to not get it.") + } +} diff --git a/official/foscam-connect.groovy b/official/foscam-connect.groovy new file mode 100755 index 0000000..51ec6fa --- /dev/null +++ b/official/foscam-connect.groovy @@ -0,0 +1,248 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Foscam (connect) + * + * Author: smartthings + * Date: 2014-03-10 + */ + +definition( + name: "Foscam (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Connect and take pictures using your Foscam camera from inside the Smartthings app.", + category: "SmartThings Internal", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/foscam.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/foscam@2x.png", + singleInstance: true +) + +preferences { + page(name: "cameraDiscovery", title:"Foscam Camera Setup", content:"cameraDiscovery") + page(name: "loginToFoscam", title: "Foscam Login") +} + +//PAGES +///////////////////////////////////// +def cameraDiscovery() +{ + if(canInstallLabs()) + { + int refreshCount = !state.refreshCount ? 0 : state.refreshCount as int + state.refreshCount = refreshCount + 1 + def refreshInterval = 3 + + def options = camerasDiscovered() ?: [] + def numFound = options.size() ?: 0 + + if(!state.subscribe) { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + //bridge discovery request every + if((refreshCount % 5) == 0) { + discoverCameras() + } + + return dynamicPage(name:"cameraDiscovery", title:"Discovery Started!", nextPage:"loginToFoscam", refreshInterval:refreshInterval, uninstall: true) { + section("Please wait while we discover your Foscam. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedFoscam", "enum", required:false, title:"Select Foscam (${numFound} found)", multiple:true, options:options + } + } + } + else + { + def upgradeNeeded = """To use Foscam, your Hub should be completely up to date. + + To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"cameraDiscovery", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section("Upgrade") { + paragraph "$upgradeNeeded" + } + } + + } +} + +def loginToFoscam() { + def showUninstall = username != null && password != null + return dynamicPage(name: "loginToFoscam", title: "Foscam", uninstall:showUninstall, install:true,) { + section("Log in to Foscam") { + input "username", "text", title: "Username", required: true, autoCorrect:false + input "password", "password", title: "Password", required: true, autoCorrect:false + } + } +} +//END PAGES + +///////////////////////////////////// +private discoverCameras() +{ + //add type UDP_CLIENT + def action = new physicalgraph.device.HubAction("0b4D4F5F490000000000000000000000040000000400000000000001", physicalgraph.device.Protocol.LAN, "FFFFFFFF:2710") + action.options = [type:"LAN_TYPE_UDPCLIENT"] + sendHubCommand(action) +} + +def camerasDiscovered() { + def cameras = getCameras() + def map = [:] + cameras.each { + def value = it.value.name ?: "Foscam Camera" + def key = it.value.ip + ":" + it.value.port + map["${key}"] = value + } + map +} + +///////////////////////////////////// +def getCameras() +{ + state.cameras = state.cameras ?: [:] +} + +///////////////////////////////////// +def installed() { + //log.debug "Installed with settings: ${settings}" + initialize() + + runIn(300, "doDeviceSync" , [overwrite: false]) //setup ip:port syncing every 5 minutes + + //wait 5 seconds and get the deviceInfo + //log.info "calling 'getDeviceInfo()'" + //runIn(5, getDeviceInfo) +} + +///////////////////////////////////// +def updated() { + //log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +///////////////////////////////////// +def initialize() { + // remove location subscription aftwards + unsubscribe() + state.subscribe = false + + if (selectedFoscam) + { + addCameras() + } +} + +def addCameras() { + def cameras = getCameras() + + selectedFoscam.each { dni -> + def d = getChildDevice(dni) + + if(!d) + { + def newFoscam = cameras.find { (it.value.ip + ":" + it.value.port) == dni } + d = addChildDevice("smartthings", "Foscam", dni, newFoscam?.value?.hub, ["label":newFoscam?.value?.name ?: "Foscam Camera", "data":["mac": newFoscam?.value?.mac, "ip": newFoscam.value.ip, "port":newFoscam.value.port], "preferences":["username":username, "password":password]]) + + log.debug "created ${d.displayName} with id $dni" + } + else + { + log.debug "found ${d.displayName} with id $dni already exists" + } + } +} + +def getDeviceInfo() { + def devices = getAllChildDevices() + devices.each { d -> + d.getDeviceInfo() + } +} + +///////////////////////////////////// +def locationHandler(evt) { + /* + FOSCAM EXAMPLE + 4D4F5F4901000000000000000000006200000000000000 (SOF) //46 + 30303632364534443042344200 (mac) //26 + 466F7363616D5F44617274684D61756C0000000000 (name) //42 + 0A01652C (ip) //8 + FFFFFE00 (mask) //8 + 00000000 (gateway ip) //8 + 00000000 (dns) //8 + 01005800 (reserve) //8 + 01040108 (system software version) //8 + 020B0106 (app software version) //8 + 0058 (port) //4 + 01 (dhcp enabled) //2 + */ + def description = evt.description + def hub = evt?.hubId + + log.debug "GOT LOCATION EVT: $description" + + def parsedEvent = stringToMap(description) + + //FOSCAM does a UDP response with camera operate protocol:“MO_I” i.e. "4D4F5F49" + if (parsedEvent?.type == "LAN_TYPE_UDPCLIENT" && parsedEvent?.payload?.startsWith("4D4F5F49")) + { + def unpacked = [:] + unpacked.mac = parsedEvent.mac.toString() + unpacked.name = hexToString(parsedEvent.payload[72..113]).trim() + unpacked.ip = parsedEvent.payload[114..121] + unpacked.subnet = parsedEvent.payload[122..129] + unpacked.gateway = parsedEvent.payload[130..137] + unpacked.dns = parsedEvent.payload[138..145] + unpacked.reserve = parsedEvent.payload[146..153] + unpacked.sysVersion = parsedEvent.payload[154..161] + unpacked.appVersion = parsedEvent.payload[162..169] + unpacked.port = parsedEvent.payload[170..173] + unpacked.dhcp = parsedEvent.payload[174..175] + unpacked.hub = hub + + def cameras = getCameras() + if (!(cameras."${parsedEvent.mac.toString()}")) + { + cameras << [("${parsedEvent.mac.toString()}"):unpacked] + } + } +} + +///////////////////////////////////// +private Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +private Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +private List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} + +private String hexToString(String txtInHex) +{ + byte [] txtInByte = new byte [txtInHex.length() / 2]; + int j = 0; + for (int i = 0; i < txtInHex.length(); i += 2) + { + txtInByte[j++] = Byte.parseByte(txtInHex.substring(i, i + 2), 16); + } + return new String(txtInByte); +} diff --git a/official/garage-door-monitor.groovy b/official/garage-door-monitor.groovy new file mode 100755 index 0000000..cd07b23 --- /dev/null +++ b/official/garage-door-monitor.groovy @@ -0,0 +1,126 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Garage Door Monitor + * + * Author: SmartThings + */ +definition( + name: "Garage Door Monitor", + namespace: "smartthings", + author: "SmartThings", + description: "Monitor your garage door and get a text message if it is open too long", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact@2x.png" +) + +preferences { + section("When the garage door is open...") { + input "multisensor", "capability.threeAxis", title: "Which?" + } + section("For too long...") { + input "maxOpenTime", "number", title: "Minutes?" + } + section("Text me at (optional, sends a push notification if not specified)...") { + input("recipients", "contact", title: "Notify", description: "Send notifications to") { + input "phone", "phone", title: "Phone number?", required: false + } + } +} + +def installed() +{ + subscribe(multisensor, "acceleration", accelerationHandler) +} + +def updated() +{ + unsubscribe() + subscribe(multisensor, "acceleration", accelerationHandler) +} + +def accelerationHandler(evt) { + def latestThreeAxisState = multisensor.threeAxisState // e.g.: 0,0,-1000 + if (latestThreeAxisState) { + def isOpen = Math.abs(latestThreeAxisState.xyzValue.z) > 250 // TODO: Test that this value works in most cases... + def isNotScheduled = state.status != "scheduled" + + if (!isOpen) { + clearSmsHistory() + clearStatus() + } + + if (isOpen && isNotScheduled) { + runIn(maxOpenTime * 60, takeAction, [overwrite: false]) + state.status = "scheduled" + } + + } + else { + log.warn "COULD NOT FIND LATEST 3-AXIS STATE FOR: ${multisensor}" + } +} + +def takeAction(){ + if (state.status == "scheduled") + { + def deltaMillis = 1000 * 60 * maxOpenTime + def timeAgo = new Date(now() - deltaMillis) + def openTooLong = multisensor.threeAxisState.dateCreated.toSystemDate() < timeAgo + + def recentTexts = state.smsHistory.find { it.sentDate.toSystemDate() > timeAgo } + + if (!recentTexts) { + sendTextMessage() + } + runIn(maxOpenTime * 60, takeAction, [overwrite: false]) + } else { + log.trace "Status is no longer scheduled. Not sending text." + } +} + +def sendTextMessage() { + log.debug "$multisensor was open too long, texting phone" + + updateSmsHistory() + def openMinutes = maxOpenTime * (state.smsHistory?.size() ?: 1) + def msg = "Your ${multisensor.label ?: multisensor.name} has been open for more than ${openMinutes} minutes!" + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + if (phone) { + sendSms(phone, msg) + } else { + sendPush msg + } + } +} + +def updateSmsHistory() { + if (!state.smsHistory) state.smsHistory = [] + + if(state.smsHistory.size() > 9) { + log.debug "SmsHistory is too big, reducing size" + state.smsHistory = state.smsHistory[-9..-1] + } + state.smsHistory << [sentDate: new Date().toSystemFormat()] +} + +def clearSmsHistory() { + state.smsHistory = null +} + +def clearStatus() { + state.status = null +} diff --git a/official/garage-door-opener.groovy b/official/garage-door-opener.groovy new file mode 100755 index 0000000..30ffdf0 --- /dev/null +++ b/official/garage-door-opener.groovy @@ -0,0 +1,52 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Garage Door Opener + * + * Author: SmartThings + */ +definition( + name: "Garage Door Opener", + namespace: "smartthings", + author: "SmartThings", + description: "Open your garage door when a switch is turned on.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_outlet@2x.png" +) + +preferences { + section("When the garage door switch is turned on, open the garage door...") { + input "switch1", "capability.switch" + } +} + +def installed() { + subscribe(app, appTouchHandler) + subscribeToCommand(switch1, "on", onCommand) +} + +def updated() { + unsubscribe() + subscribe(app, appTouchHandler) + subscribeToCommand(switch1, "on", onCommand) +} + +def appTouch(evt) { + log.debug "appTouch: $evt.value, $evt" + switch1?.on() +} + +def onCommand(evt) { + log.debug "onCommand: $evt.value, $evt" + switch1?.off(delay: 3000) +} diff --git a/official/gentle-wake-up.groovy b/official/gentle-wake-up.groovy new file mode 100755 index 0000000..3fed2e8 --- /dev/null +++ b/official/gentle-wake-up.groovy @@ -0,0 +1,1108 @@ +/** + * Copyright 2016 SmartThings + * + * 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. + * + * Gentle Wake Up + * + * Author: Steve Vlaminck + * Date: 2013-03-11 + * + * https://s3.amazonaws.com/smartapp-icons/HealthAndWellness/App-SleepyTime.png + * https://s3.amazonaws.com/smartapp-icons/HealthAndWellness/App-SleepyTime%402x.png + * Gentle Wake Up turns on your lights slowly, allowing you to wake up more + * naturally. Once your lights have reached full brightness, optionally turn on + * more things, or send yourself a text for a more gentle nudge into the waking + * world (you may want to set your normal alarm as a backup plan). + * + */ +definition( + name: "Gentle Wake Up", + namespace: "smartthings", + author: "SmartThings", + description: "Dim your lights up slowly, allowing you to wake up more naturally.", + category: "Health & Wellness", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/HealthAndWellness/App-SleepyTime.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/HealthAndWellness/App-SleepyTime@2x.png" +) + +preferences { + page(name: "rootPage") + page(name: "schedulingPage") + page(name: "completionPage") + page(name: "numbersPage") + page(name: "controllerExplanationPage") + page(name: "unsupportedDevicesPage") +} + +def rootPage() { + dynamicPage(name: "rootPage", title: "", install: true, uninstall: true) { + + section("What to dim") { + input(name: "dimmers", type: "capability.switchLevel", title: "Dimmers", description: null, multiple: true, required: true, submitOnChange: true) + if (dimmers) { + if (dimmersContainUnsupportedDevices()) { + href(name: "toUnsupportedDevicesPage", page: "unsupportedDevicesPage", title: "Some of your selected dimmers don't seem to be supported", description: "Tap here to fix it", required: true) + } + href(name: "toNumbersPage", page: "numbersPage", title: "Duration & Direction", description: numbersPageHrefDescription(), state: "complete") + } + } + + if (dimmers) { + + section("Gentle Wake Up Has A Controller") { + href(title: "Learn how to control Gentle Wake Up", page: "controllerExplanationPage", description: null) + } + + section("Rules For Dimming") { + href(name: "toSchedulingPage", page: "schedulingPage", title: "Automation", description: schedulingHrefDescription() ?: "Set rules for when to start", state: schedulingHrefDescription() ? "complete" : "") + input(name: "manualOverride", type: "enum", options: ["cancel": "Cancel dimming", "jumpTo": "Jump to the end"], title: "When one of the dimmers is manually turned off…", description: "dimming will continue", required: false, multiple: false) + href(name: "toCompletionPage", title: "Completion Actions", page: "completionPage", state: completionHrefDescription() ? "complete" : "", description: completionHrefDescription() ?: "Set rules for what to do when dimming completes") + } + + section { + // TODO: fancy label + label(title: "Label This SmartApp", required: false, defaultValue: "", description: "Highly recommended", submitOnChange: true) + } + } + } +} + +def unsupportedDevicesPage() { + + def unsupportedDimmers = dimmers.findAll { !hasSetLevelCommand(it) } + + dynamicPage(name: "unsupportedDevicesPage") { + if (unsupportedDimmers) { + section("These devices do not support the setLevel command") { + unsupportedDimmers.each { + paragraph deviceLabel(it) + } + } + section { + input(name: "dimmers", type: "capability.sensor", title: "Please remove the above devices from this list.", submitOnChange: true, multiple: true) + } + section { + paragraph "If you think there is a mistake here, please contact support." + } + } else { + section { + paragraph "You're all set. You can hit the back button, now. Thanks for cleaning up your settings :)" + } + } + } +} + +def controllerExplanationPage() { + dynamicPage(name: "controllerExplanationPage", title: "How To Control Gentle Wake Up") { + + section("With other SmartApps", hideable: true, hidden: false) { + paragraph "When this SmartApp is installed, it will create a controller device which you can use in other SmartApps for even more customizable automation!" + paragraph "The controller acts like a switch so any SmartApp that can control a switch can control Gentle Wake Up, too!" + paragraph "Routines and 'Smart Lighting' are great ways to automate Gentle Wake Up." + } + + section("More about the controller", hideable: true, hidden: true) { + paragraph "You can find the controller with your other 'Things'. It will look like this." + image "http://f.cl.ly/items/2O0v0h41301U14042z3i/GentleWakeUpController-tile-stopped.png" + paragraph "You can start and stop Gentle Wake up by tapping the control on the right." + image "http://f.cl.ly/items/3W323J3M1b3K0k0V3X3a/GentleWakeUpController-tile-running.png" + paragraph "If you look at the device details screen, you will find even more information about Gentle Wake Up and more fine grain controls." + image "http://f.cl.ly/items/291s3z2I2Q0r2q0x171H/GentleWakeUpController-richTile-stopped.png" + paragraph "The slider allows you to jump to any point in the dimming process. Think of it as a percentage. If Gentle Wake Up is set to dim down as you fall asleep, but your book is just too good to put down; simply drag the slider to the left and Gentle Wake Up will give you more time to finish your chapter and drift off to sleep." + image "http://f.cl.ly/items/0F0N2G0S3v1q0L0R3J3Y/GentleWakeUpController-richTile-running.png" + paragraph "In the lower left, you will see the amount of time remaining in the dimming cycle. It does not count down evenly. Instead, it will update whenever the slider is updated; typically every 6-18 seconds depending on the duration of your dimming cycle." + paragraph "Of course, you may also tap the middle to start or stop the dimming cycle at any time." + } + + section("Starting and stopping the SmartApp itself", hideable: true, hidden: true) { + paragraph "Tap the 'play' button on the SmartApp to start or stop dimming." + image "http://f.cl.ly/items/0R2u1Z2H30393z2I2V3S/GentleWakeUp-appTouch2.png" + } + + section("Turning off devices while dimming", hideable: true, hidden: true) { + paragraph "It's best to use other Devices and SmartApps for triggering the Controller device. However, that isn't always an option." + paragraph "If you turn off a switch that is being dimmed, it will either continue to dim, stop dimming, or jump to the end of the dimming cycle depending on your settings." + paragraph "Unfortunately, some switches take a little time to turn off and may not finish turning off before Gentle Wake Up sets its dim level again. You may need to try a few times to get it to stop." + paragraph "That's why it's best to use devices that aren't currently dimming. Remember that you can use other SmartApps to toggle the controller. :)" + } + } +} + +def numbersPage() { + dynamicPage(name:"numbersPage", title:"") { + + section { + paragraph(name: "pGraph", title: "These lights will dim", fancyDeviceString(dimmers)) + } + + section { + input(name: "duration", type: "number", title: "For this many minutes", description: "30", required: false, defaultValue: 30) + } + + section { + input(name: "startLevel", type: "number", range: "0..99", title: "From this level", defaultValue: defaultStart(), description: "Current Level", required: false, multiple: false) + input(name: "endLevel", type: "number", range: "0..99", title: "To this level", defaultValue: defaultEnd(), description: "Between 0 and 99", required: true, multiple: false) + } + + def colorDimmers = dimmersWithSetColorCommand() + if (colorDimmers) { + section { + input(name: "colorize", type: "bool", title: "Gradually change the color of ${fancyDeviceString(colorDimmers)}", description: null, required: false, defaultValue: "true") + } + } + } +} + +def defaultStart() { + if (usesOldSettings() && direction && direction == "Down") { + return 99 + } + return 0 +} + +def defaultEnd() { + if (usesOldSettings() && direction && direction == "Down") { + return 0 + } + return 99 +} + +def startLevelLabel() { + if (usesOldSettings()) { // using old settings + if (direction && direction == "Down") { // 99 -> 1 + return "99%" + } + return "0%" + } + return hasStartLevel() ? "${startLevel}%" : "Current Level" +} + +def endLevelLabel() { + if (usesOldSettings()) { + if (direction && direction == "Down") { // 99 -> 1 + return "0%" + } + return "99%" + } + return "${endLevel}%" +} + +def weekdays() { + ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] +} + +def weekends() { + ["Saturday", "Sunday"] +} + +def schedulingPage() { + dynamicPage(name: "schedulingPage", title: "Rules For Automatically Dimming Your Lights") { + + section("Use Other SmartApps!") { + href(title: "Learn how to control Gentle Wake Up", page: "controllerExplanationPage", description: null) + } + + section("Allow Automatic Dimming") { + input(name: "days", type: "enum", title: "On These Days", description: "Every day", required: false, multiple: true, options: weekdays() + weekends()) + } + + section("Start Dimming...") { + input(name: "startTime", type: "time", title: "At This Time", description: null, required: false) + input(name: "modeStart", title: "When Entering This Mode", type: "mode", required: false, mutliple: false, submitOnChange: true, description: null) + if (modeStart) { + input(name: "modeStop", title: "Stop when leaving '${modeStart}' mode", type: "bool", required: false) + } + } + + } +} + +def completionPage() { + dynamicPage(name: "completionPage", title: "Completion Rules") { + + section("Switches") { + input(name: "completionSwitches", type: "capability.switch", title: "Set these switches", description: null, required: false, multiple: true, submitOnChange: true) + if (completionSwitches) { + input(name: "completionSwitchesState", type: "enum", title: "To", description: null, required: false, multiple: false, options: ["on", "off"], defaultValue: "on") + input(name: "completionSwitchesLevel", type: "number", title: "Optionally, Set Dimmer Levels To", description: null, required: false, multiple: false, range: "(0..99)") + } + } + + section("Notifications") { + input("recipients", "contact", title: "Send notifications to", required: false) { + input(name: "completionPhoneNumber", type: "phone", title: "Text This Number", description: "Phone number", required: false) + input(name: "completionPush", type: "bool", title: "Send A Push Notification", description: "Phone number", required: false) + } + input(name: "completionMusicPlayer", type: "capability.musicPlayer", title: "Speak Using This Music Player", required: false) + input(name: "completionMessage", type: "text", title: "With This Message", description: null, required: false) + } + + section("Modes and Phrases") { + input(name: "completionMode", type: "mode", title: "Change ${location.name} Mode To", description: null, required: false) + input(name: "completionPhrase", type: "enum", title: "Execute The Phrase", description: null, required: false, multiple: false, options: location.helloHome.getPhrases().label) + } + + section("Delay") { + input(name: "completionDelay", type: "number", title: "Delay This Many Minutes Before Executing These Actions", description: "0", required: false) + } + } +} + +// ======================================================== +// Handlers +// ======================================================== + +def installed() { + log.debug "Installing 'Gentle Wake Up' with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updating 'Gentle Wake Up' with settings: ${settings}" + unschedule() + + def controller = getController() + if (controller) { + controller.label = app.label + } + + initialize() +} + +private initialize() { + stop("settingsChange") + + if (startTime) { + log.debug "scheduling dimming routine to run at $startTime" + schedule(startTime, "scheduledStart") + } + + // TODO: make this an option + subscribe(app, appHandler) + + subscribe(location, locationHandler) + + if (manualOverride) { + subscribe(dimmers, "switch.off", stopDimmersHandler) + } + + if (!getAllChildDevices()) { + // create controller device and set name to the label used here + def dni = "${new Date().getTime()}" + log.debug "app.label: ${app.label}" + addChildDevice("smartthings", "Gentle Wake Up Controller", dni, null, ["label": app.label]) + state.controllerDni = dni + } +} + +def appHandler(evt) { + log.debug "appHandler evt: ${evt.value}" + if (evt.value == "touch") { + if (atomicState.running) { + stop("appTouch") + } else { + start("appTouch") + } + } +} + +def locationHandler(evt) { + log.debug "locationHandler evt: ${evt.value}" + + if (!modeStart) { + return + } + + def isSpecifiedMode = (evt.value == modeStart) + def modeStopIsTrue = (modeStop && modeStop != "false") + + if (isSpecifiedMode && canStartAutomatically()) { + start("modeChange") + } else if (!isSpecifiedMode && modeStopIsTrue) { + stop("modeChange") + } + +} + +def stopDimmersHandler(evt) { + log.trace "stopDimmersHandler evt: ${evt.value}" + def percentComplete = completionPercentage() + // Often times, the first thing we do is turn lights on or off so make sure we don't stop as soon as we start + if (percentComplete > 2 && percentComplete < 98) { + if (manualOverride == "cancel") { + log.debug "STOPPING in stopDimmersHandler" + stop("manualOverride") + } else if (manualOverride == "jumpTo") { + def end = dynamicEndLevel() + log.debug "Jumping to 99% complete in stopDimmersHandler" + jumpTo(99) + } + + } else { + log.debug "not stopping in stopDimmersHandler" + } +} + +// ======================================================== +// Scheduling +// ======================================================== + +def scheduledStart() { + if (canStartAutomatically()) { + start("schedule") + } +} + +public def start(source) { + log.trace "START" + + sendStartEvent(source) + + setLevelsInState() + + atomicState.running = true + + atomicState.start = new Date().getTime() + + schedule("0 * * * * ?", "healthCheck") + increment() +} + +public def stop(source) { + log.trace "STOP" + + sendStopEvent(source) + + atomicState.running = false + atomicState.start = 0 + + unschedule("healthCheck") +} + +private healthCheck() { + log.trace "'Gentle Wake Up' healthCheck" + + if (!atomicState.running) { + return + } + + increment() +} + +// ======================================================== +// Controller +// ======================================================== + +def sendStartEvent(source) { + log.trace "sendStartEvent(${source})" + def eventData = [ + name: "sessionStatus", + value: "running", + descriptionText: "${app.label} has started dimming", + displayed: true, + linkText: app.label, + isStateChange: true + ] + if (source == "modeChange") { + eventData.descriptionText += " because of a mode change" + } else if (source == "schedule") { + eventData.descriptionText += " as scheduled" + } else if (source == "appTouch") { + eventData.descriptionText += " because you pressed play on the app" + } else if (source == "controller") { + eventData.descriptionText += " because you pressed play on the controller" + } + + sendControllerEvent(eventData) +} + +def sendStopEvent(source) { + log.trace "sendStopEvent(${source})" + def eventData = [ + name: "sessionStatus", + value: "stopped", + descriptionText: "${app.label} has stopped dimming", + displayed: true, + linkText: app.label, + isStateChange: true + ] + if (source == "modeChange") { + eventData.descriptionText += " because of a mode change" + eventData.value += "cancelled" + } else if (source == "schedule") { + eventData.descriptionText = "${app.label} has finished dimming" + } else if (source == "appTouch") { + eventData.descriptionText += " because you pressed play on the app" + eventData.value += "cancelled" + } else if (source == "controller") { + eventData.descriptionText += " because you pressed stop on the controller" + eventData.value += "cancelled" + } else if (source == "settingsChange") { + eventData.descriptionText += " because the settings have changed" + eventData.value += "cancelled" + } else if (source == "manualOverride") { + eventData.descriptionText += " because the dimmer was manually turned off" + eventData.value += "cancelled" + } + + // send 100% completion event + sendTimeRemainingEvent(100) + + // send a non-displayed 0% completion to reset tiles + sendTimeRemainingEvent(0, false) + + // send sessionStatus event last so the event feed is ordered properly + sendControllerEvent(eventData) +} + +def sendTimeRemainingEvent(percentComplete, displayed = true) { + log.trace "sendTimeRemainingEvent(${percentComplete})" + + def percentCompleteEventData = [ + name: "percentComplete", + value: percentComplete as int, + displayed: displayed, + isStateChange: true + ] + sendControllerEvent(percentCompleteEventData) + + def duration = sanitizeInt(duration, 30) + def timeRemaining = duration - (duration * (percentComplete / 100)) + def timeRemainingEventData = [ + name: "timeRemaining", + value: displayableTime(timeRemaining), + displayed: displayed, + isStateChange: true + ] + sendControllerEvent(timeRemainingEventData) +} + +def sendControllerEvent(eventData) { + def controller = getController() + if (controller) { + controller.controllerEvent(eventData) + } +} + +def getController() { + def dni = state.controllerDni + if (!dni) { + log.warn "no controller dni" + return null + } + def controller = getChildDevice(dni) + if (!controller) { + log.warn "no controller" + return null + } + log.debug "controller: ${controller}" + return controller +} + +// ======================================================== +// Setting levels +// ======================================================== + + +private increment() { + + if (!atomicState.running) { + return + } + + def percentComplete = completionPercentage() + + if (percentComplete > 99) { + percentComplete = 99 + } + + updateDimmers(percentComplete) + + if (percentComplete < 99) { + + def runAgain = stepDuration() + log.debug "Rescheduling to run again in ${runAgain} seconds" + + runIn(runAgain, 'increment', [overwrite: true]) + + } else { + + int completionDelay = completionDelaySeconds() + if (completionDelay) { + log.debug "Finished with steps. Scheduling completion for ${completionDelay} second(s) from now" + runIn(completionDelay, 'completion', [overwrite: true]) + unschedule("healthCheck") + // don't let the health check start incrementing again while we wait for the delayed execution of completion + } else { + log.debug "Finished with steps. Execution completion" + completion() + } + + } +} + + +def updateDimmers(percentComplete) { + dimmers.each { dimmer -> + + def nextLevel = dynamicLevel(dimmer, percentComplete) + + if (nextLevel == 0) { + + dimmer.off() + + } else { + + def shouldChangeColors = (colorize && colorize != "false") + + if (shouldChangeColors && hasSetColorCommand(dimmer)) { + def hue = getHue(dimmer, nextLevel) + log.debug "Setting ${deviceLabel(dimmer)} level to ${nextLevel} and hue to ${hue}" + dimmer.setColor([hue: hue, saturation: 100, level: nextLevel]) + } else if (hasSetLevelCommand(dimmer)) { + log.debug "Setting ${deviceLabel(dimmer)} level to ${nextLevel}" + dimmer.setLevel(nextLevel) + } else { + log.warn "${deviceLabel(dimmer)} does not have setColor or setLevel commands." + } + + } + } + + sendTimeRemainingEvent(percentComplete) +} + +int dynamicLevel(dimmer, percentComplete) { + def start = atomicState.startLevels[dimmer.id] + def end = dynamicEndLevel() + + if (!percentComplete) { + return start + } + + def totalDiff = end - start + def actualPercentage = percentComplete / 100 + def percentOfTotalDiff = totalDiff * actualPercentage + + (start + percentOfTotalDiff) as int +} + +// ======================================================== +// Completion +// ======================================================== + +private completion() { + log.trace "Starting completion block" + + if (!atomicState.running) { + return + } + + stop("schedule") + + handleCompletionSwitches() + + handleCompletionMessaging() + + handleCompletionModesAndPhrases() +} + +private handleCompletionSwitches() { + completionSwitches.each { completionSwitch -> + + def isDimmer = hasSetLevelCommand(completionSwitch) + + if (completionSwitchesLevel && isDimmer) { + completionSwitch.setLevel(completionSwitchesLevel) + } else { + def command = completionSwitchesState ?: "on" + completionSwitch."${command}"() + } + } +} + +private handleCompletionMessaging() { + if (completionMessage) { + if (location.contactBookEnabled) { + sendNotificationToContacts(completionMessage, recipients) + } else { + if (completionPhoneNumber) { + sendSms(completionPhoneNumber, completionMessage) + } + if (completionPush) { + sendPush(completionMessage) + } + } + if (completionMusicPlayer) { + speak(completionMessage) + } + } +} + +private handleCompletionModesAndPhrases() { + + if (completionMode) { + setLocationMode(completionMode) + } + + if (completionPhrase) { + location.helloHome.execute(completionPhrase) + } + +} + +def speak(message) { + def sound = textToSpeech(message) + def soundDuration = (sound.duration as Integer) + 2 + log.debug "Playing $sound.uri" + completionMusicPlayer.playTrack(sound.uri) + log.debug "Scheduled resume in $soundDuration sec" + runIn(soundDuration, resumePlaying, [overwrite: true]) +} + +def resumePlaying() { + log.trace "resumePlaying()" + def sonos = completionMusicPlayer + if (sonos) { + def currentTrack = sonos.currentState("trackData").jsonValue + if (currentTrack.status == "playing") { + sonos.playTrack(currentTrack) + } else { + sonos.setTrack(currentTrack) + } + } +} + +// ======================================================== +// Helpers +// ======================================================== + +def setLevelsInState() { + def startLevels = [:] + dimmers.each { dimmer -> + if (usesOldSettings()) { + startLevels[dimmer.id] = defaultStart() + } else if (hasStartLevel()) { + startLevels[dimmer.id] = startLevel + } else { + def dimmerIsOff = dimmer.currentValue("switch") == "off" + startLevels[dimmer.id] = dimmerIsOff ? 0 : dimmer.currentValue("level") + } + } + + atomicState.startLevels = startLevels +} + +def canStartAutomatically() { + + def today = new Date().format("EEEE") + log.debug "today: ${today}, days: ${days}" + + if (!days || days.contains(today)) {// if no days, assume every day + return true + } + + log.trace "should not run" + return false +} + +def completionPercentage() { + log.trace "checkingTime" + + if (!atomicState.running) { + return + } + + def now = new Date().getTime() + def timeElapsed = now - atomicState.start + def totalRunTime = totalRunTimeMillis() ?: 1 + def percentComplete = timeElapsed / totalRunTime * 100 + log.debug "percentComplete: ${percentComplete}" + + return percentComplete +} + +int totalRunTimeMillis() { + int minutes = sanitizeInt(duration, 30) + convertToMillis(minutes) +} + +int convertToMillis(minutes) { + def seconds = minutes * 60 + def millis = seconds * 1000 + return millis +} + +def timeRemaining(percentComplete) { + def normalizedPercentComplete = percentComplete / 100 + def duration = sanitizeInt(duration, 30) + def timeElapsed = duration * normalizedPercentComplete + def timeRemaining = duration - timeElapsed + return timeRemaining +} + +int millisToEnd(percentComplete) { + convertToMillis(timeRemaining(percentComplete)) +} + +String displayableTime(timeRemaining) { + def timeString = "${timeRemaining}" + def parts = timeString.split(/\./) + if (!parts.size()) { + return "0:00" + } + def minutes = parts[0] + if (parts.size() == 1) { + return "${minutes}:00" + } + def fraction = "0.${parts[1]}" as double + def seconds = "${60 * fraction as int}".padLeft(2, "0") + return "${minutes}:${seconds}" +} + +def jumpTo(percentComplete) { + def millisToEnd = millisToEnd(percentComplete) + def endTime = new Date().getTime() + millisToEnd + def duration = sanitizeInt(duration, 30) + def durationMillis = convertToMillis(duration) + def shiftedStart = endTime - durationMillis + atomicState.start = shiftedStart + updateDimmers(percentComplete) + sendTimeRemainingEvent(percentComplete) +} + + +int dynamicEndLevel() { + if (usesOldSettings()) { + if (direction && direction == "Down") { + return 0 + } + return 99 + } + return endLevel as int +} + +def getHue(dimmer, level) { + def start = atomicState.startLevels[dimmer.id] as int + def end = dynamicEndLevel() + if (start > end) { + return getDownHue(level) + } else { + return getUpHue(level) + } +} + +def getUpHue(level) { + getBlueHue(level) +} + +def getDownHue(level) { + getRedHue(level) +} + +private getBlueHue(level) { + if (level < 5) return 72 + if (level < 10) return 71 + if (level < 15) return 70 + if (level < 20) return 69 + if (level < 25) return 68 + if (level < 30) return 67 + if (level < 35) return 66 + if (level < 40) return 65 + if (level < 45) return 64 + if (level < 50) return 63 + if (level < 55) return 62 + if (level < 60) return 61 + if (level < 65) return 60 + if (level < 70) return 59 + if (level < 75) return 58 + if (level < 80) return 57 + if (level < 85) return 56 + if (level < 90) return 55 + if (level < 95) return 54 + if (level >= 95) return 53 +} + +private getRedHue(level) { + if (level < 6) return 1 + if (level < 12) return 2 + if (level < 18) return 3 + if (level < 24) return 4 + if (level < 30) return 5 + if (level < 36) return 6 + if (level < 42) return 7 + if (level < 48) return 8 + if (level < 54) return 9 + if (level < 60) return 10 + if (level < 66) return 11 + if (level < 72) return 12 + if (level < 78) return 13 + if (level < 84) return 14 + if (level < 90) return 15 + if (level < 96) return 16 + if (level >= 96) return 17 +} + +private dimmersContainUnsupportedDevices() { + def found = dimmers.find { hasSetLevelCommand(it) == false } + return found != null +} + +private hasSetLevelCommand(device) { + return hasCommand(device, "setLevel") +} + +private hasSetColorCommand(device) { + return hasCommand(device, "setColor") +} + +private hasCommand(device, String command) { + return (device.supportedCommands.find { it.name == command } != null) +} + +private dimmersWithSetColorCommand() { + def colorDimmers = [] + dimmers.each { dimmer -> + if (hasSetColorCommand(dimmer)) { + colorDimmers << dimmer + } + } + return colorDimmers +} + +private int sanitizeInt(i, int defaultValue = 0) { + try { + if (!i) { + return defaultValue + } else { + return i as int + } + } + catch (Exception e) { + log.debug e + return defaultValue + } +} + +private completionDelaySeconds() { + int completionDelayMinutes = sanitizeInt(completionDelay) + int completionDelaySeconds = (completionDelayMinutes * 60) + return completionDelaySeconds ?: 0 +} + +private stepDuration() { + int minutes = sanitizeInt(duration, 30) + int stepDuration = (minutes * 60) / 100 + return stepDuration ?: 1 +} + +private debug(message) { + log.debug "${message}\nstate: ${state}" +} + +public smartThingsDateFormat() { "yyyy-MM-dd'T'HH:mm:ss.SSSZ" } + +public humanReadableStartDate() { + new Date().parse(smartThingsDateFormat(), startTime).format("h:mm a", timeZone(startTime)) +} + +def fancyString(listOfStrings) { + + def fancify = { list -> + return list.collect { + def label = it + if (list.size() > 1 && it == list[-1]) { + label = "and ${label}" + } + label + }.join(", ") + } + + return fancify(listOfStrings) +} + +def fancyDeviceString(devices = []) { + fancyString(devices.collect { deviceLabel(it) }) +} + +def deviceLabel(device) { + return device.label ?: device.name +} + +def schedulingHrefDescription() { + + def descriptionParts = [] + if (days) { + if (days == weekdays()) { + descriptionParts << "On weekdays," + } else if (days == weekends()) { + descriptionParts << "On weekends," + } else { + descriptionParts << "On ${fancyString(days)}," + } + } + + descriptionParts << "${fancyDeviceString(dimmers)} will start dimming" + + if (startTime) { + descriptionParts << "at ${humanReadableStartDate()}" + } + + if (modeStart) { + if (startTime) { + descriptionParts << "or" + } + descriptionParts << "when ${location.name} enters '${modeStart}' mode" + } + + if (descriptionParts.size() <= 1) { + // dimmers will be in the list no matter what. No rules are set if only dimmers are in the list + return null + } + + return descriptionParts.join(" ") +} + +def completionHrefDescription() { + + def descriptionParts = [] + def example = "Switch1 will be turned on. Switch2, Switch3, and Switch4 will be dimmed to 50%. The message '' will be spoken, sent as a text, and sent as a push notification. The mode will be changed to ''. The phrase '' will be executed" + + if (completionSwitches) { + def switchesList = [] + def dimmersList = [] + + + completionSwitches.each { + def isDimmer = completionSwitchesLevel ? hasSetLevelCommand(it) : false + + if (isDimmer) { + dimmersList << deviceLabel(it) + } + + if (!isDimmer) { + switchesList << deviceLabel(it) + } + } + + + if (switchesList) { + descriptionParts << "${fancyString(switchesList)} will be turned ${completionSwitchesState ?: 'on'}." + } + + if (dimmersList) { + descriptionParts << "${fancyString(dimmersList)} will be dimmed to ${completionSwitchesLevel}%." + } + + } + + if (completionMessage && (completionPhoneNumber || completionPush || completionMusicPlayer)) { + def messageParts = [] + + if (completionMusicPlayer) { + messageParts << "spoken" + } + if (completionPhoneNumber) { + messageParts << "sent as a text" + } + if (completionPush) { + messageParts << "sent as a push notification" + } + + descriptionParts << "The message '${completionMessage}' will be ${fancyString(messageParts)}." + } + + if (completionMode) { + descriptionParts << "The mode will be changed to '${completionMode}'." + } + + if (completionPhrase) { + descriptionParts << "The phrase '${completionPhrase}' will be executed." + } + + return descriptionParts.join(" ") +} + +def numbersPageHrefDescription() { + def title = "All dimmers will dim for ${duration ?: '30'} minutes from ${startLevelLabel()} to ${endLevelLabel()}" + if (colorize) { + def colorDimmers = dimmersWithSetColorCommand() + if (colorDimmers == dimmers) { + title += " and will gradually change color." + } else { + title += ".\n${fancyDeviceString(colorDimmers)} will gradually change color." + } + } + return title +} + +def hueSatToHex(h, s) { + def convertedRGB = hslToRgb(h, s, 0.5) + return rgbToHex(convertedRGB) +} + +def hslToRgb(h, s, l) { + def r, g, b; + + if (s == 0) { + r = g = b = l; // achromatic + } else { + def hue2rgb = { p, q, t -> + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + } + + def q = l < 0.5 ? l * (1 + s) : l + s - l * s; + def p = 2 * l - q; + + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + return [r * 255, g * 255, b * 255]; +} + +def rgbToHex(red, green, blue) { + def toHex = { + int n = it as int; + n = Math.max(0, Math.min(n, 255)); + def hexOptions = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"] + + def firstDecimal = ((n - n % 16) / 16) as int + def secondDecimal = (n % 16) as int + + return "${hexOptions[firstDecimal]}${hexOptions[secondDecimal]}" + } + + def rgbToHex = { r, g, b -> + return toHex(r) + toHex(g) + toHex(b) + } + + return rgbToHex(red, green, blue) +} + +def usesOldSettings() { + !hasEndLevel() +} + +def hasStartLevel() { + return (startLevel != null && startLevel != "") +} + +def hasEndLevel() { + return (endLevel != null && endLevel != "") +} diff --git a/official/gideon-smart-home.groovy b/official/gideon-smart-home.groovy new file mode 100755 index 0000000..a198d72 --- /dev/null +++ b/official/gideon-smart-home.groovy @@ -0,0 +1,794 @@ +/** + * Gideon + * + * Copyright 2016 Nicola Russo + * + * 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: "Gideon Smart Home", + namespace: "gideon.api", + author: "Braindrain Solutions ltd", + description: "Gideon Smart Home SmartApp allows you to connect and control all of your SmartThings devices through the Gideon app, making your SmartThings devices even smarter.", + category: "Family", + iconUrl: "http://s33.postimg.org/t77u7y7v3/logo.png", + iconX2Url: "http://s33.postimg.org/t77u7y7v3/logo.png", + iconX3Url: "http://s33.postimg.org/t77u7y7v3/logo.png", + oauth: [displayName: "Gideon Smart Home API app", displayLink: "gideon.ai"]) + +preferences { + section("Control these contact sensors...") { + input "contact", "capability.contactSensor", multiple:true, required:false + } + section("Control these switch levels...") { + input "switchlevels", "capability.switchLevel", multiple:true, required:false + } +/* section("Control these thermostats...") { + input "thermostats", "capability.thermostat", multiple:true, required:false + }*/ + section("Control the color for these devices...") { + input "colors", "capability.colorControl", multiple:true, required:false + } + section("Control the color temperature for these devices...") { + input "kelvin", "capability.colorTemperature", multiple:true, required:false + } + section("Control these switches...") { + input "switches", "capability.switch", multiple:true, required:false + } + section("Control these smoke alarms...") { + input "smoke_alarms", "capability.smokeDetector", multiple:true, required:false + } + section("Control these window shades...") { + input "shades", "capability.windowShade", multiple:true, required:false + } + section("Control these garage doors...") { + input "garage", "capability.garageDoorControl", multiple:true, required:false + } + section("Control these water sensors...") { + input "water_sensors", "capability.waterSensor", multiple:true, required:false + } + section("Control these motion sensors...") { + input "motions", "capability.motionSensor", multiple:true, required:false + } + section("Control these presence sensors...") { + input "presence_sensors", "capability.presenceSensor", multiple:true, required:false + } + section("Control these outlets...") { + input "outlets", "capability.outlet", multiple:true, required:false + } + section("Control these power meters...") { + input "meters", "capability.powerMeter", multiple:true, required:false + } + section("Control these locks...") { + input "locks", "capability.lock", multiple:true, required:false + } + section("Control these temperature sensors...") { + input "temperature_sensors", "capability.temperatureMeasurement", multiple:true, required:false + } + section("Control these batteries...") { + input "batteries", "capability.battery", multiple:true, required:false + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { +} + +private device(it, type) { + it ? [id: it.id, label: it.label, type: type] : null +} + +//API Mapping +mappings { + path("/getalldevices") { + action: [ + GET: "getAllDevices" + ] + } + /* + path("/thermostat/setcool/:id/:temp") { + action: [ + GET: "setCoolTemp" + ] + } + path("/thermostat/setheat/:id/:temp") { + action: [ + GET: "setHeatTemp" + ] + } + path("/thermostat/setfanmode/:id/:mode") { + action: [ + GET: "setFanMode" + ] + } + path("/thermostat/setmode/:id/:mode") { + action: [ + GET: "setThermostatMode" + ] + } + path("/thermostat/:id") { + action: [ + GET: "getThermostatStatus" + ] + } + */ + path("/light/dim/:id/:dim") { + action: [ + GET: "setLevelStatus" + ] + } + path("/light/kelvin/:id/:kelvin") { + action: [ + GET: "setKelvin" + ] + } + path("/colorlight/:id/:hue/:sat") { + action: [ + GET: "setColor" + ] + } + path("/light/status/:id") { + action: [ + GET: "getLightStatus" + ] + } + path("/light/on/:id") { + action: [ + GET: "turnOnLight" + ] + } + path("/light/off/:id") { + action: [ + GET: "turnOffLight" + ] + } + path("/doorlocks/lock/:id") { + action: [ + GET: "lockDoorLock" + ] + } + path("/doorlocks/unlock/:id") { + action: [ + GET: "unlockDoorLock" + ] + } + path("/doorlocks/:id") { + action: [ + GET: "getDoorLockStatus" + ] + } + path("/contacts/:id") { + action: [ + GET: "getContactStatus" + ] + } + path("/smoke/:id") { + action: [ + GET: "getSmokeStatus" + ] + } + path("/shades/open/:id") { + action: [ + GET: "openShade" + ] + } + path("/shades/preset/:id") { + action: [ + GET: "presetShade" + ] + } + path("/shades/close/:id") { + action: [ + GET: "closeShade" + ] + } + path("/shades/:id") { + action: [ + GET: "getShadeStatus" + ] +} + path("/garage/open/:id") { + action: [ + GET: "openGarage" + ] + } + path("/garage/close/:id") { + action: [ + GET: "closeGarage" + ] + } + path("/garage/:id") { + action: [ + GET: "getGarageStatus" + ] + } + path("/watersensors/:id") { + action: [ + GET: "getWaterSensorStatus" + ] + } + path("/tempsensors/:id") { + action: [ + GET: "getTempSensorsStatus" + ] + } + path("/meters/:id") { + action: [ + GET: "getMeterStatus" + ] + } + path("/batteries/:id") { + action: [ + GET: "getBatteryStatus" + ] + } + path("/presences/:id") { + action: [ + GET: "getPresenceStatus" + ] + } + path("/motions/:id") { + action: [ + GET: "getMotionStatus" + ] + } + path("/outlets/:id") { + action: [ + GET: "getOutletStatus" + ] + } + path("/outlets/turnon/:id") { + action: [ + GET: "turnOnOutlet" + ] + } + path("/outlets/turnoff/:id") { + action: [ + GET: "turnOffOutlet" + ] + } + path("/switches/turnon/:id") { + action: [ + GET: "turnOnSwitch" + ] + } + path("/switches/turnoff/:id") { + action: [ + GET: "turnOffSwitch" + ] + } + path("/switches/:id") { + action: [ + GET: "getSwitchStatus" + ] + } +} + +//API Methods +def getAllDevices() { + def locks_list = locks.collect{device(it,"Lock")} + /*def thermo_list = thermostats.collect{device(it,"Thermostat")}*/ + def colors_list = colors.collect{device(it,"Color")} + def kelvin_list = kelvin.collect{device(it,"Kelvin")} + def contact_list = contact.collect{device(it,"Contact Sensor")} + def smokes_list = smoke_alarms.collect{device(it,"Smoke Alarm")} + def shades_list = shades.collect{device(it,"Window Shade")} + def garage_list = garage.collect{device(it,"Garage Door")} + def water_sensors_list = water_sensors.collect{device(it,"Water Sensor")} + def presences_list = presence_sensors.collect{device(it,"Presence")} + def motions_list = motions.collect{device(it,"Motion")} + def outlets_list = outlets.collect{device(it,"Outlet")} + def switches_list = switches.collect{device(it,"Switch")} + def switchlevels_list = switchlevels.collect{device(it,"Switch Level")} + def temp_list = temperature_sensors.collect{device(it,"Temperature")} + def meters_list = meters.collect{device(it,"Power Meters")} + def battery_list = batteries.collect{device(it,"Batteries")} + return outlets_list + kelvin_list + colors_list + switchlevels_list + smokes_list + contact_list + water_sensors_list + shades_list + garage_list + locks_list + presences_list + motions_list + switches_list + temp_list + meters_list + battery_list +} + +//thermostat +/* +def setCoolTemp() { + def device = thermostats.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + if(device.hasCommand("setCoolingSetpoint")) { + device.setCoolingSetpoint(params.temp.toInteger()); + return [result_action: "200"] + } + else { + httpError(510, "Not supported!") + } + } +} +def setHeatTemp() { + def device = thermostats.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + if(device.hasCommand("setHeatingSetpoint")) { + device.setHeatingSetpoint(params.temp.toInteger()); + return [result_action: "200"] + } + else { + httpError(510, "Not supported!") + } + } +} +def setFanMode() { + def device = thermostats.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + if(device.hasCommand("setThermostatFanMode")) { + device.setThermostatFanMode(params.mode); + return [result_action: "200"] + } + else { + httpError(510, "Not supported!") + } + } +} +def setThermostatMode() { + def device = thermostats.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + if(device.hasCommand("setThermostatMode")) { + device.setThermostatMode(params.mode); + return [result_action: "200"] + } + else { + httpError(510, "Not supported!") + } + } +} +def getThermostatStatus() { + def device = thermostats.find{ it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [ThermostatOperatingState: device.currentValue('thermostatOperatingState'), ThermostatSetpoint: device.currentValue('thermostatSetpoint'), + ThermostatFanMode: device.currentValue('thermostatFanMode'), ThermostatMode: device.currentValue('thermostatMode')] + } +} +*/ +//light +def turnOnLight() { + def device = switches.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + device.on(); + + return [Device_id: params.id, result_action: "200"] + } + } + +def turnOffLight() { + def device = switches.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + device.off(); + + return [Device_id: params.id, result_action: "200"] + } +} + +def getLightStatus() { + def device = switches.find{ it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Status: device.currentValue('switch'), Dim: getLevelStatus(params.id), Color: getColorStatus(params.id), Kelvin: getKelvinStatus(params.id)] + } +} + +//color control +def setColor() { + def device = colors.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + def map = [hue:params.hue.toInteger(), saturation:params.sat.toInteger()] + + device.setColor(map); + + return [Device_id: params.id, result_action: "200"] + } +} + +def getColorStatus(id) { + def device = colors.find { it.id == id } + if (!device) { + return [Color: "none"] + } else { + return [hue: device.currentValue('hue'), saturation: device.currentValue('saturation')] + } +} + +//kelvin control +def setKelvin() { + def device = kelvin.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.setColorTemperature(params.kelvin.toInteger()); + + return [Device_id: params.id, result_action: "200"] + } +} + +def getKelvinStatus(id) { + def device = kelvin.find { it.id == id } + if (!device) { + return [kelvin: "none"] + } else { + return [kelvin: device.currentValue('colorTemperature')] + } +} + +//switch level +def getLevelStatus() { + def device = switchlevels.find { it.id == params.id } + if (!device) { + [Level: "No dimmer"] + } else { + return [Level: device.currentValue('level')] + } +} + +def getLevelStatus(id) { + def device = switchlevels.find { it.id == id } + if (!device) { + [Level: "No dimmer"] + } else { + return [Level: device.currentValue('level')] + } +} + + +def setLevelStatus() { + def device = switchlevels.find { it.id == params.id } + def level = params.dim + if (!device) { + httpError(404, "Device not found") + } else { + device.setLevel(level.toInteger()) + return [result_action: "200", Level: device.currentValue('level')] + } +} + + +//contact sensors +def getContactStatus() { + def device = contact.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + def args = getTempSensorsStatus(device.id) + return [Device_state: device.currentValue('contact')] + args + } +} + +//smoke detectors +def getSmokeStatus() { + def device = smoke_alarms.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + def bat = getBatteryStatus(device.id) + return [Device_state: device.currentValue('smoke')] + bat + } +} + +//garage +def getGarageStatus() { + def device = garage.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.currentValue('door')] + } +} + +def openGarage() { + def device = garage.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.open(); + + return [Device_id: params.id, result_action: "200"] + } + } + +def closeGarage() { + def device = garage.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.close(); + + return [Device_id: params.id, result_action: "200"] + } + } +//shades +def getShadeStatus() { + def device = shades.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.currentValue('windowShade')] + } +} + +def openShade() { + def device = shades.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.open(); + + return [Device_id: params.id, result_action: "200"] + } + } + +def presetShade() { + def device = shades.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.presetPosition(); + + return [Device_id: params.id, result_action: "200"] + } + } + +def closeShade() { + def device = shades.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.close(); + + return [Device_id: params.id, result_action: "200"] + } + } + +//water sensor +def getWaterSensorStatus() { + def device = water_sensors.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + def bat = getBatteryStatus(device.id) + return [Device_state: device.currentValue('water')] + bat + } +} +//batteries +def getBatteryStatus() { + def device = batteries.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.latestValue("battery")] + } +} + +def getBatteryStatus(id) { + def device = batteries.find { it.id == id } + if (!device) { + return [] + } else { + return [battery_state: device.latestValue("battery")] + } +} + +//LOCKS +def getDoorLockStatus() { + def device = locks.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + def bat = getBatteryStatus(device.id) + return [Device_state: device.currentValue('lock')] + bat + } +} + +def lockDoorLock() { + def device = locks.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.lock(); + + return [Device_id: params.id, result_action: "200"] + } + } + +def unlockDoorLock() { + def device = locks.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.unlock(); + + return [Device_id: params.id, result_action: "200"] + } + } +//PRESENCE +def getPresenceStatus() { + + def device = presence_sensors.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + def bat = getBatteryStatus(device.id) + return [Device_state: device.currentValue('presence')] + bat + } +} + +//MOTION +def getMotionStatus() { + + def device = motions.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + def args = getTempSensorsStatus(device.id) + return [Device_state: device.currentValue('motion')] + args + } +} + +//OUTLET +def getOutletStatus() { + + def device = outlets.find { it.id == params.id } + if (!device) { + device = switches.find { it.id == params.id } + if(!device) { + httpError(404, "Device not found") + } + } + def watt = getMeterStatus(device.id) + + return [Device_state: device.currentValue('switch')] + watt +} + +def getMeterStatus() { + + def device = meters.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_id: device.id, Device_type: device.type, Current_watt: device.currentValue("power")] + } +} + +def getMeterStatus(id) { + + def device = meters.find { it.id == id } + if (!device) { + return [] + } else { + return [Current_watt: device.currentValue("power")] + } +} + + +def turnOnOutlet() { + def device = outlets.find { it.id == params.id } + if (!device) { + device = switches.find { it.id == params.id } + if(!device) { + httpError(404, "Device not found") + } + } + + device.on(); + + return [Device_id: params.id, result_action: "200"] +} + +def turnOffOutlet() { + def device = outlets.find { it.id == params.id } + if (!device) { + device = switches.find { it.id == params.id } + if(!device) { + httpError(404, "Device not found") + } + } + + device.off(); + + return [Device_id: params.id, result_action: "200"] +} + +//SWITCH +def getSwitchStatus() { + def device = switches.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.currentValue('switch'), Dim: getLevelStatus(params.id)] + } +} + +def turnOnSwitch() { + def device = switches.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.on(); + + return [Device_id: params.id, result_action: "200"] + } +} + +def turnOffSwitch() { + def device = switches.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + + device.off(); + + return [Device_id: params.id, result_action: "200"] + } +} + + +//TEMPERATURE +def getTempSensorsStatus() { + def device = temperature_sensors.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + def bat = getBatteryStatus(device.id) + def scale = [Scale: location.temperatureScale] + return [Device_state: device.currentValue('temperature')] + scale + bat + } +} + +def getTempSensorsStatus(id) { + def device = temperature_sensors.find { it.id == id } + if (!device) { + return [] + } else { + def bat = getBatteryStatus(device.id) + return [temperature: device.currentValue('temperature')] + bat + } + } \ No newline at end of file diff --git a/official/gideon.groovy b/official/gideon.groovy new file mode 100755 index 0000000..b277d25 --- /dev/null +++ b/official/gideon.groovy @@ -0,0 +1,253 @@ +/** + * Gideon + * + * Copyright 2016 Nicola Russo + * + * 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: "Gideon", + namespace: "gideon.api", + author: "Braindrain Solutions", + description: "Gideon AI Smart app allows you to connect and control all of your SmartThings devices through the Gideon AI app, making your SmartThings devices even smarter.", + category: "Family", + iconUrl: "http://s33.postimg.org/t77u7y7v3/logo.png", + iconX2Url: "http://s33.postimg.org/t77u7y7v3/logo.png", + iconX3Url: "http://s33.postimg.org/t77u7y7v3/logo.png", + oauth: [displayName: "Gideon AI API", displayLink: "gideon.ai"]) + + +preferences { + section("Control these switches...") { + input "switches", "capability.switch", multiple:true + } + section("Control these motion sensors...") { + input "motions", "capability.motionSensor", multiple:true + } + section("Control these presence sensors...") { + input "presence_sensors", "capability.presenceSensor", multiple:true + } + section("Control these outlets...") { + input "outlets", "capability.switch", multiple:true + } + section("Control these locks...") { + input "locks", "capability.lock", multiple:true + } + section("Control these locks...") { + input "temperature_sensors", "capability.temperatureMeasurement" + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. + subscribe(outlet, "energy", outletHandler) + subscribe(outlet, "switch", outletHandler) +} + +// TODO: implement event handlers +def outletHandler(evt) { + log.debug "$outlet.currentEnergy" + //TODO call G API +} + + +private device(it, type) { + it ? [id: it.id, label: it.label, type: type] : null +} + +//API Mapping +mappings { + path("/getalldevices") { + action: [ + GET: "getAllDevices" + ] + } + path("/doorlocks/:id/:command") { + action: [ + GET: "updateDoorLock" + ] + } + path("/doorlocks/:id") { + action: [ + GET: "getDoorLockStatus" + ] + } + path("/tempsensors/:id") { + action: [ + GET: "getTempSensorsStatus" + ] + } + path("/presences/:id") { + action: [ + GET: "getPresenceStatus" + ] + } + path("/motions/:id") { + action: [ + GET: "getMotionStatus" + ] + } + path("/outlets/:id") { + action: [ + GET: "getOutletStatus" + ] + } + path("/outlets/:id/:command") { + action: [ + GET: "updateOutlet" + ] + } + path("/switches/:command") { + action: [ + PUT: "updateSwitch" + ] + } +} + +//API Methods +def getAllDevices() { + def locks_list = locks.collect{device(it,"Lock")} + def presences_list = presence_sensors.collect{device(it,"Presence")} + def motions_list = motions.collect{device(it,"Motion")} + def outlets_list = outlets.collect{device(it,"Outlet")} + def switches_list = switches.collect{device(it,"Switch")} + def temp_list = temperature_sensors.collect{device(it,"Temperature")} + return [Locks: locks_list, Presences: presences_list, Motions: motions_list, Outlets: outlets_list, Switches: switches_list, Temperatures: temp_list] +} + +//LOCKS +def getDoorLockStatus() { + def device = locks.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.currentValue('lock')] + } +} + +def updateDoorLock() { + def command = params.command + def device = locks.find { it.id == params.id } + if (command){ + if (!device) { + httpError(404, "Device not found") + } else { + if(command == "toggle") + { + if(device.currentValue('lock') == "locked") + device.unlock(); + else + device.lock(); + + return [Device_id: params.id, result_action: "200"] + } + } + } +} + +//PRESENCE +def getPresenceStatus() { + + def device = presence_sensors.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.currentValue('presence')] + } +} + +//MOTION +def getMotionStatus() { + + def device = motions.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.currentValue('motion')] + } +} + +//OUTLET +def getOutletStatus() { + + def device = outlets.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.currentSwitch, Current_watt: device.currentValue("energy")] + } +} + +def updateOutlet() { + + def command = params.command + def device = outlets.find { it.id == params.id } + if (command){ + if (!device) { + httpError(404, "Device not found") + } else { + if(command == "toggle") + { + if(device.currentSwitch == "on") + device.off(); + else + device.on(); + + return [Device_id: params.id, result_action: "200"] + } + } + } +} + +//SWITCH +def updateSwitch() { + def command = params.command + def device = switches.find { it.id == params.id } + if (command){ + if (!device) { + httpError(404, "Device not found") + } else { + if(command == "toggle") + { + if(device.currentSwitch == "on") + device.off(); + else + device.on(); + + return [Device_id: params.id, result_action: "200"] + } + } + } +} + +//TEMPERATURE +def getTempSensorsStatus() { + + def device = temperature_sensors.find { it.id == params.id } + if (!device) { + httpError(404, "Device not found") + } else { + return [Device_state: device.currentValue('temperature')] + } +} \ No newline at end of file diff --git a/official/gidjit-hub.groovy b/official/gidjit-hub.groovy new file mode 100755 index 0000000..abd8f3f --- /dev/null +++ b/official/gidjit-hub.groovy @@ -0,0 +1,259 @@ +/** + * Gidjit Hub + * + * Copyright 2016 Matthew Page + * + * 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: "Gidjit Hub", + namespace: "com.gidjit.smartthings.hub", + author: "Matthew Page", + description: "Act as an endpoint so user's of Gidjit can quickly access and control their devices and execute routines. Users can do this quickly as Gidjit filters these actions based on their environment", + category: "Convenience", + iconUrl: "http://www.gidjit.com/appicon.png", + iconX2Url: "http://www.gidjit.com/appicon@2x.png", + iconX3Url: "http://www.gidjit.com/appicon@3x.png", + oauth: [displayName: "Gidjit", displayLink: "www.gidjit.com"]) + +preferences(oauthPage: "deviceAuthorization") { + // deviceAuthorization page is simply the devices to authorize + page(name: "deviceAuthorization", title: "Device Authorization", nextPage: "instructionPage", + install: false, uninstall: true) { + section ("Allow Gidjit to have access, thereby allowing you to quickly control and monitor your following devices. Privacy Policy can be found at http://priv.gidjit.com/privacy.html") { + input "switches", "capability.switch", title: "Control/Monitor your switches", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Control/Monitor your thermostats", multiple: true, required: false + input "windowShades", "capability.windowShade", title: "Control/Monitor your window shades", multiple: true, required: false //windowShade + } + + } + page(name: "instructionPage", title: "Device Discovery", install: true) { + section() { + paragraph "Now the process is complete return to the Devices section of the Detected Screen. From there and you can add actions to each of your device panels, including launching SmartThings routines." + } + } +} + +mappings { + path("/structureinfo") { + action: [ + GET: "structureInfo" + ] + } + path("/helloactions") { + action: [ + GET: "helloActions" + ] + } + path("/helloactions/:label") { + action: [ + PUT: "executeAction" + ] + } + + path("/switch/:id/:command") { + action: [ + PUT: "updateSwitch" + ] + } + + path("/thermostat/:id/:command") { + action: [ + PUT: "updateThermostat" + ] + } + + path("/windowshade/:id/:command") { + action: [ + PUT: "updateWindowShade" + ] + } + path("/acquiredata/:id") { + action: [ + GET: "acquiredata" + ] + } +} + + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + // subscribe to attributes, devices, locations, etc. +} +def helloActions() { + def actions = location.helloHome?.getPhrases()*.label + if(!actions) { + return [] + } + return actions +} +def executeAction() { + def actions = location.helloHome?.getPhrases()*.label + def a = actions?.find() { it == params.label } + if (!a) { + httpError(400, "invalid label $params.label") + return + } + location.helloHome?.execute(params.label) +} +/* this is the primary function called to query at the structure and its devices */ +def structureInfo() { //list all devices + def list = [:] + def currId = location.id + list[currId] = [:] + list[currId].name = location.name + list[currId].id = location.id + list[currId].temperatureScale = location.temperatureScale + list[currId].devices = [:] + + def setValues = { + if (params.brief) { + return [id: it.id, name: it.displayName] + } + def newList = [id: it.id, name: it.displayName, suppCapab: it.capabilities.collect { + "$it.name" + }, suppAttributes: it.supportedAttributes.collect { + "$it.name" + }, suppCommands: it.supportedCommands.collect { + "$it.name" + }] + + return newList + } + switches?.each { + list[currId].devices[it.id] = setValues(it) + } + thermostats?.each { + list[currId].devices[it.id] = setValues(it) + } + windowShades?.each { + list[currId].devices[it.id] = setValues(it) + } + + return list + +} +/* This function returns all of the current values of the specified Devices attributes */ +def acquiredata() { + def resp = [:] + if (!params.id) { + httpError(400, "invalid id $params.id") + return + } + def dev = switches.find() { it.id == params.id } ?: windowShades.find() { it.id == params.id } ?: + thermostats.find() { it.id == params.id } + + if (!dev) { + httpError(400, "invalid id $params.id") + return + } + def att = dev.supportedAttributes + att.each { + resp[it.name] = dev.currentValue("$it.name") + } + return resp +} + +void updateSwitch() { + // use the built-in request object to get the command parameter + def command = params.command + def sw = switches.find() { it.id == params.id } + if (!sw) { + httpError(400, "invalid id $params.id") + return + } + switch(command) { + case "on": + if ( sw.currentSwitch != "on" ) { + sw.on() + } + break + case "off": + if ( sw.currentSwitch != "off" ) { + sw.off() + } + break + default: + httpError(400, "$command is not a valid") + } +} + + +void updateThermostat() { + // use the built-in request object to get the command parameter + def command = params.command + def therm = thermostats.find() { it.id == params.id } + if (!therm || !command) { + httpError(400, "invalid id $params.id") + return + } + def passComm = [ + "off", + "heat", + "emergencyHeat", + "cool", + "fanOn", + "fanAuto", + "fanCirculate", + "auto" + + ] + def passNumParamComm = [ + "setHeatingSetpoint", + "setCoolingSetpoint", + ] + def passStringParamComm = [ + "setThermostatMode", + "setThermostatFanMode", + ] + if (command in passComm) { + therm."$command"() + } else if (command in passNumParamComm && params.p1 && params.p1.isFloat()) { + therm."$command"(Float.parseFloat(params.p1)) + } else if (command in passStringParamComm && params.p1) { + therm."$command"(params.p1) + } else { + httpError(400, "$command is not a valid command") + } +} + +void updateWindowShade() { + // use the built-in request object to get the command parameter + def command = params.command + def ws = windowShades.find() { it.id == params.id } + if (!ws || !command) { + httpError(400, "invalid id $params.id") + return + } + def passComm = [ + "open", + "close", + "presetPosition", + ] + if (command in passComm) { + ws."$command"() + } else { + httpError(400, "$command is not a valid command") + } +} +// TODO: implement event handlers \ No newline at end of file diff --git a/official/good-night-house.groovy b/official/good-night-house.groovy new file mode 100755 index 0000000..176a336 --- /dev/null +++ b/official/good-night-house.groovy @@ -0,0 +1,80 @@ +/** + * Good Night House + * + * Copyright 2014 Joseph Charette + * + * 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: "Good Night House", + namespace: "charette.joseph@gmail.com", + author: "Joseph Charette", + description: "Some on, some off with delay for bedtime, Lock The Doors", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +/** +* Borrowed code from +* Walk Gentle Into That Good Night +* +* Author: oneaccttorulethehouse@gmail.com +* Date: 2014-02-01 + */ + ) +preferences { + section("When I touch the app turn these lights off…"){ + input "switchesoff", "capability.switch", multiple: true, required:true + } + section("When I touch the app turn these lights on…"){ + input "switcheson", "capability.switch", multiple: true, required:false + } + section("Lock theses locks...") { + input "lock1","capability.lock", multiple: true + } + section("And change to this mode...") { + input "newMode", "mode", title: "Mode?" + } + section("After so many seconds (optional)"){ + input "waitfor", "number", title: "Off after (default 120)", required: true + } +} + + +def installed() +{ + log.debug "Installed with settings: ${settings}" + log.debug "Current mode = ${location.mode}" + subscribe(app, appTouch) +} + + +def updated() +{ + log.debug "Updated with settings: ${settings}" + log.debug "Current mode = ${location.mode}" + unsubscribe() + subscribe(app, appTouch) +} + +def appTouch(evt) { + log.debug "changeMode, location.mode = $location.mode, newMode = $newMode, location.modes = $location.modes" + if (location.mode != newMode) { + setLocationMode(newMode) + log.debug "Changed the mode to '${newMode}'" + } else { + log.debug "New mode is the same as the old mode, leaving it be" + } + log.debug "appTouch: $evt" + lock1.lock() + switcheson.on() + def delay = (waitfor != null && waitfor != "") ? waitfor * 1000 : 120000 + switchesoff.off(delay: delay) +} diff --git a/official/good-night.groovy b/official/good-night.groovy new file mode 100755 index 0000000..3561eab --- /dev/null +++ b/official/good-night.groovy @@ -0,0 +1,198 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Good Night + * + * Author: SmartThings + * Date: 2013-03-07 + */ +definition( + name: "Good Night", + namespace: "smartthings", + author: "SmartThings", + description: "Changes mode when motion ceases after a specific time of night.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/good-night.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/good-night@2x.png" +) + +preferences { + section("When there is no motion on any of these sensors") { + input "motionSensors", "capability.motionSensor", title: "Where?", multiple: true + } + section("For this amount of time") { + input "minutes", "number", title: "Minutes?" + } + section("After this time of day") { + input "timeOfDay", "time", title: "Time?" + } + section("And (optionally) these switches are all off") { + input "switches", "capability.switch", multiple: true, required: false + } + section("Change to this mode") { + input "newMode", "mode", title: "Mode?" + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to") { + input "sendPushMessage", "enum", title: "Send a push notification?", options: ["Yes", "No"], required: false + input "phoneNumber", "phone", title: "Send a Text Message?", required: false + } + } + +} + +def installed() { + log.debug "Current mode = ${location.mode}" + createSubscriptions() +} + +def updated() { + log.debug "Current mode = ${location.mode}" + unsubscribe() + createSubscriptions() +} + +def createSubscriptions() +{ + subscribe(motionSensors, "motion.active", motionActiveHandler) + subscribe(motionSensors, "motion.inactive", motionInactiveHandler) + subscribe(switches, "switch.off", switchOffHandler) + subscribe(location, modeChangeHandler) + + if (state.modeStartTime == null) { + state.modeStartTime = 0 + } +} + +def modeChangeHandler(evt) { + state.modeStartTime = now() +} + +def switchOffHandler(evt) { + if (correctMode() && correctTime()) { + if (allQuiet() && switchesOk()) { + takeActions() + } + } +} + +def motionActiveHandler(evt) +{ + log.debug "Motion active" +} + +def motionInactiveHandler(evt) +{ + // for backward compatibility + if (state.modeStartTime == null) { + subscribe(location, modeChangeHandler) + state.modeStartTime = 0 + } + + if (correctMode() && correctTime()) { + runIn(minutes * 60, scheduleCheck, [overwrite: false]) + } +} + +def scheduleCheck() +{ + log.debug "scheduleCheck, currentMode = ${location.mode}, newMode = $newMode" + + if (correctMode() && correctTime()) { + if (allQuiet() && switchesOk()) { + takeActions() + } + } +} + +private takeActions() { + def message = "Goodnight! SmartThings changed the mode to '$newMode'" + send(message) + setLocationMode(newMode) + log.debug message +} + +private correctMode() { + if (location.mode != newMode) { + true + } else { + log.debug "Location is already in the desired mode: doing nothing" + false + } +} + +private correctTime() { + def t0 = now() + def modeStartTime = new Date(state.modeStartTime) + def startTime = timeTodayAfter(modeStartTime, timeOfDay, location.timeZone) + if (t0 >= startTime.time) { + true + } else { + log.debug "The current time of day (${new Date(t0)}), is not in the correct time window ($startTime): doing nothing" + false + } +} + +private switchesOk() { + def result = true + for (it in (switches ?: [])) { + if (it.currentSwitch == "on") { + result = false + break + } + } + log.debug "Switches are all off: $result" + result +} + +private allQuiet() { + def threshold = 1000 * 60 * minutes - 1000 + def states = motionSensors.collect { it.currentState("motion") ?: [:] }.sort { a, b -> b.dateCreated <=> a.dateCreated } + if (states) { + if (states.find { it.value == "active" }) { + log.debug "Found active state" + false + } else { + def sensor = states.first() + def elapsed = now() - sensor.rawDateCreated.time + if (elapsed >= threshold) { + log.debug "No active states, and enough time has passed" + true + } else { + log.debug "No active states, but not enough time has passed" + false + } + } + } else { + log.debug "No states to check for activity" + true + } +} + +private send(msg) { + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + } + + log.debug msg +} diff --git a/official/goodnight-ubi.groovy b/official/goodnight-ubi.groovy new file mode 100755 index 0000000..ede8454 --- /dev/null +++ b/official/goodnight-ubi.groovy @@ -0,0 +1,117 @@ +/** + * Goodnight Ubi + * + * Copyright 2014 Christopher Boerma + * + * 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: "Goodnight Ubi", + namespace: "chrisb", + author: "chrisb", + description: "An app to coordinate bedtime activities between Ubi and SmartThings. This app will activate when a Virtual Tile is triggers (Setup custom behavior in Ubi to turn on this tile when you say goodnight to ubi). This app will then turn off selected lights after a specified number of minutes. It will also check if any doors or windows are open. If they are, Ubi will tell you which ones are open. Finally, the app will say goodnight to hello home if requested.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") + + +preferences { + section("Enter Ubi information:") { + input "behaviorToken", "text", title: "What is the Ubi Token?", required: true, autoCorrect:false + // Get token from the Ubi Portal. Select HTTP request as trigger and token will be displayed. + input "trigger", "capability.switch", title: "Which virtual tile is the trigger?", required: true + // Create a Virtual on/off button tile for this. + } + + section("Which doors and windows should I check?"){ + input "doors", "capability.contactSensor", multiple: true + } + + section("Which light switches will I be turning off?") { + input "theSwitches", "capability.switch", Title: "Which?", multiple: true, required: false + input "minutes", "number", Title: "After how many minutes?", required: true + } + section("Should I say 'Goodnight' to Hello Home?") { + input "sayPhrase", "enum", metadata:[values:["Yes","No"]] + } +} + + +def installed() { + log.debug "Installed with settings: ${settings}" + +initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(trigger, "switch.on", switchOnHandler) // User should set up on Ubi that when the choosen +} // trigger is said, Ubi turns on this virtual switch. + +def switchOnHandler(evt) { + log.debug "trigger turned on!" + + def timeDelay = minutes * 60 // convert minutes to seconds. + runIn (timeDelay, lightsOut) // schedule the lights out procedure + + def phrase = "" // Make sure Phrase is empty at the start of each run. + + doors.each { doorOpen -> // cycles through all contact sensor devices selected + if (doorOpen.currentContact == "open") { // if the current selected device is open, then: + log.debug "$doorOpen.displayName" // echo to the simulator the device's name + def toReplace = doorOpen.displayName // make variable 'toReplace' = the devices name. + def replaced = toReplace.replaceAll(' ', '%20') // make variable 'replaced' = 'toReplace' with all the space changed to %20 + log.debug replaced // echo to the simulator the new name. + + phrase = phrase.replaceAll('%20And%20', '%20') // Remove any previously added "and's" to make it sound natural. + + if (phrase == "") { // If Phrase is empty (ie, this is the first name to be added)... + phrase = "The%20" + replaced // ...then add "The%20" plus the device name. + } else { // If Phrase isn't empty... + phrase = phrase + ",%20And%20The%20" + replaced // ...then add ",%20And%20The%20". + } + + log.debug phrase // Echo the current version of 'Phrase' + } // Closes the IF statement. + } // Closes the doors.each cycle + + if (phrase == "") { + phrase = "The%20house%20is%20ready%20for%20night." + } + else { + phrase = "You%20have%20left%20" + phrase + "open" + } + + httpGet("https://portal.theubi.com/webapi/behaviour?access_token=${behaviorToken}&variable=${phrase}") + // send the http request and push the device name (replaced) as the variable. + // On the Ubi side you need to setup a custom behavior (which you've already done to get the token) + // and have say something like: "Hold on! The ${variable} is open!" Ubi will then take 'replaced' + // from this http request and insert it into the phrase that it says. + + if (sayPhrase == "Yes") { // If the user selected to say Goodnight... + location.helloHome.execute("Good Night!") // ...say goodnight to Hello Home. + } +} // Close the switchOnHandler Process + +def lightsOut() { + log.debug "Turning off trigger" + trigger.off() // Turn off the trigger tile button for next run + if (theSwitches == "") {} else { // If the user didn't enter any light to turn off, do nothing... + log.debug "Turning off switches" // ...but if the user did enter lights, then turn them + theSwitches.off() // off here. + } +} diff --git a/official/greetings-earthling.groovy b/official/greetings-earthling.groovy new file mode 100755 index 0000000..e97c684 --- /dev/null +++ b/official/greetings-earthling.groovy @@ -0,0 +1,108 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Greetings Earthling + * + * Author: SmartThings + * Date: 2013-03-07 + */ +definition( + name: "Greetings Earthling", + namespace: "smartthings", + author: "SmartThings", + description: "Monitors a set of presence detectors and triggers a mode change when someone arrives at home.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld@2x.png" +) + +preferences { + + section("When one of these people arrive at home") { + input "people", "capability.presenceSensor", multiple: true + } + section("Change to this mode") { + input "newMode", "mode", title: "Mode?" + } + section("False alarm threshold (defaults to 10 min)") { + input "falseAlarmThreshold", "decimal", title: "Number of minutes", required: false + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to") { + input "sendPushMessage", "enum", title: "Send a push notification?", options: ["Yes", "No"], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + // log.debug "Current mode = ${location.mode}, people = ${people.collect{it.label + ': ' + it.currentPresence}}" + subscribe(people, "presence", presence) +} + +def updated() { + log.debug "Updated with settings: ${settings}" + // log.debug "Current mode = ${location.mode}, people = ${people.collect{it.label + ': ' + it.currentPresence}}" + unsubscribe() + subscribe(people, "presence", presence) +} + +def presence(evt) +{ + log.debug "evt.name: $evt.value" + def threshold = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? (falseAlarmThreshold * 60 * 1000) as Long : 10 * 60 * 1000L + + if (location.mode != newMode) { + + def t0 = new Date(now() - threshold) + if (evt.value == "present") { + + def person = getPerson(evt) + def recentNotPresent = person.statesSince("presence", t0).find{it.value == "not present"} + if (recentNotPresent) { + log.debug "skipping notification of arrival of Person because last departure was only ${now() - recentNotPresent.date.time} msec ago" + } + else { + def message = "${person.displayName} arrived at home, changing mode to '${newMode}'" + send(message) + setLocationMode(newMode) + } + } + } + else { + log.debug "mode is the same, not evaluating" + } +} + +private getPerson(evt) +{ + people.find{evt.deviceId == it.id} +} + +private send(msg) { + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phone) { + log.debug("sending text message") + sendSms(phone, msg) + } + } +} diff --git a/official/habit-helper.groovy b/official/habit-helper.groovy new file mode 100755 index 0000000..64d2052 --- /dev/null +++ b/official/habit-helper.groovy @@ -0,0 +1,67 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Habit Helper + * Every day at a specific time, get a text reminding you about your habit + * + * Author: SmartThings + */ +definition( + name: "Habit Helper", + namespace: "smartthings", + author: "SmartThings", + description: "Add something you want to be reminded about each day and get a text message to help you form positive habits.", + category: "Family", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/text.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text@2x.png" +) + +preferences { + section("Remind me about..."){ + input "message1", "text", title: "What?" + } + section("At what time?"){ + input "time1", "time", title: "When?" + } + section("Text me at..."){ + input("recipients", "contact", title: "Send notifications to") { + input "phone1", "phone", title: "Phone number?" + } + } +} + +def installed() +{ + schedule(time1, "scheduleCheck") +} + +def updated() +{ + unschedule() + schedule(time1, "scheduleCheck") +} + +def scheduleCheck() +{ + log.trace "scheduledCheck" + + def message = message1 ?: "SmartThings - Habit Helper Reminder!" + + if (location.contactBookEnabled) { + log.debug "Texting reminder to contacts:${recipients?.size()}" + sendNotificationToContacts(message, recipients) + } + else { + log.debug "Texting reminder" + sendSms(phone1, message) + } +} diff --git a/official/hall-light-welcome-home.groovy b/official/hall-light-welcome-home.groovy new file mode 100755 index 0000000..e639187 --- /dev/null +++ b/official/hall-light-welcome-home.groovy @@ -0,0 +1,77 @@ +/** + * Hall Light: Welcome Home + * + * Author: brian@bevey.org + * Date: 9/25/13 + * + * Turn on the hall light if someone comes home (presence) and the door opens. + */ + +definition( + name: "Hall Light: Welcome Home", + namespace: "imbrianj", + author: "brian@bevey.org", + description: "Turn on the hall light if someone comes home (presence) and the door opens.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("People to watch for?") { + input "people", "capability.presenceSensor", multiple: true + } + + section("Front Door?") { + input "sensors", "capability.contactSensor", multiple: true + } + + section("Hall Light?") { + input "lights", "capability.switch", title: "Switch Turned On", multilple: true + } + + section("Presence Delay (defaults to 30s)?") { + input name: "presenceDelay", type: "number", title: "How Long?", required: false + } + + section("Door Contact Delay (defaults to 10s)?") { + input name: "contactDelay", type: "number", title: "How Long?", required: false + } +} + +def installed() { + init() +} + +def updated() { + unsubscribe() + init() +} + +def init() { + state.lastClosed = now() + subscribe(people, "presence.present", presence) + subscribe(sensors, "contact.open", doorOpened) +} + +def presence(evt) { + def delay = contactDelay ?: 10 + + state.lastPresence = now() + + if(now() - (delay * 1000) < state.lastContact) { + log.info('Presence was delayed, but you probably still want the light on.') + lights?.on() + } +} + +def doorOpened(evt) { + def delay = presenceDelay ?: 30 + + state.lastContact = now() + + if(now() - (delay * 1000) < state.lastPresence) { + log.info('Welcome home! Let me get that light for you.') + lights?.on() + } +} \ No newline at end of file diff --git a/official/has-barkley-been-fed.groovy b/official/has-barkley-been-fed.groovy new file mode 100755 index 0000000..28cca95 --- /dev/null +++ b/official/has-barkley-been-fed.groovy @@ -0,0 +1,75 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Has Barkley Been Fed + * + * Author: SmartThings + */ +definition( + name: "Has Barkley Been Fed?", + namespace: "smartthings", + author: "SmartThings", + description: "Setup a schedule to be reminded to feed your pet. Purchase any SmartThings certified pet food feeder and install the Feed My Pet app, and set the time. You and your pet are ready to go. Your life just got smarter.", + category: "Pets", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/dogfood_feeder.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/dogfood_feeder@2x.png" +) + +preferences { + section("Choose your pet feeder...") { + input "feeder1", "capability.contactSensor", title: "Where?" + } + section("Feed my pet at...") { + input "time1", "time", title: "When?" + } + section("Text me if I forget...") { + input("recipients", "contact", title: "Send notifications to") { + input "phone1", "phone", title: "Phone number?" + } + } +} + +def installed() +{ + schedule(time1, "scheduleCheck") +} + +def updated() +{ + unsubscribe() //TODO no longer subscribe like we used to - clean this up after all apps updated + unschedule() + schedule(time1, "scheduleCheck") +} + +def scheduleCheck() +{ + log.trace "scheduledCheck" + + def midnight = (new Date()).clearTime() + def now = new Date() + def feederEvents = feeder1.eventsBetween(midnight, now) + log.trace "Found ${feederEvents?.size() ?: 0} feeder events since $midnight" + def feederOpened = feederEvents.count { it.value && it.value == "open" } > 0 + + if (feederOpened) { + log.debug "Feeder was opened since $midnight, no SMS required" + } else { + if (location.contactBookEnabled) { + log.debug "Feeder was not opened since $midnight, texting contacts:${recipients?.size()}" + sendNotificationToContacts("No one has fed the dog", recipients) + } + else { + log.debug "Feeder was not opened since $midnight, texting one phone number" + sendSms(phone1, "No one has fed the dog") + } + } +} diff --git a/official/hello-home-phrase-director.groovy b/official/hello-home-phrase-director.groovy new file mode 100755 index 0000000..cb78696 --- /dev/null +++ b/official/hello-home-phrase-director.groovy @@ -0,0 +1,332 @@ +/** + * Magic Home + * + * Copyright 2014 Tim Slagle + * + * 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: "Hello, Home Phrase Director", + namespace: "tslagle13", + author: "Tim Slagle", + description: "Monitor a set of presence sensors and activate Hello, Home phrases based on whether your home is empty or occupied. Each presence status change will check against the current 'sun state' to run phrases based on occupancy and whether the sun is up or down.", + category: "Convenience", + iconUrl: "http://icons.iconarchive.com/icons/icons8/ios7/512/Very-Basic-Home-Filled-icon.png", + iconX2Url: "http://icons.iconarchive.com/icons/icons8/ios7/512/Very-Basic-Home-Filled-icon.png" + ) + + preferences { + page(name: "selectPhrases") + + page( name:"Settings", title:"Settings", uninstall:true, install:true ) { + section("False alarm threshold (defaults to 10 min)") { + input "falseAlarmThreshold", "decimal", title: "Number of minutes", required: false + } + + section("Zip code (for sunrise/sunset)") { + input "zip", "decimal", required: true + } + + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification when house is empty?", metadata:[values:["Yes","No"]], required:false + input "sendPushMessageHome", "enum", title: "Send a push notification when home is occupied?", metadata:[values:["Yes","No"]], required:false + } + + section(title: "More options", hidden: hideOptionsSection(), hideable: true) { + label title: "Assign a name", required: false + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + } + } + + def selectPhrases() { + def configured = (settings.awayDay && settings.awayNight && settings.homeDay && settings.homeNight) + dynamicPage(name: "selectPhrases", title: "Configure", nextPage:"Settings", uninstall: true) { + section("Who?") { + input "people", "capability.presenceSensor", title: "Monitor These Presences", required: true, multiple: true, submitOnChange:true + } + + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + section("Run This Phrase When...") { + log.trace phrases + input "awayDay", "enum", title: "Everyone Is Away And It's Day", required: true, options: phrases, submitOnChange:true + input "awayNight", "enum", title: "Everyone Is Away And It's Night", required: true, options: phrases, submitOnChange:true + input "homeDay", "enum", title: "At Least One Person Is Home And It's Day", required: true, options: phrases, submitOnChange:true + input "homeNight", "enum", title: "At Least One Person Is Home And It's Night", required: true, options: phrases, submitOnChange:true + } + section("Select modes used for each condition. (Needed for better app logic)") { + input "homeModeDay", "mode", title: "Select Mode Used for 'Home Day'", required: true + input "homeModeNight", "mode", title: "Select Mode Used for 'Home Night'", required: true + } + } + } + } + + def installed() { + initialize() + + } + + def updated() { + unsubscribe() + initialize() + } + + def initialize() { + subscribe(people, "presence", presence) + runIn(60, checkSun) + subscribe(location, "sunrise", setSunrise) + subscribe(location, "sunset", setSunset) + } + + //check current sun state when installed. + def checkSun() { + def zip = settings.zip as String + def sunInfo = getSunriseAndSunset(zipCode: zip) + def current = now() + + if (sunInfo.sunrise.time < current && sunInfo.sunset.time > current) { + state.sunMode = "sunrise" + setSunrise() + } + + else { + state.sunMode = "sunset" + setSunset() + } + } + + //change to sunrise mode on sunrise event + def setSunrise(evt) { + state.sunMode = "sunrise"; + changeSunMode(newMode); + } + + //change to sunset mode on sunset event + def setSunset(evt) { + state.sunMode = "sunset"; + changeSunMode(newMode) + } + + //change mode on sun event + def changeSunMode(newMode) { + if(allOk) { + + if(everyoneIsAway() && (state.sunMode == "sunrise")) { + log.info("Home is Empty Setting New Away Mode") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + runIn(delay, "setAway") + } + + if(everyoneIsAway() && (state.sunMode == "sunset")) { + log.info("Home is Empty Setting New Away Mode") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + runIn(delay, "setAway") + } + + else { + log.info("Home is Occupied Setting New Home Mode") + setHome() + + + } + } + } + + //presence change run logic based on presence state of home + def presence(evt) { + if(allOk) { + if(evt.value == "not present") { + log.debug("Checking if everyone is away") + + if(everyoneIsAway()) { + log.info("Nobody is home, running away sequence") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + runIn(delay, "setAway") + } + } + + else { + def lastTime = state[evt.deviceId] + if (lastTime == null || now() - lastTime >= 1 * 60000) { + log.info("Someone is home, running home sequence") + setHome() + } + state[evt.deviceId] = now() + + } + } + } + + //if empty set home to one of the away modes + def setAway() { + if(everyoneIsAway()) { + if(state.sunMode == "sunset") { + def message = "Performing \"${awayNight}\" for you as requested." + log.info(message) + sendAway(message) + location.helloHome.execute(settings.awayNight) + } + + else if(state.sunMode == "sunrise") { + def message = "Performing \"${awayDay}\" for you as requested." + log.info(message) + sendAway(message) + location.helloHome.execute(settings.awayDay) + } + else { + log.debug("Mode is the same, not evaluating") + } + } + + else { + log.info("Somebody returned home before we set to '${newAwayMode}'") + } + } + + //set home mode when house is occupied + def setHome() { + sendOutOfDateNotification() + log.info("Setting Home Mode!!") + if(anyoneIsHome()) { + if(state.sunMode == "sunset"){ + if (location.mode != "${homeModeNight}"){ + def message = "Performing \"${homeNight}\" for you as requested." + log.info(message) + sendHome(message) + location.helloHome.execute(settings.homeNight) + } + } + + if(state.sunMode == "sunrise"){ + if (location.mode != "${homeModeDay}"){ + def message = "Performing \"${homeDay}\" for you as requested." + log.info(message) + sendHome(message) + location.helloHome.execute(settings.homeDay) + } + } + } + + } + + private everyoneIsAway() { + def result = true + + if(people.findAll { it?.currentPresence == "present" }) { + result = false + } + + log.debug("everyoneIsAway: ${result}") + + return result + } + + private anyoneIsHome() { + def result = false + + if(people.findAll { it?.currentPresence == "present" }) { + result = true + } + + log.debug("anyoneIsHome: ${result}") + + return result + } + + def sendAway(msg) { + if(sendPushMessage != "No") { + log.debug("Sending push message") + sendPush(msg) + } + + log.debug(msg) + } + + def sendHome(msg) { + if(sendPushMessageHome != "No") { + log.debug("Sending push message") + sendPush(msg) + } + + log.debug(msg) + } + + private getAllOk() { + modeOk && daysOk && timeOk + } + + private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result + } + + 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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) + } + + private getTimeIntervalLabel() + { + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" + } + + private hideOptionsSection() { + (starting || ending || days || modes) ? false : true + } + + def sendOutOfDateNotification(){ + if(!state.lastTime){ + state.lastTime = (new Date() + 31).getTime() + sendNotification("Your version of Hello, Home Phrase Director is currently out of date. Please look for the new version of Hello, Home Phrase Director now called 'Routine Director' in the marketplace.") + } + else if (((new Date()).getTime()) >= state.lastTime){ + sendNotification("Your version of Hello, Home Phrase Director is currently out of date. Please look for the new version of Hello, Home Phrase Director now called 'Routine Director' in the marketplace.") + state.lastTime = (new Date() + 31).getTime() + } + } \ No newline at end of file diff --git a/official/hub-ip-notifier.groovy b/official/hub-ip-notifier.groovy new file mode 100755 index 0000000..46fe147 --- /dev/null +++ b/official/hub-ip-notifier.groovy @@ -0,0 +1,80 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Hub IP Notifier + * + * Author: luke + * Date: 2014-01-28 + */ +definition( + name: "Hub IP Notifier", + namespace: "smartthings", + author: "SmartThings", + description: "Listen for local IP changes when your hub registers.", + category: "SmartThings Internal", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/MyApps/Cat-MyApps.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/MyApps/Cat-MyApps@2x.png" +) + +preferences { + page(name: "pageWithIp", title: "Hub IP Notifier", install: true) + +} + +def pageWithIp() { + def currentIp = state.localip ?: 'unknown' + def registerDate = state.lastRegister ?: null + dynamicPage(name: "pageWithIp", title: "Hub IP Notifier", install: true, uninstall: true) { + section("When Hub Comes Online") { + input "hub", "hub", title: "Select a hub" + } + section("Last Registration Details") { + if(hub && registerDate) { + paragraph """Your hub last registered with IP: +$currentIp +on: +$registerDate""" + } else if (hub && !registerDate) { + paragraph "Your hub has not (re)registered since you installed this app" + } else { + paragraph "Check back here after installing to see the current IP of your hub" + } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(hub, "hubInfo", registrationHandler, [filterEvents: false]) +} + +def registrationHandler(evt) { + def hubInfo = evt.description.split(',').inject([:]) { map, token -> + token.split(':').with { map[it[0].trim()] = it[1] } + map + } + state.localip = hubInfo.localip + state.lastRegister = new Date() + sendNotificationEvent("${hub.name} registered in prod with IP: ${hubInfo.localip}") +} diff --git a/official/hue-connect.groovy b/official/hue-connect.groovy new file mode 100755 index 0000000..b343b20 --- /dev/null +++ b/official/hue-connect.groovy @@ -0,0 +1,1801 @@ +/** + * Hue Service Manager + * + * Author: Juan Risso (juan@smartthings.com) + * + * Copyright 2015 SmartThings + * + * 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. + * + */ +include 'localization' + +definition( + name: "Hue (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Allows you to connect your Philips Hue lights with SmartThings and control them from your Things area or Dashboard in the SmartThings Mobile app. Please update your Hue Bridge first, outside of the SmartThings app, using the Philips Hue app.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/hue.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/hue@2x.png", + singleInstance: true +) + +preferences { + page(name: "mainPage", title: "", content: "mainPage", refreshTimeout: 5) + page(name: "bridgeDiscovery", title: "", content: "bridgeDiscovery", refreshTimeout: 5) + page(name: "bridgeDiscoveryFailed", title: "", content: "bridgeDiscoveryFailed", refreshTimeout: 0) + page(name: "bridgeBtnPush", title: "", content: "bridgeLinking", refreshTimeout: 5) + page(name: "bulbDiscovery", title: "", content: "bulbDiscovery", refreshTimeout: 5) +} + +def mainPage() { + def bridges = bridgesDiscovered() + + if (state.refreshUsernameNeeded) { + return bridgeLinking() + } else if (state.username && bridges) { + return bulbDiscovery() + } else { + return bridgeDiscovery() + } +} + +def bridgeDiscovery(params = [:]) { + def bridges = bridgesDiscovered() + int bridgeRefreshCount = !state.bridgeRefreshCount ? 0 : state.bridgeRefreshCount as int + state.bridgeRefreshCount = bridgeRefreshCount + 1 + def refreshInterval = 3 + + def options = bridges ?: [] + def numFound = options.size() ?: "0" + if (numFound == 0) { + if (state.bridgeRefreshCount == 25) { + log.trace "Cleaning old bridges memory" + state.bridges = [:] + app.updateSetting("selectedHue", "") + } else if (state.bridgeRefreshCount > 100) { + // five minutes have passed, give up + // there seems to be a problem going back from discovey failed page in some instances (compared to pressing next) + // however it is probably a SmartThings settings issue + state.bridges = [:] + app.updateSetting("selectedHue", "") + state.bridgeRefreshCount = 0 + return bridgeDiscoveryFailed() + } + } + + ssdpSubscribe() + log.trace "bridgeRefreshCount: $bridgeRefreshCount" + //bridge discovery request every 15 //25 seconds + if ((bridgeRefreshCount % 5) == 0) { + discoverBridges() + } + + //setup.xml request every 3 seconds except on discoveries + if (((bridgeRefreshCount % 3) == 0) && ((bridgeRefreshCount % 5) != 0)) { + verifyHueBridges() + } + + return dynamicPage(name: "bridgeDiscovery", title: "Discovery Started!", nextPage: "bridgeBtnPush", refreshInterval: refreshInterval, uninstall: true) { + section("Please wait while we discover your Hue Bridge. Kindly note that you must first configure your Hue Bridge and Lights using the Philips Hue application. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + + + input(name: "selectedHue", type: "enum", required: false, title: "Select Hue Bridge ({{numFound}} found)", messageArgs: [numFound: numFound], multiple: false, options: options, submitOnChange: true) + } + } +} + +def bridgeDiscoveryFailed() { + return dynamicPage(name: "bridgeDiscoveryFailed", title: "Bridge Discovery Failed!", nextPage: "bridgeDiscovery") { + section("Failed to discover any Hue Bridges. Please confirm that the Hue Bridge is connected to the same network as your SmartThings Hub, and that it has power.") { + } + } +} + +def bridgeLinking() { + int linkRefreshcount = !state.linkRefreshcount ? 0 : state.linkRefreshcount as int + state.linkRefreshcount = linkRefreshcount + 1 + def refreshInterval = 3 + + def nextPage = "" + def title = "Linking with your Hue" + def paragraphText + if (selectedHue) { + if (state.refreshUsernameNeeded) { + paragraphText = "The current Hue username is invalid. Please press the button on your Hue Bridge to relink." + } else { + paragraphText = "Press the button on your Hue Bridge to setup a link." + } + } else { + paragraphText = "You haven't selected a Hue Bridge, please Press 'Done' and select one before clicking next." + } + if (state.username) { //if discovery worked + if (state.refreshUsernameNeeded) { + state.refreshUsernameNeeded = false + // Issue one poll with new username to cancel local polling with old username + poll() + } + nextPage = "bulbDiscovery" + title = "Success!" + paragraphText = "Linking to your hub was a success! Please click 'Next'!" + } + + if ((linkRefreshcount % 2) == 0 && !state.username) { + sendDeveloperReq() + } + + return dynamicPage(name: "bridgeBtnPush", title: title, nextPage: nextPage, refreshInterval: refreshInterval) { + section("") { + paragraph "$paragraphText" + } + } +} + +def bulbDiscovery() { + int bulbRefreshCount = !state.bulbRefreshCount ? 0 : state.bulbRefreshCount as int + state.bulbRefreshCount = bulbRefreshCount + 1 + def refreshInterval = 3 + state.inBulbDiscovery = true + def bridge = null + + state.bridgeRefreshCount = 0 + def allLightsFound = bulbsDiscovered() ?: [:] + + // List lights currently not added to the user (editable) + def newLights = allLightsFound.findAll { getChildDevice(it.key) == null } ?: [:] + newLights = newLights.sort { it.value.toLowerCase() } + + // List lights already added to the user (not editable) + def existingLights = allLightsFound.findAll { getChildDevice(it.key) != null } ?: [:] + existingLights = existingLights.sort { it.value.toLowerCase() } + + def numFound = newLights.size() ?: "0" + if (numFound == "0") + app.updateSetting("selectedBulbs", "") + + if ((bulbRefreshCount % 5) == 0) { + discoverHueBulbs() + } + def selectedBridge = state.bridges.find { key, value -> value?.serialNumber?.equalsIgnoreCase(selectedHue) } + def title = selectedBridge?.value?.name ?: "Find bridges" + + // List of all lights previously added shown to user + def existingLightsDescription = "" + if (existingLights) { + existingLights.each { + if (existingLightsDescription.isEmpty()) { + existingLightsDescription += it.value + } else { + existingLightsDescription += ", ${it.value}" + } + } + } + + def existingLightsSize = "${existingLights.size()}" + if (bulbRefreshCount > 200 && numFound == "0") { + // Time out after 10 minutes + state.inBulbDiscovery = false + bulbRefreshCount = 0 + return dynamicPage(name: "bulbDiscovery", title: "Light Discovery Failed!", nextPage: "", refreshInterval: 0, install: true, uninstall: true) { + section("Failed to discover any lights, please try again later. Click 'Done' to exit.") { + paragraph title: "Previously added Hue Lights ({{existingLightsSize}} added)", messageArgs: [existingLightsSize: existingLightsSize], existingLightsDescription + } + section { + href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true] + } + } + + } else { + return dynamicPage(name: "bulbDiscovery", title: "Light Discovery Started!", nextPage: "", refreshInterval: refreshInterval, install: true, uninstall: true) { + section("Please wait while we discover your Hue Lights. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input(name: "selectedBulbs", type: "enum", required: false, title: "Select Hue Lights to add ({{numFound}} found)", messageArgs: [numFound: numFound], multiple: true, submitOnChange: true, options: newLights) + paragraph title: "Previously added Hue Lights ({{existingLightsSize}} added)", messageArgs: [existingLightsSize: existingLightsSize], existingLightsDescription + } + section { + href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true] + } + } + } +} + +private discoverBridges() { + log.trace "Sending Hue Discovery message to the hub" + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:basic:1", physicalgraph.device.Protocol.LAN)) +} + +void ssdpSubscribe() { + subscribe(location, "ssdpTerm.urn:schemas-upnp-org:device:basic:1", ssdpBridgeHandler) +} + +private sendDeveloperReq() { + def token = app.id + def host = getBridgeIP() + sendHubCommand(new physicalgraph.device.HubAction([ + method : "POST", + path : "/api", + headers: [ + HOST: host + ], + body : [devicetype: "$token-0"]], "${selectedHue}", [callback: "usernameHandler"])) +} + +private discoverHueBulbs() { + def host = getBridgeIP() + sendHubCommand(new physicalgraph.device.HubAction([ + method : "GET", + path : "/api/${state.username}/lights", + headers: [ + HOST: host + ]], "${selectedHue}", [callback: "lightsHandler"])) +} + +private verifyHueBridge(String deviceNetworkId, String host) { + log.trace "Verify Hue Bridge $deviceNetworkId" + sendHubCommand(new physicalgraph.device.HubAction([ + method : "GET", + path : "/description.xml", + headers: [ + HOST: host + ]], deviceNetworkId, [callback: "bridgeDescriptionHandler"])) +} + +private verifyHueBridges() { + def devices = getHueBridges().findAll { it?.value?.verified != true } + devices.each { + def ip = convertHexToIP(it.value.networkAddress) + def port = convertHexToInt(it.value.deviceAddress) + verifyHueBridge("${it.value.mac}", (ip + ":" + port)) + } +} + +Map bridgesDiscovered() { + def vbridges = getVerifiedHueBridges() + def map = [:] + vbridges.each { + def value = "${it.value.name}" + def key = "${it.value.mac}" + map["${key}"] = value + } + map +} + +Map bulbsDiscovered() { + def bulbs = getHueBulbs() + def bulbmap = [:] + if (bulbs instanceof java.util.Map) { + bulbs.each { + def value = "${it.value.name}" + def key = app.id + "/" + it.value.id + bulbmap["${key}"] = value + } + } else { //backwards compatable + bulbs.each { + def value = "${it.name}" + def key = app.id + "/" + it.id + logg += "$value - $key, " + bulbmap["${key}"] = value + } + } + return bulbmap +} + +Map getHueBulbs() { + state.bulbs = state.bulbs ?: [:] +} + +def getHueBridges() { + state.bridges = state.bridges ?: [:] +} + +def getVerifiedHueBridges() { + getHueBridges().findAll { it?.value?.verified == true } +} + +def installed() { + log.trace "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.trace "Updated with settings: ${settings}" + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + log.debug "Initializing" + unsubscribe(bridge) + state.inBulbDiscovery = false + state.bridgeRefreshCount = 0 + state.bulbRefreshCount = 0 + state.updating = false + setupDeviceWatch() + if (selectedHue) { + addBridge() + addBulbs() + doDeviceSync() + runEvery5Minutes("doDeviceSync") + } +} + +def manualRefresh() { + checkBridgeStatus() + state.updating = false + poll() +} + +def uninstalled() { + // Remove bridgedevice connection to allow uninstall of smartapp even though bridge is listed + // as user of smartapp + app.updateSetting("bridgeDevice", null) + state.bridges = [:] + state.username = null +} + +private setupDeviceWatch() { + def hub = location.hubs[0] + // Make sure that all child devices are enrolled in device watch + getChildDevices().each { + it.sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${hub?.hub?.hardwareID}\"}") + } +} + +private upgradeDeviceType(device, newHueType) { + def deviceType = getDeviceType(newHueType) + + // Automatically change users Hue bulbs to correct device types + if (deviceType && !(device?.typeName?.equalsIgnoreCase(deviceType))) { + log.debug "Update device type: \"$device.label\" ${device?.typeName}->$deviceType" + device.setDeviceType(deviceType) + } +} + +private getDeviceType(hueType) { + // Determine ST device type based on Hue classification of light + if (hueType?.equalsIgnoreCase("Dimmable light")) + return "Hue Lux Bulb" + else if (hueType?.equalsIgnoreCase("Extended Color Light")) + return "Hue Bulb" + else if (hueType?.equalsIgnoreCase("Color Light")) + return "Hue Bloom" + else if (hueType?.equalsIgnoreCase("Color Temperature Light")) + return "Hue White Ambiance Bulb" + else + return null +} + +private addChildBulb(dni, hueType, name, hub, update = false, device = null) { + def deviceType = getDeviceType(hueType) + + if (deviceType) { + return addChildDevice("smartthings", deviceType, dni, hub, ["label": name]) + } else { + log.warn "Device type $hueType not supported" + return null + } +} + +def addBulbs() { + def bulbs = getHueBulbs() + selectedBulbs?.each { dni -> + def d = getChildDevice(dni) + if (!d) { + def newHueBulb + if (bulbs instanceof java.util.Map) { + newHueBulb = bulbs.find { (app.id + "/" + it.value.id) == dni } + if (newHueBulb != null) { + d = addChildBulb(dni, newHueBulb?.value?.type, newHueBulb?.value?.name, newHueBulb?.value?.hub) + if (d) { + log.debug "created ${d.displayName} with id $dni" + d.completedSetup = true + } + } else { + log.debug "$dni in not longer paired to the Hue Bridge or ID changed" + } + } else { + //backwards compatable + newHueBulb = bulbs.find { (app.id + "/" + it.id) == dni } + d = addChildBulb(dni, "Extended Color Light", newHueBulb?.value?.name, newHueBulb?.value?.hub) + d?.completedSetup = true + d?.refresh() + } + } else { + log.debug "found ${d.displayName} with id $dni already exists, type: '$d.typeName'" + if (bulbs instanceof java.util.Map) { + // Update device type if incorrect + def newHueBulb = bulbs.find { (app.id + "/" + it.value.id) == dni } + upgradeDeviceType(d, newHueBulb?.value?.type) + } + } + } +} + +def addBridge() { + def vbridges = getVerifiedHueBridges() + def vbridge = vbridges.find { "${it.value.mac}" == selectedHue } + + if (vbridge) { + def d = getChildDevice(selectedHue) + if (!d) { + // compatibility with old devices + def newbridge = true + childDevices.each { + if (it.getDeviceDataByName("mac")) { + def newDNI = "${it.getDeviceDataByName("mac")}" + if (newDNI != it.deviceNetworkId) { + def oldDNI = it.deviceNetworkId + log.debug "updating dni for device ${it} with $newDNI - previous DNI = ${it.deviceNetworkId}" + it.setDeviceNetworkId("${newDNI}") + if (oldDNI == selectedHue) { + app.updateSetting("selectedHue", newDNI) + } + newbridge = false + } + } + } + if (newbridge) { + // Hue uses last 6 digits of MAC address as ID number, this number is shown on the bottom of the bridge + def idNumber = getBridgeIdNumber(selectedHue) + d = addChildDevice("smartthings", "Hue Bridge", selectedHue, vbridge.value.hub, ["label": "Hue Bridge ($idNumber)"]) + if (d) { + // Associate smartapp to bridge so user will be warned if trying to delete bridge + app.updateSetting("bridgeDevice", [type: "device.hueBridge", value: d.id]) + + d.completedSetup = true + log.debug "created ${d.displayName} with id ${d.deviceNetworkId}" + def childDevice = getChildDevice(d.deviceNetworkId) + childDevice?.sendEvent(name: "status", value: "Online") + childDevice?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false, isStateChange: true) + updateBridgeStatus(childDevice) + + childDevice?.sendEvent(name: "idNumber", value: idNumber) + if (vbridge.value.ip && vbridge.value.port) { + if (vbridge.value.ip.contains(".")) { + childDevice.sendEvent(name: "networkAddress", value: vbridge.value.ip + ":" + vbridge.value.port) + childDevice.updateDataValue("networkAddress", vbridge.value.ip + ":" + vbridge.value.port) + } else { + childDevice.sendEvent(name: "networkAddress", value: convertHexToIP(vbridge.value.ip) + ":" + convertHexToInt(vbridge.value.port)) + childDevice.updateDataValue("networkAddress", convertHexToIP(vbridge.value.ip) + ":" + convertHexToInt(vbridge.value.port)) + } + } else { + childDevice.sendEvent(name: "networkAddress", value: convertHexToIP(vbridge.value.networkAddress) + ":" + convertHexToInt(vbridge.value.deviceAddress)) + childDevice.updateDataValue("networkAddress", convertHexToIP(vbridge.value.networkAddress) + ":" + convertHexToInt(vbridge.value.deviceAddress)) + } + } else { + log.error "Failed to create Hue Bridge device" + } + } + } else { + log.debug "found ${d.displayName} with id $selectedHue already exists" + } + } +} + +def ssdpBridgeHandler(evt) { + def description = evt.description + log.trace "Location: $description" + + def hub = evt?.hubId + def parsedEvent = parseLanMessage(description) + parsedEvent << ["hub": hub] + + def bridges = getHueBridges() + log.trace bridges.toString() + if (!(bridges."${parsedEvent.ssdpUSN.toString()}")) { + //bridge does not exist + log.trace "Adding bridge ${parsedEvent.ssdpUSN}" + bridges << ["${parsedEvent.ssdpUSN.toString()}": parsedEvent] + } else { + // update the values + def ip = convertHexToIP(parsedEvent.networkAddress) + def port = convertHexToInt(parsedEvent.deviceAddress) + def host = ip + ":" + port + log.debug "Device ($parsedEvent.mac) was already found in state with ip = $host." + def dstate = bridges."${parsedEvent.ssdpUSN.toString()}" + def dniReceived = "${parsedEvent.mac}" + def currentDni = dstate.mac + def d = getChildDevice(dniReceived) + def networkAddress = null + if (!d) { + // There might be a mismatch between bridge DNI and the actual bridge mac address, correct that + log.debug "Bridge with $dniReceived not found" + def bridge = childDevices.find { it.deviceNetworkId == currentDni } + if (bridge != null) { + log.warn "Bridge is set to ${bridge.deviceNetworkId}, updating to $dniReceived" + bridge.setDeviceNetworkId("${dniReceived}") + dstate.mac = dniReceived + // Check to see if selectedHue is a valid bridge, otherwise update it + def isSelectedValid = bridges?.find { it.value?.mac == selectedHue } + if (isSelectedValid == null) { + log.warn "Correcting selectedHue in state" + app.updateSetting("selectedHue", dniReceived) + } + doDeviceSync() + } + } else { + updateBridgeStatus(d) + if (d.getDeviceDataByName("networkAddress")) { + networkAddress = d.getDeviceDataByName("networkAddress") + } else { + networkAddress = d.latestState('networkAddress').stringValue + } + log.trace "Host: $host - $networkAddress" + if (host != networkAddress) { + log.debug "Device's port or ip changed for device $d..." + dstate.ip = ip + dstate.port = port + dstate.name = "Philips hue ($ip)" + d.sendEvent(name: "networkAddress", value: host) + d.updateDataValue("networkAddress", host) + } + if (dstate.mac != dniReceived) { + log.warn "Correcting bridge mac address in state" + dstate.mac = dniReceived + } + if (selectedHue != dniReceived) { + // Check to see if selectedHue is a valid bridge, otherwise update it + def isSelectedValid = bridges?.find { it.value?.mac == selectedHue } + if (isSelectedValid == null) { + log.warn "Correcting selectedHue in state" + app.updateSetting("selectedHue", dniReceived) + } + } + } + } +} + +void bridgeDescriptionHandler(physicalgraph.device.HubResponse hubResponse) { + log.trace "description.xml response (application/xml)" + def body = hubResponse.xml + if (body?.device?.modelName?.text()?.startsWith("Philips hue bridge")) { + def bridges = getHueBridges() + def bridge = bridges.find { it?.key?.contains(body?.device?.UDN?.text()) } + if (bridge) { + def idNumber = getBridgeIdNumber(body?.device?.serialNumber?.text()) + + // usually in form of bridge name followed by (ip), i.e. defaults to Philips Hue (192.168.1.2) + // replace IP with id number to make it easier for user to identify + def name = body?.device?.friendlyName?.text() + def index = name?.indexOf('(') + if (index != -1) { + name = name.substring(0, index) + name += " ($idNumber)" + } + bridge.value << [name: name, serialNumber: body?.device?.serialNumber?.text(), idNumber: idNumber, verified: true] + } else { + log.error "/description.xml returned a bridge that didn't exist" + } + } +} + +void lightsHandler(physicalgraph.device.HubResponse hubResponse) { + if (isValidSource(hubResponse.mac)) { + def body = hubResponse.json + if (!body?.state?.on) { //check if first time poll made it here by mistake + log.debug "Adding bulbs to state!" + updateBulbState(body, hubResponse.hubId) + } + } +} + +void usernameHandler(physicalgraph.device.HubResponse hubResponse) { + if (isValidSource(hubResponse.mac)) { + def body = hubResponse.json + if (body.success != null) { + if (body.success[0] != null) { + if (body.success[0].username) + state.username = body.success[0].username + } + } else if (body.error != null) { + //TODO: handle retries... + log.error "ERROR: application/json ${body.error}" + } + } +} + +/** + * @deprecated This has been replaced by the combination of {@link #ssdpBridgeHandler()}, {@link #bridgeDescriptionHandler()}, + * {@link #lightsHandler()}, and {@link #usernameHandler()}. After a pending event subscription migration, it can be removed. + */ +@Deprecated +def locationHandler(evt) { + def description = evt.description + log.trace "Location: $description" + + def hub = evt?.hubId + def parsedEvent = parseLanMessage(description) + parsedEvent << ["hub": hub] + + if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:basic:1")) { + //SSDP DISCOVERY EVENTS + log.trace "SSDP DISCOVERY EVENTS" + def bridges = getHueBridges() + log.trace bridges.toString() + if (!(bridges."${parsedEvent.ssdpUSN.toString()}")) { + //bridge does not exist + log.trace "Adding bridge ${parsedEvent.ssdpUSN}" + bridges << ["${parsedEvent.ssdpUSN.toString()}": parsedEvent] + } else { + // update the values + def ip = convertHexToIP(parsedEvent.networkAddress) + def port = convertHexToInt(parsedEvent.deviceAddress) + def host = ip + ":" + port + log.debug "Device ($parsedEvent.mac) was already found in state with ip = $host." + def dstate = bridges."${parsedEvent.ssdpUSN.toString()}" + def dni = "${parsedEvent.mac}" + def d = getChildDevice(dni) + def networkAddress = null + if (!d) { + childDevices.each { + if (it.getDeviceDataByName("mac")) { + def newDNI = "${it.getDeviceDataByName("mac")}" + d = it + if (newDNI != it.deviceNetworkId) { + def oldDNI = it.deviceNetworkId + log.debug "updating dni for device ${it} with $newDNI - previous DNI = ${it.deviceNetworkId}" + it.setDeviceNetworkId("${newDNI}") + if (oldDNI == selectedHue) { + app.updateSetting("selectedHue", newDNI) + } + doDeviceSync() + } + } + } + } else { + updateBridgeStatus(d) + if (d.getDeviceDataByName("networkAddress")) { + networkAddress = d.getDeviceDataByName("networkAddress") + } else { + networkAddress = d.latestState('networkAddress').stringValue + } + log.trace "Host: $host - $networkAddress" + if (host != networkAddress) { + log.debug "Device's port or ip changed for device $d..." + dstate.ip = ip + dstate.port = port + dstate.name = "Philips hue ($ip)" + d.sendEvent(name: "networkAddress", value: host) + d.updateDataValue("networkAddress", host) + } + } + } + } else if (parsedEvent.headers && parsedEvent.body) { + log.trace "HUE BRIDGE RESPONSES" + def headerString = parsedEvent.headers.toString() + if (headerString?.contains("xml")) { + log.trace "description.xml response (application/xml)" + def body = new XmlSlurper().parseText(parsedEvent.body) + if (body?.device?.modelName?.text().startsWith("Philips hue bridge")) { + def bridges = getHueBridges() + def bridge = bridges.find { it?.key?.contains(body?.device?.UDN?.text()) } + if (bridge) { + bridge.value << [name: body?.device?.friendlyName?.text(), serialNumber: body?.device?.serialNumber?.text(), verified: true] + } else { + log.error "/description.xml returned a bridge that didn't exist" + } + } + } else if (headerString?.contains("json") && isValidSource(parsedEvent.mac)) { + log.trace "description.xml response (application/json)" + def body = new groovy.json.JsonSlurper().parseText(parsedEvent.body) + if (body.success != null) { + if (body.success[0] != null) { + if (body.success[0].username) { + state.username = body.success[0].username + } + } + } else if (body.error != null) { + //TODO: handle retries... + log.error "ERROR: application/json ${body.error}" + } else { + //GET /api/${state.username}/lights response (application/json) + if (!body?.state?.on) { //check if first time poll made it here by mistake + log.debug "Adding bulbs to state!" + updateBulbState(body, parsedEvent.hub) + } + } + } + } else { + log.trace "NON-HUE EVENT $evt.description" + } +} + +def doDeviceSync() { + log.trace "Doing Hue Device Sync!" + + // Check if state.updating failed to clear + if (state.lastUpdateStarted < (now() - 20 * 1000) && state.updating) { + state.updating = false + log.warn "state.updating failed to clear" + } + + convertBulbListToMap() + poll() + ssdpSubscribe() + discoverBridges() + checkBridgeStatus() +} + +/** + * Called when data is received from the Hue bridge, this will update the lastActivity() that + * is used to keep track of online/offline status of the bridge. Bridge is considered offline + * if not heard from in 16 minutes + * + * @param childDevice Hue Bridge child device + */ +private void updateBridgeStatus(childDevice) { + // Update activity timestamp if child device is a valid bridge + def vbridges = getVerifiedHueBridges() + def vbridge = vbridges.find { + "${it.value.mac}".toUpperCase() == childDevice?.device?.deviceNetworkId?.toUpperCase() + } + vbridge?.value?.lastActivity = now() + if (vbridge && childDevice?.device?.currentValue("status") == "Offline") { + log.debug "$childDevice is back Online" + childDevice?.sendEvent(name: "status", value: "Online") + childDevice?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false, isStateChange: true) + } +} + +/** + * Check if all Hue bridges have been heard from in the last 11 minutes, if not an Offline event will be sent + * for the bridge and all connected lights. Also, set ID number on bridge if not done previously. + */ +private void checkBridgeStatus() { + def bridges = getHueBridges() + // Check if each bridge has been heard from within the last 11 minutes (2 poll intervals times 5 minutes plus buffer) + def time = now() - (1000 * 60 * 11) + bridges.each { + def d = getChildDevice(it.value.mac) + if (d) { + // Set id number on bridge if not done + if (it.value.idNumber == null) { + it.value.idNumber = getBridgeIdNumber(it.value.serialNumber) + d.sendEvent(name: "idNumber", value: it.value.idNumber) + } + + if (it.value.lastActivity < time) { // it.value.lastActivity != null && + if (d.currentStatus == "Online") { + log.warn "$d is Offline" + d.sendEvent(name: "status", value: "Offline") + d.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: false, isStateChange: true) + + Calendar currentTime = Calendar.getInstance() + getChildDevices().each { + def id = getId(it) + if (state.bulbs[id]?.online == true) { + state.bulbs[id]?.online = false + state.bulbs[id]?.unreachableSince = currentTime.getTimeInMillis() + it.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: false, isStateChange: true) + } + } + } + } else if (d.currentStatus == "Offline") { + log.debug "$d is back Online" + d.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false, isStateChange: true) + d.sendEvent(name: "status", value: "Online")//setOnline(false) + } + } + } +} + +def isValidSource(macAddress) { + def vbridges = getVerifiedHueBridges() + return (vbridges?.find { "${it.value.mac}" == macAddress }) != null +} + +def isInBulbDiscovery() { + return state.inBulbDiscovery +} + +private updateBulbState(messageBody, hub) { + def bulbs = getHueBulbs() + + // Copy of bulbs used to locate old lights in state that are no longer on bridge + def toRemove = [:] + toRemove << bulbs + + messageBody.each { k, v -> + + if (v instanceof Map) { + if (bulbs[k] == null) { + bulbs[k] = [:] + } + bulbs[k] << [id: k, name: v.name, type: v.type, modelid: v.modelid, hub: hub, remove: false] + toRemove.remove(k) + } + } + + // Remove bulbs from state that are no longer discovered + toRemove.each { k, v -> + log.warn "${bulbs[k].name} no longer exists on bridge, removing" + bulbs.remove(k) + } +} + +///////////////////////////////////// +//CHILD DEVICE METHODS +///////////////////////////////////// + +def parse(childDevice, description) { + // Update activity timestamp if child device is a valid bridge + updateBridgeStatus(childDevice) + + def parsedEvent = parseLanMessage(description) + if (parsedEvent.headers && parsedEvent.body) { + def headerString = parsedEvent.headers.toString() + def bodyString = parsedEvent.body.toString() + if (headerString?.contains("json")) { + def body + try { + body = new groovy.json.JsonSlurper().parseText(bodyString) + } catch (all) { + log.warn "Parsing Body failed" + } + if (body instanceof java.util.Map) { + // get (poll) reponse + return handlePoll(body) + } else { + //put response + return handleCommandResponse(body) + } + } + } else { + log.debug "parse - got something other than headers,body..." + return [] + } +} + +// Philips Hue priority for color is xy > ct > hs +// For SmartThings, try to always send hue, sat and hex +private sendColorEvents(device, xy, hue, sat, ct, colormode = null) { + if (device == null || (xy == null && hue == null && sat == null && ct == null)) + return + + def events = [:] + // For now, only care about changing color temperature if requested by user + if (ct != null && ct != 0 && (colormode == "ct" || (xy == null && hue == null && sat == null))) { + // for some reason setting Hue to their specified minimum off 153 yields 154, dealt with below + // 153 (6500K) to 500 (2000K) + def temp = (ct == 154) ? 6500 : Math.round(1000000 / ct) + device.sendEvent([name: "colorTemperature", value: temp, descriptionText: "Color temperature has changed"]) + // Return because color temperature change is not counted as a color change in SmartThings so no hex update necessary + return + } + + if (hue != null) { + // 0-65535 + def value = Math.min(Math.round(hue * 100 / 65535), 65535) as int + events["hue"] = [name: "hue", value: value, descriptionText: "Color has changed", displayed: false] + } + + if (sat != null) { + // 0-254 + def value = Math.round(sat * 100 / 254) as int + events["saturation"] = [name: "saturation", value: value, descriptionText: "Color has changed", displayed: false] + } + + // Following is used to decide what to base hex calculations on since it is preferred to return a colorchange in hex + if (xy != null && colormode != "hs") { + // If xy is present and color mode is not specified to hs, pick xy because of priority + + // [0.0-1.0, 0.0-1.0] + def id = device.deviceNetworkId?.split("/")[1] + def model = state.bulbs[id]?.modelid + def hex = colorFromXY(xy, model) + + // Create Hue and Saturation events if not previously existing + def hsv = hexToHsv(hex) + if (events["hue"] == null) + events["hue"] = [name: "hue", value: hsv[0], descriptionText: "Color has changed", displayed: false] + if (events["saturation"] == null) + events["saturation"] = [name: "saturation", value: hsv[1], descriptionText: "Color has changed", displayed: false] + + events["color"] = [name: "color", value: hex.toUpperCase(), descriptionText: "Color has changed", displayed: true] + } else if (colormode == "hs" || colormode == null) { + // colormode is "hs" or "xy" is missing, default to follow hue/sat which is already handled above + def hueValue = (hue != null) ? events["hue"].value : Integer.parseInt("$device.currentHue") + def satValue = (sat != null) ? events["saturation"].value : Integer.parseInt("$device.currentSaturation") + + + def hex = hsvToHex(hueValue, satValue) + events["color"] = [name: "color", value: hex.toUpperCase(), descriptionText: "Color has changed", displayed: true] + } + + boolean sendColorChanged = false + events.each { + device.sendEvent(it.value) + } +} + +private sendBasicEvents(device, param, value) { + if (device == null || value == null || param == null) + return + + switch (param) { + case "on": + device.sendEvent(name: "switch", value: (value == true) ? "on" : "off") + break + case "bri": + // 1-254 + def level = Math.max(1, Math.round(value * 100 / 254)) as int + device.sendEvent(name: "level", value: level, descriptionText: "Level has changed to ${level}%") + break + } +} + +/** + * Handles a response to a command (PUT) sent to the Hue Bridge. + * + * Will send appropriate events depending on values changed. + * + * Example payload + * [, + *{"success":{"/lights/5/state/bri":87}}, + *{"success":{"/lights/5/state/transitiontime":4}}, + *{"success":{"/lights/5/state/on":true}}, + *{"success":{"/lights/5/state/xy":[0.4152,0.5336]}}, + *{"success":{"/lights/5/state/alert":"none"}}* ] + * + * @param body a data structure of lists and maps based on a JSON data + * @return empty array + */ +private handleCommandResponse(body) { + // scan entire response before sending events to make sure they are always in the same order + def updates = [:] + + body.each { payload -> + if (payload?.success) { + def childDeviceNetworkId = app.id + "/" + def eventType + payload.success.each { k, v -> + def data = k.split("/") + if (data.length == 5) { + childDeviceNetworkId = app.id + "/" + k.split("/")[2] + if (!updates[childDeviceNetworkId]) + updates[childDeviceNetworkId] = [:] + eventType = k.split("/")[4] + updates[childDeviceNetworkId]."$eventType" = v + } + } + } else if (payload?.error) { + log.warn "Error returned from Hue bridge, error = ${payload?.error}" + // Check for unauthorized user + if (payload?.error?.type?.value == 1) { + log.error "Hue username is not valid" + state.refreshUsernameNeeded = true + state.username = null + } + return [] + } + } + + // send events for each update found above (order of events should be same as handlePoll()) + updates.each { childDeviceNetworkId, params -> + def device = getChildDevice(childDeviceNetworkId) + def id = getId(device) + sendBasicEvents(device, "on", params.on) + sendBasicEvents(device, "bri", params.bri) + sendColorEvents(device, params.xy, params.hue, params.sat, params.ct) + } + return [] +} + +/** + * Handles a response to a poll (GET) sent to the Hue Bridge. + * + * Will send appropriate events depending on values changed. + * + * Example payload + * + *{"5":{"state": {"on":true,"bri":102,"hue":25600,"sat":254,"effect":"none","xy":[0.1700,0.7000],"ct":153,"alert":"none", + * "colormode":"xy","reachable":true}, "type": "Extended color light", "name": "Go", "modelid": "LLC020", "manufacturername": "Philips", + * "uniqueid":"00:17:88:01:01:13:d5:11-0b", "swversion": "5.38.1.14378"}, + * "6":{"state": {"on":true,"bri":103,"hue":14910,"sat":144,"effect":"none","xy":[0.4596,0.4105],"ct":370,"alert":"none", + * "colormode":"ct","reachable":true}, "type": "Extended color light", "name": "Upstairs Light", "modelid": "LCT007", "manufacturername": "Philips", + * "uniqueid":"00:17:88:01:10:56:ba:2c-0b", "swversion": "5.38.1.14919"}, + * + * @param body a data structure of lists and maps based on a JSON data + * @return empty array + */ +private handlePoll(body) { + // Used to track "unreachable" time + // Device is considered "offline" if it has been in the "unreachable" state for + // 11 minutes (e.g. two poll intervals) + // Note, Hue Bridge marks devices as "unreachable" often even when they accept commands + Calendar time11 = Calendar.getInstance() + time11.add(Calendar.MINUTE, -11) + Calendar currentTime = Calendar.getInstance() + + def bulbs = getChildDevices() + for (bulb in body) { + def device = bulbs.find { it.deviceNetworkId == "${app.id}/${bulb.key}" } + if (device) { + if (bulb.value.state?.reachable) { + if (state.bulbs[bulb.key]?.online == false || state.bulbs[bulb.key]?.online == null) { + // light just came back online, notify device watch + device.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false, isStateChange: true) + log.debug "$device is Online" + } + // Mark light as "online" + state.bulbs[bulb.key]?.unreachableSince = null + state.bulbs[bulb.key]?.online = true + } else { + if (state.bulbs[bulb.key]?.unreachableSince == null) { + // Store the first time where device was reported as "unreachable" + state.bulbs[bulb.key]?.unreachableSince = currentTime.getTimeInMillis() + } + if (state.bulbs[bulb.key]?.online || state.bulbs[bulb.key]?.online == null) { + // Check if device was "unreachable" for more than 11 minutes and mark "offline" if necessary + if (state.bulbs[bulb.key]?.unreachableSince < time11.getTimeInMillis() || state.bulbs[bulb.key]?.online == null) { + log.warn "$device went Offline" + state.bulbs[bulb.key]?.online = false + device.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: false, isStateChange: true) + } + } + log.warn "$device may not reachable by Hue bridge" + } + // If user just executed commands, then do not send events to avoid confusing the turning on/off state + if (!state.updating) { + sendBasicEvents(device, "on", bulb.value?.state?.on) + sendBasicEvents(device, "bri", bulb.value?.state?.bri) + sendColorEvents(device, bulb.value?.state?.xy, bulb.value?.state?.hue, bulb.value?.state?.sat, bulb.value?.state?.ct, bulb.value?.state?.colormode) + } + } + } + return [] +} + +private updateInProgress() { + state.updating = true + state.lastUpdateStarted = now() + runIn(20, updateHandler) +} + +def updateHandler() { + state.updating = false + poll() +} + +def hubVerification(bodytext) { + log.trace "Bridge sent back description.xml for verification" + def body = new XmlSlurper().parseText(bodytext) + if (body?.device?.modelName?.text().startsWith("Philips hue bridge")) { + def bridges = getHueBridges() + def bridge = bridges.find { it?.key?.contains(body?.device?.UDN?.text()) } + if (bridge) { + bridge.value << [name: body?.device?.friendlyName?.text(), serialNumber: body?.device?.serialNumber?.text(), verified: true] + } else { + log.error "/description.xml returned a bridge that didn't exist" + } + } +} + +def on(childDevice) { + log.debug "Executing 'on'" + def id = getId(childDevice) + updateInProgress() + createSwitchEvent(childDevice, "on") + put("lights/$id/state", [on: true]) + return "Bulb is turning On" +} + +def off(childDevice) { + log.debug "Executing 'off'" + def id = getId(childDevice) + updateInProgress() + createSwitchEvent(childDevice, "off") + put("lights/$id/state", [on: false]) + return "Bulb is turning Off" +} + +def setLevel(childDevice, percent) { + log.debug "Executing 'setLevel'" + def id = getId(childDevice) + updateInProgress() + // 1 - 254 + def level + if (percent == 1) + level = 1 + else + level = Math.min(Math.round(percent * 254 / 100), 254) + + createSwitchEvent(childDevice, level > 0, percent) + + // For Zigbee lights, if level is set to 0 ST just turns them off without changing level + // that means that the light will still be on when on is called next time + // Lets emulate that here + if (percent > 0) { + put("lights/$id/state", [bri: level, on: true]) + } else { + put("lights/$id/state", [on: false]) + } + return "Setting level to $percent" +} + +def setSaturation(childDevice, percent) { + log.debug "Executing 'setSaturation($percent)'" + def id = getId(childDevice) + updateInProgress() + // 0 - 254 + def level = Math.min(Math.round(percent * 254 / 100), 254) + // TODO should this be done by app only or should we default to on? + createSwitchEvent(childDevice, "on") + put("lights/$id/state", [sat: level, on: true]) + return "Setting saturation to $percent" +} + +def setHue(childDevice, percent) { + log.debug "Executing 'setHue($percent)'" + def id = getId(childDevice) + updateInProgress() + // 0 - 65535 + def level = Math.min(Math.round(percent * 65535 / 100), 65535) + // TODO should this be done by app only or should we default to on? + createSwitchEvent(childDevice, "on") + put("lights/$id/state", [hue: level, on: true]) + return "Setting hue to $percent" +} + +def setColorTemperature(childDevice, huesettings) { + log.debug "Executing 'setColorTemperature($huesettings)'" + def id = getId(childDevice) + updateInProgress() + // 153 (6500K) to 500 (2000K) + def ct = hueSettings == 6500 ? 153 : Math.round(1000000 / huesettings) + createSwitchEvent(childDevice, "on") + put("lights/$id/state", [ct: ct, on: true]) + return "Setting color temperature to $ct" +} + +def setColor(childDevice, huesettings) { + log.debug "Executing 'setColor($huesettings)'" + def id = getId(childDevice) + updateInProgress() + + def value = [:] + def hue = null + def sat = null + def xy = null + + // Prefer hue/sat over hex to make sure it works with the majority of the smartapps + if (huesettings.hue != null || huesettings.sat != null) { + // If both hex and hue/sat are set, send all values to bridge to get hue/sat in response from bridge to + // generate hue/sat events even though bridge will prioritize XY when setting color + if (huesettings.hue != null) + value.hue = Math.min(Math.round(huesettings.hue * 65535 / 100), 65535) + if (huesettings.saturation != null) + value.sat = Math.min(Math.round(huesettings.saturation * 254 / 100), 254) + } else if (huesettings.hex != null) { + // For now ignore model to get a consistent color if same color is set across multiple devices + // def model = state.bulbs[getId(childDevice)]?.modelid + // value.xy = calculateXY(huesettings.hex, model) + // Once groups, or scenes are introduced it might be a good idea to use unique models again + value.xy = calculateXY(huesettings.hex) + } + +/* Disabled for now due to bad behavior via Lightning Wizard + if (!value.xy) { + // Below will translate values to hex->XY to take into account the color support of the different hue types + def hex = colorUtil.hslToHex((int) huesettings.hue, (int) huesettings.saturation) + // value.xy = calculateXY(hex, model) + // Once groups, or scenes are introduced it might be a good idea to use unique models again + value.xy = calculateXY(hex) + } +*/ + + // Default behavior is to turn light on + value.on = true + + if (huesettings.level != null) { + if (huesettings.level <= 0) + value.on = false + else if (huesettings.level == 1) + value.bri = 1 + else + value.bri = Math.min(Math.round(huesettings.level * 254 / 100), 254) + } + value.alert = huesettings.alert ? huesettings.alert : "none" + value.transitiontime = huesettings.transitiontime ? huesettings.transitiontime : 4 + + // Make sure to turn off light if requested + if (huesettings.switch == "off") + value.on = false + + createSwitchEvent(childDevice, value.on ? "on" : "off") + put("lights/$id/state", value) + return "Setting color to $value" +} + +private getId(childDevice) { + if (childDevice.device?.deviceNetworkId?.startsWith("HUE")) { + return childDevice.device?.deviceNetworkId[3..-1] + } else { + return childDevice.device?.deviceNetworkId.split("/")[-1] + } +} + +private poll() { + def host = getBridgeIP() + def uri = "/api/${state.username}/lights/" + log.debug "GET: $host$uri" + sendHubCommand(new physicalgraph.device.HubAction("GET ${uri} HTTP/1.1\r\n" + + "HOST: ${host}\r\n\r\n", physicalgraph.device.Protocol.LAN, selectedHue)) +} + +private isOnline(id) { + return (state.bulbs[id]?.online != null && state.bulbs[id]?.online) || state.bulbs[id]?.online == null +} + +private put(path, body) { + def host = getBridgeIP() + def uri = "/api/${state.username}/$path" + def bodyJSON = new groovy.json.JsonBuilder(body).toString() + def length = bodyJSON.getBytes().size().toString() + + log.debug "PUT: $host$uri" + log.debug "BODY: ${bodyJSON}" + + sendHubCommand(new physicalgraph.device.HubAction("PUT $uri HTTP/1.1\r\n" + + "HOST: ${host}\r\n" + + "Content-Length: ${length}\r\n" + + "\r\n" + + "${bodyJSON}", physicalgraph.device.Protocol.LAN, "${selectedHue}")) +} + +/* + * Bridge serial number from Hue API is in format of 0017882413ad (mac address), however on the actual bridge hardware + * the id is printed as only last six characters so using that to identify bridge to users + */ +private getBridgeIdNumber(serialNumber) { + def idNumber = serialNumber ?: "" + if (idNumber?.size() >= 6) + idNumber = idNumber[-6..-1].toUpperCase() + return idNumber +} + +private getBridgeIP() { + def host = null + if (selectedHue) { + def d = getChildDevice(selectedHue) + if (d) { + if (d.getDeviceDataByName("networkAddress")) + host = d.getDeviceDataByName("networkAddress") + else + host = d.latestState('networkAddress').stringValue + } + if (host == null || host == "") { + def serialNumber = selectedHue + def bridge = getHueBridges().find { it?.value?.serialNumber?.equalsIgnoreCase(serialNumber) }?.value + if (!bridge) { + bridge = getHueBridges().find { it?.value?.mac?.equalsIgnoreCase(serialNumber) }?.value + } + if (bridge?.ip && bridge?.port) { + if (bridge?.ip.contains(".")) + host = "${bridge?.ip}:${bridge?.port}" + else + host = "${convertHexToIP(bridge?.ip)}:${convertHexToInt(bridge?.port)}" + } else if (bridge?.networkAddress && bridge?.deviceAddress) + host = "${convertHexToIP(bridge?.networkAddress)}:${convertHexToInt(bridge?.deviceAddress)}" + } + log.trace "Bridge: $selectedHue - Host: $host" + } + return host +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex, 16) +} + +def convertBulbListToMap() { + try { + if (state.bulbs instanceof java.util.List) { + def map = [:] + state.bulbs?.unique { it.id }.each { bulb -> + map << ["${bulb.id}": ["id": bulb.id, "name": bulb.name, "type": bulb.type, "modelid": bulb.modelid, "hub": bulb.hub, "online": bulb.online]] + } + state.bulbs = map + } + } + catch (Exception e) { + log.error "Caught error attempting to convert bulb list to map: $e" + } +} + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]), convertHexToInt(hex[2..3]), convertHexToInt(hex[4..5]), convertHexToInt(hex[6..7])].join(".") +} + +private Boolean hasAllHubsOver(String desiredFirmware) { + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +private List getRealHubFirmwareVersions() { + return location.hubs*.firmwareVersionString.findAll { it } +} + +/** + * Sends appropriate turningOn/turningOff state events depending on switch or level changes. + * + * @param childDevice device to send event for + * @param setSwitch The new switch state, "on" or "off" + * @param setLevel Optional, switchLevel between 0-100, used if you set level to 0 for example since + * that should generate "off" instead of level change + */ +private void createSwitchEvent(childDevice, setSwitch, setLevel = null) { + + if (setLevel == null) { + setLevel = childDevice.device?.currentValue("level") + } + // Create on, off, turningOn or turningOff event as necessary + def currentState = childDevice.device?.currentValue("switch") + if ((currentState == "off" || currentState == "turningOff")) { + if (setSwitch == "on" || setLevel > 0) { + childDevice.sendEvent(name: "switch", value: "turningOn", displayed: false) + } + } else if ((currentState == "on" || currentState == "turningOn")) { + if (setSwitch == "off" || setLevel == 0) { + childDevice.sendEvent(name: "switch", value: "turningOff", displayed: false) + } + } +} + +/** + * Return the supported color range for different Hue lights. If model is not specified + * it defaults to the smallest Gamut (B) to ensure that colors set on a mix of devices + * will be consistent. + * + * @param model Philips model number, e.g. LCT001 + * @return a map with x,y coordinates for red, green and blue according to CIE color space + */ +private colorPointsForModel(model = null) { + def result = null + switch (model) { + // Gamut A + case "LLC001": /* Monet, Renoir, Mondriaan (gen II) */ + case "LLC005": /* Bloom (gen II) */ + case "LLC006": /* Iris (gen III) */ + case "LLC007": /* Bloom, Aura (gen III) */ + case "LLC011": /* Hue Bloom */ + case "LLC012": /* Hue Bloom */ + case "LLC013": /* Storylight */ + case "LST001": /* Light Strips */ + case "LLC010": /* Hue Living Colors Iris + */ + result = [r: [x: 0.704f, y: 0.296f], g: [x: 0.2151f, y: 0.7106f], b: [x: 0.138f, y: 0.08f]]; + break + // Gamut C + case "LLC020": /* Hue Go */ + case "LST002": /* Hue LightStrips Plus */ + result = [r: [x: 0.692f, y: 0.308f], g: [x: 0.17f, y: 0.7f], b: [x: 0.153f, y: 0.048f]]; + break + // Gamut B + case "LCT001": /* Hue A19 */ + case "LCT002": /* Hue BR30 */ + case "LCT003": /* Hue GU10 */ + case "LCT007": /* Hue A19 + */ + case "LLM001": /* Color Light Module + */ + default: + result = [r: [x: 0.675f, y: 0.322f], g: [x: 0.4091f, y: 0.518f], b: [x: 0.167f, y: 0.04f]]; + + } + return result; +} + +/** + * Return x, y value from android color and light model Id. + * Please note: This method does not incorporate brightness values. Get/set it from/to your light seperately. + * + * From Philips Hue SDK modified for Groovy + * + * @param color the color value in hex like // #ffa013 + * @param model the model Id of Light (or null to get default Gamut B) + * @return the float array of length 2, where index 0 and 1 gives x and y values respectively. + */ +private float[] calculateXY(colorStr, model = null) { + + // #ffa013 + def cred = Integer.valueOf(colorStr.substring(1, 3), 16) + def cgreen = Integer.valueOf(colorStr.substring(3, 5), 16) + def cblue = Integer.valueOf(colorStr.substring(5, 7), 16) + + float red = cred / 255.0f; + float green = cgreen / 255.0f; + float blue = cblue / 255.0f; + + // Wide gamut conversion D65 + float r = ((red > 0.04045f) ? (float) Math.pow((red + 0.055f) / (1.0f + 0.055f), 2.4f) : (red / 12.92f)); + float g = (green > 0.04045f) ? (float) Math.pow((green + 0.055f) / (1.0f + 0.055f), 2.4f) : (green / 12.92f); + float b = (blue > 0.04045f) ? (float) Math.pow((blue + 0.055f) / (1.0f + 0.055f), 2.4f) : (blue / 12.92f); + + // Why values are different in ios and android , IOS is considered + // Modified conversion from RGB -> XYZ with better results on colors for + // the lights + float x = r * 0.664511f + g * 0.154324f + b * 0.162028f; + float y = r * 0.283881f + g * 0.668433f + b * 0.047685f; + float z = r * 0.000088f + g * 0.072310f + b * 0.986039f; + + float[] xy = new float[2]; + + xy[0] = (x / (x + y + z)); + xy[1] = (y / (x + y + z)); + if (Float.isNaN(xy[0])) { + xy[0] = 0.0f; + } + if (Float.isNaN(xy[1])) { + xy[1] = 0.0f; + } + /*if(true) + return [0.0f,0.0f]*/ + + // Check if the given XY value is within the colourreach of our lamps. + def xyPoint = [x: xy[0], y: xy[1]]; + def colorPoints = colorPointsForModel(model); + boolean inReachOfLamps = checkPointInLampsReach(xyPoint, colorPoints); + if (!inReachOfLamps) { + // It seems the colour is out of reach + // let's find the closes colour we can produce with our lamp and + // send this XY value out. + + // Find the closest point on each line in the triangle. + def pAB = getClosestPointToPoints(colorPoints.r, colorPoints.g, xyPoint); + def pAC = getClosestPointToPoints(colorPoints.b, colorPoints.r, xyPoint); + def pBC = getClosestPointToPoints(colorPoints.g, colorPoints.b, xyPoint); + + // Get the distances per point and see which point is closer to our + // Point. + float dAB = getDistanceBetweenTwoPoints(xyPoint, pAB); + float dAC = getDistanceBetweenTwoPoints(xyPoint, pAC); + float dBC = getDistanceBetweenTwoPoints(xyPoint, pBC); + + float lowest = dAB; + def closestPoint = pAB; + if (dAC < lowest) { + lowest = dAC; + closestPoint = pAC; + } + if (dBC < lowest) { + lowest = dBC; + closestPoint = pBC; + } + + // Change the xy value to a value which is within the reach of the + // lamp. + xy[0] = closestPoint.x; + xy[1] = closestPoint.y; + } + // xy[0] = PHHueHelper.precision(4, xy[0]); + // xy[1] = PHHueHelper.precision(4, xy[1]); + + // TODO needed, assume it just sets number of decimals? + //xy[0] = PHHueHelper.precision(xy[0]); + //xy[1] = PHHueHelper.precision(xy[1]); + return xy; +} + +/** + * Generates the color for the given XY values and light model. Model can be null if it is not known. + * Note: When the exact values cannot be represented, it will return the closest match. + * Note 2: This method does not incorporate brightness values. Get/Set it from/to your light seperately. + * + * From Philips Hue SDK modified for Groovy + * + * @param points the float array contain x and the y value. [x,y] + * @param model the model of the lamp, example: "LCT001" for hue bulb. Used to calculate the color gamut. + * If this value is empty the default gamut values are used. + * @return the color value in hex (#ff03d3). If xy is null OR xy is not an array of size 2, Color. BLACK will be returned + */ +private String colorFromXY(points, model) { + + if (points == null || model == null) { + log.warn "Input color missing" + return "#000000" + } + + def xy = [x: points[0], y: points[1]]; + + def colorPoints = colorPointsForModel(model); + boolean inReachOfLamps = checkPointInLampsReach(xy, colorPoints); + + if (!inReachOfLamps) { + // It seems the colour is out of reach + // let's find the closest colour we can produce with our lamp and + // send this XY value out. + // Find the closest point on each line in the triangle. + def pAB = getClosestPointToPoints(colorPoints.r, colorPoints.g, xy); + def pAC = getClosestPointToPoints(colorPoints.b, colorPoints.r, xy); + def pBC = getClosestPointToPoints(colorPoints.g, colorPoints.b, xy); + + // Get the distances per point and see which point is closer to our + // Point. + float dAB = getDistanceBetweenTwoPoints(xy, pAB); + float dAC = getDistanceBetweenTwoPoints(xy, pAC); + float dBC = getDistanceBetweenTwoPoints(xy, pBC); + float lowest = dAB; + def closestPoint = pAB; + if (dAC < lowest) { + lowest = dAC; + closestPoint = pAC; + } + if (dBC < lowest) { + lowest = dBC; + closestPoint = pBC; + } + // Change the xy value to a value which is within the reach of the + // lamp. + xy.x = closestPoint.x; + xy.y = closestPoint.y; + } + float x = xy.x; + float y = xy.y; + float z = 1.0f - x - y; + float y2 = 1.0f; + float x2 = (y2 / y) * x; + float z2 = (y2 / y) * z; + /* + * // Wide gamut conversion float r = X * 1.612f - Y * 0.203f - Z * + * 0.302f; float g = -X * 0.509f + Y * 1.412f + Z * 0.066f; float b = X + * * 0.026f - Y * 0.072f + Z * 0.962f; + */ + // sRGB conversion + // float r = X * 3.2410f - Y * 1.5374f - Z * 0.4986f; + // float g = -X * 0.9692f + Y * 1.8760f + Z * 0.0416f; + // float b = X * 0.0556f - Y * 0.2040f + Z * 1.0570f; + + // sRGB D65 conversion + float r = x2 * 1.656492f - y2 * 0.354851f - z2 * 0.255038f; + float g = -x2 * 0.707196f + y2 * 1.655397f + z2 * 0.036152f; + float b = x2 * 0.051713f - y2 * 0.121364f + z2 * 1.011530f; + + if (r > b && r > g && r > 1.0f) { + // red is too big + g = g / r; + b = b / r; + r = 1.0f; + } else if (g > b && g > r && g > 1.0f) { + // green is too big + r = r / g; + b = b / g; + g = 1.0f; + } else if (b > r && b > g && b > 1.0f) { + // blue is too big + r = r / b; + g = g / b; + b = 1.0f; + } + // Apply gamma correction + r = r <= 0.0031308f ? 12.92f * r : (1.0f + 0.055f) * (float) Math.pow(r, (1.0f / 2.4f)) - 0.055f; + g = g <= 0.0031308f ? 12.92f * g : (1.0f + 0.055f) * (float) Math.pow(g, (1.0f / 2.4f)) - 0.055f; + b = b <= 0.0031308f ? 12.92f * b : (1.0f + 0.055f) * (float) Math.pow(b, (1.0f / 2.4f)) - 0.055f; + + if (r > b && r > g) { + // red is biggest + if (r > 1.0f) { + g = g / r; + b = b / r; + r = 1.0f; + } + } else if (g > b && g > r) { + // green is biggest + if (g > 1.0f) { + r = r / g; + b = b / g; + g = 1.0f; + } + } else if (b > r && b > g && b > 1.0f) { + r = r / b; + g = g / b; + b = 1.0f; + } + + // neglecting if the value is negative. + if (r < 0.0f) { + r = 0.0f; + } + if (g < 0.0f) { + g = 0.0f; + } + if (b < 0.0f) { + b = 0.0f; + } + + // Converting float components to int components. + def r1 = String.format("%02X", (int) (r * 255.0f)); + def g1 = String.format("%02X", (int) (g * 255.0f)); + def b1 = String.format("%02X", (int) (b * 255.0f)); + + return "#$r1$g1$b1" +} + +/** + * Calculates crossProduct of two 2D vectors / points. + * + * From Philips Hue SDK modified for Groovy + * + * @param p1 first point used as vector [x: 0.0f, y: 0.0f] + * @param p2 second point used as vector [x: 0.0f, y: 0.0f] + * @return crossProduct of vectors + */ +private float crossProduct(p1, p2) { + return (p1.x * p2.y - p1.y * p2.x); +} + +/** + * Find the closest point on a line. + * This point will be within reach of the lamp. + * + * From Philips Hue SDK modified for Groovy + * + * @param A the point where the line starts [x:..,y:..] + * @param B the point where the line ends [x:..,y:..] + * @param P the point which is close to a line. [x:..,y:..] + * @return the point which is on the line. [x:..,y:..] + */ +private getClosestPointToPoints(A, B, P) { + def AP = [x: (P.x - A.x), y: (P.y - A.y)]; + def AB = [x: (B.x - A.x), y: (B.y - A.y)]; + + float ab2 = AB.x * AB.x + AB.y * AB.y; + float ap_ab = AP.x * AB.x + AP.y * AB.y; + + float t = ap_ab / ab2; + + if (t < 0.0f) + t = 0.0f; + else if (t > 1.0f) + t = 1.0f; + + def newPoint = [x: (A.x + AB.x * t), y: (A.y + AB.y * t)]; + return newPoint; +} + +/** + * Find the distance between two points. + * + * From Philips Hue SDK modified for Groovy + * + * @param one [x:..,y:..] + * @param two [x:..,y:..] + * @return the distance between point one and two + */ +private float getDistanceBetweenTwoPoints(one, two) { + float dx = one.x - two.x; // horizontal difference + float dy = one.y - two.y; // vertical difference + float dist = Math.sqrt(dx * dx + dy * dy); + + return dist; +} + +/** + * Method to see if the given XY value is within the reach of the lamps. + * + * From Philips Hue SDK modified for Groovy + * + * @param p the point containing the X,Y value [x:..,y:..] + * @return true if within reach, false otherwise. + */ +private boolean checkPointInLampsReach(p, colorPoints) { + + def red = colorPoints.r; + def green = colorPoints.g; + def blue = colorPoints.b; + + def v1 = [x: (green.x - red.x), y: (green.y - red.y)]; + def v2 = [x: (blue.x - red.x), y: (blue.y - red.y)]; + + def q = [x: (p.x - red.x), y: (p.y - red.y)]; + + float s = crossProduct(q, v2) / crossProduct(v1, v2); + float t = crossProduct(v1, q) / crossProduct(v1, v2); + + if ((s >= 0.0f) && (t >= 0.0f) && (s + t <= 1.0f)) { + return true; + } else { + return false; + } +} + +/** + * Converts an RGB color in hex to HSV/HSB. + * Algorithm based on http://en.wikipedia.org/wiki/HSV_color_space. + * + * @param colorStr color value in hex (#ff03d3) + * + * @return HSV representation in an array (0-100) [hue, sat, value] + */ +def hexToHsv(colorStr) { + def r = Integer.valueOf(colorStr.substring(1, 3), 16) / 255 + def g = Integer.valueOf(colorStr.substring(3, 5), 16) / 255 + def b = Integer.valueOf(colorStr.substring(5, 7), 16) / 255 + + def max = Math.max(Math.max(r, g), b) + def min = Math.min(Math.min(r, g), b) + + def h, s, v = max + + def d = max - min + s = max == 0 ? 0 : d / max + + if (max == min) { + h = 0 + } else { + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break + case g: h = (b - r) / d + 2; break + case b: h = (r - g) / d + 4; break + } + h /= 6; + } + + return [Math.round(h * 100), Math.round(s * 100), Math.round(v * 100)] +} + +/** + * Converts HSV/HSB color to RGB in hex. + * Algorithm based on http://en.wikipedia.org/wiki/HSV_color_space. + * + * @param hue hue 0-100 + * @param sat saturation 0-100 + * @param value value 0-100 (defaults to 100) + + * @return the color in hex (#ff03d3) + */ +def hsvToHex(hue, sat, value = 100) { + def r, g, b; + def h = hue / 100 + def s = sat / 100 + def v = value / 100 + + def i = Math.floor(h * 6) + def f = h * 6 - i + def p = v * (1 - s) + def q = v * (1 - f * s) + def t = v * (1 - (1 - f) * s) + + switch (i % 6) { + case 0: + r = v + g = t + b = p + break + case 1: + r = q + g = v + b = p + break + case 2: + r = p + g = v + b = t + break + case 3: + r = p + g = q + b = v + break + case 4: + r = t + g = p + b = v + break + case 5: + r = v + g = p + b = q + break + } + + // Converting float components to int components. + def r1 = String.format("%02X", (int) (r * 255.0f)) + def g1 = String.format("%02X", (int) (g * 255.0f)) + def b1 = String.format("%02X", (int) (b * 255.0f)) + + return "#$r1$g1$b1" +} diff --git a/official/hue-mood-lighting.groovy b/official/hue-mood-lighting.groovy new file mode 100755 index 0000000..0fe42be --- /dev/null +++ b/official/hue-mood-lighting.groovy @@ -0,0 +1,339 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Hue Mood Lighting + * + * Author: SmartThings + * * + * Date: 2014-02-21 + */ +definition( + name: "Hue Mood Lighting", + namespace: "smartthings", + author: "SmartThings", + description: "Sets the colors and brightness level of your Philips Hue lights to match your mood.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/hue.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/hue@2x.png" +) + +preferences { + page(name: "mainPage", title: "Adjust the color of your Hue lights to match your mood.", install: true, uninstall: true) + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def mainPage() { + dynamicPage(name: "mainPage") { + def anythingSet = anythingSet() + if (anythingSet) { + section("Set the lighting mood when..."){ + ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + } + section(anythingSet ? "Select additional mood lighting triggers" : "Set the lighting mood when...", hideable: anythingSet, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + section("Control these bulbs...") { + input "hues", "capability.colorControl", title: "Which Hue Bulbs?", required:true, multiple:true + } + section("Choose light effects...") + { + input "color", "enum", title: "Hue Color?", required: false, multiple:false, options: [ + ["Soft White":"Soft White - Default"], + ["White":"White - Concentrate"], + ["Daylight":"Daylight - Energize"], + ["Warm White":"Warm White - Relax"], + "Red","Green","Blue","Yellow","Orange","Purple","Pink"] + input "lightLevel", "enum", title: "Light Level?", required: false, options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]] + } + + section("More options", hideable: true, hidden: true) { + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} +private anythingSet() { + for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","triggerModes","timeOfDay"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } +} + +def eventHandler(evt=null) { + log.trace "Executing Mood Lighting" + if (allOk) { + log.trace "allOk" + def lastTime = state[frequencyKey(evt)] + if (oncePerDayOk(lastTime)) { + if (frequency) { + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + } + else { + takeAction(evt) + } + } + else { + log.debug "Not taking action because it was already taken today" + } + } +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + log.trace "scheduledTimeHandler()" + eventHandler() +} + +def appTouchHandler(evt) { + takeAction(evt) +} + +private takeAction(evt) { + + if (frequency || oncePerDay) { + state[frequencyKey(evt)] = now() + } + + def hueColor = 0 + def saturation = 100 + + switch(color) { + case "White": + hueColor = 52 + saturation = 19 + break; + case "Daylight": + hueColor = 53 + saturation = 91 + break; + case "Soft White": + hueColor = 23 + saturation = 56 + break; + case "Warm White": + hueColor = 20 + saturation = 80 //83 + break; + case "Blue": + hueColor = 70 + break; + case "Green": + hueColor = 39 + break; + case "Yellow": + hueColor = 25 + break; + case "Orange": + hueColor = 10 + break; + case "Purple": + hueColor = 75 + break; + case "Pink": + hueColor = 83 + break; + case "Red": + hueColor = 100 + break; + } + + state.previous = [:] + + hues.each { + state.previous[it.id] = [ + "switch": it.currentValue("switch"), + "level" : it.currentValue("level"), + "hue": it.currentValue("hue"), + "saturation": it.currentValue("saturation") + ] + } + + log.debug "current values = $state.previous" + + def newValue = [hue: hueColor, saturation: saturation, level: lightLevel as Integer ?: 100] + log.debug "new value = $newValue" + + hues*.setColor(newValue) +} + +private frequencyKey(evt) { + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + + +private oncePerDayOk(Long lastTime) { + def result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result - $lastTime" + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private timeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} +// TODO - End Centralize diff --git a/official/humidity-alert.groovy b/official/humidity-alert.groovy new file mode 100755 index 0000000..d194906 --- /dev/null +++ b/official/humidity-alert.groovy @@ -0,0 +1,113 @@ +/** + * Its too humid! + * + * Copyright 2014 Brian Critchlow + * Based on Its too cold code by SmartThings + * 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: "Humidity Alert!", + namespace: "docwisdom", + author: "Brian Critchlow", + description: "Notify me when the humidity rises above or falls below the given threshold. It will turn on a switch when it rises above the first threshold and off when it falls below the second threshold.", + category: "Convenience", + iconUrl: "https://graph.api.smartthings.com/api/devices/icons/st.Weather.weather9-icn", + iconX2Url: "https://graph.api.smartthings.com/api/devices/icons/st.Weather.weather9-icn?displaySize=2x" +) + + +preferences { + section("Monitor the humidity of:") { + input "humiditySensor1", "capability.relativeHumidityMeasurement" + } + section("When the humidity rises above:") { + input "humidity1", "number", title: "Percentage ?" + } + section("When the humidity falls below:") { + input "humidity2", "number", title: "Percentage ?" + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes","No"]], required:false + input "phone1", "phone", title: "Send a Text Message?", required: false + } + section("Control this switch:") { + input "switch1", "capability.switch", required: false + } +} + +def installed() { + subscribe(humiditySensor1, "humidity", humidityHandler) +} + +def updated() { + unsubscribe() + subscribe(humiditySensor1, "humidity", humidityHandler) +} + +def humidityHandler(evt) { + log.trace "humidity: ${evt.value}" + log.trace "set point: ${humidity1}" + + def currentHumidity = Double.parseDouble(evt.value.replace("%", "")) + def tooHumid = humidity1 + def notHumidEnough = humidity2 + def mySwitch = settings.switch1 + def deltaMinutes = 10 + + def timeAgo = new Date(now() - (1000 * 60 * deltaMinutes).toLong()) + def recentEvents = humiditySensor1.eventsSince(timeAgo) + log.trace "Found ${recentEvents?.size() ?: 0} events in the last ${deltaMinutes} minutes" + def alreadySentSms = recentEvents.count { Double.parseDouble(it.value.replace("%", "")) >= tooHumid } > 1 || recentEvents.count { Double.parseDouble(it.value.replace("%", "")) <= notHumidEnough } > 1 + + if (currentHumidity >= tooHumid) { + log.debug "Checking how long the humidity sensor has been reporting >= ${tooHumid}" + + // Don't send a continuous stream of text messages + + + + if (alreadySentSms) { + log.debug "Notification already sent within the last ${deltaMinutes} minutes" + + } else { + log.debug "Humidity Rose Above ${tooHumid}: sending SMS and activating ${mySwitch}" + send("${humiditySensor1.label} sensed high humidity level of ${evt.value}") + switch1?.on() + } + } + + if (currentHumidity <= notHumidEnough) { + log.debug "Checking how long the humidity sensor has been reporting <= ${notHumidEnough}" + + if (alreadySentSms) { + log.debug "Notification already sent within the last ${deltaMinutes} minutes" + + } else { + log.debug "Humidity Fell Below ${notHumidEnough}: sending SMS and activating ${mySwitch}" + send("${humiditySensor1.label} sensed high humidity level of ${evt.value}") + switch1?.off() + } + } +} + +private send(msg) { + if ( sendPushMessage != "No" ) { + log.debug( "sending push message" ) + sendPush( msg ) + } + + if ( phone1 ) { + log.debug( "sending text message" ) + sendSms( phone1, msg ) + } + + log.debug msg +} diff --git a/official/ifttt.groovy b/official/ifttt.groovy new file mode 100755 index 0000000..74dd2b2 --- /dev/null +++ b/official/ifttt.groovy @@ -0,0 +1,289 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * IFTTT API Access Application + * + * Author: SmartThings + * + * ---------------------+----------------+--------------------------+------------------------------------ + * Device Type | Attribute Name | Commands | Attribute Values + * ---------------------+----------------+--------------------------+------------------------------------ + * switches | switch | on, off | on, off + * motionSensors | motion | | active, inactive + * contactSensors | contact | | open, closed + * presenceSensors | presence | | present, 'not present' + * temperatureSensors | temperature | | + * accelerationSensors | acceleration | | active, inactive + * waterSensors | water | | wet, dry + * lightSensors | illuminance | | + * humiditySensors | humidity | | + * alarms | alarm | strobe, siren, both, off | strobe, siren, both, off + * locks | lock | lock, unlock | locked, unlocked + * ---------------------+----------------+--------------------------+------------------------------------ + */ + +definition( + name: "IFTTT", + namespace: "smartthings", + author: "SmartThings", + description: "Put the internet to work for you.", + category: "SmartThings Internal", + iconUrl: "https://ifttt.com/images/channels/ifttt.png", + iconX2Url: "https://ifttt.com/images/channels/ifttt_med.png", + oauth: [displayName: "IFTTT", displayLink: "https://ifttt.com"] +) + +preferences { + section("Allow IFTTT to control these things...") { + input "switches", "capability.switch", title: "Which Switches?", multiple: true, required: false + input "motionSensors", "capability.motionSensor", title: "Which Motion Sensors?", multiple: true, required: false + input "contactSensors", "capability.contactSensor", title: "Which Contact Sensors?", multiple: true, required: false + input "presenceSensors", "capability.presenceSensor", title: "Which Presence Sensors?", multiple: true, required: false + input "temperatureSensors", "capability.temperatureMeasurement", title: "Which Temperature Sensors?", multiple: true, required: false + input "accelerationSensors", "capability.accelerationSensor", title: "Which Vibration Sensors?", multiple: true, required: false + input "waterSensors", "capability.waterSensor", title: "Which Water Sensors?", multiple: true, required: false + input "lightSensors", "capability.illuminanceMeasurement", title: "Which Light Sensors?", multiple: true, required: false + input "humiditySensors", "capability.relativeHumidityMeasurement", title: "Which Relative Humidity Sensors?", multiple: true, required: false + input "alarms", "capability.alarm", title: "Which Sirens?", multiple: true, required: false + input "locks", "capability.lock", title: "Which Locks?", multiple: true, required: false + } +} + +mappings { + + path("/:deviceType") { + action: [ + GET: "list" + ] + } + path("/:deviceType/states") { + action: [ + GET: "listStates" + ] + } + path("/:deviceType/subscription") { + action: [ + POST: "addSubscription" + ] + } + path("/:deviceType/subscriptions/:id") { + action: [ + DELETE: "removeSubscription" + ] + } + path("/:deviceType/:id") { + action: [ + GET: "show", + PUT: "update" + ] + } + path("/subscriptions") { + action: [ + GET: "listSubscriptions" + ] + } +} + +def installed() { + log.debug settings +} + +def updated() { + def currentDeviceIds = settings.collect { k, devices -> devices }.flatten().collect { it.id }.unique() + def subscriptionDevicesToRemove = app.subscriptions*.device.findAll { device -> + !currentDeviceIds.contains(device.id) + } + subscriptionDevicesToRemove.each { device -> + log.debug "Removing $device.displayName subscription" + state.remove(device.id) + unsubscribe(device) + } + log.debug settings +} + +def list() { + log.debug "[PROD] list, params: ${params}" + def type = params.deviceType + settings[type]?.collect{deviceItem(it)} ?: [] +} + +def listStates() { + log.debug "[PROD] states, params: ${params}" + def type = params.deviceType + def attributeName = attributeFor(type) + settings[type]?.collect{deviceState(it, it.currentState(attributeName))} ?: [] +} + +def listSubscriptions() { + state +} + +def update() { + def type = params.deviceType + def data = request.JSON + def devices = settings[type] + def device = settings[type]?.find { it.id == params.id } + def command = data.command + + log.debug "[PROD] update, params: ${params}, request: ${data}, devices: ${devices*.id}" + + if (!device) { + httpError(404, "Device not found") + } + + if (validateCommand(device, type, command)) { + device."$command"() + } else { + httpError(403, "Access denied. This command is not supported by current capability.") + } +} + +/** + * Validating the command passed by the user based on capability. + * @return boolean + */ +def validateCommand(device, deviceType, command) { + def capabilityCommands = getDeviceCapabilityCommands(device.capabilities) + def currentDeviceCapability = getCapabilityName(deviceType) + if (capabilityCommands[currentDeviceCapability]) { + return command in capabilityCommands[currentDeviceCapability] ? true : false + } else { + // Handling other device types here, which don't accept commands + httpError(400, "Bad request.") + } +} + +/** + * Need to get the attribute name to do the lookup. Only + * doing it for the device types which accept commands + * @return attribute name of the device type + */ +def getCapabilityName(type) { + switch(type) { + case "switches": + return "Switch" + case "alarms": + return "Alarm" + case "locks": + return "Lock" + default: + return type + } +} + +/** + * Constructing the map over here of + * supported commands by device capability + * @return a map of device capability -> supported commands + */ +def getDeviceCapabilityCommands(deviceCapabilities) { + def map = [:] + deviceCapabilities.collect { + map[it.name] = it.commands.collect{ it.name.toString() } + } + return map +} + + +def show() { + def type = params.deviceType + def devices = settings[type] + def device = devices.find { it.id == params.id } + + log.debug "[PROD] show, params: ${params}, devices: ${devices*.id}" + if (!device) { + httpError(404, "Device not found") + } + else { + def attributeName = attributeFor(type) + def s = device.currentState(attributeName) + deviceState(device, s) + } +} + +def addSubscription() { + log.debug "[PROD] addSubscription1" + def type = params.deviceType + def data = request.JSON + def attribute = attributeFor(type) + def devices = settings[type] + def deviceId = data.deviceId + def callbackUrl = data.callbackUrl + def device = devices.find { it.id == deviceId } + + log.debug "[PROD] addSubscription, params: ${params}, request: ${data}, device: ${device}" + if (device) { + log.debug "Adding switch subscription " + callbackUrl + state[deviceId] = [callbackUrl: callbackUrl] + subscribe(device, attribute, deviceHandler) + } + log.info state + +} + +def removeSubscription() { + def type = params.deviceType + def devices = settings[type] + def deviceId = params.id + def device = devices.find { it.id == deviceId } + + log.debug "[PROD] removeSubscription, params: ${params}, request: ${data}, device: ${device}" + if (device) { + log.debug "Removing $device.displayName subscription" + state.remove(device.id) + unsubscribe(device) + } + log.info state +} + +def deviceHandler(evt) { + def deviceInfo = state[evt.deviceId] + if (deviceInfo) { + try { + httpPostJson(uri: deviceInfo.callbackUrl, path: '', body: [evt: [deviceId: evt.deviceId, name: evt.name, value: evt.value]]) { + log.debug "[PROD IFTTT] Event data successfully posted" + } + } catch (groovyx.net.http.ResponseParseException e) { + log.debug("Error parsing ifttt payload ${e}") + } + } else { + log.debug "[PROD] No subscribed device found" + } +} + +private deviceItem(it) { + it ? [id: it.id, label: it.displayName] : null +} + +private deviceState(device, s) { + device && s ? [id: device.id, label: device.displayName, name: s.name, value: s.value, unixTime: s.date.time] : null +} + +private attributeFor(type) { + switch (type) { + case "switches": + log.debug "[PROD] switch type" + return "switch" + case "locks": + log.debug "[PROD] lock type" + return "lock" + case "alarms": + log.debug "[PROD] alarm type" + return "alarm" + case "lightSensors": + log.debug "[PROD] illuminance type" + return "illuminance" + default: + log.debug "[PROD] other sensor type" + return type - "Sensors" + } +} diff --git a/official/initial-state-event-streamer.groovy b/official/initial-state-event-streamer.groovy new file mode 100755 index 0000000..5fa0ae9 --- /dev/null +++ b/official/initial-state-event-streamer.groovy @@ -0,0 +1,376 @@ +/** + * Initial State Event Streamer + * + * Copyright 2016 David Sulpy + * + * 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. + * + * SmartThings data is sent from this SmartApp to Initial State. This is event data only for + * devices for which the user has authorized. Likewise, Initial State's services call this + * SmartApp on the user's behalf to configure Initial State specific parameters. The ToS and + * Privacy Policy for Initial State can be found here: https://www.initialstate.com/terms + */ + +definition( + name: "Initial State Event Streamer", + namespace: "initialstate.events", + author: "David Sulpy", + description: "A SmartThings SmartApp to allow SmartThings events to be viewable inside an Initial State Event Bucket in your https://www.initialstate.com account.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertica_small.png", + iconX2Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + iconX3Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + oauth: [displayName: "Initial State", displayLink: "https://www.initialstate.com"]) + +import groovy.json.JsonSlurper + +preferences { + section("Choose which devices to monitor...") { + input "accelerometers", "capability.accelerationSensor", title: "Accelerometers", multiple: true, required: false + input "alarms", "capability.alarm", title: "Alarms", multiple: true, required: false + input "batteries", "capability.battery", title: "Batteries", multiple: true, required: false + input "beacons", "capability.beacon", title: "Beacons", multiple: true, required: false + input "cos", "capability.carbonMonoxideDetector", title: "Carbon Monoxide Detectors", multiple: true, required: false + input "colors", "capability.colorControl", title: "Color Controllers", multiple: true, required: false + input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false + input "doorsControllers", "capability.doorControl", title: "Door Controllers", multiple: true, required: false + input "energyMeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false + input "illuminances", "capability.illuminanceMeasurement", title: "Illuminance Meters", multiple: true, required: false + input "locks", "capability.lock", title: "Locks", multiple: true, required: false + input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Music Players", multiple: true, required: false + input "powerMeters", "capability.powerMeter", title: "Power Meters", multiple: true, required: false + input "presences", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false + input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity Meters", multiple: true, required: false + input "relaySwitches", "capability.relaySwitch", title: "Relay Switches", multiple: true, required: false + input "sleepSensors", "capability.sleepSensor", title: "Sleep Sensors", multiple: true, required: false + input "smokeDetectors", "capability.smokeDetector", title: "Smoke Detectors", multiple: true, required: false + input "peds", "capability.stepSensor", title: "Pedometers", multiple: true, required: false + input "switches", "capability.switch", title: "Switches", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Switch Levels", multiple: true, required: false + input "temperatures", "capability.temperatureMeasurement", title: "Temperature Sensors", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Thermostats", multiple: true, required: false + input "valves", "capability.valve", title: "Valves", multiple: true, required: false + input "waterSensors", "capability.waterSensor", title: "Water Sensors", multiple: true, required: false + } +} + +mappings { + path("/access_key") { + action: [ + GET: "getAccessKey", + PUT: "setAccessKey" + ] + } + path("/bucket") { + action: [ + GET: "getBucketKey", + PUT: "setBucketKey" + ] + } +} + +def getAccessKey() { + log.trace "get access key" + if (atomicState.accessKey == null) { + httpError(404, "Access Key Not Found") + } else { + [ + accessKey: atomicState.accessKey + ] + } +} + +def getBucketKey() { + log.trace "get bucket key" + if (atomicState.bucketKey == null) { + httpError(404, "Bucket key Not Found") + } else { + [ + bucketKey: atomicState.bucketKey, + bucketName: atomicState.bucketName + ] + } +} + +def setBucketKey() { + log.trace "set bucket key" + def newBucketKey = request.JSON?.bucketKey + def newBucketName = request.JSON?.bucketName + + log.debug "bucket name: $newBucketName" + log.debug "bucket key: $newBucketKey" + + if (newBucketKey && (newBucketKey != atomicState.bucketKey || newBucketName != atomicState.bucketName)) { + atomicState.bucketKey = "$newBucketKey" + atomicState.bucketName = "$newBucketName" + atomicState.isBucketCreated = false + } + + tryCreateBucket() +} + +def setAccessKey() { + log.trace "set access key" + def newAccessKey = request.JSON?.accessKey + def newGrokerSubdomain = request.JSON?.grokerSubdomain + + if (newGrokerSubdomain && newGrokerSubdomain != "" && newGrokerSubdomain != atomicState.grokerSubdomain) { + atomicState.grokerSubdomain = "$newGrokerSubdomain" + atomicState.isBucketCreated = false + } + + if (newAccessKey && newAccessKey != atomicState.accessKey) { + atomicState.accessKey = "$newAccessKey" + atomicState.isBucketCreated = false + } +} + +def subscribeToEvents() { + if (accelerometers != null) { + subscribe(accelerometers, "acceleration", genericHandler) + } + if (alarms != null) { + subscribe(alarms, "alarm", genericHandler) + } + if (batteries != null) { + subscribe(batteries, "battery", genericHandler) + } + if (beacons != null) { + subscribe(beacons, "presence", genericHandler) + } + + if (cos != null) { + subscribe(cos, "carbonMonoxide", genericHandler) + } + if (colors != null) { + subscribe(colors, "hue", genericHandler) + subscribe(colors, "saturation", genericHandler) + subscribe(colors, "color", genericHandler) + } + if (contacts != null) { + subscribe(contacts, "contact", genericHandler) + } + if (energyMeters != null) { + subscribe(energyMeters, "energy", genericHandler) + } + if (illuminances != null) { + subscribe(illuminances, "illuminance", genericHandler) + } + if (locks != null) { + subscribe(locks, "lock", genericHandler) + } + if (motions != null) { + subscribe(motions, "motion", genericHandler) + } + if (musicPlayers != null) { + subscribe(musicPlayers, "status", genericHandler) + subscribe(musicPlayers, "level", genericHandler) + subscribe(musicPlayers, "trackDescription", genericHandler) + subscribe(musicPlayers, "trackData", genericHandler) + subscribe(musicPlayers, "mute", genericHandler) + } + if (powerMeters != null) { + subscribe(powerMeters, "power", genericHandler) + } + if (presences != null) { + subscribe(presences, "presence", genericHandler) + } + if (humidities != null) { + subscribe(humidities, "humidity", genericHandler) + } + if (relaySwitches != null) { + subscribe(relaySwitches, "switch", genericHandler) + } + if (sleepSensors != null) { + subscribe(sleepSensors, "sleeping", genericHandler) + } + if (smokeDetectors != null) { + subscribe(smokeDetectors, "smoke", genericHandler) + } + if (peds != null) { + subscribe(peds, "steps", genericHandler) + subscribe(peds, "goal", genericHandler) + } + if (switches != null) { + subscribe(switches, "switch", genericHandler) + } + if (switchLevels != null) { + subscribe(switchLevels, "level", genericHandler) + } + if (temperatures != null) { + subscribe(temperatures, "temperature", genericHandler) + } + if (thermostats != null) { + subscribe(thermostats, "temperature", genericHandler) + subscribe(thermostats, "heatingSetpoint", genericHandler) + subscribe(thermostats, "coolingSetpoint", genericHandler) + subscribe(thermostats, "thermostatSetpoint", genericHandler) + subscribe(thermostats, "thermostatMode", genericHandler) + subscribe(thermostats, "thermostatFanMode", genericHandler) + subscribe(thermostats, "thermostatOperatingState", genericHandler) + } + if (valves != null) { + subscribe(valves, "contact", genericHandler) + } + if (waterSensors != null) { + subscribe(waterSensors, "water", genericHandler) + } +} + +def installed() { + atomicState.version = "1.1.0" + + atomicState.isBucketCreated = false + atomicState.grokerSubdomain = "groker" + + subscribeToEvents() + + atomicState.isBucketCreated = false + atomicState.grokerSubdomain = "groker" + + log.debug "installed (version $atomicState.version)" +} + +def updated() { + atomicState.version = "1.1.0" + unsubscribe() + + if (atomicState.bucketKey != null && atomicState.accessKey != null) { + atomicState.isBucketCreated = false + } + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + atomicState.grokerSubdomain = "groker" + } + + subscribeToEvents() + + log.debug "updated (version $atomicState.version)" +} + +def uninstalled() { + log.debug "uninstalled (version $atomicState.version)" +} + +def tryCreateBucket() { + + // can't ship events if there is no grokerSubdomain + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + + // if the bucket has already been created, no need to continue + if (atomicState.isBucketCreated) { + return + } + + if (!atomicState.bucketName) { + atomicState.bucketName = atomicState.bucketKey + } + if (!atomicState.accessKey) { + return + } + def bucketName = "${atomicState.bucketName}" + def bucketKey = "${atomicState.bucketKey}" + def accessKey = "${atomicState.accessKey}" + + def bucketCreateBody = new JsonSlurper().parseText("{\"bucketKey\": \"$bucketKey\", \"bucketName\": \"$bucketName\"}") + + def bucketCreatePost = [ + uri: "https://${atomicState.grokerSubdomain}.initialstate.com/api/buckets", + headers: [ + "Content-Type": "application/json", + "X-IS-AccessKey": accessKey + ], + body: bucketCreateBody + ] + + log.debug bucketCreatePost + + try { + // Create a bucket on Initial State so the data has a logical grouping + httpPostJson(bucketCreatePost) { resp -> + log.debug "bucket posted" + if (resp.status >= 400) { + log.error "bucket not created successfully" + } else { + atomicState.isBucketCreated = true + } + } + } catch (e) { + log.error "bucket creation error: $e" + } + +} + +def genericHandler(evt) { + log.trace "$evt.displayName($evt.name:$evt.unit) $evt.value" + + def key = "$evt.displayName($evt.name)" + if (evt.unit != null) { + key = "$evt.displayName(${evt.name}_$evt.unit)" + } + def value = "$evt.value" + + tryCreateBucket() + + eventHandler(key, value) +} + +def eventHandler(name, value) { + def epoch = now() / 1000 + + def event = new JsonSlurper().parseText("{\"key\": \"$name\", \"value\": \"$value\", \"epoch\": \"$epoch\"}") + + tryShipEvents(event) + + log.debug "Shipped Event: " + event +} + +def tryShipEvents(event) { + + def grokerSubdomain = atomicState.grokerSubdomain + // can't ship events if there is no grokerSubdomain + if (grokerSubdomain == null || grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + def accessKey = atomicState.accessKey + def bucketKey = atomicState.bucketKey + // can't ship if access key and bucket key are null, so finish trying + if (accessKey == null || bucketKey == null) { + return + } + + def eventPost = [ + uri: "https://${grokerSubdomain}.initialstate.com/api/events", + headers: [ + "Content-Type": "application/json", + "X-IS-BucketKey": "${bucketKey}", + "X-IS-AccessKey": "${accessKey}", + "Accept-Version": "0.0.2" + ], + body: event + ] + + try { + // post the events to initial state + httpPostJson(eventPost) { resp -> + log.debug "shipped events and got ${resp.status}" + if (resp.status >= 400) { + log.error "shipping failed... ${resp.data}" + } + } + } catch (e) { + log.error "shipping events failed: $e" + } + +} \ No newline at end of file diff --git a/official/it-moved.groovy b/official/it-moved.groovy new file mode 100755 index 0000000..a7a6899 --- /dev/null +++ b/official/it-moved.groovy @@ -0,0 +1,67 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * It Moved + * + * Author: SmartThings + */ +definition( + name: "It Moved", + namespace: "smartthings", + author: "SmartThings", + description: "Send a text when movement is detected", + category: "Fun & Social", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/text_accelerometer.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text_accelerometer@2x.png" +) + +preferences { + section("When movement is detected...") { + input "accelerationSensor", "capability.accelerationSensor", title: "Where?" + } + section("Text me at...") { + input("recipients", "contact", title: "Send notifications to") { + input "phone1", "phone", title: "Phone number?" + } + } +} + +def installed() { + subscribe(accelerationSensor, "acceleration.active", accelerationActiveHandler) +} + +def updated() { + unsubscribe() + subscribe(accelerationSensor, "acceleration.active", accelerationActiveHandler) +} + +def accelerationActiveHandler(evt) { + // Don't send a continuous stream of text messages + def deltaSeconds = 5 + def timeAgo = new Date(now() - (1000 * deltaSeconds)) + def recentEvents = accelerationSensor.eventsSince(timeAgo) + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $deltaSeconds seconds" + def alreadySentSms = recentEvents.count { it.value && it.value == "active" } > 1 + + if (alreadySentSms) { + log.debug "SMS already sent within the last $deltaSeconds seconds" + } else { + if (location.contactBookEnabled) { + log.debug "accelerationSensor has moved, texting contacts: ${recipients?.size()}" + sendNotificationToContacts("${accelerationSensor.label ?: accelerationSensor.name} moved", recipients) + } + else { + log.debug "accelerationSensor has moved, sending text message" + sendSms(phone1, "${accelerationSensor.label ?: accelerationSensor.name} moved") + } + } +} diff --git a/official/its-too-cold.groovy b/official/its-too-cold.groovy new file mode 100755 index 0000000..e06f8a2 --- /dev/null +++ b/official/its-too-cold.groovy @@ -0,0 +1,101 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * It's Too Cold + * + * Author: SmartThings + */ +definition( + name: "It's Too Cold", + namespace: "smartthings", + author: "SmartThings", + description: "Monitor the temperature and when it drops below your setting get a text and/or turn on a heater or additional appliance.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch@2x.png" +) + +preferences { + section("Monitor the temperature...") { + input "temperatureSensor1", "capability.temperatureMeasurement" + } + section("When the temperature drops below...") { + input "temperature1", "number", title: "Temperature?" + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to") { + input "sendPushMessage", "enum", title: "Send a push notification?", options: ["Yes", "No"], required: false + input "phone1", "phone", title: "Send a Text Message?", required: false + } + } + section("Turn on a heater...") { + input "switch1", "capability.switch", required: false + } +} + +def installed() { + subscribe(temperatureSensor1, "temperature", temperatureHandler) +} + +def updated() { + unsubscribe() + subscribe(temperatureSensor1, "temperature", temperatureHandler) +} + +def temperatureHandler(evt) { + log.trace "temperature: $evt.value, $evt" + + def tooCold = temperature1 + def mySwitch = settings.switch1 + + // TODO: Replace event checks with internal state (the most reliable way to know if an SMS has been sent recently or not). + if (evt.doubleValue <= tooCold) { + log.debug "Checking how long the temperature sensor has been reporting <= $tooCold" + + // Don't send a continuous stream of text messages + def deltaMinutes = 10 // TODO: Ask for "retry interval" in prefs? + def timeAgo = new Date(now() - (1000 * 60 * deltaMinutes).toLong()) + def recentEvents = temperatureSensor1.eventsSince(timeAgo)?.findAll { it.name == "temperature" } + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $deltaMinutes minutes" + def alreadySentSms = recentEvents.count { it.doubleValue <= tooCold } > 1 + + if (alreadySentSms) { + log.debug "SMS already sent within the last $deltaMinutes minutes" + // TODO: Send "Temperature back to normal" SMS, turn switch off + } else { + log.debug "Temperature dropped below $tooCold: sending SMS and activating $mySwitch" + def tempScale = location.temperatureScale ?: "F" + send("${temperatureSensor1.displayName} is too cold, reporting a temperature of ${evt.value}${evt.unit?:tempScale}") + switch1?.on() + } + } +} + +private send(msg) { + if (location.contactBookEnabled) { + log.debug("sending notifications to: ${recipients?.size()}") + sendNotificationToContacts(msg, recipients) + } + else { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phone1) { + log.debug("sending text message") + sendSms(phone1, msg) + } + } + + log.debug msg +} diff --git a/official/its-too-hot.groovy b/official/its-too-hot.groovy new file mode 100755 index 0000000..6ffcebf --- /dev/null +++ b/official/its-too-hot.groovy @@ -0,0 +1,101 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * It's Too Hot + * + * Author: SmartThings + */ +definition( + name: "It's Too Hot", + namespace: "smartthings", + author: "SmartThings", + description: "Monitor the temperature and when it rises above your setting get a notification and/or turn on an A/C unit or fan.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/its-too-hot.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/its-too-hot@2x.png" +) + +preferences { + section("Monitor the temperature...") { + input "temperatureSensor1", "capability.temperatureMeasurement" + } + section("When the temperature rises above...") { + input "temperature1", "number", title: "Temperature?" + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to") { + input "sendPushMessage", "enum", title: "Send a push notification?", options: ["Yes", "No"], required: false + input "phone1", "phone", title: "Send a Text Message?", required: false + } + } + section("Turn on which A/C or fan...") { + input "switch1", "capability.switch", required: false + } +} + +def installed() { + subscribe(temperatureSensor1, "temperature", temperatureHandler) +} + +def updated() { + unsubscribe() + subscribe(temperatureSensor1, "temperature", temperatureHandler) +} + +def temperatureHandler(evt) { + log.trace "temperature: $evt.value, $evt" + + def tooHot = temperature1 + def mySwitch = settings.switch1 + + // TODO: Replace event checks with internal state (the most reliable way to know if an SMS has been sent recently or not). + if (evt.doubleValue >= tooHot) { + log.debug "Checking how long the temperature sensor has been reporting <= $tooHot" + + // Don't send a continuous stream of text messages + def deltaMinutes = 10 // TODO: Ask for "retry interval" in prefs? + def timeAgo = new Date(now() - (1000 * 60 * deltaMinutes).toLong()) + def recentEvents = temperatureSensor1.eventsSince(timeAgo)?.findAll { it.name == "temperature" } + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $deltaMinutes minutes" + def alreadySentSms = recentEvents.count { it.doubleValue >= tooHot } > 1 + + if (alreadySentSms) { + log.debug "SMS already sent within the last $deltaMinutes minutes" + // TODO: Send "Temperature back to normal" SMS, turn switch off + } else { + log.debug "Temperature rose above $tooHot: sending SMS and activating $mySwitch" + def tempScale = location.temperatureScale ?: "F" + send("${temperatureSensor1.displayName} is too hot, reporting a temperature of ${evt.value}${evt.unit?:tempScale}") + switch1?.on() + } + } +} + +private send(msg) { + if (location.contactBookEnabled) { + log.debug("sending notifications to: ${recipients?.size()}") + sendNotificationToContacts(msg, recipients) + } + else { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phone1) { + log.debug("sending text message") + sendSms(phone1, msg) + } + } + + log.debug msg +} diff --git a/official/jawbone-button-notifier.groovy b/official/jawbone-button-notifier.groovy new file mode 100755 index 0000000..41b7211 --- /dev/null +++ b/official/jawbone-button-notifier.groovy @@ -0,0 +1,85 @@ +/** + * Jawbone Panic Button + * + * Copyright 2014 Juan Risso + * + * 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. + * + */ + +// Automatically generated. Make future change here. +definition( + name: "Jawbone Button Notifier", + namespace: "juano2310", + author: "Juan Risso", + category: "SmartThings Labs", + description: "Send push notifications or text messages with your Jawbone Up when you hold the button.", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/jawbone-up.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/jawbone-up@2x.png", +) + +preferences { + section("Use this Jawbone as a notification button and...") { + input "jawbone", "device.jawboneUser", multiple: true + } + section("Send a message when you press and hold the button...") { + input "warnMessage", "text", title: "Warning Message" + } + section("Or text message to these numbers (optional)") { + input ("phone1", "contact", required: false) { + input "phone1", "phone", required: false + } + input ("phone2", "contact", required: false) { + input "phone2", "phone", required: false + } + input ("phone3", "contact", required: false) { + input "phone3", "phone", required: false + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(jawbone, "sleeping", sendit) +} + +def sendit(evt) { + log.debug "$evt.value: $evt" + sendMessage() +} + +def sendMessage() { + log.debug "Sending Message" + def msg = warnMessage + log.info msg + if (phone1) { + sendSms phone1, msg + } + if (phone2) { + sendSms phone2, msg + } + if (phone3) { + sendSms phone3, msg + } + if (!phone1 && !phone2 && !phone3) { + sendPush msg + } +} diff --git a/official/jawbone-up-connect.groovy b/official/jawbone-up-connect.groovy new file mode 100755 index 0000000..a13a7d7 --- /dev/null +++ b/official/jawbone-up-connect.groovy @@ -0,0 +1,490 @@ +/** + * Jawbone Service Manager + * + * Author: Juan Risso + * Date: 2013-12-19 + */ + +include 'asynchttp_v1' + +definition( + name: "Jawbone UP (Connect)", + namespace: "juano2310", + author: "Juan Pablo Risso", + description: "Connect your Jawbone UP to SmartThings", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/jawbone-up.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/jawbone-up@2x.png", + oauth: true, + usePreferencesForAuthorization: false, + singleInstance: true +) { + appSetting "clientId" + appSetting "clientSecret" +} + +preferences { + page(name: "Credentials", title: "Jawbone UP", content: "authPage", install: false) +} + +mappings { + path("/receivedToken") { action: [ POST: "receivedToken", GET: "receivedToken"] } + path("/receiveToken") { action: [ POST: "receiveToken", GET: "receiveToken"] } + path("/hookCallback") { action: [ POST: "hookEventHandler", GET: "hookEventHandler"] } + path("/oauth/initialize") {action: [GET: "oauthInitUrl"]} + path("/oauth/callback") { action: [ GET: "callback" ] } +} + +def getServerUrl() { return "https://graph.api.smartthings.com" } +def getBuildRedirectUrl() { "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${apiServerUrl}" } +def buildRedirectUrl(page) { return buildActionUrl(page) } + +def callback() { + def redirectUrl = null + if (params.authQueryString) { + redirectUrl = URLDecoder.decode(params.authQueryString.replaceAll(".+&redirect_url=", "")) + log.debug "redirectUrl: ${redirectUrl}" + } else { + log.warn "No authQueryString" + } + + if (state.JawboneAccessToken) { + log.debug "Access token already exists" + setup() + success() + } else { + def code = params.code + if (code) { + if (code.size() > 6) { + // Jawbone code + log.debug "Exchanging code for access token" + receiveToken(redirectUrl) + } else { + // SmartThings code, which we ignore, as we don't need to exchange for an access token. + // Instead, go initiate the Jawbone OAuth flow. + log.debug "Executing callback redirect to auth page" + state.oauthInitState = UUID.randomUUID().toString() + def oauthParams = [response_type: "code", client_id: appSettings.clientId, scope: "move_read sleep_read", redirect_uri: "${serverUrl}/oauth/callback"] + redirect(location: "https://jawbone.com/auth/oauth2/auth?${toQueryString(oauthParams)}") + } + } else { + log.debug "This code should be unreachable" + success() + } + } +} + +def authPage() { + log.debug "authPage" + def description = null + if (state.JawboneAccessToken == null) { + if (!state.accessToken) { + log.debug "About to create access token" + createAccessToken() + } + description = "Click to enter Jawbone Credentials" + def redirectUrl = buildRedirectUrl + log.debug "RedirectURL = ${redirectUrl}" + def donebutton= state.JawboneAccessToken != null + return dynamicPage(name: "Credentials", title: "Jawbone UP", nextPage: null, uninstall: true, install: donebutton) { + section { paragraph title: "Note:", "This device has not been officially tested and certified to “Work with SmartThings”. You can connect it to your SmartThings home but performance may vary and we will not be able to provide support or assistance." } + section { href url:redirectUrl, style:"embedded", required:true, title:"Jawbone UP", state: hast ,description:description } + } + } else { + description = "Jawbone Credentials Already Entered." + return dynamicPage(name: "Credentials", title: "Jawbone UP", uninstall: true, install:true) { + section { href url: buildRedirectUrl("receivedToken"), style:"embedded", state: "complete", title:"Jawbone UP", description:description } + } + } +} + +def oauthInitUrl() { + log.debug "oauthInitUrl" + state.oauthInitState = UUID.randomUUID().toString() + def oauthParams = [ response_type: "code", client_id: appSettings.clientId, scope: "move_read sleep_read", redirect_uri: "${serverUrl}/oauth/callback" ] + redirect(location: "https://jawbone.com/auth/oauth2/auth?${toQueryString(oauthParams)}") +} + +def receiveToken(redirectUrl = null) { + log.debug "receiveToken" + def oauthParams = [ client_id: appSettings.clientId, client_secret: appSettings.clientSecret, grant_type: "authorization_code", code: params.code ] + def params = [ + uri: "https://jawbone.com/auth/oauth2/token?${toQueryString(oauthParams)}", + ] + httpGet(params) { response -> + log.debug "${response.data}" + log.debug "Setting access token to ${response.data.access_token}, refresh token to ${response.data.refresh_token}" + state.JawboneAccessToken = response.data.access_token + state.refreshToken = response.data.refresh_token + } + + setup() + if (state.JawboneAccessToken) { + success() + } else { + def message = """ +

The connection could not be established!

+

Click 'Done' to return to the menu.

+ """ + connectionStatus(message) + } +} + +def success() { + def message = """ +

Your Jawbone Account is now connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + connectionStatus(message) +} + +def receivedToken() { + def message = """ +

Your Jawbone Account is already connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + connectionStatus(message) +} + +def connectionStatus(message, redirectUrl = null) { + def redirectHtml = "" + if (redirectUrl) { + redirectHtml = """ + + """ + } + + def html = """ + + + + + SmartThings Connection + + ${redirectHtml} + + +
+ Jawbone UP icon + connected device icon + SmartThings logo + ${message} +
+ + + """ + render contentType: 'text/html', data: html +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +def validateCurrentToken() { + log.debug "validateCurrentToken" + def url = "https://jawbone.com/nudge/api/v.1.1/users/@me/refreshToken" + def requestBody = "secret=${appSettings.clientSecret}" + + try { + httpPost(uri: url, headers: ["Authorization": "Bearer ${state.JawboneAccessToken}" ], body: requestBody) {response -> + if (response.status == 200) { + log.debug "${response.data}" + log.debug "Setting refresh token" + state.refreshToken = response.data.data.refresh_token + } + } + } catch (groovyx.net.http.HttpResponseException e) { + if (e.statusCode == 401) { // token is expired + log.debug "Access token is expired" + if (state.refreshToken) { // if we have this we are okay + def oauthParams = [client_id: appSettings.clientId, client_secret: appSettings.clientSecret, grant_type: "refresh_token", refresh_token: state.refreshToken] + def tokenUrl = "https://jawbone.com/auth/oauth2/token?${toQueryString(oauthParams)}" + def params = [ + uri: tokenUrl + ] + httpGet(params) { refreshResponse -> + def data = refreshResponse.data + log.debug "Status: ${refreshResponse.status}, data: ${data}" + if (data.error) { + if (data.error == "access_denied") { + // User has removed authorization (probably) + log.warn "Access denied, because: ${data.error_description}" + state.remove("JawboneAccessToken") + state.remove("refreshToken") + } + } else { + log.debug "Setting access token" + state.JawboneAccessToken = data.access_token + state.refreshToken = data.refresh_token + } + } + } + } + } catch (java.net.SocketTimeoutException e) { + log.warn "Connection timed out, not much we can do here" + } +} + +def initialize() { + log.debug "Callback URL - Webhook" + def localServerUrl = getApiServerUrl() + def hookUrl = "${localServerUrl}/api/token/${state.accessToken}/smartapps/installations/${app.id}/hookCallback" + def webhook = "https://jawbone.com/nudge/api/v.1.1/users/@me/pubsub?webhook=$hookUrl" + httpPost(uri: webhook, headers: ["Authorization": "Bearer ${state.JawboneAccessToken}" ]) +} + +def setup() { + // make sure this is going to work + validateCurrentToken() + + if (state.JawboneAccessToken) { + def urlmember = "https://jawbone.com/nudge/api/users/@me/" + def member = null + httpGet(uri: urlmember, headers: ["Authorization": "Bearer ${state.JawboneAccessToken}" ]) {response -> + member = response.data.data + } + + if (member) { + state.member = member + def externalId = "${app.id}.${member.xid}" + + // find the appropriate child device based on my app id and the device network id + def deviceWrapper = getChildDevice("${externalId}") + + // invoke the generatePresenceEvent method on the child device + log.debug "Device $externalId: $deviceWrapper" + if (!deviceWrapper) { + def childDevice = addChildDevice('juano2310', "Jawbone User", "${app.id}.${member.xid}",null,[name:"Jawbone UP - " + member.first, completedSetup: true]) + if (childDevice) { + log.debug "Child Device Successfully Created" + childDevice?.generateSleepingEvent(false) + pollChild(childDevice) + } + } + } + + initialize() + } +} + +def installed() { + + if (!state.accessToken) { + log.debug "About to create access token" + createAccessToken() + } + + if (state.JawboneAccessToken) { + setup() + } +} + +def updated() { + + if (!state.accessToken) { + log.debug "About to create access token" + createAccessToken() + } + + if (state.JawboneAccessToken) { + setup() + } +} + +def uninstalled() { + if (state.JawboneAccessToken) { + try { + httpDelete(uri: "https://jawbone.com/nudge/api/v.1.0/users/@me/PartnerAppMembership", headers: ["Authorization": "Bearer ${state.JawboneAccessToken}" ]) { response -> + log.debug "Success disconnecting Jawbone from SmartThings" + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "Error disconnecting Jawbone from SmartThings: ${e.statusCode}" + } + } +} + +def pollChild(childDevice) { + def childMap = [ value: "$childDevice.device.deviceNetworkId}"] + + def params = [ + uri: 'https://jawbone.com', + path: '/nudge/api/users/@me/goals', + headers: ["Authorization": "Bearer ${state.JawboneAccessToken}" ], + contentType: 'application/json' + ] + + asynchttp_v1.get('responseGoals', params, childMap) + + def params2 = [ + uri: 'https://jawbone.com', + path: '/nudge/api/users/@me/moves', + headers: ["Authorization": "Bearer ${state.JawboneAccessToken}" ], + contentType: 'application/json' + ] + + asynchttp_v1.get('responseMoves', params2, childMap) +} + +def responseGoals(response, dni) { + if (response.hasError()) { + log.error "response has error: $response.errorMessage" + } else { + def goals + try { + // json response already parsed into JSONElement object + goals = response.json.data + } catch (e) { + log.error "error parsing json from response: $e" + } + if (goals) { + def childDevice = getChildDevice(dni.value) + log.debug "Goal = ${goals.move_steps} Steps" + childDevice?.sendEvent(name:"goal", value: goals.move_steps) + } else { + log.debug "did not get json results from response body: $response.data" + } + } +} + +def responseMoves(response, dni) { + if (response.hasError()) { + log.error "response has error: $response.errorMessage" + } else { + def moves + try { + // json response already parsed into JSONElement object + moves = response.json.data.items[0] + } catch (e) { + log.error "error parsing json from response: $e" + } + if (moves) { + def childDevice = getChildDevice(dni.value) + log.debug "Moves = ${moves.details.steps} Steps" + childDevice?.sendEvent(name:"steps", value: moves.details.steps) + } else { + log.debug "did not get json results from response body: $response.data" + } + } +} + +def setColor (steps,goal,childDevice) { + def result = steps * 100 / goal + if (result < 25) + childDevice?.sendEvent(name:"steps", value: "steps", label: steps) + else if ((result >= 25) && (result < 50)) + childDevice?.sendEvent(name:"steps", value: "steps1", label: steps) + else if ((result >= 50) && (result < 75)) + childDevice?.sendEvent(name:"steps", value: "steps1", label: steps) + else if (result >= 75) + childDevice?.sendEvent(name:"steps", value: "stepsgoal", label: steps) +} + +def hookEventHandler() { + // log.debug "In hookEventHandler method." + log.debug "request = ${request}" + + def json = request.JSON + + // get some stuff we need + def userId = json.events.user_xid[0] + def json_type = json.events.type[0] + def json_action = json.events.action[0] + + //log.debug json + log.debug "Userid = ${userId}" + log.debug "Notification Type: " + json_type + log.debug "Notification Action: " + json_action + + // find the appropriate child device based on my app id and the device network id + def externalId = "${app.id}.${userId}" + def childDevice = getChildDevice("${externalId}") + + if (childDevice) { + switch (json_action) { + case "enter_sleep_mode": + childDevice?.generateSleepingEvent(true) + break + case "exit_sleep_mode": + childDevice?.generateSleepingEvent(false) + break + case "creation": + childDevice?.sendEvent(name:"steps", value: 0) + break + case "updation": + def urlgoals = "https://jawbone.com/nudge/api/users/@me/goals" + def urlmoves = "https://jawbone.com/nudge/api/users/@me/moves" + def goals = null + def moves = null + httpGet(uri: urlgoals, headers: ["Authorization": "Bearer ${state.JawboneAccessToken}" ]) {response -> + goals = response.data.data + } + httpGet(uri: urlmoves, headers: ["Authorization": "Bearer ${state.JawboneAccessToken}" ]) {response -> + moves = response.data.data.items[0] + } + log.debug "Goal = ${goals.move_steps} Steps" + log.debug "Steps = ${moves.details.steps} Steps" + childDevice?.sendEvent(name:"steps", value: moves.details.steps) + childDevice?.sendEvent(name:"goal", value: goals.move_steps) + //setColor(moves.details.steps,goals.move_steps,childDevice) + break + case "deletion": + app.delete() + break + } + } + else { + log.debug "Couldn't find child device associated with Jawbone." + } + + def html = """{"code":200,"message":"OK"}""" + render contentType: 'application/json', data: html +} diff --git a/official/jenkins-notifier.groovy b/official/jenkins-notifier.groovy new file mode 100755 index 0000000..f822ee3 --- /dev/null +++ b/official/jenkins-notifier.groovy @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2014 Andrew Reitz + * + * 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. + */ + +/** + * Jenkins Notifier + * + * Checks a Jenkins server at a specific time, if the build fails it will turn on a light. If the build goes from + * failing back to succeeding the light will turn off. Hues can also be used in place of the light in order to create + * colors for build statuses + */ + +// Automatically generated. Make future change here. +definition( + name: "Jenkins Notifier", + namespace: "com.andrewreitz", + author: "aj.reitz@gmail.com", + description: "Turn off and on devices based on the state that your Jenkins Build is in.", + category: "Fun & Social", + iconUrl: "http://i.imgur.com/tyIp8wQ.jpg", + iconX2Url: "http://i.imgur.com/tyIp8wQ.jpg" +) + +preferences { + section("The URL to your Jenkins, including the job you want to monitor. Ex. https://jenkins.example.com/job/myproject/") { + input "jenkinsUrl", "text", title: "Jenkins URL" + } + section("Jenkins Username") { + input "jenkinsUsername", "text", title: "Jenkins Username" + } + section("Jenkins Password") { + input "jenkinsPassword", "password", title: "Jenkins Password" + } + section("On Failed Build Turn On...") { + input "switches", "capability.switch", multiple: true, required: false + } + section("Or Change These Bulbs...") { + input "hues", "capability.colorControl", title: "Which Hue Bulbs?", required: false, multiple: true + input "colorSuccess", "enum", title: "Hue Color On Success?", required: false, multiple: false, options: getHueColors().keySet() as String[] + input "colorFail", "enum", title: "Hue Color On Fail?", required: false, multiple: false, options: getHueColors().keySet() as String[] + input "lightLevelSuccess", "number", title: "Light Level On Success?", required: false + input "lightLevelFail", "number", title: "Light Level On Fail?", required: false + } + section("Additional settings", hideable: true, hidden: true) { + paragraph("Default check time is 15 Minutes") + input "refreshInterval", "decimal", title: "Check Server... (minutes)", + description: "Enter time in minutes", defaultValue: 15, required: false + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +/** Constants for Hue Colors */ +Map getHueColors() { + return [Red: 0, Green: 39, Blue: 70, Yellow: 25, Orange: 10, Purple: 75, Pink: 83] +} + +/** Constant for Saturation */ +int getSaturation() { + return 100; +} + +/** Constant for Level */ +int getMaxLevel() { + return 100; +} + +def initialize() { + def successColor = [switch: "on", hue: getHueColors()[colorSuccess], saturation: getSaturation(), level: lightLevelSuccess ?: getMaxLevel()] + def failColor = [switch: "on", hue: getHueColors()[colorFail], saturation: getSaturation(), level: lightLevelFail ?: getMaxLevel()] + state.successColor = successColor + state.failColor = failColor + log.debug "successColor: ${successColor}, failColor: ${failColor}" + + checkServer() + + def cron = "* */${refreshInterval ?: 15} * * * ?" + schedule(cron, checkServer) +} + +def checkServer() { + log.debug "Checking Server Now" + + def successColor = state.successColor + def failColor = state.failColor + + def basicCredentials = "${jenkinsUsername}:${jenkinsPassword}" + def encodedCredentials = basicCredentials.encodeAsBase64().toString() + def basicAuth = "Basic ${encodedCredentials}" + + def head = ["Authorization": basicAuth] + + log.debug "Auth ${head}" + + def host = jenkinsUrl.contains("lastBuild/api/json") ? jenkinsUrl : "${jenkinsUrl}/lastBuild/api/json" + + httpGet(uri: host, headers: ["Authorization": "${basicAuth}"]) { resp -> + def buildError = (resp.data.result == "FAILURE") + def buildSuccess = (resp.data.result == "SUCCESS") + log.debug "Build Success? ${buildSuccess}" + if (buildError) { + switches?.on() + hues?.setColor(failColor) + } else if (buildSuccess) { + switches?.off() + hues?.setColor(successColor) + } // else in some other state, probably building, do nothing. + + } +} diff --git a/official/keep-me-cozy-ii.groovy b/official/keep-me-cozy-ii.groovy new file mode 100755 index 0000000..c4b3b8e --- /dev/null +++ b/official/keep-me-cozy-ii.groovy @@ -0,0 +1,123 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Keep Me Cozy II + * + * Author: SmartThings + */ + +definition( + name: "Keep Me Cozy II", + namespace: "smartthings", + author: "SmartThings", + description: "Works the same as Keep Me Cozy, but enables you to pick an alternative temperature sensor in a separate space from the thermostat. Focuses on making you comfortable where you are spending your time rather than where the thermostat is located.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo@2x.png" +) + +preferences() { + section("Choose thermostat... ") { + input "thermostat", "capability.thermostat" + } + section("Heat setting..." ) { + input "heatingSetpoint", "decimal", title: "Degrees" + } + section("Air conditioning setting...") { + input "coolingSetpoint", "decimal", title: "Degrees" + } + section("Optionally choose temperature sensor to use instead of the thermostat's... ") { + input "sensor", "capability.temperatureMeasurement", title: "Temp Sensors", required: false + } +} + +def installed() +{ + log.debug "enter installed, state: $state" + subscribeToEvents() +} + +def updated() +{ + log.debug "enter updated, state: $state" + unsubscribe() + subscribeToEvents() +} + +def subscribeToEvents() +{ + subscribe(location, changedLocationMode) + if (sensor) { + subscribe(sensor, "temperature", temperatureHandler) + subscribe(thermostat, "temperature", temperatureHandler) + subscribe(thermostat, "thermostatMode", temperatureHandler) + } + evaluate() +} + +def changedLocationMode(evt) +{ + log.debug "changedLocationMode mode: $evt.value, heat: $heat, cool: $cool" + evaluate() +} + +def temperatureHandler(evt) +{ + evaluate() +} + +private evaluate() +{ + if (sensor) { + def threshold = 1.0 + def tm = thermostat.currentThermostatMode + def ct = thermostat.currentTemperature + def currentTemp = sensor.currentTemperature + log.trace("evaluate:, mode: $tm -- temp: $ct, heat: $thermostat.currentHeatingSetpoint, cool: $thermostat.currentCoolingSetpoint -- " + + "sensor: $currentTemp, heat: $heatingSetpoint, cool: $coolingSetpoint") + if (tm in ["cool","auto"]) { + // air conditioner + if (currentTemp - coolingSetpoint >= threshold) { + thermostat.setCoolingSetpoint(ct - 2) + log.debug "thermostat.setCoolingSetpoint(${ct - 2}), ON" + } + else if (coolingSetpoint - currentTemp >= threshold && ct - thermostat.currentCoolingSetpoint >= threshold) { + thermostat.setCoolingSetpoint(ct + 2) + log.debug "thermostat.setCoolingSetpoint(${ct + 2}), OFF" + } + } + if (tm in ["heat","emergency heat","auto"]) { + // heater + if (heatingSetpoint - currentTemp >= threshold) { + thermostat.setHeatingSetpoint(ct + 2) + log.debug "thermostat.setHeatingSetpoint(${ct + 2}), ON" + } + else if (currentTemp - heatingSetpoint >= threshold && thermostat.currentHeatingSetpoint - ct >= threshold) { + thermostat.setHeatingSetpoint(ct - 2) + log.debug "thermostat.setHeatingSetpoint(${ct - 2}), OFF" + } + } + } + else { + thermostat.setHeatingSetpoint(heatingSetpoint) + thermostat.setCoolingSetpoint(coolingSetpoint) + thermostat.poll() + } +} + +// for backward compatibility with existing subscriptions +def coolingSetpointHandler(evt) { + log.debug "coolingSetpointHandler()" +} +def heatingSetpointHandler (evt) { + log.debug "heatingSetpointHandler ()" +} diff --git a/official/keep-me-cozy.groovy b/official/keep-me-cozy.groovy new file mode 100755 index 0000000..8a8444e --- /dev/null +++ b/official/keep-me-cozy.groovy @@ -0,0 +1,95 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Keep Me Cozy + * + * Author: SmartThings + */ +definition( + name: "Keep Me Cozy", + namespace: "smartthings", + author: "SmartThings", + description: "Changes your thermostat settings automatically in response to a mode change. Often used with Bon Voyage, Rise and Shine, and other Mode Magic SmartApps to automatically keep you comfortable while you're present and save you energy and money while you are away.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo@2x.png" +) + +preferences { + section("Choose thermostat... ") { + input "thermostat", "capability.thermostat" + } + section("Heat setting...") { + input "heatingSetpoint", "number", title: "Degrees?" + } + section("Air conditioning setting..."){ + input "coolingSetpoint", "number", title: "Degrees?" + } +} + +def installed() +{ + subscribe(thermostat, "heatingSetpoint", heatingSetpointHandler) + subscribe(thermostat, "coolingSetpoint", coolingSetpointHandler) + subscribe(thermostat, "temperature", temperatureHandler) + subscribe(location, changedLocationMode) + subscribe(app, appTouch) +} + +def updated() +{ + unsubscribe() + subscribe(thermostat, "heatingSetpoint", heatingSetpointHandler) + subscribe(thermostat, "coolingSetpoint", coolingSetpointHandler) + subscribe(thermostat, "temperature", temperatureHandler) + subscribe(location, changedLocationMode) + subscribe(app, appTouch) +} + +def heatingSetpointHandler(evt) +{ + log.debug "heatingSetpoint: $evt, $settings" +} + +def coolingSetpointHandler(evt) +{ + log.debug "coolingSetpoint: $evt, $settings" +} + +def temperatureHandler(evt) +{ + log.debug "currentTemperature: $evt, $settings" +} + +def changedLocationMode(evt) +{ + log.debug "changedLocationMode: $evt, $settings" + + thermostat.setHeatingSetpoint(heatingSetpoint) + thermostat.setCoolingSetpoint(coolingSetpoint) + thermostat.poll() +} + +def appTouch(evt) +{ + log.debug "appTouch: $evt, $settings" + + thermostat.setHeatingSetpoint(heatingSetpoint) + thermostat.setCoolingSetpoint(coolingSetpoint) + thermostat.poll() +} + +// catchall +def event(evt) +{ + log.debug "value: $evt.value, event: $evt, settings: $settings, handlerName: ${evt.handlerName}" +} diff --git a/official/laundry-monitor.groovy b/official/laundry-monitor.groovy new file mode 100755 index 0000000..b47f657 --- /dev/null +++ b/official/laundry-monitor.groovy @@ -0,0 +1,182 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Laundry Monitor + * + * Author: SmartThings + * + * Sends a message and (optionally) turns on or blinks a light to indicate that laundry is done. + * + * Date: 2013-02-21 + */ + +definition( + name: "Laundry Monitor", + namespace: "smartthings", + author: "SmartThings", + description: "Sends a message and (optionally) turns on or blinks a light to indicate that laundry is done.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/FunAndSocial/App-HotTubTuner.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/FunAndSocial/App-HotTubTuner%402x.png" +) + +preferences { + section("Tell me when this washer/dryer has stopped..."){ + input "sensor1", "capability.accelerationSensor" + } + section("Via this number (optional, sends push notification if not specified)"){ + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Phone Number", required: false + } + } + section("And by turning on these lights (optional)") { + input "switches", "capability.switch", required: false, multiple: true, title: "Which lights?" + input "lightMode", "enum", options: ["Flash Lights", "Turn On Lights"], required: false, defaultValue: "Turn On Lights", title: "Action?" + } + section("Time thresholds (in minutes, optional)"){ + input "cycleTime", "decimal", title: "Minimum cycle time", required: false, defaultValue: 10 + input "fillTime", "decimal", title: "Time to fill tub", required: false, defaultValue: 5 + } +} + +def installed() +{ + initialize() +} + +def updated() +{ + unsubscribe() + initialize() +} + +def initialize() { + subscribe(sensor1, "acceleration.active", accelerationActiveHandler) + subscribe(sensor1, "acceleration.inactive", accelerationInactiveHandler) +} + +def accelerationActiveHandler(evt) { + log.trace "vibration" + if (!state.isRunning) { + log.info "Arming detector" + state.isRunning = true + state.startedAt = now() + } + state.stoppedAt = null +} + +def accelerationInactiveHandler(evt) { + log.trace "no vibration, isRunning: $state.isRunning" + if (state.isRunning) { + log.debug "startedAt: ${state.startedAt}, stoppedAt: ${state.stoppedAt}" + if (!state.stoppedAt) { + state.stoppedAt = now() + def delay = Math.floor(fillTime * 60).toInteger() + runIn(delay, checkRunning, [overwrite: false]) + } + } +} + +def checkRunning() { + log.trace "checkRunning()" + if (state.isRunning) { + def fillTimeMsec = fillTime ? fillTime * 60000 : 300000 + def sensorStates = sensor1.statesSince("acceleration", new Date((now() - fillTimeMsec) as Long)) + + if (!sensorStates.find{it.value == "active"}) { + + def cycleTimeMsec = cycleTime ? cycleTime * 60000 : 600000 + def duration = now() - state.startedAt + if (duration - fillTimeMsec > cycleTimeMsec) { + log.debug "Sending notification" + + def msg = "${sensor1.displayName} is finished" + log.info msg + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + + if (phone) { + sendSms phone, msg + } else { + sendPush msg + } + + } + + if (switches) { + if (lightMode?.equals("Turn On Lights")) { + switches.on() + } else { + flashLights() + } + } + } else { + log.debug "Not sending notification because machine wasn't running long enough $duration versus $cycleTimeMsec msec" + } + state.isRunning = false + log.info "Disarming detector" + } else { + log.debug "skipping notification because vibration detected again" + } + } + else { + log.debug "machine no longer running" + } +} + +private flashLights() { + def doFlash = true + def onFor = onFor ?: 1000 + def offFor = offFor ?: 1000 + def numFlashes = numFlashes ?: 3 + + log.debug "LAST ACTIVATED IS: ${state.lastActivated}" + if (state.lastActivated) { + def elapsed = now() - state.lastActivated + def sequenceTime = (numFlashes + 1) * (onFor + offFor) + doFlash = elapsed > sequenceTime + log.debug "DO FLASH: $doFlash, ELAPSED: $elapsed, LAST ACTIVATED: ${state.lastActivated}" + } + + if (doFlash) { + log.debug "FLASHING $numFlashes times" + state.lastActivated = now() + log.debug "LAST ACTIVATED SET TO: ${state.lastActivated}" + def initialActionOn = switches.collect{it.currentSwitch != "on"} + def delay = 1L + numFlashes.times { + log.trace "Switch on after $delay msec" + switches.eachWithIndex {s, i -> + if (initialActionOn[i]) { + s.on(delay: delay) + } + else { + s.off(delay:delay) + } + } + delay += onFor + log.trace "Switch off after $delay msec" + switches.eachWithIndex {s, i -> + if (initialActionOn[i]) { + s.off(delay: delay) + } + else { + s.on(delay:delay) + } + } + delay += offFor + } + } +} diff --git a/official/left-it-open.groovy b/official/left-it-open.groovy new file mode 100755 index 0000000..43732f0 --- /dev/null +++ b/official/left-it-open.groovy @@ -0,0 +1,110 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Left It Open + * + * Author: SmartThings + * Date: 2013-05-09 + */ +definition( + name: "Left It Open", + namespace: "smartthings", + author: "SmartThings", + description: "Notifies you when you have left a door or window open longer that a specified amount of time.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/bon-voyage.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/bon-voyage%402x.png" +) + +preferences { + + section("Monitor this door or window") { + input "contact", "capability.contactSensor" + } + section("And notify me if it's open for more than this many minutes (default 10)") { + input "openThreshold", "number", description: "Number of minutes", required: false + } + section("Delay between notifications (default 10 minutes") { + input "frequency", "number", title: "Number of minutes", description: "", required: false + } + section("Via text message at this number (or via push notification if not specified") { + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Phone number (optional)", required: false + } + } +} + +def installed() { + log.trace "installed()" + subscribe() +} + +def updated() { + log.trace "updated()" + unsubscribe() + subscribe() +} + +def subscribe() { + subscribe(contact, "contact.open", doorOpen) + subscribe(contact, "contact.closed", doorClosed) +} + +def doorOpen(evt) +{ + log.trace "doorOpen($evt.name: $evt.value)" + def t0 = now() + def delay = (openThreshold != null && openThreshold != "") ? openThreshold * 60 : 600 + runIn(delay, doorOpenTooLong, [overwrite: false]) + log.debug "scheduled doorOpenTooLong in ${now() - t0} msec" +} + +def doorClosed(evt) +{ + log.trace "doorClosed($evt.name: $evt.value)" +} + +def doorOpenTooLong() { + def contactState = contact.currentState("contact") + def freq = (frequency != null && frequency != "") ? frequency * 60 : 600 + + if (contactState.value == "open") { + def elapsed = now() - contactState.rawDateCreated.time + def threshold = ((openThreshold != null && openThreshold != "") ? openThreshold * 60000 : 60000) - 1000 + if (elapsed >= threshold) { + log.debug "Contact has stayed open long enough since last check ($elapsed ms): calling sendMessage()" + sendMessage() + runIn(freq, doorOpenTooLong, [overwrite: false]) + } else { + log.debug "Contact has not stayed open long enough since last check ($elapsed ms): doing nothing" + } + } else { + log.warn "doorOpenTooLong() called but contact is closed: doing nothing" + } +} + +void sendMessage() +{ + def minutes = (openThreshold != null && openThreshold != "") ? openThreshold : 10 + def msg = "${contact.displayName} has been left open for ${minutes} minutes." + log.info msg + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + if (phone) { + sendSms phone, msg + } else { + sendPush msg + } + } +} diff --git a/official/let-there-be-dark.groovy b/official/let-there-be-dark.groovy new file mode 100755 index 0000000..3e9d99d --- /dev/null +++ b/official/let-there-be-dark.groovy @@ -0,0 +1,45 @@ +/** + * Let There Be Dark! + * Turn your lights off when a Contact Sensor is opened and turn them back on when it is closed, ONLY if the Lights were previouly on. + * + * Author: SmartThings modified by Douglas Rich + */ +definition( + name: "Let There Be Dark!", + namespace: "Dooglave", + author: "Dooglave", + description: "Turn your lights off when a Contact Sensor is opened and turn them back on when it is closed, ONLY if the Lights were previouly on", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet@2x.png" +) + +preferences { + section("When the door opens") { + input "contact1", "capability.contactSensor", title: "Where?" + } + section("Turn off a light") { + input "switch1", "capability.switch" + } +} + +def installed() { + subscribe(contact1, "contact", contactHandler) +} + +def updated() { + unsubscribe() + subscribe(contact1, "contact", contactHandler) +} + +def contactHandler(evt) { + log.debug "$evt.value" + if (evt.value == "open") { + state.wasOn = switch1.currentValue("switch") == "on" + switch1.off() +} + +if (evt.value == "closed") { + if(state.wasOn)switch1.on() +} +} diff --git a/official/let-there-be-light.groovy b/official/let-there-be-light.groovy new file mode 100755 index 0000000..eecff6c --- /dev/null +++ b/official/let-there-be-light.groovy @@ -0,0 +1,53 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Let There Be Light! + * Turn your lights on when an open/close sensor opens and off when the sensor closes. + * + * Author: SmartThings + */ +definition( + name: "Let There Be Light!", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights on when a SmartSense Multi is opened and turn them off when it is closed.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet@2x.png" +) + +preferences { + section("When the door opens/closes...") { + input "contact1", "capability.contactSensor", title: "Where?" + } + section("Turn on/off a light...") { + input "switch1", "capability.switch" + } +} + +def installed() { + subscribe(contact1, "contact", contactHandler) +} + +def updated() { + unsubscribe() + subscribe(contact1, "contact", contactHandler) +} + +def contactHandler(evt) { + log.debug "$evt.value" + if (evt.value == "open") { + switch1.on() + } else if (evt.value == "closed") { + switch1.off() + } +} diff --git a/official/life360-connect.groovy b/official/life360-connect.groovy new file mode 100755 index 0000000..d32fc14 --- /dev/null +++ b/official/life360-connect.groovy @@ -0,0 +1,755 @@ +/** + * life360 + * + * Copyright 2014 Jeff's Account + * + * 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: "Life360 (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Life360 Service Manager", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/life360.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/life360@2x.png", + oauth: [displayName: "Life360", displayLink: "Life360"], + singleInstance: true +) { + appSetting "clientId" + appSetting "clientSecret" +} + +preferences { + page(name: "Credentials", title: "Life360 Authentication", content: "authPage", nextPage: "listCirclesPage", install: false) + page(name: "listCirclesPage", title: "Select Life360 Circle", nextPage: "listPlacesPage", content: "listCircles", install: false) + page(name: "listPlacesPage", title: "Select Life360 Place", nextPage: "listUsersPage", content: "listPlaces", install: false) + page(name: "listUsersPage", title: "Select Life360 Users", content: "listUsers", install: true) +} + +// page(name: "Credentials", title: "Enter Life360 Credentials", content: "getCredentialsPage", nextPage: "listCirclesPage", install: false) +// page(name: "page3", title: "Select Life360 Users", content: "listUsers") + +mappings { + + path("/placecallback") { + action: [ + POST: "placeEventHandler", + GET: "placeEventHandler" + ] + } + + path("/receiveToken") { + action: [ + POST: "receiveToken", + GET: "receiveToken" + ] + } +} + +def authPage() +{ + log.debug "authPage()" + + def description = "Life360 Credentials Already Entered." + + def uninstallOption = false + if (app.installationState == "COMPLETE") + uninstallOption = true + + if(!state.life360AccessToken) + { + log.debug "about to create access token" + createAccessToken() + description = "Click to enter Life360 Credentials." + + def redirectUrl = oauthInitUrl() + + return dynamicPage(name: "Credentials", title: "Life360", nextPage:"listCirclesPage", uninstall: uninstallOption, install:false) { + section { + href url:redirectUrl, style:"embedded", required:false, title:"Life360", description:description + } + } + } + else + { + listCircles() + } +} + +def receiveToken() { + + state.life360AccessToken = params.access_token + + def html = """ + + + + +Withings Connection + + + +
+ Life360 icon + connected device icon + SmartThings logo +

Your Life360 Account is now connected to SmartThings!

+

Click 'Done' to finish setup.

+
+ + +""" + + render contentType: 'text/html', data: html + +} + +def oauthInitUrl() +{ + log.debug "oauthInitUrl" + def stcid = getSmartThingsClientId(); + + // def oauth_url = "https://api.life360.com/v3/oauth2/authorize?client_id=pREqugabRetre4EstetherufrePumamExucrEHuc&response_type=token&redirect_uri=http%3A%2F%2Fwww.smartthings.com" + + state.oauthInitState = UUID.randomUUID().toString() + + def oauthParams = [ + response_type: "token", + client_id: stcid, + redirect_uri: buildRedirectUrl() + ] + + return "https://api.life360.com/v3/oauth2/authorize?" + toQueryString(oauthParams) +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +def getSmartThingsClientId() { + return "pREqugabRetre4EstetherufrePumamExucrEHuc" +} + +def getServerUrl() { getApiServerUrl() } + +def buildRedirectUrl() +{ + log.debug "buildRedirectUrl" + // /api/token/:st_token/smartapps/installations/:id/something + + return serverUrl + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/receiveToken" +} + +// +// This method is no longer used - was part of the initial username/password based authentication that has now been replaced +// by the full OAUTH web flow +// + +def getCredentialsPage() { + + dynamicPage(name: "Credentials", title: "Enter Life360 Credentials", nextPage: "listCirclesPage", uninstall: true, install:false) + { + section("Life 360 Credentials ...") { + input "username", "text", title: "Life360 Username?", multiple: false, required: true + input "password", "password", title: "Life360 Password?", multiple: false, required: true, autoCorrect: false + } + + } + +} + +// +// This method is no longer used - was part of the initial username/password based authentication that has now been replaced +// by the full OAUTH web flow +// + +def getCredentialsErrorPage(String message) { + + dynamicPage(name: "Credentials", title: "Enter Life360 Credentials", nextPage: "listCirclesPage", uninstall: true, install:false) + { + section("Life 360 Credentials ...") { + input "username", "text", title: "Life360 Username?", multiple: false, required: true + input "password", "password", title: "Life360 Password?", multiple: false, required: true, autoCorrect: false + paragraph "${message}" + } + + } + +} + +def testLife360Connection() { + + if (state.life360AccessToken) + true + else + false + +} + +// +// This method is no longer used - was part of the initial username/password based authentication that has now been replaced +// by the full OAUTH web flow +// + +def initializeLife360Connection() { + + def oauthClientId = appSettings.clientId + def oauthClientSecret = appSettings.clientSecret + + initialize() + + def username = settings.username + def password = settings.password + + // Base 64 encode the credentials + + def basicCredentials = "${oauthClientId}:${oauthClientSecret}" + def encodedCredentials = basicCredentials.encodeAsBase64().toString() + + + // call life360, get OAUTH token using password flow, save + // curl -X POST -H "Authorization: Basic cFJFcXVnYWJSZXRyZTRFc3RldGhlcnVmcmVQdW1hbUV4dWNyRUh1YzptM2ZydXBSZXRSZXN3ZXJFQ2hBUHJFOTZxYWtFZHI0Vg==" + // -F "grant_type=password" -F "username=jeff@hagins.us" -F "password=tondeleo" https://api.life360.com/v3/oauth2/token.json + + + def url = "https://api.life360.com/v3/oauth2/token.json" + + + def postBody = "grant_type=password&" + + "username=${username}&"+ + "password=${password}" + + def result = null + + try { + + httpPost(uri: url, body: postBody, headers: ["Authorization": "Basic ${encodedCredentials}" ]) {response -> + result = response + } + if (result.data.access_token) { + state.life360AccessToken = result.data.access_token + return true; + } + log.info "Life360 initializeLife360Connection, response=${result.data}" + return false; + + } + catch (e) { + log.error "Life360 initializeLife360Connection, error: $e" + return false; + } + +} + +def listCircles (){ + + // understand whether to present the Uninstall option + def uninstallOption = false + if (app.installationState == "COMPLETE") + uninstallOption = true + + // get connected to life360 api + + if (testLife360Connection()) { + + // now pull back the list of Life360 circles + // curl -X GET -H "Authorization: Bearer MmEzODQxYWQtMGZmMy00MDZhLWEwMGQtMTIzYmYxYzFmNGU3" https://api.life360.com/v3/circles.json + + def url = "https://api.life360.com/v3/circles.json" + + def result = null + + httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> + result = response + } + + log.debug "Circles=${result.data}" + + def circles = result.data.circles + + if (circles.size > 1) { + return ( + dynamicPage(name: "listCirclesPage", title: "Life360 Circles", nextPage: null, uninstall: uninstallOption, install:false) { + section("Select Life360 Circle:") { + input "circle", "enum", multiple: false, required:true, title:"Life360 Circle: ", options: circles.collectEntries{[it.id, it.name]} + } + } + ) + } + else { + state.circle = circles[0].id + return (listPlaces()) + } + } + else { + getCredentialsErrorPage("Invalid Usernaname or password.") + } + +} + +def listPlaces() { + + // understand whether to present the Uninstall option + def uninstallOption = false + if (app.installationState == "COMPLETE") + uninstallOption = true + + if (!state?.circle) + state.circle = settings.circle + + // call life360 and get the list of places in the circle + + def url = "https://api.life360.com/v3/circles/${state.circle}/places.json" + + def result = null + + httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> + result = response + } + + log.debug "Places=${result.data}" + + def places = result.data.places + state.places = places + + // If there is a place called "Home" use it as the default + def defaultPlace = places.find{it.name=="Home"} + def defaultPlaceId + if (defaultPlace) { + defaultPlaceId = defaultPlace.id + log.debug "Place = $defaultPlace.name, Id=$defaultPlace.id" + } + + dynamicPage(name: "listPlacesPage", title: "Life360 Places", nextPage: null, uninstall: uninstallOption, install:false) { + section("Select Life360 Place to Match Current Location:") { + paragraph "Please select the ONE Life360 Place that matches your SmartThings location: ${location.name}" + input "place", "enum", multiple: false, required:true, title:"Life360 Places: ", options: places.collectEntries{[it.id, it.name]}, defaultValue: defaultPlaceId + } + } + +} + +def listUsers () { + + // understand whether to present the Uninstall option + def uninstallOption = false + if (app.installationState == "COMPLETE") + uninstallOption = true + + if (!state?.circle) + state.circle = settings.circle + + // call life360 and get list of users (members) + + def url = "https://api.life360.com/v3/circles/${state.circle}/members.json" + + def result = null + + httpGet(uri: url, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> + result = response + } + + log.debug "Members=${result.data}" + + // save members list for later + + def members = result.data.members + + state.members = members + + // build preferences page + + dynamicPage(name: "listUsersPage", title: "Life360 Users", nextPage: null, uninstall: uninstallOption, install:true) { + section("Select Life360 Users to Import into SmartThings:") { + input "users", "enum", multiple: true, required:true, title:"Life360 Users: ", options: members.collectEntries{[it.id, it.firstName+" "+it.lastName]} + } + } +} + +def installed() { + + if (!state?.circle) + state.circle = settings.circle + + log.debug "In installed() method." + // log.debug "Members: ${state.members}" + // log.debug "Users: ${settings.users}" + + settings.users.each {memberId-> + + // log.debug "Find by Member Id = ${memberId}" + + def member = state.members.find{it.id==memberId} + + // log.debug "After Find Attempt." + + // log.debug "Member Id = ${member.id}, Name = ${member.firstName} ${member.lastName}, Email Address = ${member.loginEmail}" + + // log.debug "External Id=${app.id}:${member.id}" + + // create the device + if (member) { + + def childDevice = addChildDevice("smartthings", "Life360 User", "${app.id}.${member.id}",null,[name:member.firstName, completedSetup: true]) + + // save the memberId on the device itself so we can find easily later + // childDevice.setMemberId(member.id) + + if (childDevice) + { + // log.debug "Child Device Successfully Created" + generateInitialEvent (member, childDevice) + + // build the icon name form the L360 Avatar URL + // URL Format: https://www.life360.com/img/user_images/b4698717-1f2e-4b7a-b0d4-98ccfb4e9730/Maddie_Hagins_51d2eea2019c7.jpeg + // SmartThings Icon format is: L360.b4698717-1f2e-4b7a-b0d4-98ccfb4e9730.Maddie_Hagins_51d2eea2019c7 + try { + + // build the icon name from the avatar URL + log.debug "Avatar URL = ${member.avatar}" + def urlPathElements = member.avatar.tokenize("/") + def fileElements = urlPathElements[5].tokenize(".") + // def icon = "st.Lighting.light1" + def icon="l360.${urlPathElements[4]}.${fileElements[0]}" + log.debug "Icon = ${icon}" + + // set the icon on the device + childDevice.setIcon("presence","present",icon) + childDevice.setIcon("presence","not present",icon) + childDevice.save() + } + catch (e) { // do nothing + log.debug "Error = ${e}" + } + } + } + } + + createCircleSubscription() + +} + +def createCircleSubscription() { + + // delete any existing webhook subscriptions for this circle + // + // curl -X DELETE https://webhook.qa.life360.com/v3/circles/:circleId/webhook.json + + log.debug "Remove any existing Life360 Webhooks for this Circle." + + def deleteUrl = "https://api.life360.com/v3/circles/${state.circle}/webhook.json" + + try { // ignore any errors - there many not be any existing webhooks + + httpDelete (uri: deleteUrl, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> + result = response} + } + + catch (e) { + + log.debug (e) + } + + // subscribe to the life360 webhook to get push notifications on place events within this circle + + // POST /circles/:circle_id/places/webooks + // Params: hook_url + + log.debug "Create a new Life360 Webhooks for this Circle." + + createAccessToken() // create our own OAUTH access token to use in webhook url + + def hookUrl = "${serverUrl}/api/smartapps/installations/${app.id}/placecallback?access_token=${state.accessToken}".encodeAsURL() + + def url = "https://api.life360.com/v3/circles/${state.circle}/webhook.json" + + def postBody = "url=${hookUrl}" + + def result = null + + try { + + httpPost(uri: url, body: postBody, headers: ["Authorization": "Bearer ${state.life360AccessToken}" ]) {response -> + result = response} + + } catch (e) { + log.debug (e) + } + + // response from this call looks like this: + // {"circleId":"41094b6a-32fc-4ef5-a9cd-913f82268836","userId":"0d1db550-9163-471b-8829-80b375e0fa51","clientId":"11", + // "hookUrl":"https://testurl.com"} + + log.debug "Response = ${response}" + + if (result.data?.hookUrl) { + log.debug "Webhook creation successful. Response = ${result.data}" + + } +} + + +def updated() { + + if (!state?.circle) + state.circle = settings.circle + + log.debug "In updated() method." + // log.debug "Members: ${state.members}" + // log.debug "Users: ${settings.users}" + + // loop through selected users and try to find child device for each + + settings.users.each {memberId-> + + def externalId = "${app.id}.${memberId}" + + // find the appropriate child device based on my app id and the device network id + + def deviceWrapper = getChildDevice("${externalId}") + + if (!deviceWrapper) { // device isn't there - so we need to create + + // log.debug "Find by Member Id = ${memberId}" + + def member = state.members.find{it.id==memberId} + + // log.debug "After Find Attempt." + + // log.debug "External Id=${app.id}:${member.id}" + + // create the device + def childDevice = addChildDevice("smartthings", "Life360 User", "${app.id}.${member.id}",null,[name:member.firstName, completedSetup: true]) + // childDevice.setMemberId(member.id) + + if (childDevice) + { + // log.debug "Child Device Successfully Created" + generateInitialEvent (member, childDevice) + + // build the icon name form the L360 Avatar URL + // URL Format: https://www.life360.com/img/user_images/b4698717-1f2e-4b7a-b0d4-98ccfb4e9730/Maddie_Hagins_51d2eea2019c7.jpeg + // SmartThings Icon format is: L360.b4698717-1f2e-4b7a-b0d4-98ccfb4e9730.Maddie_Hagins_51d2eea2019c7 + try { + + // build the icon name from the avatar URL + log.debug "Avatar URL = ${member.avatar}" + def urlPathElements = member.avatar.tokenize("/") + def icon="l360.${urlPathElements[4]}.${urlPathElements[5]}" + + // set the icon on the device + childDevice.setIcon("presence","present",icon) + childDevice.setIcon("presence","not present",icon) + childDevice.save() + } + catch (e) { // do nothing + log.debug "Error = ${e}" + } + } + + } + else { + + // log.debug "Find by Member Id = ${memberId}" + + def member = state.members.find{it.id==memberId} + + generateInitialEvent (member, deviceWrapper) + + } + } + + // Now remove any existing devices that represent users that are no longer selected + + def childDevices = getAllChildDevices() + + log.debug "Child Devices = ${childDevices}" + + childDevices.each {childDevice-> + + log.debug "Child = ${childDevice}, DNI=${childDevice.deviceNetworkId}" + + // def childMemberId = childDevice.getMemberId() + + def splitStrings = childDevice.deviceNetworkId.split("\\.") + + log.debug "Strings = ${splitStrings}" + + def childMemberId = splitStrings[1] + + log.debug "Child Member Id = ${childMemberId}" + + log.debug "Settings.users = ${settings.users}" + + if (!settings.users.find{it==childMemberId}) { + deleteChildDevice(childDevice.deviceNetworkId) + def member = state.members.find {it.id==memberId} + if (member) + state.members.remove(member) + } + + } +} + +def generateInitialEvent (member, childDevice) { + + // lets figure out if the member is currently "home" (At the place) + + try { // we are going to just ignore any errors + + log.info "Life360 generateInitialEvent($member, $childDevice)" + + def place = state.places.find{it.id==settings.place} + + if (place) { + + def memberLatitude = new Float (member.location.latitude) + def memberLongitude = new Float (member.location.longitude) + def placeLatitude = new Float (place.latitude) + def placeLongitude = new Float (place.longitude) + def placeRadius = new Float (place.radius) + + // log.debug "Member Location = ${memberLatitude}/${memberLongitude}" + // log.debug "Place Location = ${placeLatitude}/${placeLongitude}" + // log.debug "Place Radius = ${placeRadius}" + + def distanceAway = haversine(memberLatitude, memberLongitude, placeLatitude, placeLongitude)*1000 // in meters + + // log.debug "Distance Away = ${distanceAway}" + + boolean isPresent = (distanceAway <= placeRadius) + + log.info "Life360 generateInitialEvent, member: ($memberLatitude, $memberLongitude), place: ($placeLatitude, $placeLongitude), radius: $placeRadius, dist: $distanceAway, present: $isPresent" + + // log.debug "External Id=${app.id}:${member.id}" + + // def childDevice2 = getChildDevice("${app.id}.${member.id}") + + // log.debug "Child Device = ${childDevice2}" + + childDevice?.generatePresenceEvent(isPresent) + + // log.debug "After generating presence event." + + } + + } + catch (e) { + // eat it + } + +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. +} + +def haversine(lat1, lon1, lat2, lon2) { + def R = 6372.8 + // In kilometers + def dLat = Math.toRadians(lat2 - lat1) + def dLon = Math.toRadians(lon2 - lon1) + lat1 = Math.toRadians(lat1) + lat2 = Math.toRadians(lat2) + + def a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2) + def c = 2 * Math.asin(Math.sqrt(a)) + def d = R * c + return(d) +} + + +def placeEventHandler() { + + log.info "Life360 placeEventHandler: params=$params, settings.place=$settings.place" + + // the POST to this end-point will look like: + // POST http://test.com/webhook?circleId=XXXX&placeId=XXXX&userId=XXXX&direction=arrive + + def circleId = params?.circleId + def placeId = params?.placeId + def userId = params?.userId + def direction = params?.direction + def timestamp = params?.timestamp + + if (placeId == settings.place) { + + def presenceState = (direction=="in") + + def externalId = "${app.id}.${userId}" + + // find the appropriate child device based on my app id and the device network id + + def deviceWrapper = getChildDevice("${externalId}") + + // invoke the generatePresenceEvent method on the child device + + if (deviceWrapper) { + deviceWrapper.generatePresenceEvent(presenceState) + log.debug "Life360 event raised on child device: ${externalId}" + } + else { + log.warn "Life360 couldn't find child device associated with inbound Life360 event." + } + } + +} diff --git a/official/lifx-connect.groovy b/official/lifx-connect.groovy new file mode 100755 index 0000000..caca7b2 --- /dev/null +++ b/official/lifx-connect.groovy @@ -0,0 +1,443 @@ +/** + * LIFX + * + * Copyright 2015 LIFX + * + */ +include 'localization' + +definition( + name: "LIFX (Connect)", + namespace: "smartthings", + author: "LIFX", + description: "Allows you to use LIFX smart light bulbs with SmartThings.", + category: "Convenience", + iconUrl: "https://cloud.lifx.com/images/lifx.png", + iconX2Url: "https://cloud.lifx.com/images/lifx.png", + iconX3Url: "https://cloud.lifx.com/images/lifx.png", + oauth: true, + singleInstance: true) { + appSetting "clientId" + appSetting "clientSecret" + appSetting "serverUrl" // See note below +} + +// NOTE regarding OAuth settings. On NA01 (i.e. graph.api), NA01S, and NA01D the serverUrl app setting can be left +// Blank. For other shards is should be set to the callback URL registered with LIFX, which is: +// +// Production -- https://graph.api.smartthings.com +// Staging -- https://graph-na01s-useast1.smartthingsgdev.com +// Development -- https://graph-na01d-useast1.smartthingsgdev.com + +preferences { + page(name: "Credentials", title: "LIFX", content: "authPage", install: true) +} + +mappings { + path("/receivedToken") { action: [ POST: "oauthReceivedToken", GET: "oauthReceivedToken"] } + path("/receiveToken") { action: [ POST: "oauthReceiveToken", GET: "oauthReceiveToken"] } + path("/hookCallback") { action: [ POST: "hookEventHandler", GET: "hookEventHandler"] } + path("/oauth/callback") { action: [ GET: "oauthCallback" ] } + path("/oauth/initialize") { action: [ GET: "oauthInit"] } + path("/test") { action: [ GET: "oauthSuccess" ] } +} + +def getServerUrl() { return appSettings.serverUrl ?: apiServerUrl } +def getCallbackUrl() { return "${getServerUrl()}/oauth/callback" } +def apiURL(path = '/') { return "https://api.lifx.com/v1${path}" } +def getSecretKey() { return appSettings.secretKey } +def getClientId() { return appSettings.clientId } +private getVendorName() { "LIFX" } + +def authPage() { + log.debug "authPage test1" + if (!state.lifxAccessToken) { + log.debug "no LIFX access token" + // This is the SmartThings access token + if (!state.accessToken) { + log.debug "no access token, create access token" + state.accessToken = createAccessToken() // predefined method + } + def description = "Tap to enter LIFX credentials" + def redirectUrl = "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${apiServerUrl}" // this triggers oauthInit() below + // def redirectUrl = "${apiServerUrl}" + // log.debug "app id: ${app.id}" + // log.debug "redirect url: ${redirectUrl}"s + return dynamicPage(name: "Credentials", title: "Connect to LIFX", nextPage: null, uninstall: true, install:true) { + section { + href(url:redirectUrl, required:true, title:"Connect to LIFX", description:"Tap here to connect your LIFX account") + } + } + } else { + log.debug "have LIFX access token" + + def options = locationOptions() ?: [] + def count = options.size() + + return dynamicPage(name:"Credentials", title:"", nextPage:"", install:true, uninstall: true) { + section("Select your location") { + input "selectedLocationId", "enum", required:true, title:"Select location ({{count}} found)", messageArgs: [count: count], multiple:false, options:options, submitOnChange: true + paragraph "Devices will be added automatically from your ${vendorName} account. To add or delete devices please use the Official ${vendorName} App." + } + } + } +} + +// OAuth + +def oauthInit() { + def oauthParams = [client_id: "${appSettings.clientId}", scope: "remote_control:all", response_type: "code" ] + log.info("Redirecting user to OAuth setup") + redirect(location: "https://cloud.lifx.com/oauth/authorize?${toQueryString(oauthParams)}") +} + +def oauthCallback() { + def redirectUrl = null + if (params.authQueryString) { + redirectUrl = URLDecoder.decode(params.authQueryString.replaceAll(".+&redirect_url=", "")) + } else { + log.warn "No authQueryString" + } + + if (state.lifxAccessToken) { + log.debug "Access token already exists" + success() + } else { + def code = params.code + if (code) { + if (code.size() > 6) { + // LIFX code + log.debug "Exchanging code for access token" + oauthReceiveToken(redirectUrl) + } else { + // Initiate the LIFX OAuth flow. + oauthInit() + } + } else { + log.debug "This code should be unreachable" + success() + } + } +} + +def oauthReceiveToken(redirectUrl = null) { + // Not sure what redirectUrl is for + log.debug "receiveToken - params: ${params}" + def oauthParams = [ client_id: "${appSettings.clientId}", client_secret: "${appSettings.clientSecret}", grant_type: "authorization_code", code: params.code, scope: params.scope ] // how is params.code valid here? + def params = [ + uri: "https://cloud.lifx.com/oauth/token", + body: oauthParams, + headers: [ + "User-Agent": "SmartThings Integration" + ] + ] + httpPost(params) { response -> + state.lifxAccessToken = response.data.access_token + } + + if (state.lifxAccessToken) { + oauthSuccess() + } else { + oauthFailure() + } +} + +def oauthSuccess() { + def message = """ +

Your LIFX Account is now connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + oauthConnectionStatus(message) +} + +def oauthFailure() { + def message = """ +

The connection could not be established!

+

Click 'Done' to return to the menu.

+ """ + oauthConnectionStatus(message) +} + +def oauthReceivedToken() { + def message = """ +

Your LIFX Account is already connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + oauthConnectionStatus(message) +} + +def oauthConnectionStatus(message, redirectUrl = null) { + def redirectHtml = "" + if (redirectUrl) { + redirectHtml = """ + + """ + } + + def html = """ + + + + + SmartThings Connection + + ${redirectHtml} + + +
+ LIFX icon + connected device icon + SmartThings logo +

+ ${message} +

+
+ + + """ + render contentType: 'text/html', data: html +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +// App lifecycle hooks + +def installed() { + if (!state.accessToken) { + createAccessToken() + } else { + initialize() + } +} + +// called after settings are changed +def updated() { + if (!state.accessToken) { + createAccessToken() + } else { + initialize() + } +} + +def uninstalled() { + log.info("Uninstalling, removing child devices...") + unschedule('updateDevices') + 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" + updateDevices() + // Check for new devices and remove old ones every 3 hours + runEvery5Minutes('updateDevices') + setupDeviceWatch() +} + +// Misc +private setupDeviceWatch() { + def hub = location.hubs[0] + // Make sure that all child devices are enrolled in device watch + getChildDevices().each { + it.sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${hub?.hub?.hardwareID}\"}") + } +} + +Map apiRequestHeaders() { + return ["Authorization": "Bearer ${state.lifxAccessToken}", + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "SmartThings Integration" + ] +} + +// Requests + +def logResponse(response) { + log.info("Status: ${response.status}") + log.info("Body: ${response.data}") +} + +// API Requests +// logObject is because log doesn't work if this method is being called from a Device +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("lifxAccessToken") + 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 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 apiPUT(path, body = [:]) { + try { + log.debug("Beginning API PUT: ${path}, ${body}") + httpPutJson(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 devicesList(selector = '') { + logErrors([]) { + def resp = apiGET("/lights/${selector}") + if (resp.status == 200) { + return resp.data + } else { + log.debug("No response from device list call. ${resp.status} ${resp.data}") + return [] + } + } +} + +Map locationOptions() { + def options = [:] + def devices = devicesList() + devices.each { device -> + options[device.location.id] = device.location.name + } + log.debug("Locations: ${options}") + return options +} + +def devicesInLocation() { + return devicesList("location_id:${settings.selectedLocationId}") +} + +// ensures the devices list is up to date +def updateDevices() { + if (!state.devices) { + state.devices = [:] + } + def devices = devicesInLocation() + def selectors = [] + + log.debug("All selectors: ${selectors}") + + devices.each { device -> + def childDevice = getChildDevice(device.id) + selectors.add("${device.id}") + if (!childDevice) { + // log.info("Adding device ${device.id}: ${device.product}") + if (device.product.capabilities.has_color) { + childDevice = addChildDevice(app.namespace, "LIFX Color Bulb", device.id, null, ["label": device.label, "completedSetup": true]) + } else { + childDevice = addChildDevice(app.namespace, "LIFX White Bulb", device.id, null, ["label": device.label, "completedSetup": true]) + } + } + + if (device.product.capabilities.has_color) { + childDevice.sendEvent(name: "color", value: colorUtil.hslToHex((device.color.hue / 3.6) as int, (device.color.saturation * 100) as int)) + childDevice.sendEvent(name: "hue", value: device.color.hue / 3.6) + childDevice.sendEvent(name: "saturation", value: device.color.saturation * 100) + } + childDevice.sendEvent(name: "label", value: device.label) + childDevice.sendEvent(name: "level", value: Math.round((device.brightness ?: 1) * 100)) + childDevice.sendEvent(name: "switch.setLevel", value: Math.round((device.brightness ?: 1) * 100)) + childDevice.sendEvent(name: "switch", value: device.power) + childDevice.sendEvent(name: "colorTemperature", value: device.color.kelvin) + childDevice.sendEvent(name: "model", value: device.product.name) + + if (state.devices[device.id] == null) { + // State missing, add it and set it to opposite status as current status to provoke event below + state.devices[device.id] = [online: !device.connected] + } + + if (!state.devices[device.id]?.online && device.connected) { + // Device came online after being offline + childDevice?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "online", displayed: false) + log.debug "$device is back Online" + } else if (state.devices[device.id]?.online && !device.connected) { + // Device went offline after being online + childDevice?.sendEvent(name: "DeviceWatch-DeviceStatus", value: "offline", displayed: false) + log.debug "$device went Offline" + } + state.devices[device.id] = [online: device.connected] + } + getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { + log.info("Deleting ${it.deviceNetworkId}") + if (state.devices[it.deviceNetworkId]) + state.devices[it.deviceNetworkId] = null + // The reason the implementation is trying to delete this bulb is because it is not longer connected to the LIFX location. + // Adding "try" will prevent this exception from happening. + // Ideally device health would show to the user that the device is not longer accessible so that the user can either force delete it or remove it from the SmartApp. + try { + deleteChildDevice(it.deviceNetworkId) + } catch (Exception e) { + log.debug("Can't remove this device because it's being used by an SmartApp") + } + } +} diff --git a/official/light-follows-me.groovy b/official/light-follows-me.groovy new file mode 100755 index 0000000..ddc1a0a --- /dev/null +++ b/official/light-follows-me.groovy @@ -0,0 +1,74 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Light Follows Me + * + * Author: SmartThings + */ + +definition( + name: "Light Follows Me", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights on when motion is detected and then off again once the motion stops for a set period of time.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch@2x.png" +) + +preferences { + section("Turn on when there's movement..."){ + input "motion1", "capability.motionSensor", title: "Where?" + } + section("And off when there's been no movement for..."){ + input "minutes1", "number", title: "Minutes?" + } + section("Turn on/off light(s)..."){ + input "switches", "capability.switch", multiple: true + } +} + +def installed() { + subscribe(motion1, "motion", motionHandler) +} + +def updated() { + unsubscribe() + subscribe(motion1, "motion", motionHandler) +} + +def motionHandler(evt) { + log.debug "$evt.name: $evt.value" + if (evt.value == "active") { + log.debug "turning on lights" + switches.on() + } else if (evt.value == "inactive") { + runIn(minutes1 * 60, scheduleCheck, [overwrite: false]) + } +} + +def scheduleCheck() { + log.debug "schedule check" + def motionState = motion1.currentState("motion") + if (motionState.value == "inactive") { + def elapsed = now() - motionState.rawDateCreated.time + def threshold = 1000 * 60 * minutes1 - 1000 + if (elapsed >= threshold) { + log.debug "Motion has stayed inactive long enough since last check ($elapsed ms): turning lights off" + switches.off() + } else { + log.debug "Motion has not stayed inactive long enough since last check ($elapsed ms): doing nothing" + } + } else { + log.debug "Motion is active, do nothing and wait for inactive" + } +} diff --git a/official/light-up-the-night.groovy b/official/light-up-the-night.groovy new file mode 100755 index 0000000..4f3c2af --- /dev/null +++ b/official/light-up-the-night.groovy @@ -0,0 +1,56 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Light Up The Night + * + * Author: SmartThings + */ +definition( + name: "Light Up the Night", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights on when it gets dark and off when it becomes light again.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet-luminance.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet-luminance@2x.png" +) + +preferences { + section("Monitor the luminosity...") { + input "lightSensor", "capability.illuminanceMeasurement" + } + section("Turn on a light...") { + input "lights", "capability.switch", multiple: true + } +} + +def installed() { + subscribe(lightSensor, "illuminance", illuminanceHandler) +} + +def updated() { + unsubscribe() + subscribe(lightSensor, "illuminance", illuminanceHandler) +} + +// New aeon implementation +def illuminanceHandler(evt) { + def lastStatus = state.lastStatus + if (lastStatus != "on" && evt.integerValue < 30) { + lights.on() + state.lastStatus = "on" + } + else if (lastStatus != "off" && evt.integerValue > 50) { + lights.off() + state.lastStatus = "off" + } +} diff --git a/official/lighting-director.groovy b/official/lighting-director.groovy new file mode 100755 index 0000000..d9ddfc3 --- /dev/null +++ b/official/lighting-director.groovy @@ -0,0 +1,1311 @@ +/** + * Lighting Director + * + * Source: https://github.com/tslagle13/SmartThings/blob/master/Director-Series-Apps/Lighting-Director/Lighting%20Director.groovy + * + * Current Version: 2.9.4 + * + * + * Changelog: + * Version - 1.3 + * Version - 1.30.1 Modification by Michael Struck - Fixed syntax of help text and titles of scenarios, along with a new icon + * Version - 1.40.0 Modification by Michael Struck - Code optimization and added door contact sensor capability + * Version - 1.41.0 Modification by Michael Struck - Code optimization and added time restrictions to each scenario + * Version - 2.0 Tim Slagle - Moved to only have 4 slots. Code was to heavy and needed to be trimmed. + * Version - 2.1 Tim Slagle - Moved time interval inputs inline with STs design. + * Version - 2.2 Michael Struck - Added the ability to activate switches via the status locks and fixed some syntax issues + * Version - 2.5 Michael Struck - Changed the way the app unschedules re-triggered events + * Version - 2.5.1 Tim Slagle - Fixed Time Logic + * Version - 2.6 Michael Struck - Added the additional restriction of running triggers once per day and misc cleanup of code + * Version - 2.7 Michael Struck - Added feature that turns off triggering if the physical switch is pressed. + * Version - 2.81 Michael Struck - Fixed an issue with dimmers not stopping light action + * Version - 2.9 Michael Struck - Fixed issue where button presses outside of the time restrictions prevent the triggers from firing and code optimization + * Version - 2.9.1 Tim Slagle - Further enhanced time interval logic. + * Version - 2.9.2 Brandon Gordon - Added support for acceleration sensors. + * Version - 2.9.3 Brandon Gordon - Added mode change subscriptions. + * Version - 2.9.4 Michael Struck - Code Optimization when triggers are tripped + * +* Source code can be found here: https://github.com/tslagle13/SmartThings/blob/master/smartapps/tslagle13/vacation-lighting-director.groovy + * + * Copyright 2015 Tim Slagle and Michael Struck + * + * 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: "Lighting Director", + namespace: "tslagle13", + author: "Tim Slagle & Michael Struck", + description: "Control up to 4 sets (scenarios) of lights based on motion, door contacts and illuminance levels.", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Lighting-Director/LightingDirector.png", + iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Lighting-Director/LightingDirector@2x.png", + iconX3Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Lighting-Director/LightingDirector@2x.png") + +preferences { + page name:"pageSetup" + page name:"pageSetupScenarioA" + page name:"pageSetupScenarioB" + page name:"pageSetupScenarioC" + page name:"pageSetupScenarioD" +} + +// Show setup page +def pageSetup() { + + def pageProperties = [ + name: "pageSetup", + nextPage: null, + install: true, + uninstall: true + ] + + return dynamicPage(pageProperties) { + section("Setup Menu") { + href "pageSetupScenarioA", title: getTitle(settings.ScenarioNameA), description: getDesc(settings.ScenarioNameA), state: greyOut(settings.ScenarioNameA) + href "pageSetupScenarioB", title: getTitle(settings.ScenarioNameB), description: getDesc(settings.ScenarioNameB), state: greyOut(settings.ScenarioNameB) + href "pageSetupScenarioC", title: getTitle(settings.ScenarioNameC), description: getDesc(settings.ScenarioNameC), state: greyOut(settings.ScenarioNameC) + href "pageSetupScenarioD", title: getTitle(settings.ScenarioNameD), description: getDesc(settings.ScenarioNameD), state: greyOut(settings.ScenarioNameD) + } + section([title:"Options", mobileOnly:true]) { + label title:"Assign a name", required:false + } + } +} + +// Show "pageSetupScenarioA" page +def pageSetupScenarioA() { + + def inputLightsA = [ + name: "A_switches", + type: "capability.switch", + title: "Control the following switches...", + multiple: true, + required: false + ] + def inputDimmersA = [ + name: "A_dimmers", + type: "capability.switchLevel", + title: "Dim the following...", + multiple: true, + required: false + ] + + def inputMotionA = [ + name: "A_motion", + type: "capability.motionSensor", + title: "Using these motion sensors...", + multiple: true, + required: false + ] + + def inputAccelerationA = [ + name: "A_acceleration", + type: "capability.accelerationSensor", + title: "Or using these acceleration sensors...", + multiple: true, + required: false + ] + def inputContactA = [ + name: "A_contact", + type: "capability.contactSensor", + title: "Or using these contact sensors...", + multiple: true, + required: false + ] + + def inputTriggerOnceA = [ + name: "A_triggerOnce", + type: "bool", + title: "Trigger only once per day...", + defaultValue:false + ] + + def inputSwitchDisableA = [ + name: "A_switchDisable", + type: "bool", + title: "Stop triggering if physical switches/dimmers are turned off...", + defaultValue:false + ] + + def inputLockA = [ + name: "A_lock", + type: "capability.lock", + title: "Or using these locks...", + multiple: true, + required: false + ] + + def inputModeA = [ + name: "A_mode", + type: "mode", + title: "Only during the following modes...", + multiple: true, + required: false + ] + + def inputDayA = [ + name: "A_day", + type: "enum", + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], + title: "Only on certain days of the week...", + multiple: true, + required: false + ] + + + def inputLevelA = [ + name: "A_level", + type: "enum", + options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]], + title: "Set dimmers to this level", + multiple: false, + required: false + ] + + def inputTurnOnLuxA = [ + name: "A_turnOnLux", + type: "number", + title: "Only run this scenario if lux is below...", + multiple: false, + required: false + ] + + def inputLuxSensorsA = [ + name: "A_luxSensors", + type: "capability.illuminanceMeasurement", + title: "On these lux sensors", + multiple: false, + required: false + ] + + def inputTurnOffA = [ + name: "A_turnOff", + type: "number", + title: "Turn off this scenario after motion stops or doors close/lock (minutes)...", + multiple: false, + required: false + ] + + def inputScenarioNameA = [ + name: "ScenarioNameA", + type: "text", + title: "Scenario Name", + multiple: false, + required: false, + defaultValue: empty + ] + + def pageProperties = [ + name: "pageSetupScenarioA", + ] + + return dynamicPage(pageProperties) { +section("Name your scenario") { + input inputScenarioNameA + } + +section("Devices included in the scenario") { + input inputMotionA + input inputAccelerationA + input inputContactA + input inputLockA + input inputLightsA + input inputDimmersA + } + +section("Scenario settings") { + input inputLevelA + input inputTurnOnLuxA + input inputLuxSensorsA + input inputTurnOffA + } + +section("Scenario restrictions") { + input inputTriggerOnceA + input inputSwitchDisableA + href "timeIntervalInputA", title: "Only during a certain time...", description: getTimeLabel(A_timeStart, A_timeEnd), state: greyedOutTime(A_timeStart, A_timeEnd), refreshAfterSelection:true + input inputDayA + input inputModeA + } + +section("Help") { + paragraph helpText() + } + } + +} + +def pageSetupScenarioB() { + + def inputLightsB = [ + name: "B_switches", + type: "capability.switch", + title: "Control the following switches...", + multiple: true, + required: false + ] + def inputDimmersB = [ + name: "B_dimmers", + type: "capability.switchLevel", + title: "Dim the following...", + multiple: true, + required: false + ] + + def inputTurnOnLuxB = [ + name: "B_turnOnLux", + type: "number", + title: "Only run this scenario if lux is below...", + multiple: false, + required: false + ] + + def inputLuxSensorsB = [ + name: "B_luxSensors", + type: "capability.illuminanceMeasurement", + title: "On these lux sensors", + multiple: false, + required: false + ] + + def inputMotionB = [ + name: "B_motion", + type: "capability.motionSensor", + title: "Using these motion sensors...", + multiple: true, + required: false + ] + + def inputAccelerationB = [ + name: "B_acceleration", + type: "capability.accelerationSensor", + title: "Or using these acceleration sensors...", + multiple: true, + required: false + ] + def inputContactB = [ + name: "B_contact", + type: "capability.contactSensor", + title: "Or using these contact sensors...", + multiple: true, + required: false + ] + + def inputTriggerOnceB = [ + name: "B_triggerOnce", + type: "bool", + title: "Trigger only once per day...", + defaultValue:false + ] + + def inputSwitchDisableB = [ + name: "B_switchDisable", + type: "bool", + title: "Stop triggering if physical switches/dimmers are turned off...", + defaultValue:false + ] + + def inputLockB = [ + name: "B_lock", + type: "capability.lock", + title: "Or using these locks...", + multiple: true, + required: false + ] + + def inputModeB = [ + name: "B_mode", + type: "mode", + title: "Only during the following modes...", + multiple: true, + required: false + ] + + def inputDayB = [ + name: "B_day", + type: "enum", + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], + title: "Only on certain days of the week...", + multiple: true, + required: false + ] + + def inputLevelB = [ + name: "B_level", + type: "enum", + options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]], + title: "Set dimmers to this level", + multiple: false, + required: false + ] + + def inputTurnOffB = [ + name: "B_turnOff", + type: "number", + title: "Turn off this scenario after motion stops or doors close/lock (minutes)...", + multiple: false, + required: false + ] + + def inputScenarioNameB = [ + name: "ScenarioNameB", + type: "text", + title: "Scenario Name", + multiple: false, + required: false, + defaultValue: empty + ] + + def pageProperties = [ + name: "pageSetupScenarioB", + ] + + return dynamicPage(pageProperties) { +section("Name your scenario") { + input inputScenarioNameB + } + +section("Devices included in the scenario") { + input inputMotionB + input inputAccelerationB + input inputContactB + input inputLockB + input inputLightsB + input inputDimmersB + } + +section("Scenario settings") { + input inputLevelB + input inputTurnOnLuxB + input inputLuxSensorsB + input inputTurnOffB + } + +section("Scenario restrictions") { + input inputTriggerOnceB + input inputSwitchDisableB + href "timeIntervalInputB", title: "Only during a certain time...", description: getTimeLabel(B_timeStart, B_timeEnd), state: greyedOutTime(B_timeStart, B_timeEnd), refreshAfterSelection:true + input inputDayB + input inputModeB + } + +section("Help") { + paragraph helpText() + } + } +} + +def pageSetupScenarioC() { + + def inputLightsC = [ + name: "C_switches", + type: "capability.switch", + title: "Control the following switches...", + multiple: true, + required: false + ] + def inputDimmersC = [ + name: "C_dimmers", + type: "capability.switchLevel", + title: "Dim the following...", + multiple: true, + required: false + ] + + def inputMotionC = [ + name: "C_motion", + type: "capability.motionSensor", + title: "Using these motion sensors...", + multiple: true, + required: false + ] + + def inputAccelerationC = [ + name: "C_acceleration", + type: "capability.accelerationSensor", + title: "Or using these acceleration sensors...", + multiple: true, + required: false + ] + def inputContactC = [ + name: "C_contact", + type: "capability.contactSensor", + title: "Or using these contact sensors...", + multiple: true, + required: false + ] + + def inputTriggerOnceC = [ + name: "C_triggerOnce", + type: "bool", + title: "Trigger only once per day...", + defaultValue:false + ] + + def inputSwitchDisableC = [ + name: "C_switchDisable", + type: "bool", + title: "Stop triggering if physical switches/dimmers are turned off...", + defaultValue:false + ] + + def inputLockC = [ + name: "C_lock", + type: "capability.lock", + title: "Or using these locks...", + multiple: true, + required: false + ] + + def inputModeC = [ + name: "C_mode", + type: "mode", + title: "Only during the following modes...", + multiple: true, + required: false + ] + + def inputDayC = [ + name: "C_day", + type: "enum", + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], + title: "Only on certain days of the week...", + multiple: true, + required: false + ] + + def inputLevelC = [ + name: "C_level", + type: "enum", + options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]], + title: "Set dimmers to this level", + multiple: false, + required: false + ] + + def inputTurnOffC = [ + name: "C_turnOff", + type: "number", + title: "Turn off this scenario after motion stops or doors close/lock (minutes)...", + multiple: false, + required: false + ] + + def inputScenarioNameC = [ + name: "ScenarioNameC", + type: "text", + title: "Scenario Name", + multiple: false, + required: false, + defaultValue: empty + ] + + def inputTurnOnLuxC = [ + name: "C_turnOnLux", + type: "number", + title: "Only run this scenario if lux is below...", + multiple: false, + required: false + ] + + def inputLuxSensorsC = [ + name: "C_luxSensors", + type: "capability.illuminanceMeasurement", + title: "On these lux sensors", + multiple: false, + required: false + ] + + def pageProperties = [ + name: "pageSetupScenarioC", + ] + + return dynamicPage(pageProperties) { + section("Name your scenario") { + input inputScenarioNameC + } + +section("Devices included in the scenario") { + input inputMotionC + input inputAccelerationC + input inputContactC + input inputLockC + input inputLightsC + input inputDimmersC + } + +section("Scenario settings") { + input inputLevelC + input inputTurnOnLuxC + input inputLuxSensorsC + input inputTurnOffC + } + +section("Scenario restrictions") { + input inputTriggerOnceC + input inputSwitchDisableC + href "timeIntervalInputC", title: "Only during a certain time...", description: getTimeLabel(C_timeStart, C_timeEnd), state: greyedOutTime(C_timeStart, C_timeEnd), refreshAfterSelection:true + input inputDayC + input inputModeC + } + +section("Help") { + paragraph helpText() + } + } +} + +def pageSetupScenarioD() { + + def inputLightsD = [ + name: "D_switches", + type: "capability.switch", + title: "Control the following switches...", + multiple: true, + required: false + ] + def inputDimmersD = [ + name: "D_dimmers", + type: "capability.switchLevel", + title: "Dim the following...", + multiple: true, + required: false + ] + + def inputMotionD = [ + name: "D_motion", + type: "capability.motionSensor", + title: "Using these motion sensors...", + multiple: true, + required: false + ] + + def inputAccelerationD = [ + name: "D_acceleration", + type: "capability.accelerationSensor", + title: "Or using these acceleration sensors...", + multiple: true, + required: false + ] + def inputContactD = [ + name: "D_contact", + type: "capability.contactSensor", + title: "Or using these contact sensors...", + multiple: true, + required: false + ] + + def inputLockD = [ + name: "D_lock", + type: "capability.lock", + title: "Or using these locks...", + multiple: true, + required: false + ] + + def inputModeD = [ + name: "D_mode", + type: "mode", + title: "Only during the following modes...", + multiple: true, + required: false + ] + + def inputTriggerOnceD = [ + name: "D_triggerOnce", + type: "bool", + title: "Trigger only once per day...", + defaultValue:false + ] + + def inputSwitchDisableD = [ + name: "D_switchDisable", + type: "bool", + title: "Stop triggering if physical switches/dimmers are turned off...", + defaultValue:false + ] + + def inputDayD = [ + name: "D_day", + type: "enum", + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], + title: "Only on certain days of the week...", + multiple: true, + required: false + ] + + + def inputLevelD = [ + name: "D_level", + type: "enum", + options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]], + title: "Set dimmers to this level", + multiple: false, + required: false + ] + + def inputTurnOffD = [ + name: "D_turnOff", + type: "number", + title: "Turn off this scenario after motion stops, doors close or close/lock (minutes)...", + multiple: false, + required: false + ] + + def inputScenarioNameD = [ + name: "ScenarioNameD", + type: "text", + title: "Scenario Name", + multiple: false, + required: false, + defaultValue: empty + ] + + def inputTurnOnLuxD = [ + name: "D_turnOnLux", + type: "number", + title: "Only run this scenario if lux is below...", + multiple: false, + required: false + ] + + def inputLuxSensorsD = [ + name: "D_luxSensors", + type: "capability.illuminanceMeasurement", + title: "On these lux sensors", + multiple: false, + required: false + ] + + def pageProperties = [ + name: "pageSetupScenarioD", + ] + + return dynamicPage(pageProperties) { + section("Name your scenario") { + input inputScenarioNameD + } + +section("Devices included in the scenario") { + input inputMotionD + input inputAccelerationD + input inputContactD + input inputLockD + input inputLightsD + input inputDimmersD + } + +section("Scenario settings") { + input inputLevelD + input inputTurnOnLuxD + input inputLuxSensorsD + input inputTurnOffD + } + +section("Scenario restrictions") { + input inputTriggerOnceD + input inputSwitchDisableD + href "timeIntervalInputD", title: "Only during a certain time", description: getTimeLabel(D_timeStart, D_timeEnd), state: greyedOutTime(D_timeStart, D_timeEnd), refreshAfterSelection:true + input inputDayD + input inputModeD + } + +section("Help") { + paragraph helpText() + } + } +} + +def installed() { + initialize() +} + +def updated() { + unschedule() + unsubscribe() + initialize() +} + +def initialize() { + +midNightReset() + +if(A_motion) { + subscribe(settings.A_motion, "motion", onEventA) +} + +if(A_acceleration) { + subscribe(settings.A_acceleration, "acceleration", onEventA) +} + +if(A_contact) { + subscribe(settings.A_contact, "contact", onEventA) +} + +if(A_lock) { + subscribe(settings.A_lock, "lock", onEventA) +} + +if(A_switchDisable) { + subscribe(A_switches, "switch.off", onPressA) + subscribe(A_dimmers, "switch.off", onPressA) +} + +if(A_mode) { + subscribe(location, onEventA) +} + +if(B_motion) { + subscribe(settings.B_motion, "motion", onEventB) +} + +if(B_acceleration) { + subscribe(settings.B_acceleration, "acceleration", onEventB) +} + +if(B_contact) { + subscribe(settings.B_contact, "contact", onEventB) +} + +if(B_lock) { + subscribe(settings.B_lock, "lock", onEventB) +} + +if(B_switchDisable) { + subscribe(B_switches, "switch.off", onPressB) + subscribe(B_dimmers, "switch.off", onPressB) +} + +if(B_mode) { + subscribe(location, onEventB) +} + +if(C_motion) { + subscribe(settings.C_motion, "motion", onEventC) +} + +if(C_acceleration) { + subscribe(settings.C_acceleration, "acceleration", onEventC) +} + +if(C_contact) { + subscribe(settings.C_contact, "contact", onEventC) +} + +if(C_lock) { + subscribe(settings.C_lock, "lock", onEventC) +} + +if(C_switchDisable) { + subscribe(C_switches, "switch.off", onPressC) + subscribe(C_dimmers, "switch.off", onPressC) +} + +if(C_mode) { + subscribe(location, onEventC) +} + +if(D_motion) { + subscribe(settings.D_motion, "motion", onEventD) +} + +if(D_acceleration) { + subscribe(settings.D_acceleration, "acceleration", onEventD) +} + +if(D_contact) { + subscribe(settings.D_contact, "contact", onEventD) +} + +if(D_lock) { + subscribe(settings.D_lock, "lock", onEventD) +} + +if(D_switchDisable) { + subscribe(D_switches, "switch.off", onPressD) + subscribe(D_dimmers, "switch.off", onPressD) +} + +if(D_mode) { + subscribe(location, onEventD) +} + +} + +def onEventA(evt) { + +if ((!A_triggerOnce || (A_triggerOnce && !state.A_triggered)) && (!A_switchDisable || (A_switchDisable && !state.A_triggered))) { //Checks to make sure this scenario should be triggered more then once in a day +if ((!A_mode || A_mode.contains(location.mode)) && getTimeOk (A_timeStart, A_timeEnd) && getDayOk(A_day)) { //checks to make sure we are not opperating outside of set restrictions. +if ((!A_luxSensors) || (A_luxSensors.latestValue("illuminance") <= A_turnOnLux)){ //checks to make sure illimunance is either not cared about or if the value is within the restrictions +def A_levelOn = A_level as Integer + +//Check states of each device to see if they are to be ignored or if they meet the requirments of the app to produce an action. +if (getInputOk(A_motion, A_contact, A_lock, A_acceleration)) { + log.debug("Motion, Door Open or Unlock Detected Running '${ScenarioNameA}'") + settings.A_dimmers?.setLevel(A_levelOn) + settings.A_switches?.on() + if (A_triggerOnce){ + state.A_triggered = true + if (!A_turnOff) { + runOnce (getMidnight(), midNightReset) + } + } + if (state.A_timerStart){ + unschedule(delayTurnOffA) + state.A_timerStart = false + } +} + +//if none of the above paramenters meet the expectation of the app then turn off +else { + + if (settings.A_turnOff) { + runIn(A_turnOff * 60, "delayTurnOffA") + state.A_timerStart = true + } + else { + settings.A_switches?.off() + settings.A_dimmers?.setLevel(0) + if (state.A_triggered) { + runOnce (getMidnight(), midNightReset) + } + } +} +} +} +else{ +log.debug("Motion, Contact or Unlock detected outside of mode or time/day restriction. Not running scenario.") +} +} +} + +def delayTurnOffA(){ + settings.A_switches?.off() + settings.A_dimmers?.setLevel(0) + state.A_timerStart = false + if (state.A_triggered) { + runOnce (getMidnight(), midNightReset) + } + +} + +//when physical switch is actuated disable the scenario +def onPressA(evt) { +if ((!A_mode || A_mode.contains(location.mode)) && getTimeOk (A_timeStart, A_timeEnd) && getDayOk(A_day)) { //checks to make sure we are not opperating outside of set restrictions. +if ((!A_luxSensors) || (A_luxSensors.latestValue("illuminance") <= A_turnOnLux)){ +if ((!A_triggerOnce || (A_triggerOnce && !state.A_triggered)) && (!A_switchDisable || (A_switchDisable && !state.A_triggered))) { + if (evt.physical){ + state.A_triggered = true + unschedule(delayTurnOffA) + runOnce (getMidnight(), midNightReset) + log.debug "Physical switch in '${ScenarioNameA}' pressed. Triggers for this scenario disabled." + } +} +}}} + +def onEventB(evt) { + +if ((!B_triggerOnce || (B_triggerOnce && !state.B_triggered)) && (!B_switchDisable || (B_switchDisable && !state.B_triggered))) { //Checks to make sure this scenario should be triggered more then once in a day +if ((!B_mode ||B_mode.contains(location.mode)) && getTimeOk (B_timeStart, B_timeEnd) && getDayOk(B_day)) { //checks to make sure we are not opperating outside of set restrictions. +if ((!B_luxSensors) || (B_luxSensors.latestValue("illuminance") <= B_turnOnLux)) { //checks to make sure illimunance is either not cared about or if the value is within the restrictions +def B_levelOn = B_level as Integer + +//Check states of each device to see if they are to be ignored or if they meet the requirments of the app to produce an action. +if (getInputOk(B_motion, B_contact, B_lock, B_acceleration)) { + + log.debug("Motion, Door Open or Unlock Detected Running '${ScenarioNameB}'") + settings.B_dimmers?.setLevel(B_levelOn) + settings.B_switches?.on() + if (B_triggerOnce){ + state.B_triggered = true + if (!B_turnOff) { + runOnce (getMidnight(), midNightReset) + } + } + if (state.B_timerStart) { + unschedule(delayTurnOffB) + state.B_timerStart = false + } +} + +//if none of the above paramenters meet the expectation of the app then turn off +else { + if (settings.B_turnOff) { + runIn(B_turnOff * 60, "delayTurnOffB") + state.B_timerStart = true + } + + else { + settings.B_switches?.off() + settings.B_dimmers?.setLevel(0) + if (state.B_triggered) { + runOnce (getMidnight(), midNightReset) + } + } + +} +} +} +else{ +log.debug("Motion, Contact or Unlock detected outside of mode or time/day restriction. Not running scenario.") +} +} +} + +def delayTurnOffB(){ + settings.B_switches?.off() + settings.B_dimmers?.setLevel(0) + state.B_timerStart = false + if (state.B_triggered) { + runOnce (getMidnight(), midNightReset) + } +} + +//when physical switch is actuated disable the scenario +def onPressB(evt) { +if ((!B_mode ||B_mode.contains(location.mode)) && getTimeOk (B_timeStart, B_timeEnd) && getDayOk(B_day)) { //checks to make sure we are not opperating outside of set restrictions. +if ((!B_luxSensors) || (B_luxSensors.latestValue("illuminance") <= B_turnOnLux)) { +if ((!B_triggerOnce || (B_triggerOnce && !state.B_triggered)) && (!B_switchDisable || (B_switchDisable && !state.B_triggered))) { + if (evt.physical){ + state.B_triggered = true + unschedule(delayTurnOffB) + runOnce (getMidnight(), midNightReset) + log.debug "Physical switch in '${ScenarioNameB}' pressed. Triggers for this scenario disabled." + } +} +}}} + +def onEventC(evt) { + +if ((!C_triggerOnce || (C_triggerOnce && !state.C_triggered)) && (!C_switchDisable || (C_switchDisable && !state.C_triggered))) { //Checks to make sure this scenario should be triggered more then once in a day +if ((!C_mode || C_mode.contains(location.mode)) && getTimeOk (C_timeStart, C_timeEnd) && getDayOk(C_day) && !state.C_triggered){ //checks to make sure we are not opperating outside of set restrictions. +if ((!C_luxSensors) || (C_luxSensors.latestValue("illuminance") <= C_turnOnLux)){ //checks to make sure illimunance is either not cared about or if the value is within the restrictions +def C_levelOn = settings.C_level as Integer + +//Check states of each device to see if they are to be ignored or if they meet the requirments of the app to produce an action. +if (getInputOk(C_motion, C_contact, C_lock, C_acceleration)) { + log.debug("Motion, Door Open or Unlock Detected Running '${ScenarioNameC}'") + settings.C_dimmers?.setLevel(C_levelOn) + settings.C_switches?.on() + if (C_triggerOnce){ + state.C_triggered = true + if (!C_turnOff) { + runOnce (getMidnight(), midNightReset) + } + } + if (state.C_timerStart){ + unschedule(delayTurnOffC) + state.C_timerStart = false + } +} + +//if none of the above paramenters meet the expectation of the app then turn off +else { + if (settings.C_turnOff) { + runIn(C_turnOff * 60, "delayTurnOffC") + state.C_timerStart = true + } + else { + settings.C_switches?.off() + settings.C_dimmers?.setLevel(0) + if (state.C_triggered) { + runOnce (getMidnight(), midNightReset) + } + } + +} +} +} +else{ +log.debug("Motion, Contact or Unlock detected outside of mode or time/day restriction. Not running scenario.") +} +} +} + +def delayTurnOffC(){ + settings.C_switches?.off() + settings.C_dimmers?.setLevel(0) + state.C_timerStart = false + if (state.C_triggered) { + runOnce (getMidnight(), midNightReset) + } + +} + +//when physical switch is actuated disable the scenario +def onPressC(evt) { +if ((!C_mode || C_mode.contains(location.mode)) && getTimeOk (C_timeStart, C_timeEnd) && getDayOk(C_day) && !state.C_triggered){ +if ((!C_luxSensors) || (C_luxSensors.latestValue("illuminance") <= C_turnOnLux)){ +if ((!C_triggerOnce || (C_triggerOnce && !state.C_triggered)) && (!C_switchDisable || (C_switchDisable && !state.C_triggered))) { + if (evt.physical){ + state.C_triggered = true + unschedule(delayTurnOffC) + runOnce (getMidnight(), midNightReset) + log.debug "Physical switch in '${ScenarioNameC}' pressed. Triggers for this scenario disabled." + } +} +}}} + +def onEventD(evt) { + +if ((!D_triggerOnce || (D_triggerOnce && !state.D_triggered)) && (!D_switchDisable || (D_switchDisable && !state.D_triggered))) { //Checks to make sure this scenario should be triggered more then once in a day +if ((!D_mode || D_mode.contains(location.mode)) && getTimeOk (D_timeStart, D_timeEnd) && getDayOk(D_day) && !state.D_triggered){ //checks to make sure we are not opperating outside of set restrictions. +if ((!D_luxSensors) || (D_luxSensors.latestValue("illuminance") <= D_turnOnLux)){ //checks to make sure illimunance is either not cared about or if the value is within the restrictions +def D_levelOn = D_level as Integer + +//Check states of each device to see if they are to be ignored or if they meet the requirments of the app to produce an action. +if (getInputOk(D_motion, D_contact, D_lock, D_acceleration)) { + log.debug("Motion, Door Open or Unlock Detected Running '${ScenarioNameD}'") + settings.D_dimmers?.setLevel(D_levelOn) + settings.D_switches?.on() + if (D_triggerOnce){ + state.D_triggered = true + if (!D_turnOff) { + runOnce (getMidnight(), midNightReset) + } + } + if (state.D_timerStart){ + unschedule(delayTurnOffD) + state.D_timerStart = false + } +} + +//if none of the above paramenters meet the expectation of the app then turn off +else { + if (settings.D_turnOff) { + runIn(D_turnOff * 60, "delayTurnOffD") + state.D_timerStart = true + } + else { + settings.D_switches?.off() + settings.D_dimmers?.setLevel(0) + if (state.D_triggered) { + runOnce (getMidnight(), midNightReset) + } + } +} +} +} +else{ +log.debug("Motion, Contact or Unlock detected outside of mode or time/day restriction. Not running scenario.") +} +} +} + +def delayTurnOffD(){ + settings.D_switches?.off() + settings.D_dimmers?.setLevel(0) + state.D_timerStart = false + if (state.D_triggered) { + runOnce (getMidnight(), midNightReset) + } + +} + +//when physical switch is actuated disable the scenario +def onPressD(evt) { +if ((!D_mode || D_mode.contains(location.mode)) && getTimeOk (D_timeStart, D_timeEnd) && getDayOk(D_day) && !state.D_triggered){ //checks to make sure we are not opperating outside of set restrictions. +if ((!D_luxSensors) || (D_luxSensors.latestValue("illuminance") <= D_turnOnLux)){ +if ((!D_triggerOnce || (D_triggerOnce && !state.D_triggered)) && (!D_switchDisable || (D_switchDisable && !state.D_triggered))) { + if (evt.physical){ + state.D_triggered = true + unschedule(delayTurnOffD) + runOnce (getMidnight(), midNightReset) + log.debug "Physical switch in '${ScenarioNameD}' pressed. Triggers for this scenario disabled." + } +} +}}} + +//Common Methods + +//resets once a day trigger at midnight so trigger can be ran again the next day. +def midNightReset() { + state.A_triggered = false + state.B_triggered = false + state.C_triggered = false + state.D_triggered = false +} + +private def helpText() { + def text = + "Select motion sensors, acceleration sensors, contact sensors or locks to control a set of lights. " + + "Each scenario can control dimmers and switches but can also be " + + "restricted to modes or between certain times and turned off after " + + "motion stops, doors close or lock. Scenarios can also be limited to " + + "running once or to stop running if the physical switches are turned off." + text +} + +//should scenario be marked complete or not +def greyOut(scenario){ + def result = "" + if (scenario) { + result = "complete" + } + result +} + +//should i mark the time restriction green or grey +def greyedOutTime(start, end){ + def result = "" + if (start || end) { + result = "complete" + } + result +} + + +def getTitle(scenario) { + def title = "Empty" + if (scenario) { + title = scenario + } + title +} + +//recursively applies label to each scenario depending on if the scenario has deatils inside it or not +def getDesc(scenario) { + def desc = "Tap to create a scenario" + if (scenario) { + desc = "Tap to edit scenario" + } + desc +} + + +def getMidnight() { + def midnightToday = timeToday("2000-01-01T23:59:59.999-0000", location.timeZone) + midnightToday +} + +//used to recursively check device states when methods are triggered +private getInputOk(motion, contact, lock, acceleration) { + +def motionDetected = false +def accelerationDetected = false +def contactDetected = false +def unlockDetected = false +def result = false + +if (motion) { + if (motion.latestValue("motion").contains("active")) { + motionDetected = true + } +} + +if (acceleration) { + if (acceleration.latestValue("acceleration").contains("active")) { + accelerationDetected = true + } +} + +if (contact) { + if (contact.latestValue("contact").contains("open")) { + contactDetected = true + } +} + +if (lock) { + if (lock.latestValue("lock").contains("unlocked")) { + unlockDetected = true + } +} + +result = motionDetected || contactDetected || unlockDetected || accelerationDetected +result + +} + +private getTimeOk(starting, ending) { + 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 + } + + else if (starting){ + result = currTime >= start + } + else if (ending){ + result = currTime <= stop + } + + log.trace "timeOk = $result" + result +} + +def getTimeLabel(start, end){ + def timeLabel = "Tap to set" + + if(start && end){ + timeLabel = "Between" + " " + hhmm(start) + " " + "and" + " " + hhmm(end) + } + else if (start) { + timeLabel = "Start at" + " " + hhmm(start) + } + else if(end){ + timeLabel = "End at" + hhmm(end) + } + timeLabel +} + +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) +} + +private getDayOk(dayList) { + def result = true + if (dayList) { + def df = new java.text.SimpleDateFormat("EEEE") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + def day = df.format(new Date()) + result = dayList.contains(day) + } + result +} + + +page(name: "timeIntervalInputA", title: "Only during a certain time", refreshAfterSelection:true) { + section { + input "A_timeStart", "time", title: "Starting", required: false, refreshAfterSelection:true + input "A_timeEnd", "time", title: "Ending", required: false, refreshAfterSelection:true + } + } +page(name: "timeIntervalInputB", title: "Only during a certain time", refreshAfterSelection:true) { + section { + input "B_timeStart", "time", title: "Starting", required: false, refreshAfterSelection:true + input "B_timeEnd", "time", title: "Ending", required: false, refreshAfterSelection:true + } + } +page(name: "timeIntervalInputC", title: "Only during a certain time", refreshAfterSelection:true) { + section { + input "C_timeStart", "time", title: "Starting", required: false, refreshAfterSelection:true + input "C_timeEnd", "time", title: "Ending", required: false, refreshAfterSelection:true + } + } +page(name: "timeIntervalInputD", title: "Only during a certain time", refreshAfterSelection:true) { + section { + input "D_timeStart", "time", title: "Starting", required: false, refreshAfterSelection:true + input "D_timeEnd", "time", title: "Ending", required: false, refreshAfterSelection:true + } + } \ No newline at end of file diff --git a/official/lights-off-when-closed.groovy b/official/lights-off-when-closed.groovy new file mode 100755 index 0000000..2813e9b --- /dev/null +++ b/official/lights-off-when-closed.groovy @@ -0,0 +1,49 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Lights Off, When Closed + * + * Author: SmartThings + */ +definition( + name: "Lights Off, When Closed", + namespace: "smartthings", + author: "SmartThings", + description: "Turn your lights off when an open/close sensor closes.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet@2x.png" +) + +preferences { + section ("When the door closes...") { + input "contact1", "capability.contactSensor", title: "Where?" + } + section ("Turn off a light...") { + input "switch1", "capability.switch" + } +} + +def installed() +{ + subscribe(contact1, "contact.closed", contactClosedHandler) +} + +def updated() +{ + unsubscribe() + subscribe(contact1, "contact.closed", contactClosedHandler) +} + +def contactClosedHandler(evt) { + switch1.off() +} diff --git a/official/lights-off-with-no-motion-and-presence.groovy b/official/lights-off-with-no-motion-and-presence.groovy new file mode 100755 index 0000000..bf739f1 --- /dev/null +++ b/official/lights-off-with-no-motion-and-presence.groovy @@ -0,0 +1,80 @@ +/** + * Lights Off with No Motion and Presence + * + * Author: Bruce Adelsman + */ + +definition( + name: "Lights Off with No Motion and Presence", + namespace: "naissan", + author: "Bruce Adelsman", + description: "Turn lights off when no motion and presence is detected for a set period of time.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_presence-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_presence-outlet@2x.png" +) + +preferences { + section("Light switches to turn off") { + input "switches", "capability.switch", title: "Choose light switches", multiple: true + } + section("Turn off when there is no motion and presence") { + input "motionSensor", "capability.motionSensor", title: "Choose motion sensor" + input "presenceSensors", "capability.presenceSensor", title: "Choose presence sensors", multiple: true + } + section("Delay before turning off") { + input "delayMins", "number", title: "Minutes of inactivity?" + } +} + +def installed() { + subscribe(motionSensor, "motion", motionHandler) + subscribe(presenceSensors, "presence", presenceHandler) +} + +def updated() { + unsubscribe() + subscribe(motionSensor, "motion", motionHandler) + subscribe(presenceSensors, "presence", presenceHandler) +} + +def motionHandler(evt) { + log.debug "handler $evt.name: $evt.value" + if (evt.value == "inactive") { + runIn(delayMins * 60, scheduleCheck, [overwrite: false]) + } +} + +def presenceHandler(evt) { + log.debug "handler $evt.name: $evt.value" + if (evt.value == "not present") { + runIn(delayMins * 60, scheduleCheck, [overwrite: false]) + } +} + +def isActivePresence() { + // check all the presence sensors, make sure none are present + def noPresence = presenceSensors.find{it.currentPresence == "present"} == null + !noPresence +} + +def scheduleCheck() { + log.debug "scheduled check" + def motionState = motionSensor.currentState("motion") + if (motionState.value == "inactive") { + def elapsed = now() - motionState.rawDateCreated.time + def threshold = 1000 * 60 * delayMins - 1000 + if (elapsed >= threshold) { + if (!isActivePresence()) { + log.debug "Motion has stayed inactive since last check ($elapsed ms) and no presence: turning lights off" + switches.off() + } else { + log.debug "Presence is active: do nothing" + } + } else { + log.debug "Motion has not stayed inactive long enough since last check ($elapsed ms): do nothing" + } + } else { + log.debug "Motion is active: do nothing" + } +} \ No newline at end of file diff --git a/official/lock-it-at-a-specific-time.groovy b/official/lock-it-at-a-specific-time.groovy new file mode 100755 index 0000000..299b494 --- /dev/null +++ b/official/lock-it-at-a-specific-time.groovy @@ -0,0 +1,86 @@ +/** + * Lock it at a specific time + * + * Copyright 2014 Erik Thayer + * + * 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: "Lock it at a specific time", + namespace: "user8798", + author: "Erik Thayer", + description: "Make sure a door is locked at a specific time. Option to add door contact sensor to only lock if closed.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + + +preferences { + section("At this time every day") { + input "time", "time", title: "Time of Day" + } + section("Make sure this is locked") { + input "lock","capability.lock" + } + section("Make sure it's closed first..."){ + input "contact", "capability.contactSensor", title: "Which contact sensor?", required: false + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes", "No"]], required: false + input "phone", "phone", title: "Send a text message?", required: false + } +} +def installed() { + schedule(time, "setTimeCallback") + +} + +def updated(settings) { + unschedule() + schedule(time, "setTimeCallback") +} + +def setTimeCallback() { + if (contact) { + doorOpenCheck() + } else { + lockMessage() + lock.lock() + } +} +def doorOpenCheck() { + def currentState = contact.contactState + if (currentState?.value == "open") { + def msg = "${contact.displayName} is open. Scheduled lock failed." + log.info msg + if (sendPushMessage) { + sendPush msg + } + if (phone) { + sendSms phone, msg + } + } else { + lockMessage() + lock.lock() + } +} + +def lockMessage() { + def msg = "Locking ${lock.displayName} due to scheduled lock." + log.info msg + if (sendPushMessage) { + sendPush msg + } + if (phone) { + sendSms phone, msg + } +} diff --git a/official/lock-it-when-i-leave.groovy b/official/lock-it-when-i-leave.groovy new file mode 100755 index 0000000..1378ece --- /dev/null +++ b/official/lock-it-when-i-leave.groovy @@ -0,0 +1,87 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Lock It When I Leave + * + * Author: SmartThings + * Date: 2013-02-11 + */ + +definition( + name: "Lock It When I Leave", + namespace: "smartthings", + author: "SmartThings", + description: "Locks a deadbolt or lever lock when a SmartSense Presence tag or smartphone leaves a location.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png", + oauth: true +) + +preferences { + section("When I leave...") { + input "presence1", "capability.presenceSensor", title: "Who?", multiple: true + } + section("Lock the lock...") { + input "lock1","capability.lock", multiple: true + input "unlock", "enum", title: "Unlock when presence is detected?", options: ["Yes","No"] + input("recipients", "contact", title: "Send notifications to") { + input "spam", "enum", title: "Send Me Notifications?", options: ["Yes", "No"] + } + } +} + +def installed() +{ + subscribe(presence1, "presence", presence) +} + +def updated() +{ + unsubscribe() + subscribe(presence1, "presence", presence) +} + +def presence(evt) +{ + if (evt.value == "present") { + if (unlock == "Yes") { + def anyLocked = lock1.count{it.currentLock == "unlocked"} != lock1.size() + if (anyLocked) { + sendMessage("Doors unlocked at arrival of $evt.linkText") + } + lock1.unlock() + } + } + else { + def nobodyHome = presence1.find{it.currentPresence == "present"} == null + if (nobodyHome) { + def anyUnlocked = lock1.count{it.currentLock == "locked"} != lock1.size() + if (anyUnlocked) { + sendMessage("Doors locked after everyone departed") + } + lock1.lock() + } + } +} + +def sendMessage(msg) { + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + if (spam == "Yes") { + sendPush msg + } + } +} diff --git a/official/logitech-harmony-connect.groovy b/official/logitech-harmony-connect.groovy new file mode 100755 index 0000000..72763ef --- /dev/null +++ b/official/logitech-harmony-connect.groovy @@ -0,0 +1,984 @@ +/** + * Harmony (Connect) - https://developer.Harmony.com/documentation + * + * Copyright 2015 SmartThings + * + * 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. + * + * Author: SmartThings + * + * For complete set of capabilities, attributes, and commands see: + * + * https://graph.api.smartthings.com/ide/doc/capabilities + * + * ---------------------+-------------------+-----------------------------+------------------------------------ + * Device Type | Attribute Name | Commands | Attribute Values + * ---------------------+-------------------+-----------------------------+------------------------------------ + * switches | switch | on, off | on, off + * motionSensors | motion | | active, inactive + * contactSensors | contact | | open, closed + * thermostat | thermostat | setHeatingSetpoint, | temperature, heatingSetpoint + * | | setCoolingSetpoint(number) | coolingSetpoint, thermostatSetpoint + * | | off, heat, emergencyHeat | thermostatMode — ["emergency heat", "auto", "cool", "off", "heat"] + * | | cool, setThermostatMode | thermostatFanMode — ["auto", "on", "circulate"] + * | | fanOn, fanAuto, fanCirculate| thermostatOperatingState — ["cooling", "heating", "pending heat", + * | | setThermostatFanMode, auto | "fan only", "vent economizer", "pending cool", "idle"] + * presenceSensors | presence | | present, 'not present' + * temperatureSensors | temperature | | + * accelerationSensors | acceleration | | active, inactive + * waterSensors | water | | wet, dry + * lightSensors | illuminance | | + * humiditySensors | humidity | | + * alarms | alarm | strobe, siren, both, off | strobe, siren, both, off + * locks | lock | lock, unlock | locked, unlocked + * ---------------------+-------------------+-----------------------------+------------------------------------ + */ +include 'asynchttp_v1' + +definition( + name: "Logitech Harmony (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Allows you to integrate your Logitech Harmony account with SmartThings.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/harmony.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/harmony%402x.png", + oauth: [displayName: "Logitech Harmony", displayLink: "http://www.logitech.com/en-us/harmony-remotes"], + singleInstance: true +){ + appSetting "clientId" + appSetting "clientSecret" +} + +preferences(oauthPage: "deviceAuthorization") { + page(name: "Credentials", title: "Connect to your Logitech Harmony device", content: "authPage", install: false, nextPage: "deviceAuthorization") + page(name: "deviceAuthorization", title: "Logitech Harmony device authorization", install: true) { + section("Allow Logitech Harmony to control these things...") { + input "switches", "capability.switch", title: "Which Switches?", multiple: true, required: false + input "motionSensors", "capability.motionSensor", title: "Which Motion Sensors?", multiple: true, required: false + input "contactSensors", "capability.contactSensor", title: "Which Contact Sensors?", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Which Thermostats?", multiple: true, required: false + input "presenceSensors", "capability.presenceSensor", title: "Which Presence Sensors?", multiple: true, required: false + input "temperatureSensors", "capability.temperatureMeasurement", title: "Which Temperature Sensors?", multiple: true, required: false + input "accelerationSensors", "capability.accelerationSensor", title: "Which Vibration Sensors?", multiple: true, required: false + input "waterSensors", "capability.waterSensor", title: "Which Water Sensors?", multiple: true, required: false + input "lightSensors", "capability.illuminanceMeasurement", title: "Which Light Sensors?", multiple: true, required: false + input "humiditySensors", "capability.relativeHumidityMeasurement", title: "Which Relative Humidity Sensors?", multiple: true, required: false + input "alarms", "capability.alarm", title: "Which Sirens?", multiple: true, required: false + input "locks", "capability.lock", title: "Which Locks?", multiple: true, required: false + } + } +} + +mappings { + path("/devices") { action: [ GET: "listDevices"] } + path("/devices/:id") { action: [ GET: "getDevice", PUT: "updateDevice"] } + path("/subscriptions") { action: [ GET: "listSubscriptions", POST: "addSubscription"] } + path("/subscriptions/:id") { action: [ DELETE: "removeSubscription"] } + path("/phrases") { action: [ GET: "listPhrases"] } + path("/phrases/:id") { action: [ PUT: "executePhrase"] } + path("/hubs") { action: [ GET: "listHubs" ] } + path("/hubs/:id") { action: [ GET: "getHub" ] } + path("/activityCallback/:dni") { action: [ POST: "activityCallback" ] } + path("/harmony") { action: [ GET: "getHarmony", POST: "harmony" ] } + path("/harmony/:mac") { action: [ DELETE: "deleteHarmony" ] } + path("/receivedToken") { action: [ POST: "receivedToken", GET: "receivedToken"] } + path("/receiveToken") { action: [ POST: "receiveToken", GET: "receiveToken"] } + path("/hookCallback") { action: [ POST: "hookEventHandler", GET: "hookEventHandler"] } + path("/oauth/callback") { action: [ GET: "callback" ] } + path("/oauth/initialize") { action: [ GET: "init"] } +} + +def getServerUrl() { return "https://graph.api.smartthings.com" } +def getServercallbackUrl() { "https://graph.api.smartthings.com/oauth/callback" } +def getBuildRedirectUrl() { "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${state.accessToken}&apiServerUrl=${apiServerUrl}" } + +def authPage() { + def description = null + if (!state.HarmonyAccessToken) { + if (!state.accessToken) { + log.debug "Harmony - About to create access token" + createAccessToken() + } + description = "Click to enter Harmony Credentials" + def redirectUrl = buildRedirectUrl + return dynamicPage(name: "Credentials", title: "Harmony", nextPage: null, uninstall: true, install:false) { + section { paragraph title: "Note:", "This device has not been officially tested and certified to “Work with SmartThings”. You can connect it to your SmartThings home but performance may vary and we will not be able to provide support or assistance." } + section { href url:redirectUrl, style:"embedded", required:true, title:"Harmony", description:description } + } + } else { + //device discovery request every 5 //25 seconds + int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int + state.deviceRefreshCount = deviceRefreshCount + 1 + def refreshInterval = 5 + + def huboptions = state.HarmonyHubs ?: [] + def actoptions = state.HarmonyActivities ?: [] + + def numFoundHub = huboptions.size() ?: 0 + def numFoundAct = actoptions.size() ?: 0 + + if((deviceRefreshCount % 5) == 0) { + discoverDevices() + } + + return dynamicPage(name:"Credentials", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) { + section("Please wait while we discover your Harmony Hubs and Activities. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedhubs", "enum", required:false, title:"Select Harmony Hubs (${numFoundHub} found)", multiple:true, submitOnChange: true, options:huboptions + } + // Virtual activity flag + if (numFoundHub > 0 && numFoundAct > 0 && true) + section("You can also add activities as virtual switches for other convenient integrations") { + input "selectedactivities", "enum", required:false, title:"Select Harmony Activities (${numFoundAct} found)", multiple:true, submitOnChange: true, options:actoptions + } + if (state.resethub) + section("Connection to the hub timed out. Please restart the hub and try again.") {} + } + } +} + +def callback() { + def redirectUrl = null + if (params.authQueryString) { + redirectUrl = URLDecoder.decode(params.authQueryString.replaceAll(".+&redirect_url=", "")) + log.debug "Harmony - redirectUrl: ${redirectUrl}" + } else { + log.warn "Harmony - No authQueryString" + } + + if (state.HarmonyAccessToken) { + log.debug "Harmony - Access token already exists" + discovery() + success() + } else { + def code = params.code + if (code) { + if (code.size() > 6) { + // Harmony code + log.debug "Harmony - Exchanging code for access token" + receiveToken(redirectUrl) + } else { + // Initiate the Harmony OAuth flow. + init() + } + } else { + log.debug "Harmony - This code should be unreachable" + success() + } + } +} + +def init() { + log.debug "Harmony - Requesting Code" + def oauthParams = [client_id: "${appSettings.clientId}", scope: "remote", response_type: "code", redirect_uri: "${servercallbackUrl}" ] + redirect(location: "https://home.myharmony.com/oauth2/authorize?${toQueryString(oauthParams)}") +} + +def receiveToken(redirectUrl = null) { + log.debug "Harmony - receiveToken" + def oauthParams = [ client_id: "${appSettings.clientId}", client_secret: "${appSettings.clientSecret}", grant_type: "authorization_code", code: params.code ] + def params = [ + uri: "https://home.myharmony.com/oauth2/token?${toQueryString(oauthParams)}", + ] + try { + httpPost(params) { response -> + state.HarmonyAccessToken = response.data.access_token + } + } catch (java.util.concurrent.TimeoutException e) { + fail(e) + log.warn "Harmony - Connection timed out, please try again later." + } + discovery() + if (state.HarmonyAccessToken) { + success() + } else { + fail("") + } +} + +def success() { + def message = """ +

Your Harmony Account is now connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + connectionStatus(message) +} + +def fail(msg) { + def message = """ +

The connection could not be established!

+

$msg

+

Click 'Done' to return to the menu.

+ """ + connectionStatus(message) +} + +def receivedToken() { + def message = """ +

Your Harmony Account is already connected to SmartThings!

+

Click 'Done' to finish setup.

+ """ + connectionStatus(message) +} + +def connectionStatus(message, redirectUrl = null) { + def redirectHtml = "" + if (redirectUrl) { + redirectHtml = """ + + """ + } + + def html = """ + + + + + SmartThings Connection + + ${redirectHtml} + + +
+ Harmony icon + connected device icon + SmartThings logo + ${message} +
+ + + """ + render contentType: 'text/html', data: html +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +def buildRedirectUrl(page) { + return "${serverUrl}/api/token/${state.accessToken}/smartapps/installations/${app.id}/${page}" +} + +def installed() { + if (!state.accessToken) { + log.debug "Harmony - About to create access token" + createAccessToken() + } else { + initialize() + } +} + +def updated() { + if (!state.accessToken) { + log.debug "Harmony - About to create access token" + createAccessToken() + } else { + initialize() + } +} + +def uninstalled() { + if (state.HarmonyAccessToken) { + try { + state.HarmonyAccessToken = "" + log.debug "Harmony - Success disconnecting Harmony from SmartThings" + } catch (groovyx.net.http.HttpResponseException e) { + log.error "Harmony - Error disconnecting Harmony from SmartThings: ${e.statusCode}" + } + } +} + +def initialize() { + state.aux = 0 + if (selectedhubs || selectedactivities) { + addDevice() + runEvery5Minutes("poll") + getActivityList() + } +} + +def getHarmonydevices() { + state.Harmonydevices ?: [] +} + +Map discoverDevices() { + log.trace "Harmony - Discovering devices..." + discovery() + if (getHarmonydevices() != []) { + def devices = state.Harmonydevices.hubs + log.trace devices.toString() + def activities = [:] + def hubs = [:] + devices.each { + def hubkey = it.key + def hubname = getHubName(it.key) + def hubvalue = "${hubname}" + hubs["harmony-${hubkey}"] = hubvalue + it.value.response.data.activities.each { + def value = "${it.value.name}" + def key = "harmony-${hubkey}-${it.key}" + activities["${key}"] = value + } + } + state.HarmonyHubs = hubs + state.HarmonyActivities = activities + } +} + +//CHILD DEVICE METHODS +def discovery() { + def Params = [auth: state.HarmonyAccessToken] + def url = "https://home.myharmony.com/cloudapi/activity/all?${toQueryString(Params)}" + try { + httpGet(uri: url, headers: ["Accept": "application/json"]) {response -> + if (response.status == 200) { + log.debug "Harmony - valid Token" + state.Harmonydevices = response.data + state.resethub = false + } else { + log.debug "Harmony - Error: $response.status" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + if (e.statusCode == 401) { // token is expired + state.remove("HarmonyAccessToken") + log.warn "Harmony - Harmony Access token has expired" + } + } catch (java.net.SocketTimeoutException e) { + log.warn "Harmony - Connection to the hub timed out. Please restart the hub and try again." + state.resethub = true + } catch (e) { + log.info "Harmony - Error: $e" + } + return null +} + +def addDevice() { + log.trace "Harmony - Adding Hubs" + selectedhubs.each { dni -> + def d = getChildDevice(dni) + if(!d) { + def newAction = state.HarmonyHubs.find { it.key == dni } + d = addChildDevice("smartthings", "Logitech Harmony Hub C2C", dni, null, [label:"${newAction.value}"]) + log.trace "Harmony - Created ${d.displayName} with id $dni" + poll() + } else { + log.trace "Harmony - Found ${d.displayName} with id $dni already exists" + } + } + log.trace "Harmony - Adding Activities" + selectedactivities.each { dni -> + def d = getChildDevice(dni) + if(!d) { + def newAction = state.HarmonyActivities.find { it.key == dni } + if (newAction) { + d = addChildDevice("smartthings", "Harmony Activity", dni, null, [label:"${newAction.value} [Harmony Activity]"]) + log.trace "Harmony - Created ${d.displayName} with id $dni" + poll() + } + } else { + log.trace "Harmony - Found ${d.displayName} with id $dni already exists" + } + } +} + +def activity(dni,mode) { + def tokenParam = [auth: state.HarmonyAccessToken] + def url + if (dni == "all") { + url = "https://home.myharmony.com/cloudapi/activity/off?${toQueryString(tokenParam)}" + } else { + def aux = dni.split('-') + def hubId = aux[1] + if (mode == "hub" || (aux.size() <= 2) || (aux[2] == "off")){ + url = "https://home.myharmony.com/cloudapi/hub/${hubId}/activity/off?${toQueryString(tokenParam)}" + } else { + def activityId = aux[2] + url = "https://home.myharmony.com/cloudapi/hub/${hubId}/activity/${activityId}/${mode}?${toQueryString(tokenParam)}" + } + } + def params = [ + uri: url, + contentType: 'application/json' + ] + asynchttp_v1.post('activityResponse', params) + return "Command Sent" +} + +def activityResponse(response, data) { + if (response.hasError()) { + log.error "Harmony - response has error: $response.errorMessage" + if (response.status == 401) { // token is expired + state.remove("HarmonyAccessToken") + log.warn "Harmony - Access token has expired" + } + } else { + if (response.status == 200) { + log.trace "Harmony - Command sent succesfully" + poll() + } else { + log.trace "Harmony - Command failed. Error: $response.status" + } + } +} + +def poll() { + // GET THE LIST OF ACTIVITIES + if (state.HarmonyAccessToken) { + def tokenParam = [auth: state.HarmonyAccessToken] + def params = [ + uri: "https://home.myharmony.com/cloudapi/state?${toQueryString(tokenParam)}", + headers: ["Accept": "application/json"], + contentType: 'application/json' + ] + asynchttp_v1.get('pollResponse', params) + } else { + log.warn "Harmony - Access token has expired" + } +} + +def pollResponse(response, data) { + if (response.hasError()) { + log.error "Harmony - response has error: $response.errorMessage" + if (response.status == 401) { // token is expired + state.remove("HarmonyAccessToken") + log.warn "Harmony - Access token has expired" + } + } else { + def ResponseValues + try { + // json response already parsed into JSONElement object + ResponseValues = response.json + } catch (e) { + log.error "Harmony - error parsing json from response: $e" + } + if (ResponseValues) { + def map = [:] + ResponseValues.hubs.each { + // Device-Watch relies on the Logitech Harmony Cloud to get the Device state. + def isAlive = it.value.status + def d = getChildDevice("harmony-${it.key}") + d?.sendEvent(name: "DeviceWatch-DeviceStatus", value: isAlive!=504? "online":"offline", displayed: false, isStateChange: true) + if (it.value.message == "OK") { + map["${it.key}"] = "${it.value.response.data.currentAvActivity},${it.value.response.data.activityStatus}" + def hub = getChildDevice("harmony-${it.key}") + if (hub) { + if (it.value.response.data.currentAvActivity == "-1") { + hub.sendEvent(name: "currentActivity", value: "--", descriptionText: "There isn't any activity running", displayed: false) + } else { + def currentActivity + def activityDTH = getChildDevice("harmony-${it.key}-${it.value.response.data.currentAvActivity}") + if (activityDTH) + currentActivity = activityDTH.device.displayName + else + currentActivity = getActivityName(it.value.response.data.currentAvActivity,it.key) + hub.sendEvent(name: "currentActivity", value: currentActivity, descriptionText: "Current activity is ${currentActivity}", displayed: false) + } + } + } else { + log.trace "Harmony - error response: $it.value.message" + } + } + def activities = getChildDevices() + def activitynotrunning = true + activities.each { activity -> + def act = activity.deviceNetworkId.split('-') + if (act.size() > 2) { + def aux = map.find { it.key == act[1] } + if (aux) { + def aux2 = aux.value.split(',') + def childDevice = getChildDevice(activity.deviceNetworkId) + if ((act[2] == aux2[0]) && (aux2[1] == "1" || aux2[1] == "2")) { + childDevice?.sendEvent(name: "switch", value: "on") + if (aux2[1] == "1") + runIn(5, "poll", [overwrite: true]) + } else { + childDevice?.sendEvent(name: "switch", value: "off") + if (aux2[1] == "3") + runIn(5, "poll", [overwrite: true]) + } + } + } + } + } else { + log.debug "Harmony - did not get json results from response body: $response.data" + } + } +} + +def getActivityList() { + if (state.HarmonyAccessToken) { + def Params = [auth: state.HarmonyAccessToken] + def url = "https://home.myharmony.com/cloudapi/activity/all?${toQueryString(Params)}" + try { + httpGet(uri: url, headers: ["Accept": "application/json"]) {response -> + response.data.hubs.each { + def hub = getChildDevice("harmony-${it.key}") + if (hub) { + def hubname = getHubName("${it.key}") + def activities = [] + def aux = it.value.response.data.activities.size() + if (aux >= 1) { + activities = it.value.response.data.activities.collect { + [id: it.key, name: it.value['name'], type: it.value['type']] + } + activities += [id: "off", name: "Activity OFF", type: "0"] + } + hub.sendEvent(name: "activities", value: new groovy.json.JsonBuilder(activities).toString(), descriptionText: "Activities are ${activities.collect { it.name }?.join(', ')}", displayed: false) + } + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.trace e + } catch (java.net.SocketTimeoutException e) { + log.trace e + } catch(Exception e) { + log.trace e + } + } +} + +def getActivityName(activity,hubId) { + // GET ACTIVITY'S NAME + def actname = activity + if (state.HarmonyAccessToken) { + def Params = [auth: state.HarmonyAccessToken] + def url = "https://home.myharmony.com/cloudapi/hub/${hubId}/activity/all?${toQueryString(Params)}" + try { + httpGet(uri: url, headers: ["Accept": "application/json"]) {response -> + actname = response.data.data.activities[activity].name + } + } catch(Exception e) { + log.trace e + } + } + return actname +} + +def getActivityId(activity,hubId) { + // GET ACTIVITY'S NAME + def actid = activity + if (state.HarmonyAccessToken) { + def Params = [auth: state.HarmonyAccessToken] + def url = "https://home.myharmony.com/cloudapi/hub/${hubId}/activity/all?${toQueryString(Params)}" + try { + httpGet(uri: url, headers: ["Accept": "application/json"]) {response -> + response.data.data.activities.each { + if (it.value.name == activity) + actid = it.key + } + } + } catch(Exception e) { + log.trace e + } + } + return actid +} + +def getHubName(hubId) { + // GET HUB'S NAME + def hubname = hubId + if (state.HarmonyAccessToken) { + def Params = [auth: state.HarmonyAccessToken] + def url = "https://home.myharmony.com/cloudapi/hub/${hubId}/discover?${toQueryString(Params)}" + try { + httpGet(uri: url, headers: ["Accept": "application/json"]) {response -> + hubname = response.data.data.name + } + } catch(Exception e) { + log.trace e + } + } + return hubname +} + +def sendNotification(msg) { + sendNotification(msg) +} + +def hookEventHandler() { + // log.debug "In hookEventHandler method." + log.debug "Harmony - request = ${request}" + + def json = request.JSON + + def html = """{"code":200,"message":"OK"}""" + render contentType: 'application/json', data: html +} + +def listDevices() { + log.debug "Harmony - getDevices(), params: ${params}" + allDevices.collect { + deviceItem(it) + } +} + +def getDevice() { + log.debug "Harmony - getDevice(), params: ${params}" + def device = allDevices.find { it.id == params.id } + if (!device) { + render status: 404, data: '{"msg": "Device not found"}' + } else { + deviceItem(device) + } +} + +def updateDevice() { + def data = request.JSON + def command = data.command + def arguments = data.arguments + log.debug "Harmony - updateDevice(), params: ${params}, request: ${data}" + if (!command) { + render status: 400, data: '{"msg": "command is required"}' + } else { + def device = allDevices.find { it.id == params.id } + if (device) { + if (validateCommand(device, command)) { + if (arguments) { + device."$command"(*arguments) + } else { + device."$command"() + } + render status: 204, data: "{}" + } else { + render status: 403, data: '{"msg": "Access denied. This command is not supported by current capability."}' + } + } else { + render status: 404, data: '{"msg": "Device not found"}' + } + } +} + +/** + * Validating the command passed by the user based on capability. + * @return boolean + */ +def validateCommand(device, command) { + def capabilityCommands = getDeviceCapabilityCommands(device.capabilities) + def currentDeviceCapability = getCapabilityName(device) + if (currentDeviceCapability != "" && capabilityCommands[currentDeviceCapability]) { + return (command in capabilityCommands[currentDeviceCapability] || (currentDeviceCapability == "Switch" && command == "setLevel" && device.hasCommand("setLevel"))) ? true : false + } else { + // Handling other device types here, which don't accept commands + httpError(400, "Bad request.") + } +} + +/** + * Need to get the attribute name to do the lookup. Only + * doing it for the device types which accept commands + * @return attribute name of the device type + */ +def getCapabilityName(device) { + def capName = "" + if (switches.find{it.id == device.id}) + capName = "Switch" + else if (alarms.find{it.id == device.id}) + capName = "Alarm" + else if (locks.find{it.id == device.id}) + capName = "Lock" + log.trace "Device: $device - Capability Name: $capName" + return capName +} + +/** + * Constructing the map over here of + * supported commands by device capability + * @return a map of device capability -> supported commands + */ +def getDeviceCapabilityCommands(deviceCapabilities) { + def map = [:] + deviceCapabilities.collect { + map[it.name] = it.commands.collect{ it.name.toString() } + } + return map +} + +def listSubscriptions() { + log.debug "Harmony - listSubscriptions()" + app.subscriptions?.findAll { it.device?.device && it.device.id }?.collect { + def deviceInfo = state[it.device.id] + def response = [ + id: it.id, + deviceId: it.device.id, + attributeName: it.data, + handler: it.handler + ] + if (!state.harmonyHubs) { + response.callbackUrl = deviceInfo?.callbackUrl + } + response + } ?: [] +} + +def addSubscription() { + def data = request.JSON + def attribute = data.attributeName + def callbackUrl = data.callbackUrl + + log.debug "Harmony - addSubscription, params: ${params}, request: ${data}" + if (!attribute) { + render status: 400, data: '{"msg": "attributeName is required"}' + } else { + def device = allDevices.find { it.id == data.deviceId } + if (device) { + if (!state.harmonyHubs) { + log.debug "Harmony - Adding callbackUrl: $callbackUrl" + state[device.id] = [callbackUrl: callbackUrl] + } + log.debug "Harmony - Adding subscription" + def subscription = subscribe(device, attribute, deviceHandler) + if (!subscription || !subscription.eventSubscription) { + subscription = app.subscriptions?.find { it.device?.device && it.device.id == data.deviceId && it.data == attribute && it.handler == 'deviceHandler' } + } + + def response = [ + id: subscription.id, + deviceId: subscription.device.id, + attributeName: subscription.data, + handler: subscription.handler + ] + if (!state.harmonyHubs) { + response.callbackUrl = callbackUrl + } + response + } else { + render status: 400, data: '{"msg": "Device not found"}' + } + } +} + +def removeSubscription() { + def subscription = app.subscriptions?.find { it.id == params.id } + def device = subscription?.device + + log.debug "removeSubscription, params: ${params}, subscription: ${subscription}, device: ${device}" + if (device) { + log.debug "Harmony - Removing subscription for device: ${device.id}" + state.remove(device.id) + unsubscribe(device) + } + render status: 204, data: "{}" +} + +def listPhrases() { + location.helloHome.getPhrases()?.collect {[ + id: it.id, + label: it.label + ]} +} + +def executePhrase() { + log.debug "executedPhrase, params: ${params}" + location.helloHome.execute(params.id) + render status: 204, data: "{}" +} + +def deviceHandler(evt) { + def deviceInfo = state[evt.deviceId] + if (state.harmonyHubs) { + state.harmonyHubs.each { harmonyHub -> + log.trace "Harmony - Sending data to $harmonyHub.name" + sendToHarmony(evt, harmonyHub.callbackUrl) + } + } else if (deviceInfo) { + if (deviceInfo.callbackUrl) { + sendToHarmony(evt, deviceInfo.callbackUrl) + } else { + log.warn "Harmony - No callbackUrl set for device: ${evt.deviceId}" + } + } else { + log.warn "Harmony - No subscribed device found for device: ${evt.deviceId}" + } +} + +def sendToHarmony(evt, String callbackUrl) { + def callback = new URI(callbackUrl) + if (callback.port != -1) { + def host = callback.port != -1 ? "${callback.host}:${callback.port}" : callback.host + def path = callback.query ? "${callback.path}?${callback.query}".toString() : callback.path + sendHubCommand(new physicalgraph.device.HubAction( + method: "POST", + path: path, + headers: [ + "Host": host, + "Content-Type": "application/json" + ], + body: [evt: [deviceId: evt.deviceId, name: evt.name, value: evt.value]] + )) + } else { + def params = [ + uri: callbackUrl, + body: [evt: [deviceId: evt.deviceId, name: evt.name, value: evt.value]] + ] + try { + log.debug "Harmony - Sending data to Harmony Cloud: $params" + httpPostJson(params) { resp -> + log.debug "Harmony - Cloud Response: ${resp.status}" + } + } catch (e) { + log.error "Harmony - Cloud Something went wrong: $e" + } + } +} + +def listHubs() { + location.hubs?.findAll { it.type.toString() == "PHYSICAL" }?.collect { hubItem(it) } +} + +def getHub() { + def hub = location.hubs?.findAll { it.type.toString() == "PHYSICAL" }?.find { it.id == params.id } + if (!hub) { + render status: 404, data: '{"msg": "Hub not found"}' + } else { + hubItem(hub) + } +} + +def activityCallback() { + def data = request.JSON + def device = getChildDevice(params.dni) + if (device) { + if (data.errorCode == "200") { + device.setCurrentActivity(data.currentActivityId) + } else { + log.warn "Harmony - Activity callback error: ${data}" + } + } else { + log.warn "Harmony - Activity callback sent to non-existant dni: ${params.dni}" + } + render status: 200, data: '{"msg": "Successfully received callbackUrl"}' +} + +def getHarmony() { + state.harmonyHubs ?: [] +} + +def harmony() { + def data = request.JSON + if (data.mac && data.callbackUrl && data.name) { + if (!state.harmonyHubs) { state.harmonyHubs = [] } + def harmonyHub = state.harmonyHubs.find { it.mac == data.mac } + if (harmonyHub) { + harmonyHub.mac = data.mac + harmonyHub.callbackUrl = data.callbackUrl + harmonyHub.name = data.name + } else { + state.harmonyHubs << [mac: data.mac, callbackUrl: data.callbackUrl, name: data.name] + } + render status: 200, data: '{"msg": "Successfully received Harmony data"}' + } else { + if (!data.mac) { + render status: 400, data: '{"msg": "mac is required"}' + } else if (!data.callbackUrl) { + render status: 400, data: '{"msg": "callbackUrl is required"}' + } else if (!data.name) { + render status: 400, data: '{"msg": "name is required"}' + } + } +} + +def deleteHarmony() { + log.debug "Harmony - Trying to delete Harmony hub with mac: ${params.mac}" + def harmonyHub = state.harmonyHubs?.find { it.mac == params.mac } + if (harmonyHub) { + log.debug "Harmony - Deleting Harmony hub with mac: ${params.mac}" + state.harmonyHubs.remove(harmonyHub) + } else { + log.debug "Harmony - Couldn't find Harmony hub with mac: ${params.mac}" + } + render status: 204, data: "{}" +} + +private getAllDevices() { + ([] + switches + motionSensors + contactSensors + thermostats + presenceSensors + temperatureSensors + accelerationSensors + waterSensors + lightSensors + humiditySensors + alarms + locks)?.findAll()?.unique { it.id } +} + +private deviceItem(device) { + [ + id: device.id, + label: device.displayName, + currentStates: device.currentStates, + capabilities: device.capabilities?.collect {[ + name: it.name + ]}, + attributes: device.supportedAttributes?.collect {[ + name: it.name, + dataType: it.dataType, + values: it.values + ]}, + commands: device.supportedCommands?.collect {[ + name: it.name, + arguments: it.arguments + ]}, + type: [ + name: device.typeName, + author: device.typeAuthor + ] + ] +} + +private hubItem(hub) { + [ + id: hub.id, + name: hub.name, + ip: hub.localIP, + port: hub.localSrvPortTCP + ] +} diff --git a/official/mail-arrived.groovy b/official/mail-arrived.groovy new file mode 100755 index 0000000..333a8fa --- /dev/null +++ b/official/mail-arrived.groovy @@ -0,0 +1,77 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Mail Arrived + * + * Author: SmartThings + */ +definition( + name: "Mail Arrived", + namespace: "smartthings", + author: "SmartThings", + description: "Send a text when mail arrives in your mailbox using a SmartSense Multi on your mailbox door. Note: battery life may be impacted in cold climates.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/mail_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/mail_contact@2x.png" +) + +preferences { + section("When mail arrives...") { + input "accelerationSensor", "capability.accelerationSensor", title: "Where?" + } + section("Notify me...") { + input("recipients", "contact", title: "Send notifications to") { + input "pushNotification", "bool", title: "Push notification", required: false, defaultValue: "true" + input "phone1", "phone", title: "Phone number", required: false + } + } +} + +def installed() { + subscribe(accelerationSensor, "acceleration.active", accelerationActiveHandler) +} + +def updated() { + unsubscribe() + subscribe(accelerationSensor, "acceleration.active", accelerationActiveHandler) +} + +def accelerationActiveHandler(evt) { + log.trace "$evt.value: $evt, $settings" + + // Don't send a continuous stream of notifications + def deltaSeconds = 5 + def timeAgo = new Date(now() - (1000 * deltaSeconds)) + def recentEvents = accelerationSensor.eventsSince(timeAgo) + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $deltaSeconds seconds" + def alreadySentNotifications = recentEvents.count { it.value && it.value == "active" } > 1 + + if (alreadySentNotifications) { + log.debug "Notifications already sent within the last $deltaSeconds seconds (phone1: $phone1, pushNotification: $pushNotification)" + } + else { + if (location.contactBookEnabled) { + log.debug "$accelerationSensor has moved, notifying ${recipients?.size()}" + sendNotificationToContacts("Mail has arrived!", recipients) + } + else { + if (phone1 != null && phone1 != "") { + log.debug "$accelerationSensor has moved, texting $phone1" + sendSms(phone1, "Mail has arrived!") + } + if (pushNotification) { + log.debug "$accelerationSensor has moved, sending push" + sendPush("Mail has arrived!") + } + } + } +} diff --git a/official/make-it-so.groovy b/official/make-it-so.groovy new file mode 100755 index 0000000..718d69e --- /dev/null +++ b/official/make-it-so.groovy @@ -0,0 +1,137 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Make it So + * + * Author: SmartThings + * Date: 2013-03-06 + */ +definition( + name: "Make It So", + namespace: "smartthings", + author: "SmartThings", + description: "Saves the states of a specified set switches and thermostat setpoints and restores them at each mode change. To use 1) Set the mode, 2) Change switches and setpoint to where you want them for that mode, and 3) Install or update the app. Changing to that mode or touching the app will set the devices to the saved state.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_thermo-switch.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_thermo-switch@2x.png" +) + +preferences { + section("Switches") { + input "switches", "capability.switch", multiple: true, required: false + } + section("Thermostats") { + input "thermostats", "capability.thermostat", multiple: true, required: false + } + section("Locks") { + input "locks", "capability.lock", multiple: true, required: false + } +} + +def installed() { + subscribe(location, changedLocationMode) + subscribe(app, appTouch) + saveState() +} + +def updated() { + unsubscribe() + subscribe(location, changedLocationMode) + subscribe(app, appTouch) + saveState() +} + +def appTouch(evt) +{ + restoreState(currentMode) +} + +def changedLocationMode(evt) +{ + restoreState(evt.value) +} + +private restoreState(mode) +{ + log.info "restoring state for mode '$mode'" + def map = state[mode] ?: [:] + switches?.each { + def value = map[it.id] + if (value?.switch == "on") { + def level = value.level + if (level) { + log.debug "setting $it.label level to $level" + it.setLevel(level) + } + else { + log.debug "turning $it.label on" + it.on() + } + } + else if (value?.switch == "off") { + log.debug "turning $it.label off" + it.off() + } + } + + thermostats?.each { + def value = map[it.id] + if (value?.coolingSetpoint) { + log.debug "coolingSetpoint = $value.coolingSetpoint" + it.setCoolingSetpoint(value.coolingSetpoint) + } + if (value?.heatingSetpoint) { + log.debug "heatingSetpoint = $value.heatingSetpoint" + it.setHeatingSetpoint(value.heatingSetpoint) + } + } + + locks?.each { + def value = map[it.id] + if (value) { + if (value?.locked) { + it.lock() + } + else { + it.unlock() + } + } + } +} + + +private saveState() +{ + def mode = currentMode + def map = state[mode] ?: [:] + + switches?.each { + map[it.id] = [switch: it.currentSwitch, level: it.currentLevel] + } + + thermostats?.each { + map[it.id] = [coolingSetpoint: it.currentCoolingSetpoint, heatingSetpoint: it.currentHeatingSetpoint] + } + + locks?.each { + map[it.id] = [locked: it.currentLock == "locked"] + } + + state[mode] = map + log.debug "saved state for mode ${mode}: ${state[mode]}" + log.debug "state: $state" +} + +private getCurrentMode() +{ + location.mode ?: "_none_" +} diff --git a/official/medicine-management-contact-sensor.groovy b/official/medicine-management-contact-sensor.groovy new file mode 100755 index 0000000..3c4c9d1 --- /dev/null +++ b/official/medicine-management-contact-sensor.groovy @@ -0,0 +1,188 @@ +/** + * Medicine Management - Contact Sensor + * + * Copyright 2016 Jim Mangione + * + * 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. + * + * Logic: + * --- Send notification at the medicine reminder time IF draw wasn't alread opened in past 60 minutes + * --- If draw still isn't open 10 minutes AFTER reminder time, LED will turn RED. + * --- ----- Once draw IS open, LED will return back to it's original color + * + */ +import groovy.time.TimeCategory + +definition( + name: "Medicine Management - Contact Sensor", + namespace: "MangioneImagery", + author: "Jim Mangione", + description: "This supports devices with capabilities of ContactSensor and ColorControl (LED). It sends an in-app and ambient light notification if you forget to open the drawer or cabinet where meds are stored. A reminder will be set to a single time per day. If the draw or cabinet isn't opened within 60 minutes of that reminder, an in-app message will be sent. If the draw or cabinet still isn't opened after an additional 10 minutes, then an LED light turns red until the draw or cabinet is opened", + category: "Health & Wellness", + 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 { + + section("My Medicine Draw/Cabinet"){ + input "deviceContactSensor", "capability.contactSensor", title: "Opened Sensor" + } + + section("Remind me to take my medicine at"){ + input "reminderTime", "time", title: "Time" + } + + // NOTE: Use REAL device - virtual device causes compilation errors + section("My LED Light"){ + input "deviceLight", "capability.colorControl", title: "Smart light" + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + + initialize() +} + +def initialize() { + + // will stop LED notification incase it was set by med reminder + subscribe(deviceContactSensor, "contact", contactHandler) + + // how many minutes to look in the past from the reminder time, for an open draw + state.minutesToCheckOpenDraw = 60 + + // is true when LED notification is set after exceeding 10 minutes past reminder time + state.ledNotificationTriggered = false + + // Set a timer to run once a day to notify if draw wasn't opened yet + schedule(reminderTime, checkOpenDrawInPast) + +} + +// Should turn off any LED notification on OPEN state +def contactHandler(evt){ + if (evt.value == "open") { + // if LED notification triggered, reset it. + log.debug "Cabinet opened" + if (state.ledNotificationTriggered) { + resetLEDNotification() + } + } +} + +// If the draw was NOT opened within 60 minutes of the timer send notification out. +def checkOpenDrawInPast(){ + log.debug "Checking past 60 minutes of activity from $reminderTime" + + // check activity of sensor for past 60 minutes for any OPENED status + def cabinetOpened = isOpened(state.minutesToCheckOpenDraw) + log.debug "Cabinet found opened: $cabinetOpened" + + // if it's opened, then do nothing and assume they took their meds + if (!cabinetOpened) { + sendNotification("Hi, please remember to take your meds in the cabinet") + + // if no open activity, send out notification and set new reminder + def reminderTimePlus10 = new Date(now() + (10 * 60000)) + + // needs to be scheduled if draw wasn't already opened + runOnce(reminderTimePlus10, checkOpenDrawAfterReminder) + } +} + +// If the draw was NOT opened after 10 minutes past reminder, use LED notification +def checkOpenDrawAfterReminder(){ + log.debug "Checking additional 10 minutes of activity from $reminderTime" + + // check activity of sensor for past 10 minutes for any OPENED status + def cabinetOpened = isOpened(10) + + log.debug "Cabinet found opened: $cabinetOpened" + + // if no open activity, blink lights + if (!cabinetOpened) { + log.debug "Set LED to Notification color" + setLEDNotification() + } + +} + +// Helper function for sending out an app notification +def sendNotification(msg){ + log.debug "Message Sent: $msg" + sendPush(msg) +} + +// Check if the sensor has been opened since the minutes entered +// Return true if opened found, else false. +def isOpened(minutes){ + // query last X minutes of activity log + def previousDateTime = new Date(now() - (minutes * 60000)) + + // capture all events recorded + def evts = deviceContactSensor.eventsSince(previousDateTime) + def cabinetOpened = false + if (evts.size() > 0) { + evts.each{ + if(it.value == "open") { + cabinetOpened = true + } + } + } + + return cabinetOpened +} + +// Saves current color and sets the light to RED +def setLEDNotification(){ + + state.ledNotificationTriggered = true + + // turn light back off when reset is called if it was originally off + state.ledState = deviceLight.currentValue("switch") + + // set light to RED and store original color until stopped + state.origColor = deviceLight.currentValue("hue") + deviceLight.on() + deviceLight.setHue(100) + + log.debug "LED set to RED. Original color stored: $state.origColor" + +} + +// Sets the color back to the original saved color +def resetLEDNotification(){ + + state.ledNotificationTriggered = false + + // return color to original + log.debug "Reset LED color to: $state.origColor" + if (state.origColor != null) { + deviceLight.setHue(state.origColor) + } + + // if the light was turned on just for the notification, turn it back off now + if (state.ledState == "off") { + deviceLight.off() + } + +} diff --git a/official/medicine-management-temp-motion.groovy b/official/medicine-management-temp-motion.groovy new file mode 100755 index 0000000..a047306 --- /dev/null +++ b/official/medicine-management-temp-motion.groovy @@ -0,0 +1,189 @@ +/** + * Medicine Management - Temp-Motion + * + * Copyright 2016 Jim Mangione + * + * 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. + * + * Logic: + * --- If temp > threshold set, send notification + * --- Send in-app notification at the medicine reminder time if no motion is detected in past 60 minutes + * --- If motion still isn't detected 10 minutes AFTER reminder time, LED will turn RED + * --- ----- Once motion is detected, LED will turn back to it's original color + */ +import groovy.time.TimeCategory + +definition( + name: "Medicine Management - Temp-Motion", + namespace: "MangioneImagery", + author: "Jim Mangione", + description: "This only supports devices with capabilities TemperatureMeasurement, AccelerationSensor and ColorControl (LED). Supports two use cases. First, will notifies via in-app if the fridge where meds are stored exceeds a temperature threshold set in degrees. Secondly, sends an in-app and ambient light notification if you forget to take your meds by sensing movement of the medicine box in the fridge. A reminder will be set to a single time per day. If the box isn't moved within 60 minutes of that reminder, an in-app message will be sent. If the box still isn't moved after an additional 10 minutes, then an LED light turns red until the box is moved", + category: "Health & Wellness", + 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 { + + section("My Medicine in the Refrigerator"){ + input "deviceAccelerationSensor", "capability.accelerationSensor", required: true, multiple: false, title: "Movement" + input "deviceTemperatureMeasurement", "capability.temperatureMeasurement", required: true, multiple: false, title: "Temperature" + } + + section("Temperature Threshold"){ + input "tempThreshold", "number", title: "Temperature Threshold" + } + + section("Remind me to take my medicine at"){ + input "reminderTime", "time", title: "Time" + } + + // NOTE: Use REAL device - virtual device causes compilation errors + section("My LED Light"){ + input "deviceLight", "capability.colorControl", title: "Smart light" + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + + initialize() +} + +def initialize() { + // will notify when temp exceeds max + subscribe(deviceTemperatureMeasurement, "temperature", tempHandler) + + // will stop LED notification incase it was set by med reminder + subscribe(deviceAccelerationSensor, "acceleration.active", motionHandler) + + // how many minutes to look in the past from the reminder time + state.minutesToCheckPriorToReminder = 60 + + // Set a timer to run once a day to notify if draw wasn't opened yet + schedule(reminderTime, checkMotionInPast) +} + + +// If temp > 39 then send an app notification out. +def tempHandler(evt){ + if (evt.doubleValue > tempThreshold) { + log.debug "Fridge temp of $evt.value exceeded threshold" + sendNotification("WARNING: Fridge temp is $evt.value with threshold of $tempThreshold") + } +} + +// Should turn off any LED notification once motion detected +def motionHandler(evt){ + // always call out to stop any possible LED notification + log.debug "Medication moved. Send stop LED notification" + resetLEDNotification() +} + +// If no motion detected within 60 minutes of the timer send notification out. +def checkMotionInPast(){ + log.debug "Checking past 60 minutes of activity from $reminderTime" + + // check activity of sensor for past 60 minutes for any OPENED status + def movement = isMoved(state.minutesToCheckPriorToReminder) + log.debug "Motion found: $movement" + + // if there was movement, then do nothing and assume they took their meds + if (!movement) { + sendNotification("Hi, please remember to take your meds in the fridge") + + // if no movement, send out notification and set new reminder + def reminderTimePlus10 = new Date(now() + (10 * 60000)) + + // needs to be scheduled if draw wasn't already opened + runOnce(reminderTimePlus10, checkMotionAfterReminder) + } +} + +// If still no movement after 10 minutes past reminder, use LED notification +def checkMotionAfterReminder(){ + log.debug "Checking additional 10 minutes of activity from $reminderTime" + + // check activity of sensor for past 10 minutes for any OPENED status + def movement = isMoved(10) + + log.debug "Motion found: $movement" + + // if no open activity, blink lights + if (!movement) { + log.debug "Notify LED API" + setLEDNotification() + } + +} + +// Helper function for sending out an app notification +def sendNotification(msg){ + log.debug "Message Sent: $msg" + sendPush(msg) +} + +// Check if the accelerometer has been activated since the minutes entered +// Return true if active, else false. +def isMoved(minutes){ + // query last X minutes of activity log + def previousDateTime = new Date(now() - (minutes * 60000)) + + // capture all events recorded + def evts = deviceAccelerationSensor.eventsSince(previousDateTime) + def motion = false + if (evts.size() > 0) { + evts.each{ + if(it.value == "active") { + motion = true + } + } + } + + return motion +} + +// Saves current color and sets the light to RED +def setLEDNotification(){ + + // turn light back off when reset is called if it was originally off + state.ledState = deviceLight.currentValue("switch") + + // set light to RED and store original color until stopped + state.origColor = deviceLight.currentValue("hue") + deviceLight.on() + deviceLight.setHue(100) + + log.debug "LED set to RED. Original color stored: $state.origColor" + +} + +// Sets the color back to the original saved color +def resetLEDNotification(){ + + // return color to original + log.debug "Reset LED color to: $state.origColor" + deviceLight.setHue(state.origColor) + + // if the light was turned on just for the notification, turn it back off now + if (state.ledState == "off") { + deviceLight.off() + } +} \ No newline at end of file diff --git a/official/medicine-reminder.groovy b/official/medicine-reminder.groovy new file mode 100755 index 0000000..7c0f693 --- /dev/null +++ b/official/medicine-reminder.groovy @@ -0,0 +1,124 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Medicine Reminder + * + * Author: SmartThings + */ + +definition( + name: "Medicine Reminder", + namespace: "smartthings", + author: "SmartThings", + description: "Set up a reminder so that if you forget to take your medicine (determined by whether a cabinet or drawer has been opened) by specified time you get a notification or text message.", + category: "Health & Wellness", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/text_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text_contact@2x.png" +) + +preferences { + section("Choose your medicine cabinet..."){ + input "cabinet1", "capability.contactSensor", title: "Where?" + } + section("Take my medicine at..."){ + input "time1", "time", title: "Time 1" + input "time2", "time", title: "Time 2", required: false + input "time3", "time", title: "Time 3", required: false + input "time4", "time", title: "Time 4", required: false + } + section("I forget send me a notification and/or text message..."){ + input("recipients", "contact", title: "Send notifications to") { + input "sendPush", "enum", title: "Push Notification", required: false, options: ["Yes", "No"] + input "phone1", "phone", title: "Phone Number", required: false + } + } + section("Time window (optional, defaults to plus or minus 15 minutes") { + input "timeWindow", "decimal", title: "Minutes", required: false + } +} + +def installed() +{ + initialize() +} + +def updated() +{ + unschedule() + initialize() +} + +def initialize() { + def window = timeWindowMsec + [time1, time2, time3, time4].eachWithIndex {time, index -> + if (time != null) { + def endTime = new Date(timeToday(time, location?.timeZone).time + window) + log.debug "Scheduling check at $endTime" + //runDaily(endTime, "scheduleCheck${index}") + switch (index) { + case 0: + schedule(endTime, scheduleCheck0) + break + case 1: + schedule(endTime, scheduleCheck1) + break + case 2: + schedule(endTime, scheduleCheck2) + break + case 3: + schedule(endTime, scheduleCheck3) + break + } + } + } +} + +def scheduleCheck0() { scheduleCheck() } +def scheduleCheck1() { scheduleCheck() } +def scheduleCheck2() { scheduleCheck() } +def scheduleCheck3() { scheduleCheck() } + +def scheduleCheck() +{ + log.debug "scheduleCheck" + def t0 = new Date(now() - (2 * timeWindowMsec)) + def t1 = new Date() + def cabinetOpened = cabinet1.eventsBetween(t0, t1).find{it.name == "contact" && it.value == "open"} + log.trace "Looking for events between $t0 and $t1: $cabinetOpened" + + if (cabinetOpened) { + log.trace "Medicine cabinet was opened since $midnight, no notification required" + } else { + log.trace "Medicine cabinet was not opened since $midnight, sending notification" + sendMessage() + } +} + +private sendMessage() { + def msg = "Please remember to take your medicine" + log.info msg + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + if (phone1) { + sendSms(phone1, msg) + } + if (sendPush == "Yes") { + sendPush(msg) + } + } +} + +def getTimeWindowMsec() { + (timeWindow ?: 15) * 60000 as Long +} diff --git a/official/mini-hue-controller.groovy b/official/mini-hue-controller.groovy new file mode 100755 index 0000000..051022b --- /dev/null +++ b/official/mini-hue-controller.groovy @@ -0,0 +1,160 @@ +/** + * Mini Hue Controller + * + * Copyright 2014 SmartThings + * + * 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: "Mini Hue Controller", + namespace: "smartthings", + author: "SmartThings", + description: "Control one or more Hue bulbs using an Aeon MiniMote.", + category: "SmartThings Labs", + 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 { + section("Control these lights") { + input "bulbs", "capability.colorControl", title: "Hue light bulbs", multiple: true + } + section("Using this controller") { + input "controller", "capability.button", title: "Aeon minimote" + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + state.colorIndex = -1 + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + subscribe controller, "button", buttonHandler +} + +def buttonHandler(evt) { + switch(evt.jsonData?.buttonNumber) { + case 2: + if (evt.value == "held") { + bulbs.setLevel(100) + } + else { + levelUp() + } + break + + case 3: + if (evt.value == "held") { + def color = [name:"Soft White", hue: 23, saturation: 56] + bulbs.setColor(hue: color.hue, saturation: color.saturation) + } + else { + changeColor() + } + break + + case 4: + if (evt.value == "held") { + bulbs.setLevel(10) + } + else { + levelDown() + } + break + + default: + toggleState() + break + } +} + +private toggleState() { + if (currentSwitchState == "on") { + log.debug "off" + bulbs.off() + } + else { + log.debug "on" + bulbs.on() + } +} + +private levelUp() { + def level = Math.min(currentSwitchLevel + 10, 100) + log.debug "level = $level" + bulbs.setLevel(level) +} + +private levelDown() { + def level = Math.max(currentSwitchLevel - 10, 10) + log.debug "level = $level" + bulbs.setLevel(level) +} + +private changeColor() { + + final colors = [ + [name:"Soft White", hue: 23, saturation: 56], + [name:"Daylight", hue: 53, saturation: 91], + [name:"White", hue: 52, saturation: 19], + [name:"Warm White", hue: 20, saturation: 80], + [name:"Blue", hue: 70, saturation: 100], + [name:"Green", hue: 39, saturation: 100], + [name:"Yellow", hue: 25, saturation: 100], + [name:"Orange", hue: 10, saturation: 100], + [name:"Purple", hue: 75, saturation: 100], + [name:"Pink", hue: 83, saturation: 100], + [name:"Red", hue: 100, saturation: 100] + ] + + final maxIndex = colors.size() - 1 + + if (state.colorIndex < maxIndex) { + state.colorIndex = state.colorIndex + 1 + } + else { + state.colorIndex = 0 + } + + def color = colors[state.colorIndex] + bulbs.setColor(hue: color.hue, saturation: color.saturation) +} + +private getCurrentSwitchState() { + def on = 0 + def off = 0 + bulbs.each { + if (it.currentValue("switch") == "on") { + on++ + } + else { + off++ + } + } + on > off ? "on" : "off" +} + +private getCurrentSwitchLevel() { + def level = 0 + bulbs.each { + level = Math.max(it.currentValue("level")?.toInteger() ?: 0, level) + } + level.toInteger() +} diff --git a/official/monitor-on-sense.groovy b/official/monitor-on-sense.groovy new file mode 100755 index 0000000..b547214 --- /dev/null +++ b/official/monitor-on-sense.groovy @@ -0,0 +1,49 @@ +/** + * Monitor on Sense + * + * Copyright 2014 Rachel Steele + * + * 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: "Monitor on Sense", + namespace: "resteele", + author: "Rachel Steele", + description: "Turn on switch when vibration is sensed", + 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", + oauth: [displayName: "Monitor on Vibrate", displayLink: ""]) + + +preferences { + section("When vibration is sensed...") { + input "accelerationSensor", "capability.accelerationSensor", title: "Which Sensor?" + } +section("Turn on switch...") { + input "switch1", "capability.switch" + } +} + + +def installed() { + subscribe(accelerationSensor, "acceleration.active", accelerationActiveHandler) +} + +def updated() { + unsubscribe() + subscribe(accelerationSensor, "acceleration.active", accelerationActiveHandler) +} + + +def accelerationActiveHandler(evt) { + switch1.on() + } diff --git a/official/mood-cube.groovy b/official/mood-cube.groovy new file mode 100755 index 0000000..4bc18b3 --- /dev/null +++ b/official/mood-cube.groovy @@ -0,0 +1,341 @@ +/** + * Mood Cube + * + * Copyright 2014 SmartThings, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ + +/************ + * Metadata * + ************/ +definition( + name: "Mood Cube", + namespace: "smartthings", + author: "SmartThings", + description: "Set your lighting by rotating a cube containing a SmartSense Multi", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld@2x.png" +) + +/********** + * Setup * + **********/ +preferences { + page(name: "mainPage", title: "", nextPage: "scenesPage", uninstall: true) { + section("Use the orientation of this cube") { + input "cube", "capability.threeAxis", required: false, title: "SmartSense Multi sensor" + } + section("To control these lights") { + input "lights", "capability.switch", multiple: true, required: false, title: "Lights, switches & dimmers" + } + section([title: " ", mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } + page(name: "scenesPage", title: "Scenes", install: true, uninstall: true) + page(name: "scenePage", title: "Scene", install: false, uninstall: false, previousPage: "scenesPage") + page(name: "devicePage", install: false, uninstall: false, previousPage: "scenePage") + page(name: "saveStatesPage", install: false, uninstall: false, previousPage: "scenePage") +} + + +def scenesPage() { + log.debug "scenesPage()" + def sceneId = getOrientation() + dynamicPage(name:"scenesPage") { + section { + for (num in 1..6) { + href "scenePage", title: "${num}. ${sceneName(num)}${sceneId==num ? ' (current)' : ''}", params: [sceneId:num], description: "", state: sceneIsDefined(num) ? "complete" : "incomplete" + } + } + section { + href "scenesPage", title: "Refresh", description: "" + } + } +} + +def scenePage(params=[:]) { + log.debug "scenePage($params)" + def currentSceneId = getOrientation() + def sceneId = params.sceneId as Integer ?: state.lastDisplayedSceneId + state.lastDisplayedSceneId = sceneId + dynamicPage(name:"scenePage", title: "${sceneId}. ${sceneName(sceneId)}") { + section { + input "sceneName${sceneId}", "text", title: "Scene Name", required: false + } + + section { + href "devicePage", title: "Show Device States", params: [sceneId:sceneId], description: "", state: sceneIsDefined(sceneId) ? "complete" : "incomplete" + } + + section { + href "saveStatesPage", title: "Record Current Device States", params: [sceneId:sceneId], description: "" + } + } +} + +def devicePage(params) { + log.debug "devicePage($params)" + + getDeviceCapabilities() + + def sceneId = params.sceneId as Integer ?: state.lastDisplayedSceneId + + dynamicPage(name:"devicePage", title: "${sceneId}. ${sceneName(sceneId)} Device States") { + section("Lights") { + lights.each {light -> + input "onoff_${sceneId}_${light.id}", "boolean", title: light.displayName + } + } + + section("Dimmers") { + lights.each {light -> + if (state.lightCapabilities[light.id] in ["level", "color"]) { + input "level_${sceneId}_${light.id}", "enum", title: light.displayName, options: levels, description: "", required: false + } + } + } + + section("Colors (hue/saturation)") { + lights.each {light -> + if (state.lightCapabilities[light.id] == "color") { + input "color_${sceneId}_${light.id}", "text", title: light.displayName, description: "", required: false + } + } + } + } +} + +def saveStatesPage(params) { + saveStates(params) + devicePage(params) +} + + +/************************* + * Installation & update * + *************************/ +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe cube, "threeAxis", positionHandler +} + + +/****************** + * Event handlers * + ******************/ +def positionHandler(evt) { + + def sceneId = getOrientation(evt.xyzValue) + log.trace "orientation: $sceneId" + + if (sceneId != state.lastActiveSceneId) { + restoreStates(sceneId) + } + else { + log.trace "No status change" + } + state.lastActiveSceneId = sceneId +} + + +/****************** + * Helper methods * + ******************/ +private Boolean sceneIsDefined(sceneId) { + def tgt = "onoff_${sceneId}".toString() + settings.find{it.key.startsWith(tgt)} != null +} + +private updateSetting(name, value) { + app.updateSetting(name, value) + settings[name] = value +} + +private closestLevel(level) { + level ? "${Math.round(level/5) * 5}%" : "0%" +} + +private saveStates(params) { + log.trace "saveStates($params)" + def sceneId = params.sceneId as Integer + getDeviceCapabilities() + + lights.each {light -> + def type = state.lightCapabilities[light.id] + + updateSetting("onoff_${sceneId}_${light.id}", light.currentValue("switch") == "on") + + if (type == "level") { + updateSetting("level_${sceneId}_${light.id}", closestLevel(light.currentValue('level'))) + } + else if (type == "color") { + updateSetting("level_${sceneId}_${light.id}", closestLevel(light.currentValue('level'))) + updateSetting("color_${sceneId}_${light.id}", "${light.currentValue("hue")}/${light.currentValue("saturation")}") + } + } +} + + +private restoreStates(sceneId) { + log.trace "restoreStates($sceneId)" + getDeviceCapabilities() + + lights.each {light -> + def type = state.lightCapabilities[light.id] + + def isOn = settings."onoff_${sceneId}_${light.id}" == "true" ? true : false + log.debug "${light.displayName} is '$isOn'" + if (isOn) { + light.on() + } + else { + light.off() + } + + if (type != "switch") { + def level = switchLevel(sceneId, light) + + if (type == "level") { + log.debug "${light.displayName} level is '$level'" + if (level != null) { + light.setLevel(level) + } + } + else if (type == "color") { + def segs = settings."color_${sceneId}_${light.id}"?.split("/") + if (segs?.size() == 2) { + def hue = segs[0].toInteger() + def saturation = segs[1].toInteger() + log.debug "${light.displayName} color is level: $level, hue: $hue, sat: $saturation" + if (level != null) { + light.setColor(level: level, hue: hue, saturation: saturation) + } + else { + light.setColor(hue: hue, saturation: saturation) + } + } + else { + log.debug "${light.displayName} level is '$level'" + if (level != null) { + light.setLevel(level) + } + } + } + else { + log.error "Unknown type '$type'" + } + } + + + } +} + +private switchLevel(sceneId, light) { + def percent = settings."level_${sceneId}_${light.id}" + if (percent) { + percent[0..-2].toInteger() + } + else { + null + } +} + +private getDeviceCapabilities() { + def caps = [:] + lights.each { + if (it.hasCapability("Color Control")) { + caps[it.id] = "color" + } + else if (it.hasCapability("Switch Level")) { + caps[it.id] = "level" + } + else { + caps[it.id] = "switch" + } + } + state.lightCapabilities = caps +} + +private getLevels() { + def levels = [] + for (int i = 0; i <= 100; i += 5) { + levels << "$i%" + } + levels +} + +private getOrientation(xyz=null) { + final threshold = 250 + + def value = xyz ?: cube.currentValue("threeAxis") + + def x = Math.abs(value.x) > threshold ? (value.x > 0 ? 1 : -1) : 0 + def y = Math.abs(value.y) > threshold ? (value.y > 0 ? 1 : -1) : 0 + def z = Math.abs(value.z) > threshold ? (value.z > 0 ? 1 : -1) : 0 + + def orientation = 0 + if (z > 0) { + if (x == 0 && y == 0) { + orientation = 1 + } + } + else if (z < 0) { + if (x == 0 && y == 0) { + orientation = 2 + } + } + else { + if (x > 0) { + if (y == 0) { + orientation = 3 + } + } + else if (x < 0) { + if (y == 0) { + orientation = 4 + } + } + else { + if (y > 0) { + orientation = 5 + } + else if (y < 0) { + orientation = 6 + } + } + } + + orientation +} + +private sceneName(num) { + final names = ["UNDEFINED","One","Two","Three","Four","Five","Six"] + settings."sceneName${num}" ?: "Scene ${names[num]}" +} + + + diff --git a/official/my-light-toggle.groovy b/official/my-light-toggle.groovy new file mode 100755 index 0000000..5696913 --- /dev/null +++ b/official/my-light-toggle.groovy @@ -0,0 +1,76 @@ +/** + * My Light Toggle + * + * Copyright 2015 Jesse Silverberg + * + * 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: "My Light Toggle", + namespace: "JLS", + author: "Jesse Silverberg", + description: "Toggle lights on/off with a motion sensor", + category: "Convenience", + iconUrl: "https://www.dropbox.com/s/6kxtd2v5reggonq/lightswitch.gif?raw=1", + iconX2Url: "https://www.dropbox.com/s/6kxtd2v5reggonq/lightswitch.gif?raw=1", + iconX3Url: "https://www.dropbox.com/s/6kxtd2v5reggonq/lightswitch.gif?raw=1") + + +preferences { + section("When this sensor detects motion...") { + input "motionToggler", "capability.motionSensor", title: "Motion Here", required: true, multiple: false + } + + section("Master switch for the toggle reference...") { + input "masterToggle", "capability.switch", title: "Reference switch", required: true, multiple: false + } + + section("Toggle lights...") { + input "switchesToToggle", "capability.switch", title: "These go on/off", required: true, multiple: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(motionToggler, "motion", toggleSwitches) +} + + +def toggleSwitches(evt) { + log.debug "$evt.value" + + if (evt.value == "active" && masterToggle.currentSwitch == "off") { +// for (thisSwitch in switchesToToggle) { +// log.debug "$thisSwitch.label" +// thisSwitch.on() + switchesToToggle.on() + masterToggle.on() + } else if (evt.value == "active" && masterToggle.currentSwitch == "on") { +// for (thisSwitch in switchesToToggle) { +// log.debug "$thisSwitch.label" +// thisSwitch.off() + switchesToToggle.off() + masterToggle.off() + } + +} \ No newline at end of file diff --git a/official/netatmo-connect.groovy b/official/netatmo-connect.groovy new file mode 100755 index 0000000..3a7d663 --- /dev/null +++ b/official/netatmo-connect.groovy @@ -0,0 +1,555 @@ +/** + * Netatmo Connect + */ +import java.text.DecimalFormat +import groovy.json.JsonSlurper + +private getApiUrl() { "https://api.netatmo.com" } +private getVendorAuthPath() { "${apiUrl}/oauth2/authorize?" } +private getVendorTokenPath(){ "${apiUrl}/oauth2/token" } +private getVendorIcon() { "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png" } +private getClientId() { appSettings.clientId } +private getClientSecret() { appSettings.clientSecret } +private getServerUrl() { appSettings.serverUrl } +private getShardUrl() { return getApiServerUrl() } +private getCallbackUrl() { "${serverUrl}/oauth/callback" } +private getBuildRedirectUrl() { "${serverUrl}/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${shardUrl}" } + +definition( + name: "Netatmo (Connect)", + namespace: "dianoga", + author: "Brian Steere", + description: "Integrate your Netatmo devices with SmartThings", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/netamo-icon-1%402x.png", + oauth: true, + singleInstance: true +){ + appSetting "clientId" + appSetting "clientSecret" + appSetting "serverUrl" +} + +preferences { + page(name: "Credentials", title: "Fetch OAuth2 Credentials", content: "authPage", install: false) + page(name: "listDevices", title: "Netatmo Devices", content: "listDevices", install: false) +} + +mappings { + path("/oauth/initialize") {action: [GET: "oauthInitUrl"]} + path("/oauth/callback") {action: [GET: "callback"]} +} + +def authPage() { + // log.debug "running authPage()" + + def description + def uninstallAllowed = false + def oauthTokenProvided = false + + // If an access token doesn't exist, create one + if (!atomicState.accessToken) { + atomicState.accessToken = createAccessToken() + log.debug "Created access token" + } + + if (canInstallLabs()) { + + def redirectUrl = getBuildRedirectUrl() + // log.debug "Redirect url = ${redirectUrl}" + + if (atomicState.authToken) { + description = "Tap 'Next' to select devices" + uninstallAllowed = true + oauthTokenProvided = true + } else { + description = "Tap to enter credentials" + } + + if (!oauthTokenProvided) { + log.debug "Showing the login page" + return dynamicPage(name: "Credentials", title: "Authorize Connection", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) { + section() { + paragraph "Tap below to login to Netatmo and authorize SmartThings access" + href url:redirectUrl, style:"embedded", required:false, title:"Connect to Netatmo", description:description + } + } + } else { + log.debug "Showing the devices page" + return dynamicPage(name: "Credentials", title: "Connected", nextPage:"listDevices", uninstall: uninstallAllowed, install:false) { + section() { + input(name:"Devices", style:"embedded", required:false, title:"Netatmo is connected to SmartThings", description:description) + } + } + } + } else { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + return dynamicPage(name:"Credentials", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section { + paragraph "$upgradeNeeded" + } + } + + } +} + +def oauthInitUrl() { + // log.debug "runing oauthInitUrl()" + + atomicState.oauthInitState = UUID.randomUUID().toString() + + def oauthParams = [ + response_type: "code", + client_id: getClientId(), + client_secret: getClientSecret(), + state: atomicState.oauthInitState, + redirect_uri: getCallbackUrl(), + scope: "read_station" + ] + + // log.debug "REDIRECT URL: ${getVendorAuthPath() + toQueryString(oauthParams)}" + + redirect (location: getVendorAuthPath() + toQueryString(oauthParams)) +} + +def callback() { + // log.debug "running callback()" + + def code = params.code + def oauthState = params.state + + if (oauthState == atomicState.oauthInitState) { + + def tokenParams = [ + grant_type: "authorization_code", + client_secret: getClientSecret(), + client_id : getClientId(), + code: code, + scope: "read_station", + redirect_uri: getCallbackUrl() + ] + + // log.debug "TOKEN URL: ${getVendorTokenPath() + toQueryString(tokenParams)}" + + def tokenUrl = getVendorTokenPath() + def requestTokenParams = [ + uri: tokenUrl, + requestContentType: 'application/x-www-form-urlencoded', + body: tokenParams + ] + + // log.debug "PARAMS: ${requestTokenParams}" + + try { + httpPost(requestTokenParams) { resp -> + //log.debug "Data: ${resp.data}" + atomicState.refreshToken = resp.data.refresh_token + atomicState.authToken = resp.data.access_token + // resp.data.expires_in is in milliseconds so we need to convert it to seconds + atomicState.tokenExpires = now() + (resp.data.expires_in * 1000) + } + } catch (e) { + log.debug "callback() failed: $e" + } + + // If we successfully got an authToken run sucess(), else fail() + if (atomicState.authToken) { + success() + } else { + fail() + } + + } else { + log.error "callback() failed oauthState != atomicState.oauthInitState" + } +} + +def success() { + log.debug "OAuth flow succeeded" + def message = """ +

Success!

+

Tap 'Done' to continue

+ """ + connectionStatus(message) +} + +def fail() { + log.debug "OAuth flow failed" + atomicState.authToken = null + def message = """ +

Error

+

Tap 'Done' to return

+ """ + connectionStatus(message) +} + +def connectionStatus(message, redirectUrl = null) { + def redirectHtml = "" + if (redirectUrl) { + redirectHtml = """ + + """ + } + def html = """ + + + + + Netatmo Connection + + + +
+ Vendor icon + connected device icon + SmartThings logo + ${message} +
+ + + """ + render contentType: 'text/html', data: html +} + +def refreshToken() { + // Check if atomicState has a refresh token + if (atomicState.refreshToken) { + log.debug "running refreshToken()" + + def oauthParams = [ + grant_type: "refresh_token", + refresh_token: atomicState.refreshToken, + client_secret: getClientSecret(), + client_id: getClientId(), + ] + + def tokenUrl = getVendorTokenPath() + + def requestOauthParams = [ + uri: tokenUrl, + requestContentType: 'application/x-www-form-urlencoded', + body: oauthParams + ] + + // log.debug "PARAMS: ${requestOauthParams}" + + try { + httpPost(requestOauthParams) { resp -> + //log.debug "Data: ${resp.data}" + atomicState.refreshToken = resp.data.refresh_token + atomicState.authToken = resp.data.access_token + // resp.data.expires_in is in milliseconds so we need to convert it to seconds + atomicState.tokenExpires = now() + (resp.data.expires_in * 1000) + return true + } + } catch (e) { + log.debug "refreshToken() failed: $e" + } + + // If we didn't get an authToken + if (!atomicState.authToken) { + return false + } + } else { + return false + } +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + log.debug "Initialized with settings: ${settings}" + + // Pull the latest device info into state + getDeviceList() + + settings.devices.each { + def deviceId = it + def detail = state?.deviceDetail[deviceId] + + try { + switch(detail?.type) { + case 'NAMain': + log.debug "Base station" + createChildDevice("Netatmo Basestation", deviceId, "${detail.type}.${deviceId}", detail.module_name) + break + case 'NAModule1': + log.debug "Outdoor module" + createChildDevice("Netatmo Outdoor Module", deviceId, "${detail.type}.${deviceId}", detail.module_name) + break + case 'NAModule3': + log.debug "Rain Gauge" + createChildDevice("Netatmo Rain", deviceId, "${detail.type}.${deviceId}", detail.module_name) + break + case 'NAModule4': + log.debug "Additional module" + createChildDevice("Netatmo Additional Module", deviceId, "${detail.type}.${deviceId}", detail.module_name) + break + } + } catch (Exception e) { + log.error "Error creating device: ${e}" + } + } + + // Cleanup any other devices that need to go away + def delete = getChildDevices().findAll { !settings.devices.contains(it.deviceNetworkId) } + log.debug "Delete: $delete" + delete.each { deleteChildDevice(it.deviceNetworkId) } + + // Run initial poll and schedule future polls + poll() + runEvery5Minutes("poll") +} + +def uninstalled() { + log.debug "Uninstalling" + removeChildDevices(getChildDevices()) +} + +def getDeviceList() { + if (atomicState.authToken) { + + log.debug "Getting stations data" + + def deviceList = [:] + state.deviceDetail = [:] + state.deviceState = [:] + + apiGet("/api/getstationsdata") { resp -> + resp.data.body.devices.each { value -> + def key = value._id + deviceList[key] = "${value.station_name}: ${value.module_name}" + state.deviceDetail[key] = value + state.deviceState[key] = value.dashboard_data + value.modules.each { value2 -> + def key2 = value2._id + deviceList[key2] = "${value.station_name}: ${value2.module_name}" + state.deviceDetail[key2] = value2 + state.deviceState[key2] = value2.dashboard_data + } + } + } + + return deviceList.sort() { it.value.toLowerCase() } + + } else { + return null + } +} + +private removeChildDevices(delete) { + log.debug "Removing ${delete.size()} devices" + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} + +def createChildDevice(deviceFile, dni, name, label) { + try { + def existingDevice = getChildDevice(dni) + if(!existingDevice) { + log.debug "Creating child" + def childDevice = addChildDevice("dianoga", deviceFile, dni, null, [name: name, label: label, completedSetup: true]) + } else { + log.debug "Device $dni already exists" + } + } catch (e) { + log.error "Error creating device: ${e}" + } +} + +def listDevices() { + log.debug "Listing devices" + + def devices = getDeviceList() + + dynamicPage(name: "listDevices", title: "Choose Devices", install: true) { + section("Devices") { + input "devices", "enum", title: "Select Devices", required: false, multiple: true, options: devices + } + + section("Preferences") { + input "rainUnits", "enum", title: "Rain Units", description: "Millimeters (mm) or Inches (in)", required: true, options: [mm:'Millimeters', in:'Inches'] + } + } +} + +def apiGet(String path, Map query, Closure callback) { + log.debug "running apiGet()" + + // If the current time is over the expiration time, request a new token + if(now() >= atomicState.tokenExpires) { + atomicState.authToken = null + refreshToken() + } + + def queryParam = [ + access_token: atomicState.authToken + ] + + def apiGetParams = [ + uri: getApiUrl(), + path: path, + query: queryParam + ] + + // log.debug "apiGet(): $apiGetParams" + + try { + httpGet(apiGetParams) { resp -> + callback.call(resp) + } + } catch (e) { + log.debug "apiGet() failed: $e" + // Netatmo API has rate limits so a failure here doesn't necessarily mean our token has expired, but we will check anyways + if(now() >= atomicState.tokenExpires) { + atomicState.authToken = null + refreshToken() + } + } +} + +def apiGet(String path, Closure callback) { + apiGet(path, [:], callback); +} + +def poll() { + log.debug "Polling..." + + getDeviceList() + + def children = getChildDevices() + //log.debug "State: ${state.deviceState}" + + settings.devices.each { deviceId -> + def detail = state?.deviceDetail[deviceId] + def data = state?.deviceState[deviceId] + def child = children?.find { it.deviceNetworkId == deviceId } + + log.debug "Update: $child"; + switch(detail?.type) { + case 'NAMain': + log.debug "Updating NAMain $data" + child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale()) + child?.sendEvent(name: 'carbonDioxide', value: data['CO2']) + child?.sendEvent(name: 'humidity', value: data['Humidity']) + child?.sendEvent(name: 'pressure', value: data['Pressure']) + child?.sendEvent(name: 'noise', value: data['Noise']) + break; + case 'NAModule1': + log.debug "Updating NAModule1 $data" + child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale()) + child?.sendEvent(name: 'humidity', value: data['Humidity']) + break; + case 'NAModule3': + log.debug "Updating NAModule3 $data" + child?.sendEvent(name: 'rain', value: rainToPref(data['Rain']) as float, unit: settings.rainUnits) + child?.sendEvent(name: 'rainSumHour', value: rainToPref(data['sum_rain_1']) as float, unit: settings.rainUnits) + child?.sendEvent(name: 'rainSumDay', value: rainToPref(data['sum_rain_24']) as float, unit: settings.rainUnits) + child?.sendEvent(name: 'units', value: settings.rainUnits) + break; + case 'NAModule4': + log.debug "Updating NAModule4 $data" + child?.sendEvent(name: 'temperature', value: cToPref(data['Temperature']) as float, unit: getTemperatureScale()) + child?.sendEvent(name: 'carbonDioxide', value: data['CO2']) + child?.sendEvent(name: 'humidity', value: data['Humidity']) + break; + } + } +} + +def cToPref(temp) { + if(getTemperatureScale() == 'C') { + return temp + } else { + return temp * 1.8 + 32 + } +} + +def rainToPref(rain) { + if(settings.rainUnits == 'mm') { + return rain + } else { + return rain * 0.039370 + } +} + +def debugEvent(message, displayEvent) { + def results = [ + name: "appdebug", + descriptionText: message, + displayed: displayEvent + ] + log.debug "Generating AppDebug Event: ${results}" + sendEvent(results) +} + +private Boolean canInstallLabs() { + return hasAllHubsOver("000.011.00603") +} + +private Boolean hasAllHubsOver(String desiredFirmware) { + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +private List getRealHubFirmwareVersions() { + return location.hubs*.firmwareVersionString.findAll { it } +} \ No newline at end of file diff --git a/official/nfc-tag-toggle.groovy b/official/nfc-tag-toggle.groovy new file mode 100755 index 0000000..497be41 --- /dev/null +++ b/official/nfc-tag-toggle.groovy @@ -0,0 +1,143 @@ +/** + * NFC Tag Toggle + * + * Copyright 2014 SmartThings + * + * 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: "NFC Tag Toggle", + namespace: "smartthings", + author: "SmartThings", + description: "Allows toggling of a switch, lock, or garage door based on an NFC Tag touch event", + category: "SmartThings Internal", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Developers/nfc-tag-executor.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Developers/nfc-tag-executor@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Developers/nfc-tag-executor@2x.png") + + +preferences { + page(name: "pageOne", title: "Device selection", uninstall: true, nextPage: "pageTwo") { + section("Select an NFC tag") { + input "tag", "capability.touchSensor", title: "NFC Tag" + } + section("Select devices to control") { + input "switch1", "capability.switch", title: "Light or switch", required: false, multiple: true + input "lock", "capability.lock", title: "Lock", required: false, multiple: true + input "garageDoor", "capability.doorControl", title: "Garage door controller", required: false, multiple: true + } + } + + page(name: "pageTwo", title: "Master devices", install: true, uninstall: true) +} + +def pageTwo() { + dynamicPage(name: "pageTwo") { + section("If set, the state of these devices will be toggled each time the tag is touched, " + + "e.g. a light that's on will be turned off and one that's off will be turned on, " + + "other devices of the same type will be set to the same state as their master device. " + + "If no master is designated then the majority of devices of the same type will be used " + + "to determine whether to turn on or off the devices.") { + + if (switch1 || masterSwitch) { + input "masterSwitch", "enum", title: "Master switch", options: switch1.collect{[(it.id): it.displayName]}, required: false + } + if (lock || masterLock) { + input "masterLock", "enum", title: "Master lock", options: lock.collect{[(it.id): it.displayName]}, required: false + } + if (garageDoor || masterDoor) { + input "masterDoor", "enum", title: "Master door", options: garageDoor.collect{[(it.id): it.displayName]}, required: false + } + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe tag, "nfcTouch", touchHandler + subscribe app, touchHandler +} + +private currentStatus(devices, master, attribute) { + log.trace "currentStatus($devices, $master, $attribute)" + def result = null + if (master) { + result = devices.find{it.id == master}?.currentValue(attribute) + } + else { + def map = [:] + devices.each { + def value = it.currentValue(attribute) + map[value] = (map[value] ?: 0) + 1 + log.trace "$it.displayName: $value" + } + log.trace map + result = map.collect{it}.sort{it.value}[-1].key + } + log.debug "$attribute = $result" + result +} + +def touchHandler(evt) { + log.trace "touchHandler($evt.descriptionText)" + if (switch1) { + def status = currentStatus(switch1, masterSwitch, "switch") + switch1.each { + if (status == "on") { + it.off() + } + else { + it.on() + } + } + } + + if (lock) { + def status = currentStatus(lock, masterLock, "lock") + lock.each { + if (status == "locked") { + lock.unlock() + } + else { + lock.lock() + } + } + } + + if (garageDoor) { + def status = currentStatus(garageDoor, masterDoor, "status") + garageDoor.each { + if (status == "open") { + it.close() + } + else { + it.open() + } + } + } +} diff --git a/official/nobody-home.groovy b/official/nobody-home.groovy new file mode 100755 index 0000000..2588ea1 --- /dev/null +++ b/official/nobody-home.groovy @@ -0,0 +1,164 @@ +/** + * Nobody Home + * + * Author: brian@bevey.org + * Date: 12/19/14 + * + * Monitors a set of presence detectors and triggers a mode change when everyone has left. + * When everyone has left, sets mode to a new defined mode. + * When at least one person returns home, set the mode back to a new defined mode. + * When someone is home - or upon entering the home, their mode may change dependent on sunrise / sunset. + */ + +definition( + name: "Nobody Home", + namespace: "imbrianj", + author: "brian@bevey.org", + description: "When everyone leaves, change mode. If at least one person home, switch mode based on sun position.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("When all of these people leave home") { + input "people", "capability.presenceSensor", multiple: true + } + + section("Change to this mode to...") { + input "newAwayMode", "mode", title: "Everyone is away" + input "newSunsetMode", "mode", title: "At least one person home and nightfall" + input "newSunriseMode", "mode", title: "At least one person home and sunrise" + } + + section("Away threshold (defaults to 10 min)") { + input "awayThreshold", "decimal", title: "Number of minutes", required: false + } + + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes","No"]], required:false + } +} + +def installed() { + init() +} + +def updated() { + unsubscribe() + init() +} + +def init() { + subscribe(people, "presence", presence) + subscribe(location, "sunrise", setSunrise) + subscribe(location, "sunset", setSunset) + + state.sunMode = location.mode +} + +def setSunrise(evt) { + changeSunMode(newSunriseMode) +} + +def setSunset(evt) { + changeSunMode(newSunsetMode) +} + +def changeSunMode(newMode) { + state.sunMode = newMode + + if(everyoneIsAway() && (location.mode == newAwayMode)) { + log.debug("Mode is away, not evaluating") + } + + else if(location.mode != newMode) { + def message = "${app.label} changed your mode to '${newMode}'" + send(message) + setLocationMode(newMode) + } + + else { + log.debug("Mode is the same, not evaluating") + } +} + +def presence(evt) { + if(evt.value == "not present") { + log.debug("Checking if everyone is away") + + if(everyoneIsAway()) { + log.info("Starting ${newAwayMode} sequence") + def delay = (awayThreshold != null && awayThreshold != "") ? awayThreshold * 60 : 10 * 60 + runIn(delay, "setAway") + } + } + + else { + if(location.mode != state.sunMode) { + log.debug("Checking if anyone is home") + + if(anyoneIsHome()) { + log.info("Starting ${state.sunMode} sequence") + + changeSunMode(state.sunMode) + } + } + + else { + log.debug("Mode is the same, not evaluating") + } + } +} + +def setAway() { + if(everyoneIsAway()) { + if(location.mode != newAwayMode) { + def message = "${app.label} changed your mode to '${newAwayMode}' because everyone left home" + log.info(message) + send(message) + setLocationMode(newAwayMode) + } + + else { + log.debug("Mode is the same, not evaluating") + } + } + + else { + log.info("Somebody returned home before we set to '${newAwayMode}'") + } +} + +private everyoneIsAway() { + def result = true + + if(people.findAll { it?.currentPresence == "present" }) { + result = false + } + + log.debug("everyoneIsAway: ${result}") + + return result +} + +private anyoneIsHome() { + def result = false + + if(people.findAll { it?.currentPresence == "present" }) { + result = true + } + + log.debug("anyoneIsHome: ${result}") + + return result +} + +private send(msg) { + if(sendPushMessage != "No") { + log.debug("Sending push message") + sendPush(msg) + } + + log.debug(msg) +} diff --git a/official/notify-me-when-it-opens.groovy b/official/notify-me-when-it-opens.groovy new file mode 100755 index 0000000..d305166 --- /dev/null +++ b/official/notify-me-when-it-opens.groovy @@ -0,0 +1,49 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Notify Me When It Opens + * + * Author: SmartThings + */ +definition( + name: "Notify Me When It Opens", + namespace: "smartthings", + author: "SmartThings", + description: "Get a push message sent to your phone when an open/close sensor is opened.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/window_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/window_contact@2x.png" +) + +preferences { + section("When the door opens..."){ + input "contact1", "capability.contactSensor", title: "Where?" + } +} + +def installed() +{ + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def updated() +{ + unsubscribe() + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def contactOpenHandler(evt) { + log.trace "$evt.value: $evt, $settings" + + log.debug "$contact1 was opened, sending push message to user" + sendPush("Your ${contact1.label ?: contact1.name} was opened") +} diff --git a/official/notify-me-when.groovy b/official/notify-me-when.groovy new file mode 100755 index 0000000..3b286a8 --- /dev/null +++ b/official/notify-me-when.groovy @@ -0,0 +1,163 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Notify Me When + * + * Author: SmartThings + * Date: 2013-03-20 + * + * Change Log: + * 1. Todd Wackford + * 2014-10-03: Added capability.button device picker and button.pushed event subscription. For Doorbell. + */ +definition( + name: "Notify Me When", + namespace: "smartthings", + author: "SmartThings", + description: "Receive notifications when anything happens in your home.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/window_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/window_contact@2x.png" +) + +preferences { + section("Choose one or more, when..."){ + input "button", "capability.button", title: "Button Pushed", required: false, multiple: true //tw + input "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + input "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + input "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + input "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + input "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + input "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + input "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + input "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + input "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + input "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + } + section("Send this message (optional, sends standard status message if not specified)"){ + input "messageText", "text", title: "Message Text", required: false + } + section("Via a push notification and/or an SMS message"){ + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Enter a phone number to get SMS", required: false + paragraph "If outside the US please make sure to enter the proper country code" + input "pushAndPhone", "enum", title: "Notify me via Push Notification", required: false, options: ["Yes", "No"] + } + } + section("Minimum time between messages (optional, defaults to every message)") { + input "frequency", "decimal", title: "Minutes", required: false + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(button, "button.pushed", eventHandler) //tw + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) +} + +def eventHandler(evt) { + log.debug "Notify got evt ${evt}" + if (frequency) { + def lastTime = state[evt.deviceId] + if (lastTime == null || now() - lastTime >= frequency * 60000) { + sendMessage(evt) + } + } + else { + sendMessage(evt) + } +} + +private sendMessage(evt) { + String msg = messageText + Map options = [:] + + if (!messageText) { + msg = defaultText(evt) + options = [translatable: true, triggerEvent: evt] + } + log.debug "$evt.name:$evt.value, pushAndPhone:$pushAndPhone, '$msg'" + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients, options) + } else { + if (phone) { + options.phone = phone + if (pushAndPhone != 'No') { + log.debug 'Sending push and SMS' + options.method = 'both' + } else { + log.debug 'Sending SMS' + options.method = 'phone' + } + } else if (pushAndPhone != 'No') { + log.debug 'Sending push' + options.method = 'push' + } else { + log.debug 'Sending nothing' + options.method = 'none' + } + sendNotification(msg, options) + } + if (frequency) { + state[evt.deviceId] = now() + } +} + +private defaultText(evt) { + if (evt.name == 'presence') { + if (evt.value == 'present') { + if (includeArticle) { + '{{ triggerEvent.linkText }} has arrived at the {{ location.name }}' + } + else { + '{{ triggerEvent.linkText }} has arrived at {{ location.name }}' + } + } else { + if (includeArticle) { + '{{ triggerEvent.linkText }} has left the {{ location.name }}' + } + else { + '{{ triggerEvent.linkText }} has left {{ location.name }}' + } + } + } else { + '{{ triggerEvent.descriptionText }}' + } +} + +private getIncludeArticle() { + def name = location.name.toLowerCase() + def segs = name.split(" ") + !(["work","home"].contains(name) || (segs.size() > 1 && (["the","my","a","an"].contains(segs[0]) || segs[0].endsWith("'s")))) +} diff --git a/official/notify-me-with-hue.groovy b/official/notify-me-with-hue.groovy new file mode 100755 index 0000000..8cfd296 --- /dev/null +++ b/official/notify-me-with-hue.groovy @@ -0,0 +1,198 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Notify Me With Hue + * + * Author: SmartThings + * Date: 2014-01-20 + */ +definition( + name: "Notify Me With Hue", + namespace: "smartthings", + author: "SmartThings", + description: "Changes the color and brightness of Philips Hue bulbs when any of a variety of SmartThings is activated. Supports motion, contact, acceleration, moisture and presence sensors as well as switches.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/hue.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/hue@2x.png" +) + +preferences { + + section("Control these bulbs...") { + input "hues", "capability.colorControl", title: "Which Hue Bulbs?", required:true, multiple:true + } + + section("Choose one or more, when..."){ + input "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + input "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + input "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + input "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + input "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + input "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + input "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + input "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + input "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + input "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + input "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + input "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true + input "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + + section("Choose light effects...") + { + input "color", "enum", title: "Hue Color?", required: false, multiple:false, options: ["Red","Green","Blue","Yellow","Orange","Purple","Pink"] + input "lightLevel", "enum", title: "Light Level?", required: false, options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]] + input "duration", "number", title: "Duration Seconds?", required: false + //input "turnOn", "enum", title: "Turn On when Off?", required: false, options: ["Yes","No"] + } + + section("Minimum time between messages (optional, defaults to every message)") { + input "frequency", "decimal", title: "Minutes", required: false + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } +} + +def eventHandler(evt) { + if (frequency) { + def lastTime = state[evt.deviceId] + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + } + else { + takeAction(evt) + } +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + takeAction(evt) +} + +private takeAction(evt) { + + if (frequency) { + state[evt.deviceId] = now() + } + + def hueColor = 0 + if(color == "Blue") + hueColor = 70//60 + else if(color == "Green") + hueColor = 39//30 + else if(color == "Yellow") + hueColor = 25//16 + else if(color == "Orange") + hueColor = 10 + else if(color == "Purple") + hueColor = 75 + else if(color == "Pink") + hueColor = 83 + + + state.previous = [:] + + hues.each { + state.previous[it.id] = [ + "switch": it.currentValue("switch"), + "level" : it.currentValue("level"), + "hue": it.currentValue("hue"), + "saturation": it.currentValue("saturation"), + "color": it.currentValue("color") + ] + } + + log.debug "current values = $state.previous" + + def newValue = [hue: hueColor, saturation: 100, level: (lightLevel as Integer) ?: 100] + log.debug "new value = $newValue" + + hues*.setColor(newValue) + setTimer() +} + +def setTimer() +{ + if(!duration) //default to 10 seconds + { + log.debug "pause 10" + pause(10 * 1000) + log.debug "reset hue" + resetHue() + } + else if(duration < 10) + { + log.debug "pause $duration" + pause(duration * 1000) + log.debug "resetHue" + resetHue() + } + else + { + log.debug "runIn $duration, resetHue" + runIn(duration,"resetHue", [overwrite: false]) + } +} + + +def resetHue() +{ + hues.each { + it.setColor(state.previous[it.id]) + } +} \ No newline at end of file diff --git a/official/obything-music-connect.groovy b/official/obything-music-connect.groovy new file mode 100755 index 0000000..f6dd374 --- /dev/null +++ b/official/obything-music-connect.groovy @@ -0,0 +1,77 @@ +/** + * ObyThing Music SmartApp + * + * Copyright 2014 obycode + * + * 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: "ObyThing Music (Connect)", + namespace: "com.obycode", + author: "obycode", + description: "Use this free SmartApp in conjunction with the ObyThing Music app for your Mac to control and automate music and more with iTunes and SmartThings.", + category: "SmartThings Labs", + iconUrl: "http://obycode.com/obything/ObyThingSTLogo.png", + iconX2Url: "http://obycode.com/obything/ObyThingSTLogo@2x.png", + singleInstance: true) + + +preferences { + section("Get the IP address and port for your Mac computer using the ObyThing App (http://obything.obycode.com) and set up the SmartApp below:") { + input "theAddr", "string", title: "IP:port (click icon in status bar)", multiple: false, required: true + } + section("on this hub...") { + input "theHub", "hub", multiple: false, required: true + } + +} + +def installed() { + log.debug "Installed ${app.label} with address '${settings.theAddr}' on hub '${settings.theHub.name}'" + + initialize() +} + +def updated() { + /* + log.debug "Updated ${app.label} with address '${settings.theAddr}' on hub '${settings.theHub.name}'" + + def current = getChildDevices() + log.debug "children: $current" + + if (app.label != current.label) { + log.debug "CHANGING name from ${current.label} to ${app.label}" + log.debug "label props: ${current.label.getProperties()}" + current.label[0] = app.label + } + */ +} + +def initialize() { + def parts = theAddr.split(":") + def iphex = convertIPtoHex(parts[0]) + def porthex = convertPortToHex(parts[1]) + def dni = "$iphex:$porthex" + def hubNames = location.hubs*.name.findAll { it } + def d = addChildDevice("com.obycode", "ObyThing Music", dni, theHub.id, [label:"${app.label}", name:"ObyThing"]) + log.trace "created ObyThing '${d.displayName}' with id $dni" +} + +private String convertIPtoHex(ipAddress) { + String hex = ipAddress.tokenize( '.' ).collect { String.format( '%02X', it.toInteger() ) }.join() + return hex + +} + +private String convertPortToHex(port) { + String hexport = port.toString().format( '%04X', port.toInteger() ) + return hexport +} diff --git a/official/once-a-day.groovy b/official/once-a-day.groovy new file mode 100755 index 0000000..1158e28 --- /dev/null +++ b/official/once-a-day.groovy @@ -0,0 +1,64 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Once a Day + * + * Author: SmartThings + * + * Turn on one or more switches at a specified time and turn them off at a later time. + */ + +definition( + name: "Once a Day", + namespace: "smartthings", + author: "SmartThings", + description: "Turn on one or more switches at a specified time and turn them off at a later time.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" +) + +preferences { + section("Select switches to control...") { + input name: "switches", type: "capability.switch", multiple: true + } + section("Turn them all on at...") { + input name: "startTime", title: "Turn On Time?", type: "time" + } + section("And turn them off at...") { + input name: "stopTime", title: "Turn Off Time?", type: "time" + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + schedule(startTime, "startTimerCallback") + schedule(stopTime, "stopTimerCallback") + +} + +def updated(settings) { + unschedule() + schedule(startTime, "startTimerCallback") + schedule(stopTime, "stopTimerCallback") +} + +def startTimerCallback() { + log.debug "Turning on switches" + switches.on() + +} + +def stopTimerCallback() { + log.debug "Turning off switches" + switches.off() +} diff --git a/official/opent2t-smartapp-test.groovy b/official/opent2t-smartapp-test.groovy new file mode 100755 index 0000000..24d829c --- /dev/null +++ b/official/opent2t-smartapp-test.groovy @@ -0,0 +1,651 @@ +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.InvalidKeyException; + +/** + * OpenT2T SmartApp Test + * + * Copyright 2016 OpenT2T + * + * 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: "OpenT2T SmartApp Test", + namespace: "opent2t", + author: "OpenT2T", + description: "Test app to test end to end SmartThings scenarios via OpenT2T", + category: "SmartThings Labs", + 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") + +/** --------------------+---------------+-----------------------+------------------------------------ + * Device Type | Attribute Name| Commands | Attribute Values + * --------------------+---------------+-----------------------+------------------------------------ + * switches | switch | on, off | on, off + * motionSensors | motion | | active, inactive + * contactSensors | contact | | open, closed + * presenceSensors | presence | | present, 'not present' + * temperatureSensors | temperature | | + * accelerationSensors | acceleration | | active, inactive + * waterSensors | water | | wet, dry + * lightSensors | illuminance | | + * humiditySensors | humidity | | + * locks | lock | lock, unlock | locked, unlocked + * garageDoors | door | open, close | unknown, closed, open, closing, opening + * cameras | image | take | + * thermostats | thermostat | setHeatingSetpoint, | temperature, heatingSetpoint, coolingSetpoint, + * | | setCoolingSetpoint, | thermostatSetpoint, thermostatMode, + * | | off, heat, cool, auto,| thermostatFanMode, thermostatOperatingState + * | | emergencyHeat, | + * | | setThermostatMode, | + * | | fanOn, fanAuto, | + * | | fanCirculate, | + * | | setThermostatFanMode | + * --------------------+---------------+-----------------------+------------------------------------ + */ + +//Device Inputs +preferences { + section("Allow OpenT2T to control these things...") { + input "contactSensors", "capability.contactSensor", title: "Which Contact Sensors", multiple: true, required: false, hideWhenEmpty: true + input "garageDoors", "capability.garageDoorControl", title: "Which Garage Doors?", multiple: true, required: false, hideWhenEmpty: true + input "locks", "capability.lock", title: "Which Locks?", multiple: true, required: false, hideWhenEmpty: true + input "cameras", "capability.videoCapture", title: "Which Cameras?", multiple: true, required: false, hideWhenEmpty: true + input "motionSensors", "capability.motionSensor", title: "Which Motion Sensors?", multiple: true, required: false, hideWhenEmpty: true + input "presenceSensors", "capability.presenceSensor", title: "Which Presence Sensors", multiple: true, required: false, hideWhenEmpty: true + input "switches", "capability.switch", title: "Which Switches and Lights?", multiple: true, required: false, hideWhenEmpty: true + input "thermostats", "capability.thermostat", title: "Which Thermostat?", multiple: true, required: false, hideWhenEmpty: true + input "waterSensors", "capability.waterSensor", title: "Which Water Leak Sensors?", multiple: true, required: false, hideWhenEmpty: true + } +} + +def getInputs() { + def inputList = [] + inputList += contactSensors ?: [] + inputList += garageDoors ?: [] + inputList += locks ?: [] + inputList += cameras ?: [] + inputList += motionSensors ?: [] + inputList += presenceSensors ?: [] + inputList += switches ?: [] + inputList += thermostats ?: [] + inputList += waterSensors ?: [] + return inputList +} + +//API external Endpoints +mappings { + path("/devices") { + action: + [ + GET: "getDevices" + ] + } + path("/devices/:id") { + action: + [ + GET: "getDevice" + ] + } + path("/update/:id") { + action: + [ + PUT: "updateDevice" + ] + } + path("/deviceSubscription") { + action: + [ + POST : "registerDeviceChange", + DELETE: "unregisterDeviceChange" + ] + } + path("/locationSubscription") { + action: + [ + POST : "registerDeviceGraph", + DELETE: "unregisterDeviceGraph" + ] + } +} + +def installed() { + log.debug "Installing with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updating with settings: ${settings}" + + //Initialize state variables if didn't exist. + if (state.deviceSubscriptionMap == null) { + state.deviceSubscriptionMap = [:] + log.debug "deviceSubscriptionMap created." + } + if (state.locationSubscriptionMap == null) { + state.locationSubscriptionMap = [:] + log.debug "locationSubscriptionMap created." + } + if (state.verificationKeyMap == null) { + state.verificationKeyMap = [:] + log.debug "verificationKeyMap created." + } + + unsubscribe() + registerAllDeviceSubscriptions() +} + +def initialize() { + log.debug "Initializing with settings: ${settings}" + state.deviceSubscriptionMap = [:] + log.debug "deviceSubscriptionMap created." + state.locationSubscriptionMap = [:] + log.debug "locationSubscriptionMap created." + state.verificationKeyMap = [:] + log.debug "verificationKeyMap created." + registerAllDeviceSubscriptions() +} + +/*** Subscription Functions ***/ + +//Subscribe events for all devices +def registerAllDeviceSubscriptions() { + registerChangeHandler(inputs) +} + +//Endpoints function: Subscribe to events from a specific device +def registerDeviceChange() { + def subscriptionEndpt = params.subscriptionURL + def deviceId = params.deviceId + def myDevice = findDevice(deviceId) + + if (myDevice == null) { + httpError(404, "Cannot find device with device ID ${deviceId}.") + } + + def theAtts = myDevice.supportedAttributes + try { + theAtts.each { att -> + subscribe(myDevice, att.name, deviceEventHandler) + } + log.info "Subscribing for ${myDevice.displayName}" + + if (subscriptionEndpt != null) { + if (state.deviceSubscriptionMap[deviceId] == null) { + state.deviceSubscriptionMap.put(deviceId, [subscriptionEndpt]) + log.info "Added subscription URL: ${subscriptionEndpt} for ${myDevice.displayName}" + } else if (!state.deviceSubscriptionMap[deviceId].contains(subscriptionEndpt)) { + state.deviceSubscriptionMap[deviceId] << subscriptionEndpt + log.info "Added subscription URL: ${subscriptionEndpt} for ${myDevice.displayName}" + } + + if (params.key != null) { + state.verificationKeyMap[subscriptionEndpt] = params.key + log.info "Added verification key: ${params.key} for ${subscriptionEndpt}" + } + } + } catch (e) { + httpError(500, "something went wrong: $e") + } + + log.info "Current subscription map is ${state.deviceSubscriptionMap}" + log.info "Current verification key map is ${state.verificationKeyMap}" + return ["succeed"] +} + +//Endpoints function: Unsubscribe to events from a specific device +def unregisterDeviceChange() { + def subscriptionEndpt = params.subscriptionURL + def deviceId = params.deviceId + def myDevice = findDevice(deviceId) + + if (myDevice == null) { + httpError(404, "Cannot find device with device ID ${deviceId}.") + } + + try { + if (subscriptionEndpt != null && subscriptionEndpt != "undefined") { + if (state.deviceSubscriptionMap[deviceId]?.contains(subscriptionEndpt)) { + if (state.deviceSubscriptionMap[deviceId].size() == 1) { + state.deviceSubscriptionMap.remove(deviceId) + } else { + state.deviceSubscriptionMap[deviceId].remove(subscriptionEndpt) + } + state.verificationKeyMap.remove(subscriptionEndpt) + log.info "Removed subscription URL: ${subscriptionEndpt} for ${myDevice.displayName}" + } + } else { + state.deviceSubscriptionMap.remove(deviceId) + log.info "Unsubscriping for ${myDevice.displayName}" + } + } catch (e) { + httpError(500, "something went wrong: $e") + } + + log.info "Current subscription map is ${state.deviceSubscriptionMap}" + log.info "Current verification key map is ${state.verificationKeyMap}" +} + +//Endpoints function: Subscribe to device additiona/removal updated in a location +def registerDeviceGraph() { + def subscriptionEndpt = params.subscriptionURL + + if (subscriptionEndpt != null && subscriptionEndpt != "undefined") { + subscribe(location, "DeviceCreated", locationEventHandler, [filterEvents: false]) + subscribe(location, "DeviceUpdated", locationEventHandler, [filterEvents: false]) + subscribe(location, "DeviceDeleted", locationEventHandler, [filterEvents: false]) + + if (state.locationSubscriptionMap[location.id] == null) { + state.locationSubscriptionMap.put(location.id, [subscriptionEndpt]) + log.info "Added subscription URL: ${subscriptionEndpt} for Location ${location.name}" + } else if (!state.locationSubscriptionMap[location.id].contains(subscriptionEndpt)) { + state.locationSubscriptionMap[location.id] << subscriptionEndpt + log.info "Added subscription URL: ${subscriptionEndpt} for Location ${location.name}" + } + + if (params.key != null) { + state.verificationKeyMap[subscriptionEndpt] = params.key + log.info "Added verification key: ${params.key} for ${subscriptionEndpt}" + } + + log.info "Current location subscription map is ${state.locationSubscriptionMap}" + log.info "Current verification key map is ${state.verificationKeyMap}" + return ["succeed"] + } else { + httpError(400, "missing input parameter: subscriptionURL") + } +} + +//Endpoints function: Unsubscribe to events from a specific device +def unregisterDeviceGraph() { + def subscriptionEndpt = params.subscriptionURL + + try { + if (subscriptionEndpt != null && subscriptionEndpt != "undefined") { + if (state.locationSubscriptionMap[location.id]?.contains(subscriptionEndpt)) { + if (state.locationSubscriptionMap[location.id].size() == 1) { + state.locationSubscriptionMap.remove(location.id) + } else { + state.locationSubscriptionMap[location.id].remove(subscriptionEndpt) + } + state.verificationKeyMap.remove(subscriptionEndpt) + log.info "Removed subscription URL: ${subscriptionEndpt} for Location ${location.name}" + } + } else { + httpError(400, "missing input parameter: subscriptionURL") + } + } catch (e) { + httpError(500, "something went wrong: $e") + } + + log.info "Current location subscription map is ${state.locationSubscriptionMap}" + log.info "Current verification key map is ${state.verificationKeyMap}" +} + +//When events are triggered, send HTTP post to web socket servers +def deviceEventHandler(evt) { + def evtDevice = evt.device + def evtDeviceType = getDeviceType(evtDevice) + def deviceData = []; + + if (evt.data != null) { + def evtData = parseJson(evt.data) + log.info "Received event for ${evtDevice.displayName}, data: ${evtData}, description: ${evt.descriptionText}" + } + + if (evtDeviceType == "thermostat") { + deviceData = [name: evtDevice.displayName, id: evtDevice.id, status: evtDevice.status, deviceType: evtDeviceType, manufacturer: evtDevice.manufacturerName, model: evtDevice.modelName, attributes: deviceAttributeList(evtDevice, evtDeviceType), locationMode: getLocationModeInfo(), locationId: location.id] + } else { + deviceData = [name: evtDevice.displayName, id: evtDevice.id, status: evtDevice.status, deviceType: evtDeviceType, manufacturer: evtDevice.manufacturerName, model: evtDevice.modelName, attributes: deviceAttributeList(evtDevice, evtDeviceType), locationId: location.id] + } + + def params = [body: deviceData] + + //send event to all subscriptions urls + log.debug "Current subscription urls for ${evtDevice.displayName} is ${state.deviceSubscriptionMap[evtDevice.id]}" + state.deviceSubscriptionMap[evtDevice.id].each { + params.uri = "${it}" + if (state.verificationKeyMap[it] != null) { + def key = state.verificationKeyMap[it] + params.header = [Signature: ComputHMACValue(key, groovy.json.JsonOutput.toJson(params.body))] + } + log.trace "POST URI: ${params.uri}" + log.trace "Header: ${params.header}" + log.trace "Payload: ${params.body}" + try { + httpPostJson(params) { resp -> + log.trace "response status code: ${resp.status}" + log.trace "response data: ${resp.data}" + } + } catch (e) { + log.error "something went wrong: $e" + } + } +} + +def locationEventHandler(evt) { + log.info "Received event for location ${location.name}/${location.id}, Event: ${evt.name}, description: ${evt.descriptionText}, apiServerUrl: ${apiServerUrl("")}" + switch (evt.name) { + case "DeviceCreated": + case "DeviceDeleted": + def evtDevice = evt.device + def evtDeviceType = getDeviceType(evtDevice) + def params = [body: [eventType: evt.name, deviceId: evtDevice.id, locationId: location.id]] + + if (evt.name == "DeviceDeleted" && state.deviceSubscriptionMap[deviceId] != null) { + state.deviceSubscriptionMap.remove(evtDevice.id) + } + + state.locationSubscriptionMap[location.id].each { + params.uri = "${it}" + if (state.verificationKeyMap[it] != null) { + def key = state.verificationKeyMap[it] + params.header = [Signature: ComputHMACValue(key, groovy.json.JsonOutput.toJson(params.body))] + } + log.trace "POST URI: ${params.uri}" + log.trace "Header: ${params.header}" + log.trace "Payload: ${params.body}" + try { + httpPostJson(params) { resp -> + log.trace "response status code: ${resp.status}" + log.trace "response data: ${resp.data}" + } + } catch (e) { + log.error "something went wrong: $e" + } + } + case "DeviceUpdated": + default: + break + } +} + +private ComputHMACValue(key, data) { + try { + SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA1") + Mac mac = Mac.getInstance("HmacSHA1") + mac.init(secretKeySpec) + byte[] digest = mac.doFinal(data.getBytes("UTF-8")) + return byteArrayToString(digest) + } catch (InvalidKeyException e) { + log.error "Invalid key exception while converting to HMac SHA1" + } +} + +private def byteArrayToString(byte[] data) { + BigInteger bigInteger = new BigInteger(1, data) + String hash = bigInteger.toString(16) + return hash +} + +/*** Device Query/Update Functions ***/ + +//Endpoints function: return all device data in json format +def getDevices() { + def deviceData = [] + inputs?.each { + def deviceType = getDeviceType(it) + if (deviceType == "thermostat") { + deviceData << [name: it.displayName, id: it.id, status: it.status, deviceType: deviceType, manufacturer: it.manufacturerName, model: it.modelName, attributes: deviceAttributeList(it, deviceType), locationMode: getLocationModeInfo()] + } else { + deviceData << [name: it.displayName, id: it.id, status: it.status, deviceType: deviceType, manufacturer: it.manufacturerName, model: it.modelName, attributes: deviceAttributeList(it, deviceType)] + } + } + + log.debug "getDevices, return: ${deviceData}" + return deviceData +} + +//Endpoints function: get device data +def getDevice() { + def it = findDevice(params.id) + def deviceType = getDeviceType(it) + def device + if (deviceType == "thermostat") { + device = [name: it.displayName, id: it.id, status: it.status, deviceType: deviceType, manufacturer: it.manufacturerName, model: it.modelName, attributes: deviceAttributeList(it, deviceType), locationMode: getLocationModeInfo()] + } else { + device = [name: it.displayName, id: it.id, status: it.status, deviceType: deviceType, manufacturer: it.manufacturerName, model: it.modelName, attributes: deviceAttributeList(it, deviceType)] + } + + log.debug "getDevice, return: ${device}" + return device +} + +//Endpoints function: update device data +void updateDevice() { + def device = findDevice(params.id) + request.JSON.each { + def command = it.key + def value = it.value + if (command) { + def commandList = mapDeviceCommands(command, value) + command = commandList[0] + value = commandList[1] + + if (command == "setAwayMode") { + log.info "Setting away mode to ${value}" + if (location.modes?.find { it.name == value }) { + location.setMode(value) + } + } else if (command == "thermostatSetpoint") { + switch (device.currentThermostatMode) { + case "cool": + log.info "Update: ${device.displayName}, [${command}, ${value}]" + device.setCoolingSetpoint(value) + break + case "heat": + case "emergency heat": + log.info "Update: ${device.displayName}, [${command}, ${value}]" + device.setHeatingSetpoint(value) + break + default: + httpError(501, "this mode: ${device.currentThermostatMode} does not allow changing thermostat setpoint.") + break + } + } else if (!device) { + log.error "updateDevice, Device not found" + httpError(404, "Device not found") + } else if (!device.hasCommand(command)) { + log.error "updateDevice, Device does not have the command" + httpError(404, "Device does not have such command") + } else { + if (command == "setColor") { + log.info "Update: ${device.displayName}, [${command}, ${value}]" + device."$command"(hex: value) + } else if (value.isNumber()) { + def intValue = value as Integer + log.info "Update: ${device.displayName}, [${command}, ${intValue}(int)]" + device."$command"(intValue) + } else if (value) { + log.info "Update: ${device.displayName}, [${command}, ${value}]" + device."$command"(value) + } else { + log.info "Update: ${device.displayName}, [${command}]" + device."$command"() + } + } + } + } +} + +/*** Private Functions ***/ + +//Return current location mode info +private getLocationModeInfo() { + return [mode: location.mode, supported: location.modes.name] +} + +//Map each device to a type given it's capabilities +private getDeviceType(device) { + def deviceType + def capabilities = device.capabilities + log.debug "capabilities: [${device}, ${capabilities}]" + log.debug "supported commands: [${device}, ${device.supportedCommands}]" + + //Loop through the device capability list to determine the device type. + capabilities.each { capability -> + switch (capability.name.toLowerCase()) { + case "switch": + deviceType = "switch" + + //If the device also contains "Switch Level" capability, identify it as a "light" device. + if (capabilities.any { it.name.toLowerCase() == "switch level" }) { + + //If the device also contains "Power Meter" capability, identify it as a "dimmerSwitch" device. + if (capabilities.any { it.name.toLowerCase() == "power meter" }) { + deviceType = "dimmerSwitch" + return deviceType + } else { + deviceType = "light" + return deviceType + } + } + break + case "garageDoorControl": + deviceType = "garageDoor" + return deviceType + case "lock": + deviceType = "lock" + return deviceType + case "video camera": + deviceType = "camera" + return deviceType + case "thermostat": + deviceType = "thermostat" + return deviceType + case "acceleration sensor": + case "contact sensor": + case "motion sensor": + case "presence sensor": + case "water sensor": + deviceType = "genericSensor" + return deviceType + default: + break + } + } + return deviceType +} + +//Return a specific device give the device ID. +private findDevice(deviceId) { + return inputs?.find { it.id == deviceId } +} + +//Return a list of device attributes +private deviceAttributeList(device, deviceType) { + def attributeList = [:] + def allAttributes = device.supportedAttributes + allAttributes.each { attribute -> + try { + def currentState = device.currentState(attribute.name) + if (currentState != null) { + switch (attribute.name) { + case 'temperature': + attributeList.putAll([(attribute.name): currentState.value, 'temperatureScale': location.temperatureScale]) + break; + default: + attributeList.putAll([(attribute.name): currentState.value]) + break; + } + if (deviceType == "genericSensor") { + def key = attribute.name + "_lastUpdated" + attributeList.putAll([(key): currentState.isoDate]) + } + } else { + attributeList.putAll([(attribute.name): null]); + } + } catch (e) { + attributeList.putAll([(attribute.name): null]); + } + } + return attributeList +} + +//Map device command and value. +//input command and value are from UWP, +//returns resultCommand and resultValue that corresponds with function and value in SmartApps +private mapDeviceCommands(command, value) { + log.debug "mapDeviceCommands: [${command}, ${value}]" + def resultCommand = command + def resultValue = value + switch (command) { + case "switch": + if (value == 1 || value == "1" || value == "on") { + resultCommand = "on" + resultValue = "" + } else if (value == 0 || value == "0" || value == "off") { + resultCommand = "off" + resultValue = "" + } + break + // light attributes + case "level": + resultCommand = "setLevel" + resultValue = value + break + case "hue": + resultCommand = "setHue" + resultValue = value + break + case "saturation": + resultCommand = "setSaturation" + resultValue = value + break + case "colorTemperature": + resultCommand = "setColorTemperature" + resultValue = value + break + case "color": + resultCommand = "setColor" + resultValue = value + // thermostat attributes + case "hvacMode": + resultCommand = "setThermostatMode" + resultValue = value + break + case "fanMode": + resultCommand = "setThermostatFanMode" + resultValue = value + break + case "awayMode": + resultCommand = "setAwayMode" + resultValue = value + break + case "coolingSetpoint": + resultCommand = "setCoolingSetpoint" + resultValue = value + break + case "heatingSetpoint": + resultCommand = "setHeatingSetpoint" + resultValue = value + break + case "thermostatSetpoint": + resultCommand = "thermostatSetpoint" + resultValue = value + break + // lock attributes + case "locked": + if (value == 1 || value == "1" || value == "lock") { + resultCommand = "lock" + resultValue = "" + } else if (value == 0 || value == "0" || value == "unlock") { + resultCommand = "unlock" + resultValue = "" + } + break + default: + break + } + + return [resultCommand, resultValue] +} diff --git a/official/photo-burst-when.groovy b/official/photo-burst-when.groovy new file mode 100755 index 0000000..ac58efb --- /dev/null +++ b/official/photo-burst-when.groovy @@ -0,0 +1,91 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Photo Burst When... + * + * Author: SmartThings + * + * Date: 2013-09-30 + */ + +definition( + name: "Photo Burst When...", + namespace: "smartthings", + author: "SmartThings", + description: "Take a burst of photos and send a push notification when...", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/photo-burst-when.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/photo-burst-when@2x.png" +) + +preferences { + section("Choose one or more, when..."){ + input "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + input "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + input "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + input "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + input "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + input "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + } + section("Take a burst of pictures") { + input "camera", "capability.imageCapture" + input "burstCount", "number", title: "How many? (default 5)", defaultValue:5 + } + section("Then send this message in a push notification"){ + input "messageText", "text", title: "Message Text" + } + section("And as text message to this number (optional)"){ + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Phone Number", required: false + } + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(contact, "contact.open", sendMessage) + subscribe(acceleration, "acceleration.active", sendMessage) + subscribe(motion, "motion.active", sendMessage) + subscribe(mySwitch, "switch.on", sendMessage) + subscribe(arrivalPresence, "presence.present", sendMessage) + subscribe(departurePresence, "presence.not present", sendMessage) +} + +def sendMessage(evt) { + log.debug "$evt.name: $evt.value, $messageText" + + camera.take() + (1..((burstCount ?: 5) - 1)).each { + camera.take(delay: (500 * it)) + } + + if (location.contactBookEnabled) { + sendNotificationToContacts(messageText, recipients) + } + else { + sendPush(messageText) + if (phone) { + sendSms(phone, messageText) + } + } +} diff --git a/official/plantlink-connector.groovy b/official/plantlink-connector.groovy new file mode 100755 index 0000000..9421d23 --- /dev/null +++ b/official/plantlink-connector.groovy @@ -0,0 +1,411 @@ +/** + * Required PlantLink Connector + * This SmartApp forwards the raw data of the deviceType to myplantlink.com + * and returns it back to your device after calculating soil and plant type. + * + * Copyright 2015 Oso Technologies + * + * 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. + * + */ +import groovy.json.JsonBuilder +import java.util.regex.Matcher +import java.util.regex.Pattern + +definition( + name: "PlantLink Connector", + namespace: "Osotech", + author: "Oso Technologies", + description: "This SmartApp connects to myplantlink.com and forwards the device data to it so it can calculate easy to read plant status for your specific plant's needs.", + category: "Convenience", + iconUrl: "https://dashboard.myplantlink.com/images/apple-touch-icon-76x76-precomposed.png", + iconX2Url: "https://dashboard.myplantlink.com/images/apple-touch-icon-120x120-precomposed.png", + iconX3Url: "https://dashboard.myplantlink.com/images/apple-touch-icon-152x152-precomposed.png" +) { + appSetting "client_id" + appSetting "client_secret" + appSetting "https_plantLinkServer" +} + +preferences { + page(name: "auth", title: "Step 1 of 2", nextPage:"deviceList", content:"authPage") + page(name: "deviceList", title: "Step 2 of 2", install:true, uninstall:false){ + section { + input "plantlinksensors", "capability.sensor", title: "Select PlantLink sensors", multiple: true + } + } +} + +mappings { + path("/swapToken") { + action: [ + GET: "swapToken" + ] + } +} + +def authPage(){ + if(!atomicState.accessToken){ + createAccessToken() + atomicState.accessToken = state.accessToken + } + + def redirectUrl = oauthInitUrl() + def uninstallAllowed = false + def oauthTokenProvided = false + if(atomicState.authToken){ + uninstallAllowed = true + oauthTokenProvided = true + } + + if (!oauthTokenProvided) { + return dynamicPage(name: "auth", title: "Step 1 of 2", nextPage:null, uninstall:uninstallAllowed) { + section(){ + href(name:"login", + url:redirectUrl, + style:"embedded", + title:"PlantLink", + image:"https://dashboard.myplantlink.com/images/PLlogo.png", + description:"Tap to login to myplantlink.com") + } + } + }else{ + return dynamicPage(name: "auth", title: "Step 1 of 2 - Completed", nextPage:"deviceList", uninstall:uninstallAllowed) { + section(){ + paragraph "You are logged in to myplantlink.com, tap next to continue", image: iconUrl + href(url:redirectUrl, title:"Or", description:"tap to switch accounts") + } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def uninstalled() { + if (plantlinksensors){ + plantlinksensors.each{ sensor_device -> + sensor_device.setInstallSmartApp("needSmartApp") + } + } +} + +def initialize() { + atomicState.attached_sensors = [:] + if (plantlinksensors){ + subscribe(plantlinksensors, "moisture_status", moistureHandler) + subscribe(plantlinksensors, "battery_status", batteryHandler) + plantlinksensors.each{ sensor_device -> + sensor_device.setStatusIcon("Waiting on First Measurement") + sensor_device.setInstallSmartApp("connectedToSmartApp") + } + } +} + +def dock_sensor(device_serial, expected_plant_name) { + def docking_body_json_builder = new JsonBuilder([version: '1c', smartthings_device_id: device_serial]) + def docking_params = [ + uri : appSettings.https_plantLinkServer, + path : "/api/v1/smartthings/links", + headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType: "application/json", + body: docking_body_json_builder.toString() + ] + def plant_post_body_map = [ + plant_type_key: 999999, + soil_type_key : 1000004 + ] + def plant_post_params = [ + uri : appSettings.https_plantLinkServer, + path : "/api/v1/plants", + headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType: "application/json", + ] + log.debug "Creating new plant on myplantlink.com - ${expected_plant_name}" + try { + httpPost(docking_params) { docking_response -> + if (parse_api_response(docking_response, "Docking a link")) { + if (docking_response.data.plants.size() == 0) { + log.debug "creating plant for - ${expected_plant_name}" + plant_post_body_map["name"] = expected_plant_name + plant_post_body_map['links_key'] = [docking_response.data.key] + def plant_post_body_json_builder = new JsonBuilder(plant_post_body_map) + plant_post_params["body"] = plant_post_body_json_builder.toString() + try { + httpPost(plant_post_params) { plant_post_response -> + if(parse_api_response(plant_post_response, 'creating plant')){ + def attached_map = atomicState.attached_sensors + attached_map[device_serial] = plant_post_response.data + atomicState.attached_sensors = attached_map + } + } + } catch (Exception f) { + log.debug "call failed $f" + } + } else { + def plant = docking_response.data.plants[0] + def attached_map = atomicState.attached_sensors + attached_map[device_serial] = plant + atomicState.attached_sensors = attached_map + checkAndUpdatePlantIfNeeded(plant, expected_plant_name) + } + } + } + } catch (Exception e) { + log.debug "call failed $e" + } + return true +} + +def checkAndUpdatePlantIfNeeded(plant, expected_plant_name){ + def plant_put_params = [ + uri : appSettings.https_plantLinkServer, + headers : ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType : "application/json" + ] + if (plant.name != expected_plant_name) { + log.debug "updating plant for - ${expected_plant_name}" + plant_put_params["path"] = "/api/v1/plants/${plant.key}" + def plant_put_body_map = [ + name: expected_plant_name + ] + def plant_put_body_json_builder = new JsonBuilder(plant_put_body_map) + plant_put_params["body"] = plant_put_body_json_builder.toString() + try { + httpPut(plant_put_params) { plant_put_response -> + parse_api_response(plant_put_response, 'updating plant name') + } + } catch (Exception e) { + log.debug "call failed $e" + } + } +} + +def moistureHandler(event){ + def expected_plant_name = "SmartThings - ${event.displayName}" + def device_serial = getDeviceSerialFromEvent(event) + + if (!atomicState.attached_sensors.containsKey(device_serial)){ + dock_sensor(device_serial, expected_plant_name) + }else{ + def measurement_post_params = [ + uri: appSettings.https_plantLinkServer, + path: "/api/v1/smartthings/links/${device_serial}/measurements", + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType: "application/json", + body: event.value + ] + try { + httpPost(measurement_post_params) { measurement_post_response -> + if (parse_api_response(measurement_post_response, 'creating moisture measurement') && + measurement_post_response.data.size() >0){ + def measurement = measurement_post_response.data[0] + def plant = measurement.plant + log.debug plant + checkAndUpdatePlantIfNeeded(plant, expected_plant_name) + plantlinksensors.each{ sensor_device -> + if (sensor_device.id == event.deviceId){ + sensor_device.setStatusIcon(plant.status) + if (plant.last_measurements && plant.last_measurements[0].moisture){ + sensor_device.setPlantFuelLevel(plant.last_measurements[0].moisture * 100 as int) + } + if (plant.last_measurements && plant.last_measurements[0].battery){ + sensor_device.setBatteryLevel(plant.last_measurements[0].battery * 100 as int) + } + } + } + } + } + } catch (Exception e) { + log.debug "call failed $e" + } + } +} + +def batteryHandler(event){ + def expected_plant_name = "SmartThings - ${event.displayName}" + def device_serial = getDeviceSerialFromEvent(event) + + if (!atomicState.attached_sensors.containsKey(device_serial)){ + dock_sensor(device_serial, expected_plant_name) + }else{ + def measurement_post_params = [ + uri: appSettings.https_plantLinkServer, + path: "/api/v1/smartthings/links/${device_serial}/measurements", + headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], + contentType: "application/json", + body: event.value + ] + try { + httpPost(measurement_post_params) { measurement_post_response -> + parse_api_response(measurement_post_response, 'creating battery measurement') + } + } catch (Exception e) { + log.debug "call failed $e" + } + } +} + +def getDeviceSerialFromEvent(event){ + def pattern = /.*"zigbeedeviceid"\s*:\s*"(\w+)".*/ + def match_result = (event.value =~ pattern) + return match_result[0][1] +} + +def oauthInitUrl(){ + atomicState.oauthInitState = UUID.randomUUID().toString() + def oauthParams = [ + response_type: "code", + client_id: appSettings.client_id, + state: atomicState.oauthInitState, + redirect_uri: buildRedirectUrl() + ] + return appSettings.https_plantLinkServer + "/oauth/oauth2/authorize?" + toQueryString(oauthParams) +} + +def buildRedirectUrl(){ + return getServerUrl() + "/api/token/${atomicState.accessToken}/smartapps/installations/${app.id}/swapToken" +} + +def swapToken(){ + def code = params.code + def oauthState = params.state + def stcid = appSettings.client_id + def postParams = [ + method: 'POST', + uri: "https://oso-tech.appspot.com", + path: "/api/v1/oauth-token", + query: [grant_type:'authorization_code', code:params.code, client_id:stcid, + client_secret:appSettings.client_secret, redirect_uri: buildRedirectUrl()], + ] + + def jsonMap + try { + httpPost(postParams) { resp -> + jsonMap = resp.data + } + } catch (Exception e) { + log.debug "call failed $e" + } + + atomicState.refreshToken = jsonMap.refresh_token + atomicState.authToken = jsonMap.access_token + + def html = """ + + + + + + +
+
PlantLink
+
connected to
+
SmartThings
+
+
+
+

Your PlantLink Account is now connected to SmartThings!

+

Click Done at the top right to finish setup.

+
+ + +""" + render contentType: 'text/html', data: html +} + +private refreshAuthToken() { + def stcid = appSettings.client_id + def refreshParams = [ + method: 'POST', + uri: "https://hardware-dot-oso-tech.appspot.com", + path: "/api/v1/oauth-token", + query: [grant_type:'refresh_token', code:"${atomicState.refreshToken}", client_id:stcid, + client_secret:appSettings.client_secret], + ] + try{ + def jsonMap + httpPost(refreshParams) { resp -> + if(resp.status == 200){ + log.debug "OAuth Token refreshed" + jsonMap = resp.data + if (resp.data) { + atomicState.refreshToken = resp?.data?.refresh_token + atomicState.authToken = resp?.data?.access_token + if (data?.action && data?.action != "") { + log.debug data.action + "{data.action}"() + data.action = "" + } + } + data.action = "" + }else{ + log.debug "refresh failed ${resp.status} : ${resp.status.code}" + } + } + } + catch(Exception e){ + log.debug "caught exception refreshing auth token: " + e + } +} + +def parse_api_response(resp, message) { + if (resp.status == 200) { + return true + } else { + log.error "sent ${message} Json & got http status ${resp.status} - ${resp.status.code}" + if (resp.status == 401) { + refreshAuthToken() + return false + } else { + debugEvent("Authentication error, invalid authentication method, lack of credentials, etc.", true) + return false + } + } +} + +def getServerUrl() { return getApiServerUrl() } + +def debugEvent(message, displayEvent) { + def results = [ + name: "appdebug", + descriptionText: message, + displayed: displayEvent + ] + log.debug "Generating AppDebug Event: ${results}" + sendEvent (results) +} + +def toQueryString(Map m){ + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} \ No newline at end of file diff --git a/official/power-allowance.groovy b/official/power-allowance.groovy new file mode 100755 index 0000000..7ecf963 --- /dev/null +++ b/official/power-allowance.groovy @@ -0,0 +1,57 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Power Allowance + * + * Author: SmartThings + */ +definition( + name: "Power Allowance", + namespace: "smartthings", + author: "SmartThings", + description: "Save energy or restrict total time an appliance (like a curling iron or TV) can be in use. When a switch turns on, automatically turn it back off after a set number of minutes you specify.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" +) + +preferences { + section("When a switch turns on...") { + input "theSwitch", "capability.switch" + } + section("Turn it off how many minutes later?") { + input "minutesLater", "number", title: "When?" + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribe(theSwitch, "switch.on", switchOnHandler, [filterEvents: false]) +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + subscribe(theSwitch, "switch.on", switchOnHandler, [filterEvents: false]) +} + +def switchOnHandler(evt) { + log.debug "Switch ${theSwitch} turned: ${evt.value}" + def delay = minutesLater * 60 + log.debug "Turning off in ${minutesLater} minutes (${delay}seconds)" + runIn(delay, turnOffSwitch) +} + +def turnOffSwitch() { + theSwitch.off() +} diff --git a/official/presence-change-push.groovy b/official/presence-change-push.groovy new file mode 100755 index 0000000..04e8c90 --- /dev/null +++ b/official/presence-change-push.groovy @@ -0,0 +1,50 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Presence Change Push + * + * Author: SmartThings + */ +definition( + name: "Presence Change Push", + namespace: "smartthings", + author: "SmartThings", + description: "Get a push notification when a SmartSense Presence tag or smartphone arrives at or departs from a location.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/text_presence.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text_presence@2x.png" +) + +preferences { + section("When a presence sensor arrives or departs this location..") { + input "presence", "capability.presenceSensor", title: "Which sensor?" + } +} + +def installed() { + subscribe(presence, "presence", presenceHandler) +} + +def updated() { + unsubscribe() + subscribe(presence, "presence", presenceHandler) +} + +def presenceHandler(evt) { + if (evt.value == "present") { + // log.debug "${presence.label ?: presence.name} has arrived at the ${location}" + sendPush("${presence.label ?: presence.name} has arrived at the ${location}") + } else if (evt.value == "not present") { + // log.debug "${presence.label ?: presence.name} has left the ${location}" + sendPush("${presence.label ?: presence.name} has left the ${location}") + } +} diff --git a/official/presence-change-text.groovy b/official/presence-change-text.groovy new file mode 100755 index 0000000..4c4d5a2 --- /dev/null +++ b/official/presence-change-text.groovy @@ -0,0 +1,68 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Presence Change Text + * + * Author: SmartThings + */ +definition( + name: "Presence Change Text", + namespace: "smartthings", + author: "SmartThings", + description: "Send me a text message when my presence status changes.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/text_presence.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text_presence@2x.png" +) + +preferences { + section("When a presence sensor arrives or departs this location..") { + input "presence", "capability.presenceSensor", title: "Which sensor?" + } + section("Send a text message to...") { + input("recipients", "contact", title: "Send notifications to") { + input "phone1", "phone", title: "Phone number?" + } + } +} + + +def installed() { + subscribe(presence, "presence", presenceHandler) +} + +def updated() { + unsubscribe() + subscribe(presence, "presence", presenceHandler) +} + +def presenceHandler(evt) { + if (evt.value == "present") { + // log.debug "${presence.label ?: presence.name} has arrived at the ${location}" + + if (location.contactBookEnabled) { + sendNotificationToContacts("${presence.label ?: presence.name} has arrived at the ${location}", recipients) + } + else { + sendSms(phone1, "${presence.label ?: presence.name} has arrived at the ${location}") + } + } else if (evt.value == "not present") { + // log.debug "${presence.label ?: presence.name} has left the ${location}" + + if (location.contactBookEnabled) { + sendNotificationToContacts("${presence.label ?: presence.name} has left the ${location}", recipients) + } + else { + sendSms(phone1, "${presence.label ?: presence.name} has left the ${location}") + } + } +} diff --git a/official/quirky-connect.groovy b/official/quirky-connect.groovy new file mode 100755 index 0000000..30f5b55 --- /dev/null +++ b/official/quirky-connect.groovy @@ -0,0 +1,1118 @@ +/** + * Quirky (Connect) + * + * Author: todd@wackford.net + * Date: 2014-02-15 + * + * Update: 2014-02-22 + * Added eggtray + * Added device specific methods called from poll (versus in poll) + * + * Update2:2014-02-22 + * Added nimbus + * + * Update3:2014-02-26 + * Improved eggtray integration + * Added notifications to hello home + * Introduced Quirky Eggtray specific icons (Thanks to Dane) + * Added an Egg Report that outputs to hello home. + * Switched to Dan Lieberman's client and secret + * Still not browser flow (next update?) + * + * Update4:2014-03-08 + * Added Browser Flow OAuth + * + * + * Update5:2014-03-14 + * Added dynamic icon/tile updating to the nimbus. Changes the device icon from app. + * + * Update6:2014-03-31 + * Stubbed out creation and choice of nimbus, eggtray and porkfolio per request. + * + * Update7:2014-04-01 + * Renamed to 'Quirky (Connect)' and updated device names + * + * Update8:2014-04-08 (dlieberman) + * Stubbed out Spotter + * + * Update9:2014-04-08 (twackford) + * resubscribe to events on each poll + */ + +import java.text.DecimalFormat + +// Wink API +private apiUrl() { "https://winkapi.quirky.com/" } +private getVendorName() { "Quirky Wink" } +private getVendorAuthPath() { "https://winkapi.quirky.com/oauth2/authorize?" } +private getVendorTokenPath(){ "https://winkapi.quirky.com/oauth2/token?" } +private getVendorIcon() { "https://s3.amazonaws.com/smartthings-device-icons/custom/quirky/quirky-device@2x.png" } +private getClientId() { appSettings.clientId } +private getClientSecret() { appSettings.clientSecret } +private getServerUrl() { appSettings.serverUrl } + +definition( + name: "Quirky (Connect)", + namespace: "wackford", + author: "SmartThings", + description: "Connect your Quirky to SmartThings.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/quirky.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/quirky@2x.png", + singleInstance: true +) { + appSetting "clientId" + appSetting "clientSecret" + appSetting "serverUrl" +} + +preferences { + page(name: "Credentials", title: "Fetch OAuth2 Credentials", content: "authPage", install: false) + page(name: "listDevices", title: "Quirky Devices", content: "listDevices", install: false) +} + +mappings { + path("/receivedToken") { action:[ POST: "receivedToken", GET: "receivedToken"] } + path("/receiveToken") { action:[ POST: "receiveToken", GET: "receiveToken"] } + path("/powerstripCallback") { action:[ POST: "powerstripEventHandler", GET: "subscriberIdentifyVerification"]} + path("/sensor_podCallback") { action:[ POST: "sensor_podEventHandler", GET: "subscriberIdentifyVerification"]} + path("/piggy_bankCallback") { action:[ POST: "piggy_bankEventHandler", GET: "subscriberIdentifyVerification"]} + path("/eggtrayCallback") { action:[ POST: "eggtrayEventHandler", GET: "subscriberIdentifyVerification"]} + path("/cloud_clockCallback"){ action:[ POST: "cloud_clockEventHandler", GET: "subscriberIdentifyVerification"]} +} + +def authPage() { + log.debug "In authPage" + if(canInstallLabs()) { + def description = null + + if (state.vendorAccessToken == null) { + log.debug "About to create access token." + + createAccessToken() + description = "Tap to enter Credentials." + + def redirectUrl = oauthInitUrl() + + + return dynamicPage(name: "Credentials", title: "Authorize Connection", nextPage:"listDevices", uninstall: true, install:false) { + section { href url:redirectUrl, style:"embedded", required:false, title:"Connect to ${getVendorName()}:", description:description } + } + } else { + description = "Tap 'Next' to proceed" + + return dynamicPage(name: "Credentials", title: "Credentials Accepted!", nextPage:"listDevices", uninstall: true, install:false) { + section { href url: buildRedirectUrl("receivedToken"), style:"embedded", required:false, title:"${getVendorName()} is now connected to SmartThings!", description:description } + } + } + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + + return dynamicPage(name:"Credentials", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section { + paragraph "$upgradeNeeded" + } + } + + } +} + +def oauthInitUrl() { + log.debug "In oauthInitUrl" + + /* OAuth Step 1: Request access code with our client ID */ + + state.oauthInitState = UUID.randomUUID().toString() + + def oauthParams = [ response_type: "code", + client_id: getClientId(), + state: state.oauthInitState, + redirect_uri: buildRedirectUrl("receiveToken") ] + + return getVendorAuthPath() + toQueryString(oauthParams) +} + +def buildRedirectUrl(endPoint) { + log.debug "In buildRedirectUrl" + + return getServerUrl() + "/api/token/${state.accessToken}/smartapps/installations/${app.id}/${endPoint}" +} + +def receiveToken() { + log.debug "In receiveToken" + + def oauthParams = [ client_secret: getClientSecret(), + grant_type: "authorization_code", + code: params.code ] + + def tokenUrl = getVendorTokenPath() + toQueryString(oauthParams) + def params = [ + uri: tokenUrl, + ] + + /* OAuth Step 2: Request access token with our client Secret and OAuth "Code" */ + httpPost(params) { response -> + + def data = response.data.data + + state.vendorRefreshToken = data.refresh_token //these may need to be adjusted depending on depth of returned data + state.vendorAccessToken = data.access_token + } + + if ( !state.vendorAccessToken ) { //We didn't get an access token, bail on install + return + } + + /* OAuth Step 3: Use the access token to call into the vendor API throughout your code using state.vendorAccessToken. */ + + def html = """ + + + + + ${getVendorName()} Connection + + + +
+ Vendor icon + connected device icon + SmartThings logo +

We have located your """ + getVendorName() + """ account.

+

Tap 'Done' to process your credentials.

+
+ + + """ + render contentType: 'text/html', data: html +} + +def receivedToken() { + log.debug "In receivedToken" + + def html = """ + + + + + Withings Connection + + + +
+ Vendor icon + connected device icon + SmartThings logo +

Tap 'Done' to continue to Devices.

+
+ + + """ + render contentType: 'text/html', data: html +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + + +def subscriberIdentifyVerification() +{ + log.debug "In subscriberIdentifyVerification" + + def challengeToken = params.hub.challenge + + render contentType: 'text/plain', data: challengeToken +} + +def initialize() +{ + log.debug "Initialized with settings: ${settings}" + + //createAccessToken() + + //state.oauthInitState = UUID.randomUUID().toString() + + settings.devices.each { + def deviceId = it + + state.deviceDataArr.each { + if ( it.id == deviceId ) { + switch(it.type) { + + case "powerstrip": + log.debug "we have a Pivot Power Genius" + createPowerstripChildren(it.data) //has sub-devices, so we call out to create kids + createWinkSubscription( it.subsPath, it.subsSuff ) + break + + case "sensor_pod": + log.debug "we have a Spotter" + addChildDevice("wackford", "Quirky Wink Spotter", deviceId, null, [name: it.name, label: it.label, completedSetup: true]) + createWinkSubscription( it.subsPath, it.subsSuff ) + break + + case "piggy_bank": + log.debug "we have a Piggy Bank" + addChildDevice("wackford", "Quirky Wink Porkfolio", deviceId, null, [name: it.name, label: it.label, completedSetup: true]) + createWinkSubscription( it.subsPath, it.subsSuff ) + break + + case "eggtray": + log.debug "we have a Egg Minder" + addChildDevice("wackford", "Quirky Wink Eggtray", deviceId, null, [name: it.name, label: it.label, completedSetup: true]) + createWinkSubscription( it.subsPath, it.subsSuff ) + break + + case "cloud_clock": + log.debug "we have a Nimbus" + createNimbusChildren(it.data) //has sub-devices, so we call out to create kids + createWinkSubscription( it.subsPath, it.subsSuff ) + break + } + } + } + } +} + +def getDeviceList() +{ + log.debug "In getDeviceList" + + def deviceList = [:] + state.deviceDataArr = [] + + apiGet("/users/me/wink_devices") { response -> + response.data.data.each() { + if ( it.powerstrip_id ) { + deviceList["${it.powerstrip_id}"] = it.name + state.deviceDataArr.push(['name' : it.name, + 'id' : it.powerstrip_id, + 'type' : "powerstrip", + 'serial' : it.serial, + 'data' : it, + 'subsSuff': "/powerstripCallback", + 'subsPath': "/powerstrips/${it.powerstrip_id}/subscriptions" + ]) + } + + /* stubbing out these out for later release + if ( it.sensor_pod_id ) { + deviceList["${it.sensor_pod_id}"] = it.name + state.deviceDataArr.push(['name' : it.name, + 'id' : it.sensor_pod_id, + 'type' : "sensor_pod", + 'serial' : it.serial, + 'data' : it, + 'subsSuff': "/sensor_podCallback", + 'subsPath': "/sensor_pods/${it.sensor_pod_id}/subscriptions" + + ]) + } + + if ( it.piggy_bank_id ) { + deviceList["${it.piggy_bank_id}"] = it.name + state.deviceDataArr.push(['name' : it.name, + 'id' : it.piggy_bank_id, + 'type' : "piggy_bank", + 'serial' : it.serial, + 'data' : it, + 'subsSuff': "/piggy_bankCallback", + 'subsPath': "/piggy_banks/${it.piggy_bank_id}/subscriptions" + ]) + } + if ( it.cloud_clock_id ) { + deviceList["${it.cloud_clock_id}"] = it.name + state.deviceDataArr.push(['name' : it.name, + 'id' : it.cloud_clock_id, + 'type' : "cloud_clock", + 'serial' : it.serial, + 'data' : it, + 'subsSuff': "/cloud_clockCallback", + 'subsPath': "/cloud_clocks/${it.cloud_clock_id}/subscriptions" + ]) + } + if ( it.eggtray_id ) { + deviceList["${it.eggtray_id}"] = it.name + state.deviceDataArr.push(['name' : it.name, + 'id' : it.eggtray_id, + 'type' : "eggtray", + 'serial' : it.serial, + 'data' : it, + 'subsSuff': "/eggtrayCallback", + 'subsPath': "/eggtrays/${it.eggtray_id}/subscriptions" + ]) + } */ + + + } + } + return deviceList +} + +private removeChildDevices(delete) +{ + log.debug "In removeChildDevices" + + log.debug "deleting ${delete.size()} devices" + + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} + +def uninstalled() +{ + log.debug "In uninstalled" + + removeWinkSubscriptions() + + removeChildDevices(getChildDevices()) +} + +def updateWinkSubscriptions() +{ //since we don't know when wink subscription dies, we'll delete and recreate on every poll + log.debug "In updateWinkSubscriptions" + + state.deviceDataArr.each() { + if (it.subsPath) { + def path = it.subsPath + def suffix = it.subsSuff + apiGet(it.subsPath) { response -> + response.data.data.each { + if ( it.subscription_id ) { + deleteWinkSubscription(path + "/", it.subscription_id) + createWinkSubscription(path, suffix) + } + } + } + } + } +} + +def createWinkSubscription(path, suffix) +{ + log.debug "In createWinkSubscription" + + def callbackUrl = buildCallbackUrl(suffix) + + httpPostJson([ + uri : apiUrl(), + path: path, + body: ['callback': callbackUrl], + headers : ['Authorization' : 'Bearer ' + state.vendorAccessToken] + ],) + { response -> + log.debug "Created subscription ID ${response.data.data.subscription_id}" + } +} + +def deleteWinkSubscription(path, subscriptionId) +{ + log.debug "Deleting the wink subscription ${subscriptionId}" + + httpDelete([ + uri : apiUrl(), + path: path + subscriptionId, + headers : [ 'Authorization' : 'Bearer ' + state.vendorAccessToken ] + ],) + { response -> + log.debug "Subscription ${subscriptionId} deleted" + } +} + + +def removeWinkSubscriptions() +{ + log.debug "In removeSubscriptions" + + try { + state.deviceDataArr.each() { + if (it.subsPath) { + def path = it.subsPath + apiGet(it.subsPath) { response -> + response.data.data.each { + if ( it.subscription_id ) { + deleteWinkSubscription(path + "/", it.subscription_id) + } + } + } + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.warn "Caught HttpResponseException: $e, with status: ${e.statusCode}" + } +} + +def buildCallbackUrl(suffix) +{ + log.debug "In buildRedirectUrl" + + def serverUrl = getServerUrl() + return serverUrl + "/api/token/${state.accessToken}/smartapps/installations/${app.id}" + suffix +} + +def createChildDevice(deviceFile, dni, name, label) +{ + log.debug "In createChildDevice" + + try { + def existingDevice = getChildDevice(dni) + if(!existingDevice) { + log.debug "Creating child" + def childDevice = addChildDevice("wackford", deviceFile, dni, null, [name: name, label: label, completedSetup: true]) + } else { + log.debug "Device $dni already exists" + } + } catch (e) { + log.error "Error creating device: ${e}" + } + +} + +def listDevices() +{ + log.debug "In listDevices" + + //login() + + def devices = getDeviceList() + log.debug "Device List = ${devices}" + + dynamicPage(name: "listDevices", title: "Choose devices", install: true) { + section("Devices") { + input "devices", "enum", title: "Select Device(s)", required: false, multiple: true, options: devices + } + } +} + +def apiGet(String path, Closure callback) +{ + httpGet([ + uri : apiUrl(), + path : path, + headers : [ 'Authorization' : 'Bearer ' + state.vendorAccessToken ] + ],) + { + response -> + callback.call(response) + } +} + +def apiPut(String path, cmd, Closure callback) +{ + httpPutJson([ + uri : apiUrl(), + path: path, + body: cmd, + headers : [ 'Authorization' : 'Bearer ' + state.vendorAccessToken ] + ],) + + { + response -> + callback.call(response) + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + //initialize() + listDevices() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + //unsubscribe() + //unschedule() + initialize() + + listDevices() +} + + +def poll(childDevice) +{ + log.debug "In poll" + log.debug childDevice + + //login() + + def dni = childDevice.device.deviceNetworkId + + log.debug dni + + def deviceType = null + + state.deviceDataArr.each() { + if (it.id == dni) { + deviceType = it.type + } + } + + log.debug "device type is: ${deviceType}" + + switch(deviceType) { //outlets are polled in unique method not here + + case "sensor_pod": + log.debug "Polling sensor_pod" + getSensorPodUpdate(childDevice) + log.debug "sensor pod status updated" + break + + case "piggy_bank": + log.debug "Polling piggy_bank" + getPiggyBankUpdate(childDevice) + log.debug "piggy bank status updated" + break + + case "eggtray": + log.debug "Polling eggtray" + getEggtrayUpdate(childDevice) + log.debug "eggtray status updated" + break + + } + updateWinkSubscriptions() +} + +def cToF(temp) { + return temp * 1.8 + 32 +} + +def fToC(temp) { + return (temp - 32) / 1.8 +} + +def dollarize(int money) +{ + def value = money.toString() + + if ( value.length() == 1 ) { + value = "00" + value + } + + if ( value.length() == 2 ) { + value = "0" + value + } + + def newval = value.substring(0, value.length() - 2) + "." + value.substring(value.length()-2, value.length()) + value = newval + + def pattern = "\$0.00" + def moneyform = new DecimalFormat(pattern) + String output = moneyform.format(value.toBigDecimal()) + + return output +} + +def debugEvent(message, displayEvent) { + + def results = [ + name: "appdebug", + descriptionText: message, + displayed: displayEvent + ] + log.debug "Generating AppDebug Event: ${results}" + sendEvent (results) + +} + +///////////////////////////////////////////////////////////////////////// +// START NIMBUS SPECIFIC CODE HERE +///////////////////////////////////////////////////////////////////////// +def createNimbusChildren(deviceData) +{ + log.debug "In createNimbusChildren" + + def nimbusName = deviceData.name + def deviceFile = "Quirky-Wink-Nimbus" + def index = 1 + deviceData.dials.each { + log.debug "creating dial device for ${it.dial_id}" + def dialName = "Dial ${index}" + def dialLabel = "${nimbusName} ${dialName}" + createChildDevice( deviceFile, it.dial_id, dialName, dialLabel ) + index++ + } +} + +def cloud_clockEventHandler() +{ + log.debug "In Nimbus Event Handler..." + + def json = request.JSON + def dials = json.dials + + def html = """{"code":200,"message":"OK"}""" + render contentType: 'application/json', data: html + + if ( dials ) { + dials.each() { + def childDevice = getChildDevice(it.dial_id) + childDevice?.sendEvent( name : "dial", value : it.label , unit : "" ) + childDevice?.sendEvent( name : "info", value : it.name , unit : "" ) + } + } +} + +def pollNimbus(dni) +{ + + log.debug "In pollNimbus using dni # ${dni}" + + //login() + + def dials = null + + apiGet("/users/me/wink_devices") { response -> + + response.data.data.each() { + if (it.cloud_clock_id ) { + log.debug "Found Nimbus #" + it.cloud_clock_id + dials = it.dials + //log.debug dials + } + } + } + + if ( dials ) { + dials.each() { + def childDevice = getChildDevice(it.dial_id) + + childDevice?.sendEvent( name : "dial", value : it.label , unit : "" ) + childDevice?.sendEvent( name : "info", value : it.name , unit : "" ) + + //Change the tile/icon to what info is being displayed + switch(it.name) { + case "Weather": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-weather") + break + case "Traffic": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-traffic") + break + case "Time": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-time") + break + case "Twitter": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-twitter") + break + case "Calendar": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-calendar") + break + case "Email": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-mail") + break + case "Facebook": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-facebook") + break + case "Instagram": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-instagram") + break + case "Fitbit": + childDevice?.setIcon("dial", "dial", "st.quirky.nimbus.quirky-nimbus-fitbit") + break + case "Egg Minder": + childDevice?.setIcon("dial", "dial", "st.quirky.egg-minder.quirky-egg-device") + break + case "Porkfolio": + childDevice?.setIcon("dial", "dial", "st.quirky.porkfolio.quirky-porkfolio-side") + break + } + childDevice.save() + } + } + return +} + +///////////////////////////////////////////////////////////////////////// +// START EGG TRAY SPECIFIC CODE HERE +///////////////////////////////////////////////////////////////////////// +def getEggtrayUpdate(childDevice) +{ + log.debug "In getEggtrayUpdate" + + apiGet("/eggtrays/" + childDevice.device.deviceNetworkId) { response -> + + def data = response.data.data + def freshnessPeriod = data.freshness_period + def trayName = data.name + log.debug data + + int totalEggs = 0 + int oldEggs = 0 + + def now = new Date() + def nowUnixTime = now.getTime()/1000 + + data.eggs.each() { it -> + if (it != 0) + { + totalEggs++ + + def eggArriveDate = it + def eggStaleDate = eggArriveDate + freshnessPeriod + if ( nowUnixTime > eggStaleDate ){ + oldEggs++ + } + } + } + + int freshEggs = totalEggs - oldEggs + + if ( oldEggs > 0 ) { + childDevice?.sendEvent(name:"inventory",value:"haveBadEgg") + def msg = "${trayName} says: " + msg+= "Did you know that all it takes is one bad egg? " + msg+= "And it looks like I found one.\n\n" + msg+= "You should probably run an Egg Report before you use any eggs." + sendNotificationEvent(msg) + } + if ( totalEggs == 0 ) { + childDevice?.sendEvent(name:"inventory",value:"noEggs") + sendNotificationEvent("${trayName} says:\n'Oh no, I'm out of eggs!'") + sendNotificationEvent(msg) + } + if ( (freshEggs == totalEggs) && (totalEggs != 0) ) { + childDevice?.sendEvent(name:"inventory",value:"goodEggs") + } + childDevice?.sendEvent( name : "totalEggs", value : totalEggs , unit : "" ) + childDevice?.sendEvent( name : "freshEggs", value : freshEggs , unit : "" ) + childDevice?.sendEvent( name : "oldEggs", value : oldEggs , unit : "" ) + } +} + +def runEggReport(childDevice) +{ + apiGet("/eggtrays/" + childDevice.device.deviceNetworkId) { response -> + + def data = response.data.data + def trayName = data.name + def freshnessPeriod = data.freshness_period + def now = new Date() + def nowUnixTime = now.getTime()/1000 + + def eggArray = [] + + def i = 0 + + data.eggs.each() { it -> + if (it != 0 ) { + def eggArriveDate = it + def eggStaleDate = eggArriveDate + freshnessPeriod + if ( nowUnixTime > eggStaleDate ){ + eggArray.push("Bad ") + } else { + eggArray.push("Good ") + } + } else { + eggArray.push("Empty") + } + i++ + } + + def msg = " Egg Report for ${trayName}\n\n" + msg+= "#7:${eggArray[6]} #14:${eggArray[13]}\n" + msg+= "#6:${eggArray[5]} #13:${eggArray[12]}\n" + msg+= "#5:${eggArray[4]} #12:${eggArray[11]}\n" + msg+= "#4:${eggArray[3]} #11:${eggArray[10]}\n" + msg+= "#3:${eggArray[2]} #10:${eggArray[9]}\n" + msg+= "#2:${eggArray[1]} #9:${eggArray[8]}\n" + msg+= "#1:${eggArray[0]} #8:${eggArray[7]}\n" + msg+= " +\n" + msg+= " ===\n" + msg+= " ===" + + sendNotificationEvent(msg) + } +} + +def eggtrayEventHandler() +{ + log.debug "In eggtrayEventHandler..." + + def json = request.JSON + def dni = getChildDevice(json.eggtray_id) + + log.debug "event received from ${dni}" + + poll(dni) //sometimes events are stale, poll for all latest states + + + def html = """{"code":200,"message":"OK"}""" + render contentType: 'application/json', data: html +} + +///////////////////////////////////////////////////////////////////////// +// START PIGGY BANK SPECIFIC CODE HERE +///////////////////////////////////////////////////////////////////////// +def getPiggyBankUpdate(childDevice) +{ + apiGet("/piggy_banks/" + childDevice.device.deviceNetworkId) { response -> + def status = response.data.data + def alertData = status.triggers + + if (( alertData.enabled ) && ( state.lastCheckTime )) { + if ( alertData.triggered_at[0].toInteger() > state.lastCheckTime ) { + childDevice?.sendEvent(name:"acceleration",value:"active",unit:"") + } else { + childDevice?.sendEvent(name:"acceleration",value:"inactive",unit:"") + } + } + + childDevice?.sendEvent(name:"goal",value:dollarize(status.savings_goal),unit:"") + + childDevice?.sendEvent(name:"balance",value:dollarize(status.balance),unit:"") + + def now = new Date() + def longTime = now.getTime()/1000 + state.lastCheckTime = longTime.toInteger() + } +} + +def piggy_bankEventHandler() +{ + log.debug "In piggy_bankEventHandler..." + + def json = request.JSON + def dni = getChildDevice(json.piggy_bank_id) + + log.debug "event received from ${dni}" + + poll(dni) //sometimes events are stale, poll for all latest states + + + def html = """{"code":200,"message":"OK"}""" + render contentType: 'application/json', data: html +} + +///////////////////////////////////////////////////////////////////////// +// START SENSOR POD SPECIFIC CODE HERE +///////////////////////////////////////////////////////////////////////// +def getSensorPodUpdate(childDevice) +{ + apiGet("/sensor_pods/" + childDevice.device.deviceNetworkId) { response -> + def status = response.data.data.last_reading + + status.loudness ? childDevice?.sendEvent(name:"sound",value:"active",unit:"") : + childDevice?.sendEvent(name:"sound",value:"inactive",unit:"") + + status.brightness ? childDevice?.sendEvent(name:"light",value:"active",unit:"") : + childDevice?.sendEvent(name:"light",value:"inactive",unit:"") + + status.vibration ? childDevice?.sendEvent(name:"acceleration",value:"active",unit:"") : + childDevice?.sendEvent(name:"acceleration",value:"inactive",unit:"") + + status.external_power ? childDevice?.sendEvent(name:"powerSource",value:"powered",unit:"") : + childDevice?.sendEvent(name:"powerSource",value:"battery",unit:"") + + childDevice?.sendEvent(name:"humidity",value:status.humidity,unit:"") + + childDevice?.sendEvent(name:"battery",value:(status.battery * 100).toInteger(),unit:"") + + childDevice?.sendEvent(name:"temperature",value:cToF(status.temperature),unit:"F") + } +} + +def sensor_podEventHandler() +{ + log.debug "In sensor_podEventHandler..." + + def json = request.JSON + //log.debug json + def dni = getChildDevice(json.sensor_pod_id) + + log.debug "event received from ${dni}" + + poll(dni) //sometimes events are stale, poll for all latest states + + + def html = """{"code":200,"message":"OK"}""" + render contentType: 'application/json', data: html +} + +///////////////////////////////////////////////////////////////////////// +// START POWERSTRIP SPECIFIC CODE HERE +///////////////////////////////////////////////////////////////////////// + +def powerstripEventHandler() +{ + log.debug "In Powerstrip Event Handler..." + + def json = request.JSON + def outlets = json.outlets + + outlets.each() { + def dni = getChildDevice(it.outlet_id) + pollOutlet(dni) //sometimes events are stale, poll for all latest states + } + + def html = """{"code":200,"message":"OK"}""" + render contentType: 'application/json', data: html +} + +def pollOutlet(childDevice) +{ + log.debug "In pollOutlet" + + //login() + + log.debug "Polling powerstrip" + apiGet("/outlets/" + childDevice.device.deviceNetworkId) { response -> + def data = response.data.data + data.powered ? childDevice?.sendEvent(name:"switch",value:"on") : + childDevice?.sendEvent(name:"switch",value:"off") + } +} + +def on(childDevice) +{ + //login() + + apiPut("/outlets/" + childDevice.device.deviceNetworkId, [powered : true]) { response -> + def data = response.data.data + log.debug "Sending 'on' to device" + } +} + +def off(childDevice) +{ + //login() + + apiPut("/outlets/" + childDevice.device.deviceNetworkId, [powered : false]) { response -> + def data = response.data.data + log.debug "Sending 'off' to device" + } +} + +def createPowerstripChildren(deviceData) +{ + log.debug "In createPowerstripChildren" + + def powerstripName = deviceData.name + def deviceFile = "Quirky Wink Powerstrip" + + deviceData.outlets.each { + createChildDevice( deviceFile, it.outlet_id, it.name, "$powerstripName ${it.name}" ) + } +} + +private Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +private Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +private List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} + diff --git a/official/ready-for-rain.groovy b/official/ready-for-rain.groovy new file mode 100755 index 0000000..c7ffb39 --- /dev/null +++ b/official/ready-for-rain.groovy @@ -0,0 +1,143 @@ +/** + * Ready for Rain + * + * Author: brian@bevey.org + * Date: 9/10/13 + * + * Warn if doors or windows are open when inclement weather is approaching. + */ + +definition( + name: "Ready For Rain", + namespace: "imbrianj", + author: "brian@bevey.org", + description: "Warn if doors or windows are open when inclement weather is approaching.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + + if (!(location.zipCode || ( location.latitude && location.longitude )) && location.channelName == 'samsungtv') { + section { paragraph title: "Note:", "Location is required for this SmartApp. Go to 'Location Name' settings to setup your correct location." } + } + + if (location.channelName != 'samsungtv') { + section( "Set your location" ) { input "zipCode", "text", title: "Zip code" } + } + + section("Things to check?") { + input "sensors", "capability.contactSensor", multiple: true + } + + section("Notifications?") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + + section("Message interval?") { + input name: "messageDelay", type: "number", title: "Minutes (default to every message)", required: false + } +} + +def installed() { + init() +} + +def updated() { + unsubscribe() + unschedule() + init() +} + +def init() { + state.lastMessage = 0 + state.lastCheck = ["time": 0, "result": false] + schedule("0 0,30 * * * ?", scheduleCheck) // Check at top and half-past of every hour + subscribe(sensors, "contact.open", scheduleCheck) +} + +def scheduleCheck(evt) { + def open = sensors.findAll { it?.latestValue("contact") == "open" } + def plural = open.size() > 1 ? "are" : "is" + + // Only need to poll if we haven't checked in a while - and if something is left open. + if((now() - (30 * 60 * 1000) > state.lastCheck["time"]) && open) { + log.info("Something's open - let's check the weather.") + def response + if (location.channelName != 'samsungtv') + response = getWeatherFeature("forecast", zipCode) + else + response = getWeatherFeature("forecast") + def weather = isStormy(response) + + if(weather) { + send("${open.join(', ')} ${plural} open and ${weather} coming.") + } + } + + else if(((now() - (30 * 60 * 1000) <= state.lastCheck["time"]) && state.lastCheck["result"]) && open) { + log.info("We have fresh weather data, no need to poll.") + send("${open.join(', ')} ${plural} open and ${state.lastCheck["result"]} coming.") + } + + else { + log.info("Everything looks closed, no reason to check weather.") + } +} + +private send(msg) { + def delay = (messageDelay != null && messageDelay != "") ? messageDelay * 60 * 1000 : 0 + + if(now() - delay > state.lastMessage) { + state.lastMessage = now() + if(sendPushMessage == "Yes") { + log.debug("Sending push message.") + sendPush(msg) + } + + if(phone) { + log.debug("Sending text message.") + sendSms(phone, msg) + } + + log.debug(msg) + } + + else { + log.info("Have a message to send, but user requested to not get it.") + } +} + +private isStormy(json) { + def types = ["rain", "snow", "showers", "sprinkles", "precipitation"] + def forecast = json?.forecast?.txt_forecast?.forecastday?.first() + def result = false + + if(forecast) { + def text = forecast?.fcttext?.toLowerCase() + + log.debug(text) + + if(text) { + for (int i = 0; i < types.size() && !result; i++) { + if(text.contains(types[i])) { + result = types[i] + } + } + } + + else { + log.warn("Got forecast, couldn't parse.") + } + } + + else { + log.warn("Did not get a forecast: ${json}") + } + + state.lastCheck = ["time": now(), "result": result] + + return result +} diff --git a/official/ridiculously-automated-garage-door.groovy b/official/ridiculously-automated-garage-door.groovy new file mode 100755 index 0000000..824d8d9 --- /dev/null +++ b/official/ridiculously-automated-garage-door.groovy @@ -0,0 +1,209 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Ridiculously Automated Garage Door + * + * Author: SmartThings + * Date: 2013-03-10 + * + * Monitors arrival and departure of car(s) and + * + * 1) opens door when car arrives, + * 2) closes door after car has departed (for N minutes), + * 3) opens door when car door motion is detected, + * 4) closes door when door was opened due to arrival and interior door is closed. + */ + +definition( + name: "Ridiculously Automated Garage Door", + namespace: "smartthings", + author: "SmartThings", + description: "Monitors arrival and departure of car(s) and 1) opens door when car arrives, 2) closes door after car has departed (for N minutes), 3) opens door when car door motion is detected, 4) closes door when door was opened due to arrival and interior door is closed.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/garage_contact@2x.png" +) + +preferences { + + section("Garage door") { + input "doorSensor", "capability.contactSensor", title: "Which sensor?" + input "doorSwitch", "capability.momentary", title: "Which switch?" + input "openThreshold", "number", title: "Warn when open longer than (optional)",description: "Number of minutes", required: false + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Warn with text message (optional)", description: "Phone Number", required: false + } + } + section("Car(s) using this garage door") { + input "cars", "capability.presenceSensor", title: "Presence sensor", description: "Which car(s)?", multiple: true, required: false + input "carDoorSensors", "capability.accelerationSensor", title: "Car door sensor(s)", description: "Which car(s)?", multiple: true, required: false + } + section("Interior door (optional)") { + input "interiorDoorSensor", "capability.contactSensor", title: "Contact sensor?", required: false + } + section("False alarm threshold (defaults to 10 min)") { + input "falseAlarmThreshold", "number", title: "Number of minutes", required: false + } +} + +def installed() { + log.trace "installed()" + subscribe() +} + +def updated() { + log.trace "updated()" + unsubscribe() + subscribe() +} + +def subscribe() { + // log.debug "present: ${cars.collect{it.displayName + ': ' + it.currentPresence}}" + subscribe(doorSensor, "contact", garageDoorContact) + + subscribe(cars, "presence", carPresence) + subscribe(carDoorSensors, "acceleration", accelerationActive) + + if (interiorDoorSensor) { + subscribe(interiorDoorSensor, "contact.closed", interiorDoorClosed) + } +} + +def doorOpenCheck() +{ + final thresholdMinutes = openThreshold + if (thresholdMinutes) { + def currentState = doorSensor.contactState + log.debug "doorOpenCheck" + if (currentState?.value == "open") { + log.debug "open for ${now() - currentState.date.time}, openDoorNotificationSent: ${state.openDoorNotificationSent}" + if (!state.openDoorNotificationSent && now() - currentState.date.time > thresholdMinutes * 60 *1000) { + def msg = "${doorSwitch.displayName} was been open for ${thresholdMinutes} minutes" + log.info msg + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients) + } + else { + sendPush msg + if (phone) { + sendSms phone, msg + } + } + state.openDoorNotificationSent = true + } + } + else { + state.openDoorNotificationSent = false + } + } +} + +def carPresence(evt) +{ + log.info "$evt.name: $evt.value" + // time in which there must be no "not present" events in order to open the door + final openDoorAwayInterval = falseAlarmThreshold ? falseAlarmThreshold * 60 : 600 + + if (evt.value == "present") { + // A car comes home + + def car = getCar(evt) + def t0 = new Date(now() - (openDoorAwayInterval * 1000)) + def states = car.statesSince("presence", t0) + def recentNotPresentState = states.find{it.value == "not present"} + + if (recentNotPresentState) { + log.debug "Not opening ${doorSwitch.displayName} since car was not present at ${recentNotPresentState.date}, less than ${openDoorAwayInterval} sec ago" + } + else { + if (doorSensor.currentContact == "closed") { + openDoor() + sendPush "Opening garage door due to arrival of ${car.displayName}" + state.appOpenedDoor = now() + } + else { + log.debug "door already open" + } + } + } + else { + // A car departs + if (doorSensor.currentContact == "open") { + closeDoor() + log.debug "Closing ${doorSwitch.displayName} after departure" + sendPush("Closing ${doorSwitch.displayName} after departure") + + } + else { + log.debug "Not closing ${doorSwitch.displayName} because its already closed" + } + } +} + +def garageDoorContact(evt) +{ + log.info "garageDoorContact, $evt.name: $evt.value" + if (evt.value == "open") { + schedule("0 * * * * ?", "doorOpenCheck") + } + else { + unschedule("doorOpenCheck") + } +} + + +def interiorDoorClosed(evt) +{ + log.info "interiorContact, $evt.name: $evt.value" + + // time during which closing the interior door will shut the garage door, if the app opened it + final threshold = 15 * 60 * 1000 + if (state.appOpenedDoor && now() - state.appOpenedDoor < threshold) { + state.appOpenedDoor = 0 + closeDoor() + } + else { + log.debug "app didn't open door" + } +} + +def accelerationActive(evt) +{ + log.info "$evt.name: $evt.value" + + if (doorSensor.currentContact == "closed") { + log.debug "opening door when car door opened" + openDoor() + } +} + +private openDoor() +{ + if (doorSensor.currentContact == "closed") { + log.debug "opening door" + doorSwitch.push() + } +} + +private closeDoor() +{ + if (doorSensor.currentContact == "open") { + log.debug "closing door" + doorSwitch.push() + } +} + +private getCar(evt) +{ + cars.find{it.id == evt.deviceId} +} diff --git a/official/rise-and-shine.groovy b/official/rise-and-shine.groovy new file mode 100755 index 0000000..947067b --- /dev/null +++ b/official/rise-and-shine.groovy @@ -0,0 +1,129 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Rise and Shine + * + * Author: SmartThings + * Date: 2013-03-07 + */ + +definition( + name: "Rise and Shine", + namespace: "smartthings", + author: "SmartThings", + description: "Changes mode when someone wakes up after a set time in the morning.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/rise-and-shine.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/rise-and-shine@2x.png" +) + +preferences { + section("When there's motion on any of these sensors") { + input "motionSensors", "capability.motionSensor", multiple: true + } + section("During this time window (default End Time is 4:00 PM)") { + input "timeOfDay", "time", title: "Start Time?" + input "endTime", "time", title: "End Time?", required: false + } + section("Change to this mode") { + input "newMode", "mode", title: "Mode?" + } + section("And (optionally) turn on these appliances") { + input "switches", "capability.switch", multiple: true, required: false + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to") { + input "sendPushMessage", "enum", title: "Send a push notification?", options: ["Yes", "No"], required: false + input "phoneNumber", "phone", title: "Send a Text Message?", required: false + } + } +} + +def installed() { + log.debug "installed, current mode = ${location.mode}, state.actionTakenOn = ${state.actionTakenOn}" + initialize() +} + +def updated() { + log.debug "updated, current mode = ${location.mode}, state.actionTakenOn = ${state.actionTakenOn}" + unsubscribe() + initialize() +} + +def initialize() { + log.trace "timeOfDay: $timeOfDay, endTime: $endTime" + subscribe(motionSensors, "motion.active", motionActiveHandler) + subscribe(location, modeChangeHandler) + if (state.modeStartTime == null) { + state.modeStartTime = 0 + } +} + +def modeChangeHandler(evt) { + state.modeStartTime = now() +} + +def motionActiveHandler(evt) +{ + // for backward compatibility + if (state.modeStartTime == null) { + subscribe(location, modeChangeHandler) + state.modeStartTime = 0 + } + + def t0 = now() + def modeStartTime = new Date(state.modeStartTime) + def timeZone = location.timeZone ?: timeZone(timeOfDay) + def startTime = timeTodayAfter(modeStartTime, timeOfDay, timeZone) + def endTime = timeTodayAfter(startTime, endTime ?: "16:00", timeZone) + log.debug "startTime: $startTime, endTime: $endTime, t0: ${new Date(t0)}, modeStartTime: ${modeStartTime}, actionTakenOn: $state.actionTakenOn, currentMode: $location.mode, newMode: $newMode " + + if (t0 >= startTime.time && t0 <= endTime.time && location.mode != newMode) { + def message = "Good morning! SmartThings changed the mode to '$newMode'" + send(message) + setLocationMode(newMode) + log.debug message + + def dateString = new Date().format("yyyy-MM-dd") + log.debug "last turned on switches on ${state.actionTakenOn}, today is ${dateString}" + if (state.actionTakenOn != dateString) { + log.debug "turning on switches" + state.actionTakenOn = dateString + switches?.on() + } + + } + else { + log.debug "not in time window, or mode is already set, currentMode = ${location.mode}, newMode = $newMode" + } +} + +private send(msg) { + + if (location.contactBookEnabled) { + log.debug("sending notifications to: ${recipients?.size()}") + sendNotificationToContacts(msg, recipients) + } + else { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + } + + log.debug msg +} diff --git a/official/routine-director.groovy b/official/routine-director.groovy new file mode 100755 index 0000000..21d0c48 --- /dev/null +++ b/official/routine-director.groovy @@ -0,0 +1,346 @@ +/** + * Rotuine Director + * + * + * Changelog + * + * 2015-09-01 + * --Added Contact Book + * --Removed references to phrases and replaced with routines + * --Added bool logic to inputs instead of enum for "yes" "no" options + * --Fixed halting error with code installation + * + * Copyright 2015 Tim Slagle + * + * 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: "Routine Director", + namespace: "tslagle13", + author: "Tim Slagle", + description: "Monitor a set of presence sensors and activate routines based on whether your home is empty or occupied. Each presence status change will check against the current 'sun state' to run routines based on occupancy and whether the sun is up or down.", + category: "Convenience", + iconUrl: "http://icons.iconarchive.com/icons/icons8/ios7/512/Very-Basic-Home-Filled-icon.png", + iconX2Url: "http://icons.iconarchive.com/icons/icons8/ios7/512/Very-Basic-Home-Filled-icon.png" +) + +preferences { + page(name: "selectRoutines") + + page(name: "Settings", title: "Settings", uninstall: true, install: true) { + section("False alarm threshold (defaults to 10 min)") { + input "falseAlarmThreshold", "decimal", title: "Number of minutes", required: false + } + + section("Zip code (for sunrise/sunset)") { + input "zip", "text", required: true + } + + section("Notifications") { + input "sendPushMessage", "bool", title: "Send notifications when house is empty?" + input "sendPushMessageHome", "bool", title: "Send notifications when home is occupied?" + } + section("Send Notifications?") { + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Send an SMS to this number?", required:false + } + } + + section(title: "More options", hidden: hideOptionsSection(), hideable: true) { + label title: "Assign a name", required: false + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + } +} + +def selectRoutines() { + def configured = (settings.awayDay && settings.awayNight && settings.homeDay && settings.homeNight) + dynamicPage(name: "selectRoutines", title: "Configure", nextPage: "Settings", uninstall: true) { + section("Who?") { + input "people", "capability.presenceSensor", title: "Monitor These Presences", required: true, multiple: true, submitOnChange: true + } + + def routines = location.helloHome?.getPhrases()*.label + if (routines) { + routines.sort() + section("Run This Routine When...") { + log.trace routines + input "awayDay", "enum", title: "Everyone Is Away And It's Day", required: true, options: routines, submitOnChange: true + input "awayNight", "enum", title: "Everyone Is Away And It's Night", required: true, options: routines, submitOnChange: true + input "homeDay", "enum", title: "At Least One Person Is Home And It's Day", required: true, options: routines, submitOnChange: true + input "homeNight", "enum", title: "At Least One Person Is Home And It's Night", required: true, options: routines, submitOnChange: true + } + /* section("Select modes used for each condition.") { This allows the director to know which rotuine has already been ran so it does not run again if someone else comes home. + input "homeModeDay", "mode", title: "Select Mode Used for 'Home Day'", required: true + input "homeModeNight", "mode", title: "Select Mode Used for 'Home Night'", required: true + }*/ + } + } +} + +def installed() { + log.debug "Updated with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + subscribe(people, "presence", presence) + checkSun() + subscribe(location, "sunrise", setSunrise) + subscribe(location, "sunset", setSunset) + state.homestate = null +} + +//check current sun state when installed. +def checkSun() { + def zip = settings.zip as String + def sunInfo = getSunriseAndSunset(zipCode: zip) + def current = now() + + if (sunInfo.sunrise.time < current && sunInfo.sunset.time > current) { + state.sunMode = "sunrise" + runIn(60,"setSunrise") + } + else { + state.sunMode = "sunset" + runIn(60,"setSunset") + } +} + +//change to sunrise mode on sunrise event +def setSunrise(evt) { + state.sunMode = "sunrise"; + changeSunMode(newMode); + log.debug "Current sun mode is ${state.sunMode}" +} + +//change to sunset mode on sunset event +def setSunset(evt) { + state.sunMode = "sunset"; + changeSunMode(newMode) + log.debug "Current sun mode is ${state.sunMode}" +} + +//change mode on sun event +def changeSunMode(newMode) { + if (allOk) { + + if (everyoneIsAway()) /*&& (state.sunMode == "sunrise")*/ { + log.info("Home is Empty Setting New Away Mode") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + setAway() + } +/* + else if (everyoneIsAway() && (state.sunMode == "sunset")) { + log.info("Home is Empty Setting New Away Mode") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + setAway() + }*/ + else if (anyoneIsHome()) { + log.info("Home is Occupied Setting New Home Mode") + setHome() + + + } + } +} + +//presence change run logic based on presence state of home +def presence(evt) { + if (allOk) { + if (evt.value == "not present") { + log.debug("Checking if everyone is away") + + if (everyoneIsAway()) { + log.info("Nobody is home, running away sequence") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + runIn(delay, "setAway") + } + } + else { + def lastTime = state[evt.deviceId] + if (lastTime == null || now() - lastTime >= 1 * 60000) { + log.info("Someone is home, running home sequence") + setHome() + } + state[evt.deviceId] = now() + + } + } +} + +//if empty set home to one of the away modes +def setAway() { + if (everyoneIsAway()) { + if (state.sunMode == "sunset") { + def message = "Performing \"${awayNight}\" for you as requested." + log.info(message) + sendAway(message) + location.helloHome.execute(settings.awayNight) + state.homestate = "away" + + } + else if (state.sunMode == "sunrise") { + def message = "Performing \"${awayDay}\" for you as requested." + log.info(message) + sendAway(message) + location.helloHome.execute(settings.awayDay) + state.homestate = "away" + } + else { + log.debug("Mode is the same, not evaluating") + } + } +} + +//set home mode when house is occupied +def setHome() { + log.info("Setting Home Mode!!") + if (anyoneIsHome()) { + if (state.sunMode == "sunset") { + if (state.homestate != "homeNight") { + def message = "Performing \"${homeNight}\" for you as requested." + log.info(message) + sendHome(message) + location.helloHome.execute(settings.homeNight) + state.homestate = "homeNight" + } + } + + if (state.sunMode == "sunrise") { + if (state.homestate != "homeDay") { + def message = "Performing \"${homeDay}\" for you as requested." + log.info(message) + sendHome(message) + location.helloHome.execute(settings.homeDay) + state.homestate = "homeDay" + } + } + } +} + +private everyoneIsAway() { + def result = true + + if(people.findAll { it?.currentPresence == "present" }) { + result = false + } + + log.debug("everyoneIsAway: ${result}") + + return result +} + +private anyoneIsHome() { + def result = false + + if(people.findAll { it?.currentPresence == "present" }) { + result = true + } + + log.debug("anyoneIsHome: ${result}") + + return result +} + +def sendAway(msg) { + if (sendPushMessage) { + if (recipients) { + sendNotificationToContacts(msg, recipients) + } + else { + sendPush(msg) + if(phone){ + sendSms(phone, msg) + } + } + } + + log.debug(msg) +} + +def sendHome(msg) { + if (sendPushMessageHome) { + if (recipients) { + sendNotificationToContacts(msg, recipients) + } + else { + sendPush(msg) + if(phone){ + sendSms(phone, msg) + } + } + } + + log.debug(msg) +} + +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private getTimeIntervalLabel() { + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z"): "" +} + +private hideOptionsSection() { + (starting || ending || days || modes) ? false: true +} diff --git a/official/safe-watch.groovy b/official/safe-watch.groovy new file mode 100755 index 0000000..22d2c4d --- /dev/null +++ b/official/safe-watch.groovy @@ -0,0 +1,131 @@ +/** + * Safe Watch + * + * Author: brian@bevey.org + * Date: 2013-11-17 + * + * Watch a series of sensors for any anomalies for securing a safe or room. + */ + +definition( + name: "Safe Watch", + namespace: "imbrianj", + author: "brian@bevey.org", + description: "Watch a series of sensors for any anomalies for securing a safe.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("Things to secure?") { + input "contact", "capability.contactSensor", title: "Contact Sensor", required: false + input "motion", "capability.motionSensor", title: "Motion Sensor", required: false + input "knock", "capability.accelerationSensor", title: "Knock Sensor", required: false + input "axis", "capability.threeAxis", title: "Three-Axis Sensor", required: false + } + + section("Temperature monitor?") { + input "temp", "capability.temperatureMeasurement", title: "Temperature Sensor", required: false + input "maxTemp", "number", title: "Max Temperature (°${location.temperatureScale})", required: false + input "minTemp", "number", title: "Min Temperature (°${location.temperatureScale})", required: false + } + + section("When which people are away?") { + input "people", "capability.presenceSensor", multiple: true + } + + section("Notifications?") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + + section("Message interval?") { + input name: "messageDelay", type: "number", title: "Minutes (default to every message)", required: false + } +} + +def installed() { + init() +} + +def updated() { + unsubscribe() + init() +} + +def init() { + subscribe(contact, "contact.open", triggerContact) + subscribe(motion, "motion.active", triggerMotion) + subscribe(knock, "acceleration.active", triggerKnock) + subscribe(temp, "temperature", triggerTemp) + subscribe(axis, "threeAxis", triggerAxis) +} + +def triggerContact(evt) { + if(everyoneIsAway()) { + send("Safe Watch: ${contact.label ?: contact.name} was opened!") + } +} + +def triggerMotion(evt) { + if(everyoneIsAway()) { + send("Safe Watch: ${motion.label ?: motion.name} sensed motion!") + } +} + +def triggerKnock(evt) { + if(everyoneIsAway()) { + send("Safe Watch: ${knock.label ?: knock.name} was knocked!") + } +} + +def triggerTemp(evt) { + def temperature = evt.doubleValue + + if((maxTemp && maxTemp < temperature) || + (minTemp && minTemp > temperature)) { + send("Safe Watch: ${temp.label ?: temp.name} is ${temperature}") + } +} + +def triggerAxis(evt) { + if(everyoneIsAway()) { + send("Safe Watch: ${axis.label ?: axis.name} was tilted!") + } +} + +private everyoneIsAway() { + def result = true + + if(people.findAll { it?.currentPresence == "present" }) { + result = false + } + + log.debug("everyoneIsAway: ${result}") + + return result +} + +private send(msg) { + def delay = (messageDelay != null && messageDelay != "") ? messageDelay * 60 * 1000 : 0 + + if(now() - delay > state.lastMessage) { + state.lastMessage = now() + if(sendPushMessage == "Yes") { + log.debug("Sending push message.") + sendPush(msg) + } + + if(phone) { + log.debug("Sending text message.") + sendSms(phone, msg) + } + + log.debug(msg) + } + + else { + log.info("Have a message to send, but user requested to not get it.") + } +} diff --git a/official/scheduled-mode-change.groovy b/official/scheduled-mode-change.groovy new file mode 100755 index 0000000..6b23505 --- /dev/null +++ b/official/scheduled-mode-change.groovy @@ -0,0 +1,94 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Scheduled Mode Change - Presence Optional + * + * Author: SmartThings + + * + */ + +definition( + name: "Scheduled Mode Change", + namespace: "smartthings", + author: "SmartThings", + description: "Changes mode at a specific time of day.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/App-LightUpMyWorld@2x.png" +) + +preferences { + section("At this time every day") { + input "time", "time", title: "Time of Day" + } + section("Change to this mode") { + input "newMode", "mode", title: "Mode?" + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to") { + input "sendPushMessage", "enum", title: "Send a push notification?", options: ["Yes", "No"], required: false + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + } +} + +def installed() { + initialize() +} + +def updated() { + unschedule() + initialize() +} + +def initialize() { + schedule(time, changeMode) +} + +def changeMode() { + log.debug "changeMode, location.mode = $location.mode, newMode = $newMode, location.modes = $location.modes" + if (location.mode != newMode) { + if (location.modes?.find{it.name == newMode}) { + setLocationMode(newMode) + send "${label} has changed the mode to '${newMode}'" + } + else { + send "${label} tried to change to undefined mode '${newMode}'" + } + } +} + +private send(msg) { + + if (location.contactBookEnabled) { + log.debug("sending notifications to: ${recipients?.size()}") + sendNotificationToContacts(msg, recipients) + } + else { + if (sendPushMessage == "Yes") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + } + + log.debug msg +} + +private getLabel() { + app.label ?: "SmartThings" +} diff --git a/official/send-ham-bridge-command-when.groovy b/official/send-ham-bridge-command-when.groovy new file mode 100755 index 0000000..4e7b3d0 --- /dev/null +++ b/official/send-ham-bridge-command-when.groovy @@ -0,0 +1,84 @@ +/** + * Send HAM Bridge Command When… + * + * For more information about HAM Bridge please visit http://solutionsetcetera.com/HAMBridge/ + * + * Copyright 2014 Scottin Pollock + * + * 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: "Send HAM Bridge Command When", + namespace: "smartthings", + author: "Scottin Pollock", + description: "Sends a command to your HAM Bridge server when SmartThings are activated.", + category: "Convenience", + iconUrl: "http://solutionsetcetera.com/stuff/STIcons/HB.png", + iconX2Url: "http://solutionsetcetera.com/stuff/STIcons/HB@2x.png" +) + +preferences { + section("Choose one or more, when..."){ + input "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + input "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + input "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + input "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + input "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + input "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + input "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + input "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + input "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + input "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + } + section("Send this command to HAM Bridge"){ + input "HAMBcommand", "text", title: "Command to send", required: true + } + section("Server address and port number"){ + input "server", "text", title: "Server IP", description: "Your HAM Bridger Server IP", required: true + input "port", "number", title: "Port", description: "Port Number", required: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) +} + +def eventHandler(evt) { + sendHttp() +} + +def sendHttp() { +def ip = "${settings.server}:${settings.port}" +def deviceNetworkId = "1234" +sendHubCommand(new physicalgraph.device.HubAction("""GET /?${settings.HAMBcommand} HTTP/1.1\r\nHOST: $ip\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}")) +} \ No newline at end of file diff --git a/official/severe-weather-alert.groovy b/official/severe-weather-alert.groovy new file mode 100755 index 0000000..eafa863 --- /dev/null +++ b/official/severe-weather-alert.groovy @@ -0,0 +1,131 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Severe Weather Alert + * + * Author: SmartThings + * Date: 2013-03-04 + */ +definition( + name: "Severe Weather Alert", + namespace: "smartthings", + author: "SmartThings", + description: "Get a push notification when severe weather is in your area.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-SevereWeather.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-SevereWeather@2x.png" +) + +preferences { + + if (!(location.zipCode || ( location.latitude && location.longitude )) && location.channelName == 'samsungtv') { + section { paragraph title: "Note:", "Location is required for this SmartApp. Go to 'Location Name' settings to setup your correct location." } + } + + if (location.channelName != 'samsungtv') { + section( "Set your location" ) { input "zipCode", "text", title: "Zip code" } + } + + section ("In addition to push notifications, send text alerts to...") { + input("recipients", "contact", title: "Send notifications to") { + input "phone1", "phone", title: "Phone Number 1", required: false + input "phone2", "phone", title: "Phone Number 2", required: false + input "phone3", "phone", title: "Phone Number 3", required: false + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + scheduleJob() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unschedule() + scheduleJob() +} + +def scheduleJob() { + def sec = Math.round(Math.floor(Math.random() * 60)) + def min = Math.round(Math.floor(Math.random() * 60)) + def cron = "$sec $min * * * ?" + schedule(cron, "checkForSevereWeather") +} + +def checkForSevereWeather() { + def alerts + if(locationIsDefined()) { + if(zipcodeIsValid()) { + alerts = getWeatherFeature("alerts", zipCode)?.alerts + } else { + log.warn "Severe Weather Alert: Invalid zipcode entered, defaulting to location's zipcode" + alerts = getWeatherFeature("alerts")?.alerts + } + } else { + log.warn "Severe Weather Alert: Location is not defined" + } + + def newKeys = alerts?.collect{it.type + it.date_epoch} ?: [] + log.debug "Severe Weather Alert: newKeys: $newKeys" + + def oldKeys = state.alertKeys ?: [] + log.debug "Severe Weather Alert: oldKeys: $oldKeys" + + if (newKeys != oldKeys) { + + state.alertKeys = newKeys + + alerts.each {alert -> + if (!oldKeys.contains(alert.type + alert.date_epoch) && descriptionFilter(alert.description)) { + def msg = "Weather Alert! ${alert.description} from ${alert.date} until ${alert.expires}" + send(msg) + } + } + } +} + +def descriptionFilter(String description) { + def filterList = ["special", "statement", "test"] + def passesFilter = true + filterList.each() { word -> + if(description.toLowerCase().contains(word)) { passesFilter = false } + } + passesFilter +} + +def locationIsDefined() { + zipcodeIsValid() || location.zipCode || ( location.latitude && location.longitude ) +} + +def zipcodeIsValid() { + zipcode && zipcode.isNumber() && zipcode.size() == 5 +} + +private send(message) { + if (location.contactBookEnabled) { + log.debug("sending notifications to: ${recipients?.size()}") + sendNotificationToContacts(msg, recipients) + } + else { + sendPush message + if (settings.phone1) { + sendSms phone1, message + } + if (settings.phone2) { + sendSms phone2, message + } + if (settings.phone3) { + sendSms phone3, message + } + } +} diff --git a/official/shabbat-and-holiday-modes.groovy b/official/shabbat-and-holiday-modes.groovy new file mode 100755 index 0000000..86b6b8d --- /dev/null +++ b/official/shabbat-and-holiday-modes.groovy @@ -0,0 +1,208 @@ +/** + * HebcalModes + * + * Author: danielbarak@live.com + * Date: 2014-02-21 + */ + +// Automatically generated. Make future change here. +definition( + name: "Shabbat and Holiday Modes", + namespace: "ShabbatHolidayMode", + author: "danielbarak@live.com", + description: "Changes the mode at candle lighting and back after havdalah. Uses the HebCal.com API to look for days that are shabbat or chag and pull real time candle lighting and havdalah times to change modes automatically", + category: "My Apps", + iconUrl: "http://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Star_of_David.svg/200px-Star_of_David.svg.png", + iconX2Url: "http://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Star_of_David.svg/200px-Star_of_David.svg.png", + iconX3Url: "http://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Star_of_David.svg/200px-Star_of_David.svg.png") + +preferences { + + section("At Candlelighting Change Mode To:") + { + input "startMode", "mode", title: "Mode?" + } + section("At Havdalah Change Mode To:") + { + input "endMode", "mode", title: "Mode?" + } + section("Havdalah Offset (Usually 50 or 72)") { + input "havdalahOffset", "number", title: "Minutes After Sundown", required:true + } + section("Your ZipCode") { + input "zipcode", "text", title: "ZipCode", required:true + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes","No"]], required:false + input "phone", "phone", title: "Send a Text Message?", required: false + } + /**/ +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + poll(); + schedule("0 0 8 1/1 * ? *", poll) +} + +//Check hebcal for today's candle lighting or havdalah +def poll() +{ + + unschedule("endChag") + unschedule("setChag") + Hebcal_WebRequest() + +}//END def poll() + + + +/********************************************** +// HEBCAL FUNCTIONS +-----------------------------------------------*/ + +//This function is the web request and response parse +def Hebcal_WebRequest(){ + +def today = new Date().format("yyyy-MM-dd") +//def today = "2014-11-14" +def zip = settings.zip as String +def locale = getWeatherFeature("geolookup", zip) +def timezone = TimeZone.getTimeZone(locale.location.tz_long) +def hebcal_date +def hebcal_category +def hebcal_title +def candlelighting +def candlelightingLocalTime +def havdalah +def havdalahLocalTime +def pushMessage +def testmessage +def urlRequest = "http://www.hebcal.com/hebcal/?v=1&cfg=json&nh=off&nx=off&year=now&month=now&mf=off&c=on&zip=${zipcode}&m=${havdalahOffset}&s=off&D=off&d=off&o=off&ss=off" +log.trace "${urlRequest}" + +def hebcal = { response -> + hebcal_date = response.data.items.date + hebcal_category = response.data.items.category + hebcal_title = response.data.items.title + + for (int i = 0; i < hebcal_date.size; i++) + { + if(hebcal_date[i].split("T")[0]==today) + { + if(hebcal_category[i]=="candles") + { + candlelightingLocalTime = HebCal_GetTime12(hebcal_title[i]) + pushMessage = "Candle Lighting is at ${candlelightingLocalTime}" + candlelightingLocalTime = HebCal_GetTime24(hebcal_date[i]) + candlelighting = timeToday(candlelightingLocalTime, timezone) + + sendMessage(pushMessage) + schedule(candlelighting, setChag) + log.debug pushMessage + }//END if(hebcal_category=="candles") + + else if(hebcal_category[i]=="havdalah") + { + havdalahLocalTime = HebCal_GetTime12(hebcal_title[i]) + pushMessage = "Havdalah is at ${havdalahLocalTime}" + havdalahLocalTime = HebCal_GetTime24(hebcal_date[i]) + havdalah = timeToday(havdalahLocalTime, timezone) + testmessage = "Scheduling for ${havdalah}" + schedule(havdalah, endChag) + log.debug pushMessage + log.debug testmessage + }//END if(hebcal_category=="havdalah"){ + }//END if(hebcal_date[i].split("T")[0]==today) + + }//END for (int i = 0; i < hebcal_date.size; i++) + }//END def hebcal = { response -> +httpGet(urlRequest, hebcal); +}//END def queryHebcal() + + +//This function gets candle lighting time +def HebCal_GetTime12(hebcal_title){ +def returnTime = hebcal_title.split(":")[1] + ":" + hebcal_title.split(":")[2] + " " +return returnTime +}//END def HebCal_GetTime12() + +//This function gets candle lighting time +def HebCal_GetTime24(hebcal_date){ +def returnTime = hebcal_date.split("T")[1] +returnTime = returnTime.split("-")[0] +return returnTime +}//END def HebCal_GetTime12() + +/*----------------------------------------------- + END OF HEBCAL FUNCTIONS +-----------------------------------------------*/ +def setChag() +{ + + if (location.mode != startMode) + { + if (location.modes?.find{it.name == startMode}) + { + setLocationMode(startMode) + //sendMessage("Changed the mode to '${startMode}'") + def dayofweek = new Date().format("EEE") + if(dayofweek=='Fri'){ + sendMessage("Shabbat Shalom!") + } + else{ + sendMessage("Chag Sameach!") + } + + }//END if (location.modes?.find{it.name == startMode}) + else + { + sendMessage("Tried to change to undefined mode '${startMode}'") + }//END else + }//END if (location.mode != newMode) + + unschedule("setChag") +}//END def setChag() + + +def endChag() +{ + + if (location.mode != endMode) + { + if (location.modes?.find{it.name == endMode}) + { + setLocationMode(endMode) + sendMessage("Changed the mode to '${endMode}'") + }//END if (location.modes?.find{it.name == endMode}) + else + { + sendMessage("Tried to change to undefined mode '${endMode}'") + }//END else + }//END if (location.mode != endMode) + + //sendMessage("Shavuah Tov!") + unschedule("endChag") +}//END def setChag() + +def sendMessage(msg){ +if ( sendPushMessage != "No" ) { + log.debug( "sending push message" ) + //sendPush( msg ) + } + + if ( phone ) { + log.debug( "sending text message" ) + sendSms( phone, msg ) + } +}//END def sendMessage(msg) diff --git a/official/simple-control.groovy b/official/simple-control.groovy new file mode 100755 index 0000000..3b4bfaa --- /dev/null +++ b/official/simple-control.groovy @@ -0,0 +1,774 @@ +/** + * Simple Control + * + * Copyright 2015 Roomie Remote, Inc. + * + * Date: 2015-09-22 + */ + +definition( + name: "Simple Control", + namespace: "roomieremote-roomieconnect", + author: "Roomie Remote, Inc.", + description: "Integrate SmartThings with your Simple Control activities.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-60.png", + iconX2Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png", + iconX3Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png") + +preferences() +{ + section("Allow Simple Control to Monitor and Control These Things...") + { + input "switches", "capability.switch", title: "Which Switches?", multiple: true, required: false + input "locks", "capability.lock", title: "Which Locks?", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Which Thermostats?", multiple: true, required: false + input "doorControls", "capability.doorControl", title: "Which Door Controls?", multiple: true, required: false + input "colorControls", "capability.colorControl", title: "Which Color Controllers?", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Which Music Players?", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Which Adjustable Switches?", multiple: true, required: false + } + + page(name: "mainPage", title: "Simple Control Setup", content: "mainPage", refreshTimeout: 5) + page(name:"agentDiscovery", title:"Simple Sync Discovery", content:"agentDiscovery", refreshTimeout:5) + page(name:"manualAgentEntry") + page(name:"verifyManualEntry") +} + +mappings { + path("/devices") { + action: [ + GET: "getDevices" + ] + } + path("/:deviceType/devices") { + action: [ + GET: "getDevices", + POST: "handleDevicesWithIDs" + ] + } + path("/device/:deviceType/:id") { + action: [ + GET: "getDevice", + POST: "updateDevice" + ] + } + path("/subscriptions") { + action: [ + GET: "listSubscriptions", + POST: "addSubscription", // {"deviceId":"xxx", "attributeName":"xxx","callbackUrl":"http://..."} + DELETE: "removeAllSubscriptions" + ] + } + path("/subscriptions/:id") { + action: [ + DELETE: "removeSubscription" + ] + } +} + +private getAllDevices() +{ + //log.debug("getAllDevices()") + ([] + switches + locks + thermostats + imageCaptures + relaySwitches + doorControls + colorControls + musicPlayers + speechSynthesizers + switchLevels + indicators + mediaControllers + tones + tvs + alarms + valves + motionSensors + presenceSensors + beacons + pushButtons + smokeDetectors + coDetectors + contactSensors + accelerationSensors + energyMeters + powerMeters + lightSensors + humiditySensors + temperatureSensors + speechRecognizers + stepSensors + touchSensors)?.findAll()?.unique { it.id } +} + +def getDevices() +{ + //log.debug("getDevices, params: ${params}") + allDevices.collect { + //log.debug("device: ${it}") + deviceItem(it) + } +} + +def getDevice() +{ + //log.debug("getDevice, params: ${params}") + def device = allDevices.find { it.id == params.id } + if (!device) + { + render status: 404, data: '{"msg": "Device not found"}' + } + else + { + deviceItem(device) + } +} + +def handleDevicesWithIDs() +{ + //log.debug("handleDevicesWithIDs, params: ${params}") + def data = request.JSON + def ids = data?.ids?.findAll()?.unique() + //log.debug("ids: ${ids}") + def command = data?.command + def arguments = data?.arguments + def type = params?.deviceType + //log.debug("device type: ${type}") + if (command) + { + def statusCode = 404 + //log.debug("command ${command}, arguments ${arguments}") + for (devId in ids) + { + def device = allDevices.find { it.id == devId } + //log.debug("device: ${device}") + // Check if we have a device that responds to the specified command + if (validateCommand(device, type, command)) { + if (arguments) { + device."$command"(*arguments) + } + else { + device."$command"() + } + statusCode = 200 + } else { + statusCode = 403 + } + } + def responseData = "{}" + switch (statusCode) + { + case 403: + responseData = '{"msg": "Access denied. This command is not supported by current capability."}' + break + case 404: + responseData = '{"msg": "Device not found"}' + break + } + render status: statusCode, data: responseData + } + else + { + ids.collect { + def currentId = it + def device = allDevices.find { it.id == currentId } + if (device) + { + deviceItem(device) + } + } + } +} + +private deviceItem(device) { + [ + id: device.id, + label: device.displayName, + currentState: device.currentStates, + capabilities: device.capabilities?.collect {[ + name: it.name + ]}, + attributes: device.supportedAttributes?.collect {[ + name: it.name, + dataType: it.dataType, + values: it.values + ]}, + commands: device.supportedCommands?.collect {[ + name: it.name, + arguments: it.arguments + ]}, + type: [ + name: device.typeName, + author: device.typeAuthor + ] + ] +} + +def updateDevice() +{ + //log.debug("updateDevice, params: ${params}") + def data = request.JSON + def command = data?.command + def arguments = data?.arguments + def type = params?.deviceType + //log.debug("device type: ${type}") + + //log.debug("updateDevice, params: ${params}, request: ${data}") + if (!command) { + render status: 400, data: '{"msg": "command is required"}' + } else { + def statusCode = 404 + def device = allDevices.find { it.id == params.id } + if (device) { + // Check if we have a device that responds to the specified command + if (validateCommand(device, type, command)) { + if (arguments) { + device."$command"(*arguments) + } + else { + device."$command"() + } + statusCode = 200 + } else { + statusCode = 403 + } + } + + def responseData = "{}" + switch (statusCode) + { + case 403: + responseData = '{"msg": "Access denied. This command is not supported by current capability."}' + break + case 404: + responseData = '{"msg": "Device not found"}' + break + } + render status: statusCode, data: responseData + } +} + +/** + * Validating the command passed by the user based on capability. + * @return boolean + */ +def validateCommand(device, deviceType, command) { + //log.debug("validateCommand ${command}") + def capabilityCommands = getDeviceCapabilityCommands(device.capabilities) + //log.debug("capabilityCommands: ${capabilityCommands}") + def currentDeviceCapability = getCapabilityName(deviceType) + //log.debug("currentDeviceCapability: ${currentDeviceCapability}") + if (capabilityCommands[currentDeviceCapability]) { + return command in capabilityCommands[currentDeviceCapability] ? true : false + } else { + // Handling other device types here, which don't accept commands + httpError(400, "Bad request.") + } +} + +/** + * Need to get the attribute name to do the lookup. Only + * doing it for the device types which accept commands + * @return attribute name of the device type + */ +def getCapabilityName(type) { + switch(type) { + case "switches": + return "Switch" + case "locks": + return "Lock" + case "thermostats": + return "Thermostat" + case "doorControls": + return "Door Control" + case "colorControls": + return "Color Control" + case "musicPlayers": + return "Music Player" + case "switchLevels": + return "Switch Level" + default: + return type + } +} + +/** + * Constructing the map over here of + * supported commands by device capability + * @return a map of device capability -> supported commands + */ +def getDeviceCapabilityCommands(deviceCapabilities) { + def map = [:] + deviceCapabilities.collect { + map[it.name] = it.commands.collect{ it.name.toString() } + } + return map +} + +def listSubscriptions() +{ + //log.debug "listSubscriptions()" + app.subscriptions?.findAll { it.deviceId }?.collect { + def deviceInfo = state[it.deviceId] + def response = [ + id: it.id, + deviceId: it.deviceId, + attributeName: it.data, + handler: it.handler + ] + //if (!selectedAgent) { + response.callbackUrl = deviceInfo?.callbackUrl + //} + response + } ?: [] +} + +def addSubscription() { + def data = request.JSON + def attribute = data.attributeName + def callbackUrl = data.callbackUrl + + //log.debug "addSubscription, params: ${params}, request: ${data}" + if (!attribute) { + render status: 400, data: '{"msg": "attributeName is required"}' + } else { + def device = allDevices.find { it.id == data.deviceId } + if (device) { + //if (!selectedAgent) { + //log.debug "Adding callbackUrl: $callbackUrl" + state[device.id] = [callbackUrl: callbackUrl] + //} + //log.debug "Adding subscription" + def subscription = subscribe(device, attribute, deviceHandler) + if (!subscription || !subscription.eventSubscription) { + //log.debug("subscriptions: ${app.subscriptions}") + //for (sub in app.subscriptions) + //{ + //log.debug("subscription.id ${sub.id} subscription.handler ${sub.handler} subscription.deviceId ${sub.deviceId}") + //log.debug(sub.properties.collect{it}.join('\n')) + //} + subscription = app.subscriptions?.find { it.device.id == data.deviceId && it.data == attribute && it.handler == 'deviceHandler' } + } + + def response = [ + id: subscription.id, + deviceId: subscription.device?.id, + attributeName: subscription.data, + handler: subscription.handler + ] + //if (!selectedAgent) { + response.callbackUrl = callbackUrl + //} + response + } else { + render status: 400, data: '{"msg": "Device not found"}' + } + } +} + +def removeSubscription() +{ + def subscription = app.subscriptions?.find { it.id == params.id } + def device = subscription?.device + + //log.debug "removeSubscription, params: ${params}, subscription: ${subscription}, device: ${device}" + if (device) { + //log.debug "Removing subscription for device: ${device.id}" + state.remove(device.id) + unsubscribe(device) + } + render status: 204, data: "{}" +} + +def removeAllSubscriptions() +{ + for (sub in app.subscriptions) + { + //log.debug("Subscription: ${sub}") + //log.debug(sub.properties.collect{it}.join('\n')) + def handler = sub.handler + def device = sub.device + + if (device && handler == 'deviceHandler') + { + //log.debug(device.properties.collect{it}.join('\n')) + //log.debug("Removing subscription for device: ${device}") + state.remove(device.id) + unsubscribe(device) + } + } +} + +def deviceHandler(evt) { + def deviceInfo = state[evt.deviceId] + //if (selectedAgent) { + // sendToRoomie(evt, agentCallbackUrl) + //} else if (deviceInfo) { + if (deviceInfo) + { + if (deviceInfo.callbackUrl) { + sendToRoomie(evt, deviceInfo.callbackUrl) + } else { + log.warn "No callbackUrl set for device: ${evt.deviceId}" + } + } else { + log.warn "No subscribed device found for device: ${evt.deviceId}" + } +} + +def sendToRoomie(evt, String callbackUrl) { + def callback = new URI(callbackUrl) + def host = callback.port != -1 ? "${callback.host}:${callback.port}" : callback.host + def path = callback.query ? "${callback.path}?${callback.query}".toString() : callback.path + sendHubCommand(new physicalgraph.device.HubAction( + method: "POST", + path: path, + headers: [ + "Host": host, + "Content-Type": "application/json" + ], + body: [evt: [deviceId: evt.deviceId, name: evt.name, value: evt.value]] + )) +} + +def mainPage() +{ + if (canInstallLabs()) + { + return agentDiscovery() + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"mainPage", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section("Upgrade") + { + paragraph "$upgradeNeeded" + } + } + } +} + +def agentDiscovery(params=[:]) +{ + int refreshCount = !state.refreshCount ? 0 : state.refreshCount as int + state.refreshCount = refreshCount + 1 + def refreshInterval = refreshCount == 0 ? 2 : 5 + + if (!state.subscribe) + { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + //ssdp request every fifth refresh + if ((refreshCount % 5) == 0) + { + discoverAgents() + } + + def agentsDiscovered = agentsDiscovered() + + return dynamicPage(name:"agentDiscovery", title:"Pair with Simple Sync", nextPage:"", refreshInterval: refreshInterval, install:true, uninstall: true) { + section("Pair with Simple Sync") + { + input "selectedAgent", "enum", required:false, title:"Select Simple Sync\n(${agentsDiscovered.size() ?: 0} found)", multiple:false, options:agentsDiscovered + href(name:"manualAgentEntry", + title:"Manually Configure Simple Sync", + required:false, + page:"manualAgentEntry") + } + section("Allow Simple Control to Monitor and Control These Things...") + { + input "switches", "capability.switch", title: "Which Switches?", multiple: true, required: false + input "locks", "capability.lock", title: "Which Locks?", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Which Thermostats?", multiple: true, required: false + input "doorControls", "capability.doorControl", title: "Which Door Controls?", multiple: true, required: false + input "colorControls", "capability.colorControl", title: "Which Color Controllers?", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Which Music Players?", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Which Adjustable Switches?", multiple: true, required: false + } + } +} + +def manualAgentEntry() +{ + dynamicPage(name:"manualAgentEntry", title:"Manually Configure Simple Sync", nextPage:"verifyManualEntry", install:false, uninstall:true) { + section("Manually Configure Simple Sync") + { + paragraph "In the event that Simple Sync cannot be automatically discovered by your SmartThings hub, you may enter Simple Sync's IP address here." + input(name: "manualIPAddress", type: "text", title: "IP Address", required: true) + } + } +} + +def verifyManualEntry() +{ + def hexIP = convertIPToHexString(manualIPAddress) + def hexPort = convertToHexString(47147) + def uuid = "593C03D2-1DA9-4CDB-A335-6C6DC98E56C3" + def hubId = "" + + for (hub in location.hubs) + { + if (hub.localIP != null) + { + hubId = hub.id + break + } + } + + def manualAgent = [deviceType: "04", + mac: "unknown", + ip: hexIP, + port: hexPort, + ssdpPath: "/upnp/Roomie.xml", + ssdpUSN: "uuid:$uuid::urn:roomieremote-com:device:roomie:1", + hub: hubId, + verified: true, + name: "Simple Sync $manualIPAddress"] + + state.agents[uuid] = manualAgent + + addOrUpdateAgent(state.agents[uuid]) + + dynamicPage(name: "verifyManualEntry", title: "Manual Configuration Complete", nextPage: "", install:true, uninstall:true) { + section("") + { + paragraph("Tap Done to complete the installation process.") + } + } +} + +def discoverAgents() +{ + def urn = getURN() + + sendHubCommand(new physicalgraph.device.HubAction("lan discovery $urn", physicalgraph.device.Protocol.LAN)) +} + +def agentsDiscovered() +{ + def gAgents = getAgents() + def agents = gAgents.findAll { it?.value?.verified == true } + def map = [:] + agents.each + { + map["${it.value.uuid}"] = it.value.name + } + map +} + +def getAgents() +{ + if (!state.agents) + { + state.agents = [:] + } + + state.agents +} + +def installed() +{ + initialize() +} + +def updated() +{ + initialize() +} + +def initialize() +{ + if (state.subscribe) + { + unsubscribe() + state.subscribe = false + } + + if (selectedAgent) + { + addOrUpdateAgent(state.agents[selectedAgent]) + } +} + +def addOrUpdateAgent(agent) +{ + def children = getChildDevices() + def dni = agent.ip + ":" + agent.port + def found = false + + children.each + { + if ((it.getDeviceDataByName("mac") == agent.mac)) + { + found = true + + if (it.getDeviceNetworkId() != dni) + { + it.setDeviceNetworkId(dni) + } + } + else if (it.getDeviceNetworkId() == dni) + { + found = true + } + } + + if (!found) + { + addChildDevice("roomieremote-agent", "Simple Sync", dni, agent.hub, [label: "Simple Sync"]) + } +} + +def locationHandler(evt) +{ + def description = evt?.description + def urn = getURN() + def hub = evt?.hubId + def parsedEvent = parseEventMessage(description) + + parsedEvent?.putAt("hub", hub) + + //SSDP DISCOVERY EVENTS + if (parsedEvent?.ssdpTerm?.contains(urn)) + { + def agent = parsedEvent + def ip = convertHexToIP(agent.ip) + def agents = getAgents() + + agent.verified = true + agent.name = "Simple Sync $ip" + + if (!agents[agent.uuid]) + { + state.agents[agent.uuid] = agent + } + } +} + +private def parseEventMessage(String description) +{ + def event = [:] + def parts = description.split(',') + + parts.each + { part -> + part = part.trim() + if (part.startsWith('devicetype:')) + { + def valueString = part.split(":")[1].trim() + event.devicetype = valueString + } + else if (part.startsWith('mac:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.mac = valueString + } + } + else if (part.startsWith('networkAddress:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.ip = valueString + } + } + else if (part.startsWith('deviceAddress:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.port = valueString + } + } + else if (part.startsWith('ssdpPath:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.ssdpPath = valueString + } + } + else if (part.startsWith('ssdpUSN:')) + { + part -= "ssdpUSN:" + def valueString = part.trim() + if (valueString) + { + event.ssdpUSN = valueString + + def uuid = getUUIDFromUSN(valueString) + + if (uuid) + { + event.uuid = uuid + } + } + } + else if (part.startsWith('ssdpTerm:')) + { + part -= "ssdpTerm:" + def valueString = part.trim() + if (valueString) + { + event.ssdpTerm = valueString + } + } + else if (part.startsWith('headers')) + { + part -= "headers:" + def valueString = part.trim() + if (valueString) + { + event.headers = valueString + } + } + else if (part.startsWith('body')) + { + part -= "body:" + def valueString = part.trim() + if (valueString) + { + event.body = valueString + } + } + } + + event +} + +def getURN() +{ + return "urn:roomieremote-com:device:roomie:1" +} + +def getUUIDFromUSN(usn) +{ + def parts = usn.split(":") + + for (int i = 0; i < parts.size(); ++i) + { + if (parts[i] == "uuid") + { + return parts[i + 1] + } + } +} + +def String convertHexToIP(hex) +{ + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +def Integer convertHexToInt(hex) +{ + Integer.parseInt(hex,16) +} + +def String convertToHexString(n) +{ + String hex = String.format("%X", n.toInteger()) +} + +def String convertIPToHexString(ipString) +{ + String hex = ipString.tokenize(".").collect { + String.format("%02X", it.toInteger()) + }.join() +} + +def Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +def Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +def List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} + + diff --git a/official/simple-sync-connect.groovy b/official/simple-sync-connect.groovy new file mode 100755 index 0000000..44edc2f --- /dev/null +++ b/official/simple-sync-connect.groovy @@ -0,0 +1,383 @@ +/** + * Simple Sync Connect + * + * Copyright 2015 Roomie Remote, Inc. + * + * Date: 2015-09-22 + */ + +definition( + name: "Simple Sync Connect", + namespace: "roomieremote-raconnect", + author: "Roomie Remote, Inc.", + description: "Integrate SmartThings with your Simple Control activities via Simple Sync.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-60.png", + iconX2Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png", + iconX3Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png") + +preferences() +{ + page(name: "mainPage", title: "Simple Sync Setup", content: "mainPage", refreshTimeout: 5) + page(name:"agentDiscovery", title:"Simple Sync Discovery", content:"agentDiscovery", refreshTimeout:5) + page(name:"manualAgentEntry") + page(name:"verifyManualEntry") +} + +def mainPage() +{ + if (canInstallLabs()) + { + return agentDiscovery() + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"mainPage", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section("Upgrade") + { + paragraph "$upgradeNeeded" + } + } + } +} + +def agentDiscovery(params=[:]) +{ + int refreshCount = !state.refreshCount ? 0 : state.refreshCount as int + state.refreshCount = refreshCount + 1 + def refreshInterval = refreshCount == 0 ? 2 : 5 + + if (!state.subscribe) + { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + //ssdp request every fifth refresh + if ((refreshCount % 5) == 0) + { + discoverAgents() + } + + def agentsDiscovered = agentsDiscovered() + + return dynamicPage(name:"agentDiscovery", title:"Pair with Simple Sync", nextPage:"", refreshInterval: refreshInterval, install:true, uninstall: true) { + section("Pair with Simple Sync") + { + input "selectedAgent", "enum", required:true, title:"Select Simple Sync\n(${agentsDiscovered.size() ?: 0} found)", multiple:false, options:agentsDiscovered + href(name:"manualAgentEntry", + title:"Manually Configure Simple Sync", + required:false, + page:"manualAgentEntry") + } + } +} + +def manualAgentEntry() +{ + dynamicPage(name:"manualAgentEntry", title:"Manually Configure Simple Sync", nextPage:"verifyManualEntry", install:false, uninstall:true) { + section("Manually Configure Simple Sync") + { + paragraph "In the event that Simple Sync cannot be automatically discovered by your SmartThings hub, you may enter Simple Sync's IP address here." + input(name: "manualIPAddress", type: "text", title: "IP Address", required: true) + } + } +} + +def verifyManualEntry() +{ + def hexIP = convertIPToHexString(manualIPAddress) + def hexPort = convertToHexString(47147) + def uuid = "593C03D2-1DA9-4CDB-A335-6C6DC98E56C3" + def hubId = "" + + for (hub in location.hubs) + { + if (hub.localIP != null) + { + hubId = hub.id + break + } + } + + def manualAgent = [deviceType: "04", + mac: "unknown", + ip: hexIP, + port: hexPort, + ssdpPath: "/upnp/Roomie.xml", + ssdpUSN: "uuid:$uuid::urn:roomieremote-com:device:roomie:1", + hub: hubId, + verified: true, + name: "Simple Sync $manualIPAddress"] + + state.agents[uuid] = manualAgent + + addOrUpdateAgent(state.agents[uuid]) + + dynamicPage(name: "verifyManualEntry", title: "Manual Configuration Complete", nextPage: "", install:true, uninstall:true) { + section("") + { + paragraph("Tap Done to complete the installation process.") + } + } +} + +def discoverAgents() +{ + def urn = getURN() + + sendHubCommand(new physicalgraph.device.HubAction("lan discovery $urn", physicalgraph.device.Protocol.LAN)) +} + +def agentsDiscovered() +{ + def gAgents = getAgents() + def agents = gAgents.findAll { it?.value?.verified == true } + def map = [:] + agents.each + { + map["${it.value.uuid}"] = it.value.name + } + map +} + +def getAgents() +{ + if (!state.agents) + { + state.agents = [:] + } + + state.agents +} + +def installed() +{ + initialize() +} + +def updated() +{ + initialize() +} + +def initialize() +{ + if (state.subscribe) + { + unsubscribe() + state.subscribe = false + } + + if (selectedAgent) + { + addOrUpdateAgent(state.agents[selectedAgent]) + } +} + +def addOrUpdateAgent(agent) +{ + def children = getChildDevices() + def dni = agent.ip + ":" + agent.port + def found = false + + children.each + { + if ((it.getDeviceDataByName("mac") == agent.mac)) + { + found = true + + if (it.getDeviceNetworkId() != dni) + { + it.setDeviceNetworkId(dni) + } + } + else if (it.getDeviceNetworkId() == dni) + { + found = true + } + } + + if (!found) + { + addChildDevice("roomieremote-agent", "Simple Sync", dni, agent.hub, [label: "Simple Sync"]) + } +} + +def locationHandler(evt) +{ + def description = evt?.description + def urn = getURN() + def hub = evt?.hubId + def parsedEvent = parseEventMessage(description) + + parsedEvent?.putAt("hub", hub) + + //SSDP DISCOVERY EVENTS + if (parsedEvent?.ssdpTerm?.contains(urn)) + { + def agent = parsedEvent + def ip = convertHexToIP(agent.ip) + def agents = getAgents() + + agent.verified = true + agent.name = "Simple Sync $ip" + + if (!agents[agent.uuid]) + { + state.agents[agent.uuid] = agent + } + } +} + +private def parseEventMessage(String description) +{ + def event = [:] + def parts = description.split(',') + + parts.each + { part -> + part = part.trim() + if (part.startsWith('devicetype:')) + { + def valueString = part.split(":")[1].trim() + event.devicetype = valueString + } + else if (part.startsWith('mac:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.mac = valueString + } + } + else if (part.startsWith('networkAddress:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.ip = valueString + } + } + else if (part.startsWith('deviceAddress:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.port = valueString + } + } + else if (part.startsWith('ssdpPath:')) + { + def valueString = part.split(":")[1].trim() + if (valueString) + { + event.ssdpPath = valueString + } + } + else if (part.startsWith('ssdpUSN:')) + { + part -= "ssdpUSN:" + def valueString = part.trim() + if (valueString) + { + event.ssdpUSN = valueString + + def uuid = getUUIDFromUSN(valueString) + + if (uuid) + { + event.uuid = uuid + } + } + } + else if (part.startsWith('ssdpTerm:')) + { + part -= "ssdpTerm:" + def valueString = part.trim() + if (valueString) + { + event.ssdpTerm = valueString + } + } + else if (part.startsWith('headers')) + { + part -= "headers:" + def valueString = part.trim() + if (valueString) + { + event.headers = valueString + } + } + else if (part.startsWith('body')) + { + part -= "body:" + def valueString = part.trim() + if (valueString) + { + event.body = valueString + } + } + } + + event +} + +def getURN() +{ + return "urn:roomieremote-com:device:roomie:1" +} + +def getUUIDFromUSN(usn) +{ + def parts = usn.split(":") + + for (int i = 0; i < parts.size(); ++i) + { + if (parts[i] == "uuid") + { + return parts[i + 1] + } + } +} + +def String convertHexToIP(hex) +{ + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +def Integer convertHexToInt(hex) +{ + Integer.parseInt(hex,16) +} + +def String convertToHexString(n) +{ + String hex = String.format("%X", n.toInteger()) +} + +def String convertIPToHexString(ipString) +{ + String hex = ipString.tokenize(".").collect { + String.format("%02X", it.toInteger()) + }.join() +} + +def Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +def Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +def List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} \ No newline at end of file diff --git a/official/simple-sync-trigger.groovy b/official/simple-sync-trigger.groovy new file mode 100755 index 0000000..3fd4d08 --- /dev/null +++ b/official/simple-sync-trigger.groovy @@ -0,0 +1,296 @@ +/** + * Simple Sync Trigger + * + * Copyright 2015 Roomie Remote, Inc. + * + * Date: 2015-09-22 + */ +definition( + name: "Simple Sync Trigger", + namespace: "roomieremote-ratrigger", + author: "Roomie Remote, Inc.", + description: "Trigger Simple Control activities when certain actions take place in your home.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-60.png", + iconX2Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png", + iconX3Url: "https://s3.amazonaws.com/roomieuser/remotes/simplesync-120.png") + + +preferences { + page(name: "agentSelection", title: "Select your Simple Sync") + page(name: "refreshActivities", title: "Updating list of Simple Sync activities") + page(name: "control", title: "Run a Simple Control activity when something happens") + page(name: "timeIntervalInput", title: "Only during a certain time", install: true, uninstall: true) { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def agentSelection() +{ + if (agent) + { + state.refreshCount = 0 + } + + dynamicPage(name: "agentSelection", title: "Select your Simple Sync", nextPage: "control", install: false, uninstall: true) { + section { + input "agent", "capability.mediaController", title: "Simple Sync", required: true, multiple: false + } + } +} + +def control() +{ + def activities = agent.latestValue('activities') + + if (!activities || !state.refreshCount) + { + int refreshCount = !state.refreshCount ? 0 : state.refreshCount as int + state.refreshCount = refreshCount + 1 + def refreshInterval = refreshCount == 0 ? 2 : 4 + + // Request activities every 5th attempt + if((refreshCount % 5) == 0) + { + agent.getAllActivities() + } + + dynamicPage(name: "control", title: "Updating list of Simple Control activities", nextPage: "", refreshInterval: refreshInterval, install: false, uninstall: true) { + section("") { + paragraph "Retrieving activities from Simple Sync" + } + } + } + else + { + dynamicPage(name: "control", title: "Run a Simple Control activity when something happens", nextPage: "timeIntervalInput", install: false, uninstall: true) { + def anythingSet = anythingSet() + if (anythingSet) { + section("When..."){ + ifSet "motion", "capability.motionSensor", title: "Motion Detected", required: false, multiple: true + ifSet "motionInactive", "capability.motionSensor", title: "Motion Stops", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + } + section(anythingSet ? "Select additional triggers" : "When...", hideable: anythingSet, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "motionInactive", "capability.motionSensor", title: "Motion Stops", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + section("Run this activity"){ + input "activity", "enum", title: "Activity?", required: true, options: new groovy.json.JsonSlurper().parseText(activities ?: "[]").activities?.collect { ["${it.uuid}": it.name] } + } + + section("More options", hideable: true, hidden: true) { + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)" + } + } + } +} + +private anythingSet() { + for (name in ["motion","motionInactive","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","button1","triggerModes","timeOfDay"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + subscribeToEvents() +} + +def updated() { + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace "subscribeToEvents()" + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(motionInactive, "motion.inactive", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } +} + +def eventHandler(evt) { + if (allOk) { + def lastTime = state[frequencyKey(evt)] + if (oncePerDayOk(lastTime)) { + if (frequency) { + if (lastTime == null || now() - lastTime >= frequency * 60000) { + startActivity(evt) + } + else { + log.debug "Not taking action because $frequency minutes have not elapsed since last action" + } + } + else { + startActivity(evt) + } + } + else { + log.debug "Not taking action because it was already taken today" + } + } +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + startActivity(evt) +} + +private startActivity(evt) { + agent.startActivity(activity) + + if (frequency) { + state.lastActionTimeStamp = now() + } +} + +private frequencyKey(evt) { + //evt.deviceId ?: evt.value + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = true + if (oncePerDay) { + result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + } + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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) +} + +private timeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} \ No newline at end of file diff --git a/official/single-button-controller.groovy b/official/single-button-controller.groovy new file mode 100755 index 0000000..1d65e23 --- /dev/null +++ b/official/single-button-controller.groovy @@ -0,0 +1,151 @@ +/** + * Button Controller + * + * Copyright 2014 SmartThings + * + * 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: "Single Button Controller", + namespace: "smartthings", + author: "SmartThings", + description: "Use your Aeon Panic Button to setup events when the button is used", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + category: "Reviewers") + +preferences { + page(name: "selectButton") +} + +def selectButton() { + dynamicPage(name: "selectButton", title: "First, select your button device", install: true, uninstall: configured()) { + section { + input "buttonDevice", "device.aeonKeyFob", title: "Button", multiple: false, required: true + } + section("Lights") { + input "lights_1_pushed", "capability.switch", title: "Pushed", multiple: true, required: false + input "lights_1_held", "capability.switch", title: "Held", multiple: true, required: false + } + section("Locks") { + input "locks_1_pushed", "capability.lock", title: "Pushed", multiple: true, required: false + input "locks_1_held", "capability.lock", title: "Held", multiple: true, required: false + } + section("Sonos") { + input "sonos_1_pushed", "capability.musicPlayer", title: "Pushed", multiple: true, required: false + input "sonos_1_held", "capability.musicPlayer", title: "Held", multiple: true, required: false + } + section("Modes") { + input "mode_1_pushed", "mode", title: "Pushed", required: false + input "mode_1_held", "mode", title: "Held", required: false + } + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + section("Hello Home Actions") { + log.trace phrases + input "phrase_1_pushed", "enum", title: "Pushed", required: false, options: phrases + input "phrase_1_held", "enum", title: "Held", required: false, options: phrases + } + } + } +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + initialize() +} + +def initialize() { + subscribe(buttonDevice, "button", buttonEvent) +} + +def configured() { + return buttonDevice || buttonConfigured(1) +} + +def buttonConfigured(idx) { + return settings["lights_$idx_pushed"] || + settings["locks_$idx_pushed"] || + settings["sonos_$idx_pushed"] || + settings["mode_$idx_pushed"] +} + +def buttonEvent(evt){ + def buttonNumber = evt.data // why doesn't jsonData work? always returning [:] + def value = evt.value + log.debug "buttonEvent: $evt.name = $evt.value ($evt.data)" + log.debug "button: $buttonNumber, value: $value" + + def recentEvents = buttonDevice.eventsSince(new Date(now() - 3000)).findAll{it.value == evt.value} + log.debug "Found ${recentEvents.size()?:0} events in past 3 seconds" + + executeHandlers(1, value) +} + +def executeHandlers(buttonNumber, value) { + log.debug "executeHandlers: $buttonNumber - $value" + + def lights = find('lights', buttonNumber, value) + if (lights != null) toggle(lights) + + def locks = find('locks', buttonNumber, value) + if (locks != null) toggle(locks) + + def sonos = find('sonos', buttonNumber, value) + if (sonos != null) toggle(sonos) + + def mode = find('mode', buttonNumber, value) + if (mode != null) changeMode(mode) + + def phrase = find('phrase', buttonNumber, value) + if (phrase != null) location.helloHome.execute(phrase) +} + +def find(type, buttonNumber, value) { + def preferenceName = type + "_" + buttonNumber + "_" + value + def pref = settings[preferenceName] + if(pref != null) { + log.debug "Found: $pref for $preferenceName" + } + + return pref +} + +def toggle(devices) { + log.debug "toggle: $devices = ${devices*.currentValue('switch')}" + + if (devices*.currentValue('switch').contains('on')) { + devices.off() + } + else if (devices*.currentValue('switch').contains('off')) { + devices.on() + } + else if (devices*.currentValue('lock').contains('locked')) { + devices.unlock() + } + else if (devices*.currentValue('lock').contains('unlocked')) { + devices.lock() + } + else { + devices.on() + } +} + +def changeMode(mode) { + log.debug "changeMode: $mode, location.mode = $location.mode, location.modes = $location.modes" + + if (location.mode != mode && location.modes?.find { it.name == mode }) { + setLocationMode(mode) + } +} diff --git a/official/sleepy-time.groovy b/official/sleepy-time.groovy new file mode 100755 index 0000000..edec40c --- /dev/null +++ b/official/sleepy-time.groovy @@ -0,0 +1,89 @@ +/** + * Sleepy Time + * + * Copyright 2014 Physical Graph Corporation + * + * 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: "Sleepy Time", + namespace: "smartthings", + author: "SmartThings", + description: "Use Jawbone sleep mode events to automatically execute Hello, Home phrases. Automatially put the house to bed or wake it up in the morning by pushing the button on your UP.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/jawbone-up.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/jawbone-up@2x.png" +) + +preferences { + page(name: "selectPhrases") +} + +def selectPhrases() { + dynamicPage(name: "selectPhrases", title: "Configure Your Jawbone Phrases.", install: true, uninstall: true) { + section("Select your Jawbone UP") { + input "jawbone", "device.jawboneUser", title: "Jawbone UP", required: true, multiple: false, submitOnChange:true + } + + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + section("Hello Home Actions") { + log.trace phrases + input "sleepPhrase", "enum", title: "Enter Sleep Mode (Bedtime) Phrase", required: false, options: phrases, submitOnChange:true + input "wakePhrase", "enum", title: "Exit Sleep Mode (Waking Up) Phrase", required: false, options: phrases, submitOnChange:true + } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() + + log.debug "Subscribing to sleeping events." + + subscribe (jawbone, "sleeping", jawboneHandler) + +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + + log.debug "Subscribing to sleeping events." + + subscribe (jawbone, "sleeping", jawboneHandler) + + initialize() +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. +} + +def jawboneHandler(evt) { + log.debug "In Jawbone Event Handler, Event Name = ${evt.name}, Value = ${evt.value}" + if (evt.value == "sleeping" && sleepPhrase) { + log.debug "Sleeping" + sendNotificationEvent("Sleepy Time performing \"${sleepPhrase}\" for you as requested.") + location.helloHome.execute(settings.sleepPhrase) + } + else if (evt.value == "not sleeping" && wakePhrase) { + log.debug "Awake" + sendNotificationEvent("Sleepy Time performing \"${wakePhrase}\" for you as requested.") + location.helloHome.execute(settings.wakePhrase) + } + +} \ No newline at end of file diff --git a/official/smart-alarm.groovy b/official/smart-alarm.groovy new file mode 100755 index 0000000..89306d1 --- /dev/null +++ b/official/smart-alarm.groovy @@ -0,0 +1,1852 @@ +/** + * Smart Alarm is a multi-zone virtual alarm panel, featuring customizable + * security zones. Setting of an alarm can activate sirens, turn on light + * switches, push notification and text message. Alarm is armed and disarmed + * simply by setting SmartThings location 'mode'. + * + * Please visit for more + * information. + * + * Version 2.4.3 (7/7/2015) + * + * The latest version of this file can be found on GitHub at: + * + * + * -------------------------------------------------------------------------- + * + * Copyright (c) 2014 Statusbits.com + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +import groovy.json.JsonSlurper + +definition( + name: "Smart Alarm", + namespace: "statusbits", + author: "geko@statusbits.com", + description: '''A multi-zone virtual alarm panel, featuring customizable\ + security zones. Setting of an alarm can activate sirens, turn on light\ + switches, push notification and text message. Alarm is armed and disarmed\ + simply by setting SmartThings location 'mode'.''', + category: "Safety & Security", + iconUrl: "http://statusbits.github.io/icons/SmartAlarm-128.png", + iconX2Url: "http://statusbits.github.io/icons/SmartAlarm-256.png", + oauth: [displayName:"Smart Alarm", displayLink:"http://statusbits.github.io/smartalarm/"] +) + +preferences { + page name:"pageSetup" + page name:"pageAbout" + page name:"pageUninstall" + page name:"pageStatus" + page name:"pageHistory" + page name:"pageSelectZones" + page name:"pageConfigureZones" + page name:"pageArmingOptions" + page name:"pageAlarmOptions" + page name:"pageNotifications" + page name:"pageRemoteOptions" + page name:"pageRestApiOptions" +} + +mappings { + path("/armaway") { + action: [ GET: "apiArmAway" ] + } + + path("/armaway/:pincode") { + action: [ GET: "apiArmAway" ] + } + + path("/armstay") { + action: [ GET: "apiArmStay" ] + } + + path("/armstay/:pincode") { + action: [ GET: "apiArmStay" ] + } + + path("/disarm") { + action: [ GET: "apiDisarm" ] + } + + path("/disarm/:pincode") { + action: [ GET: "apiDisarm" ] + } + + path("/panic") { + action: [ GET: "apiPanic" ] + } + + path("/status") { + action: [ GET: "apiStatus" ] + } +} + +// Show setup page +def pageSetup() { + LOG("pageSetup()") + + if (state.version != getVersion()) { + return setupInit() ? pageAbout() : pageUninstall() + } + + if (getNumZones() == 0) { + return pageSelectZones() + } + + def alarmStatus = "Alarm is ${getAlarmStatus()}" + + def pageProperties = [ + name: "pageSetup", + //title: "Status", + nextPage: null, + install: true, + uninstall: state.installed + ] + + return dynamicPage(pageProperties) { + section("Status") { + if (state.zones.size() > 0) { + href "pageStatus", title:alarmStatus, description:"Tap for more information" + } else { + paragraph alarmStatus + } + if (state.history.size() > 0) { + href "pageHistory", title:"Event History", description:"Tap to view" + } + } + section("Setup Menu") { + href "pageSelectZones", title:"Add/Remove Zones", description:"Tap to open" + href "pageConfigureZones", title:"Configure Zones", description:"Tap to open" + href "pageArmingOptions", title:"Arming/Disarming Options", description:"Tap to open" + href "pageAlarmOptions", title:"Alarm Options", description:"Tap to open" + href "pageNotifications", title:"Notification Options", description:"Tap to open" + href "pageRemoteOptions", title:"Remote Control Options", description:"Tap to open" + href "pageRestApiOptions", title:"REST API Options", description:"Tap to open" + href "pageAbout", title:"About Smart Alarm", description:"Tap to open" + } + section([title:"Options", mobileOnly:true]) { + label title:"Assign a name", required:false + } + } +} + +// Show "About" page +def pageAbout() { + LOG("pageAbout()") + + def textAbout = + "Version ${getVersion()}\n${textCopyright()}\n\n" + + "You can contribute to the development of this app by making " + + "donation to geko@statusbits.com via PayPal." + + def hrefInfo = [ + url: "http://statusbits.github.io/smartalarm/", + style: "embedded", + title: "Tap here for more information...", + description:"http://statusbits.github.io/smartalarm/", + required: false + ] + + def pageProperties = [ + name: "pageAbout", + //title: "About", + nextPage: "pageSetup", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("About") { + paragraph textAbout + href hrefInfo + } + section("License") { + paragraph textLicense() + } + } +} + +// Show "Uninstall" page +def pageUninstall() { + LOG("pageUninstall()") + + def text = + "Smart Alarm version ${getVersion()} is not backward compatible " + + "with the currently installed version. Please uninstall the " + + "current version by tapping the Uninstall button below, then " + + "re-install Smart Alarm from the Dashboard. We are sorry for the " + + "inconvenience." + + def pageProperties = [ + name: "pageUninstall", + title: "Warning!", + nextPage: null, + uninstall: true, + install: false + ] + + return dynamicPage(pageProperties) { + section("Uninstall Required") { + paragraph text + } + } +} + +// Show "Status" page +def pageStatus() { + LOG("pageStatus()") + + def pageProperties = [ + name: "pageStatus", + //title: "Status", + nextPage: "pageSetup", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("Status") { + paragraph "Alarm is ${getAlarmStatus()}" + } + + if (settings.z_contact) { + section("Contact Sensors") { + settings.z_contact.each() { + def text = getZoneStatus(it, "contact") + if (text) { + paragraph text + } + } + } + } + + if (settings.z_motion) { + section("Motion Sensors") { + settings.z_motion.each() { + def text = getZoneStatus(it, "motion") + if (text) { + paragraph text + } + } + } + } + + if (settings.z_movement) { + section("Movement Sensors") { + settings.z_movement.each() { + def text = getZoneStatus(it, "acceleration") + if (text) { + paragraph text + } + } + } + } + + if (settings.z_smoke) { + section("Smoke & CO Sensors") { + settings.z_smoke.each() { + def text = getZoneStatus(it, "smoke") + if (text) { + paragraph text + } + } + } + } + + if (settings.z_water) { + section("Moisture Sensors") { + settings.z_water.each() { + def text = getZoneStatus(it, "water") + if (text) { + paragraph text + } + } + } + } + } +} + +// Show "History" page +def pageHistory() { + LOG("pageHistory()") + + def pageProperties = [ + name: "pageHistory", + //title: "Event History", + nextPage: "pageSetup", + uninstall: false + ] + + def history = atomicState.history + + return dynamicPage(pageProperties) { + section("Event History") { + if (history.size() == 0) { + paragraph "No history available." + } else { + paragraph "Not implemented" + } + } + } +} + +// Show "Add/Remove Zones" page +def pageSelectZones() { + LOG("pageSelectZones()") + + def helpPage = + "A security zone is an area of your property protected by a sensor " + + "(contact, motion, movement, moisture or smoke)." + + def inputContact = [ + name: "z_contact", + type: "capability.contactSensor", + title: "Which contact sensors?", + multiple: true, + required: false + ] + + def inputMotion = [ + name: "z_motion", + type: "capability.motionSensor", + title: "Which motion sensors?", + multiple: true, + required: false + ] + + def inputMovement = [ + name: "z_movement", + type: "capability.accelerationSensor", + title: "Which movement sensors?", + multiple: true, + required: false + ] + + def inputSmoke = [ + name: "z_smoke", + type: "capability.smokeDetector", + title: "Which smoke & CO sensors?", + multiple: true, + required: false + ] + + def inputMoisture = [ + name: "z_water", + type: "capability.waterSensor", + title: "Which moisture sensors?", + multiple: true, + required: false + ] + + def pageProperties = [ + name: "pageSelectZones", + //title: "Add/Remove Zones", + nextPage: "pageConfigureZones", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("Add/Remove Zones") { + paragraph helpPage + input inputContact + input inputMotion + input inputMovement + input inputSmoke + input inputMoisture + } + } +} + +// Show "Configure Zones" page +def pageConfigureZones() { + LOG("pageConfigureZones()") + + def helpZones = + "Security zones can be configured as either Exterior, Interior, " + + "Alert or Bypass. Exterior zones are armed in both Away and Stay " + + "modes, while Interior zones are armed only in Away mode, allowing " + + "you to move freely inside the premises while the alarm is armed " + + "in Stay mode. Alert zones are always armed and are typically used " + + "for smoke and flood alarms. Bypass zones are never armed. This " + + "allows you to temporarily exclude a zone from your security " + + "system.\n\n" + + "You can disable Entry and Exit Delays for individual zones." + + def zoneTypes = ["exterior", "interior", "alert", "bypass"] + + def pageProperties = [ + name: "pageConfigureZones", + //title: "Configure Zones", + nextPage: "pageSetup", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("Configure Zones") { + paragraph helpZones + } + + if (settings.z_contact) { + def devices = settings.z_contact.sort {it.displayName} + devices.each() { + def devId = it.id + section("${it.displayName} (contact)") { + input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"exterior" + input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:true + } + } + } + + if (settings.z_motion) { + def devices = settings.z_motion.sort {it.displayName} + devices.each() { + def devId = it.id + section("${it.displayName} (motion)") { + input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"interior" + input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:false + } + } + } + + if (settings.z_movement) { + def devices = settings.z_movement.sort {it.displayName} + devices.each() { + def devId = it.id + section("${it.displayName} (movement)") { + input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"interior" + input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:false + } + } + } + + if (settings.z_smoke) { + def devices = settings.z_smoke.sort {it.displayName} + devices.each() { + def devId = it.id + section("${it.displayName} (smoke)") { + input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"alert" + input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:false + } + } + } + + if (settings.z_water) { + def devices = settings.z_water.sort {it.displayName} + devices.each() { + def devId = it.id + section("${it.displayName} (moisture)") { + input "type_${devId}", "enum", title:"Zone Type", metadata:[values:zoneTypes], defaultValue:"alert" + input "delay_${devId}", "bool", title:"Entry/Exit Delays", defaultValue:false + } + } + } + } +} + +// Show "Arming/Disarming Options" page +def pageArmingOptions() { + LOG("pageArmingOptions()") + + def helpArming = + "Smart Alarm can be armed and disarmed by setting the home Mode. " + + "There are two arming modes - Stay and Away. Interior zones are " + + "not armed in Stay mode, allowing you to move freely inside your " + + "home." + + def helpDelay = + "Exit and entry delay allows you to exit the premises after arming " + + "your alarm system and enter the premises while the alarm system " + + "is armed without setting off an alarm. You can optionally disable " + + "entry and exit delay when the alarm is armed in Stay mode." + + def inputAwayModes = [ + name: "awayModes", + type: "mode", + title: "Arm 'Away' in these Modes", + multiple: true, + required: false + ] + + def inputStayModes = [ + name: "stayModes", + type: "mode", + title: "Arm 'Stay' in these Modes", + multiple: true, + required: false + ] + + def inputDisarmModes = [ + name: "disarmModes", + type: "mode", + title: "Disarm in these Modes", + multiple: true, + required: false + ] + + def inputDelay = [ + name: "delay", + type: "enum", + metadata: [values:["30","45","60","90"]], + title: "Delay (in seconds)", + defaultValue: "30", + required: true + ] + + def inputDelayStay = [ + name: "stayDelayOff", + type: "bool", + title: "Disable delays in Stay mode", + defaultValue: false, + required: true + ] + + def pageProperties = [ + name: "pageArmingOptions", + //title: "Arming/Disarming Options", + nextPage: "pageSetup", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("Arming/Disarming Options") { + paragraph helpArming + } + + section("Modes") { + input inputAwayModes + input inputStayModes + input inputDisarmModes + } + + section("Exit and Entry Delay") { + paragraph helpDelay + input inputDelay + input inputDelayStay + } + } +} + +// Show "Alarm Options" page +def pageAlarmOptions() { + LOG("pageAlarmOptions()") + + def helpAlarm = + "You can configure Smart Alarm to take several actions when an " + + "alarm is set off, such as turning on sirens and light switches, " + + "taking camera snapshots and executing a 'Hello, Home' action." + + def inputAlarms = [ + name: "alarms", + type: "capability.alarm", + title: "Which sirens?", + multiple: true, + required: false + ] + + def inputSirenMode = [ + name: "sirenMode", + type: "enum", + metadata: [values:["Off","Siren","Strobe","Both"]], + title: "Choose siren mode", + defaultValue: "Both" + ] + + def inputSwitches = [ + name: "switches", + type: "capability.switch", + title: "Which switches?", + multiple: true, + required: false + ] + + def inputCameras = [ + name: "cameras", + type: "capability.imageCapture", + title: "Which cameras?", + multiple: true, + required: false + ] + + def hhActions = getHelloHomeActions() + def inputHelloHome = [ + name: "helloHomeAction", + type: "enum", + title: "Which 'Hello, Home' action?", + metadata: [values: hhActions], + required: false + ] + + def pageProperties = [ + name: "pageAlarmOptions", + //title: "Alarm Options", + nextPage: "pageSetup", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("Alarm Options") { + paragraph helpAlarm + } + section("Sirens") { + input inputAlarms + input inputSirenMode + } + section("Switches") { + input inputSwitches + } + section("Cameras") { + input inputCameras + } + section("'Hello, Home' Actions") { + input inputHelloHome + } + } +} + +// Show "Notification Options" page +def pageNotifications() { + LOG("pageNotifications()") + + def helpAbout = + "You can configure Smart Alarm to notify you when it is armed, " + + "disarmed or when an alarm is set off. Notifications can be send " + + "using either Push messages, SMS (text) messages and Pushbullet " + + "messaging service. Smart Alarm can also notify you with sounds or " + + "voice alerts using compatible audio devices, such as Sonos." + + def inputPushAlarm = [ + name: "pushMessage", + type: "bool", + title: "Notify on Alarm", + defaultValue: true + ] + + def inputPushStatus = [ + name: "pushStatusMessage", + type: "bool", + title: "Notify on Status Change", + defaultValue: true + ] + + def inputPhone1 = [ + name: "phone1", + type: "phone", + title: "Send to this number", + required: false + ] + + def inputPhone1Alarm = [ + name: "smsAlarmPhone1", + type: "bool", + title: "Notify on Alarm", + defaultValue: false + ] + + def inputPhone1Status = [ + name: "smsStatusPhone1", + type: "bool", + title: "Notify on Status Change", + defaultValue: false + ] + + def inputPhone2 = [ + name: "phone2", + type: "phone", + title: "Send to this number", + required: false + ] + + def inputPhone2Alarm = [ + name: "smsAlarmPhone2", + type: "bool", + title: "Notify on Alarm", + defaultValue: false + ] + + def inputPhone2Status = [ + name: "smsStatusPhone2", + type: "bool", + title: "Notify on Status Change", + defaultValue: false + ] + + def inputPhone3 = [ + name: "phone3", + type: "phone", + title: "Send to this number", + required: false + ] + + def inputPhone3Alarm = [ + name: "smsAlarmPhone3", + type: "bool", + title: "Notify on Alarm", + defaultValue: false + ] + + def inputPhone3Status = [ + name: "smsStatusPhone3", + type: "bool", + title: "Notify on Status Change", + defaultValue: false + ] + + def inputPhone4 = [ + name: "phone4", + type: "phone", + title: "Send to this number", + required: false + ] + + def inputPhone4Alarm = [ + name: "smsAlarmPhone4", + type: "bool", + title: "Notify on Alarm", + defaultValue: false + ] + + def inputPhone4Status = [ + name: "smsStatusPhone4", + type: "bool", + title: "Notify on Status Change", + defaultValue: false + ] + + def inputPushbulletDevice = [ + name: "pushbullet", + type: "device.pushbullet", + title: "Which Pushbullet devices?", + multiple: true, + required: false + ] + + def inputPushbulletAlarm = [ + name: "pushbulletAlarm", + type: "bool", + title: "Notify on Alarm", + defaultValue: true + ] + + def inputPushbulletStatus = [ + name: "pushbulletStatus", + type: "bool", + title: "Notify on Status Change", + defaultValue: true + ] + + def inputAudioPlayers = [ + name: "audioPlayer", + type: "capability.musicPlayer", + title: "Which audio players?", + multiple: true, + required: false + ] + + def inputSpeechOnAlarm = [ + name: "speechOnAlarm", + type: "bool", + title: "Notify on Alarm", + defaultValue: true + ] + + def inputSpeechOnStatus = [ + name: "speechOnStatus", + type: "bool", + title: "Notify on Status Change", + defaultValue: true + ] + + def inputSpeechTextAlarm = [ + name: "speechText", + type: "text", + title: "Alarm Phrase", + required: false + ] + + def inputSpeechTextArmedAway = [ + name: "speechTextArmedAway", + type: "text", + title: "Armed Away Phrase", + required: false + ] + + def inputSpeechTextArmedStay = [ + name: "speechTextArmedStay", + type: "text", + title: "Armed Stay Phrase", + required: false + ] + + def inputSpeechTextDisarmed = [ + name: "speechTextDisarmed", + type: "text", + title: "Disarmed Phrase", + required: false + ] + + def pageProperties = [ + name: "pageNotifications", + //title: "Notification Options", + nextPage: "pageSetup", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("Notification Options") { + paragraph helpAbout + } + section("Push Notifications") { + input inputPushAlarm + input inputPushStatus + } + section("Text Message (SMS) #1") { + input inputPhone1 + input inputPhone1Alarm + input inputPhone1Status + } + section("Text Message (SMS) #2") { + input inputPhone2 + input inputPhone2Alarm + input inputPhone2Status + } + section("Text Message (SMS) #3") { + input inputPhone3 + input inputPhone3Alarm + input inputPhone3Status + } + section("Text Message (SMS) #4") { + input inputPhone4 + input inputPhone4Alarm + input inputPhone4Status + } + section("Pushbullet Notifications") { + input inputPushbulletDevice + input inputPushbulletAlarm + input inputPushbulletStatus + } + section("Audio Notifications") { + input inputAudioPlayers + input inputSpeechOnAlarm + input inputSpeechOnStatus + input inputSpeechTextAlarm + input inputSpeechTextArmedAway + input inputSpeechTextArmedStay + input inputSpeechTextDisarmed + } + } +} + +// Show "Remote Control Options" page +def pageRemoteOptions() { + LOG("pageRemoteOptions()") + + def helpRemote = + "You can arm and disarm Smart Alarm using any compatible remote " + + "control, for example Aeon Labs Minimote." + + def inputRemotes = [ + name: "remotes", + type: "capability.button", + title: "Which remote controls?", + multiple: true, + required: false + ] + + def inputArmAwayButton = [ + name: "buttonArmAway", + type: "number", + title: "Which button?", + required: false + ] + + def inputArmAwayHold = [ + name: "holdArmAway", + type: "bool", + title: "Hold to activate", + defaultValue: false, + required: true + ] + + def inputArmStayButton = [ + name: "buttonArmStay", + type: "number", + title: "Which button?", + required: false + ] + + def inputArmStayHold = [ + name: "holdArmStay", + type: "bool", + title: "Hold to activate", + defaultValue: false, + required: true + ] + + def inputDisarmButton = [ + name: "buttonDisarm", + type: "number", + title: "Which button?", + required: false + ] + + def inputDisarmHold = [ + name: "holdDisarm", + type: "bool", + title: "Hold to activate", + defaultValue: false, + required: true + ] + + def inputPanicButton = [ + name: "buttonPanic", + type: "number", + title: "Which button?", + required: false + ] + + def inputPanicHold = [ + name: "holdPanic", + type: "bool", + title: "Hold to activate", + defaultValue: false, + required: true + ] + + def pageProperties = [ + name: "pageRemoteOptions", + //title: "Remote Control Options", + nextPage: "pageSetup", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("Remote Control Options") { + paragraph helpRemote + input inputRemotes + } + + section("Arm Away Button") { + input inputArmAwayButton + input inputArmAwayHold + } + + section("Arm Stay Button") { + input inputArmStayButton + input inputArmStayHold + } + + section("Disarm Button") { + input inputDisarmButton + input inputDisarmHold + } + + section("Panic Button") { + input inputPanicButton + input inputPanicHold + } + } +} + +// Show "REST API Options" page +def pageRestApiOptions() { + LOG("pageRestApiOptions()") + + def textHelp = + "Smart Alarm can be controlled remotely by any Web client using " + + "REST API. Please refer to Smart Alarm documentation for more " + + "information." + + def textPincode = + "You can specify optional PIN code to protect arming and disarming " + + "Smart Alarm via REST API from unauthorized access. If set, the " + + "PIN code is always required for disarming Smart Alarm, however " + + "you can optionally turn it off for arming Smart Alarm." + + def inputRestApi = [ + name: "restApiEnabled", + type: "bool", + title: "Enable REST API", + defaultValue: false + ] + + def inputPincode = [ + name: "pincode", + type: "number", + title: "PIN Code", + required: false + ] + + def inputArmWithPin = [ + name: "armWithPin", + type: "bool", + title: "Require PIN code to arm", + defaultValue: true + ] + + def pageProperties = [ + name: "pageRestApiOptions", + //title: "REST API Options", + nextPage: "pageSetup", + uninstall: false + ] + + return dynamicPage(pageProperties) { + section("REST API Options") { + paragraph textHelp + input inputRestApi + } + + section("PIN Code") { + paragraph textPincode + input inputPincode + input inputArmWithPin + } + + if (isRestApiEnabled()) { + section("REST API Info") { + paragraph "App ID:\n${app.id}" + paragraph "Access Token:\n${state.accessToken}" + } + } + } +} + +def installed() { + LOG("installed()") + + initialize() + state.installed = true +} + +def updated() { + LOG("updated()") + + unschedule() + unsubscribe() + initialize() +} + +private def setupInit() { + LOG("setupInit()") + + if (state.installed == null) { + state.installed = false + state.armed = false + state.zones = [] + state.alarms = [] + state.history = [] + } else { + def version = state.version as String + if (version == null || version.startsWith('1')) { + return false + } + } + + state.version = getVersion() + return true +} + +private def initialize() { + log.info "Smart Alarm. Version ${getVersion()}. ${textCopyright()}" + LOG("settings: ${settings}") + + clearAlarm() + state.delay = settings.delay?.toInteger() ?: 30 + state.offSwitches = [] + state.history = [] + + if (settings.awayModes?.contains(location.mode)) { + state.armed = true + state.stay = false + } else if (settings.stayModes?.contains(location.mode)) { + state.armed = true + state.stay = true + } else { + state.armed = false + state.stay = false + } + + initZones() + initButtons() + initRestApi() + subscribe(location, onLocation) + + STATE() +} + +private def clearAlarm() { + LOG("clearAlarm()") + + state.alarms = [] + settings.alarms*.off() + + // Turn off only those switches that we've turned on + def switchesOff = state.offSwitches + if (switchesOff) { + LOG("switchesOff: ${switchesOff}") + settings.switches.each() { + if (switchesOff.contains(it.id)) { + it.off() + } + } + state.offSwitches = [] + } +} + +private def initZones() { + LOG("initZones()") + + state.zones = [] + + state.zones << [ + deviceId: null, + sensorType: "panic", + zoneType: "alert", + delay: false + ] + + if (settings.z_contact) { + settings.z_contact.each() { + state.zones << [ + deviceId: it.id, + sensorType: "contact", + zoneType: settings["type_${it.id}"] ?: "exterior", + delay: settings["delay_${it.id}"] + ] + } + subscribe(settings.z_contact, "contact.open", onContact) + } + + if (settings.z_motion) { + settings.z_motion.each() { + state.zones << [ + deviceId: it.id, + sensorType: "motion", + zoneType: settings["type_${it.id}"] ?: "interior", + delay: settings["delay_${it.id}"] + ] + } + subscribe(settings.z_motion, "motion.active", onMotion) + } + + if (settings.z_movement) { + settings.z_movement.each() { + state.zones << [ + deviceId: it.id, + sensorType: "acceleration", + zoneType: settings["type_${it.id}"] ?: "interior", + delay: settings["delay_${it.id}"] + ] + } + subscribe(settings.z_movement, "acceleration.active", onMovement) + } + + if (settings.z_smoke) { + settings.z_smoke.each() { + state.zones << [ + deviceId: it.id, + sensorType: "smoke", + zoneType: settings["type_${it.id}"] ?: "alert", + delay: settings["delay_${it.id}"] + ] + } + subscribe(settings.z_smoke, "smoke.detected", onSmoke) + subscribe(settings.z_smoke, "smoke.tested", onSmoke) + subscribe(settings.z_smoke, "carbonMonoxide.detected", onSmoke) + subscribe(settings.z_smoke, "carbonMonoxide.tested", onSmoke) + } + + if (settings.z_water) { + settings.z_water.each() { + state.zones << [ + deviceId: it.id, + sensorType: "water", + zoneType: settings["type_${it.id}"] ?: "alert", + delay: settings["delay_${it.id}"] + ] + } + subscribe(settings.z_water, "water.wet", onWater) + } + + state.zones.each() { + def zoneType = it.zoneType + + if (zoneType == "alert") { + it.armed = true + } else if (zoneType == "exterior") { + it.armed = state.armed + } else if (zoneType == "interior") { + it.armed = state.armed && !state.stay + } else { + it.armed = false + } + } +} + +private def initButtons() { + LOG("initButtons()") + + state.buttonActions = [] + if (settings.remotes) { + if (settings.buttonArmAway) { + def button = settings.buttonArmAway.toInteger() + def event = settings.holdArmAway ? "held" : "pushed" + state.buttonActions << [button:button, event:event, action:"armAway"] + } + + if (settings.buttonArmStay) { + def button = settings.buttonArmStay.toInteger() + def event = settings.holdArmStay ? "held" : "pushed" + state.buttonActions << [button:button, event:event, action:"armStay"] + } + + if (settings.buttonDisarm) { + def button = settings.buttonDisarm.toInteger() + def event = settings.holdDisarm ? "held" : "pushed" + state.buttonActions << [button:button, event:event, action:"disarm"] + } + + if (settings.buttonPanic) { + def button = settings.buttonPanic.toInteger() + def event = settings.holdPanic ? "held" : "pushed" + state.buttonActions << [button:button, event:event, action:"panic"] + } + + if (state.buttonActions) { + subscribe(settings.remotes, "button", onButtonEvent) + } + } +} + +private def initRestApi() { + if (settings.restApiEnabled) { + if (!state.accessToken) { + def token = createAccessToken() + LOG("Created new access token: ${token})") + } + state.url = "https://graph.api.smartthings.com/api/smartapps/installations/${app.id}/" + log.info "REST API enabled" + } else { + state.url = "" + log.info "REST API disabled" + } +} + +private def isRestApiEnabled() { + return settings.restApiEnabled && state.accessToken +} + +def onContact(evt) { onZoneEvent(evt, "contact") } +def onMotion(evt) { onZoneEvent(evt, "motion") } +def onMovement(evt) { onZoneEvent(evt, "acceleration") } +def onSmoke(evt) { onZoneEvent(evt, "smoke") } +def onWater(evt) { onZoneEvent(evt, "water") } + +private def onZoneEvent(evt, sensorType) { + LOG("onZoneEvent(${evt.displayName}, ${sensorType})") + + def zone = getZoneForDevice(evt.deviceId, sensorType) + if (!zone) { + log.warn "Cannot find zone for device ${evt.deviceId}" + return + } + + if (zone.armed) { + state.alarms << evt.displayName + if (zone.zoneType == "alert" || !zone.delay || (state.stay && settings.stayDelayOff)) { + activateAlarm() + } else { + myRunIn(state.delay, activateAlarm) + } + } +} + +def onLocation(evt) { + LOG("onLocation(${evt.value})") + + String mode = evt.value + if (settings.awayModes?.contains(mode)) { + armAway() + } else if (settings.stayModes?.contains(mode)) { + armStay() + } else if (settings.disarmModes?.contains(mode)) { + disarm() + } +} + +def onButtonEvent(evt) { + LOG("onButtonEvent(${evt.displayName})") + + if (!state.buttonActions || !evt.data) { + return + } + + def slurper = new JsonSlurper() + def data = slurper.parseText(evt.data) + def button = data.buttonNumber?.toInteger() + if (button) { + LOG("Button '${button}' was ${evt.value}.") + def item = state.buttonActions.find { + it.button == button && it.event == evt.value + } + + if (item) { + LOG("Executing '${item.action}' button action") + "${item.action}"() + } + } +} + +def armAway() { + LOG("armAway()") + + if (!atomicState.armed || atomicState.stay) { + armPanel(false) + } +} + +def armStay() { + LOG("armStay()") + + if (!atomicState.armed || !atomicState.stay) { + armPanel(true) + } +} + +def disarm() { + LOG("disarm()") + + if (atomicState.armed) { + state.armed = false + state.zones.each() { + if (it.zoneType != "alert") { + it.armed = false + } + } + + reset() + } +} + +def panic() { + LOG("panic()") + + state.alarms << "Panic" + activateAlarm() +} + +def reset() { + LOG("reset()") + + unschedule() + clearAlarm() + + // Send notification + def msg = "${location.name} is " + if (state.armed) { + msg += "ARMED " + msg += state.stay ? "STAY" : "AWAY" + } else { + msg += "DISARMED." + } + + notify(msg) + notifyVoice() +} + +def exitDelayExpired() { + LOG("exitDelayExpired()") + + def armed = atomicState.armed + def stay = atomicState.stay + if (!armed) { + log.warn "exitDelayExpired: unexpected state!" + STATE() + return + } + + state.zones.each() { + def zoneType = it.zoneType + if (zoneType == "exterior" || (zoneType == "interior" && !stay)) { + it.armed = true + } + } + + def msg = "${location.name}: all " + if (stay) { + msg += "exterior " + } + msg += "zones are armed." + + notify(msg) +} + +private def armPanel(stay) { + LOG("armPanel(${stay})") + + unschedule() + clearAlarm() + + state.armed = true + state.stay = stay + + def armDelay = false + state.zones.each() { + def zoneType = it.zoneType + if (zoneType == "exterior") { + if (it.delay) { + it.armed = false + armDelay = true + } else { + it.armed = true + } + } else if (zoneType == "interior") { + if (stay) { + it.armed = false + } else if (it.delay) { + it.armed = false + armDelay = true + } else { + it.armed = true + } + } + } + + def delay = armDelay && !(stay && settings.stayDelayOff) ? atomicState.delay : 0 + if (delay) { + myRunIn(delay, exitDelayExpired) + } + + def mode = stay ? "STAY" : "AWAY" + def msg = "${location.name} " + if (delay) { + msg += "will arm ${mode} in ${state.delay} seconds." + } else { + msg += "is ARMED ${mode}." + } + + notify(msg) + notifyVoice() +} + +// .../armaway REST API endpoint +def apiArmAway() { + LOG("apiArmAway()") + + if (!isRestApiEnabled()) { + log.error "REST API disabled" + return httpError(403, "Access denied") + } + + if (settings.pincode && settings.armWithPin && (params.pincode != settings.pincode.toString())) { + log.error "Invalid PIN code '${params.pincode}'" + return httpError(403, "Access denied") + } + + armAway() + return apiStatus() +} + +// .../armstay REST API endpoint +def apiArmStay() { + LOG("apiArmStay()") + + if (!isRestApiEnabled()) { + log.error "REST API disabled" + return httpError(403, "Access denied") + } + + if (settings.pincode && settings.armWithPin && (params.pincode != settings.pincode.toString())) { + log.error "Invalid PIN code '${params.pincode}'" + return httpError(403, "Access denied") + } + + armStay() + return apiStatus() +} + +// .../disarm REST API endpoint +def apiDisarm() { + LOG("apiDisarm()") + + if (!isRestApiEnabled()) { + log.error "REST API disabled" + return httpError(403, "Access denied") + } + + if (settings.pincode && (params.pincode != settings.pincode.toString())) { + log.error "Invalid PIN code '${params.pincode}'" + return httpError(403, "Access denied") + } + + disarm() + return apiStatus() +} + +// .../panic REST API endpoint +def apiPanic() { + LOG("apiPanic()") + + if (!isRestApiEnabled()) { + log.error "REST API disabled" + return httpError(403, "Access denied") + } + + panic() + return apiStatus() +} + +// .../status REST API endpoint +def apiStatus() { + LOG("apiStatus()") + + if (!isRestApiEnabled()) { + log.error "REST API disabled" + return httpError(403, "Access denied") + } + + def status = [ + status: state.armed ? (state.stay ? "armed stay" : "armed away") : "disarmed", + alarms: state.alarms + ] + + return status +} + +def activateAlarm() { + LOG("activateAlarm()") + + if (state.alarms.size() == 0) { + log.warn "activateAlarm: false alarm" + return + } + + switch (settings.sirenMode) { + case "Siren": + settings.alarms*.siren() + break + + case "Strobe": + settings.alarms*.strobe() + break + + case "Both": + settings.alarms*.both() + break + } + + // Only turn on those switches that are currently off + def switchesOn = settings.switches?.findAll { it?.currentSwitch == "off" } + LOG("switchesOn: ${switchesOn}") + if (switchesOn) { + switchesOn*.on() + state.offSwitches = switchesOn.collect { it.id } + } + + settings.cameras*.take() + + if (settings.helloHomeAction) { + log.info "Executing HelloHome action '${settings.helloHomeAction}'" + location.helloHome.execute(settings.helloHomeAction) + } + + def msg = "Alarm at ${location.name}!" + state.alarms.each() { + msg += "\n${it}" + } + + notify(msg) + notifyVoice() + + myRunIn(180, reset) +} + +private def notify(msg) { + LOG("notify(${msg})") + + log.info msg + + if (state.alarms.size()) { + // Alarm notification + if (settings.pushMessage) { + mySendPush(msg) + } else { + sendNotificationEvent(msg) + } + + if (settings.smsAlarmPhone1 && settings.phone1) { + sendSms(phone1, msg) + } + + if (settings.smsAlarmPhone2 && settings.phone2) { + sendSms(phone2, msg) + } + + if (settings.smsAlarmPhone3 && settings.phone3) { + sendSms(phone3, msg) + } + + if (settings.smsAlarmPhone4 && settings.phone4) { + sendSms(phone4, msg) + } + + if (settings.pushbulletAlarm && settings.pushbullet) { + settings.pushbullet*.push(location.name, msg) + } + } else { + // Status change notification + if (settings.pushStatusMessage) { + mySendPush(msg) + } else { + sendNotificationEvent(msg) + } + + if (settings.smsStatusPhone1 && settings.phone1) { + sendSms(phone1, msg) + } + + if (settings.smsStatusPhone2 && settings.phone2) { + sendSms(phone2, msg) + } + + if (settings.smsStatusPhone3 && settings.phone3) { + sendSms(phone3, msg) + } + + if (settings.smsStatusPhone4 && settings.phone4) { + sendSms(phone4, msg) + } + + if (settings.pushbulletStatus && settings.pushbullet) { + settings.pushbullet*.push(location.name, msg) + } + } +} + +private def notifyVoice() { + LOG("notifyVoice()") + + if (!settings.audioPlayer) { + return + } + + def phrase = null + if (state.alarms.size()) { + // Alarm notification + if (settings.speechOnAlarm) { + phrase = settings.speechText ?: getStatusPhrase() + } + } else { + // Status change notification + if (settings.speechOnStatus) { + if (state.armed) { + if (state.stay) { + phrase = settings.speechTextArmedStay ?: getStatusPhrase() + } else { + phrase = settings.speechTextArmedAway ?: getStatusPhrase() + } + } else { + phrase = settings.speechTextDisarmed ?: getStatusPhrase() + } + } + } + + if (phrase) { + settings.audioPlayer*.playText(phrase) + } +} + +private def history(String event, String description = "") { + LOG("history(${event}, ${description})") + + def history = atomicState.history + history << [time: now(), event: event, description: description] + if (history.size() > 10) { + history = history.sort{it.time} + history = history[1..-1] + } + + LOG("history: ${history}") + state.history = history +} + +private def getStatusPhrase() { + LOG("getStatusPhrase()") + + def phrase = "" + if (state.alarms.size()) { + phrase = "Alarm at ${location.name}!" + state.alarms.each() { + phrase += " ${it}." + } + } else { + phrase = "${location.name} security is " + if (state.armed) { + def mode = state.stay ? "stay" : "away" + phrase += "armed in ${mode} mode." + } else { + phrase += "disarmed." + } + } + + return phrase +} + +private def getHelloHomeActions() { + def actions = location.helloHome?.getPhrases().collect() { it.label } + return actions.sort() +} + +private def getAlarmStatus() { + def alarmStatus + + if (atomicState.armed) { + alarmStatus = "ARMED " + alarmStatus += atomicState.stay ? "STAY" : "AWAY" + } else { + alarmStatus = "DISARMED" + } + + return alarmStatus +} + +private def getZoneStatus(device, sensorType) { + + def zone = getZoneForDevice(device.id, sensorType) + if (!zone) { + return null + } + + def str = "${device.displayName}: ${zone.zoneType}, " + str += zone.armed ? "armed, " : "disarmed, " + str += device.currentValue(sensorType) + + return str +} + +private def getZoneForDevice(id, sensorType) { + return state.zones.find() { it.deviceId == id && it.sensorType == sensorType } +} + +private def isZoneReady(device, sensorType) { + def ready + + switch (sensorType) { + case "contact": + ready = "closed".equals(device.currentValue("contact")) + break + + case "motion": + ready = "inactive".equals(device.currentValue("motion")) + break + + case "acceleration": + ready = "inactive".equals(device.currentValue("acceleration")) + break + + case "smoke": + ready = "clear".equals(device.currentValue("smoke")) + break + + case "water": + ready = "dry".equals(device.currentValue("water")) + break + + default: + ready = false + } + + return ready +} + +private def getDeviceById(id, sensorType) { + switch (sensorType) { + case "contact": + return settings.z_contact?.find() { it.id == id } + + case "motion": + return settings.z_motion?.find() { it.id == id } + + case "acceleration": + return settings.z_movement?.find() { it.id == id } + + case "smoke": + return settings.z_smoke?.find() { it.id == id } + + case "water": + return settings.z_water?.find() { it.id == id } + } + + return null +} + +private def getNumZones() { + def numZones = 0 + + numZones += settings.z_contact?.size() ?: 0 + numZones += settings.z_motion?.size() ?: 0 + numZones += settings.z_movement?.size() ?: 0 + numZones += settings.z_smoke?.size() ?: 0 + numZones += settings.z_water?.size() ?: 0 + + return numZones +} + +private def myRunIn(delay_s, func) { + if (delay_s > 0) { + def date = new Date(now() + (delay_s * 1000)) + runOnce(date, func) + LOG("scheduled '${func}' to run at ${date}") + } +} + +private def mySendPush(msg) { + // sendPush can throw an exception + try { + sendPush(msg) + } catch (e) { + log.error e + } +} + +private def getVersion() { + return "2.4.3" +} + +private def textCopyright() { + def text = "Copyright © 2014 Statusbits.com" +} + +private def textLicense() { + def text = + "This program is free software: you can redistribute it and/or " + + "modify it under the terms of the GNU General Public License as " + + "published by the Free Software Foundation, either version 3 of " + + "the License, or (at your option) any later version.\n\n" + + "This program is distributed in the hope that it will be useful, " + + "but WITHOUT ANY WARRANTY; without even the implied warranty of " + + "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU " + + "General Public License for more details.\n\n" + + "You should have received a copy of the GNU General Public License " + + "along with this program. If not, see ." +} + +private def LOG(message) { + //log.trace message +} + +private def STATE() { + //log.trace "state: ${state}" +} diff --git a/official/smart-auto-lock-unlock.groovy b/official/smart-auto-lock-unlock.groovy new file mode 100755 index 0000000..b22c764 --- /dev/null +++ b/official/smart-auto-lock-unlock.groovy @@ -0,0 +1,156 @@ +/** + * Smart Lock / Unlock + * + * Copyright 2014 Arnaud + * + * 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: "Smart Auto Lock / Unlock", + namespace: "smart-auto-lock-unlock", + author: "Arnaud", + description: "Automatically locks door X minutes after being closed and keeps door unlocked if door is open.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") + +preferences +{ + section("Select the door lock:") { + input "lock1", "capability.lock", required: true + } + section("Select the door contact sensor:") { + input "contact1", "capability.contactSensor", required: true + } + section("Automatically lock the door when closed...") { + input "minutesLater", "number", title: "Delay (in minutes):", required: true + } + section("Automatically unlock the door when open...") { + input "secondsLater", "number", title: "Delay (in seconds):", required: true + } + section( "Push notification?" ) { + input "sendPushMessage", "enum", title: "Send push notification?", metadata:[values:["Yes", "No"]], required: false + } + section( "Text message?" ) { + input "sendText", "enum", title: "Send text message notification?", metadata:[values:["Yes", "No"]], required: false + input "phoneNumber", "phone", title: "Enter phone number:", required: false + } +} + +def installed() +{ + initialize() +} + +def updated() +{ + unsubscribe() + unschedule() + initialize() +} + +def initialize() +{ + log.debug "Settings: ${settings}" + subscribe(lock1, "lock", doorHandler, [filterEvents: false]) + subscribe(lock1, "unlock", doorHandler, [filterEvents: false]) + subscribe(contact1, "contact.open", doorHandler) + subscribe(contact1, "contact.closed", doorHandler) +} + +def lockDoor() +{ + if (lock1.latestValue("lock") == "unlocked") + { + log.debug "Locking $lock1..." + lock1.lock() + log.debug ("Sending Push Notification...") + if (sendPushMessage != "No") sendPush("$lock1 locked after $contact1 was closed for $minutesLater minute(s)!") + log.debug("Sending text message...") + if ((sendText == "Yes") && (phoneNumber != "0")) sendSms(phoneNumber, "$lock1 locked after $contact1 was closed for $minutesLater minute(s)!") + } + else if (lock1.latestValue("lock") == "locked") + { + log.debug "$lock1 was already locked..." + } +} + +def unlockDoor() +{ + if (lock1.latestValue("lock") == "locked") + { + log.debug "Unlocking $lock1..." + lock1.unlock() + log.debug ("Sending Push Notification...") + if (sendPushMessage != "No") sendPush("$lock1 unlocked after $contact1 was open for $secondsLater seconds(s)!") + log.debug("Sending text message...") + if ((sendText == "Yes") && (phoneNumber != "0")) sendSms(phoneNumber, "$lock1 unlocked after $contact1 was open for $secondsLater seconds(s)!") + } + else if (lock1.latestValue("lock") == "unlocked") + { + log.debug "$lock1 was already unlocked..." + } +} + +def doorHandler(evt) +{ + if ((contact1.latestValue("contact") == "open") && (evt.value == "locked")) + { + def delay = secondsLater + runIn (delay, unlockDoor) + } + else if ((contact1.latestValue("contact") == "open") && (evt.value == "unlocked")) + { + unschedule (unlockDoor) + } + else if ((contact1.latestValue("contact") == "closed") && (evt.value == "locked")) + { + unschedule (lockDoor) + } + else if ((contact1.latestValue("contact") == "closed") && (evt.value == "unlocked")) + { + log.debug "Unlocking $lock1..." + lock1.unlock() + def delay = (minutesLater * 60) + runIn (delay, lockDoor) + } + else if ((lock1.latestValue("lock") == "unlocked") && (evt.value == "open")) + { + unschedule (lockDoor) + log.debug "Unlocking $lock1..." + lock1.unlock() + } + else if ((lock1.latestValue("lock") == "unlocked") && (evt.value == "closed")) + { + log.debug "Unlocking $lock1..." + lock1.unlock() + def delay = (minutesLater * 60) + runIn (delay, lockDoor) + } + else if ((lock1.latestValue("lock") == "locked") && (evt.value == "open")) + { + unschedule (lockDoor) + log.debug "Unlocking $lock1..." + lock1.unlock() + } + else if ((lock1.latestValue("lock") == "locked") && (evt.value == "closed")) + { + unschedule (lockDoor) + log.debug "Unlocking $lock1..." + lock1.unlock() + } + else + { + log.debug "Problem with $lock1, the lock might be jammed!" + unschedule (lockDoor) + unschedule (unlockDoor) + } +} \ No newline at end of file diff --git a/official/smart-energy-service.groovy b/official/smart-energy-service.groovy new file mode 100755 index 0000000..84e6e3a --- /dev/null +++ b/official/smart-energy-service.groovy @@ -0,0 +1,1388 @@ +/** + * ProtoType Smart Energy Service + * + * Copyright 2015 hyeon seok yang + * + * 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: "Smart Energy Service", + namespace: "Encored Technologies", + author: "hyeon seok yang", + description: "With visible realtime energy usage status, have good energy habits and enrich your life\r\n", + category: "SmartThings Labs", + iconUrl: "https://s3-ap-northeast-1.amazonaws.com/smartthings-images/appicon_enertalk%401.png", + iconX2Url: "https://s3-ap-northeast-1.amazonaws.com/smartthings-images/appicon_enertalk%402x", + iconX3Url: "https://s3-ap-northeast-1.amazonaws.com/smartthings-images/appicon_enertalk%403x", + oauth: true) +{ + appSetting "clientId" + appSetting "clientSecret" + appSetting "callback" +} + + +preferences { + page(name: "checkAccessToken") +} + +cards { + card(name: "Encored Energy Service", type: "html", action: "getHtml", whitelist: whiteList()) {} +} + +/* This list contains, that url need to be allowed in Smart Energy Service.*/ +def whiteList() { + [ + "code.jquery.com", + "ajax.googleapis.com", + "fonts.googleapis.com", + "code.highcharts.com", + "enertalk-card.encoredtech.com", + "s3-ap-northeast-1.amazonaws.com", + "s3.amazonaws.com", + "ui-hub.encoredtech.com", + "enertalk-auth.encoredtech.com", + "api.encoredtech.com", + "cdnjs.cloudflare.com", + "encoredtech.com", + "itunes.apple.com" + ] +} + +/* url endpoints */ +mappings { + path("/requestCode") { action: [ GET: "requestCode" ] } + path("/receiveToken") { action: [ GET: "receiveToken"] } + path("/getHtml") { action: [GET: "getHtml"] } + path("/consoleLog") { action: [POST: "consoleLog"]} + path("/getInitialData") { action: [GET: "getInitialData"]} + path("/getEncoredPush") { action: [POST: "getEncoredPush"]} +} + + +/* This method does two things depends on the existence of Encored access token. : +* 1. If Encored access token does not exits, it starts the process of getting access token. +* 2. If Encored access token does exist, it will show a list of configurations, that user need to define values. +*/ +def checkAccessToken() { + log.debug "Staring the installation" + + /* Choose the level */ + atomicState.env_mode ="prod" + + def lang = clientLocale?.language + + /* getting language settings of user's device. */ + if ("${lang}" == "ko") { + atomicState.language = "ko" + } else { + atomicState.language = "en" + } + + /* create tanslation for descriptive and informative strings that can be seen by users. */ + if (!state.languageString) { + createLocaleStrings() + } + + if (!atomicState.encoredAccessToken) { /*check if Encored access token does exist.*/ + + log.debug "Encored Access Token does not exist." + + if (!state.accessToken) { /*if smartThings' access token does not exitst*/ + log.debug "SmartThings Access Token does not exist." + + createAccessToken() /*request and get access token from smartThings*/ + + /* re-create strings to make sure it's been initialized. */ + //createLocaleStrings() + } + + def redirectUrl = buildRedirectUrl("requestCode") /* build a redirect url with endpoint "requestCode"*/ + + /* These lines will start the OAuth process.\n*/ + log.debug "Start OAuth request." + return dynamicPage(name: "checkAccessToken", nextPage:null, uninstall: true, install:false) { + section{ + paragraph state.languageString."${atomicState.language}".desc1 + href(title: state.languageString."${atomicState.language}".main, + description: state.languageString."${atomicState.language}".desc2, + required: true, + style:"embedded", + url: redirectUrl) + } + } + } else { + /* This part will load the configuration for this application */ + return dynamicPage(name:"checkAccessToken",install:true, uninstall : true) { + section(title:state.languageString."${atomicState.language}".title6) { + + /* A push alarm for this application */ + input( + type: "boolean", + name: "notification", + title: state.languageString."${atomicState.language}".title1, + required: false, + default: true, + multiple: false + ) + + /* A plan that user need to decide */ + input( + type: "number", + name: "energyPlan", + title: state.languageString."${atomicState.language}".title2, + description : state.languageString."${atomicState.language}".subTitle1, + defaultValue: state.languageString.energyPlan, + range: "1130..*", + submitOnChange: true, + required: true, + multiple: false + ) + + /* A displaying unit that user need to decide */ + input( + type: "enum", + name: "displayUnit", + title: state.languageString."${atomicState.language}".title3, + defaultValue : state.languageString."${atomicState.language}".defaultValues.default1, + required: true, + multiple: false, + options: state.languageString."${atomicState.language}".displayUnits + ) + + /* A metering date that user should know */ + input( + type: "enum", + name: "meteringDate", + title: state.languageString."${atomicState.language}".title4, + defaultValue: state.languageString."${atomicState.language}".defaultValues.default2, + required: true, + multiple: false, + options: state.languageString."${atomicState.language}".meteringDays + ) + + /* A contract type that user should know */ + input( + type: "enum", + name: "contractType", + title: state.languageString."${atomicState.language}".title5, + defaultValue: state.languageString."${atomicState.language}".defaultValues.default3, + required: true, + multiple: false, + options: state.languageString."${atomicState.language}".contractTypes) + } + + } + } +} + +def requestCode(){ + log.debug "In state of sending a request to Encored for OAuth code.\n" + + /* Make a parameter to request Encored for a OAuth code. */ + def oauthParams = + [ + response_type: "code", + scope: "remote", + client_id: "${appSettings.clientId}", + app_version: "web", + redirect_uri: buildRedirectUrl("receiveToken") + ] + + /* Request Encored a code. */ + redirect location: "https://enertalk-auth.encoredtech.com/authorization?${toQueryString(oauthParams)}" +} + +def receiveToken(){ + log.debug "Request Encored to swap code with Encored Aceess Token" + + /* Making a parameter to swap code with a token */ + def authorization = "Basic " + "${appSettings.clientId}:${appSettings.clientSecret}".bytes.encodeBase64() + def uri = "https://enertalk-auth.encoredtech.com/token" + def header = [Authorization: authorization, contentType: "application/json"] + def body = [grant_type: "authorization_code", code: params.code] + + log.debug "Swap code with a token" + def encoredTokenParams = makePostParams(uri, header, body) + + log.debug "API call to Encored to swap code with a token" + def encoredTokens = getHttpPostJson(encoredTokenParams) + + /* make a page to show people if the REST was successful or not. */ + if (encoredTokens) { + log.debug "Got Encored OAuth token\n" + atomicState.encoredRefreshToken = encoredTokens.refresh_token + atomicState.encoredAccessToken = encoredTokens.access_token + + success() + } else { + log.debug "Could not get Encored OAuth token\n" + fail() + } + +} + + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + /* Make sure uuid is there. */ + getUUID() + + /* Check uuid and if it does not exist then don't update.*/ + if (!atomicState.notPaired) { + def theDay = 1 + + for(def i=1; i < 28; i++) { + + /* set user choosen option to apropriate value. */ + if (atomicState.language == "en") { + if ("${i}st day of the month" == settings.meteringDate || + "${i}nd day of the month" == settings.meteringDate || + "${i}rd day of the month" == settings.meteringDate || + "${i}th day of the month" == settings.meteringDate) { + + theDay = i + i = 28 + + } else if ("Rest of the month" == settings.meteringDate) { + theDay = 27 + i = 28 + } + } else { + + if (settings.meteringDate == "매월 ${i}일") { + + theDay = i + i = 28 + + } else if ("말일" == settings.meteringDate) { + theDay = 27 + i = 28 + } + } + + } + + /* Set choosen contract to apropriate variable. */ + def contract = 1 + if (settings.contractType == "High voltage" || settings.contractType == "주택용 고압") { + contract = 2 + + if (settings.energyPlan < 460) { + settings.energyPlan = 490 + } + } else { + + if (settings.energyPlan < 1130) { + settings.energyPlan = 1130 + } + } + + + /* convert bill to milliwatts */ + def changeToUsageParam = makeGetParams("${state.domains."${atomicState.env_mode}"}/1.2/devices/${atomicState.uuid}/bill/expectedUsage?bill=${settings.energyPlan}", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + + def energyPlanUsage = getHttpGetJson(changeToUsageParam, 'CheckEnergyPlanUsage') + def epUsage = 0 + if (energyPlanUsage) { + epUsage = energyPlanUsage.usage + } + + /* update the the information depends on the option choosen */ + def configurationParam = makePostParams("${state.domains."${atomicState.env_mode}"}/1.2/me", + [Authorization : "Bearer ${atomicState.encoredAccessToken}"], + [contractType : contract, + meteringDay : theDay, + maxLimitUsage : epUsage]) + getHttpPutJson(configurationParam) + } + +} + +def initialize() { + log.debug "Initializing Application" + + def EATValidation = checkEncoreAccessTokenValidation() + + /* if token exist get user's device id, uuid */ + if (EATValidation) { + getUUID() + if (atomicState.uuid) { + + def pushParams = makePostParams("${state.domains."${atomicState.env_mode}"}/1.2/devices/${atomicState.uuid}/events/push", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"], + [type: "REST", regId:"${state.accessToken}__${app.id}"]) + getHttpPostJson(pushParams) + } + + } else { + log.warning "Ecored Access Token did not get refreshed!" + } + + + /* add device Type Handler */ + atomicState.dni = "EncoredDTH01" + def d = getChildDevice(atomicState.dni) + if(!d) { + log.debug "Creating Device Type Handler." + + d = addChildDevice("Encored Technologies", "EnerTalk Energy Meter", atomicState.dni, null, [name:"EnerTalk Energy Meter", label:name]) + + } else { + log.debug "Device already created" + } + + setSummary() +} + +def setSummary() { + + log.debug "in setSummary" + def text = "Successfully installed." + sendEvent(linkText:count.toString(), descriptionText: app.label, + eventType:"SOLUTION_SUMMARY", + name: "summary", + value: text, + data: [["icon":"indicator-dot-gray","iconColor":"#878787","value":text]], + displayed: false) +} + +// TODO: implement event handlers + +/* Check the validation of Encored Access Token (EAT) +* If it's not valid try refresh Access Token. +* If the token gets refreshed, it will refresh the value of Encored Access Token +* If it doesn't get refreshed, then it returns null +*/ +private checkEncoreAccessTokenValidation() { + /* make a parameter to check the validation of Encored access token */ + def verifyParam = makeGetParams("https://enertalk-auth.encoredtech.com/verify", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + /* check the validation */ + def verified = getHttpGetJson(verifyParam, 'verifyToken') + + log.debug "verified : ${verified}" + + /* if Encored Access Token need to be renewed. */ + if (!verified) { + try { + refreshAuthToken() + + /* Recheck the renewed Encored access token. */ + verifyParam.headers = [Authorization: "Bearer ${atomicState.encoredAccessToken}"] + verified = getHttpGetJson(verifyParam, 'CheckRefresh') + + } catch (groovyx.net.http.HttpResponseException e) { + /* If refreshing token raises an error */ + log.warn "Refresh Token Error : ${e}" + } + } + + return verified +} + +/* Get device UUID, if it does not exist, return false. true otherwise.*/ +private getUUID() { + atomicState.uuid = null + atomicState.notPaired = true + /* Make a parameter to get device id (uuid)*/ + def uuidParams = makeGetParams( "https://enertalk-auth.encoredtech.com/uuid", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + + def deviceUUID = getHttpGetJson(uuidParams, 'UUID') + log.debug "device uuid is : ${deviceUUID}" + if (!deviceUUID) { + return false + } + log.debug "got here even tho" + atomicState.uuid = deviceUUID.uuid + atomicState.notPaired = false + return true +} + +private createLocaleStrings() { + state.domains = [ + test : "http://api.encoredtech.com", + prod : "https://api.encoredtech.com:8082/" + ] + state.languageString = + [ + energyPlan : 30000, + en : [ + desc1 : "Tab below to sign in or sign up to Encored EnerTalk smart energy service and authorize SmartThings access.", + desc2 : "Click to proceed authorization.", + main : "EnerTalk", + defaultValues : [ + default1 : "kWh", + default2 : "1st day of the month", + default3 : "Low voltage" + ], + meteringDays : [ + "1st day of the month", + "2nd day of the month", + "3rd day of the month", + "4th day of the month", + "5th day of the month", + "6th day of the month", + "7th day of the month", + "8th day of the month", + "9th day of the month", + "10th day of the month", + "11th day of the month", + "12th day of the month", + "13th day of the month", + "14th day of the month", + "15th day of the month", + "16th day of the month", + "17th day of the month", + "18th day of the month", + "19th day of the month", + "20st day of the month", + "21st day of the month", + "22nd day of the month", + "23rd day of the month", + "24th day of the month", + "25th day of the month", + "26th day of the month", + "Rest of the month" + ], + displayUnits : ["WON(₩)", "kWh"], + contractTypes : ["Low voltage", "High voltage"], + title1 : "Send push notification", + title2 : "Energy Plan", + subTitle1 : "Setup your energy plan by won", + title3 : "Display Unit", + title4 : "Metering Date", + title5 : "Contract Type", + title6 : "User & Notifications", + message1 : """

Your Encored Account is now connected to SmartThings!

Click 'Done' to finish setup.

""", + message2 : """

The connection could not be established!

Click 'Done' to return to the menu.

""", + message3 : [ + header : "Device is not installed", + body1 : "You need to install EnerTalk device at first,", + body2 : "and proceed setup and register device.", + button1 : "Setup device", + button2 : "Not Installed" + ], + message4 : [ + header : "Device is not connected.", + body1 : "Please check the Wi-Fi network connection", + body2 : "and EnerTalk device status.", + body3 : "Select ‘Setup Device’ to reset the device." + ] + ], + ko :[ + desc1 : "스마트 에너지 서비스를 이용하시려면 EnerTalk 서비스 가입과 SmartThings 접근 권한이 필요합니다.", + desc2 : "아래 버튼을 누르면 인증을 시작합니다", + main : "EnerTalk 인증", + defaultValues : [ + default1 : "kWh", + default2 : "매월 1일", + default3 : "주택용 저압" + ], + meteringDays : [ + "매월 1일", + "매월 2일", + "매월 3일", + "매월 4일", + "매월 5일", + "매월 6일", + "매월 7일", + "매월 8일", + "매월 9일", + "매월 10일", + "매월 11일", + "매월 12일", + "매월 13일", + "매월 14일", + "매월 15일", + "매월 16일", + "매월 17일", + "매월 18일", + "매월 19일", + "매월 20일", + "매월 21일", + "매월 22일", + "매월 23일", + "매월 24일", + "매월 25일", + "매월 26일", + "말일" + ], + displayUnits : ["원(₩)", "kWh"], + contractTypes : ["주택용 저압", "주택용 고압"], + title1 : "알람 설정", + title2 : "사용 계획 (원)", + subTitle1 : "월간 계획을 금액으로 입력하세요", + title3 : "표시 단위", + title4 : "정기검침일", + title5 : "계약종별", + title6 : "사용자 & 알람 설정", + message1 : """

EnerTalk 계정이 SmartThings와 연결 되었습니다!

Done을 눌러 계속 진행해 주세요.

""", + message2 : """

계정 연결이 실패했습니다.

Done 버튼을 눌러 다시 시도해주세요.

""", + message3 : [ + header : "기기 설치가 필요합니다.", + body1 : "가정 내 분전반에 EnerTalk 기기를 먼저 설치하고,", + body2 : "아래 버튼을 눌러 기기등록 및 연결을 진행하세요.", + button1 : "기기 설정", + button2 : "설치필요" + ], + message4 : [ + header : "Device is not connected.", + body1 : "Please check the Wi-Fi network connection", + body2 : "and EnerTalk device status.", + body3 : "Select ‘Setup Device’ to reset the device." + ] + + ] + ] + +} + +/* This method makes a redirect url with a given endpoint */ +private buildRedirectUrl(mappingPath) { + log.debug "Start : Starting to making a redirect URL with endpoint : /${mappingPath}" + def url = "https://graph.api.smartthings.com/api/token/${state.accessToken}/smartapps/installations/${app.id}/${mappingPath}" + log.debug "Done : Finished to make a URL : ${url}" + url +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +/* make a success message. */ +private success() { + def lang = clientLocale?.language + + if ("${lang}" == "ko") { + log.debug "I was here at first." + atomicState.language = "ko" + } else { + + atomicState.language = "en" + } + log.debug atomicState.language + def message = atomicState.languageString."${atomicState.language}".message1 + connectionStatus(message) +} + +/* make a failure message. */ +private fail() { + def lang = clientLocale?.language + + if ("${lang}" == "ko") { + log.debug "I was here at first." + atomicState.language = "ko" + } else { + + atomicState.language = "en" + } + def message = atomicState.languageString."${atomicState.language}".message2 + connectionStatus(message) +} + +private connectionStatus(message) { + def html = """ + + + + + + + SmartThings Connection + + + + +
+ Encored icon + connected device icon + SmartThings logo +

${message}

+ +
+ + + + """ + render contentType: 'text/html', data: html +} + +private refreshAuthToken() { + /*Refreshing Encored Access Token*/ + + log.debug "Refreshing Encored Access Token" + if(!atomicState.encoredRefreshToken) { + log.error "Encored Refresh Token does not exist!" + } else { + + def authorization = "Basic " + "${appSettings.clientId}:${appSettings.clientSecret}".bytes.encodeBase64() + def refreshParam = makePostParams("https://enertalk-auth.encoredtech.com/token", + [Authorization: authorization], + [grant_type: 'refresh_token', refresh_token: "${atomicState.encoredRefreshToken}"]) + + def newAccessToken = getHttpPostJson(refreshParam) + + if (newAccessToken) { + atomicState.encoredAccessToken = newAccessToken.access_token + log.debug "Successfully got new Encored Access Token.\n" + } else { + log.error "Was unable to renew Encored Access Token.\n" + } + } +} + +private getHttpPutJson(param) { + + log.debug "Put URI : ${param.uri}" + try { + httpPut(param) { resp -> + log.debug "HTTP Put Success" + } + } catch(groovyx.net.http.HttpResponseException e) { + log.warn "HTTP Put Error : ${e}" + } +} + +private getHttpPostJson(param) { + log.debug "Post URI : ${param.uri}" + def jsonMap = null + try { + httpPost(param) { resp -> + jsonMap = resp.data + log.debug resp.data + } + } catch(groovyx.net.http.HttpResponseException e) { + log.warn "HTTP Post Error : ${e}" + } + + return jsonMap +} + +private getHttpGetJson(param, testLog) { + log.debug "Get URI : ${param.uri}" + def jsonMap = null + try { + httpGet(param) { resp -> + jsonMap = resp.data + } + } catch(groovyx.net.http.HttpResponseException e) { + log.warn "HTTP Get Error : ${e}" + } + + return jsonMap + +} + +private makePostParams(uri, header, body=[]) { + return [ + uri : uri, + headers : header, + body : body + ] +} + +private makeGetParams(uri, headers, path="") { + return [ + uri : uri, + path : path, + headers : headers + ] +} + +def getInitialData() { + def lang = clientLocale?.language + if ("${lang}" == "ko") { + lang = "ko" + } else { + lang = "en" + } + atomicState.solutionModuleSettings.language = lang + atomicState.solutionModuleSettings +} + +def consoleLog() { + log.debug "console log: ${request.JSON.str}" +} + +def getHtml() { + + /* initializing variables */ + def deviceStatusData = "", standbyData = "", meData = "", meteringData = "", rankingData = "", lastMonth = "", deviceId = "" + def standby = "", plan = "", start = "", end = "", meteringDay = "", meteringUsage = "", percent = "", tier = "", meteringPeriodBill = "" + def maxLimitUsageBill, maxLimitUsage = 0 + def deviceStatus = false + def displayUnit = "watt" + + def meteringPeriodBillShow = "", meteringPeriodBillFalse = "collecting data" + def standbyShow = "", standbyFalse = "collecting data" + def rankingShow = "collecting data" + def tierShow = "collecting data" + def lastMonthShow = "", lastMonthFalse = "no records" + def planShow = "", planFalse = "set up plan" + + def thisMonthUnitOne ="", thisMonthUnitTwo = "", planUnitOne = "", planUnitTwo = "", lastMonthUnit = "", standbyUnit = "" + def thisMonthTitle = "This Month", tierTitle = "Billing Tier", planTitle = "Energy Goal", + lastMonthTitle = "Last Month", rankingTitle = "Ranking", standbyTitle = "Always on", energyMonitorDeviceTitle = "EnerTalk Device" , realtimeTitle = "Realtime" + def onOff = "OFF", rankImage = "", tierImage = "" + + def htmlBody = "" + + /* Get the language setting on device. */ + def lang = clientLocale?.language + if ("${lang}" == "ko") { + atomicState.language = "ko" + } else { + atomicState.language = "en" + } + + if (atomicState.language == "ko") { + rankingShow = "데이터 수집 중" + meteringPeriodBillFalse = "데이터 수집 중" + lastMonthFalse = "정보가 없습니다" + standbyFalse = "데이터 수집 중" + planFalse = "계획을 입력하세요" + thisMonthTitle = "이번 달" + tierTitle = "누진단계" + planTitle = "사용 계획" + lastMonthTitle = "지난달" + rankingTitle = "랭킹" + standbyTitle = "대기전력" + energyMonitorDeviceTitle = "스마트미터 상태" + realtimeTitle = "실시간" + } + + /* check Encored Access Token */ + def EATValidation = checkEncoreAccessTokenValidation() + log.debug EATValidation + /* check if uuid already exist or not.*/ + if (EATValidation && atomicState.notPaired) { + getUUID() + } + + /* If token has been verified or refreshed and if uuid exist, call other apis */ + log.debug atomicState.notPaired + if (!atomicState.notPaired) { + + if(EATValidation) { + /* make a parameter to get device status */ + def deviceStatusParam = makeGetParams( "${state.domains."${atomicState.env_mode}"}/1.2/devices/${atomicState.uuid}/status", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + + /* get device status. */ + deviceStatusData = getHttpGetJson(deviceStatusParam, 'CheckDeviceStatus') + + + /* make a parameter to get standby value.*/ + def standbyParam = makeGetParams( "${state.domains."${atomicState.env_mode}"}/1.2/devices/${atomicState.uuid}/standbyPower", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + + /* get standby value */ + standbyData = getHttpGetJson(standbyParam, 'CheckStandbyPower') + + + + /* make a parameter to get user's info. */ + def meParam = makeGetParams( "${state.domains."${atomicState.env_mode}"}/1.2/me", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + + /* Get user's info */ + meData = getHttpGetJson(meParam, 'CheckMe') + + + /* make a parameter to get energy used since metering date */ + def meteringParam = makeGetParams( "${state.domains."${atomicState.env_mode}"}/1.2/devices/${atomicState.uuid}/meteringUsage", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + + /* Get the value of energy used since metering date. */ + meteringData = getHttpGetJson(meteringParam, 'CheckMeteringUsage') + + + /* make a parameter to get the energy usage ranking of a user. */ + def rankingParam = makeGetParams( "${state.domains."${atomicState.env_mode}"}/1.2/ranking/usages/${atomicState.uuid}?state=current&period=monthly", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + + /* Get user's energy usage rank */ + rankingData = getHttpGetJson(rankingParam, 'CheckingRanking') + + /* Parse the values from the returned value of api calls. Then use these values to inform user how much they have used or will use. */ + + /* parse device status. */ + if (deviceStatusData) { + if (deviceStatusData.status == "NORMAL") { + deviceStatus = true + } + } + + log.debug "deiceStatusData : ${deviceStatus} || ${deviceStatusData}" + + /* Parse standby power. */ + if (standbyData) { + if (standbyData.standbyPower) { + standby = (standbyData.standbyPower / 1000) + } + } + + /* Parse max limit usage and it's bill from user's info. */ + if (meData) { + if (meData.maxLimitUsageBill) { + maxLimitUsageBill = meData.maxLimitUsageBill + maxLimitUsage = meData.maxLimitUsage + } + } + + /* Parse the values which have been used since metering date. + * The list is : + * meteringPeriodBill : A bill for energy usage. + * plan : The left amount of bill until it reaches limit. + * start : metering date in millisecond e.g. if the metering started on june and 1st, 2015,06,01 + * end : Today's date in millisecond + * meteringDay : The day of the metering date. e.g. if the metering date is June 1st, then it will return 1. + * meteringUSage : The amount of energy that user has used. + * tier : the level of energy use, tier exits from 1 to 6. + */ + if (meteringData) { + if (meteringData.meteringPeriodBill) { + meteringPeriodBill = meteringData.meteringPeriodBill + plan = maxLimitUsageBill - meteringData.meteringPeriodBill + start = meteringData.meteringStart + end = meteringData.meteringEnd + meteringDay = meteringData.meteringDay + meteringUsage = meteringData.meteringPeriodUsage + tier = ((int) (meteringData.meteringPeriodUsage / 100000000) + 1) + if(tier > 6) { + tier = 6 + } + + } + } + + /* Get ranking data of a user and the percent */ + if (rankingData) { + if (rankingData.user.ranking) { + percent = ((int)((rankingData.user.ranking / rankingData.user.population) * 10)) + if (percent > 10) { + percent = 10 + } + } + } + + /* if the start value exist, get last month energy usage. */ + if (start) { + def lastMonthParam = makeGetParams( "${state.domains."${atomicState.env_mode}"}/1.2/devices/${atomicState.uuid}/meteringUsages?period=monthly&start=${start}&end=${end}", + [Authorization: "Bearer ${atomicState.encoredAccessToken}", ContentType: "application/json"]) + + lastMonth = getHttpGetJson(lastMonthParam, 'ChecklastMonth') + + } + + /* I decided to set values to device type handler, on loading solution module. + So, users may need to go back to solution module to update their device type handler. */ + def d = getChildDevice(atomicState.dni) + def kWhMonth = Math.round(meteringUsage / 10000) / 100 /* milliwatt to kilowatt*/ + def planUsed = 0 + if ( maxLimitUsage > 0 ) { + planUsed = Math.round((meteringUsage / maxLimitUsage) * 100) /* get the pecent of used amount against max usage */ + } else { + planUsed = Math.round((meteringUsage/ 1000000) * 100) /* if max was not decided let the used value be percent. e.g. 1kWh = 100% */ + } + + /* get realtime usage of user's device.*/ + def realTimeParam = makeGetParams("${state.domains."${atomicState.env_mode}"}/1.2/devices/${atomicState.uuid}/realtimeUsage", + [Authorization: "Bearer ${atomicState.encoredAccessToken}"]) + def realTimeInfo = getHttpGetJson(realTimeParam, 'CheckRealtimeinfo') + + if (!realTimeInfo) { + realTimeInfo = 0 + } else { + realTimeInfo = Math.round(realTimeInfo.activePower / 1000 ) + } + + + + /* inserting values to device type handler */ + + d?.sendEvent(name: "view", value : "${kWhMonth}") + if (deviceStatus) { + + d?.sendEvent(name: "month", value : "${thisMonthTitle} \n ${kWhMonth} \n kWh") + } else { + + d?.sendEvent(name: "month", value : "\n ${state.languageString."${atomicState.language}".message4.header} \n\n " + + "${state.languageString."${atomicState.language}".message4.body1} \n " + + "${state.languageString."${atomicState.language}".message4.body2} \n " + + "${state.languageString."${atomicState.language}".message4.body3}") + } + + d?.sendEvent(name: "real", value : "${realTimeInfo}w \n\n ${realtimeTitle}") + d?.sendEvent(name: "tier", value : "${tier} \n\n ${tierTitle}") + d?.sendEvent(name: "plan", value : "${planUsed}% \n\n ${planTitle}") + + deviceId = d.id + + } else { + /* If it finally couldn't get Encored access token. */ + log.error "Could not get Encored Access Token. Please try later." + } + + /* change the display uinit to bill from kWh if user want. */ + if (settings.displayUnit == "WON(₩)" || settings.displayUnit == "원(₩)") { + displayUnit = "bill" + } + + if (meteringPeriodBill) { + /* reform the value of the bill with the , separator */ + meteringPeriodBillShow = formatMoney("${meteringPeriodBill}") + meteringPeriodBillFalse = "" + thisMonthUnitOne = "₩" + + def dayPassed = getDayPassed(start, end, meteringDay) + if (atomicState.language == 'ko') { + thisMonthUnitTwo = "/ ${dayPassed}일" + } else { + if (dayPassed == 1) { + thisMonthUnitTwo = "/${dayPassed} day" + } else { + thisMonthUnitTwo = "/${dayPassed} days" + } + } + } + + if (plan) { + planShow = plan + if (plan >= 1000) {planShow = formatMoney("${plan}") } + planFalse = "" + planUnitOne = "₩" + + if (atomicState.language == 'ko') { + planUnitTwo = "남음" + } else { + planUnitTwo = "left" + } + + } + + /*set the showing units for html.*/ + log.debug lastMonth + if (lastMonth.usages) { + lastMonthShow = formatMoney("${lastMonth.usages[0].meteringPeriodBill}") + lastMonthFalse = "" + lastMonthUnit = "₩" + + } + + if (standby) { + standbyShow = standby + standbyFalse = "" + standbyUnit = "W" + } + + if (percent) { + rankImage = "" + rankingShow = "" + } + + if (tier) { + tierImage = "" + tierShow = "" + } + + if (deviceStatus) { + onOff = "ON" + } + + atomicState.solutionModuleSettings = [ + auth : atomicState.encoredAccessToken, + deviceState : deviceStatus, + percent : percent, + displayUnit : displayUnit, + language : atomicState.language, + deviceId : deviceId, + pairing : true + ] + + htmlBody = """ +
+ + +
+ + +
+

${thisMonthTitle}

+ +

${thisMonthUnitOne}

+

${meteringPeriodBillShow}

+

${meteringPeriodBillFalse}

+

${thisMonthUnitTwo}

+
+
+ + +
+

${tierTitle}

+ +
${tierImage}
+

${tierShow}

+
+
+ + +
+

${planTitle}

+ +

${planUnitOne}

+

${planShow}

+

${planFalse}

+

${planUnitTwo}

+
+
+ + +
+

${lastMonthTitle}

+ +

${lastMonthUnit}

+

${lastMonthShow}

+

${lastMonthFalse}

+
+
+ + +
+

${rankingTitle}

+ +
${rankImage}
+

${rankingShow}

+
+
+ + +
+

${standbyTitle}

+ +

${standbyShow}

+

${standbyFalse}

+

${standbyUnit}

+ +

+ + +
+

${energyMonitorDeviceTitle}

+ +
+

${onOff}

+
+
+ +
+ + + +
+
+

${thisMonthTitle}

+ +
+
+
+
+ +
+
+

${lastMonthTitle}

+ +
+
+
+ +
+
+

${tierTitle}

+ +
+
+
+ +
+
+

${rankingTitle}

+ +
+
+
+ +
+
+

${planTitle}

+ +
+
+
+ +
+
+

${standbyTitle}

+ +
+
+
+ + + + """ + } else { + log.debug "abotu to ask device connection" + def d = getChildDevice(atomicState.dni) + /* inserting values to device type handler */ + + d?.sendEvent(name: "month", value : "\n ${state.languageString."${atomicState.language}".message3.header} \n\n ${state.languageString."${atomicState.language}".message3.body1} \n ${state.languageString."${atomicState.language}".message3.body2}") + deviceId = d.id + + if (state.language == "ko") { + energyMonitorDeviceTitle = "스마트미터 상태" + } + /* need device pairing */ + atomicState.solutionModuleSettings = [ + dId : deviceId, + pairing : false + ] + + htmlBody = """ + +
+ + +
+

${state.languageString."${atomicState.language}".message3.header}

+

${state.languageString."${atomicState.language}".message3.body1}
${state.languageString."${atomicState.language}".message3.body2}

+ +
+ + + + +
+

${energyMonitorDeviceTitle}

+ +

${state.languageString."${atomicState.language}".message3.button2}

+
+
+ +
+ + + + + """ + } + + renderHTML() { + head { + """ + + + + + + + """ + } + body { + htmlBody + } + } +} + + +/* put commas for money or if there are things that need to have a comma separator.*/ +private formatMoney(money) { + def i = money.length()-1 + def ret = "" + def commas = ((int) Math.floor(i/3)) + + def j = 0 + def counter = 0 + + while (i >= 0) { + + if (counter > 0 && (counter % 3) == 0) { + ret = "${money[i]},${ret}" + j++ + } else { + ret = "${money[i]}${ret}" + } + + counter++ + i-- + } + + ret +} + +/* Count how many days have been passed since metering day: +* if metering day < today, it returns today - metering day +* else if metering day > today, it calcualtes how many days have been passed since meterin day and return calculated value. +* else return 1 (today). +*/ +private getDayPassed(start, end, meteringDay){ + + def day = 1 + def today = new Date(end) + def tzDifference = 9 * 60 + today.getTimezoneOffset() + today = new Date(today.getTime() + tzDifference * 60 * 1000).getDate(); + + if (today > meteringDay) { + day += today - meteringDay; + + } + if (today < meteringDay) { + def startDate = new Date(start); + def month = startDate.getMonth(); + def year = startDate.getYear(); + def lastDate = new Date(year, month, 31).getDate(); + + if (lastDate == 1) { + day += 30; + } else { + day += 31; + } + + day = day - meteringDay + today; + } + + day +} + +/* Get Encored push and send the notification. */ +def getEncoredPush() { + + byte[] decoded = "${params.msg}".decodeBase64() + def decodedString = new String(decoded) + + if (settings.notification == "true") { + sendNotification("${decodedString}", [method: "push"]) + } else { + sendNotificationEvent("${decodedString}") + } + +} \ No newline at end of file diff --git a/official/smart-home-ventilation.groovy b/official/smart-home-ventilation.groovy new file mode 100755 index 0000000..57fa957 --- /dev/null +++ b/official/smart-home-ventilation.groovy @@ -0,0 +1,419 @@ +/** + * Smart Home Ventilation + * Version 2.1.2 - 5/31/15 + * + * Copyright 2015 Michael Struck + * + * 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: "Smart Home Ventilation", + namespace: "MichaelStruck", + author: "Michael Struck", + description: "Allows for setting up various schedule scenarios for turning on and off home ventilation switches.", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Smart-Home-Ventilation/HomeVent.png", + iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Smart-Home-Ventilation/HomeVent@2x.png", + iconX3Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Smart-Home-Ventilation/HomeVent@2x.png") + +preferences { + page name: "mainPage" +} + +def mainPage() { + dynamicPage(name: "mainPage", title: "", install: true, uninstall: true) { + section("Select ventilation switches..."){ + input "switches", title: "Switches", "capability.switch", multiple: true + } + section ("Scheduling scenarios...") { + href(name: "toA_Scenario", page: "A_Scenario", title: getTitle (titleA, "A"), description: schedDesc(timeOnA1,timeOffA1,timeOnA2,timeOffA2,timeOnA3,timeOffA3,timeOnA4,timeOffA4, modeA, daysA), state: greyOut(timeOnA1,timeOnA2,timeOnA3,timeOnA4)) + href(name: "toB_Scenario", page: "B_Scenario", title: getTitle (titleB, "B"), description: schedDesc(timeOnB1,timeOffB1,timeOnB2,timeOffB2,timeOnB3,timeOffB3,timeOnB4,timeOffB4, modeB, daysB), state: greyOut(timeOnB1,timeOnB2,timeOnB3,timeOnB4)) + href(name: "toC_Scenario", page: "C_Scenario", title: getTitle (titleC, "C"), description: schedDesc(timeOnC1,timeOffC1,timeOnC2,timeOffC2,timeOnC3,timeOffC3,timeOnC4,timeOffC4, modeC, daysC), state: greyOut(timeOnC1,timeOnC2,timeOnC3,timeOnC4)) + href(name: "toD_Scenario", page: "D_Scenario", title: getTitle (titleD, "D"), description: schedDesc(timeOnD1,timeOffD1,timeOnD2,timeOffD2,timeOnD3,timeOffD3,timeOnD4,timeOffD4, modeD, daysD), state: greyOut(timeOnD1,timeOnD2,timeOnD3,timeOnD4)) + } + section([mobileOnly:true], "Options") { + label(title: "Assign a name", required: false, defaultValue: "Smart Home Ventilation") + href "pageAbout", title: "About ${textAppName()}", description: "Tap to get application version, license and instructions" + } + } +} +//----Scheduling Pages +page(name: "A_Scenario", title: getTitle (titleA, "A")) { + section{ + input "timeOnA1", title: "Schedule 1 time to turn on", "time", required: false + input "timeOffA1", title: "Schedule 1 time to turn off", "time", required: false + } + section{ + input "timeOnA2", title: "Schedule 2 time to turn on", "time", required: false + input "timeOffA2", title: "Schedule 2 time to turn off", "time", required: false + } + section{ + input "timeOnA3", title: "Schedule 3 time to turn on", "time", required: false + input "timeOffA3", title: "Schedule 3 time to turn off", "time", required: false + } + section{ + input "timeOnA4", title: "Schedule 4 time to turn on", "time", required: false + input "timeOffA4", title: "Schedule 4 time to turn off", "time", required: false + } + section ("Options") { + input "titleA", title: "Assign a scenario name", "text", required: false + input "modeA", "mode", required: false, multiple: true, title: "Run in specific mode(s)", description: "Choose Modes" + input "daysA", "enum", multiple: true, title: "Run on specific day(s)", description: "Choose Days", required: false, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + } + } + +page(name: "B_Scenario", title: getTitle (titleB, "B")) { + section{ + input "timeOnB1", title: "Schedule 1 time to turn on", "time", required: false + input "timeOffB1", title: "Schedule 1 time to turn off", "time", required: false + } + section{ + input "timeOnB2", title: "Schedule 2 time to turn on", "time", required: false + input "timeOffB2", title: "Schedule 2 time to turn off", "time", required: false + } + section{ + input "timeOnB3", title: "Schedule 3 time to turn on", "time", required: false + input "timeOffB3", title: "Schedule 3 time to turn off", "time", required: false + } + section{ + input "timeOnB4", title: "Schedule 4 time to turn on", "time", required: false + input "timeOffB4", title: "Schedule 4 time to turn off", "time", required: false + } + section("Options") { + input "titleB", title: "Assign a scenario name", "text", required: false + input "modeB", "mode", required: false, multiple: true, title: "Run in specific mode(s)", description: "Choose Modes" + input "daysB", "enum", multiple: true, title: "Run on specific day(s)", description: "Choose Days", required: false, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + } + } + +page(name: "C_Scenario", title: getTitle (titleC, "C")) { + section{ + input "timeOnC1", title: "Schedule 1 time to turn on", "time", required: false + input "timeOffC1", title: "Schedule 1 time to turn off", "time", required: false + } + section{ + input "timeOnC2", title: "Schedule 2 time to turn on", "time", required: false + input "timeOffC2", title: "Schedule 2 time to turn off", "time", required: false + } + section{ + input "timeOnC3", title: "Schedule 3 time to turn on", "time", required: false + input "timeOffC3", title: "Schedule 3 time to turn off", "time", required: false + } + section{ + input "timeOnC4", title: "Schedule 4 time to turn on", "time", required: false + input "timeOffC4", title: "Schedule 4 time to turn off", "time", required: false + } + section("Options") { + input "titleC", title: "Assign a scenario name", "text", required: false + input "modeC", "mode", required: false, multiple: true, title: "Run in specific mode(s)", description: "Choose Modes" + input "daysC", "enum", multiple: true, title: "Run on specific day(s)", description: "Choose Days", required: false, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + } + } + + +page(name: "D_Scenario", title: getTitle (titleD, "D")) { + section{ + input "timeOnD1", title: "Schedule 1 time to turn on", "time", required: false + input "timeOffD1", title: "Schedule 1 time to turn off", "time", required: false + } + section{ + input "timeOnD2", title: "Schedule 2 time to turn on", "time", required: false + input "timeOffD2", title: "Schedule 2 time to turn off", "time", required: false + } + section{ + input "timeOnD3", title: "Schedule 3 time to turn on", "time", required: false + input "timeOffD3", title: "Schedule 3 time to turn off", "time", required: false + } + section{ + input "timeOnD4", title: "Schedule 4 time to turn on", "time", required: false + input "timeOffD4", title: "Schedule 4 time to turn off", "time", required: false + } + section("Options") { + input "titleD", title: "Assign a scenario name", "text", required: false + input "modeD", "mode", required: false, multiple: true, title: "Run in specific mode(s)", description: "Choose Modes" + input "daysD", "enum", multiple: true, title: "Run on specific day(s)", description: "Choose Days", required: false, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + } + } + + +page(name: "pageAbout", title: "About ${textAppName()}") { + section { + paragraph "${textVersion()}\n${textCopyright()}\n\n${textLicense()}\n" + } + section("Instructions") { + paragraph textHelp() + } +} + +// Install and initiate + +def installed() { + log.debug "Installed with settings: ${settings}" + init() +} + +def updated() { + unschedule() + turnOffSwitch() //Turn off all switches if the schedules are changed while in mid-schedule + unsubscribe() + log.debug "Updated with settings: ${settings}" + init() +} + +def init() { + def midnightTime = timeToday("2000-01-01T00:01:00.999-0000", location.timeZone) + schedule (midnightTime, midNight) + subscribe(location, "mode", locationHandler) + startProcess() +} + +// Common methods + +def startProcess () { + createDayArray() + state.dayCount=state.data.size() + if (state.dayCount){ + state.counter = 0 + startDay() + } +} + +def startDay() { + def start = convertEpoch(state.data[state.counter].start) + def stop = convertEpoch(state.data[state.counter].stop) + + runOnce(start, turnOnSwitch, [overwrite: true]) + runOnce(stop, incDay, [overwrite: true]) +} + +def incDay() { + turnOffSwitch() + if (state.modeChange) { + startProcess() + } + else { + state.counter = state.counter + 1 + if (state.counter < state.dayCount) { + startDay() + } + } +} + +def locationHandler(evt) { + def result = false + state.modeChange = true + switches.each { + if (it.currentValue("switch")=="on"){ + result = true + } + } + if (!result) { + startProcess() + } +} + +def midNight(){ + startProcess() +} + +def turnOnSwitch() { + switches.on() + log.debug "Home ventilation switches are on." +} + +def turnOffSwitch() { + switches.each { + if (it.currentValue("switch")=="on"){ + it.off() + } + } + log.debug "Home ventilation switches are off." +} + +def schedDesc(on1, off1, on2, off2, on3, off3, on4, off4, modeList, dayList) { + def title = "" + def dayListClean = "On " + def modeListClean ="Scenario runs in " + if (dayList && dayList.size() < 7) { + def dayListSize = dayList.size() + for (dayName in dayList) { + dayListClean = "${dayListClean}"+"${dayName}" + dayListSize = dayListSize -1 + if (dayListSize) { + dayListClean = "${dayListClean}, " + } + } + } + else { + dayListClean = "Every day" + } + if (modeList) { + def modeListSize = modeList.size() + def modePrefix ="modes" + if (modeListSize == 1) { + modePrefix = "mode" + } + for (modeName in modeList) { + modeListClean = "${modeListClean}"+"'${modeName}'" + modeListSize = modeListSize -1 + if (modeListSize) { + modeListClean = "${modeListClean}, " + } + else { + modeListClean = "${modeListClean} ${modePrefix}" + } + } + } + else { + modeListClean = "${modeListClean}all modes" + } + if (on1 && off1){ + title += "Schedule 1: ${humanReadableTime(on1)} to ${humanReadableTime(off1)}" + } + if (on2 && off2) { + title += "\nSchedule 2: ${humanReadableTime(on2)} to ${humanReadableTime(off2)}" + } + if (on3 && off3) { + title += "\nSchedule 3: ${humanReadableTime(on3)} to ${humanReadableTime(off3)}" + } + if (on4 && off4) { + title += "\nSchedule 4: ${humanReadableTime(on4)} to ${humanReadableTime(off4)}" + } + if (on1 || on2 || on3 || on4) { + title += "\n$modeListClean" + title += "\n$dayListClean" + } + + if (!on1 && !on2 && !on3 && !on4) { + title="Click to configure scenario" + } + title +} + +def greyOut(on1, on2, on3, on4){ + def result = on1 || on2 || on3 || on4 ? "complete" : "" +} + +public humanReadableTime(dateTxt) { + new Date().parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", dateTxt).format("h:mm a", timeZone(dateTxt)) +} + +public convertEpoch(epochDate) { + new Date(epochDate).format("yyyy-MM-dd'T'HH:mm:ss.SSSZ", location.timeZone) +} + +private getTitle(txt, scenario) { + def title = txt ? txt : "Scenario ${scenario}" +} + +private daysOk(dayList) { + def result = true + if (dayList) { + def df = new java.text.SimpleDateFormat("EEEE") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + def day = df.format(new Date()) + result = dayList.contains(day) + } + result +} + +private timeOk(starting, ending) { + if (starting && ending) { + def currTime = now() + def start = timeToday(starting).time + def stop = timeToday(ending).time + if (start < stop && start >= currTime && stop>=currTime) { + state.data << [start:start, stop:stop] + } + } +} + +def createDayArray() { + state.modeChange = false + state.data = [] + if (modeA && modeA.contains(location.mode)) { + if (daysOk(daysA)){ + timeOk(timeOnA1, timeOffA1) + timeOk(timeOnA2, timeOffA2) + timeOk(timeOnA3, timeOffA3) + timeOk(timeOnA4, timeOffA4) + } + } + if (modeB && modeB.contains(location.mode)) { + if (daysOk(daysB)){ + timeOk(timeOnB1, timeOffB1) + timeOk(timeOnB2, timeOffB2) + timeOk(timeOnB3, timeOffB3) + timeOk(timeOnB4, timeOffB4) + } + } + if (modeC && modeC.contains(location.mode)) { + if (daysOk(daysC)){ + timeOk(timeOnC1, timeOffC1) + timeOk(timeOnC2, timeOffC2) + timeOk(timeOnC3, timeOffC3) + timeOk(timeOnC4, timeOffC4) + } + } + if (modeD && modeD.contains(location.mode)) { + if (daysOk(daysD)){ + timeOk(timeOnD1, timeOffD1) + timeOk(timeOnD2, timeOffD2) + timeOk(timeOnD3, timeOffD3) + timeOk(timeOnD4, timeOffD4) + } + } + state.data.sort{it.start} +} + +//Version/Copyright/Information/Help + +private def textAppName() { + def text = "Smart Home Ventilation" +} + +private def textVersion() { + def text = "Version 2.1.2 (05/31/2015)" +} + +private def textCopyright() { + def text = "Copyright © 2015 Michael Struck" +} + +private def textLicense() { + def text = + "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"+ + "\n\n"+ + " http://www.apache.org/licenses/LICENSE-2.0"+ + "\n\n"+ + "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." +} + +private def textHelp() { + def text = + "Within each scenario, choose a start and end time for the ventilation fan. You can have up to 4 different " + + "venting scenarios, and 4 schedules within each scenario. Each scenario can be restricted to specific modes or certain days of the week. It is recommended "+ + "that each scenario does not overlap and run in separate modes (i.e. Home, Out of town, etc). Also note that you should " + + "avoid scheduling the ventilation fan at exactly midnight; the app resets itself at that time. It is suggested to start any new schedule " + + "at 12:15 am or later." +} diff --git a/official/smart-humidifier.groovy b/official/smart-humidifier.groovy new file mode 100755 index 0000000..eaf30ba --- /dev/null +++ b/official/smart-humidifier.groovy @@ -0,0 +1,128 @@ +/** + * Smart Humidifier + * + * Copyright 2014 Sheikh Dawood + * + * 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: "Smart Humidifier", + namespace: "Sheikhsphere", + author: "Sheikh Dawood", + description: "Turn on/off humidifier based on relative humidity from a sensor.", + category: "Convenience", + iconUrl: "https://graph.api.smartthings.com/api/devices/icons/st.Weather.weather12-icn", + iconX2Url: "https://graph.api.smartthings.com/api/devices/icons/st.Weather.weather12-icn?displaySize=2x" +) + + +preferences { + section("Monitor the humidity of:") { + input "humiditySensor1", "capability.relativeHumidityMeasurement" + } + section("When the humidity rises above:") { + input "humidityHigh", "number", title: "Percentage ?" + } + section("When the humidity drops below:") { + input "humidityLow", "number", title: "Percentage ?" + } + section("Control Humidifier:") { + input "switch1", "capability.switch" + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes","No"]], required:false + input "phone1", "phone", title: "Send a Text Message?", required: false + } +} + +def installed() { + subscribe(humiditySensor1, "humidity", humidityHandler) +} + +def updated() { + unsubscribe() + subscribe(humiditySensor1, "humidity", humidityHandler) +} + +def humidityHandler(evt) { + log.trace "humidity: $evt.value" + log.trace "set high point: $humidityHigh" + log.trace "set low point: $humidityLow" + + def currentHumidity = Double.parseDouble(evt.value.replace("%", "")) + def humidityHigh1 = humidityHigh + def humidityLow1 = humidityLow + def mySwitch = settings.switch1 + + if (currentHumidity >= humidityHigh1) { + log.debug "Checking how long the humidity sensor has been reporting >= $humidityHigh1" + + // Don't send a continuous stream of text messages + def deltaMinutes = 10 + def timeAgo = new Date(now() - (1000 * 60 * deltaMinutes).toLong()) + def recentEvents = humiditySensor1.eventsSince(timeAgo) + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $deltaMinutes minutes" + def alreadySentSms1 = recentEvents.count { Double.parseDouble(it.value.replace("%", "")) >= humidityHigh1 } > 1 + + if (alreadySentSms1) { + log.debug "Notification already sent within the last $deltaMinutes minutes" + + } else { + if (state.lastStatus != "off") { + log.debug "Humidity Rose Above $humidityHigh1: sending SMS and deactivating $mySwitch" + send("${humiditySensor1.label} sensed high humidity level of ${evt.value}, turning off ${switch1.label}") + switch1?.off() + state.lastStatus = "off" + } + } + } + else if (currentHumidity <= humidityLow1) { + log.debug "Checking how long the humidity sensor has been reporting <= $humidityLow1" + + // Don't send a continuous stream of text messages + def deltaMinutes = 10 + def timeAgo = new Date(now() - (1000 * 60 * deltaMinutes).toLong()) + def recentEvents = humiditySensor1.eventsSince(timeAgo) + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $deltaMinutes minutes" + def alreadySentSms2 = recentEvents.count { Double.parseDouble(it.value.replace("%", "")) <= humidityLow1 } > 1 + + if (alreadySentSms2) { + log.debug "Notification already sent within the last $deltaMinutes minutes" + + } else { + if (state.lastStatus != "on") { + log.debug "Humidity Dropped Below $humidityLow1: sending SMS and activating $mySwitch" + send("${humiditySensor1.label} sensed low humidity level of ${evt.value}, turning on ${switch1.label}") + switch1?.on() + state.lastStatus = "on" + } + } + } + else { + //log.debug "Humidity remained in threshold: sending SMS to $phone1 and activating $mySwitch" + //send("${humiditySensor1.label} sensed humidity level of ${evt.value} is within threshold, keeping on ${switch1.label}") + //switch1?.on() + } +} + +private send(msg) { + if ( sendPushMessage != "No" ) { + log.debug( "sending push message" ) + sendPush( msg ) + } + + if ( phone1 ) { + log.debug( "sending text message" ) + sendSms( phone1, msg ) + } + + log.debug msg +} diff --git a/official/smart-light-timer-x-minutes-unless-already-on.groovy b/official/smart-light-timer-x-minutes-unless-already-on.groovy new file mode 100755 index 0000000..1af9a2d --- /dev/null +++ b/official/smart-light-timer-x-minutes-unless-already-on.groovy @@ -0,0 +1,148 @@ +/** + * Smart Timer + * Loosely based on "Light Follows Me" + * + * This prevent them from turning off when the timer expires, if they were already turned on + * + * If the switch is already on, if won't be affected by the timer (Must be turned of manually) + * If the switch is toggled while in timeout-mode, it will remain on and ignore the timer (Must be turned of manually) + * + * The timeout perid begins when the contact is closed, or motion stops, so leaving a door open won't start the timer until it's closed. + * + * Author: andersheie@gmail.com + * Date: 2014-08-31 + */ + +definition( + name: "Smart Light Timer, X minutes unless already on", + namespace: "Pope", + author: "listpope@cox.net", + description: "Turns on a switch for X minutes, then turns it off. Unless, the switch is already on, in which case it stays on. If the switch is toggled while the timer is running, the timer is canceled.", + category: "Convenience", + iconUrl: "http://upload.wikimedia.org/wikipedia/commons/6/6a/Light_bulb_icon_tips.svg", + iconX2Url: "http://upload.wikimedia.org/wikipedia/commons/6/6a/Light_bulb_icon_tips.svg") + +preferences { + section("Turn on when there's movement..."){ + input "motions", "capability.motionSensor", multiple: true, title: "Select motion detectors", required: false + } + section("Or, turn on when one of these contacts opened"){ + input "contacts", "capability.contactSensor", multiple: true, title: "Select Contacts", required: false + } + section("And off after no more triggers after..."){ + input "minutes1", "number", title: "Minutes?", defaultValue: "5" + } + section("Turn on/off light(s)..."){ + input "switches", "capability.switch", multiple: true, title: "Select Lights" + } +} + + +def installed() +{ + subscribe(switches, "switch", switchChange) + subscribe(motions, "motion", motionHandler) + subscribe(contacts, "contact", contactHandler) + schedule("0 * * * * ?", "scheduleCheck") + state.myState = "ready" +} + + +def updated() +{ + unsubscribe() + subscribe(motions, "motion", motionHandler) + subscribe(switches, "switch", switchChange) + subscribe(contacts, "contact", contactHandler) + + state.myState = "ready" + log.debug "state: " + state.myState +} + +def switchChange(evt) { + log.debug "SwitchChange: $evt.name: $evt.value" + + if(evt.value == "on") { + // Slight change of Race condition between motion or contact turning the switch on, + // versus user turning the switch on. Since we can't pass event parameters :-(, we rely + // on the state and hope the best. + if(state.myState == "activating") { + // OK, probably an event from Activating something, and not the switch itself. Go to Active mode. + state.myState = "active" + } else if(state.myState != "active") { + state.myState = "already on" + } + } else { + // If active and switch is turned of manually, then stop the schedule and go to ready state + if(state.myState == "active" || state.myState == "activating") { + unschedule() + } + state.myState = "ready" + } + log.debug "state: " + state.myState +} + +def contactHandler(evt) { + log.debug "contactHandler: $evt.name: $evt.value" + + if (evt.value == "open") { + if(state.myState == "ready") { + log.debug "Turning on lights by contact opening" + switches.on() + state.inactiveAt = null + state.myState = "activating" + } + } else if (evt.value == "closed") { + if (!state.inactiveAt && state.myState == "active" || state.myState == "activating") { + // When contact closes, we reset the timer if not already set + setActiveAndSchedule() + } + } + log.debug "state: " + state.myState +} + +def motionHandler(evt) { + log.debug "motionHandler: $evt.name: $evt.value" + + if (evt.value == "active") { + if(state.myState == "ready" || state.myState == "active" || state.myState == "activating" ) { + log.debug "turning on lights" + switches.on() + state.inactiveAt = null + state.myState = "activating" + } + } else if (evt.value == "inactive") { + if (!state.inactiveAt && state.myState == "active" || state.myState == "activating") { + // When Motion ends, we reset the timer if not already set + setActiveAndSchedule() + } + } + log.debug "state: " + state.myState +} + +def setActiveAndSchedule() { + unschedule() + state.myState = "active" + state.inactiveAt = now() + schedule("0 * * * * ?", "scheduleCheck") +} + +def scheduleCheck() { + log.debug "schedule check, ts = ${state.inactiveAt}" + if(state.myState != "already on") { + if(state.inactiveAt != null) { + def elapsed = now() - state.inactiveAt + log.debug "${elapsed / 1000} sec since motion stopped" + def threshold = 1000 * 60 * minutes1 + if (elapsed >= threshold) { + if (state.myState == "active") { + log.debug "turning off lights" + switches.off() + } + state.inactiveAt = null + state.myState = "ready" + } + } + } + log.debug "state: " + state.myState +} diff --git a/official/smart-nightlight.groovy b/official/smart-nightlight.groovy new file mode 100755 index 0000000..5afb0be --- /dev/null +++ b/official/smart-nightlight.groovy @@ -0,0 +1,174 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Smart Nightlight + * + * Author: SmartThings + * + */ +definition( + name: "Smart Nightlight", + namespace: "smartthings", + author: "SmartThings", + description: "Turns on lights when it's dark and motion is detected. Turns lights off when it becomes light or some time after motion ceases.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_motion-outlet-luminance.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_motion-outlet-luminance@2x.png" +) + +preferences { + section("Control these lights..."){ + input "lights", "capability.switch", multiple: true + } + section("Turning on when it's dark and there's movement..."){ + input "motionSensor", "capability.motionSensor", title: "Where?" + } + section("And then off when it's light or there's been no movement for..."){ + input "delayMinutes", "number", title: "Minutes?" + } + section("Using either on this light sensor (optional) or the local sunrise and sunset"){ + input "lightSensor", "capability.illuminanceMeasurement", required: false + } + section ("Sunrise offset (optional)...") { + input "sunriseOffsetValue", "text", title: "HH:MM", required: false + input "sunriseOffsetDir", "enum", title: "Before or After", required: false, options: ["Before","After"] + } + section ("Sunset offset (optional)...") { + input "sunsetOffsetValue", "text", title: "HH:MM", required: false + input "sunsetOffsetDir", "enum", title: "Before or After", required: false, options: ["Before","After"] + } + section ("Zip code (optional, defaults to location coordinates when location services are enabled)...") { + input "zipCode", "text", title: "Zip code", required: false + } +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + subscribe(motionSensor, "motion", motionHandler) + if (lightSensor) { + subscribe(lightSensor, "illuminance", illuminanceHandler, [filterEvents: false]) + } + else { + subscribe(location, "position", locationPositionChange) + subscribe(location, "sunriseTime", sunriseSunsetTimeHandler) + subscribe(location, "sunsetTime", sunriseSunsetTimeHandler) + astroCheck() + } +} + +def locationPositionChange(evt) { + log.trace "locationChange()" + astroCheck() +} + +def sunriseSunsetTimeHandler(evt) { + state.lastSunriseSunsetEvent = now() + log.debug "SmartNightlight.sunriseSunsetTimeHandler($app.id)" + astroCheck() +} + +def motionHandler(evt) { + log.debug "$evt.name: $evt.value" + if (evt.value == "active") { + if (enabled()) { + log.debug "turning on lights due to motion" + lights.on() + state.lastStatus = "on" + } + state.motionStopTime = null + } + else { + state.motionStopTime = now() + if(delayMinutes) { + runIn(delayMinutes*60, turnOffMotionAfterDelay, [overwrite: false]) + } else { + turnOffMotionAfterDelay() + } + } +} + +def illuminanceHandler(evt) { + log.debug "$evt.name: $evt.value, lastStatus: $state.lastStatus, motionStopTime: $state.motionStopTime" + def lastStatus = state.lastStatus + if (lastStatus != "off" && evt.integerValue > 50) { + lights.off() + state.lastStatus = "off" + } + else if (state.motionStopTime) { + if (lastStatus != "off") { + def elapsed = now() - state.motionStopTime + if (elapsed >= ((delayMinutes ?: 0) * 60000L) - 2000) { + lights.off() + state.lastStatus = "off" + } + } + } + else if (lastStatus != "on" && evt.integerValue < 30){ + lights.on() + state.lastStatus = "on" + } +} + +def turnOffMotionAfterDelay() { + log.trace "In turnOffMotionAfterDelay, state.motionStopTime = $state.motionStopTime, state.lastStatus = $state.lastStatus" + if (state.motionStopTime && state.lastStatus != "off") { + def elapsed = now() - state.motionStopTime + log.trace "elapsed = $elapsed" + if (elapsed >= ((delayMinutes ?: 0) * 60000L) - 2000) { + log.debug "Turning off lights" + lights.off() + state.lastStatus = "off" + } + } +} + +def scheduleCheck() { + log.debug "In scheduleCheck - skipping" + //turnOffMotionAfterDelay() +} + +def astroCheck() { + def s = getSunriseAndSunset(zipCode: zipCode, sunriseOffset: sunriseOffset, sunsetOffset: sunsetOffset) + state.riseTime = s.sunrise.time + state.setTime = s.sunset.time + log.debug "rise: ${new Date(state.riseTime)}($state.riseTime), set: ${new Date(state.setTime)}($state.setTime)" +} + +private enabled() { + def result + if (lightSensor) { + result = lightSensor.currentIlluminance?.toInteger() < 30 + } + else { + def t = now() + result = t < state.riseTime || t > state.setTime + } + result +} + +private getSunriseOffset() { + sunriseOffsetValue ? (sunriseOffsetDir == "Before" ? "-$sunriseOffsetValue" : sunriseOffsetValue) : null +} + +private getSunsetOffset() { + sunsetOffsetValue ? (sunsetOffsetDir == "Before" ? "-$sunsetOffsetValue" : sunsetOffsetValue) : null +} + diff --git a/official/smart-security.groovy b/official/smart-security.groovy new file mode 100755 index 0000000..acc571e --- /dev/null +++ b/official/smart-security.groovy @@ -0,0 +1,318 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Smart Security + * + * Author: SmartThings + * Date: 2013-03-07 + */ +definition( + name: "Smart Security", + namespace: "smartthings", + author: "SmartThings", + description: "Alerts you when there are intruders but not when you just got up for a glass of water in the middle of the night", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-IsItSafe.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-IsItSafe@2x.png" +) + +preferences { + section("Sensors detecting an intruder") { + input "intrusionMotions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + input "intrusionContacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false + } + section("Sensors detecting residents") { + input "residentMotions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + } + section("Alarm settings and actions") { + input "alarms", "capability.alarm", title: "Which Alarm(s)", multiple: true, required: false + input "silent", "enum", options: ["Yes","No"], title: "Silent alarm only (Yes/No)" + input "seconds", "number", title: "Delay in seconds before siren sounds" + input "lights", "capability.switch", title: "Flash these lights (optional)", multiple: true, required: false + input "newMode", "mode", title: "Change to this mode (optional)", required: false + } + section("Notify others (optional)") { + input "textMessage", "text", title: "Send this message", multiple: false, required: false + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "To this phone", multiple: false, required: false + } + } + section("Arm system when residents quiet for (default 3 minutes)") { + input "residentsQuietThreshold", "number", title: "Time in minutes", required: false + } +} + +def installed() { + log.debug "INSTALLED" + subscribeToEvents() + state.alarmActive = null +} + +def updated() { + log.debug "UPDATED" + unsubscribe() + subscribeToEvents() + unschedule() + state.alarmActive = null + state.residentsAreUp = null + state.lastIntruderMotion = null + alarms?.off() +} + +private subscribeToEvents() +{ + subscribe intrusionMotions, "motion", intruderMotion + // subscribe residentMotions, "motion", residentMotion + subscribe intrusionContacts, "contact", contact + subscribe alarms, "alarm", alarm + subscribe(app, appTouch) +} + +private residentsHaveBeenQuiet() +{ + def threshold = ((residentsQuietThreshold != null && residentsQuietThreshold != "") ? residentsQuietThreshold : 3) * 60 * 1000 + def result = true + def t0 = new Date(now() - threshold) + for (sensor in residentMotions) { + def recentStates = sensor.statesSince("motion", t0) + if (recentStates.find{it.value == "active"}) { + result = false + break + } + } + log.debug "residentsHaveBeenQuiet: $result" + result +} + +private intruderMotionInactive() +{ + def result = true + for (sensor in intrusionMotions) { + if (sensor.currentMotion == "active") { + result = false + break + } + } + result +} + +private isResidentMotionSensor(evt) +{ + residentMotions?.find{it.id == evt.deviceId} != null +} + +def appTouch(evt) +{ + alarms?.off() + state.alarmActive = false +} + +// Here to handle old subscriptions +def motion(evt) +{ + if (isResidentMotionSensor(evt)) { + log.debug "resident motion, $evt.name: $evt.value" + residentMotion(evt) + } + else { + log.debug "intruder motion, $evt.name: $evt.value" + intruderMotion(evt) + } +} + +def intruderMotion(evt) +{ + if (evt.value == "active") { + log.debug "motion by potential intruder, residentsAreUp: $state.residentsAreUp" + if (!state.residentsAreUp) { + log.trace "checking if residents have been quiet" + if (residentsHaveBeenQuiet()) { + log.trace "calling startAlarmSequence" + startAlarmSequence() + } + else { + log.trace "calling disarmIntrusionDetection" + disarmIntrusionDetection() + } + } + } + state.lastIntruderMotion = now() +} + +def residentMotion(evt) +{ + // Don't think we need this any more + //if (evt.value == "inactive") { + // if (state.residentsAreUp) { + // startReArmSequence() + // } + //} + unsubscribe(residentMotions) +} + +def contact(evt) +{ + if (evt.value == "open") { + // TODO - check for residents being up? + if (!state.residentsAreUp) { + if (residentsHaveBeenQuiet()) { + startAlarmSequence() + } + else { + disarmIntrusionDetection() + } + } + } +} + +def alarm(evt) +{ + log.debug "$evt.name: $evt.value" + if (evt.value == "off") { + alarms?.off() + state.alarmActive = false + } +} + +private disarmIntrusionDetection() +{ + log.debug "residents are up, disarming intrusion detection" + state.residentsAreUp = true + scheduleReArmCheck() +} + +private scheduleReArmCheck() +{ + def cron = "0 * * * * ?" + schedule(cron, "checkForReArm") + log.debug "Starting re-arm check, cron: $cron" +} + +def checkForReArm() +{ + def threshold = ((residentsQuietThreshold != null && residentsQuietThreshold != "") ? residentsQuietThreshold : 3) * 60 * 1000 + log.debug "checkForReArm: threshold is $threshold" + // check last intruder motion + def lastIntruderMotion = state.lastIntruderMotion + log.debug "checkForReArm: lastIntruderMotion=$lastIntruderMotion" + if (lastIntruderMotion != null) + { + log.debug "checkForReArm, time since last intruder motion: ${now() - lastIntruderMotion}" + if (now() - lastIntruderMotion > threshold) { + log.debug "re-arming intrusion detection" + state.residentsAreUp = false + unschedule() + } + } + else { + log.warn "checkForReArm: lastIntruderMotion was null, unable to check for re-arming intrusion detection" + } +} + +private startAlarmSequence() +{ + if (state.alarmActive) { + log.debug "alarm already active" + } + else { + state.alarmActive = true + log.debug "starting alarm sequence" + + sendPush("Potential intruder detected!") + + if (newMode) { + setLocationMode(newMode) + } + + if (silentAlarm()) { + log.debug "Silent alarm only" + alarms?.strobe() + if (location.contactBookEnabled) { + sendNotificationToContacts(textMessage ?: "Potential intruder detected", recipients) + } + else { + if (phone) { + sendSms(phone, textMessage ?: "Potential intruder detected") + } + } + } + else { + def delayTime = seconds + if (delayTime) { + alarms?.strobe() + runIn(delayTime, "soundSiren") + log.debug "Sounding siren in $delayTime seconds" + } + else { + soundSiren() + } + } + + if (lights) { + flashLights(Math.min((seconds/2) as Integer, 10)) + } + } +} + +def soundSiren() +{ + if (state.alarmActive) { + log.debug "Sounding siren" + if (location.contactBookEnabled) { + sendNotificationToContacts(textMessage ?: "Potential intruder detected", recipients) + } + else { + if (phone) { + sendSms(phone, textMessage ?: "Potential intruder detected") + } + } + alarms?.both() + if (lights) { + log.debug "continue flashing lights" + continueFlashing() + } + } + else { + log.debug "alarm activation aborted" + } + unschedule("soundSiren") // Temporary work-around to scheduling bug +} + +def continueFlashing() +{ + unschedule() + if (state.alarmActive) { + flashLights(10) + schedule(util.cronExpression(now() + 10000), "continueFlashing") + } +} + +private flashLights(numFlashes) { + def onFor = 1000 + def offFor = 1000 + + log.debug "FLASHING $numFlashes times" + def delay = 1L + numFlashes.times { + log.trace "Switch on after $delay msec" + lights?.on(delay: delay) + delay += onFor + log.trace "Switch off after $delay msec" + lights?.off(delay: delay) + delay += offFor + } +} + +private silentAlarm() +{ + silent?.toLowerCase() in ["yes","true","y"] +} diff --git a/official/smart-turn-it-on.groovy b/official/smart-turn-it-on.groovy new file mode 100755 index 0000000..8e31d2a --- /dev/null +++ b/official/smart-turn-it-on.groovy @@ -0,0 +1,73 @@ +/** + * Smart turn it on + * + * Author: sidjohn1@gmail.com + * Date: 2013-10-21 + */ + +// Automatically generated. Make future change here. +definition( + name: "Smart turn it on", + namespace: "sidjohn1", + author: "sidjohn1@gmail.com", + description: "Turns on selected device(s) at a set time on selected days of the week only if a selected person is present and turns off selected device(s) after a set time.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("Turn on which device?"){ + input "switchOne", "capability.switch",title:"Select Light", required: true, multiple: true + } + section("For Whom?") { + input "presenceOne", "capability.presenceSensor", title: "Select Person", required: true, multiple: true + } + section("On which Days?") { + input "dayOne", "enum", title:"Select Days", required: true, multiple:true, metadata: [values: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']] + } + section("At what time?") { + input name: "timeOne", title: "Select Time", type: "time", required: true + } + section("For how long?") { + input name: "timeTwo", title: "Number of minutes", type: "number", required: true + } +} + +def installed() { + if (timeOne) + { + log.debug "scheduling 'Smart turn it on' to run at $timeOne" + schedule(timeOne, "turnOn") + } +} + +def updated() { + unsubscribe() + unschedule() + if (timeOne) + { + log.debug "scheduling 'Smart turn it on' to run at $timeOne" + schedule(timeOne, "turnOn") + } +} + +def turnOn(){ +log.debug "Start" + def dayCheck = dayOne.contains(new Date().format("EEE")) + def dayTwo = new Date().format("EEE"); + if(dayCheck){ + def presenceTwo = presenceOne.latestValue("presence").contains("present") + if (presenceTwo) { + switchOne.on() + def delay = timeTwo * 60 + runIn(delay, "turnOff") + } + } +} + + + +def turnOff() { + switchOne.off() +} \ No newline at end of file diff --git a/official/smart-windows.groovy b/official/smart-windows.groovy new file mode 100755 index 0000000..924da87 --- /dev/null +++ b/official/smart-windows.groovy @@ -0,0 +1,164 @@ +/** + * Smart Windows + * Compares two temperatures – indoor vs outdoor, for example – then sends an alert if windows are open (or closed!). + * + * Copyright 2014 Eric Gideon + * + * Based in part on the "When it's going to rain" SmartApp by the SmartThings team, + * primarily the message throttling code. + * + * 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: "Smart Windows", + namespace: "egid", + author: "Eric Gideon", + description: "Compares two temperatures – indoor vs outdoor, for example – then sends an alert if windows are open (or closed!). If you don't use an external temperature device, your location will be used instead.", + iconUrl: "https://s3.amazonaws.com/smartthings-device-icons/Home/home9-icn.png", + iconX2Url: "https://s3.amazonaws.com/smartthings-device-icons/Home/home9-icn@2x.png" +) + + +preferences { + + if (!(location.zipCode || ( location.latitude && location.longitude )) && location.channelName == 'samsungtv') { + section { paragraph title: "Note:", "Location is required for this SmartApp. Go to 'Location Name' settings to setup your correct location." } + } + + section( "Set the temperature range for your comfort zone..." ) { + input "minTemp", "number", title: "Minimum temperature" + input "maxTemp", "number", title: "Maximum temperature" + } + section( "Select windows to check..." ) { + input "sensors", "capability.contactSensor", multiple: true + } + section( "Select temperature devices to monitor..." ) { + input "inTemp", "capability.temperatureMeasurement", title: "Indoor" + input "outTemp", "capability.temperatureMeasurement", title: "Outdoor (optional)", required: false + } + + if (location.channelName != 'samsungtv') { + section( "Set your location" ) { input "zipCode", "text", title: "Zip code" } + } + + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes","No"]], required:false + input "retryPeriod", "number", title: "Minutes between notifications:" + } +} + + +def installed() { + log.debug "Installed: $settings" + subscribe( inTemp, "temperature", temperatureHandler ) +} + +def updated() { + log.debug "Updated: $settings" + unsubscribe() + subscribe( inTemp, "temperature", temperatureHandler ) +} + + +def temperatureHandler(evt) { + def currentOutTemp = null + if ( outTemp ) { + currentOutTemp = outTemp.latestValue("temperature") + } else { + log.debug "No external temperature device set. Checking WUnderground...." + currentOutTemp = weatherCheck() + } + + def currentInTemp = evt.doubleValue + def openWindows = sensors.findAll { it?.latestValue("contact") == 'open' } + + log.trace "Temp event: $evt" + log.info "In: $currentInTemp; Out: $currentOutTemp" + + // Don't spam notifications + // *TODO* use state.foo from Severe Weather Alert to do this better + if (!retryPeriod) { + def retryPeriod = 30 + } + def timeAgo = new Date(now() - (1000 * 60 * retryPeriod).toLong()) + def recentEvents = inTemp.eventsSince(timeAgo) + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $retryPeriod minutes" + + // Figure out if we should notify + if ( currentInTemp > minTemp && currentInTemp < maxTemp ) { + log.info "In comfort zone: $currentInTemp is between $minTemp and $maxTemp." + log.debug "No notifications sent." + } else if ( currentInTemp > maxTemp ) { + // Too warm. Can we do anything? + + def alreadyNotified = recentEvents.count { it.doubleValue > currentOutTemp } > 1 + + if ( !alreadyNotified ) { + if ( currentOutTemp < maxTemp && !openWindows ) { + send( "Open some windows to cool down the house! Currently ${currentInTemp}°F inside and ${currentOutTemp}°F outside." ) + } else if ( currentOutTemp > maxTemp && openWindows ) { + send( "It's gotten warmer outside! You should close these windows: ${openWindows.join(', ')}. Currently ${currentInTemp}°F inside and ${currentOutTemp}°F outside." ) + } else { + log.debug "No notifications sent. Everything is in the right place." + } + } else { + log.debug "Already notified! No notifications sent." + } + } else if ( currentInTemp < minTemp ) { + // Too cold! Is it warmer outside? + + def alreadyNotified = recentEvents.count { it.doubleValue < currentOutTemp } > 1 + + if ( !alreadyNotified ) { + if ( currentOutTemp > minTemp && !openWindows ) { + send( "Open some windows to warm up the house! Currently ${currentInTemp}°F inside and ${currentOutTemp}°F outside." ) + } else if ( currentOutTemp < minTemp && openWindows ) { + send( "It's gotten colder outside! You should close these windows: ${openWindows.join(', ')}. Currently ${currentInTemp}°F inside and ${currentOutTemp}°F outside." ) + } else { + log.debug "No notifications sent. Everything is in the right place." + } + } else { + log.debug "Already notified! No notifications sent." + } + } +} + +def weatherCheck() { + def json + if (location.channelName != 'samsungtv') + json = getWeatherFeature("conditions", zipCode) + else + json = getWeatherFeature("conditions") + def currentTemp = json?.current_observation?.temp_f + + if ( currentTemp ) { + log.trace "Temp: $currentTemp (WeatherUnderground)" + return currentTemp + } else { + log.warn "Did not get a temp: $json" + return false + } +} + +private send(msg) { + if ( sendPushMessage != "No" ) { + log.debug( "sending push message" ) + sendPush( msg ) + sendEvent(linkText:app.label, descriptionText:msg, eventType:"SOLUTION_EVENT", displayed: true, name:"summary") + } + + if ( phone1 ) { + log.debug( "sending text message" ) + sendSms( phone1, msg ) + } + + log.info msg +} diff --git a/official/smartblock-chat-sender.groovy b/official/smartblock-chat-sender.groovy new file mode 100755 index 0000000..c4401fd --- /dev/null +++ b/official/smartblock-chat-sender.groovy @@ -0,0 +1,87 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * SmartClockChatSender + * + * Author: Steve Vlaminck + * + * Date: 2014-02-03 + */ + +definition( + name: "SmartBlock Chat Sender", + namespace: "vlaminck/Minecraft", + author: "SmartThings", + description: "Send chat messages into Minecraft via the SmartBlock mod", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + page(name: "chatPage", title: "Send Notifications Into Minecraft", install: true, uninstall: true) { + section { + input(name: "chatsEnabled", type: "bool", title: "Enable This Notification?", defaultValue: "true") + input(name: "modes", type: "mode", title: "Notify SmartBlock users when the mode changes to:", description: "(optional)", multiple: true, required: false) + input(name: "username", type: "string", title: "Only send to this username", required: false, description: "(optional)") + input(name: "customMessage", type: "string", title: "Custom message?", required: false, description: "(optional)") + } + section(hidden: true, hideable: true, title: "Other Options") { + label(title: "Label this Notification", required: false) + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(location, modeChangeHandler) +} + +def modeChangeHandler(evt) { + def newMode = evt.value + log.debug "evt: ${newMode}" + if (modes && modes.contains(newMode)) + { + def message = customMessage ?: "SmartThings mode has changed to: \"${newMode}\"" + chatMessageToMC(message) + } +} + +def chatMessageToMC(message) { + + def parent = app.getParent() + + def url = "${parent.getServerURL()}/chat?message=${message.encodeAsURL()}" + + if (username) + { + url += "&username=${username.encodeAsURL()}" + } + log.debug "POST to ${url}" + + httpPost(url, "foo=bar") { response -> + content = response.data + log.debug "response: ${content}" + } + +} diff --git a/official/smartblock-linker.groovy b/official/smartblock-linker.groovy new file mode 100755 index 0000000..78540a9 --- /dev/null +++ b/official/smartblock-linker.groovy @@ -0,0 +1,178 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * SmartBlock Linker + * + * Author: Steve Vlaminck + * + * Date: 2013-12-26 + */ + +definition( + name: "SmartBlock Linker", + namespace: "vlaminck/Minecraft", + author: "SmartThings", + description: "A SmartApp that links SmartBlocks to switches", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + + page(name: "linkerPage") +} + +def linkerPage(params) { + + log.debug "linkerPage params: ${params}" + + dynamicPage(name: "linkerPage", title: "Link your SmartBlock to a physical device", install: true, uninstall: false) { + + section { + input( + name: "linkedSmartBlock", + type: "capability.switch", +// type: "device.SmartBlock", + title: "Linked SmartBlock", + required: true, + multiple: false + ) + input( + name: "switchUpdatesBlock", + type: "bool", + title: "Update this SmartBlock when the switch below changes state", + description: "", + defaultValue: "false" + ) + } + section { + input( + name: "linkedSwitch", + type: "capability.switch", + title: "Linked Switch", + required: true, + multiple: false + ) + input( + name: "blockUpdatesSwitch", + type: "bool", + title: "Update this switch when the SmartBlock above changes state", + description: "", + defaultValue: "true" + ) + } + + section { + label( + title: "Label this Link", + required: false + ) + mode( + title: "Only link these devices when in one of these modes", + description: "All modes" + ) + } + + section("When \"Update this SmartBlock...\" is on") { + paragraph "If you place a Redstone Lamp next to your SmartBlock, it will turn on/off when \"Linked Switch\" turns on/off" + } + + section("When \"Update this switch...\" is on") { + paragraph "If you place a lever on your Minecraft SmartBlock, it will control \"Linked Switch\"" + } + + section("Why turning both on can be bad") { + paragraph "Because there can be latency." + paragraph "Flipping the lever will send a signal from Minecraft to SmartThings. SmartThings will then send the signal back when the light has turned on." + paragraph "If you flip the lever again before that round trip is complete, you can get into an infinite loop of signals being sent back and forth." + paragraph "You've been warned ;)" + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + + if (blockUpdatesSwitch) + { + subscribe(linkedSmartBlock, "level", updateSwitchLevel) + subscribe(linkedSmartBlock, "switch", updateSwitchState) + } + + if (switchUpdatesBlock) + { + subscribe(linkedSwitch, "level", updateBlockLevel) + subscribe(linkedSwitch, "switch", updateBlockState) + } + +} + +def updateSwitchLevel(evt) { + int level = evt.value as int + log.debug "matching level: ${level}" + linkedSwitch.setLevel(level) +} + +def updateBlockLevel(evt) { + int level = evt.value as int + log.debug "matching level: ${level}" + linkedSmartBlock.setLevel(level) +} + +def updateSwitchState(evt) { + log.debug "setting linkedSwitch to ${evt.value}" + linkedSwitch."${evt.value}"() +} + +def updateBlockState(evt) { + log.debug "setting linkedSmartBlock to ${evt.value}" + linkedSmartBlock."${evt.value}"() +} + +def getBlockId() { + return linkedSmartBlock.id +} + +def getLinkerDescription() { + + def left = linkedSmartBlock ? "${linkedSmartBlock.label ?: linkedSmartBlock.name}" : "" + def right = linkedSwitch ? "${linkedSwitch.label ?: linkedSwitch.name}" : "" + + log.debug "left: ${left}, right: ${right}" + + def leftLink = switchUpdatesBlock ? "<" : "" + def rightLink = blockUpdatesSwitch ? ">" : "" + + log.debug "leftLink: ${leftLink}, rightLink: ${rightLink}" + + log.debug "switchUpdatesBlock: ${switchUpdatesBlock}" + log.debug "blockUpdatesSwitch: ${blockUpdatesSwitch}" + + if (leftLink == "" && rightLink == "") + { + return null + } + + "${left} ${leftLink}--${rightLink} ${right}" +} diff --git a/official/smartblock-manager.groovy b/official/smartblock-manager.groovy new file mode 100755 index 0000000..c659d4a --- /dev/null +++ b/official/smartblock-manager.groovy @@ -0,0 +1,289 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + */ +import com.sun.corba.se.spi.activation._ServerImplBase + +/** + * SmartBlock Manager + * + * Author: Steve Vlaminck + * + * Date: 2013-12-24 + */ + +definition( + name: "SmartBlock Manager", + namespace: "vlaminck/Minecraft", + author: "SmartThings", + description: "A SmartApp for managing SmartBlocks", + iconUrl: "http://f.cl.ly/items/0p2c222z0p2K0y3y3w2M/SmartApp-icon.png", + iconX2Url: "http://f.cl.ly/items/0p2c222z0p2K0y3y3w2M/SmartApp-icon.png", + oauth: [displayName: "SmartBlock Manager", displayLink: ""] +) + +preferences { + + page(name: "listPage") + page(name: "serverPage") + page(name: "blockPage") + page(name: "linkerPage") + page(name: "notifierPage") + page(name: "chatPage") + + section("SmartBlock Manager") { + input name: "explanation1", title: "Every time you place a SmartBlock in Minecraft, a new SmartThings Device will be created. These Devices can be used in various SmartApps like \"Minecraft Notifier\", or \"Switch State Matcher\"", description: "", type: "paragraph", element: "paragraph", required: false + input name: "explanation2", title: "In order for SmartThings to send commands back to your Minecraft SmartBlocks, you will have to enter your Server Address via the SmartThings iOS or Android app", description: "", type: "paragraph", element: "paragraph", required: false + } +} + +def listPage() { + + log.debug "listPage" + + def linkerApps = findAllChildAppsByName("SmartBlock Linker") + def linkerState = linkerApps ? "complete" : "" + def linkerDescription = linkerApps.collect { it.label ?: it.name }.sort().join("\n") + + def notifierApps = findAllChildAppsByName("SmartBlock Notifier") + def notifierState = notifierApps ? "complete" : "" + def notifierDescription = notifierApps.collect { it.label ?: it.name }.sort().join("\n") + + def chatApps = findAllChildAppsByName("SmartBlock Chat Sender") + def chatState = chatApps ? "complete" : "" + def chatDescription = chatApps.collect { it.label ?: it.name }.sort().join("\n") + + return dynamicPage(name: "listPage", title: "Configure Your SmartBlocks", install: true, uninstall: true) { + section { + href( + name: "toLinkerPage", + title: "Link SmartBlocks To Switches", + description: linkerDescription, + page: "linkerPage", + state: linkerState + ) + href( + name: "toNotifierPage", + title: "Get Notified When a SmartBlock updates", + description: notifierDescription, + page: "notifierPage", + state: notifierState + ) + href( + name: "toChatPage", + title: "Send Notifications into Minecraft", + description: chatDescription, + page: "chatPage", + state: chatState + ) + } + + section { + input( + name: "serverIp", + title: "In order for SmartThings to send commands back to the SmartBlocks on your Minecraft server, you will have to enter your Server Address", + type: "text", + required: false + ) + } + } +} + +def serverPage() { + log.debug "serverPage" + dynamicPage(name: "serverPage", title: "Connect SmartThings To Your Minecraft Server") { + section { + input( + name: "serverIp", + title: "In order for SmartThings to send commands back to the SmartBlocks on your Minecraft server, you will have to enter your Server Address", + type: "text", + required: false + ) + } + } +} + + +def linkerPage() { + dynamicPage(name: "linkerPage", title: "Link SmartBlocks To Switches") { + section { + app( + title: "Link a SmartBlock to a switch", + name: "blockLinker-new", + namespace: "vlaminck/Minecraft", + appName: "SmartBlock Linker", + page: "linkerPage", + multiple: true, + params: ["blocks": getChildDevices()] + ) + } + } + +} + +def notifierPage() { + return dynamicPage(name: "notifierPage", title: "Get Notified When a SmartBlock is updated") { + section { + app( + title: "Get Notified", + name: "blockNotifier-new", + namespace: "vlaminck/Minecraft", + appName: "SmartBlock Notifier", + multiple: true + ) + } + } +} + +def chatPage() { + return dynamicPage(name: "chatPage", title: "Send Notifications into Minecraft") { + section { + app( + title: "Send Notifications", + name: "chatSender-new", + namespace: "vlaminck/Minecraft", + appName: "SmartBlock Chat Sender", + multiple: true + ) + } + } +} + +mappings { + path("/block") { // any need for GET? + action: + [ + POST : "createBlock", + PUT : "updateBlock", + DELETE: "deleteBlock" + ] + } + path("/ack") { + action: + [ + POST: "ack" + ] + } +} + +def createBlock() { + def data = request.JSON + def blockCoordinates = blockCoordinates(data) + def blockDNI = blockDNI(data) + def block = block(data) + + if (block) { + log.debug "Block ${block?.label} with id $blockDNI already exists" + } else { + block = addChildDevice("vlaminck/Minecraft", "Smart Block", blockDNI, null, [name: "SmartBlock", label: "SmartBlock $blockCoordinates"]) + } + + block?.setCoordinates(data.x, data.y, data.z) + block?.setDestroyed(false) + block?.setWorldSeed(data?.worldSeed) + block?.setDimensionName(data?.dimensionName) + block?.setPlacedBy(data?.placedBy) + + if (serverIp) { + block.setServerIp(serverIp) + } + + log.debug "created ${block?.label} with id $blockDNI" +} + +def ack() { + log.debug "ack params : $params" + log.debug "ack JSON : ${request.JSON}" + + sendDataToBlock(request?.JSON, false) +} + +def updateBlock() { + sendDataToBlock(request?.JSON, true) +} + +def sendDataToBlock(data, isStateChange) { + + def blockCoordinates = blockCoordinates(data) + def blockDNI = blockDNI(data) + def block = block(data) + log.debug "updating Block ${block?.label} with id $blockDNI" + + block?.neighborBlockChange(data) + + if (data.worldSeed) { + block.setWorldSeed(data.worldSeed) + } + + if (data.dimensionName) { + block.setDimensionName(data.dimensionName) + } + + if (data.placedBy) { + block.setPlacedBy(data.placedBy) + } + + block.setServerIp(serverIp) + +} + +def deleteBlock() { + def data = request.JSON + def blockDNI = blockDNI(data) + def block = block(data) + + block?.setDestroyed(true) + + + + log.debug "attempting to delete Block ${block?.label} with id $blockDNI" + deleteChildDevice(blockDNI) +} + +private blockCoordinates(data) { + return "(${data?.x},${data?.y},${data?.z})" +} + +private blockDNI(data) { + "${data.worldSeed}|${data.dimensionName}|${blockCoordinates(data)}".encodeAsMD5() +} + +private block(data) { + return getChildDevice(blockDNI(data)) +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + // update all children with serverIp. + if (serverIp) { + getChildDevices().each { block -> + block.setServerIp(serverIp) + } + } + +} + +public getServerURL() { + return "http://${serverIp}:3333" +} diff --git a/official/smartblock-notifier.groovy b/official/smartblock-notifier.groovy new file mode 100755 index 0000000..3832df4 --- /dev/null +++ b/official/smartblock-notifier.groovy @@ -0,0 +1,989 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * SmartBlock Notifier + * + * Author: Steve Vlaminck + * + * Date: 2013-12-27 + */ + +definition( + name: "SmartBlock Notifier", + namespace: "vlaminck/Minecraft", + author: "SmartThings", + description: "A SmartApp that notifies you when things are happening around your SmartBlocks", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + page(name: "firstPage") + page(name: "redstonePage") + page(name: "neighborBlockPage") + page(name: "messageBuilderPage") + page(name: "destroyedPage") +} + +def firstPage() { + + def defaultLabelValue = smartBlock ? (smartBlock.label ?: smartBlock.name) : null + + + def destroyedPageName = "destroyedPage" + def destroyedComplete = pageStateComplete(destroyedPageName) + def destroyedState = destroyedComplete ? "complete" : null + def destroyedDescription = destroyedComplete ? messageDescriptionForPage(destroyedPageName) : null + + def redstonePageName = "redstonePage" + def redstoneComplete = pageStateComplete(redstonePageName) + def redstoneState = redstoneComplete ? "complete" : null + def redstoneDescription = redstoneComplete ? messageDescriptionForPage(redstonePageName) : null + + def neighborPageName = "neighborBlockPage" + def neighborComplete = pageStateComplete(neighborPageName) + def neighborState = neighborComplete ? "complete" : null + def neighborDescription = neighborComplete ? messageDescriptionForPage(neighborPageNamePageName) : null + + dynamicPage(name: "firstPage", title: "Setup your notifications", install: true, uninstall: true) { + + section("Get notifications for this SmartBlock") { + input(name: "smartBlock", type: "capability.switch", title: "Which SmartBlock would you like to monitor?", multiple: false) + // TODO: type: "device.smartBlock", + } + + section("Why would you like to be notified?") { + href(name: "toDestroyedPage", page: destroyedPageName, title: "Because it was destroyed", description: destroyedDescription, state: destroyedState) + + href(name: "toRedstonePage", page: redstonePageName, title: "Because its redstone signal changed", description: redstoneDescription, state: redstoneState) + href(name: "toNeighborPage", page: neighborPageName, title: "Because a block next to it changed", description: neighborDescription, state: neighborState) + } + + section("Other Options") { + label(title: "Label this notification", description: app.name, required: false, defaultValue: defaultLabelValue) + mode(title: "Only send notifications when in one of these modes", description: "All modes") + } + } +} + +def destroyedPage() { + def pageName = "destroyedPage" + dynamicPage(name: pageName, title: "For when your block is destroyed") { + smartPhoneNotificationSection(pageName) + chatSection(pageName) + messageBuilderSection(pageName) + chatClosestPlayerSection(pageName) + } +} + +def redstonePage() { + def pageName = "redstonePage" + dynamicPage(name: pageName, title: "Get Notified For Redstone Changes") { + section("When Redstone Is") { + input(name: "redstoneGreaterThan", type: "enum", required: false, title: "Greater Than", options: (0..15).collect { + "${it}" + }) + input(name: "redstoneLessThan", type: "enum", required: false, title: "Less than", options: (0..15).collect { + "${it}" + }) + input(name: "redstoneEqualTo", type: "enum", required: false, title: "Equal to", options: (0..15).collect { + "${it}" + }) + } + smartPhoneNotificationSection(pageName) + chatSection(pageName) + messageBuilderSection(pageName) + } +} + +def neighborBlockPage() { + def pageName = "neighborBlockPage" + dynamicPage(name: pageName, title: "Get Notified When a neighbor block updates") { + section("Not all blocks send updates, but Chests definitely do") { + input(type: "enum", name: "neighborBlockParsed", title: "When any of these blocks are updated", required: false, multiple: true, options: allBlocksParsed()) + } + + smartPhoneNotificationSection(pageName) + chatSection(pageName) + messageBuilderSection(pageName) + chatClosestPlayerSection(pageName) + + section(title: "More Info", hideable: true, hidden: true) { + href(name: "allIds", title: "A full list of blocks and items can be found here", url: "http://minecraft.gamepedia.com/Ids", style: "external", description: null) + } + } +} + +def messageBuilderPage(params) { + + def pageName = params.pageName + def size = messageBuilderOptions().size() * 2 + + dynamicPage(name: "messageBuilderPage", title: "Build your message") { + section("These will be combined to form the final message.") { + (0..size).each { + input( + name: "${pageName}MessagePart${it}", + type: (it % 2) ? "enum" : "text", + defaultValue: messagePartDefaultValue(pageName, it), + options: (it % 2) ? messageBuilderOptions() : null, + title: null, description: null, required: false, multiple: false + ) + } + } + } +} + +def smartPhoneNotificationSection(pageName) { + section("SmartPhone notifications") { + input("recipients", "contact", title: "Send notifications to") { + input(name: "${pageName}WantsPush", title: "Push Notification", description: null, type: "bool", required: false, defaultValue: "false") + input(name: "${pageName}WantsSms", title: "Text Message", description: "phone number", type: "phone", required: false) + } + input(name: "${pageName}WantsHH", title: "Hello Home only", description: null, type: "bool", required: false) + } +} + +def chatSection(pageName) { + section("Minecraft Chat Message") { + input(name: "${pageName}ChatAllUsers", title: "Chat all users", type: "bool", required: false) + input(name: "${pageName}ChatUsername", title: "Or chat to a specific username", type: "text", required: false) + } +} + +def messageBuilderSection(pageName) { + section("What should your message say?") { + messageBuilderHref(pageName) + } +} + +def messageBuilderHref(pageName) { + def partsAreSet = messagePartsSet(pageName) + def messageState = partsAreSet ? "complete" : "" + def messageDescription = partsAreSet ? messageDescriptionForPage(pageName) : defaultMessageDescription(pageName) + + href( + name: "toBuilder", + page: "messageBuilderPage", + title: null, + description: messageDescription ?: "Construct your message", + state: messageState, + params: [pageName: pageName] + ) +} + +def chatClosestPlayerSection(pageName) { + section("Chat the closest player to the block. (usually the player that destroyed it)") { + messageBuilderHref("${pageName}ClosestPlayer") + } +} + +def pageStateComplete(pageName) { + + if (pageName == "redstonePage") { + if (redstoneGreaterThan) return true + if (redstoneLessThan) return true + if (redstoneEqualTo) return true + return false + } + + if (pageName == "neighborBlockPage") { + if (neighborBlockParsed) return true + return false + } + + if (app."${pageName}WantsPush") return true + if (app."${pageName}WantsSms") return true + if (app."${pageName}WantsHH") return true + if (app."${pageName}ChatAllUsers") return true + if (app."${pageName}ChatUsername") return true + if (app."${pageName}ClosestPlayer") return true + + return false +} + +/* +* INITIALIZE +*/ + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + log.debug "initializing" + subscribe(smartBlock, "redstoneSignalStrength", redstoneSignalStrengthHandler) + subscribe(smartBlock, "smartBlockNeighborChanged", smartBlockNeighborChangedHandler, [filterEvents: false]) + subscribe(smartBlock, "smartBlockNeighborChanged", smartBlockNeighborChangedHandler, [filterEvents: false]) + subscribe(smartBlock, "blockDestroyed.true", smartBlockDestroyedHandler, [filterEvents: false]) +} + +/* +* EVENT HANDLERS +*/ + +def smartBlockDestroyedHandler(evt) { + log.debug "smartBlockDestroyedHandler evt.value: ${evt.value}" + + def pageName = "destroyedPage" + def message = message(pageName) + notifyUser(pageName, message) +} + +def smartBlockNeighborChangedHandler(evt) { + log.debug "smartBlockNeighborChangedHandler evt.value: ${evt.value}" + log.debug "neighborBlockParsed: ${neighborBlockParsed}" + + if (neighborBlockParsed?.contains(evt.value)) { + notifyUserOfNeighborChange(evt.value) + } +} + +def redstoneSignalStrengthHandler(evt) { + log.debug "redstoneSignalStrengthHandler: ${evt.value}" + + int newValue = evt.value as int + int lastValue = smartBlock.latestState("redstoneSignalStrength").value as int + + if (redstoneGreaterThan) { + int gt = redstoneGreaterThan as int +// log.debug "$newValue > $gt" + if (newValue > gt) { + log.debug "greater than ${gt}. send notification" + notifyUserOfRedstoneChange(newValue) + } + } + + if (redstoneLessThan) { + int lt = redstoneLessThan as int +// log.debug "$newValue < $lt" + if (newValue < lt) { + log.debug "less than ${lt}. send notification" + notifyUserOfRedstoneChange(newValue) + } + } + + if (redstoneEqualTo) { + int et = redstoneEqualTo as int +// log.debug "$newValue == $et" + if (newValue == et) { + log.debug "equal to ${et}. send notification" + notifyUserOfRedstoneChange(newValue) + } + } + +} + +/* +* NOTIFICATIONS +*/ + +def notifyUserOfRedstoneChange(value) { + def msg = message("redstonePage") + log.debug "message: ${msg}" + def notificationMessage = msg ?: "${smartBlock} redstone signal is ${value}" + notifyUser(notificationMessage) +} + +def notifyUserOfNeighborChange(value) { + def msg = message("neighborPage") + log.debug "message: ${msg}" + def notificationMessage = msg ?: "${smartBlock} was updated by ${value}" + notifyUser(notificationMessage) +} + +def notifyUser(pageName, messageToSend) { + log.debug "notifyUser pageName: ${pageName}" + + def closestPlayerMessage = message("${pageName}ClosestPlayer") + log.debug "closestPlayerMessage = ${closestPlayerMessage}" + def latestClosePlayer = getLatestClosePlayer() + log.debug "latestClosePlayer = ${latestClosePlayer}" + if (closestPlayerMessage && latestClosePlayer != "unknown") { + log.debug "chatting closestPlayer" + chatMessageToMC(closestPlayerMessage, latestClosePlayer) + } + + + def wantsHH = app."${pageName}WantsHH" + log.debug "wantsHH = ${wantsHH}" + if (wantsHH) { + + log.debug "sending HH" + sendNotificationEvent(messageToSend) + + } else { + if (location.contactBookEnabled) { + sendNotificationToContacts(messageToSend, recipients) + } + else { + + def wantsPush = app."${pageName}WantsPush" + log.debug "wantsPush = ${wantsPush}" + if (wantsPush && wantsPush != "false") { + log.debug "sending push" + sendPush(messageToSend) + } + + def wantsSms = app."${pageName}WantsSms" + log.debug "wantsSms = ${wantsSms}" + if (wantsSms) { + log.debug "sending sms to: ${wantsSms}" + sendSms(wantsSms, messageToSend) + } + } + } + + def username = app."${pageName}ChatUsername" + def allUsers = app."${pageName}ChatAllUsers" + + log.debug "username = ${username}" + log.debug "allUsers = ${allUsers}" + + if (username && username != "") { + log.debug "chatting username: ${username}" + chatMessageToMC(messageToSend, username) + } else if (allUsers) { + log.debug "chatting all users" + chatMessageToMC(messageToSend, null) + } + +} + +def chatMessageToMC(message, username) { + log.debug "chatMessageToMC" + + def url = "${app.getParent().getServerURL()}/chat?message=${message.encodeAsURL()}" + if (username) { + url = "${url}&username=${username.encodeAsURL()}" + } + + log.debug "POST to ${url}" + + httpPost(url, "foo=bar") {} +} + +def messageDescriptionPartsForPage(pageName) { + def size = messageBuilderOptions().size() * 2 + (0..size).collect { app."${pageName}MessagePart${it}" } +} + +def messagePartsSet(pageName) { // are any set? + messageDescriptionPartsForPage(pageName).collect { !it }.unique().contains(false) +} + +def defaultMessageDescription(pageName) { + def description = "" + + if (pageName == "destroyedPage" || pageName == "redstonePage" || pageName == "neighborBlockPage") { + def second = messageBuilderOptions()[messagePartDefaultValue(pageName, 1)] + if (second) description = "\${${second}}" + + def third = messagePartDefaultValue(pageName, 2) + if (third) description = "${description} ${third}" + + def fourth = messageBuilderOptions()[messagePartDefaultValue(pageName, 3)] + if (fourth) description = "${description} \${${fourth}}" + } + + return description +} + +def messageDescriptionForPage(pageName) { + + def parts = messageDescriptionPartsForPage(pageName) + def messageParts = [] + parts.eachWithIndex { part, idx -> + if (part != null && part != "null") { + if (idx % 2) { + messageParts << "\${${messageBuilderOptions()[part]}}" + } else { + messageParts << part + } + } + } + + if (messageParts) { + return messageParts.join(" ").trim() + } else { + return defaultMessageDescription() + } +} + +def messagePartDefaultValue(pageName, part) { + if (pageName == "destroyedPage") { + if (part == 1) return "name" + if (part == 2) return "was destroyed by" + if (part == 3) return "closestPlayer" + } + + if (pageName == "neighborBlockPage") { + if (part == 1) return "name" + if (part == 2) return "has a redstone signal of" + if (part == 3) return "redstoneSignalStrength" + } + + if (pageName == "redstonePage") { + if (part == 1) return "name" + if (part == 2) return "was updated by" + if (part == 3) return "closestPlayer" + } + + return null +} + +def message(pageName) { + log.debug "building message" + def messageParts = [] + + messageDescriptionPartsForPage(pageName).eachWithIndex { part, idx -> + if (idx % 2) { +// def option = messageBuilderOptions()[part] + def optionPart = getMessagePartFromOption(part) + if (optionPart) messageParts << optionPart + } else { + if (part) messageParts << part + } + } + + def message = messageParts.join(" ").trim() + log.debug "message: ${message}" + return message +} + +def messageBuilderOptions() { + return [ + "name": "SmartBlock name", + "neighborBlockName": "Neighbor block name", + "blockDestroyed": "Destroyed State ('destroyed' / 'OK')", + "redstoneSignalStrength": "Redstone signal strength", + "worldSeed": "World seed", + "dimensionName": "Dimension name (World, Nether, End)", + "coordinates": "Block coordinates", + "closestPlayer": "Username of Closest player (within the past minute)", + "placedBy": "Username of who placed the block" + ] +} + +def getMessagePartFromOption(optionKey) { + log.debug "optionKey: ${optionKey}" + if (optionKey == "name") return smartBlock.label ?: smartBlock.name + if (optionKey == "closestPlayer") return getLatestClosePlayer() + if (optionKey == "blockDestroyed") return smartBlock.latestValue("blockDestroyed") ? "OK" : "destroyed" + return smartBlock.latestValue(optionKey) +} + +def getLatestClosePlayer() { + def now = new Date() + def minusOne = new Date(minutes: now.minutes - 1) + def latestStates = smartBlock.statesSince("closestPlayer", minusOne) + if (latestStates.size) { + return latestStates[0].value + } + return "unknown" +} + +/* +* BLOCKS +*/ + +def settingsAsIds() { + log.debug "settingsAsIds" + log.debug "neighborBlockParsed: $neighborBlockParsed" + + def subscribedIds = [] + + neighborBlockParsed.each { + subscribedIds << convertBlockSettingToBlockId(it) + } + + return subscribedIds +} + +def convertBlockSettingToBlockId(setting) { + def id = setting.substring(0, setting.indexOf(" ")) + def name = allBlocks()[id] + log.debug "id: $id, name:${name}" + return id +} + +def allBlocksParsed() { + allBlocks().collect { k, v -> "${k} ${v}" } +} + +def allBlocks() { + [ + "0": "Air", + "1": "Stone", + "2": "Grass", + "3": "Dirt", + "4": "Cobblestone", + "5": "Oak Wood Plank", + "5:1": "Spruce Wood Plank", + "5:2": "Birch Wood Plank", + "5:3": "Jungle Wood Plank", + "6": "Oak Sapling", + "6:1": "Spruce Sapling", + "6:2": "Birch Sapling", + "6:3": "Jungle Sapling", + "7": "Bedrock", + "8": "Water", + "9": "Stationary Water", + "10": "Lava", + "11": "Stationary Lava", + "12": "Sand", + "13": "Gravel", + "14": "Gold Ore", + "15": "Iron Ore", + "16": "Coal Ore", + "17": "Oak Wood", + "17:1": "Spruce Wood", + "17:2": "Birch Wood", + "17:3": "Jungle Wood", + "18": "Oak Leaves", + "18:1": "Spruce Leaves", + "18:2": "Birch Leaves", + "18:3": "Jungle Leaves", + "19": "Sponge", + "20": "Glass", + "21": "Lapis Lazuli Ore", + "22": "Lapis Lazuli Block", + "23": "Dispenser", + "24": "Sandstone", + "24:1": "Chiseled Sandstone", + "24:2": "Smooth Sandstone", + "25": "Note Block", + "26": "Bed Block", + "27": "Powered Rail", + "28": "Detector Rail", + "29": "Sticky Piston", + "30": "Web", + "31": "Dead Shrub", + "31:1": "Grass", + "31:2": "Fern", + "32": "Dead Shrub", + "33": "Piston", + "34": "Piston Head", + "35": "White Wool", + "35:1": "Orange Wool", + "35:2": "Magenta Wool", + "35:3": "Light Blue Wool", + "35:4": "Yellow Wool", + "35:5": "Lime Wool", + "35:6": "Pink Wool", + "35:7": "Gray Wool", + "35:8": "Light Gray Wool", + "35:9": "Cyan Wool", + "35:10": "Purple Wool", + "35:11": "Blue Wool", + "35:12": "Brown Wool", + "35:13": "Green Wool", + "35:14": "Red Wool", + "35:15": "Black Wool", + "37": "Dandelion", + "38": "Rose", + "39": "Brown Mushroom", + "40": "Red Mushroom", + "41": "Gold Block", + "42": "Iron Block", + "43": "Double Stone Slab", + "43:1": "Double Sandstone Slab", + "43:2": "Double Wooden Slab", + "43:3": "Double Cobblestone Slab", + "43:4": "Double Brick Slab", + "43:5": "Double Stone Brick Slab", + "43:6": "Double Nether Brick Slab", + "43:7": "Double Quartz Slab", + "44": "Stone Slab", + "44:1": "Sandstone Slab", + "44:2": "Wooden Slab", + "44:3": "Cobblestone Slab", + "44:4": "Brick Slab", + "44:5": "Stone Brick Slab", + "44:6": "Nether Brick Slab", + "44:7": "Quartz Slab", + "45": "Brick", + "46": "TNT", + "47": "Bookshelf", + "48": "Mossy Cobblestone", + "49": "Obsidian", + "50": "Torch", + "51": "Fire", + "52": "Monster Spawner", + "53": "Oak Wood Stairs", + "54": "Chest", + "55": "Redstone Wire", + "56": "Diamond Ore", + "57": "Diamond Block", + "58": "Workbench", + "59": "Wheat Crops", + "60": "Soil", + "61": "Furnace", + "62": "Burning Furnace", + "63": "Sign Post", + "64": "Wooden Door Block", + "65": "Ladder", + "66": "Rails", + "67": "Cobblestone Stairs", + "68": "Wall Sign", + "69": "Lever", + "70": "Stone Pressure Plate", + "71": "Iron Door Block", + "72": "Wooden Pressure Plate", + "73": "Redstone Ore", + "74": "Glowing Redstone Ore", + "75": "Redstone Torch(off)", + "76": "Redstone Torch(on)", + "77": "Stone Button", + "78": "Snow", + "79": "Ice", + "80": "Snow Block", + "81": "Cactus", + "82": "Clay", + "83": "Sugar Cane", + "84": "Jukebox", + "85": "Fence", + "86": "Pumpkin", + "87": "Netherrack", + "88": "Soul Sand", + "89": "Glowstone", + "90": "Portal", + "91": "Jack - O - Lantern", + "92": "Cake Block", + "93": "Redstone Repeater Block(off)", + "94": "Redstone Repeater Block(on)", + "95": "Locked Chest", + "96": "Trapdoor", + "97": "Stone(Silverfish)", + "97:1": "Cobblestone(Silverfish)", + "97:2": "Stone Brick(Silverfish)", + "98": "Stone Brick", + "98:1": "Mossy Stone Brick", + "98:2": "Cracked Stone Brick", + "98:3": "Chiseled Stone Brick", + "99": "Red Mushroom Cap", + "100": "Brown Mushroom Cap", + "101": "Iron Bars", + "102": "Glass Pane", + "103": "Melon Block", + "104": "Pumpkin Stem", + "105": "Melon Stem", + "106": "Vines", + "107": "Fence Gate", + "108": "Brick Stairs", + "109": "Stone Brick Stairs", + "110": "Mycelium", + "111": "Lily Pad", + "112": "Nether Brick", + "113": "Nether Brick Fence", + "114": "Nether Brick Stairs", + "115": "Nether Wart", + "116": "Enchantment Table", + "117": "Brewing Stand", + "118": "Cauldron", + "119": "End Portal", + "120": "End Portal Frame", + "121": "End Stone", + "122": "Dragon Egg", + "123": "Redstone Lamp(inactive)", + "124": "Redstone Lamp(active)", + "125": "Double Oak Wood Slab", + "125:1": "Double Spruce Wood Slab", + "125:2": "Double Birch Wood Slab", + "125:3": "Double Jungle Wood Slab", + "126": "Oak Wood Slab", + "126:1": "Spruce Wood Slab", + "126:2": "Birch Wood Slab", + "126:3": "Jungle Wood Slab", + "127": "Cocoa Plant", + "128": "Sandstone Stairs", + "129": "Emerald Ore", + "130": "Ender Chest", + "131": "Tripwire Hook", + "132": "Tripwire", + "133": "Emerald Block", + "134": "Spruce Wood Stairs", + "135": "Birch Wood Stairs", + "136": "Jungle Wood Stairs", + "137": "Command Block", + "138": "Beacon Block", + "139": "Cobblestone Wall", + "139:1": "Mossy Cobblestone Wall", + "140": "Flower Pot", + "141": "Carrots", + "142": "Potatoes", + "143": "Wooden Button", + "144": "Mob Head", + "145": "Anvil", + "146": "Trapped Chest", + "147": "Weighted Pressure Plate(light)", + "148": "Weighted Pressure Plate(heavy)", + "149": "Redstone Comparator(inactive)", + "150": "Redstone Comparator(active)", + "151": "Daylight Sensor", + "152": "Redstone Block", + "153": "Nether Quartz Ore", + "154": "Hopper", + "155": "Quartz Block", + "155:1": "Chiseled Quartz Block", + "155:2": "Pillar Quartz Block", + "156": "Quartz Stairs", + "157": "Activator Rail", + "158": "Dropper", + "159": "White Stained Clay", + "159:1": "Orange Stained Clay", + "159:2": "Magenta Stained Clay", + "159:3": "Light Blue Stained Clay", + "159:4": "Yellow Stained Clay", + "159:5": "Lime Stained Clay", + "159:6": "Pink Stained Clay", + "159:7": "Gray Stained Clay", + "159:8": "Light Gray Stained Clay", + "159:9": "Cyan Stained Clay", + "159:10": "Purple Stained Clay", + "159:11": "Blue Stained Clay", + "159:12": "Brown Stained Clay", + "159:13": "Green Stained Clay", + "159:14": "Red Stained Clay", + "159:15": "Black Stained Clay", + "170": "Hay Bale", + "171": "White Carpet", + "171:1": "Orange Carpet", + "171:2": "Magenta Carpet", + "171:3": "Light Blue Carpet", + "171:4": "Yellow Carpet", + "171:5": "Lime Carpet", + "171:6": "Pink Carpet", + "171:7": "Gray Carpet", + "171:8": "Light Gray Carpet", + "171:9": "Cyan Carpet", + "171:10": "Purple Carpet", + "171:11": "Blue Carpet", + "171:12": "Brown Carpet", + "171:13": "Green Carpet", + "171:14": "Red Carpet", + "171:15": "Black Carpet", + "172": "Hardened Clay", + "173": "Block of Coal", + "256": "Iron Shovel", + "257": "Iron Pickaxe", + "258": "Iron Axe", + "259": "Flint and Steel", + "260": "Apple", + "261": "Bow", + "262": "Arrow", + "263": "Coal", + "263:1": "Charcoal", + "264": "Diamond", + "265": "Iron Ingot", + "266": "Gold Ingot", + "267": "Iron Sword", + "268": "Wooden Sword", + "269": "Wooden Shovel", + "270": "Wooden Pickaxe", + "271": "Wooden Axe", + "272": "Stone Sword", + "273": "Stone Shovel", + "274": "Stone Pickaxe", + "275": "Stone Axe", + "276": "Diamond Sword", + "277": "Diamond Shovel", + "278": "Diamond Pickaxe", + "279": "Diamond Axe", + "280": "Stick", + "281": "Bowl", + "282": "Mushroom Soup", + "283": "Gold Sword", + "284": "Gold Shovel", + "285": "Gold Pickaxe", + "286": "Gold Axe", + "287": "String", + "288": "Feather", + "289": "Sulphur", + "290": "Wooden Hoe", + "291": "Stone Hoe", + "292": "Iron Hoe", + "293": "Diamond Hoe", + "294": "Gold Hoe", + "295": "Wheat Seeds", + "296": "Wheat", + "297": "Bread", + "298": "Leather Helmet", + "299": "Leather Chestplate", + "300": "Leather Leggings", + "301": "Leather Boots", + "302": "Chainmail Helmet", + "303": "Chainmail Chestplate", + "304": "Chainmail Leggings", + "305": "Chainmail Boots", + "306": "Iron Helmet", + "307": "Iron Chestplate", + "308": "Iron Leggings", + "309": "Iron Boots", + "310": "Diamond Helmet", + "311": "Diamond Chestplate", + "312": "Diamond Leggings", + "313": "Diamond Boots", + "314": "Gold Helmet", + "315": "Gold Chestplate", + "316": "Gold Leggings", + "317": "Gold Boots", + "318": "Flint", + "319": "Raw Porkchop", + "320": "Cooked Porkchop", + "321": "Painting", + "322": "Golden Apple", + "322:1": "Enchanted Golden Apple", + "323": "Sign", + "324": "Wooden Door", + "325": "Bucket", + "326": "Water Bucket", + "327": "Lava Bucket", + "328": "Minecart", + "329": "Saddle", + "330": "Iron Door", + "331": "Redstone", + "332": "Snowball", + "333": "Boat", + "334": "Leather", + "335": "Milk Bucket", + "336": "Clay Brick", + "337": "Clay Balls", + "338": "Sugarcane", + "339": "Paper", + "340": "Book", + "341": "Slimeball", + "342": "Storage Minecart", + "343": "Powered Minecart", + "344": "Egg", + "345": "Compass", + "346": "Fishing Rod", + "347": "Clock", + "348": "Glowstone Dust", + "349": "Raw Fish", + "350": "Cooked Fish", + "351": "Ink Sack", + "351:1": "Rose Red", + "351:2": "Cactus Green", + "351:3": "Coco Beans", + "351:4": "Lapis Lazuli", + "351:5": "Purple Dye", + "351:6": "Cyan Dye", + "351:7": "Light Gray Dye", + "351:8": "Gray Dye", + "351:9": "Pink Dye", + "351:10": "Lime Dye", + "351:11": "Dandelion Yellow", + "351:12": "Light Blue Dye", + "351:13": "Magenta Dye", + "351:14": "Orange Dye", + "351:15": "Bone Meal", + "352": "Bone", + "353": "Sugar", + "354": "Cake", + "355": "Bed", + "356": "Redstone Repeater", + "357": "Cookie", + "358": "Map", + "359": "Shears", + "360": "Melon", + "361": "Pumpkin Seeds", + "362": "Melon Seeds", + "363": "Raw Beef", + "364": "Steak", + "365": "Raw Chicken", + "366": "Cooked Chicken", + "367": "Rotten Flesh", + "368": "Ender Pearl", + "369": "Blaze Rod", + "370": "Ghast Tear", + "371": "Gold Nugget", + "372": "Nether Wart Seeds", + "373": "Potion", + "374": "Glass Bottle", + "375": "Spider Eye", + "376": "Fermented Spider Eye", + "377": "Blaze Powder", + "378": "Magma Cream", + "379": "Brewing Stand", + "380": "Cauldron", + "381": "Eye of Ender", + "382": "Glistering Melon", + "383:50": "Spawn Creeper", + "383:51": "Spawn Skeleton", + "383:52": "Spawn Spider", + "383:54": "Spawn Zombie", + "383:55": "Spawn Slime", + "383:56": "Spawn Ghast", + "383:57": "Spawn Pigman", + "383:58": "Spawn Enderman", + "383:59": "Spawn Cave Spider", + "383:60": "Spawn Silverfish ", + "383:61": "Spawn Blaze", + "383:62": "Spawn Magma Cube ", + "383:65": "Spawn Bat", + "383:66": "Spawn Witch", + "383:90": "Spawn Pig", + "383:91": "Spawn Sheep", + "383:92": "Spawn Cow", + "383:93": "Spawn Chicken", + "383:94": "Spawn Squid", + "383:95": "Spawn Wolf", + "383:96": "Spawn Mooshroom", + "383:98": "Spawn Ocelot", + "383:100": "Spawn Horse", + "383:120": "Spawn Villager", + "384": "Bottle o' Enchanting", + "385": "Fire Charge", + "386": "Book and Quill", + "387": "Written Book", + "388": "Emerald", + "389": "Item Frame", + "390": "Flower Pot", + "391": "Carrots", + "392": "Potato", + "393": "Baked Potato", + "394": "Poisonous Potato", + "395": "Map", + "396": "Golden Carrot", + "397": "Mob Head (Skeleton)", + "397:1": "Mob Head (Wither Skeleton)", + "397:2": "Mob Head (Zombie)", + "397:3": "Mob Head (Human)", + "397:4": "Mob Head (Creeper)", + "398": "Carrot on a Stick", + "399": "Nether Star", + "400": "Pumpkin Pie", + "401": "Firework Rocket", + "402": "Firework Star", + "403": "Enchanted Book", + "404": "Redstone Comparator", + "405": "Nether Brick", + "406": "Nether Quartz", + "407": "Minecart with TNT", + "408": "Minecart with Hopper", + "417": "Iron Horse Armor", + "418": "Gold Horse Armor", + "419": "Diamond Horse Armor", + "420": "Lead", + "421": "Name Tag" + ] +} diff --git a/official/smartweather-station-controller.groovy b/official/smartweather-station-controller.groovy new file mode 100755 index 0000000..3a40277 --- /dev/null +++ b/official/smartweather-station-controller.groovy @@ -0,0 +1,67 @@ +/** + * Weather Station Controller + * + * Copyright 2014 SmartThings + * + * 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: "SmartWeather Station Controller", + namespace: "smartthings", + author: "SmartThings", + description: "Updates SmartWeather Station Tile devices every hour.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-MindYourHome.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-MindYourHome@2x.png" +) + +preferences { + section { + input "weatherDevices", "device.smartweatherStationTile" + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unschedule() + initialize() +} + +def initialize() { + scheduledEvent() +} + +def scheduledEvent() { + log.info "SmartWeather Station Controller / scheduledEvent terminated due to deprecation" // device handles this itself now -- Bob +/* + log.trace "scheduledEvent()" + + def delayTimeSecs = 60 * 60 // reschedule every 60 minutes + def runAgainWindowMS = 58 * 60 * 1000 // can run at most every 58 minutes + def timeSinceLastRunMS = state.lastRunTime ? now() - state.lastRunTime : null //how long since it last ran? + + if(!timeSinceLastRunMS || timeSinceLastRunMS > runAgainWindowMS){ + runIn(delayTimeSecs, scheduledEvent, [overwrite: false]) + state.lastRunTime = now() + weatherDevices.refresh() + } else { + log.trace "Trying to run smartweather-station-controller too soon. Has only been ${timeSinceLastRunMS} ms but needs to be at least ${runAgainWindowMS} ms" + } + */ +} diff --git a/official/sonos-music-modes.groovy b/official/sonos-music-modes.groovy new file mode 100755 index 0000000..29591db --- /dev/null +++ b/official/sonos-music-modes.groovy @@ -0,0 +1,269 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Sonos Mood Music + * + * Author: SmartThings + * Date: 2014-02-12 + */ + + +private songOptions() { + + // Make sure current selection is in the set + + def options = new LinkedHashSet() + options << "STOP PLAYING" + if (state.selectedSong?.station) { + options << state.selectedSong.station + } + else if (state.selectedSong?.description) { + // TODO - Remove eventually? 'description' for backward compatibility + options << state.selectedSong.description + } + + // Query for recent tracks + def states = sonos.statesSince("trackData", new Date(0), [max:30]) + def dataMaps = states.collect{it.jsonValue} + options.addAll(dataMaps.collect{it.station}) + + log.trace "${options.size()} songs in list" + options.take(20) as List +} + +private saveSelectedSongs() { + try { + def songs = sonos.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} + log.info "Searching ${songs.size()} records" + + if (!state.selectedSongs) { + state.selectedSongs = [:] + } + + settings.each {name, thisSong -> + if (thisSong == "STOP PLAYING") { + state.selectedSongs."$name" = "PAUSE" + } + if (name.startsWith("mode_")) { + log.info "Looking for $thisSong" + + def data = songs.find {s -> s.station == thisSong} + log.info "Found ${data?.station}" + if (data) { + state.selectedSongs."$name" = data + log.debug "Selected song = $data.station" + } + else if (song == state.selectedSongs."$name"?.station) { + log.debug "Selected existing entry '$thisSong', which is no longer in the last 20 list" + } + else { + log.warn "Selected song '$thisSong' not found" + } + } + } + } + catch (Throwable t) { + log.error t + } +} + +definition( + name: "Sonos Music Modes", + namespace: "smartthings", + author: "SmartThings", + description: "Plays a different selected song or station for each mode.", + category: "SmartThings Internal", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos@2x.png" +) + +preferences { + page(name: "mainPage", title: "Play a message on your Sonos when something happens", nextPage: "chooseTrack", uninstall: true) + page(name: "chooseTrack", title: "Select a song", install: true) + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def mainPage() { + dynamicPage(name: "mainPage") { + + section { + input "sonos", "capability.musicPlayer", title: "Sonos player", required: true + } + section("More options", hideable: true, hidden: true) { + input "volume", "number", title: "Set the volume", description: "0-100%", required: false + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + if (settings.modes) { + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + } +} + +def chooseTrack() { + dynamicPage(name: "chooseTrack") { + section("Play a different song for each mode in which you want music") { + def options = songOptions() + location.modes.each {mode -> + input "mode_$mode.name", "enum", title: mode.name, options: options, required: false + } + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace "subscribeToEvents()" + saveSelectedSongs() + + subscribe(location, modeChangeHandler) +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler($evt.name: $evt.value)" + if (allOk) { + if (frequency) { + def lastTime = state[frequencyKey(evt)] + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + } + else { + takeAction(evt) + } + } +} + +private takeAction(evt) { + + def name = "mode_$evt.value".toString() + def selectedSong = state.selectedSongs."$name" + + if (selectedSong == "PAUSE") { + sonos.stop() + } + else { + log.info "Playing '$selectedSong" + + if (volume != null) { + sonos.stop() + pause(500) + sonos.setLevel(volume) + pause(500) + } + + sonos.playTrack(selectedSong) + } + + if (frequency || oncePerDay) { + state[frequencyKey(evt)] = now() + } + log.trace "Exiting takeAction()" +} + +private frequencyKey(evt) { + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private timeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} +// TODO - End Centralize + diff --git a/official/sonos-remote-control.groovy b/official/sonos-remote-control.groovy new file mode 100755 index 0000000..eed02d2 --- /dev/null +++ b/official/sonos-remote-control.groovy @@ -0,0 +1,165 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Sonos Remote Control + * + * Author: Matt Nohr + * Date: 2014-04-14 + */ + +/** + * Buttons: + * 1 2 + * 3 4 + * + * Pushed: + * 1: Play/Pause + * 2: Volume Up + * 3: Next Track + * 4: Volume Down + * + * Held: + * 1: + * 2: Volume Up (2x) + * 3: Previous Track + * 4: Volume Down (2x) + */ + +definition( + name: "Sonos Remote Control", + namespace: "smartthings", + author: "SmartThings", + description: "Control your Sonos system with an Aeon Minimote", + category: "SmartThings Internal", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("Select your devices") { + input "buttonDevice", "capability.button", title: "Minimote", multiple: false, required: true + input "sonos", "capability.musicPlayer", title: "Sonos", multiple: false, required: true + } + section("Options") { + input "volumeOffset", "number", title: "Adjust Volume by this amount", required: false, description: "optional - 5% default" + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(buttonDevice, "button", buttonEvent) +} + +def buttonEvent(evt){ + def buttonNumber = evt.data + def value = evt.value + log.debug "buttonEvent: $evt.name = $evt.value ($evt.data)" + log.debug "button: $buttonNumber, value: $value" + + def recentEvents = buttonDevice.eventsSince(new Date(now() - 2000)).findAll{it.value == evt.value && it.data == evt.data} + log.debug "Found ${recentEvents.size()?:0} events in past 2 seconds" + + if(recentEvents.size <= 1){ + handleButton(extractButtonNumber(buttonNumber), value) + } else { + log.debug "Found recent button press events for $buttonNumber with value $value" + } +} + +def extractButtonNumber(data) { + def buttonNumber + //TODO must be a better way to do this. Data is like {buttonNumber:1} + switch(data) { + case ~/.*1.*/: + buttonNumber = 1 + break + case ~/.*2.*/: + buttonNumber = 2 + break + case ~/.*3.*/: + buttonNumber = 3 + break + case ~/.*4.*/: + buttonNumber = 4 + break + } + return buttonNumber +} + +def handleButton(buttonNumber, value) { + switch([number: buttonNumber, value: value]) { + case{it.number == 1 && it.value == 'pushed'}: + log.debug "Button 1 pushed - Play/Pause" + togglePlayPause() + break + case{it.number == 2 && it.value == 'pushed'}: + log.debug "Button 2 pushed - Volume Up" + adjustVolume(true, false) + break + case{it.number == 3 && it.value == 'pushed'}: + log.debug "Button 3 pushed - Next Track" + sonos.nextTrack() + break + case{it.number == 4 && it.value == 'pushed'}: + log.debug "Button 4 pushed - Volume Down" + adjustVolume(false, false) + break + case{it.number == 2 && it.value == 'held'}: + log.debug "Button 2 held - Volume Up 2x" + adjustVolume(true, true) + break + case{it.number == 3 && it.value == 'held'}: + log.debug "Button 3 held - Previous Track" + sonos.previousTrack() + break + case{it.number == 4 && it.value == 'held'}: + log.debug "Button 4 held - Volume Down 2x" + adjustVolume(false, true) + break + default: + log.debug "Unhandled command: $buttonNumber $value" + + } +} + +def togglePlayPause() { + def currentStatus = sonos.currentValue("status") + if (currentStatus == "playing") { + options ? sonos.pause(options) : sonos.pause() + } + else { + options ? sonos.play(options) : sonos.play() + } +} + +def adjustVolume(boolean up, boolean doubleAmount) { + def changeAmount = (volumeOffset ?: 5) * (doubleAmount ? 2 : 1) + def currentVolume = sonos.currentValue("level") + + if(up) { + sonos.setLevel(currentVolume + changeAmount) + } else { + sonos.setLevel(currentVolume - changeAmount) + } +} diff --git a/official/speaker-control.groovy b/official/speaker-control.groovy new file mode 100755 index 0000000..66cd6bf --- /dev/null +++ b/official/speaker-control.groovy @@ -0,0 +1,317 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Speaker Control + * + * Author: SmartThings + * + * Date: 2013-12-10 + */ +definition( + name: "Speaker Control", + namespace: "smartthings", + author: "SmartThings", + description: "Play or pause your Speaker when certain actions take place in your home.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos@2x.png" +) + +preferences { + page(name: "mainPage", title: "Control your Speaker when something happens", install: true, uninstall: true) + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def mainPage() { + dynamicPage(name: "mainPage") { + def anythingSet = anythingSet() + if (anythingSet) { + section("When..."){ + ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + } + section(anythingSet ? "Select additional triggers" : "When...", hideable: anythingSet, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + section("Perform this action"){ + input "actionType", "enum", title: "Action?", required: true, defaultValue: "play", options: [ + "Play", + "Stop Playing", + "Toggle Play/Pause", + "Skip to Next Track", + "Play Previous Track" + ] + } + section { + input "sonos", "capability.musicPlayer", title: "Speaker music player", required: true + } + section("More options", hideable: true, hidden: true) { + input "volume", "number", title: "Set the volume volume", description: "0-100%", required: false + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + if (settings.modes) { + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)" + } + } +} + +private anythingSet() { + for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","triggerModes","timeOfDay"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace "subscribeToEvents()" + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } +} + +def eventHandler(evt) { + if (allOk) { + def lastTime = state[frequencyKey(evt)] + if (oncePerDayOk(lastTime)) { + if (frequency) { + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + else { + log.debug "Not taking action because $frequency minutes have not elapsed since last action" + } + } + else { + takeAction(evt) + } + } + else { + log.debug "Not taking action because it was already taken today" + } + } +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + takeAction(evt) +} + +private takeAction(evt) { + log.debug "takeAction($actionType)" + def options = [:] + if (volume) { + sonos.setLevel(volume as Integer) + options.delay = 1000 + } + + switch (actionType) { + case "Play": + options ? sonos.on(options) : sonos.on() + break + case "Stop Playing": + options ? sonos.off(options) : sonos.off() + break + case "Toggle Play/Pause": + def currentStatus = sonos.currentValue("status") + if (currentStatus == "playing") { + options ? sonos.pause(options) : sonos.pause() + } + else { + options ? sonos.play(options) : sonos.play() + } + break + case "Skip to Next Track": + options ? sonos.nextTrack(options) : sonos.nextTrack() + break + case "Play Previous Track": + options ? sonos.previousTrack(options) : sonos.previousTrack() + break + default: + log.error "Action type '$actionType' not defined" + } + + if (frequency) { + state.lastActionTimeStamp = now() + } +} + +private frequencyKey(evt) { + //evt.deviceId ?: evt.value + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = true + if (oncePerDay) { + result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + } + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private timeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} +// TODO - End Centralize + diff --git a/official/speaker-mood-music.groovy b/official/speaker-mood-music.groovy new file mode 100755 index 0000000..9c47731 --- /dev/null +++ b/official/speaker-mood-music.groovy @@ -0,0 +1,339 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Speaker Mood Music + * + * Author: SmartThings + * Date: 2014-02-12 + */ + + +private songOptions() { + + // Make sure current selection is in the set + + def options = new LinkedHashSet() + if (state.selectedSong?.station) { + options << state.selectedSong.station + } + else if (state.selectedSong?.description) { + // TODO - Remove eventually? 'description' for backward compatibility + options << state.selectedSong.description + } + + // Query for recent tracks + def states = sonos.statesSince("trackData", new Date(0), [max:30]) + def dataMaps = states.collect{it.jsonValue} + options.addAll(dataMaps.collect{it.station}) + + log.trace "${options.size()} songs in list" + options.take(20) as List +} + +private saveSelectedSong() { + try { + def thisSong = song + log.info "Looking for $thisSong" + def songs = sonos.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} + log.info "Searching ${songs.size()} records" + + def data = songs.find {s -> s.station == thisSong} + log.info "Found ${data?.station}" + if (data) { + state.selectedSong = data + log.debug "Selected song = $state.selectedSong" + } + else if (song == state.selectedSong?.station) { + log.debug "Selected existing entry '$song', which is no longer in the last 20 list" + } + else { + log.warn "Selected song '$song' not found" + } + } + catch (Throwable t) { + log.error t + } +} + +definition( + name: "Speaker Mood Music", + namespace: "smartthings", + author: "SmartThings", + description: "Plays a selected song or station.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos@2x.png" +) + +preferences { + page(name: "mainPage", title: "Play a selected song or station on your Speaker when something happens", nextPage: "chooseTrack", uninstall: true) + page(name: "chooseTrack", title: "Select a song", install: true) + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def mainPage() { + dynamicPage(name: "mainPage") { + def anythingSet = anythingSet() + if (anythingSet) { + section("Play music when..."){ + ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + } + + def hideable = anythingSet || app.installationState == "COMPLETE" + def sectionTitle = anythingSet ? "Select additional triggers" : "Play music when..." + + section(sectionTitle, hideable: hideable, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + section { + input "sonos", "capability.musicPlayer", title: "On this Speaker player", required: true + } + section("More options", hideable: true, hidden: true) { + input "volume", "number", title: "Set the volume", description: "0-100%", required: false + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + if (settings.modes) { + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + } +} + +def chooseTrack() { + dynamicPage(name: "chooseTrack") { + section{ + input "song","enum",title:"Play this track", required:true, multiple: false, options: songOptions() + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + +private anythingSet() { + for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","timeOfDay","triggerModes","timeOfDay"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + log.trace "subscribeToEvents()" + saveSelectedSong() + + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } +} + +def eventHandler(evt) { + if (allOk) { + if (frequency) { + def lastTime = state[frequencyKey(evt)] + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + } + else { + takeAction(evt) + } + } +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + takeAction(evt) +} + +private takeAction(evt) { + + log.info "Playing '$state.selectedSong" + + if (volume != null) { + sonos.stop() + pause(500) + sonos.setLevel(volume) + pause(500) + } + + sonos.playTrack(state.selectedSong) + + if (frequency || oncePerDay) { + state[frequencyKey(evt)] = now() + } + log.trace "Exiting takeAction()" +} + +private frequencyKey(evt) { + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private timeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} +// TODO - End Centralize + diff --git a/official/speaker-notify-with-sound.groovy b/official/speaker-notify-with-sound.groovy new file mode 100755 index 0000000..bb72b8a --- /dev/null +++ b/official/speaker-notify-with-sound.groovy @@ -0,0 +1,423 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Speaker Custom Message + * + * Author: SmartThings + * Date: 2014-1-29 + */ +definition( + name: "Speaker Notify with Sound", + namespace: "smartthings", + author: "SmartThings", + description: "Play a sound or custom message through your Speaker when the mode changes or other events occur.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos@2x.png" +) + +preferences { + page(name: "mainPage", title: "Play a message on your Speaker when something happens", install: true, uninstall: true) + page(name: "chooseTrack", title: "Select a song or station") + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def mainPage() { + dynamicPage(name: "mainPage") { + def anythingSet = anythingSet() + if (anythingSet) { + section("Play message when"){ + ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + } + def hideable = anythingSet || app.installationState == "COMPLETE" + def sectionTitle = anythingSet ? "Select additional triggers" : "Play message when..." + + section(sectionTitle, hideable: hideable, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + section{ + input "actionType", "enum", title: "Action?", required: true, defaultValue: "Bell 1", options: [ + "Custom Message", + "Bell 1", + "Bell 2", + "Dogs Barking", + "Fire Alarm", + "The mail has arrived", + "A door opened", + "There is motion", + "Smartthings detected a flood", + "Smartthings detected smoke", + "Someone is arriving", + "Piano", + "Lightsaber"] + input "message","text",title:"Play this message", required:false, multiple: false + } + section { + input "sonos", "capability.musicPlayer", title: "On this Speaker player", required: true + } + section("More options", hideable: true, hidden: true) { + input "resumePlaying", "bool", title: "Resume currently playing music after notification", required: false, defaultValue: true + href "chooseTrack", title: "Or play this music or radio station", description: song ? state.selectedSong?.station : "Tap to set", state: song ? "complete" : "incomplete" + + input "volume", "number", title: "Temporarily change volume", description: "0-100%", required: false + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + if (settings.modes) { + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + +def chooseTrack() { + dynamicPage(name: "chooseTrack") { + section{ + input "song","enum",title:"Play this track", required:true, multiple: false, options: songOptions() + } + } +} + +private songOptions() { + + // Make sure current selection is in the set + + def options = new LinkedHashSet() + if (state.selectedSong?.station) { + options << state.selectedSong.station + } + else if (state.selectedSong?.description) { + // TODO - Remove eventually? 'description' for backward compatibility + options << state.selectedSong.description + } + + // Query for recent tracks + def states = sonos.statesSince("trackData", new Date(0), [max:30]) + def dataMaps = states.collect{it.jsonValue} + options.addAll(dataMaps.collect{it.station}) + + log.trace "${options.size()} songs in list" + options.take(20) as List +} + +private saveSelectedSong() { + try { + def thisSong = song + log.info "Looking for $thisSong" + def songs = sonos.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} + log.info "Searching ${songs.size()} records" + + def data = songs.find {s -> s.station == thisSong} + log.info "Found ${data?.station}" + if (data) { + state.selectedSong = data + log.debug "Selected song = $state.selectedSong" + } + else if (song == state.selectedSong?.station) { + log.debug "Selected existing entry '$song', which is no longer in the last 20 list" + } + else { + log.warn "Selected song '$song' not found" + } + } + catch (Throwable t) { + log.error t + } +} + +private anythingSet() { + for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","timeOfDay","triggerModes","timeOfDay"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location, modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } + + if (song) { + saveSelectedSong() + } + + loadText() +} + +def eventHandler(evt) { + log.trace "eventHandler($evt?.name: $evt?.value)" + if (allOk) { + log.trace "allOk" + def lastTime = state[frequencyKey(evt)] + if (oncePerDayOk(lastTime)) { + if (frequency) { + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + else { + log.debug "Not taking action because $frequency minutes have not elapsed since last action" + } + } + else { + takeAction(evt) + } + } + else { + log.debug "Not taking action because it was already taken today" + } + } +} +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + takeAction(evt) +} + +private takeAction(evt) { + + log.trace "takeAction()" + + if (song) { + sonos.playSoundAndTrack(state.sound.uri, state.sound.duration, state.selectedSong, volume) + } + else if (resumePlaying){ + sonos.playTrackAndResume(state.sound.uri, state.sound.duration, volume) + } + else { + sonos.playTrackAndRestore(state.sound.uri, state.sound.duration, volume) + } + + if (frequency || oncePerDay) { + state[frequencyKey(evt)] = now() + } + log.trace "Exiting takeAction()" +} + +private frequencyKey(evt) { + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = true + if (oncePerDay) { + result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + } + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private getTimeLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} +// TODO - End Centralize + +private loadText() { + switch ( actionType) { + case "Bell 1": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/bell1.mp3", duration: "10"] + break; + case "Bell 2": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/bell2.mp3", duration: "10"] + break; + case "Dogs Barking": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/dogs.mp3", duration: "10"] + break; + case "Fire Alarm": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/alarm.mp3", duration: "17"] + break; + case "The mail has arrived": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/the+mail+has+arrived.mp3", duration: "1"] + break; + case "A door opened": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/a+door+opened.mp3", duration: "1"] + break; + case "There is motion": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/there+is+motion.mp3", duration: "1"] + break; + case "Smartthings detected a flood": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/smartthings+detected+a+flood.mp3", duration: "2"] + break; + case "Smartthings detected smoke": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/smartthings+detected+smoke.mp3", duration: "1"] + break; + case "Someone is arriving": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/someone+is+arriving.mp3", duration: "1"] + break; + case "Piano": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/piano2.mp3", duration: "10"] + break; + case "Lightsaber": + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/lightsaber.mp3", duration: "10"] + break; + case "Custom Message": + if (message) { + state.sound = textToSpeech(message instanceof List ? message[0] : message) // not sure why this is (sometimes) needed) + } + else { + state.sound = textToSpeech("You selected the custom message option but did not enter a message in the $app.label Smart App") + } + break; + default: + state.sound = [uri: "http://s3.amazonaws.com/smartapp-media/sonos/bell1.mp3", duration: "10"] + break; + } +} diff --git a/official/speaker-weather-forecast.groovy b/official/speaker-weather-forecast.groovy new file mode 100755 index 0000000..bb3bc41 --- /dev/null +++ b/official/speaker-weather-forecast.groovy @@ -0,0 +1,432 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Speaker Weather Forecast + * + * Author: SmartThings + * Date: 2014-1-29 + */ +definition( + name: "Speaker Weather Forecast", + namespace: "smartthings", + author: "SmartThings", + description: "Play a weather report through your Speaker when the mode changes or other events occur", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/sonos@2x.png" +) + +preferences { + page(name: "mainPage", title: "Play the weather report on your speaker", install: true, uninstall: true) + page(name: "chooseTrack", title: "Select a song or station") + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def mainPage() { + dynamicPage(name: "mainPage") { + def anythingSet = anythingSet() + if (anythingSet) { + section("Play weather report when"){ + ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + } + def hideable = anythingSet || app.installationState == "COMPLETE" + def sectionTitle = anythingSet ? "Select additional triggers" : "Play weather report when..." + + section(sectionTitle, hideable: hideable, hidden: true){ + ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production + ifUnset "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true + ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false + } + section { + input("forecastOptions", "enum", defaultValue: "0", title: "Weather report options", description: "Select one or more", multiple: true, + options: [ + ["0": "Current Conditions"], + ["1": "Today's Forecast"], + ["2": "Tonight's Forecast"], + ["3": "Tomorrow's Forecast"], + ] + ) + } + section { + input "sonos", "capability.musicPlayer", title: "On this Speaker player", required: true + } + section("More options", hideable: true, hidden: true) { + input "resumePlaying", "bool", title: "Resume currently playing music after weather report finishes", required: false, defaultValue: true + href "chooseTrack", title: "Or play this music or radio station", description: song ? state.selectedSong?.station : "Tap to set", state: song ? "complete" : "incomplete" + + input "zipCode", "text", title: "Zip Code", required: false + input "volume", "number", title: "Temporarily change volume", description: "0-100%", required: false + input "frequency", "decimal", title: "Minimum time between actions (defaults to every event)", description: "Minutes", required: false + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete" + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + if (settings.modes) { + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + input "oncePerDay", "bool", title: "Only once per day", required: false, defaultValue: false + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)" + } + } +} + +def chooseTrack() { + dynamicPage(name: "chooseTrack") { + section{ + input "song","enum",title:"Play this track", required:true, multiple: false, options: songOptions() + } + } +} + +private anythingSet() { + for (name in ["motion","contact","contactClosed","acceleration","mySwitch","mySwitchOff","arrivalPresence","departurePresence","smoke","water","button1","timeOfDay","triggerModes"]) { + if (settings[name]) { + return true + } + } + return false +} + +private ifUnset(Map options, String name, String capability) { + if (!settings[name]) { + input(options, name, capability) + } +} + +private ifSet(Map options, String name, String capability) { + if (settings[name]) { + input(options, name, capability) + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + unschedule() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(app, appTouchHandler) + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + subscribe(button1, "button.pushed", eventHandler) + + if (triggerModes) { + subscribe(location,modeChangeHandler) + } + + if (timeOfDay) { + schedule(timeOfDay, scheduledTimeHandler) + } + + if (song) { + saveSelectedSong() + } +} + +def eventHandler(evt) { + log.trace "eventHandler($evt?.name: $evt?.value)" + if (allOk) { + log.trace "allOk" + def lastTime = state[frequencyKey(evt)] + if (oncePerDayOk(lastTime)) { + if (frequency) { + if (lastTime == null || now() - lastTime >= frequency * 60000) { + takeAction(evt) + } + else { + log.debug "Not taking action because $frequency minutes have not elapsed since last action" + } + } + else { + takeAction(evt) + } + } + else { + log.debug "Not taking action because it was already taken today" + } + } +} + +def modeChangeHandler(evt) { + log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)" + if (evt.value in triggerModes) { + eventHandler(evt) + } +} + +def scheduledTimeHandler() { + eventHandler(null) +} + +def appTouchHandler(evt) { + takeAction(evt) +} + +private takeAction(evt) { + + loadText() + + if (song) { + sonos.playSoundAndTrack(state.sound.uri, state.sound.duration, state.selectedSong, volume) + } + else if (resumePlaying){ + sonos.playTrackAndResume(state.sound.uri, state.sound.duration, volume) + } + else if (volume) { + sonos.playTrackAtVolume(state.sound.uri, volume) + } + else { + sonos.playTrack(state.sound.uri) + } + + if (frequency || oncePerDay) { + state[frequencyKey(evt)] = now() + } +} + +private songOptions() { + + // Make sure current selection is in the set + + def options = new LinkedHashSet() + if (state.selectedSong?.station) { + options << state.selectedSong.station + } + else if (state.selectedSong?.description) { + // TODO - Remove eventually? 'description' for backward compatibility + options << state.selectedSong.description + } + + // Query for recent tracks + def states = sonos.statesSince("trackData", new Date(0), [max:30]) + def dataMaps = states.collect{it.jsonValue} + options.addAll(dataMaps.collect{it.station}) + + log.trace "${options.size()} songs in list" + options.take(20) as List +} + +private saveSelectedSong() { + try { + def thisSong = song + log.info "Looking for $thisSong" + def songs = sonos.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} + log.info "Searching ${songs.size()} records" + + def data = songs.find {s -> s.station == thisSong} + log.info "Found ${data?.station}" + if (data) { + state.selectedSong = data + log.debug "Selected song = $state.selectedSong" + } + else if (song == state.selectedSong?.station) { + log.debug "Selected existing entry '$song', which is no longer in the last 20 list" + } + else { + log.warn "Selected song '$song' not found" + } + } + catch (Throwable t) { + log.error t + } +} + +private frequencyKey(evt) { + "lastActionTimeStamp" +} + +private dayString(Date date) { + def df = new java.text.SimpleDateFormat("yyyy-MM-dd") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + df.format(date) +} + +private oncePerDayOk(Long lastTime) { + def result = true + if (oncePerDay) { + result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true + log.trace "oncePerDayOk = $result" + } + result +} + +// TODO - centralize somehow +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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, location?.timeZone).time + def stop = timeToday(ending, location?.timeZone).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) +} + +private getTimeLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} +// TODO - End Centralize + +private loadText() { + if (location.timeZone || zipCode) { + def weather = getWeatherFeature("forecast", zipCode) + def current = getWeatherFeature("conditions", zipCode) + def isMetric = location.temperatureScale == "C" + def delim = "" + def sb = new StringBuilder() + list(forecastOptions).sort().each {opt -> + if (opt == "0") { + if (isMetric) { + sb << "The current temperature is ${Math.round(current.current_observation.temp_c)} degrees." + } + else { + sb << "The current temperature is ${Math.round(current.current_observation.temp_f)} degrees." + } + delim = " " + } + else if (opt == "1") { + sb << delim + sb << "Today's forecast is " + if (isMetric) { + sb << weather.forecast.txt_forecast.forecastday[0].fcttext_metric + } + else { + sb << weather.forecast.txt_forecast.forecastday[0].fcttext + } + } + else if (opt == "2") { + sb << delim + sb << "Tonight will be " + if (isMetric) { + sb << weather.forecast.txt_forecast.forecastday[1].fcttext_metric + } + else { + sb << weather.forecast.txt_forecast.forecastday[1].fcttext + } + } + else if (opt == "3") { + sb << delim + sb << "Tomorrow will be " + if (isMetric) { + sb << weather.forecast.txt_forecast.forecastday[2].fcttext_metric + } + else { + sb << weather.forecast.txt_forecast.forecastday[2].fcttext + } + } + } + + def msg = sb.toString() + msg = msg.replaceAll(/([0-9]+)C/,'$1 degrees') // TODO - remove after next release + log.debug "msg = ${msg}" + state.sound = textToSpeech(msg, true) + } + else { + state.sound = textToSpeech("Please set the location of your hub with the SmartThings mobile app, or enter a zip code to receive weather forecasts.") + } +} + +private list(String s) { + [s] +} +private list(l) { + l +} diff --git a/official/sprayer-controller-2.groovy b/official/sprayer-controller-2.groovy new file mode 100755 index 0000000..e23da21 --- /dev/null +++ b/official/sprayer-controller-2.groovy @@ -0,0 +1,155 @@ +/** + * Sprayer Controller 2 + * + * Copyright 2014 Cooper Lee + * + * 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: "Sprayer Controller 2", + namespace: "sprayercontroller", + author: "Cooper Lee", + description: "Control Sprayers for a period of time a number of times per hour", + category: "My Apps", + iconUrl: "http://www.mountpleasantwaterworks.com/images/ground_sprinkler.png", + iconX2Url: "http://www.mountpleasantwaterworks.com/images/ground_sprinkler.png" +) + + +preferences { + section("Select First Valve(s):") { + input name: "valves1", type: "capability.switch", multiple: true + input name: "startHour1", title: "Start Hour", type: "number" + input name: "stopHour1", title: "Stop Hour", type: "number" + input "minutes", "enum", title: "Run how many times an Hour?", expanded: true, + options: ["1","2","3","4","5","6","12","20","30","60"] /*/ + options: ["0", "0,30", "0,20,40", "0,15,30,45", "0, 10, 15, 20, 25,30,35,40,45,50,55", "6", "7"] */ + input "duration", "number", title: "For how many seconds?" + } + +} + + +def installed() { + log.debug "Installed with settings: ${settings}" + def startHour = startHour1 + def stopHour = stopHour1 + def startTime = minutes + if (minutes == "1") { + startTime = "0 0 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "2") { + startTime = "0 0,30 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "3") { + startTime = "0 0,20,40 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "4") { + startTime = "0 0,15,30,45 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "5") { + startTime = "0 0,12,24,36,48 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "6") { + startTime = "0 0,10,20,30,40,50 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "12") { + startTime = "0 0,5,10,15,20,25,30,35,40,45,50,55 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "20") { + startTime = "0 0,3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "30") { + startTime = "0 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58 " + startHour + "-" + stopHour + " * * ?" + } else { + startTime = "0 0 " + startHour + "-" + stopHour + " * * ?" + } + log.debug "${startTime}" + /* + def stopTime = "0 $minutes $stopHour * * ?" */ + schedule(startTime, openValve) +/* schedule("0 0,5,10,15,20,25,30,35,40,45,50,55 " + startHour + "-" + stopHour + " * * ?", openValve) */ +/* schedule(stopTime, closeValve) */ + subscribe(valves1, "switch.on", valveOnHandler, [filterEvents: false]) + +} + +def updated(settings) { + unschedule() + unsubscribe() + log.debug "Installed with settings: ${settings}" + def startHour = startHour1 + def stopHour = stopHour1 + def startTime = minutes + if (minutes == "1") { + startTime = "0 0 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "2") { + startTime = "0 0,30 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "3") { + startTime = "0 0,20,40 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "4") { + startTime = "0 0,15,30,45 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "5") { + startTime = "0 0,12,24,36,48 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "6") { + startTime = "0 0,10,20,30,40,50 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "12") { + startTime = "0 0,5,10,15,20,25,30,35,40,45,50,55 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "20") { + startTime = "0 0,3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57 " + startHour + "-" + stopHour + " * * ?" + } else if (minutes == "30") { + startTime = "0 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58 " + startHour + "-" + stopHour + " * * ?" + } else { + startTime = "0 0 " + startHour + "-" + stopHour + " * * ?" + } + log.debug "${startTime}" + /* + def stopTime = "0 $minutes $stopHour * * ?" */ + schedule(startTime, openValve) +/* schedule(stopTime, closeValve) */ + subscribe(valves1, "switch.on", valveOnHandler, [filterEvents: false]) +/* schedule("0 0,5,10,15,20,25,30,35,40,45,50,55 " + startHour + "-" + stopHour + " * * ?", openValve) */ + +} + +def openValve() { + log.debug "Turning on Sprinklers ${valves1}" + valves1.on() + +} + +def closeValve() { + log.debug "Turning off Sprinklers ${valves1}" + valves1.off() +} + +def valveOnHandler(evt) { + log.debug "Valve ${valves1} turned: ${evt.value}" + def delay = duration + log.debug "Turning off in ${duration/60} minutes (${delay}seconds)" + runIn(delay, closeValve) +} + +def setStartTime() { + if (minutes == "1") { + def startTime = "0 0 $startHour * * ?" + } else if (minutes == "2") { + def startTime = "0 0,30 $startHour * * ?" + } else if (minutes == "3") { + def startTime = "0 0,20,40 $startHour * * ?" + } else if (minutes == "4") { + def startTime = "0 0,15,30,45 $startHour * * ?" + } else if (minutes == "5") { + def startTime = "0 0,12,24,36,48 $startHour * * ?" + } else if (minutes == "6") { + def startTime = "0 0,10,20,30,40,50 $startHour * * ?" + } else if (minutes == "12") { + def startTime = "0 0,5,10,15,20,25,30,35,40,45,50,55 $startHour * * ?" + } else if (minutes == "20") { + def startTime = "0 0,3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57 $startHour * * ?" + } else if (minutes == "30") { + def startTime = "0 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58 $startHour * * ?" + } else { + def startTime = "0 0 $startHour * * ?" + } +} diff --git a/official/spruce-scheduler.groovy b/official/spruce-scheduler.groovy new file mode 100755 index 0000000..54837a8 --- /dev/null +++ b/official/spruce-scheduler.groovy @@ -0,0 +1,2634 @@ +/** + * Spruce Scheduler Pre-release V2.53.1 - Updated 11/07/2016, BAB + * + * + * Copyright 2015 Plaid Systems + * + * 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. + * + +-------v2.53.1------------------- +-ln 210: enableManual string modified +-ln 496: added code for old ST app zoneNumber number to convert to enum for app update compatibility +-ln 854: unschedule if NOT running to clear/correct manual subscription +-ln 863: weather scheduled if rain OR seasonal enabled, both off is no weather check scheduled +-ln 1083: added sync check to manual start +-ln 1538: corrected contact delay minimum fro 5s to 10s + +-------v2.52--------------------- + -Major revision by BAB + * + */ + +definition( + name: "Spruce Scheduler", + namespace: "plaidsystems", + author: "Plaid Systems", + description: "Setup schedules for Spruce irrigation controller", + category: "Green Living", + iconUrl: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png", + iconX2Url: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png", + iconX3Url: "http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png") + +preferences { + page(name: 'startPage') + page(name: 'autoPage') + page(name: 'zipcodePage') + page(name: 'weatherPage') + page(name: 'globalPage') + page(name: 'contactPage') + page(name: 'delayPage') + page(name: 'zonePage') + + page(name: 'zoneSettingsPage') + page(name: 'zoneSetPage') + page(name: 'plantSetPage') + page(name: 'sprinklerSetPage') + page(name: 'optionSetPage') + + //found at bottom - transition pages + page(name: 'zoneSetPage1') + page(name: 'zoneSetPage2') + page(name: 'zoneSetPage3') + page(name: 'zoneSetPage4') + page(name: 'zoneSetPage5') + page(name: 'zoneSetPage6') + page(name: 'zoneSetPage7') + page(name: 'zoneSetPage8') + page(name: 'zoneSetPage9') + page(name: 'zoneSetPage10') + page(name: 'zoneSetPage11') + page(name: 'zoneSetPage12') + page(name: 'zoneSetPage13') + page(name: 'zoneSetPage14') + page(name: 'zoneSetPage15') + page(name: 'zoneSetPage16') +} + +def startPage(){ + dynamicPage(name: 'startPage', title: 'Spruce Smart Irrigation setup', install: true, uninstall: true) + { + section(''){ + href(name: 'globalPage', title: 'Schedule settings', required: false, page: 'globalPage', + image: 'http://www.plaidsystems.com/smartthings/st_settings.png', + description: "Schedule: ${enableString()}\nWatering Time: ${startTimeString()}\nDays:${daysString()}\nNotifications:${notifyString()}" + ) + } + + section(''){ + href(name: 'weatherPage', title: 'Weather Settings', required: false, page: 'weatherPage', + image: 'http://www.plaidsystems.com/smartthings/st_rain_225_r.png', + description: "Weather from: ${zipString()}\nRain Delay: ${isRainString()}\nSeasonal Adjust: ${seasonalAdjString()}" + ) + } + + section(''){ + href(name: 'zonePage', title: 'Zone summary and setup', required: false, page: 'zonePage', + image: 'http://www.plaidsystems.com/smartthings/st_zone16_225.png', + description: "${getZoneSummary()}" + ) + } + + section(''){ + href(name: 'delayPage', title: 'Valve delays & Pause controls', required: false, page: 'delayPage', + image: 'http://www.plaidsystems.com/smartthings/st_timer.png', + description: "Valve Delay: ${pumpDelayString()} s\n${waterStoppersString()}\nSchedule Sync: ${syncString()}" + ) + } + + section(''){ + href(title: 'Spruce Irrigation Knowledge Base', //page: 'customPage', + description: 'Explore our knowledge base for more information on Spruce and Spruce sensors. Contact form is ' + + 'also available here.', + required: false, style:'embedded', + image: 'http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png', + url: 'http://support.spruceirrigation.com' + ) + } + } +} + +def globalPage() { + dynamicPage(name: 'globalPage', title: '') { + section('Spruce schedule Settings') { + label title: 'Schedule Name:', description: 'Name this schedule', required: false + input 'switches', 'capability.switch', title: 'Spruce Irrigation Controller:', description: 'Select a Spruce controller', required: true, multiple: false + } + + section('Program Scheduling'){ + input 'enable', 'bool', title: 'Enable watering:', defaultValue: 'true', metadata: [values: ['true', 'false']] + input 'enableManual', 'bool', title: 'Enable this schedule for manual start, only 1 schedule should be enabled for manual start at a time!', defaultValue: 'true', metadata: [values: ['true', 'false']] + input 'startTime', 'time', title: 'Watering start time', required: true + paragraph(image: 'http://www.plaidsystems.com/smartthings/st_calander.png', + title: 'Selecting watering days', + 'Selecting watering days is optional. Spruce will optimize your watering schedule automatically. ' + + 'If your area has water restrictions or you prefer set days, select the days to meet your requirements. ') + input (name: 'days', type: 'enum', title: 'Water only on these days...', required: false, multiple: true, metadata: [values: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', 'Even', 'Odd']]) + } + + section('Push Notifications') { + input(name: 'notify', type: 'enum', title: 'Select what push notifications to receive.', required: false, + multiple: true, metadata: [values: ['Daily', 'Delays', 'Warnings', 'Weather', 'Moisture', 'Events']]) + input('recipients', 'contact', title: 'Send push notifications to', required: false, multiple: true) + input(name: 'logAll', type: 'bool', title: 'Log all notices to Hello Home?', defaultValue: 'false', options: ['true', 'false']) + } + } +} + +def weatherPage() { + dynamicPage(name: 'weatherPage', title: 'Weather settings') { + section('Location to get weather forecast and conditions:') { + href(name: 'hrefWithImage', title: "${zipString()}", page: 'zipcodePage', + description: 'Set local weather station', + required: false, + image: 'http://www.plaidsystems.com/smartthings/rain.png' + ) + input 'isRain', 'bool', title: 'Enable Rain check:', metadata: [values: ['true', 'false']] + input 'rainDelay', 'decimal', title: 'inches of rain that will delay watering, default: 0.2', required: false + input 'isSeason', 'bool', title: 'Enable Seasonal Weather Adjustment:', metadata: [values: ['true', 'false']] + } + } +} + +def zipcodePage() { + return dynamicPage(name: 'zipcodePage', title: 'Spruce weather station setup') { + section(''){ + input(name: 'zipcode', type: 'text', title: 'Zipcode or WeatherUnderground station id. Default value is current Zip code', + defaultValue: getPWSID(), required: false, submitOnChange: true ) + } + + section(''){ + paragraph(image: 'http://www.plaidsystems.com/smartthings/wu.png', title: 'WeatherUnderground Personal Weather Stations (PWS)', + required: false, + 'To automatically select the PWS nearest to your hub location, select the toggle below and clear the ' + + 'location field above') + input(name: 'nearestPWS', type: 'bool', title: 'Use nearest PWS', options: ['true', 'false'], + defaultValue: false, submitOnChange: true) + href(title: 'Or, Search WeatherUnderground.com for your desired PWS', + description: 'After page loads, select "Change Station" for a list of weather stations. ' + + 'You will need to copy the station code into the location field above', + required: false, style:'embedded', + url: (location.latitude && location.longitude)? "http://www.wunderground.com/cgi-bin/findweather/hdfForecast?query=${location.latitude}%2C${location.longitude}" : + "http://www.wunderground.com/q/${location.zipCode}") + } + } +} + +private String getPWSID() { + String PWSID = location.zipCode + if (zipcode) PWSID = zipcode + if (nearestPWS && !zipcode) { + // find the nearest PWS to the hub's geo location + String geoLocation = location.zipCode + // use coordinates, if available + if (location.latitude && location.longitude) geoLocation = "${location.latitude}%2C${location.longitude}" + Map wdata = getWeatherFeature('geolookup', geoLocation) + if (wdata && wdata.response && !wdata.response.containsKey('error')) { // if we get good data + if (wdata.response.features.containsKey('geolookup') && (wdata.response.features.geolookup.toInteger() == 1) && wdata.location) { + PWSID = wdata.location.nearby_weather_stations.pws.station[0].id + } + else log.debug "bad response" + } + else log.debug "null or error" + } + log.debug "Nearest PWS ${PWSID}" + return PWSID +} + +private String startTimeString(){ + if (!startTime) return 'Please set!' else return hhmm(startTime) +} + +private String enableString(){ + if(enable && enableManual) return 'On & Manual Set' + else if (enable) return 'On & Manual Off' + else if (enableManual) return 'Off & Manual Set' + else return 'Off' +} + +private String waterStoppersString(){ + String stoppers = 'Contact Sensor' + if (settings.contacts) { + if (settings.contacts.size() != 1) stoppers += 's' + stoppers += ': ' + int i = 1 + settings.contacts.each { + if ( i > 1) stoppers += ', ' + stoppers += it.displayName + i++ + } + stoppers = "${stoppers}\nPause: When ${settings.contactStop}\n" + } + else { + stoppers += ': None\n' + } + stoppers += "Switch" + if (settings.toggles) { + if (settings.toggles.size() != 1) stoppers += 'es' + stoppers += ': ' + int i = 1 + settings.toggles.each { + if ( i > 1) stoppers += ', ' + stoppers += it.displayName + i++ + } + stoppers = "${stoppers}\nPause: When switched ${settings.toggleStop}\n" + } + else { + stoppers += ': None\n' + } + int cd = 10 + if (settings.contactDelay && settings.contactDelay > 10) cd = settings.contactDelay.toInteger() + stoppers += "Restart Delay: ${cd} secs" + return stoppers +} + +private String isRainString(){ + if (settings.isRain && !settings.rainDelay) return '0.2' as String + if (settings.isRain) return settings.rainDelay as String else return 'Off' +} + +private String seasonalAdjString(){ + if(settings.isSeason) return 'On' else return 'Off' +} + +private String syncString(){ + if (settings.sync) return "${settings.sync.displayName}" else return 'None' +} + +private String notifyString(){ + String notifyStr = '' + if(settings.notify) { + if (settings.notify.contains('Daily')) notifyStr += ' Daily' + //if (settings.notify.contains('Weekly')) notifyStr += ' Weekly' + if (settings.notify.contains('Delays')) notifyStr += ' Delays' + if (settings.notify.contains('Warnings')) notifyStr += ' Warnings' + if (settings.notify.contains('Weather')) notifyStr += ' Weather' + if (settings.notify.contains('Moisture')) notifyStr += ' Moisture' + if (settings.notify.contains('Events')) notifyStr += ' Events' + } + if (notifyStr == '') notifyStr = ' None' + if (settings.logAll) notifyStr += '\nSending all Notifications to Hello Home log' + + return notifyStr +} + +private String daysString(){ + String daysString = '' + if (days){ + if(days.contains('Even') || days.contains('Odd')) { + if (days.contains('Even')) daysString += ' Even' + if (days.contains('Odd')) daysString += ' Odd' + } + else { + if (days.contains('Monday')) daysString += ' M' + if (days.contains('Tuesday')) daysString += ' Tu' + if (days.contains('Wednesday')) daysString += ' W' + if (days.contains('Thursday')) daysString += ' Th' + if (days.contains('Friday')) daysString += ' F' + if (days.contains('Saturday')) daysString += ' Sa' + if (days.contains('Sunday')) daysString += ' Su' + } + } + if(daysString == '') return ' Any' + + else return daysString +} + +private String 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)) + return f.format(t) +} + +private String pumpDelayString(){ + if (!pumpDelay) return '0' else return pumpDelay as String + + +} + +def delayPage() { + dynamicPage(name: 'delayPage', title: 'Additional Options') { + section(''){ + paragraph image: 'http://www.plaidsystems.com/smartthings/st_timer.png', + title: 'Pump and Master valve delay', + required: false, + 'Setting a delay is optional, default is 0. If you have a pump that feeds water directly into your valves, ' + + 'set this to 0. To fill a tank or build pressure, you may increase the delay.\n\nStart->Pump On->delay->Valve ' + + 'On->Valve Off->delay->...' + input name: 'pumpDelay', type: 'number', title: 'Set a delay in seconds?', defaultValue: '0', required: false + } + + section(''){ + paragraph(image: 'http://www.plaidsystems.com/smartthings/st_pause.png', + title: 'Pause Control Contacts & Switches', + required: false, + 'Selecting contacts or control switches is optional. When a selected contact sensor is opened or switch is ' + + 'toggled, water immediately stops and will not resume until all of the contact sensors are closed and all of ' + + 'the switches are reset.\n\nCaution: if all contacts or switches are left in the stop state, the dependent ' + + 'schedule(s) will never run.') + input(name: 'contacts', title: 'Select water delay contact sensors', type: 'capability.contactSensor', multiple: true, + required: false, submitOnChange: true) + // if (settings.contact) settings.contact = null // 'contact' has been deprecated + if (contacts) + input(name: 'contactStop', title: 'Stop watering when sensors are...', type: 'enum', required: (settings.contacts != null), + options: ['open', 'closed'], defaultValue: 'open') + input(name: 'toggles', title: 'Select water delay switches', type: 'capability.switch', multiple: true, required: false, + submitOnChange: true) + if (toggles) + input(name: 'toggleStop', title: 'Stop watering when switches are...', type: 'enum', + required: (settings.toggles != null), options: ['on', 'off'], defaultValue: 'off') + input(name: 'contactDelay', type: 'number', title: 'Restart watering how many seconds after all contacts and switches ' + + 'are reset? (minimum 10s)', defaultValue: '10', required: false) + } + + section(''){ + paragraph image: 'http://www.plaidsystems.com/smartthings/st_spruce_controller_250.png', + title: 'Controller Sync', + required: false, + 'For multiple controllers only. This schedule will wait for the selected controller to finish before ' + + 'starting. Do not set with a single controller!' + input name: 'sync', type: 'capability.switch', title: 'Select Master Controller', description: 'Only use this setting with multiple controllers', required: false, multiple: false + } + } +} + +def zonePage() { + dynamicPage(name: 'zonePage', title: 'Zone setup', install: false, uninstall: false) { + section('') { + href(name: 'hrefWithImage', title: 'Zone configuration', page: 'zoneSettingsPage', + description: "${zoneString()}", + required: false, + image: 'http://www.plaidsystems.com/smartthings/st_spruce_leaf_250f.png') + } + + if (zoneActive('1')){ + section(''){ + href(name: 'z1Page', title: "1: ${getname("1")}", required: false, page: 'zoneSetPage1', + image: "${getimage("1")}", + description: "${display("1")}" ) + } + } + if (zoneActive('2')){ + section(''){ + href(name: 'z2Page', title: "2: ${getname("2")}", required: false, page: 'zoneSetPage2', + image: "${getimage("2")}", + description: "${display("2")}" ) + } + } + if (zoneActive('3')){ + section(''){ + href(name: 'z3Page', title: "3: ${getname("3")}", required: false, page: 'zoneSetPage3', + image: "${getimage("3")}", + description: "${display("3")}" ) + } + } + if (zoneActive('4')){ + section(''){ + href(name: 'z4Page', title: "4: ${getname("4")}", required: false, page: 'zoneSetPage4', + image: "${getimage("4")}", + description: "${display("4")}" ) + } + } + if (zoneActive('5')){ + section(''){ + href(name: 'z5Page', title: "5: ${getname("5")}", required: false, page: 'zoneSetPage5', + image: "${getimage("5")}", + description: "${display("5")}" ) + } + } + if (zoneActive('6')){ + section(''){ + href(name: 'z6Page', title: "6: ${getname("6")}", required: false, page: 'zoneSetPage6', + image: "${getimage("6")}", + description: "${display("6")}" ) + } + } + if (zoneActive('7')){ + section(''){ + href(name: 'z7Page', title: "7: ${getname("7")}", required: false, page: 'zoneSetPage7', + image: "${getimage("7")}", + description: "${display("7")}" ) + } + } + if (zoneActive('8')){ + section(''){ + href(name: 'z8Page', title: "8: ${getname("8")}", required: false, page: 'zoneSetPage8', + image: "${getimage("8")}", + description: "${display("8")}" ) + } + } + if (zoneActive('9')){ + section(''){ + href(name: 'z9Page', title: "9: ${getname("9")}", required: false, page: 'zoneSetPage9', + image: "${getimage("9")}", + description: "${display("9")}" ) + } + } + if (zoneActive('10')){ + section(''){ + href(name: 'z10Page', title: "10: ${getname("10")}", required: false, page: 'zoneSetPage10', + image: "${getimage("10")}", + description: "${display("10")}" ) + } + } + if (zoneActive('11')){ + section(''){ + href(name: 'z11Page', title: "11: ${getname("11")}", required: false, page: 'zoneSetPage11', + image: "${getimage("11")}", + description: "${display("11")}" ) + } + } + if (zoneActive('12')){ + section(''){ + href(name: 'z12Page', title: "12: ${getname("12")}", required: false, page: 'zoneSetPage12', + image: "${getimage("12")}", + description: "${display("12")}" ) + } + } + if (zoneActive('13')){ + section(''){ + href(name: 'z13Page', title: "13: ${getname("13")}", required: false, page: 'zoneSetPage13', + image: "${getimage("13")}", + description: "${display("13")}" ) + } + } + if (zoneActive('14')){ + section(''){ + href(name: 'z14Page', title: "14: ${getname("14")}", required: false, page: 'zoneSetPage14', + image: "${getimage("14")}", + description: "${display("14")}" ) + } + } + if (zoneActive('15')){ + section(''){ + href(name: 'z15Page', title: "15: ${getname("15")}", required: false, page: 'zoneSetPage15', + image: "${getimage("15")}", + description: "${display("15")}" ) + } + } + if (zoneActive('16')){ + section(''){ + href(name: 'z16Page', title: "16: ${getname("16")}", required: false, page: 'zoneSetPage16', + image: "${getimage("16")}", + description: "${display("16")}" ) + } + } + } +} + +// Verify whether a zone is active +/*//Code for fresh install +private boolean zoneActive(String zoneStr){ + if (!zoneNumber) return false + if (zoneNumber.contains(zoneStr)) return true // don't display zones that are not selected + return false +} +*/ +//code change for ST update file -> change input to zoneNumberEnum +private boolean zoneActive(z){ + if (!zoneNumberEnum && zoneNumber && zoneNumber >= z.toInteger()) return true + else if (!zoneNumberEnum && zoneNumber && zoneNumber != z.toInteger()) return false + else if (zoneNumberEnum && zoneNumberEnum.contains(z)) return true + return false +} + + +private String zoneString() { + String numberString = 'Add zones to setup' + if (zoneNumber) numberString = "Zones enabled: ${zoneNumber}" + if (learn) numberString = "${numberString}\nSensor mode: Adaptive" + else numberString = "${numberString}\nSensor mode: Delay" + return numberString +} + +def zoneSettingsPage() { + dynamicPage(name: 'zoneSettingsPage', title: 'Zone Configuration') { + section(''){ + //input (name: "zoneNumber", type: "number", title: "Enter number of zones to configure?",description: "How many valves do you have? 1-16", required: true)//, defaultValue: 16) + input 'zoneNumberEnum', 'enum', title: 'Select zones to configure', multiple: true, metadata: [values: ['1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16']] + input 'gain', 'number', title: 'Increase or decrease all water times by this %, enter a negative or positive value, Default: 0', required: false, range: '-99..99' + paragraph image: 'http://www.plaidsystems.com/smartthings/st_sensor_200_r.png', + title: 'Moisture sensor adapt mode', + 'Adaptive mode enabled: Watering times will be adjusted based on the assigned moisture sensor.\n\nAdaptive mode ' + + 'disabled (Delay): Zones with moisture sensors will water on any available days when the low moisture setpoint has ' + + 'been reached.' + input 'learn', 'bool', title: 'Enable Adaptive Moisture Control (with moisture sensors)', metadata: [values: ['true', 'false']] + } + } +} + +def zoneSetPage() { + dynamicPage(name: 'zoneSetPage', title: "Zone ${state.app} Setup") { + section(''){ + paragraph image: "http://www.plaidsystems.com/smartthings/st_${state.app}.png", + title: 'Current Settings', + "${display("${state.app}")}" + } + + section(''){ + input "name${state.app}", 'text', title: 'Zone name?', required: false, defaultValue: "Zone ${state.app}" + } + + section(''){ + href(name: 'tosprinklerSetPage', title: "Sprinkler type: ${setString('zone')}", required: false, page: 'sprinklerSetPage', + image: "${getimage("${settings."zone${state.app}"}")}", + //description: "Set sprinkler nozzle type or turn zone off") + description: 'Sprinkler type descriptions') + input "zone${state.app}", 'enum', title: 'Sprinkler Type', multiple: false, required: false, defaultValue: 'Off', submitOnChange: true, metadata: [values: ['Off', 'Spray', 'Rotor', 'Drip', 'Master Valve', 'Pump']] + } + + section(''){ + href(name: 'toplantSetPage', title: "Landscape Select: ${setString('plant')}", required: false, page: 'plantSetPage', + image: "${getimage("${settings["plant${state.app}"]}")}", + //description: "Set landscape type") + description: 'Landscape type descriptions') + input "plant${state.app}", 'enum', title: 'Landscape', multiple: false, required: false, submitOnChange: true, metadata: [values: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants']] + } + + section(''){ + href(name: 'tooptionSetPage', title: "Options: ${setString('option')}", required: false, page: 'optionSetPage', + image: "${getimage("${settings["option${state.app}"]}")}", + //description: "Set watering options") + description: 'Watering option descriptions') + input "option${state.app}", 'enum', title: 'Options', multiple: false, required: false, defaultValue: 'Cycle 2x', submitOnChange: true,metadata: [values: ['Slope', 'Sand', 'Clay', 'No Cycle', 'Cycle 2x', 'Cycle 3x']] + } + + section(''){ + paragraph image: 'http://www.plaidsystems.com/smartthings/st_sensor_200_r.png', + title: 'Moisture sensor settings', + 'Select a soil moisture sensor to monitor and control watering. The soil moisture target value is set to a default value but can be adjusted to tune watering' + input "sensor${state.app}", 'capability.relativeHumidityMeasurement', title: 'Select moisture sensor?', required: false, multiple: false + input "sensorSp${state.app}", 'number', title: "Minimum moisture sensor target value, Setpoint: ${getDrySp(state.app)}", required: false + } + + section(''){ + paragraph image: 'http://www.plaidsystems.com/smartthings/st_timer.png', + title: 'Optional: Enter total watering time per week', + 'This value will replace the calculated time from other settings' + input "minWeek${state.app}", 'number', title: 'Minimum water time per week.\nDefault: 0 = autoadjust', description: 'minutes per week', required: false + input "perDay${state.app}", 'number', title: 'Guideline value for time per day, this divides minutes per week into watering days. Default: 20', defaultValue: '20', required: false + } + } +} + +private String setString(String type) { + switch (type) { + case 'zone': + if (settings."zone${state.app}") return settings."zone${state.app}" else return 'Not Set' + break + case 'plant': + if (settings."plant${state.app}") return settings."plant${state.app}" else return 'Not Set' + break + case 'option': + if (settings."option${state.app}") return settings."option${state.app}" else return 'Not Set' + break + default: + return '????' + } +} + +def plantSetPage() { + dynamicPage(name: 'plantSetPage', title: "${settings["name${state.app}"]} Landscape Select") { + section(''){ + paragraph image: 'http://www.plaidsystems.com/img/st_${state.app}.png', + title: "${settings["name${state.app}"]}", + "Current settings ${display("${state.app}")}" + //input "plant${state.app}", "enum", title: "Landscape", multiple: false, required: false, submitOnChange: true, metadata: [values: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants']] + } + section(''){ + paragraph image: 'http://www.plaidsystems.com/smartthings/st_lawn_200_r.png', + title: 'Lawn', + 'Select Lawn for typical grass applications' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_garden_225_r.png', + title: 'Garden', + 'Select Garden for vegetable gardens' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_flowers_225_r.png', + title: 'Flowers', + 'Select Flowers for beds with smaller seasonal plants' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_shrubs_225_r.png', + title: 'Shrubs', + 'Select Shrubs for beds with larger established plants' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_trees_225_r.png', + title: 'Trees', + 'Select Trees for deep rooted areas without other plants' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_xeriscape_225_r.png', + title: 'Xeriscape', + 'Reduces water for native or drought tolorent plants' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_newplants_225_r.png', + title: 'New Plants', + 'Increases watering time per week and reduces automatic adjustments to help establish new plants. No weekly seasonal adjustment and moisture setpoint set to 40.' + } + } +} + +def sprinklerSetPage(){ + dynamicPage(name: 'sprinklerSetPage', title: "${settings["name${state.app}"]} Sprinkler Select") { + section(''){ + paragraph image: "http://www.plaidsystems.com/img/st_${state.app}.png", + title: "${settings["name${state.app}"]}", + "Current settings ${display("${state.app}")}" + //input "zone${state.app}", "enum", title: "Sprinkler Type", multiple: false, required: false, defaultValue: 'Off', metadata: [values: ['Off', 'Spray', 'Rotor', 'Drip', 'Master Valve', 'Pump']] + } + section(''){ + paragraph image: 'http://www.plaidsystems.com/smartthings/st_spray_225_r.png', + title: 'Spray', + 'Spray sprinkler heads spray a fan of water over the lawn. The water is applied evenly and can be turned on for a shorter duration of time.' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_rotor_225_r.png', + title: 'Rotor', + 'Rotor sprinkler heads rotate, spraying a stream over the lawn. Because they move back and forth across the lawn, they require a longer water period.' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_drip_225_r.png', + title: 'Drip', + 'Drip lines or low flow emitters water slowely to minimize evaporation, because they are low flow, they require longer watering periods.' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_master_225_r.png', + title: 'Master', + 'Master valves will open before watering begins. Set the delay between master opening and watering in delay settings.' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_pump_225_r.png', + title: 'Pump', + 'Attach a pump relay to this zone and the pump will turn on before watering begins. Set the delay between pump start and watering in delay settings.' + } + } +} + +def optionSetPage(){ + dynamicPage(name: 'optionSetPage', title: "${settings["name${state.app}"]} Options") { + section(''){ + paragraph image: "http://www.plaidsystems.com/img/st_${state.app}.png", + title: "${settings["name${state.app}"]}", + "Current settings ${display("${state.app}")}" + //input "option${state.app}", "enum", title: "Options", multiple: false, required: false, defaultValue: 'Cycle 2x', metadata: [values: ['Slope', 'Sand', 'Clay', 'No Cycle', 'Cycle 2x', 'Cycle 3x']] + } + section(''){ + paragraph image: 'http://www.plaidsystems.com/smartthings/st_slope_225_r.png', + title: 'Slope', + 'Slope sets the sprinklers to cycle 3x, each with a short duration to minimize runoff' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_sand_225_r.png', + title: 'Sand', + 'Sandy soil drains quickly and requires more frequent but shorter intervals of water' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_clay_225_r.png', + title: 'Clay', + 'Clay sets the sprinklers to cycle 2x, each with a short duration to maximize absorption' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle1x_225_r.png', + title: 'No Cycle', + 'The sprinklers will run for 1 long duration' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle2x_225_r.png', + title: 'Cycle 2x', + 'Cycle 2x will break the water period up into 2 shorter cycles to help minimize runoff and maximize adsorption' + + paragraph image: 'http://www.plaidsystems.com/smartthings/st_cycle3x_225_r.png', + title: 'Cycle 3x', + 'Cycle 3x will break the water period up into 3 shorter cycles to help minimize runoff and maximize adsorption' + } + } +} + +def setPage(i){ + if (i) state.app = i + return state.app +} + +private String getaZoneSummary(int zone){ + if (!settings."zone${zone}" || (settings."zone${zone}" == 'Off')) return "${zone}: Off" + + String daysString = '' + int tpw = initTPW(zone) + int dpw = initDPW(zone) + int runTime = calcRunTime(tpw, dpw) + + if ( !learn && (settings."sensor${zone}")) { + daysString = 'if Moisture is low on: ' + dpw = daysAvailable() + } + if (days && (days.contains('Even') || days.contains('Odd'))) { + if (dpw == 1) daysString = 'Every 8 days' + if (dpw == 2) daysString = 'Every 4 days' + if (dpw == 4) daysString = 'Every 2 days' + if (days.contains('Even') && days.contains('Odd')) daysString = 'any day' + } + else { + def int[] dpwMap = [0,0,0,0,0,0,0] + dpwMap = getDPWDays(dpw) + daysString += getRunDays(dpwMap) + } + return "${zone}: ${runTime} min, ${daysString}" +} + +private String getZoneSummary(){ + String summary = '' + if (learn) summary = 'Moisture Learning enabled' else summary = 'Moisture Learning disabled' + + int zone = 1 + createDPWMap() + while(zone <= 16) { + if (nozzle(zone) == 4) summary = "${summary}\n${zone}: ${settings."zone${zone}"}" + else if ( (initDPW(zone) != 0) && zoneActive(zone.toString())) summary = "${summary}\n${getaZoneSummary(zone)}" + zone++ + } + if (summary) return summary else return zoneString() //"Setup all 16 zones" +} + +private String display(String i){ + //log.trace "display(${i})" + String displayString = '' + int tpw = initTPW(i.toInteger()) + int dpw = initDPW(i.toInteger()) + int runTime = calcRunTime(tpw, dpw) + if (settings."zone${i}") displayString += settings."zone${i}" + ' : ' + if (settings."plant${i}") displayString += settings."plant${i}" + ' : ' + if (settings."option${i}") displayString += settings."option${i}" + ' : ' + int j = i.toInteger() + if (settings."sensor${i}") { + displayString += settings."sensor${i}" + displayString += "=${getDrySp(j)}% : " + } + if ((runTime != 0) && (dpw != 0)) displayString = "${displayString}${runTime} minutes, ${dpw} days per week" + return displayString +} + +private String getimage(String image){ + String imageStr = image + if (image.isNumber()) { + String zoneStr = settings."zone${image}" + if (zoneStr) { + if (zoneStr == 'Off') return 'http://www.plaidsystems.com/smartthings/off2.png' + if (zoneStr == 'Master Valve') return 'http://www.plaidsystems.com/smartthings/master.png' + if (zoneStr == 'Pump') return 'http://www.plaidsystems.com/smartthings/pump.png' + + if (settings."plant${image}") imageStr = settings."plant${image}" // default assume asking for the plant image + } + } + // OK, lookup the requested image + switch (imageStr) { + case "null": + case null: + return 'http://www.plaidsystems.com/smartthings/off2.png' + case 'Off': + return 'http://www.plaidsystems.com/smartthings/off2.png' + case 'Lawn': + return 'http://www.plaidsystems.com/smartthings/st_lawn_200_r.png' + case 'Garden': + return 'http://www.plaidsystems.com/smartthings/st_garden_225_r.png' + case 'Flowers': + return 'http://www.plaidsystems.com/smartthings/st_flowers_225_r.png' + case 'Shrubs': + return 'http://www.plaidsystems.com/smartthings/st_shrubs_225_r.png' + case 'Trees': + return 'http://www.plaidsystems.com/smartthings/st_trees_225_r.png' + case 'Xeriscape': + return 'http://www.plaidsystems.com/smartthings/st_xeriscape_225_r.png' + case 'New Plants': + return 'http://www.plaidsystems.com/smartthings/st_newplants_225_r.png' + case 'Spray': + return 'http://www.plaidsystems.com/smartthings/st_spray_225_r.png' + case 'Rotor': + return 'http://www.plaidsystems.com/smartthings/st_rotor_225_r.png' + case 'Drip': + return 'http://www.plaidsystems.com/smartthings/st_drip_225_r.png' + case 'Master Valve': + return "http://www.plaidsystems.com/smartthings/st_master_225_r.png" + case 'Pump': + return 'http://www.plaidsystems.com/smartthings/st_pump_225_r.png' + case 'Slope': + return 'http://www.plaidsystems.com/smartthings/st_slope_225_r.png' + case 'Sand': + return 'http://www.plaidsystems.com/smartthings/st_sand_225_r.png' + case 'Clay': + return 'http://www.plaidsystems.com/smartthings/st_clay_225_r.png' + case 'No Cycle': + return 'http://www.plaidsystems.com/smartthings/st_cycle1x_225_r.png' + case 'Cycle 2x': + return 'http://www.plaidsystems.com/smartthings/st_cycle2x_225_r.png' + case "Cycle 3x": + return 'http://www.plaidsystems.com/smartthings/st_cycle3x_225_r.png' + default: + return 'http://www.plaidsystems.com/smartthings/off2.png' + } +} + +private String getname(String i) { + if (settings."name${i}") return settings."name${i}" else return "Zone ${i}" +} + +private String zipString() { + if (!settings.zipcode) return "${location.zipCode}" + //add pws for correct weatherunderground lookup + if (!settings.zipcode.isNumber()) return "pws:${settings.zipcode}" + else return settings.zipcode +} + +//app install +def installed() { + state.dpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + state.tpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + state.Rain = [0,0,0,0,0,0,0] + state.daycount = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + atomicState.run = false // must be atomic - used to recover from crashes + state.pauseTime = null + atomicState.startTime = null + atomicState.finishTime = null // must be atomic - used to recover from crashes + + log.debug "Installed with settings: ${settings}" + installSchedule() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + installSchedule() +} + +def installSchedule(){ + if (!state.seasonAdj) state.seasonAdj = 100.0 + if (!state.weekseasonAdj) state.weekseasonAdj = 0 + if (state.daysAvailable != 0) state.daysAvailable = 0 // force daysAvailable to be initialized by daysAvailable() + state.daysAvailable = daysAvailable() // every time we save the schedule + + if (atomicState.run) { + attemptRecovery() // clean up if we crashed earlier + } + else { + unsubscribe() //added back in to reset manual subscription + resetEverything() + } + subscribe(app, appTouch) // enable the "play" button for this schedule + Random rand = new Random() + long randomOffset = 0 + + // always collect rainfall + int randomSeconds = rand.nextInt(59) + if (settings.isRain || settings.isSeason) schedule("${randomSeconds} 57 23 1/1 * ? *", getRainToday) // capture today's rainfall just before midnight + + if (settings.switches && settings.startTime && settings.enable){ + + randomOffset = rand.nextInt(60000) + 20000 + def checktime = timeToday(settings.startTime, location.timeZone).getTime() + randomOffset + //log.debug "randomOffset ${randomOffset} checktime ${checktime}" + schedule(checktime, preCheck) //check weather & Days + writeSettings() + note('schedule', "${app.label}: Starts at ${startTimeString()}", 'i') + } + else { + unschedule( preCheck ) + note('disable', "${app.label}: Automatic watering disabled or setup is incomplete", 'a') + } +} + +// Called to find and repair after crashes - called by installSchedule() and busy() +private boolean attemptRecovery() { + if (!atomicState.run) { + return false // only clean up if we think we are still running + } + else { // Hmmm...seems we were running before... + def csw = settings.switches.currentSwitch + def cst = settings.switches.currentStatus + switch (csw) { + case 'on': // looks like this schedule is running the controller at the moment + if (!atomicState.startTime) { // cycleLoop cleared the startTime, but cycleOn() didn't set it + log.debug "${app.label}: crashed in cycleLoop(), cycleOn() never started, cst is ${cst} - resetting" + resetEverything() // reset and try again...it's probably not us running the controller, though + return false + } + // We have a startTime... + if (!atomicState.finishTime) { // started, but we don't think we're done yet..so it's probably us! + runIn(15, cycleOn) // goose the cycle, just in case + note('active', "${app.label}: schedule is apparently already running", 'i') + return true + } + + // hmmm...switch is on and we think we're finished...probably somebody else is running...let busy figure it out + resetEverything() + return false + break + + case 'off': // switch is off - did we finish? + if (atomicState.finishTime) { // off and finished, let's just reset things + resetEverything() + return false + } + + if (switches.currentStatus != 'pause') { // off and not paused - probably another schedule, let's clean up + resetEverything() + return false + } + + // off and not finished, and paused, we apparently crashed while paused + runIn(15, cycleOn) + return true + break + + case 'programOn': // died while manual program running? + case 'programWait': // looks like died previously before we got started, let's try to clean things up + resetEverything() + if (atomicState.finishTime) atomicState.finishTime = null + if ((cst == 'active') || atomicState.startTime) { // if we announced we were in preCheck, or made it all the way to cycleOn before it crashed + settings.switches.programOff() // only if we think we actually started (cycleOn() started) + // probably kills manual cycles too, but we'll let that go for now + } + if (atomicState.startTime) atomicState.startTime = null + note ('schedule', "Looks like ${app.label} crashed recently...cleaning up", c) + return false + break + + default: + log.debug "attemptRecovery(): atomicState.run == true, and I've nothing left to do" + return true + } + } +} + +// reset everything to the initial (not running) state +private def resetEverything() { + if (atomicState.run) atomicState.run = false // we're not running the controller any more + unsubAllBut() // release manual, switches, sync, contacts & toggles + + // take care not to unschedule preCheck() or getRainToday() + unschedule(cycleOn) + unschedule(checkRunMap) + unschedule(writeCycles) + unschedule(subOff) + + if (settings.enableManual) subscribe(settings.switches, 'switch.programOn', manualStart) +} + +// unsubscribe from ALL events EXCEPT app.touch +private def unsubAllBut() { + unsubscribe(settings.switches) + unsubWaterStoppers() + if (settings.sync) unsubscribe(settings.sync) + +} + +// enable the "Play" button in SmartApp list +def appTouch(evt) { + + log.debug "appTouch(): atomicState.run = ${atomicState.run}" + + runIn(2, preCheck) // run it off a schedule, so we can see how long it takes in the app.state +} + +// true if one of the stoppers is in Stop state +private boolean isWaterStopped() { + if (settings.contacts && settings.contacts.currentContact.contains(settings.contactStop)) return true + + if (settings.toggles && settings.toggles.currentSwitch.contains(settings.toggleStop)) return true + + return false +} + +// watch for water stoppers +private def subWaterStop() { + if (settings.contacts) { + unsubscribe(settings.contacts) + subscribe(settings.contacts, "contact.${settings.contactStop}", waterStop) + } + if (settings.toggles) { + unsubscribe(settings.toggles) + subscribe(settings.toggles, "switch.${settings.toggleStop}", waterStop) + } +} + +// watch for water starters +private def subWaterStart() { + if (settings.contacts) { + unsubscribe(settings.contacts) + def cond = (settings.contactStop == 'open') ? 'closed' : 'open' + subscribe(settings.contacts, "contact.${cond}", waterStart) + } + if (settings.toggles) { + unsubscribe(settings.toggles) + def cond = (settings.toggleStop == 'on') ? 'off' : 'on' + subscribe(settings.toggles, "switch.${cond}", waterStart) + } +} + +// stop watching water stoppers and starters +private def unsubWaterStoppers() { + if (settings.contacts) unsubscribe(settings.contacts) + if (settings.toggles) unsubscribe(settings.toggles) +} + +// which of the stoppers are in stop mode? +private String getWaterStopList() { + String deviceList = '' + int i = 1 + if (settings.contacts) { + settings.contacts.each { + if (it.currentContact == settings.contactStop) { + if (i > 1) deviceList += ', ' + deviceList = "${deviceList}${it.displayName} is ${settings.contactStop}" + i++ + } + } + } + if (settings.toggles) { + settings.toggles.each { + if (it.currentSwitch == settings.toggleStop) { + if (i > 1) deviceList += ', ' + deviceList = "${deviceList}${it.displayName} is ${settings.toggleStop}" + i++ + } + } + } + return deviceList +} + +//write initial zone settings to device at install/update +def writeSettings(){ + if (!state.tpwMap) state.tpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + if (!state.dpwMap) state.dpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + if (state.setMoisture) state.setMoisture = null // not using any more + if (!state.seasonAdj) state.seasonAdj = 100.0 + if (!state.weekseasonAdj) state.weekseasonAdj = 0 + setSeason() +} + +//get day of week integer +int getWeekDay(day) +{ + def weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + def mapDay = [Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7] + if(day && weekdays.contains(day)) { + return mapDay.get(day).toInteger() + } + def today = new Date().format('EEEE', location.timeZone) + return mapDay.get(today).toInteger() +} + +// Get string of run days from dpwMap +private String getRunDays(day1,day2,day3,day4,day5,day6,day7) +{ + String str = '' + if(day1) str += 'M' + if(day2) str += 'T' + if(day3) str += 'W' + if(day4) str += 'Th' + if(day5) str += 'F' + if(day6) str += 'Sa' + if(day7) str += 'Su' + if(str == '') str = '0 Days/week' + return str +} + +//start manual schedule +def manualStart(evt){ + boolean running = attemptRecovery() // clean up if prior run crashed + + if (settings.enableManual && !running && (settings.switches.currentStatus != 'pause')){ + if (settings.sync && ( (settings.sync.currentSwitch != 'off') || settings.sync.currentStatus == 'pause') ) { + note('skipping', "${app.label}: Manual run aborted, ${settings.sync.displayName} appears to be busy", 'a') + } + else { + def runNowMap = [] + runNowMap = cycleLoop(0) + + if (runNowMap) { + atomicState.run = true + settings.switches.programWait() + subscribe(settings.switches, 'switch.off', cycleOff) + + runIn(60, cycleOn) // start water program + + // note that manual DOES abide by waterStoppers (if configured) + String newString = '' + int tt = state.totalTime + if (tt) { + int hours = tt / 60 // DON'T Math.round this one + int mins = tt - (hours * 60) + String hourString = '' + String s = '' + if (hours > 1) s = 's' + if (hours > 0) hourString = "${hours} hour${s} & " + s = 's' + if (mins == 1) s = '' + newString = "run time: ${hourString}${mins} minute${s}:\n" + } + + note('active', "${app.label}: Manual run, watering in 1 minute: ${newString}${runNowMap}", 'd') + } + else note('skipping', "${app.label}: Manual run failed, check configuration", 'a') + } + } + else note('skipping', "${app.label}: Manual run aborted, ${settings.switches.displayName} appears to be busy", 'a') +} + +//true if another schedule is running +boolean busy(){ + // Check if we are already running, crashed or somebody changed the schedule time while this schedule is running + if (atomicState.run){ + if (!attemptRecovery()) { // recovery will clean out any prior crashes and correct state of atomicState.run + return false // (atomicState.run = false) + } + else { + // don't change the current status, in case the currently running schedule is in off/paused mode + note(settings.switches.currentStatus, "${app.label}: Already running, skipping additional start", 'i') + return true + } + } + // Not already running... + + // Moved from cycleOn() - don't even start pre-check until the other controller completes its cycle + if (settings.sync) { + if ((settings.sync.currentSwitch != 'off') || settings.sync.currentStatus == 'pause') { + subscribe(settings.sync, 'switch.off', syncOn) + + note('delayed', "${app.label}: Waiting for ${settings.sync.displayName} to complete before starting", 'c') + return true + } + } + + // Check that the controller isn't paused while running some other schedule + def csw = settings.switches.currentSwitch + def cst = settings.switches.currentStatus + + if ((csw == 'off') && (cst != 'pause')) { // off && !paused: controller is NOT in use + log.debug "switches ${csw}, status ${cst} (1st)" + resetEverything() // get back to the start state + return false + } + + if (isDay()) { // Yup, we need to run today, so wait for the other schedule to finish + log.debug "switches ${csw}, status ${cst} (3rd)" + resetEverything() + subscribe(settings.switches, 'switch.off', busyOff) + note('delayed', "${app.label}: Waiting for currently running schedule to complete before starting", 'c') + return true + } + + // Somthing is running, but we don't need to run today anyway - don't need to do busyOff() + // (Probably should never get here, because preCheck() should check isDay() before calling busy() + log.debug "Another schedule is running, but ${app.label} is not scheduled for today anyway" + return true +} + +def busyOff(evt){ + def cst = settings.switches.currentStatus + if ((settings.switches.currentSwitch == 'off') && (cst != 'pause')) { // double check that prior schedule is done + unsubscribe(switches) // we don't want any more button pushes until preCheck runs + Random rand = new Random() // just in case there are multiple schedules waiting on the same controller + int randomSeconds = rand.nextInt(120) + 15 + runIn(randomSeconds, preCheck) // no message so we don't clog the system + note('active', "${app.label}: ${settings.switches} finished, starting in ${randomSeconds} seconds", 'i') + } +} + +//run check every day +def preCheck() { + + if (!isDay()) { + log.debug "preCheck() Skipping: ${app.label} is not scheduled for today" // silent - no note + //if (!atomicState.run && enableManual) subscribe(switches, 'switch.programOn', manualStart) // only if we aren't running already + return + } + + if (!busy()) { + atomicState.run = true // set true before doing anything, atomic in case we crash (busy() set it false if !busy) + settings.switches.programWait() // take over the controller so other schedules don't mess with us + runIn(45, checkRunMap) // schedule checkRunMap() before doing weather check, gives isWeather 45s to complete + // because that seems to be a little more than the max that the ST platform allows + unsubAllBut() // unsubscribe to everything except appTouch() + subscribe(settings.switches, 'switch.off', cycleOff) // and start setting up for today's cycle + def start = now() + note('active', "${app.label}: Starting...", 'd') // + def end = now() + log.debug "preCheck note active ${end - start}ms" + + if (isWeather()) { // set adjustments and check if we shold skip because of rain + resetEverything() // if so, clean up our subscriptions + switches.programOff() // and release the controller + } + else { + log.debug 'preCheck(): running checkRunMap in 2 seconds' //COOL! We finished before timing out, and we're supposed to water today + runIn(2, checkRunMap) // jack the schedule so it runs sooner! + } + } +} + +//start water program +def cycleOn(){ + if (atomicState.run) { // block if manually stopped during precheck which goes to cycleOff + + if (!isWaterStopped()) { // make sure ALL the contacts and toggles aren't paused + // All clear, let's start running! + subscribe(settings.switches, 'switch.off', cycleOff) + subWaterStop() // subscribe to all the pause contacts and toggles + resume() + + // send the notification AFTER we start the controller (in case note() causes us to run over our execution time limit) + String newString = "${app.label}: Starting..." + if (!atomicState.startTime) { + atomicState.startTime = now() // if we haven't already started + if (atomicState.startTime) atomicState.finishTime = null // so recovery in busy() knows we didn't finish + if (state.pauseTime) state.pauseTime = null + if (state.totalTime) { + String finishTime = new Date(now() + (60000 * state.totalTime).toLong()).format('EE @ h:mm a', location.timeZone) + newString = "${app.label}: Starting - ETC: ${finishTime}" + } + } + else if (state.pauseTime) { // resuming after a pause + + def elapsedTime = Math.round((now() - state.pauseTime) / 60000) // convert ms to minutes + int tt = state.totalTime + elapsedTime + 1 + state.totalTime = tt // keep track of the pauses, and the 1 minute delay above + String finishTime = new Date(atomicState.startTime + (60000 * tt).toLong()).format('EE @ h:mm a', location.timeZone) + state.pauseTime = null + newString = "${app.label}: Resuming - New ETC: ${finishTime}" + } + note('active', newString, 'd') + } + else { + // Ready to run, but one of the control contacts is still open, so we wait + subWaterStart() // one of them is paused, let's wait until the are all clear! + note('pause', "${app.label}: Watering paused, ${getWaterStopList()}", 'c') + } + } +} + +//when switch reports off, watering program is finished +def cycleOff(evt){ + + if (atomicState.run) { + def ft = new Date() + atomicState.finishTime = ft // this is important to reset the schedule after failures in busy() + String finishTime = ft.format('h:mm a', location.timeZone) + note('finished', "${app.label}: Finished watering at ${finishTime}", 'd') + } + else { + log.debug "${settings.switches} turned off" // is this a manual off? perhaps we should send a note? + } + resetEverything() // all done here, back to starting state +} + +//run check each day at scheduled time +def checkRunMap(){ + + //check if isWeather returned true or false before checking + if (atomicState.run) { + + //get & set watering times for today + def runNowMap = [] + runNowMap = cycleLoop(1) // build the map + + if (runNowMap) { + runIn(60, cycleOn) // start water + subscribe(settings.switches, 'switch.off', cycleOff) // allow manual off before cycleOn() starts + if (atomicState.startTime) atomicState.startTime = null // these were already cleared in cycleLoop() above + if (state.pauseTime) state.pauseTime = null // ditto + // leave atomicState.finishTime alone so that recovery in busy() knows we never started if cycleOn() doesn't clear it + + String newString = '' + int tt = state.totalTime + if (tt) { + int hours = tt / 60 // DON'T Math.round this one + int mins = tt - (hours * 60) + String hourString = '' + String s = '' + if (hours > 1) s = 's' + if (hours > 0) hourString = "${hours} hour${s} & " + s = 's' + if (mins == 1) s = '' + newString = "run time: ${hourString}${mins} minute${s}:\n" + } + note('active', "${app.label}: Watering in 1 minute, ${newString}${runNowMap}", 'd') + } + else { + unsubscribe(settings.switches) + unsubWaterStoppers() + switches.programOff() + if (enableManual) subscribe(settings.switches, 'switch.programOn', manualStart) + note('skipping', "${app.label}: No watering today", 'd') + if (atomicState.run) atomicState.run = false // do this last, so that the above note gets sent to the controller + } + } + else { + log.debug 'checkRunMap(): atomicState.run = false' // isWeather cancelled us out before we got started + } +} + +//get todays schedule +def cycleLoop(int i) +{ + boolean isDebug = false + if (isDebug) log.debug "cycleLoop(${i})" + + int zone = 1 + int dpw = 0 + int tpw = 0 + int cyc = 0 + int rtime = 0 + def timeMap = [:] + def pumpMap = "" + def runNowMap = "" + String soilString = '' + int totalCycles = 0 + int totalTime = 0 + if (atomicState.startTime) atomicState.startTime = null // haven't started yet + + while(zone <= 16) + { + rtime = 0 + def setZ = settings."zone${zone}" + if ((setZ && (setZ != 'Off')) && (nozzle(zone) != 4) && zoneActive(zone.toString())) { + + // First check if we run this zone today, use either dpwMap or even/odd date + dpw = getDPW(zone) + int runToday = 0 + // if manual, or every day allowed, or zone uses a sensor, then we assume we can today + // - preCheck() has already verified that today isDay() + if ((i == 0) || (state.daysAvailable == 7) || (settings."sensor${zone}")) { + runToday = 1 + } + else { + + dpw = getDPW(zone) // figure out if we need to run (if we don't already know we do) + if (settings.days && (settings.days.contains('Even') || settings.days.contains('Odd'))) { + def daynum = new Date().format('dd', location.timeZone) + int dayint = Integer.parseInt(daynum) + if (settings.days.contains('Odd') && (((dayint +1) % Math.round(31 / (dpw * 4))) == 0)) runToday = 1 + else if (settings.days.contains('Even') && ((dayint % Math.round(31 / (dpw * 4))) == 0)) runToday = 1 + } + else { + int weekDay = getWeekDay()-1 + def dpwMap = getDPWDays(dpw) + runToday = dpwMap[weekDay] //1 or 0 + if (isDebug) log.debug "Zone: ${zone} dpw: ${dpw} weekDay: ${weekDay} dpwMap: ${dpwMap} runToday: ${runToday}" + + } + } + + // OK, we're supposed to run (or at least adjust the sensors) + if (runToday == 1) + { + def soil + if (i == 0) soil = moisture(0) // manual + else soil = moisture(zone) // moisture check + soilString = "${soilString}${soil[1]}" + + // Run this zone if soil moisture needed + if ( soil[0] == 1 ) + { + cyc = cycles(zone) + tpw = getTPW(zone) + dpw = getDPW(zone) // moisture() may have changed DPW + + rtime = calcRunTime(tpw, dpw) + //daily weather adjust if no sensor + if(settings.isSeason && (!settings.learn || !settings."sensor${zone}")) { + + + rtime = Math.round(((rtime / cyc) * (state.seasonAdj / 100.0)) + 0.4) + } + else { + rtime = Math.round((rtime / cyc) + 0.4) // let moisture handle the seasonAdjust for Adaptive (learn) zones + } + totalCycles += cyc + totalTime += (rtime * cyc) + runNowMap += "${settings."name${zone}"}: ${cyc} x ${rtime} min\n" + if (isDebug) log.debug "Zone ${zone} Map: ${cyc} x ${rtime} min - totalTime: ${totalTime}" + } + } + } + if (nozzle(zone) == 4) pumpMap += "${settings."name${zone}"}: ${settings."zone${zone}"} on\n" + timeMap."${zone+1}" = "${rtime}" + zone++ + } + + if (soilString) { + String seasonStr = '' + String plus = '' + float sa = state.seasonAdj + if (settings.isSeason && (sa != 100.0) && (sa != 0.0)) { + float sadj = sa - 100.0 + if (sadj > 0.0) plus = '+' //display once in cycleLoop() + int iadj = Math.round(sadj) + if (iadj != 0) seasonStr = "Adjusting ${plus}${iadj}% for weather forecast\n" + } + note('moisture', "${app.label} Sensor status:\n${seasonStr}${soilString}" /* + seasonStr + soilString */,'m') + } + + if (!runNowMap) { + return runNowMap // nothing to run today + } + + //send settings to Spruce Controller + switches.settingsMap(timeMap,4002) + runIn(30, writeCycles) + + // meanwhile, calculate our total run time + int pDelay = 0 + if (settings.pumpDelay && settings.pumpDelay.isNumber()) pDelay = settings.pumpDelay.toInteger() + totalTime += Math.round(((pDelay * (totalCycles-1)) / 60.0)) // add in the pump startup and inter-zone delays + state.totalTime = totalTime + + if (state.pauseTime) state.pauseTime = null // and we haven't paused yet + // but let cycleOn() reset finishTime + return (runNowMap + pumpMap) +} + +//send cycle settings +def writeCycles(){ + //log.trace "writeCycles()" + def cyclesMap = [:] + //add pumpdelay @ 1 + cyclesMap."1" = pumpDelayString() + int zone = 1 + int cycle = 0 + while(zone <= 17) + { + if(nozzle(zone) == 4) cycle = 4 + else cycle = cycles(zone) + //offset by 1, due to pumpdelay @ 1 + cyclesMap."${zone+1}" = "${cycle}" + zone++ + } + switches.settingsMap(cyclesMap, 4001) +} + +def resume(){ + log.debug 'resume()' + settings.switches.zon() +} + +def syncOn(evt){ + // double check that the switch is actually finished and not just paused + if ((settings.sync.currentSwitch == 'off') && (settings.sync.currentStatus != 'pause')) { + resetEverything() // back to our known state + Random rand = new Random() // just in case there are multiple schedules waiting on the same controller + int randomSeconds = rand.nextInt(120) + 15 + runIn(randomSeconds, preCheck) // no message so we don't clog the system + note('schedule', "${app.label}: ${settings.sync} finished, starting in ${randomSeconds} seconds", 'c') + } // else, it is just pausing...keep waiting for the next "off" +} + +// handle start of pause session +def waterStop(evt){ + log.debug "waterStop: ${evt.displayName}" + + unschedule(cycleOn) // in case we got stopped again before cycleOn starts from the restart + unsubscribe(settings.switches) + subWaterStart() + + if (!state.pauseTime) { // only need to do this for the first event if multiple contacts + state.pauseTime = now() + + String cond = evt.value + switch (cond) { + case 'open': + cond = 'opened' + break + case 'on': + cond = 'switched on' + break + case 'off': + cond = 'switched off' + break + //case 'closed': + // cond = 'closed' + // break + case null: + cond = '????' + break + default: + break + } + note('pause', "${app.label}: Watering paused - ${evt.displayName} ${cond}", 'c') // set to Paused + } + if (settings.switches.currentSwitch != 'off') { + runIn(30, subOff) + settings.switches.off() // stop the water + } + else + subscribe(settings.switches, 'switch.off', cycleOff) +} + +// This is a hack to work around the delay in response from the controller to the above programOff command... +// We frequently see the off notification coming a long time after the command is issued, so we try to catch that so that +// we don't prematurely exit the cycle. +def subOff() { + subscribe(settings.switches, 'switch.off', offPauseCheck) +} + +def offPauseCheck( evt ) { + unsubscribe(settings.switches) + subscribe(settings.switches, 'switch.off', cycleOff) + if (/*(switches.currentSwitch != 'off') && */ (settings.switches.currentStatus != 'pause')) { // eat the first off while paused + cycleOff(evt) + } +} + +// handle end of pause session +def waterStart(evt){ + if (!isWaterStopped()){ // only if ALL of the selected contacts are not open + def cDelay = 10 + if (settings.contactDelay > 10) cDelay = settings.contactDelay + runIn(cDelay, cycleOn) + + unsubscribe(settings.switches) + subWaterStop() // allow stopping again while we wait for cycleOn to start + + log.debug "waterStart(): enabling device is ${evt.device} ${evt.value}" + + String cond = evt.value + switch (cond) { + case 'open': + cond = 'opened' + break + case 'on': + cond = 'switched on' + break + case 'off': + cond = 'switched off' + break + //case 'closed': + // cond = 'closed' + // break + case null: + cond = '????' + break + default: + break + } + // let cycleOn() change the status to Active - keep us paused until then + + note('pause', "${app.label}: ${evt.displayName} ${cond}, watering in ${cDelay} seconds", 'c') + } + else { + log.debug "waterStart(): one down - ${evt.displayName}" + } +} + +//Initialize Days per week, based on TPW, perDay and daysAvailable settings +int initDPW(int zone){ + //log.debug "initDPW(${zone})" + if(!state.dpwMap) state.dpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + + int tpw = getTPW(zone) // was getTPW -does not update times in scheduler without initTPW + int dpw = 0 + + if(tpw > 0) { + float perDay = 20.0 + if(settings."perDay${zone}") perDay = settings."perDay${zone}".toFloat() + + dpw = Math.round(tpw.toFloat() / perDay) + if(dpw <= 1) dpw = 1 + // 3 days per week not allowed for even or odd day selection + if(dpw == 3 && days && (days.contains('Even') || days.contains('Odd')) && !(days.contains('Even') && days.contains('Odd'))) + if((tpw.toFloat() / perDay) < 3.0) dpw = 2 else dpw = 4 + int daycheck = daysAvailable() // initialize & optimize daysAvailable + if (daycheck < dpw) dpw = daycheck + } + state.dpwMap[zone-1] = dpw + return dpw +} + +// Get current days per week value, calls init if not defined +int getDPW(int zone) { + if (state.dpwMap) return state.dpwMap[zone-1] else return initDPW(zone) +} + +//Initialize Time per Week +int initTPW(int zone) { + //log.trace "initTPW(${zone})" + if (!state.tpwMap) state.tpwMap = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + + int n = nozzle(zone) + def zn = settings."zone${zone}" + if (!zn || (zn == 'Off') || (n == 0) || (n == 4) || (plant(zone) == 0) || !zoneActive(zone.toString())) return 0 + + // apply gain adjustment + float gainAdjust = 100.0 + if (settings.gain && settings.gain != 0) gainAdjust += settings.gain + + // apply seasonal adjustment if enabled and not set to new plants + float seasonAdjust = 100.0 + def wsa = state.weekseasonAdj + if (wsa && isSeason && (settings."plant${zone}" != 'New Plants')) seasonAdjust = wsa + + int tpw = 0 + // Use learned, previous tpw if it is available + if ( settings."sensor${zone}" ) { + seasonAdjust = 100.0 // no weekly seasonAdjust if this zone uses a sensor + if(state.tpwMap && settings.learn) tpw = state.tpwMap[zone-1] + } + + // set user-specified minimum time with seasonal adjust + int minWeek = 0 + def mw = settings."minWeek${zone}" + if (mw) minWeek = mw.toInteger() + if (minWeek != 0) { + tpw = Math.round(minWeek * (seasonAdjust / 100.0)) + } + else if (!tpw || (tpw == 0)) { // use calculated tpw + tpw = Math.round((plant(zone) * nozzle(zone) * (gainAdjust / 100.0) * (seasonAdjust / 100.0))) + } + state.tpwMap[zone-1] = tpw + return tpw +} + +// Get the current time per week, calls init if not defined +int getTPW(int zone) +{ + if (state.tpwMap) return state.tpwMap[zone-1] else return initTPW(zone) +} + +// Calculate daily run time based on tpw and dpw +int calcRunTime(int tpw, int dpw) +{ + int duration = 0 + if ((tpw > 0) && (dpw > 0)) duration = Math.round(tpw.toFloat() / dpw.toFloat()) + return duration +} + +// Check the moisture level of a zone returning dry (1) or wet (0) and adjust tpw if overly dry/wet +def moisture(int i) +{ + boolean isDebug = false + if (isDebug) log.debug "moisture(${i})" + + def endMsecs = 0 + // No Sensor on this zone or manual start skips moisture checking altogether + if ((i == 0) || !settings."sensor${i}") { + return [1,''] + } + + // Ensure that the sensor has reported within last 48 hours + int spHum = getDrySp(i) + int hours = 48 + def yesterday = new Date(now() - (/* 1000 * 60 * 60 */ 3600000 * hours).toLong()) + float latestHum = settings."sensor${i}".latestValue('humidity').toFloat() // state = 29, value = 29.13 + def lastHumDate = settings."sensor${i}".latestState('humidity').date + if (lastHumDate < yesterday) { + note('warning', "${app.label}: Please check sensor ${settings."sensor${i}"}, no humidity reports in the last ${hours} hours", 'a') + + if (latestHum < spHum) + latestHum = spHum - 1.0 // amke sure we water and do seasonal adjustments, but not tpw adjustments + else + latestHum = spHum + 0.99 // make sure we don't water, do seasonal adjustments, but not tpw adjustments + } + + if (!settings.learn) + { + // in Delay mode, only looks at target moisture level, doesn't try to adjust tpw + // (Temporary) seasonal adjustment WILL be applied in cycleLoop(), as if we didn't have a sensor + if (latestHum <= spHum.toFloat()) { + //dry soil + return [1,"${settings."name${i}"}, Watering: ${settings."sensor${i}"} reads ${latestHum}%, SP is ${spHum}%\n"] + } + else { + //wet soil + return [0,"${settings."name${i}"}, Skipping: ${settings."sensor${i}"} reads ${latestHum}%, SP is ${spHum}%\n"] + } + } + + //in Adaptive mode + int tpw = getTPW(i) + int dpw = getDPW(i) + int cpd = cycles(i) + + + + + if (isDebug) log.debug "moisture(${i}): tpw: ${tpw}, dpw: ${dpw}, cycles: ${cpd} (before adjustment)" + + float diffHum = 0.0 + if (latestHum > 0.0) diffHum = (spHum - latestHum) / 100.0 + else { + diffHum = 0.02 // Safety valve in case sensor is reporting 0% humidity (e.g., somebody pulled it out of the ground or flower pot) + note('warning', "${app.label}: Please check sensor ${settings."sensor${i}"}, it is currently reading 0%", 'a') + } + + int daysA = state.daysAvailable + int minimum = cpd * dpw // minimum of 1 minute per scheduled days per week (note - can be 1*1=1) + if (minimum < daysA) minimum = daysA // but at least 1 minute per available day + int tpwAdjust = 0 + + if (diffHum > 0.01) { // only adjust tpw if more than 1% of target SP + tpwAdjust = Math.round(((tpw * diffHum) + 0.5) * dpw * cpd) // Compute adjustment as a function of the current tpw + float adjFactor = 2.0 / daysA // Limit adjustments to 200% per week - spread over available days + if (tpwAdjust > (tpw * adjFactor)) tpwAdjust = Math.round((tpw * adjFactor) + 0.5) // limit fast rise + if (tpwAdjust < minimum) tpwAdjust = minimum // but we need to move at least 1 minute per cycle per day to actually increase the watering time + } else if (diffHum < -0.01) { + if (diffHum < -0.05) diffHum = -0.05 // try not to over-compensate for a heavy rainstorm... + tpwAdjust = Math.round(((tpw * diffHum) - 0.5) * dpw * cpd) + float adjFactor = -0.6667 / daysA // Limit adjustments to 66% per week + if (tpwAdjust < (tpw * adjFactor)) tpwAdjust = Math.round((tpw * adjFactor) - 0.5) // limit slow decay + if (tpwAdjust > (-1 * minimum)) tpwAdjust = -1 * minimum // but we need to move at least 1 minute per cycle per day to actually increase the watering time + } + + int seasonAdjust = 0 + if (isSeason) { + float sa = state.seasonAdj + if ((sa != 100.0) && (sa != 0.0)) { + float sadj = sa - 100.0 + if (sa > 0.0) + seasonAdjust = Math.round(((sadj / 100.0) * tpw) + 0.5) + else + seasonAdjust = Math.round(((sadj / 100.0) * tpw) - 0.5) + } + } + if (isDebug) log.debug "moisture(${i}): diffHum: ${diffHum}, tpwAdjust: ${tpwAdjust} seasonAdjust: ${seasonAdjust}" + + // Now, adjust the tpw. + // With seasonal adjustments enabled, tpw can go up or down independent of the difference in the sensor vs SP + int newTPW = tpw + tpwAdjust + seasonAdjust + + int perDay = 20 + def perD = settings."perDay${i}" + if (perD) perDay = perD.toInteger() + if (perDay == 0) perDay = daysA * cpd // at least 1 minute per cycle per available day + if (newTPW < perDay) newTPW = perDay // make sure we have always have enough for 1 day of minimum water + + int adjusted = 0 + if ((tpwAdjust + seasonAdjust) > 0) { // needs more water + int maxTPW = daysA * 120 // arbitrary maximum of 2 hours per available watering day per week + if (newTPW > maxTPW) newTPW = maxTPW // initDPW() below may spread this across more days + if (newTPW > (maxTPW * 0.75)) note('warning', "${app.label}: Please check ${settings["sensor${i}"]}, ${settings."name${i}"} time per week seems high: ${newTPW} mins/week",'a') + if (state.tpwMap[i-1] != newTPW) { // are we changing the tpw? + state.tpwMap[i-1] = newTPW + dpw = initDPW(i) // need to recalculate days per week since tpw changed - initDPW() stores the value into dpwMap + adjusted = newTPW - tpw // so that the adjustment note is accurate + } + } + else if ((tpwAdjust + seasonAdjust) < 0) { // Needs less water + // Find the minimum tpw + minimum = cpd * daysA // at least 1 minute per cycle per available day + int minLimit = 0 + def minL = settings."minWeek${i}" + if (minL) minLimit = minL.toInteger() // unless otherwise specified in configuration + if (minLimit > 0) { + if (newTPW < minLimit) newTPW = minLimit // use configured minutes per week as the minimum + } else if (newTPW < minimum) { + newTPW = minimum // else at least 1 minute per cycle per available day + note('warning', "${app.label}: Please check ${settings."sensor${i}"}, ${settings."name${i}"} time per week is very low: ${newTPW} mins/week",'a') + } + if (state.tpwMap[i-1] != newTPW) { // are we changing the tpw? + state.tpwMap[i-1] = newTPW // store the new tpw + dpw = initDPW(i) // may need to reclac days per week - initDPW() now stores the value into state.dpwMap - avoid doing that twice + adjusted = newTPW - tpw // so that the adjustment note is accurate + } + } + // else no adjustments, or adjustments cancelled each other out. + + String moistureSum = '' + String adjStr = '' + String plus = '' + if (adjusted > 0) plus = '+' + if (adjusted != 0) adjStr = ", ${plus}${adjusted} min" + if (Math.abs(adjusted) > 1) adjStr = "${adjStr}s" + if (diffHum >= 0.0) { // water only if ground is drier than SP + moistureSum = "> ${settings."name${i}"}, Water: ${settings."sensor${i}"} @ ${latestHum}% (${spHum}%)${adjStr} (${newTPW} min/wk)\n" + return [1, moistureSum] + } + else { // not watering + moistureSum = "> ${settings."name${i}"}, Skip: ${settings."sensor${i}"} @ ${latestHum}% (${spHum}%)${adjStr} (${newTPW} min/wk)\n" + return [0, moistureSum] + } + return [0, moistureSum] +} + +//get moisture SP +int getDrySp(int i){ + if (settings."sensorSp${i}") return settings."sensorSp${i}".toInteger() // configured SP + + + if (settings."plant${i}" == 'New Plants') return 40 // New Plants get special care + + + switch (settings."option${i}") { // else, defaults based off of soil type + case 'Sand': + return 22 + case 'Clay': + return 38 + default: + return 28 + } +} + +//notifications to device, pushed if requested +def note(String statStr, String msg, String msgType) { + + // send to debug first (near-zero cost) + log.debug "${statStr}: ${msg}" + + // notify user second (small cost) + boolean notifyController = true + if(settings.notify || settings.logAll) { + String spruceMsg = "Spruce ${msg}" + switch(msgType) { + case 'd': + if (settings.notify && settings.notify.contains('Daily')) { // always log the daily events to the controller + sendIt(spruceMsg) + } + else if (settings.logAll) { + sendNotificationEvent(spruceMsg) + } + break + case 'c': + if (settings.notify && settings.notify.contains('Delays')) { + sendIt(spruceMsg) + } + else if (settings.logAll) { + sendNotificationEvent(spruceMsg) + } + break + case 'i': + if (settings.notify && settings.notify.contains('Events')) { + sendIt(spruceMsg) + //notifyController = false // no need to notify controller unless we don't notify the user + } + else if (settings.logAll) { + sendNotificationEvent(spruceMsg) + } + break + case 'f': + notifyController = false // no need to notify the controller, ever + if (settings.notify && settings.notify.contains('Weather')) { + sendIt(spruceMsg) + } + else if (settings.logAll) { + sendNotificationEvent(spruceMsg) + } + break + case 'a': + notifyController = false // no need to notify the controller, ever + if (settings.notify && settings.notify.contains('Warnings')) { + sendIt(spruceMsg) + } else + sendNotificationEvent(spruceMsg) // Special case - make sure this goes into the Hello Home log, if not notifying + break + case 'm': + if (settings.notify && settings.notify.contains('Moisture')) { + sendIt(spruceMsg) + //notifyController = false // no need to notify controller unless we don't notify the user + } + else if (settings.logAll) { + sendNotificationEvent(spruceMsg) + } + break + default: + break + } + } + // finally, send to controller DTH, to change the state and to log important stuff in the event log + if (notifyController) { // do we really need to send these to the controller? + // only send status updates to the controller if WE are running, or nobody else is + if (atomicState.run || ((settings.switches.currentSwitch == 'off') && (settings.switches.currentStatus != 'pause'))) { + settings.switches.notify(statStr, msg) + + } + else { // we aren't running, so we don't want to change the status of the controller + // send the event using the current status of the switch, so we don't change it + //log.debug "note - direct sendEvent()" + settings.switches.notify(settings.switches.currentStatus, msg) + + } + } +} + +def sendIt(String msg) { + if (location.contactBookEnabled && settings.recipients) { + sendNotificationToContacts(msg, settings.recipients, [event: true]) + } + else { + sendPush( msg ) + } +} + +//days available +int daysAvailable(){ + + // Calculate days available for watering and save in state variable for future use + def daysA = state.daysAvailable + if (daysA && (daysA > 0)) { // state.daysAvailable has already calculated and stored in state.daysAvailable + return daysA + } + + if (!settings.days) { // settings.days = "" --> every day is available + state.daysAvailable = 7 + return 7 // every day is allowed + } + + int dayCount = 0 // settings.days specified, need to calculate state.davsAvailable (once) + if (settings.days.contains('Even') || settings.days.contains('Odd')) { + dayCount = 4 + if(settings.days.contains('Even') && settings.days.contains('Odd')) dayCount = 7 + } + else { + if (settings.days.contains('Monday')) dayCount += 1 + if (settings.days.contains('Tuesday')) dayCount += 1 + if (settings.days.contains('Wednesday')) dayCount += 1 + if (settings.days.contains('Thursday')) dayCount += 1 + if (settings.days.contains('Friday')) dayCount += 1 + if (settings.days.contains('Saturday')) dayCount += 1 + if (settings.days.contains('Sunday')) dayCount += 1 + } + + state.daysAvailable = dayCount + return dayCount +} + +//zone: ['Off', 'Spray', 'rotor', 'Drip', 'Master Valve', 'Pump'] +int nozzle(int i){ + String getT = settings."zone${i}" + if (!getT) return 0 + + switch(getT) { + case 'Spray': + return 1 + case 'Rotor': + return 1.4 + case 'Drip': + return 2.4 + case 'Master Valve': + return 4 + case 'Pump': + return 4 + default: + return 0 + } +} + +//plant: ['Lawn', 'Garden', 'Flowers', 'Shrubs', 'Trees', 'Xeriscape', 'New Plants'] +int plant(int i){ + String getP = settings."plant${i}" + if(!getP) return 0 + + switch(getP) { + case 'Lawn': + return 60 + case 'Garden': + return 50 + case 'Flowers': + return 40 + case 'Shrubs': + return 30 + case 'Trees': + return 20 + case 'Xeriscape': + return 30 + case 'New Plants': + return 80 + default: + return 0 + } +} + +//option: ['Slope', 'Sand', 'Clay', 'No Cycle', 'Cycle 2x', 'Cycle 3x'] +int cycles(int i){ + String getC = settings."option${i}" + if(!getC) return 2 + + switch(getC) { + case 'Slope': + return 3 + case 'Sand': + return 1 + case 'Clay': + return 2 + case 'No Cycle': + return 1 + case 'Cycle 2x': + return 2 + case 'Cycle 3x': + return 3 + default: + return 2 + } +} + +//check if day is allowed +boolean isDay() { + + if (daysAvailable() == 7) return true // every day is allowed + + def daynow = new Date() + String today = daynow.format('EEEE', location.timeZone) + if (settings.days.contains(today)) return true + + def daynum = daynow.format('dd', location.timeZone) + int dayint = Integer.parseInt(daynum) + if (settings.days.contains('Even') && (dayint % 2 == 0)) return true + if (settings.days.contains('Odd') && (dayint % 2 != 0)) return true + return false +} + +//set season adjustment & remove season adjustment +def setSeason() { + boolean isDebug = false + if (isDebug) log.debug 'setSeason()' + + int zone = 1 + while(zone <= 16) { + if ( !settings.learn || !settings."sensor${zone}" || state.tpwMap[zone-1] == 0) { + + int tpw = initTPW(zone) // now updates state.tpwMap + int dpw = initDPW(zone) // now updates state.dpwMap + if (isDebug) { + if (!settings.learn && (tpw != 0) && (state.weekseasonAdj != 0)) { + log.debug "Zone ${zone}: seasonally adjusted by ${state.weekseasonAdj-100}% to ${tpw}" + } + } + } + zone++ + } +} + +//capture today's total rainfall - scheduled for just before midnight each day +def getRainToday() { + def wzipcode = zipString() + Map wdata = getWeatherFeature('conditions', wzipcode) + if (!wdata) { + + note('warning', "${app.label}: Please check Zipcode/PWS setting, error: null", 'a') + } + else { + if (!wdata.response || wdata.response.containsKey('error')) { + log.debug wdata.response + note('warning', "${app.label}: Please check Zipcode/PWS setting, error:\n${wdata.response.error.type}: ${wdata.response.error.description}" , 'a') + } + else { + float TRain = 0.0 + if (wdata.current_observation.precip_today_in.isNumber()) { // WU can return "t" for "Trace" - we'll assume that means 0.0 + TRain = wdata.current_observation.precip_today_in.toFloat() + if (TRain > 25.0) TRain = 25.0 + else if (TRain < 0.0) TRain = 0.0 // WU sometimes returns -999 for "estimated" locations + log.debug "getRainToday(): ${wdata.current_observation.precip_today_in} / ${TRain}" + } + int day = getWeekDay() // what day is it today? + if (day == 7) day = 0 // adjust: state.Rain order is Su,Mo,Tu,We,Th,Fr,Sa + state.Rain[day] = TRain as Float // store today's total rainfall + } + } +} + +//check weather, set seasonal adjustment factors, skip today if rainy +boolean isWeather(){ + def startMsecs = 0 + def endMsecs = 0 + boolean isDebug = false + if (isDebug) log.debug 'isWeather()' + + if (!settings.isRain && !settings.isSeason) return false // no need to do any of this + + String wzipcode = zipString() + if (isDebug) log.debug "isWeather(): ${wzipcode}" + + // get only the data we need + // Moved geolookup to installSchedule() + String featureString = 'forecast/conditions' + if (settings.isSeason) featureString = "${featureString}/astronomy" + if (isDebug) startMsecs= now() + Map wdata = getWeatherFeature(featureString, wzipcode) + if (isDebug) { + endMsecs = now() + log.debug "isWeather() getWeatherFeature elapsed time: ${endMsecs - startMsecs}ms" + } + if (wdata && wdata.response) { + if (isDebug) log.debug wdata.response + if (wdata.response.containsKey('error')) { + if (wdata.response.error.type != 'invalidfeature') { + note('warning', "${app.label}: Please check Zipcode/PWS setting, error:\n${wdata.response.error.type}: ${wdata.response.error.description}" , 'a') + return false + } + else { + // Will find out which one(s) weren't reported later (probably never happens now that we don't ask for history) + log.debug 'Rate limited...one or more WU features unavailable at this time.' + } + } + } + else { + if (isDebug) log.debug 'wdata is null' + note('warning', "${app.label}: Please check Zipcode/PWS setting, error: null" , 'a') + return false + } + + String city = wzipcode + + + + if (wdata.current_observation) { + if (wdata.current_observation.observation_location.city != '') city = wdata.current_observation.observation_location.city + else if (wdata.current_observation.observation_location.full != '') city = wdata.current_observation.display_location.full + + if (wdata.current_observation.estimated.estimated) city = "${city} (est)" + } + + // OK, we have good data, let's start the analysis + float qpfTodayIn = 0.0 + float qpfTomIn = 0.0 + float popToday = 50.0 + float popTom = 50.0 + float TRain = 0.0 + float YRain = 0.0 + float weeklyRain = 0.0 + + if (settings.isRain) { + if (isDebug) log.debug 'isWeather(): isRain' + + // Get forecasted rain for today and tomorrow + + if (!wdata.forecast) { + log.debug 'isWeather(): Unable to get weather forecast.' + return false + } + if (wdata.forecast.simpleforecast.forecastday[0].qpf_allday.in.isNumber()) qpfTodayIn = wdata.forecast.simpleforecast.forecastday[0].qpf_allday.in.toFloat() + if (wdata.forecast.simpleforecast.forecastday[0].pop.isNumber()) popToday = wdata.forecast.simpleforecast.forecastday[0].pop.toFloat() + if (wdata.forecast.simpleforecast.forecastday[1].qpf_allday.in.isNumber()) qpfTomIn = wdata.forecast.simpleforecast.forecastday[1].qpf_allday.in.toFloat() + if (wdata.forecast.simpleforecast.forecastday[1].pop.isNumber()) popTom = wdata.forecast.simpleforecast.forecastday[1].pop.toFloat() + if (qpfTodayIn > 25.0) qpfTodayIn = 25.0 + else if (qpfTodayIn < 0.0) qpfTodayIn = 0.0 + if (qpfTomIn > 25.0) qpfTomIn = 25.0 + else if (qpfTomIn < 0.0) qpfTomIn = 0.0 + + // Get rainfall so far today + + if (!wdata.current_observation) { + log.debug 'isWeather(): Unable to get current weather conditions.' + return false + } + if (wdata.current_observation.precip_today_in.isNumber()) { + TRain = wdata.current_observation.precip_today_in.toFloat() + if (TRain > 25.0) TRain = 25.0 // Ignore runaway weather + else if (TRain < 0.0) TRain = 0.0 // WU can return -999 for estimated locations + } + if (TRain > (qpfTodayIn * (popToday / 100.0))) { // Not really what PoP means, but use as an adjustment factor of sorts + qpfTodayIn = TRain // already have more rain than was forecast for today, so use that instead + popToday = 100 // we KNOW this rain happened + } + + // Get yesterday's rainfall + int day = getWeekDay() + YRain = state.Rain[day - 1] + + if (isDebug) log.debug "TRain ${TRain} qpfTodayIn ${qpfTodayIn} @ ${popToday}%, YRain ${YRain}" + + int i = 0 + while (i <= 6){ // calculate (un)weighted average (only heavy rainstorms matter) + int factor = 0 + if ((day - i) > 0) factor = day - i else factor = day + 7 - i + float getrain = state.Rain[i] + if (factor != 0) weeklyRain += (getrain / factor) + i++ + } + + if (isDebug) log.debug "isWeather(): weeklyRain ${weeklyRain}" + } + + if (isDebug) log.debug 'isWeather(): build report' + + //get highs + int highToday = 0 + int highTom = 0 + if (wdata.forecast.simpleforecast.forecastday[0].high.fahrenheit.isNumber()) highToday = wdata.forecast.simpleforecast.forecastday[0].high.fahrenheit.toInteger() + if (wdata.forecast.simpleforecast.forecastday[1].high.fahrenheit.isNumber()) highTom = wdata.forecast.simpleforecast.forecastday[1].high.fahrenheit.toInteger() + + String weatherString = "${app.label}: ${city} weather:\n TDA: ${highToday}F" + if (settings.isRain) weatherString = "${weatherString}, ${qpfTodayIn}in rain (${Math.round(popToday)}% PoP)" + weatherString = "${weatherString}\n TMW: ${highTom}F" + if (settings.isRain) weatherString = "${weatherString}, ${qpfTomIn}in rain (${Math.round(popTom)}% PoP)\n YDA: ${YRain}in rain" + + if (settings.isSeason) + { + if (!settings.isRain) { // we need to verify we have good data first if we didn't do it above + + if (!wdata.forecast) { + log.debug 'Unable to get weather forecast' + return false + } + } + + // is the temp going up or down for the next few days? + float heatAdjust = 100.0 + float avgHigh = highToday.toFloat() + if (highToday != 0) { + // is the temp going up or down for the next few days? + int totalHigh = highToday + int j = 1 + int highs = 1 + while (j < 4) { // get forecasted high for next 3 days + if (wdata.forecast.simpleforecast.forecastday[j].high.fahrenheit.isNumber()) { + totalHigh += wdata.forecast.simpleforecast.forecastday[j].high.fahrenheit.toInteger() + highs++ + } + j++ + } + if ( highs > 0 ) avgHigh = (totalHigh / highs) + heatAdjust = avgHigh / highToday + } + if (isDebug) log.debug "highToday ${highToday}, avgHigh ${avgHigh}, heatAdjust ${heatAdjust}" + + //get humidity + int humToday = 0 + if (wdata.forecast.simpleforecast.forecastday[0].avehumidity.isNumber()) + humToday = wdata.forecast.simpleforecast.forecastday[0].avehumidity.toInteger() + + float humAdjust = 100.0 + float avgHum = humToday.toFloat() + if (humToday != 0) { + int j = 1 + int highs = 1 + int totalHum = humToday + while (j < 4) { // get forcasted humitidty for today and the next 3 days + if (wdata.forecast.simpleforecast.forecastday[j].avehumidity.isNumber()) { + totalHum += wdata.forecast.simpleforecast.forecastday[j].avehumidity.toInteger() + highs++ + } + j++ + } + if (highs > 1) avgHum = totalHum / highs + humAdjust = 1.5 - ((0.5 * avgHum) / humToday) // basically, half of the delta % between today and today+3 days + } + if (isDebug) log.debug "humToday ${humToday}, avgHum ${avgHum}, humAdjust ${humAdjust}" + + //daily adjustment - average of heat and humidity factors + //hotter over next 3 days, more water + //cooler over next 3 days, less water + //drier over next 3 days, more water + //wetter over next 3 days, less water + // + //Note: these should never get to be very large, and work best if allowed to cumulate over time (watering amount will change marginally + // as days get warmer/cooler and drier/wetter) + def sa = ((heatAdjust + humAdjust) / 2) * 100.0 + state.seasonAdj = sa + sa = sa - 100.0 + String plus = '' + if (sa > 0) plus = '+' + weatherString = "${weatherString}\n Adjusting ${plus}${Math.round(sa)}% for weather forecast" + + // Apply seasonal adjustment on Monday each week or at install + if ((getWeekDay() == 1) || (state.weekseasonAdj == 0)) { + //get daylight + + if (wdata.sun_phase) { + int getsunRH = 0 + int getsunRM = 0 + int getsunSH = 0 + int getsunSM = 0 + + if (wdata.sun_phase.sunrise.hour.isNumber()) getsunRH = wdata.sun_phase.sunrise.hour.toInteger() + if (wdata.sun_phase.sunrise.minute.isNumber()) getsunRM = wdata.sun_phase.sunrise.minute.toInteger() + if (wdata.sun_phase.sunset.hour.isNumber()) getsunSH = wdata.sun_phase.sunset.hour.toInteger() + if (wdata.sun_phase.sunset.minute.isNumber()) getsunSM = wdata.sun_phase.sunset.minute.toInteger() + + int daylight = ((getsunSH * 60) + getsunSM)-((getsunRH * 60) + getsunRM) + if (daylight >= 850) daylight = 850 + + //set seasonal adjustment + //seasonal q (fudge) factor + float qFact = 75.0 + + // (Daylight / 11.66 hours) * ( Average of ((Avg Temp / 70F) + ((1/2 of Average Humidity) / 65.46))) * calibration quotient + // Longer days = more water (day length constant = approx USA day length at fall equinox) + // Higher temps = more water + // Lower humidity = more water (humidity constant = USA National Average humidity in July) + float wa = ((daylight / 700.0) * (((avgHigh / 70.0) + (1.5-((avgHum * 0.5) / 65.46))) / 2.0) * qFact) + state.weekseasonAdj = wa + + //apply seasonal time adjustment + plus = '' + if (wa != 0) { + if (wa > 100.0) plus = '+' + String waStr = String.format('%.2f', (wa - 100.0)) + weatherString = "${weatherString}\n Seasonal adjustment of ${waStr}% for the week" + } + setSeason() + } + else { + log.debug 'isWeather(): Unable to get sunrise/set info for today.' + } + } + } + note('season', weatherString , 'f') + + // if only doing seasonal adjustments, we are done + if (!settings.isRain) return false + + float setrainDelay = 0.2 + if (settings.rainDelay) setrainDelay = settings.rainDelay.toFloat() + + // if we have no sensors, rain causes us to skip watering for the day + if (!anySensors()) { + if (settings.switches.latestValue('rainsensor') == 'rainsensoron'){ + note('raintoday', "${app.label}: skipping, rain sensor is on", 'd') + return true + } + float popRain = qpfTodayIn * (popToday / 100.0) + if (popRain > setrainDelay){ + String rainStr = String.format('%.2f', popRain) + note('raintoday', "${app.label}: skipping, ${rainStr}in of rain is probable today", 'd') + return true + } + popRain += qpfTomIn * (popTom / 100.0) + if (popRain > setrainDelay){ + String rainStr = String.format('%.2f', popRain) + note('raintom', "${app.label}: skipping, ${rainStr}in of rain is probable today + tomorrow", 'd') + return true + } + if (weeklyRain > setrainDelay){ + String rainStr = String.format('%.2f', weeklyRain) + note('rainy', "${app.label}: skipping, ${rainStr}in weighted average rain over the past week", 'd') + return true + } + } + else { // we have at least one sensor in the schedule + // Ignore rain sensor & historical rain - only skip if more than setrainDelay is expected before midnight tomorrow + float popRain = (qpfTodayIn * (popToday / 100.0)) - TRain // ignore rain that has already fallen so far today - sensors should already reflect that + if (popRain > setrainDelay){ + String rainStr = String.format('%.2f', popRain) + note('raintoday', "${app.label}: skipping, at least ${rainStr}in of rain is probable later today", 'd') + return true + } + popRain += qpfTomIn * (popTom / 100.0) + if (popRain > setrainDelay){ + String rainStr = String.format('%.2f', popRain) + note('raintom', "${app.label}: skipping, at least ${rainStr}in of rain is probable later today + tomorrow", 'd') + return true + } + } + if (isDebug) log.debug "isWeather() ends" + return false +} + +// true if ANY of this schedule's zones are on and using sensors +private boolean anySensors() { + int zone=1 + while (zone <= 16) { + def zoneStr = settings."zone${zone}" + if (zoneStr && (zoneStr != 'Off') && settings."sensor${zone}") return true + zone++ + } + return false +} + +def getDPWDays(int dpw){ + if (dpw && (dpw.isNumber()) && (dpw >= 1) && (dpw <= 7)) { + return state."DPWDays${dpw}" + } else + return [0,0,0,0,0,0,0] +} + +// Create a map of what days each possible DPW value will run on +// Example: User sets allowed days to Monday Wed and Fri +// Map would look like: DPWDays1:[1,0,0,0,0,0,0] (run on Monday) +// DPWDays2:[1,0,0,0,1,0,0] (run on Monday and Friday) +// DPWDays3:[1,0,1,0,1,0,0] (run on Monday Wed and Fri) +// Everything runs on the first day possible, starting with Monday. +def createDPWMap() { + state.DPWDays1 = [] + state.DPWDays2 = [] + state.DPWDays3 = [] + state.DPWDays4 = [] + state.DPWDays5 = [] + state.DPWDays6 = [] + state.DPWDays7 = [] + //def NDAYS = 7 + // day Distance[NDAYS][NDAYS], easier to just define than calculate everytime + def int[][] dayDistance = [[0,1,2,3,3,2,1],[1,0,1,2,3,3,2],[2,1,0,1,2,3,3],[3,2,1,0,1,2,3],[3,3,2,1,0,1,2],[2,3,3,2,1,0,1],[1,2,3,3,2,1,0]] + def ndaysAvailable = daysAvailable() + int i = 0 + + // def int[] daysAvailable = [0,1,2,3,4,5,6] + def int[] daysAvailable = [0,0,0,0,0,0,0] + + if(settings.days) { + if (settings.days.contains('Even') || settings.days.contains('Odd')) { + return + } + if (settings.days.contains('Monday')) { + daysAvailable[i] = 0 + i++ + } + if (settings.days.contains('Tuesday')) { + daysAvailable[i] = 1 + i++ + } + if (settings.days.contains('Wednesday')) { + daysAvailable[i] = 2 + i++ + } + if (settings.days.contains('Thursday')) { + daysAvailable[i] = 3 + i++ + } + if (settings.days.contains('Friday')) { + daysAvailable[i] = 4 + i++ + } + if (settings.days.contains('Saturday')) { + daysAvailable[i] = 5 + i++ + } + if (settings.days.contains('Sunday')) { + daysAvailable[i] = 6 + i++ + } + if(i != ndaysAvailable) { + log.debug 'ERROR: days and daysAvailable do not match in setup - overriding' + log.debug "${i} ${ndaysAvailable}" + ndaysAvailable = i // override incorrect setup execution + state.daysAvailable = i + } + } + else { // all days are available if settings.days == "" + daysAvailable = [0,1,2,3,4,5,6] + } + //log.debug "Ndays: ${ndaysAvailable} Available Days: ${daysAvailable}" + def maxday = -1 + def max = -1 + def dDays = new int[7] + def int[][] runDays = [[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,0,0]] + + for(def a=0; a < ndaysAvailable; a++) { + // Figure out next day using the dayDistance map, getting the farthest away day (max value) + if(a > 0 && ndaysAvailable >= 2 && a != ndaysAvailable-1) { + if(a == 1) { + for(def c=1; c < ndaysAvailable; c++) { + def d = dayDistance[daysAvailable[0]][daysAvailable[c]] + if(d > max) { + max = d + maxday = daysAvailable[c] + } + } + //log.debug "max: ${max} maxday: ${maxday}" + dDays[0] = maxday + } + + // Find successive maxes for the following days + if(a > 1) { + def lmax = max + def lmaxday = maxday + max = -1 + for(int c = 1; c < ndaysAvailable; c++) { + def d = dayDistance[daysAvailable[0]][daysAvailable[c]] + def t = d > max + if (a % 2 == 0) t = d >= max + if(d < lmax && d >= max) { + if(d == max) { + d = dayDistance[lmaxday][daysAvailable[c]] + if(d > dayDistance[lmaxday][maxday]) { + max = d + maxday = daysAvailable[c] + } + } + else { + max = d + maxday = daysAvailable[c] + } + } + } + lmax = 5 + while(max == -1) { + lmax = lmax -1 + for(int c = 1; c < ndaysAvailable; c++) { + def d = dayDistance[daysAvailable[0]][daysAvailable[c]] + if(d < lmax && d >= max) { + if(d == max) { + d = dayDistance[lmaxday][daysAvailable[c]] + if(d > dayDistance[lmaxday][maxday]) { + max = d + maxday = daysAvailable[c] + } + } + else { + max = d + maxday = daysAvailable[c] + } + } + } + for (def d=0; d< a-2; d++) { + if(maxday == dDays[d]) max = -1 + } + } + //log.debug "max: ${max} maxday: ${maxday}" + dDays[a-1] = maxday + } + } + + // Set the runDays map using the calculated maxdays + for(int b=0; b < 7; b++) { + // Runs every day available + if(a == ndaysAvailable-1) { + runDays[a][b] = 0 + for (def c=0; c < ndaysAvailable; c++) { + if(b == daysAvailable[c]) runDays[a][b] = 1 + } + } + else { + // runs weekly, use first available day + if(a == 0) { + if(b == daysAvailable[0]) + runDays[a][b] = 1 + else + runDays[a][b] = 0 + } + else { + // Otherwise, start with first available day + if(b == daysAvailable[0]) + runDays[a][b] = 1 + else { + runDays[a][b] = 0 + for(def c=0; c < a; c++) + if(b == dDays[c]) + runDays[a][b] = 1 + } + } + } + } + } + + //log.debug "DPW: ${runDays}" + state.DPWDays1 = runDays[0] + state.DPWDays2 = runDays[1] + state.DPWDays3 = runDays[2] + state.DPWDays4 = runDays[3] + state.DPWDays5 = runDays[4] + state.DPWDays6 = runDays[5] + state.DPWDays7 = runDays[6] +} + +//transition page to populate app state - this is a fix for WP param +def zoneSetPage1(){ + state.app = 1 + zoneSetPage() + } +def zoneSetPage2(){ + state.app = 2 + zoneSetPage() + } +def zoneSetPage3(){ + state.app = 3 + zoneSetPage() + } +def zoneSetPage4(){ + state.app = 4 + zoneSetPage() + } +def zoneSetPage5(){ + state.app = 5 + zoneSetPage() + } +def zoneSetPage6(){ + state.app = 6 + zoneSetPage() + } +def zoneSetPage7(){ + state.app = 7 + zoneSetPage() + } +def zoneSetPage8(){ + state.app = 8 + zoneSetPage() + } +def zoneSetPage9(i){ + state.app = 9 + zoneSetPage() + } +def zoneSetPage10(){ + state.app = 10 + zoneSetPage() + } +def zoneSetPage11(){ + state.app = 11 + zoneSetPage() + } +def zoneSetPage12(){ + state.app = 12 + zoneSetPage() + } +def zoneSetPage13(){ + state.app = 13 + zoneSetPage() + } +def zoneSetPage14(){ + state.app = 14 + zoneSetPage() + } +def zoneSetPage15(){ + state.app = 15 + zoneSetPage() + } +def zoneSetPage16(){ + state.app = 16 + zoneSetPage() + } + diff --git a/official/step-notifier.groovy b/official/step-notifier.groovy new file mode 100755 index 0000000..7aec19c --- /dev/null +++ b/official/step-notifier.groovy @@ -0,0 +1,366 @@ +/** + * Step Notifier + * + * Copyright 2014 Jeff's Account + * + * 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: "Step Notifier", + namespace: "smartthings", + author: "SmartThings", + description: "Use a step tracker device to track daily step goals and trigger various device actions when your goals are met!", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/jawbone-up.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/jawbone-up@2x.png" +) + +preferences { + page(name: "setupNotifications") + page(name: "chooseTrack", title: "Select a song or station") + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def setupNotifications() { + + dynamicPage(name: "setupNotifications", title: "Configure Your Goal Notifications.", install: true, uninstall: true) { + + section("Select your Jawbone UP") { + input "jawbone", "device.jawboneUser", title: "Jawbone UP", required: true, multiple: false + } + + section("Notify Me When"){ + input "thresholdType", "enum", title: "Select When to Notify", required: false, defaultValue: "Goal Reached", options: [["Goal":"Goal Reached"],["Threshold":"Specific Number of Steps"]], submitOnChange:true + if (settings.thresholdType) { + if (settings.thresholdType == "Threshold") { + input "threshold", "number", title: "Enter Step Threshold", description: "Number", required: true + } + } + } + + section("Via a push notification and/or an SMS message"){ + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Phone Number (for SMS, optional)", required: false + input "notificationType", "enum", title: "Select Notification", required: false, defaultValue: "None", options: ["None", "Push", "SMS", "Both"] + } + } + + section("Flash the Lights") { + input "lights", "capability.switch", title: "Which Lights?", required: false, multiple: true + input "flashCount", "number", title: "How Many Times?", defaultValue: 5, required: false + } + + section("Change the Color of the Lights") { + input "hues", "capability.colorControl", title: "Which Hue Bulbs?", required:false, multiple:true + input "color", "enum", title: "Hue Color?", required: false, multiple:false, options: ["Red","Green","Blue","Yellow","Orange","Purple","Pink"] + input "lightLevel", "enum", title: "Light Level?", required: false, options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]] + input "duration", "number", title: "Duration in Seconds?", defaultValue: 30, required: false + } + + section("Play a song on the Sonos") { + input "sonos", "capability.musicPlayer", title: "On this Sonos player", required: false, submitOnChange:true + if (settings.sonos) { + input "song","enum",title:"Play this track or radio station", required:true, multiple: false, options: songOptions() + input "resumePlaying", "bool", title: "Resume currently playing music after notification", required: false, defaultValue: true + input "volume", "number", title: "Temporarily change volume", description: "0-100%", required: false + input "songDuration", "number", title: "Play for this many seconds", defaultValue: 60, description: "0-100%", required: true + } + + } + } +} + +def chooseTrack() { + dynamicPage(name: "chooseTrack") { + section{ + input "song","enum",title:"Play this track", required:true, multiple: false, options: songOptions() + } + } +} + +private songOptions() { + + // Make sure current selection is in the set + + def options = new LinkedHashSet() + if (state.selectedSong?.station) { + options << state.selectedSong.station + } + else if (state.selectedSong?.description) { + // TODO - Remove eventually? 'description' for backward compatibility + options << state.selectedSong.description + } + + // Query for recent tracks + def states = sonos.statesSince("trackData", new Date(0), [max:30]) + def dataMaps = states.collect{it.jsonValue} + options.addAll(dataMaps.collect{it.station}) + + log.trace "${options.size()} songs in list" + options.take(20) as List +} + +private saveSelectedSong() { + try { + def thisSong = song + log.info "Looking for $thisSong" + def songs = sonos.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} + log.info "Searching ${songs.size()} records" + + def data = songs.find {s -> s.station == thisSong} + log.info "Found ${data?.station}" + if (data) { + state.selectedSong = data + log.debug "Selected song = $state.selectedSong" + } + else if (song == state.selectedSong?.station) { + log.debug "Selected existing entry '$song', which is no longer in the last 20 list" + } + else { + log.warn "Selected song '$song' not found" + } + } + catch (Throwable t) { + log.error t + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + + log.trace "Entering initialize()" + + state.lastSteps = 0 + state.steps = jawbone.currentValue("steps").toInteger() + state.goal = jawbone.currentValue("goal").toInteger() + + subscribe (jawbone,"goal",goalHandler) + subscribe (jawbone,"steps",stepHandler) + + if (song) { + saveSelectedSong() + } + + log.trace "Exiting initialize()" +} + +def goalHandler(evt) { + + log.trace "Entering goalHandler()" + + def goal = evt.value.toInteger() + + state.goal = goal + + log.trace "Exiting goalHandler()" +} + +def stepHandler(evt) { + + log.trace "Entering stepHandler()" + + log.debug "Event Value ${evt.value}" + log.debug "state.steps = ${state.steps}" + log.debug "state.goal = ${state.goal}" + + def steps = evt.value.toInteger() + + state.lastSteps = state.steps + state.steps = steps + + def stepGoal + if (settings.thresholdType == "Goal") + stepGoal = state.goal + else + stepGoal = settings.threshold + + if ((state.lastSteps < stepGoal) && (state.steps >= stepGoal)) { // only trigger when crossing through the goal threshold + + // goal achieved for the day! Yay! Lets tell someone! + + if (settings.notificationType != "None") { // Push or SMS Notification requested + + if (location.contactBookEnabled) { + sendNotificationToContacts(stepMessage, recipients) + } + else { + + def options = [ + method: settings.notificationType.toLowerCase(), + phone: settings.phone + ] + + sendNotification(stepMessage, options) + } + } + + if (settings.sonos) { // play a song on the Sonos as requested + + // runIn(1, sonosNotification, [overwrite: false]) + sonosNotification() + + } + + if (settings.hues) { // change the color of hue bulbs ras equested + + // runIn(1, hueNotification, [overwrite: false]) + hueNotification() + + } + + if (settings.lights) { // flash the lights as requested + + // runIn(1, lightsNotification, [overwrite: false]) + lightsNotification() + + } + + } + + log.trace "Exiting stepHandler()" + +} + + +def lightsNotification() { + + // save the current state of the lights + + log.trace "Save current state of lights" + + state.previousLights = [:] + + lights.each { + state.previousLights[it.id] = it.currentValue("switch") + } + + // Flash the light on and off 5 times for now - this could be configurable + + log.trace "Now flash the lights" + + for (i in 1..flashCount) { + + lights.on() + pause(500) + lights.off() + + } + + // restore the original state + + log.trace "Now restore the original state of lights" + + lights.each { + it."${state.previousLights[it.id]}"() + } + + +} + +def hueNotification() { + + log.trace "Entering hueNotification()" + + def hueColor = 0 + if(color == "Blue") + hueColor = 70//60 + else if(color == "Green") + hueColor = 39//30 + else if(color == "Yellow") + hueColor = 25//16 + else if(color == "Orange") + hueColor = 10 + else if(color == "Purple") + hueColor = 75 + else if(color == "Pink") + hueColor = 83 + + + state.previousHue = [:] + + hues.each { + state.previousHue[it.id] = [ + "switch": it.currentValue("switch"), + "level" : it.currentValue("level"), + "hue": it.currentValue("hue"), + "saturation": it.currentValue("saturation") + ] + } + + log.debug "current values = ${state.previousHue}" + + def newValue = [hue: hueColor, saturation: 100, level: (lightLevel as Integer) ?: 100] + log.debug "new value = $newValue" + + hues*.setColor(newValue) + setTimer() + + log.trace "Exiting hueNotification()" + +} + +def setTimer() +{ + log.debug "runIn ${duration}, resetHue" + runIn(duration, resetHue, [overwrite: false]) +} + + +def resetHue() +{ + log.trace "Entering resetHue()" + settings.hues.each { + it.setColor(state.previousHue[it.id]) + } + log.trace "Exiting resetHue()" +} + +def sonosNotification() { + + log.trace "sonosNotification()" + + if (settings.song) { + + if (settings.resumePlaying) { + if (settings.volume) + sonos.playTrackAndResume(state.selectedSong, settings.songDuration, settings.volume) + else + sonos.playTrackAndResume(state.selectedSong, settings.songDuration) + } else { + if (settings.volume) + sonos.playTrackAtVolume(state.selectedSong, settings.volume) + else + sonos.playTrack(state.selectedSong) + } + + sonos.on() // make sure it is playing + + } + + log.trace "Exiting sonosNotification()" +} diff --git a/official/sunrise-sunset.groovy b/official/sunrise-sunset.groovy new file mode 100755 index 0000000..417cce6 --- /dev/null +++ b/official/sunrise-sunset.groovy @@ -0,0 +1,189 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Sunrise, Sunset + * + * Author: SmartThings + * + * Date: 2013-04-30 + */ +definition( + name: "Sunrise/Sunset", + namespace: "smartthings", + author: "SmartThings", + description: "Changes mode and controls lights based on local sunrise and sunset times.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/rise-and-shine.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/rise-and-shine@2x.png" +) + +preferences { + section ("At sunrise...") { + input "sunriseMode", "mode", title: "Change mode to?", required: false + input "sunriseOn", "capability.switch", title: "Turn on?", required: false, multiple: true + input "sunriseOff", "capability.switch", title: "Turn off?", required: false, multiple: true + } + section ("At sunset...") { + input "sunsetMode", "mode", title: "Change mode to?", required: false + input "sunsetOn", "capability.switch", title: "Turn on?", required: false, multiple: true + input "sunsetOff", "capability.switch", title: "Turn off?", required: false, multiple: true + } + section ("Sunrise offset (optional)...") { + input "sunriseOffsetValue", "text", title: "HH:MM", required: false + input "sunriseOffsetDir", "enum", title: "Before or After", required: false, options: ["Before","After"] + } + section ("Sunset offset (optional)...") { + input "sunsetOffsetValue", "text", title: "HH:MM", required: false + input "sunsetOffsetDir", "enum", title: "Before or After", required: false, options: ["Before","After"] + } + section ("Zip code (optional, defaults to location coordinates)...") { + input "zipCode", "text", required: false + } + section( "Notifications" ) { + input("recipients", "contact", title: "Send notifications to") { + input "sendPushMessage", "enum", title: "Send a push notification?", options: ["Yes", "No"], required: false + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + } + +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + //unschedule handled in astroCheck method + initialize() +} + +def initialize() { + subscribe(location, "position", locationPositionChange) + subscribe(location, "sunriseTime", sunriseSunsetTimeHandler) + subscribe(location, "sunsetTime", sunriseSunsetTimeHandler) + + astroCheck() +} + +def locationPositionChange(evt) { + log.trace "locationChange()" + astroCheck() +} + +def sunriseSunsetTimeHandler(evt) { + log.trace "sunriseSunsetTimeHandler()" + astroCheck() +} + +def astroCheck() { + def s = getSunriseAndSunset(zipCode: zipCode, sunriseOffset: sunriseOffset, sunsetOffset: sunsetOffset) + + def now = new Date() + def riseTime = s.sunrise + def setTime = s.sunset + log.debug "riseTime: $riseTime" + log.debug "setTime: $setTime" + + if (state.riseTime != riseTime.time) { + unschedule("sunriseHandler") + + if(riseTime.before(now)) { + riseTime = riseTime.next() + } + + state.riseTime = riseTime.time + + log.info "scheduling sunrise handler for $riseTime" + schedule(riseTime, sunriseHandler) + } + + if (state.setTime != setTime.time) { + unschedule("sunsetHandler") + + if(setTime.before(now)) { + setTime = setTime.next() + } + + state.setTime = setTime.time + + log.info "scheduling sunset handler for $setTime" + schedule(setTime, sunsetHandler) + } +} + +def sunriseHandler() { + log.info "Executing sunrise handler" + if (sunriseOn) { + sunriseOn.on() + } + if (sunriseOff) { + sunriseOff.off() + } + changeMode(sunriseMode) +} + +def sunsetHandler() { + log.info "Executing sunset handler" + if (sunsetOn) { + sunsetOn.on() + } + if (sunsetOff) { + sunsetOff.off() + } + changeMode(sunsetMode) +} + +def changeMode(newMode) { + if (newMode && location.mode != newMode) { + if (location.modes?.find{it.name == newMode}) { + setLocationMode(newMode) + send "${label} has changed the mode to '${newMode}'" + } + else { + send "${label} tried to change to undefined mode '${newMode}'" + } + } +} + +private send(msg) { + if (location.contactBookEnabled) { + log.debug("sending notifications to: ${recipients?.size()}") + sendNotificationToContacts(msg, recipients) + } + else { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + } + + log.debug msg +} + +private getLabel() { + app.label ?: "SmartThings" +} + +private getSunriseOffset() { + sunriseOffsetValue ? (sunriseOffsetDir == "Before" ? "-$sunriseOffsetValue" : sunriseOffsetValue) : null +} + +private getSunsetOffset() { + sunsetOffsetValue ? (sunsetOffsetDir == "Before" ? "-$sunsetOffsetValue" : sunsetOffsetValue) : null +} + diff --git a/official/switch-activates-home-phrase-or-mode.groovy b/official/switch-activates-home-phrase-or-mode.groovy new file mode 100755 index 0000000..6415c4f --- /dev/null +++ b/official/switch-activates-home-phrase-or-mode.groovy @@ -0,0 +1,146 @@ +/** + * Switch Activates Home Phrase or Mode + * + * Copyright 2015 Michael Struck + * Version 1.0.1 6/20/15 + * + * 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. + * + * Ties a Hello, Home phrase to a switch's (virtual or real) on/off state. Perfect for use with IFTTT. + * Simple define a switch to be used, then tie the on/off state of the switch to a specific Hello, Home phrases. + * Connect the switch to an IFTTT action, and the Hello, Home phrase will fire with the switch state change. + * + * + */ +definition( + name: "Switch Activates Home Phrase or Mode", + namespace: "MichaelStruck", + author: "Michael Struck", + description: "Ties a Hello, Home phrase or mode to a switch's state. Perfect for use with IFTTT.", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1.png", + iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1@2x.png", + iconX3Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1@2x.png") + + +preferences { + page(name: "getPref") +} + +def getPref() { + dynamicPage(name: "getPref", install:true, uninstall: true) { + section("Choose a switch to use...") { + input "controlSwitch", "capability.switch", title: "Switch", multiple: false, required: true + } + + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + section("Perform which phrase when...") { + input "phrase_on", "enum", title: "Switch is on", options: phrases, required: false + input "phrase_off", "enum", title: "Switch is off", options: phrases, required: false + } + } + section("Change to which mode when...") { + input "onMode", "mode", title: "Switch is on", required: false + input "offMode", "mode", title: "Switch is off", required: false + } + section([mobileOnly:true], "Options") { + label(title: "Assign a name", required: false) + mode title: "Set for specific mode(s)", required: false + href "pageAbout", title: "About ${textAppName()}", description: "Tap to get application version, license and instructions" + } + } +} + +page(name: "pageAbout", title: "About ${textAppName()}") { + section { + paragraph "${textVersion()}\n${textCopyright()}\n\n${textLicense()}\n" + } + section("Instructions") { + paragraph textHelp() + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribe(controlSwitch, "switch", "switchHandler") +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + subscribe(controlSwitch, "switch", "switchHandler") +} + +def switchHandler(evt) { + if (evt.value == "on" && (phrase_on || onMode)) { + if (phrase_on){ + location.helloHome.execute(settings.phrase_on) + } + if (onMode) { + changeMode(onMode) + } + } + else if (evt.value == "off" && (phrase_off || offMode)) { + if (phrase_off){ + location.helloHome.execute(settings.phrase_off) + } + if (offMode) { + changeMode(offMode) + } + } +} + +def changeMode(newMode) { + if (location.mode != newMode) { + if (location.modes?.find{it.name == newMode}) { + setLocationMode(newMode) + } else { + log.debug "Unable to change to undefined mode '${newMode}'" + } + } +} + +//Version/Copyright/Information/Help + +private def textAppName() { + def text = "Switch Activates Home Phrase or Mode" +} + +private def textVersion() { + def text = "Version 1.0.1 (06/20/2015)" +} + +private def textCopyright() { + def text = "Copyright © 2015 Michael Struck" +} + +private def textLicense() { + def text = + "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"+ + "\n\n"+ + " http://www.apache.org/licenses/LICENSE-2.0"+ + "\n\n"+ + "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." +} + +private def textHelp() { + def text = + "Ties a Hello, Home phrase or mode to a switch's (virtual or real) on/off state. Perfect for use with IFTTT. "+ + "Simple define a switch to be used, then tie the on/off state of the switch to a specific Hello, Home phrases or mode. "+ + "Connect the switch to an IFTTT action, and the Hello, Home phrase or mode will fire with the switch state change." +} \ No newline at end of file diff --git a/official/switch-activates-home-phrase.groovy b/official/switch-activates-home-phrase.groovy new file mode 100755 index 0000000..a3a9c32 --- /dev/null +++ b/official/switch-activates-home-phrase.groovy @@ -0,0 +1,72 @@ +/** + * Switch Activates Hello, Home Phrase + * + * Copyright 2015 Michael Struck + * Version 1.01 3/8/15 + * + * 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. + * + * Ties a Hello, Home phrase to a switch's (virtual or real) on/off state. Perfect for use with IFTTT. + * Simple define a switch to be used, then tie the on/off state of the switch to a specific Hello, Home phrases. + * Connect the switch to an IFTTT action, and the Hello, Home phrase will fire with the switch state change. + * + * + */ +definition( + name: "Switch Activates Home Phrase", + namespace: "MichaelStruck", + author: "Michael Struck", + description: "Ties a Hello, Home phrase to a switch's state. Perfect for use with IFTTT.", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1.png", + iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1@2x.png", + iconX3Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1@2x.png") + + +preferences { + page(name: "getPref") +} + +def getPref() { + dynamicPage(name: "getPref", title: "Choose Switch and Phrases", install:true, uninstall: true) { + section("Choose a switch to use...") { + input "controlSwitch", "capability.switch", title: "Switch", multiple: false, required: true + } + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + section("Perform the following phrase when...") { + log.trace phrases + input "phrase_on", "enum", title: "Switch is on", required: true, options: phrases + input "phrase_off", "enum", title: "Switch is off", required: true, options: phrases + } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribe(controlSwitch, "switch", "switchHandler") +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + subscribe(controlSwitch, "switch", "switchHandler") +} + +def switchHandler(evt) { + if (evt.value == "on") { + location.helloHome.execute(settings.phrase_on) + } else { + location.helloHome.execute(settings.phrase_off) + } +} + diff --git a/official/switch-changes-mode.groovy b/official/switch-changes-mode.groovy new file mode 100755 index 0000000..84cb2e2 --- /dev/null +++ b/official/switch-changes-mode.groovy @@ -0,0 +1,76 @@ +/** + * Switch Changes Mode + * + * Copyright 2015 Michael Struck + * Version 1.01 3/8/15 + * + * 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. + * + * Ties a mode to a switch's (virtual or real) on/off state. Perfect for use with IFTTT. + * Simple define a switch to be used, then tie the on/off state of the switch to a specific mode. + * Connect the switch to an IFTTT action, and the mode will fire with the switch state change. + * + * + */ +definition( + name: "Switch Changes Mode", + namespace: "MichaelStruck", + author: "Michael Struck", + description: "Ties a mode to a switch's state. Perfect for use with IFTTT.", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1.png", + iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1@2x.png", + iconX3Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/IFTTT-SmartApps/App1@2x.png") + +preferences { + page(name: "getPref", title: "Choose Switch and Modes", install:true, uninstall: true) { + section("Choose a switch to use...") { + input "controlSwitch", "capability.switch", title: "Switch", multiple: false, required: true + } + section("Change to a new mode when...") { + input "onMode", "mode", title: "Switch is on", required: false + input "offMode", "mode", title: "Switch is off", required: false + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + subscribe(controlSwitch, "switch", "switchHandler") +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + subscribe(controlSwitch, "switch", "switchHandler") +} + +def switchHandler(evt) { + if (evt.value == "on") { + changeMode(onMode) + } else { + changeMode(offMode) + } +} + +def changeMode(newMode) { + + if (newMode && location.mode != newMode) { + if (location.modes?.find{it.name == newMode}) { + setLocationMode(newMode) + } + else { + log.debug "Unable to change to undefined mode '${newMode}'" + } + } +} + diff --git a/official/talking-alarm-clock.groovy b/official/talking-alarm-clock.groovy new file mode 100755 index 0000000..56fc46a --- /dev/null +++ b/official/talking-alarm-clock.groovy @@ -0,0 +1,1340 @@ +/** + * Talking Alarm Clock + * + * Version - 1.0.0 5/23/15 + * Version - 1.1.0 5/24/15 - A song can now be selected to play after the voice greeting and bug fixes + * Version - 1.2.0 5/27/15 - Added About screen and misc code clean up and GUI revisions + * Version - 1.3.0 5/29/15 - Further code optimizations and addition of alarm summary action + * Version - 1.3.1 5/30/15 - Fixed one small code syntax issue in Scenario D + * Version - 1.4.0 6/7/15 - Revised About screen, enhanced the weather forecast voice summary, added a mode change option with alarm, and added secondary alarm options + * Version - 1.4.1 6/9/15 - Changed the mode change speech to make it clear when the mode change is taking place + * Version - 1.4.2 6/10/15 - To prevent accidental triggering of summary, put in a mode switch restriction + * Version - 1.4.3 6/12/15 - Syntax issues and minor GUI fixes + * Version - 1.4.4 6/15/15 - Fixed a bug with Phrase change at alarm time + * Version - 1.4.5 6/17/15 - Code optimization, implemented the new submitOnChange option and a new license agreement change + * + * Copyright 2015 Michael Struck - Uses code from Lighting Director by Tim Slagle & Michael Struck + * + * 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: "Talking Alarm Clock", + namespace: "MichaelStruck", + author: "Michael Struck", + description: "Control up to 4 waking schedules using a Sonos speaker as an alarm.", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/Talkingclock.png", + iconX2Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/Talkingclock@2x.png", + iconX3Url: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/Talkingclock@2x.png" + ) + +preferences { + page name:"pageMain" + page name:"pageSetupScenarioA" + page name:"pageSetupScenarioB" + page name:"pageSetupScenarioC" + page name:"pageSetupScenarioD" + page name:"pageWeatherSettingsA" //technically, these 4 pages should not be dynamic, but are here to work around a crash on the Andriod app + page name:"pageWeatherSettingsB" + page name:"pageWeatherSettingsC" + page name:"pageWeatherSettingsD" +} + +// Show setup page +def pageMain() { + dynamicPage(name: "pageMain", install: true, uninstall: true) { + section ("Alarms") { + href "pageSetupScenarioA", title: getTitle(ScenarioNameA, 1), description: getDesc(A_timeStart, A_sonos, A_day, A_mode), state: greyOut(ScenarioNameA, A_sonos, A_timeStart, A_alarmOn, A_alarmType) + if (ScenarioNameA && A_sonos && A_timeStart && A_alarmType){ + input "A_alarmOn", "bool", title: "Enable this alarm?", defaultValue: "true", submitOnChange:true + } + } + section { + href "pageSetupScenarioB", title: getTitle(ScenarioNameB, 2), description: getDesc(B_timeStart, B_sonos, B_day, B_mode), state: greyOut(ScenarioNameB, B_sonos, B_timeStart, B_alarmOn, B_alarmType) + if (ScenarioNameB && B_sonos && B_timeStart && B_alarmType){ + input "B_alarmOn", "bool", title: "Enable this alarm?", defaultValue: "false", submitOnChange:true + } + } + section { + href "pageSetupScenarioC", title: getTitle(ScenarioNameC, 3), description: getDesc(C_timeStart, C_sonos, C_day, C_mode), state: greyOut(ScenarioNameC, C_sonos, C_timeStart, C_alarmOn, C_alarmType) + if (ScenarioNameC && C_sonos && C_timeStart && C_alarmType){ + input "C_alarmOn", "bool", title: "Enable this alarm?", defaultValue: "false", submitOnChange:true + } + } + section { + href "pageSetupScenarioD", title: getTitle(ScenarioNameD, 4), description: getDesc(D_timeStart, D_sonos, D_day, D_mode), state: greyOut(ScenarioNameD, D_sonos, D_timeStart, D_alarmOn, D_alarmType) + if (ScenarioNameD && D_sonos && D_timeStart && D_alarmType){ + input "D_alarmOn", "bool", title: "Enable this alarm?", defaultValue: "false", submitOnChange:true + } + } + section([title:"Options", mobileOnly:true]) { + input "alarmSummary", "bool", title: "Enable Alarm Summary", defaultValue: "false", submitOnChange:true + if (alarmSummary) { + href "pageAlarmSummary", title: "Alarm Summary Settings", description: "Tap to configure alarm summary settings", state: "complete" + } + input "zipCode", "text", title: "Zip Code", required: false + label title:"Assign a name", required: false + href "pageAbout", title: "About ${textAppName()}", description: "Tap to get application version, license and instructions" + } + } +} + +page(name: "pageAlarmSummary", title: "Alarm Summary Settings") { + section { + input "summarySonos", "capability.musicPlayer", title: "Choose a Sonos speaker", required: false + input "summaryVolume", "number", title: "Set the summary volume", description: "0-100%", required: false + input "summaryDisabled", "bool", title: "Include disabled or unconfigured alarms in summary", defaultValue: "false" + input "summaryMode", "mode", title: "Speak summary only during the following modes...", multiple: true, required: false + } +} +//Show "pageSetupScenarioA" page +def pageSetupScenarioA() { + dynamicPage(name: "pageSetupScenarioA") { + section("Alarm settings") { + input "ScenarioNameA", "text", title: "Scenario Name", multiple: false, required: true + input "A_sonos", "capability.musicPlayer", title: "Choose a Sonos speaker", required: true, submitOnChange:true + input "A_volume", "number", title: "Alarm volume", description: "0-100%", required: false + input "A_timeStart", "time", title: "Time to trigger alarm", required: true + input "A_day", "enum", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], title: "Alarm on certain days of the week...", multiple: true, required: false + input "A_mode", "mode", title: "Alarm only during the following modes...", multiple: true, required: false + input "A_alarmType", "enum", title: "Select a primary alarm type...", multiple: false, required: true, options: [[1:"Alarm sound (up to 20 seconds)"],[2:"Voice Greeting"],[3:"Music track/Internet Radio"]], submitOnChange:true + + if (A_alarmType != "3") { + if (A_alarmType == "1"){ + input "A_secondAlarm", "enum", title: "Select a second alarm after the first is completed", multiple: false, required: false, options: [[1:"Voice Greeting"],[2:"Music track/Internet Radio"]], submitOnChange:true + } + if (A_alarmType == "2"){ + input "A_secondAlarmMusic", "bool", title: "Play a track after voice greeting", defaultValue: "false", required: false, submitOnChange:true + } + } + } + if (A_alarmType == "1"){ + section ("Alarm sound options"){ + input "A_soundAlarm", "enum", title: "Play this sound...", required:false, multiple: false, options: [[1:"Alien-8 seconds"],[2:"Bell-12 seconds"], [3:"Buzzer-20 seconds"], [4:"Fire-20 seconds"], [5:"Rooster-2 seconds"], [6:"Siren-20 seconds"]] + input "A_soundLength", "number", title: "Maximum time to play sound (empty=use sound default)", description: "1-20", required: false + } + } + if (A_alarmType == "2" || (A_alarmType == "1" && A_secondAlarm =="1")) { + section ("Voice greeting options") { + input "A_wakeMsg", "text", title: "Wake voice message", defaultValue: "Good morning! It is %time% on %day%, %date%.", required: false + href "pageWeatherSettingsA", title: "Weather Reporting Settings", description: getWeatherDesc(A_weatherReport, A_includeSunrise, A_includeSunset, A_includeTemp, A_humidity, A_localTemp), state: greyOut1(A_weatherReport, A_includeSunrise, A_includeSunset, A_includeTemp, A_humidity, A_localTemp) + } + } + if (A_alarmType == "3" || (A_alarmType == "1" && A_secondAlarm =="2") || (A_alarmType == "2" && A_secondAlarmMusic)){ + section ("Music track/internet radio options"){ + input "A_musicTrack", "enum", title: "Play this track/internet radio station", required:false, multiple: false, options: songOptions(A_sonos, 1) + } + } + section("Devices to control in this alarm scenario") { + input "A_switches", "capability.switch",title: "Control the following switches...", multiple: true, required: false, submitOnChange:true + href "pageDimmersA", title: "Dimmer Settings", description: dimmerDesc(A_dimmers), state: greyOutOption(A_dimmers), submitOnChange:true + href "pageThermostatsA", title: "Thermostat Settings", description: thermostatDesc(A_thermostats, A_temperatureH, A_temperatureC), state: greyOutOption(A_thermostats), submitOnChange:true + if ((A_switches || A_dimmers || A_thermostats) && (A_alarmType == "2" || (A_alarmType == "1" && A_secondAlarm =="1"))){ + input "A_confirmSwitches", "bool", title: "Confirm switches/thermostats status in voice message", defaultValue: "false" + } + } + section ("Other actions at alarm time"){ + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + input "A_phrase", "enum", title: "Alarm triggers the following phrase", required: false, options: phrases, multiple: false, submitOnChange:true + if (A_phrase && (A_alarmType == "2" || (A_alarmType == "1" && A_secondAlarm =="1"))){ + input "A_confirmPhrase", "bool", title: "Confirm Hello, Home phrase in voice message", defaultValue: "false" + } + } + input "A_triggerMode", "mode", title: "Alarm triggers the following mode", required: false, submitOnChange:true + if (A_triggerMode && (A_alarmType == "2" || (A_alarmType == "1" && A_secondAlarm =="1"))){ + input "A_confirmMode", "bool", title: "Confirm mode in voice message", defaultValue: "false" + } + } + } +} + +page(name: "pageDimmersA", title: "Dimmer Settings") { + section { + input "A_dimmers", "capability.switchLevel", title: "Dim the following...", multiple: true, required: false + input "A_level", "enum", options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]],title: "Set dimmers to this level", multiple: false, required: false + } +} + +page(name: "pageThermostatsA", title: "Thermostat Settings") { + section { + input "A_thermostats", "capability.thermostat", title: "Thermostat to control...", multiple: false, required: false + } + section { + input "A_temperatureH", "number", title: "Heating setpoint", required: false, description: "Temperature when in heat mode" + input "A_temperatureC", "number", title: "Cooling setpoint", required: false, description: "Temperature when in cool mode" + } +} + +def pageWeatherSettingsA() { + dynamicPage(name: "pageWeatherSettingsA", title: "Weather Reporting Settings") { + section { + input "A_includeTemp", "bool", title: "Speak current temperature (from local forecast)", defaultValue: "false" + input "A_localTemp", "capability.temperatureMeasurement", title: "Speak local temperature (from device)", required: false, multiple: false + input "A_humidity", "capability.relativeHumidityMeasurement", title: "Speak local humidity (from device)", required: false, multiple: false + input "A_weatherReport", "bool", title: "Speak today's weather forecast", defaultValue: "false" + input "A_includeSunrise", "bool", title: "Speak today's sunrise", defaultValue: "false" + input "A_includeSunset", "bool", title: "Speak today's sunset", defaultValue: "false" + } + } +} + +//Show "pageSetupScenarioB" page +def pageSetupScenarioB() { + dynamicPage(name: "pageSetupScenarioB") { + section("Alarm settings") { + input "ScenarioNameB", "text", title: "Scenario Name", multiple: false, required: true + input "B_sonos", "capability.musicPlayer", title: "Choose a Sonos speaker", required: true, submitOnChange:true + input "B_volume", "number", title: "Alarm volume", description: "0-100%", required: false + input "B_timeStart", "time", title: "Time to trigger alarm", required: true + input "B_day", "enum", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], title: "Alarm on certain days of the week...", multiple: true, required: false + input "B_mode", "mode", title: "Alarm only during the following modes...", multiple: true, required: false + input "B_alarmType", "enum", title: "Select a primary alarm type...", multiple: false, required: true, options: [[1:"Alarm sound (up to 20 seconds)"],[2:"Voice Greeting"],[3:"Music track/Internet Radio"]], submitOnChange:true + + if (B_alarmType != "3") { + if (B_alarmType == "1"){ + input "B_secondAlarm", "enum", title: "Select a second alarm after the first is completed", multiple: false, required: false, options: [[1:"Voice Greeting"],[2:"Music track/Internet Radio"]], submitOnChange:true + } + if (B_alarmType == "2"){ + input "B_secondAlarmMusic", "bool", title: "Play a track after voice greeting", defaultValue: "false", required: false, submitOnChange:true + } + } + } + if (B_alarmType == "1"){ + section ("Alarm sound options"){ + input "B_soundAlarm", "enum", title: "Play this sound...", required:false, multiple: false, options: [[1:"Alien-8 seconds"],[2:"Bell-12 seconds"], [3:"Buzzer-20 seconds"], [4:"Fire-20 seconds"], [5:"Rooster-2 seconds"], [6:"Siren-20 seconds"]] + input "B_soundLength", "number", title: "Maximum time to play sound (empty=use sound default)", description: "1-20", required: false + } + } + if (B_alarmType == "2" || (B_alarmType == "1" && B_secondAlarm =="1")){ + section ("Voice greeting options") { + input "B_wakeMsg", "text", title: "Wake voice message", defaultValue: "Good morning! It is %time% on %day%, %date%.", required: false + href "pageWeatherSettingsB", title: "Weather Reporting Settings", description: getWeatherDesc(B_weatherReport, B_includeSunrise, B_includeSunset, B_includeTemp, B_humidity, B_localTemp), state: greyOut1(B_weatherReport, B_includeSunrise, B_includeSunset, B_includeTemp, B_humidity, B_localTemp) + } + } + if (B_alarmType == "3" || (B_alarmType == "1" && B_secondAlarm =="2") || (B_alarmType == "2" && B_secondAlarmMusic)){ + section ("Music track/internet radio options"){ + input "B_musicTrack", "enum", title: "Play this track/internet radio station", required:false, multiple: false, options: songOptions(B_sonos, 1) + } + } + section("Devices to control in this alarm scenario") { + input "B_switches", "capability.switch",title: "Control the following switches...", multiple: true, required: false, submitOnChange:true + href "pageDimmersB", title: "Dimmer Settings", description: dimmerDesc(B_dimmers), state: greyOutOption(B_dimmers), submitOnChange:true + href "pageThermostatsB", title: "Thermostat Settings", description: thermostatDesc(B_thermostats, B_temperatureH, B_temperatureC), state: greyOutOption(B_thermostats), submitOnChange:true + if ((B_switches || B_dimmers || B_thermostats) && (B_alarmType == "2" || (B_alarmType == "1" && B_secondAlarm =="1"))){ + input "B_confirmSwitches", "bool", title: "Confirm switches/thermostats status in voice message", defaultValue: "false" + } + } + section ("Other actions at alarm time"){ + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + input "B_phrase", "enum", title: "Alarm triggers the following phrase", required: false, options: phrases, multiple: false, submitOnChange:true + if (B_phrase && (B_alarmType == "2" || (B_alarmType == "1" && B_secondAlarm =="1"))){ + input "B_confirmPhrase", "bool", title: "Confirm Hello, Home phrase in voice message", defaultValue: "false" + } + } + input "B_triggerMode", "mode", title: "Alarm triggers the following mode", required: false, submitOnChange:true + if (B_triggerMode && (B_alarmType == "2" || (B_alarmType == "1" && B_secondAlarm =="1"))){ + input "B_confirmMode", "bool", title: "Confirm mode in voice message", defaultValue: "false" + } + } + } +} + +page(name: "pageDimmersB", title: "Dimmer Settings") { + section { + input "B_dimmers", "capability.switchLevel", title: "Dim the following...", multiple: true, required: false + input "B_level", "enum", options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]],title: "Set dimmers to this level", multiple: false, required: false + } +} + +page(name: "pageThermostatsB", title: "Thermostat Settings") { + section { + input "B_thermostats", "capability.thermostat", title: "Thermostat to control...", multiple: false, required: false + } + section { + input "B_temperatureH", "number", title: "Heating setpoint", required: false, description: "Temperature when in heat mode" + input "B_temperatureC", "number", title: "Cooling setpoint", required: false, description: "Temperature when in cool mode" + } +} + +def pageWeatherSettingsB() { + dynamicPage(name: "pageWeatherSettingsB", title: "Weather Reporting Settings") { + section { + input "B_includeTemp", "bool", title: "Speak current temperature (from local forecast)", defaultValue: "false" + input "B_localTemp", "capability.temperatureMeasurement", title: "Speak local temperature (from device)", required: false, multiple: false + input "B_humidity", "capability.relativeHumidityMeasurement", title: "Speak local humidity (from device)", required: false, multiple: false + input "B_weatherReport", "bool", title: "Speak today's weather forecast", defaultValue: "false" + input "B_includeSunrise", "bool", title: "Speak today's sunrise", defaultValue: "false" + input "B_includeSunset", "bool", title: "Speak today's sunset", defaultValue: "false" + } + } +} + +//Show "pageSetupScenarioC" page +def pageSetupScenarioC() { + dynamicPage(name: "pageSetupScenarioC") { + section("Alarm settings") { + input "ScenarioNameC", "text", title: "Scenario Name", multiple: false, required: true + input "C_sonos", "capability.musicPlayer", title: "Choose a Sonos speaker", required: true, submitOnChange:true + input "C_volume", "number", title: "Alarm volume", description: "0-100%", required: false + input "C_timeStart", "time", title: "Time to trigger alarm", required: true + input "C_day", "enum", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], title: "Alarm on certain days of the week...", multiple: true, required: false + input "C_mode", "mode", title: "Alarm only during the following modes...", multiple: true, required: false + input "C_alarmType", "enum", title: "Select a primary alarm type...", multiple: false, required: true, options: [[1:"Alarm sound (up to 20 seconds)"],[2:"Voice Greeting"],[3:"Music track/Internet Radio"]], submitOnChange:true + + if (C_alarmType != "3") { + if (C_alarmType == "1"){ + input "C_secondAlarm", "enum", title: "Select a second alarm after the first is completed", multiple: false, required: false, options: [[1:"Voice Greeting"],[2:"Music track/Internet Radio"]], submitOnChange:true + } + if (C_alarmType == "2"){ + input "C_secondAlarmMusic", "bool", title: "Play a track after voice greeting", defaultValue: "false", required: false, submitOnChange:true + } + } + } + if (C_alarmType == "1"){ + section ("Alarm sound options"){ + input "C_soundAlarm", "enum", title: "Play this sound...", required:false, multiple: false, options: [[1:"Alien-8 seconds"],[2:"Bell-12 seconds"], [3:"Buzzer-20 seconds"], [4:"Fire-20 seconds"], [5:"Rooster-2 seconds"], [6:"Siren-20 seconds"]] + input "C_soundLength", "number", title: "Maximum time to play sound (empty=use sound default)", description: "1-20", required: false + } + } + + if (C_alarmType == "2" || (C_alarmType == "1" && C_secondAlarm =="1")) { + section ("Voice greeting options") { + input "C_wakeMsg", "text", title: "Wake voice message", defaultValue: "Good morning! It is %time% on %day%, %date%.", required: false + href "pageWeatherSettingsC", title: "Weather Reporting Settings", description: getWeatherDesc(C_weatherReport, C_includeSunrise, C_includeSunset, C_includeTemp, A_humidity, C_localTemp), state: greyOut1(C_weatherReport, C_includeSunrise, C_includeSunset, C_includeTemp, C_humidity, C_localTemp) } + } + + if (C_alarmType == "3" || (C_alarmType == "1" && C_secondAlarm =="2") || (C_alarmType == "2" && C_secondAlarmMusic)){ + section ("Music track/internet radio options"){ + input "C_musicTrack", "enum", title: "Play this track/internet radio station", required:false, multiple: false, options: songOptions(C_sonos, 1) + } + } + section("Devices to control in this alarm scenario") { + input "C_switches", "capability.switch",title: "Control the following switches...", multiple: true, required: false, submitOnChange:true + href "pageDimmersC", title: "Dimmer Settings", description: dimmerDesc(C_dimmers), state: greyOutOption(C_dimmers), submitOnChange:true + href "pageThermostatsC", title: "Thermostat Settings", description: thermostatDesc(C_thermostats, C_temperatureH, C_temperatureC), state: greyOutOption(C_thermostats), submitOnChange:true + if ((C_switches || C_dimmers || C_thermostats) && (C_alarmType == "2" || (C_alarmType == "1" && C_secondAlarm =="1"))){ + input "C_confirmSwitches", "bool", title: "Confirm switches/thermostats status in voice message", defaultValue: "false" + } + } + section ("Other actions at alarm time"){ + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + input "C_phrase", "enum", title: "Alarm triggers the following phrase", required: false, options: phrases, multiple: false, submitOnChange:true + if (C_phrase && (C_alarmType == "2" || (C_alarmType == "1" && C_secondAlarm =="1"))){ + input "C_confirmPhrase", "bool", title: "Confirm Hello, Home phrase in voice message", defaultValue: "false" + } + } + input "C_triggerMode", "mode", title: "Alarm triggers the following mode", required: false, submitOnChange:true + if (C_triggerMode && (C_alarmType == "2" || (C_alarmType == "1" && C_secondAlarm =="1"))){ + input "C_confirmMode", "bool", title: "Confirm mode in voice message", defaultValue: "false" + } + } + } +} + +page(name: "pageDimmersC", title: "Dimmer Settings") { + section { + input "C_dimmers", "capability.switchLevel", title: "Dim the following...", multiple: true, required: false + input "C_level", "enum", options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]],title: "Set dimmers to this level", multiple: false, required: false + } +} + +page(name: "pageThermostatsC", title: "Thermostat Settings") { + section { + input "C_thermostats", "capability.thermostat", title: "Thermostat to control...", multiple: false, required: false + } + section { + input "C_temperatureH", "number", title: "Heating setpoint", required: false, description: "Temperature when in heat mode" + input "C_temperatureC", "number", title: "Cooling setpoint", required: false, description: "Temperature when in cool mode" + } +} + +def pageWeatherSettingsC() { + dynamicPage(name: "pageWeatherSettingsC", title: "Weather Reporting Settings") { + section { + input "C_includeTemp", "bool", title: "Speak current temperature (from local forecast)", defaultValue: "false" + input "C_localTemp", "capability.temperatureMeasurement", title: "Speak local temperature (from device)", required: false, multiple: false + input "C_humidity", "capability.relativeHumidityMeasurement", title: "Speak local humidity (from device)", required: false, multiple: false + input "C_weatherReport", "bool", title: "Speak today's weather forecast", defaultValue: "false" + input "C_includeSunrise", "bool", title: "Speak today's sunrise", defaultValue: "false" + input "C_includeSunset", "bool", title: "Speak today's sunset", defaultValue: "false" + } + } +} + +//Show "pageSetupScenarioD" page +def pageSetupScenarioD() { + dynamicPage(name: "pageSetupScenarioD") { + section("Alarm settings") { + input "ScenarioNameD", "text", title: "Scenario Name", multiple: false, required: true + input "D_sonos", "capability.musicPlayer", title: "Choose a Sonos speaker", required: true, submitOnChange:true + input "D_volume", "number", title: "Alarm volume", description: "0-100%", required: false + input "D_timeStart", "time", title: "Time to trigger alarm", required: true + input "D_day", "enum", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], title: "Alarm on certain days of the week...", multiple: true, required: false + input "D_mode", "mode", title: "Alarm only during the following modes...", multiple: true, required: false + input "D_alarmType", "enum", title: "Select a primary alarm type...", multiple: false, required: true, options: [[1:"Alarm sound (up to 20 seconds)"],[2:"Voice Greeting"],[3:"Music track/Internet Radio"]], submitOnChange:true + + if (D_alarmType != "3") { + if (D_alarmType == "1"){ + input "D_secondAlarm", "enum", title: "Select a second alarm after the first is completed", multiple: false, required: false, options: [[1:"Voice Greeting"],[2:"Music track/Internet Radio"]], submitOnChange:true + } + if (D_alarmType == "2"){ + input "D_secondAlarmMusic", "bool", title: "Play a track after voice greeting", defaultValue: "false", required: false, submitOnChange:true + } + } + } + if (D_alarmType == "1"){ + section ("Alarm sound options"){ + input "D_soundAlarm", "enum", title: "Play this sound...", required:false, multiple: false, options: [[1:"Alien-8 seconds"],[2:"Bell-12 seconds"], [3:"Buzzer-20 seconds"], [4:"Fire-20 seconds"], [5:"Rooster-2 seconds"], [6:"Siren-20 seconds"]] + input "D_soundLength", "number", title: "Maximum time to play sound (empty=use sound default)", description: "1-20", required: false + } + } + + if (D_alarmType == "2" || (D_alarmType == "1" && D_secondAlarm =="1")) { + section ("Voice greeting options") { + input "D_wakeMsg", "text", title: "Wake voice message", defaultValue: "Good morning! It is %time% on %day%, %date%.", required: false + href "pageWeatherSettingsD", title: "Weather Reporting Settings", description: getWeatherDesc(D_weatherReport, D_includeSunrise, D_includeSunset, D_includeTemp, D_humidity, D_localTemp), state: greyOut1(D_weatherReport, D_includeSunrise, D_includeSunset, D_includeTemp, D_humidity, D_localTemp) } + } + + if (D_alarmType == "3" || (D_alarmType == "1" && D_secondAlarm =="2") || (D_alarmType == "2" && D_secondAlarmMusic)){ + section ("Music track/internet radio options"){ + input "D_musicTrack", "enum", title: "Play this track/internet radio station", required:false, multiple: false, options: songOptions(D_sonos, 1) + } + } + section("Devices to control in this alarm scenario") { + input "D_switches", "capability.switch",title: "Control the following switches...", multiple: true, required: false, submitOnChange:true + href "pageDimmersD", title: "Dimmer Settings", description: dimmerDesc(D_dimmers), state: greyOutOption(D_dimmers), submitOnChange:true + href "pageThermostatsD", title: "Thermostat Settings", description: thermostatDesc(D_thermostats, D_temperatureH, D_temperatureC), state: greyOutOption(D_thermostats), submitOnChange:true + if ((D_switches || D_dimmers || D_thermostats) && (D_alarmType == "2" || (D_alarmType == "1" && D_secondAlarm =="1"))){ + input "D_confirmSwitches", "bool", title: "Confirm switches/thermostats status in voice message", defaultValue: "false" + } + } + section ("Other actions at alarm time"){ + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + input "D_phrase", "enum", title: "Alarm triggers the following phrase", required: false, options: phrases, multiple: false, submitOnChange:true + if (D_phrase && (D_alarmType == "2" || (D_alarmType == "1" && D_secondAlarm =="1"))){ + input "D_confirmPhrase", "bool", title: "Confirm Hello, Home phrase in voice message", defaultValue: "false" + } + } + input "D_triggerMode", "mode", title: "Alarm triggers the following mode", required: false, submitOnChange:true + if (D_triggerMode && (D_alarmType == "2" || (D_alarmType == "1" && D_secondAlarm =="1"))){ + input "D_confirmMode", "bool", title: "Confirm mode in voice message", defaultValue: "false" + } + } + } +} + +page(name: "pageDimmersD", title: "Dimmer Settings") { + section { + input "D_dimmers", "capability.switchLevel", title: "Dim the following...", multiple: true, required: false + input "D_level", "enum", options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]],title: "Set dimmers to this level", multiple: false, required: false + } +} + +page(name: "pageThermostatsD", title: "Thermostat Settings") { + section { + input "D_thermostats", "capability.thermostat", title: "Thermostat to control...", multiple: false, required: false + } + section { + input "D_temperatureH", "number", title: "Heating setpoint", required: false, description: "Temperature when in heat mode" + input "D_temperatureC", "number", title: "Cooling setpoint", required: false, description: "Temperature when in cool mode" + } +} + +def pageWeatherSettingsD() { + dynamicPage(name: "pageWeatherSettingsD", title: "Weather Reporting Settings") { + section { + input "D_includeTemp", "bool", title: "Speak current temperature (from local forecast)", defaultValue: "false" + input "D_localTemp", "capability.temperatureMeasurement", title: "Speak local temperature (from device)", required: false, multiple: false + input "D_humidity", "capability.relativeHumidityMeasurement", title: "Speak local humidity (from device)", required: false, multiple: false + input "D_weatherReport", "bool", title: "Speak today's weather forecast", defaultValue: "false" + input "D_includeSunrise", "bool", title: "Speak today's sunrise", defaultValue: "false" + input "D_includeSunset", "bool", title: "Speak today's sunset", defaultValue: "false" + } + } +} + +page(name: "pageAbout", title: "About ${textAppName()}") { + section { + paragraph "${textVersion()}\n${textCopyright()}\n\n${textLicense()}\n" + } + section("Instructions") { + paragraph textHelp() + } +} + +//-------------------------------------- + +def installed() { + initialize() +} + +def updated() { + unschedule() + unsubscribe() + initialize() +} + +def initialize() { + if (A_alarmType =="1"){ + alarmSoundUri(A_soundAlarm, A_soundLength, 1) + } + if (B_alarmType =="1"){ + alarmSoundUri(B_soundAlarm, B_soundLength, 2) + } + if (C_alarmType =="1"){ + alarmSoundUri(C_soundAlarm, C_soundLength, 3) + } + if (D_alarmType =="1"){ + alarmSoundUri(D_soundAlarm, D_soundLength, 4) + } + + if (alarmSummary && summarySonos) { + subscribe(app, appTouchHandler) + } + if (ScenarioNameA && A_timeStart && A_sonos && A_alarmOn && A_alarmType){ + schedule (A_timeStart, alarm_A) + if (A_musicTrack){ + saveSelectedSong(A_sonos, A_musicTrack, 1) + } + } + if (ScenarioNameB && B_timeStart && B_sonos &&B_alarmOn && B_alarmType){ + schedule (B_timeStart, alarm_B) + if (B_musicTrack){ + saveSelectedSong(B_sonos, B_musicTrack, 2) + } + } + if (ScenarioNameC && C_timeStart && C_sonos && C_alarmOn && C_alarmType){ + schedule (C_timeStart, alarm_C) + if (C_musicTrack){ + saveSelectedSong(C_sonos, C_musicTrack, 3) + } + } + if (ScenarioNameD && D_timeStart && D_sonos && D_alarmOn && D_alarmType){ + schedule (D_timeStart, alarm_D) + if (D_musicTrack){ + saveSelectedSong(D_sonos, D_musicTrack, 4) + } + } +} + +//-------------------------------------- + +def alarm_A() { + if ((!A_mode || A_mode.contains(location.mode)) && getDayOk(A_day)) { + if (A_switches || A_dimmers || A_thermostats) { + def dimLevel = A_level as Integer + A_switches?.on() + A_dimmers?.setLevel(dimLevel) + if (A_thermostats) { + def thermostatState = A_thermostats.currentThermostatMode + if (thermostatState == "auto") { + A_thermostats.setHeatingSetpoint(A_temperatureH) + A_thermostats.setCoolingSetpoint(A_temperatureC) + } + else if (thermostatState == "heat") { + A_thermostats.setHeatingSetpoint(A_temperatureH) + log.info "Set $A_thermostats Heat $A_temperatureH°" + } + else { + A_thermostats.setCoolingSetpoint(A_temperatureC) + log.info "Set $A_thermostats Cool $A_temperatureC°" + } + } + } + if (A_phrase) { + location.helloHome.execute(A_phrase) + } + + if (A_triggerMode && location.mode != A_triggerMode) { + if (location.modes?.find{it.name == A_triggerMode}) { + setLocationMode(A_triggerMode) + } + else { + log.debug "Unable to change to undefined mode '${A_triggerMode}'" + } + } + + if (A_volume) { + A_sonos.setLevel(A_volume) + } + + if (A_alarmType == "2" || (A_alarmType == "1" && A_secondAlarm =="1")) { + state.fullMsgA = "" + if (A_wakeMsg) { + getGreeting(A_wakeMsg, 1) + } + + if (A_weatherReport || A_humidity || A_includeTemp || A_localTemp) { + getWeatherReport(1, A_weatherReport, A_humidity, A_includeTemp, A_localTemp) + } + + if (A_includeSunrise || A_includeSunset) { + getSunriseSunset(1, A_includeSunrise, A_includeSunset) + } + + if ((A_switches || A_dimmers || A_thermostats) && A_confirmSwitches) { + getOnConfimation(A_switches, A_dimmers, A_thermostats, 1) + } + + if (A_phrase && A_confirmPhrase) { + getPhraseConfirmation(1, A_phrase) + } + + if (A_triggerMode && A_confirmMode){ + getModeConfirmation(A_triggerMode, 1) + } + + state.soundA = textToSpeech(state.fullMsgA, true) + } + + if (A_alarmType == "1"){ + if (A_secondAlarm == "1" && state.soundAlarmA){ + A_sonos.playSoundAndTrack (state.soundAlarmA.uri, state.soundAlarmA.duration, state.soundA.uri) + } + if (A_secondAlarm == "2" && state.selectedSongA && state.soundAlarmA){ + A_sonos.playSoundAndTrack (state.soundAlarmA.uri, state.soundAlarmA.duration, state.selectedSongA) + } + if (!A_secondAlarm){ + A_sonos.playTrack(state.soundAlarmA.uri) + } + } + + if (A_alarmType == "2") { + if (A_secondAlarmMusic && state.selectedSongA){ + A_sonos.playSoundAndTrack (state.soundA.uri, state.soundA.duration, state.selectedSongA) + } + else { + A_sonos.playTrack(state.soundA.uri) + } + } + + if (A_alarmType == "3") { + A_sonos.playTrack(state.selectedSongA) + } + } +} + +def alarm_B() { + if ((!B_mode || B_mode.contains(location.mode)) && getDayOk(B_day)) { + if (B_switches || B_dimmers || B_thermostats) { + def dimLevel = B_level as Integer + B_switches?.on() + B_dimmers?.setLevel(dimLevel) + if (B_thermostats) { + def thermostatState = B_thermostats.currentThermostatMode + if (thermostatState == "auto") { + B_thermostats.setHeatingSetpoint(B_temperatureH) + B_thermostats.setCoolingSetpoint(B_temperatureC) + } + else if (thermostatState == "heat") { + B_thermostats.setHeatingSetpoint(B_temperatureH) + log.info "Set $B_thermostats Heat $B_temperatureH°" + } + else { + B_thermostats.setCoolingSetpoint(B_temperatureC) + log.info "Set $B_thermostats Cool $B_temperatureC°" + } + } + } + if (B_phrase) { + location.helloHome.execute(B_phrase) + } + + if (B_triggerMode && location.mode != B_triggerMode) { + if (location.modes?.find{it.name == B_triggerMode}) { + setLocationMode(B_triggerMode) + } + else { + log.debug "Unable to change to undefined mode '${B_triggerMode}'" + } + } + + if (B_volume) { + B_sonos.setLevel(B_volume) + } + + if (B_alarmType == "2" || (B_alarmType == "1" && B_secondAlarm =="1")) { + state.fullMsgB = "" + if (B_wakeMsg) { + getGreeting(B_wakeMsg, 2) + } + + if (B_weatherReport || B_humidity || B_includeTemp || B_localTemp) { + getWeatherReport(2, B_weatherReport, B_humidity, B_includeTemp, B_localTemp) + } + + if (B_includeSunrise || B_includeSunset) { + getSunriseSunset(2, B_includeSunrise, B_includeSunset) + } + + if ((B_switches || B_dimmers || B_thermostats) && B_confirmSwitches) { + getOnConfimation(B_switches, B_dimmers, B_thermostats, 2) + } + + if (B_phrase && B_confirmPhrase) { + getPhraseConfirmation(2, B_phrase) + } + + if (B_triggerMode && B_confirmMode){ + getModeConfirmation(B_triggerMode, 2) + } + + state.soundB = textToSpeech(state.fullMsgB, true) + } + + if (B_alarmType == "1"){ + if (B_secondAlarm == "1" && state.soundAlarmB) { + B_sonos.playSoundAndTrack (state.soundAlarmB.uri, state.soundAlarmB.duration, state.soundB.uri) + } + if (B_secondAlarm == "2" && state.selectedSongB && state.soundAlarmB){ + B_sonos.playSoundAndTrack (state.soundAlarmB.uri, state.soundAlarmB.duration, state.selectedSongB) + } + if (!B_secondAlarm){ + B_sonos.playTrack(state.soundAlarmB.uri) + } + } + + if (B_alarmType == "2") { + if (B_secondAlarmMusic && state.selectedSongB){ + B_sonos.playSoundAndTrack (state.soundB.uri, state.soundB.duration, state.selectedSongB) + } + else { + B_sonos.playTrack(state.soundB.uri) + } + } + + if (B_alarmType == "3") { + B_sonos.playTrack(state.selectedSongB) + } + } +} + +def alarm_C() { + if ((!C_mode || C_mode.contains(location.mode)) && getDayOk(C_day)) { + if (C_switches || C_dimmers || C_thermostats) { + def dimLevel = C_level as Integer + C_switches?.on() + C_dimmers?.setLevel(dimLevel) + if (C_thermostats) { + def thermostatState = C_thermostats.currentThermostatMode + if (thermostatState == "auto") { + C_thermostats.setHeatingSetpoint(C_temperatureH) + C_thermostats.setCoolingSetpoint(C_temperatureC) + } + else if (thermostatState == "heat") { + C_thermostats.setHeatingSetpoint(C_temperatureH) + log.info "Set $C_thermostats Heat $C_temperatureH°" + } + else { + C_thermostats.setCoolingSetpoint(C_temperatureC) + log.info "Set $C_thermostats Cool $C_temperatureC°" + } + } + } + if (C_phrase) { + location.helloHome.execute(C_phrase) + } + + if (C_triggerMode && location.mode != C_triggerMode) { + if (location.modes?.find{it.name == C_triggerMode}) { + setLocationMode(C_triggerMode) + } + else { + log.debug "Unable to change to undefined mode '${C_triggerMode}'" + } + } + + if (C_volume) { + C_sonos.setLevel(C_volume) + } + + if (C_alarmType == "2" || (C_alarmType == "1" && C_secondAlarm =="1")) { + state.fullMsgC = "" + if (C_wakeMsg) { + getGreeting(C_wakeMsg, 3) + } + + if (C_weatherReport || C_humidity || C_includeTemp || C_localTemp) { + getWeatherReport(3, C_weatherReport, C_humidity, C_includeTemp, C_localTemp) + } + + if (C_includeSunrise || C_includeSunset) { + getSunriseSunset(3, C_includeSunrise, C_includeSunset) + } + + if ((C_switches || C_dimmers || C_thermostats) && C_confirmSwitches) { + getOnConfimation(C_switches, C_dimmers, C_thermostats, 3) + } + + if (C_phrase && C_confirmPhrase) { + getPhraseConfirmation(3, C_phrase) + } + + if (C_triggerMode && C_confirmMode){ + getModeConfirmation(C_triggerMode, 3) + } + + state.soundC = textToSpeech(state.fullMsgC, true) + } + + if (C_alarmType == "1"){ + if (C_secondAlarm == "1" && state.soundAlarmC){ + C_sonos.playSoundAndTrack (state.soundAlarmC.uri, state.soundAlarmC.duration, state.soundC.uri) + } + if (C_secondAlarm == "2" && state.selectedSongC && state.soundAlarmC){ + C_sonos.playSoundAndTrack (state.soundAlarmC.uri, state.soundAlarmC.duration, state.selectedSongC) + } + if (!C_secondAlarm){ + C_sonos.playTrack(state.soundAlarmC.uri) + } + } + + if (C_alarmType == "2") { + if (C_secondAlarmMusic && state.selectedSongC){ + C_sonos.playSoundAndTrack (state.soundC.uri, state.soundC.duration, state.selectedSongC) + } + else { + C_sonos.playTrack(state.soundC.uri) + } + } + + if (C_alarmType == "3") { + C_sonos.playTrack(state.selectedSongC) + } + } +} + +def alarm_D() { + if ((!D_mode || D_mode.contains(location.mode)) && getDayOk(D_day)) { + if (D_switches || D_dimmers || D_thermostats) { + def dimLevel = D_level as Integer + D_switches?.on() + D_dimmers?.setLevel(dimLevel) + if (D_thermostats) { + def thermostatState = D_thermostats.currentThermostatMode + if (thermostatState == "auto") { + D_thermostats.setHeatingSetpoint(D_temperatureH) + D_thermostats.setCoolingSetpoint(D_temperatureC) + } + else if (thermostatState == "heat") { + D_thermostats.setHeatingSetpoint(D_temperatureH) + log.info "Set $D_thermostats Heat $D_temperatureH°" + } + else { + D_thermostats.setCoolingSetpoint(D_temperatureC) + log.info "Set $D_thermostats Cool $D_temperatureC°" + } + } + } + if (D_phrase) { + location.helloHome.execute(D_phrase) + } + + if (D_triggerMode && location.mode != D_triggerMode) { + if (location.modes?.find{it.name == D_triggerMode}) { + setLocationMode(D_triggerMode) + } + else { + log.debug "Unable to change to undefined mode '${D_triggerMode}'" + } + } + + if (D_volume) { + D_sonos.setLevel(D_volume) + } + + if (D_alarmType == "2" || (D_alarmType == "1" && D_secondAlarm =="1")) { + state.fullMsgD = "" + if (D_wakeMsg) { + getGreeting(D_wakeMsg, 4) + } + + if (D_weatherReport || D_humidity || D_includeTemp || D_localTemp) { + getWeatherReport(4, D_weatherReport, D_humidity, D_includeTemp, D_localTemp) + } + + if (D_includeSunrise || D_includeSunset) { + getSunriseSunset(4, D_includeSunrise, D_includeSunset) + } + + if ((D_switches || D_dimmers || D_thermostats) && D_confirmSwitches) { + getOnConfimation(D_switches, D_dimmers, D_thermostats, 4) + } + + if (D_phrase && D_confirmPhrase) { + getPhraseConfirmation(4, D_phrase) + } + + if (D_triggerMode && D_confirmMode){ + getModeConfirmation(D_triggerMode, 4) + } + + state.soundD = textToSpeech(state.fullMsgD, true) + } + + if (D_alarmType == "1"){ + if (D_secondAlarm == "1" && state.soundAlarmD){ + D_sonos.playSoundAndTrack (state.soundAlarmD.uri, state.soundAlarmD.duration, state.soundD.uri) + } + if (D_secondAlarm == "2" && state.selectedSongD && state.soundAlarmD){ + D_sonos.playSoundAndTrack (state.soundAlarmD.uri, state.soundAlarmD.duration, state.selectedSongD) + } + if (!D_secondAlarm){ + D_sonos.playTrack(state.soundAlarmD.uri) + } + } + + if (D_alarmType == "2") { + if (D_secondAlarmMusic && state.selectedSongD){ + D_sonos.playSoundAndTrack (state.soundD.uri, state.soundD.duration, state.selectedSongD) + } + else { + D_sonos.playTrack(state.soundD.uri) + } + } + + if (D_alarmType == "3") { + D_sonos.playTrack(state.selectedSongD) + } + } +} + +def appTouchHandler(evt){ + if (!summaryMode || summaryMode.contains(location.mode)) { + state.summaryMsg = "The following is a summary of the alarm settings. " + getSummary (A_alarmOn, ScenarioNameA, A_timeStart, 1) + getSummary (B_alarmOn, ScenarioNameB, B_timeStart, 2) + getSummary (C_alarmOn, ScenarioNameC, C_timeStart, 3) + getSummary (D_alarmOn, ScenarioNameD, D_timeStart, 4) + + log.debug "Summary message = ${state.summaryMsg}" + def summarySound = textToSpeech(state.summaryMsg, true) + if (summaryVolume) { + summarySonos.setLevel(summaryVolume) + } + summarySonos.playTrack(summarySound.uri) + } +} + +def getSummary (alarmOn, scenarioName, timeStart, num){ + if (alarmOn && scenarioName) { + state.summaryMsg = "${state.summaryMsg} Alarm ${num}, ${scenarioName}, set for ${parseDate(timeStart,"", "h:mm a" )}, is enabled. " + } + else if (summaryDisabled && !alarmOn && scenarioName) { + state.summaryMsg = "${state.summaryMsg} Alarm ${num}, ${scenarioName}, set for ${parseDate(timeStart,"", "h:mm a")}, is disabled. " + } + else if (summaryDisabled && !scenarioName) { + state.summaryMsg = "${state.summaryMsg} Alarm ${num} is not configured. " + } +} + +//-------------------------------------- + +def getDesc(timeStart, sonos, day, mode) { + def desc = "Tap to set alarm" + if (timeStart) { + desc = "Alarm set to " + parseDate(timeStart,"", "h:mm a") +" on ${sonos}" + + def dayListSize = day ? day.size() : 7 + + if (day && dayListSize < 7) { + desc = desc + " on" + for (dayName in day) { + desc = desc + " ${dayName}" + dayListSize = dayListSize -1 + if (dayListSize) { + desc = "${desc}, " + } + } + } + else { + desc = desc + " every day" + } + + if (mode) { + def modeListSize = mode.size() + def modePrefix =" in the following modes: " + if (modeListSize == 1) { + modePrefix = " in the following mode: " + } + desc = desc + "${modePrefix}" + for (modeName in mode) { + desc = desc + "'${modeName}'" + modeListSize = modeListSize -1 + if (modeListSize) { + desc = "${desc}, " + } + else { + desc = "${desc}" + } + } + } + else { + desc = desc + " in all modes" + } + } + desc +} +def greyOut(scenario, sonos, alarmTime, alarmOn, alarmType){ + def result = scenario && sonos && alarmTime && alarmOn && alarmType ? "complete" : "" +} + +def greyOut1(param1, param2, param3, param4, param5, param6){ + def result = param1 || param2 || param3 || param4 || param5 || param6 ? "complete" : "" +} + +def getWeatherDesc(param1, param2, param3, param4, param5, param6) { + def title = param1 || param2 || param3 || param4 || param5 || param6 ? "Tap to edit weather reporting options" : "Tap to setup weather reporting options" +} + +def greyOutOption(param){ + def result = param ? "complete" : "" +} + +def getTitle(scenario, num) { + def title = scenario ? scenario : "Alarm ${num} not configured" +} + +def dimmerDesc(dimmer){ + def desc = dimmer ? "Tap to edit dimmer settings" : "Tap to set dimmer setting" +} + +def thermostatDesc(thermostat, heating, cooling){ + def tempText + if (heating || cooling){ + if (heating){ + tempText = "${heating} heat" + } + if (cooling){ + tempText = "${cooling} cool" + } + if (heating && cooling) { + tempText ="${heating} heat / ${cooling} cool" + } + } + else { + tempText="Tap to edit thermostat settings" + } + + def desc = thermostat ? "${tempText}" : "Tap to set thermostat settings" + return desc +} + +private getDayOk(dayList) { + def result = true + if (dayList) { + result = dayList.contains(getDay()) + } + result +} + +private getDay(){ + def df = new java.text.SimpleDateFormat("EEEE") + if (location.timeZone) { + df.setTimeZone(location.timeZone) + } + else { + df.setTimeZone(TimeZone.getTimeZone("America/New_York")) + } + def day = df.format(new Date()) +} + +private parseDate(date, epoch, type){ + def parseDate = "" + if (epoch){ + long longDate = Long.valueOf(epoch).longValue() + parseDate = new Date(longDate).format("yyyy-MM-dd'T'HH:mm:ss.SSSZ", location.timeZone) + } + else { + parseDate = date + } + new Date().parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", parseDate).format("${type}", timeZone(parseDate)) +} + +private getSunriseSunset(scenario, includeSunrise, includeSunset){ + if (location.timeZone || zipCode) { + def todayDate = new Date() + def s = getSunriseAndSunset(zipcode: zipCode, date: todayDate) + def riseTime = parseDate("", s.sunrise.time, "h:mm a") + def setTime = parseDate ("", s.sunset.time, "h:mm a") + def msg = "" + def currTime = now() + def verb1 = currTime >= s.sunrise.time ? "rose" : "will rise" + def verb2 = currTime >= s.sunset.time ? "set" : "will set" + + if (includeSunrise && includeSunset) { + msg = "The sun ${verb1} this morning at ${riseTime} and ${verb2} at ${setTime}. " + } + else if (includeSunrise && !includeSunset) { + msg = "The sun ${verb1} this morning at ${riseTime}. " + } + else if (!includeSunrise && includeSunset) { + msg = "The sun ${verb2} tonight at ${setTime}. " + } + compileMsg(msg, scenario) + } + else { + msg = "Please set the location of your hub with the SmartThings mobile app, or enter a zip code to receive sunset and sunrise information. " + compileMsg(msg, scenario) + } +} + +private getGreeting(msg, scenario) { + def day = getDay() + def time = parseDate("", now(), "h:mm a") + def month = parseDate("", now(), "MMMM") + def year = parseDate("", now(), "yyyy") + def dayNum = parseDate("", now(), "dd") + msg = msg.replace('%day%', day) + msg = msg.replace('%date%', "${month} ${dayNum}, ${year}") + msg = msg.replace('%time%', "${time}") + msg = "${msg} " + compileMsg(msg, scenario) +} + +private getWeatherReport(scenario, weatherReport, humidity, includeTemp, localTemp) { + if (location.timeZone || zipCode) { + def isMetric = location.temperatureScale == "C" + def sb = new StringBuilder() + + if (includeTemp){ + def current = getWeatherFeature("conditions", zipCode) + if (isMetric) { + sb << "The current temperature is ${Math.round(current.current_observation.temp_c)} degrees. " + } + else { + sb << "The current temperature is ${Math.round(current.current_observation.temp_f)} degrees. " + } + } + + if (localTemp){ + sb << "The local temperature is ${Math.round(localTemp.currentTemperature)} degrees. " + } + + if (humidity) { + sb << "The local relative humidity is ${humidity.currentValue("humidity")}%. " + } + + if (weatherReport) { + def weather = getWeatherFeature("forecast", zipCode) + + sb << "Today's forecast is " + if (isMetric) { + sb << weather.forecast.txt_forecast.forecastday[0].fcttext_metric + } + else { + sb << weather.forecast.txt_forecast.forecastday[0].fcttext + } + } + + def msg = sb.toString() + msg = msg.replaceAll(/([0-9]+)C/,'$1 degrees') + msg = msg.replaceAll(/([0-9]+)F/,'$1 degrees') + compileMsg(msg, scenario) + } + else { + msg = "Please set the location of your hub with the SmartThings mobile app, or enter a zip code to receive weather forecasts." + compileMsg(msg, scenario) + } +} + +private getOnConfimation(switches, dimmers, thermostats, scenario) { + def msg = "" + if ((switches || dimmers) && !thermostats) { + msg = "All switches" + } + if (!switches && !dimmers && thermostats) { + msg = "All Thermostats" + } + if ((switches || dimmers) && thermostats) { + msg = "All switches and thermostats" + } + msg = "${msg} are now on and set. " + compileMsg(msg, scenario) +} + +private getPhraseConfirmation(scenario, phrase) { + def msg="The Smart Things Hello Home phrase, ${phrase}, has been activated. " + compileMsg(msg, scenario) +} + +private getModeConfirmation(mode, scenario) { + def msg="The Smart Things mode is now being set to, ${mode}. " + compileMsg(msg, scenario) +} + +private compileMsg(msg, scenario) { + log.debug "msg = ${msg}" + if (scenario == 1) {state.fullMsgA = state.fullMsgA + "${msg}"} + if (scenario == 2) {state.fullMsgB = state.fullMsgB + "${msg}"} + if (scenario == 3) {state.fullMsgC = state.fullMsgC + "${msg}"} + if (scenario == 4) {state.fullMsgD = state.fullMsgD + "${msg}"} +} + +private alarmSoundUri(selection, length, scenario){ + def soundUri = "" + def soundLength = "" + switch(selection) { + case "1": + soundLength = length >0 && length < 8 ? length : 8 + soundUri = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/AlarmSounds/AlarmAlien.mp3", duration: "${soundLength}"] + break + case "2": + soundLength = length >0 && length < 12 ? length : 12 + soundUri = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/AlarmSounds/AlarmBell.mp3", duration: "${soundLength}"] + break + case "3": + soundLength = length >0 && length < 20 ? length : 20 + soundUri = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/AlarmSounds/AlarmBuzzer.mp3", duration: "${soundLength}"] + break + case "4": + soundLength = length >0 && length < 20 ? length : 20 + soundUri = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/AlarmSounds/AlarmFire.mp3", duration: "${soundLength}"] + break + case "5": + soundLength = length >0 && length < 2 ? length : 2 + soundUri = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/AlarmSounds/AlarmRooster.mp3", duration: "${soundLength}"] + break + case "6": + soundLength = length >0 && length < 20 ? length : 20 + soundUri = [uri: "https://raw.githubusercontent.com/MichaelStruck/SmartThings/master/Other-SmartApps/Talking-Alarm-Clock/AlarmSounds/AlarmSiren.mp3", duration: "${soundLength}"] + break + } + if (scenario == 1) {state.soundAlarmA = soundUri} + if (scenario == 2) {state.soundAlarmB = soundUri} + if (scenario == 3) {state.soundAlarmC = soundUri} + if (scenario == 4) {state.soundAlarmD = soundUri} +} + +//Sonos Aquire Track from SmartThings code +private songOptions(sonos, scenario) { + if (sonos){ + // Make sure current selection is in the set + def options = new LinkedHashSet() + if (scenario == 1){ + if (state.selectedSongA?.station) { + options << state.selectedSongA.station + } + else if (state.selectedSongA?.description) { + options << state.selectedSongA.description + } + } + if (scenario == 2){ + if (state.selectedSongB?.station) { + options << state.selectedSongB.station + } + else if (state.selectedSongB?.description) { + options << state.selectedSongB.description + } + } + if (scenario == 3){ + if (state.selectedSongC?.station) { + options << state.selectedSongC.station + } + else if (state.selectedSongC?.description) { + options << state.selectedSongC.description + } + } + if (scenario == 4){ + if (state.selectedSongD?.station) { + options << state.selectedSongD.station + } + else if (state.selectedSongD?.description) { + options << state.selectedSongD.description + } + } + // Query for recent tracks + def states = sonos.statesSince("trackData", new Date(0), [max:30]) + def dataMaps = states.collect{it.jsonValue} + options.addAll(dataMaps.collect{it.station}) + + log.trace "${options.size()} songs in list" + options.take(20) as List + } +} + +private saveSelectedSong(sonos, song, scenario) { + try { + def thisSong = song + log.info "Looking for $thisSong" + def songs = sonos.statesSince("trackData", new Date(0), [max:30]).collect{it.jsonValue} + log.info "Searching ${songs.size()} records" + + def data = songs.find {s -> s.station == thisSong} + log.info "Found ${data?.station}" + if (data) { + if (scenario == 1) {state.selectedSongA = data} + if (scenario == 2) {state.selectedSongB = data} + if (scenario == 3) {state.selectedSongC = data} + if (scenario == 4) {state.selectedSongD = data} + log.debug "Selected song for Scenario ${scenario} = ${data}" + } + else if (song == state.selectedSongA?.station || song == state.selectedSongB?.station || song == state.selectedSongC?.station || song == state.selectedSongD?.station) { + log.debug "Selected existing entry '$song', which is no longer in the last 20 list" + } + else { + log.warn "Selected song '$song' not found" + } + } + catch (Throwable t) { + log.error t + } +} + +//Version/Copyright/Information/Help + +private def textAppName() { + def text = "Talking Alarm Clock" +} + +private def textVersion() { + def text = "Version 1.4.5 (06/17/2015)" +} + +private def textCopyright() { + def text = "Copyright © 2015 Michael Struck" +} + +private def textLicense() { + def text = + "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"+ + "\n\n"+ + " http://www.apache.org/licenses/LICENSE-2.0"+ + "\n\n"+ + "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." +} + +private def textHelp() { + def text = + "Within each alarm scenario, choose a Sonos speaker, an alarm time and alarm type along with " + + "switches, dimmers and thermostat to control when the alarm is triggered. Hello, Home phrases and modes can be triggered at alarm time. "+ + "You also have the option of setting up different alarm sounds, tracks and a personalized spoken greeting that can include a weather report. " + + "Variables that can be used in the voice greeting include %day%, %time% and %date%.\n\n"+ + "From the main SmartApp convenience page, tapping the 'Talking Alarm Clock' icon (if enabled within the app) will "+ + "speak a summary of the alarms enabled or disabled without having to go into the application itself. This " + + "functionality is optional and can be configured from the main setup page." +} + diff --git a/official/tcp-bulbs-connect.groovy b/official/tcp-bulbs-connect.groovy new file mode 100755 index 0000000..69b6e4c --- /dev/null +++ b/official/tcp-bulbs-connect.groovy @@ -0,0 +1,663 @@ +/** + * TCP Bulbs (Connect) + * + * Copyright 2014 Todd Wackford + * + * 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. + * + */ + +import java.security.MessageDigest; + +private apiUrl() { "https://tcp.greenwavereality.com/gwr/gop.php?" } + +definition( + name: "Tcp Bulbs (Connect)", + namespace: "wackford", + author: "SmartThings", + description: "Connect your TCP bulbs to SmartThings using Cloud to Cloud integration. You must create a remote login acct on TCP Mobile App.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/tcp.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/tcp@2x.png", + singleInstance: true +) + + +preferences { + def msg = """Tap 'Next' after you have entered in your TCP Mobile remote credentials. + +Once your credentials are accepted, SmartThings will scan your TCP installation for Bulbs.""" + + page(name: "selectDevices", title: "Connect Your TCP Lights to SmartThings", install: false, uninstall: true, nextPage: "chooseBulbs") { + section("TCP Connected Remote Credentials") { + input "username", "text", title: "Enter TCP Remote Email/UserName", required: true + input "password", "password", title: "Enter TCP Remote Password", required: true + paragraph msg + } + } + + page(name: "chooseBulbs", title: "Choose Bulbs to Control With SmartThings", content: "initialize") +} + +def installed() { + debugOut "Installed with settings: ${settings}" + + unschedule() + unsubscribe() + + setupBulbs() + + def cron = "0 11 23 * * ?" + log.debug "schedule('$cron', syncronizeDevices)" + schedule(cron, syncronizeDevices) +} + +def updated() { + debugOut "Updated with settings: ${settings}" + + unschedule() + + setupBulbs() + + def cron = "0 11 23 * * ?" + log.debug "schedule('$cron', syncronizeDevices)" + schedule(cron, syncronizeDevices) +} + +def uninstalled() +{ + unschedule() //in case we have hanging runIn()'s +} + +private removeChildDevices(delete) +{ + debugOut "deleting ${delete.size()} bulbs" + debugOut "deleting ${delete}" + delete.each { + deleteChildDevice(it.device.deviceNetworkId) + } +} + +def uninstallFromChildDevice(childDevice) +{ + def errorMsg = "uninstallFromChildDevice was called and " + if (!settings.selectedBulbs) { + debugOut errorMsg += "had empty list passed in" + return + } + + def dni = childDevice.device.deviceNetworkId + + if ( !dni ) { + debugOut errorMsg += "could not find dni of device" + return + } + + def newDeviceList = settings.selectedBulbs - dni + app.updateSetting("selectedBulbs", newDeviceList) + + debugOut errorMsg += "completed succesfully" +} + + +def setupBulbs() { + debugOut "In setupBulbs" + + def bulbs = state.devices + def deviceFile = "TCP Bulb" + + selectedBulbs.each { did -> + //see if this is a selected bulb and install it if not already + def d = getChildDevice(did) + + if(!d) { + def newBulb = bulbs.find { (it.did) == did } + d = addChildDevice("wackford", deviceFile, did, null, [name: "${newBulb?.name}", label: "${newBulb?.name}", completedSetup: true]) + + /*if ( isRoom(did) ) { //change to the multi light group icon for a room device + d.setIcon("switch", "on", "st.lights.multi-light-bulb-on") + d.setIcon("switch", "off", "st.lights.multi-light-bulb-off") + d.save() + }*/ + + } else { + debugOut "We already added this device" + } + } + + // Delete any that are no longer in settings + def delete = getChildDevices().findAll { !selectedBulbs?.contains(it.deviceNetworkId) } + removeChildDevices(delete) + + //we want to ensure syncronization between rooms and bulbs + //syncronizeDevices() +} + +def initialize() { + + atomicState.token = "" + + getToken() + + if ( atomicState.token == "error" ) { + return dynamicPage(name:"chooseBulbs", title:"TCP Login Failed!\r\nTap 'Done' to try again", nextPage:"", install:false, uninstall: false) { + section("") {} + } + } else { + "we're good to go" + debugOut "We have Token." + } + + //getGatewayData() //we really don't need anything from the gateway + + deviceDiscovery() + + def options = devicesDiscovered() ?: [] + + def msg = """Tap 'Done' after you have selected the desired devices.""" + + return dynamicPage(name:"chooseBulbs", title:"TCP and SmartThings Connected!", nextPage:"", install:true, uninstall: true) { + section("Tap Below to View Device List") { + input "selectedBulbs", "enum", required:false, title:"Select Bulb/Fixture", multiple:true, options:options + paragraph msg + } + } +} + +def deviceDiscovery() { + def data = "1${atomicState.token}" + + def Params = [ + cmd: "RoomGetCarousel", + data: "${data}", + fmt: "json" + ] + + def cmd = toQueryString(Params) + + def rooms = "" + + apiPost(cmd) { response -> + rooms = response.data.gip.room + } + + debugOut "rooms data = ${rooms}" + + def devices = [] + def bulbIndex = 1 + def lastRoomName = null + def deviceList = [] + + if ( rooms[1] == null ) { + def roomId = rooms.rid + def roomName = rooms.name + devices = rooms.device + if ( devices[1] != null ) { + debugOut "Room Device Data: did:${roomId} roomName:${roomName}" + //deviceList += ["name" : "${roomName}", "did" : "${roomId}", "type" : "room"] + devices.each({ + debugOut "Bulb Device Data: did:${it?.did} room:${roomName} BulbName:${it?.name}" + deviceList += ["name" : "${roomName} ${it?.name}", "did" : "${it?.did}", "type" : "bulb"] + }) + } else { + debugOut "Bulb Device Data: did:${it?.did} room:${roomName} BulbName:${it?.name}" + deviceList += ["name" : "${roomName} ${it?.name}", "did" : "${it?.did}", "type" : "bulb"] + } + } else { + rooms.each({ + devices = it.device + def roomName = it.name + if ( devices[1] != null ) { + def roomId = it?.rid + debugOut "Room Device Data: did:${roomId} roomName:${roomName}" + //deviceList += ["name" : "${roomName}", "did" : "${roomId}", "type" : "room"] + devices.each({ + debugOut "Bulb Device Data: did:${it?.did} room:${roomName} BulbName:${it?.name}" + deviceList += ["name" : "${roomName} ${it?.name}", "did" : "${it?.did}", "type" : "bulb"] + }) + } else { + debugOut "Bulb Device Data: did:${devices?.did} room:${roomName} BulbName:${devices?.name}" + deviceList += ["name" : "${roomName} ${devices?.name}", "did" : "${devices?.did}", "type" : "bulb"] + } + }) + } + devices = ["devices" : deviceList] + state.devices = devices.devices +} + +Map devicesDiscovered() { + def devices = state.devices + def map = [:] + if (devices instanceof java.util.Map) { + devices.each { + def value = "${it?.name}" + def key = it?.did + map["${key}"] = value + } + } else { //backwards compatable + devices.each { + def value = "${it?.name}" + def key = it?.did + map["${key}"] = value + } + } + map +} + +def getGatewayData() { + debugOut "In getGatewayData" + + def data = "1${atomicState.token}" + + def qParams = [ + cmd: "GatewayGetInfo", + data: "${data}", + fmt: "json" + ] + + def cmd = toQueryString(qParams) + + apiPost(cmd) { response -> + debugOut "the gateway reponse is ${response.data.gip.gateway}" + } + +} + +def getToken() { + + atomicState.token = "" + + if (password) { + def hashedPassword = generateMD5(password) + + def data = "1${username}${hashedPassword}" + + def qParams = [ + cmd : "GWRLogin", + data: "${data}", + fmt : "json" + ] + + def cmd = toQueryString(qParams) + + apiPost(cmd) { response -> + def status = response.data.gip.rc + + //sendNotificationEvent("Get token status ${status}") + + if (status != "200") {//success code = 200 + def errorText = response.data.gip.error + debugOut "Error logging into TCP Gateway. Error = ${errorText}" + atomicState.token = "error" + } else { + atomicState.token = response.data.gip.token + } + } + } else { + log.warn "Unable to log into TCP Gateway. Error = Password is null" + atomicState.token = "error" + } +} + +def apiPost(String data, Closure callback) { + //debugOut "In apiPost with data: ${data}" + def params = [ + uri: apiUrl(), + body: data + ] + + httpPost(params) { + response -> + def rc = response.data.gip.rc + + if ( rc == "200" ) { + debugOut ("Return Code = ${rc} = Command Succeeded.") + callback.call(response) + + } else if ( rc == "401" ) { + debugOut "Return Code = ${rc} = Error: User not logged in!" //Error code from gateway + log.debug "Refreshing Token" + getToken() + //callback.call(response) //stubbed out so getToken works (we had race issue) + + } else { + log.error "Return Code = ${rc} = Error!" //Error code from gateway + sendNotificationEvent("TCP Lighting is having Communication Errors. Error code = ${rc}. Check that TCP Gateway is online") + callback.call(response) + } + } +} + + +//this is not working. TCP power reporting is broken. Leave it here for future fix +def calculateCurrentPowerUse(deviceCapability, usePercentage) { + debugOut "In calculateCurrentPowerUse()" + + debugOut "deviceCapability: ${deviceCapability}" + debugOut "usePercentage: ${usePercentage}" + + def calcPower = usePercentage * 1000 + def reportPower = calcPower.round(1) as String + + debugOut "report power = ${reportPower}" + + return reportPower +} + +def generateSha256(String s) { + + MessageDigest digest = MessageDigest.getInstance("SHA-256") + digest.update(s.bytes) + new BigInteger(1, digest.digest()).toString(16).padLeft(40, '0') +} + +def generateMD5(String s) { + MessageDigest digest = MessageDigest.getInstance("MD5") + digest.update(s.bytes); + new BigInteger(1, digest.digest()).toString(16).padLeft(32, '0') +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +def checkDevicesOnline(bulbs) { + debugOut "In checkDevicesOnline()" + + def onlineBulbs = [] + def thisBulb = [] + + bulbs.each { + def dni = it?.did + thisBulb = it + + def data = "1${atomicState.token}${dni}" + + def qParams = [ + cmd: "DeviceGetInfo", + data: "${data}", + fmt: "json" + ] + + def cmd = toQueryString(qParams) + + def bulbData = [] + + apiPost(cmd) { response -> + bulbData = response.data.gip + } + + if ( bulbData?.offline == "1" ) { + debugOut "${it?.name} is offline with offline value of ${bulbData?.offline}" + + } else { + debugOut "${it?.name} is online with offline value of ${bulbData?.offline}" + onlineBulbs += thisBulb + } + } + return onlineBulbs +} + +def syncronizeDevices() { + debugOut "In syncronizeDevices" + + def update = getChildDevices().findAll { selectedBulbs?.contains(it.deviceNetworkId) } + + update.each { + def dni = getChildDevice( it.deviceNetworkId ) + debugOut "dni = ${dni}" + + if (isRoom(dni)) { + pollRoom(dni) + } else { + poll(dni) + } + } +} + +boolean isRoom(dni) { + def device = state.devices.find() {(( it.type == 'room') && (it.did == "${dni}"))} +} + +boolean isBulb(dni) { + def device = state.devices.find() {(( it.type == 'bulb') && (it.did == "${dni}"))} +} + +def debugEvent(message, displayEvent) { + + def results = [ + name: "appdebug", + descriptionText: message, + displayed: displayEvent + ] + log.debug "Generating AppDebug Event: ${results}" + sendEvent (results) + +} + +def debugOut(msg) { + //log.debug msg + //sendNotificationEvent(msg) //Uncomment this for troubleshooting only +} + + +/************************************************************************** + Child Device Call In Methods + **************************************************************************/ +def on(childDevice) { + debugOut "On request from child device" + + def dni = childDevice.device.deviceNetworkId + def data = "" + def cmd = "" + + if ( isRoom(dni) ) { // this is a room, not a bulb + data = "1$atomicState.token${dni}power1" + cmd = "RoomSendCommand" + } else { + data = "1$atomicState.token${dni}power1" + cmd = "DeviceSendCommand" + } + + def qParams = [ + cmd: cmd, + data: "${data}", + fmt: "json" + ] + + cmd = toQueryString(qParams) + + apiPost(cmd) { response -> + debugOut "ON result: ${response.data}" + } + + //we want to ensure syncronization between rooms and bulbs + //runIn(2, "syncronizeDevices") +} + +def off(childDevice) { + debugOut "Off request from child device" + + def dni = childDevice.device.deviceNetworkId + def data = "" + def cmd = "" + + if ( isRoom(dni) ) { // this is a room, not a bulb + data = "1$atomicState.token${dni}power0" + cmd = "RoomSendCommand" + } else { + data = "1$atomicState.token${dni}power0" + cmd = "DeviceSendCommand" + } + + def qParams = [ + cmd: cmd, + data: "${data}", + fmt: "json" + ] + + cmd = toQueryString(qParams) + + apiPost(cmd) { response -> + debugOut "${response.data}" + } + + //we want to ensure syncronization between rooms and bulbs + //runIn(2, "syncronizeDevices") +} + +def setLevel(childDevice, value) { + debugOut "setLevel request from child device" + + def dni = childDevice.device.deviceNetworkId + def data = "" + def cmd = "" + + if ( isRoom(dni) ) { // this is a room, not a bulb + data = "1${atomicState.token}${dni}level${value}" + cmd = "RoomSendCommand" + } else { + data = "1${atomicState.token}${dni}level${value}" + cmd = "DeviceSendCommand" + } + + def qParams = [ + cmd: cmd, + data: "${data}", + fmt: "json" + ] + + cmd = toQueryString(qParams) + + apiPost(cmd) { response -> + debugOut "${response.data}" + } + + //we want to ensure syncronization between rooms and bulbs + //runIn(2, "syncronizeDevices") +} + +// Really not called from child, but called from poll() if it is a room +def pollRoom(dni) { + debugOut "In pollRoom" + def data = "" + def cmd = "" + def roomDeviceData = [] + + data = "1${atomicState.token}${dni}name,power,control,status,state" + cmd = "RoomGetDevices" + + def qParams = [ + cmd: cmd, + data: "${data}", + fmt: "json" + ] + + cmd = toQueryString(qParams) + + apiPost(cmd) { response -> + roomDeviceData = response.data.gip + } + + debugOut "Room Data: ${roomDeviceData}" + + def totalPower = 0 + def totalLevel = 0 + def cnt = 0 + def onCnt = 0 //used to tally on/off states + + roomDeviceData.device.each({ + if ( getChildDevice(it.did) ) { + totalPower += it.other.bulbpower.toInteger() + totalLevel += it.level.toInteger() + onCnt += it.state.toInteger() + cnt += 1 + } + }) + + def avgLevel = totalLevel/cnt + def usingPower = totalPower * (avgLevel / 100) as float + def room = getChildDevice( dni ) + + //the device is a room but we use same type file + sendEvent( dni, [name: "setBulbPower",value:"${totalPower}"] ) //used in child device calcs + + //if all devices in room are on, room is on + if ( cnt == onCnt ) { // all devices are on + sendEvent( dni, [name: "switch",value:"on"] ) + sendEvent( dni, [name: "power",value:usingPower.round(1)] ) + + } else { //if any device in room is off, room is off + sendEvent( dni, [name: "switch",value:"off"] ) + sendEvent( dni, [name: "power",value:0.0] ) + } + + debugOut "Room Using Power: ${usingPower.round(1)}" +} + +def poll(childDevice) { + debugOut "In poll() with ${childDevice}" + + + def dni = childDevice.device.deviceNetworkId + + def bulbData = [] + def data = "" + def cmd = "" + + if ( isRoom(dni) ) { // this is a room, not a bulb + pollRoom(dni) + return + } + + data = "1${atomicState.token}${dni}" + cmd = "DeviceGetInfo" + + def qParams = [ + cmd: cmd, + data: "${data}", + fmt: "json" + ] + + cmd = toQueryString(qParams) + + apiPost(cmd) { response -> + bulbData = response.data.gip + } + + debugOut "This Bulbs Data Return = ${bulbData}" + + def bulb = getChildDevice( dni ) + + //set the devices power max setting to do calcs within the device type + if ( bulbData.other.bulbpower ) + sendEvent( dni, [name: "setBulbPower",value:"${bulbData.other.bulbpower}"] ) + + if (( bulbData.state == "1" ) && ( bulb?.currentValue("switch") != "on" )) + sendEvent( dni, [name: "switch",value:"on"] ) + + if (( bulbData.state == "0" ) && ( bulb?.currentValue("switch") != "off" )) + sendEvent( dni, [name: "switch",value:"off"] ) + + //if ( bulbData.level != bulb?.currentValue("level")) { + // sendEvent( dni, [name: "level",value: "${bulbData.level}"] ) + // sendEvent( dni, [name: "setLevel",value: "${bulbData.level}"] ) + //} + + if (( bulbData.state == "1" ) && ( bulbData.other.bulbpower )) { + def levelSetting = bulbData.level as float + def bulbPowerMax = bulbData.other.bulbpower as float + def calculatedPower = bulbPowerMax * (levelSetting / 100) + sendEvent( dni, [name: "power", value: calculatedPower.round(1)] ) + } + + if (( bulbData.state == "0" ) && ( bulbData.other.bulbpower )) + sendEvent( dni, [name: "power", value: 0.0] ) +} diff --git a/official/tesla-connect.groovy b/official/tesla-connect.groovy new file mode 100755 index 0000000..5022c42 --- /dev/null +++ b/official/tesla-connect.groovy @@ -0,0 +1,420 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Tesla Service Manager + * + * Author: juano23@gmail.com + * Date: 2013-08-15 + */ + +definition( + name: "Tesla (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Integrate your Tesla car with SmartThings.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/tesla-app%402x.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/tesla-app%403x.png", + singleInstance: true +) + +preferences { + page(name: "loginToTesla", title: "Tesla") + page(name: "selectCars", title: "Tesla") +} + +def loginToTesla() { + def showUninstall = username != null && password != null + return dynamicPage(name: "loginToTesla", title: "Connect your Tesla", nextPage:"selectCars", uninstall:showUninstall) { + section("Log in to your Tesla account:") { + input "username", "text", title: "Username", required: true, autoCorrect:false + input "password", "password", title: "Password", required: true, autoCorrect:false + } + section("To use Tesla, SmartThings encrypts and securely stores your Tesla credentials.") {} + } +} + +def selectCars() { + def loginResult = forceLogin() + + if(loginResult.success) + { + def options = carsDiscovered() ?: [] + + return dynamicPage(name: "selectCars", title: "Tesla", install:true, uninstall:true) { + section("Select which Tesla to connect"){ + input(name: "selectedCars", type: "enum", required:false, multiple:true, options:options) + } + } + } + else + { + log.error "login result false" + return dynamicPage(name: "selectCars", title: "Tesla", install:false, uninstall:true, nextPage:"") { + section("") { + paragraph "Please check your username and password" + } + } + } +} + + +def installed() { + log.debug "Installed" + initialize() +} + +def updated() { + log.debug "Updated" + + unsubscribe() + initialize() +} + +def uninstalled() { + removeChildDevices(getChildDevices()) +} + +def initialize() { + + if (selectCars) { + addDevice() + } + + // Delete any that are no longer in settings + def delete = getChildDevices().findAll { !selectedCars } + log.info delete + //removeChildDevices(delete) +} + +//CHILD DEVICE METHODS +def addDevice() { + def devices = getcarList() + log.trace "Adding childs $devices - $selectedCars" + selectedCars.each { dni -> + def d = getChildDevice(dni) + if(!d) { + def newCar = devices.find { (it.dni) == dni } + d = addChildDevice("smartthings", "Tesla", dni, null, [name:"Tesla", label:"Tesla"]) + log.trace "created ${d.name} with id $dni" + } else { + log.trace "found ${d.name} with id $key already exists" + } + } +} + +private removeChildDevices(delete) +{ + log.debug "deleting ${delete.size()} Teslas" + delete.each { + state.suppressDelete[it.deviceNetworkId] = true + deleteChildDevice(it.deviceNetworkId) + state.suppressDelete.remove(it.deviceNetworkId) + } +} + +def getcarList() { + def devices = [] + + def carListParams = [ + uri: "https://portal.vn.teslamotors.com/", + path: "/vehicles", + headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()] + ] + + httpGet(carListParams) { resp -> + log.debug "Getting car list" + if(resp.status == 200) { + def vehicleId = resp.data.id.value[0].toString() + def vehicleVIN = resp.data.vin[0] + def dni = vehicleVIN + ":" + vehicleId + def name = "Tesla [${vehicleId}]" + // CHECK HERE IF MOBILE IS ENABLE + // path: "/vehicles/${vehicleId}/mobile_enabled", + // if (enable) + devices += ["name" : "${name}", "dni" : "${dni}"] + // else return [errorMessage:"Mobile communication isn't enable on all of your vehicles."] + } else if(resp.status == 302) { + // Token expired or incorrect + singleUrl = resp.headers.Location.value + } else { + // ERROR + log.error "car list: unknown response" + } + } + return devices +} + +Map carsDiscovered() { + def devices = getcarList() + log.trace "Map $devices" + def map = [:] + if (devices instanceof java.util.Map) { + devices.each { + def value = "${it?.name}" + def key = it?.dni + map["${key}"] = value + } + } else { //backwards compatable + devices.each { + def value = "${it?.name}" + def key = it?.dni + map["${key}"] = value + } + } + map +} + +def removeChildFromSettings(child) { + def device = child.device + def dni = device.deviceNetworkId + log.debug "removing child device $device with dni ${dni}" + if(!state?.suppressDelete?.get(dni)) + { + def newSettings = settings.cars?.findAll { it != dni } ?: [] + app.updateSetting("cars", newSettings) + } +} + +private forceLogin() { + updateCookie(null) + login() +} + + +private login() { + if(getCookieValueIsValid()) { + return [success:true] + } + return doLogin() +} + +private doLogin() { + def loginParams = [ + uri: "https://portal.vn.teslamotors.com", + path: "/login", + contentType: "application/x-www-form-urlencoded", + body: "user_session%5Bemail%5D=${username}&user_session%5Bpassword%5D=${password}" + ] + + def result = [success:false] + + try { + httpPost(loginParams) { resp -> + if (resp.status == 302) { + log.debug "login 302 json headers: " + resp.headers.collect { "${it.name}:${it.value}" } + def cookie = resp?.headers?.'Set-Cookie'?.split(";")?.getAt(0) + if (cookie) { + log.debug "login setting cookie to $cookie" + updateCookie(cookie) + result.success = true + } else { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + } else { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + result.reason = "Bad login" + } + return result +} + +private command(String dni, String command, String value = '') { + def id = getVehicleId(dni) + def commandPath + switch (command) { + case "flash": + commandPath = "/vehicles/${id}/command/flash_lights" + break; + case "honk": + commandPath = "/vehicles/${id}/command/honk_horn" + break; + case "doorlock": + commandPath = "/vehicles/${id}/command/door_lock" + break; + case "doorunlock": + commandPath = "/vehicles/${id}/command/door_unlock" + break; + case "climaon": + commandPath = "/vehicles/${id}/command/auto_conditioning_start" + break; + case "climaoff": + commandPath = "/vehicles/${id}/command/auto_conditioning_stop" + break; + case "roof": + commandPath = "/vehicles/${id}/command/sun_roof_control?state=${value}" + break; + case "temp": + commandPath = "/vehicles/${id}/command/set_temps?driver_temp=${value}&passenger_temp=${value}" + break; + default: + break; + } + + def commandParams = [ + uri: "https://portal.vn.teslamotors.com", + path: commandPath, + headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()] + ] + + def loginRequired = false + + httpGet(commandParams) { resp -> + + if(resp.status == 403) { + loginRequired = true + } else if (resp.status == 200) { + def data = resp.data + sendNotification(data.toString()) + } else { + log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}" + } + } + if(loginRequired) { throw new Exception("Login Required") } +} + +private honk(String dni) { + def id = getVehicleId(dni) + def honkParams = [ + uri: "https://portal.vn.teslamotors.com", + path: "/vehicles/${id}/command/honk_horn", + headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()] + ] + + def loginRequired = false + + httpGet(honkParams) { resp -> + + if(resp.status == 403) { + loginRequired = true + } else if (resp.status == 200) { + def data = resp.data + } else { + log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}" + } + } + + if(loginRequired) { + throw new Exception("Login Required") + } +} + +private poll(String dni) { + def id = getVehicleId(dni) + def pollParams1 = [ + uri: "https://portal.vn.teslamotors.com", + path: "/vehicles/${id}/command/climate_state", + headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()] + ] + + def childDevice = getChildDevice(dni) + + def loginRequired = false + + httpGet(pollParams1) { resp -> + + if(resp.status == 403) { + loginRequired = true + } else if (resp.status == 200) { + def data = resp.data + childDevice?.sendEvent(name: 'temperature', value: cToF(data.inside_temp).toString()) + if (data.is_auto_conditioning_on) + childDevice?.sendEvent(name: 'clima', value: 'on') + else + childDevice?.sendEvent(name: 'clima', value: 'off') + childDevice?.sendEvent(name: 'thermostatSetpoint', value: cToF(data.driver_temp_setting).toString()) + } else { + log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}" + } + } + + def pollParams2 = [ + uri: "https://portal.vn.teslamotors.com", + path: "/vehicles/${id}/command/vehicle_state", + headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()] + ] + + httpGet(pollParams2) { resp -> + if(resp.status == 403) { + loginRequired = true + } else if (resp.status == 200) { + def data = resp.data + if (data.sun_roof_percent_open == 0) + childDevice?.sendEvent(name: 'roof', value: 'close') + else if (data.sun_roof_percent_open > 0 && data.sun_roof_percent_open < 70) + childDevice?.sendEvent(name: 'roof', value: 'vent') + else if (data.sun_roof_percent_open >= 70 && data.sun_roof_percent_open <= 80) + childDevice?.sendEvent(name: 'roof', value: 'comfort') + else if (data.sun_roof_percent_open > 80 && data.sun_roof_percent_open <= 100) + childDevice?.sendEvent(name: 'roof', value: 'open') + if (data.locked) + childDevice?.sendEvent(name: 'door', value: 'lock') + else + childDevice?.sendEvent(name: 'door', value: 'unlock') + } else { + log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}" + } + } + + def pollParams3 = [ + uri: "https://portal.vn.teslamotors.com", + path: "/vehicles/${id}/command/charge_state", + headers: [Cookie: getCookieValue(), 'User-Agent': validUserAgent()] + ] + + httpGet(pollParams3) { resp -> + if(resp.status == 403) { + loginRequired = true + } else if (resp.status == 200) { + def data = resp.data + childDevice?.sendEvent(name: 'connected', value: data.charging_state.toString()) + childDevice?.sendEvent(name: 'miles', value: data.battery_range.toString()) + childDevice?.sendEvent(name: 'battery', value: data.battery_level.toString()) + } else { + log.error "unknown response: ${resp.status} - ${resp.headers.'Content-Type'}" + } + } + + if(loginRequired) { + throw new Exception("Login Required") + } +} + +private getVehicleId(String dni) { + return dni.split(":").last() +} + +private Boolean getCookieValueIsValid() +{ + // TODO: make a call with the cookie to verify that it works + return getCookieValue() +} + +private updateCookie(String cookie) { + state.cookie = cookie +} + +private getCookieValue() { + state.cookie +} + +def cToF(temp) { + return temp * 1.8 + 32 +} + +private validUserAgent() { + "curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8x zlib/1.2.5" +} \ No newline at end of file diff --git a/official/text-me-when-it-opens.groovy b/official/text-me-when-it-opens.groovy new file mode 100755 index 0000000..cbf8698 --- /dev/null +++ b/official/text-me-when-it-opens.groovy @@ -0,0 +1,58 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Text Me When It Opens + * + * Author: SmartThings + */ +definition( + name: "Text Me When It Opens", + namespace: "smartthings", + author: "SmartThings", + description: "Get a text message sent to your phone when an open/close sensor is opened.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/window_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/window_contact@2x.png" +) + +preferences { + section("When the door opens...") { + input "contact1", "capability.contactSensor", title: "Where?" + } + section("Text me at...") { + input("recipients", "contact", title: "Send notifications to") { + input "phone1", "phone", title: "Phone number?" + } + } +} + +def installed() +{ + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def updated() +{ + unsubscribe() + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def contactOpenHandler(evt) { + log.trace "$evt.value: $evt, $settings" + log.debug "$contact1 was opened, sending text" + if (location.contactBookEnabled) { + sendNotificationToContacts("Your ${contact1.label ?: contact1.name} was opened", recipients) + } + else { + sendSms(phone1, "Your ${contact1.label ?: contact1.name} was opened") + } +} diff --git a/official/text-me-when-theres-motion-and-im-not-here.groovy b/official/text-me-when-theres-motion-and-im-not-here.groovy new file mode 100755 index 0000000..1094255 --- /dev/null +++ b/official/text-me-when-theres-motion-and-im-not-here.groovy @@ -0,0 +1,77 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Text Me When There's Motion and I'm Not Here + * + * Author: SmartThings + */ + +definition( + name: "Text Me When There's Motion and I'm Not Here", + namespace: "smartthings", + author: "SmartThings", + description: "Send a text message when there is motion while you are away.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/intruder_motion-presence.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/intruder_motion-presence@2x.png" +) + +preferences { + section("When there's movement...") { + input "motion1", "capability.motionSensor", title: "Where?" + } + section("While I'm out...") { + input "presence1", "capability.presenceSensor", title: "Who?" + } + section("Text me at...") { + input("recipients", "contact", title: "Send notifications to") { + input "phone1", "phone", title: "Phone number?" + } + } +} + +def installed() { + subscribe(motion1, "motion.active", motionActiveHandler) +} + +def updated() { + unsubscribe() + subscribe(motion1, "motion.active", motionActiveHandler) +} + +def motionActiveHandler(evt) { + log.trace "$evt.value: $evt, $settings" + + if (presence1.latestValue("presence") == "not present") { + // Don't send a continuous stream of text messages + def deltaSeconds = 10 + def timeAgo = new Date(now() - (1000 * deltaSeconds)) + def recentEvents = motion1.eventsSince(timeAgo) + log.debug "Found ${recentEvents?.size() ?: 0} events in the last $deltaSeconds seconds" + def alreadySentSms = recentEvents.count { it.value && it.value == "active" } > 1 + + if (alreadySentSms) { + log.debug "SMS already sent within the last $deltaSeconds seconds" + } else { + if (location.contactBookEnabled) { + log.debug "$motion1 has moved while you were out, sending notifications to: ${recipients?.size()}" + sendNotificationToContacts("${motion1.label} ${motion1.name} moved while you were out", recipients) + } + else { + log.debug "$motion1 has moved while you were out, sending text" + sendSms(phone1, "${motion1.label} ${motion1.name} moved while you were out") + } + } + } else { + log.debug "Motion detected, but presence sensor indicates you are present" + } +} diff --git a/official/the-big-switch.groovy b/official/the-big-switch.groovy new file mode 100755 index 0000000..e631fa0 --- /dev/null +++ b/official/the-big-switch.groovy @@ -0,0 +1,93 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * The Big Switch + * + * Author: SmartThings + * + * Date: 2013-05-01 + */ +definition( + name: "The Big Switch", + namespace: "smartthings", + author: "SmartThings", + description: "Turns on, off and dim a collection of lights based on the state of a specific switch.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_outlet@2x.png" +) + +preferences { + section("When this switch is turned on, off or dimmed") { + input "master", "capability.switch", title: "Where?" + } + section("Turn on or off all of these switches as well") { + input "switches", "capability.switch", multiple: true, required: false + } + section("And turn off but not on all of these switches") { + input "offSwitches", "capability.switch", multiple: true, required: false + } + section("And turn on but not off all of these switches") { + input "onSwitches", "capability.switch", multiple: true, required: false + } + section("And Dim these switches") { + input "dimSwitches", "capability.switchLevel", multiple: true, required: false + } +} + +def installed() +{ + subscribe(master, "switch.on", onHandler) + subscribe(master, "switch.off", offHandler) + subscribe(master, "level", dimHandler) +} + +def updated() +{ + unsubscribe() + subscribe(master, "switch.on", onHandler) + subscribe(master, "switch.off", offHandler) + subscribe(master, "level", dimHandler) +} + +def logHandler(evt) { + log.debug evt.value +} + +def onHandler(evt) { + log.debug evt.value + log.debug onSwitches() + onSwitches()?.on() +} + +def offHandler(evt) { + log.debug evt.value + log.debug offSwitches() + offSwitches()?.off() +} + +def dimHandler(evt) { + log.debug "Dim level: $evt.value" + dimSwitches?.setLevel(evt.value) +} + +private onSwitches() { + if(switches && onSwitches) { switches + onSwitches } + else if(switches) { switches } + else { onSwitches } +} + +private offSwitches() { + if(switches && offSwitches) { switches + offSwitches } + else if(switches) { switches } + else { offSwitches } +} diff --git a/official/the-flasher.groovy b/official/the-flasher.groovy new file mode 100755 index 0000000..60feb4c --- /dev/null +++ b/official/the-flasher.groovy @@ -0,0 +1,150 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * The Flasher + * + * Author: bob + * Date: 2013-02-06 + */ +definition( + name: "The Flasher", + namespace: "smartthings", + author: "SmartThings", + description: "Flashes a set of lights in response to motion, an open/close event, or a switch.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_motion-outlet-contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_motion-outlet-contact@2x.png" +) + +preferences { + section("When any of the following devices trigger..."){ + input "motion", "capability.motionSensor", title: "Motion Sensor?", required: false + input "contact", "capability.contactSensor", title: "Contact Sensor?", required: false + input "acceleration", "capability.accelerationSensor", title: "Acceleration Sensor?", required: false + input "mySwitch", "capability.switch", title: "Switch?", required: false + input "myPresence", "capability.presenceSensor", title: "Presence Sensor?", required: false + } + section("Then flash..."){ + input "switches", "capability.switch", title: "These lights", multiple: true + input "numFlashes", "number", title: "This number of times (default 3)", required: false + } + section("Time settings in milliseconds (optional)..."){ + input "onFor", "number", title: "On for (default 1000)", required: false + input "offFor", "number", title: "Off for (default 1000)", required: false + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + subscribe() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + subscribe() +} + +def subscribe() { + if (contact) { + subscribe(contact, "contact.open", contactOpenHandler) + } + if (acceleration) { + subscribe(acceleration, "acceleration.active", accelerationActiveHandler) + } + if (motion) { + subscribe(motion, "motion.active", motionActiveHandler) + } + if (mySwitch) { + subscribe(mySwitch, "switch.on", switchOnHandler) + } + if (myPresence) { + subscribe(myPresence, "presence", presenceHandler) + } +} + +def motionActiveHandler(evt) { + log.debug "motion $evt.value" + flashLights() +} + +def contactOpenHandler(evt) { + log.debug "contact $evt.value" + flashLights() +} + +def accelerationActiveHandler(evt) { + log.debug "acceleration $evt.value" + flashLights() +} + +def switchOnHandler(evt) { + log.debug "switch $evt.value" + flashLights() +} + +def presenceHandler(evt) { + log.debug "presence $evt.value" + if (evt.value == "present") { + flashLights() + } else if (evt.value == "not present") { + flashLights() + } +} + +private flashLights() { + def doFlash = true + def onFor = onFor ?: 1000 + def offFor = offFor ?: 1000 + def numFlashes = numFlashes ?: 3 + + log.debug "LAST ACTIVATED IS: ${state.lastActivated}" + if (state.lastActivated) { + def elapsed = now() - state.lastActivated + def sequenceTime = (numFlashes + 1) * (onFor + offFor) + doFlash = elapsed > sequenceTime + log.debug "DO FLASH: $doFlash, ELAPSED: $elapsed, LAST ACTIVATED: ${state.lastActivated}" + } + + if (doFlash) { + log.debug "FLASHING $numFlashes times" + state.lastActivated = now() + log.debug "LAST ACTIVATED SET TO: ${state.lastActivated}" + def initialActionOn = switches.collect{it.currentSwitch != "on"} + def delay = 0L + numFlashes.times { + log.trace "Switch on after $delay msec" + switches.eachWithIndex {s, i -> + if (initialActionOn[i]) { + s.on(delay: delay) + } + else { + s.off(delay:delay) + } + } + delay += onFor + log.trace "Switch off after $delay msec" + switches.eachWithIndex {s, i -> + if (initialActionOn[i]) { + s.off(delay: delay) + } + else { + s.on(delay:delay) + } + } + delay += offFor + } + } +} + diff --git a/official/the-gun-case-moved.groovy b/official/the-gun-case-moved.groovy new file mode 100755 index 0000000..1cce9c2 --- /dev/null +++ b/official/the-gun-case-moved.groovy @@ -0,0 +1,66 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * The Gun Case Moved + * + * Author: SmartThings + */ +definition( + name: "The Gun Case Moved", + namespace: "smartthings", + author: "SmartThings", + description: "Send a text when your gun case moves", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/text_accelerometer.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text_accelerometer@2x.png" +) + +preferences { + section("When the gun case moves..."){ + input "accelerationSensor", "capability.accelerationSensor", title: "Where?" + } + section("Text me at..."){ + input("recipients", "contact", title: "Send notifications to") { + input "phone1", "phone", title: "Phone number?" + } + } +} + +def installed() { + subscribe(accelerationSensor, "acceleration.active", accelerationActiveHandler) +} + +def updated() { + unsubscribe() + subscribe(accelerationSensor, "acceleration.active", accelerationActiveHandler) +} + +def accelerationActiveHandler(evt) { + // Don't send a continuous stream of text messages + def deltaSeconds = 5 + def timeAgo = new Date(now() - (1000 * deltaSeconds)) + def recentEvents = accelerationSensor.eventsSince(timeAgo) + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $deltaSeconds seconds" + def alreadySentSms = recentEvents.count { it.value && it.value == "active" } > 1 + + if (alreadySentSms) { + log.debug "SMS already sent to phone within the last $deltaSeconds seconds" + } else { + if (location.contactBookEnabled) { + sendNotificationToContacts("Gun case has moved!", recipients) + } + else { + log.debug "$accelerationSensor has moved, texting phone" + sendSms(phone1, "Gun case has moved!") + } + } +} diff --git a/official/thermostat-auto-off.groovy b/official/thermostat-auto-off.groovy new file mode 100755 index 0000000..fd06c6d --- /dev/null +++ b/official/thermostat-auto-off.groovy @@ -0,0 +1,83 @@ +/** + * HVAC Auto Off + * + * Author: dianoga7@3dgo.net + * Date: 2013-07-21 + */ + +// Automatically generated. Make future change here. +definition( + name: "Thermostat Auto Off", + namespace: "dianoga", + author: "dianoga7@3dgo.net", + description: "Automatically turn off thermostat when windows/doors open. Turn it back on when everything is closed up.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png", + oauth: true +) + +preferences { + section("Control") { + input("thermostat", "capability.thermostat", title: "Thermostat") + } + + section("Open/Close") { + input("sensors", "capability.contactSensor", title: "Sensors", multiple: true) + input("delay", "number", title: "Delay (seconds)") + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + state.changed = false + subscribe(sensors, 'contact', "sensorChange") +} + +def sensorChange(evt) { + log.debug "Desc: $evt.value , $state" + if(evt.value == 'open' && !state.changed) { + unschedule() + runIn(delay, 'turnOff') + } else if(evt.value == 'closed' && state.changed) { + // All closed? + def isOpen = false + for(sensor in sensors) { + if(sensor.id != evt.deviceId && sensor.currentValue('contact') == 'open') { + isOpen = true + } + } + + if(!isOpen) { + unschedule() + runIn(delay, 'restore') + } + } +} + +def turnOff() { + log.debug "Turning off thermostat due to contact open" + state.thermostatMode = thermostat.currentValue("thermostatMode") + thermostat.off() + state.changed = true + log.debug "State: $state" +} + +def restore() { + log.debug "Setting thermostat to $state.thermostatMode" + thermostat.setThermostatMode(state.thermostatMode) + state.changed = false +} \ No newline at end of file diff --git a/official/thermostat-mode-director.groovy b/official/thermostat-mode-director.groovy new file mode 100755 index 0000000..c98f578 --- /dev/null +++ b/official/thermostat-mode-director.groovy @@ -0,0 +1,643 @@ +/** + * Thermostat Mode Director + * Source: https://github.com/tslagle13/SmartThings/blob/master/Director-Series-Apps/Thermostat-Mode-Director/Thermostat%20Mode%20Director.groovy + * + * Version 3.0 + * + * Changelog: + * 2015-05-25 + * --Updated UI to make it look pretty. + * 2015-06-01 + * --Added option for modes to trigger thermostat boost. + * + * Source code can be found here: https://github.com/tslagle13/SmartThings/blob/master/smartapps/tslagle13/vacation-lighting-director.groovy + * + * Copyright 2015 Tim Slagle + * + * 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. + * + */ + +// Automatically generated. Make future change here. +definition( + name: "Thermostat Mode Director", + namespace: "tslagle13", + author: "Tim Slagle", + description: "Changes mode of your thermostat based on the temperature range of a specified temperature sensor and shuts off the thermostat if any windows/doors are open.", + category: "Green Living", + iconUrl: "http://icons.iconarchive.com/icons/icons8/windows-8/512/Science-Temperature-icon.png", + iconX2Url: "http://icons.iconarchive.com/icons/icons8/windows-8/512/Science-Temperature-icon.png" +) + +preferences { + page name:"pageSetup" + page name:"directorSettings" + page name:"ThermostatandDoors" + page name:"ThermostatBoost" + page name:"Settings" + +} + +// Show setup page +def pageSetup() { + + def pageProperties = [ + name: "pageSetup", + title: "Status", + nextPage: null, + install: true, + uninstall: true + ] + + return dynamicPage(pageProperties) { + section("About 'Thermostat Mode Director'"){ + paragraph "Changes mode of your thermostat based on the temperature range of a specified temperature sensor and shuts off the thermostat if any windows/doors are open." + } + section("Setup Menu") { + href "directorSettings", title: "Director Settings", description: "", state:greyedOut() + href "ThermostatandDoors", title: "Thermostat and Doors", description: "", state: greyedOutTherm() + href "ThermostatBoost", title: "Thermostat Boost", description: "", state: greyedOutTherm1() + href "Settings", title: "Settings", description: "", state: greyedOutSettings() + } + section([title:"Options", mobileOnly:true]) { + label title:"Assign a name", required:false + } + } +} + +// Show "Setup" page +def directorSettings() { + + def sensor = [ + name: "sensor", + type: "capability.temperatureMeasurement", + title: "Which?", + multiple: false, + required: true + ] + def setLow = [ + name: "setLow", + type: "decimal", + title: "Low temp?", + required: true + ] + + def cold = [ + name: "cold", + type: "enum", + title: "Mode?", + metadata: [values:["auto", "heat", "cool", "off"]] + ] + + def setHigh = [ + name: "setHigh", + type: "decimal", + title: "High temp?", + required: true + ] + + def hot = [ + name: "hot", + type: "enum", + title: "Mode?", + metadata: [values:["auto", "heat", "cool", "off"]] + ] + + def neutral = [ + name: "neutral", + type: "enum", + title: "Mode?", + metadata: [values:["auto", "heat", "cool", "off"]] + ] + + def pageName = "Setup" + + def pageProperties = [ + name: "directorSettings", + title: "Setup", + nextPage: "pageSetup" + ] + + return dynamicPage(pageProperties) { + + section("Which temperature sensor will control your thermostat?"){ + input sensor + } + section(""){ + paragraph "Here you will setup the upper and lower thresholds for the temperature sensor that will send commands to your thermostat." + } + section("When the temperature falls below this tempurature set mode to..."){ + input setLow + input cold + } + section("When the temperature goes above this tempurature set mode to..."){ + input setHigh + input hot + } + section("When temperature is between the previous temperatures, change mode to..."){ + input neutral + } + } + +} + +def ThermostatandDoors() { + + def thermostat = [ + name: "thermostat", + type: "capability.thermostat", + title: "Which?", + multiple: true, + required: true + ] + def doors = [ + name: "doors", + type: "capability.contactSensor", + title: "Low temp?", + multiple: true, + required: true + ] + + def turnOffDelay = [ + name: "turnOffDelay", + type: "decimal", + title: "Number of minutes", + required: false + ] + + def pageName = "Thermostat and Doors" + + def pageProperties = [ + name: "ThermostatandDoors", + title: "Thermostat and Doors", + nextPage: "pageSetup" + ] + + return dynamicPage(pageProperties) { + + section(""){ + paragraph "If any of the doors selected here are open the thermostat will automatically be turned off and this app will be 'disabled' until all the doors are closed. (This is optional)" + } + section("Choose thermostat...") { + input thermostat + } + section("If these doors/windows are open turn off thermostat regardless of outdoor temperature") { + input doors + } + section("Wait this long before turning the thermostat off (defaults to 1 minute)") { + input turnOffDelay + } + } + +} + +def ThermostatBoost() { + + def thermostat1 = [ + name: "thermostat1", + type: "capability.thermostat", + title: "Which?", + multiple: true, + required: true + ] + def turnOnTherm = [ + name: "turnOnTherm", + type: "enum", + metadata: [values: ["cool", "heat"]], + required: false + ] + + def modes1 = [ + name: "modes1", + type: "mode", + title: "Put thermostat into boost mode when mode is...", + multiple: true, + required: false + ] + + def coolingTemp = [ + name: "coolingTemp", + type: "decimal", + title: "Cooling Temp?", + required: false + ] + + def heatingTemp = [ + name: "heatingTemp", + type: "decimal", + title: "Heating Temp?", + required: false + ] + + def turnOffDelay2 = [ + name: "turnOffDelay2", + type: "decimal", + title: "Number of minutes", + required: false, + defaultValue:30 + ] + + def pageName = "Thermostat Boost" + + def pageProperties = [ + name: "ThermostatBoost", + title: "Thermostat Boost", + nextPage: "pageSetup" + ] + + return dynamicPage(pageProperties) { + + section(""){ + paragraph "Here you can setup the ability to 'boost' your thermostat. In the event that your thermostat is 'off'" + + " and you need to heat or cool your your home for a little bit you can 'touch' the app in the 'My Apps' section to boost your thermostat." + } + section("Choose a thermostats to boost") { + input thermostat1 + } + section("If thermostat is off switch to which mode?") { + input turnOnTherm + } + section("Set the thermostat to the following temps") { + input coolingTemp + input heatingTemp + } + section("For how long?") { + input turnOffDelay2 + } + section("In addtion to 'app touch' the following modes will also boost the thermostat") { + input modes1 + } + } + +} + +// Show "Setup" page +def Settings() { + + def sendPushMessage = [ + name: "sendPushMessage", + type: "enum", + title: "Send a push notification?", + metadata: [values:["Yes","No"]], + required: true, + defaultValue: "Yes" + ] + + def phoneNumber = [ + name: "phoneNumber", + type: "phone", + title: "Send SMS notifications to?", + required: false + ] + + def days = [ + name: "days", + type: "enum", + title: "Only on certain days of the week", + multiple: true, + required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + ] + + def modes = [ + name: "modes", + type: "mode", + title: "Only when mode is", + multiple: true, + required: false + ] + + def pageName = "Settings" + + def pageProperties = [ + name: "Settings", + title: "Settings", + nextPage: "pageSetup" + ] + + return dynamicPage(pageProperties) { + + + section( "Notifications" ) { + input sendPushMessage + input phoneNumber + } + section(title: "More options", hideable: true) { + href "timeIntervalInput", title: "Only during a certain time", description: getTimeLabel(starting, ending), state: greyedOutTime(starting, ending), refreshAfterSelection:true + input days + input modes + } + } + +} + +def installed(){ + init() +} + +def updated(){ + unsubscribe() + init() +} + +def init(){ + state.lastStatus = null + subscribe(app, appTouch) + runIn(60, "temperatureHandler") + subscribe(sensor, "temperature", temperatureHandler) + if(modes1){ + subscribe(location, modeBoostChange) + } + if(doors){ + subscribe(doors, "contact.open", temperatureHandler) + subscribe(doors, "contact.closed", doorCheck) + } +} + +def temperatureHandler(evt) { + if(modeOk && daysOk && timeOk) { + if(setLow > setHigh){ + def temp = setLow + setLow = setHigh + setHigh = temp + } + if (doorsOk) { + def currentTemp = sensor.latestValue("temperature") + if (currentTemp < setLow) { + if (state.lastStatus == "two" || state.lastStatus == "three" || state.lastStatus == null){ + //log.info "Setting thermostat mode to ${cold}" + def msg = "I changed your thermostat mode to ${cold} because temperature is below ${setLow}" + thermostat?."${cold}"() + sendMessage(msg) + } + state.lastStatus = "one" + } + if (currentTemp > setHigh) { + if (state.lastStatus == "one" || state.lastStatus == "three" || state.lastStatus == null){ + //log.info "Setting thermostat mode to ${hot}" + def msg = "I changed your thermostat mode to ${hot} because temperature is above ${setHigh}" + thermostat?."${hot}"() + sendMessage(msg) + } + state.lastStatus = "two" + } + if (currentTemp > setLow && currentTemp < setHigh) { + if (state.lastStatus == "two" || state.lastStatus == "one" || state.lastStatus == null){ + //log.info "Setting thermostat mode to ${neutral}" + def msg = "I changed your thermostat mode to ${neutral} because temperature is neutral" + thermostat?."${neutral}"() + sendMessage(msg) + } + state.lastStatus = "three" + } + } + else{ + def delay = (turnOffDelay != null && turnOffDelay != "") ? turnOffDelay * 60 : 60 + log.debug("Detected open doors. Checking door states again") + runIn(delay, "doorCheck") + } + } +} + +def appTouch(evt) { +if(thermostat1){ + state.lastStatus = "disabled" + def currentCoolSetpoint = thermostat1.latestValue("coolingSetpoint") as String + def currentHeatSetpoint = thermostat1.latestValue("heatingSetpoint") as String + def currentMode = thermostat1.latestValue("thermostatMode") as String + def mode = turnOnTherm + state.currentCoolSetpoint1 = currentCoolSetpoint + state.currentHeatSetpoint1 = currentHeatSetpoint + state.currentMode1 = currentMode + + thermostat1."${mode}"() + thermostat1.setCoolingSetpoint(coolingTemp) + thermostat1.setHeatingSetpoint(heatingTemp) + + thermoShutOffTrigger() + //log.debug("current coolingsetpoint is ${state.currentCoolSetpoint1}") + //log.debug("current heatingsetpoint is ${state.currentHeatSetpoint1}") + //log.debug("current mode is ${state.currentMode1}") +} +} + +def modeBoostChange(evt) { + if(thermostat1 && modes1.contains(location.mode)){ + state.lastStatus = "disabled" + def currentCoolSetpoint = thermostat1.latestValue("coolingSetpoint") as String + def currentHeatSetpoint = thermostat1.latestValue("heatingSetpoint") as String + def currentMode = thermostat1.latestValue("thermostatMode") as String + def mode = turnOnTherm + state.currentCoolSetpoint1 = currentCoolSetpoint + state.currentHeatSetpoint1 = currentHeatSetpoint + state.currentMode1 = currentMode + + thermostat1."${mode}"() + thermostat1.setCoolingSetpoint(coolingTemp) + thermostat1.setHeatingSetpoint(heatingTemp) + + log.debug("current coolingsetpoint is ${state.currentCoolSetpoint1}") + log.debug("current heatingsetpoint is ${state.currentHeatSetpoint1}") + log.debug("current mode is ${state.currentMode1}") + } + else{ + thermoShutOff() + } +} + +def thermoShutOffTrigger() { + //log.info("Starting timer to turn off thermostat") + def delay = (turnOffDelay2 != null && turnOffDelay2 != "") ? turnOffDelay2 * 60 : 60 + state.turnOffTime = now() + log.debug ("Turn off delay is ${delay}") + runIn(delay, "thermoShutOff") + } + +def thermoShutOff(){ + if(state.lastStatus == "disabled"){ + def coolSetpoint = state.currentCoolSetpoint1 + def heatSetpoint = state.currentHeatSetpoint1 + def mode = state.currentMode1 + def coolSetpoint1 = coolSetpoint.replaceAll("\\]", "").replaceAll("\\[", "") + def heatSetpoint1 = heatSetpoint.replaceAll("\\]", "").replaceAll("\\[", "") + def mode1 = mode.replaceAll("\\]", "").replaceAll("\\[", "") + + state.lastStatus = null + //log.info("Returning thermostat back to normal") + thermostat1.setCoolingSetpoint("${coolSetpoint1}") + thermostat1.setHeatingSetpoint("${heatSetpoint1}") + thermostat1."${mode1}"() + temperatureHandler() + } +} + +def doorCheck(evt){ + if (!doorsOk){ + log.debug("doors still open turning off ${thermostat}") + def msg = "I changed your thermostat mode to off because some doors are open" + + if (state.lastStatus != "off"){ + thermostat?.off() + sendMessage(msg) + } + state.lastStatus = "off" + } + + else{ + if (state.lastStatus == "off"){ + state.lastStatus = null + } + temperatureHandler() + } +} + +private sendMessage(msg){ + if (sendPushMessage == "Yes") { + sendPush(msg) + } + if (phoneNumber != null) { + sendSms(phoneNumber, msg) + } +} + +private getAllOk() { + modeOk && daysOk && timeOk && doorsOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +private getDoorsOk() { + def result = !doors || !doors.latestValue("contact").contains("open") + log.trace "doorsOk = $result" + result +} + +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("America/New_York")) + } + 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 + } + + else if (starting){ + result = currTime >= start + } + else if (ending){ + result = currTime <= stop + } + + log.trace "timeOk = $result" + result +} + +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 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 greyedOut(){ + def result = "" + if (sensor) { + result = "complete" + } + result +} + +def greyedOutTherm(){ + def result = "" + if (thermostat) { + result = "complete" + } + result +} + +def greyedOutTherm1(){ + def result = "" + if (thermostat1) { + result = "complete" + } + result +} + +def greyedOutSettings(){ + def result = "" + if (starting || ending || days || modes || sendPushMessage) { + result = "complete" + } + result +} + +def greyedOutTime(starting, ending){ + def result = "" + if (starting || ending) { + result = "complete" + } + result +} + +private anyoneIsHome() { + def result = false + + if(people.findAll { it?.currentPresence == "present" }) { + result = true + } + + log.debug("anyoneIsHome: ${result}") + + return result +} + +page(name: "timeIntervalInput", title: "Only during a certain time", refreshAfterSelection:true) { + section { + input "starting", "time", title: "Starting (both are required)", required: false + input "ending", "time", title: "Ending (both are required)", required: false + } + } diff --git a/official/thermostat-window-check.groovy b/official/thermostat-window-check.groovy new file mode 100755 index 0000000..7ae443d --- /dev/null +++ b/official/thermostat-window-check.groovy @@ -0,0 +1,128 @@ +/** + * Thermostat Window Check + * + * Author: brian@bevey.org + * Date: 9/13/13 + * + * If your heating or cooling system come on, it gives you notice if there are + * any windows or doors left open, preventing the system from working + * optimally. + */ + +definition( + name: "Thermostat Window Check", + namespace: "imbrianj", + author: "brian@bevey.org", + description: "If your heating or cooling system come on, it gives you notice if there are any windows or doors left open, preventing the system from working optimally.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("Things to check?") { + input "sensors", "capability.contactSensor", multiple: true + } + + section("Thermostats to monitor") { + input "thermostats", "capability.thermostat", multiple: true + } + + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + + section("Turn thermostat off automatically?") { + input "turnOffTherm", "enum", metadata: [values: ["Yes", "No"]], required: false + } + + section("Delay to wait before turning thermostat off (defaults to 1 minute)") { + input "turnOffDelay", "decimal", title: "Number of minutes", required: false + } +} + +def installed() { + subscribe(thermostats, "thermostatMode", thermoChange); + subscribe(sensors, "contact.open", windowChange); +} + +def updated() { + unsubscribe() + subscribe(thermostats, "thermostatMode", thermoChange); + subscribe(sensors, "contact.open", windowChange); +} + +def thermoChange(evt) { + if(evt.value == "heat" || + evt.value == "cool") { + def open = sensors.findAll { it?.latestValue("contact") == "open" } + + if(open) { + def plural = open.size() > 1 ? "are" : "is" + send("${open.join(', ')} ${plural} still open and the thermostat just came on.") + + thermoShutOffTrigger() + } + + else { + log.info("Thermostat came on and nothing is open."); + } + } +} + +def windowChange(evt) { + def heating = thermostats.findAll { it?.latestValue("thermostatMode") == "heat" } + def cooling = thermostats.findAll { it?.latestValue("thermostatMode") == "cool" } + + if(heating || cooling) { + def open = sensors.findAll { it?.latestValue("contact") == "open" } + def tempDirection = heating ? "heating" : "cooling" + def plural = open.size() > 1 ? "were" : "was" + send("${open.join(', ')} ${plural} opened and the thermostat is still ${tempDirection}.") + + thermoShutOffTrigger() + } +} + +def thermoShutOffTrigger() { + if(turnOffTherm == "Yes") { + log.info("Starting timer to turn off thermostat") + def delay = (turnOffDelay != null && turnOffDelay != "") ? turnOffDelay * 60 : 60 + state.turnOffTime = now() + + runIn(delay, "thermoShutOff") + } +} + +def thermoShutOff() { + def open = sensors.findAll { it?.latestValue("contact") == "open" } + def tempDirection = heating ? "heating" : "cooling" + def plural = open.size() > 1 ? "are" : "is" + + log.info("Checking if we need to turn thermostats off") + + if(open.size()) { + send("Thermostats turned off: ${open.join(', ')} ${plural} open and thermostats ${tempDirection}.") + log.info("Windows still open, turning thermostats off") + thermostats?.off() + } + + else { + log.info("Looks like everything is shut now - no need to turn off thermostats") + } +} + +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) +} diff --git a/official/turn-it-on-for-5-minutes.groovy b/official/turn-it-on-for-5-minutes.groovy new file mode 100755 index 0000000..5da1ee6 --- /dev/null +++ b/official/turn-it-on-for-5-minutes.groovy @@ -0,0 +1,56 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Turn It On For 5 Minutes + * Turn on a switch when a contact sensor opens and then turn it back off 5 minutes later. + * + * Author: SmartThings + */ +definition( + name: "Turn It On For 5 Minutes", + namespace: "smartthings", + author: "SmartThings", + description: "When a SmartSense Multi is opened, a switch will be turned on, and then turned off after 5 minutes.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet@2x.png" +) + +preferences { + section("When it opens..."){ + input "contact1", "capability.contactSensor" + } + section("Turn on a switch for 5 minutes..."){ + input "switch1", "capability.switch" + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def updated(settings) { + log.debug "Updated with settings: ${settings}" + unsubscribe() + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def contactOpenHandler(evt) { + switch1.on() + def fiveMinuteDelay = 60 * 5 + runIn(fiveMinuteDelay, turnOffSwitch) +} + +def turnOffSwitch() { + switch1.off() +} diff --git a/official/turn-it-on-when-im-here.groovy b/official/turn-it-on-when-im-here.groovy new file mode 100755 index 0000000..d4d95a4 --- /dev/null +++ b/official/turn-it-on-when-im-here.groovy @@ -0,0 +1,62 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Turn It On When I'm Here + * + * Author: SmartThings + */ +definition( + name: "Turn It On When I'm Here", + namespace: "smartthings", + author: "SmartThings", + description: "Turn something on when you arrive and back off when you leave.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_presence-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_presence-outlet@2x.png" +) + +preferences { + section("When I arrive and leave..."){ + input "presence1", "capability.presenceSensor", title: "Who?", multiple: true + } + section("Turn on/off a light..."){ + input "switch1", "capability.switch", multiple: true + } +} + +def installed() +{ + subscribe(presence1, "presence", presenceHandler) +} + +def updated() +{ + unsubscribe() + subscribe(presence1, "presence", presenceHandler) +} + +def presenceHandler(evt) +{ + log.debug "presenceHandler $evt.name: $evt.value" + def current = presence1.currentValue("presence") + log.debug current + def presenceValue = presence1.find{it.currentPresence == "present"} + log.debug presenceValue + if(presenceValue){ + switch1.on() + log.debug "Someone's home!" + } + else{ + switch1.off() + log.debug "Everyone's away." + } +} diff --git a/official/turn-it-on-when-it-opens.groovy b/official/turn-it-on-when-it-opens.groovy new file mode 100755 index 0000000..4a144c3 --- /dev/null +++ b/official/turn-it-on-when-it-opens.groovy @@ -0,0 +1,53 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Turn It On When It Opens + * + * Author: SmartThings + */ +definition( + name: "Turn It On When It Opens", + namespace: "smartthings", + author: "SmartThings", + description: "Turn something on when an open/close sensor opens.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_contact-outlet@2x.png" +) + +preferences { + section("When the door opens..."){ + input "contact1", "capability.contactSensor", title: "Where?" + } + section("Turn on a light..."){ + input "switches", "capability.switch", multiple: true + } +} + + +def installed() +{ + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def updated() +{ + unsubscribe() + subscribe(contact1, "contact.open", contactOpenHandler) +} + +def contactOpenHandler(evt) { + log.debug "$evt.value: $evt, $settings" + log.trace "Turning on switches: $switches" + switches.on() +} + diff --git a/official/turn-off-with-motion.groovy b/official/turn-off-with-motion.groovy new file mode 100755 index 0000000..951db43 --- /dev/null +++ b/official/turn-off-with-motion.groovy @@ -0,0 +1,82 @@ +/** + * Turn Off With Motion + * + * Copyright 2014 Kristopher Kubicki + * + * 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: "Turn Off With Motion", + namespace: "KristopherKubicki", + author: "Kristopher Kubicki", + description: "Turns off a device if there is motion", + 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") + + + +preferences { + section("Turn off when there's movement..."){ + input "motion1", "capability.motionSensor", title: "Where?", multiple: true + } + section("And on when there's been no movement for..."){ + input "minutes1", "number", title: "Minutes?" + } + section("Turn off/on light(s)..."){ + input "switches", "capability.switch", multiple: true + } +} + + +def installed() +{ + subscribe(motion1, "motion", motionHandler) + schedule("0 * * * * ?", "scheduleCheck") +} + +def updated() +{ + unsubscribe() + subscribe(motion1, "motion", motionHandler) + unschedule() + schedule("0 * * * * ?", "scheduleCheck") +} + +def motionHandler(evt) { + log.debug "$evt.name: $evt.value" + + if (evt.value == "active") { + log.debug "turning on lights" + switches.off() + state.inactiveAt = null + } else if (evt.value == "inactive") { + if (!state.inactiveAt) { + state.inactiveAt = now() + } + } +} + +def scheduleCheck() { + log.debug "schedule check, ts = ${state.inactiveAt}" + if (state.inactiveAt) { + def elapsed = now() - state.inactiveAt + def threshold = 1000 * 60 * minutes1 + if (elapsed >= threshold) { + log.debug "turning off lights" + switches.on() + state.inactiveAt = null + } + else { + log.debug "${elapsed / 1000} sec since motion stopped" + } + } +} \ No newline at end of file diff --git a/official/turn-on-only-if-i-arrive-after-sunset.groovy b/official/turn-on-only-if-i-arrive-after-sunset.groovy new file mode 100755 index 0000000..0c04d4c --- /dev/null +++ b/official/turn-on-only-if-i-arrive-after-sunset.groovy @@ -0,0 +1,62 @@ +/** + * Turn On Only If I Arrive After Sunset + * + * Author: Danny De Leo + */ +definition( + name: "Turn On Only If I Arrive After Sunset", + namespace: "smartthings", + author: "SmartThings", + description: "Turn something on only if you arrive after sunset and back off anytime you leave.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/light_presence-outlet.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/light_presence-outlet@2x.png" +) + +preferences { + section("When I arrive and leave..."){ + input "presence1", "capability.presenceSensor", title: "Who?", multiple: true + } + section("Turn on/off a light..."){ + input "switch1", "capability.switch", multiple: true + } +} + +def installed() +{ + subscribe(presence1, "presence", presenceHandler) +} + +def updated() +{ + unsubscribe() + subscribe(presence1, "presence", presenceHandler) +} + +def presenceHandler(evt) +{ + def now = new Date() + def sunTime = getSunriseAndSunset(); + + log.debug "nowTime: $now" + log.debug "riseTime: $sunTime.sunrise" + log.debug "setTime: $sunTime.sunset" + log.debug "presenceHandler $evt.name: $evt.value" + + def current = presence1.currentValue("presence") + log.debug current + def presenceValue = presence1.find{it.currentPresence == "present"} + log.debug presenceValue + if(presenceValue && (now > sunTime.sunset)) { + switch1.on() + log.debug "Welcome home at night!" + } + else if(presenceValue && (now < sunTime.sunset)) { + log.debug "Welcome home at daytime!" + } + else { + switch1.off() + log.debug "Everyone's away." + } +} + diff --git a/official/ubi.groovy b/official/ubi.groovy new file mode 100755 index 0000000..6e8d357 --- /dev/null +++ b/official/ubi.groovy @@ -0,0 +1,588 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Ubi + * + * Author: SmartThings + */ + +definition( + name: "Ubi", + namespace: "smartthings", + author: "SmartThings", + description: "Add your Ubi device to your SmartThings Account", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ubi-app-icn.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ubi-app-icn@2x.png", + oauth: [displayName: "Ubi", displayLink: ""] +) + +preferences { + section("Allow a web application to control these things...") { + input name: "switches", type: "capability.switch", title: "Which Switches?", multiple: true, required: false + input name: "motions", type: "capability.motionSensor", title: "Which Motion Sensors?", multiple: true, required: false + input name: "locks", type: "capability.lock", title: "Which Locks?", multiple: true, required: false + input name: "contactSensors", type: "capability.contactSensor", title: "Which Contact Sensors?", multiple: true, required: false + input name: "presenceSensors", type: "capability.presenceSensor", title: "Which Presence Sensors?", multiple: true, required: false + } +} + +mappings { + path("/list") { + action: [ + GET: "listAll" + ] + } + + path("/events/:id") { + action: [ + GET: "showEvents" + ] + } + + path("/switches") { + action: [ + GET: "listSwitches", + PUT: "updateSwitches", + POST: "updateSwitches" + ] + } + path("/switches/:id") { + action: [ + GET: "showSwitch", + PUT: "updateSwitch", + POST: "updateSwitch" + ] + } + path("/switches/subscriptions") { + log.debug "switches added" + action: [ + POST: "addSwitchSubscription" + ] + } + path("/switches/subscriptions/:id") { + action: [ + DELETE: "removeSwitchSubscription", + GET: "removeSwitchSubscription" + ] + } + + path("/motionSensors") { + action: [ + GET: "listMotions", + PUT: "updateMotions", + POST: "updateMotions" + + ] + } + path("/motionSensors/:id") { + action: [ + GET: "showMotion", + PUT: "updateMotion", + POST: "updateMotion" + ] + } + path("/motionSensors/subscriptions") { + log.debug "motionSensors added" + action: [ + POST: "addMotionSubscription" + ] + } + path("/motionSensors/subscriptions/:id") { + log.debug "motionSensors Deleted" + action: [ + DELETE: "removeMotionSubscription", + GET: "removeMotionSubscription" + ] + } + + path("/locks") { + action: [ + GET: "listLocks", + PUT: "updateLocks", + POST: "updateLocks" + ] + } + path("/locks/:id") { + action: [ + GET: "showLock", + PUT: "updateLock", + POST: "updateLock" + ] + } + path("/locks/subscriptions") { + action: [ + POST: "addLockSubscription" + ] + } + path("/locks/subscriptions/:id") { + action: [ + DELETE: "removeLockSubscription", + GET: "removeLockSubscription" + ] + } + + path("/contactSensors") { + action: [ + GET: "listContactSensors", + PUT: "updateContactSensor", + POST: "updateContactSensor" + ] + } + path("/contactSensors/:id") { + action: [ + GET: "showContactSensor", + PUT: "updateContactSensor", + POST: "updateContactSensor" + ] + } + path("/contactSensors/subscriptions") { + log.debug "contactSensors/subscriptions" + action: [ + POST: "addContactSubscription" + ] + } + path("/contactSensors/subscriptions/:id") { + action: [ + DELETE: "removeContactSensorSubscription", + GET: "removeContactSensorSubscription" + ] + } + + path("/presenceSensors") { + action: [ + GET: "listPresenceSensors", + PUT: "updatePresenceSensor", + POST: "updatePresenceSensor" + ] + } + path("/presenceSensors/:id") { + action: [ + GET: "showPresenceSensor", + PUT: "updatePresenceSensor", + POST: "updatePresenceSensor" + ] + } + path("/presenceSensors/subscriptions") { + log.debug "PresenceSensors/subscriptions" + action: [ + POST: "addPresenceSubscription" + ] + } + path("/presenceSensors/subscriptions/:id") { + action: [ + DELETE: "removePresenceSensorSubscription", + GET: "removePresenceSensorSubscription" + ] + } + + path("/state") { + action: [ + GET: "currentState" + ] + } + + path("/phrases") { + action: [ + GET: "listPhrases" + ] + } + path("/phrases/:phraseName") { + action: [ + GET: "executePhrase", + POST: "executePhrase", + ] + } + +} + +def installed() { +// subscribe(motions, "motion.active", motionOpenHandler) + +// subscribe(contactSensors, "contact.open", contactOpenHandler) +// log.trace "contactSensors Installed" + +} + +def updated() { + +// unsubscribe() +// subscribe(motions, "motion.active", motionOpenHandler) +// subscribe(contactSensors, "contact.open", contactOpenHandler) + +//log.trace "contactSensors Updated" + +} + +def listAll() { + listSwitches() + listMotions() + listLocks() + listContactSensors() + listPresenceSensors() + listPhrasesWithType() +} + +def listContactSensors() { + contactSensors.collect { device(it, "contactSensor") } +} + + +void updateContactSensors() { + updateAll(contactSensors) +} + +def showContactSensor() { + show(contactSensors, "contact") +} + +void updateContactSensor() { + update(contactSensors) +} + +def addContactSubscription() { + log.debug "addContactSensorSubscription, params: ${params}" + addSubscription(contactSensors, "contact") +} + +def removeContactSensorSubscription() { + removeSubscription(contactSensors) +} + + +def listPresenceSensors() { + presenceSensors.collect { device(it, "presenceSensor") } +} + + +void updatePresenceSensors() { + updateAll(presenceSensors) +} + +def showPresenceSensor() { + show(presenceSensors, "presence") +} + +void updatePresenceSensor() { + update(presenceSensors) +} + +def addPresenceSubscription() { + log.debug "addPresenceSensorSubscription, params: ${params}" + addSubscription(presenceSensors, "presence") +} + +def removePresenceSensorSubscription() { + removeSubscription(presenceSensors) +} + + +def listSwitches() { + switches.collect { device(it, "switch") } +} + +void updateSwitches() { + updateAll(switches) +} + +def showSwitch() { + show(switches, "switch") +} + +void updateSwitch() { + update(switches) +} + +def addSwitchSubscription() { + log.debug "addSwitchSubscription, params: ${params}" + addSubscription(switches, "switch") +} + +def removeSwitchSubscription() { + removeSubscription(switches) +} + +def listMotions() { + motions.collect { device(it, "motionSensor") } +} + +void updateMotions() { + updateAll(motions) +} + +def showMotion() { + show(motions, "motion") +} + +void updateMotion() { + update(motions) +} + +def addMotionSubscription() { + + addSubscription(motions, "motion") +} + +def removeMotionSubscription() { + removeSubscription(motions) +} + +def listLocks() { + locks.collect { device(it, "lock") } +} + +void updateLocks() { + updateAll(locks) +} + +def showLock() { + show(locks, "lock") +} + +void updateLock() { + update(locks) +} + +def addLockSubscription() { + addSubscription(locks, "lock") +} + +def removeLockSubscription() { + removeSubscription(locks) +} + +/* +def motionOpenHandler(evt) { +//log.trace "$evt.value: $evt, $settings" + + log.debug "$motions was active, sending push message to user" + //sendPush("Your ${contact1.label ?: contact1.name} was opened") + + + httpPostJson(uri: "http://automatesolutions.ca/test.php", path: '', body: [evt: [value: "motionSensor Active"]]) { + log.debug "Event data successfully posted" + } + +} +def contactOpenHandler(evt) { + //log.trace "$evt.value: $evt, $settings" + + log.debug "$contactSensors was opened, sending push message to user" + //sendPush("Your ${contact1.label ?: contact1.name} was opened") + + + httpPostJson(uri: "http://automatesolutions.ca/test.php", path: '', body: [evt: [value: "ContactSensor Opened"]]) { + log.debug "Event data successfully posted" + } + + +} +*/ + + +def deviceHandler(evt) { + log.debug "~~~~~TEST~~~~~~" + def deviceInfo = state[evt.deviceId] + if (deviceInfo) + { + httpPostJson(uri: deviceInfo.callbackUrl, path: '', body: [evt: [value: evt.value]]) { + log.debug "Event data successfully posted" + } + } + else + { + log.debug "No subscribed device found" + } +} + +def currentState() { + state +} + +def showStates() { + def device = (switches + motions + locks).find { it.id == params.id } + if (!device) + { + httpError(404, "Switch not found") + } + else + { + device.events(params) + } +} + +def listPhrasesWithType() { + location.helloHome.getPhrases().collect { + [ + "id" : it.id, + "label": it.label, + "type" : "phrase" + ] + } +} + +def listPhrases() { + location.helloHome.getPhrases().label +} + +def executePhrase() { + def phraseName = params.phraseName + if (phraseName) + { + location.helloHome.execute(phraseName) + log.debug "executed phrase: $phraseName" + } + else + { + httpError(404, "Phrase not found") + } +} + +private void updateAll(devices) { + def type = params.param1 + def command = request.JSON?.command + if (!devices) { + httpError(404, "Devices not found") + } + if (command){ + devices.each { device -> + executeCommand(device, type, command) + } + } +} + +private void update(devices) { + log.debug "update, request: ${request.JSON}, params: ${params}, devices: $devices.id" + def type = params.param1 + def command = request.JSON?.command + def device = devices?.find { it.id == params.id } + + if (!device) { + httpError(404, "Device not found") + } + + if (command) { + executeCommand(device, type, command) + } +} + +/** + * Validating the command passed by the user based on capability. + * @return boolean + */ +def validateCommand(device, deviceType, command) { + def capabilityCommands = getDeviceCapabilityCommands(device.capabilities) + def currentDeviceCapability = getCapabilityName(deviceType) + if (capabilityCommands[currentDeviceCapability]) { + return command in capabilityCommands[currentDeviceCapability] ? true : false + } else { + // Handling other device types here, which don't accept commands + httpError(400, "Bad request.") + } +} + +/** + * Need to get the attribute name to do the lookup. Only + * doing it for the device types which accept commands + * @return attribute name of the device type + */ +def getCapabilityName(type) { + switch(type) { + case "switches": + return "Switch" + case "locks": + return "Lock" + default: + return type + } +} + +/** + * Constructing the map over here of + * supported commands by device capability + * @return a map of device capability -> supported commands + */ +def getDeviceCapabilityCommands(deviceCapabilities) { + def map = [:] + deviceCapabilities.collect { + map[it.name] = it.commands.collect{ it.name.toString() } + } + return map +} + +/** + * Validates and executes the command + * on the device or devices + */ +def executeCommand(device, type, command) { + if (validateCommand(device, type, command)) { + device."$command"() + } else { + httpError(403, "Access denied. This command is not supported by current capability.") + } +} + +private show(devices, type) { + def device = devices.find { it.id == params.id } + if (!device) + { + httpError(404, "Device not found") + } + else + { + def attributeName = type + + def s = device.currentState(attributeName) + [id: device.id, label: device.displayName, value: s?.value, unitTime: s?.date?.time, type: type] + } +} + +private addSubscription(devices, attribute) { + //def deviceId = request.JSON?.deviceId + //def callbackUrl = request.JSON?.callbackUrl + + log.debug "addSubscription, params: ${params}" + + def deviceId = params.deviceId + def callbackUrl = params.callbackUrl + + def myDevice = devices.find { it.id == deviceId } + if (myDevice) + { + log.debug "Adding switch subscription" + callbackUrl + state[deviceId] = [callbackUrl: callbackUrl] + log.debug "Added state: $state" + def subscription = subscribe(myDevice, attribute, deviceHandler) + if (subscription && subscription.eventSubscription) { + log.debug "Subscription is newly created" + } else { + log.debug "Subscription already exists, returning existing subscription" + subscription = app.subscriptions?.find { it.deviceId == deviceId && it.data == attribute && it.handler == 'deviceHandler' } + } + [ + id: subscription.id, + deviceId: subscription.deviceId, + data: subscription.data, + handler: subscription.handler, + callbackUrl: callbackUrl + ] + } +} + +private removeSubscription(devices) { + def deviceId = params.id + def device = devices.find { it.id == deviceId } + if (device) + { + log.debug "Removing $device.displayName subscription" + state.remove(device.id) + unsubscribe(device) + } +} + +private device(it, type) { + it ? [id: it.id, label: it.displayName, type: type] : null +} diff --git a/official/undead-early-warning.groovy b/official/undead-early-warning.groovy new file mode 100755 index 0000000..f51fa5a --- /dev/null +++ b/official/undead-early-warning.groovy @@ -0,0 +1,51 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * The simplest Undead Early Warning system that could possibly work. ;) + * + * Author: SmartThings + */ +definition( + name: "Undead Early Warning", + namespace: "smartthings", + author: "SmartThings", + description: "Undead Early Warning", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-UndeadEarlyWarning.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/SafetyAndSecurity/App-UndeadEarlyWarning@2x.png" +) + +preferences { + section("When the door opens...") { + input "contacts", "capability.contactSensor", multiple: true, title: "Where could they come from?" + } + section("Turn on the lights!") { + input "switches", "capability.switch", multiple: true + } +} + +def installed() +{ + subscribe(contacts, "contact.open", contactOpenHandler) +} + +def updated() +{ + unsubscribe() + subscribe(contacts, "contact.open", contactOpenHandler) +} + +def contactOpenHandler(evt) { + log.debug "$evt.value: $evt, $settings" + log.trace "The Undead are coming! Turning on the lights: $switches" + switches.on() +} diff --git a/official/unlock-it-when-i-arrive.groovy b/official/unlock-it-when-i-arrive.groovy new file mode 100755 index 0000000..fcac331 --- /dev/null +++ b/official/unlock-it-when-i-arrive.groovy @@ -0,0 +1,57 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Unlock It When I Arrive + * + * Author: SmartThings + * Date: 2013-02-11 + */ + +definition( + name: "Unlock It When I Arrive", + namespace: "smartthings", + author: "SmartThings", + description: "Unlocks the door when you arrive at your location.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png", + oauth: true +) + +preferences { + section("When I arrive..."){ + input "presence1", "capability.presenceSensor", title: "Who?", multiple: true + } + section("Unlock the lock..."){ + input "lock1", "capability.lock", multiple: true + } +} + +def installed() +{ + subscribe(presence1, "presence.present", presence) +} + +def updated() +{ + unsubscribe() + subscribe(presence1, "presence.present", presence) +} + +def presence(evt) +{ + def anyLocked = lock1.count{it.currentLock == "unlocked"} != lock1.size() + if (anyLocked) { + sendPush "Unlocked door due to arrival of $evt.displayName" + lock1.unlock() + } +} diff --git a/official/vacation-lighting-director.groovy b/official/vacation-lighting-director.groovy new file mode 100755 index 0000000..c3187b7 --- /dev/null +++ b/official/vacation-lighting-director.groovy @@ -0,0 +1,458 @@ +/** + * Vacation Lighting Director + * + * Version 2.5 - Moved scheduling over to Cron and added time as a trigger. + * Cleaned up formatting and some typos. + * Updated license. + * Made people option optional + * Added sttement to unschedule on mode change if people option is not selected + * + * Version 2.4 - Added information paragraphs + * + * Source code can be found here: https://github.com/tslagle13/SmartThings/blob/master/smartapps/tslagle13/vacation-lighting-director.groovy + * + * Copyright 2016 Tim Slagle + * + * 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. + * + */ + + +// Automatically generated. Make future change here. +definition( + name: "Vacation Lighting Director", + namespace: "tslagle13", + author: "Tim Slagle", + category: "Safety & Security", + description: "Randomly turn on/off lights to simulate the appearance of a occupied home while you are away.", + iconUrl: "http://icons.iconarchive.com/icons/custom-icon-design/mono-general-2/512/settings-icon.png", + iconX2Url: "http://icons.iconarchive.com/icons/custom-icon-design/mono-general-2/512/settings-icon.png" +) + +preferences { + page name:"pageSetup" + page name:"Setup" + page name:"Settings" + page name: "timeIntervalInput" + +} + +// Show setup page +def pageSetup() { + + def pageProperties = [ + name: "pageSetup", + title: "Status", + nextPage: null, + install: true, + uninstall: true + ] + + return dynamicPage(pageProperties) { + section(""){ + paragraph "This app can be used to make your home seem occupied anytime you are away from your home. " + + "Please use each of the the sections below to setup the different preferences to your liking. " + } + section("Setup Menu") { + href "Setup", title: "Setup", description: "", state:greyedOut() + href "Settings", title: "Settings", description: "", state: greyedOutSettings() + } + section([title:"Options", mobileOnly:true]) { + label title:"Assign a name", required:false + } + } +} + +// Show "Setup" page +def Setup() { + + def newMode = [ + name: "newMode", + type: "mode", + title: "Modes", + multiple: true, + required: true + ] + def switches = [ + name: "switches", + type: "capability.switch", + title: "Switches", + multiple: true, + required: true + ] + + def frequency_minutes = [ + name: "frequency_minutes", + type: "number", + title: "Minutes?", + required: true + ] + + def number_of_active_lights = [ + name: "number_of_active_lights", + type: "number", + title: "Number of active lights", + required: true, + ] + + def pageName = "Setup" + + def pageProperties = [ + name: "Setup", + title: "Setup", + nextPage: "pageSetup" + ] + + return dynamicPage(pageProperties) { + + section(""){ + paragraph "In this section you need to setup the deatils of how you want your lighting to be affected while " + + "you are away. All of these settings are required in order for the simulator to run correctly." + } + section("Simulator Triggers") { + input newMode + href "timeIntervalInput", title: "Times", description: timeIntervalLabel(), refreshAfterSelection:true + } + section("Light switches to turn on/off") { + input switches + } + section("How often to cycle the lights") { + input frequency_minutes + } + section("Number of active lights at any given time") { + input number_of_active_lights + } + } + +} + +// Show "Setup" page +def Settings() { + + def falseAlarmThreshold = [ + name: "falseAlarmThreshold", + type: "decimal", + title: "Default is 2 minutes", + required: false + ] + def days = [ + name: "days", + type: "enum", + title: "Only on certain days of the week", + multiple: true, + required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + ] + + def pageName = "Settings" + + def pageProperties = [ + name: "Settings", + title: "Settings", + nextPage: "pageSetup" + ] + + def people = [ + name: "people", + type: "capability.presenceSensor", + title: "If these people are home do not change light status", + required: false, + multiple: true + ] + + return dynamicPage(pageProperties) { + + section(""){ + paragraph "In this section you can restrict how your simulator runs. For instance you can restrict on which days it will run " + + "as well as a delay for the simulator to start after it is in the correct mode. Delaying the simulator helps with false starts based on a incorrect mode change." + } + section("Delay to start simulator") { + input falseAlarmThreshold + } + section("People") { + paragraph "Not using this setting may cause some lights to remain on when you arrive home" + input people + } + section("More options") { + input days + } + } +} + +def timeIntervalInput() { + dynamicPage(name: "timeIntervalInput") { + section { + input "startTimeType", "enum", title: "Starting at", options: [["time": "A specific time"], ["sunrise": "Sunrise"], ["sunset": "Sunset"]], defaultValue: "time", submitOnChange: true + if (startTimeType in ["sunrise","sunset"]) { + input "startTimeOffset", "number", title: "Offset in minutes (+/-)", range: "*..*", required: false + } + else { + input "starting", "time", title: "Start time", required: false + } + } + section { + input "endTimeType", "enum", title: "Ending at", options: [["time": "A specific time"], ["sunrise": "Sunrise"], ["sunset": "Sunset"]], defaultValue: "time", submitOnChange: true + if (endTimeType in ["sunrise","sunset"]) { + input "endTimeOffset", "number", title: "Offset in minutes (+/-)", range: "*..*", required: false + } + else { + input "ending", "time", title: "End time", required: false + } + } + } +} + + +def installed() { +initialize() +} + +def updated() { + unsubscribe(); + unschedule(); + initialize() +} + +def initialize(){ + + if (newMode != null) { + subscribe(location, modeChangeHandler) + } + if (starting != null) { + schedule(starting, modeChangeHandler) + } + log.debug "Installed with settings: ${settings}" +} + +def modeChangeHandler(evt) { + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 2 * 60 + runIn(delay, scheduleCheck) +} + + +//Main logic to pick a random set of lights from the large set of lights to turn on and then turn the rest off +def scheduleCheck(evt) { + if(allOk){ + log.debug("Running") + // turn off all the switches + switches.off() + + // grab a random switch + def random = new Random() + def inactive_switches = switches + for (int i = 0 ; i < number_of_active_lights ; i++) { + // if there are no inactive switches to turn on then let's break + if (inactive_switches.size() == 0){ + break + } + + // grab a random switch and turn it on + def random_int = random.nextInt(inactive_switches.size()) + inactive_switches[random_int].on() + + // then remove that switch from the pool off switches that can be turned on + inactive_switches.remove(random_int) + } + + // re-run again when the frequency demands it + schedule("0 0/${frequency_minutes} * 1/1 * ? *", scheduleCheck) + } + //Check to see if mode is ok but not time/day. If mode is still ok, check again after frequency period. + else if (modeOk) { + log.debug("mode OK. Running again") + switches.off() + } + //if none is ok turn off frequency check and turn off lights. + else { + if(people){ + //don't turn off lights if anyone is home + if(someoneIsHome){ + log.debug("Stopping Check for Light") + unschedule() + } + else{ + log.debug("Stopping Check for Light and turning off all lights") + switches.off() + unschedule() + } + } + else if (!modeOk) { + unschedule() + } + } +} + + +//below is used to check restrictions +private getAllOk() { + modeOk && daysOk && timeOk && homeIsEmpty +} + + +private getModeOk() { + def result = !newMode || newMode.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + log.trace "daysOk = $result" + result +} + +private getHomeIsEmpty() { + def result = true + + if(people?.findAll { it?.currentPresence == "present" }) { + result = false + } + + log.debug("homeIsEmpty: ${result}") + + return result +} + +private getSomeoneIsHome() { + def result = false + + if(people?.findAll { it?.currentPresence == "present" }) { + result = true + } + + log.debug("anyoneIsHome: ${result}") + + return result +} + +private getTimeOk() { + def result = true + def start = timeWindowStart() + def stop = timeWindowStop() + if (start && stop && location.timeZone) { + result = timeOfDayIsBetween(start, stop, new Date(), location.timeZone) + } + log.trace "timeOk = $result" + result +} + +private timeWindowStart() { + def result = null + if (startTimeType == "sunrise") { + result = location.currentState("sunriseTime")?.dateValue + if (result && startTimeOffset) { + result = new Date(result.time + Math.round(startTimeOffset * 60000)) + } + } + else if (startTimeType == "sunset") { + result = location.currentState("sunsetTime")?.dateValue + if (result && startTimeOffset) { + result = new Date(result.time + Math.round(startTimeOffset * 60000)) + } + } + else if (starting && location.timeZone) { + result = timeToday(starting, location.timeZone) + } + log.trace "timeWindowStart = ${result}" + result +} + +private timeWindowStop() { + def result = null + if (endTimeType == "sunrise") { + result = location.currentState("sunriseTime")?.dateValue + if (result && endTimeOffset) { + result = new Date(result.time + Math.round(endTimeOffset * 60000)) + } + } + else if (endTimeType == "sunset") { + result = location.currentState("sunsetTime")?.dateValue + if (result && endTimeOffset) { + result = new Date(result.time + Math.round(endTimeOffset * 60000)) + } + } + else if (ending && location.timeZone) { + result = timeToday(ending, location.timeZone) + } + log.trace "timeWindowStop = ${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) +} + +private timeIntervalLabel() { + def start = "" + switch (startTimeType) { + case "time": + if (ending) { + start += hhmm(starting) + } + break + case "sunrise": + case "sunset": + start += startTimeType[0].toUpperCase() + startTimeType[1..-1] + if (startTimeOffset) { + start += startTimeOffset > 0 ? "+${startTimeOffset} min" : "${startTimeOffset} min" + } + break + } + + def finish = "" + switch (endTimeType) { + case "time": + if (ending) { + finish += hhmm(ending) + } + break + case "sunrise": + case "sunset": + finish += endTimeType[0].toUpperCase() + endTimeType[1..-1] + if (endTimeOffset) { + finish += endTimeOffset > 0 ? "+${endTimeOffset} min" : "${endTimeOffset} min" + } + break + } + start && finish ? "${start} to ${finish}" : "" +} + +//sets complete/not complete for the setup section on the main dynamic page +def greyedOut(){ + def result = "" + if (switches) { + result = "complete" + } + result +} + +//sets complete/not complete for the settings section on the main dynamic page +def greyedOutSettings(){ + def result = "" + if (people || days || falseAlarmThreshold ) { + result = "complete" + } + result +} diff --git a/official/vinli-home-connect.groovy b/official/vinli-home-connect.groovy new file mode 100755 index 0000000..3bd89fb --- /dev/null +++ b/official/vinli-home-connect.groovy @@ -0,0 +1,189 @@ +/** + * Vinli Home Beta + * + * Copyright 2015 Daniel + * + */ +definition( + name: "Vinli Home Connect", + namespace: "com.vinli.smartthings", + author: "Daniel", + description: "Allows Vinli users to connect their car to SmartThings", + category: "SmartThings Labs", + iconUrl: "https://d3azp77rte0gip.cloudfront.net/smartapps/baeb2e5d-ebd0-49fe-a4ec-e92417ae20bb/images/vinli_oauth_60.png", + iconX2Url: "https://d3azp77rte0gip.cloudfront.net/smartapps/baeb2e5d-ebd0-49fe-a4ec-e92417ae20bb/images/vinli_oauth_120.png", + iconX3Url: "https://d3azp77rte0gip.cloudfront.net/smartapps/baeb2e5d-ebd0-49fe-a4ec-e92417ae20bb/images/vinli_oauth_120.png", + oauth: true) + +preferences { + section ("Allow external service to control these things...") { + input "switches", "capability.switch", multiple: true, required: true + input "locks", "capability.lock", multiple: true, required: true + } +} + +mappings { + + path("/devices") { + action: [ + GET: "listAllDevices" + ] + } + + path("/switches") { + action: [ + GET: "listSwitches" + ] + } + path("/switches/:command") { + action: [ + PUT: "updateSwitches" + ] + } + path("/switches/:id/:command") { + action: [ + PUT: "updateSwitch" + ] + } + path("/locks/:command") { + action: [ + PUT: "updateLocks" + ] + } + path("/locks/:id/:command") { + action: [ + PUT: "updateLock" + ] + } + + path("/devices/:id/:command") { + action: [ + PUT: "commandDevice" + ] + } +} + +// returns a list of all devices +def listAllDevices() { + def resp = [] + switches.each { + resp << [name: it.name, label: it.label, value: it.currentValue("switch"), type: "switch", id: it.id, hub: it.hub?.name] + } + + locks.each { + resp << [name: it.name, label: it.label, value: it.currentValue("lock"), type: "lock", id: it.id, hub: it.hub?.name] + } + return resp +} + +// returns a list like +// [[name: "kitchen lamp", value: "off"], [name: "bathroom", value: "on"]] +def listSwitches() { + def resp = [] + switches.each { + resp << [name: it.displayName, value: it.currentValue("switch"), type: "switch", id: it.id] + } + return resp +} + +void updateLocks() { + // use the built-in request object to get the command parameter + def command = params.command + + if (command) { + + // check that the switch supports the specified command + // If not, return an error using httpError, providing a HTTP status code. + locks.each { + if (!it.hasCommand(command)) { + httpError(501, "$command is not a valid command for all switches specified") + } + } + + // all switches have the comand + // execute the command on all switches + // (note we can do this on the array - the command will be invoked on every element + locks."$command"() + } +} + +void updateLock() { + def command = params.command + + locks.each { + if (!it.hasCommand(command)) { + httpError(400, "$command is not a valid command for all lock specified") + } + + if (it.id == params.id) { + it."$command"() + } + } +} + +void updateSwitch() { + def command = params.command + + switches.each { + if (!it.hasCommand(command)) { + httpError(400, "$command is not a valid command for all switches specified") + } + + if (it.id == params.id) { + it."$command"() + } + } +} + +void commandDevice() { + def command = params.command + def devices = [] + + switches.each { + devices << it + } + + locks.each { + devices << it + } + + devices.each { + if (it.id == params.id) { + if (!it.hasCommand(command)) { + httpError(400, "$command is not a valid command for specified device") + } + it."$command"() + } + } +} + +void updateSwitches() { + // use the built-in request object to get the command parameter + def command = params.command + + if (command) { + + // check that the switch supports the specified command + // If not, return an error using httpError, providing a HTTP status code. + switches.each { + if (!it.hasCommand(command)) { + httpError(400, "$command is not a valid command for all switches specified") + } + } + + // all switches have the comand + // execute the command on all switches + // (note we can do this on the array - the command will be invoked on every element + switches."$command"() + } +} + + def installed() { + log.debug "Installed with settings: ${settings}" +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() +} diff --git a/official/virtual-thermostat.groovy b/official/virtual-thermostat.groovy new file mode 100755 index 0000000..1584820 --- /dev/null +++ b/official/virtual-thermostat.groovy @@ -0,0 +1,143 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Virtual Thermostat + * + * Author: SmartThings + */ +definition( + name: "Virtual Thermostat", + namespace: "smartthings", + author: "SmartThings", + description: "Control a space heater or window air conditioner in conjunction with any temperature sensor, like a SmartSense Multi.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/temp_thermo-switch@2x.png" +) + +preferences { + section("Choose a temperature sensor... "){ + input "sensor", "capability.temperatureMeasurement", title: "Sensor" + } + section("Select the heater or air conditioner outlet(s)... "){ + input "outlets", "capability.switch", title: "Outlets", multiple: true + } + section("Set the desired temperature..."){ + input "setpoint", "decimal", title: "Set Temp" + } + section("When there's been movement from (optional, leave blank to not require motion)..."){ + input "motion", "capability.motionSensor", title: "Motion", required: false + } + section("Within this number of minutes..."){ + input "minutes", "number", title: "Minutes", required: false + } + section("But never go below (or above if A/C) this value with or without motion..."){ + input "emergencySetpoint", "decimal", title: "Emer Temp", required: false + } + section("Select 'heat' for a heater and 'cool' for an air conditioner..."){ + input "mode", "enum", title: "Heating or cooling?", options: ["heat","cool"] + } +} + +def installed() +{ + subscribe(sensor, "temperature", temperatureHandler) + if (motion) { + subscribe(motion, "motion", motionHandler) + } +} + +def updated() +{ + unsubscribe() + subscribe(sensor, "temperature", temperatureHandler) + if (motion) { + subscribe(motion, "motion", motionHandler) + } +} + +def temperatureHandler(evt) +{ + def isActive = hasBeenRecentMotion() + if (isActive || emergencySetpoint) { + evaluate(evt.doubleValue, isActive ? setpoint : emergencySetpoint) + } + else { + outlets.off() + } +} + +def motionHandler(evt) +{ + if (evt.value == "active") { + def lastTemp = sensor.currentTemperature + if (lastTemp != null) { + evaluate(lastTemp, setpoint) + } + } else if (evt.value == "inactive") { + def isActive = hasBeenRecentMotion() + log.debug "INACTIVE($isActive)" + if (isActive || emergencySetpoint) { + def lastTemp = sensor.currentTemperature + if (lastTemp != null) { + evaluate(lastTemp, isActive ? setpoint : emergencySetpoint) + } + } + else { + outlets.off() + } + } +} + +private evaluate(currentTemp, desiredTemp) +{ + log.debug "EVALUATE($currentTemp, $desiredTemp)" + def threshold = 1.0 + if (mode == "cool") { + // air conditioner + if (currentTemp - desiredTemp >= threshold) { + outlets.on() + } + else if (desiredTemp - currentTemp >= threshold) { + outlets.off() + } + } + else { + // heater + if (desiredTemp - currentTemp >= threshold) { + outlets.on() + } + else if (currentTemp - desiredTemp >= threshold) { + outlets.off() + } + } +} + +private hasBeenRecentMotion() +{ + def isActive = false + if (motion && minutes) { + def deltaMinutes = minutes as Long + if (deltaMinutes) { + def motionEvents = motion.eventsSince(new Date(now() - (60000 * deltaMinutes))) + log.trace "Found ${motionEvents?.size() ?: 0} events in the last $deltaMinutes minutes" + if (motionEvents.find { it.value == "active" }) { + isActive = true + } + } + } + else { + isActive = true + } + isActive +} + diff --git a/official/wattvision-manager.groovy b/official/wattvision-manager.groovy new file mode 100755 index 0000000..fbb8034 --- /dev/null +++ b/official/wattvision-manager.groovy @@ -0,0 +1,497 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Wattvision Manager + * + * Author: steve + * Date: 2014-02-13 + */ + +// Automatically generated. Make future change here. +definition( + name: "Wattvision Manager", + namespace: "smartthings", + author: "SmartThings", + description: "Monitor your whole-house energy use by connecting to your Wattvision account", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/wattvision.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/wattvision%402x.png", + oauth: [displayName: "Wattvision", displayLink: "https://www.wattvision.com/"] +) + +preferences { + page(name: "rootPage") +} + +def rootPage() { + def sensors = state.sensors + def hrefState = sensors ? "complete" : "" + def hrefDescription = "" + sensors.each { sensorId, sensorName -> + hrefDescription += "${sensorName}\n" + } + + dynamicPage(name: "rootPage", install: sensors ? true : false, uninstall: true) { + section { + href(url: loginURL(), title: "Connect Wattvision Sensors", style: "embedded", description: hrefDescription, state: hrefState) + } + section { + href(url: "https://www.wattvision.com", title: "Learn More About Wattvision", style: "external", description: null) + } + } +} + +mappings { + path("/access") { + actions: + [ + POST : "setApiAccess", + DELETE: "revokeApiAccess" + ] + } + path("/devices") { + actions: + [ + GET: "listDevices" + ] + } + path("/device/:sensorId") { + actions: + [ + GET : "getDevice", + PUT : "updateDevice", + POST : "createDevice", + DELETE: "deleteDevice" + ] + } + path("/${loginCallbackPath()}") { + actions: + [ + GET: "loginCallback" + ] + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + getDataFromWattvision() + scheduleDataCollection() +} + +def getDataFromWattvision() { + + log.trace "Getting data from Wattvision" + + def children = getChildDevices() + if (!children) { + log.warn "No children. Not collecting data from Wattviwion" + // currently only support one child + return + } + + def endDate = new Date() + def startDate + + if (!state.lastUpdated) { +// log.debug "no state.lastUpdated" + startDate = new Date(hours: endDate.hours - 3) + } else { +// log.debug "parsing state.lastUpdated" + startDate = new Date().parse(smartThingsDateFormat(), state.lastUpdated) + } + + state.lastUpdated = endDate.format(smartThingsDateFormat()) + + children.each { child -> + getDataForChild(child, startDate, endDate) + } + +} + +def getDataForChild(child, startDate, endDate) { + if (!child) { + return + } + + def wattvisionURL = wattvisionURL(child.deviceNetworkId, startDate, endDate) + if (wattvisionURL) { + try { + httpGet(uri: wattvisionURL) { response -> + def json = new org.json.JSONObject(response.data.toString()) + child.addWattvisionData(json) + return "success" + } + } catch (groovyx.net.http.HttpResponseException httpE) { + log.error "Wattvision getDataForChild HttpResponseException: ${httpE} -> ${httpE.response.data}" + //log.debug "wattvisionURL = ${wattvisionURL}" + return "fail" + } catch (e) { + log.error "Wattvision getDataForChild General Exception: ${e}" + //log.debug "wattvisionURL = ${wattvisionURL}" + return "fail" + } + } +} + +def wattvisionURL(senorId, startDate, endDate) { + + log.trace "getting wattvisionURL" + + def wattvisionApiAccess = state.wattvisionApiAccess + if (!wattvisionApiAccess.id || !wattvisionApiAccess.key) { + return null + } + + if (!endDate) { + endDate = new Date() + } + if (!startDate) { + startDate = new Date(hours: endDate.hours - 3) + } + + def diff = endDate.getTime() - startDate.getTime() + if (diff > 259200000) { // 3 days in milliseconds + // Wattvision only allows pulling 3 hours of data at a time + startDate = new Date(hours: endDate.hours - 3) + } else if (diff < 10000) { // 10 seconds in milliseconds + // Wattvision throws errors when the difference between start_time and end_time is 5 seconds or less + // So we are going to make sure that we have a few more seconds of breathing room + use (groovy.time.TimeCategory) { + startDate = endDate - 10.seconds + } + } + + def params = [ + "sensor_id" : senorId, + "api_id" : wattvisionApiAccess.id, + "api_key" : wattvisionApiAccess.key, + "type" : wattvisionDataType ?: "rate", + "start_time": startDate.format(wattvisionDateFormat()), + "end_time" : endDate.format(wattvisionDateFormat()) + ] + + def parameterString = params.collect { key, value -> "${key.encodeAsURL()}=${value.encodeAsURL()}" }.join("&") + def accessURL = wattvisionApiAccess.url ?: "https://www.wattvision.com/api/v0.2/elec" + def url = "${accessURL}?${parameterString}" + +// log.debug "wattvisionURL: ${url}" + return url +} + +def getData() { + state.lastUpdated = new Date().format(smartThingsDateFormat()) +} + +public smartThingsDateFormat() { "yyyy-MM-dd'T'HH:mm:ss.SSSZ" } + +public wattvisionDateFormat() { "yyyy-MM-dd'T'HH:mm:ss" } + +def childMarshaller(child) { + return [ + name : child.name, + label : child.label, + sensor_id: child.deviceNetworkId, + location : child.location.name + ] +} + +// ======================================================== +// ENDPOINTS +// ======================================================== + +def listDevices() { + getChildDevices().collect { childMarshaller(it) } +} + +def getDevice() { + + log.trace "Getting device" + + def child = getChildDevice(params.sensorId) + + if (!child) { + httpError(404, "Device not found") + } + + return childMarshaller(child) +} + +def updateDevice() { + + log.trace "Updating Device with data from Wattvision" + + def body = request.JSON + + def child = getChildDevice(params.sensorId) + + if (!child) { + httpError(404, "Device not found") + } + + child.addWattvisionData(body) + + render([status: 204, data: " "]) +} + +def createDevice() { + + log.trace "Creating Wattvision device" + + if (getChildDevice(params.sensorId)) { + httpError(403, "Device already exists") + } + + def child = addChildDevice("smartthings", "Wattvision", params.sensorId, null, [name: "Wattvision", label: request.JSON.label]) + + child.setGraphUrl(getGraphUrl(params.sensorId)); + + getDataForChild(child, null, null) + + return childMarshaller(child) +} + +def deleteDevice() { + + log.trace "Deleting Wattvision device" + + deleteChildDevice(params.sensorId) + render([status: 204, data: " "]) +} + +def setApiAccess() { + + log.trace "Granting access to Wattvision API" + + def body = request.JSON + + state.wattvisionApiAccess = [ + url: body.url, + id : body.id, + key: body.key + ] + + scheduleDataCollection() + + render([status: 204, data: " "]) +} + +def scheduleDataCollection() { + schedule("* /1 * * * ?", "getDataFromWattvision") // every 1 minute +} + +def revokeApiAccess() { + + log.trace "Revoking access to Wattvision API" + + state.wattvisionApiAccess = [:] + render([status: 204, data: " "]) +} + +public getGraphUrl(sensorId) { + + log.trace "Collecting URL for Wattvision graph" + + def apiId = state.wattvisionApiAccess.id + def apiKey = state.wattvisionApiAccess.key + + // TODO: allow the changing of type? + "http://www.wattvision.com/partners/smartthings/charts?s=${sensorId}&api_id=${apiId}&api_key=${apiKey}&type=w" +} + +// ======================================================== +// SmartThings initiated setup +// ======================================================== + +/* Debug info for Steve / Andrew + +this page: /partners/smartthings/whatswv + - linked from within smartthings, will tell you how to get a wattvision sensor, etc. + - pass the debug flag (?debug=1) to show this text. + +login page: /partners/smartthings/login?callback_url=CALLBACKURL + - open this page, which will require login. + - once login is complete, we call you back at callback_url with: + ?id=&key= + question: will you know which user this is on your end? + +sensor json: /partners/smartthings/sensor_list?api_id=...&api_key=... + - returns a list of sensors and their associated house names, as a json object + - example return value with one sensor id 2, associated with house 'Test's House' + - content type is application/json + - {"2": "Test's House"} + +*/ + +def loginCallback() { + log.trace "loginCallback" + + state.wattvisionApiAccess = [ + id : params.id, + key: params.key + ] + + getSensorJSON(params.id, params.key) + + connectionSuccessful("Wattvision", "https://s3.amazonaws.com/smartapp-icons/Partner/wattvision@2x.png") +} + +private getSensorJSON(id, key) { + log.trace "getSensorJSON" + + def sensorUrl = "${wattvisionBaseURL()}/partners/smartthings/sensor_list?api_id=${id}&api_key=${key}" + + httpGet(uri: sensorUrl) { response -> + + def sensors = [:] + + response.data.each { sensorId, sensorName -> + sensors[sensorId] = sensorName + createChild(sensorId, sensorName) + } + + state.sensors = sensors + + return "success" + } + +} + +def createChild(sensorId, sensorName) { + log.trace "creating Wattvision Child" + + def child = getChildDevice(sensorId) + + if (child) { + log.warn "Device already exists" + } else { + child = addChildDevice("smartthings", "Wattvision", sensorId, null, [name: "Wattvision", label: sensorName]) + } + + child.setGraphUrl(getGraphUrl(sensorId)); + + getDataForChild(child, null, null) + + scheduleDataCollection() + + return childMarshaller(child) +} + +// ======================================================== +// URL HELPERS +// ======================================================== + +private loginURL() { "${wattvisionBaseURL()}${loginPath()}" } + +private wattvisionBaseURL() { "https://www.wattvision.com" } + +private loginPath() { "/partners/smartthings/login?callback_url=${loginCallbackURL().encodeAsURL()}" } + +private loginCallbackURL() { + if (!atomicState.accessToken) { createAccessToken() } + buildActionUrl(loginCallbackPath()) +} +private loginCallbackPath() { "login/callback" } + +// ======================================================== +// Access Token +// ======================================================== + +private getMyAccessToken() { return atomicState.accessToken ?: createAccessToken() } + +// ======================================================== +// CONNECTED HTML +// ======================================================== + +def connectionSuccessful(deviceName, iconSrc) { + def html = """ + + + + +Withings Connection + + + +
+ ${deviceName} icon + connected device icon + SmartThings logo +

Your ${deviceName} is now connected to SmartThings!

+

Click 'Done' to finish setup.

+
+ + +""" + + render contentType: 'text/html', data: html +} diff --git a/official/weather-underground-pws-connect.groovy b/official/weather-underground-pws-connect.groovy new file mode 100755 index 0000000..21ecb1d --- /dev/null +++ b/official/weather-underground-pws-connect.groovy @@ -0,0 +1,112 @@ +/** + * Weather Underground PWS Connect + * + * Copyright 2015 Andrew Mager + * + * 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. + * + */ + +// This imports the Java class "DecimalFormat" +import java.text.DecimalFormat + +definition( + name: "Weather Underground PWS Connect", + namespace: "mager", + author: "Andrew Mager", + description: "Connect your SmartSense Temp/Humidity sensor to your Weather Underground Personal Weather Station.", + category: "Green Living", + iconUrl: "http://i.imgur.com/HU0ANBp.png", + iconX2Url: "http://i.imgur.com/HU0ANBp.png", + iconX3Url: "http://i.imgur.com/HU0ANBp.png", + oauth: true, + singleInstance: true) + + +preferences { + section("Select a sensor") { + input "temp", "capability.temperatureMeasurement", title: "Temperature", required: true + input "humidity", "capability.relativeHumidityMeasurement", title: "Humidity", required: true + } + section("Configure your Weather Underground credentials") { + input "weatherID", "text", title: "Weather Station ID", required: true + input "password", "password", title: "Weather Underground password", required: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + + +def initialize() { + + /* + Check to see if the sensor is reporting temperature, then run the updateCurrentWeather + every 10 minutes + */ + if (temp.currentTemperature) { + runEvery5Minutes(updateCurrentWeather) + } +} + + +/* + Updates the Weather Underground Personal Weather Station (PWS) Upload Protocol + Reference: http://wiki.wunderground.com/index.php/PWS_-_Upload_Protocol +*/ +def updateCurrentWeather() { + + // Logs of the current data from the sensor + log.trace "Temp: " + temp.currentTemperature + log.trace "Humidity: " + humidity.currentHumidity + log.trace "Dew Point: " + calculateDewPoint(temp.currentTemperature, humidity.currentHumidity) + + // Builds the URL that will be sent to Weather Underground to update your PWS + def params = [ + uri: "http://weatherstation.wunderground.com", + path: "/weatherstation/updateweatherstation.php", + query: [ + "ID": weatherID, + "PASSWORD": password, + "dateutc": "now", + "tempf": temp.currentTemperature, + "humidity": humidity.currentHumidity, + "dewptf": calculateDewPoint(temp.currentTemperature, humidity.currentHumidity), + "action": "updateraw", + "softwaretype": "SmartThings" + ] + ] + + try { + // Make the HTTP request using httpGet() + httpGet(params) { resp -> // This is how we define the "return data". Can also use $it. + log.debug "response data: ${resp.data}" + } + } catch (e) { + log.error "something went wrong: $e" + } + +} + +// Calculates dewpoint based on temperature and humidity +def calculateDewPoint(t, rh) { + def dp = 243.04 * ( Math.log(rh / 100) + ( (17.625 * t) / (243.04 + t) ) ) / (17.625 - Math.log(rh / 100) - ( (17.625 * t) / (243.04 + t) ) ) + // Format the response for Weather Underground + return new DecimalFormat("##.##").format(dp) +} diff --git a/official/weather-windows.groovy b/official/weather-windows.groovy new file mode 100755 index 0000000..7b7745e --- /dev/null +++ b/official/weather-windows.groovy @@ -0,0 +1,152 @@ +/** + * Weather Windows + * Compares two temperatures – indoor vs outdoor, for example – then sends an alert if windows are open (or closed!). + * + * Copyright 2015 Eric Gideon + * + * Based in part on the "When it's going to rain" SmartApp by the SmartThings team, + * primarily the message throttling code. + * + * 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: "Weather Windows", + namespace: "egid", + author: "Eric Gideon", + category: "Convenience", + description: "Compares two temperatures – indoor vs outdoor, for example – then sends an alert if windows are open (or closed!). If you don't use an external temperature device, your zipcode will be used instead.", + iconUrl: "https://s3.amazonaws.com/smartthings-device-icons/Home/home9-icn.png", + iconX2Url: "https://s3.amazonaws.com/smartthings-device-icons/Home/home9-icn@2x.png" +) + + +preferences { + section( "Set the temperature range for your comfort zone..." ) { + input "minTemp", "number", title: "Minimum temperature" + input "maxTemp", "number", title: "Maximum temperature" + } + section( "Select windows to check..." ) { + input "sensors", "capability.contactSensor", multiple: true + } + section( "Select temperature devices to monitor..." ) { + input "inTemp", "capability.temperatureMeasurement", title: "Indoor" + input "outTemp", "capability.temperatureMeasurement", title: "Outdoor (optional)", required: false + } + section( "Set your location" ) { + input "zipCode", "text", title: "Zip code" + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes","No"]], required:false + input "retryPeriod", "number", title: "Minutes between notifications:" + } +} + + +def installed() { + log.debug "Installed: $settings" + subscribe( inTemp, "temperature", temperatureHandler ) +} + +def updated() { + log.debug "Updated: $settings" + unsubscribe() + subscribe( inTemp, "temperature", temperatureHandler ) +} + + +def temperatureHandler(evt) { + def currentOutTemp = null + if ( outTemp ) { + currentOutTemp = outTemp.latestValue("temperature") + } else { + log.debug "No external temperature device set. Checking WUnderground...." + currentOutTemp = weatherCheck() + } + + def currentInTemp = evt.doubleValue + def openWindows = sensors.findAll { it?.latestValue("contact") == 'open' } + + log.trace "Temp event: $evt" + log.info "In: $currentInTemp; Out: $currentOutTemp" + + // Don't spam notifications + // *TODO* use state.foo from Severe Weather Alert to do this better + def retryPeriodInMinutes = retryPeriod ?: 30 + def timeAgo = new Date(now() - (1000 * 60 * retryPeriodInMinutes).toLong()) + def recentEvents = inTemp.eventsSince(timeAgo) + log.trace "Found ${recentEvents?.size() ?: 0} events in the last $retryPeriodInMinutes minutes" + + // Figure out if we should notify + if ( currentInTemp > minTemp && currentInTemp < maxTemp ) { + log.info "In comfort zone: $currentInTemp is between $minTemp and $maxTemp." + log.debug "No notifications sent." + } else if ( currentInTemp > maxTemp ) { + // Too warm. Can we do anything? + + def alreadyNotified = recentEvents.count { it.doubleValue > currentOutTemp } > 1 + + if ( !alreadyNotified ) { + if ( currentOutTemp < maxTemp && !openWindows ) { + send( "Open some windows to cool down the house! Currently ${currentInTemp}°F inside and ${currentOutTemp}°F outside." ) + } else if ( currentOutTemp > maxTemp && openWindows ) { + send( "It's gotten warmer outside! You should close these windows: ${openWindows.join(', ')}. Currently ${currentInTemp}°F inside and ${currentOutTemp}°F outside." ) + } else { + log.debug "No notifications sent. Everything is in the right place." + } + } else { + log.debug "Already notified! No notifications sent." + } + } else if ( currentInTemp < minTemp ) { + // Too cold! Is it warmer outside? + + def alreadyNotified = recentEvents.count { it.doubleValue < currentOutTemp } > 1 + + if ( !alreadyNotified ) { + if ( currentOutTemp > minTemp && !openWindows ) { + send( "Open some windows to warm up the house! Currently ${currentInTemp}°F inside and ${currentOutTemp}°F outside." ) + } else if ( currentOutTemp < minTemp && openWindows ) { + send( "It's gotten colder outside! You should close these windows: ${openWindows.join(', ')}. Currently ${currentInTemp}°F inside and ${currentOutTemp}°F outside." ) + } else { + log.debug "No notifications sent. Everything is in the right place." + } + } else { + log.debug "Already notified! No notifications sent." + } + } +} + +def weatherCheck() { + def json = getWeatherFeature("conditions", zipCode) + def currentTemp = json?.current_observation?.temp_f + + if ( currentTemp ) { + log.trace "Temp: $currentTemp (WeatherUnderground)" + return currentTemp + } else { + log.warn "Did not get a temp: $json" + return false + } +} + +private send(msg) { + if ( sendPushMessage != "No" ) { + log.debug( "sending push message" ) + sendPush( msg ) + sendEvent(linkText:app.label, descriptionText:msg, eventType:"SOLUTION_EVENT", displayed: true, name:"summary") + } + + if ( phone1 ) { + log.debug( "sending text message" ) + sendSms( phone1, msg ) + } + + log.info msg +} \ No newline at end of file diff --git a/official/weatherbug-home.groovy b/official/weatherbug-home.groovy new file mode 100755 index 0000000..a92d9d1 --- /dev/null +++ b/official/weatherbug-home.groovy @@ -0,0 +1,307 @@ +/** + * WeatherBug Home + * + * Copyright 2015 WeatherBug + * + */ +definition( + name: "WeatherBug Home", + namespace: "WeatherBug", + author: "WeatherBug Home", + description: "WeatherBug Home", + category: "My Apps", + iconUrl: "http://stg.static.myenergy.enqa.co/apps/wbhc/v2/images/weatherbughomemedium.png", + iconX2Url: "http://stg.static.myenergy.enqa.co/apps/wbhc/v2/images/weatherbughomemedium.png", + iconX3Url: "http://stg.static.myenergy.enqa.co/apps/wbhc/v2/images/weatherbughome.png", + oauth: [displayName: "WeatherBug Home", displayLink: "http://weatherbughome.com/"]) + + +preferences { + section("Select thermostats") { + input "thermostatDevice", "capability.thermostat", multiple: true + } +} + +mappings { + path("/appInfo") { action: [ GET: "getAppInfo" ] } + path("/getLocation") { action: [ GET: "getLoc" ] } + path("/currentReport/:id") { action: [ GET: "getCurrentReport" ] } + path("/setTemp/:temp/:id") { action: [ POST: "setTemperature", GET: "setTemperature" ] } +} + +/** + * This API call will be leveraged by a WeatherBug Home Service to retrieve + * data from the installed SmartApp, including the location data, and + * a list of the devices that were authorized to be accessed. The WeatherBug + * Home Service will leverage this data to represent the connected devices as well as their + * location and associated the data with a WeatherBug user account. + * Privacy Policy: http://weatherbughome.com/privacy/ + * @return Location, including id, latitude, longitude, zip code, and name, and the list of devices + */ +def getAppInfo() { + def devices = thermostatDevice + def lat = location.latitude + def lon = location.longitude + if(!(devices instanceof Collection)) + { + devices = [devices] + } + return [ + Id: UUID.randomUUID().toString(), + Code: 200, + ErrorMessage: null, + Result: [ "Devices": devices, + "Location":[ + "Id": location.id, + "Latitude":lat, + "Longitude":lon, + "ZipCode":location.zipCode, + "Name":location.name + ] + ] + ] +} + +/** + * This API call will be leveraged by a WeatherBug Home Service to retrieve + * location data from the installed SmartApp. The WeatherBug + * Home Service will leverage this data to associate the location to a WeatherBug Home account + * Privacy Policy: http://weatherbughome.com/privacy/ + * + * @return Location, including id, latitude, longitude, zip code, and name + */ +def getLoc() { + return [ + Id: UUID.randomUUID().toString(), + Code: 200, + ErrorMessage: null, + Result: [ + "Id": location.id, + "Latitude":location.latitude, + "Longitude":location.longitude, + "ZipCode":location.zipCode, + "Name":location.name] + ] +} + +/** + * This API call will be leveraged by a WeatherBug Home Service to retrieve + * thermostat data and store it for display to a WeatherBug user. + * Privacy Policy: http://weatherbughome.com/privacy/ + * + * @param id The id of the device to get data for + * @return Thermostat data including temperature, set points, running modes, and operating states + */ +def getCurrentReport() { + log.debug "device id parameter=" + params.id + def unixTime = (int)((new Date().getTime() / 1000)) + def device = thermostatDevice.find{ it.id == params.id} + + if(device == null) + { + return [ + Id: UUID.randomUUID().toString(), + Code: 404, + ErrorMessage: "Device not found. id=" + params.id, + Result: null + ] + } + return [ + Id: UUID.randomUUID().toString(), + Code: 200, + ErrorMessage: null, + Result: [ + DeviceId: device.id, + LocationId: location.id, + ReportType: 2, + ReportList: [ + [Key: "Temperature", Value: GetOrDefault(device, "temperature")], + [Key: "ThermostatSetpoint", Value: GetOrDefault(device, "thermostatSetpoint")], + [Key: "CoolingSetpoint", Value: GetOrDefault(device, "coolingSetpoint")], + [Key: "HeatingSetpoint", Value: GetOrDefault(device, "heatingSetpoint")], + [Key: "ThermostatMode", Value: GetOrDefault(device, "thermostatMode")], + [Key: "ThermostatFanMode", Value: GetOrDefault(device, "thermostatFanMode")], + [Key: "ThermostatOperatingState", Value: GetOrDefault(device, "thermostatOperatingState")] + ], + UnixTime: unixTime + ] + ] +} + +/** + * This API call will be leveraged by a WeatherBug Home Service to set + * the thermostat setpoint. + * Privacy Policy: http://weatherbughome.com/privacy/ + * + * @param id The id of the device to set + * @return Indication of whether the operation succeeded or failed + +def setTemperature() { + log.debug "device id parameter=" + params.id + def device = thermostatDevice.find{ it.id == params.id} + if(device != null) + { + def mode = device.latestState('thermostatMode').stringValue + def value = params.temp as Integer + log.trace "Suggested temperature: $value, $mode" + if ( mode == "cool") + device.setCoolingSetpoint(value) + else if ( mode == "heat") + device.setHeatingSetpoint(value) + return [ + Id: UUID.randomUUID().toString(), + Code: 200, + ErrorMessage: null, + Result: null + ] + } + return [ + Id: UUID.randomUUID().toString(), + Code : 404, + ErrorMessage: "Device not found. id=" + params.id, + Result: null + ] +} +*/ + + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +/** + * The updated event will be pushed to a WeatherBug Home Service to notify the system to take appropriate action. + * Data that will be sent includes the list of devices, and location data + * Privacy Policy: http://weatherbughome.com/privacy/ + */ +def updated() { + log.debug "Updated with settings: ${settings}" + log.debug "Updated with state: ${state}" + log.debug "Updated with location ${location} ${location.id} ${location.name}" + unsubscribe() + initialize() + def postParams = [ + uri: 'https://smartthingsrec.api.earthnetworks.com/api/v1/receive/smartapp/update', + body: [ + "Devices": devices, + "Location":[ + "Id": location.id, + "Latitude":location.latitude, + "Longitude":location.longitude, + "ZipCode":location.zipCode, + "Name":location.name + ] + ] + ] + sendToWeatherBug(postParams) +} + +/* +* Subscribe to changes on the thermostat attributes +*/ +def initialize() { + log.trace "initialize enter" + subscribe(thermostatDevice, "heatingSetpoint", pushLatest) + subscribe(thermostatDevice, "coolingSetpoint", pushLatest) + subscribe(thermostatDevice, "thermostatSetpoint", pushLatest) + subscribe(thermostatDevice, "thermostatMode", pushLatest) + subscribe(thermostatDevice, "thermostatFanMode", pushLatest) + subscribe(thermostatDevice, "thermostatOperatingState", pushLatest) + subscribe(thermostatDevice, "temperature", pushLatest) +} + +/** + * The uninstall event will be pushed to a WeatherBug Home Service to notify the system to take appropriate action. + * Data that will be sent includes the list of devices, and location data + * Privacy Policy: http://weatherbughome.com/privacy/ + */ +def uninstalled() { + log.trace "uninstall entered" + def postParams = [ + uri: 'https://smartthingsrec.api.earthnetworks.com/api/v1/receive/smartapp/delete', + body: [ + "Devices": devices, + "Location":[ + "Id": location.id, + "Latitude":location.latitude, + "Longitude":location.longitude, + "ZipCode":location.zipCode, + "Name":location.name + ] + ] + ] + sendToWeatherBug(postParams) +} + +/** + * This method will push the latest thermostat data to the WeatherBug Home Service so it can store + * and display the data to the WeatherBug user. Data pushed includes the thermostat data as well + * as location id. + * Privacy Policy: http://weatherbughome.com/privacy/ + */ +def pushLatest(evt) { + def unixTime = (int)((new Date().getTime() / 1000)) + def device = thermostatDevice.find{ it.id == evt.deviceId} + def postParams = [ + uri: 'https://smartthingsrec.api.earthnetworks.com/api/v1/receive', + body: [ + DeviceId: evt.deviceId, + LocationId: location.id, + ReportType: 2, + ReportList: [ + [Key: "Temperature", Value: GetOrDefault(device, "temperature")], + [Key: "ThermostatSetpoint", Value: GetOrDefault(device, "thermostatSetpoint")], + [Key: "CoolingSetpoint", Value: GetOrDefault(device, "coolingSetpoint")], + [Key: "HeatingSetpoint", Value: GetOrDefault(device, "heatingSetpoint")], + [Key: "ThermostatMode", Value: GetOrDefault(device, "thermostatMode")], + [Key: "ThermostatFanMode", Value: GetOrDefault(device, "thermostatFanMode")], + [Key: "ThermostatOperatingState", Value: GetOrDefault(device, "thermostatOperatingState")] + ], + UnixTime: unixTime + ] + ] + log.debug postParams + sendToWeatherBug(postParams) +} + +/* +* This method attempts to get the value of a device attribute, but if an error occurs null is returned +* @return The device attribute value, or null +*/ +def GetOrDefault(device, attrib) +{ + def val + try{ + val = device.latestValue(attrib) + + }catch(ex) + { + log.debug "Failed to get attribute " + attrib + " from device " + device + val = null + } + return val +} + +/* +* Convenience method that sends data to WeatherBug, logging any exceptions that may occur +* Privacy Policy: http://weatherbughome.com/privacy/ +*/ +def sendToWeatherBug(postParams) +{ + try{ + log.debug postParams + httpPostJson(postParams) { resp -> + resp.headers.each { + log.debug "${it.name} : ${it.value}" + } + log.debug "response contentType: ${resp.contentType}" + log.debug "response data: ${resp.data}" + } + log.debug "Communication with WeatherBug succeeded"; + + }catch(ex) + { + log.debug "Communication with WeatherBug failed.\n${ex}"; + } +} \ No newline at end of file diff --git a/official/wemo-connect.groovy b/official/wemo-connect.groovy new file mode 100755 index 0000000..73b4426 --- /dev/null +++ b/official/wemo-connect.groovy @@ -0,0 +1,685 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Wemo Service Manager + * + * Author: superuser + * Date: 2013-09-06 + */ +definition( + name: "Wemo (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Allows you to integrate your WeMo Switch and Wemo Motion sensor with SmartThings.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/wemo.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/wemo@2x.png", + singleInstance: true +) + +preferences { + page(name:"firstPage", title:"Wemo Device Setup", content:"firstPage") +} + +private discoverAllWemoTypes() +{ + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:Belkin:device:insight:1/urn:Belkin:device:controllee:1/urn:Belkin:device:sensor:1/urn:Belkin:device:lightswitch:1", physicalgraph.device.Protocol.LAN)) +} + +private getFriendlyName(String deviceNetworkId) { + sendHubCommand(new physicalgraph.device.HubAction("""GET /setup.xml HTTP/1.1 +HOST: ${deviceNetworkId} + +""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}", [callback: "setupHandler"])) +} + +private verifyDevices() { + def switches = getWemoSwitches().findAll { it?.value?.verified != true } + def motions = getWemoMotions().findAll { it?.value?.verified != true } + def lightSwitches = getWemoLightSwitches().findAll { it?.value?.verified != true } + def devices = switches + motions + lightSwitches + devices.each { + getFriendlyName((it.value.ip + ":" + it.value.port)) + } +} + +void ssdpSubscribe() { + subscribe(location, "ssdpTerm.urn:Belkin:device:insight:1", ssdpSwitchHandler) + subscribe(location, "ssdpTerm.urn:Belkin:device:controllee:1", ssdpSwitchHandler) + subscribe(location, "ssdpTerm.urn:Belkin:device:sensor:1", ssdpMotionHandler) + subscribe(location, "ssdpTerm.urn:Belkin:device:lightswitch:1", ssdpLightSwitchHandler) +} + +def firstPage() +{ + if(canInstallLabs()) + { + int refreshCount = !state.refreshCount ? 0 : state.refreshCount as int + state.refreshCount = refreshCount + 1 + def refreshInterval = 5 + + log.debug "REFRESH COUNT :: ${refreshCount}" + + ssdpSubscribe() + + //ssdp request every 25 seconds + if((refreshCount % 5) == 0) { + discoverAllWemoTypes() + } + + //setup.xml request every 5 seconds except on discoveries + if(((refreshCount % 1) == 0) && ((refreshCount % 5) != 0)) { + verifyDevices() + } + + def switchesDiscovered = switchesDiscovered() + def motionsDiscovered = motionsDiscovered() + def lightSwitchesDiscovered = lightSwitchesDiscovered() + + return dynamicPage(name:"firstPage", title:"Discovery Started!", nextPage:"", refreshInterval: refreshInterval, install:true, uninstall: true) { + section { paragraph title: "Note:", "This device has not been officially tested and certified to “Work with SmartThings”. You can connect it to your SmartThings home but performance may vary and we will not be able to provide support or assistance." } + section("Select a device...") { + input "selectedSwitches", "enum", required:false, title:"Select Wemo Switches \n(${switchesDiscovered.size() ?: 0} found)", multiple:true, options:switchesDiscovered + input "selectedMotions", "enum", required:false, title:"Select Wemo Motions \n(${motionsDiscovered.size() ?: 0} found)", multiple:true, options:motionsDiscovered + input "selectedLightSwitches", "enum", required:false, title:"Select Wemo Light Switches \n(${lightSwitchesDiscovered.size() ?: 0} found)", multiple:true, options:lightSwitchesDiscovered + } + } + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + +To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"firstPage", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section("Upgrade") { + paragraph "$upgradeNeeded" + } + } + } +} + +def devicesDiscovered() { + def switches = getWemoSwitches() + def motions = getWemoMotions() + def lightSwitches = getWemoLightSwitches() + def devices = switches + motions + lightSwitches + devices?.collect{ [app.id, it.ssdpUSN].join('.') } +} + +def switchesDiscovered() { + def switches = getWemoSwitches().findAll { it?.value?.verified == true } + def map = [:] + switches.each { + def value = it.value.name ?: "WeMo Switch ${it.value.ssdpUSN.split(':')[1][-3..-1]}" + def key = it.value.mac + map["${key}"] = value + } + map +} + +def motionsDiscovered() { + def motions = getWemoMotions().findAll { it?.value?.verified == true } + def map = [:] + motions.each { + def value = it.value.name ?: "WeMo Motion ${it.value.ssdpUSN.split(':')[1][-3..-1]}" + def key = it.value.mac + map["${key}"] = value + } + map +} + +def lightSwitchesDiscovered() { + //def vmotions = switches.findAll { it?.verified == true } + //log.trace "MOTIONS HERE: ${vmotions}" + def lightSwitches = getWemoLightSwitches().findAll { it?.value?.verified == true } + def map = [:] + lightSwitches.each { + def value = it.value.name ?: "WeMo Light Switch ${it.value.ssdpUSN.split(':')[1][-3..-1]}" + def key = it.value.mac + map["${key}"] = value + } + map +} + +def getWemoSwitches() +{ + if (!state.switches) { state.switches = [:] } + state.switches +} + +def getWemoMotions() +{ + if (!state.motions) { state.motions = [:] } + state.motions +} + +def getWemoLightSwitches() +{ + if (!state.lightSwitches) { state.lightSwitches = [:] } + state.lightSwitches +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + initialize() +} + +def initialize() { + unsubscribe() + unschedule() + + ssdpSubscribe() + + if (selectedSwitches) + addSwitches() + + if (selectedMotions) + addMotions() + + if (selectedLightSwitches) + addLightSwitches() + + runIn(5, "subscribeToDevices") //initial subscriptions delayed by 5 seconds + runIn(10, "refreshDevices") //refresh devices, delayed by 10 seconds + runEvery5Minutes("refresh") +} + +def resubscribe() { + log.debug "Resubscribe called, delegating to refresh()" + refresh() +} + +def refresh() { + log.debug "refresh() called" + doDeviceSync() + refreshDevices() +} + +def refreshDevices() { + log.debug "refreshDevices() called" + def devices = getAllChildDevices() + devices.each { d -> + log.debug "Calling refresh() on device: ${d.id}" + d.refresh() + } +} + +def subscribeToDevices() { + log.debug "subscribeToDevices() called" + def devices = getAllChildDevices() + devices.each { d -> + d.subscribe() + } +} + +def addSwitches() { + def switches = getWemoSwitches() + + selectedSwitches.each { dni -> + def selectedSwitch = switches.find { it.value.mac == dni } ?: switches.find { "${it.value.ip}:${it.value.port}" == dni } + def d + if (selectedSwitch) { + d = getChildDevices()?.find { + it.deviceNetworkId == selectedSwitch.value.mac || it.device.getDataValue("mac") == selectedSwitch.value.mac + } + if (!d) { + log.debug "Creating WeMo Switch with dni: ${selectedSwitch.value.mac}" + d = addChildDevice("smartthings", "Wemo Switch", selectedSwitch.value.mac, selectedSwitch?.value.hub, [ + "label": selectedSwitch?.value?.name ?: "Wemo Switch", + "data": [ + "mac": selectedSwitch.value.mac, + "ip": selectedSwitch.value.ip, + "port": selectedSwitch.value.port + ] + ]) + def ipvalue = convertHexToIP(selectedSwitch.value.ip) + d.sendEvent(name: "currentIP", value: ipvalue, descriptionText: "IP is ${ipvalue}") + log.debug "Created ${d.displayName} with id: ${d.id}, dni: ${d.deviceNetworkId}" + } else { + log.debug "found ${d.displayName} with id $dni already exists" + } + } + } +} + +def addMotions() { + def motions = getWemoMotions() + + selectedMotions.each { dni -> + def selectedMotion = motions.find { it.value.mac == dni } ?: motions.find { "${it.value.ip}:${it.value.port}" == dni } + def d + if (selectedMotion) { + d = getChildDevices()?.find { + it.deviceNetworkId == selectedMotion.value.mac || it.device.getDataValue("mac") == selectedMotion.value.mac + } + if (!d) { + log.debug "Creating WeMo Motion with dni: ${selectedMotion.value.mac}" + d = addChildDevice("smartthings", "Wemo Motion", selectedMotion.value.mac, selectedMotion?.value.hub, [ + "label": selectedMotion?.value?.name ?: "Wemo Motion", + "data": [ + "mac": selectedMotion.value.mac, + "ip": selectedMotion.value.ip, + "port": selectedMotion.value.port + ] + ]) + def ipvalue = convertHexToIP(selectedMotion.value.ip) + d.sendEvent(name: "currentIP", value: ipvalue, descriptionText: "IP is ${ipvalue}") + log.debug "Created ${d.displayName} with id: ${d.id}, dni: ${d.deviceNetworkId}" + } else { + log.debug "found ${d.displayName} with id $dni already exists" + } + } + } +} + +def addLightSwitches() { + def lightSwitches = getWemoLightSwitches() + + selectedLightSwitches.each { dni -> + def selectedLightSwitch = lightSwitches.find { it.value.mac == dni } ?: lightSwitches.find { "${it.value.ip}:${it.value.port}" == dni } + def d + if (selectedLightSwitch) { + d = getChildDevices()?.find { + it.deviceNetworkId == selectedLightSwitch.value.mac || it.device.getDataValue("mac") == selectedLightSwitch.value.mac + } + if (!d) { + log.debug "Creating WeMo Light Switch with dni: ${selectedLightSwitch.value.mac}" + d = addChildDevice("smartthings", "Wemo Light Switch", selectedLightSwitch.value.mac, selectedLightSwitch?.value.hub, [ + "label": selectedLightSwitch?.value?.name ?: "Wemo Light Switch", + "data": [ + "mac": selectedLightSwitch.value.mac, + "ip": selectedLightSwitch.value.ip, + "port": selectedLightSwitch.value.port + ] + ]) + def ipvalue = convertHexToIP(selectedLightSwitch.value.ip) + d.sendEvent(name: "currentIP", value: ipvalue, descriptionText: "IP is ${ipvalue}") + log.debug "created ${d.displayName} with id $dni" + } else { + log.debug "found ${d.displayName} with id $dni already exists" + } + } + } +} + +def ssdpSwitchHandler(evt) { + def description = evt.description + def hub = evt?.hubId + def parsedEvent = parseDiscoveryMessage(description) + parsedEvent << ["hub":hub] + log.debug parsedEvent + + def switches = getWemoSwitches() + if (!(switches."${parsedEvent.ssdpUSN.toString()}")) { + //if it doesn't already exist + switches << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } else { + log.debug "Device was already found in state..." + def d = switches."${parsedEvent.ssdpUSN.toString()}" + boolean deviceChangedValues = false + log.debug "$d.ip <==> $parsedEvent.ip" + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { + d.ip = parsedEvent.ip + d.port = parsedEvent.port + deviceChangedValues = true + log.debug "Device's port or ip changed..." + def child = getChildDevice(parsedEvent.mac) + if (child) { + child.subscribe(parsedEvent.ip, parsedEvent.port) + child.poll() + } else { + log.debug "Device with mac $parsedEvent.mac not found" + } + } + } +} + +def ssdpMotionHandler(evt) { + log.info("ssdpMotionHandler") + def description = evt.description + def hub = evt?.hubId + def parsedEvent = parseDiscoveryMessage(description) + parsedEvent << ["hub":hub] + log.debug parsedEvent + + def motions = getWemoMotions() + if (!(motions."${parsedEvent.ssdpUSN.toString()}")) { + //if it doesn't already exist + motions << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } else { // just update the values + log.debug "Device was already found in state..." + + def d = motions."${parsedEvent.ssdpUSN.toString()}" + boolean deviceChangedValues = false + + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { + d.ip = parsedEvent.ip + d.port = parsedEvent.port + deviceChangedValues = true + log.debug "Device's port or ip changed..." + } + + if (deviceChangedValues) { + def children = getChildDevices() + log.debug "Found children ${children}" + children.each { + if (it.getDeviceDataByName("mac") == parsedEvent.mac) { + log.debug "updating ip and port, and resubscribing, for device ${it} with mac ${parsedEvent.mac}" + it.subscribe(parsedEvent.ip, parsedEvent.port) + } + } + } + } +} + +def ssdpLightSwitchHandler(evt) { + log.info("ssdpLightSwitchHandler") + def description = evt.description + def hub = evt?.hubId + def parsedEvent = parseDiscoveryMessage(description) + parsedEvent << ["hub":hub] + log.debug parsedEvent + + def lightSwitches = getWemoLightSwitches() + + if (!(lightSwitches."${parsedEvent.ssdpUSN.toString()}")) { + //if it doesn't already exist + lightSwitches << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } else { + log.debug "Device was already found in state..." + + def d = lightSwitches."${parsedEvent.ssdpUSN.toString()}" + boolean deviceChangedValues = false + + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { + d.ip = parsedEvent.ip + d.port = parsedEvent.port + deviceChangedValues = true + log.debug "Device's port or ip changed..." + def child = getChildDevice(parsedEvent.mac) + if (child) { + log.debug "updating ip and port, and resubscribing, for device with mac ${parsedEvent.mac}" + child.subscribe(parsedEvent.ip, parsedEvent.port) + } else { + log.debug "Device with mac $parsedEvent.mac not found" + } + } + } +} + +void setupHandler(hubResponse) { + String contentType = hubResponse?.headers['Content-Type'] + if (contentType != null && contentType == 'text/xml') { + def body = hubResponse.xml + def wemoDevices = [] + String deviceType = body?.device?.deviceType?.text() ?: "" + if (deviceType.startsWith("urn:Belkin:device:controllee:1") || deviceType.startsWith("urn:Belkin:device:insight:1")) { + wemoDevices = getWemoSwitches() + } else if (deviceType.startsWith("urn:Belkin:device:sensor")) { + wemoDevices = getWemoMotions() + } else if (deviceType.startsWith("urn:Belkin:device:lightswitch")) { + wemoDevices = getWemoLightSwitches() + } + + def wemoDevice = wemoDevices.find {it?.key?.contains(body?.device?.UDN?.text())} + if (wemoDevice) { + wemoDevice.value << [name:body?.device?.friendlyName?.text(), verified: true] + } else { + log.error "/setup.xml returned a wemo device that didn't exist" + } + } +} + +@Deprecated +def locationHandler(evt) { + def description = evt.description + def hub = evt?.hubId + def parsedEvent = parseDiscoveryMessage(description) + parsedEvent << ["hub":hub] + log.debug parsedEvent + + if (parsedEvent?.ssdpTerm?.contains("Belkin:device:controllee") || parsedEvent?.ssdpTerm?.contains("Belkin:device:insight")) { + def switches = getWemoSwitches() + if (!(switches."${parsedEvent.ssdpUSN.toString()}")) { + //if it doesn't already exist + switches << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } else { + log.debug "Device was already found in state..." + def d = switches."${parsedEvent.ssdpUSN.toString()}" + boolean deviceChangedValues = false + log.debug "$d.ip <==> $parsedEvent.ip" + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { + d.ip = parsedEvent.ip + d.port = parsedEvent.port + deviceChangedValues = true + log.debug "Device's port or ip changed..." + def child = getChildDevice(parsedEvent.mac) + if (child) { + child.subscribe(parsedEvent.ip, parsedEvent.port) + child.poll() + } else { + log.debug "Device with mac $parsedEvent.mac not found" + } + } + } + } + else if (parsedEvent?.ssdpTerm?.contains("Belkin:device:sensor")) { + def motions = getWemoMotions() + if (!(motions."${parsedEvent.ssdpUSN.toString()}")) { + //if it doesn't already exist + motions << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } else { // just update the values + log.debug "Device was already found in state..." + + def d = motions."${parsedEvent.ssdpUSN.toString()}" + boolean deviceChangedValues = false + + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { + d.ip = parsedEvent.ip + d.port = parsedEvent.port + deviceChangedValues = true + log.debug "Device's port or ip changed..." + } + + if (deviceChangedValues) { + def children = getChildDevices() + log.debug "Found children ${children}" + children.each { + if (it.getDeviceDataByName("mac") == parsedEvent.mac) { + log.debug "updating ip and port, and resubscribing, for device ${it} with mac ${parsedEvent.mac}" + it.subscribe(parsedEvent.ip, parsedEvent.port) + } + } + } + } + + } + else if (parsedEvent?.ssdpTerm?.contains("Belkin:device:lightswitch")) { + + def lightSwitches = getWemoLightSwitches() + + if (!(lightSwitches."${parsedEvent.ssdpUSN.toString()}")) + { //if it doesn't already exist + lightSwitches << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } else { + log.debug "Device was already found in state..." + + def d = lightSwitches."${parsedEvent.ssdpUSN.toString()}" + boolean deviceChangedValues = false + + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { + d.ip = parsedEvent.ip + d.port = parsedEvent.port + deviceChangedValues = true + log.debug "Device's port or ip changed..." + def child = getChildDevice(parsedEvent.mac) + if (child) { + log.debug "updating ip and port, and resubscribing, for device with mac ${parsedEvent.mac}" + child.subscribe(parsedEvent.ip, parsedEvent.port) + } else { + log.debug "Device with mac $parsedEvent.mac not found" + } + } + } + } + else if (parsedEvent.headers && parsedEvent.body) { + String headerString = new String(parsedEvent.headers.decodeBase64())?.toLowerCase() + if (headerString != null && (headerString.contains('text/xml') || headerString.contains('application/xml'))) { + def body = parseXmlBody(parsedEvent.body) + if (body?.device?.deviceType?.text().startsWith("urn:Belkin:device:controllee:1")) + { + def switches = getWemoSwitches() + def wemoSwitch = switches.find {it?.key?.contains(body?.device?.UDN?.text())} + if (wemoSwitch) + { + wemoSwitch.value << [name:body?.device?.friendlyName?.text(), verified: true] + } + else + { + log.error "/setup.xml returned a wemo device that didn't exist" + } + } + + if (body?.device?.deviceType?.text().startsWith("urn:Belkin:device:insight:1")) + { + def switches = getWemoSwitches() + def wemoSwitch = switches.find {it?.key?.contains(body?.device?.UDN?.text())} + if (wemoSwitch) + { + wemoSwitch.value << [name:body?.device?.friendlyName?.text(), verified: true] + } + else + { + log.error "/setup.xml returned a wemo device that didn't exist" + } + } + + if (body?.device?.deviceType?.text().startsWith("urn:Belkin:device:sensor")) //?:1 + { + def motions = getWemoMotions() + def wemoMotion = motions.find {it?.key?.contains(body?.device?.UDN?.text())} + if (wemoMotion) + { + wemoMotion.value << [name:body?.device?.friendlyName?.text(), verified: true] + } + else + { + log.error "/setup.xml returned a wemo device that didn't exist" + } + } + + if (body?.device?.deviceType?.text().startsWith("urn:Belkin:device:lightswitch")) //?:1 + { + def lightSwitches = getWemoLightSwitches() + def wemoLightSwitch = lightSwitches.find {it?.key?.contains(body?.device?.UDN?.text())} + if (wemoLightSwitch) + { + wemoLightSwitch.value << [name:body?.device?.friendlyName?.text(), verified: true] + } + else + { + log.error "/setup.xml returned a wemo device that didn't exist" + } + } + } + } +} + +@Deprecated +private def parseXmlBody(def body) { + def decodedBytes = body.decodeBase64() + def bodyString + try { + bodyString = new String(decodedBytes) + } catch (Exception e) { + // Keep this log for debugging StringIndexOutOfBoundsException issue + log.error("Exception decoding bytes in sonos connect: ${decodedBytes}") + throw e + } + return new XmlSlurper().parseText(bodyString) +} + +private def parseDiscoveryMessage(String description) { + def event = [:] + def parts = description.split(',') + parts.each { part -> + part = part.trim() + if (part.startsWith('devicetype:')) { + part -= "devicetype:" + event.devicetype = part.trim() + } + else if (part.startsWith('mac:')) { + part -= "mac:" + event.mac = part.trim() + } + else if (part.startsWith('networkAddress:')) { + part -= "networkAddress:" + event.ip = part.trim() + } + else if (part.startsWith('deviceAddress:')) { + part -= "deviceAddress:" + event.port = part.trim() + } + else if (part.startsWith('ssdpPath:')) { + part -= "ssdpPath:" + event.ssdpPath = part.trim() + } + else if (part.startsWith('ssdpUSN:')) { + part -= "ssdpUSN:" + event.ssdpUSN = part.trim() + } + else if (part.startsWith('ssdpTerm:')) { + part -= "ssdpTerm:" + event.ssdpTerm = part.trim() + } + else if (part.startsWith('headers')) { + part -= "headers:" + event.headers = part.trim() + } + else if (part.startsWith('body')) { + part -= "body:" + event.body = part.trim() + } + } + event +} + +def doDeviceSync(){ + log.debug "Doing Device Sync!" + discoverAllWemoTypes() +} + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +private Boolean canInstallLabs() { + return hasAllHubsOver("000.011.00603") +} + +private Boolean hasAllHubsOver(String desiredFirmware) { + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +private List getRealHubFirmwareVersions() { + return location.hubs*.firmwareVersionString.findAll { it } +} diff --git a/official/when-its-going-to-rain.groovy b/official/when-its-going-to-rain.groovy new file mode 100755 index 0000000..f9751ee --- /dev/null +++ b/official/when-its-going-to-rain.groovy @@ -0,0 +1,91 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * When It's Going To Rain + * + * Author: SmartThings + */ +definition( + name: "When It's Going to Rain", + namespace: "smartthings", + author: "SmartThings", + description: "Is your shed closed? Are your windows shut? Is the grill covered? Are your dogs indoors? Will the lawn and plants need to be watered tomorrow?", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/text.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/text@2x.png" +) + +preferences { + section("Zip code..."){ + input "zipcode", "text", title: "Zipcode?" + } + // TODO: would be nice to cron this so we could check every hour or so + section("Check at..."){ + input "time", "time", title: "When?" + } + section("Things to check..."){ + input "sensors", "capability.contactSensor", multiple: true + } + section("Text me if I anything is open..."){ + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Phone number?" + } + } +} + + +def installed() { + log.debug "Installed: $settings" + schedule(time, "scheduleCheck") +} + +def updated() { + log.debug "Updated: $settings" + unschedule() + schedule(time, "scheduleCheck") +} + +def scheduleCheck() { + def response = getWeatherFeature("forecast", zipcode) + if (isStormy(response)) { + def open = sensors.findAll { it?.latestValue("contact") == 'open' } + if (open) { + if (location.contactBookEnabled) { + sendNotificationToContacts("A storm is a coming and the following things are open: ${open.join(', ')}", recipients) + } + else { + sendSms(phone, "A storm is a coming and the following things are open: ${open.join(', ')}") + } + } + } +} + +private isStormy(json) { + def STORMY = ['rain', 'snow', 'showers', 'sprinkles', 'precipitation'] + + def forecast = json?.forecast?.txt_forecast?.forecastday?.first() + if (forecast) { + def text = forecast?.fcttext?.toLowerCase() + if (text) { + def result = false + for (int i = 0; i < STORMY.size() && !result; i++) { + result = text.contains(STORMY[i]) + } + return result + } else { + return false + } + } else { + log.warn "Did not get a forecast: $json" + return false + } +} diff --git a/official/whole-house-fan.groovy b/official/whole-house-fan.groovy new file mode 100755 index 0000000..e7666ca --- /dev/null +++ b/official/whole-house-fan.groovy @@ -0,0 +1,108 @@ +/** + * Whole House Fan + * + * Copyright 2014 Brian Steere + * + * 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: "Whole House Fan", + namespace: "dianoga", + author: "Brian Steere", + description: "Toggle a whole house fan (switch) when: Outside is cooler than inside, Inside is above x temp, Thermostat is off", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Developers/whole-house-fan.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Developers/whole-house-fan%402x.png" +) + + +preferences { + section("Outdoor") { + input "outTemp", "capability.temperatureMeasurement", title: "Outdoor Thermometer", required: true + } + + section("Indoor") { + input "inTemp", "capability.temperatureMeasurement", title: "Indoor Thermometer", required: true + input "minTemp", "number", title: "Minimum Indoor Temperature" + input "fans", "capability.switch", title: "Vent Fan", multiple: true, required: true + } + + section("Thermostat") { + input "thermostat", "capability.thermostat", title: "Thermostat" + } + + section("Windows/Doors") { + paragraph "[Optional] Only turn on the fan if at least one of these is open" + input "checkContacts", "enum", title: "Check windows/doors", options: ['Yes', 'No'], required: true + input "contacts", "capability.contactSensor", title: "Windows/Doors", multiple: true, required: false + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + state.fanRunning = false; + + subscribe(outTemp, "temperature", "checkThings"); + subscribe(inTemp, "temperature", "checkThings"); + subscribe(thermostat, "thermostatMode", "checkThings"); + subscribe(contacts, "contact", "checkThings"); +} + +def checkThings(evt) { + def outsideTemp = settings.outTemp.currentTemperature + def insideTemp = settings.inTemp.currentTemperature + def thermostatMode = settings.thermostat.currentThermostatMode + def somethingOpen = settings.checkContacts == 'No' || settings.contacts?.find { it.currentContact == 'open' } + + log.debug "Inside: $insideTemp, Outside: $outsideTemp, Thermostat: $thermostatMode, Something Open: $somethingOpen" + + def shouldRun = true; + + if(thermostatMode != 'off') { + log.debug "Not running due to thermostat mode" + shouldRun = false; + } + + if(insideTemp < outsideTemp) { + log.debug "Not running due to insideTemp > outdoorTemp" + shouldRun = false; + } + + if(insideTemp < settings.minTemp) { + log.debug "Not running due to insideTemp < minTemp" + shouldRun = false; + } + + if(!somethingOpen) { + log.debug "Not running due to nothing open" + shouldRun = false + } + + if(shouldRun && !state.fanRunning) { + fans.on(); + state.fanRunning = true; + } else if(!shouldRun && state.fanRunning) { + fans.off(); + state.fanRunning = false; + } +} \ No newline at end of file diff --git a/official/withings-manager.groovy b/official/withings-manager.groovy new file mode 100755 index 0000000..1e4d8ba --- /dev/null +++ b/official/withings-manager.groovy @@ -0,0 +1,775 @@ +/** + * Title: Withings Service Manager + * Description: Connect Your Withings Devices + * + * Author: steve + * Date: 1/9/15 + * + * + * Copyright 2015 steve + * + * 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: "Withings Manager", + namespace: "smartthings", + author: "SmartThings", + description: "Connect With Withings", + category: "", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/withings.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png", + oauth: true +) { + appSetting "consumerKey" + appSetting "consumerSecret" +} + +// ======================================================== +// PAGES +// ======================================================== + +preferences { + page(name: "authPage") +} + +def authPage() { + + def installOptions = false + def description = "Required (tap to set)" + def authState + + if (oauth_token()) { + // TODO: Check if it's valid + if (true) { + description = "Saved (tap to change)" + installOptions = true + authState = "complete" + } else { + // Worth differentiating here? (no longer valid vs. non-existent state.externalAuthToken?) + description = "Required (tap to set)" + } + } + + + dynamicPage(name: "authPage", install: installOptions, uninstall: true) { + section { + + if (installOptions) { + input(name: "withingsLabel", type: "text", title: "Add a name", description: null, required: true) + } + + href url: shortUrl("authenticate"), style: "embedded", required: false, title: "Authenticate with Withings", description: description, state: authState + } + } +} + +// ======================================================== +// MAPPINGS +// ======================================================== + +mappings { + path("/authenticate") { + action: + [ + GET: "authenticate" + ] + } + path("/x") { + action: + [ + GET: "exchangeTokenFromWithings" + ] + } + path("/n") { + action: + [POST: "notificationReceived"] + } + + path("/test/:action") { + action: + [GET: "test"] + } +} + +def test() { + "${params.action}"() +} + +def authenticate() { + // do not hit userAuthorizationUrl when the page is executed. It will replace oauth_tokens + // instead, redirect through here so we know for sure that the user wants to authenticate + // plus, the short-lived tokens that are used during authentication are only valid for 2 minutes + // so make sure we give the user as much of that 2 minutes as possible to enter their credentials and deal with network latency + log.trace "starting Withings authentication flow" + redirect location: userAuthorizationUrl() +} + +def exchangeTokenFromWithings() { + // Withings hits us here during the oAuth flow +// log.trace "exchangeTokenFromWithings ${params}" + atomicState.userid = params.userid // TODO: restructure this for multi-user access + exchangeToken() +} + +def notificationReceived() { +// log.trace "notificationReceived params: ${params}" + + def notificationParams = [ + startdate: params.startdate, + userid : params.userid, + enddate : params.enddate, + ] + + def measures = wGetMeasures(notificationParams) + sendMeasureEvents(measures) + return [status: 0] +} + +// ======================================================== +// HANDLERS +// ======================================================== + + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + +// wRevokeAllNotifications() + + unsubscribe() + initialize() +} + +def initialize() { + if (!getChild()) { createChild() } + app.updateLabel(withingsLabel) + wCreateNotification() + backfillMeasures() +} + +// ======================================================== +// CHILD DEVICE +// ======================================================== + +private getChild() { + def children = childDevices + children.size() ? children.first() : null +} + +private void createChild() { + def child = addChildDevice("smartthings", "Withings User", userid(), null, [name: app.label, label: withingsLabel]) + atomicState.child = [dni: child.deviceNetworkId] +} + +// ======================================================== +// URL HELPERS +// ======================================================== + +def stBaseUrl() { + if (!atomicState.serverUrl) { + stToken() + atomicState.serverUrl = buildActionUrl("").split(/api\//).first() + } + return atomicState.serverUrl +} + +def stToken() { + atomicState.accessToken ?: createAccessToken() +} + +def shortUrl(path = "", urlParams = [:]) { + attachParams("${stBaseUrl()}api/t/${stToken()}/s/${app.id}/${path}", urlParams) +} + +def noTokenUrl(path = "", urlParams = [:]) { + attachParams("${stBaseUrl()}api/smartapps/installations/${app.id}/${path}", urlParams) +} + +def attachParams(url, urlParams = [:]) { + [url, toQueryString(urlParams)].findAll().join("?") +} + +String toQueryString(Map m = [:]) { +// log.trace "toQueryString. URLEncoder will be used on ${m}" + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +// ======================================================== +// WITHINGS MEASURES +// ======================================================== + +def unixTime(date = new Date()) { + def unixTime = date.time / 1000 as int +// log.debug "converting ${date.time} to ${unixTime}" + unixTime +} + +def backfillMeasures() { +// log.trace "backfillMeasures" + def measureParams = [startdate: unixTime(new Date() - 10)] + def measures = wGetMeasures(measureParams) + sendMeasureEvents(measures) +} + +// this is body measures. // TODO: get activity and others too +def wGetMeasures(measureParams = [:]) { + def baseUrl = "https://wbsapi.withings.net/measure" + def urlParams = [ + action : "getmeas", + userid : userid(), + startdate : unixTime(new Date() - 5), + enddate : unixTime(), + oauth_token: oauth_token() + ] + measureParams + def measureData = fetchDataFromWithings(baseUrl, urlParams) +// log.debug "measureData: ${measureData}" + measureData.body.measuregrps.collect { parseMeasureGroup(it) }.flatten() +} +/* +[ + body:[ + measuregrps:[ + [ + category:1, // 1 for real measurements, 2 for user objectives. + grpid:310040317, + measures:[ + [ + unit:0, // Power of ten the "value" parameter should be multiplied to to get the real value. Eg : value = 20 and unit=-1 means the value really is 2.0 + value:60, // Value for the measure in S.I units (kilogram, meters, etc.). Value should be multiplied by 10 to the power of "unit" (see below) to get the real value. + type:11 // 1 : Weight (kg), 4 : Height (meter), 5 : Fat Free Mass (kg), 6 : Fat Ratio (%), 8 : Fat Mass Weight (kg), 9 : Diastolic Blood Pressure (mmHg), 10 : Systolic Blood Pressure (mmHg), 11 : Heart Pulse (bpm), 54 : SP02(%) + ], + [ + unit:-3, + value:-1000, + type:18 + ] + ], + date:1422750210, + attrib:2 + ] + ], + updatetime:1422750227 + ], + status:0 +] +*/ + +def sendMeasureEvents(measures) { +// log.debug "measures: ${measures}" + measures.each { + if (it.name && it.value) { + sendEvent(userid(), it) + } + } +} + +def parseMeasureGroup(measureGroup) { + long time = measureGroup.date // must be long. INT_MAX is too small + time *= 1000 + measureGroup.measures.collect { parseMeasure(it) + [date: new Date(time)] } +} + +def parseMeasure(measure) { +// log.debug "parseMeasure($measure)" + [ + name : measureAttribute(measure), + value: measureValue(measure) + ] +} + +def measureValue(measure) { + def value = measure.value * 10.power(measure.unit) + if (measure.type == 1) { // Weight (kg) + value *= 2.20462262 // kg to lbs + } + value +} + +String measureAttribute(measure) { + def attribute = "" + switch (measure.type) { + case 1: attribute = "weight"; break; + case 4: attribute = "height"; break; + case 5: attribute = "leanMass"; break; + case 6: attribute = "fatRatio"; break; + case 8: attribute = "fatMass"; break; + case 9: attribute = "diastolicPressure"; break; + case 10: attribute = "systolicPressure"; break; + case 11: attribute = "heartPulse"; break; + case 54: attribute = "SP02"; break; + } + return attribute +} + +String measureDescription(measure) { + def description = "" + switch (measure.type) { + case 1: description = "Weight (kg)"; break; + case 4: description = "Height (meter)"; break; + case 5: description = "Fat Free Mass (kg)"; break; + case 6: description = "Fat Ratio (%)"; break; + case 8: description = "Fat Mass Weight (kg)"; break; + case 9: description = "Diastolic Blood Pressure (mmHg)"; break; + case 10: description = "Systolic Blood Pressure (mmHg)"; break; + case 11: description = "Heart Pulse (bpm)"; break; + case 54: description = "SP02(%)"; break; + } + return description +} + +// ======================================================== +// WITHINGS NOTIFICATIONS +// ======================================================== + +def wNotificationBaseUrl() { "https://wbsapi.withings.net/notify" } + +def wNotificationCallbackUrl() { shortUrl("n") } + +def wGetNotification() { + def userId = userid() + def url = wNotificationBaseUrl() + def params = [ + action: "subscribe" + ] + +} + +// TODO: keep track of notification expiration +def wCreateNotification() { + def baseUrl = wNotificationBaseUrl() + def urlParams = [ + action : "subscribe", + userid : userid(), + callbackurl: wNotificationCallbackUrl(), + oauth_token: oauth_token(), + comment : "hmm" // TODO: figure out what to do here. spaces seem to break the request + ] + + fetchDataFromWithings(baseUrl, urlParams) +} + +def wRevokeAllNotifications() { + def notifications = wListNotifications() + notifications.each { + wRevokeNotification([callbackurl: it.callbackurl]) // use the callbackurl Withings has on file + } +} + +def wRevokeNotification(notificationParams = [:]) { + def baseUrl = wNotificationBaseUrl() + def urlParams = [ + action : "revoke", + userid : userid(), + callbackurl: wNotificationCallbackUrl(), + oauth_token: oauth_token() + ] + notificationParams + + fetchDataFromWithings(baseUrl, urlParams) +} + +def wListNotifications() { + + /* + { + body: { + profiles: [ + { + appli: 1, + expires: 2147483647, + callbackurl: "https://graph.api.smartthings.com/api/t/72ab3e57-5839-4cca-9562-dcc818f83bc9/s/537757a0-c4c8-40ea-8cea-aa283915bbd9/n", + comment: "hmm" + } + ] + }, + status: 0 + }*/ + + def baseUrl = wNotificationBaseUrl() + def urlParams = [ + action : "list", + userid : userid(), + callbackurl: wNotificationCallbackUrl(), + oauth_token: oauth_token() + ] + + def notificationData = fetchDataFromWithings(baseUrl, urlParams) + notificationData.body.profiles +} + +def defaultOauthParams() { + defaultParameterKeys().inject([:]) { keyMap, currentKey -> + keyMap[currentKey] = "${currentKey}"() + keyMap + } +} + +// ======================================================== +// WITHINGS DATA FETCHING +// ======================================================== + +def fetchDataFromWithings(baseUrl, urlParams) { + +// log.debug "fetchDataFromWithings(${baseUrl}, ${urlParams})" + + def defaultParams = defaultOauthParams() + def paramStrings = buildOauthParams(urlParams + defaultParams) +// log.debug "paramStrings: $paramStrings" + def url = buildOauthUrl(baseUrl, paramStrings, oauth_token_secret()) + def json +// log.debug "about to make request to ${url}" + httpGet(uri: url, headers: ["Content-Type": "application/json"]) { response -> + json = new groovy.json.JsonSlurper().parse(response.data) + } + return json +} + +// ======================================================== +// WITHINGS OAUTH LOGGING +// ======================================================== + +def wLogEnabled() { false } // For troubleshooting Oauth flow + +void wLog(message = "") { + if (!wLogEnabled()) { return } + def wLogMessage = atomicState.wLogMessage + if (wLogMessage.length()) { + wLogMessage += "\n|" + } + wLogMessage += message + atomicState.wLogMessage = wLogMessage +} + +void wLogNew(seedMessage = "") { + if (!wLogEnabled()) { return } + def olMessage = atomicState.wLogMessage + if (oldMessage) { + log.debug "purging old wLogMessage: ${olMessage}" + } + atomicState.wLogMessage = seedMessage +} + +String wLogMessage() { + if (!wLogEnabled()) { return } + def wLogMessage = atomicState.wLogMessage + atomicState.wLogMessage = "" + wLogMessage +} + +// ======================================================== +// WITHINGS OAUTH DESCRIPTION +// >>>>>> The user opens the authPage for this SmartApp +// STEP 1 get a token to be used in the url the user taps +// STEP 2 generate the url to be tapped by the user +// >>>>>> The user taps the url and logs in to Withings +// STEP 3 generate a token to be used for accessing user data +// STEP 4 access user data +// ======================================================== + +// ======================================================== +// WITHINGS OAUTH STEP 1: get an oAuth "request token" +// ======================================================== + +def requestTokenUrl() { + wLogNew "WITHINGS OAUTH STEP 1: get an oAuth 'request token'" + + def keys = defaultParameterKeys() + "oauth_callback" + def paramStrings = buildOauthParams(keys.sort()) + + buildOauthUrl("https://oauth.withings.com/account/request_token", paramStrings, "") +} + +// ======================================================== +// WITHINGS OAUTH STEP 2: End-user authorization +// ======================================================== + +def userAuthorizationUrl() { + + // get url from Step 1 + def tokenUrl = requestTokenUrl() + + // collect token from Withings + collectTokenFromWithings(tokenUrl) + + wLogNew "WITHINGS OAUTH STEP 2: End-user authorization" + + def keys = defaultParameterKeys() + "oauth_token" + def paramStrings = buildOauthParams(keys.sort()) + + buildOauthUrl("https://oauth.withings.com/account/authorize", paramStrings, oauth_token_secret()) +} + +// ======================================================== +// WITHINGS OAUTH STEP 3: Generating access token +// ======================================================== + +def exchangeTokenUrl() { + wLogNew "WITHINGS OAUTH STEP 3: Generating access token" + + def keys = defaultParameterKeys() + ["oauth_token", "userid"] + def paramStrings = buildOauthParams(keys.sort()) + + buildOauthUrl("https://oauth.withings.com/account/access_token", paramStrings, oauth_token_secret()) +} + +def exchangeToken() { + + def tokenUrl = exchangeTokenUrl() +// log.debug "about to hit ${tokenUrl}" + + try { + // replace old token with a long-lived token + def token = collectTokenFromWithings(tokenUrl) +// log.debug "collected token from Withings: ${token}" + renderAction("authorized", "Withings Connection") + } + catch (Exception e) { + log.error e + renderAction("notAuthorized", "Withings Connection Failed") + } +} + +// ======================================================== +// OAUTH 1.0 +// ======================================================== + +def defaultParameterKeys() { + [ + "oauth_consumer_key", + "oauth_nonce", + "oauth_signature_method", + "oauth_timestamp", + "oauth_version" + ] +} + +def oauth_consumer_key() { consumerKey } + +def oauth_nonce() { nonce() } + +def nonce() { UUID.randomUUID().toString().replaceAll("-", "") } + +def oauth_signature_method() { "HMAC-SHA1" } + +def oauth_timestamp() { (int) (new Date().time / 1000) } + +def oauth_version() { 1.0 } + +def oauth_callback() { shortUrl("x") } + +def oauth_token() { atomicState.wToken?.oauth_token } + +def oauth_token_secret() { atomicState.wToken?.oauth_token_secret } + +def userid() { atomicState.userid } + +String hmac(String oAuthSignatureBaseString, String oAuthSecret) throws java.security.SignatureException { + if (!oAuthSecret.contains("&")) { log.warn "Withings requires \"&\" to be included no matter what" } + // get an hmac_sha1 key from the raw key bytes + def signingKey = new javax.crypto.spec.SecretKeySpec(oAuthSecret.getBytes(), "HmacSHA1") + // get an hmac_sha1 Mac instance and initialize with the signing key + def mac = javax.crypto.Mac.getInstance("HmacSHA1") + mac.init(signingKey) + // compute the hmac on input data bytes + byte[] rawHmac = mac.doFinal(oAuthSignatureBaseString.getBytes()) + return org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac) +} + +Map parseResponseString(String responseString) { +// log.debug "parseResponseString: ${responseString}" + responseString.split("&").inject([:]) { c, it -> + def parts = it.split('=') + def k = parts[0] + def v = parts[1] + c[k] = v + return c + } +} + +String applyParams(endpoint, oauthParams) { endpoint + "?" + oauthParams.sort().join("&") } + +String buildSignature(endpoint, oAuthParams, oAuthSecret) { + def oAuthSignatureBaseParts = ["GET", endpoint, oAuthParams.join("&")] + def oAuthSignatureBaseString = oAuthSignatureBaseParts.collect { URLEncoder.encode(it) }.join("&") + wLog " ==> oAuth signature base string : \n${oAuthSignatureBaseString}" + wLog " .. applying hmac-sha1 to base string, with secret : ${oAuthSecret} (notice the \"&\")" + wLog " .. base64 encode then url-encode the hmac-sha1 hash" + String hmacResult = hmac(oAuthSignatureBaseString, oAuthSecret) + def signature = URLEncoder.encode(hmacResult) + wLog " ==> oauth_signature = ${signature}" + return signature +} + +List buildOauthParams(List parameterKeys) { + wLog " .. adding oAuth parameters : " + def oauthParams = [] + parameterKeys.each { key -> + def value = "${key}"() + wLog " ${key} = ${value}" + oauthParams << "${key}=${URLEncoder.encode(value.toString())}" + } + + wLog " .. sorting all request parameters alphabetically " + oauthParams.sort() +} + +List buildOauthParams(Map parameters) { + wLog " .. adding oAuth parameters : " + def oauthParams = [] + parameters.each { k, v -> + wLog " ${k} = ${v}" + oauthParams << "${k}=${URLEncoder.encode(v.toString())}" + } + + wLog " .. sorting all request parameters alphabetically " + oauthParams.sort() +} + +String buildOauthUrl(String endpoint, List parameterStrings, String oAuthTokenSecret) { + wLog "Api endpoint : ${endpoint}" + + wLog "Signing request :" + def oAuthSecret = "${consumerSecret}&${oAuthTokenSecret}" + def signature = buildSignature(endpoint, parameterStrings, oAuthSecret) + + parameterStrings << "oauth_signature=${signature}" + + def finalUrl = applyParams(endpoint, parameterStrings) + wLog "Result: ${finalUrl}" + if (wLogEnabled()) { + log.debug wLogMessage() + } + return finalUrl +} + +def collectTokenFromWithings(tokenUrl) { + // get token from Withings using the url generated in Step 1 + def tokenString + httpGet(uri: tokenUrl) { resp -> // oauth_token=&oauth_token_secret= + tokenString = resp.data.toString() +// log.debug "collectTokenFromWithings: ${tokenString}" + } + def token = parseResponseString(tokenString) + atomicState.wToken = token + return token +} + +// ======================================================== +// APP SETTINGS +// ======================================================== + +def getConsumerKey() { appSettings.consumerKey } + +def getConsumerSecret() { appSettings.consumerSecret } + +// figure out how to put this in settings +def getUserId() { atomicState.wToken?.userid } + +// ======================================================== +// HTML rendering +// ======================================================== + +def renderAction(action, title = "") { + log.debug "renderAction: $action" + renderHTML(title) { + head { "${action}HtmlHead"() } + body { "${action}HtmlBody"() } + } +} + +def authorizedHtmlHead() { + log.trace "authorizedHtmlHead" + """ + + """ +} + +def authorizedHtmlBody() { + """ +
+ withings icon + connected device icon + SmartThings logo +

Your Withings scale is now connected to SmartThings!

+

Click 'Done' to finish setup.

+
+ """ +} + +def notAuthorizedHtmlHead() { + log.trace "notAuthorizedHtmlHead" + authorizedHtmlHead() +} + +def notAuthorizedHtmlBody() { + """ +
+

There was an error connecting to SmartThings!

+

Click 'Done' to try again.

+
+ """ +} diff --git a/official/withings.groovy b/official/withings.groovy new file mode 100755 index 0000000..0e09ffc --- /dev/null +++ b/official/withings.groovy @@ -0,0 +1,579 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Withings Service Manager + * + * Author: SmartThings + * Date: 2013-09-26 + */ + +definition( + name: "Withings", + namespace: "smartthings", + author: "SmartThings", + description: "Connect your Withings scale to SmartThings.", + category: "Connections", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/withings.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/withings%402x.png", + oauth: true, + singleInstance: true +) { + appSetting "clientId" + appSetting "clientSecret" + appSetting "serverUrl" +} + +preferences { + page(name: "auth", title: "Withings", content:"authPage") +} + +mappings { + path("/exchange") { + action: [ + GET: "exchangeToken" + ] + } + path("/load") { + action: [ + GET: "load" + ] + } +} + +def authPage() { + log.debug "authPage()" + dynamicPage(name: "auth", title: "Withings", install:false, uninstall:true) { + section { + paragraph "This version is no longer supported. Please uninstall it." + } + } +} + +def oauthInitUrl() { + def token = getToken() + //log.debug "initiateOauth got token: $token" + + // store these for validate after the user takes the oauth journey + state.oauth_request_token = token.oauth_token + state.oauth_request_token_secret = token.oauth_token_secret + + return buildOauthUrlWithToken(token.oauth_token, token.oauth_token_secret) +} + +def getToken() { + def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}" + def params = [ + oauth_callback:URLEncoder.encode(callback), + ] + def requestTokenBaseUrl = "https://oauth.withings.com/account/request_token" + def url = buildSignedUrl(requestTokenBaseUrl, params) + //log.debug "getToken - url: $url" + + return getJsonFromUrl(url) +} + +def buildOauthUrlWithToken(String token, String tokenSecret) { + def callback = getServerUrl() + "/api/smartapps/installations/${app.id}/exchange?access_token=${state.accessToken}" + def params = [ + oauth_callback:URLEncoder.encode(callback), + oauth_token:token + ] + def authorizeBaseUrl = "https://oauth.withings.com/account/authorize" + + return buildSignedUrl(authorizeBaseUrl, params, tokenSecret) +} + +///////////////////////////////////////// +///////////////////////////////////////// +// vvv vvv OAuth 1.0 vvv vvv // +///////////////////////////////////////// +///////////////////////////////////////// +String buildSignedUrl(String baseUrl, Map urlParams, String tokenSecret="") { + def params = [ + oauth_consumer_key: smartThingsConsumerKey, + oauth_nonce: nonce(), + oauth_signature_method: "HMAC-SHA1", + oauth_timestamp: timestampInSeconds(), + oauth_version: 1.0 + ] + urlParams + def signatureBaseString = ["GET", baseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&") + + params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), tokenSecret) + + // query string is different from what is used in generating the signature above b/c it includes "oauth_signature" + def url = [baseUrl, toQueryString(params)].join('?') + return url +} + +String nonce() { + return UUID.randomUUID().toString().replaceAll("-", "") +} + +Integer timestampInSeconds() { + return (int)(new Date().time/1000) +} + +String toQueryString(Map m) { + return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") +} + +String hmac(String dataString, String consumerSecret, String tokenSecret="") throws java.security.SignatureException { + String result + + def key = [consumerSecret, tokenSecret].join('&') + + // get an hmac_sha1 key from the raw key bytes + def signingKey = new javax.crypto.spec.SecretKeySpec(key.getBytes(), "HmacSHA1") + + // get an hmac_sha1 Mac instance and initialize with the signing key + def mac = javax.crypto.Mac.getInstance("HmacSHA1") + mac.init(signingKey) + + // compute the hmac on input data bytes + byte[] rawHmac = mac.doFinal(dataString.getBytes()) + + result = org.apache.commons.codec.binary.Base64.encodeBase64String(rawHmac) + + return result +} +///////////////////////////////////////// +///////////////////////////////////////// +// ^^^ ^^^ OAuth 1.0 ^^^ ^^^ // +///////////////////////////////////////// +///////////////////////////////////////// + +///////////////////////////////////////// +///////////////////////////////////////// +// vvv vvv rest vvv vvv // +///////////////////////////////////////// +///////////////////////////////////////// + +protected rest(Map params) { + new physicalgraph.device.RestAction(params) +} + +///////////////////////////////////////// +///////////////////////////////////////// +// ^^^ ^^^ rest ^^^ ^^^ // +///////////////////////////////////////// +///////////////////////////////////////// + +def exchangeToken() { + // oauth_token=abcd + // &userid=123 + + def newToken = params.oauth_token + def userid = params.userid + def tokenSecret = state.oauth_request_token_secret + + def params = [ + oauth_token: newToken, + userid: userid + ] + + def requestTokenBaseUrl = "https://oauth.withings.com/account/access_token" + def url = buildSignedUrl(requestTokenBaseUrl, params, tokenSecret) + //log.debug "signed url: $url with secret $tokenSecret" + + def token = getJsonFromUrl(url) + + state.userid = userid + state.oauth_token = token.oauth_token + state.oauth_token_secret = token.oauth_token_secret + + log.debug "swapped token" + + def location = getServerUrl() + "/api/smartapps/installations/${app.id}/load?access_token=${state.accessToken}" + redirect(location:location) +} + +def load() { + def json = get(getMeasurement(new Date() - 30)) + // removed logging of actual json payload. Can be put back for debugging + log.debug "swapped, then received json" + parse(data:json) + + def html = """ + + + + +Withings Connection + + + +
+ withings icon + connected device icon + SmartThings logo +

Your Withings scale is now connected to SmartThings!

+

Click 'Done' to finish setup.

+
+ + +""" + + render contentType: 'text/html', data: html +} + +Map getJsonFromUrl(String url) { + return [:] // stop making requests to Withings API. This entire SmartApp will be replaced with a fix + + def jsonString + httpGet(uri: url) { resp -> + jsonString = resp.data.toString() + } + + return getJsonFromText(jsonString) +} + +Map getJsonFromText(String jsonString) { + def jsonMap = jsonString.split("&").inject([:]) { c, it -> + def parts = it.split('=') + def k = parts[0] + def v = parts[1] + c[k] = v + return c + } + + return jsonMap +} + +def getMeasurement(Date since=null) { + return null // stop making requests to Withings API. This entire SmartApp will be replaced with a fix + + // TODO: add startdate and enddate ... esp. when in response to notify + def params = [ + action:"getmeas", + oauth_consumer_key:getSmartThingsConsumerKey(), + oauth_nonce:nonce(), + oauth_signature_method:"HMAC-SHA1", + oauth_timestamp:timestampInSeconds(), + oauth_token:state.oauth_token, + oauth_version:1.0, + userid: state.userid + ] + + if(since) + { + params.startdate = dateToSeconds(since) + } + + def requestTokenBaseUrl = "http://wbsapi.withings.net/measure" + def signatureBaseString = ["GET", requestTokenBaseUrl, toQueryString(params)].collect { URLEncoder.encode(it) }.join("&") + + params.oauth_signature = hmac(signatureBaseString, getSmartThingsConsumerSecret(), state.oauth_token_secret) + + return rest( + method: 'GET', + endpoint: "http://wbsapi.withings.net", + path: "/measure", + query: params, + synchronous: true + ) + +} + +String get(measurementRestAction) { + return "" // stop making requests to Withings API. This entire SmartApp will be replaced with a fix + + def httpGetParams = [ + uri: measurementRestAction.endpoint, + path: measurementRestAction.path, + query: measurementRestAction.query + ] + + String json + httpGet(httpGetParams) {resp -> + json = resp.data.text.toString() + } + + return json +} + +def parse(Map response) { + def json = new org.codehaus.groovy.grails.web.json.JSONObject(response.data) + parseJson(json) +} + +def parseJson(json) { + log.debug "parseJson: $json" + + def lastDataPointMillis = (state.lastDataPointMillis ?: 0).toLong() + def i = 0 + + if(json.status == 0) + { + log.debug "parseJson measure group size: ${json.body.measuregrps.size()}" + + state.errorCount = 0 + + def childDni = getWithingsDevice(json.body.measuregrps).deviceNetworkId + + def latestMillis = lastDataPointMillis + json.body.measuregrps.sort { it.date }.each { group -> + + def measurementDateSeconds = group.date + def dataPointMillis = measurementDateSeconds * 1000L + + if(dataPointMillis > lastDataPointMillis) + { + group.measures.each { measure -> + i++ + saveMeasurement(childDni, measure, measurementDateSeconds) + } + } + + if(dataPointMillis > latestMillis) + { + latestMillis = dataPointMillis + } + + } + + if(latestMillis > lastDataPointMillis) + { + state.lastDataPointMillis = latestMillis + } + + def weightData = state.findAll { it.key.startsWith("measure.") } + + // remove old data + def old = "measure." + (new Date() - 30).format('yyyy-MM-dd') + state.findAll { it.key.startsWith("measure.") && it.key < old }.collect { it.key }.each { state.remove(it) } + } + else + { + def errorCount = (state.errorCount ?: 0).toInteger() + state.errorCount = errorCount + 1 + + // TODO: If we poll, consider waiting for a couple failures before showing an error + // But if we are only notified, then we need to raise the error right away + measurementError(json.status) + } + + log.debug "Done adding $i measurements" + return +} + +def measurementError(status) { + log.error "received api response status ${status}" + sendEvent(state.childDni, [name: "connection", value:"Connection error: ${status}", isStateChange:true, displayed:true]) +} + +def saveMeasurement(childDni, measure, measurementDateSeconds) { + def dateString = secondsToDate(measurementDateSeconds).format('yyyy-MM-dd') + + def measurement = withingsEvent(measure) + sendEvent(state.childDni, measurement + [date:dateString], [dateCreated:secondsToDate(measurementDateSeconds)]) + + log.debug "sm: ${measure.type} (${measure.type == 1})" + + if(measure.type == 6) + { + sendEvent(state.childDni, [name: "leanRatio", value:(100-measurement.value), date:dateString, isStateChange:true, display:true], [dateCreated:secondsToDate(measurementDateSeconds)]) + } + else if(measure.type == 1) + { + state["measure." + dateString] = measurement.value + } +} + +def eventValue(measure, roundDigits=1) { + def value = measure.value * 10.power(measure.unit) + + if(roundDigits != null) + { + def significantDigits = 10.power(roundDigits) + value = (value * significantDigits).toInteger() / significantDigits + } + + return value +} + +def withingsEvent(measure) { + def withingsTypes = [ + (1):"weight", + (4):"height", + (5):"leanMass", + (6):"fatRatio", + (8):"fatMass", + (11):"pulse" + ] + + def value = eventValue(measure, (measure.type == 4 ? null : 1)) + + if(measure.type == 1) { + value *= 2.20462 + } else if(measure.type == 4) { + value *= 39.3701 + } + + log.debug "m:${measure.type}, v:${value}" + + return [ + name: withingsTypes[measure.type], + value: value + ] +} + +Integer dateToSeconds(Date d) { + return d.time / 1000 +} + +Date secondsToDate(Number seconds) { + return new Date(seconds * 1000L) +} + +def getWithingsDevice(measuregrps=null) { + // unfortunately, Withings doesn't seem to give us enough information to know which device(s) they have, + // ... so we have to guess and create a single device + + if(state.childDni) + { + return getChildDevice(state.childDni) + } + else + { + def children = getChildDevices() + if(children.size() > 0) + { + return children[0] + } + else + { + // no child yet, create one + def dni = [app.id, UUID.randomUUID().toString()].join('.') + state.childDni = dni + + def childDeviceType = getBodyAnalyzerChildName() + + if(measuregrps) + { + def hasNoHeartRate = measuregrps.find { grp -> grp.measures.find { it.type == 11 } } == null + if(hasNoHeartRate) + { + childDeviceType = getScaleChildName() + } + } + + def child = addChildDevice(getChildNamespace(), childDeviceType, dni, null, [label:"Withings"]) + state.childId = child.id + return child + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. +} + +def poll() { + if(shouldPoll()) + { + return getMeasurement() + } + + return null +} + +def shouldPoll() { + def lastPollString = state.lastPollMillisString + def lastPoll = lastPollString?.isNumber() ? lastPollString.toLong() : 0 + def ONE_HOUR = 60 * 60 * 1000 + + def time = new Date().time + + if(time > (lastPoll + ONE_HOUR)) + { + log.debug "Executing poll b/c (now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})" + state.lastPollMillisString = time + + return true + } + + log.debug "skipping poll b/c !(now > last + 1hr): ${time} > ${lastPoll + ONE_HOUR} (last: ${lastPollString})" + return false +} + +def refresh() { + log.debug "Executing 'refresh'" + return getMeasurement() +} + +def getChildNamespace() { "smartthings" } +def getScaleChildName() { "Wireless Scale" } +def getBodyAnalyzerChildName() { "Smart Body Analyzer" } + +def getServerUrl() { appSettings.serverUrl } +def getSmartThingsConsumerKey() { appSettings.clientId } +def getSmartThingsConsumerSecret() { appSettings.clientSecret } diff --git a/official/working-from-home.groovy b/official/working-from-home.groovy new file mode 100755 index 0000000..d804c07 --- /dev/null +++ b/official/working-from-home.groovy @@ -0,0 +1,125 @@ +/** + * Working From Home + * + * Copyright 2014 George Sudarkoff + * + * 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: "Working From Home", + namespace: "com.sudarkoff", + author: "George Sudarkoff", + description: "If after a particular time of day a certain person is still at home, trigger a 'Working From Home' action.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/Cat-ModeMagic.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/ModeMagic/Cat-ModeMagic@2x.png" +) + +preferences { + page (name:"configActions") +} + +def configActions() { + dynamicPage(name: "configActions", title: "Configure Actions", uninstall: true, install: true) { + section ("When this person") { + input "person", "capability.presenceSensor", title: "Who?", multiple: false, required: true + } + section ("Still at home past") { + input "timeOfDay", "time", title: "What time?", required: true + } + + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + section("Perform this action") { + input "wfhPhrase", "enum", title: "\"Hello, Home\" action", required: true, options: phrases + } + } + + section (title: "More options", hidden: hideOptions(), hideable: true) { + input "sendPushMessage", "bool", title: "Send a push notification?" + input "phone", "phone", title: "Send a Text Message?", required: false + input "days", "enum", title: "Set for specific day(s) of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + } + + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + +def installed() { + initialize() +} + +def updated() { + unschedule() + initialize() +} + +def initialize() { + schedule(timeToday(timeOfDay, location?.timeZone), "checkPresence") + if (customName) { + app.setTitle(customName) + } +} + +def checkPresence() { + if (daysOk && modeOk) { + if (person.latestValue("presence") == "present") { + log.debug "${person} is present, triggering WFH action." + location.helloHome.execute(settings.wfhPhrase) + def message = "${location.name} executed '${settings.wfhPhrase}' because ${person} is home." + send(message) + } + } +} + +private send(msg) { + if (sendPushMessage != "No") { + sendPush(msg) + } + + if (phone) { + sendSms(phone, msg) + } + + log.debug msg +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + result +} + +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("America/Los_Angeles")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + result +} + +private hideOptions() { + (days || modes)? false: true +} + diff --git a/official/yoics-connect.groovy b/official/yoics-connect.groovy new file mode 100755 index 0000000..cd18e88 --- /dev/null +++ b/official/yoics-connect.groovy @@ -0,0 +1,752 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Yoics Service Manager + * + * Author: SmartThings + * Date: 2013-11-19 + */ + +definition( + name: "Yoics (Connect)", + namespace: "smartthings", + author: "SmartThings", + description: "Connect and Control your Yoics Enabled Devices", + category: "SmartThings Internal", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png", + oauth: true, + singleInstance: true +) { + appSetting "serverUrl" +} + +preferences { + page(name: "auth", title: "Sign in", content: "authPage", uninstall:true) + page(name: "page2", title: "Yoics Devices", install:true, content: "listAvailableCameras") +} + + +mappings { + path("/foauth") { + action: [ + GET: "foauth" + ] + } + path("/authorize") { + action: [ + POST: "authorize" + ] + } + +} + +def authPage() +{ + log.debug "authPage()" + + if(!state.accessToken) + { + log.debug "about to create access token" + createAccessToken() + } + + + def description = "Required" + + if(getAuthHashValueIsValid()) + { + // TODO: Check if it's valid + if(true) + { + description = "Already saved" + } + else + { + description = "Required" + } + } + + def redirectUrl = buildUrl("", "foauth") + + return dynamicPage(name: "auth", title: "Yoics", nextPage:"page2") { + section("Yoics Login"){ + href url:redirectUrl, style:"embedded", required:false, title:"Yoics", description:description + } + } + +} + +def buildUrl(String key, String endpoint="increment", Boolean absolute=true) +{ + if(key) { + key = "/${key}" + } + + def url = "/api/smartapps/installations/${app.id}/${endpoint}${key}?access_token=${state.accessToken}" + + if (q) { + url += "q=${q}" + } + + if(absolute) + { + url = serverUrl + url + } + + return url +} + +//Deprecated +def getServerName() { + return getServerUrl() +} + +def getServerUrl() { + return appSettings.serverUrl +} + +def listAvailableCameras() { + + //def loginResult = forceLogin() + + //if(loginResult.success) + //{ + state.cameraNames = [:] + + def cameras = getDeviceList().inject([:]) { c, it -> + def dni = [app.id, it.uuid].join('.') + def cameraName = it.title ?: "Yoics" + + state.cameraNames[dni] = cameraName + c[dni] = cameraName + + return c + } + + return dynamicPage(name: "page2", title: "Yoics Devices", install:true) { + section("Select which Yoics Devices to connect"){ + input(name: "cameras", title:"", type: "enum", required:false, multiple:true, metadata:[values:cameras]) + } + section("Turn on which Lights when taking pictures") + { + input "switches", "capability.switch", multiple: true, required:false + } + } + //} + /*else + { + log.error "login result false" + return [errorMessage:"There was an error logging in to Dropcam"] + }*/ + +} + + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def uninstalled() { + removeChildDevices(getChildDevices()) +} + +def initialize() { + + if(!state.suppressDelete) + { + state.suppressDelete = [:] + } + + log.debug "settings: $settings" + + def devices = cameras.collect { dni -> + + def name = state.cameraNames[dni] ?: "Yoics Device" + + def d = getChildDevice(dni) + + if(!d) + { + d = addChildDevice("smartthings", "Yoics Camera", dni, null, [name:"YoicsCamera", label:name]) + + /* WE'LL GET PROXY ON TAKE REQUEST + def setupProxyResult = setupProxy(dni) + if(setupProxyResult.success) + { + log.debug "Setting up the proxy worked...taking image capture now?" + + } + */ + + //Let's not take photos on add + //d.take() + + log.debug "created ${d.displayName} with id $dni" + } + else + { + log.debug "found ${d.displayName} with id $dni already exists" + } + + return d + } + + log.debug "created ${devices.size()} dropcams" + + /* //Original Code seems to delete the dropcam that is being added */ + + // Delete any that are no longer in settings + def delete = getChildDevices().findAll { !cameras?.contains(it.deviceNetworkId) } + removeChildDevices(delete) +} + +private removeChildDevices(delete) +{ + log.debug "deleting ${delete.size()} dropcams" + delete.each { + state.suppressDelete[it.deviceNetworkId] = true + deleteChildDevice(it.deviceNetworkId) + state.suppressDelete.remove(it.deviceNetworkId) + } +} +private List getDeviceList() +{ + + //https://apilb.yoics.net/web/api/getdevices.ashx?token=&filter=all&whose=me&state=%20all&type=xml + + def deviceListParams = [ + uri: "https://apilb.yoics.net", + path: "/web/api/getdevices.ashx", + headers: ['User-Agent': validUserAgent()], + requestContentType: "application/json", + query: [token: getLoginTokenValue(), filter: "all", whose: "me", state: "all", type:"json" ] + ] + + log.debug "cam list via: $deviceListParams" + + def multipleHtml + def singleUrl + def something + def more + + def devices = [] + + httpGet(deviceListParams) { resp -> + + log.debug "getting device list..." + + something = resp.status + more = "headers: " + resp.headers.collect { "${it.name}:${it.value}" } + + if(resp.status == 200) + { + def jsonString = resp.data.str + def body = new groovy.json.JsonSlurper().parseText(jsonString) + + //log.debug "get devices list response: ${jsonString}" + //log.debug "get device list response: ${body}" + + body.NewDataSet.Table.each { d -> + //log.debug "Addding ${d.devicealias} with address: ${d.deviceaddress}" + devices << [title: d.devicealias, uuid: d.deviceaddress] //uuid should be another name + } + + } + else + { + // ERROR + log.error "camera list: unknown response" + } + + } + + log.debug "list: after getting cameras: " + [devices:devices, url:singleUrl, html:multipleHtml?.size(), something:something, more:more] + + // ERROR? + return devices +} + +def removeChildFromSettings(child) +{ + def device = child.device + + def dni = device.deviceNetworkId + log.debug "removing child device $device with dni ${dni}" + + if(!state?.suppressDelete?.get(dni)) + { + def newSettings = settings.cameras?.findAll { it != dni } ?: [] + app.updateSetting("cameras", newSettings) + } +} + +private forceLogin() { + updateAuthHash(null) + login() +} + + +private login() { + + if(getAuthHashValueIsValid()) + { + return [success:true] + } + return doLogin() +} + +/*private setupProxy(dni) { + //https://apilb.yoics.net/web/api/connect.ashx?token=&deviceaddress=00:00:48:02:2A:A2:08:0E&type=xml + + def address = dni?.split(/\./)?.last() + + def loginParams = [ + uri: "https://apilb.yoics.net", + path: "/web/api/connect.ashx", + headers: ['User-Agent': validUserAgent()], + requestContentType: "application/json", + query: [token: getLoginTokenValue(), deviceaddress:address, type:"json" ] + ] + + def result = [success:false] + + httpGet(loginParams) { resp -> + if (resp.status == 200) //&& resp.headers.'Content-Type'.contains("application/json") + { + log.debug "login 200 json headers: " + resp.headers.collect { "${it.name}:${it.value}" } + def jsonString = resp.data.str + def body = new groovy.json.JsonSlurper().parseText(jsonString) + + def proxy = body?.NewDataSet?.Table[0]?.proxy + def requested = body?.NewDataSet?.Table[0]?.requested + def expirationsec = body?.NewDataSet?.Table[0]?.expirationsec + def url = body?.NewDataSet?.Table[0]?.url + + def proxyMap = [proxy:proxy, requested: requested, expirationsec:expirationsec, url: url] + + if (proxy) { + //log.debug "setting ${dni} proxy to ${proxyMap}" + //updateDeviceProxy(address, proxyMap) + result.success = true + } + else + { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + } + else + { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + + + } + + return result +}*/ + + + +private doLogin(user = "", pwd = "") { //change this name + + def loginParams = [ + uri: "https://apilb.yoics.net", + path: "/web/api/login.ashx", + headers: ['User-Agent': validUserAgent()], + requestContentType: "application/json", + query: [key: "SmartThingsApplication", usr: username, pwd: password, apilevel: 12, type:"json" ] + ] + + if (user) { + loginParams.query = [key: "SmartThingsApplication", usr: user, pwd: pwd, apilevel: 12, type:"json" ] + } + + def result = [success:false] + + httpGet(loginParams) { resp -> + if (resp.status == 200) //&& resp.headers.'Content-Type'.contains("application/json") + { + log.debug "login 200 json headers: " + resp.headers.collect { "${it.name}:${it.value}" } + def jsonString = resp.data.str + def body = new groovy.json.JsonSlurper().parseText(jsonString) + + log.debug "login response: ${jsonString}" + log.debug "login response: ${body}" + + def authhash = body?.NewDataSet?.Table[0]?.authhash //.token + + //this may return as well?? + def token = body?.NewDataSet?.Table[0]?.token ?: null + + if (authhash) { + log.debug "login setting authhash to ${authhash}" + updateAuthHash(authhash) + if (token) { + log.debug "login setting login token to ${token}" + updateLoginToken(token) + result.success = true + } else { + result.success = doLoginToken() + } + } + else + { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + } + else + { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + + + } + + return result +} + +private doLoginToken() { + + def loginParams = [ + uri: "https://apilb.yoics.net", + path: "/web/api/login.ashx", + headers: ['User-Agent': validUserAgent()], + requestContentType: "application/json", + query: [key: "SmartThingsApplication", usr: getUserName(), auth: getAuthHashValue(), apilevel: 12, type:"json" ] + ] + + def result = [success:false] + + httpGet(loginParams) { resp -> + if (resp.status == 200) + { + log.debug "login 200 json headers: " + resp.headers.collect { "${it.name}:${it.value}" } + + def jsonString = resp.data.str + def body = new groovy.json.JsonSlurper().parseText(jsonString) + + def token = body?.NewDataSet?.Table[0]?.token + + if (token) { + log.debug "login setting login to $token" + updateLoginToken(token) + result.success = true + } + else + { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + } + else + { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + + + } + + return result +} + +def takePicture(String dni, Integer imgWidth=null) +{ + + //turn on any of the selected lights that are off + def offLights = switches.findAll{(it.currentValue("switch") == "off")} + log.debug offLights + offLights.collect{it.on()} + + log.debug "parent.takePicture(${dni}, ${imgWidth})" + + def uuid = dni?.split(/\./)?.last() + + log.debug "taking picture for $uuid (${dni})" + + def imageBytes + def loginRequired = false + + try + { + imageBytes = doTakePicture(uuid, imgWidth) + } + catch(Exception e) + { + log.error "Exception $e trying to take a picture, attempting to login again" + loginRequired = true + } + + if(loginRequired) + { + def loginResult = doLoginToken() + if(loginResult.success) + { + // try once more + imageBytes = doTakePicture(uuid, imgWidth) + } + else + { + log.error "tried to login to dropcam after failing to take a picture and failed" + } + } + + //turn previously off lights to their original state + offLights.collect{it.off()} + return imageBytes +} + +private doTakePicture(String uuid, Integer imgWidth) +{ + imgWidth = imgWidth ?: 1280 + def loginRequired = false + + def proxyParams = getDeviceProxy(uuid) + if(!proxyParams.success) + { + throw new Exception("Login Required") + } + + def takeParams = [ + uri: "${proxyParams.uri}", + path: "${proxyParams.path}", + headers: ['User-Agent': validUserAgent()] + ] + + def imageBytes + + httpGet(takeParams) { resp -> + + if(resp.status == 403) + { + loginRequired = true + } + else if (resp.status == 200 && resp.headers.'Content-Type'.contains("image/jpeg")) + { + imageBytes = resp.data + } + else + { + log.error "unknown takePicture() response: ${resp.status} - ${resp.headers.'Content-Type'}" + } + } + + if(loginRequired) + { + throw new Exception("Login Required") + } + + return imageBytes +} + +///////////////////////// +private Boolean getLoginTokenValueIsValid() +{ + return getLoginTokenValue() +} + +private updateLoginToken(String token) { + state.loginToken = token +} + +private getLoginTokenValue() { + state.loginToken +} + +private Boolean getAuthHashValueIsValid() +{ + return getAuthHashValue() +} + +private updateAuthHash(String hash) { + state.authHash = hash +} + +private getAuthHashValue() { + state.authHash +} + +private updateUserName(String username) { + state.username = username +} + +private getUserName() { + state.username +} + +/*private getDeviceProxy(dni){ + //check if it exists or is not longer valid and create a new proxy here + log.debug "returning proxy ${state.proxy[dni].proxy}" + def proxy = [uri:state.proxy[dni].proxy, path:state.proxy[dni].url] + log.debug "returning proxy ${proxy}" + proxy +}*/ + +private updateDeviceProxy(dni, map){ + if (!state.proxy) { state.proxy = [:] } + state.proxy[dni] = map +} + +private getDeviceProxy(dni) { + def address = dni?.split(/\./)?.last() + + def loginParams = [ + uri: "https://apilb.yoics.net", + path: "/web/api/connect.ashx", + headers: ['User-Agent': validUserAgent()], + requestContentType: "application/json", + query: [token: getLoginTokenValue(), deviceaddress:address, type:"json" ] + ] + + def result = [success:false] + + httpGet(loginParams) { resp -> + if (resp.status == 200) //&& resp.headers.'Content-Type'.contains("application/json") + { + log.debug "login 200 json headers: " + resp.headers.collect { "${it.name}:${it.value}" } + def jsonString = resp.data.str + def body = new groovy.json.JsonSlurper().parseText(jsonString) + + if (body?.NewDataSet?.Table[0]?.error) + { + log.error "Attempt to get Yoics Proxy failed" + // ERROR: any more information we can give? + result.reason = body?.NewDataSet?.Table[0]?.message + } + else + { + result.uri = body?.NewDataSet?.Table[0]?.proxy + result.path = body?.NewDataSet?.Table[0]?.url + result.requested = body?.NewDataSet?.Table[0]?.requested + result.expirationsec = body?.NewDataSet?.Table[0]?.expirationsec + result.success = true + } + + } + else + { + // ERROR: any more information we can give? + result.reason = "Bad login" + } + + + } + + return result + +} + +private validUserAgent() { + "curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8x zlib/1.2.5" +} + +def foauth() { + def html = """ + + $inputQuery results + + + + + + + + + + + + + +

+ Yoics Login +

+ +
+User: +
+ +
+
+Password: +
+ + + +
+ + + +""" + + render status: 200, contentType: 'text/html', data: html +} + +def authorize() { + + def loginResult = doLogin(params.user, params.password) + + def result + if (loginResult.success) { + result = "Successful" + + //save username + updateUserName(params.user) + } else { + result = "Failed" + } + + def html = """ + + $inputQuery results + + + + + + + + + + + + + + + + +

+ Yoics Login ${result}! +

+ + +""" + + render status: 200, contentType: 'text/html', data: html +} diff --git a/third-party/AeonMinimote.groovy b/third-party/AeonMinimote.groovy new file mode 100755 index 0000000..c961eda --- /dev/null +++ b/third-party/AeonMinimote.groovy @@ -0,0 +1,104 @@ +metadata { + definition (name: "Aeon Minimote", namespace: "smartthings", author: "SmartThings") { + capability "Actuator" + capability "Button" + capability "Configuration" + capability "Sensor" + attribute "numButtons", "STRING" + + fingerprint deviceId: "0x0101", inClusters: "0x86,0x72,0x70,0x9B", outClusters: "0x26,0x2B" + fingerprint deviceId: "0x0101", inClusters: "0x86,0x72,0x70,0x9B,0x85,0x84", outClusters: "0x26" // old style with numbered buttons + } + + simulator { + status "button 1 pushed": "command: 2001, payload: 01" + status "button 1 held": "command: 2001, payload: 15" + status "button 2 pushed": "command: 2001, payload: 29" + status "button 2 held": "command: 2001, payload: 3D" + status "button 3 pushed": "command: 2001, payload: 51" + status "button 3 held": "command: 2001, payload: 65" + status "button 4 pushed": "command: 2001, payload: 79" + status "button 4 held": "command: 2001, payload: 8D" + status "wakeup": "command: 8407, payload: " + } + tiles { + standardTile("button", "device.button", width: 2, height: 2) { + state "default", label: "", icon: "st.unknown.zwave.remote-controller", backgroundColor: "#ffffff" + } + // Configure button. Syncronize the device capabilities that the UI provides + standardTile("configure", "device.configure", inactiveLabel: false, decoration: "flat") { + state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure" + } + main "button" + details(["button", "configure"]) + } +} + +def parse(String description) { + def results = [] + if (description.startsWith("Err")) { + results = createEvent(descriptionText:description, displayed:true) + } else { + def cmd = zwave.parse(description, [0x2B: 1, 0x80: 1, 0x84: 1]) + if(cmd) results += zwaveEvent(cmd) + if(!results) results = [ descriptionText: cmd, displayed: false ] + } + // log.debug("Parsed '$description' to $results") + return results +} + +def zwaveEvent(physicalgraph.zwave.commands.wakeupv1.WakeUpNotification cmd) { + def results = [createEvent(descriptionText: "$device.displayName woke up", isStateChange: false)] + + results += configurationCmds().collect{ response(it) } + results << response(zwave.wakeUpV1.wakeUpNoMoreInformation().format()) + + return results +} + +def buttonEvent(button, held) { + button = button as Integer + if (held) { + createEvent(name: "button", value: "held", data: [buttonNumber: button], descriptionText: "$device.displayName button $button was held", isStateChange: true) + } else { + createEvent(name: "button", value: "pushed", data: [buttonNumber: button], descriptionText: "$device.displayName button $button was pushed", isStateChange: true) + } +} + +def zwaveEvent(physicalgraph.zwave.commands.sceneactivationv1.SceneActivationSet cmd) { + Integer button = ((cmd.sceneId + 1) / 2) as Integer + Boolean held = !(cmd.sceneId % 2) + buttonEvent(button, held) +} + +def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicSet cmd) { + Integer button = (cmd.value / 40 + 1) as Integer + Boolean held = (button * 40 - cmd.value) <= 20 + buttonEvent(button, held) +} + +def zwaveEvent(physicalgraph.zwave.Command cmd) { + [ descriptionText: "$device.displayName: $cmd", linkText:device.displayName, displayed: false ] +} + +def configurationCmds() { + def cmds = [] + def hubId = zwaveHubNodeId + (1..4).each { button -> + cmds << zwave.configurationV1.configurationSet(parameterNumber: 240+button, scaledConfigurationValue: 1).format() + } + (1..4).each { button -> + cmds << zwave.configurationV1.configurationSet(parameterNumber: (button-1)*40, configurationValue: [hubId, (button-1)*40 + 1, 0, 0]).format() + cmds << zwave.configurationV1.configurationSet(parameterNumber: (button-1)*40 + 20, configurationValue: [hubId, (button-1)*40 + 21, 0, 0]).format() + } + cmds +} + +def configure() { + // Set the number of buttons to 4 + sendEvent(name: "numButtons", value: "4", displayed: false) + + def cmds = configurationCmds() + log.debug("Sending configuration: $cmds") + return cmds +} diff --git a/third-party/AlarmThing-Alert.groovy b/third-party/AlarmThing-Alert.groovy new file mode 100755 index 0000000..4fb337f --- /dev/null +++ b/third-party/AlarmThing-Alert.groovy @@ -0,0 +1,62 @@ +/** + * SmartAlarmAlert + * + * Copyright 2014 ObyCode + * + * 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: "SmartAlarmAlert", + namespace: "com.obycode", + author: "ObyCode", + description: "Alert me when my alarm status changes (armed, alarming, disarmed).", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + +preferences { + section("Alert me when this alarm changes state (arming, armed, disarmed, alarm):") { + input "theAlarm", "capability.alarm", multiple: false, required: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + log.debug "in initialize" + subscribe(theAlarm, "alarmStatus", statusChanged) +} + +def statusChanged(evt) { + if (evt.value == "away") { + sendPush("Alarm armed to 'away'") + } else if (evt.value == "stay") { + sendPush("Alarm armed to 'stay'") + } else if (evt.value == "arming") { + sendPush("Alarm is arming") + } else if (evt.value == "alarm") { + sendPush("ALARM IS GOING OFF!") + } else if (evt.value == "disarmed") { + sendPush("Alarm is disarmed") + } +} diff --git a/third-party/AlarmThing-AlertAll.groovy b/third-party/AlarmThing-AlertAll.groovy new file mode 100755 index 0000000..f8738a2 --- /dev/null +++ b/third-party/AlarmThing-AlertAll.groovy @@ -0,0 +1,65 @@ +/** + * AlarmThing AlertAll + * + * Copyright 2014 ObyCode + * + * 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: "AlarmThing AlertAll", + namespace: "com.obycode", + author: "ObyCode", + description: "Send a message whenever any sensor changes on the alarm.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + +preferences { + section("Notify me when there is any activity on this alarm:") { + input "theAlarm", "capability.alarm", multiple: false, required: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + log.debug "in initialize" + subscribe(theAlarm, "contact", contactTriggered) + subscribe(theAlarm, "motion", motionTriggered) +} + +def contactTriggered(evt) { + if (evt.value == "open") { + sendPush("A door was opened") + } else { + sendPush("A door was closed") + } +} + +def motionTriggered(evt) { + if (evt.value == "active") { + sendPush("Alarm motion detected") + }// else { + //sendPush("Motion stopped") + //} +} diff --git a/third-party/AlarmThing-AlertContact.groovy b/third-party/AlarmThing-AlertContact.groovy new file mode 100755 index 0000000..83b1957 --- /dev/null +++ b/third-party/AlarmThing-AlertContact.groovy @@ -0,0 +1,58 @@ +/** + * AlarmThing AlertContact + * + * Copyright 2014 ObyCode + * + * 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: "AlarmThing AlertContact", + namespace: "com.obycode", + author: "ObyCode", + description: "Alert me when a specific contact sensor on my alarm is opened.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + +preferences { + section("When a contact is opened on this alarm...") { + input "theAlarm", "capability.alarm", multiple: false, required: true + } + section("with this sensor name...") { + input "theSensor", "string", multiple: false, required: true + } + section("push me this message...") { + input "theMessage", "string", multiple: false, required: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + log.debug "in initialize" + subscribe(theAlarm, theSensor + ".open", sensorTriggered) +} + +def sensorTriggered(evt) { + sendPush(theMessage) +} diff --git a/third-party/AlarmThing-AlertMotion.groovy b/third-party/AlarmThing-AlertMotion.groovy new file mode 100755 index 0000000..5500188 --- /dev/null +++ b/third-party/AlarmThing-AlertMotion.groovy @@ -0,0 +1,59 @@ +/** + * AlarmThing AlertMotion + * + * Copyright 2014 ObyCode + * + * 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: "AlarmThing AlertMotion", + namespace: "com.obycode", + author: "ObyCode", + description: "Alert me when a specific motion sensor on my alarm is activated.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + + +preferences { + section("When there is motion on this alarm...") { + input "theAlarm", "capability.alarm", multiple: false, required: true + } + section("for this sensor...") { + input "theSensor", "string", multiple: false, required: true + } + section("push me this message...") { + input "theMessage", "string", multiple: false, required: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + log.debug "in initialize" + subscribe(theAlarm, theSensor + ".active", sensorTriggered) +} + +def sensorTriggered(evt) { + sendPush(theMessage) +} diff --git a/third-party/AlarmThing-AlertSensor.groovy b/third-party/AlarmThing-AlertSensor.groovy new file mode 100755 index 0000000..2d02851 --- /dev/null +++ b/third-party/AlarmThing-AlertSensor.groovy @@ -0,0 +1,114 @@ +/** + * AlarmThing Alert Sensor + * + * Copyright 2014 ObyCode + * + * 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: "AlarmThing Sensor Alert", + namespace: "com.obycode", + author: "ObyCode", + description: "Alert me when there is activity on one or more of my alarm's sensors.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + +preferences { + page(name: "selectAlarm") + page(name: "selectSensors") + page(name: "selectStates") +} + +def selectAlarm() { + dynamicPage(name: "selectAlarm", title: "Configure Alarm", nextPage:"selectSensors", uninstall: true) { + section("When there is activity on this alarm...") { + input "theAlarm", "capability.alarm", multiple: false, required: true + } + } +} + +def selectSensors() { + dynamicPage(name: "selectSensors", title: "Configure Sensors", uninstall: true, nextPage:"selectStates") { + def sensors = theAlarm.supportedAttributes*.name + if (sensors) { + section("On these sensors...") { + input "theSensors", "enum", required: true, multiple:true, metadata:[values:sensors], refreshAfterSelection:true + } + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + } + } +} + +def selectStates() { + dynamicPage(name: "selectStates", title: "Which states should trigger a notification?", uninstall: true, install: true) { + theSensors.each() { + def sensor = it + def states = [] + // TODO: Cannot figure out how to get these possible states, so have to guess them based on the current value + switch(theAlarm.currentValue("$it")) { + case "active": + case "inactive": + states = ["active", "inactive"] + break + case "on": + case "off": + states = ["on", "off"] + break + case "detected": + case "clear": + case "tested": + states = ["detected", "clear", "tested"] + break + case "closed": + case "open": + states = ["closed", "open"] + break + default: + log.debug "value not handled: ${theAlarm.currentValue("$sensor")}" + } + if (states) { + section() { + input "${sensor}States", "enum", title:"For $sensor...", required: true, multiple:true, metadata:[values:states], refreshAfterSelection:true + } + } + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + theSensors.each() { + def sensor = it + settings."${it}States".each() { + subscribe(theAlarm, "${sensor}.$it", sensorTriggered) + } + } +} + +def sensorTriggered(evt) { + sendPush("Alarm: ${evt.name} is ${evt.value}") + log.debug "Alarm: ${evt.name} is ${evt.value}" +} diff --git a/third-party/AutomaticCarHA.groovy b/third-party/AutomaticCarHA.groovy new file mode 100755 index 0000000..c574dc1 --- /dev/null +++ b/third-party/AutomaticCarHA.groovy @@ -0,0 +1,244 @@ +/** + * AutomaticCarHA + * + * Copyright 2015 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Requires MyAutomatic device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + * + */ +definition( + name: "AutomaticCarHA", + namespace: "yracine", + author: "Yves Racine", + description: "Near Real-Time Automatic Car automation with SmartThings", + category: "My Apps", + iconUrl: "https://www.automatic.com/_assets/images/favicons/favicon-32x32-3df4de42.png", + iconX2Url: "https://www.automatic.com/_assets/images/favicons/favicon-96x96-06fd8c85.png", + iconX3Url: "https://www.automatic.com/_assets/images/favicons/favicon-96x96-06fd8c85.png" +) + +preferences { + + page(name: "HASettingsPage", title: "Home Automation Settings") + page(name: "otherSettings", title: "OtherSettings") + +} + +def HASettingsPage() { + def phrases = location.helloHome?.getPhrases()*.label + + dynamicPage(name: "HASettingsPage", install: false, uninstall: true, nextPage: "otherSettings") { + section("About") { + paragraph "Near Real-Time Automatic Car automation with SmartThings" + paragraph "Version 1.0.4" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2016 Yves Racine" + href url:"http://github.com/yracine/device-type.myautomatic", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine" + } + section("For the following Automatic Connected Vehicle") { + input "vehicle", "capability.presenceSensor", title: "Which vehicle?" + } + section("And, when these trip events are triggered") { + input "givenEvents", "enum", + title: "Which Events(s)?", + multiple: true, + required: true, + metadata: [ + values: [ + 'ignition:on', + 'ignition:off', + 'trip:finished', + 'notification:speeding', + 'notification:hard_brake', + 'notification:hard_accel', + 'mil:on', + 'mil:off', + 'hmi:interaction', + 'location:updated', + ] + ] + + } + section("Turn on/off or Flash the following switch(es) [optional]") { + input "switches", "capability.switch", required:false, multiple: true, title: "Which switch(es)?" + input "switchMode", "enum", metadata: [values: ["Flash", "Turn On","Turn Off"]], required: false, defaultValue: "Turn On", title: "Action?" + } + section("Select Routine for Execution [optional]") { + input "phrase", "enum", title: "Routine?", required: false, options: phrases + } + } /* end of dynamic page */ +} + + +def otherSettings() { + dynamicPage(name: "otherSettings", title: "Other Settings", install: true, uninstall: false) { + section("Detailed Notifications") { + input "detailedNotif", "bool", title: "Detailed Notifications?", required: + false + } + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: + false + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + section([mobileOnly: true]) { + label title: "Assign a name for this SmartApp", required: false + } + } +} + + + + +def installed() { + initialize() +} + +def updated() { + try { + unschedule() + } catch (e) { + log.debug ("updated>exception $e while trying to call unschedule") + } + unsubscribe() + initialize() +} + +def initialize() { + subscribe(vehicle, "eventTripCreatedAt", eventHandler) +} + +def eventHandler(evt) { + def msg + + def createdAt =vehicle.currentEventTripCreatedAt + String eventType = vehicle.currentEventType + log.debug "eventHandler>evt.value=${evt.value}, eventType=${eventType}" + + def lat = vehicle.currentEventTripLocationLat + def lon = vehicle.currentEventTripLocationLon + msg = "AutomaticCarHA>${vehicle} vehicle has triggered ${eventType} event at ${createdAt}, (lon: ${lon}, lat: ${lat})..." + log.debug msg + if (detailedNotif) { + send msg + } + check_event(eventType) +} + + +private boolean check_event(eventType) { + def msg + boolean foundEvent=false + + log.debug "check_event>eventType=${eventType}, givenEvents list=${givenEvents}" + if ((givenEvents.contains(eventType))) { + foundEvent=true + msg = "AutomaticCarHA>${vehicle} vehicle has triggered ${eventType}, about to ${switchMode} ${switches}" + log.debug msg + if (detailedNotif) { + send msg + } + + if (switches) { + if (switchMode?.equals("Turn On")) { + switches.on() + } else if (switchMode?.equals("Turn Off")) { + switches.off() + } else { + flashLights() + } + } + if (phrase) { + msg = "AutomaticCarHA>${vehicle} vehicle has triggered ${eventType}, about to execute ${phrase} routine" + log.debug msg + if (detailedNotif) { + send msg + } + location.helloHome?.execute(phrase) + } + } + return foundEvent +} + + +private flashLights() { + def doFlash = true + def onFor = onFor ?: 1000 + def offFor = offFor ?: 1000 + def numFlashes = numFlashes ?: 3 + + log.debug "LAST ACTIVATED IS: ${state.lastActivated}" + if (state.lastActivated) { + def elapsed = now() - state.lastActivated + def sequenceTime = (numFlashes + 1) * (onFor + offFor) + doFlash = elapsed > sequenceTime + log.debug "DO FLASH: $doFlash, ELAPSED: $elapsed, LAST ACTIVATED: ${state.lastActivated}" + } + + if (doFlash) { + log.debug "FLASHING $numFlashes times" + state.lastActivated = now() + log.debug "LAST ACTIVATED SET TO: ${state.lastActivated}" + def initialActionOn = switches.collect { + it.currentSwitch != "on" + } + int delay = 1 + numFlashes.times { + log.trace "Switch on after $delay msec" + switches.eachWithIndex { + s, i -> + if (initialActionOn[i]) { + s.on(delay: delay) + } else { + s.off(delay: delay) + } + } + delay += onFor + log.trace "Switch off after $delay msec" + switches.eachWithIndex { + s, i -> + if (initialActionOn[i]) { + s.off(delay: delay) + } else { + s.on(delay: delay) + } + } + delay += offFor + } + } +} + + + + + +private def send(msg) { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + + log.debug msg +} diff --git a/third-party/AutomaticReport.groovy b/third-party/AutomaticReport.groovy new file mode 100755 index 0000000..42c3200 --- /dev/null +++ b/third-party/AutomaticReport.groovy @@ -0,0 +1,288 @@ +/** + * AutomaticReport + * + * Copyright 2015 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Requires MyAutomatic device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + **/ + +import java.text.SimpleDateFormat +import groovy.json.JsonSlurper + +definition( + name: "AutomaticReport", + namespace: "yracine", + author: "Yves Racine", + description: "This smartapp allows a ST user to manually generate daily Reports on their Automatic Connected vehicle", + category: "My Apps", + iconUrl: "https://www.automatic.com/_assets/images/favicons/favicon-32x32-3df4de42.png", + iconX2Url: "https://www.automatic.com/_assets/images/favicons/favicon-96x96-06fd8c85.png", + iconX3Url: "https://www.automatic.com/_assets/images/favicons/favicon-96x96-06fd8c85.png") + +preferences { + section("About") { + paragraph "automaticReport, the smartapp that generates daily runtime reports about your Automatic connected vehicle" + paragraph "You can only run the smartapp manually by pressing the arrow sign on the app's icon" + paragraph "Version 1.6.8" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2015 Yves Racine" + href url:"http://github.com/yracine/device-type.myautomatic", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine" + } + + section("Generate daily report for this Automatic Connected Vehicle") { + input "automatic", "capability.presenceSensor", title: "Automatic?" + + } + section("Start date for the report, format = YYYY-MM-DD") { + input "givenStartDate", "text", title: "Beginning Date [default=yesterday]", required:false + } + section("Start time for report HH:MM (24HR)") { + input "givenStartTime", "text", title: "Beginning time [default=00:00]", required:false + } + section("End date for the report = YYYY-MM-DD") { + input "givenEndDate", "text", title: "End Date [default=today]", required:false + } + section("End time for the report (24HR)" ) { + input "givenEndTime", "text", title: "End time [default=00:00]", required:false + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes", "No"]], required: false + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + +} + +def installed() { + log.debug "automaticReport>installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + subscribe(app, appTouch) +} + +def appTouch(evt) { + generateReport() +} + + +private def generateReport() { + def AUTOMATIC_SPEEDING_EVENT='speeding' + def AUTOMATIC_HARD_BRAKE_EVENT='hard_brake' + def AUTOMATIC_HARD_BRAKE_ACCEL='hard_accel' + def msg + + String dateInLocalTime = new Date().format("yyyy-MM-dd", location.timeZone) + String timezone = new Date().format("zzz", location.timeZone) + String dateAtMidnight = dateInLocalTime + " 00:00 " + timezone + log.debug("generateReport>date at Midnight= ${dateAtMidnight}") + Date endDate = formatDate(dateAtMidnight) + Date startDate = endDate -1 + + def givenStartDate = (settings.givenStartDate) ?: startDate.format("yyyy-MM-dd", location.timeZone) + def givenStartTime=(settings.givenStartTime) ?:"00:00" + def dateTime = givenStartDate + " " + givenStartTime + " " + timezone + startDate = formatDate(dateTime) + log.debug("generateReport>start dateTime = ${dateTime}, startDate in UTC = ${startDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + + def givenEndDate = (settings.givenEndDate) ?: endDate.format("yyyy-MM-dd", location.timeZone) + def givenEndTime=(settings.givenEndTime) ?:"00:00" + dateTime = givenEndDate + " " + givenEndTime + " " + timezone + endDate = formatDate(dateTime) + log.debug("generateReport>end dateTime = ${dateTime}, endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + + automatic.getTrips("","", null,null, null, true) + + def currentTripList = automatic.currentTripsList + def tripFields =null + if (!currentTripList) { + log.debug "generateReport> empty tripList exiting" + return + } + String tripData = automatic.currentTripsData + if (!tripData) { + log.debug("generateReport>no trip data found, exiting") + return + } + try { + tripFields = new JsonSlurper().parseText(tripData) +// log.debug("generateReport> vehicle_events=$tripFields.vehicle_events[0]") + } catch (e) { + log.error("generateReport>tripData not formatted correctly or empty (exception $e), exiting") + return + } + def nbTripsValue = automatic.currentTotalNbTripsInPeriod + int nbTrips = (nbTripsValue)? nbTripsValue.toInteger():0 + for (i in 0..nbTrips-1) { + def tripId=tripFields[i].id + String startAddress=tripFields[i].start_address.name + String endAddress=tripFields[i].end_address.name + def vehicleEvents=tripFields[i]?.vehicle_events + log.debug ("generateReport> found ${tripId} trip from start Address=${startAddress} to endAddress=${endAddress}, \nvehicleEvents=${vehicleEvents}") + String eventCreatedAt + vehicleEvents.each { + def type = it.type + if (type == AUTOMATIC_SPEEDING_EVENT) { + eventCreatedAt=formatDateInLocalTime(it.started_at) + def startDistance = it.start_distance_m + def endDistance= it.end_distance_m + float startPos= getDistance(startDistance) + float endPos= getDistance(endDistance) + log.debug ("generateReport> found ${type} event startedAt: ${it.started_at}, eventCreatedInLocalTime=$eventCreatedAt, timezone=${tripFields[i].end_timezone}") + def speed =it.velocity_kph + float speedValue=getSpeed(speed) + msg = "automaticReport>${automatic} was speeding (speed> ${speedValue.round()}${getSpeedScale()}) at ${eventCreatedAt} on trip ${tripId} from ${startAddress} to ${endAddress};" + + "Start Trip Distance=${startPos.round()}${getDistanceScale()},End Trip Distance=${endPos.round()}${getDistanceScale()}" + send(msg) + } + + if (type ==AUTOMATIC_HARD_BRAKE_EVENT) { + eventCreatedAt=formatDateInLocalTime(it.created_at) + def gforce=it.g_force + def lon =it.lon + def lat = it.lat + log.debug ("generateReport> found ${type} event startedAt: ${it.created_at}, eventCreatedInLocalTime=$eventCreatedAt, timezone=${tripFields[i].end_timezone}") + msg = "automaticReport>${automatic} triggered the ${type} event (gforce=${gforce}, lat=${lat},lon=${lon}) at ${eventCreatedAt} on trip ${tripId} from ${startAddress} to ${endAddress} " + send(msg) + } + if (type==AUTOMATIC_HARD_ACCEL_EVENT) { + eventCreatedAt=formatDateInLocalTime(it.created_at) + def gforce=it.g_force + def lon =it.lon + def lat = it.lat + log.debug ("generateReport> found ${type} event startedAt: ${it.created_at}, eventCreatedInLocalTime=$eventCreatedAt, timezone=${tripFields[i].end_timezone}") + msg = "automaticReport>${automatic} triggered the ${type} event (gforce=${gforce}, lat=${lat},lon=${lon}) at ${eventCreatedAt} on trip ${tripId} from ${startAddress} to ${endAddress} " + send(msg) + } + } /* end each vehicle Event */ + } /* end for each Trip */ + + +} + +private def report_states_between_dates(eventType, startDate, endDate) { + def eventStates = automatic.statesBetween("eventType", startDate as Date, endDate as Date) + + def countEventType = eventStates.count {it.value && it.value == eventType} + + def msg = "automaticReport>${automatic} triggered ${countEventType} ${eventType} event(s) between " + + "${startDate.format("yyyy-MM-dd HH:mm:ss")} and ${endDate.format("yyyy-MM-dd HH:mm:ss")}" + send(msg) +} + + +private def getSpeed(value) { + if (!value) { + return 0 + } + if(getTemperatureScale() == "C"){ + return value + } else { + return milesToKm(value) + } +} + +private def getSpeedScale() { + def scale= getTemperatureScale() + if (scale == 'C') { + return "kmh" + } + return "mph" +} + +private def milesToKm(distance) { + if (!distance) { + return 0 + } + return (distance * 1.609344) +} + +private def kmToMiles(distance) { + if (!distance) { + return 0 + } + return (distance * 0.62137) +} +private def getDistance(value) { + if (!value) { + return 0 + } + def km = value/1000 + if (getTemperatureScale() == "C"){ + return km + } else { + return kmToMiles(km) + } +} + +private def getDistanceScale() { + def scale= getTemperatureScale() + if (scale == 'C') { + return "km" + } + return "mi" +} + +private String formatDateInLocalTime(dateInString, timezone='') { + def myTimezone=(timezone)?TimeZone.getTimeZone(timezone):location.timeZone + if ((dateInString==null) || (dateInString.trim()=="")) { + return (new Date().format("yyyy-MM-dd HH:mm:ss", myTimezone)) + } + SimpleDateFormat ISODateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + Date ISODate = ISODateFormat.parse(dateInString.substring(0,19)+ 'Z') + String dateInLocalTime =new Date(ISODate.getTime()).format("yyyy-MM-dd HH:mm:ss zzz", myTimezone) + return dateInLocalTime +} + + +private def formatDate(dateString) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm zzz") + Date aDate = sdf.parse(dateString) + return aDate +} + +private def sendMsgWithDelay() { + + if (state.msg) { + send(state.msg) + } + state.msg="" +} + +private send(msg) { + if ( sendPushMessage != "No" ) { + log.debug( "sending push message" ) + sendPush( msg ) + + } + if ( phoneNumber ) { + log.debug( "sending text message" ) + sendSms( phoneNumber, msg ) + } + + log.debug msg +} diff --git a/third-party/BatteryLow.groovy b/third-party/BatteryLow.groovy new file mode 100755 index 0000000..590a2c5 --- /dev/null +++ b/third-party/BatteryLow.groovy @@ -0,0 +1,77 @@ +/** + * Low Battery Notification + * + */ + +definition( + name: "Battery Low", + namespace: "com.sudarkoff", + author: "George Sudarkoff", + description: "Notify when battery charge drops below the specified level.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section ("When battery change in these devices") { + input "devices", "capability.battery", title:"Battery Operated Devices", multiple: true + } + section ("Drops below this level") { + input "level", "number", title:"Battery Level (%)" + } + section ("Notify") { + input "sendPushMessage", "bool", title: "Send a push notification?", required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + initialize() +} + +def initialize() { + if (level < 5 || level > 90) { + sendPush("Battery level should be between 5 and 90 percent") + return false + } + subscribe(devices, "battery", batteryHandler) + + state.lowBattNoticeSent = [:] + updateBatteryStatus() +} + +def batteryHandler(evt) { + updateBatteryStatus() +} + +private send(message) { + if (phone) { + sendSms(phone, message) + } + if (sendPushMessage) { + sendPush(message) + } +} + +private updateBatteryStatus() { + for (device in devices) { + if (device.currentBattery < level) { + if (!state.lowBattNoticeSent.containsKey(device.id)) { + send("${device.displayName}'s battery is at ${device.currentBattery}%.") + } + state.lowBattNoticeSent[(device.id)] = true + } + else { + if (state.lowBattNoticeSent.containsKey(device.id)) { + state.lowBattNoticeSent.remove(device.id) + } + } + } +} + diff --git a/third-party/BeaconThing.groovy b/third-party/BeaconThing.groovy new file mode 100755 index 0000000..de1850c --- /dev/null +++ b/third-party/BeaconThing.groovy @@ -0,0 +1,107 @@ +/** +* BeaconThing +* +* Copyright 2015 obycode +* +* 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. +* +*/ + +import groovy.json.JsonSlurper + +metadata { + definition (name: "BeaconThing", namespace: "com.obycode", author: "obycode") { + capability "Beacon" + capability "Presence Sensor" + capability "Sensor" + + attribute "inRange", "json_object" + attribute "inRangeFriendly", "string" + + command "setPresence", ["string"] + command "arrived", ["string"] + command "left", ["string"] + } + + simulator { + status "present": "presence: 1" + status "not present": "presence: 0" + } + + tiles { + standardTile("presence", "device.presence", width: 2, height: 2, canChangeBackground: true) { + state("present", labelIcon:"st.presence.tile.present", backgroundColor:"#53a7c0") + state("not present", labelIcon:"st.presence.tile.not-present", backgroundColor:"#ffffff") + } + valueTile("inRange", "device.inRangeFriendly", inactiveLabel: true, height:1, width:3, decoration: "flat") { + state "default", label:'${currentValue}', backgroundColor:"#ffffff" + } + main "presence" + details (["presence","inRange"]) + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" +} + +def installed() { + sendEvent(name: "presence", value: "not present") + def emptyList = [] + def json = new groovy.json.JsonBuilder(emptyList) + sendEvent(name:"inRange", value:json.toString()) +} + +def setPresence(status) { + log.debug "Status is $status" + sendEvent(name:"presence", value:status) +} + +def arrived(id) { + log.debug "$id has arrived" + def theList = device.latestValue("inRange") + def inRangeList = new JsonSlurper().parseText(theList) + if (inRangeList.contains(id)) { + return + } + inRangeList += id + def json = new groovy.json.JsonBuilder(inRangeList) + log.debug "Now in range: ${json.toString()}" + sendEvent(name:"inRange", value:json.toString()) + + // Generate human friendly string for tile + def friendlyList = "Nearby: " + inRangeList.join(", ") + sendEvent(name:"inRangeFriendly", value:friendlyList) + + if (inRangeList.size() == 1) { + setPresence("present") + } +} + +def left(id) { + log.debug "$id has left" + def theList = device.latestValue("inRange") + def inRangeList = new JsonSlurper().parseText(theList) + inRangeList -= id + def json = new groovy.json.JsonBuilder(inRangeList) + log.debug "Now in range: ${json.toString()}" + sendEvent(name:"inRange", value:json.toString()) + + // Generate human friendly string for tile + def friendlyList = "Nearby: " + inRangeList.join(", ") + + if (inRangeList.empty) { + setPresence("not present") + friendlyList = "No one is nearby" + } + + sendEvent(name:"inRangeFriendly", value:friendlyList) +} diff --git a/third-party/BeaconThingsManager.groovy b/third-party/BeaconThingsManager.groovy new file mode 100755 index 0000000..50eef2c --- /dev/null +++ b/third-party/BeaconThingsManager.groovy @@ -0,0 +1,135 @@ +/** + * BeaconThing Manager + * + * Copyright 2015 obycode + * + * 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: "BeaconThings Manager", + namespace: "com.obycode", + author: "obycode", + description: "SmartApp to interact with the BeaconThings iOS app. Use this app to integrate iBeacons into your smart home.", + category: "Convenience", + iconUrl: "http://beaconthingsapp.com/images/Icon-60.png", + iconX2Url: "http://beaconthingsapp.com/images/Icon-60@2x.png", + iconX3Url: "http://beaconthingsapp.com/images/Icon-60@3x.png", + oauth: true) + + +preferences { + section("Allow BeaconThings to talk to your home") { + + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def initialize() { +} + +def uninstalled() { + removeChildDevices(getChildDevices()) +} + +mappings { + path("/beacons") { + action: [ + DELETE: "clearBeacons", + POST: "addBeacon" + ] + } + + path("/beacons/:id") { + action: [ + PUT: "updateBeacon", + DELETE: "deleteBeacon" + ] + } +} + +void clearBeacons() { + removeChildDevices(getChildDevices()) +} + +void addBeacon() { + def beacon = request.JSON?.beacon + if (beacon) { + def beaconId = "BeaconThings" + if (beacon.major) { + beaconId = "$beaconId-${beacon.major}" + if (beacon.minor) { + beaconId = "$beaconId-${beacon.minor}" + } + } + log.debug "adding beacon $beaconId" + def d = addChildDevice("com.obycode", "BeaconThing", beaconId, null, [label:beacon.name, name:"BeaconThing", completedSetup: true]) + log.debug "addChildDevice returned $d" + + if (beacon.present) { + d.arrive(beacon.present) + } + else if (beacon.presence) { + d.setPresence(beacon.presence) + } + } +} + +void updateBeacon() { + log.debug "updating beacon ${params.id}" + def beaconDevice = getChildDevice(params.id) + if (!beaconDevice) { + log.debug "Beacon not found" + return + } + + // This could be just updating the presence + def presence = request.JSON?.presence + if (presence) { + log.debug "Setting ${beaconDevice.label} to $presence" + beaconDevice.setPresence(presence) + } + + // It could be someone arriving + def arrived = request.JSON?.arrived + if (arrived) { + log.debug "$arrived arrived at ${beaconDevice.label}" + beaconDevice.arrived(arrived) + } + + // It could be someone left + def left = request.JSON?.left + if (left) { + log.debug "$left left ${beaconDevice.label}" + beaconDevice.left(left) + } + + // or it could be updating the name + def beacon = request.JSON?.beacon + if (beacon) { + beaconDevice.label = beacon.name + } +} + +void deleteBeacon() { + log.debug "deleting beacon ${params.id}" + deleteChildDevice(params.id) +} + +private removeChildDevices(delete) { + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} diff --git a/third-party/BetterLaundryMonitor.groovy b/third-party/BetterLaundryMonitor.groovy new file mode 100755 index 0000000..eac009d --- /dev/null +++ b/third-party/BetterLaundryMonitor.groovy @@ -0,0 +1,193 @@ +/** + * Alert on Power Consumption + * + * Copyright 2014 George Sudarkoff + * + * 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. + * + */ + +import groovy.time.* + +definition( + name: "Better Laundry Monitor", + namespace: "com.sudarkoff", + author: "George Sudarkoff", + description: "Using a switch with powerMonitor capability, monitor the laundry cycle and alert when it's done.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartthings-device-icons/Appliances/appliances8-icn.png", + iconX2Url: "https://s3.amazonaws.com/smartthings-device-icons/Appliances/appliances8-icn@2x.png") + + +preferences { + section ("When this device stops drawing power") { + input "meter", "capability.powerMeter", multiple: false, required: true + input "cycle_start_power_threshold", "number", title: "Start cycle when power consumption goes above (W)", required: true + input "cycle_end_power_threshold", "number", title: "Stop cycle when power consumption drops below (W) ...", required: true + input "cycle_end_wait", "number", title: "... for at least this long (min)", required: true + } + + section ("Send this message") { + input "message", "text", title: "Notification message", description: "Laudry is done!", required: true + } + + section ("Notification method") { + input "sendPushMessage", "bool", title: "Send a push notification?" + } + + section ("Additionally", hidden: hideOptionsSection(), hideable: true) { + input "phone", "phone", title: "Send a text message to:", required: false + input "switches", "capability.switch", title: "Turn on this switch", required:false, multiple:true + input "hues", "capability.colorControl", title: "Turn these hue bulbs", required:false, multiple:true + input "color", "enum", title: "This color", required: false, multiple:false, options: ["White", "Red","Green","Blue","Yellow","Orange","Purple","Pink"] + input "lightLevel", "enum", title: "This light Level", required: false, options: [[10:"10%"],[20:"20%"],[30:"30%"],[40:"40%"],[50:"50%"],[60:"60%"],[70:"70%"],[80:"80%"],[90:"90%"],[100:"100%"]] + input "speech", "capability.capability.speechSynthesis", title:"Speak message via: ", multiple: true, required: false + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(meter, "power", handler) +} + +def handler(evt) { + def latestPower = meter.currentValue("power") + log.trace "Power: ${latestPower}W" + + if (!state.cycleOn && latestPower > cycle_start_power_threshold) { + cycleOn(evt) + } + // If power drops below threshold, wait for a few minutes. + else if (state.cycleOn && latestPower <= cycle_end_power_threshold) { + runIn(cycle_end_wait * 60, cycleOff) + } +} + +private cycleOn(evc) { + state.cycleOn = true + log.trace "Cycle started." +} + +private cycleOff(evt) { + def latestPower = meter.currentValue("power") + log.trace "Power: ${latestPower}W" + + // If power is still below threshold, end cycle. + if (state.cycleOn && latestPower <= cycle_end_power_threshold) { + state.cycleOn = false + log.trace "Cycle ended." + + send(message) + lightAlert(evt) + speechAlert(evt) + } +} + +private lightAlert(evt) { + def hueColor = 0 + def saturation = 100 + + if (hues) { + switch(color) { + case "White": + hueColor = 52 + saturation = 19 + break; + case "Daylight": + hueColor = 53 + saturation = 91 + break; + case "Soft White": + hueColor = 23 + saturation = 56 + break; + case "Warm White": + hueColor = 20 + saturation = 80 //83 + break; + case "Blue": + hueColor = 70 + break; + case "Green": + hueColor = 39 + break; + case "Yellow": + hueColor = 25 + break; + case "Orange": + hueColor = 10 + break; + case "Purple": + hueColor = 75 + break; + case "Pink": + hueColor = 83 + break; + case "Red": + hueColor = 100 + break; + } + + state.previous = [:] + + hues.each { + state.previous[it.id] = [ + "switch": it.currentValue("switch"), + "level" : it.currentValue("level"), + "hue": it.currentValue("hue"), + "saturation": it.currentValue("saturation") + ] + } + + log.debug "current values = $state.previous" + + def newValue = [hue: hueColor, saturation: saturation, level: lightLevel as Integer ?: 100] + log.debug "new value = $newValue" + + if (switches) { + switches*.on() + } + hues*.setColor(newValue) + } +} + +private speechAlert(msg) { + speech.speak(msg) +} + +private send(msg) { + if (sendPushMessage) { + sendPush(msg) + } + + if (phone) { + sendSms(phone, msg) + } + + log.debug msg +} + +private hideOptionsSection() { + (phone || switches || hues || color || lightLevel) ? false : true +} + + diff --git a/third-party/Color Temp via Virtual Dimmer.groovy b/third-party/Color Temp via Virtual Dimmer.groovy new file mode 100755 index 0000000..c3f9c05 --- /dev/null +++ b/third-party/Color Temp via Virtual Dimmer.groovy @@ -0,0 +1,92 @@ +/** + * Virtual Dimmer to Color Temp Adjustments + * + * Copyright 2015 Scott Gibson + * + * 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. + * + * Many thanks to Eric Roberts for his virtual switch creator, which served as the template for creating child devices in this SmartApp! + * + */ +definition( + name: "Color Temp via Virtual Dimmer", + namespace: "sticks18", + author: "Scott Gibson", + description: "Creates a virtual dimmer switch that will convert level settings to color temp adjustments in selected Color Temp bulbs.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + page(name: "basicInfo", title: "Name your virtual dimmer and select color temp lights", install: true, uninstall: true){ + section("Create Virtual Dimmer Switch to be used as a dimmer when you want color temp adjustments") { + input "switchLabel", "text", title: "Virtual Dimmer Label", multiple: false, required: true + } + section("Choose your Color Temp bulbs") { + paragraph "This SmartApp will allow you to set the level of the Virtual Dimmer as part of your automations using the much more common Set Level option, and have that level converted to a color temperature adjustment in the selected bulbs. The conversion will take 0-100 and convert to 2700-6500k via colorTemp = (level*38)+2700. Some common conversions: 0 level = 2700k (Soft White), 10 level = 3080k (Warm White), 20 level = 3460k (Cool White), 40 level = 4220k (Bright White), 60 level = 4980k (Natural), 100 level = 6500k (Daylight).", title: "How to use...", required: true + input "cLights", "capability.color temperature", title: "Select Color Temp Lights", required: true, multiple: true + } + } +} + + + + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + + def deviceId = app.id + "vDimmerForCTemp" + log.debug(deviceId) + def existing = getChildDevice(deviceId) + log.debug existing + if (!existing) { + log.debug "Add child" + def childDevice = addChildDevice("sticks18", "Color Temp Virtual Dimmer", deviceId, null, [label: switchLabel]) + } + +} + +def uninstalled() { + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(delete) { + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} + +// Child device methods after this point. Instead of subscribing to child, have child directly call parent + +def setLevel(childDevice, value) { + + def degrees = Math.round((value * 38) + 2700) + if (degrees == 6462) { degrees = degrees + 38 } + + log.debug "Converting dimmer level ${value} to color temp ${degrees}..." + childDevice.updateTemp(degrees) + cLights?.setColorTemperature(degrees) + + + +} diff --git a/third-party/DeviceTamperAlarm.groovy b/third-party/DeviceTamperAlarm.groovy new file mode 100755 index 0000000..da6aecf --- /dev/null +++ b/third-party/DeviceTamperAlarm.groovy @@ -0,0 +1,67 @@ +/** + * Device Tamper Alarm + * + * Author: Mitch Pond, SmartThings + * Date: 2013-03-20 + */ +definition( + name: "Device Tamper Alarm", + namespace: "mitchpond", + author: "Mitch Pond", + description: "Receive notification when a device is tampered with. Currently supports Quirky Tripper.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Solution/tampering@2x.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Solution/tampering@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Solution/tampering@2x.png" +) + +preferences { + section("Choose devices..."){ + input "contact", "capability.contactSensor", title: "Devices supporting tamper", required: false, multiple: true + } + section("Via a push notification and/or an SMS message"){ + input "phone", "phone", title: "Phone Number (for SMS, optional)", required: false + input "pushAndPhone", "enum", title: "Both Push and SMS?", required: false, options: ["Yes","No"] + } + section("Sound these alarms..."){ + input "alarms", "capability.alarm", title: "Alarm Devices", required: false, multiple: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(contact, "tamper.tampered", eventHandler) +} + +def eventHandler(evt) { + String msg = "${evt.displayName} has been tampered with!" + log.debug msg + + sendMessage(msg) + alarms ?: soundAlarms(alarms) +} + +private sendMessage(msg) { + if (!phone || pushAndPhone != "No") { + log.debug "sending push" + sendPush(msg) + } + if (phone) { + log.debug "sending SMS" + sendSms(phone, msg) + } +} + +private soundAlarms(alarms){ + alarms?.both() +} diff --git a/third-party/DoubleTapModeChange.groovy b/third-party/DoubleTapModeChange.groovy new file mode 100755 index 0000000..872cdca --- /dev/null +++ b/third-party/DoubleTapModeChange.groovy @@ -0,0 +1,247 @@ +/** + * Double Tap Switch for "Hello, Home" Action + * + * Copyright 2014 George Sudarkoff + * + * 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: "Double Tap Switch for 'Hello, Home' Action", + namespace: "com.sudarkoff", + author: "George Sudarkoff", + description: "Execute a 'Hello, Home' action when an existing switch is tapped twice in a row.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartthings-device-icons/Appliances/appliances17-icn.png", + iconX2Url: "https://s3.amazonaws.com/smartthings-device-icons/Appliances/appliances17-icn@2x.png" +) + +preferences { + page (name: "configApp") + + page (name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def configApp() { + dynamicPage(name: "configApp", install: true, uninstall: true) { + section ("When this switch is double-tapped...") { + input "master", "capability.switch", required: true + } + + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + section("Perform this actions...") { + input "onPhrase", "enum", title: "ON action", required: false, options: phrases + input "offPhrase", "enum", title: "OFF action", required: false, options: phrases + } + } + + section (title: "Notification method") { + input "sendPushMessage", "bool", title: "Send a push notification?" + } + + section (title: "More Options", hidden: hideOptionsSection(), hideable: true) { + input "phone", "phone", title: "Additionally, also send a text message to:", required: false + input "flashLights", "capability.switch", title: "And flash these lights", multiple: true, required: false + + def timeLabel = timeIntervalLabel() + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : null + + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + } + + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + +def installed() +{ + initialize() +} + +def updated() +{ + unsubscribe() + initialize() +} + +def initialize() +{ + if (customName) { + state.currentAppLabel = customName + } + subscribe(master, "switch", switchHandler, [filterEvents: false]) +} + +def switchHandler(evt) { + log.info evt.value + + if (allOk) { + // use Event rather than DeviceState because we may be changing DeviceState to only store changed values + def recentStates = master.eventsSince(new Date(now() - 4000), [all:true, max: 10]).findAll{it.name == "switch"} + log.debug "${recentStates?.size()} STATES FOUND, LAST AT ${recentStates ? recentStates[0].dateCreated : ''}" + + if (evt.isPhysical()) { + if (evt.value == "on" && lastTwoStatesWere("on", recentStates, evt)) { + log.debug "detected two taps, execute ON phrase" + location.helloHome.execute(settings.onPhrase) + def message = "${location.name} executed ${settings.onPhrase} because ${evt.title} was tapped twice." + send(message) + flashLights() + } else if (evt.value == "off" && lastTwoStatesWere("off", recentStates, evt)) { + log.debug "detected two taps, execute OFF phrase" + location.helloHome.execute(settings.offPhrase) + def message = "${location.name} executed ${settings.offPhrase} because ${evt.title} was tapped twice." + send(message) + flashLights() + } + } + else { + log.trace "Skipping digital on/off event" + } + } +} + +private lastTwoStatesWere(value, states, evt) { + def result = false + if (states) { + log.trace "unfiltered: [${states.collect{it.dateCreated + ':' + it.value}.join(', ')}]" + def onOff = states.findAll { it.isPhysical() || !it.type } + log.trace "filtered: [${onOff.collect{it.dateCreated + ':' + it.value}.join(', ')}]" + + // This test was needed before the change to use Event rather than DeviceState. It should never pass now. + if (onOff[0].date.before(evt.date)) { + log.warn "Last state does not reflect current event, evt.date: ${evt.dateCreated}, state.date: ${onOff[0].dateCreated}" + result = evt.value == value && onOff[0].value == value + } + else { + result = onOff.size() > 1 && onOff[0].value == value && onOff[1].value == value + } + } + result +} + +private send(msg) { + if (sendPushMessage != "No") { + sendPush(msg) + } + + if (phone) { + sendSms(phone, msg) + } + + log.debug msg +} + +private flashLights() { + def doFlash = true + def onFor = onFor ?: 200 + def offFor = offFor ?: 200 + def numFlashes = numFlashes ?: 2 + + if (state.lastActivated) { + def elapsed = now() - state.lastActivated + def sequenceTime = (numFlashes + 1) * (onFor + offFor) + doFlash = elapsed > sequenceTime + } + + if (doFlash) { + state.lastActivated = now() + def initialActionOn = flashLights.collect{it.currentSwitch != "on"} + def delay = 1L + numFlashes.times { + flashLights.eachWithIndex {s, i -> + if (initialActionOn[i]) { + s.on(delay: delay) + } + else { + s.off(delay:delay) + } + } + delay += onFor + flashLights.eachWithIndex {s, i -> + if (initialActionOn[i]) { + s.off(delay: delay) + } + else { + s.on(delay:delay) + } + } + delay += offFor + } + } +} + +// execution filter methods +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + result +} + +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("America/Los_Angeles")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + 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 hideOptionsSection() { + (phone || starting || ending || flashLights || customName || days || modes) ? false : true +} + +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) +} + +private timeIntervalLabel() { + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} + + diff --git a/third-party/DoubleTapTimedLight.groovy b/third-party/DoubleTapTimedLight.groovy new file mode 100755 index 0000000..5728ac4 --- /dev/null +++ b/third-party/DoubleTapTimedLight.groovy @@ -0,0 +1,197 @@ +/** + * Double Tap Mode Switch + * + * Copyright 2014 George Sudarkoff + * + * 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: + * + a 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: "Double Tap Timed Light Switch", + namespace: "com.sudarkoff", + author: "George Sudarkoff", + description: "Turn a light for a period of time when an existing switch is tapped OFF twice in a row.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + +preferences { + page (name: "configPage", install: true, uninstall: true) { + section ("When this switch is double-tapped OFF...") { + input "master", "capability.switch", required: true + } + + section ("Turn it on for this many minutes...") { + input "duration", "number", required: true + } + + section ("Notification method") { + input "sendPushMessage", "bool", title: "Send a push notification?" + } + + section (title: "More Options", hidden: hideOptionsSection(), hideable: true) { + input "phone", "phone", title: "Additionally, also send a text message to:", required: false + + input "customName", "text", title: "Assign a name", required: false + + def timeLabel = timeIntervalLabel() + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : null + + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + } + + page (name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def installed() +{ + initialize() +} + +def updated() +{ + unsubscribe() + unschedule() + initialize() +} + +def initialize() +{ + if (customName) { + state.currentAppLabel = customName + } + subscribe(master, "switch", switchHandler, [filterEvents: false]) +} + +def switchHandler(evt) { + log.info evt.value + + if (allOk) { + // use Event rather than DeviceState because we may be changing DeviceState to only store changed values + def recentStates = master.eventsSince(new Date(now() - 4000), [all:true, max: 10]).findAll{it.name == "switch"} + log.debug "${recentStates?.size()} STATES FOUND, LAST AT ${recentStates ? recentStates[0].dateCreated : ''}" + + if (evt.isPhysical()) { + if (evt.value == "on") { + unschedule(); + } + else if (evt.value == "off" && lastTwoStatesWere("off", recentStates, evt)) { + log.debug "detected two OFF taps, turning the light ON for ${duration} minutes" + master.on() + runIn(duration * 60, switchOff) + def message = "${master.label} turned on for ${duration} minutes" + send(message) + } + } + else { + log.trace "Skipping digital on/off event" + } + } +} + +def switchOff() { + master.off() +} + +private lastTwoStatesWere(value, states, evt) { + def result = false + if (states) { + log.trace "unfiltered: [${states.collect{it.dateCreated + ':' + it.value}.join(', ')}]" + def onOff = states.findAll { it.isPhysical() || !it.type } + log.trace "filtered: [${onOff.collect{it.dateCreated + ':' + it.value}.join(', ')}]" + + // This test was needed before the change to use Event rather than DeviceState. It should never pass now. + if (onOff[0].date.before(evt.date)) { + log.warn "Last state does not reflect current event, evt.date: ${evt.dateCreated}, state.date: ${onOff[0].dateCreated}" + result = evt.value == value && onOff[0].value == value + } + else { + result = onOff.size() > 1 && onOff[0].value == value && onOff[1].value == value + } + } + result +} + +private send(msg) { + if (sendPushMessage != "No") { + sendPush(msg) + } + + if (phone) { + sendSms(phone, msg) + } + + log.debug msg +} + +// execution filter methods +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + result +} + +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("America/Los_Angeles")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + 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 hideOptionsSection() { + (phone || starting || ending || customName || days || modes) ? false : true +} + +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) +} + +private timeIntervalLabel() { + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} + diff --git a/third-party/FireCO2Alarm.groovy b/third-party/FireCO2Alarm.groovy new file mode 100755 index 0000000..c87b152 --- /dev/null +++ b/third-party/FireCO2Alarm.groovy @@ -0,0 +1,499 @@ +/** + * Copyright 2014 Yves Racine + * linkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. * + * Take a series of actions in case of smoke or CO2 alert, i.e. turn on/flash the lights, turn on the siren, unlock the doors, turn + * off the thermostat(s), turn off the alarm system, etc. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Compatible with MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ + +// Automatically generated. Make future change here. +definition( + name: "FireCO2Alarm", + namespace: "yracine", + author: "yracine@yahoo.com", + description: "In case of a fire/CO2 alarm,turn on all the lights/turn off all thermostats, unlock the doors, disarm the alarm system & open the garage door when CO2 is detected ", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + page(name: "actionsSettings", title: "actionsSettings") + page(name: "otherSettings", title: "OtherSettings") + + +} + +def actionsSettings() { + dynamicPage(name: "actionsSettings", install: false, uninstall: true, nextPage: "otherSettings") { + section("About") { + paragraph "FireCO2Alarm, the smartapp that executes a series of actions when a Fire or CO2 alarm is triggerred" + paragraph "Version 1.3" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=yracine%40yahoo%2ecom&lc=US&item_name=Maisons%20ecomatiq&no_note=0¤cy_code=USD&bn=PP%2dDonationsBF%3abtn_donateCC_LG%2egif%3aNonHostedGuest", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine" + } + section("When these smoke/CO2 detector trigger, the following actions will be taken...") { + input "smoke_detectors", "capability.smokeDetector", title: "Which Smoke/CO2 detector(s)?", multiple: true + } + section("Unlock the doors [optional]") { + input "locks", "capability.lock", multiple: true, required: false + } + section("Open this Garage Door in case of CO2...") { + input "garageSwitch", "capability.switch", title: "Which Garage Door Switch", required: false + } + section("Only If this Garage's Contact is closed") { + input "garageMulti", "capability.contactSensor", title: "Which Garage Door Contact", required: false + } + section("Turn off the thermostat(s) [optional]") { + input "tstat", "capability.thermostat", title: "Thermostat(s)", multiple: true, required: false + } + section("Disarm the alarm system if armed [optional]") { + input "alarmSwitch", "capability.contactSensor", title: "Alarm System", required: false + } + section("Flash/turn on the lights...") { + input "switches", "capability.switch", title: "These lights", multiple: true + input "numFlashes", "number", title: "This number of times (default 20)", required: false + } + section("Time settings in milliseconds [optional]") { + input "givenOnFor", "number", title: "On for (default 1000)", required: false + input "givenOffFor", "number", title: "Off for (default 1000)", required: false + } + section("And activate the siren [optional]") { + input "securityAlert", "capability.alarm", title: "Security Alert", required: false, multiple:true + } + section("Clear alarm threshold (default = 1 min) to revert actions[optional]") { + input "clearAlarmThreshold", "decimal", title: "Number of minutes after clear alarm", required: false + } + } +} + + +def otherSettings() { + dynamicPage(name: "otherSettings", title: "Other Settings", install: true, uninstall: false) { + section("Detectors' low battery warning") { + input "lowBattThreshold", "number", title: "Low Batt Threshold % (default 10%)", required: false + } + section("Use Speech capability to warn the residents (optional) ") { + input "theVoice", "capability.speechSynthesis", required: false, multiple: true + } + section("What do I use for the Master on/off switch to enable/disable voice notifications? (optional)") { + input "powerSwitch", "capability.switch", required: false + } + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + section([mobileOnly: true]) { + label title: "Assign a name for this SmartApp", required: false + } + } +} + + + + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + initialize() +} + +private initialize() { + subscribe(smoke_detectors, "smoke", smokeHandler) + subscribe(smoke_detectors, "carbonMonoxide", carbonMonoxideHandler) + subscribe(smoke_detectors, "battery", batteryHandler) + subscribe(locks, "lock", doorUnlockedHandler) + subscribe(garageMulti, "contact", garageDoorContact) + subscribe(alarmSwitch, "contact", alarmSwitchContact) + if (tstat) { + subscribe(tstat, "thermostatMode", thermostatModeHandler) + } + + reset_state_variables() +} + + +private def reset_state_variables() { + + state.lastActivated = null + state.lastThermostatMode = null + + if (tstat) { + state.lastThermostatMode = " " + tstat.each { + log.debug "reset_state_variables>thermostat mode reset for $it" + state.lastThermostatMode = state.lastThermostatMode + "${it.currentThermostatMode}" + "," + } + } + log.debug "reset_state_variables>state.lastThermostatMode= $state.lastThermostatMode" + +} + + +def thermostatModeHandler(evt) { + log.debug "thermostat mode: $evt.value" +} + +def garageDoorContact(evt) { + log.info "garageDoorContact, $evt.name: $evt.value" +} + +def doorUnlockedHandler(evt) { + log.debug "Lock ${locks} was: ${evt.value}" + +} + + +def smokeHandler(evt) { + def SMOKE_ALERT = 'detected_SMOKE' + def CLEAR_ALERT = 'clear' + def CLEAR_SMOKE_ALERT = 'clear_SMOKE' + def DETECTED_ALERT = 'detected' + def TESTED_ALERT = 'tested' + + log.trace "$evt.value: $evt, $settings" + + String theMessage + + if (evt.value == TESTED_ALERT) { + theMessage = "${evt.displayName} was tested for smoke." + send("FireCO2Alarm>${theMessage}") + takeActions(evt.value) + + } else if (evt.value == CLEAR_ALERT) { + theMessage = "${evt.displayName} is clear of smoke." + send("FireCO2Alarm>${theMessage}") + takeActions(CLEAR_SMOKE_ALERT) + + } else if (evt.value == DETECTED_ALERT) { + theMessage = "${evt.displayName} detected smoke!" + send("FireCO2Alarm>${theMessage}") + takeActions(SMOKE_ALERT) + } else { + theMessage = ("Unknown event received from ${evt.name}") + send("FireCO2Alarm>${theMessage}") + } + +} + + +def carbonMonoxideHandler(evt) { + def CO2_ALERT = 'detected_CO2' + def CLEAR_CO2_ALERT = 'clear_CO2' + def CLEAR_ALERT = 'clear' + def DETECTED_ALERT = 'detected' + def TESTED_ALERT = 'tested' + + log.trace "$evt.value: $evt, $settings" + + String theMessage + + if (evt.value == TESTED_ALERT) { + theMessage = "${evt.displayName} was tested for carbon monoxide." + send("FireCO2Alarm>${theMessage}") + } else if (evt.value == CLEAR_ALERT) { + theMessage = "${evt.displayName} is clear of carbon monoxide." + send("FireCO2Alarm>${theMessage}") + takeActions(CLEAR_CO2_ALERT) + } else if (evt.value == DETECTED_ALERT) { + theMessage = "${evt.displayName} detected carbon monoxide!" + send("FireCO2Alarm>${theMessage}") + takeActions(CO2_ALERT) + } else { + theMessage = "Unknown event received from ${evt.name}" + send("FireCO2Alarm>${theMessage}") + } + +} + +def batteryHandler(evt) { + log.trace "$evt.value: $evt, $settings" + String theMessage + int battLevel = evt.integerValue + + log.debug "${evt.displayName} has battery of ${battLevel}" + + if (battLevel < lowBattThreshold ?: 10) { + theMessage = "${evt.displayName} has battery of ${battLevel}" + send("FireCO2Alarm>${theMessage}") + } +} + + +def alarmSwitchContact(evt) + +{ + log.info "alarmSwitchContact, $evt.name: $evt.value" +} + +def clearAlert() { + def msg + + if (securityAlert) { + securityAlert.off() // Turned off the security alert + msg = "turned security alert off" + send("FireCO2Alarm>now clear, ${msg}") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.setLevel(40) + theVoice.speak(msg) + } + } + /* + if (locks) { + locks.lock() // Lock the locks + send("FireCO2Alarm>Cleared, locked all locks...") + } + */ + if (tstat) { + if (state.lastThermostatMode) { + def lastThermostatMode = state.lastThermostatMode.toString().split(',') + int i = 0 + tstat.each { + def lastSavedMode = lastThermostatMode[i].trim() + + if (lastSavedMode) { + log.debug "About to set ${it}, back to saved thermostatMode=${lastSavedMode}" + if (lastSavedMode == 'cool') { + it.cool() + } else if (lastSavedMode.contains('heat')) { + it.heat() + } else if (lastSavedMode == 'auto') { + it.auto() + } + msg = "thermostat ${it}'s mode is now set back to ${lastThermostatMode[i]}" + send("FireCO2Alarm>Cleared, ${msg}") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.speak(msg) + } + } + i++ + } + } else { + tstat.auto() + msg = "thermostats set to auto" + send("FireCO2Alarm>Cleared, ${msg}") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.speak(msg) + } + } + } +} + + +private takeActions(String alert) { + def msg + def CO2_ALERT = 'detected_CO2' + def SMOKE_ALERT = 'detected_SMOKE' + def CLEAR_ALERT = 'clear' + def CLEAR_SMOKE_ALERT = 'clear_SMOKE' + def CLEAR_CO2_ALERT = 'clear_CO2' + def DETECTED_ALERT = 'detected' + def TESTED_ALERT = 'tested' + + // Proceed with the following actions when clear alert + + if ((alert == CLEAR_SMOKE_ALERT) || (alert == CLEAR_CO2_ALERT)) { + + def delay = (clearAlarmThreshold ?: 1) * 60 // default is 1 minute + // Wait a certain delay before clearing the alert + + + send("FireCO2Alarm>Cleared, wait for ${delay} seconds...") + runIn(delay, "clearAlert", [overwrite: false]) + + if ((alert == CLEAR_CO2_ALERT) && (garageMulti?.currentContact == "open")) { + log.debug "garage door is open,about to close it following cleared CO2 alert..." + garageSwitch?.on() // Open the garage door if it is closed + msg = "closed the garage door following cleared CO2 alert" + send("FireCO2Alarm>${msg}...") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.setLevel(50) + theVoice.speak(msg) + } + + } + + return + } + + if ((alert != TESTED_ALERT) && (alert != SMOKE_ALERT) && (alert != CO2_ALERT)) { + log.debug "Not in test mode nor smoke/CO2 detected, exiting..." + return + } + + // Proceed with the following actions in case of SMOKE or CO2 alert + + + // Reset state variables + + reset_state_variables() + + if (securityAlert) { + securityAlert.on() // Turned on the security alert + msg = "security Alert on" + send("FireCO2Alarm>${msg}...") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.setLevel(75) + theVoice.speak(msg) + } + } + if (alarmSwitch) { + if (alarmSwitch.currentContact == "closed") { + log.debug "alarm system is on, about to disarm it..." + alarmSwitch.off() // disarm the alarm system + msg = "alarm system disarmed" + send("FireCO2Alarm>${msg}...") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.setLevel(75) + theVoice.speak(msg) + } + + } + } + if (tstat) { + tstat.off() // Turn off the thermostats + msg = "turning off all thermostats" + send("FireCO2Alarm>${msg}...") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.speak(msg) + } + } + if (!location.mode.contains('Away')) { + if (locks) { + locks.unlock() // Unlock the locks if mode is not 'Away' + msg = "unlocked the doors" + send("FireCO2Alarm>${msg}...") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.speak(msg) + } + } + if ((alert == CO2_ALERT) && (garageSwitch)) { + if (garageMulti?.currentContact == "closed") { + log.debug "garage door is closed,about to open it following CO2 alert..." + garageSwitch.on() // Open the garage door if it is closed + msg = "opened the garage door following CO2 alert" + send("FireCO2Alarm>${msg}...") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.speak(msg) + } + } + } + + } + + flashLights() // Flash the lights + msg = "flashed the lights" + send("FireCO2Alarm>${msg}...") + + if (theVoice) { + theVoice.speak(msg) + } + + def now = new Date().getTime() // Turn the switches on at night + astroCheck() + if (now > state.setTime) { + switches?.on() + + msg = "turned on the lights" + send("FireCO2Alarm>${msg}") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.speak(msg) + } + + } + + + +} + +def astroCheck() { + def s = getSunriseAndSunset(zipCode: zipCode) + + state.riseTime = s.sunrise.time + state.setTime = s.sunset.time + log.debug "rise: ${new Date(state.riseTime)}($state.riseTime), set: ${new Date(state.setTime)}($state.setTime)" +} + + + +private flashLights() { + def doFlash = true + def onFor = givenOnFor ?: 1000 + def offFor = givenOffFor ?: 1000 + def numFlashes = numFlashes ?: 20 + + log.debug "LAST ACTIVATED IS: ${state.lastActivated}" + if (state.lastActivated) { + def elapsed = now() - state.lastActivated + def sequenceTime = (numFlashes + 1) * (onFor + offFor) + doFlash = elapsed > sequenceTime + log.debug "DO FLASH: $doFlash, ELAPSED: $elapsed, LAST ACTIVATED: ${state.lastActivated}" + } + + if (doFlash) { + log.debug "FLASHING $numFlashes times" + state.lastActivated = now() + log.debug "LAST ACTIVATED SET TO: ${state.lastActivated}" + def initialActionOn = switches.collect { + it.currentSwitch != "on" + } + def delay = 1 L + numFlashes.times { + log.trace "Switch on after $delay msec" + switches.eachWithIndex { + s, i -> + if (initialActionOn[i]) { + s.on(delay: delay) + + } else { + s.off(delay: delay) + } + } + delay += onFor + log.trace "Switch off after $delay msec" + switches.eachWithIndex { + s, i -> + if (initialActionOn[i]) { + s.off(delay: delay) + + } else { + s.on(delay: delay) + } + } + delay += offFor + } + } +} + +private send(msg) { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + log.debug msg +} diff --git a/third-party/Hue Party Mode.groovy b/third-party/Hue Party Mode.groovy new file mode 100755 index 0000000..c2e7c76 --- /dev/null +++ b/third-party/Hue Party Mode.groovy @@ -0,0 +1,119 @@ +/************************************************************************************* +* Hue Party Mode +* +* Author: Mitch Pond +* Date: 2015-05-29 + +Copyright (c) 2015, Mitch Pond +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +*************************************************************************************/ + +definition( + name: "Hue Party Mode", + namespace: "mitchpond", + author: "Mitch Pond", + description: "Change the color of your lights randomly at an interval of your choosing.", + category: "Fun & Social", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/FunAndSocial/App-ItsPartyTime.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/FunAndSocial/App-ItsPartyTime@2x.png", +) + +preferences { + section("Choose lights..."){ + input "lights", "capability.colorControl", title: "Pick your lights", required: false, multiple: true + } + section("Adjust color change speed and timeout"){ + input "interval", "number", title: "Color change interval (seconds)", required: false, defaultValue: 10 + input "timeout", "number", title: "How long to run (minutes)", required: false, defaultValue: 60 + } +} + +def installed() { + settings.interval = 10 //default value: 10 seconds + settings.timeout = 60 //default value: 60 minutes + state.running = false + log.debug("Installed with settings: ${settings}") + updated() +} + +def updated() { + log.debug("Updated with settings: ${settings}") + unsubscribe() + subscribe(app, onAppTouch) + for (light in lights) { + subscribeToCommand(light, "off", onLightOff) + } + + +} + +def onLightOff(evt) { + //if one of the lights in our device list is turned off, and we are running, unschedule any pending color changes + if (state.running) { + log.info("${app.name}: One of our lights was turned off.") + stop() + } +} + +def onAppTouch(evt) { + //if currently running, unschedule any scheduled function calls + //if not running, start our scheduling loop + + if (state.running) { + log.debug("${app.name} is running.") + stop() + } + else if (!state.running) { + log.debug("${app.name} is not running.") + start() + } + +} + +def changeColor() { + if (!state.running) return //just return without doing anything in case unschedule() doesn't finish before next function call + + //calculate a random color, send the setColor command, then schedule our next execution + log.info("${app.name}: Running scheduled color change") + def nextHue = new Random().nextInt(101) + def nextSat = new Random().nextInt(51)+50 + //def nextColor = Integer.toHexString(new Random().nextInt(0x1000000)) + log.debug nextColor + lights*.setColor(hue: nextHue, saturation: nextSat) + runIn(settings.interval, changeColor) +} + +def start() { + log.debug("${app.name}: Beginning execution...") + state.running = true + lights*.on() + changeColor() + runIn(settings.timeout*60, stop) +} + +def stop() { + log.debug("${app.name}: Stopping execution...") + unschedule() + state.running = false +} diff --git a/third-party/JSON.groovy b/third-party/JSON.groovy new file mode 100755 index 0000000..fae377a --- /dev/null +++ b/third-party/JSON.groovy @@ -0,0 +1,145 @@ +/** + * JSON + * + * Copyright 2015 Jesse Newland + * + * 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: "JSON API", + namespace: "jnewland", + author: "Jesse Newland", + description: "A JSON API for SmartThings", + category: "SmartThings Labs", + 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", + oauth: true) + + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + initialize() +} + +def initialize() { + if (!state.accessToken) { + createAccessToken() + } +} + +preferences { + page(name: "copyConfig") +} + +def copyConfig() { + if (!state.accessToken) { + createAccessToken() + } + dynamicPage(name: "copyConfig", title: "Config", install:true) { + section("Select devices to include in the /devices API call") { + input "switches", "capability.switch", title: "Switches", multiple: true, required: false + input "hues", "capability.colorControl", title: "Hues", multiple: true, required: false + } + + section() { + paragraph "View this SmartApp's configuration to use it in other places." + href url:"https://graph.api.smartthings.com/api/smartapps/installations/${app.id}/config?access_token=${state.accessToken}", style:"embedded", required:false, title:"Config", description:"Tap, select, copy, then click \"Done\"" + } + + section() { + href url:"https://graph.api.smartthings.com/api/smartapps/installations/${app.id}/devices?access_token=${state.accessToken}", style:"embedded", required:false, title:"Debug", description:"View accessories JSON" + } + } +} + +def renderConfig() { + def configJson = new groovy.json.JsonOutput().toJson([ + description: "JSON API", + platforms: [ + [ + platform: "SmartThings", + name: "SmartThings", + app_id: app.id, + access_token: state.accessToken + ] + ], + ]) + + def configString = new groovy.json.JsonOutput().prettyPrint(configJson) + render contentType: "text/plain", data: configString +} + +def deviceCommandMap(device, type) { + device.supportedCommands.collectEntries { command-> + def commandUrl = "https://graph.api.smartthings.com/api/smartapps/installations/${app.id}/${type}/${device.id}/command/${command.name}?access_token=${state.accessToken}" + [ + (command.name): commandUrl + ] + } +} + +def authorizedDevices() { + [ + switches: switches, + hues: hues + ] +} + +def renderDevices() { + def deviceData = authorizedDevices().collectEntries { devices-> + [ + (devices.key): devices.value.collect { device-> + [ + name: device.displayName, + commands: deviceCommandMap(device, devices.key) + ] + } + ] + } + def deviceJson = new groovy.json.JsonOutput().toJson(deviceData) + def deviceString = new groovy.json.JsonOutput().prettyPrint(deviceJson) + render contentType: "application/json", data: deviceString +} + +def deviceCommand() { + def device = authorizedDevices()[params.type].find { it.id == params.id } + def command = params.command + if (!device) { + httpError(404, "Device not found") + } else { + if (params.value) { + device."$command"(params.value) + } else { + device."$command"() + } + } +} + +mappings { + if (!params.access_token || (params.access_token && params.access_token != state.accessToken)) { + path("/devices") { action: [GET: "authError"] } + path("/config") { action: [GET: "authError"] } + path("/:type/:id/command/:command") { action: [PUT: "authError"] } + } else { + path("/devices") { action: [GET: "renderDevices"] } + path("/config") { action: [GET: "renderConfig"] } + path("/:type/:id/command/:command") { action: [PUT: "deviceCommand"] } + } +} + +def authError() { + [error: "Permission denied"] +} diff --git a/third-party/Light-Alert-SmartApp.groovy b/third-party/Light-Alert-SmartApp.groovy new file mode 100755 index 0000000..080ae13 --- /dev/null +++ b/third-party/Light-Alert-SmartApp.groovy @@ -0,0 +1,405 @@ +/** + * Smart Bulb Notifier/Flasher + * + * Copyright 2015 Scott Gibson + * + * 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. + * + * Many thanks to Eric Roberts for his virtual switch creator, which served as the template for creating child switch devices! + * + */ +definition( + name: "Smart Bulb Alert (Blink/Flash)", + namespace: "sticks18", + author: "Scott Gibson", + description: "Creates a virtual switch button that when turned on will trigger selected smart bulbs to blink or flash", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + page(name: "basicInfo") + page(name: "configureBulbs") +} + +def basicInfo(){ + dynamicPage(name: "basicInfo", title: "First, name your trigger and select smart bulb types", nextPage: "configureBulbs", uninstall: true){ + section("Create Virtual Momentary Button as Trigger") { + input "switchLabel", "text", title: "Switch Button Label", multiple: false, required: true + } + section("Choose your Smart Bulbs") { + paragraph "This SmartApp will work with most zigbee bulbs by directly calling the Identify function built into the hardware. This function is generally run when you first pair a bulb to the hub to let you know it was successful. Different bulbs have different capabilities and/or endpoints to address, so they must be selected separately in this SmartApp. If expanded functionality exists for a particular bulb type, you will be given additional options to select. Caveats: Non-Hue bulbs connected to the Hue Bridge, such as GE Link or Cree Connected, will not work because the Hue API is not designed for them.", + title: "How to select your bulbs...", required: true + input "geLinks", "capability.switch level", title: "Select GE Link Bulbs", required: false, multiple: true + //input "creeCons", "capability.switch level", title: "Select Cree Connected Bulbs", required: false, multiple: true + //input "hueHubs", "capability.switch level", title: "Select Hue Bulbs connected to Hue Hub", required: false, multiple: true + //input "hueDirs", "capability.switch level", title: "Select Hue Bulbs directly connected to ST Hub", required: false, multiple: true + //input "osramLs", "capability.switch level", title: "Select Osram Lightify Bulbs", required: false, multiple: true + } + } +} + +def configureBulbs(){ + dynamicPage(name: "configureBulbs", title: "Additional Configuration Options by Bulb Type", install: true, uninstall: true) { + section(title: "Auto-off for bulbs directly connected to SmartThings Hub") { + input "autoOff", "bool", title: "Automatically stop the alert", required: false, submitOnChange: true + input "offDelay", "number", title: "Turn off alert after X seconds (default = 5, max = 10)", required: false, submitOnChange: true + } + /*section(title: "Options for Hue Bulbs connected to Hue Bridge", hidden: hideHueHubBulbs(), hideable: true) { + input "hBlink", "enum", title: "Select Blink for single flash or Breathe for 15 seconds continuous (default = Breathe)", multiple: false, required: false, + options: ["Blink", "Breathe"] + } + section(title: "Options for Hue Bulbs connected directly to SmartThings Hub", hidden: hideHueDirBulbs(), hideable: true) { + input "hdStyle", "enum", title: "Select alert style: Normal = Bulb will blink until stopped via auto-off or trigger, Blink = Single flash, Breathe = Mult Flash for 15 seconds, Okay = Bulb turn green for 2 seconds", + multiple: false, required: false, options: ["Normal", "Blink", "Breathe", "Okay"] + } + section(title: "Options for Osram Lightify Bulbs", hidden: hideOsramBulbs(), hideable: true) { + input "osStyle", "enum", title: "Select alert style: Normal = Bulb will blink until stopped via auto-off or trigger", + multiple: false, required: false, options: ["Normal"] + }*/ + } +} + + + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + + state.autoOff = offDelay + // state.hueHubOption = getHueHubOption() + // state.hueDirOption = getHueDirOption() + // state.osramOption = getOsramOption() + // state.onlyHueHub = getHueHubOnly() + + def deviceId = app.id + "SimulatedSwitch" + log.debug(deviceId) + def existing = getChildDevice(deviceId) + log.debug existing + if (!existing) { + log.debug "Add child" + def childDevice = addChildDevice("sticks18", "Smart Bulb Alert Switch", deviceId, null, [label: switchLabel]) + } + +} + +def uninstalled() { + removeChildDevices(getChildDevices()) +} + +private removeChildDevices(delete) { + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} + +/* private getOffDelay() { + def result = 5 + log.debug "autoOff is ${autoOff}" + if (autoOff) { + result = 5 + log.debug "offDelay is ${offDelay}" + if(offDelay) { + def offSec = Math.Min(offDelay, 10) + offSec = Math.Max(offSec, 1) + result = offSec + } + } + log.debug "off delay: ${result}" + return result +} + +private getHueDirOption() { + def result = null + log.debug hueDirs + if (hueDirs) { + switch(hdStyle) { + case "Normal": + result = "" + break + case "Blink": + result = "00" + break + case "Breathe": + result = "01" + break + case "Okay": + result = "02" + break + default: + result = "" + break + } + } + log.debug "hue dir option: ${result}" + return result +} + +private getHueHubOnly() { + (creeCons || geLinks || osramLs || hueDirs) ? false : true +} + +private getHueHubOption() { + def result = null + if (hueHubs) { + switch(hBlink) { + case "Blink": + result = "select" + break + case "Breathe": + result = "lselect" + break + default: + result = "lselect" + break + } + } + log.debug "hue hub option: ${result}" + return result +} + +private getOsramOption() { + def result = null + if (osramLs) { + switch(osStyle) { + case "Normal": + result = "" + break + case "Blink": + result = "00" + break + case "Breathe": + result = "01" + break + case "Okay": + result = "02" + break + default: + result = "" + break + } + } + log.debug "osram option: ${result}" + return result +} +*/ +private hideHueHubBulbs() { + (hueHubs) ? false : true +} + +private hideHueDirBulbs() { + (hueDirs) ? false : true +} + +private hideOsramBulbs() { + (osramLs) ? false : true +} + +private hex(value, width=4) { + def s = new BigInteger(Math.round(value).toString()).toString(16) + while (s.size() < width) { + s = "0" + s + } + s +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +private String swapEndianHex(String hex) { + reverseArray(hex.decodeHex()).encodeHex() +} + +private byte[] reverseArray(byte[] array) { + int i = 0; + int j = array.length - 1; + byte tmp; + while (j > i) { + tmp = array[j]; + array[j] = array[i]; + array[i] = tmp; + j--; + i++; + } + return array +} + +// Child device methods after this point. Instead of subscribing to child, have child directly call parent + +def on(childDevice) { + log.debug "Start alert" + if(state.autoOff != null) { + log.debug "Only alert for ${state.autoOff} seconds" + if(geLinks != null) { + geLinks.each { bulb -> + // bulb.setLevel(99, 1800) + bulb.alert(state.autoOff) + // childDevice.attWrite(bulb.deviceNetworkId, 1, 3, 0, "0x21", state.autoOff) + // childDevice.zigbeeCmd(bulb.deviceNetworkId, 1, 3, 0, "") + } + } + /*if(creeCons != null) { + creeCons.each { bulb -> + childDevice.attWrite(bulb.deviceNetworkId, "10", "3", "0", "0x21", state.autoOff) + } + } + if(state.hueDirOption != null) { + if(state.hueDirOption == "") { + hueDirs.each { bulb -> + childDevice.attWrite(bulb.deviceNetworkId, "0B", "3", "0", "0x21", state.autoOff) + } + } + else { + hueDirs.each { bulb -> + def payload = state.hueDirOption + " 00" + childDevice.zigbeeCmd(bulb.deviceNetworkId, "0B", "3", "0x40", payload) + } + } + } + if(state.osramOption != null) { + if(state.osramOption == "") { + osramLs.each { bulb -> + childDevice.attWrite(bulb.deviceNetworkId, "03", "3", "0", "0x21", state.autoOff) + } + } + else { + osramLs.each { bulb -> + def payload = state.osramOption + " 00" + childDevice.zigbeeCmd(bulb.deviceNetworkId, "03", "3", "0x40", payload) + } + } + } */ + childDevice.justOff() + + } /* + else { + log.debug "Starting continous alert" + if(geLinks != null) { + geLinks.each { bulb -> + childDevice.zigbeeCmd(bulb.deviceNetworkId, "1", "3", "0", "") + } + } + if(creeCons != null) { + creeCons.each { bulb -> + childDevice.zigbeeCmd(bulb.deviceNetworkId, "10", "3", "0", "") + } + } + if(state.hueDirOption != null) { + if(state.hueDirOption == "") { + hueDirs.each { bulb -> + childDevice.zigbeeCmd(bulb.deviceNetworkId, "0B", "3", "0", "") + } + } + else { + hueDirs.each { bulb -> + def payload = state.hueDirOption + " 00" + childDevice.zigbeeCmd(bulb.deviceNetworkId, "0B", "3", "0x40", payload) + } + } + } + if(state.osramOption != null) { + if(state.osramOption == "") { + osramLs.each { bulb -> + childDevice.zigbeeCmd(bulb.deviceNetworkId, "03", "3", "0", "") + } + } + else { + osramLs.each { bulb -> + def payload = state.osramOption + " 00" + childDevice.zigbeeCmd(bulb.deviceNetworkId, "03", "3", "0x40", payload) + } + } + } + } + if(state.hueHubOption != null) { + log.debug "Send planned alert to Hue Bulbs via Hue Bridge" + hueHubs.each { bulb -> + def hue = bulb.currentValue("hue") + def sat = bulb.currentValue("saturation") + def alert = state.hueHubOption + def value = [hue: hue, saturation: sat, alert: alert] + bulb.setColor(value) + } + if(state.hueHubOnly) { childDevice.justOff() } + }*/ + +} + +def off(childDevice) { + log.debug "Stop Alert" /* + if(state.autoOff != null) { + childDevice.justOff() + } + else { + log.debug "Stopping continous alert" + if(geLinks != null) { + geLinks.each { bulb -> + childDevice.zigbeeCmd(bulb.deviceNetworkId, "1", "3", "0", "") + } + } + if(creeCons != null) { + creeCons.each { bulb -> + childDevice.zigbeeCmd(bulb.deviceNetworkId, "10", "3", "0", "") + } + } + if(state.hueDirOption != null) { + if(state.hueDirOption == "") { + hueDirs.each { bulb -> + childDevice.zigbeeCmd(bulb.deviceNetworkId, "0B", "3", "0", "") + } + } + } + if(state.osramOption != null) { + if(state.osramOption == "") { + osramLs.each { bulb -> + childDevice.zigbeeCmd(bulb.deviceNetworkId, "03", "3", "0", "") + } + } + } + } + */ +} + +def allOff(childDevice) { + log.debug "Stopping alerts" /* + if(geLinks != null) { + geLinks.each { bulb -> + childDevice.attWrite(bulb.deviceNetworkId, "1", "3", "0", "0x21", "0100") + } + } + if(creeCons != null) { + creeCons.each { bulb -> + childDevice.attWrite(bulb.deviceNetworkId, "10", "3", "0", "0x21", "0100") + } + } + if(hueDirs != null) { + hueDirs.each { bulb -> + childDevice.attWrite(bulb.deviceNetworkId, "0B", "3", "0", "0x21", "0100") + } + } + if(osramLs != null) { + osramLs.each { bulb -> + childDevice.attWrite(bulb.deviceNetworkId, "03", "3", "0", "0x21", "0100") + } + } */ +} diff --git a/third-party/Lutron-Group-Control.groovy b/third-party/Lutron-Group-Control.groovy new file mode 100755 index 0000000..68dfcef --- /dev/null +++ b/third-party/Lutron-Group-Control.groovy @@ -0,0 +1,82 @@ +definition( + name: "Lutron Group Control", + namespace: "sticks18", + author: "sgibson18@gmail.com", + description: "Add bulbs to a Lutron Remote without using the remote's join process", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + page(name: "inputPage") +} + +def inputPage() { + dynamicPage(name: "inputPage", title: "", install: true, uninstall: true) { + + section("Remote Setup") { + paragraph "To get the Group Id of your remote, you must open Live Logging then fill in the inputs from this section. The code for the connected bulb selected must have a debug statement in its parse section, so you can see the response containing the group id.", + title: "First let's get the Group Id from your Remote by checking an already connected bulb", required: true + input (name: "remote", type: "capability.button", title: "Lutron Remote", multiple: false, required: true, submitOnChange: true) + input (name: "cBulb", type: "capability.switch", title: "Select a bulb connected to the remote", multiple: false, required: true, submitOnChange: true) + input (name: "endpoint", type: "text", title: "Zigbee Endpoint of selected bulb", required: true, submitOnChange: true) + } + + if (remote && cBulb && endpoint) { + + remote.getGroup(cBulb.deviceNetworkId, endpoint) + + section("Enter the Group Id") { + input (name: "grpId", type: "text", title: "Group Id for Remote", required: true) + } + section("Now let's pick some new bulbs"){ + input (name: "nBulbs", type: "capability.switch", title: "Select bulbs to add to the remote (all must use same endpoint)", multiple: true, required: true) + input (name: "nBulbEp", type: "text", title: "Zigbee Endpoint of selected bulbs", required: true) + } + } + } +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + removeBulbs() + addBulbs() + updateGrp() +} + + +def addBulbs() { + nBulbs.each { + def zigId = it.deviceNetworkId + def endpoint = nBulbEp + remote.assignGroup(zigId, endpoint, grpId) + } + +} + +def removeBulbs() { + nBulbs.each { + def zigId = it.deviceNetworkId + def endpoint = nBulbEp + remote.removeGroup(zigId, endpoint, grpId) + } + +} + +def updateGrp() { + remote.updateGroup(grpId) +} + +def initialize() { + addBulbs() + updateGrp() +} + +def uninstalled() { + removeBulbs() +} diff --git a/third-party/MonitorAndSetEcobeeHumidity.groovy b/third-party/MonitorAndSetEcobeeHumidity.groovy new file mode 100755 index 0000000..1fb1b39 --- /dev/null +++ b/third-party/MonitorAndSetEcobeeHumidity.groovy @@ -0,0 +1,884 @@ +/*** + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * + * Monitor and set Humidity with Ecobee Thermostat(s): + * Monitor humidity level indoor vs. outdoor at a regular interval (in minutes) and + * set the humidifier/dehumidifier to a target humidity level. + * Use also HRV/ERV/dehumidifier to get fresh air (free cooling) when appropriate based on outdoor temperature. + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +// Automatically generated. Make future change here. +definition( + name: "MonitorAndSetEcobeeHumidity", + namespace: "yracine", + author: "Yves Racine", + description: "Monitor And set Ecobee's humidity via your connected humidifier/dehumidifier/HRV/ERV", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +def get_APP_VERSION() {return "3.3.5"} + +preferences { + page(name: "dashboardPage", title: "DashboardPage") + page(name: "humidifySettings", title: "HumidifySettings") + page(name: "dehumidifySettings", title: "DehumidifySettings") + page(name: "ventilatorSettings", title: "ventilatorSettings") + page(name: "sensorSettings", title: "SensorSettings") + page(name: "otherSettings", title: "OtherSettings") + +} + +def dashboardPage() { + dynamicPage(name: "dashboardPage", title: "MonitorAndSetEcobeeHumidity-Dashboard", uninstall: true, nextPage:sensorSettings, submitOnChange: true) { + section("Monitor & set the ecobee thermostat's dehumidifer/humidifier/HRV/ERV settings") { + input "ecobee", "capability.thermostat", title: "Which Ecobee?" + } + section("To this humidity level") { + input "givenHumidityLevel", "number", title: "Humidity level (default=calculated based on outside temp)", required: false + } + section("At which interval in minutes (range=[10..59],default =59 min.)?") { + input "givenInterval", "number", title: "Interval", required: false + } + section("Humidity differential for adjustments") { + input "givenHumidityDiff", "number", title: "Humidity Differential [default=5%]", required: false + } + section("Press Next in the upper section for Initial setup") { + if (ecobee) { + def scale= getTemperatureScale() + String currentProgName = ecobee?.currentClimateName + String currentProgType = ecobee?.currentProgramType + def scheduleProgramName = ecobee?.currentProgramScheduleName + String mode =ecobee?.currentThermostatMode.toString() + def operatingState=ecobee?.currentThermostatOperatingState + def ecobeeHumidity = ecobee.currentHumidity + def indoorTemp = ecobee.currentTemperature + def hasDehumidifier = (ecobee.currentHasDehumidifier) ? ecobee.currentHasDehumidifier : 'false' + def hasHumidifier = (ecobee.currentHasHumidifier) ? ecobee.currentHasHumidifier : 'false' + def hasHrv = (ecobee.currentHasHrv) ? ecobee.currentHasHrv : 'false' + def hasErv = (ecobee.currentHasErv) ? ecobee.currentHasErv : 'false' + def heatingSetpoint,coolingSetpoint + switch (mode) { + case 'cool': + coolingSetpoint = ecobee?.currentValue('coolingSetpoint') + break + case 'auto': + coolingSetpoint = ecobee?.currentValue('coolingSetpoint') + case 'heat': + case 'emergency heat': + case 'auto': + case 'off': + heatingSetpoint = ecobee?.currentValue('heatingSetpoint') + break + } + def detailedNotifFlag = (detailedNotif)? 'true':'false' + int min_vent_time = (givenVentMinTime!=null) ? givenVentMinTime : 20 // 20 min. ventilator time per hour by default + int min_fan_time = (givenFanMinTime!=null) ? givenFanMinTime : 20 // 20 min. fan time per hour by default + def dParagraph = "TstatMode: $mode\n" + + "TstatOperatingState $operatingState\n" + + "TstatTemperature: $indoorTemp${scale}\n" + if (coolingSetpoint) { + dParagraph = dParagraph + "CoolingSetpoint: ${coolingSetpoint}$scale\n" + } + if (heatingSetpoint) { + dParagraph = dParagraph + "HeatingSetpoint: ${heatingSetpoint}$scale\n" + } + dParagraph = dParagraph + + "EcobeeClimateSet: $currentProgName\n" + + "EcobeeProgramType: $currentProgType\n" + + "EcobeeHasHumidifier: $hasHumidifier\n" + + "EcobeeHasDeHumidifier: $hasDehumidifier\n" + + "EcobeeHasHRV: $hasHrv\n" + + "EcobeeHasERV: $hasErv\n" + + "EcobeeHumidity: $ecobeeHumidity%\n" + + "MinFanTime: ${min_fan_time} min.\n" + + "DetailedNotification: ${detailedNotif}\n" + paragraph dParagraph + if (hasDehumidifier=='true') { + def min_temp = (givenMinTemp) ? givenMinTemp : ((scale=='C') ? -15 : 10) + def useDehumidifierAsHRVFlag = (useDehumidifierAsHRV) ? 'true' : 'false' + dParagraph= "UseDehumidifierAsHRV: $useDehumidifierAsHRVFlag" + + "\nMinDehumidifyTemp: $min_temp${scale}" + if (useDehumidifierAsHRVFlag=='true') { + dParagraph= dParagraph + "\nMinVentTime $min_vent_time min." + } + paragraph dParagraph + } + if (hasHumidifier=='true') { + def frostControlFlag = (frostControl) ? 'true' : 'false' + dParagraph = "HumidifyFrostControl: $frostControlFlag" + paragraph dParagraph + } + if ((hasHrv=='true') || (hasErv=='true')) { + dParagraph= "MinVentTime $min_vent_time min." + def freeCoolingFlag= (freeCooling) ? 'true' : 'false' + dParagraph = dParagraph + "\nFreeCooling: $freeCoolingFlag" + paragraph dParagraph + } + if (ted) { + int max_power = givenPowerLevel ?: 3000 // Do not run above 3000w consumption level by default + dParagraph = "PowerMeter: $ted" + + "\nDoNotRunOver: ${max_power}W" + paragraph dParagraph + } + + } /* end if ecobee */ + } + section("Humidifier/Dehumidifier/HRV/ERV Setup") { + href(name: "toSensorsPage", title: "Configure your sensors", description: "Tap to Configure...", image: getImagePath() + "HumiditySensor.png", page: "sensorSettings") + href(name: "toHumidifyPage", title: "Configure your humidifier settings", description: "Tap to Configure...", image: getImagePath() + "Humidifier.jpg", page: "humidifySettings") + href(name: "toDehumidifyPage", title: "Configure your dehumidifier settings", description: "Tap to Configure...", image: getImagePath() + "dehumidifier.png", page: "dehumidifySettings") + href(name: "toVentilatorPage", title: "Configure your HRV/ERV settings", description: "Tap to Configure...", image: getImagePath() + "HRV.jpg", page: "ventilatorSettings") + href(name: "toNotificationsPage", title: "Other Options & Notification Setup", description: "Tap to Configure...", image: getImagePath() + "Fan.png", page: "otherSettings") + } + section("About") { + paragraph "MonitorAndSetEcobeeHumdity, the smartapp that can control your house's humidity via your connected humidifier/dehumidifier/HRV/ERV" + paragraph "Version ${get_APP_VERSION()}" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + + } /* end dashboardPage */ + +} + +def sensorSettings() { + dynamicPage(name: "sensorSettings", title: "Sensors to be used", install: false, nextPage: humidifySettings) { + section("Choose Indoor humidity sensor to be used for better adjustment (optional, default=ecobee sensor)") { + input "indoorSensor", "capability.relativeHumidityMeasurement", title: "Indoor Humidity Sensor", required: false + } + section("Choose Outdoor humidity sensor to be used (weatherStation or sensor)") { + input "outdoorSensor", "capability.relativeHumidityMeasurement", title: "Outdoor Humidity Sensor" + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + } +} + + +def humidifySettings() { + dynamicPage(name: "humidifySettings", install: false, uninstall: false, nextPage: dehumidifySettings) { + section("Frost control for humidifier [optional]") { + input "frostControl", "bool", title: "Frost control [default=false]?", description: 'optional', required: false + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + } +} + +def dehumidifySettings() { + dynamicPage(name: "dehumidifySettings", install: false, uninstall: false, nextPage: ventilatorSettings) { + section("Free cooling using HRV/Dehumidifier [By default=false]") { + input "freeCooling", "bool", title: "Free Cooling?", required: false + } + section("Your dehumidifier as HRV input parameters section [optional]") { + input "useDehumidifierAsHRV", "bool", title: "Use Dehumidifier as HRV (By default=false)?", description: 'optional', required: false + input "givenVentMinTime", "number", title: "Minimum HRV/ERV runtime [default=20]", description: 'optional',required: false + } + section("Minimum outdoor threshold for stopping dehumidification (in Farenheits/Celsius) [optional]") { + input "givenMinTemp", "decimal", title: "Min Outdoor Temp [default=10°F/-15°C]", description: 'optional', required: false + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + } +} + +def ventilatorSettings() { + dynamicPage(name: "ventilatorSettings", install: false, uninstall: false, nextPage: otherSettings) { + section("Free cooling using HRV [By default=false]") { + input "freeCooling", "bool", title: "Free Cooling?", required: false + } + section("Minimum HRV/ERV runtime in minutes") { + input "givenVentMinTime", "number", title: "Minimum HRV/ERV [default=20 min.]", description: 'optional',required: false + } + section("Minimum outdoor threshold for stopping ventilation (in Farenheits/Celsius) [optional]") { + input "givenMinTemp", "decimal", title: "Min Outdoor Temp [default=10°F/-15°C]", description: 'optional', required: false + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + } +} + + + +def otherSettings() { + def enumModes=location.modes.collect{ it.name } + + dynamicPage(name: "otherSettings", title: "Other Settings", install: true, uninstall: false) { + section("Minimum fan runtime per hour in minutes") { + input "givenFanMinTime", "number", title: "Minimum fan runtime [default=20]", required: false + } + section("Check energy consumption at [optional, to avoid using HRV/ERV/Humidifier/Dehumidifier at peak]") { + input "ted", "capability.powerMeter", title: "Power meter?", description: 'optional', required: false + input "givenPowerLevel", "number", title: "power?", description: 'optional',required: false + } + section("What do I use for the Master on/off switch to enable/disable processing? (optional)") { + input "powerSwitch", "capability.switch", required: false + } + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: + false + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + section("Detailed Notifications") { + input "detailedNotif", "bool", title: "Detailed Notifications?", required: + false + } + section("Enable Amazon Echo/Ask Alexa Notifications [optional, default=false]") { + input (name:"askAlexaFlag", title: "Ask Alexa verbal Notifications?", type:"bool", + description:"optional",required:false) + } + section("Set Humidity Level only for specific mode(s) [default=all]") { + input (name:"selectedMode", type:"enum", title: "Choose Mode", options: enumModes, + required: false, multiple:true, description: "Optional") + } + section([mobileOnly: true]) { + label title: "Assign a name for this SmartApp", required: false + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + } +} + + + +def installed() { + initialize() +} + +def updated() { + // we have had an update + // remove everything and reinstall + unschedule() + unsubscribe() + initialize() +} + +def initialize() { + state.currentRevision = null // for further check with thermostatRevision later + + if (powerSwitch) { + subscribe(powerSwitch, "switch.off", offHandler) + subscribe(powerSwitch, "switch.on", onHandler) + } + Integer delay = givenInterval ?: 59 // By default, do it every hour + if ((delay < 10) || (delay > 59)) { + log.error "Scheduling delay not in range (${delay} min.), exiting" + runIn(30, "sendNotifDelayNotInRange") + return + } + if (detailedNotif) { + log.debug "initialize>scheduling Humidity Monitoring and adjustment every ${delay} minutes" + } + + state?.poll = [ last: 0, rescheduled: now() ] + + //Subscribe to different events (ex. sunrise and sunset events) to trigger rescheduling if needed + subscribe(location, "sunrise", rescheduleIfNeeded) + subscribe(location, "sunset", rescheduleIfNeeded) + subscribe(location, "mode", rescheduleIfNeeded) + subscribe(location, "sunriseTime", rescheduleIfNeeded) + subscribe(location, "sunsetTime", rescheduleIfNeeded) + + rescheduleIfNeeded() +} + +def appTouch(evt) { + rescheduleIfNeeded() + +} + +def rescheduleIfNeeded(evt) { + if (evt) log.debug("rescheduleIfNeeded>$evt.name=$evt.value") + Integer delay = givenInterval ?: 59 // By default, do it every hour + BigDecimal currentTime = now() + BigDecimal lastPollTime = (currentTime - (state?.poll["last"]?:0)) + + if (lastPollTime != currentTime) { + Double lastPollTimeInMinutes = (lastPollTime/60000).toDouble().round(1) + log.info "rescheduleIfNeeded>last poll was ${lastPollTimeInMinutes.toString()} minutes ago" + } + if (((state?.poll["last"]?:0) + (delay * 60000) < currentTime) && canSchedule()) { + log.info "rescheduleIfNeeded>scheduling setHumidityLevel in ${delay} minutes.." + schedule("0 0/${delay} * * * ?", setHumidityLevel) + setHumidityLevel() + } + + + // Update rescheduled state + + if (!evt) state.poll["rescheduled"] = now() +} + + + +private def sendNotifDelayNotInRange() { + + send "scheduling delay (${givenInterval} min.) not in range, please restart..." + +} + + +def offHandler(evt) { + log.debug "$evt.name: $evt.value" +} + +def onHandler(evt) { + log.debug "$evt.name: $evt.value" + setHumidityLevel() +} + + + +def setHumidityLevel() { + Integer scheduleInterval = givenInterval ?: 59 // By default, do it every hour + + def todayDay = new Date().format("dd",location.timeZone) + if ((!state?.today) || (todayDay != state?.today)) { + state?.exceptionCount=0 + state?.sendExceptionCount=0 + state?.today=todayDay + } + + state?.poll["last"] = now() + + + + //schedule the rescheduleIfNeeded() function + if (((state?.poll["rescheduled"]?:0) + (scheduleInterval * 60000)) < now()) { + log.info "takeAction>scheduling rescheduleIfNeeded() in ${scheduleInterval} minutes.." + schedule("0 0/${scheduleInterval} * * * ?", rescheduleIfNeeded) + // Update rescheduled state + state?.poll["rescheduled"] = now() + } + + boolean foundMode=selectedMode.find{it == (location.currentMode as String)} + if ((selectedMode != null) && (!foundMode)) { + if (detailedNotif) { + log.trace("setHumidityLevel does not apply,location.mode= $location.mode, selectedMode=${selectedMode},foundMode=${foundMode}, turning off all equipments") + } + ecobee.setThermostatSettings("", ['dehumidifierMode': 'off', 'humidifierMode': 'off', 'dehumidifyWithAC': 'false', + 'vent': 'off', 'ventilatorFreeCooling': 'false' + ]) + return + } + + if (detailedNotif) { + send("monitoring every ${scheduleInterval} minute(s)") + log.debug "Scheduling Humidity Monitoring & Change every ${scheduleInterval} minutes" + } + + + if (powerSwitch ?.currentSwitch == "off") { + if (detailedNotif) { + send("Virtual master switch ${powerSwitch.name} is off, processing on hold...") + log.debug("Virtual master switch ${powerSwitch.name} is off, processing on hold...") + } + return + } + def min_humidity_diff = givenHumidityDiff ?: 5 // 5% humidity differential by default + Integer min_fan_time = (givenFanMinTime!=null) ? givenFanMinTime : 20 // 20 min. fan time per hour by default + Integer min_vent_time = (givenVentMinTime!=null) ? givenVentMinTime : 20 // 20 min. ventilator time per hour by default + def freeCoolingFlag = (freeCooling != null) ? freeCooling : false // Free cooling using the Hrv/Erv/dehumidifier + def frostControlFlag = (frostControl != null) ? frostControl : false // Frost Control for humdifier, by default=false + def min_temp // Min temp in Farenheits for using HRV/ERV,otherwise too cold + + def scale = getTemperatureScale() + if (scale == 'C') { + min_temp = (givenMinTemp) ? givenMinTemp : -15 // Min. temp in Celcius for using HRV/ERV,otherwise too cold + } else { + + min_temp = (givenMinTemp) ? givenMinTemp : 10 // Min temp in Farenheits for using HRV/ERV,otherwise too cold + } + Integer max_power = givenPowerLevel ?: 3000 // Do not run above 3000w consumption level by default + + + // Polling of all devices + + def MAX_EXCEPTION_COUNT=10 + String exceptionCheck, msg + try { + ecobee.poll() + exceptionCheck= ecobee.currentVerboseTrace.toString() + if ((exceptionCheck) && ((exceptionCheck.contains("exception") || (exceptionCheck.contains("error")) && + (!exceptionCheck.contains("Java.util.concurrent.TimeoutException"))))) { + // check if there is any exception or an error reported in the verboseTrace associated to the device (except the ones linked to rate limiting). + state?.exceptionCount=state.exceptionCount+1 + log.error "setHumidityLevel>found exception/error after polling, exceptionCount= ${state?.exceptionCount}: $exceptionCheck" + } else { + // reset exception counter + state?.exceptionCount=0 + } + } catch (e) { + log.error "setHumidityLevel>exception $e while trying to poll the device $d, exceptionCount= ${state?.exceptionCount}" + } + if ((state?.exceptionCount>=MAX_EXCEPTION_COUNT) || ((exceptionCheck) && (exceptionCheck.contains("Unauthorized")))) { + // need to authenticate again + msg="too many exceptions/errors or unauthorized exception, $exceptionCheck (${state?.exceptionCount} errors), may need to re-authenticate at ecobee..." + send " ${msg}" + log.error msg + return + } + + if (outdoorSensor.hasCapability("Polling")) { + try { + outdoorSensor.poll() + } catch (e) { + log.debug("MonitorEcobeeHumdity>not able to poll ${outdoorSensor}'s temp value") + } + } else if (outdoorSensor.hasCapability("Refresh")) { + try { + outdoorSensor.refresh() + } catch (e) { + log.debug("MonitorEcobeeHumdity>not able to refresh ${outdoorSensor}'s temp value") + } + } + if (ted) { + + try { + ted.poll() + Integer powerConsumed = ted.currentPower.toInteger() + if (powerConsumed > max_power) { + + // peak of energy consumption, turn off all devices + + if (detailedNotif) { + send "all off,power usage is too high=${ted.currentPower}" + log.debug "all off,power usage is too high=${ted.currentPower}" + } + + ecobee.setThermostatSettings("", ['vent': 'off', 'dehumidifierMode': 'off', 'humidifierMode': 'off', + 'dehumidifyWithAC': 'false' + ]) + return + + } + } catch (e) { + log.error "Exception $e while trying to get power data " + } + } + + + def heatTemp = ecobee.currentHeatingSetpoint + def coolTemp = ecobee.currentCoolingSetpoint + def ecobeeHumidity = ecobee.currentHumidity + def indoorHumidity = 0 + def indoorTemp = ecobee.currentTemperature + def hasDehumidifier = (ecobee.currentHasDehumidifier) ? ecobee.currentHasDehumidifier : 'false' + def hasHumidifier = (ecobee.currentHasHumidifier) ? ecobee.currentHasHumidifier : 'false' + def hasHrv = (ecobee.currentHasHrv) ? ecobee.currentHasHrv : 'false' + def hasErv = (ecobee.currentHasErv) ? ecobee.currentHasErv : 'false' + def useDehumidifierAsHRVFlag = (useDehumidifierAsHRV) ? useDehumidifierAsHRV : false + def outdoorHumidity + + // use the readings from another sensor if better precision neeeded + if (indoorSensor) { + indoorHumidity = indoorSensor.currentHumidity + indoorTemp = indoorSensor.currentTemperature + } + + def outdoorSensorHumidity = outdoorSensor.currentHumidity + def outdoorTemp = outdoorSensor.currentTemperature + // by default, the humidity level is calculated based on a sliding scale target based on outdoorTemp + + def target_humidity = givenHumidityLevel ?: (scale == 'C')? find_ideal_indoor_humidity(outdoorTemp): + find_ideal_indoor_humidity(fToC(outdoorTemp)) + + String ecobeeMode = ecobee.currentThermostatMode.toString() + if (detailedNotif) { + log.debug "MonitorAndSetEcobeeHumidity>location.mode = $location.mode" + log.debug "MonitorAndSetEcobeeHumidity>ecobee Mode = $ecobeeMode" + } + + outdoorHumidity = (scale == 'C') ? + calculate_corr_humidity(outdoorTemp, outdoorSensorHumidity, indoorTemp) : + calculate_corr_humidity(fToC(outdoorTemp), outdoorSensorHumidity, fToC(indoorTemp)) + + + // If indoorSensor specified, use the more precise humidity measure instead of ecobeeHumidity + + if ((indoorSensor) && (indoorHumidity < ecobeeHumidity)) { + ecobeeHumidity = indoorHumidity + } + + if (detailedNotif) { + log.trace("Ecobee's humidity: ${ecobeeHumidity} vs. indoor humidity ${indoorHumidity}") + log.debug "outdoorSensorHumidity = $outdoorSensorHumidity%, normalized outdoorHumidity based on ambient temperature = $outdoorHumidity%" + send "normalized outdoor humidity is ${outdoorHumidity}%,sensor outdoor humidity ${outdoorSensorHumidity}%,vs. indoor Humidity ${ecobeeHumidity}%" + log.trace("Evaluate: Ecobee humidity: ${ecobeeHumidity} vs. outdoor humidity ${outdoorHumidity}," + + "coolingSetpoint: ${coolTemp} , heatingSetpoint: ${heatTemp}, target humidity=${target_humidity}, fanMinOnTime=${min_fan_time}") + log.trace("hasErv=${hasErv}, hasHrv=${hasHrv},hasHumidifier=${hasHumidifier},hasDehumidifier=${hasDehumidifier}, freeCoolingFlag=${freeCoolingFlag}," + + "useDehumidifierAsHRV=${useDehumidifierAsHRVFlag}") + } + + if ((ecobeeMode == 'cool' && (hasHrv == 'true' || hasErv == 'true')) && + (ecobeeHumidity >= (outdoorHumidity - min_humidity_diff)) && + (ecobeeHumidity >= (target_humidity + min_humidity_diff))) { + if (detailedNotif) { + log.trace "Ecobee is in ${ecobeeMode} mode and its humidity > target humidity level=${target_humidity}, " + + "need to dehumidify the house and normalized outdoor humidity is lower (${outdoorHumidity})" + send "dehumidify to ${target_humidity}% in ${ecobeeMode} mode, using ERV/HRV" + } + + // Turn on the dehumidifer and HRV/ERV, the outdoor humidity is lower or equivalent than inside + + ecobee.setThermostatSettings("", ['fanMinOnTime': "${min_fan_time}", 'vent': 'minontime', 'ventilatorMinOnTime': "${min_vent_time}"]) + + + } else if (((ecobeeMode in ['heat','off', 'auto']) && (hasHrv == 'false' && hasErv == 'false' && hasDehumidifier == 'true')) && + (ecobeeHumidity >= (target_humidity + min_humidity_diff)) && + (ecobeeHumidity >= outdoorHumidity - min_humidity_diff) && + (outdoorTemp > min_temp)) { + + if (detailedNotif) { + log.trace "Ecobee is in ${ecobeeMode} mode and its humidity > target humidity level=${target_humidity}, need to dehumidify the house " + + "normalized outdoor humidity is within range (${outdoorHumidity}) & outdoor temp is ${outdoorTemp},not too cold" + send "dehumidify to ${target_humidity}% in ${ecobeeMode} mode" + } + + // Turn on the dehumidifer, the outdoor temp is not too cold + + ecobee.setThermostatSettings("", ['dehumidifierMode': 'on', 'dehumidifierLevel': "${target_humidity}", + 'humidifierMode': 'off', 'fanMinOnTime': "${min_fan_time}" + ]) + + } else if (((ecobeeMode in ['heat','off', 'auto']) && ((hasHrv == 'true' || hasErv == 'true') && hasDehumidifier == 'false')) && + (ecobeeHumidity >= (target_humidity + min_humidity_diff)) && + (ecobeeHumidity >= outdoorHumidity - min_humidity_diff) && + (outdoorTemp > min_temp)) { + + if (detailedNotif) { + log.trace "Ecobee is in ${ecobeeMode} mode and its humidity > target humidity level=${target_humidity}, need to dehumidify the house " + + "normalized outdoor humidity is within range (${outdoorHumidity}) & outdoor temp is ${outdoorTemp},not too cold" + send "use HRV/ERV to dehumidify ${target_humidity}% in ${ecobeeMode} mode" + } + + // Turn on the HRV/ERV, the outdoor temp is not too cold + + ecobee.setThermostatSettings("", ['fanMinOnTime': "${min_fan_time}", 'vent': 'minontime', 'ventilatorMinOnTime': "${min_vent_time}"]) + + } else if (((ecobeeMode in ['heat','off', 'auto']) && (hasHrv == 'true' || hasErv == 'true' || hasDehumidifier == 'true')) && + (ecobeeHumidity >= (target_humidity + min_humidity_diff)) && + (ecobeeHumidity >= outdoorHumidity - min_humidity_diff) && + (outdoorTemp <= min_temp)) { + + + // Turn off the dehumidifer and HRV/ERV because it's too cold till the next cycle. + + ecobee.setThermostatSettings("", ['dehumidifierMode': 'off', 'dehumidifierLevel': "${target_humidity}", + 'humidifierMode': 'off', 'vent': 'off' + ]) + + if (detailedNotif) { + log.trace "Ecobee is in ${ecobeeMode} mode and its humidity > target humidity level=${target_humidity}, need to dehumidify the house " + + "normalized outdoor humidity is lower (${outdoorHumidity}), but outdoor temp is ${outdoorTemp}: too cold to dehumidify" + send "Too cold (${outdoorTemp}°) to dehumidify to ${target_humidity}" + } + } else if ((((ecobeeMode in ['heat','off', 'auto']) && hasHumidifier == 'true')) && + (ecobeeHumidity < (target_humidity - min_humidity_diff))) { + + if (detailedNotif) { + log.trace("In ${ecobeeMode} mode, Ecobee's humidity provided is way lower than target humidity level=${target_humidity}, need to humidify the house") + send " humidify to ${target_humidity} in ${ecobeeMode} mode" + } + // Need a minimum differential to humidify the house to the target if any humidifier available + + def humidifierMode = (frostControlFlag) ? 'auto' : 'manual' + ecobee.setThermostatSettings("", ['humidifierMode': "${humidifierMode}", 'humidity': "${target_humidity}", 'dehumidifierMode': 'off']) + + } else if (((ecobeeMode == 'cool') && (hasDehumidifier == 'false') && (hasHrv == 'false' && hasErv == 'false')) && + (ecobeeHumidity > (target_humidity + min_humidity_diff)) && + (outdoorHumidity > target_humidity)) { + + + if (detailedNotif) { + log.trace("Ecobee humidity provided is way higher than target humidity level=${target_humidity}, need to dehumidify with AC, because normalized outdoor humidity is too high=${outdoorHumidity}") + send "dehumidifyWithAC in cooling mode, indoor humidity is ${ecobeeHumidity}% and normalized outdoor humidity (${outdoorHumidity}%) is too high to dehumidify" + + } + // If mode is cooling and outdoor humidity is too high then use the A/C to lower humidity in the house if there is no dehumidifier + + ecobee.setThermostatSettings("", ['dehumidifyWithAC': 'true', 'dehumidifierLevel': "${target_humidity}", + 'dehumidiferMode': 'off', 'fanMinOnTime': "${min_fan_time}", 'vent': 'off' + ]) + + + } else if ((ecobeeMode == 'cool') && (hasDehumidifier == 'true') && (!useDehumidifierAsHRVFlag) && + (ecobeeHumidity > (target_humidity + min_humidity_diff))) { + + // If mode is cooling and outdoor humidity is too high, then just use dehumidifier if any available + + if (detailedNotif) { + log.trace "Dehumidify to ${target_humidity} in ${ecobeeMode} mode using the dehumidifier" + send "dehumidify to ${target_humidity}% in ${ecobeeMode} mode using the dehumidifier only" + } + + ecobee.setThermostatSettings("", ['dehumidifierMode': 'on', 'dehumidifierLevel': "${target_humidity}", 'humidifierMode': 'off', + 'dehumidifyWithAC': 'false', 'fanMinOnTime': "${min_fan_time}", 'vent': 'off' + ]) + + + } else if ((ecobeeMode == 'cool') && (hasDehumidifier == 'true') && (useDehumidifierAsHRVFlag) && + (outdoorHumidity < target_humidity + min_humidity_diff) && + (ecobeeHumidity > (target_humidity + min_humidity_diff))) { + + // If mode is cooling and outdoor humidity is too high, then just use dehumidifier if any available + + if (detailedNotif) { + log.trace "Dehumidify to ${target_humidity} in ${ecobeeMode} mode using the dehumidifier" + send "dehumidify to ${target_humidity}% in ${ecobeeMode} mode using the dehumidifier only" + } + ecobee.setThermostatSettings("", ['dehumidifierMode': 'on', 'dehumidifierLevel': "${target_humidity}", 'humidifierMode': 'off', + 'dehumidifyWithAC': 'false', 'fanMinOnTime': "${min_fan_time}", 'vent': 'off' + ]) + + + + + } else if (((ecobeeMode == 'cool') && (hasDehumidifier == 'true' && hasErv == 'false' && hasHrv == 'false')) && + (outdoorTemp < indoorTemp) && (freeCoolingFlag)) { + + // If mode is cooling and outdoor temp is lower than inside, then just use dehumidifier for better cooling if any available + + if (detailedNotif) { + log.trace "In cooling mode, outdoor temp is lower than inside, using dehumidifier for free cooling" + send "Outdoor temp is lower than inside, using dehumidifier for more efficient cooling" + } + + ecobee.setThermostatSettings("", ['dehumidifierMode': 'on', 'dehumidifierLevel': "${target_humidity}", 'humidifierMode': 'off', + 'dehumidifyWithAC': 'false', 'fanMinOnTime': "${min_fan_time}" + ]) + + + } else if ((ecobeeMode == 'cool' && (hasHrv == 'true')) && (outdoorTemp < indoorTemp) && (freeCoolingFlag)) { + + if (detailedNotif) { + log.trace("In cooling mode, outdoor temp is lower than inside, using the HRV to get fresh air") + send "Outdoor temp is lower than inside, using the HRV for more efficient cooling" + + } + // If mode is cooling and outdoor's temp is lower than inside, then use HRV to get fresh air into the house + + ecobee.setThermostatSettings("", ['fanMinOnTime': "${min_fan_time}", + 'vent': 'minontime', 'ventilatorMinOnTime': "${min_vent_time}", 'ventilatorFreeCooling': 'true' + ]) + + + } else if ((outdoorHumidity > ecobeeHumidity) && (ecobeeHumidity > target_humidity)) { + + // If indoor humidity is greater than target, but outdoor humidity is way higher than indoor humidity, + // just wait for the next cycle & do nothing for now. + + ecobee.setThermostatSettings("", ['dehumidifierMode': 'off', 'humidifierMode': 'off', 'vent': 'off']) + if (detailedNotif) { + log.trace("Indoor humidity is ${ecobeeHumidity}%, but outdoor humidity (${outdoorHumidity}%) is too high to dehumidify") + send "indoor humidity is ${ecobeeHumidity}%, but outdoor humidity ${outdoorHumidity}% is too high to dehumidify" + } + + } else { + + ecobee.setThermostatSettings("", ['dehumidifierMode': 'off', 'humidifierMode': 'off', 'dehumidifyWithAC': 'false', + 'vent': 'off', 'ventilatorFreeCooling': 'false' + ]) + if (detailedNotif) { + log.trace("All off, humidity level (${ecobeeHumidity}%) within range") + send "all off, humidity level (${ecobeeHumidity}%) within range" + } + } + + if (useDehumidifierAsHRVFlag) { + use_dehumidifer_as_HRV() + } // end if useDehumidifierAsHRVFlag ' + + log.debug "End of Fcn" +} + +private void use_dehumidifer_as_HRV() { + Date now = new Date() + String nowInLocalTime = new Date().format("yyyy-MM-dd HH:mm", location.timeZone) + Calendar oneHourAgoCal = new GregorianCalendar() + oneHourAgoCal.add(Calendar.HOUR, -1) + Date oneHourAgo = oneHourAgoCal.getTime() + if (detailedNotif) { + log.debug("local date/time= ${nowInLocalTime}, date/time now in UTC = ${String.format('%tF % 0) && (!equipStatus.contains("dehumidifier"))) { + if (detailedNotif) { + send "About to turn the dehumidifier on for ${diffVentTimeInMin.toString()} min. within the next hour..." + } + + ecobee.setThermostatSettings("", ['dehumidifierMode': 'on', 'dehumidifierLevel': '25', + 'fanMinOnTime': "${min_fan_time}" + ]) + // calculate the delay to turn off the dehumidifier according to the scheduled monitoring cycle + + float delay = ((min_vent_time.toFloat() / 60) * scheduleInterval.toFloat()).round() + int delayInt = delay.toInteger() + delayInt = (delayInt > 1) ? delayInt : 1 // Min. delay should be at least 1 minute, otherwise, the dehumidifier won't stop. + send "turning off the dehumidifier (used as HRV) in ${delayInt} minute(s)..." + // save the current setpoints before scheduling the dehumidifier to be turned off + runIn((delayInt * 60), "turn_off_dehumidifier") // turn off the dehumidifier after delay + } else if (diffVentTimeInMin <= 0) { + if (detailedNotif) { + send "dehumidifier has run for at least ${min_vent_time} min. within the last hour, waiting for the next cycle" + log.trace("dehumidifier has run for at least ${min_vent_time} min. within the last hour, waiting for the next cycle") + } + + } else if (equipStatus.contains("dehumidifier")) { + turn_off_dehumidifier() + } +} + + +private void turn_off_dehumidifier() { + + + if (detailedNotif) { + send("about to turn off dehumidifier used as HRV....") + } + log.trace("About to turn off the dehumidifier used as HRV and the fan after timeout") + + + ecobee.setThermostatSettings("", ['dehumidifierMode': 'off']) + +} + + +private def bolton(t) { + + // Estimates the saturation vapour pressure in hPa at a given temperature, T, in Celcius + // return saturation vapour pressure at a given temperature in Celcius + + + Double es = 6.112 * Math.exp(17.67 * t / (t + 243.5)) + return es + +} + + +private def calculate_corr_humidity(t1, rh1, t2) { + + + log.debug("calculate_corr_humidity t1= $t1, rh1=$rh1, t2=$t2") + + Double es = bolton(t1) + Double es2 = bolton(t2) + Double vapor = rh1 / 100.0 * es + Double rh2 = ((vapor / es2) * 100.0).round(2) + + log.debug("calculate_corr_humidity rh2= $rh2") + + return rh2 +} + + +private send(msg, askAlexa=false) { +int MAX_EXCEPTION_MSG_SEND=5 + + // will not send exception msg when the maximum number of send notifications has been reached + if ((msg.contains("exception")) || (msg.contains("error"))) { + state?.sendExceptionCount=state?.sendExceptionCount+1 + if (detailedNotif) { + log.debug "checking sendExceptionCount=${state?.sendExceptionCount} vs. max=${MAX_EXCEPTION_MSG_SEND}" + } + if (state?.sendExceptionCount >= MAX_EXCEPTION_MSG_SEND) { + log.debug "send>reached $MAX_EXCEPTION_MSG_SEND exceptions, exiting" + return + } + } + def message = "${get_APP_NAME()}>${msg}" + + if (sendPushMessage != "No") { + if (location.contactBookEnabled && recipients) { + log.debug "contact book enabled" + sendNotificationToContacts(message, recipients) + } else { + sendPush(message) + } + } + if (askAlexa) { + sendLocationEvent(name: "AskAlexaMsgQueue", value: "${get_APP_NAME()}", isStateChange: true, descriptionText: msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, message) + } +} + + +private int find_ideal_indoor_humidity(outsideTemp) { + + // -30C => 30%, at 0C => 45% + + int targetHum = 45 + (0.5 * outsideTemp) + return (Math.max(Math.min(targetHum, 60), 30)) +} + + +// catchall +def event(evt) { + log.debug "value: $evt.value, event: $evt, settings: $settings, handlerName: ${evt.handlerName}" +} + +def cToF(temp) { + return (temp * 1.8 + 32) +} + +def fToC(temp) { + return (temp - 32) / 1.8 +} + +def getImagePath() { + return "http://raw.githubusercontent.com/yracine/device-type.myecobee/master/icons/" +} + +def get_APP_NAME() { + return "MonitorAndSetEcobeeHumidity" +} + diff --git a/third-party/MonitorAndSetEcobeeTemp.groovy b/third-party/MonitorAndSetEcobeeTemp.groovy new file mode 100755 index 0000000..d9baeae --- /dev/null +++ b/third-party/MonitorAndSetEcobeeTemp.groovy @@ -0,0 +1,1079 @@ +/** + * MonitorAndSetEcobeeTemp + * + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * The MonitorAndSetEcobeeTemp monitors the outdoor temp and adjusts the heating and cooling set points + * at regular intervals (input parameter in minutes) according to heat/cool thresholds that you set (input parameters). + * It also constantly monitors any 'holds' at the thermostat to make sure that these holds are justified according to + * the motion sensors at home and the given thresholds. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +definition( + name: "MonitorAndSetEcobeeTemp", + namespace: "yracine", + author: "Yves Racine", + description: "Monitors And Adjusts Ecobee your programmed temperature according to indoor motion sensors & outdoor temperature and humidity.", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + + page(name: "dashboardPage", title: "Dashboard") + page(name: "tempSensorSettings", title: "tempSensorSettings") + page(name: "motionSensorSettings", title: "motionSensorSettings") + page(name: "thresholdSettings", title: "ThresholdSettings") + page(name: "otherSettings", title: "OtherSettings") +} + +def get_APP_VERSION() { return "3.4.3"} + +def dashboardPage() { + dynamicPage(name: "dashboardPage", title: "MonitorAndSetEcobeeTemp-Dashboard", uninstall: true, nextPage: tempSensorSettings,submitOnChange: true) { + section("Press Next in the upper section for Initial setup") { + if (ecobee) { + def scale= getTemperatureScale() + String currentProgName = ecobee?.currentClimateName + String currentProgType = ecobee?.currentProgramType + def scheduleProgramName = ecobee?.currentProgramScheduleName + String mode =ecobee?.currentThermostatMode.toString() + def operatingState=ecobee?.currentThermostatOperatingState + def heatingSetpoint,coolingSetpoint + switch (mode) { + case 'cool': + coolingSetpoint = ecobee?.currentValue('coolingSetpoint') + break + case 'auto': + coolingSetpoint = ecobee?.currentValue('coolingSetpoint') + case 'heat': + case 'emergency heat': + case 'auto': + case 'off': + heatingSetpoint = ecobee?.currentValue('heatingSetpoint') + break + } + + def dParagraph = "TstatMode: $mode\n" + + "TstatOperatingState $operatingState\n" + + "EcobeeClimateSet: $currentProgName\n" + + "EcobeeProgramType: $currentProgType\n" + + "HoldProgramSet: $state.programHoldSet\n" + if (coolingSetpoint) { + dParagraph = dParagraph + "CoolingSetpoint: ${coolingSetpoint}$scale\n" + } + if (heatingSetpoint) { + dParagraph = dParagraph + "HeatingSetpoint: ${heatingSetpoint}$scale\n" + } + paragraph dParagraph + if (state?.avgTempDiff) { + paragraph "AvgTempDiff: ${state?.avgTempDiff}$scale" + } + + } /* end if ecobee */ + } + section("Monitor indoor/outdoor temp & adjust the ecobee thermostat's setpoints") { + input "ecobee", "capability.thermostat", title: "For which Ecobee?" + } + section("At which interval in minutes (range=[10..59],default=10 min.)?") { + input "givenInterval", "number", title:"Interval", required: false + } + section("Maximum Temp adjustment in Farenheits/Celsius") { + input "givenTempDiff", "decimal", title: "Max Temp adjustment [default= +/-5°F/2°C]", required: false + } + section("Outdoor/Indoor Temp & Motion Setup") { + href(name: "toTempSensorsPage", title: "Configure your indoor Temp Sensors", description: "Tap to Configure...", image: getImagePath() + "IndoorTempSensor.png", page: "tempSensorSettings") + href(name: "toMotionSensorsPage", title: "Configure your indoor Motion Sensors", description: "Tap to Configure...", image: getImagePath() + "MotionDetector.jpg", page: "motionSensorSettings") + href(name: "toOutdoorSensorsPage", title: "Configure your outdoor Thresholds based on a weatherStation/Sensor", description: "Tap to Configure...", image: getImagePath() + "WeatherStation.jpg", page: "thresholdSettings") + href(name: "toNotificationsPage", title: "Notification & other Options Setup", description: "Tap to Configure...", page: "otherSettings") + } + section("About") { + paragraph "MonitorAndSetEcobeeTemp,the smartapp that adjusts your programmed ecobee's setpoints based on indoor/outdoor sensors" + paragraph "Version ${get_APP_VERSION()}" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } /* end section About */ + } /* end dashboardPage */ +} +def tempSensorSettings() { + dynamicPage(name: "tempSensorSettings", title: "Indoor Temp Sensor(s) for setpoint adjustment", install: false, nextPage: motionSensorSettings) { + section("Choose indoor sensor(s) with both Motion & Temp capabilities to be used for dynamic temp adjustment when occupied [optional]") { + input "indoorSensors", "capability.motionSensor", title: "Which Indoor Motion/Temperature Sensor(s)", required: false, multiple:true + } + section("Choose any other indoor temp sensors for avg temp adjustment [optional]") { + input "tempSensors", "capability.temperatureMeasurement", title: "Any other temp sensors?", multiple: true, required: false + + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + + } +} + + +def motionSensorSettings() { + dynamicPage(name: "motionSensorSettings", title: "Motion Sensors for setting thermostat to Away/Present", install: false, nextPage: thresholdSettings) { + section("Set your ecobee thermostat to [Away,Present] based on all Room Motion Sensors [default=false] ") { + input (name:"setAwayOrPresentFlag", title: "Set Main thermostat to [Away,Present]?", type:"bool",required:false) + } + section("Choose additional indoor motion sensors for setting ecobee climate to [Away, Home] [optional]") { + input "motions", "capability.motionSensor", title: "Any other motion sensors?", multiple: true, required: false + } + section("Trigger climate/temp adjustment when motion or no motion has been detected for [default=15 minutes]") { + input "residentsQuietThreshold", "number", title: "Time in minutes", required: false + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + } +} +def thresholdSettings() { + dynamicPage(name: "thresholdSettings", title: "Outdoor Thresholds for setpoint adjustment", install: false, uninstall: true, nextPage: otherSettings) { + section("Choose weatherStation or outdoor Temperature & Humidity Sensor to be used for temp adjustment") { + input "outdoorSensor", "capability.temperatureMeasurement", title: "Outdoor Temperature Sensor",required: false + } + section("For more heating in cold season, outdoor temp's threshold [default <= 10°F/-17°C]") { + input "givenMoreHeatThreshold", "decimal", title: "Outdoor temp's threshold for more heating", required: false + } + section("For less heating in cold season, outdoor temp's threshold [default >= 50°F/10°C]") { + input "givenLessHeatThreshold", "decimal", title: "Outdoor temp's threshold for less heating", required: false + } + section("For more cooling in hot season, outdoor temp's threshold [default >= 85°F/30°C]") { + input "givenMoreCoolThreshold", "decimal", title: "Outdoor temp's threshold for more cooling", required: false + } + section("For less cooling in hot season, outdoor temp's threshold [default <= 75°F/22°C]") { + input "givenLessCoolThreshold", "decimal", title: "Outdoor temp's threshold for less cooling", required: false + } + section("For more cooling/heating, outdoor humidity's threshold [default >= 85%]") { + input "givenHumThreshold", "number", title: "Outdoor Relative humidity's threshold for more cooling/heating", + required: false + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + } +} +def otherSettings() { + dynamicPage(name: "otherSettings", title: "Other Settings", install: true, uninstall: false) { + + section("What do I use for the Master on/off switch to enable/disable processing? [optional]") { + input "powerSwitch", "capability.switch", required: false + } + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: + false + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + section("Detailed Notifications") { + input "detailedNotif", "bool", title: "Detailed Notifications?", required: + false + } + section("Enable Amazon Echo/Ask Alexa Notifications [optional, default=false]") { + input (name:"askAlexaFlag", title: "Ask Alexa verbal Notifications?", type:"bool", + description:"optional",required:false) + } + section([mobileOnly:true]) { + label title: "Assign a name for this SmartApp", required: false + } + section { + href(name: "toDashboardPage", title: "Back to Dashboard Page", page: "dashboardPage") + } + } +} + + + +def installed() { + initialize() +} + +def updated() { + // we have had an update + // remove everything and reinstall + unschedule() + + unsubscribe() + initialize() +} +def initialize() { + log.debug "Initialized with settings: ${settings}" + + reset_state_program_values() + reset_state_motions() + reset_state_tempSensors() + state?.exceptionCount=0 + + Integer delay = givenInterval ?: 10 // By default, do it every 10 minutes + if ((delay < 10) || (delay>59)) { + def msg= "Scheduling delay not in range (${delay} min), exiting..." + log.debug msg + send msg + return + } + log.debug "Scheduling ecobee temp Monitoring and adjustment every ${delay} minutes" + + schedule("0 0/${delay} * * * ?", monitorAdjustTemp) // monitor & set indoor temp according to delay specified + + + subscribe(indoorSensors, "motion",motionEvtHandler, [filterEvents: false]) + subscribe(motions, "motion", motionEvtHandler, [filterEvents: false]) + + subscribe(ecobee, "programHeatTemp", programHeatEvtHandler) + subscribe(ecobee, "programCoolTemp", programCoolEvtHandler) + subscribe(ecobee, "setClimate", setClimateEvtHandler) + subscribe(ecobee, "thermostatMode", changeModeHandler) + + if (powerSwitch) { + subscribe(powerSwitch, "switch.off", offHandler, [filterEvents: false]) + subscribe(powerSwitch, "switch.on", onHandler, [filterEvents: false]) + } + if (detailedNotif) { + log.debug("initialize state=$state") + } + // Resume program every time a install/update is done to remove any holds at thermostat (reset). + + ecobee.resumeThisTstat() + + subscribe(app, appTouch) + + state?.poll = [ last: 0, rescheduled: now() ] + + //Subscribe to different events (ex. sunrise and sunset events) to trigger rescheduling if needed + subscribe(location, "sunrise", rescheduleIfNeeded) + subscribe(location, "sunset", rescheduleIfNeeded) + subscribe(location, "mode", rescheduleIfNeeded) + subscribe(location, "sunriseTime", rescheduleIfNeeded) + subscribe(location, "sunsetTime", rescheduleIfNeeded) + rescheduleIfNeeded() +} + + +def rescheduleIfNeeded(evt) { + if (evt) log.debug("rescheduleIfNeeded>$evt.name=$evt.value") + Integer delay = givenInterval ?: 10 // By default, do it every 10 minutes + BigDecimal currentTime = now() + BigDecimal lastPollTime = (currentTime - (state?.poll["last"]?:0)) + if (lastPollTime != currentTime) { + Double lastPollTimeInMinutes = (lastPollTime/60000).toDouble().round(1) + log.info "rescheduleIfNeeded>last poll was ${lastPollTimeInMinutes.toString()} minutes ago" + } + if (((state?.poll["last"]?:0) + (delay * 60000) < currentTime) && canSchedule()) { + log.info "rescheduleIfNeeded>scheduling monitorAdjustTemp in ${delay} minutes.." + schedule("0 0/${delay} * * * ?", monitorAdjustTemp) + } + + monitorAdjustTemp() + // Update rescheduled state + + if (!evt) state.poll["rescheduled"] = now() +} + + + +def changeModeHandler(evt) { + log.debug "changeModeHandler>$evt.name: $evt.value" + ecobee.resumeThisTstat() + rescheduleIfNeeded(evt) // Call rescheduleIfNeeded to work around ST scheduling issues + monitorAdjustTemp() +} + +def appTouch(evt) { + monitorAdjustTemp() +} + +private def sendNotifDelayNotInRange() { + + send "scheduling delay (${givenInterval} min.) not in range, please restart..." +} + +def setClimateEvtHandler(evt) { + log.debug "SetClimateEvtHandler>$evt.name: $evt.value" +} + +def programHeatEvtHandler(evt) { + log.debug "programHeatEvtHandler>$evt.name = $evt.value" +} + +def programCoolEvtHandler(evt) { + log.debug "programCoolEvtHandler>$evt.name = $evt.value" +} + +def motionEvtHandler(evt) { + if (evt.value == "active") { + log.debug "Motion at home..." + String currentProgName = ecobee.currentClimateName + String currentProgType = ecobee.currentProgramType + + if (state?.programHoldSet == 'Away') { + check_if_hold_justified() + } else if ((currentProgName.toUpperCase()=='AWAY') && (state?.programHoldSet== "" ) && + (currentProgType.toUpperCase()!='VACATION')) { + check_if_hold_needed() + } + + } +} + + +def offHandler(evt) { + log.debug "$evt.name: $evt.value" +} + +def onHandler(evt) { + log.debug "$evt.name: $evt.value" + monitorAdjustTemp() +} + + + +private addIndoorSensorsWhenOccupied() { + + def threshold = residentsQuietThreshold ?: 15 // By default, the delay is 15 minutes + def result = false + def t0 = new Date(now() - (threshold * 60 *1000)) + for (sensor in indoorSensors) { + def recentStates = sensor.statesSince("motion", t0) + if (recentStates.find{it.value == "active"}) { + if (detailedNotif) { + log.debug "addTempSensorsWhenOccupied>added occupied sensor ${sensor} as ${sensor.device.id}" + } + state.tempSensors.add(sensor.device.id) + result= true + } + + } + log.debug "addTempSensorsWhenOccupied, result = $result" + return result +} + +private residentsHaveBeenQuiet() { + + def threshold = residentsQuietThreshold ?: 15 // By default, the delay is 15 minutes + def t0 = new Date(now() - (threshold * 60 *1000)) + for (sensor in motions) { +/* Removed the refresh following some ST platform changes which cause "offline" issues to some temp/motion sensors. + + if (sensor.hasCapability("Refresh")) { // to get the latest motion values + sensor.refresh() + } +*/ + def recentStates = sensor.statesSince("motion", t0) + if (recentStates.find{it.value == "active"}) { + log.debug "residentsHaveBeenQuiet: false, found motion at $sensor" + return false + } + } + + + for (sensor in indoorSensors) { +/* Removed the refresh following some ST platform changes which cause "offline" issues to some temp/motion sensors. + if (sensor.hasCapability("Refresh")) { // to get the latest motion values + sensor.refresh() + } +*/ + def recentStates = sensor.statesSince("motion", t0) + if (recentStates.find{it.value == "active"}) { + log.debug "residentsHaveBeenQuiet: false, found motion at $sensor" + return false + } + + } + log.debug "residentsHaveBeenQuiet: true" + return true +} + + +private isProgramScheduleSet(climateName, threshold) { + def result = false + def t0 = new Date(now() - (threshold * 60 *1000)) + def recentStates = ecobee.statesSince("climateName", t0) + if (recentStates.find{it.value == climateName}) { + result = true + } + log.debug "isProgramScheduleSet: $result" + return result +} + + +def monitorAdjustTemp() { + + Integer delay = givenInterval ?: 10 // By default, do it every 10 minutes + def todayDay = new Date().format("dd",location.timeZone) + if ((!state?.today) || (todayDay != state?.today)) { + state?.exceptionCount=0 + state?.sendExceptionCount=0 + state?.today=todayDay + } + + + state?.poll["last"] = now() + + if (((state?.poll["rescheduled"]?:0) + (delay * 60000)) < now()) { + log.info "scheduling rescheduleIfNeeded() in ${delay} minutes.." + schedule("0 0/${delay} * * * ?", rescheduleIfNeeded) + // Update rescheduled state + state?.poll["rescheduled"] = now() + } + + if (powerSwitch?.currentSwitch == "off") { + if (detailedNotif) { + send("Virtual master switch ${powerSwitch.name} is off, processing on hold...") + } + return + } + + if (detailedNotif) { + send("monitoring every ${delay} minute(s)") + } + + // Polling of the latest values at the thermostat and at the outdoor sensor + def MAX_EXCEPTION_COUNT=5 + String exceptionCheck, msg + try { + ecobee.poll() + exceptionCheck= ecobee.currentVerboseTrace.toString() + if ((exceptionCheck) && ((exceptionCheck.contains("exception") || (exceptionCheck.contains("error")) && + (!exceptionCheck.contains("Java.util.concurrent.TimeoutException"))))) { + // check if there is any exception or an error reported in the verboseTrace associated to the device (except the ones linked to rate limiting). + state?.exceptionCount=state.exceptionCount+1 + log.error "found exception/error after polling, exceptionCount= ${state?.exceptionCount}: $exceptionCheck" + } else { + // reset exception counter + state?.exceptionCount=0 + } + } catch (e) { + log.error "exception $e while trying to poll the device $d, exceptionCount= ${state?.exceptionCount}" + } + if ((state?.exceptionCount>=MAX_EXCEPTION_COUNT) || ((exceptionCheck) && (exceptionCheck.contains("Unauthorized")))) { + // need to authenticate again + msg="too many exceptions/errors or unauthorized exception, $exceptionCheck (${state?.exceptionCount} errors), may need to re-authenticate at ecobee..." + send "${msg}" + log.error msg + return + } + +/* Removed the refresh following some ST platform changes which cause "offline" issues to some temp/motion sensors. + + if ((outdoorSensor) && (outdoorSensor.hasCapability("Refresh"))) { + + try { + outdoorSensor.refresh() + } catch (e) { + log.debug("not able to refresh ${outdoorSensor}'s temp value") + } + } +*/ + String currentProgType = ecobee.currentProgramType + log.trace("program Type= ${currentProgType}") + if (currentProgType.toUpperCase().contains("HOLD")) { + log.trace("about to call check_if_hold_justified....") + check_if_hold_justified() + } + + if (!currentProgType.contains("vacation")) { // don't make adjustment if on vacation mode + log.trace("about to call check_if_needs_hold....") + check_if_hold_needed() + } +} + +private def reset_state_program_values() { + + state.programSetTime = null + state.programSetTimestamp = "" + state.programHoldSet = "" +} + +private def reset_state_tempSensors() { + + state.tempSensors=[] + settings.tempSensors.each { +// By default, the 'static' temp Sensors are the ones used for temp avg calculation +// Other 'occupied' sensors may be added dynamically when needed + + state.tempSensors.add(it.device.id) + } +} + +private def reset_state_motions() { + state.motions=[] + if (settings.motions) { + settings.motions.each { + state.motions.add(it.device.id) + } + } + + if (settings.indoorSensors) { + settings.indoorSensors.each { + state.motions.add(it.device.id) + } + } +} + +private void addAllTempsForAverage(indoorTemps) { + + for (sensorId in state.tempSensors) { // Add dynamically any indoor Sensor's when occupied + def sensor = tempSensors.find{it.device.id == sensorId} + if (detailedNotif) { + log.debug "addAllTempsForAverage>trying to find sensorId=$sensorId in $tempSensors from $state.tempSensors" + } + if (sensor != null) { + if (detailedNotif) { + log.debug "addAllTempsForAverage>found sensor $sensor in $tempSensors" + } +/* Removed the refresh following some ST platform changes which cause "offline" issues to some temp/motion sensors. + + if (sensor.hasCapability("Refresh")) { + sensor.refresh() + } +*/ + def currentTemp =sensor.currentTemperature + if (currentTemp != null) { + indoorTemps.add(currentTemp) // Add indoor temp to calculate the average based on all sensors + if (detailedNotif) { + log.trace "addAllTempsForAverage>adding $sensor temp (${currentTemp}) to tempSensors List" + } + } + } + + } + + for (sensorId in state.tempSensors) { // Add dynamically any indoor Sensor's when occupied + def sensor = indoorSensors.find{it.device.id == sensorId} + if (detailedNotif) { + log.debug "addAllTempsForAverage>trying to find sensorId=$sensorId in $indoorSensors from $state.tempSensors" + } + if (sensor != null) { + log.debug "addAllTempsForAverage>found sensor $sensor in $indoorSensors" + def currentTemp =sensor.currentTemperature + if (currentTemp != null) { + indoorTemps.add(currentTemp) // Add indoor temp to calculate the average based on all sensors + log.trace "addAllTempsForAverage> adding $sensor temp (${currentTemp}) from indoorSensors List" + } + } + } +} + +private def check_if_hold_needed() { + log.debug "Begin of Fcn check_if_hold_needed, settings= $settings" + float max_temp_diff,temp_diff=0 + Integer humidity_threshold = givenHumThreshold ?: 85 // by default, 85% is the outdoor Humidity's threshold for more cooling + float more_heat_threshold, more_cool_threshold + float less_heat_threshold, less_cool_threshold + def MIN_TEMP_DIFF=0.5 + + def scale = getTemperatureScale() + if (scale == 'C') { + max_temp_diff = givenTempDiff ?: 2 // 2°C temp differential is applied by default + more_heat_threshold = (givenMoreHeatThreshold != null) ? givenMoreHeatThreshold : (-17) // by default, -17°C is the outdoor temp's threshold for more heating + more_cool_threshold = (givenMoreCoolThreshold != null) ? givenMoreCoolThreshold : 30 // by default, 30°C is the outdoor temp's threshold for more cooling + less_heat_threshold = (givenLessHeatThreshold != null) ? givenLessHeatThreshold : 10 // by default, 10°C is the outdoor temp's threshold for less heating + less_cool_threshold = (givenLessCoolThreshold != null) ? givenLessCoolThreshold : 22 // by default, 22°C is the outdoor temp's threshold for less cooling + + } else { + max_temp_diff = givenTempDiff ?: 5 // 5°F temp differential is applied by default + more_heat_threshold = (givenMoreHeatThreshold != null) ? givenMoreHeatThreshold : 10 // by default, 10°F is the outdoor temp's threshold for more heating + more_cool_threshold = (givenMoreCoolThreshold != null) ? givenMoreCoolThreshold : 85 // by default, 85°F is the outdoor temp's threshold for more cooling + less_heat_threshold = (givenLessHeatThreshold != null) ? givenLessHeatThreshold : 50 // by default, 50°F is the outdoor temp's threshold for less heating + less_cool_threshold = (givenLessCoolThreshold != null) ? givenLessCoolThreshold : 75 // by default, 75°F is the outdoor temp's threshold for less cooling + } + + String currentProgName = ecobee.currentClimateName + String currentSetClimate = ecobee.currentSetClimate + + String ecobeeMode = ecobee.currentThermostatMode.toString() + float heatTemp = ecobee.currentHeatingSetpoint.toFloat() + float coolTemp = ecobee.currentCoolingSetpoint.toFloat() + float programHeatTemp = ecobee.currentProgramHeatTemp.toFloat() + float programCoolTemp = ecobee.currentProgramCoolTemp.toFloat() + Integer ecobeeHumidity = ecobee.currentHumidity + float ecobeeTemp = ecobee.currentTemperature.toFloat() + + reset_state_tempSensors() + if (addIndoorSensorsWhenOccupied()) { + if (detailedNotif) { + log.trace("check_if_hold_needed>some occupied indoor Sensors added for avg calculation") + } + } + def indoorTemps = [ecobeeTemp] + addAllTempsForAverage(indoorTemps) + float avg_indoor_temp = (indoorTemps.sum() / indoorTemps.size()).round(1) // this is the avg indoor temp based on indoor sensors + if (detailedNotif) { + log.trace "check_if_hold_needed> location.mode = $location.mode" + log.trace "check_if_hold_needed> ecobee Mode = $ecobeeMode" + log.trace "check_if_hold_needed> currentProgName = $currentProgName" + log.trace "check_if_hold_needed> programHeatTemp = $programHeatTemp°" + log.trace "check_if_hold_needed> programCoolTemp = $programCoolTemp°" + log.trace "check_if_hold_needed> ecobee's indoorTemp = $ecobeeTemp°" + log.trace "check_if_hold_needed> state.tempSensors = $state.tempSensors" + log.trace "check_if_hold_needed> indoorTemps = $indoorTemps" + log.trace "check_if_hold_needed> avgIndoorTemp = $avg_indoor_temp°" + log.trace("check_if_hold_needed>temp sensors count=${indoorTemps.size()}") + log.trace "check_if_hold_needed> max_temp_diff = $max_temp_diff°" + log.trace "check_if_hold_needed> heatTemp = $heatTemp°" + log.trace "check_if_hold_needed> coolTemp = $coolTemp°" + log.trace "check_if_hold_needed> state=${state}" + } + float targetTstatTemp + + if (detailedNotif) { + send("needs Hold? currentProgName ${currentProgName},indoorTemp ${ecobeeTemp}°,progHeatSetPoint ${programHeatTemp}°,progCoolSetPoint ${programCoolTemp}°") + send("needs Hold? currentProgName ${currentProgName},indoorTemp ${ecobeeTemp}°,heatingSetPoint ${heatTemp}°,coolingSetPoint ${coolTemp}°") + if (state.programHoldSet!= "") { + send("Hold ${state.programHoldSet} has been set") + } + } + reset_state_motions() + def setAwayOrPresent = (setAwayOrPresentFlag)?:false + if ((setAwayOrPresent) && (state.motions != [])) { // the following logic is done only if motion sensors are provided as input parameters + + boolean residentAway=residentsHaveBeenQuiet() + if ((!currentProgName.toUpperCase().contains('AWAY')) && (!residentAway)) { + + ecobee.present() + send("Program now set to Home, motion detected") + state.programSetTime = now() + state.programSetTimestamp = new Date().format("yyyy-MM-dd HH:mm", location.timeZone) + state.programHoldSet = 'Home' + log.debug "Program now set to Home at ${state.programSetTimestamp}, motion detected" + /* Get latest heat and cool setting points after climate adjustment */ + programHeatTemp = ecobee.currentHeatingSetpoint.toFloat() // This is the heat temp associated to the current program + programCoolTemp = ecobee.currentCoolingSetpoint.toFloat() // This is the cool temp associated to the current program + + } else if ((!currentProgName.toUpperCase().contains('SLEEP')) && (!currentProgName.toUpperCase().contains('AWAY')) && + (residentAway)) { + // Do not adjust the program when ecobee mode = Sleep or Away + + ecobee.away() + send("Program now set to Away,no motion detected") + state.programSetTime = now() + state.programSetTimestamp = new Date().format("yyyy-MM-dd HH:mm", location.timeZone) + state.programHoldSet = 'Away' + /* Get latest heat and cool setting points after climate adjustment */ + programHeatTemp = ecobee.currentHeatingSetpoint.toFloat() // This is the heat temp associated to the current program + programCoolTemp = ecobee.currentCoolingSetpoint.toFloat() // This is the cool temp associated to the current program + + + } + } /* end if state.motions */ + + if ((location.mode.toUpperCase().contains('AWAY')) || (currentProgName.toUpperCase()=='SLEEP')) { + + // Do not adjust cooling or heating settings if ST mode == Away or Program Schedule at ecobee == SLEEP + + log.debug "ST mode is $location.mode, current program is $currentProgName, no adjustment required, exiting..." + return + } + + if (ecobeeMode == 'cool') { + if (outdoorSensor) { + Integer outdoorHumidity = outdoorSensor.currentHumidity + float outdoorTemp = outdoorSensor.currentTemperature.toFloat() + if (detailedNotif) { + log.trace "check_if_hold_needed> outdoorTemp = $outdoorTemp°" + log.trace "check_if_hold_needed> moreCoolThreshold = $more_cool_threshold°" + log.trace "check_if_hold_needed> lessCoolThreshold = $less_cool_threshold°" + log.trace( + "check_if_hold_needed>evaluate: moreCoolThreshold= ${more_cool_threshold}° vs. outdoorTemp ${outdoorTemp}°") + log.trace( + "check_if_hold_needed>evaluate: moreCoolThresholdHumidity= ${humidity_threshold}% vs. outdoorHum ${outdoorHumidity}%") + log.trace( + "check_if_hold_needed>evaluate: programCoolTemp= ${programCoolTemp}° vs. avg indoor Temp= ${avg_indoor_temp}°") + send("eval: moreCoolThreshold ${more_cool_threshold}° vs. outdoorTemp ${outdoorTemp}°") + send("eval: moreCoolThresholdHumidty ${humidity_threshold}% vs. outdoorHum ${outdoorHumidity}%") + send("eval: programCoolTemp= ${programCoolTemp}° vs. avgIndoorTemp= ${avg_indoor_temp}°") + } + + if (outdoorTemp >= more_cool_threshold) { + targetTstatTemp = (programCoolTemp - max_temp_diff).round(1) + ecobee.setCoolingSetpoint(targetTstatTemp) + send("cooling setPoint now =${targetTstatTemp}°,outdoorTemp >=${more_cool_threshold}°") + } else if (outdoorHumidity >= humidity_threshold) { + def extremes = [less_cool_threshold, more_cool_threshold] + float median_temp = (extremes.sum() / extremes.size()).round(1) // Increase cooling settings based on median temp + if (detailedNotif) { + String medianTempFormat = String.format('%2.1f', median_temp) + send("eval: cool median temp ${medianTempFormat}° vs.outdoorTemp ${outdoorTemp}°") + } + if (outdoorTemp > median_temp) { // Only increase cooling settings when outdoorTemp > median_temp + targetTstatTemp = (programCoolTemp - max_temp_diff).round(1) + ecobee.setCoolingSetpoint(targetTstatTemp) + send("cooling setPoint now=${targetTstatTemp}°, outdoorHum >=${humidity_threshold}%") + } + } + if (detailedNotif) { + send("evaluate: lessCoolThreshold ${less_cool_threshold}° vs. outdoorTemp ${outdoorTemp}°") + log.trace("check_if_hold_needed>evaluate: lessCoolThreshold= ${less_cool_threshold} vs.outdoorTemp ${outdoorTemp}°") + } + if (outdoorTemp <= less_cool_threshold) { + targetTstatTemp = (programCoolTemp + max_temp_diff).round(1) + ecobee.setCoolingSetpoint(targetTstatTemp) + send( + "cooling setPoint now=${targetTstatTemp}°, outdoor temp <=${less_cool_threshold}°" + ) + } + } /* end if outdoorSensor */ + + if ((state.tempSensors) && (avg_indoor_temp > coolTemp)) { + temp_diff = (ecobee_temp - avg_indoor_temp).round(1) // adjust the coolingSetPoint at the ecobee tstat according to the avg indoor temp measured + + temp_diff = (temp_diff <0-max_temp_diff)?max_temp_diff:(temp_diff >max_temp_diff)?max_temp_diff:temp_diff // determine the temp_diff based on max_temp_diff + targetTstatTemp = (programCoolTemp - temp_diff).round(1) + if (temp_diff.abs() > MIN_TEMP_DIFF) { // adust the temp only if temp diff is significant + ecobee.setCoolingSetpoint(targetTstatTemp) + send("cooling setPoint now =${targetTstatTemp}°,adjusted by temp diff (${temp_diff}°) between sensors") + } + } + } else if (ecobeeMode in ['heat', 'emergency heat']) { + if (outdoorSensor) { + Integer outdoorHumidity = outdoorSensor.currentHumidity + float outdoorTemp = outdoorSensor.currentTemperature.toFloat() + if (detailedNotif) { + log.trace "check_if_hold_needed> outdoorTemp = $outdoorTemp°" + log.trace "check_if_hold_needed> moreHeatThreshold = $more_heat_threshold°" + log.trace "check_if_hold_needed> lessHeatThreshold = $less_heat_threshold°" + log.trace("check_if_hold_needed>evaluate: moreHeatThreshold ${more_heat_threshold}° vs.outdoorTemp ${outdoorTemp}°") + log.trace( + "check_if_hold_needed>evaluate: moreHeatThresholdHumidity= ${humidity_threshold}% vs.outdoorHumidity ${outdoorHumidity}%") + log.trace( + "check_if_hold_needed>evaluate: programHeatTemp= ${programHeatTemp}° vs. avg indoor Temp= ${avg_indoor_temp}°") + send("eval: moreHeatThreshold ${more_heat_threshold}° vs.outdoorTemp ${outdoorTemp}°") + send("eval: moreHeatThresholdHumidty=${humidity_threshold}% vs.outdoorHumidity ${outdoorHumidity}%") + send("eval: programHeatTemp= ${programHeatTemp}° vs. avgIndoorTemp= ${avg_indoor_temp}°") + } + if (outdoorTemp <= more_heat_threshold) { + targetTstatTemp = (programHeatTemp + max_temp_diff).round(1) + ecobee.setHeatingSetpoint(targetTstatTemp) + send( + "heating setPoint now= ${targetTstatTemp}°, outdoorTemp <=${more_heat_threshold}°") + } else if (outdoorHumidity >= humidity_threshold) { + def extremes = [less_heat_threshold, more_heat_threshold] + float median_temp = (extremes.sum() / extremes.size()).round(1) // Increase heating settings based on median temp + if (detailedNotif) { + String medianTempFormat = String.format('%2.1f', median_temp) + send("eval: heat median temp ${medianTempFormat}° vs.outdoorTemp ${outdoorTemp}°") + } + if (outdoorTemp < median_temp) { // Only increase heating settings when outdoorTemp < median_temp + targetTstatTemp = (programHeatTemp + max_temp_diff).round(1) + ecobee.setHeatingSetpoint(targetTstatTemp) + send("heating setPoint now=${targetTstatTemp}°, outdoorHum >=${humidity_threshold}%") + } + } + if (detailedNotif) { + log.trace("eval:lessHeatThreshold=${less_heat_threshold}° vs.outdoorTemp ${outdoorTemp}°") + send("eval: lessHeatThreshold ${less_heat_threshold}° vs.outdoorTemp ${outdoorTemp}°") + } + if (outdoorTemp >= less_heat_threshold) { + targetTstatTemp = (programHeatTemp - max_temp_diff).round(1) + ecobee.setHeatingSetpoint(targetTstatTemp) + send("heating setPoint now=${targetTstatTemp}°,outdoor temp>= ${less_heat_threshold}°") + } + } /* if outdoorSensor */ + if ((state.tempSensors) && (avg_indoor_temp < heatTemp)) { + temp_diff = (ecobeeTemp - avg_indoor_temp).round(1) // adjust the heatingSetPoint at the tstat according to the avg indoor temp measur + temp_diff = (temp_diff <0-max_temp_diff)?max_temp_diff:(temp_diff >max_temp_diff)?max_temp_diff:temp_diff // determine the temp_diff based on max_temp_diff + targetTstatTemp = (programHeatTemp + temp_diff).round(1) + if (temp_diff.abs() > MIN_TEMP_DIFF) { // adust the temp only if temp diff is significant + ecobee.setHeatingSetpoint(targetTstatTemp) + send("heating setPoint now =${targetTstatTemp}°,adjusted by temp diff (${temp_diff}°) between sensors") + } + } + } /* end if heat mode */ + state?.avgTempDiff =temp_diff // to display on the dashboardPage + log.debug "End of Fcn check_if_hold_needed" +} + +private def check_if_hold_justified() { + log.debug "Begin of Fcn check_if_hold_justified, settings=$settings" + Integer humidity_threshold = givenHumThreshold ?: 85 // by default, 85% is the outdoor Humidity's threshold for more cooling + float more_heat_threshold, more_cool_threshold + float less_heat_threshold, less_cool_threshold + float max_temp_diff + Integer delay = givenInterval ?: 10 // By default, do it every 10 minutes + + def scale = getTemperatureScale() + if (scale == 'C') { + max_temp_diff = givenTempDiff ?: 2 // 2°C temp differential is applied by default + more_heat_threshold = (givenMoreHeatThreshold != null) ? givenMoreHeatThreshold : (-17) // by default, -17°C is the outdoor temp's threshold for more heating + more_cool_threshold = (givenMoreCoolThreshold != null) ? givenMoreCoolThreshold : 30 // by default, 30°C is the outdoor temp's threshold for more cooling + less_heat_threshold = (givenLessHeatThreshold != null) ? givenLessHeatThreshold : 10 // by default, 10°C is the outdoor temp's threshold for less heating + less_cool_threshold = (givenLessCoolThreshold != null) ? givenLessCoolThreshold : 22 // by default, 22°C is the outdoor temp's threshold for less cooling + + } else { + max_temp_diff = givenTempDiff ?: 5 // 5°F temp differential is applied by default + more_heat_threshold = (givenMoreHeatThreshold != null) ? givenMoreHeatThreshold : 10 // by default, 10°F is the outdoor temp's threshold for more heating + more_cool_threshold = (givenMoreCoolThreshold != null) ? givenMoreCoolThreshold : 85 // by default, 85°F is the outdoor temp's threshold for more cooling + less_heat_threshold = (givenLessHeatThreshold != null) ? givenLessHeatThreshold : 50 // by default, 50°F is the outdoor temp's threshold for less heating + less_cool_threshold = (givenLessCoolThreshold != null) ? givenLessCoolThreshold : 75 // by default, 75°F is the outdoor temp's threshold for less cooling + } + String currentProgName = ecobee.currentClimateName + String currentSetClimate = ecobee.currentSetClimate + float heatTemp = ecobee.currentHeatingSetpoint.toFloat() + float coolTemp = ecobee.currentCoolingSetpoint.toFloat() + float programHeatTemp = ecobee.currentProgramHeatTemp.toFloat() + float programCoolTemp = ecobee.currentProgramCoolTemp.toFloat() + Integer ecobeeHumidity = ecobee.currentHumidity + float ecobeeTemp = ecobee.currentTemperature.toFloat() + + reset_state_tempSensors() + if (addIndoorSensorsWhenOccupied()) { + log.trace("check_if_hold_justified>some occupied indoor Sensors added for avg calculation") + } + + def indoorTemps = [ecobeeTemp] + addAllTempsForAverage(indoorTemps) + log.trace("check_if_hold_justified> temps count=${indoorTemps.size()}") + float avg_indoor_temp = (indoorTemps.sum() / indoorTemps.size()).round(1) // this is the avg indoor temp based on indoor sensors + + String ecobeeMode = ecobee.currentThermostatMode.toString() + if (detailedNotif) { + log.trace "check_if_hold_justified> location.mode = $location.mode" + log.trace "check_if_hold_justified> ecobee Mode = $ecobeeMode" + log.trace "check_if_hold_justified> currentProgName = $currentProgName" + log.trace "check_if_hold_justified> currentSetClimate = $currentSetClimate" + log.trace "check_if_hold_justified> state.tempSensors = $state.tempSensors" + log.trace "check_if_hold_justified> ecobee's indoorTemp = $ecobeeTemp°" + log.trace "check_if_hold_justified> indoorTemps = $indoorTemps" + log.trace "check_if_hold_justified> avgIndoorTemp = $avg_indoor_temp°" + log.trace "check_if_hold_justified> max_temp_diff = $max_temp_diff°" + log.trace "check_if_hold_justified> heatTemp = $heatTemp°" + log.trace "check_if_hold_justified> coolTemp = $coolTemp°" + log.trace "check_if_hold_justified> programHeatTemp = $programHeatTemp°" + log.trace "check_if_hold_justified> programCoolTemp = $programCoolTemp°" + log.trace "check_if_hold_justified>state=${state}" + send("Hold justified? currentProgName ${currentProgName},indoorTemp ${ecobeeTemp}°,progHeatSetPoint ${programHeatTemp}°,progCoolSetPoint ${programCoolTemp}°") + send("Hold justified? currentProgName ${currentProgName},indoorTemp ${ecobeeTemp}°,heatingSetPoint ${heatTemp}°,coolingSetPoint ${coolTemp}°") + if (state?.programHoldSet!= null && state?.programHoldSet!= "") { + + send("Hold ${state.programHoldSet} has been set") + } + } + reset_state_motions() + def setAwayOrPresent = (setAwayOrPresentFlag)?:false + if ((setAwayOrPresent) && (state.motions != [])) { // the following logic is done only if motion sensors are provided as input parameters + boolean residentAway=residentsHaveBeenQuiet() + if ((currentSetClimate.toUpperCase()=='AWAY') && (!residentAway)) { + if ((state?.programHoldSet == 'Away') && (!currentProgName.toUpperCase().contains('AWAY'))) { + log.trace("check_if_hold_justified>it's not been quiet since ${state.programSetTimestamp},resumed ${currentProgName} program") + ecobee.resumeThisTstat() + send("resumed ${currentProgName} program, motion detected") + reset_state_program_values() + check_if_hold_needed() // check if another type of hold is now needed (ex. 'Home' hold or more heat because of outside temp ) + return // no more adjustments + } + else if (state?.programHoldSet != 'Home') { /* Climate was changed since the last climate set, just reset state program values */ + reset_state_program_values() + } + } else if ((currentSetClimate.toUpperCase()=='AWAY') && (residentAway)) { + if ((state?.programHoldSet == 'Away') && (currentProgName.toUpperCase().contains('AWAY'))) { + ecobee.resumeThisTstat() + reset_state_program_values() + if (detailedNotif) { + send("'Away' hold no longer needed, resumed ${currentProgName} program ") + } + } else if (state?.programHoldSet == 'Away') { + log.trace("check_if_hold_justified>quiet since ${state.programSetTimestamp}, current program= ${currentProgName},'Away' hold justified") + send("quiet since ${state.programSetTimestamp}, current program= ${currentProgName}, 'Away' hold justified") + ecobee.away() + return // hold justified, no more adjustments + } + } + if ((currentSetClimate.toUpperCase()=='HOME') && (residentAway)) { + if ((state?.programHoldSet == 'Home') && (currentProgName.toUpperCase().contains('AWAY'))) { + log.trace("check_if_hold_justified>it's been quiet since ${state.programSetTimestamp},resume program...") + ecobee.resumeThisTstat() + send("it's been quiet since ${state.programSetTimestamp}, resumed ${currentProgName} program") + reset_state_program_values() + check_if_hold_needed() // check if another type of hold is now needed (ex. 'Away' hold or more heat b/c of low outdoor temp ) + return // no more adjustments + } else if (state?.programHoldSet != 'Away') { /* Climate was changed since the last climate set, just reset state program values */ + reset_state_program_values() + } + } else if ((currentSetClimate.toUpperCase()=='HOME') && (!residentAway)) { + if ((state?.programHoldSet == 'Home') && (!currentProgName.toUpperCase().contains('AWAY'))) { + ecobee.resumeThisTstat() + reset_state_program_values() + if (detailedNotif) { + send("'Away' hold no longer needed,resumed ${currentProgName} program") + } + check_if_hold_needed() // check if another type of hold is now needed (ex. more heat b/c of low outdoor temp ) + return + } else if (state?.programHoldSet == 'Home') { + log.trace("not quiet since ${state.programSetTimestamp}, current program= ${currentProgName}, 'Home' hold justified") + if (detailedNotif) { + send("not quiet since ${state.programSetTimestamp}, current program= ${currentProgName}, 'Home' hold justified") + } + ecobee.present() + + return // hold justified, no more adjustments + } + } + } // end if motions + + if (ecobeeMode == 'cool') { + if (outdoorSensor) { + Integer outdoorHumidity = outdoorSensor.currentHumidity + float outdoorTemp = outdoorSensor.currentTemperature.toFloat() + + if (detailedNotif) { + log.trace "check_if_hold_justified> outdoorTemp = $outdoorTemp°" + log.trace "check_if_hold_justified> moreCoolThreshold = $more_cool_threshold°" + log.trace "check_if_hold_justified> lessCoolThreshold = $less_cool_threshold°" + log.trace("check_if_hold_justified>evaluate: moreCoolThreshold=${more_cool_threshold} vs. outdoorTemp ${outdoorTemp}°") + log.trace( + "check_if_hold_justified>evaluate: moreCoolThresholdHumidity= ${humidity_threshold}% vs. outdoorHumidity ${outdoorHumidity}%") + log.trace("check_if_hold_justified>evaluate: lessCoolThreshold= ${less_cool_threshold} vs.outdoorTemp ${outdoorTemp}°") + log.trace( + "check_if_hold_justified>evaluate: programCoolTemp= ${programCoolTemp}° vs.avgIndoorTemp= ${avg_indoor_temp}°") + send("eval: moreCoolThreshold ${more_cool_threshold}° vs.outdoorTemp ${outdoorTemp}°") + send("eval: lessCoolThreshold ${less_cool_threshold}° vs.outdoorTemp ${outdoorTemp}°") + send("eval: moreCoolThresholdHumidity ${humidity_threshold}% vs. outdoorHumidity ${outdoorHumidity}%") + send("eval: programCoolTemp= ${programCoolTemp}° vs. avgIndoorTemp= ${avg_indoor_temp}°") + } + if ((outdoorTemp > less_cool_threshold) && (outdoorTemp < more_cool_threshold) && + (outdoorHumidity < humidity_threshold)) { + send("resuming program, ${less_cool_threshold}° < outdoorTemp <${more_cool_threshold}°") + ecobee.resumeThisTstat() + } else { + if (detailedNotif) { + send("Hold justified, cooling setPoint=${coolTemp}°") + } + float actual_temp_diff = (programCoolTemp - coolTemp).round(1).abs() + if (detailedNotif) { + send("eval: actual_temp_diff ${actual_temp_diff}° vs. Max temp diff ${max_temp_diff}°") + } + if ((actual_temp_diff > max_temp_diff) && (!state?.programHoldSet)) { + if (detailedNotif) { + send("Hold differential too big (${actual_temp_diff}), needs adjustment") + } + check_if_hold_needed() // call it to adjust cool temp + } + } + } /* if outdoorSensor */ + if ((state.tempSensors != []) && (avg_indoor_temp > coolTemp)) { + send("Hold justified, avgIndoorTemp ($avg_indoor_temp°) > coolingSetpoint (${coolTemp}°)") + return + } + + } else if (ecobeeMode in ['heat', 'emergency heat']) { + if (outdoorSensor) { + Integer outdoorHumidity = outdoorSensor.currentHumidity + float outdoorTemp = outdoorSensor.currentTemperature.toFloat() + if (detailedNotif) { + log.trace "check_if_hold_justified> outdoorTemp = $outdoorTemp°" + log.trace "check_if_hold_justified> moreHeatThreshold = $more_heat_threshold°" + log.trace "check_if_hold_justified> lessHeatThreshold = $less_heat_threshold°" + log.trace("eval: moreHeatingThreshold ${more_heat_threshold}° vs.outdoorTemp ${outdoorTemp}°") + log.trace( + "check_if_hold_justified>evaluate: moreHeatingThresholdHum= ${humidity_threshold}% vs. outdoorHum ${outdoorHumidity}%") + log.trace("eval:lessHeatThreshold=${less_heat_threshold}° vs.outdoorTemp ${outdoorTemp}°") + log.trace( + "check_if_hold_justified>evaluate: programHeatTemp= ${programHeatTemp}° vs.avgIndoorTemp= ${avg_indoor_temp}°") + send("eval: moreHeatThreshold ${more_heat_threshold}° vs.outdoorTemp ${outdoorTemp}°") + send("eval: lessHeatThreshold ${less_heat_threshold}° vs.outdoorTemp ${outdoorTemp}°") + send("eval: moreHeatThresholdHum ${humidity_threshold}% vs. outdoorHum ${outdoorHumidity}%") + send("eval: programHeatTemp= ${programHeatTemp}° vs. avgIndoorTemp= ${avg_indoor_temp}°") + } + if ((outdoorTemp > more_heat_threshold) && (outdoorTemp < less_heat_threshold) && + (outdoorHumidity < humidity_threshold)) { + send("resuming program, ${less_heat_threshold}° < outdoorTemp > ${more_heat_threshold}°") + ecobee.resumeThisTstat() + } else { + if (detailedNotif) { + send("Hold justified, heating setPoint=${heatTemp}°") + } + float actual_temp_diff = (heatTemp - programHeatTemp).round(1).abs() + if (detailedNotif) { + send("eval: actualTempDiff ${actual_temp_diff}° vs. Max temp Diff ${max_temp_diff}°") + } + if ((actual_temp_diff > max_temp_diff) && (!state?.programHoldSet)) { + if (detailedNotif) { + send("Hold differential too big ${actual_temp_diff}, needs adjustment") + } + check_if_hold_needed() // call it to adjust heat temp + } + } + } /* end if outdoorSensor*/ + + if ((state.tempSensors != []) && (avg_indoor_temp < heatTemp)) { + send("Hold justified, avgIndoorTemp ($avg_indoor_temp°) < heatingSetpoint (${heatTemp}°)") + return + } + } /* end if heat mode */ + log.debug "End of Fcn check_if_hold_justified" +} + + +private send(msg, askAlexa=false) { +int MAX_EXCEPTION_MSG_SEND=5 + + // will not send exception msg when the maximum number of send notifications has been reached + if ((msg.contains("exception")) || (msg.contains("error"))) { + state?.sendExceptionCount=state?.sendExceptionCount+1 + if (detailedNotif) { + log.debug "checking sendExceptionCount=${state?.sendExceptionCount} vs. max=${MAX_EXCEPTION_MSG_SEND}" + } + if (state?.sendExceptionCount >= MAX_EXCEPTION_MSG_SEND) { + log.debug "send>reached $MAX_EXCEPTION_MSG_SEND exceptions, exiting" + return + } + } + def message = "${get_APP_NAME()}>${msg}" + + if (sendPushMessage != "No") { + if (location.contactBookEnabled && recipients) { + log.debug "contact book enabled" + sendNotificationToContacts(message, recipients) + } else { + sendPush(message) + } + } + if (askAlexa) { + sendLocationEvent(name: "AskAlexaMsgQueue", value: "${get_APP_NAME()}", isStateChange: true, descriptionText: msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, message) + } +} + + +// catchall +def event(evt) { + log.debug "value: $evt.value, event: $evt, settings: $settings, handlerName: ${evt.handlerName}" +} + +def cToF(temp) { + return (temp * 1.8 + 32) +} + +def fToC(temp) { + return (temp - 32) / 1.8 +} + +def getImagePath() { + return "http://raw.githubusercontent.com/yracine/device-type.myecobee/master/icons/" +} + +def get_APP_NAME() { + return "MonitorAndSetEcobeeTemp" +} diff --git a/third-party/NotifyIfLeftUnlocked.groovy b/third-party/NotifyIfLeftUnlocked.groovy new file mode 100755 index 0000000..86096f4 --- /dev/null +++ b/third-party/NotifyIfLeftUnlocked.groovy @@ -0,0 +1,110 @@ +/** + * Notify If Left Unlocked + * + * Copyright 2014 George Sudarkoff + * + * 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: "Notify If Left Unlocked", + namespace: "com.sudarkoff", + author: "George Sudarkoff", + description: "Send a push or SMS notification (and lock, if it's closed) if a door is left unlocked for a period of time.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") + + +preferences { + section("If this lock...") { + input "aLock", "capability.lock", multiple: false, required: true + input "openSensor", "capability.contactSensor", title: "Open/close sensor (optional)", multiple: false, required: false + } + section("Left unlocked for...") { + input "duration", "number", title: "How many minutes?", required: true + } + section("Notify me...") { + input "pushNotification", "bool", title: "Push notification" + input "phoneNumber", "phone", title: "Phone number (optional)", required: false + input "lockIfClosed", "bool", title: "Lock the door if it's closed?" + } +} + +def installed() +{ + initialize() +} + +def updated() +{ + unsubscribe() + initialize() +} + +def initialize() +{ + log.trace "Initializing with: ${settings}" + subscribe(aLock, "lock", lockHandler) +} + +def lockHandler(evt) +{ + log.trace "${evt.name} is ${evt.value}." + if (evt.value == "locked") { + log.debug "Canceling lock check because the door is locked..." + unschedule(notifyUnlocked) + } + else { + log.debug "Starting the countdown for ${duration} minutes..." + state.retries = 0 + runIn(duration * 60, notifyUnlocked) + } +} + +def notifyUnlocked() +{ + // if no open/close sensor specified, assume the door is closed + def open = openSensor?.latestValue("contact") ?: "closed" + + def message = "${aLock.displayName} is left unlocked and ${open} for more than ${duration} minutes." + log.trace "Sending the notification: ${message}." + sendMessage(message) + + if (lockIfClosed) { + if (open == "closed") { + log.trace "And locking the door." + sendMessage("Locking the ${aLock.displayName} as prescribed.") + aLock.lock() + } + else { + if (state.retries++ < 3) { + log.trace "Door is open, can't lock. Rescheduling the check." + sendMessage("Can't lock the ${aLock.displayName} because the door is open. Will try again in ${duration} minutes.") + runIn(duration * 60, notifyUnlocked) + } + else { + log.trace "The door is still open after ${state.retries} retries, giving up." + sendMessage("Unable to lock the ${aLock.displayName} after ${state.retries} retries, giving up.") + } + } + } +} + +def sendMessage(msg) { + if (pushNotification) { + sendPush(msg) + } + if (phoneNumber) { + sendSMS(phoneNumber, msg) + } +} + diff --git a/third-party/Orbit Water Timer.groovy b/third-party/Orbit Water Timer.groovy new file mode 100755 index 0000000..9aba01a --- /dev/null +++ b/third-party/Orbit Water Timer.groovy @@ -0,0 +1,187 @@ +metadata { + // Automatically generated. Make future change here. + definition (name: "Orbit Water Timer", namespace: "mitchpond", author: "Mitch Pond") { + capability "Actuator" + capability "Switch" + capability "Sensor" + capability "Configuration" + capability "Refresh" + + command "test" + command "identify" + command "getClusters" + + fingerprint endpointId: "01", profileId: "0104", inClusters: "0000,0001,0003,0020,0006,0201", outClusters: "000A,0019" + } + + // UI tile definitions + tiles { + standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { + state "off", label: '${name}', action: "switch.on", icon: "st.switches.switch.off", nextState: "turningOn", backgroundColor: "#ffffff" + state "turningOn", label: '${name}', action: "switch.off", icon: "st.switches.switch.on", nextState: "turningOff", backgroundColor: "#79b821" + state "turningOff", label: '${name}', action: "switch.on", icon: "st.switches.switch.off", nextState: "turningOn", backgroundColor: "#ffffff" + state "on", label: '${name}', action: "switch.off", icon: "st.switches.switch.on", nextState: "turningOff", backgroundColor: "#79b821" + } + valueTile("battery", "device.battery", decoration: "flat") { + state "battery", label:'${currentValue}% battery', unit:"" + } + standardTile("configure", "device.configure", decoration: "flat") { + state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure" + } + standardTile("refresh", "command.refresh", decoration: "flat") { + state "default", label: '', action: 'refresh.refresh', icon: 'st.secondary.refresh' + } + + main "switch" + details (["switch","battery","refresh","configure"]) + } +} + +// Parse incoming device messages to generate events +def parse(String description) { + log.debug("Raw: $description") + + if (description?.startsWith("catchall:")) { + def report = zigbee.parse(description) + switch(report.clusterId) { + case 0x0020: + log.debug "Received Poll Control packet" + break + case 0x0006: + return (report.data).equals([0x01,0x00])? createEvent(name: 'switch', value: 'on') : + ((report.data).equals([0x00, 0x00, 0x00, 0x10, 0x01]) && (report.command == 0x01))? createEvent(name: 'switch', value: 'on') : + (report.data).equals([0x00,0x00])? createEvent(name: 'switch', value: 'off') : + ((report.data).equals([0x00, 0x00, 0x00, 0x10, 0x00]) && (report.command == 0x01))? createEvent(name: 'switch', value: 'off') : null + break + } + } + else if (description?.startsWith('read attr -')) { + return parseReportAttributeMessage(description) + } + //log.debug(report) + + +} + +//Perform initial device setup and binding +def configure(){ + log.debug("Running configure for Orbit timer...") + + [ + "zcl global send-me-a-report 0x06 0x00 0x10 0 3600 {}", "delay 200", + "send 0x${device.deviceNetworkId} 1 1", "delay 500", + + //"st cmd 0x${device.deviceNetworkId} 1 0x20 0x02 {14}", "delay 500", //set Long Poll Interval + //"st wattr 0x${device.deviceNetworkId} 1 0x20 0x00 0x23 {240}", "delay 500", //attempt to set check-in interval + + "zdo bind 0x${device.deviceNetworkId} 1 1 6 {${device.zigbeeId}} {}", "delay 500", //device reports back success + "zdo bind 0x${device.deviceNetworkId} 1 1 0 {${device.zigbeeId}} {}", "delay 500", + "zdo bind 0x${device.deviceNetworkId} 1 1 1 {${device.zigbeeId}} {}", "delay 500" + ] + refresh() +} + +/* successful attribute reads +//battery voltage +"st rattr 0x${device.deviceNetworkId} 1 0x01 0x0020", "delay 500", + +//cluster 20 poll control check-in is being sent by device. Should be able to alter polling duration and start fast poll + + + +*/ + +def test(){ + [ + //"raw 0x000A {D8CE3955 e2 00 00 00 01}","delay 500", + //"send 0x${device.deviceNetworkId} 1 1", + //"st rattr 0x${device.deviceNetworkId} 1 0x01 0x0020", "delay 500", //works! + //"st rattr 0x${device.deviceNetworkId} 1 0x000A 0x0005", "delay 500", //unsupp attrib + //"st wattr 0x${device.deviceNetworkId} 1 0x000A 0x0000 0xe2 {D8CE3955}","delay 500", //no response + //"zcl global discover 0x0A 0x00 20","delay 200", //did nothing + //"send 0x${device.deviceNetworkId} 1 1", "delay 1000", + "st wattr 0x${device.deviceNetworkId} 1 0x20 0x00 0x23 {F0}", "delay 500" + ] + //getClusters() + //def report = zigbee.parse('catchall: 0104 0020 01 01 0140 00 20C4 00 00 0000 04 01') + //log.debug report + //log.debug "Command is: ${report.command}" + //log.debug((report.command) == 0x01) +} +def identify() {"st cmd 0x${device.deviceNetworkId} 1 3 0 {0A 00}"} +def getClusters() { "zdo active 0x${device.deviceNetworkId}" } + +def refresh() { + delayBetween([ + "st rattr 0x${device.deviceNetworkId} 1 0x01 0x0020", + "st rattr 0x${device.deviceNetworkId} 1 0x06 0x0000" + ],750) +} + +// Commands to device +def on() { + 'zcl on-off on' +} + +def off() { + 'zcl on-off off' +} + +private parseReportAttributeMessage(String description) { + Map descMap = (description - "read attr - ").split(",").inject([:]) { map, param -> + def nameAndValue = param.split(":") + map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] + } + //log.debug "Desc Map: $descMap" + + def results = [] + + if (descMap.cluster == "0001" && descMap.attrId == "0020") { + log.debug "Received battery level report" + results = createEvent(getBatteryResult(Integer.parseInt(descMap.value, 16))) + } + + return results +} + +//Converts the battery level response into a percentage to display in ST +//and creates appropriate message for given level + +private getBatteryResult(rawValue) { + def linkText = getLinkText(device) + + def result = [name: 'battery'] + + def volts = rawValue / 10 + def descriptionText + if (volts > 3.5) { + result.descriptionText = "${linkText} battery has too much power (${volts} volts)." + } + else { + def minVolts = 2.1 + def maxVolts = 3.0 + def pct = (volts - minVolts) / (maxVolts - minVolts) + result.value = Math.min(100, (int) pct * 100) + result.descriptionText = "${linkText} battery was ${result.value}%" + } + + return result +} + +private String swapEndianHex(String hex) { + reverseArray(hex.decodeHex()).encodeHex() +} + +private byte[] reverseArray(byte[] array) { + int i = 0; + int j = array.length - 1; + byte tmp; + while (j > i) { + tmp = array[j]; + array[j] = array[i]; + array[i] = tmp; + j--; + i++; + } + return array +} diff --git a/third-party/QuirkyTripper.groovy b/third-party/QuirkyTripper.groovy new file mode 100755 index 0000000..b11c153 --- /dev/null +++ b/third-party/QuirkyTripper.groovy @@ -0,0 +1,253 @@ +/** + * Quirky/Wink Tripper Contact Sensor + * + * Copyright 2015 Mitch Pond, SmartThings + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ + +metadata { + definition (name: "Quirky/Wink Tripper", namespace: "mitchpond", author: "Mitch Pond") { + + capability "Contact Sensor" + capability "Battery" + capability "Configuration" + capability "Sensor" + + attribute "tamper", "string" + + command "configure" + command "resetTamper" + + fingerprint endpointId: "01", profileId: "0104", deviceId: "0402", inClusters: "0000,0001,0003,0500,0020,0B05", outClusters: "0003,0019" + } + + // simulator metadata + simulator {} + + // UI tile definitions + tiles { + standardTile("contact", "device.contact", width: 2, height: 2, canChangeIcon: true) { + state("open", label:'${name}', icon:"st.contact.contact.open", backgroundColor:"#ffa81e") + state("closed", label:'${name}', icon:"st.contact.contact.closed", backgroundColor:"#79b821") + } + + valueTile("battery", "device.battery", decoration: "flat") { + state "battery", label:'${currentValue}% battery', unit:"" + } + + standardTile("tamper", "device.tamper") { + state "OK", label: "Tamper OK", icon: "st.security.alarm.on", backgroundColor:"#79b821", decoration: "flat" + state "tampered", label: "Tampered", action: "resetTamper", icon: "st.security.alarm.off", backgroundColor:"#ffa81e", decoration: "flat" + } + + main ("contact") + details(["contact","battery","tamper"]) + } +} + +// Parse incoming device messages to generate events +def parse(String description) { + //log.debug "description: $description" + + def results = [] + if (description?.startsWith('catchall:')) { + results = parseCatchAllMessage(description) + } + else if (description?.startsWith('read attr -')) { + results = parseReportAttributeMessage(description) + } + else if (description?.startsWith('zone status')) { + results = parseIasMessage(description) + } + + log.debug "Parse returned $results" + + if (description?.startsWith('enroll request')) { + List cmds = enrollResponse() + log.debug "enroll response: ${cmds}" + results = cmds?.collect { new physicalgraph.device.HubAction(it) } + } + return results +} + +//Initializes device and sets up reporting +def configure() { + String zigbeeId = swapEndianHex(device.hub.zigbeeId) + log.debug "Confuguring Reporting, IAS CIE, and Bindings." + + def cmd = [ + "zcl global write 0x500 0x10 0xf0 {${zigbeeId}}", "delay 200", + "send 0x${device.deviceNetworkId} 1 1", "delay 1500", + + "zcl global send-me-a-report 0x500 0x0012 0x19 0 0xFF {}", "delay 200", //get notified on tamper + "send 0x${device.deviceNetworkId} 1 1", "delay 1500", + + "zcl global send-me-a-report 1 0x20 0x20 5 3600 {}", "delay 200", //battery report request + "send 0x${device.deviceNetworkId} 1 1", "delay 1500", + + "zdo bind 0x${device.deviceNetworkId} 1 1 0x500 {${device.zigbeeId}} {}", "delay 500", + "zdo bind 0x${device.deviceNetworkId} 1 1 1 {${device.zigbeeId}} {}", "delay 500", + "st rattr 0x${device.deviceNetworkId} 1 1 0x20" + ] + cmd +} + +//Sends IAS Zone Enroll response +def enrollResponse() { + log.debug "Sending enroll response" + [ + "raw 0x500 {01 23 00 00 00}", "delay 200", + "send 0x${device.deviceNetworkId} 1 1" + ] +} + +private Map parseCatchAllMessage(String description) { + def results = [:] + def cluster = zigbee.parse(description) + if (shouldProcessMessage(cluster)) { + switch(cluster.clusterId) { + case 0x0001: + log.debug "Received a catchall message for battery status. This should not happen." + results << createEvent(getBatteryResult(cluster.data.last())) + break + } + } + + return results +} + +private boolean shouldProcessMessage(cluster) { + // 0x0B is default response indicating message got through + // 0x07 is bind message + boolean ignoredMessage = cluster.profileId != 0x0104 || + cluster.command == 0x0B || + cluster.command == 0x07 || + (cluster.data.size() > 0 && cluster.data.first() == 0x3e) + return !ignoredMessage +} + +private parseReportAttributeMessage(String description) { + Map descMap = (description - "read attr - ").split(",").inject([:]) { map, param -> + def nameAndValue = param.split(":") + map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] + } + //log.debug "Desc Map: $descMap" + + def results = [] + + if (descMap.cluster == "0001" && descMap.attrId == "0020") { + log.debug "Received battery level report" + results = createEvent(getBatteryResult(Integer.parseInt(descMap.value, 16))) + } + + return results +} + +private parseIasMessage(String description) { + List parsedMsg = description.split(' ') + String msgCode = parsedMsg[2] + int status = Integer.decode(msgCode) + def linkText = getLinkText(device) + + def results = [] + //log.debug(description) + if (status & 0b00000001) {results << createEvent(getContactResult('open'))} + else if (~status & 0b00000001) results << createEvent(getContactResult('closed')) + + if (status & 0b00000100) { + //log.debug "Tampered" + results << createEvent([name: "tamper", value:"tampered"]) + } + else if (~status & 0b00000100) { + //don't reset the status here as we want to force a manual reset + //log.debug "Not tampered" + //results << createEvent([name: "tamper", value:"OK"]) + } + + if (status & 0b00001000) { + //battery reporting seems unreliable with these devices. However, they do report when low. + //Just in case the battery level reporting has stopped working, we'll at least catch the low battery warning. + // + //** Commented this out as this is currently conflicting with the battery level report **/ + //log.debug "${linkText} reports low battery!" + //results << createEvent([name: "battery", value: 10]) + } + else if (~status & 0b00001000) { + //log.debug "${linkText} battery OK" + } + //log.debug results + return results +} + +//Converts the battery level response into a percentage to display in ST +//and creates appropriate message for given level +//**real-world testing with this device shows that 2.4v is about as low as it can go **/ + +private getBatteryResult(rawValue) { + def linkText = getLinkText(device) + + def result = [name: 'battery'] + + def volts = rawValue / 10 + def descriptionText + if (volts > 3.5) { + result.descriptionText = "${linkText} battery has too much power (${volts} volts)." + } + else { + def minVolts = 2.4 + def maxVolts = 3.0 + def pct = (volts - minVolts) / (maxVolts - minVolts) + result.value = Math.min(100, (int) pct * 100) + result.descriptionText = "${linkText} battery was ${result.value}%" + } + + return result +} + + +private Map getContactResult(value) { + def linkText = getLinkText(device) + def descriptionText = "${linkText} was ${value == 'open' ? 'opened' : 'closed'}" + return [ + name: 'contact', + value: value, + descriptionText: descriptionText + ] +} + +//Resets the tamper switch state +private resetTamper(){ + log.debug "Tamper alarm reset." + sendEvent([name: "tamper", value:"OK"]) +} + +private hex(value) { + new BigInteger(Math.round(value).toString()).toString(16) +} + +private String swapEndianHex(String hex) { + reverseArray(hex.decodeHex()).encodeHex() +} + +private byte[] reverseArray(byte[] array) { + int i = 0; + int j = array.length - 1; + byte tmp; + while (j > i) { + tmp = array[j]; + array[j] = array[i]; + array[i] = tmp; + j--; + i++; + } + return array +} diff --git a/third-party/RemindToCloseDoggyDoor.groovy b/third-party/RemindToCloseDoggyDoor.groovy new file mode 100755 index 0000000..7426da5 --- /dev/null +++ b/third-party/RemindToCloseDoggyDoor.groovy @@ -0,0 +1,73 @@ +/** + * Remind to close doggy door + * + * Copyright 2014 George Sudarkoff + * + * 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: "Remind to close doggy door", + namespace: "com.sudarkoff", + author: "George Sudarkoff", + description: "Check that the doggy door is closed after the specified time and send a message if it's not.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + + +preferences { + section ("When this door") { + input "door", "capability.contactSensor", title: "Which door?", multiple: false, required: true + } + section ("Still open past") { + input "timeOfDay", "time", title: "What time?", required: true + } + section (title: "Notify") { + input "sendPushMessage", "bool", title: "Send a push notification?" + input "phone", "phone", title: "Send a Text Message?", required: false + } + +} + +def installed() { + initialize() +} + +def updated() { + unschedule() + initialize() +} + +def initialize() { + schedule(timeToday(timeOfDay, location.timeZone), "checkDoor") +} + +def checkDoor() { + if (door.latestValue("contact") == "open") { + log.debug "${door} is open, sending notification." + def message = "Remember to close the ${door}!" + send(message) + } +} + +private send(msg) { + if (sendPushMessage != "No") { + sendPush(msg) + } + + if (phone) { + sendSms(phone, msg) + } + + log.debug msg +} + diff --git a/third-party/Securify-KeyFob.groovy b/third-party/Securify-KeyFob.groovy new file mode 100755 index 0000000..8ee2ebf --- /dev/null +++ b/third-party/Securify-KeyFob.groovy @@ -0,0 +1,125 @@ +/** + * Securify Key Fob + * + * Author: Kevin Tierney based on code from Gilbert Chan; numButtons added by obycode + * Date Created: 2014-12-18 + * Last Updated: 2015-05-13 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ + +metadata { + definition (name: "Securifi Key Fob", namespace: "tierneykev", author: "Kevin Tierney") { + + capability "Configuration" + capability "Refresh" + capability "Button" + + attribute "button2","ENUM",["released","pressed"] + attribute "button3","ENUM",["released","pressed"] + attribute "numButtons", "STRING" + + fingerprint profileId: "0104", deviceId: "0401", inClusters: "0000,0003,0500", outClusters: "0003,0501" + } + + tiles { + + standardTile("button1", "device.button", width: 1, height: 1) { + state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") + state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") + } + + standardTile("button2", "device.button2", width: 1, height: 1) { + state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") + state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") + } + + standardTile("button3", "device.button3", width: 1, height: 1) { + state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") + state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") + } + + main (["button3", "button2", "button1"]) + details (["button3", "button2", "button1"]) + } +} + + + + +def parse(String description) { + + if (description?.startsWith('enroll request')) { + + List cmds = enrollResponse() + log.debug "enroll response: ${cmds}" + def result = cmds?.collect { new physicalgraph.device.HubAction(it) } + return result + } + else if (description?.startsWith('catchall:')) { + def msg = zigbee.parse(description) + log.debug msg + buttonPush(msg.data[0]) + } + else { + log.debug "parse description: $description" + } + +} + +def buttonPush(button){ + def name = null + if (button == 0) { + name = "1" + def currentST = device.currentState("button")?.value + log.debug "Unlock button Pushed" + } + else if (button == 2) { + name = "2" + def currentST = device.currentState("button2")?.value + log.debug "Home button pushed" + } + else if (button == 3) { + name = "3" + def currentST = device.currentState("button3")?.value + log.debug "Lock Button pushed" + } + + def result = createEvent(name: "button", value: "pushed", data: [buttonNumber: name], descriptionText: "$device.displayName button $name was pushed", isStateChange: true) + log.debug "Parse returned ${result?.descriptionText}" + return result +} + + +def enrollResponse() { + log.debug "Sending enroll response" + [ + "raw 0x500 {01 23 00 00 00}", "delay 200", + "send 0x${device.deviceNetworkId} 0x08 1" + ] +} + + + +def configure(){ + log.debug "Config Called" + + // Set the number of buttons to 3 + sendEvent(name: "numButtons", value: "3", displayed: false) + + def configCmds = [ + "zcl global write 0x500 0x10 0xf0 {${device.zigbeeId}}", "delay 200", + "send 0x${device.deviceNetworkId} 0x08 1", "delay 1500", + "zdo bind 0x${device.deviceNetworkId} 0x08 0x01 0x0501 {${device.zigbeeId}} {}", "delay 500", + "zdo bind 0x${device.deviceNetworkId} 0x08 1 1 {${device.zigbeeId}} {}" + ] + return configCmds +} diff --git a/third-party/SmartPresence.groovy b/third-party/SmartPresence.groovy new file mode 100755 index 0000000..6a675e4 --- /dev/null +++ b/third-party/SmartPresence.groovy @@ -0,0 +1,303 @@ +/** Name: SmartPresence + * Author: George Sudarkoff + * Change mode based on presense and current mode. + */ + +definition( + name: "SmartPresence", + namespace: "sudarkoff.com", + author: "George Sudarkoff", + description: "Change mode (by invoking various \"Hello, Home\" phrases) based on presense and current mode.", + category: "Mode Magic", + iconUrl: "https://raw.githubusercontent.com/sudarkoff/smarttings/master/SmartPresence.png", + iconX2Url: "https://raw.githubusercontent.com/sudarkoff/smarttings/master/SmartPresence@2x.png" +) + +preferences { + page(name: "selectPhrases") + + page( name:"Settings", title:"Settings", uninstall:true, install:true ) { + section("False alarm threshold (defaults to 10 min)") { + input "falseAlarmThreshold", "decimal", title: "Number of minutes", required: false + } + + section("Zip code (for sunrise/sunset)") { + input "zip", "decimal", required: true + } + + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes","No"]], required:false + } + + section(title: "More options", hidden: hideOptionsSection(), hideable: true) { + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + } +} + +def selectPhrases() { + def configured = (settings.awayDay && settings.awayNight && settings.homeDay && settings.awayDay) + dynamicPage(name: "selectPhrases", title: "Configure", nextPage:"Settings", uninstall: true) { + section("All of these sensors") { + input "people", "capability.presenceSensor", title: "Monitor All of These Presences", required: true, multiple: true, refreshAfterSelection:true + } + + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + section("Run This Phrase When...") { + log.trace phrases + input "sunriseAway", "enum", title: "It's Sunrise and Everybody's Away", required: true, options: phrases, refreshAfterSelection:true + input "sunsetAway", "enum", title: "It's Sunset and Everybody's Away", required: true, options: phrases, refreshAfterSelection:true + input "sunriseHome", "enum", title: "It's Sunrise and Somebody's Home", required: true, options: phrases, refreshAfterSelection:true + input "sunsetHome", "enum", title: "It's Sunset and Somebody's Home", required: true, options: phrases, refreshAfterSelection:true + input "awayDay", "enum", title: "Last Person Leaves and It's Daytime", required: true, options: phrases, refreshAfterSelection:true + input "awayNight", "enum", title: "Last Person Leaves and It's Nighttime", required: true, options: phrases, refreshAfterSelection:true + input "homeDay", "enum", title: "Somebody's Back and It's Daytime", required: true, options: phrases, refreshAfterSelection:true + input "homeNight", "enum", title: "Somebody's Back and It's Nighttime", required: true, options: phrases, refreshAfterSelection:true + } + } + } +} + +def installed() { + init() + initialize() + subscribe(app) +} + +def updated() { + unsubscribe() + initialize() + + init() +} + +def init() { + subscribe(people, "presence", presence) + + checkSun(); +} + +def uninstalled() { + unsubscribe() +} + +def initialize() { + schedule("0 0/5 * 1/1 * ? *", checkSun) +} + +def checkSun() { + // TODO: Use location information if zip is not provided + def zip = settings.zip as String + def sunInfo = getSunriseAndSunset(zipCode: zip) + def current = now() + + if(sunInfo.sunrise.time > current || + sunInfo.sunset.time < current) { + state.sunMode = "sunset" + } + else { + state.sunMode = "sunrise" + } + + log.info("Sunset: ${sunInfo.sunset.time}") + log.info("Sunrise: ${sunInfo.sunrise.time}") + log.info("Current: ${current}") + log.info("sunMode: ${state.sunMode}") + + if(current < sunInfo.sunrise.time) { + runIn(((sunInfo.sunrise.time - current) / 1000).toInteger(), setSunrise) + } + + if(current < sunInfo.sunset.time) { + runIn(((sunInfo.sunset.time - current) / 1000).toInteger(), setSunset) + } +} + +def setSunrise() { + state.sunMode = "sunrise"; + changeSunMode(newMode) +} + +def setSunset() { + state.sunMode = "sunset"; + changeSunMode(newMode) +} + + +def changeSunMode(newMode) { + if(allOk) { + if(everyoneIsAway() && (state.sunMode = "sunrise")) { + log.info("Sunrise but nobody's home, switching to Away mode.") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + runIn(delay, "setAway") + } + + if(everyoneIsAway() && (state.sunMode = "sunset")) { + log.info("Sunset and nobody's home, switching to Away mode") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + runIn(delay, "setAway") + } + + else { + log.info("Somebody's home, switching to Home mode") + setHome() + } + } +} + +def presence(evt) { + if(allOk) { + if(evt.value == "not present") { + log.debug("Checking if everyone is away") + + if(everyoneIsAway()) { + log.info("Everybody's gone, running Away sequence.") + def delay = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold * 60 : 10 * 60 + runIn(delay, "setAway") + } + } else { + def lastTime = state[evt.deviceId] + if (lastTime == null || now() - lastTime >= 1 * 60000) { + log.info("Somebody's back, running Home sequence") + setHome() + } + state[evt.deviceId] = now() + } + } +} + +def setAway() { + if(everyoneIsAway()) { + if(state.sunMode == "sunset") { + def message = "SmartMode says \"${awayNight}\"." + log.info(message) + send(message) + location.helloHome.execute(settings.awayNight) + } else if(state.sunMode == "sunrise") { + def message = "SmartMode says \"${awayDay}\"." + log.info(message) + send(message) + location.helloHome.execute(settings.awayDay) + } else { + log.debug("Mode is the same, not evaluating.") + } + } else { + log.info("Somebody returned home before we switched to '${newAwayMode}'") + } +} + +def setHome() { + log.info("Setting Home Mode!") + if(anyoneIsHome()) { + if(state.sunMode == "sunset") { + def message = "SmartMode says \"${homeNight}\"." + log.info(message) + location.helloHome.execute(settings.homeNight) + send(message) + sendSms(phone1, message) + } + + if(state.sunMode == "sunrise"){ + def message = "SmartMode says \"${homeDay}\"." + log.info(message) + location.helloHome.execute(settings.homeDay) + send(message) + sendSms(phone1, message) + } + } +} + +private everyoneIsAway() { + def result = true + + if(people.findAll { it?.currentPresence == "present" }) { + result = false + } + + log.debug("everyoneIsAway: ${result}") + + return result +} + +private anyoneIsHome() { + def result = false + + if(people.findAll { it?.currentPresence == "present" }) { + result = true + } + + log.debug("anyoneIsHome: ${result}") + + return result +} + +private send(msg) { + if(sendPushMessage != "No") { + log.debug("Sending push message") + sendPush(msg) + } + + log.debug(msg) +} + + + +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/Los_Angeles")) + } + 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) +} + +private getTimeIntervalLabel() +{ + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} + +private hideOptionsSection() { + (starting || ending || days || modes) ? false : true +} diff --git a/third-party/SmartenIt-ZBWS3B.groovy b/third-party/SmartenIt-ZBWS3B.groovy new file mode 100755 index 0000000..c9c20a1 --- /dev/null +++ b/third-party/SmartenIt-ZBWS3B.groovy @@ -0,0 +1,128 @@ +/** + * 3 Button Remote Zigbee ZBWS3 + * + * 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. + * + * Original code by GilbertChan, modified by obycode to add "numButtons" + * Thanks to Seth Jansen @sjansen for original contributions + */ +metadata { + definition (name: "3 Button Remote (ZBWS3)", namespace: "thegilbertchan", author: "Gilbert Chan") { + capability "Button" + capability "Configuration" + + attribute "button2","ENUM",["released","pressed"] + attribute "button3","ENUM",["released","pressed"] + attribute "numButtons", "STRING" + + fingerprint endpointId: "03", profileId: "0104", deviceId: "0000", deviceVersion: "00", inClusters: "03 0000 0003 0007", outClusters: "01 0006" + + } + // Contributors + + // simulator metadata + simulator { + } + + // UI tile definitions + tiles { + + standardTile("button1", "device.button", width: 1, height: 1) { + state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") + state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") + } + + standardTile("button2", "device.button2", width: 1, height: 1) { + state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") + state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") + } + + standardTile("button3", "device.button3", width: 1, height: 1) { + state("released", label:'${name}', icon:"st.button.button.released", backgroundColor:"#ffa81e") + state("pressed", label:'${name}', icon:"st.button.button.pressed", backgroundColor:"#79b821") + } + + main (["button3", "button2", "button1"]) + details (["button3", "button2", "button1"]) + } +} + +// Parse incoming device messages to generate events +def parse(String description) { + log.debug "Parse description $description" + def name = null + def value = null + if (description?.startsWith("catchall: 0104 0006 01")) { + name = "1" + def currentST = device.currentState("button")?.value + log.debug "Button 1 pushed" + + } + else if (description?.startsWith("catchall: 0104 0006 02")) { + name = "2" + def currentST = device.currentState("button2")?.value + log.debug "Button 2 pushed" + + } + else if (description?.startsWith("catchall: 0104 0006 03")) { + name = "3" + def currentST = device.currentState("button3")?.value + log.debug "Button 3 pushed" + } + + def result = createEvent(name: "button", value: "pushed", data: [buttonNumber: name], descriptionText: "$device.displayName button $name was pushed", isStateChange: true) + log.debug "Parse returned ${result?.descriptionText}" + + + return result +} + + +def parseDescriptionAsMap(description) { + (description - "read attr - ").split(",").inject([:]) { map, param -> + def nameAndValue = param.split(":") + map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] + } +} + +private getFPoint(String FPointHex){ // Parsh out hex string from Value: 4089999a + Long i = Long.parseLong(FPointHex, 16) // Convert Hex String to Long + Float f = Float.intBitsToFloat(i.intValue()) // Convert IEEE 754 Single-Precison floating point + log.debug "converted floating point value: ${f}" + def result = f + + return result +} + + +// Commands to device + +def configure() { + // Set the number of buttons to 3 + updateState("numButtons", "3") + + log.debug "Binding SEP 0x01 DEP 0x01 Cluster 0x0006 On/Off cluster to hub" + def configCmds = [ + + "zdo bind 0x${device.deviceNetworkId} 0x01 0x01 0x0006 {${device.zigbeeId}} {}", "delay 500", + "zdo bind 0x${device.deviceNetworkId} 0x02 0x01 0x0006 {${device.zigbeeId}} {}", "delay 500", + "zdo bind 0x${device.deviceNetworkId} 0x03 0x01 0x0006 {${device.zigbeeId}} {}", "delay 1500", + ] + log.info "Sending ZigBee Bind commands to 3 Button Switch" + + return configCmds +} + +// Update State +// Store mode and settings +def updateState(String name, String value) { + state[name] = value + device.updateDataValue(name, value) +} diff --git a/third-party/Sonos.groovy b/third-party/Sonos.groovy new file mode 100755 index 0000000..7f7a00a --- /dev/null +++ b/third-party/Sonos.groovy @@ -0,0 +1,1036 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Sonos Player + * + * Author: SmartThings + * Edited by obycode to add Speech Synthesis capability + */ + +metadata { + definition (name: "Sonos Player", namespace: "smartthings", author: "SmartThings") { + capability "Actuator" + capability "Switch" + capability "Refresh" + capability "Sensor" + capability "Music Player" + capability "Speech Synthesis" + + attribute "model", "string" + attribute "trackUri", "string" + attribute "transportUri", "string" + attribute "trackNumber", "string" + + command "subscribe" + command "getVolume" + command "getCurrentMedia" + command "getCurrentStatus" + command "seek" + command "unsubscribe" + command "setLocalLevel", ["number"] + command "tileSetLevel", ["number"] + command "playTrackAtVolume", ["string","number"] + command "playTrackAndResume", ["string","number","number"] + command "playTextAndResume", ["string","number"] + command "playTrackAndRestore", ["string","number","number"] + command "playTextAndRestore", ["string","number"] + command "playSoundAndTrack", ["string","number","json_object","number"] + command "playTextAndResume", ["string","json_object","number"] + } + + // Main + standardTile("main", "device.status", width: 1, height: 1, canChangeIcon: true) { + state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" + state "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#79b821" + state "grouped", label:'Grouped', icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" + } + + // Row 1 + standardTile("nextTrack", "device.status", width: 1, height: 1, decoration: "flat") { + state "next", label:'', action:"music Player.nextTrack", icon:"st.sonos.next-btn", backgroundColor:"#ffffff" + } + standardTile("play", "device.status", width: 1, height: 1, decoration: "flat") { + state "default", label:'', action:"music Player.play", icon:"st.sonos.play-btn", nextState:"playing", backgroundColor:"#ffffff" + state "grouped", label:'', action:"music Player.play", icon:"st.sonos.play-btn", backgroundColor:"#ffffff" + } + standardTile("previousTrack", "device.status", width: 1, height: 1, decoration: "flat") { + state "previous", label:'', action:"music Player.previousTrack", icon:"st.sonos.previous-btn", backgroundColor:"#ffffff" + } + + // Row 2 + standardTile("status", "device.status", width: 1, height: 1, decoration: "flat", canChangeIcon: true) { + state "playing", label:'Playing', action:"music Player.pause", icon:"st.Electronics.electronics16", nextState:"paused", backgroundColor:"#ffffff" + state "stopped", label:'Stopped', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" + state "paused", label:'Paused', action:"music Player.play", icon:"st.Electronics.electronics16", nextState:"playing", backgroundColor:"#ffffff" + state "grouped", label:'Grouped', action:"", icon:"st.Electronics.electronics16", backgroundColor:"#ffffff" + } + standardTile("pause", "device.status", width: 1, height: 1, decoration: "flat") { + state "default", label:'', action:"music Player.pause", icon:"st.sonos.pause-btn", nextState:"paused", backgroundColor:"#ffffff" + state "grouped", label:'', action:"music Player.pause", icon:"st.sonos.pause-btn", backgroundColor:"#ffffff" + } + standardTile("mute", "device.mute", inactiveLabel: false, decoration: "flat") { + state "unmuted", label:"", action:"music Player.mute", icon:"st.custom.sonos.unmuted", backgroundColor:"#ffffff", nextState:"muted" + state "muted", label:"", action:"music Player.unmute", icon:"st.custom.sonos.muted", backgroundColor:"#ffffff", nextState:"unmuted" + } + + // Row 3 + controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 3, inactiveLabel: false) { + state "level", action:"tileSetLevel", backgroundColor:"#ffffff" + } + + // Row 4 + valueTile("currentSong", "device.trackDescription", inactiveLabel: true, height:1, width:3, decoration: "flat") { + state "default", label:'${currentValue}', backgroundColor:"#ffffff" + } + + // Row 5 + standardTile("refresh", "device.status", inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh", backgroundColor:"#ffffff" + } + standardTile("model", "device.model", width: 1, height: 1, decoration: "flat") { + state "Sonos PLAY:1", label:'', action:"", icon:"st.sonos.sonos-play1", backgroundColor:"#ffffff" + state "Sonos PLAY:2", label:'', action:"", icon:"st.sonos.sonos-play2", backgroundColor:"#ffffff" + state "Sonos PLAY:3", label:'', action:"", icon:"st.sonos.sonos-play3", backgroundColor:"#ffffff" + state "Sonos PLAY:5", label:'', action:"", icon:"st.sonos.sonos-play5", backgroundColor:"#ffffff" + state "Sonos CONNECT:AMP", label:'', action:"", icon:"st.sonos.sonos-connect-amp", backgroundColor:"#ffffff" + state "Sonos CONNECT", label:'', action:"", icon:"st.sonos.sonos-connect", backgroundColor:"#ffffff" + state "Sonos PLAYBAR", label:'', action:"", icon:"st.sonos.sonos-playbar", backgroundColor:"#ffffff" + } + standardTile("unsubscribe", "device.status", width: 1, height: 1, decoration: "flat") { + state "previous", label:'Unsubscribe', action:"unsubscribe", backgroundColor:"#ffffff" + } + + main "main" + + details([ + "previousTrack","play","nextTrack", + "status","pause","mute", + "levelSliderControl", + "currentSong", + "refresh","model" + //,"unsubscribe" + ]) +} + +// parse events into attributes +def parse(description) { + log.trace "parse('$description')" + def results = [] + try { + + def msg = parseLanMessage(description) + log.info "requestId = $msg.requestId" + if (msg.headers) + { + def hdr = msg.header.split('\n')[0] + if (hdr.size() > 36) { + hdr = hdr[0..35] + "..." + } + + def uuid = "" + def sid = "" + if (msg.headers["SID"]) + { + sid = msg.headers["SID"] + sid -= "uuid:" + sid = sid.trim() + + def pos = sid.lastIndexOf("_") + if (pos > 0) { + uuid = sid[0..pos-1] + log.trace "uuid; $uuid" + } + } + + log.trace "${hdr} ${description.size()} bytes, body = ${msg.body?.size() ?: 0} bytes, sid = ${sid}" + + if (!msg.body) { + if (sid) { + updateSid(sid) + } + } + else if (msg.xml) { + log.trace "has XML body" + + // Process response to getVolume() + def node = msg.xml.Body.GetVolumeResponse + if (node.size()) { + log.trace "Extracting current volume" + sendEvent(name: "level",value: node.CurrentVolume.text()) + } + + // Process response to getCurrentStatus() + node = msg.xml.Body.GetTransportInfoResponse + if (node.size()) { + log.trace "Extracting current status" + def currentStatus = statusText(node.CurrentTransportState.text()) + if (currentStatus) { + if (currentStatus != "TRANSITIONING") { + def coordinator = device.getDataValue('coordinator') + log.trace "Current status: '$currentStatus', coordinator: '${coordinator}'" + updateDataValue('currentStatus', currentStatus) + log.trace "Updated saved status to '$currentStatus'" + + if (coordinator) { + sendEvent(name: "status", value: "grouped", data: [source: 'xml.Body.GetTransportInfoResponse']) + sendEvent(name: "switch", value: "off", displayed: false) + } + else { + log.trace "status = $currentStatus" + sendEvent(name: "status", value: currentStatus, data: [source: 'xml.Body.GetTransportInfoResponse']) + sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false) + } + } + } + } + + // Process group change + node = msg.xml.property.ZoneGroupState + if (node.size()) { + log.trace "Extracting group status" + // Important to use parser rather than slurper for this version of Groovy + def xml1 = new XmlParser().parseText(node.text()) + log.trace "Parsed group xml" + def myNode = xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == uuid} + log.trace "myNode: ${myNode}" + + // TODO - myNode is often false and throwing 1000 exceptions/hour, find out why + // https://smartthings.atlassian.net/browse/DVCSMP-793 + def myCoordinator = myNode?.parent()?.'@Coordinator' + + log.trace "player: ${myNode?.'@UUID'}, coordinator: ${myCoordinator}" + if (myCoordinator && myCoordinator != myNode.'@UUID') { + // this player is grouped, find the coordinator + + def coordinator = xml1.ZoneGroup.ZoneGroupMember.find {it.'@UUID' == myCoordinator} + def coordinatorDni = dniFromUri(coordinator.'@Location') + log.trace "Player has a coordinator: $coordinatorDni" + + updateDataValue("coordinator", coordinatorDni) + updateDataValue("isGroupCoordinator", "") + + def coordinatorDevice = parent.getChildDevice(coordinatorDni) + + // TODO - coordinatorDevice is also sometimes coming up null + if (coordinatorDevice) { + sendEvent(name: "trackDescription", value: "[Grouped with ${coordinatorDevice.displayName}]") + sendEvent(name: "status", value: "grouped", data: [ + coordinator: [displayName: coordinatorDevice.displayName, id: coordinatorDevice.id, deviceNetworkId: coordinatorDevice.deviceNetworkId] + ]) + sendEvent(name: "switch", value: "off", displayed: false) + } + else { + log.warn "Could not find child device for coordinator 'coordinatorDni', devices: ${parent.childDevices*.deviceNetworkId}" + } + } + else if (myNode) { + // Not grouped + updateDataValue("coordinator", "") + updateDataValue("isGroupCoordinator", myNode.parent().ZoneGroupMember.size() > 1 ? "true" : "") + } + // Return a command to read the current status again to take care of status and group events arriving at + // about the same time, but in the reverse order + results << getCurrentStatus() + } + + // Process subscription update + node = msg.xml.property.LastChange + if (node.size()) { + def xml1 = parseXml(node.text()) + + // Play/pause status + def currentStatus = statusText(xml1.InstanceID.TransportState.'@val'.text()) + if (currentStatus) { + if (currentStatus != "TRANSITIONING") { + def coordinator = device.getDataValue('coordinator') + log.trace "Current status: '$currentStatus', coordinator: '${coordinator}'" + updateDataValue('currentStatus', currentStatus) + log.trace "Updated saved status to '$currentStatus'" + + if (coordinator) { + sendEvent(name: "status", value: "grouped", data: [source: 'xml.property.LastChange.InstanceID.TransportState']) + sendEvent(name: "switch", value: "off", displayed: false) + } + else { + log.trace "status = $currentStatus" + sendEvent(name: "status", value: currentStatus, data: [source: 'xml.property.LastChange.InstanceID.TransportState']) + sendEvent(name: "switch", value: currentStatus=="playing" ? "on" : "off", displayed: false) + } + } + } + + // Volume level + def currentLevel = xml1.InstanceID.Volume.find{it.'@channel' == 'Master'}.'@val'.text() + if (currentLevel) { + log.trace "Has volume: '$currentLevel'" + sendEvent(name: "level", value: currentLevel, description: description) + } + + // Mute status + def currentMute = xml1.InstanceID.Mute.find{it.'@channel' == 'Master'}.'@val'.text() + if (currentMute) { + def value = currentMute == "1" ? "muted" : "unmuted" + log.trace "Has mute: '$currentMute', value: '$value" + sendEvent(name: "mute", value: value, descriptionText: "$device.displayName is $value") + } + + // Track data + def trackUri = xml1.InstanceID.CurrentTrackURI.'@val'.text() + def transportUri = xml1.InstanceID.AVTransportURI.'@val'.text() + def enqueuedUri = xml1.InstanceID.EnqueuedTransportURI.'@val'.text() + def trackNumber = xml1.InstanceID.CurrentTrack.'@val'.text() + + if (trackUri.contains("//s3.amazonaws.com/smartapp-")) { + log.trace "Skipping event generation for sound file $trackUri" + } + else { + def trackMeta = xml1.InstanceID.CurrentTrackMetaData.'@val'.text() + def transportMeta = xml1.InstanceID.AVTransportURIMetaData.'@val'.text() + def enqueuedMeta = xml1.InstanceID.EnqueuedTransportURIMetaData.'@val'.text() + + if (trackMeta || transportMeta) { + def isRadioStation = enqueuedUri.startsWith("x-sonosapi-stream:") + + // Use enqueued metadata, if available, otherwise transport metadata, for station ID + def metaData = enqueuedMeta ? enqueuedMeta : transportMeta + def stationMetaXml = metaData ? parseXml(metaData) : null + + // Use the track metadata for song ID unless it's a radio station + def trackXml = (trackMeta && !isRadioStation) || !stationMetaXml ? parseXml(trackMeta) : stationMetaXml + + // Song properties + def currentName = trackXml.item.title.text() + def currentArtist = trackXml.item.creator.text() + def currentAlbum = trackXml.item.album.text() + def currentTrackDescription = currentName + def descriptionText = "$device.displayName is playing $currentTrackDescription" + if (currentArtist) { + currentTrackDescription += " - $currentArtist" + descriptionText += " by $currentArtist" + } + + // Track Description Event + log.info descriptionText + sendEvent(name: "trackDescription", + value: currentTrackDescription, + descriptionText: descriptionText + ) + + // Have seen cases where there is no engueued or transport metadata. Haven't figured out how to resume in that case + // so not creating a track data event. + // + if (stationMetaXml) { + // Track Data Event + // Use track description for the data event description unless it is a queued song (to support resumption & use in mood music) + def station = (transportUri?.startsWith("x-rincon-queue:") || enqueuedUri?.contains("savedqueues")) ? currentName : stationMetaXml.item.title.text() + + def uri = enqueuedUri ?: transportUri + def previousState = device.currentState("trackData")?.jsonValue + def isDataStateChange = !previousState || (previousState.station != station || previousState.metaData != metaData) + + if (transportUri?.startsWith("x-rincon-queue:")) { + updateDataValue("queueUri", transportUri) + } + + def trackDataValue = [ + station: station, + name: currentName, + artist: currentArtist, + album: currentAlbum, + trackNumber: trackNumber, + status: currentStatus, + level: currentLevel, + uri: uri, + trackUri: trackUri, + transportUri: transportUri, + enqueuedUri: enqueuedUri, + metaData: metaData, + ] + + if (trackMeta != metaData) { + trackDataValue.trackMetaData = trackMeta + } + + results << createEvent(name: "trackData", + value: trackDataValue.encodeAsJSON(), + descriptionText: currentDescription, + displayed: false, + isStateChange: isDataStateChange + ) + } + } + } + } + if (!results) { + def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') + .replaceAll('()','\n$1\n') + .replaceAll('\n\n','\n').encodeAsHTML() : "" + results << createEvent( + name: "sonosMessage", + value: "${msg.body.encodeAsMD5()}", + description: description, + descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", + data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", + isStateChange: false, displayed: false) + } + } + else { + log.warn "Body not XML" + def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') + .replaceAll('()','\n$1\n') + .replaceAll('\n\n','\n').encodeAsHTML() : "" + results << createEvent( + name: "unknownMessage", + value: "${msg.body.encodeAsMD5()}", + description: description, + descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", + data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", + isStateChange: true, displayed: true) + } + } + //log.trace "/parse()" + } + catch (Throwable t) { + //results << createEvent(name: "parseError", value: "$t") + sendEvent(name: "parseError", value: "$t", description: description) + throw t + } + results +} + +def installed() { + def result = [delayAction(5000)] + result << refresh() + result.flatten() +} + +def on(){ + play() +} + +def off(){ + stop() +} + +def setModel(String model) +{ + log.trace "setModel to $model" + sendEvent(name:"model",value:model,isStateChange:true) +} + +def refresh() { + log.trace "refresh()" + def result = subscribe() + result << getCurrentStatus() + result << getVolume() + result.flatten() +} + +// For use by apps, sets all levels if sent to a non-coordinator in a group +def setLevel(val) +{ + log.trace "setLevel($val)" + coordinate({ + setOtherLevels(val) + setLocalLevel(val) + }, { + it.setLevel(val) + }) +} + +// For use by tiles, sets all levels if a coordinator, otherwise sets only the local one +def tileSetLevel(val) +{ + log.trace "tileSetLevel($val)" + coordinate({ + setOtherLevels(val) + setLocalLevel(val) + }, { + setLocalLevel(val) + }) +} + +// Always sets only this level +def setLocalLevel(val, delay=0) { + log.trace "setLocalLevel($val)" + def v = Math.max(Math.min(Math.round(val), 100), 0) + log.trace "volume = $v" + + def result = [] + if (delay) { + result << delayAction(delay) + } + result << sonosAction("SetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredVolume: v]) + //result << delayAction(500), + result << sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"]) + result +} + +private setOtherLevels(val, delay=0) { + log.trace "setOtherLevels($val)" + if (device.getDataValue('isGroupCoordinator')) { + log.trace "setting levels of coordinated players" + def previousMaster = device.currentState("level")?.integerValue + parent.getChildDevices().each {child -> + if (child.getDeviceDataByName("coordinator") == device.deviceNetworkId) { + def newLevel = childLevel(previousMaster, val, child.currentState("level")?.integerValue) + log.trace "Setting level of $child.displayName to $newLevel" + child.setLocalLevel(newLevel, delay) + } + } + } + log.trace "/setOtherLevels()" +} + +private childLevel(previousMaster, newMaster, previousChild) +{ + if (previousMaster) { + if (previousChild) { + Math.round(previousChild * (newMaster / previousMaster)) + } + else { + newMaster + } + } + else { + newMaster + } +} + +def getGroupStatus() { + def result = coordinate({device.currentValue("status")}, {it.currentValue("status")}) + log.trace "getGroupStatus(), result=$result" + result +} + +def play() { + log.trace "play()" + coordinate({sonosAction("Play")}, {it.play()}) +} + +def stop() { + log.trace "stop()" + coordinate({sonosAction("Stop")}, {it.stop()}) +} + +def pause() { + log.trace "pause()" + coordinate({sonosAction("Pause")}, {it.pause()}) +} + +def nextTrack() { + log.trace "nextTrack()" + coordinate({sonosAction("Next")}, {it.nextTrack()}) +} + +def previousTrack() { + log.trace "previousTrack()" + coordinate({sonosAction("Previous")}, {it.previousTrack()}) +} + +def seek(trackNumber) { + log.trace "seek($trackNumber)" + coordinate({sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: trackNumber])}, {it.seek(trackNumber)}) +} + +def mute() +{ + log.trace "mute($m)" + // TODO - handle like volume? + //coordinate({sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 1])}, {it.mute()}) + sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 1]) +} + +def unmute() +{ + log.trace "mute($m)" + // TODO - handle like volume? + //coordinate({sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 0])}, {it.unmute()}) + sonosAction("SetMute", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master", DesiredMute: 0]) +} + +def setPlayMode(mode) +{ + log.trace "setPlayMode($mode)" + coordinate({sonosAction("SetPlayMode", [InstanceID: 0, NewPlayMode: mode])}, {it.setPlayMode(mode)}) +} + +def speak(text) { + playTextAndResume(text) +} + +def playTextAndResume(text, volume=null) +{ + log.debug "playTextAndResume($text, $volume)" + coordinate({ + def sound = textToSpeech(text) + playTrackAndResume(sound.uri, (sound.duration as Integer) + 1, volume) + }, {it.playTextAndResume(text, volume)}) +} + +def playTrackAndResume(uri, duration, volume=null) { + log.debug "playTrackAndResume($uri, $duration, $volume)" + coordinate({ + def currentTrack = device.currentState("trackData")?.jsonValue + def currentVolume = device.currentState("level")?.integerValue + def currentStatus = device.currentValue("status") + def level = volume as Integer + + def result = [] + if (level) { + log.trace "Stopping and setting level to $volume" + result << sonosAction("Stop") + result << setLocalLevel(level) + } + + log.trace "Setting sound track: ${uri}" + result << setTrack(uri) + result << sonosAction("Play") + + if (currentTrack) { + def delayTime = ((duration as Integer) * 1000)+3000 + if (level) { + delayTime += 1000 + } + result << delayAction(delayTime) + log.trace "Delaying $delayTime ms before resumption" + if (level) { + log.trace "Restoring volume to $currentVolume" + result << sonosAction("Stop") + result << setLocalLevel(currentVolume) + } + log.trace "Restoring track $currentTrack.uri" + result << setTrack(currentTrack) + if (currentStatus == "playing") { + result << sonosAction("Play") + } + } + + result = result.flatten() + log.trace "Returning ${result.size()} commands" + result + }, {it.playTrackAndResume(uri, duration, volume)}) +} + +def playTextAndRestore(text, volume=null) +{ + log.debug "playTextAndResume($text, $volume)" + coordinate({ + def sound = textToSpeech(text) + playTrackAndRestore(sound.uri, (sound.duration as Integer) + 1, volume) + }, {it.playTextAndRestore(text, volume)}) +} + +def playTrackAndRestore(uri, duration, volume=null) { + log.debug "playTrackAndRestore($uri, $duration, $volume)" + coordinate({ + def currentTrack = device.currentState("trackData")?.jsonValue + def currentVolume = device.currentState("level")?.integerValue + def currentStatus = device.currentValue("status") + def level = volume as Integer + + def result = [] + if (level) { + log.trace "Stopping and setting level to $volume" + result << sonosAction("Stop") + result << setLocalLevel(level) + } + + log.trace "Setting sound track: ${uri}" + result << setTrack(uri) + result << sonosAction("Play") + + if (currentTrack) { + def delayTime = ((duration as Integer) * 1000)+3000 + if (level) { + delayTime += 1000 + } + result << delayAction(delayTime) + log.trace "Delaying $delayTime ms before restoration" + if (level) { + log.trace "Restoring volume to $currentVolume" + result << sonosAction("Stop") + result << setLocalLevel(currentVolume) + } + log.trace "Restoring track $currentTrack.uri" + result << setTrack(currentTrack) + } + + result = result.flatten() + log.trace "Returning ${result.size()} commands" + result + }, {it.playTrackAndResume(uri, duration, volume)}) +} + +def playTextAndTrack(text, trackData, volume=null) +{ + log.debug "playTextAndTrack($text, $trackData, $volume)" + coordinate({ + def sound = textToSpeech(text) + playSoundAndTrack(sound.uri, (sound.duration as Integer) + 1, trackData, volume) + }, {it.playTextAndResume(text, volume)}) +} + +def playSoundAndTrack(soundUri, duration, trackData, volume=null) { + log.debug "playSoundAndTrack($soundUri, $duration, $trackUri, $volume)" + coordinate({ + def level = volume as Integer + def result = [] + if (level) { + log.trace "Stopping and setting level to $volume" + result << sonosAction("Stop") + result << setLocalLevel(level) + } + + log.trace "Setting sound track: ${soundUri}" + result << setTrack(soundUri) + result << sonosAction("Play") + + def delayTime = ((duration as Integer) * 1000)+3000 + result << delayAction(delayTime) + log.trace "Delaying $delayTime ms before resumption" + + log.trace "Setting track $trackData" + result << setTrack(trackData) + result << sonosAction("Play") + + result = result.flatten() + log.trace "Returning ${result.size()} commands" + result + }, {it.playTrackAndResume(uri, duration, volume)}) +} + +def playTrackAtVolume(String uri, volume) { + log.trace "playTrack()" + coordinate({ + def result = [] + result << sonosAction("Stop") + result << setLocalLevel(volume as Integer) + result << setTrack(uri, metaData) + result << sonosAction("Play") + result.flatten() + }, {it.playTrack(uri, metaData)}) +} + +def playTrack(String uri, metaData="") { + log.trace "playTrack()" + coordinate({ + def result = setTrack(uri, metaData) + result << sonosAction("Play") + result.flatten() + }, {it.playTrack(uri, metaData)}) +} + +def playTrack(Map trackData) { + log.trace "playTrack(Map)" + coordinate({ + def result = setTrack(trackData) + //result << delayAction(1000) + result << sonosAction("Play") + result.flatten() + }, {it.playTrack(trackData)}) +} + +def setTrack(Map trackData) { + log.trace "setTrack($trackData.uri, ${trackData.metaData?.size()} B)" + coordinate({ + def data = trackData + def result = [] + if ((data.transportUri.startsWith("x-rincon-queue:") || data.enqueuedUri.contains("savedqueues")) && data.trackNumber != null) { + // TODO - Clear queue? + def uri = device.getDataValue('queueUri') + result << sonosAction("RemoveAllTracksFromQueue", [InstanceID: 0]) + //result << delayAction(500) + result << sonosAction("AddURIToQueue", [InstanceID: 0, EnqueuedURI: data.uri, EnqueuedURIMetaData: data.metaData, DesiredFirstTrackNumberEnqueued: 0, EnqueueAsNext: 1]) + //result << delayAction(500) + result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData]) + //result << delayAction(500) + result << sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: data.trackNumber]) + } else { + result = setTrack(data.uri, data.metaData) + } + result.flatten() + }, {it.setTrack(trackData)}) +} + +def setTrack(String uri, metaData="") +{ + log.info "setTrack($uri, $trackNumber, ${metaData?.size()} B})" + coordinate({ + def result = [] + result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: metaData]) + result + }, {it.setTrack(uri, metaData)}) +} + +def resumeTrack(Map trackData = null) { + log.trace "resumeTrack()" + coordinate({ + def result = restoreTrack(trackData) + //result << delayAction(500) + result << sonosAction("Play") + result + }, {it.resumeTrack(trackData)}) +} + +def restoreTrack(Map trackData = null) { + log.trace "restoreTrack(${trackData?.uri})" + coordinate({ + def result = [] + def data = trackData + if (!data) { + data = device.currentState("trackData")?.jsonValue + } + if (data) { + if ((data.transportUri.startsWith("x-rincon-queue:") || data.enqueuedUri.contains("savedqueues")) && data.trackNumber != null) { + def uri = device.getDataValue('queueUri') + log.trace "Restoring queue position $data.trackNumber of $data.uri" + result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: uri, CurrentURIMetaData: data.metaData]) + //result << delayAction(500) + result << sonosAction("Seek", "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID: 0, Unit: "TRACK_NR", Target: data.trackNumber]) + } else { + log.trace "Setting track to $data.uri" + //setTrack(data.uri, null, data.metaData) + result << sonosAction("SetAVTransportURI", [InstanceID: 0, CurrentURI: data.uri, CurrentURIMetaData: data.metaData]) + } + } + else { + log.warn "Previous track data not found" + } + result + }, {it.restoreTrack(trackData)}) +} + +def playText(String msg) { + coordinate({ + def result = setText(msg) + result << sonosAction("Play") + }, {it.playText(msg)}) +} + +def setText(String msg) { + log.trace "setText($msg)" + coordinate({ + def sound = textToSpeech(msg) + setTrack(sound.uri) + }, {it.setText(msg)}) +} + +// Custom commands + +def subscribe() { + log.trace "subscribe()" + def result = [] + result << subscribeAction("/MediaRenderer/AVTransport/Event") + result << delayAction(10000) + result << subscribeAction("/MediaRenderer/RenderingControl/Event") + result << delayAction(20000) + result << subscribeAction("/ZoneGroupTopology/Event") + + result +} + +def unsubscribe() { + log.trace "unsubscribe()" + def result = [ + unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId')), + unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId')), + unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId')), + + unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId1')), + unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId1')), + unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId1')), + + unsubscribeAction("/MediaRenderer/AVTransport/Event", device.getDataValue('subscriptionId2')), + unsubscribeAction("/MediaRenderer/RenderingControl/Event", device.getDataValue('subscriptionId2')), + unsubscribeAction("/ZoneGroupTopology/Event", device.getDataValue('subscriptionId2')) + ] + updateDataValue("subscriptionId", "") + updateDataValue("subscriptionId1", "") + updateDataValue("subscriptionId2", "") + result +} + +def getVolume() +{ + log.trace "getVolume()" + sonosAction("GetVolume", "RenderingControl", "/MediaRenderer/RenderingControl/Control", [InstanceID: 0, Channel: "Master"]) +} + +def getCurrentMedia() +{ + log.trace "getCurrentMedia()" + sonosAction("GetPositionInfo", [InstanceID:0, Channel: "Master"]) +} + +def getCurrentStatus() //transport info +{ + log.trace "getCurrentStatus()" + sonosAction("GetTransportInfo", [InstanceID:0]) +} + +def getSystemString() +{ + log.trace "getSystemString()" + sonosAction("GetString", "SystemProperties", "/SystemProperties/Control", [VariableName: "UMTracking"]) +} + +private messageFilename(String msg) { + msg.toLowerCase().replaceAll(/[^a-zA-Z0-9]+/,'_') +} + +private getCallBackAddress() +{ + device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP") +} + +private sonosAction(String action) { + sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", [InstanceID:0, Speed:1]) +} + +private sonosAction(String action, Map body) { + sonosAction(action, "AVTransport", "/MediaRenderer/AVTransport/Control", body) +} + +private sonosAction(String action, String service, String path, Map body = [InstanceID:0, Speed:1]) { + log.trace "sonosAction($action, $service, $path, $body)" + def result = new physicalgraph.device.HubSoapAction( + path: path ?: "/MediaRenderer/$service/Control", + urn: "urn:schemas-upnp-org:service:$service:1", + action: action, + body: body, + headers: [Host:getHostAddress(), CONNECTION: "close"] + ) + + //log.trace "\n${result.action.encodeAsHTML()}" + log.debug "sonosAction: $result.requestId" + result +} + +private subscribeAction(path, callbackPath="") { + log.trace "subscribe($path, $callbackPath)" + def address = getCallBackAddress() + def ip = getHostAddress() + + def result = new physicalgraph.device.HubAction( + method: "SUBSCRIBE", + path: path, + headers: [ + HOST: ip, + CALLBACK: "", + NT: "upnp:event", + TIMEOUT: "Second-28800"]) + + log.trace "SUBSCRIBE $path" + //log.trace "\n${result.action.encodeAsHTML()}" + result +} + +private unsubscribeAction(path, sid) { + log.trace "unsubscribe($path, $sid)" + def ip = getHostAddress() + def result = new physicalgraph.device.HubAction( + method: "UNSUBSCRIBE", + path: path, + headers: [ + HOST: ip, + SID: "uuid:${sid}"]) + + log.trace "UNSUBSCRIBE $path" + //log.trace "\n${result.action.encodeAsHTML()}" + result +} + +private delayAction(long time) { + new physicalgraph.device.HubAction("delay $time") +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +private getHostAddress() { + def parts = device.deviceNetworkId.split(":") + def ip = convertHexToIP(parts[0]) + def port = convertHexToInt(parts[1]) + return ip + ":" + port +} + +private statusText(s) { + switch(s) { + case "PLAYING": + return "playing" + case "PAUSED_PLAYBACK": + return "paused" + case "STOPPED": + return "stopped" + default: + return s + } +} + +private updateSid(sid) { + if (sid) { + def sid0 = device.getDataValue('subscriptionId') + def sid1 = device.getDataValue('subscriptionId1') + def sid2 = device.getDataValue('subscriptionId2') + def sidNumber = device.getDataValue('sidNumber') ?: "0" + + log.trace "updateSid($sid), sid0=$sid0, sid1=$sid1, sid2=$sid2, sidNumber=$sidNumber" + if (sidNumber == "0") { + if (sid != sid1 && sid != sid2) { + updateDataValue("subscriptionId", sid) + updateDataValue("sidNumber", "1") + } + } + else if (sidNumber == "1") { + if (sid != sid0 && sid != sid2) { + updateDataValue("subscriptionId1", sid) + updateDataValue("sidNumber", "2") + } + } + else { + if (sid != sid0 && sid != sid0) { + updateDataValue("subscriptionId2", sid) + updateDataValue("sidNumber", "0") + } + } + } +} + +private dniFromUri(uri) { + def segs = uri.replaceAll(/http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)\/.+/,'$1').split(":") + def nums = segs[0].split("\\.") + (nums.collect{hex(it.toInteger())}.join('') + ':' + hex(segs[-1].toInteger(),4)).toUpperCase() +} + +private hex(value, width=2) { + def s = new BigInteger(Math.round(value).toString()).toString(16) + while (s.size() < width) { + s = "0" + s + } + s +} + +private coordinate(closure1, closure2) { + def coordinator = device.getDataValue('coordinator') + if (coordinator) { + closure2(parent.getChildDevice(coordinator)) + } + else { + closure1() + } +} diff --git a/third-party/StriimLight.groovy b/third-party/StriimLight.groovy new file mode 100755 index 0000000..94738d2 --- /dev/null +++ b/third-party/StriimLight.groovy @@ -0,0 +1,443 @@ +/** +* Striim Light v0.1 +* +* Author: SmartThings - Ulises Mujica (Ule) - obycode +* +*/ + +preferences { + input(name: "customDelay", type: "enum", title: "Delay before msg (seconds)", options: ["0","1","2","3","4","5"]) + input(name: "actionsDelay", type: "enum", title: "Delay between actions (seconds)", options: ["0","1","2","3"]) +} +metadata { + // Automatically generated. Make future change here. + definition (name: "Striim Light", namespace: "obycode", author: "SmartThings-Ulises Mujica") { + capability "Actuator" + capability "Switch" + capability "Switch Level" + capability "Color Control" + capability "Refresh" + capability "Sensor" + capability "Polling" + + attribute "model", "string" + attribute "temperature", "number" + attribute "brightness", "number" + attribute "lightMode", "string" + + command "setColorHex" "string" + command "cycleColors" + command "setTemperature" + command "White" + + command "subscribe" + command "unsubscribe" + } + + // Main + standardTile("switch", "device.switch", width: 2, height: 2, canChangeIcon: true) { + state "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"off" + state "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"on" + } + standardTile("reset", "device.reset", inactiveLabel: false, decoration: "flat") { + state "default", label:"Reset Color", action:"reset", icon:"st.lights.philips.hue-single" + } + standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat") { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + standardTile("cycleColors", "device.color", inactiveLabel: false, decoration: "flat") { + state "default", label:"Colors", action:"cycleColors", icon:"st.unknown.thing.thing-circle" + } + standardTile("dimLevel", "device.level", inactiveLabel: false, decoration: "flat") { + state "default", label:"Dimmer Level", icon:"st.switches.light.on" + } + + controlTile("rgbSelector", "device.color", "color", height: 3, width: 3, inactiveLabel: false) { + state "color", action:"color control.setColor" + } + controlTile("levelSliderControl", "device.level", "slider", height: 1, width: 2, range:"(0..100)") { + state "level", label:"Dimmer Level", action:"switch level.setLevel" + } + controlTile("tempSliderControl", "device.temperature", "slider", height: 1, width: 2, inactiveLabel: false, range:"(0..100)") { + state "temperature", label:"Temperature", action:"setTemperature" + } + valueTile("level", "device.level", inactiveLabel: false, decoration: "flat") { + state "level", label: 'Level ${currentValue}%' + } + valueTile("lightMode", "device.lightMode", inactiveLabel: false, decoration: "flat") { + state "lightMode", label: 'Mode: ${currentValue}', action:"White" + } + + main(["switch"]) + details(["switch", "levelSliderControl", "tempSliderControl", "rgbSelector", "refresh", "cycleColors", "level", "lightMode"]) +} + + +def parse(description) { + def results = [] + try { + def msg = parseLanMessage(description) + if (msg.headers) { + def hdr = msg.header.split('\n')[0] + if (hdr.size() > 36) { + hdr = hdr[0..35] + "..." + } + + def sid = "" + if (msg.headers["SID"]) { + sid = msg.headers["SID"] + sid -= "uuid:" + sid = sid.trim() + + } + + if (!msg.body) { + if (sid) { + updateSid(sid) + } + } + else if (msg.xml) { + // log.debug "received $msg" + // log.debug "${msg.body}" + + // Process level status + def node = msg.xml.Body.GetLoadLevelTargetResponse + if (node.size()) { + return sendEvent(name: "level", value: node, description: "$device.displayName Level is $node") + } + + // Process subscription updates + // On/Off + node = msg.xml.property.Status + if (node.size()) { + def statusString = node == 0 ? "off" : "on" + return sendEvent(name: "switch", value: statusString, description: "$device.displayName Switch is $statusString") + } + + // Level + node = msg.xml.property.LoadLevelStatus + if (node.size()) { + return sendEvent(name: "level", value: node, description: "$device.displayName Level is $node") + } + + // TODO: Do something with Temperature, Brightness and Mode in the UI + // Temperature + node = msg.xml.property.Temperature + if (node.size()) { + def temp = node.text().toInteger() + if (temp > 4000) { + temp -= 4016 + } + def result = [] + result << sendEvent(name: "temperature", value: temp, description: "$device.displayName Temperature is $temp") + result << sendEvent(name: "lightMode", value: "White", description: "$device.displayName Mode is White") + return result + } + + // brightness + node = msg.xml.property.Brightness + if (node.size()) { + return sendEvent(name: "brightness", value: node, description: "$device.displayName Brightness is $node") + } + + // Mode? + // node = msg.xml.property.CurrentMode + // if (node.size()) { + // } + + // Color + try { + def bodyXml = parseXml(msg.xml.text()) + node = bodyXml.RGB + if (node.size()) { + def fields = node.text().split(',') + def colorHex = '#' + String.format('%02x%02x%02x', fields[0].toInteger(), fields[1].toInteger(), fields[2].toInteger()) + def result = [] + result << sendEvent(name: "color", value: colorHex, description:"$device.displayName Color is $colorHex") + result << sendEvent(name: "lightMode", value: "Color", description: "$device.displayName Mode is Color") + return result + } + } + catch (org.xml.sax.SAXParseException e) { + // log.debug "${msg.body}" + } + + // Not sure what this is for? + if (!results) { + def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') + .replaceAll('()','\n$1\n') + .replaceAll('\n\n','\n').encodeAsHTML() : "" + results << createEvent( + name: "mediaRendererMessage", + value: "${msg.body.encodeAsMD5()}", + description: description, + descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", + data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", + isStateChange: false, displayed: false) + } + } + else { + def bodyHtml = msg.body ? msg.body.replaceAll('(<[a-z,A-Z,0-9,\\-,_,:]+>)','\n$1\n') + .replaceAll('()','\n$1\n') + .replaceAll('\n\n','\n').encodeAsHTML() : "" + results << createEvent( + name: "unknownMessage", + value: "${msg.body.encodeAsMD5()}", + description: description, + descriptionText: "Body is ${msg.body?.size() ?: 0} bytes", + data: "
${msg.headers.collect{it.key + ': ' + it.value}.join('\n')}

${bodyHtml}
", + isStateChange: true, displayed: true) + } + } + } + catch (Throwable t) { + //results << createEvent(name: "parseError", value: "$t") + sendEvent(name: "parseError", value: "$t", description: description) + throw t + } + results +} + +def installed() { + sendEvent(name:"model",value:getDataValue("model"),isStateChange:true) + def result = [delayAction(5000)] + result << refresh() + result.flatten() +} + +def on(){ + dimmableLightAction("SetTarget", "SwitchPower", getDataValue("spcurl"), [newTargetValue: 1]) +} + +def off(){ + dimmableLightAction("SetTarget", "SwitchPower", getDataValue("spcurl"), [newTargetValue: 0]) +} + +def poll() { + refresh() +} + +def refresh() { + def eventTime = new Date().time + + if(eventTime > state.secureEventTime ?:0) { + if ((state.lastRefreshTime ?: 0) > (state.lastStatusTime ?:0)) { + sendEvent(name: "status", value: "no_device_present", data: "no_device_present", displayed: false) + } + state.lastRefreshTime = eventTime + log.trace "Refresh()" + def result = [] + result << subscribe() + result << getCurrentLevel() + result.flatten() + } + else { + log.trace "Refresh skipped" + } +} + +def setLevel(val) { + dimmableLightAction("SetLoadLevelTarget", "Dimming", getDataValue("dcurl"), [newLoadlevelTarget: val]) +} + +def setTemperature(val) { + // The API only accepts values 2700 - 6000, but it actually wants values + // between 0-100, so we need to do this weird offsetting (trial and error) + def offsetVal = val.toInteger() + 4016 + awoxAction("SetTemperature", "X_WhiteLight", getDataValue("xwlcurl"), [Temperature: offsetVal.toString()]) +} + +def White() { + def lastTemp = device.currentValue("temperature") + 4016 + awoxAction("SetTemperature", "X_WhiteLight", getDataValue("xwlcurl"), [Temperature: lastTemp]) +} + +def setColor(value) { + def colorString = value.red.toString() + "," + value.green.toString() + "," + value.blue.toString() + awoxAction("SetRGBColor", "X_ColorLight", getDataValue("xclcurl"), [RGBColor: colorString]) +} + +def setColorHex(hexString) { + def colorString = convertHexToInt(hexString[1..2]).toString() + "," + + convertHexToInt(hexString[3..4]).toString() + "," + + convertHexToInt(hexString[5..6]).toString() + awoxAction("SetRGBColor", "X_ColorLight", getDataValue("xclcurl"), [RGBColor: colorString]) +} + +// Custom commands + +// This method models the button on the StriimLight remote that cycles through +// some pre-defined colors +def cycleColors() { + def currentColor = device.currentValue("color") + switch(currentColor) { + case "#0000ff": + setColorHex("#ffff00") + break + case "#ffff00": + setColorHex("#00ffff") + break + case "#00ffff": + setColorHex("#ff00ff") + break + case "#ff00ff": + setColorHex("#ff0000") + break + case "#ff0000": + setColorHex("#00ff00") + break + case "#00ff00": + setColorHex("#0000ff") + break + default: + setColorHex("#0000ff") + break + } +} + +def subscribe() { + log.trace "subscribe()" + def result = [] + result << subscribeAction(getDataValue("deurl")) + result << delayAction(2500) + result << subscribeAction(getDataValue("speurl")) + result << delayAction(2500) + result << subscribeAction(getDataValue("xcleurl")) + result << delayAction(2500) + result << subscribeAction(getDataValue("xwleurl")) + result +} + +def unsubscribe() { + def result = [ + unsubscribeAction(getDataValue("deurl"), device.getDataValue('subscriptionId')), + unsubscribeAction(getDataValue("speurl"), device.getDataValue('subscriptionId')), + unsubscribeAction(getDataValue("xcleurl"), device.getDataValue('subscriptionId')), + unsubscribeAction(getDataValue("xwleurl"), device.getDataValue('subscriptionId')), + + + unsubscribeAction(getDataValue("deurl"), device.getDataValue('subscriptionId1')), + unsubscribeAction(getDataValue("speurl"), device.getDataValue('subscriptionId1')), + unsubscribeAction(getDataValue("xcleurl"), device.getDataValue('subscriptionId1')), + unsubscribeAction(getDataValue("xwleurl"), device.getDataValue('subscriptionId1')), + + ] + updateDataValue("subscriptionId", "") + updateDataValue("subscriptionId1", "") + result +} + +def getCurrentLevel() +{ + dimmableLightAction("GetLoadLevelTarget", "Dimming", getDataValue("dcurl")) +} + +def getSystemString() +{ + mediaRendererAction("GetString", "SystemProperties", "/SystemProperties/Control", [VariableName: "UMTracking"]) +} + +private getCallBackAddress() +{ + device.hub.getDataValue("localIP") + ":" + device.hub.getDataValue("localSrvPortTCP") +} + +private dimmableLightAction(String action, String service, String path, Map body = [:]) { + // log.debug "path is $path, service is $service, action is $action, body is $body" + def result = new physicalgraph.device.HubSoapAction( + path: path ?: "/DimmableLight/$service/Control", + urn: "urn:schemas-upnp-org:service:$service:1", + action: action, + body: body, + headers: [Host:getHostAddress(), CONNECTION: "close"]) + result +} + +private awoxAction(String action, String service, String path, Map body = [:]) { + // log.debug "path is $path, service is $service, action is $action, body is $body" + def result = new physicalgraph.device.HubSoapAction( + path: path ?: "/DimmableLight/$service/Control", + urn: "urn:schemas-awox-com:service:$service:1", + action: action, + body: body, + headers: [Host:getHostAddress(), CONNECTION: "close"]) + result +} + +private subscribeAction(path, callbackPath="") { + def address = getCallBackAddress() + def ip = getHostAddress() + def result = new physicalgraph.device.HubAction( + method: "SUBSCRIBE", + path: path, + headers: [ + HOST: ip, + CALLBACK: "", + NT: "upnp:event", + TIMEOUT: "Second-600"]) + result +} + +private unsubscribeAction(path, sid) { + def ip = getHostAddress() + def result = new physicalgraph.device.HubAction( + method: "UNSUBSCRIBE", + path: path, + headers: [ + HOST: ip, + SID: "uuid:${sid}"]) + result +} + +private delayAction(long time) { + new physicalgraph.device.HubAction("delay $time") +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +private getHostAddress() { + def parts = getDataValue("dni")?.split(":") + def ip = convertHexToIP(parts[0]) + def port = convertHexToInt(parts[1]) + return ip + ":" + port +} + +private updateSid(sid) { + if (sid) { + def sid0 = device.getDataValue('subscriptionId') + def sid1 = device.getDataValue('subscriptionId1') + def sidNumber = device.getDataValue('sidNumber') ?: "0" + if (sidNumber == "0") { + if (sid != sid1) { + updateDataValue("subscriptionId", sid) + updateDataValue("sidNumber", "1") + } + } + else { + if (sid != sid0) { + updateDataValue("subscriptionId1", sid) + updateDataValue("sidNumber", "0") + } + } + } +} + +private dniFromUri(uri) { + def segs = uri.replaceAll(/http:\/\/([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+)\/.+/,'$1').split(":") + def nums = segs[0].split("\\.") + (nums.collect{hex(it.toInteger())}.join('') + ':' + hex(segs[-1].toInteger(),4)).toUpperCase() +} + +private hex(value, width=2) { + def s = new BigInteger(Math.round(value).toString()).toString(16) + while (s.size() < width) { + s = "0" + s + } + s +} diff --git a/third-party/StriimLightConnect.groovy b/third-party/StriimLightConnect.groovy new file mode 100755 index 0000000..2998faa --- /dev/null +++ b/third-party/StriimLightConnect.groovy @@ -0,0 +1,417 @@ +/** + * StriimLight Connect v 0.1 + * + * Author: SmartThings - Ulises Mujica - obycode + */ + +definition( + name: "StriimLight (Connect)", + namespace: "obycode", + author: "SmartThings - Ulises Mujica - obycode", + description: "Allows you to control your StriimLight from the SmartThings app. Control the music and the light.", + category: "SmartThings Labs", + iconUrl: "http://obycode.com/img/icons/AwoxGreen.png", + iconX2Url: "http://obycode.com/img/icons/AwoxGreen@2x.png" +) + +preferences { + page(name: "MainPage", title: "Find and config your StriimLights",nextPage:"", install:true, uninstall: true){ + section("") { + href(name: "discover",title: "Discovery process",required: false,page: "striimLightDiscovery", description: "tap to start searching") + } + section("Options", hideable: true, hidden: true) { + input("refreshSLInterval", "number", title:"Enter refresh interval (min)", defaultValue:"5", required:false) + } + } + page(name: "striimLightDiscovery", title:"Discovery Started!", nextPage:"") +} + +def striimLightDiscovery() +{ + if(canInstallLabs()) + { + int striimLightRefreshCount = !state.striimLightRefreshCount ? 0 : state.striimLightRefreshCount as int + state.striimLightRefreshCount = striimLightRefreshCount + 1 + def refreshInterval = 5 + + def options = striimLightsDiscovered() ?: [] + + def numFound = options.size() ?: 0 + + if(!state.subscribe) { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + //striimLight discovery request every 5 //25 seconds + if((striimLightRefreshCount % 8) == 0) { + discoverstriimLights() + } + + //setup.xml request every 3 seconds except on discoveries + if(((striimLightRefreshCount % 1) == 0) && ((striimLightRefreshCount % 8) != 0)) { + verifystriimLightPlayer() + } + + return dynamicPage(name:"striimLightDiscovery", title:"Discovery Started!", nextPage:"", refreshInterval:refreshInterval) { + section("Please wait while we discover your Striim Light. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedstriimLight", "enum", required:false, title:"Select Striim Light(s) (${numFound} found)", multiple:true, options:options + } + } + } + else + { + def upgradeNeeded = """To use SmartThings Labs, your Hub should be completely up to date. + + To update your Hub, access Location Settings in the Main Menu (tap the gear next to your location name), select your Hub, and choose "Update Hub".""" + + return dynamicPage(name:"striimLightDiscovery", title:"Upgrade needed!", nextPage:"", install:false, uninstall: true) { + section("Upgrade") { + paragraph "$upgradeNeeded" + } + } + } +} + +private discoverstriimLights() +{ + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:DimmableLight:1", physicalgraph.device.Protocol.LAN)) +} + + +private verifystriimLightPlayer() { + def devices = getstriimLightPlayer().findAll { it?.value?.verified != true } + + devices.each { + verifystriimLight((it?.value?.ip + ":" + it?.value?.port), it?.value?.ssdpPath) + } +} + +private verifystriimLight(String deviceNetworkId, String ssdpPath) { + String ip = getHostAddress(deviceNetworkId) + if(!ssdpPath){ + ssdpPath = "/" + } + + sendHubCommand(new physicalgraph.device.HubAction("""GET $ssdpPath HTTP/1.1\r\nHOST: $ip\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}")) + //sendHubCommand(new physicalgraph.device.HubAction("""GET /aw/DimmableLight_SwitchPower/scpd.xml HTTP/1.1\r\nHOST: $ip\r\n\r\n""", physicalgraph.device.Protocol.LAN, "${deviceNetworkId}")) +} + +Map striimLightsDiscovered() { + def vstriimLights = getVerifiedstriimLightPlayer() + def map = [:] + vstriimLights.each { + def value = "${it.value.name}" + def key = it.value.ip + ":" + it.value.port + map["${key}"] = value + } + map +} + +def getstriimLightPlayer() +{ + state.striimLights = state.striimLights ?: [:] +} + +def getVerifiedstriimLightPlayer() +{ + getstriimLightPlayer().findAll{ it?.value?.verified == true } +} + +def installed() { + initialize()} + +def updated() { + unschedule() + initialize() +} + +def uninstalled() { + def devices = getChildDevices() + devices.each { + deleteChildDevice(it.deviceNetworkId) + } +} + +def initialize() { + // remove location subscription aftwards + unsubscribe() + state.subscribe = false + + unschedule() + + if (selectedstriimLight) { + addstriimLight() + } + scheduleActions() + scheduledRefreshHandler() +} + +def scheduledRefreshHandler() { + refreshAll() +} + +def scheduledActionsHandler() { + syncDevices() + runIn(60, scheduledRefreshHandler) + +} + +private scheduleActions() { + def minutes = Math.max(settings.refreshSLInterval.toInteger(),3) + def cron = "0 0/${minutes} * * * ?" + schedule(cron, scheduledActionsHandler) +} + + + +private syncDevices() { + log.debug "syncDevices()" + if(!state.subscribe) { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + discoverstriimLights() +} + +private refreshAll(){ + log.trace "refresh all" + childDevices*.refresh() +} + +def addstriimLight() { + def players = getVerifiedstriimLightPlayer() + def runSubscribe = false + selectedstriimLight.each { dni -> + def d = getChildDevice(dni) + log.trace "dni $dni" + if(!d) { + def newLight = players.find { (it.value.ip + ":" + it.value.port) == dni } + if (newLight){ + //striimLight + d = addChildDevice("obycode", "Striim Light", dni, newLight?.value.hub, [label:"${newLight?.value.name} Striim Light","data":["model":newLight?.value.model,"dcurl":newLight?.value.dcurl,"deurl":newLight?.value.deurl,"spcurl":newLight?.value.spcurl,"speurl":newLight?.value.speurl,"xclcurl":newLight?.value.xclcurl,"xcleurl":newLight?.value.xcleurl,"xwlcurl":newLight?.value.xwlcurl,"xwleurl":newLight?.value.xwleurl,"udn":newLight?.value.udn,"dni":dni]]) + } + runSubscribe = true + } + } +} + +def locationHandler(evt) { + def description = evt.description + def hub = evt?.hubId + def parsedEvent = parseEventMessage(description) + def msg = parseLanMessage(description) + parsedEvent << ["hub":hub] + + if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:DimmableLight:1")) + { //SSDP DISCOVERY EVENTS + log.debug "Striim Light device found" + parsedEvent + def striimLights = getstriimLightPlayer() + + + if (!(striimLights."${parsedEvent.ssdpUSN.toString()}")) + { //striimLight does not exist + striimLights << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } + else + { // update the values + + def d = striimLights."${parsedEvent.ssdpUSN.toString()}" + boolean deviceChangedValues = false + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port) { + d.ip = parsedEvent.ip + d.port = parsedEvent.port + deviceChangedValues = true + } + if (deviceChangedValues) { + def children = getChildDevices() + children.each { + if (parsedEvent.ssdpUSN.toString().contains(it.getDataValue("udn"))) { + it.setDeviceNetworkId((parsedEvent.ip + ":" + parsedEvent.port)) //could error if device with same dni already exists + it.updateDataValue("dni", (parsedEvent.ip + ":" + parsedEvent.port)) + log.trace "Updated Device IP" + } + } + } + } + } + if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:MediaRenderer:1")) + { //SSDP DISCOVERY EVENTS + log.debug "in media renderer section!!!!" + } + else if (parsedEvent.headers && parsedEvent.body) + { // MEDIARENDER RESPONSES + def headerString = new String(parsedEvent.headers.decodeBase64()) + def bodyString = new String(parsedEvent.body.decodeBase64()) + + def type = (headerString =~ /Content-Type:.*/) ? (headerString =~ /Content-Type:.*/)[0] : null + def body + if (bodyString?.contains("xml")) + { // description.xml response (application/xml) + body = new XmlSlurper().parseText(bodyString) + log.debug "got $body" + + // Find Awox devices + if ( body?.device?.manufacturer?.text().startsWith("Awox") && body?.device?.deviceType?.text().contains("urn:schemas-upnp-org:device:DimmableLight:1")) + { + def dcurl = "" + def deurl = "" + def spcurl = "" + def speurl = "" + def xclcurl = "" + def xcleurl = "" + def xwlcurl = "" + def xwleurl = "" + + body?.device?.serviceList?.service?.each { + if (it?.serviceType?.text().contains("Dimming")) { + dcurl = it?.controlURL.text() + deurl = it?.eventSubURL.text() + } + else if (it?.serviceType?.text().contains("SwitchPower")) { + spcurl = it?.controlURL.text() + speurl = it?.eventSubURL.text() + } + else if (it?.serviceType?.text().contains("X_ColorLight")) { + xclcurl = it?.controlURL.text() + xcleurl = it?.eventSubURL.text() + } + else if (it?.serviceType?.text().contains("X_WhiteLight")) { + xwlcurl = it?.controlURL.text() + xwleurl = it?.eventSubURL.text() + } + } + + + def striimLights = getstriimLightPlayer() + def player = striimLights.find {it?.key?.contains(body?.device?.UDN?.text())} + if (player) + { + player.value << [name:body?.device?.friendlyName?.text(),model:body?.device?.modelName?.text(), serialNumber:body?.device?.UDN?.text(), verified: true,dcurl:dcurl,deurl:deurl,spcurl:spcurl,speurl:speurl,xclcurl:xclcurl,xcleurl:xcleurl,xwlcurl:xwlcurl,xwleurl:xwleurl,udn:body?.device?.UDN?.text()] + } + + } + } + else if(type?.contains("json")) + { //(application/json) + body = new groovy.json.JsonSlurper().parseText(bodyString) + } + } +} + +private def parseEventMessage(Map event) { + //handles striimLight attribute events + return event +} + +private def parseEventMessage(String description) { + def event = [:] + def parts = description.split(',') + parts.each { part -> + part = part.trim() + if (part.startsWith('devicetype:')) { + def valueString = part.split(":")[1].trim() + event.devicetype = valueString + } + else if (part.startsWith('mac:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.mac = valueString + } + } + else if (part.startsWith('networkAddress:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.ip = valueString + } + } + else if (part.startsWith('deviceAddress:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.port = valueString + } + } + else if (part.startsWith('ssdpPath:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.ssdpPath = valueString + } + } + else if (part.startsWith('ssdpUSN:')) { + part -= "ssdpUSN:" + def valueString = part.trim() + if (valueString) { + event.ssdpUSN = valueString + } + } + else if (part.startsWith('ssdpTerm:')) { + part -= "ssdpTerm:" + def valueString = part.trim() + if (valueString) { + event.ssdpTerm = valueString + } + } + else if (part.startsWith('headers')) { + part -= "headers:" + def valueString = part.trim() + if (valueString) { + event.headers = valueString + } + } + else if (part.startsWith('body')) { + part -= "body:" + def valueString = part.trim() + if (valueString) { + event.body = valueString + } + } + } + + event +} + + +/////////CHILD DEVICE METHODS +def parse(childDevice, description) { + def parsedEvent = parseEventMessage(description) + + if (parsedEvent.headers && parsedEvent.body) { + def headerString = new String(parsedEvent.headers.decodeBase64()) + def bodyString = new String(parsedEvent.body.decodeBase64()) + + def body = new groovy.json.JsonSlurper().parseText(bodyString) + } else { + return [] + } +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +private getHostAddress(d) { + def parts = d.split(":") + def ip = convertHexToIP(parts[0]) + def port = convertHexToInt(parts[1]) + return ip + ":" + port +} + +private Boolean canInstallLabs() +{ + return hasAllHubsOver("000.011.00603") +} + +private Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +private List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} diff --git a/third-party/Switches.groovy b/third-party/Switches.groovy new file mode 100755 index 0000000..3f8ea92 --- /dev/null +++ b/third-party/Switches.groovy @@ -0,0 +1,60 @@ +/** + * Copyright 2015 Jesse Newland + * + * 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: "Hello Home / Mode Switches", + namespace: "jnewland", + author: "Jesse Newland", + description: "Name on/off tiles the same as your Hello Home phrases or Modes", + category: "SmartThings Labs", + 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") + + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + initialize() +} + +def initialize() { + subscribe(switches, "switch", switchHandler) +} + +def switchHandler(evt) { + def s = switches.find{ evt.deviceId == it.id } + def phrase = location.helloHome.getPhrases().find { it.label == s.displayName } + if (phrase) { + location.helloHome.execute(phrase.label) + } + def mode = location.modes.find { it.name == s.displayName } + if (mode) { + setLocationMode(mode) + } +} + +preferences { + page(name: selectSwitches) +} + +def selectSwitches() { + dynamicPage(name: "selectSwitches", title: "Switches", install: true) { + section("Select switches named after Hello Home phrases") { + input "switches", "capability.switch", title: "Switches", multiple: true + } + } +} diff --git a/third-party/VirtualButton.groovy b/third-party/VirtualButton.groovy new file mode 100755 index 0000000..f8c24ca --- /dev/null +++ b/third-party/VirtualButton.groovy @@ -0,0 +1,64 @@ +/** + * Virtual Button + * + * Copyright 2015 obycode + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ +metadata { + definition (name: "Virtual Button", namespace: "com.obycode", author: "obycode") { + capability "Button" + capability "Sensor" + + command "push" + command "hold" + command "release" + } + + simulator { + // TODO: define status and reply messages here + } + + tiles { + standardTile("button", "device.button", canChangeIcon: true, inactiveLabel: false, width: 2, height: 2) { + state "default", label: '', icon: "st.secondary.off", action: "push" + state "pressed", label: 'Pressed', icon: "st.illuminance.illuminance.dark", backgroundColor: "#66ccff", action: "release" + state "held", label: 'Held', icon: "st.illuminance.illuminance.light", backgroundColor: "#0066ff", action: "release" + } + + main "button" + details(["button"]) + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + if (description == "updated") { + sendEvent(name: "button", value: "released") + } +} + +// handle commands +def push() { + log.debug "Executing 'push'" + sendEvent(name: "button", value: "pushed", /*data: [buttonNumber: button], descriptionText: "$device.displayName button $button was pressed",*/ isStateChange: true) +} + +def hold() { + log.debug "Executing 'hold'" + sendEvent(name: "button", value: "held", /*data: [buttonNumber: button], descriptionText: "$device.displayName button $button was held",*/ isStateChange: true) +} + +def release() { + log.debug "Executing 'release'" + sendEvent(name: "button", value: "default", /*data: [buttonNumber: button], descriptionText: "$device.displayName button $button was held",*/ isStateChange: true) +} diff --git a/third-party/VirtualButtons.groovy b/third-party/VirtualButtons.groovy new file mode 100755 index 0000000..2d3489c --- /dev/null +++ b/third-party/VirtualButtons.groovy @@ -0,0 +1,75 @@ +/** + * Virtual Buttons for Multi-Button Controllers (ex. Minimote or ZWN-SC7) + * + * Author: obycode + * Date Created: 2015-05-13 + * + */ +definition( + name: "Virtual Buttons", + namespace: "com.obycode", + author: "obycode", + description: "Create virtual single button devices for each button of your multi-button device (ex. Minimote or ZWN-SC7)", + category: "Convenience", + iconUrl: "http://cdn.device-icons.smartthings.com/unknown/zwave/remote-controller.png", + iconX2Url: "http://cdn.device-icons.smartthings.com/unknown/zwave/remote-controller@2x.png" +) + +preferences { + section ("Select your button controller: ") { + input "buttonDevice", "capability.button", title: "Which?", multiple: false, required: true + } +} + +def installed() { + initialize() +} + +def updated() { +} + +def initialize() { + def numButtons = buttonDevice.currentValue("numButtons").toInteger() + log.info "Creating $numButtons virtual buttons" + // Create the virtual buttons + (1..numButtons).each { + def d = addChildDevice("com.obycode", "Virtual Button", buttonDevice.id + ":" + it.toString(), null, [label:buttonDevice.displayName + " " + it.toString(), name:"Virtual Button", completedSetup: true]) + } + + // Subscribe to the button events + subscribe(buttonDevice, "button", buttonEvent) +} + +def uninstalled() { + unsubscribe() + def delete = getChildDevices() + delete.each { + deleteChildDevice(it.deviceNetworkId) + } +} + +def buttonEvent(evt) { + log.debug "buttonEvent: $evt.name $evt.value ($evt.data)" + def buttonNumber = evt.jsonData.buttonNumber + + def buttonId = buttonDevice.id + ":" + buttonNumber + def children = getChildDevices() + def childButton = children.find{ d -> d.deviceNetworkId == buttonId } + + switch (evt.value) { + case "pushed": + log.debug "pushing the virtual button" + childButton.push() + break + case "held": + log.debug "holding the virtual button" + childButton.hold() + break + case "default": + log.debug "releasing the virtual button" + childButton.release() + break + default: + log.debug "Unknown event: $evt.value" + } +} diff --git a/third-party/WindowOrDoorOpen.groovy b/third-party/WindowOrDoorOpen.groovy new file mode 100755 index 0000000..cea0a32 --- /dev/null +++ b/third-party/WindowOrDoorOpen.groovy @@ -0,0 +1,610 @@ +/** + * WindowOrDoorOpen! + * + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * Compatible with MyEcobee device available at my store: + * http://www.ecomatiqhomes.com/#!store/tc3yr + * + */ +definition( + name: "WindowOrDoorOpen!", + namespace: "yracine", + author: "Yves Racine", + description: "Choose some contact sensors and get a notification (with voice as an option) when they are left open for too long. Optionally, turn off the HVAC and set it back to cool/heat when window/door is closed", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") + + +preferences { + section("About") { + paragraph "WindowOrDoorOpen!, the smartapp that warns you if you leave a door or window open (with voice as an option);" + + "it will turn off your thermostats (optional) after a delay and restore their mode when the contact is closed." + + "The smartapp can track up to 30 contacts and can keep track of 6 open contacts at the same time due to ST scheduling limitations" + paragraph "Version 2.4.1" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + section("Notify me when the following door(s) or window contact(s) are left open (maximum 30 contacts)...") { + input "theSensor", "capability.contactSensor", multiple:true, required: true + } + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + input "frequency", "number", title: "Delay between notifications in minutes", description: "", required: false + input "givenMaxNotif", "number", title: "Max Number of Notifications", description: "", required: false + } + section("Use Speech capability to warn the residents [optional]") { + input "theVoice", "capability.speechSynthesis", required: false, multiple: true + input "powerSwitch", "capability.switch", title: "On/off switch for Voice notifications? [optional]", required: false + } + section("And, when contact is left open for more than this delay in minutes [default=5 min.]") { + input "maxOpenTime", "number", title: "Minutes?", required:false + } + section("Turn off the thermostat(s) after the delay;revert this action when closed [optional]") { + input "tstats", "capability.thermostat", multiple: true, required: false + } + section("What do I use as the Master on/off switch to enable/disable other smartapps' processing? [optional,ex.for zoned heating/cooling solutions]") { + input (name:"masterSwitch", type:"capability.switch", required: false, description: "Optional") + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + def MAX_CONTACT=30 + state?.lastThermostatMode = null + // subscribe to all contact sensors to check for open/close events + state?.status=[] + state?.count=[] + state.lastThermostatMode = "" + + int i=0 + theSensor.each { + subscribe(theSensor[i], "contact.closed", "sensorTriggered${i}") + subscribe(theSensor[i], "contact.open", "sensorTriggered${i}") + state?.status[i] = " " + state?.count[i] = 0 + i++ + if (i>=MAX_CONTACT) { + return + } + } + +} + +def sensorTriggered0(evt) { + int i=0 + sensorTriggered(evt,i) +} + +def sensorTriggered1(evt) { + int i=1 + sensorTriggered(evt,i) +} + +def sensorTriggered2(evt) { + int i=2 + sensorTriggered(evt,i) +} + +def sensorTriggered3(evt) { + int i=3 + sensorTriggered(evt,i) +} + +def sensorTriggered4(evt) { + int i=4 + sensorTriggered(evt,i) +} + +def sensorTriggered5(evt) { + int i=5 + sensorTriggered(evt,i) +} + +def sensorTriggered6(evt) { + int i=6 + sensorTriggered(evt,i) +} + +def sensorTriggered7(evt) { + int i=7 + sensorTriggered(evt,i) +} + +def sensorTriggered8(evt) { + int i=8 + sensorTriggered(evt,i) +} + +def sensorTriggered9(evt) { + int i=9 + sensorTriggered(evt,i) +} + +def sensorTriggered10(evt) { + int i=10 + sensorTriggered(evt,i) +} + +def sensorTriggered11(evt) { + int i=11 + sensorTriggered(evt,i) +} + +def motionEvtHandler12(evt) { + int i=12 + sensorTriggered(evt,i) +} + +def sensorTriggered13(evt) { + int i=13 + sensorTriggered(evt,i) +} + +def sensorTriggered14(evt) { + int i=14 + sensorTriggered(evt,i) +} + +def sensorTriggered15(evt) { + int i=15 + sensorTriggered(evt,i) +} + +def sensorTriggered16(evt) { + int i=16 + sensorTriggered(evt,i) +} + +def sensorTriggered17(evt) { + int i=17 + sensorTriggered(evt,i) +} + +def sensorTriggered18(evt) { + int i=18 + sensorTriggered(evt,i) +} + +def sensorTriggered19(evt) { + int i=19 + sensorTriggered(evt,i) +} + +def sensorTriggered20(evt) { + int i=20 + sensorTriggered(evt,i) +} + +def sensorTriggered21(evt) { + int i=21 + sensorTriggered(evt,i) +} + +def sensorTriggered22(evt) { + int i=22 + sensorTriggered(evt,i) +} + +def sensorTriggered23(evt) { + int i=23 + sensorTriggered(evt,i) +} + +def sensorTriggered24(evt) { + int i=24 + sensorTriggered(evt,i) +} + +def sensorTriggered25(evt) { + int i=25 + sensorTriggered(evt,i) +} + +def sensorTriggered26(evt) { + int i=26 + sensorTriggered(evt,i) +} + +def sensorTriggered27(evt) { + int i=27 + sensorTriggered(evt,i) +} + +def sensorTriggered28(evt) { + int i=28 + sensorTriggered(evt,i) +} + + +def sensorTriggered29(evt) { + int i=29 + sensorTriggered(evt,i) +} + + +def sensorTriggered(evt, indice=0) { + def delay = (frequency) ?: 1 + def freq = delay * 60 + def max_open_time_in_min = maxOpenTime ?: 5 // By default, 5 min. is the max open time + + if (evt.value == "closed") { + restore_tstats_mode() + def msg = "your ${theSensor[indice]} is now closed" + send("WindowOrDoorOpen>${msg}") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.setLevel(30) + theVoice.speak(msg) + } + clearStatus(indice) + } else if ((evt.value == "open") && (state?.status[indice] != "scheduled")) { + def takeActionMethod= "takeAction${indice}" + runIn(freq, "${takeActionMethod}",[overwrite: false]) + state?.status[indice] = "scheduled" + log.debug "${theSensor[indice]} is now open and will be checked every ${delay} minute(s) by ${takeActionMethod}" + } +} + + +def takeAction0() { + int i=0 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + + +def takeAction1() { + int i=1 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction2() { + int i=2 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction3() { + int i=3 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction4() { + int i=4 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction5() { + int i=5 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction6() { + int i=6 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction7() { + int i=7 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction8() { + int i=8 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction9() { + int i=9 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + + +def takeAction10() { + int i=10 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction11() { + int i=11 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction12() { + int i=12 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction13() { + int i=13 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction14() { + int i=14 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + + +def takeAction15() { + int i=15 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction16() { + int i=16 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + + +def takeAction17() { + int i=17 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction18() { + int i=18 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction19() { + int i=19 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction20() { + int i=20 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + + +def takeAction21() { + int i=21 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction22() { + int i=22 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction23() { + int i=23 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + + +def takeAction24() { + int i=24 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction25() { + int i=25 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction26() { + int i=26 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction27() { + int i=27 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + + +def takeAction28() { + int i=28 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + +def takeAction29() { + int i=29 + log.debug ("about to call takeAction() for ${theSensor[i]}") + takeAction(i) +} + + +def takeAction(indice=0) { + def takeActionMethod + def delay = (frequency) ?: 1 + def freq = delay * 60 + def maxNotif = (givenMaxNotif) ?: 5 + def max_open_time_in_min = maxOpenTime ?: 5 // By default, 5 min. is the max open time + def msg + + def contactState = theSensor[indice].currentState("contact") + log.trace "takeAction>${theSensor[indice]}'s contact status = ${contactState.value}, state.status=${state.status[indice]}, indice=$indice" + if ((state?.status[indice] == "scheduled") && (contactState.value == "open")) { + state.count[indice] = state.count[indice] + 1 + log.debug "${theSensor[indice]} was open too long, sending message (count=${state.count[indice]})" + def openMinutesCount = state.count[indice] * delay + msg = "your ${theSensor[indice]} has been open for more than ${openMinutesCount} minute(s)!" + send("WindowOrDoorOpen>${msg}") + if ((masterSwitch) && (masterSwitch?.currentSwitch=="on")) { + log.debug "master switch ${masterSwitch} is now off" + masterSwitch.off() // set the master switch to off as there is an open contact + } + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.setLevel(30) + theVoice.speak(msg) + } + + if ((tstats) && (openMinutesCount > max_open_time_in_min)) { + + save_tstats_mode() + tstats.off() + + msg = "thermostats are now turned off after ${max_open_time_in_min} minutes" + send("WindowDoorOpen>${msg}") + } + if ((!tstats) && (state.count[indice] > maxNotif)) { + // stop the repeated notifications if there is no thermostats provided and we've reached maxNotif + clearStatus(indice) + takeActionMethod= "takeAction${indice}" + unschedule("${takeActionMethod}") + msg = "maximum notifications ($maxNotif) reached for ${theSensor[indice]}, unscheduled $takeActionMethod" + log.debug msg + return + } + takeActionMethod= "takeAction${indice}" + msg = "contact still open at ${theSensor[indice]}, about to reschedule $takeActionMethod" + log.debug msg + runIn(freq, "${takeActionMethod}", [overwrite: false]) + } else if (contactState.value == "closed") { + restore_tstats_mode() + clearStatus(indice) + takeActionMethod= "takeAction${indice}" + unschedule("${takeActionMethod}") + msg = "contact closed at ${theSensor[indice]}, unscheduled $takeActionMethod" + log.debug msg + } +} + +def clearStatus(indice=0) { + state?.status[indice] = " " + state?.count[indice] = 0 +} + + +private void save_tstats_mode() { + + if ((!tstats) || (state.lastThermostatMode)) { // If state already saved, then keep it + return + } + tstats.each { + it.poll() // to get the latest value at thermostat + state.lastThermostatMode = state.lastThermostatMode + "${it.currentThermostatMode}" + "," + } + log.debug "save_tstats_mode>state.lastThermostatMode= $state.lastThermostatMode" + +} + + +private void restore_tstats_mode() { + def msg + def MAX_CONTACT=30 + + log.debug "restore_tstats_mode>checking if all contacts are closed..." + for (int j = 0;(j < MAX_CONTACT); j++) { + if (!theSensor[j]) continue + def contactState = theSensor[j].currentState("contact") + log.trace "restore_tstats_mode>For ${theSensor[j]}, Contact's status = ${contactState.value}, indice=$j" + if (contactState.value == "open") { + return + } + } + if ((masterSwitch) && (masterSwitch?.currentSwitch=="off")) { + log.debug "master switch ${masterSwitch} is back on" + masterSwitch.on() // set the master switch to on as there is no more any open contacts + } + + if (!tstats) { + return + } + + if (state.lastThermostatMode) { + def lastThermostatMode = state.lastThermostatMode.toString().split(',') + int i = 0 + + tstats.each { + def lastSavedMode = lastThermostatMode[i].trim() + if (lastSavedMode) { + log.debug "restore_tstats_mode>about to set ${it}, back to saved thermostatMode=${lastSavedMode}" + if (lastSavedMode == 'cool') { + it.cool() + } else if (lastSavedMode.contains('heat')) { + it.heat() + } else if (lastSavedMode == 'auto') { + it.auto() + } else { + it.off() + } + msg = "thermostat ${it}'s mode is now set back to ${lastSavedMode}" + send("WindowOrDoorOpen>${theSensor} closed, ${msg}") + if ((theVoice) && (powerSwitch?.currentSwitch == "on")) { // Notify by voice only if the powerSwitch is on + theVoice.speak(msg) + } + + } + i++ + } + } + state.lastThermostatMode = "" +} + + + +private send(msg) { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + log.debug msg +} diff --git a/third-party/WorkingFromHome.groovy b/third-party/WorkingFromHome.groovy new file mode 100755 index 0000000..588c82e --- /dev/null +++ b/third-party/WorkingFromHome.groovy @@ -0,0 +1,125 @@ +/** + * Working From Home + * + * Copyright 2014 George Sudarkoff + * + * 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: "Working From Home", + namespace: "com.sudarkoff", + author: "George Sudarkoff", + description: "If after a particular time of day a certain person is still at home, trigger a 'Working From Home' action.", + category: "Mode Magic", + iconUrl: "https://s3.amazonaws.com/smartthings-device-icons/Office/office22-icn.png", + iconX2Url: "https://s3.amazonaws.com/smartthings-device-icons/Office/office22-icn@2x.png" +) + +preferences { + page (name:"configActions") +} + +def configActions() { + dynamicPage(name: "configActions", title: "Configure Actions", uninstall: true, install: true) { + section ("When this person") { + input "person", "capability.presenceSensor", title: "Who?", multiple: false, required: true + } + section ("Still at home past") { + input "timeOfDay", "time", title: "What time?", required: true + } + + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + phrases.sort() + section("Perform this action") { + input "wfhPhrase", "enum", title: "\"Hello, Home\" action", required: true, options: phrases + } + } + + section (title: "More options", hidden: hideOptions(), hideable: true) { + input "sendPushMessage", "bool", title: "Send a push notification?" + input "phone", "phone", title: "Send a Text Message?", required: false + input "days", "enum", title: "Set for specific day(s) of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + } + + section([mobileOnly:true]) { + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + +def installed() { + initialize() +} + +def updated() { + unschedule() + initialize() +} + +def initialize() { + schedule(timeToday(timeOfDay, location.timeZone), "checkPresence") + if (customName) { + app.setTitle(customName) + } +} + +def checkPresence() { + if (daysOk && modeOk) { + if (person.latestValue("presence") == "present") { + log.debug "${person} is present, triggering WFH action." + location.helloHome.execute(settings.wfhPhrase) + def message = "${location.name} executed '${settings.wfhPhrase}' because ${person} is home." + send(message) + } + } +} + +private send(msg) { + if (sendPushMessage != "No") { + sendPush(msg) + } + + if (phone) { + sendSms(phone, msg) + } + + log.debug msg +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + result +} + +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("America/Los_Angeles")) + } + def day = df.format(new Date()) + result = days.contains(day) + } + result +} + +private hideOptions() { + (days || modes)? false: true +} + diff --git a/third-party/YouLeftTheDoorOpen.groovy b/third-party/YouLeftTheDoorOpen.groovy new file mode 100755 index 0000000..e183cd9 --- /dev/null +++ b/third-party/YouLeftTheDoorOpen.groovy @@ -0,0 +1,79 @@ +/** + * You left the door open! + * + * Copyright 2014 ObyCode + * + * 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: "You left the door open!", + namespace: "com.obycode", + author: "ObyCode", + description: "Choose a contact sensor from your alarm system (AlarmThing) and get a notification when it is left open for too long.", + category: "Safety & Security", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") + + +preferences { + section("Notify me when the door named...") { + input "theSensor", "string", multiple: false, required: true + } + section("On this alarm...") { + input "theAlarm", "capability.alarm", multiple: false, required: true + } + section("Is left open for more than...") { + input "maxOpenTime", "number", title: "Minutes?" + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(theAlarm, theSensor, sensorTriggered) +} + +def sensorTriggered(evt) { + if (evt.value == "closed") { + clearStatus() + } + else if (evt.value == "open" && state.status != "scheduled") { + runIn(maxOpenTime * 60, takeAction, [overwrite: false]) + state.status = "scheduled" + } +} + +def takeAction(){ + if (state.status == "scheduled") + { + log.debug "$theSensor was open too long, sending message" + def msg = "Your $theSensor has been open for more than $maxOpenTime minutes!" + sendPush msg + clearStatus() + } else { + log.trace "Status is no longer scheduled. Not sending text." + } +} + +def clearStatus() { + state.status = null +} diff --git a/third-party/ZWN-SC7.groovy b/third-party/ZWN-SC7.groovy new file mode 100755 index 0000000..7d42ae5 --- /dev/null +++ b/third-party/ZWN-SC7.groovy @@ -0,0 +1,267 @@ +/** + * ZWN-SC7 Enerwave 7 Button Scene Controller + * + * Author: Matt Frank based on VRCS Button Controller by Brian Dahlem, based on SmartThings Button Controller + * Date Created: 2014-12-18 + * Last Updated: 2015-02-13 + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ + +metadata { + // Automatically generated. Make future change here. + definition (name: "ZWN-SC7 Enerwave 7 Button Scene Controller", namespace: "mattjfrank", author: "Matt Frank") { + capability "Actuator" + capability "Button" + capability "Configuration" + capability "Indicator" + capability "Sensor" + + attribute "currentButton", "STRING" + attribute "numButtons", "STRING" + + fingerprint deviceId: "0x0202", inClusters:"0x21, 0x2D, 0x85, 0x86, 0x72" + fingerprint deviceId: "0x0202", inClusters:"0x2D, 0x85, 0x86, 0x72" + } + + simulator { + status "button 1 pushed": "command: 2B01, payload: 01 FF" + status "button 2 pushed": "command: 2B01, payload: 02 FF" + status "button 3 pushed": "command: 2B01, payload: 03 FF" + status "button 4 pushed": "command: 2B01, payload: 04 FF" + status "button 5 pushed": "command: 2B01, payload: 05 FF" + status "button 6 pushed": "command: 2B01, payload: 06 FF" + status "button 7 pushed": "command: 2B01, payload: 07 FF" + status "button released": "command: 2C02, payload: 00" + } + + tiles { + standardTile("button", "device.button", width: 2, height: 2) { + state "default", label: " ", icon: "st.unknown.zwave.remote-controller", backgroundColor: "#ffffff" + state "button 1", label: "1", icon: "st.Weather.weather14", backgroundColor: "#79b821" + state "button 2", label: "2", icon: "st.Weather.weather14", backgroundColor: "#79b821" + state "button 3", label: "3", icon: "st.Weather.weather14", backgroundColor: "#79b821" + state "button 4", label: "4", icon: "st.Weather.weather14", backgroundColor: "#79b821" + state "button 5", label: "5", icon: "st.Weather.weather14", backgroundColor: "#79b821" + state "button 6", label: "6", icon: "st.Weather.weather14", backgroundColor: "#79b821" + state "button 7", label: "7", icon: "st.Weather.weather14", backgroundColor: "#79b821" + } + + // Configure button. Syncronize the device capabilities that the UI provides + standardTile("configure", "device.configure", inactiveLabel: false, decoration: "flat") { + state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure" + } + + main "button" + details (["button", "configure"]) + } +} + +// parse events into attributes +def parse(String description) { + log.debug "Parsing '${description}'" + + def result = null + def cmd = zwave.parse(description) + if (cmd) { + result = zwaveEvent(cmd) + } + + return result +} + +// Handle a button being pressed +def buttonEvent(button) { + button = button as Integer + def result = [] + + updateState("currentButton", "$button") + + if (button > 0) { + // update the device state, recording the button press + result << createEvent(name: "button", value: "pushed", data: [buttonNumber: button], descriptionText: "$device.displayName button $button was pushed", isStateChange: true) + + // turn off the button LED + result << response(zwave.sceneActuatorConfV1.sceneActuatorConfReport(dimmingDuration: 255, level: 255, sceneId: 0)) + } + else { + // update the device state, recording the button press + result << createEvent(name: "button", value: "default", descriptionText: "$device.displayName button was released", isStateChange: true) + + result << response(zwave.sceneActuatorConfV1.sceneActuatorConfReport(dimmingDuration: 255, level: 255, sceneId: 0)) + } + + result +} + +// A zwave command for a button press was received +def zwaveEvent(physicalgraph.zwave.commands.sceneactivationv1.SceneActivationSet cmd) { + + // The controller likes to repeat the command... ignore repeats + if (state.lastScene == cmd.sceneId && (state.repeatCount < 4) && (now() - state.repeatStart < 1000)) { + log.debug "Button ${cmd.sceneId} repeat ${state.repeatCount}x ${now()}" + state.repeatCount = state.repeatCount + 1 + createEvent([:]) + } + else { + // If the button was really pressed, store the new scene and handle the button press + state.lastScene = cmd.sceneId + state.lastLevel = 0 + state.repeatCount = 0 + state.repeatStart = now() + + buttonEvent(cmd.sceneId) + } +} + +// A scene command was received -- it's probably scene 0, so treat it like a button release +def zwaveEvent(physicalgraph.zwave.commands.sceneactuatorconfv1.SceneActuatorConfGet cmd) { + buttonEvent(cmd.sceneId) +} + +// The controller sent a scene activation report. Log it, but it really shouldn't happen. +def zwaveEvent(physicalgraph.zwave.commands.sceneactuatorconfv1.SceneActuatorConfReport cmd) { + log.debug "Scene activation report" + log.debug "Scene ${cmd.sceneId} set to ${cmd.level}" + + createEvent([:]) +} + + +// Configuration Reports are replys to configuration value requests... If we knew what configuration parameters +// to request this could be very helpful. +def zwaveEvent(physicalgraph.zwave.commands.configurationv1.ConfigurationReport cmd) { + createEvent([:]) +} + +// The VRC supports hail commands, but I haven't seen them. +def zwaveEvent(physicalgraph.zwave.commands.hailv1.Hail cmd) { + createEvent([name: "hail", value: "hail", descriptionText: "Switch button was pressed", displayed: false]) +} + +// Update manufacturer information when it is reported +def zwaveEvent(physicalgraph.zwave.commands.manufacturerspecificv2.ManufacturerSpecificReport cmd) { + if (state.manufacturer != cmd.manufacturerName) { + updateDataValue("manufacturer", cmd.manufacturerName) + } + + createEvent([:]) +} + +// Association Groupings Reports tell us how many groupings the device supports. This equates to the number of +// buttons/scenes in the VRCS +def zwaveEvent(physicalgraph.zwave.commands.associationv2.AssociationGroupingsReport cmd) { + def response = [] + + log.debug "${getDataByName("numButtons")} buttons stored" + if (getDataByName("numButtons") != "$cmd.supportedGroupings") { + updateState("numButtons", "$cmd.supportedGroupings") + log.debug "${cmd.supportedGroupings} groups available" + response << createEvent(name: "numButtons", value: cmd.supportedGroupings, displayed: false) + + response << associateHub() + } + else { + response << createEvent(name: "numButtons", value: cmd.supportedGroupings, displayed: false) + } + + return response +} + + +// Handles all Z-Wave commands we don't know we are interested in +def zwaveEvent(physicalgraph.zwave.Command cmd) { + createEvent([:]) +} + +// handle commands + +// Create a list of the configuration commands to send to the device +def configurationCmds() { + // Always check the manufacturer and the number of groupings allowed + def commands = [ + zwave.manufacturerSpecificV1.manufacturerSpecificGet().format(), + zwave.associationV1.associationGroupingsGet().format(), + zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:1, sceneId:1).format(), + zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:2, sceneId:2).format(), + zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:3, sceneId:3).format(), + zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:4, sceneId:4).format(), + zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:5, sceneId:5).format(), + zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:6, sceneId:6).format(), + zwave.sceneControllerConfV1.sceneControllerConfSet(groupId:7, sceneId:7).format() + ] + + commands << associateHub() + + delayBetween(commands) +} + +// Configure the device +def configure() { + def cmd=configurationCmds() + log.debug("Sending configuration: ${cmd}") + return cmd +} + + +// +// Associate the hub with the buttons on the device, so we will get status updates +def associateHub() { + def commands = [] + + // Loop through all the buttons on the controller + for (def buttonNum = 1; buttonNum <= integer(getDataByName("numButtons")); buttonNum++) { + + // Associate the hub with the button so we will get status updates + commands << zwave.associationV1.associationSet(groupingIdentifier: buttonNum, nodeId: zwaveHubNodeId).format() + + } + + return commands +} + +// Update State +// Store mode and settings +def updateState(String name, String value) { + state[name] = value + device.updateDataValue(name, value) +} + +// Get Data By Name +// Given the name of a setting/attribute, lookup the setting's value +def getDataByName(String name) { + state[name] ?: device.getDataValue(name) +} + +//Stupid conversions + +// convert a double to an integer +def integer(double v) { + return v.toInteger() +} + +// convert a hex string to integer +def integerhex(String v) { + if (v == null) { + return 0 + } + + return Integer.parseInt(v, 16) +} + +// convert a hex string to integer +def integer(String v) { + if (v == null) { + return 0 + } + + return Integer.parseInt(v) +} diff --git a/third-party/Zwave-indicator-manager.groovy b/third-party/Zwave-indicator-manager.groovy new file mode 100755 index 0000000..fe0fdbc --- /dev/null +++ b/third-party/Zwave-indicator-manager.groovy @@ -0,0 +1,65 @@ +// Automatically generated. Make future change here. +definition( + name: "Zwave Switch Indicator Light Manager", + namespace: "sticks18", + author: "Scott Gibson", + description: "Changes the indicator light setting to always be off", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png" +) + +preferences { + section("When these switches are toggled adjust the indicator...") { + input "mains", "capability.switch", + multiple: true, + title: "Switches to fix...", + required: false + } + + section("When these switches are toggled adjust the indicator in reverse (useful for Linear brand)...") { + input "mains2", "capability.switch", + multiple: true, + title: "Switches to fix in reverse...", + required: false + } + +} + +def installed() +{ + subscribe(mains, "switch.on", switchOnHandler) + subscribe(mains, "switch.off", switchOffHandler) + subscribe(mains2, "switch.on", switchOnHandler2) + subscribe(mains2, "switch.off", switchOffHandler2) +} + +def updated() +{ + unsubscribe() + subscribe(mains, "switch.on", switchOnHandler) + subscribe(mains, "switch.off", switchOffHandler) + subscribe(mains2, "switch.on", switchOnHandler2) + subscribe(mains2, "switch.off", switchOffHandler2) + log.info "subscribed to all of switches events" +} + +def switchOffHandler(evt) { + log.info "switchoffHandler Event: ${evt.value}" + mains?.indicatorWhenOn() +} + +def switchOnHandler(evt) { + log.info "switchOnHandler Event: ${evt.value}" + mains?.indicatorWhenOff() +} + +def switchOffHandler2(evt) { + log.info "switchoffHandler2 Event: ${evt.value}" + mains2?.indicatorWhenOff() +} + +def switchOnHandler2(evt) { + log.info "switchOnHandler2 Event: ${evt.value}" + mains2?.indicatorWhenOn() +} diff --git a/third-party/auto-lock-door.smartapp.groovy b/third-party/auto-lock-door.smartapp.groovy new file mode 100755 index 0000000..0961673 --- /dev/null +++ b/third-party/auto-lock-door.smartapp.groovy @@ -0,0 +1,101 @@ +/** + * Auto Lock Door + * + * Author: Chris Sader (@csader) + * Collaborators: @chrisb + * Date: 2013-08-21 + * URL: http://www.github.com/smartthings-users/smartapp.auto-lock-door + * + * Copyright (C) 2013 Chris Sader. + * Permission is hereby granted, free of charge, to any person obtaining a copy of this + * software and associated documentation files (the "Software"), to deal in the Software + * without restriction, including without limitation the rights to use, copy, modify, + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to the following + * conditions: The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +preferences +{ + section("When a door unlocks...") { + input "lock1", "capability.lock" + } + section("Lock it how many minutes later?") { + input "minutesLater", "number", title: "When?" + } + section("Lock it only when this door is closed") { + input "openSensor", "capability.contactSensor", title: "Where?" + } +} + +def installed() +{ + log.debug "Auto Lock Door installed. (URL: http://www.github.com/smartthings-users/smartapp.auto-lock-door)" + initialize() +} + +def updated() +{ + unsubscribe() + unschedule() + log.debug "Auto Lock Door updated." + initialize() +} + +def initialize() +{ + log.debug "Settings: ${settings}" + subscribe(lock1, "lock", doorHandler) + subscribe(openSensor, "contact.closed", doorClosed) + subscribe(openSensor, "contact.open", doorOpen) +} + +def lockDoor() +{ + log.debug "Locking Door if Closed" + if((openSensor.latestValue("contact") == "closed")){ + log.debug "Door Closed" + lock1.lock() + } else { + if ((openSensor.latestValue("contact") == "open")) { + def delay = minutesLater * 60 + log.debug "Door open will try again in $minutesLater minutes" + runIn( delay, lockDoor ) + } + } +} + +def doorOpen(evt) { + log.debug "Door open reset previous lock task..." + unschedule( lockDoor ) + def delay = minutesLater * 60 + runIn( delay, lockDoor ) +} + +def doorClosed(evt) { + log.debug "Door Closed" +} + +def doorHandler(evt) +{ + log.debug "Door ${openSensor.latestValue}" + log.debug "Lock ${evt.name} is ${evt.value}." + + if (evt.value == "locked") { // If the human locks the door then... + log.debug "Cancelling previous lock task..." + unschedule( lockDoor ) // ...we don't need to lock it later. + } + else { // If the door is unlocked then... + def delay = minutesLater * 60 // runIn uses seconds + log.debug "Re-arming lock in ${minutesLater} minutes (${delay}s)." + runIn( delay, lockDoor ) // ...schedule to lock in x minutes. + } +} diff --git a/third-party/buffered-event-sender.groovy b/third-party/buffered-event-sender.groovy new file mode 100755 index 0000000..1265df6 --- /dev/null +++ b/third-party/buffered-event-sender.groovy @@ -0,0 +1,398 @@ +/** + * Initial State Event Streamer + * + * Copyright 2015 David Sulpy + * + * 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. + * + * SmartThings data is sent from this SmartApp to Initial State. This is event data only for + * devices for which the user has authorized. Likewise, Initial State's services call this + * SmartApp on the user's behalf to configure Initial State specific parameters. The ToS and + * Privacy Policy for Initial State can be found here: https://www.initialstate.com/terms + */ + +definition( + name: "Initial State Event Streamer", + namespace: "initialstate.events", + author: "David Sulpy", + description: "A SmartThings SmartApp to allow SmartThings events to be viewable inside an Initial State Event Bucket in your https://www.initialstate.com account.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertica_small.png", + iconX2Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + iconX3Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + oauth: [displayName: "Initial State", displayLink: "https://www.initialstate.com"]) + +import groovy.json.JsonSlurper + +preferences { + section("Choose which devices to monitor...") { + input "accelerometers", "capability.accelerationSensor", title: "Accelerometers", multiple: true, required: false + input "alarms", "capability.alarm", title: "Alarms", multiple: true, required: false + input "batteries", "capability.battery", title: "Batteries", multiple: true, required: false + input "beacons", "capability.beacon", title: "Beacons", multiple: true, required: false + input "cos", "capability.carbonMonoxideDetector", title: "Carbon Monoxide Detectors", multiple: true, required: false + input "colors", "capability.colorControl", title: "Color Controllers", multiple: true, required: false + input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false + input "doorsControllers", "capability.doorControl", title: "Door Controllers", multiple: true, required: false + input "energyMeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false + input "illuminances", "capability.illuminanceMeasurement", title: "Illuminance Meters", multiple: true, required: false + input "locks", "capability.lock", title: "Locks", multiple: true, required: false + input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Music Players", multiple: true, required: false + input "powerMeters", "capability.powerMeter", title: "Power Meters", multiple: true, required: false + input "presences", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false + input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity Meters", multiple: true, required: false + input "relaySwitches", "capability.relaySwitch", title: "Relay Switches", multiple: true, required: false + input "sleepSensors", "capability.sleepSensor", title: "Sleep Sensors", multiple: true, required: false + input "smokeDetectors", "capability.smokeDetector", title: "Smoke Detectors", multiple: true, required: false + input "peds", "capability.stepSensor", title: "Pedometers", multiple: true, required: false + input "switches", "capability.switch", title: "Switches", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Switch Levels", multiple: true, required: false + input "temperatures", "capability.temperatureMeasurement", title: "Temperature Sensors", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Thermostats", multiple: true, required: false + input "valves", "capability.valve", title: "Valves", multiple: true, required: false + input "waterSensors", "capability.waterSensor", title: "Water Sensors", multiple: true, required: false + } +} + +mappings { + path("/access_key") { + action: [ + GET: "getAccessKey", + PUT: "setAccessKey" + ] + } + path("/bucket") { + action: [ + GET: "getBucketKey", + PUT: "setBucketKey" + ] + } +} + +def subscribeToEvents() { + if (accelerometers != null) { + subscribe(accelerometers, "acceleration", genericHandler) + } + if (alarms != null) { + subscribe(alarms, "alarm", genericHandler) + } + if (batteries != null) { + subscribe(batteries, "battery", genericHandler) + } + if (beacons != null) { + subscribe(beacons, "presence", genericHandler) + } + + if (cos != null) { + subscribe(cos, "carbonMonoxide", genericHandler) + } + if (colors != null) { + subscribe(colors, "hue", genericHandler) + subscribe(colors, "saturation", genericHandler) + subscribe(colors, "color", genericHandler) + } + if (contacts != null) { + subscribe(contacts, "contact", genericHandler) + } + if (energyMeters != null) { + subscribe(energyMeters, "energy", genericHandler) + } + if (illuminances != null) { + subscribe(illuminances, "illuminance", genericHandler) + } + if (locks != null) { + subscribe(locks, "lock", genericHandler) + } + if (motions != null) { + subscribe(motions, "motion", genericHandler) + } + if (musicPlayers != null) { + subscribe(musicPlayers, "status", genericHandler) + subscribe(musicPlayers, "level", genericHandler) + subscribe(musicPlayers, "trackDescription", genericHandler) + subscribe(musicPlayers, "trackData", genericHandler) + subscribe(musicPlayers, "mute", genericHandler) + } + if (powerMeters != null) { + subscribe(powerMeters, "power", genericHandler) + } + if (presences != null) { + subscribe(presences, "presence", genericHandler) + } + if (humidities != null) { + subscribe(humidities, "humidity", genericHandler) + } + if (relaySwitches != null) { + subscribe(relaySwitches, "switch", genericHandler) + } + if (sleepSensors != null) { + subscribe(sleepSensors, "sleeping", genericHandler) + } + if (smokeDetectors != null) { + subscribe(smokeDetectors, "smoke", genericHandler) + } + if (peds != null) { + subscribe(peds, "steps", genericHandler) + subscribe(peds, "goal", genericHandler) + } + if (switches != null) { + subscribe(switches, "switch", genericHandler) + } + if (switchLevels != null) { + subscribe(switchLevels, "level", genericHandler) + } + if (temperatures != null) { + subscribe(temperatures, "temperature", genericHandler) + } + if (thermostats != null) { + subscribe(thermostats, "temperature", genericHandler) + subscribe(thermostats, "heatingSetpoint", genericHandler) + subscribe(thermostats, "coolingSetpoint", genericHandler) + subscribe(thermostats, "thermostatSetpoint", genericHandler) + subscribe(thermostats, "thermostatMode", genericHandler) + subscribe(thermostats, "thermostatFanMode", genericHandler) + subscribe(thermostats, "thermostatOperatingState", genericHandler) + } + if (valves != null) { + subscribe(valves, "contact", genericHandler) + } + if (waterSensors != null) { + subscribe(waterSensors, "water", genericHandler) + } +} + +def getAccessKey() { + log.trace "get access key" + if (atomicState.accessKey == null) { + httpError(404, "Access Key Not Found") + } else { + [ + accessKey: atomicState.accessKey + ] + } +} + +def getBucketKey() { + log.trace "get bucket key" + if (atomicState.bucketKey == null) { + httpError(404, "Bucket key Not Found") + } else { + [ + bucketKey: atomicState.bucketKey, + bucketName: atomicState.bucketName + ] + } +} + +def setBucketKey() { + log.trace "set bucket key" + def newBucketKey = request.JSON?.bucketKey + def newBucketName = request.JSON?.bucketName + + log.debug "bucket name: $newBucketName" + log.debug "bucket key: $newBucketKey" + + if (newBucketKey && (newBucketKey != atomicState.bucketKey || newBucketName != atomicState.bucketName)) { + atomicState.bucketKey = "$newBucketKey" + atomicState.bucketName = "$newBucketName" + atomicState.isBucketCreated = false + } + + tryCreateBucket() +} + +def setAccessKey() { + log.trace "set access key" + def newAccessKey = request.JSON?.accessKey + def newGrokerSubdomain = request.JSON?.grokerSubdomain + + if (newGrokerSubdomain && newGrokerSubdomain != "" && newGrokerSubdomain != atomicState.grokerSubdomain) { + atomicState.grokerSubdomain = "$newGrokerSubdomain" + atomicState.isBucketCreated = false + } + + if (newAccessKey && newAccessKey != atomicState.accessKey) { + atomicState.accessKey = "$newAccessKey" + atomicState.isBucketCreated = false + } +} + +def installed() { + atomicState.version = "1.0.18" + subscribeToEvents() + + atomicState.isBucketCreated = false + atomicState.grokerSubdomain = "groker" + atomicState.eventBuffer = [] + + runEvery15Minutes(flushBuffer) + + log.debug "installed (version $atomicState.version)" +} + +def updated() { + atomicState.version = "1.0.18" + unsubscribe() + + if (atomicState.bucketKey != null && atomicState.accessKey != null) { + atomicState.isBucketCreated = false + } + if (atomicState.eventBuffer == null) { + atomicState.eventBuffer = [] + } + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + atomicState.grokerSubdomain = "groker" + } + + subscribeToEvents() + + log.debug "updated (version $atomicState.version)" +} + +def uninstalled() { + log.debug "uninstalled (version $atomicState.version)" +} + +def tryCreateBucket() { + + // can't ship events if there is no grokerSubdomain + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + + // if the bucket has already been created, no need to continue + if (atomicState.isBucketCreated) { + return + } + + if (!atomicState.bucketName) { + atomicState.bucketName = atomicState.bucketKey + } + if (!atomicState.accessKey) { + return + } + def bucketName = "${atomicState.bucketName}" + def bucketKey = "${atomicState.bucketKey}" + def accessKey = "${atomicState.accessKey}" + + def bucketCreateBody = new JsonSlurper().parseText("{\"bucketKey\": \"$bucketKey\", \"bucketName\": \"$bucketName\"}") + + def bucketCreatePost = [ + uri: "https://${atomicState.grokerSubdomain}.initialstate.com/api/buckets", + headers: [ + "Content-Type": "application/json", + "X-IS-AccessKey": accessKey + ], + body: bucketCreateBody + ] + + log.debug bucketCreatePost + + try { + // Create a bucket on Initial State so the data has a logical grouping + httpPostJson(bucketCreatePost) { resp -> + log.debug "bucket posted" + if (resp.status >= 400) { + log.error "bucket not created successfully" + } else { + atomicState.isBucketCreated = true + } + } + } catch (e) { + log.error "bucket creation error: $e" + } + +} + +def genericHandler(evt) { + log.trace "$evt.displayName($evt.name:$evt.unit) $evt.value" + + def key = "$evt.displayName($evt.name)" + if (evt.unit != null) { + key = "$evt.displayName(${evt.name}_$evt.unit)" + } + def value = "$evt.value" + + tryCreateBucket() + + eventHandler(key, value) +} + +// This is a handler function for flushing the event buffer +// after a specified amount of time to reduce the load on ST servers +def flushBuffer() { + def eventBuffer = atomicState.eventBuffer + log.trace "About to flush the buffer on schedule" + if (eventBuffer != null && eventBuffer.size() > 0) { + atomicState.eventBuffer = [] + tryShipEvents(eventBuffer) + } +} + +def eventHandler(name, value) { + def epoch = now() / 1000 + def eventBuffer = atomicState.eventBuffer ?: [] + eventBuffer << [key: "$name", value: "$value", epoch: "$epoch"] + + if (eventBuffer.size() >= 10) { + // Clear eventBuffer right away since we've already pulled it off of atomicState to reduce the risk of missing + // events. This assumes the grokerSubdomain, accessKey, and bucketKey are set correctly to avoid the eventBuffer + // from growing unbounded. + atomicState.eventBuffer = [] + tryShipEvents(eventBuffer) + } else { + // Make sure we persist the updated eventBuffer with the new event added back to atomicState + atomicState.eventBuffer = eventBuffer + } + log.debug "Event added to buffer: " + eventBuffer +} + +// a helper function for shipping the atomicState.eventBuffer to Initial State +def tryShipEvents(eventBuffer) { + + def grokerSubdomain = atomicState.grokerSubdomain + // can't ship events if there is no grokerSubdomain + if (grokerSubdomain == null || grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + def accessKey = atomicState.accessKey + def bucketKey = atomicState.bucketKey + // can't ship if access key and bucket key are null, so finish trying + if (accessKey == null || bucketKey == null) { + return + } + + def eventPost = [ + uri: "https://${grokerSubdomain}.initialstate.com/api/events", + headers: [ + "Content-Type": "application/json", + "X-IS-BucketKey": "${bucketKey}", + "X-IS-AccessKey": "${accessKey}", + "Accept-Version": "0.0.2" + ], + body: eventBuffer + ] + + try { + // post the events to initial state + httpPostJson(eventPost) { resp -> + log.debug "shipped events and got ${resp.status}" + if (resp.status >= 400) { + log.error "shipping failed... ${resp.data}" + } + } + } catch (e) { + log.error "shipping events failed: $e" + } + +} \ No newline at end of file diff --git a/third-party/button-controller-for-hlgs.groovy b/third-party/button-controller-for-hlgs.groovy new file mode 100755 index 0000000..48280c5 --- /dev/null +++ b/third-party/button-controller-for-hlgs.groovy @@ -0,0 +1,340 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Button Controller for HLGS + * + * Author: Anthony Pastor (based closely on SmartThings's original Button Controller App) + * Date: 2016-5-4 + */ +definition( + name: "Button Controller for HLGS", + namespace: "infofiend", + author: "Anthony Pastor", + description: "Control devices (including HLGS Scenes) with buttons like the Aeon Labs Minimote", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/MyApps/Cat-MyApps.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/MyApps/Cat-MyApps@2x.png" +) + +preferences { + page(name: "selectButton") + page(name: "configureButton1") + page(name: "configureButton2") + page(name: "configureButton3") + page(name: "configureButton4") + + page(name: "timeIntervalInput", title: "Only during a certain time") { + section { + input "starting", "time", title: "Starting", required: false + input "ending", "time", title: "Ending", required: false + } + } +} + +def selectButton() { + dynamicPage(name: "selectButton", title: "First, select your button device", nextPage: "configureButton1", uninstall: configured()) { + section { + input "buttonDevice", "capability.button", title: "Button", multiple: false, required: true + } + + section(title: "More options", hidden: hideOptionsSection(), hideable: true) { + + def timeLabel = timeIntervalLabel() + + href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : null + + input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false, + options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + input "modes", "mode", title: "Only when mode is", multiple: true, required: false + } + } +} + +def configureButton1() { + dynamicPage(name: "configureButton1", title: "Now let's decide how to use the first button", + nextPage: "configureButton2", uninstall: configured(), getButtonSections(1)) +} +def configureButton2() { + dynamicPage(name: "configureButton2", title: "If you have a second button, set it up here", + nextPage: "configureButton3", uninstall: configured(), getButtonSections(2)) +} + +def configureButton3() { + dynamicPage(name: "configureButton3", title: "If you have a third button, you can do even more here", + nextPage: "configureButton4", uninstall: configured(), getButtonSections(3)) +} +def configureButton4() { + dynamicPage(name: "configureButton4", title: "If you have a fourth button, you rule, and can set it up here", + install: true, uninstall: true, getButtonSections(4)) +} + +def getButtonSections(buttonNumber) { + return { + section("Lights (any type) - simple toggle ON / OFF") { + input "lights_${buttonNumber}_pushed", "capability.switch", title: "Pushed", multiple: true, required: false + input "lights_${buttonNumber}_held", "capability.switch", title: "Held", multiple: true, required: false + } + section("HLGS - Scene ON / Lights or Groups OFF (Requires BOTH of the following preferences to be set)") { + input "hlgs1_${buttonNumber}_pushed", "capability.momentary", title: "First Push of this Button calls this HLGS Scene (momentary device)", multiple: false, required: false + input "hlgs2_${buttonNumber}_pushed", "capability.switch", title: "Second Push of this Button turns off these HLGS Lights and Groups", multiple: true, required: false + } + section("Locks") { + input "locks_${buttonNumber}_pushed", "capability.lock", title: "Pushed", multiple: true, required: false + input "locks_${buttonNumber}_held", "capability.lock", title: "Held", multiple: true, required: false + } + section("Sonos") { + input "sonos_${buttonNumber}_pushed", "capability.musicPlayer", title: "Pushed", multiple: true, required: false + input "sonos_${buttonNumber}_held", "capability.musicPlayer", title: "Held", multiple: true, required: false + } + section("Modes") { + input "mode_${buttonNumber}_pushed", "mode", title: "Pushed", required: false + input "mode_${buttonNumber}_held", "mode", title: "Held", required: false + } + def phrases = location.helloHome?.getPhrases()*.label + if (phrases) { + section("Hello Home Actions") { + log.trace phrases + input "phrase_${buttonNumber}_pushed", "enum", title: "Pushed", required: false, options: phrases + input "phrase_${buttonNumber}_held", "enum", title: "Held", required: false, options: phrases + } + } + section("Sirens") { + input "sirens_${buttonNumber}_pushed","capability.alarm" ,title: "Pushed", multiple: true, required: false + input "sirens_${buttonNumber}_held", "capability.alarm", title: "Held", multiple: true, required: false + } + + section("Custom Message") { + input "textMessage_${buttonNumber}", "text", title: "Message", required: false + } + + section("Push Notifications") { + input "notifications_${buttonNumber}_pushed","bool" ,title: "Pushed", required: false, defaultValue: false + input "notifications_${buttonNumber}_held", "bool", title: "Held", required: false, defaultValue: false + } + + section("Sms Notifications") { + input "phone_${buttonNumber}_pushed","phone" ,title: "Pushed", required: false + input "phone_${buttonNumber}_held", "phone", title: "Held", required: false + } + } +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + initialize() +} + +def initialize() { + subscribe(buttonDevice, "button", buttonEvent) +} + +def configured() { + return buttonDevice || buttonConfigured(1) || buttonConfigured(2) || buttonConfigured(3) || buttonConfigured(4) +} + +def buttonConfigured(idx) { + return settings["lights_$idx_pushed"] || + settings["locks_$idx_pushed"] || + settings["sonos_$idx_pushed"] || + settings["mode_$idx_pushed"] || + settings["notifications_$idx_pushed"] || + settings["sirens_$idx_pushed"] || + settings["notifications_$idx_pushed"] || + settings["phone_$idx_pushed"] || + settings["hlgs1_$idx_pushed"] || + settings["hlgs2_$idx_pushed"] +} + +def buttonEvent(evt){ + if(allOk) { + def buttonNumber = evt.data // why doesn't jsonData work? always returning [:] + def value = evt.value + log.debug "buttonEvent: $evt.name = $evt.value ($evt.data)" + log.debug "button: $buttonNumber, value: $value" + + def recentEvents = buttonDevice.eventsSince(new Date(now() - 3000)).findAll{it.value == evt.value && it.data == evt.data} + log.debug "Found ${recentEvents.size()?:0} events in past 3 seconds" + + if(recentEvents.size <= 1){ + switch(buttonNumber) { + case ~/.*1.*/: + executeHandlers(1, value) + break + case ~/.*2.*/: + executeHandlers(2, value) + break + case ~/.*3.*/: + executeHandlers(3, value) + break + case ~/.*4.*/: + executeHandlers(4, value) + break + } + } else { + log.debug "Found recent button press events for $buttonNumber with value $value" + } + } +} + +def executeHandlers(buttonNumber, value) { + log.debug "executeHandlers: $buttonNumber - $value" + + def lights = find('lights', buttonNumber, value) + if (lights != null) toggle(lights) + + def hlgs1 = find('hlgs1', buttonNumber, value) + def hlgs2 = find('hlgs2', buttonNumber, value) + if (hlgs1 != null && hlgs2 != null) { + if (state.hlgs != true) { + hlgs1.push() + state.hlgs = true + } else { + hlgs2.off() + state.hlgs = false + } + } + + def locks = find('locks', buttonNumber, value) + if (locks != null) toggle(locks) + + def sonos = find('sonos', buttonNumber, value) + if (sonos != null) toggle(sonos) + + def mode = find('mode', buttonNumber, value) + if (mode != null) changeMode(mode) + + def phrase = find('phrase', buttonNumber, value) + if (phrase != null) location.helloHome.execute(phrase) + + def textMessage = findMsg('textMessage', buttonNumber) + + def notifications = find('notifications', buttonNumber, value) + if (notifications?.toBoolean()) sendPush(textMessage ?: "Button $buttonNumber was pressed" ) + + def phone = find('phone', buttonNumber, value) + if (phone != null) sendSms(phone, textMessage ?:"Button $buttonNumber was pressed") + + def sirens = find('sirens', buttonNumber, value) + if (sirens != null) toggle(sirens) +} + +def find(type, buttonNumber, value) { + def preferenceName = type + "_" + buttonNumber + "_" + value + def pref = settings[preferenceName] + if(pref != null) { + log.debug "Found: $pref for $preferenceName" + } + + return pref +} + +def findMsg(type, buttonNumber) { + def preferenceName = type + "_" + buttonNumber + def pref = settings[preferenceName] + if(pref != null) { + log.debug "Found: $pref for $preferenceName" + } + + return pref +} + +def toggle(devices) { + log.debug "toggle: $devices = ${devices*.currentValue('switch')}" + + if (devices*.currentValue('switch').contains('on')) { + devices.off() + } + else if (devices*.currentValue('switch').contains('off')) { + devices.on() + } + else if (devices*.currentValue('lock').contains('locked')) { + devices.unlock() + } + else if (devices*.currentValue('lock').contains('unlocked')) { + devices.lock() + } + else if (devices*.currentValue('alarm').contains('off')) { + devices.siren() + } + else { + devices.on() + } +} + +def changeMode(mode) { + log.debug "changeMode: $mode, location.mode = $location.mode, location.modes = $location.modes" + + if (location.mode != mode && location.modes?.find { it.name == mode }) { + setLocationMode(mode) + } +} + +// execution filter methods +private getAllOk() { + modeOk && daysOk && timeOk +} + +private getModeOk() { + def result = !modes || modes.contains(location.mode) + log.trace "modeOk = $result" + result +} + +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("America/New_York")) + } + 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) +} + +private hideOptionsSection() { + (starting || ending || days || modes) ? false : true +} + +private timeIntervalLabel() { + (starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : "" +} \ No newline at end of file diff --git a/third-party/camera-motion.groovy b/third-party/camera-motion.groovy new file mode 100755 index 0000000..150bc99 --- /dev/null +++ b/third-party/camera-motion.groovy @@ -0,0 +1,83 @@ +/** + * Camera Motion + * + * Copyright 2015 Kristopher Kubicki + * + */ +definition( + name: "Camera Motion", + namespace: "KristopherKubicki", + author: "kristopher@acm.org", + description: "Creates an endpoint for your camera ", + category: "Convenience", + 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: "selectDevices", install: false, uninstall: true, nextPage: "viewURL") { + section("Allow endpoint to control this thing...") { + input "motions", "capability.motionSensor", title: "Which simulated motion sensor?" + label title: "Assign a name", required: false + mode title: "Set for specific mode(s)", required: false + } + } + page(name: "viewURL", title: "viewURL", install: true) +} + +def installed() { + log.debug "Installed with settings: ${settings}" +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + +} + + + +mappings { + + path("/active") { + action: [ + GET: "activeMotion" + ] + } + path("/inactive") { + action: [ + GET: "inactiveMotion" + ] + } +} + +void activeMotion() { + log.debug "Updated2 with settings: ${settings}" + motions?.active() +} + +void inactiveMotion() { + log.debug "Updated2 with settings: ${settings}" + motions?.inactive() +} + + +def generateURL() { + + createAccessToken() + + ["https://graph.api.smartthings.com/api/smartapps/installations/${app.id}/active", "?access_token=${state.accessToken}"] +} + + +def viewURL() { + dynamicPage(name: "viewURL", title: "HTTP Motion Endpoint", install:!resetOauth, nextPage: resetOauth ? "viewURL" : null) { + section() { + generateURL() + paragraph "Activate: https://graph.api.smartthings.com/api/smartapps/installations/${app.id}/active?access_token=${state.accessToken}" + paragraph "Deactivate: https://graph.api.smartthings.com/api/smartapps/installations/${app.id}/inactive?access_token=${state.accessToken}" + + } + } +} diff --git a/third-party/circadian-daylight.groovy b/third-party/circadian-daylight.groovy new file mode 100755 index 0000000..0d10d7d --- /dev/null +++ b/third-party/circadian-daylight.groovy @@ -0,0 +1,342 @@ +/** +* Circadian Daylight 2.6 +* +* This SmartApp synchronizes your color changing lights with local perceived color +* temperature of the sky throughout the day. This gives your environment a more +* natural feel, with cooler whites during the midday and warmer tints near twilight +* and dawn. +* +* In addition, the SmartApp sets your lights to a nice cool white at 1% in +* "Sleep" mode, which is far brighter than starlight but won't reset your +* circadian rhythm or break down too much rhodopsin in your eyes. +* +* Human circadian rhythms are heavily influenced by ambient light levels and +* hues. Hormone production, brainwave activity, mood and wakefulness are +* just some of the cognitive functions tied to cyclical natural light. +* http://en.wikipedia.org/wiki/Zeitgeber +* +* Here's some further reading: +* +* http://www.cambridgeincolour.com/tutorials/sunrise-sunset-calculator.htm +* http://en.wikipedia.org/wiki/Color_temperature +* +* Technical notes: I had to make a lot of assumptions when writing this app +* * The Hue bulbs are only capable of producing a true color spectrum from +* 2700K to 6000K. The Hue Pro application indicates the range is +* a little wider on each side, but I stuck with the Philips +* documentation +* * I aligned the color space to CIE with white at D50. I suspect "true" +* white for this application might actually be D65, but I will have +* to recalculate the color temperature if I move it. +* * There are no considerations for weather or altitude, but does use your +* hub's zip code to calculate the sun position. +* * The app doesn't calculate a true "Blue Hour" -- it just sets the lights to +* 2700K (warm white) until your hub goes into Night mode +* +* Version 2.6: March 26, 2016 - Fixes issue with hex colors. Move your color changing bulbs to Color Temperature instead +* Version 2.5: March 14, 2016 - Add "disabled" switch +* Version 2.4: February 18, 2016 - Mode changes +* Version 2.3: January 23, 2016 - UX Improvements for publication, makes Campfire default instead of Moonlight +* Version 2.2: January 2, 2016 - Add better handling for off() schedules +* Version 2.1: October 27, 2015 - Replace motion sensors with time +* Version 2.0: September 19, 2015 - Update for Hub 2.0 +* Version 1.5: June 26, 2015 - Merged with SANdood's optimizations, breaks unofficial LIGHTIFY support +* Version 1.4: May 21, 2015 - Clean up mode handling +* Version 1.3: April 8, 2015 - Reduced Hue IO, increased robustness +* Version 1.2: April 7, 2015 - Add support for LIGHTIFY bulbs, dimmers and user selected "Sleep" +* Version 1.1: April 1, 2015 - Add support for contact sensors +* Version 1.0: March 30, 2015 - Initial release +* +* The latest version of this file can be found at +* https://github.com/KristopherKubicki/smartapp-circadian-daylight/ +* +*/ + +definition( +name: "Circadian Daylight", +namespace: "KristopherKubicki", +author: "kristopher@acm.org", +description: "Sync your color changing lights and dimmers with natural daylight hues to improve your cognitive functions and restfulness.", +category: "Green Living", +iconUrl: "https://s3.amazonaws.com/smartapp-icons/MiscHacking/mindcontrol.png", +iconX2Url: "https://s3.amazonaws.com/smartapp-icons/MiscHacking/mindcontrol@2x.png" +) + +preferences { + section("Thank you for installing Circadian Daylight! This application dims and adjusts the color temperature of your lights to match the state of the sun, which has been proven to aid in cognitive functions and restfulness. The default options are well suited for most users, but feel free to tweak accordingly!") { + } + section("Control these bulbs; Select each bulb only once") { + input "ctbulbs", "capability.colorTemperature", title: "Which Temperature Changing Bulbs?", multiple:true, required: false + input "bulbs", "capability.colorControl", title: "Which Color Changing Bulbs?", multiple:true, required: false + input "dimmers", "capability.switchLevel", title: "Which Dimmers?", multiple:true, required: false + } + section("What are your 'Sleep' modes? The modes you pick here will dim your lights and filter light to a softer, yellower hue to help you fall asleep easier. Protip: You can pick 'Nap' modes as well!") { + input "smodes", "mode", title: "What are your Sleep modes?", multiple:true, required: false + } + section("Override Constant Brightness (default) with Dynamic Brightness? If you'd like your lights to dim as the sun goes down, override this option. Most people don't like it, but it can look good in some settings.") { + input "dbright","bool", title: "On or off?", required: false + } + section("Override night time Campfire (default) with Moonlight? Circadian Daylight by default is easier on your eyes with a yellower hue at night. However if you'd like a whiter light instead, override this option. Note: this will likely disrupt your circadian rhythm.") { + input "dcamp","bool", title: "On or off?", required: false + } + section("Override night time Dimming (default) with Rhodopsin Bleaching? Override this option if you would not like Circadian Daylight to dim your lights during your Sleep modes. This is definitely not recommended!") { + input "ddim","bool", title: "On or off?", required: false + } + section("Disable Circadian Daylight when the following switches are on:") { + input "dswitches","capability.switch", title: "Switches", multiple:true, required: false + } +} + +def installed() { + unsubscribe() + unschedule() + initialize() +} + +def updated() { + unsubscribe() + unschedule() + initialize() +} + +private def initialize() { + log.debug("initialize() with settings: ${settings}") + if(ctbulbs) { subscribe(ctbulbs, "switch.on", modeHandler) } + if(bulbs) { subscribe(bulbs, "switch.on", modeHandler) } + if(dimmers) { subscribe(dimmers, "switch.on", modeHandler) } + if(dswitches) { subscribe(dswitches, "switch.off", modeHandler) } + subscribe(location, "mode", modeHandler) + + // revamped for sunset handling instead of motion events + subscribe(location, "sunset", modeHandler) + subscribe(location, "sunrise", modeHandler) + schedule("0 */15 * * * ?", modeHandler) + subscribe(app,modeHandler) + subscribe(location, "sunsetTime", scheduleTurnOn) + // rather than schedule a cron entry, fire a status update a little bit in the future recursively + scheduleTurnOn() +} + +def scheduleTurnOn() { + def int iterRate = 20 + + // get sunrise and sunset times + def sunRiseSet = getSunriseAndSunset() + def sunriseTime = sunRiseSet.sunrise + log.debug("sunrise time ${sunriseTime}") + def sunsetTime = sunRiseSet.sunset + log.debug("sunset time ${sunsetTime}") + + if(sunriseTime.time > sunsetTime.time) { + sunriseTime = new Date(sunriseTime.time - (24 * 60 * 60 * 1000)) + } + + def runTime = new Date(now() + 60*15*1000) + for (def i = 0; i < iterRate; i++) { + def long uts = sunriseTime.time + (i * ((sunsetTime.time - sunriseTime.time) / iterRate)) + def timeBeforeSunset = new Date(uts) + if(timeBeforeSunset.time > now()) { + runTime = timeBeforeSunset + last + } + } + + log.debug "checking... ${runTime.time} : $runTime" + if(state.nextTime != runTime.time) { + state.nextTimer = runTime.time + log.debug "Scheduling next step at: $runTime (sunset is $sunsetTime) :: ${state.nextTimer}" + runOnce(runTime, modeHandler) + } +} + + +// Poll all bulbs, and modify the ones that differ from the expected state +def modeHandler(evt) { + for (dswitch in dswitches) { + if(dswitch.currentSwitch == "on") { + return + } + } + + def ct = getCT() + def hex = getHex() + def hsv = getHSV() + def bright = getBright() + + for(ctbulb in ctbulbs) { + if(ctbulb.currentValue("switch") == "on") { + if((settings.dbright == true || location.mode in settings.smodes) && ctbulb.currentValue("level") != bright) { + ctbulb.setLevel(bright) + } + if(ctbulb.currentValue("colorTemperature") != ct) { + ctbulb.setColorTemperature(ct) + } + } + } + def color = [hex: hex, hue: hsv.h, saturation: hsv.s, level: bright] + for(bulb in bulbs) { + if(bulb.currentValue("switch") == "on") { + def tmp = bulb.currentValue("color") + if(bulb.currentValue("color") != hex) { + if(settings.dbright == true || location.mode in settings.smodes) { + color.value = bright + } else { + color.value = bulb.currentValue("level") + } + def ret = bulb.setColor(color) + } + } + } + for(dimmer in dimmers) { + if(dimmer.currentValue("switch") == "on") { + if(dimmer.currentValue("level") != bright) { + dimmer.setLevel(bright) + } + } + } + + scheduleTurnOn() +} + +def getCTBright() { + def after = getSunriseAndSunset() + def midDay = after.sunrise.time + ((after.sunset.time - after.sunrise.time) / 2) + + def currentTime = now() + def float brightness = 1 + def int colorTemp = 2700 + if(currentTime > after.sunrise.time && currentTime < after.sunset.time) { + if(currentTime < midDay) { + colorTemp = 2700 + ((currentTime - after.sunrise.time) / (midDay - after.sunrise.time) * 3800) + brightness = ((currentTime - after.sunrise.time) / (midDay - after.sunrise.time)) + } + else { + colorTemp = 6500 - ((currentTime - midDay) / (after.sunset.time - midDay) * 3800) + brightness = 1 - ((currentTime - midDay) / (after.sunset.time - midDay)) + + } + } + + if(settings.dbright == false) { + brightness = 1 + } + + if(location.mode in settings.smodes) { + if(currentTime > after.sunset.time) { + if(settings.dcamp == true) { + colorTemp = 6500 + } + else { + colorTemp = 2700 + } + } + if(settings.ddim == false) { + brightness = 0.01 + } + } + + def ct = [:] + ct = [colorTemp: colorTemp, brightness: Math.round(brightness * 100)] + ct +} + +def getCT() { + def ctb = getCTBright() + //log.debug "Color Temperature: " + ctb.colorTemp + return ctb.colorTemp +} + +def getHex() { + def ct = getCT() + //log.debug "Hex: " + rgbToHex(ctToRGB(ct)).toUpperCase() + return rgbToHex(ctToRGB(ct)).toUpperCase() +} + +def getHSV() { + def ct = getCT() + //log.debug "HSV: " + rgbToHSV(ctToRGB(ct)) + return rgbToHSV(ctToRGB(ct)) +} + +def getBright() { + def ctb = getCTBright() + //log.debug "Brightness: " + ctb.brightness + return ctb.brightness +} + + +// Based on color temperature converter from +// http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/ +// This will not work for color temperatures below 1000 or above 40000 +def ctToRGB(ct) { + + if(ct < 1000) { ct = 1000 } + if(ct > 40000) { ct = 40000 } + + ct = ct / 100 + + //red + def r + if(ct <= 66) { r = 255 } + else { r = 329.698727446 * ((ct - 60) ** -0.1332047592) } + if(r < 0) { r = 0 } + if(r > 255) { r = 255 } + + //green + def g + if (ct <= 66) { g = 99.4708025861 * Math.log(ct) - 161.1195681661 } + else { g = 288.1221695283 * ((ct - 60) ** -0.0755148492) } + if(g < 0) { g = 0 } + if(g > 255) { g = 255 } + + //blue + def b + if(ct >= 66) { b = 255 } + else if(ct <= 19) { b = 0 } + else { b = 138.5177312231 * Math.log(ct - 10) - 305.0447927307 } + if(b < 0) { b = 0 } + if(b > 255) { b = 255 } + + def rgb = [:] + rgb = [r: r as Integer, g: g as Integer, b: b as Integer] + rgb +} + +def rgbToHex(rgb) { + return "#" + Integer.toHexString(rgb.r).padLeft(2,'0') + Integer.toHexString(rgb.g).padLeft(2,'0') + Integer.toHexString(rgb.b).padLeft(2,'0') +} + +//http://www.rapidtables.com/convert/color/rgb-to-hsv.htm +def rgbToHSV(rgb) { + def h, s, v + + def r = rgb.r / 255 + def g = rgb.g / 255 + def b = rgb.b / 255 + + def max = [r, g, b].max() + def min = [r, g, b].min() + + def delta = max - min + + //hue + if(delta == 0) { h = 0} + else if(max == r) { + double dub = (g - b) / delta + h = 60 * (dub % 6) + } + else if(max == g) { h = 60 * (((b - r) / delta) + 2) } + else if(max == b) { h = 60 * (((r - g) / delta) + 4) } + + //saturation + if(max == 0) { s = 0 } + else { s = (delta / max) * 100 } + + //value + v = max * 100 + + def degreesRange = (360 - 0) + def percentRange = (100 - 0) + + return [h: ((h * percentRange) / degreesRange) as Integer, s: ((s * percentRange) / degreesRange) as Integer, v: v as Integer] +} diff --git a/third-party/ecobeeAwayFromHome.groovy b/third-party/ecobeeAwayFromHome.groovy new file mode 100755 index 0000000..7a1ae7d --- /dev/null +++ b/third-party/ecobeeAwayFromHome.groovy @@ -0,0 +1,310 @@ +/*** + * + * Copyright 2014 Yves Racine + * linkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. * + * + * Away from Home with Ecobee Thermostat + * Turn off the lights, turn on the security alarm, and lower the settings at ecobee when away from home + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ + +// Automatically generated. Make future change here. +definition( + name: "ecobeeAwayFromHome", + namespace: "yracine", + author: "Yves Racine", + description: "Away From Home", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + section("About") { + paragraph "ecobeeAwayFromHome, the smartapp that sets your ecobee thermostat to 'Away' or to some specific settings when all presences leave your home" + paragraph "Version 1.9.5" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + section("When all of these people leave home") { + input "people", "capability.presenceSensor", multiple: true + } + section("And there is no motion at home on these sensors [optional]") { + input "motions", "capability.motionSensor", title: "Where?", multiple: true, required: false + } + section("Turn off these lights") { + input "switches", "capability.switch", title: "Switch", multiple: true, required: optional + } + section("And activate the alarm system [optional]") { + input "alarmSwitch", "capability.contactSensor", title: "Alarm Switch", required: false + } + section("Set the ecobee thermostat(s)") { + input "ecobee", "capability.thermostat", title: "Ecobee Thermostat(s)", multiple: true + } + section("Heating set Point for the thermostat [default = 60°F/14°C]") { + input "givenHeatTemp", "decimal", title: "Heat Temp", required: false + } + section("Cooling set Point for the thermostat [default = 80°F/27°C]") { + input "givenCoolTemp", "decimal", title: "Cool Temp", required: false + } + section("Or set the ecobee to this Climate Name (ex. Away)") { + input "givenClimateName", "text", title: "Climate Name", required: false + } + section("Lock these locks [optional]") { + input "locks", "capability.lock", title: "Locks?", required: false, multiple: true + } + section("Arm this(ese) camera(s) [optional]") { + input "cameras", "capability.imageCapture", title: "Cameras", multiple: true, required: false + } + section("Trigger these actions when home has been quiet for [default=3 minutes]") { + input "residentsQuietThreshold", "number", title: "Time in minutes", required: false + } + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phone", "phone", title: "Send a Text Message?", required: false + } + section("Detailed Notifications") { + input "detailedNotif", "bool", title: "Detailed Notifications?", required: false + } + +} + + +def installed() { + log.debug "Installed with settings: ${settings}" + log.debug "Current mode = ${location.mode}, people = ${people.collect{it.label + ': ' + it.currentPresence}}" + initialize() + +} + + + +def updated() { + log.debug "Updated with settings: ${settings}" + log.debug "Current mode = ${location.mode}, people = ${people.collect{it.label + ': ' + it.currentPresence}}" + unsubscribe() + initialize() +} + +private initialize() { + subscribe(people, "presence", presence) + subscribe(alarmSwitch, "contact", alarmSwitchContact) + if (motions != null && motions != "") { + subscribe(motions, "motion", motionEvtHandler) + } + subscribe(app, appTouch) +} + + +def alarmSwitchContact(evt) { + log.info "alarmSwitchContact, $evt.name: $evt.value" + + if ((alarmSwitch.currentContact == "closed") && residentsHaveBeenQuiet() && everyoneIsAway()) { + if (detailedNotif) { + send("AwayFromHome>alarm system just armed, take actions") + } + log.debug "alarm is armed, nobody at home" + takeActions() + } +} + + +def motionEvtHandler(evt) { + if (evt.value == "active") { + state.lastIntroductionMotion = now() + log.debug "Motion at home..." + } +} + + +private residentsHaveBeenQuiet() { + + def threshold = residentsQuietThreshold ?: 3 // By default, the delay is 3 minutes + Integer delay = threshold * 60 + + def result = true + def t0 = new Date(now() - (threshold * 60 * 1000)) + for (sensor in motions) { + def recentStates = sensor.statesSince("motion", t0) + if (recentStates.find {it.value == "active"}) { + result = false + break + } + } + log.debug "residentsHaveBeenQuiet: $result" + return result +} + + +def presence(evt) { + def threshold = residentsQuietThreshold ?: 3 // By default, the delay is 3 minutes + Integer delay = threshold * 60 + + log.debug "$evt.name: $evt.value" + if (evt.value == "not present") { + def person = getPerson(evt) + if (detailedNotif) { + send("AwayFromHome> ${person.displayName} not present at home") + } + log.debug "checking if everyone is away and quiet at home" + if (residentsHaveBeenQuiet()) { + + if (everyoneIsAway()) { + if (detailedNotif) { + send("AwayFromHome>Quiet at home...") + } + runIn(delay, "takeActions") + } else { + log.debug "Not everyone is away, doing nothing" + if (detailedNotif) { + send("AwayFromHome>Not everyone is away, doing nothing..") + } + } + } else { + + log.debug "Things are not quiet at home, doing nothing" + if (detailedNotif) { + send("AwayFromHome>Things are not quiet at home...") + } + } + } else { + log.debug "Still present; doing nothing" + } +} + +def appTouch(evt) { + log.debug ("ecobeeAwayFromHome>location.mode= $location.mode, givenClimate=${givenClimateName}, about to takeAction") + + takeActions() +} + + +def takeActions() { + Integer thresholdMinutes = 2 // check that the security alarm is close in a 2-minute delay + Integer delay = 60 * thresholdMinutes + def msg, minHeatTemp, minCoolTemp + + def scale = getTemperatureScale() + if (scale == 'C') { + minHeatTemp = givenHeatTemp ?: 14 // by default, 14°C is the minimum heat temp + minCoolTemp = givenCoolTemp ?: 27 // by default, 27°C is the minimum cool temp + } else { + minHeatTemp = givenHeatTemp ?: 60 // by default, 60°F is the minimum heat temp + minCoolTemp = givenCoolTemp ?: 80 // by default, 80°F is the minimum cool temp + } + // Making sure everybody is away and no motion at home + + if (everyoneIsAway() && residentsHaveBeenQuiet()) { + send("AwayFromHome>Nobody is at home, and it's quiet, about to take actions") + if (alarmSwitch?.currentContact == "open") { + alarmSwitch.on() // arm the alarm system + if (detailedNotif) { + log.debug "alarm is not set, arm it..." + send(msg) + } + } + if ((givenClimateName != null) && (givenClimateName != "")) { + ecobee.each { + it.setClimate('', givenClimateName) // Set to the climateName + } + } else { + + // Set heating and cooling points at ecobee + ecobee.each { + it.setHold('', minCoolTemp, minHeatTemp, null, null) + } + } + + msg = "AwayFromHome>${ecobee} thermostats' settings are now lower" + if (detailedNotif ) { + log.info msg + send(msg) + } + + locks?.lock() // lock the locks + msg = "AwayFromHome>Locked the locks" + if ((locks) && (detailedNotif)) { + log.info msg + send(msg) + } + + switches?.off() // turn off the lights + msg = "AwayFromHome>Switched off all switches" + if ((switches) && (detailedNotif)) { + log.info msg + send(msg) + } + + + cameras?.alarmOn() // arm the cameras + msg = "AwayFromHome>cameras are now armed" + if ((cameras) && (detailedNotif)) { + log.info msg + send(msg) + } + if (alarmSwitch) { + runIn(delay, "checkAlarmSystem", [overwrite: false]) // check that the alarm system is armed + } + } + + +} + +private checkAlarmSystem() { + if (alarmSwitch.currentContact == "open") { + if (detailedNotif) { + send("AwayFromHome>alarm still not activated,repeat...") + } + alarmSwitch.on() // try to arm the alarm system again + } + + +} + +private everyoneIsAway() { + def result = true + for (person in people) { + if (person.currentPresence == "present") { + result = false + break + } + } + log.debug "everyoneIsAway: $result" + return result +} + +private getPerson(evt) { + people.find { + evt.deviceId == it.id + } +} + +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 +} diff --git a/third-party/ecobeeChangeMode.groovy b/third-party/ecobeeChangeMode.groovy new file mode 100755 index 0000000..3497a42 --- /dev/null +++ b/third-party/ecobeeChangeMode.groovy @@ -0,0 +1,183 @@ +/** + * ecobeeChangeMode + * + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * Change the mode manually (by pressing the app's play button) and automatically at the ecobee thermostat(s) + * If you need to set it for both Away and Home modes, you'd need to save them as 2 distinct apps + * Don't forget to set the app to run only for the target mode. + * + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +definition( + name: "ecobeeChangeMode", + namespace: "yracine", + author: "Yves Racine", + description: + "Change the mode manually (by pressing the app's play button) and automatically at the ecobee thermostat(s)", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + + + page(name: "selectThermostats", title: "Thermostats", install: false , uninstall: true, nextPage: "selectProgram") { + section("About") { + paragraph "ecobeeChangeMode, the smartapp that sets your ecobee thermostat to a given program/climate ['Away', 'Home', 'Night']" + + " based on ST hello mode." + paragraph "Version 1.9.9" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + section("Change the following ecobee thermostat(s)...") { + input "thermostats", "device.myEcobeeDevice", title: "Which thermostat(s)", multiple: true + } + section("Do the mode change manually only (by pressing the arrow to execute the smartapp)") { + input "manualFlag", "bool", title: "Manual only [default=false]", description:"optional",required:false + } + + } + page(name: "selectProgram", title: "Ecobee Programs", content: "selectProgram") + page(name: "Notifications", title: "Notifications Options", install: true, uninstall: true) { + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: + false + input "phone", "phone", title: "Send a Text Message?", required: false + } + section([mobileOnly:true]) { + label title: "Assign a name for this SmartApp", required: false + } + } +} + + +def selectProgram() { + def ecobeePrograms = thermostats[0].currentClimateList.toString().minus('[').minus(']').tokenize(',') + log.debug "programs: $ecobeePrograms" + def enumModes=[] + location.modes.each { + enumModes << it.name + } + + return dynamicPage(name: "selectProgram", title: "Select Ecobee Program", install: false, uninstall: true, nextPage: + "Notifications") { + section("Select Program") { + input "givenClimate", "enum", title: "Change to this program?", options: ecobeePrograms, required: true + } + section("When SmartThings' hello home mode changes to (ex. 'Away', 'Home')[optional]") { + input "newMode", "enum", options: enumModes, multiple:true, required: false + } + section("Enter a delay in minutes [optional, default=immediately after ST hello mode change] ") { + input "delay", "number", title: "Delay in minutes [default=immediate]", description:"no delay by default",required:false + } + + + } +} + + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + initialize() +} + +private def initialize() { + + if (!manualFlag) { + subscribe(location, "mode", changeMode) + } else { + takeAction() + } + subscribe(app, appTouch) +} + +def appTouch(evt) { + log.debug ("changeMode>location.mode= $location.mode, givenClimate=${givenClimate}, about to takeAction") + + takeAction() +} + + +def changeMode(evt) { + def message + + + if (delay) { + try { + unschedule(takeAction) + } catch (e) { + log.debug ("ecobeeChangeMode>exception when trying to unschedule: $e") + } + } + + Boolean foundMode=false + newMode.each { + + if (it==location.mode) { + foundMode=true + } + } + log.debug ("changeMode>location.mode= $location.mode, newMode=${newMode}") + + if ((newMode != null) && (!foundMode)) { + + log.debug "changeMode>location.mode= $location.mode, newMode=${newMode},foundMode=${foundMode}, not doing anything" + return + } + + if ((!delay) || (delay==null)) { + log.debug ("changeMode>about to call takeAction()") + takeAction() + } else { + runIn((delay*60), "takeAction") + } +} + +private void takeAction() { + def message = "ecobeeChangeMode>setting ${thermostats} to ${givenClimate}.." + send(message) + log.debug (message) + + thermostats.each { + it?.setThisTstatClimate(givenClimate) + } +} + + + + +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 +} diff --git a/third-party/ecobeeControlPlug.groovy b/third-party/ecobeeControlPlug.groovy new file mode 100755 index 0000000..2de9372 --- /dev/null +++ b/third-party/ecobeeControlPlug.groovy @@ -0,0 +1,132 @@ +/** + * ecobeeControlPlug + * + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ + +definition( + name: "ecobeeControlPlug", + namespace: "yracine", + author: "Yves Racine", + description: "Control a plug attached to an ecobee device", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + + +preferences { + section("About") { + paragraph "ecobeeControlPlug, the smartapp that controls your ecobee connected sensor or plug" + paragraph "Version 1.1.4" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + + section("For this Ecobee thermostat") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee Thermostat" + } + section("Control this SmartPlug Name") { + input "plugName", "text", title: "SmartPlug Name" + } + section("Target control State") { + input "plugState", "enum", title: "Control State?", metadata: [values: ["on", "off", "resume", ]] + } + section("Hold Type") { + input "givenHoldType", "enum", title: "Hold Type?", metadata: [values: ["dateTime", "nextTransition", "indefinite"]] + } + section("For 'dateTime' holdType, Start date for the hold (format = DD-MM-YYYY)") { + input "givenStartDate", "text", title: "Beginning Date", required: false + } + section("For 'dateTime' holdType, Start time for the hold (HH:MM,24HR)") { + input "givenStartTime", "text", title: "Beginning time", required: false + } + section("For 'dateTime' holdType, End date for the hold (format = DD-MM-YYYY)") { + input "givenEndDate", "text", title: "End Date", required: false + } + section("For 'dateTime' holdType, End time for the hold (HH:MM,24HR)") { + input "givenEndTime", "text", title: "End time", required: false + } + + +} + + +def installed() { + + initialize() +} + + +def updated() { + + unsubscribe() + initialize() + + +} + +def initialize() { + ecobee.poll() + subscribe(app, appTouch) +} + +private void sendMsgWithDelay() { + + if (state?.msg) { + send state.msg + } +} + +def appTouch(evt) { + log.debug "ecobeeControlPlug> about to take actions" + def plugSettings = [holdType: "${givenHoldType}"] + + + if (givenHoldType == "dateTime") { + + if ((!givenStartDate) || (!givenEndDate) || (!givenStartTime) || (!givenEndTime)) { + state?.msg="ecobeeControlPlug>holdType=dateTime and dates/times are not valid for controlling plugName ${plugName}" + log.error state.msg + runIn(30, "sendMsgWithDelay") + return + } + plugSettings = [holdType: "dateTime", startDate: "${givenStartDate}", startTime: "${givenStartTime}", endDate: "${givenEndDate}", endTime: "${givenEndTime}"] + } + log.debug("About to call controlPlug for thermostatId=${thermostatId}, plugName=${plugName}") + ecobee.controlPlug("", plugName, plugState, plugSettings) +} + + +private send(msg) { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + + log.debug msg +} diff --git a/third-party/ecobeeGenerateMonthlyStats.groovy b/third-party/ecobeeGenerateMonthlyStats.groovy new file mode 100755 index 0000000..c66dba9 --- /dev/null +++ b/third-party/ecobeeGenerateMonthlyStats.groovy @@ -0,0 +1,411 @@ +/** + * ecobeeGenerateMonthlyStats + * + * Copyright 2015 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Requires MyEcobee device ( v5.0 and higher) available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +import java.text.SimpleDateFormat +definition( + name: "${get_APP_NAME()}", + namespace: "yracine", + author: "Yves Racine", + description: "This smartapp allows a ST user to generate monthly runtime stats (by scheduling or based on custom dates) on their devices controlled by ecobee such as a heating & cooling component,fan, dehumidifier/humidifier/HRV/ERV. ", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + section("About") { + paragraph "${get_APP_NAME()}, the smartapp that generates monthly runtime reports about your ecobee components" + paragraph "Version 1.8" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2016 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + + section("Generate monthly stats for this ecobee thermostat") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee?" + + } + section("Date for the initial run = YYYY-MM-DD") { + input "givenEndDate", "text", title: "End Date [default=today]", required: false + } + section("Time for the initial run (24HR)" ) { + input "givenEndTime", "text", title: "End time [default=00:00]", required: false + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes", "No"]], required: false, default:"No" + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + section("Detailed Notifications") { + input "detailedNotif", "bool", title: "Detailed Notifications?", required:false + } + section("Enable Amazon Echo/Ask Alexa Notifications for ecobee stats reporting (optional)") { + input (name:"askAlexaFlag", title: "Ask Alexa verbal Notifications [default=false]?", type:"bool", + description:"optional",required:false) + input (name:"listOfMQs", type:"enum", title: "List of the Ask Alexa Message Queues (default=Primary)", options: state?.askAlexaMQ, multiple: true, required: false, + description:"optional") + input "AskAlexaExpiresInDays", "number", title: "Ask Alexa's messages expiration in days (optional,default=2 days)?", required: false + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + atomicState?.timestamp='' + atomicState?.componentAlreadyProcessed='' + atomicState?.retries=0 + + + runIn((1*60), "generateStats") // run 1 minute later as it requires notification. + subscribe(app, appTouch) + atomicState?.poll = [ last: 0, rescheduled: now() ] + + //Subscribe to different events (ex. sunrise and sunset events) to trigger rescheduling if needed + subscribe(location, "sunset", rescheduleIfNeeded) + subscribe(location, "mode", rescheduleIfNeeded) + subscribe(location, "sunsetTime", rescheduleIfNeeded) + + subscribe(location, "askAlexaMQ", askAlexaMQHandler) + rescheduleIfNeeded() +} + +def askAlexaMQHandler(evt) { + if (!evt) return + switch (evt.value) { + case "refresh": + state?.askAlexaMQ = evt.jsonData && evt.jsonData?.queues ? evt.jsonData.queues : [] + log.info("askAlexaMQHandler>refresh value=$state?.askAlexaMQ") + break + } +} + + +def rescheduleIfNeeded(evt) { + if (evt) log.debug("rescheduleIfNeeded>$evt.name=$evt.value") + Integer delay = (24*60) // By default, do it every day + BigDecimal currentTime = now() + BigDecimal lastPollTime = (currentTime - (atomicState?.poll["last"]?:0)) + + if (lastPollTime != currentTime) { + Double lastPollTimeInMinutes = (lastPollTime/60000).toDouble().round(1) + log.info "rescheduleIfNeeded>last poll was ${lastPollTimeInMinutes.toString()} minutes ago" + } + if (((atomicState?.poll["last"]?:0) + (delay * 60000) < currentTime) && canSchedule()) { + log.info "rescheduleIfNeeded>scheduling dailyRun in ${delay} minutes.." + schedule("0 30 0 * * ?", dailyRun) + } + + // Update rescheduled state + + if (!evt) atomicState?.poll["rescheduled"] = now() +} + + +def appTouch(evt) { + atomicState?.timestamp='' + atomicState?.componentAlreadyProcessed='' + generateStats() +} + + +void reRunIfNeeded() { + if (detailedNotif) { + log.debug("reRunIfNeeded>About to call generateStats() with state.componentAlreadyProcessed=${atomicState?.componentAlreadyProcessed}") + } + generateStats() + +} + +void dailyRun() { + Integer delay = (24*60) // By default, do it every day + atomicState?.poll["last"] = now() + + //schedule the rescheduleIfNeeded() function + + if (((atomicState?.poll["rescheduled"]?:0) + (delay * 60000)) < now()) { + log.info "takeAction>scheduling rescheduleIfNeeded() in ${delay} minutes.." +// generate the stats every day at 0:30 + schedule("0 30 0 * * ?", rescheduleIfNeeded) + // Update rescheduled state + atomicState?.poll["rescheduled"] = now() + } + settings.givenEndDate=null + settings.givenEndTime=null + if (detailedNotif) { + log.debug("dailyRun>For $ecobee, about to call generateStats() with settings.givenEndDate=${settings.givenEndDate}") + } + atomicState?.componentAlreadyProcessed='' + atomicState?.retries=0 + generateStats() + +} + + +private String formatISODateInLocalTime(dateInString, timezone='') { + def myTimezone=(timezone)?TimeZone.getTimeZone(timezone):location.timeZone + if ((dateInString==null) || (dateInString.trim()=="")) { + return (new Date().format("yyyy-MM-dd HH:mm:ss", myTimezone)) + } + SimpleDateFormat ISODateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + Date ISODate = ISODateFormat.parse(dateInString) + String dateInLocalTime =new Date(ISODate.getTime()).format("yyyy-MM-dd HH:mm:ss", myTimezone) + log.debug("formatDateInLocalTime>dateInString=$dateInString, dateInLocalTime=$dateInLocalTime") + return dateInLocalTime +} + + +private def formatDate(dateString) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm zzz") + Date aDate = sdf.parse(dateString) + return aDate +} + +private def get_nextComponentStats(component='') { + if (detailedNotif) { + log.debug "get_nextComponentStats>About to get ${component}'s next component from components table" + } + + def nextInLine=[:] + + def components = [ + '': + [position:1, next: 'auxHeat1' + ], + 'auxHeat1': + [position:2, next: 'auxHeat2' + ], + 'auxHeat2': + [position:3, next: 'auxHeat3' + ], + 'auxHeat3': + [position:4, next: 'compCool2' + ], + 'compCool2': + [position:5, next: 'compCool1' + ], + 'compCool1': + [position:6, next: 'done' + ], + ] + try { + nextInLine = components.getAt(component) + } catch (any) { + nextInLine=[position:1, next:'auxHeat1'] + if (detailedNotif) { + log.debug "get_nextComponentStats>${component} not found, nextInLine=${nextInLine}" + } + } + if (detailedNotif) { + log.debug "get_nextComponentStats>got ${component}'s next component from components table= ${nextInLine}" + } + return nextInLine + +} + + +void generateStats() { + String dateInLocalTime = new Date().format("yyyy-MM-dd", location.timeZone) + def delay = 2 // 2-minute delay for rerun + def MAX_POSITION=6 + def MAX_RETRIES=4 + float runtimeTotalAvgMonthly + String mode= ecobee.currentThermostatMode + atomicState?.retries= ((atomicState?.retries==null) ?:0) +1 + + try { + unschedule(reRunIfNeeded) + } catch (e) { + + if (detailedNotif) { + log.debug("${get_APP_NAME()}>Exception $e while unscheduling reRunIfNeeded") + } + } + + if (atomicState?.retries >= MAX_RETRIES) { + if (detailedNotif) { + log.debug("${get_APP_NAME()}>Max retries reached, exiting") + send("max retries reached ${atomicState?.retries}), exiting") + } + } + def component = atomicState?.componentAlreadyProcessed + def nextComponent = get_nextComponentStats(component) // get nextComponentToBeProcessed + if (detailedNotif) { + log.debug("${get_APP_NAME()}>for $ecobee, about to process nextComponent=${nextComponent}, state.componentAlreadyProcessed=${atomicState?.componentAlreadyProcessed}") + } + if (atomicState?.timestamp == dateInLocalTime && nextComponent.position >=MAX_POSITION) { + return // the monthly stats are already generated + } else { + // schedule a rerun till the stats are generated properly + schedule("0 0/${delay} * * * ?", reRunIfNeeded) + } + + String timezone = new Date().format("zzz", location.timeZone) + String dateAtMidnight = dateInLocalTime + " 00:00 " + timezone + if (detailedNotif) { + log.debug("${get_APP_NAME()}>date at Midnight= ${dateAtMidnight}") + } + Date endDate = formatDate(dateAtMidnight) + + + def reportEndDate = (settings.givenEndDate) ?: endDate.format("yyyy-MM-dd", location.timeZone) + def reportEndTime=(settings.givenEndTime) ?:"00:00" + def dateTime = reportEndDate + " " + reportEndTime + " " + timezone + endDate = formatDate(dateTime) + Date aMonthAgo= endDate -30 + + + component = 'auxHeat1' +// if ((mode in ['auto','heat','off']) && (nextComponent?.position <= 1)) { + if (nextComponent?.position <= 1) { + generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days + runtimeTotalAvgMonthly = (ecobee.currentAuxHeat1RuntimeAvgMonthly)? ecobee.currentAuxHeat1RuntimeAvgMonthly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgMonthly) { + send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag + } + } + + int heatStages = ecobee.currentHeatStages.toInteger() + + + component = 'auxHeat2' +// if ((mode in ['auto','heat', 'off']) && (heatStages >1) && (nextComponent.position <= 2)) { + if ((heatStages >1) && (nextComponent.position <= 2)) { + generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days + runtimeTotalAvgMonthly = (ecobee.currentAuxHeat2RuntimeAvgMonthly)? ecobee.currentAuxHeat2RuntimeAvgMonthly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgMonthly) { + send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag + } + } + + component = 'auxHeat3' +// if ((mode in ['auto','heat', 'off']) && (heatStages >2) && (nextComponent.position <= 3)) { + if ((heatStages >2) && (nextComponent.position <= 3)) { + generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days + runtimeTotalAvgMonthly = (ecobee.currentAuxHeat3RuntimeAvgMonthly)? ecobee.currentAuxHeat3RuntimeAvgMonthly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgMonthly) { + send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag + } + } + +// Get the compCool1's runtime for startDate-endDate period + + int coolStages = ecobee.currentCoolStages.toInteger() + + +// Get the compCool2's runtime for startDate-endDate period + component = 'compCool2' +// if ((mode in ['auto','cool', 'off']) && (coolStages >1) && (nextComponent.position <= 4)) { + if ((coolStages >1) && (nextComponent.position <= 4)) { + generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days + runtimeTotalAvgMonthly = (ecobee.currentCompCool2RuntimeAvgMonthly)? ecobee.currentCompCool2RuntimeAvgMonthly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgMonthly) { + send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag + } + } + component = 'compCool1' +// if ((mode in ['auto','cool', 'off']) && (nextComponent.position <= 5)) { + if (nextComponent.position <= 5) { + generateRuntimeReport(component,aMonthAgo, endDate,'monthly') // generate stats for the last 30 days + runtimeTotalAvgMonthly = (ecobee.currentCompCool1RuntimeAvgMonthly)? ecobee.currentCompCool1RuntimeAvgMonthly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgMonthly) { + send "${ecobee} ${component}'s average monthly runtime stats=${runtimeTotalAvgMonthly} minutes since ${String.format('%tF', aMonthAgo)}", settings.askAlexaFlag + } + } + + + component=atomicState?.componentAlreadyProcessed + nextComponent = get_nextComponentStats(component) // get nextComponentToBeProcessed +// int endPosition= (mode=='heat') ? ((heatStages>2) ? 4 : (heatStages>1)? 3:2) :MAX_POSITION + if (nextComponent.position >=MAX_POSITION) { + send "generated all ${ecobee}'s monthly stats since ${String.format('%tF', aMonthAgo)}" + unschedule(reRunIfNeeded) // No need to reschedule again as the stats are completed. + atomicatomicState?.timestamp = dateInLocalTime // save the date to avoid re-execution. + } + + +} + +void generateRuntimeReport(component, startDate, endDate, frequence='daily') { + + if (detailedNotif) { + log.debug("generateRuntimeReport>For component ${component}, about to call getReportData with endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + send ("For component ${component}, about to call getReportData with aMonthAgo in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + ecobee.getReportData("", startDate, endDate, null, null, component,false) + if (detailedNotif) { + log.debug("generateRuntimeReport>For component ${component}, about to call generateReportRuntimeEvents with endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + send ("For component ${component}, about to call generateReportRuntimeEvents with aMonthAgo in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + ecobee.generateReportRuntimeEvents(component, startDate,endDate, 0, null,frequence) + + +} + +private send(msg, askAlexa=false) { + def message = "${get_APP_NAME()}>${msg}" + if (sendPushMessage == "Yes") { + sendPush(message) + } + if (askAlexa) { + def expiresInDays=(AskAlexaExpiresInDays)?:2 + sendLocationEvent( + name: "AskAlexaMsgQueue", + value: "${get_APP_NAME()}", + isStateChange: true, + descriptionText: msg, + data:[ + queues: listOfMQs, + expires: (expiresInDays*24*60*60) /* Expires after 2 days by default */ + ] + ) + } /* End if Ask Alexa notifications*/ + + if (phone) { + log.debug("sending text message") + sendSms(phone, message) + } + + log.debug msg + +} + +private def get_APP_NAME() { + return "ecobeeGenerateMonthlyStats" +} diff --git a/third-party/ecobeeGenerateStats.groovy b/third-party/ecobeeGenerateStats.groovy new file mode 100755 index 0000000..2b13990 --- /dev/null +++ b/third-party/ecobeeGenerateStats.groovy @@ -0,0 +1,523 @@ +/** + * ecobeeGenerateStats + * + * Copyright 2015 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +import java.text.SimpleDateFormat +definition( + name: "${get_APP_NAME()}", + namespace: "yracine", + author: "Yves Racine", + description: "This smartapp allows a ST user to generate runtime stats (daily by scheduling or based on custom dates) on their devices controlled by ecobee such as a heating & cooling component,fan, dehumidifier/humidifier/HRV/ERV. ", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + section("About") { + paragraph "${get_APP_NAME()}, the smartapp that generates daily runtime reports about your ecobee components" + paragraph "Version 2.5.1" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + + section("Generate daily stats for this ecobee thermostat") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee?" + + } + section("Start date for the initial run, format = YYYY-MM-DD") { + input "givenStartDate", "text", title: "Beginning Date [default=yesterday]", required: false + } + section("Start time for initial run HH:MM (24HR)") { + input "givenStartTime", "text", title: "Beginning time [default=00:00]" , required: false + } + section("End date for the initial run = YYYY-MM-DD") { + input "givenEndDate", "text", title: "End Date [default=today]", required: false + } + section("End time for the initial run (24HR)" ) { + input "givenEndTime", "text", title: "End time [default=00:00]", required: false + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes", "No"]], required: false, default:"No" + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + section("Detailed Notifications") { + input "detailedNotif", "bool", title: "Detailed Notifications?", required:false + } + section("Enable Amazon Echo/Ask Alexa Notifications [optional, default=false]") { + input (name:"askAlexaFlag", title: "Ask Alexa verbal Notifications?", type:"bool", + description:"optional",required:false) + input (name:"listOfMQs", type:"enum", title: "List of the Ask Alexa Message Queues (default=Primary)", options: state?.askAlexaMQ, multiple: true, required: false, + description:"optional") + input "AskAlexaExpiresInDays", "number", title: "Ask Alexa's messages expiration in days (default=2 days)?", required: false + } + + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + + atomicState?.timestamp='' + atomicState?.componentAlreadyProcessed='' + atomicState?.retries=0 + + runIn((1*60), "generateStats") // run 1 minute later as it requires notification. + subscribe(app, appTouch) + atomicState?.poll = [ last: 0, rescheduled: now() ] + + //Subscribe to different events (ex. sunrise and sunset events) to trigger rescheduling if needed + subscribe(location, "sunset", rescheduleIfNeeded) + subscribe(location, "mode", rescheduleIfNeeded) + subscribe(location, "sunsetTime", rescheduleIfNeeded) + subscribe(location, "askAlexaMQ", askAlexaMQHandler) + rescheduleIfNeeded() +} + +def askAlexaMQHandler(evt) { + if (!evt) return + switch (evt.value) { + case "refresh": + state?.askAlexaMQ = evt.jsonData && evt.jsonData?.queues ? evt.jsonData.queues : [] + log.info("askAlexaMQHandler>refresh value=$state?.askAlexaMQ") + break + } +} + +def rescheduleIfNeeded(evt) { + if (evt) log.debug("rescheduleIfNeeded>$evt.name=$evt.value") + Integer delay = (24*60) // By default, do it every day + BigDecimal currentTime = now() + BigDecimal lastPollTime = (currentTime - (atomicState?.poll["last"]?:0)) + + if (lastPollTime != currentTime) { + Double lastPollTimeInMinutes = (lastPollTime/60000).toDouble().round(1) + log.info "rescheduleIfNeeded>last poll was ${lastPollTimeInMinutes.toString()} minutes ago" + } + if (((atomicState?.poll["last"]?:0) + (delay * 60000) < currentTime) && canSchedule()) { + log.info "rescheduleIfNeeded>scheduling dailyRun in ${delay} minutes.." +// generate the stats every day at 0:10 + + schedule("0 10 0 * * ?", dailyRun) + } + + // Update rescheduled state + + if (!evt) atomicState?.poll["rescheduled"] = now() +} + + +def appTouch(evt) { + atomicState?.timestamp='' + atomicState?.componentAlreadyProcessed='' + generateStats() +} + + + +void dailyRun() { + Integer delay = (24*60) // By default, do it every day + atomicState?.poll["last"] = now() + + //schedule the rescheduleIfNeeded() function + + if (((atomicState?.poll["rescheduled"]?:0) + (delay * 60000)) < now()) { + log.info "takeAction>scheduling rescheduleIfNeeded() in ${delay} minutes.." + schedule("0 10 0 * * ?", rescheduleIfNeeded) + // Update rescheduled state + atomicState?.poll["rescheduled"] = now() + } + settings.givenStartDate=null + settings.givenStartTime=null + settings.givenEndDate=null + settings.givenEndTime=null + if (detailedNotif) { + log.debug("dailyRun>for $ecobee, about to call generateStats() with settings.givenEndDate=${settings.givenEndDate}") + } + atomicState?.componentAlreadyProcessed='' + atomicState?.retries=0 + generateStats() + +} + +void reRunIfNeeded() { + if (detailedNotif) { + log.debug("reRunIfNeeded>About to call generateStats() with state.componentAlreadyProcessed=${atomicState?.componentAlreadyProcessed}") + } + generateStats() + +} + + + +private String formatISODateInLocalTime(dateInString, timezone='') { + def myTimezone=(timezone)?TimeZone.getTimeZone(timezone):location.timeZone + if ((dateInString==null) || (dateInString.trim()=="")) { + return (new Date().format("yyyy-MM-dd HH:mm:ss", myTimezone)) + } + SimpleDateFormat ISODateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + Date ISODate = ISODateFormat.parse(dateInString) + String dateInLocalTime =new Date(ISODate.getTime()).format("yyyy-MM-dd HH:mm:ss", myTimezone) + log.debug("formatDateInLocalTime>dateInString=$dateInString, dateInLocalTime=$dateInLocalTime") + return dateInLocalTime +} + + +private def formatDate(dateString) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm zzz") + Date aDate = sdf.parse(dateString) + return aDate +} + +private def get_nextComponentStats(component='') { + def nextInLine=[:] + + def components = [ + '': + [position:1, next: 'auxHeat1' + ], + 'auxHeat1': + [position:2, next: 'auxHeat2' + ], + 'auxHeat2': + [position:3, next: 'auxHeat3' + ], + 'auxHeat3': + [position:4, next: 'compCool1' + ], + 'compCool1': + [position:5, next: 'compCool2' + ], + 'compCool2': + [position:6, next: 'humidifier' + ], + 'humidifier': + [position:7, next: 'dehumidifier' + ], + 'dehumidifier': + [position:8, next: 'ventilator' + ], + 'ventilator': + [position:9, next: 'fan' + ], + 'fan': + [position:10, next: 'done' + ] + ] + try { + nextInLine = components.getAt(component) + } catch (any) { + if (detailedNotif) { + log.debug "get_nextComponentStats>${component} not found" + } + nextInLine=[position:1,next:'auxHeat1'] + } + + if (detailedNotif) { + log.debug "get_nextComponentStats>got ${component}'s next component from components table= ${nextInLine}" + } + return nextInLine + +} + + +void generateStats() { + def MAX_POSITION=10 + def MAX_RETRIES=4 + float runtimeTotalYesterday,runtimeTotalDaily + String dateInLocalTime = new Date().format("yyyy-MM-dd", location.timeZone) + def delay = 2 + + atomicState?.retries= ((atomicState?.retries==null) ?:0) +1 + + try { + unschedule(reRunIfNeeded) + } catch (e) { + + if (detailedNotif) { + log.debug("${get_APP_NAME()}>Exception $e while unscheduling reRunIfNeeded") + } + } + + if (atomicState?.retries >= MAX_RETRIES) { + if (detailedNotif) { + log.debug("${get_APP_NAME()}>Max retries reached, exiting") + send("max retries reached ${atomicState?.retries}), exiting") + } + } + + def component=atomicState?.componentAlreadyProcessed // use logic to restart the batch process if needed due to ST rate limiting + def nextComponent = get_nextComponentStats(component) // get next Component To Be Processed + if (detailedNotif) { + log.debug("${get_APP_NAME()}>for $ecobee, about to process nextComponent=${nextComponent}, state.componentAlreadyProcessed=${atomicState?.componentAlreadyProcessed}") + } + if (atomicState?.timestamp == dateInLocalTime && nextComponent.position >=MAX_POSITION) { + return // the daily stats are already generated + } else { + // schedule a rerun till the stats are generated properly + schedule("0 0/${delay} * * * ?", reRunIfNeeded) + + } + + String timezone = new Date().format("zzz", location.timeZone) + String dateAtMidnight = dateInLocalTime + " 00:00 " + timezone + if (detailedNotif) { + log.debug("${get_APP_NAME()}>date at Midnight= ${dateAtMidnight}") + } + Date endDate = formatDate(dateAtMidnight) + Date startDate = endDate -1 + + def reportStartDate = (settings.givenStartDate) ?: startDate.format("yyyy-MM-dd", location.timeZone) + def reportStartTime=(settings.givenStartTime) ?:"00:00" + def dateTime = reportStartDate + " " + reportStartTime + " " + timezone + startDate = formatDate(dateTime) + Date yesterday = startDate-1 + + if (detailedNotif) { + log.debug("${get_APP_NAME()}>start dateTime = ${dateTime}, startDate in UTC = ${startDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + def reportEndDate = (settings.givenEndDate) ?: endDate.format("yyyy-MM-dd", location.timeZone) + def reportEndTime=(settings.givenEndTime) ?:"00:00" + dateTime = reportEndDate + " " + reportEndTime + " " + timezone + endDate = formatDate(dateTime) + if (detailedNotif) { + log.debug("${get_APP_NAME()}>end dateTime = ${dateTime}, endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + + + // Get the auxHeat1's runtime for startDate-endDate period + component = 'auxHeat1' + if (nextComponent.position <= 1) { + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentAuxHeat1RuntimeDaily) ? ecobee.currentAuxHeat1RuntimeDaily.toFloat().round(2):0 + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)}, ${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + + generateRuntimeReport(component,yesterday, startDate,'yesterday') // generate stats for yesterday + runtimeTotalYesterday = (ecobee.currentAuxHeat1RuntimeYesterday)? ecobee.currentAuxHeat1RuntimeYesterday.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (detailedNotif && runtimeTotalYesterday) { + send "And, on ${String.format('%tF', yesterday)}, ${ecobee} ${component}'s runtime stats for the day before=${runtimeTotalYesterday} minutes" + } + } + + int heatStages = ecobee.currentHeatStages.toInteger() + + component = 'auxHeat2' + if (heatStages >1 && (nextComponent.position <= 2) ) { + +// Get the auxHeat2's runtime for startDate-endDate period + + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentAuxHeat2RuntimeDaily)? ecobee.currentAuxHeat2RuntimeDaily.toFloat().round(2):0 + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)}, ${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + generateRuntimeReport(component,yesterday, startDate,'yesterday') // generate stats for yesterday + runtimeTotalYesterday = (ecobee.currentAuxHeat2RuntimeYesterday)? ecobee.currentAuxHeat2RuntimeYesterday.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (detailedNotif && runtimeTotalYesterday) { + send "And, on ${String.format('%tF', yesterday)}, ${ecobee} ${component}'s runtime stats for the day before=${runtimeTotalYesterday} minutes" + } + } + + component = 'auxHeat3' + if (heatStages >2 && nextComponent.position <= 3) { + +// Get the auxHeat3's runtime for startDate-endDate period + + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentAuxHeat3RuntimeDaily)? ecobee.currentAuxHeat3RuntimeDaily.toFloat().round(2):0 + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)},${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + generateRuntimeReport(component,yesterday, startDate,'yesterday') // generate stats for yesterday + runtimeTotalYesterday = (ecobee.currentAuxHeat3RuntimeYesterday)? ecobee.currentAuxHeat3RuntimeYesterday.toFloat().round(2):0 + if (detailedNotif && runtimeTotalYesterday) { + send "And, on ${String.format('%tF', yesterday)}, ${ecobee} ${component}'s runtime stats for the day before=${runtimeTotalYesterday} minutes" + } + } + +// Get the compCool1's runtime for startDate-endDate period + + int coolStages = ecobee.currentCoolStages.toInteger() + component = 'compCool1' + + if (nextComponent.position <= 4) { + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentCompCool1RuntimeDaily)? ecobee.currentCompCool1RuntimeDaily.toFloat().round(2):0 + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)}, ${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + generateRuntimeReport(component,yesterday, startDate,'yesterday') // generate stats for the day before + runtimeTotalYesterday = (ecobee.currentCompCool1RuntimeYesterday)? ecobee.currentCompCool1RuntimeYesterday.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (detailedNotif && runtimeTotalYesterday) { + send "And, on ${String.format('%tF', yesterday)}, ${ecobee} ${component}'s runtime stats for the day before=${runtimeTotalYesterday} minutes" + } + } + +// Get the compCool2's runtime for startDate-endDate period + + component = 'compCool2' + if (coolStages >1 && nextComponent.position <= 5) { + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentCompCool2RuntimeDaily)? ecobee.currentCompCool2RuntimeDaily.toFloat().round(2):0 + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)}, ${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + generateRuntimeReport(component,yesterday, startDate,'yesterday') // generate stats for the day before + runtimeTotalYesterday = (ecobee.currentCompCool2RuntimeYesterday)? ecobee.currentCompCool2RuntimeYesterday.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (detailedNotif && runtimeTotalYesterday ) { + send "And, on ${String.format('%tF', yesterday)}, ${ecobee} ${component}'s runtime stats for the day before=${runtimeTotalYesterday} minutes" + } + } + + + def hasDehumidifier = (ecobee.currentHasDehumidifier) ? ecobee.currentHasDehumidifier : 'false' + def hasHumidifier = (ecobee.currentHasHumidifier) ? ecobee.currentHasHumidifier : 'false' + def hasHrv = (ecobee.currentHasHrv)? ecobee.currentHasHrv : 'false' + def hasErv = (ecobee.currentHasErv)? ecobee.currentHasErv : 'false' + + component = "humidifier" + if (hasHumidifier=='true' && (nextComponent.position <= 6)) { + // Get the humidifier's runtime for startDate-endDate period + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentHumidifierRuntimeDaily)? ecobee.currentHumidifierRuntimeDaily.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)}, ${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + + } + + component = 'dehumidifier' + if (hasDehumidifier=='true' && (nextComponent.position <= 7)) { + // Get the dehumidifier's for startDate-endDate period + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentDehumidifierRuntimeDaily)? ecobee.currentDehumidifierRuntimeDaily.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)}, ${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + + } + component = 'ventilator' + if (hasHrv=='true' || hasErv=='true' && (nextComponent.position <= 8)) { + // Get the ventilator's runtime for startDate-endDate period + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentVentilatorRuntimeDaily)? ecobee.currentVentilatorRuntimeDaily.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)}, ${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + + } + + component = 'fan' +// Get the fan's runtime for startDate-endDate period + if (nextComponent.position <= 9) { + + generateRuntimeReport(component,startDate, endDate) + runtimeTotalDaily = (ecobee.currentFanRuntimeDaily)? ecobee.currentFanRuntimeDaily.toFloat().round(2):0 + if (runtimeTotalDaily) { + send "On ${String.format('%tF', startDate)},${ecobee} ${component}'s runtime stats=${runtimeTotalDaily} minutes", settings.askAlexaFlag + } + generateRuntimeReport(component,yesterday, startDate,'yesterday') // generate stats for the day before + runtimeTotalYesterday = (ecobee.currentFanRuntimeYesterday)? ecobee.currentFanRuntimeYesterday.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (detailedNotif && runtimeTotalYesterday) { + send "And, on ${String.format('%tF', yesterday)}, ${ecobee} ${component}'s runtime stats for the day before=${runtimeTotalYesterday} minutes" + } + } + + component=atomicState?.componentAlreadyProcessed + nextComponent = get_nextComponentStats(component) // get nextComponentToBeProcessed + if (nextComponent.position >= MAX_POSITION) { + send "generated ${ecobee}'s daily stats done for ${String.format('%tF', startDate)} - ${String.format('%tF', endDate)} period" + atomicState?.timestamp = dateInLocalTime // save the local date to avoid re-execution + unschedule(reRunIfNeeded) // No need to reschedule again as the stats are completed. + atomicState?.retries=0 + } + +} + +void generateRuntimeReport(component, startDate, endDate, frequence='daily') { + + if (detailedNotif) { + log.debug("${get_APP_NAME()}>For ${ecobee} ${component}, about to call getReportData with endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + ecobee.getReportData("", startDate, endDate, null, null, component,false) + if (detailedNotif) { + log.debug("${get_APP_NAME()}>For ${ecobee} ${component}, about to call generateRuntimeReportEvents with endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + ecobee.generateReportRuntimeEvents(component, startDate,endDate, 0, null,frequence) + +} + +private send(msg, askAlexa=false) { + def message = "${get_APP_NAME()}>${msg}" + if (sendPushMessage == "Yes") { + sendPush(message) + } + if (askAlexa) { + def expiresInDays=(AskAlexaExpiresInDays)?:2 + sendLocationEvent( + name: "AskAlexaMsgQueue", + value: "${get_APP_NAME()}", + isStateChange: true, + descriptionText: msg, + data:[ + queues: listOfMQs, + expires: (expiresInDays*24*60*60) /* Expires after 2 days by default */ + ] + ) + } /* End if Ask Alexa notifications*/ + + if (phone) { + log.debug("sending text message") + sendSms(phone, message) + } + + log.debug msg + +} + +private def get_APP_NAME() { + return "ecobeeGenerateStats" +} diff --git a/third-party/ecobeeGenerateWeeklyStats.groovy b/third-party/ecobeeGenerateWeeklyStats.groovy new file mode 100755 index 0000000..073a244 --- /dev/null +++ b/third-party/ecobeeGenerateWeeklyStats.groovy @@ -0,0 +1,416 @@ +/** + * ecobeeGenerateWeeklyStats + * + * Copyright 2015 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Requires MyEcobee device (v5.0 and higher) available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +import java.text.SimpleDateFormat +definition( + name: "${get_APP_NAME()}", + namespace: "yracine", + author: "Yves Racine", + description: "This smartapp allows a ST user to generate weekly runtime stats (by scheduling or based on custom dates) on their devices controlled by ecobee such as a heating & cooling component,fan, dehumidifier/humidifier/HRV/ERV. ", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + section("About") { + paragraph "${get_APP_NAME()}, the smartapp that generates weekly runtime reports about your ecobee components" + paragraph "Version 1.7" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2016 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + + section("Generate weekly stats for this ecobee thermostat") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee?" + + } + section("Date for the initial run = YYYY-MM-DD") { + input "givenEndDate", "text", title: "End Date [default=today]", required: false + } + section("Time for the initial run (24HR)" ) { + input "givenEndTime", "text", title: "End time [default=00:00]", required: false + } + section( "Notifications" ) { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata:[values:["Yes", "No"]], required: false, default:"No" + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + section("Detailed Notifications") { + input "detailedNotif", "bool", title: "Detailed Notifications?", required:false + } + section("Enable Amazon Echo/Ask Alexa Notifications [optional, default=false]") { + input (name:"askAlexaFlag", title: "Ask Alexa verbal Notifications?", type:"bool", + description:"optional",required:false) + input (name:"listOfMQs", type:"enum", title: "List of the Ask Alexa Message Queues (default=Primary)", options: state?.askAlexaMQ, multiple: true, required: false, + description:"optional") + input "AskAlexaExpiresInDays", "number", title: "Ask Alexa's messages expiration in days (default=2 days)?", required: false + } + +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + + + atomicState?.timestamp='' + atomicState?.componentAlreadyProcessed='' + atomicState?.retries=0 + + runIn((1*60), "generateStats") // run 1 minute later as it requires notification. + subscribe(app, appTouch) + atomicState?.poll = [ last: 0, rescheduled: now() ] + + //Subscribe to different events (ex. sunrise and sunset events) to trigger rescheduling if needed + subscribe(location, "sunset", rescheduleIfNeeded) + subscribe(location, "mode", rescheduleIfNeeded) + subscribe(location, "sunsetTime", rescheduleIfNeeded) + subscribe(location, "askAlexaMQ", askAlexaMQHandler) + rescheduleIfNeeded() +} + +def askAlexaMQHandler(evt) { + if (!evt) return + switch (evt.value) { + case "refresh": + state?.askAlexaMQ = evt.jsonData && evt.jsonData?.queues ? evt.jsonData.queues : [] + log.info("askAlexaMQHandler>refresh value=$state?.askAlexaMQ") + break + } +} + + +def rescheduleIfNeeded(evt) { + if (evt) log.debug("rescheduleIfNeeded>$evt.name=$evt.value") + Integer delay = (24*60) // By default, do it every day + BigDecimal currentTime = now() + BigDecimal lastPollTime = (currentTime - (atomicState?.poll["last"]?:0)) + + if (lastPollTime != currentTime) { + Double lastPollTimeInMinutes = (lastPollTime/60000).toDouble().round(1) + log.info "rescheduleIfNeeded>last poll was ${lastPollTimeInMinutes.toString()} minutes ago" + } + if (((atomicState?.poll["last"]?:0) + (delay * 60000) < currentTime) && canSchedule()) { + log.info "rescheduleIfNeeded>scheduling dailyRun in ${delay} minutes.." +// generate the stats every day at 0:15 + schedule("0 15 0 * * ?", dailyRun) + } + + // Update rescheduled state + + if (!evt) atomicState?.poll["rescheduled"] = now() +} + + +def appTouch(evt) { + atomicState?.timestamp='' + atomicState?.componentAlreadyProcessed='' + generateStats() +} + +void reRunIfNeeded() { + if (detailedNotif) { + log.debug("reRunIfNeeded>About to call generateStats() with state.componentAlreadyProcessed=${atomicState?.componentAlreadyProcessed}") + } + generateStats() + +} + + +void dailyRun() { + Integer delay = (24*60) // By default, do it every day + atomicState?.poll["last"] = now() + + //schedule the rescheduleIfNeeded() function + + if (((atomicState?.poll["rescheduled"]?:0) + (delay * 60000)) < now()) { + log.info "takeAction>scheduling rescheduleIfNeeded() in ${delay} minutes.." + schedule("0 15 0 * * ?", rescheduleIfNeeded) + // Update rescheduled state + atomicState?.poll["rescheduled"] = now() + } + settings.givenEndDate=null + settings.givenEndTime=null + if (detailedNotif) { + log.debug("dailyRun>for $ecobee,about to call generateStats() with settings.givenEndDate=${settings.givenEndDate}") + } + atomicState?.componentAlreadyProcessed='' + atomicState?.retries=0 + generateStats() + +} + + +private String formatISODateInLocalTime(dateInString, timezone='') { + def myTimezone=(timezone)?TimeZone.getTimeZone(timezone):location.timeZone + if ((dateInString==null) || (dateInString.trim()=="")) { + return (new Date().format("yyyy-MM-dd HH:mm:ss", myTimezone)) + } + SimpleDateFormat ISODateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + Date ISODate = ISODateFormat.parse(dateInString) + String dateInLocalTime =new Date(ISODate.getTime()).format("yyyy-MM-dd HH:mm:ss", myTimezone) + log.debug("formatDateInLocalTime>dateInString=$dateInString, dateInLocalTime=$dateInLocalTime") + return dateInLocalTime +} + + +private def formatDate(dateString) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm zzz") + Date aDate = sdf.parse(dateString) + return aDate +} + +private def get_nextComponentStats(component='') { + if (detailedNotif) { + log.debug "get_nextComponentStats>About to get ${component}'s next component from components table" + } + + def nextInLine=[:] + + def components = [ + '': + [position:1, next: 'auxHeat1' + ], + 'auxHeat1': + [position:2, next: 'auxHeat2' + ], + 'auxHeat2': + [position:3, next: 'auxHeat3' + ], + 'auxHeat3': + [position:4, next: 'compCool2' + ], + 'compCool2': + [position:5, next: 'compCool1' + ], + 'compCool1': + [position:6, next: 'done' + ], + ] + try { + nextInLine = components.getAt(component) + } catch (any) { + nextInLine=[position:1, next:'auxHeat1'] + if (detailedNotif) { + log.debug "get_nextComponentStats>${component} not found, nextInLine=${nextInLine}" + } + } + if (detailedNotif) { + log.debug "get_nextComponentStats>got ${component}'s next component from components table= ${nextInLine}" + } + return nextInLine + +} + + +void generateStats() { + String dateInLocalTime = new Date().format("yyyy-MM-dd", location.timeZone) + def MAX_POSITION=6 + def MAX_RETRIES=4 + float runtimeTotalAvgWeekly + + def delay = 2 // 2-minute delay for rerun + atomicState?.retries= ((atomicState?.retries==null) ?:0) +1 + + try { + unschedule(reRunIfNeeded) + } catch (e) { + + if (detailedNotif) { + log.debug("${get_APP_NAME()}>Exception $e while unscheduling reRunIfNeeded") + } + } + if (atomicState?.retries >= MAX_RETRIES) { + if (detailedNotif) { + log.debug("${get_APP_NAME()}>Max retries reached, exiting") + send("max retries reached ${atomicState?.retries}), exiting") + } + } + + def component = atomicState?.componentAlreadyProcessed + def nextComponent = get_nextComponentStats(component) // get nextComponentToBeProcessed + if (detailedNotif) { + log.debug("${get_APP_NAME()}>For ${ecobee}, about to process nextComponent=${nextComponent}, state.componentAlreadyProcessed=${atomicState?.componentAlreadyProcessed}") + } + if (atomicState?.timestamp == dateInLocalTime && nextComponent.position >=MAX_POSITION) { + return // the weekly stats are already generated + } else { + // schedule a rerun till the stats are generated properly + schedule("0 0/${delay} * * * ?", reRunIfNeeded) + } + + String timezone = new Date().format("zzz", location.timeZone) + String dateAtMidnight = dateInLocalTime + " 00:00 " + timezone + if (detailedNotif) { + log.debug("${get_APP_NAME()}>date at Midnight= ${dateAtMidnight}") + } + Date endDate = formatDate(dateAtMidnight) + + def reportEndDate = (settings.givenEndDate) ?: endDate.format("yyyy-MM-dd", location.timeZone) + def reportEndTime=(settings.givenEndTime) ?:"00:00" + def dateTime = reportEndDate + " " + reportEndTime + " " + timezone + endDate = formatDate(dateTime) + Date aWeekAgo= endDate -7 + if (detailedNotif) { + log.debug("${get_APP_NAME()}>end dateTime = ${dateTime}, endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + + + // Get the auxHeat1's runtime for startDate-endDate period + component = 'auxHeat1' + + if (nextComponent.position <= 1) { + generateRuntimeReport(component,aWeekAgo, endDate,'weekly') // generate stats for the last 7 days + runtimeTotalAvgWeekly = (ecobee.currentAuxHeat1RuntimeAvgWeekly)? ecobee.currentAuxHeat1RuntimeAvgWeekly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgWeekly) { + send ("${ecobee} ${component}'s average weekly runtime stats=${runtimeTotalAvgWeekly} minutes since ${String.format('%tF', aWeekAgo)}", settings.askAlexaFlag) + } + } + int heatStages = ecobee.currentHeatStages.toInteger() + + + component = 'auxHeat2' + if (heatStages >1 && nextComponent.position <= 2) { + +// Get the auxHeat2's runtime for startDate-endDate period + + generateRuntimeReport(component,aWeekAgo, endDate,'weekly') // generate stats for the last 7 days + runtimeTotalAvgWeekly = (ecobee.currentAuxHeat2RuntimeAvgWeekly)? ecobee.currentAuxHeat2RuntimeAvgWeekly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgWeekly) { + send ("${ecobee} ${component}'s average weekly runtime stats=${runtimeTotalAvgWeekly} minutes since ${String.format('%tF', aWeekAgo)}", settings.askAlexaFlag) + } + + } + + component = 'auxHeat3' + if (heatStages >2 && nextComponent.position <= 3) { + +// Get the auxHeat3's runtime for startDate-endDate period + + generateRuntimeReport(component,aWeekAgo, endDate,'weekly') // generate stats for the last 7 days + runtimeTotalAvgWeekly = (ecobee.currentAuxHeat3RuntimeAvgWeekly)? ecobee.currentAuxHeat3RuntimeAvgWeekly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgWeekly) { + send ("${ecobee} ${component}'s average weekly runtime stats=${runtimeTotalAvgWeekly} minutes since ${String.format('%tF', aWeekAgo)}", settings.askAlexaFlag) + } + } + +// Get the compCool1's runtime for startDate-endDate period + + int coolStages = ecobee.currentCoolStages.toInteger() + + +// Get the compCool2's runtime for startDate-endDate period + component = 'compCool2' + + if (coolStages >1 && nextComponent.position <= 4) { + generateRuntimeReport(component,aWeekAgo, endDate,'weekly') // generate stats for the last 7 days + runtimeTotalAvgWeekly = (ecobee.currentCompCool2RuntimeAvgWeekly)? ecobee.currentCompCool2RuntimeAvgWeekly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgWeekly) { + send ("${ecobee} ${component}'s average weekly runtime stats=${runtimeTotalAvgWeekly} minutes since ${String.format('%tF', aWeekAgo)}", settings.askAlexaFlag) + } + } + + component = 'compCool1' + if (nextComponent.position <= 5) { + generateRuntimeReport(component,aWeekAgo, endDate,'weekly') // generate stats for the last 7 days + runtimeTotalAvgWeekly = (ecobee.currentCompCool1RuntimeAvgWeekly)? ecobee.currentCompCool1RuntimeAvgWeekly.toFloat().round(2):0 + atomicState?.componentAlreadyProcessed=component + if (runtimeTotalAvgWeekly) { + send ("${ecobee} ${component}'s average weekly runtime stats=${runtimeTotalAvgWeekly} minutes since ${String.format('%tF', aWeekAgo)}", settings.askAlexaFlag) + } + } + + component= atomicState?.componentAlreadyProcessed + nextComponent = get_nextComponentStats(component) // get nextComponentToBeProcessed + if (nextComponent?.position >=MAX_POSITION) { + send " generated all ${ecobee}'s weekly stats since ${String.format('%tF', aWeekAgo)}" + unschedule(reRunIfNeeded) // No need to reschedule again as the stats are completed. + atomicState?.timestamp = dateInLocalTime // save the date to avoid re-execution. + atomicState?.retries=0 + + } + +} + +void generateRuntimeReport(component, startDate, endDate, frequence='daily') { + + if (detailedNotif) { + log.debug("generateRuntimeReport>For component ${component}, about to call getReportData with endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + send ("For component ${component}, about to call getReportData with aWeekAgo in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + ecobee.getReportData("", startDate, endDate, null, null, component,false) + if (detailedNotif) { + log.debug("generateRuntimeReport>For component ${component}, about to call generateReportRuntimeEvents with endDate in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + send ("For component ${component}, about to call generateReportRuntimeEvents with aWeekAgo in UTC =${endDate.format("yyyy-MM-dd HH:mm:ss", TimeZone.getTimeZone("UTC"))}") + } + ecobee.generateReportRuntimeEvents(component, startDate,endDate, 0, null,frequence) + +} + +private send(msg, askAlexa=false) { + def message = "${get_APP_NAME()}>${msg}" + if (sendPushMessage == "Yes") { + sendPush(message) + } + if (askAlexa) { + def expiresInDays=(AskAlexaExpiresInDays)?:2 + sendLocationEvent( + name: "AskAlexaMsgQueue", + value: "${get_APP_NAME()}", + isStateChange: true, + descriptionText: msg, + data:[ + queues: listOfMQs, + expires: (expiresInDays*24*60*60) /* Expires after 2 days by default */ + ] + ) + } /* End if Ask Alexa notifications*/ + + if (phone) { + log.debug("sending text message") + sendSms(phone, message) + } + + log.debug msg + +} + +private def get_APP_NAME() { + return "ecobeeGenerateWeeklyStats" +} diff --git a/third-party/ecobeeGetTips.groovy b/third-party/ecobeeGetTips.groovy new file mode 100755 index 0000000..cd72170 --- /dev/null +++ b/third-party/ecobeeGetTips.groovy @@ -0,0 +1,127 @@ +/* + * ecobeeGetTips + * Copyright 2015 Yves Racine + * LinkedIn profile: http://www.linkedin.com/in/yracine + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * N.B. Requires My Ecobee device ( v5.0 and higher) available at: + * http://www.ecomatiqhomes.com/store + */ +definition( + name: "ecobeeGetTips", + namespace: "yracine", + author: "Yves Racine", + description: "Get Energy Saving Tips from My Ecobee device", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + + +preferences { + + page (name: "generalSetupPage", title: "General Setup", uninstall:true, nextPage: "displayTipsPage") { + section("About") { + paragraph "ecobeeGetTips, the smartapp that Get Comfort & Energy Saving Tips from My Ecobee device" + paragraph "Version 1.1" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2016 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + section("Get tips for this ecobee thermostat") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee Thermostat" + } + section("Level Of Tip") { + input (name:"level", title: "Which level ([1..4], 4 is the highest, but not always applicable, ex. for multi-stage heating/cooling systems)?", type:"number", + required:false, description:"optional") + } + section("Tip processing reset") { + input (name:"resetTipFlag", title: "Do you want to re-start over and reset tips?", type:"bool") + } + } + page (name: "displayTipsPage", content: "displayTipsPage", install: false, uninstall:true) + page(name: "OtherOptions", title: "Other Options", install: true, uninstall: true) { + section([mobileOnly:true]) { + label title: "Assign a name for this SmartApp", required: false + } + } +} + +def displayTipsPage() { + if (resetTipFlag) { + log.debug("displayPageTips>about to call resetTips()") + ecobee.resetTips() + } + log.debug("displayPageTips>about to call getTips()") + ecobee.getTips(level) + def tip1 = ecobee.currentTip1Text + def tip2 = ecobee.currentTip2Text + def tip3 = ecobee.currentTip3Text + def tip4 = ecobee.currentTip4Text + def tip5 = ecobee.currentTip5Text + def tip1Level = ecobee.currentTip1Level + def tip2Level = ecobee.currentTip2Level + def tip3Level = ecobee.currentTip3Level + def tip4Level = ecobee.currentTip4Level + def tip5Level = ecobee.currentTip5Level + + return dynamicPage (name: "displayTipsPage", title: "Display Current Tips", nextPage: "OtherOptions") { + + section("Tips") { + + if (tip1) { + paragraph image: "${getCustomImagePath()}/tip.jpg","** Tip1 - Level $tip1Level **\n\n ${tip1}\n" + + } else { + paragraph image: "${getCustomImagePath()}/tip.jpg", "Based on the input data available, no tips may apply at this time for this level. " + + "You can try with a different level of tips or later when the indoor/outdoor conditions have changed!" + } + + if (tip2) { + paragraph image: "${getCustomImagePath()}tip.jpg", "** Tip2 - Level$tip2Level **\n\n ${tip2}\n" + } + if (tip3) { + paragraph image: "${getCustomImagePath()}tip.jpg", "** Tip3 - Level $tip3Level **\n\n ${tip3}\n" + } + if (tip4) { + paragraph image: "${getCustomImagePath()}tip.jpg", "** Tip4 - Level $tip4Level **\n\n ${tip4}\n" + } + if (tip5) { + paragraph image: "${getCustomImagePath()}tip.jpg", "** Tip5 - Level $tip5Level **\n\n ${tip5}\n" + } + } + } + +} +def installed() { + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + // TODO: subscribe to attributes, devices, locations, etc. +} + + +def getCustomImagePath() { + return "http://raw.githubusercontent.com/yracine/device-type.myecobee/master/icons/" +} diff --git a/third-party/ecobeeManageClimate.groovy b/third-party/ecobeeManageClimate.groovy new file mode 100755 index 0000000..667c3a8 --- /dev/null +++ b/third-party/ecobeeManageClimate.groovy @@ -0,0 +1,153 @@ +/** + * ecobeeManageClimate + * + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +definition( + name: "ecobeeManageClimate", + namespace: "yracine", + author: "Yves Racine", + description: "Allows a user to manage ecobee's climates", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + + +preferences { + section("About") { + paragraph "ecobeeManageClimate, the smartapp that manages your ecobee climates ['creation', 'update', 'delete']" + paragraph "Version 1.9.5" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + + section("For this ecobee thermostat") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee Thermostat" + } + section("Create (if not present) or update this climate") { + input "climateName", "text", title: "Climate Name" + } + section("Or delete the Climate [default=false]") { + input "deleteClimate", "Boolean", title: "delete?", metadata: [values: ["true", "false"]], required: false + } + section("Substitute Climate name in schedule (used for delete)") { + input "subClimateName", "text", title: "Climate Name", required: false + } + section("Cool Temp [default = 75°F/23°C]") { + input "givenCoolTemp", "decimal", title: "Cool Temp", required: false + } + section("Heat Temp [default=72°F/21°C]") { + input "givenHeatTemp", "decimal", title: "Heat Temp", required: false + } + section("isOptimized [default=false]") { + input "isOptimizedFlag", "Boolean", title: "isOptimized?", metadata: [values: ["true", "false"]], required: false + } + section("isOccupied [default=false]") { + input "isOccupiedFlag", "Boolean", title: "isOccupied?", metadata: [values: ["true", "false"]], required: false + } + section("Cool Fan Mode [default=auto]") { + input "givenCoolFanMode", "enum", title: "Cool Fan Mode ?", metadata: [values: ["auto", "on"]], required: false + } + section("Heat Fan Mode [default=auto]") { + input "givenHeatFanMode", "enum", title: "Heat Fan Mode ?", metadata: [values: ["auto", "on"]], required: false + } + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + + +} + + + +def installed() { + + subscribe(app, appTouch) + takeAction() + +} + + +def updated() { + + + unsubscribe() + subscribe(app, appTouch) + takeAction() + + +} + +def appTouch(evt) { + takeAction() +} + +def takeAction() { + def scale = getTemperatureScale() + def heatTemp, coolTemp + if (scale == 'C') { + heatTemp = givenHeatTemp ?: 21 // by default, 21°C is the heat temp + coolTemp = givenCoolTemp ?: 23 // by default, 23°C is the cool temp + } else { + heatTemp = givenHeatTemp ?: 72 // by default, 72°F is the heat temp + coolTemp = givenCoolTemp ?: 75 // by default, 75°F is the cool temp + } + + def isOptimized = (isOptimizedFlag != null) ? isOptimizedFlag : false // by default, isOptimized flag is false + def isOccupied = (isOccupiedFlag != null) ? isOccupiedFlag : false // by default, isOccupied flag is false + def coolFanMode = givenCoolFanMode ?: 'auto' // By default, fanMode is auto + def heatFanMode = givenHeatFanMode ?: 'auto' // By default, fanMode is auto + def deleteClimateFlag = (deleteClimate != null) ? deleteClimate : 'false' + + log.debug "ecobeeManageClimate> about to take actions" + + + log.trace "ecobeeManageClimate>climateName =${climateName},deleteClimateFlag =${deleteClimateFlag},subClimateName= ${subClimateName}, isOptimized=${isOptimized}" + + ",isOccupied=${isOccupied},coolTemp = ${coolTemp},heatTemp = ${heatTemp},coolFanMode= ${coolFanMode}, heatFanMode= ${heatFanMode}" + + if (deleteClimateFlag == 'true') { + send("ecobeeManageClimate>about to delete climateName = ${climateName}") + ecobee.deleteClimate("", climateName, subClimateName) + + } else { + + send("ecobeeManageClimate>about to create or update climateName = ${climateName}") + ecobee.updateClimate("", climateName, deleteClimateFlag, subClimateName, + coolTemp, heatTemp, isOptimized, isOccupied, coolFanMode, heatFanMode) + } + +} + +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 +} diff --git a/third-party/ecobeeManageGroup.groovy b/third-party/ecobeeManageGroup.groovy new file mode 100755 index 0000000..d3670e2 --- /dev/null +++ b/third-party/ecobeeManageGroup.groovy @@ -0,0 +1,151 @@ +/** + * EcobeeManageGroup + * + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +definition( + name: "ecobeeManageGroup", + namespace: "yracine", + author: "Yves Racine", + description: "Allows a user to create,update, and delete an ecobee group", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + section("About") { + paragraph "ecobeeManageGroup, the smartapp that manages your ecobee groups ['creation', 'update', 'delete']" + paragraph "Version 1.9.4" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + section("For this ecobee thermostat") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee Thermostat" + } + section("Create this group or update all groups [empty for all group]") { + input "groupName", "text", title: "Group Name", required: false + } + section("Or delete the group [default=false]") { + input "deleteGroup", "Boolean", title: "delete?", metadata: [values: ["true", "false"]], required: false + } + section("Synchronize Vacation") { + input "synchronizeVacation", "Boolean", title: "synchronize Vacation [default=false]?", metadata: [values: ["true", "false"]], required: false + } + section("Synchronize Schedule") { + input "synchronizeSchedule", "Boolean", title: "synchronize Schedule [default=false]?", metadata: [values: ["true", "false"]], required: false + } + section("Synchronize SystemMode") { + input "synchronizeSystemMode", "Boolean", title: "synchronize SystemMode [default=false]?", metadata: [values: ["true", "false"]], required: false + } + section("Synchronize Alerts") { + input "synchronizeAlerts", "Boolean", title: "synchronize Alerts [default=false]?", metadata: [values: ["true", "false"]], required: false + } + section("Synchronize QuickSave") { + input "synchronizeQuickSave", "Boolean", title: "synchronize QuickSave [default=false]?", metadata: [values: ["true", "false"]], required: false + } + section("Synchronize User Preferences") { + input "synchronizeUserPreferences", "Boolean", title: "synchronize User Preferences [default=false]?", metadata: [values: ["true", "false"]], required: false + } + + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: false + input "phoneNumber", "phone", title: "Send a text message?", required: false + } + + + + +} + + + +def installed() { + + ecobee.poll() + subscribe(app, appTouch) + takeAction() +} + + +def updated() { + + unsubscribe() + + ecobee.poll() + subscribe(app, appTouch) + takeAction() + + +} + +def appTouch(evt) { + takeAction() +} + + +def takeAction() { + // by default, all flags are set to false + + log.debug "EcobeeManageGroup> about to take actions" + def syncVacationFlag = (synchronizeVacation != null) ? synchronizeVacation : 'false' + def syncScheduleFlag = (synchronizeSchedule != null) ? synchronizeSchedule : 'false' + def syncSystemModeFlag = (synchronizeSystemMode != null) ? synchronizeSystemMode : 'false' + def syncAlertsFlag = (synchronizeAlerts != null) ? synchronizeAlerts : 'false' + def syncQuickSaveFlag = (synchronizeQuickSave != null) ? synchronizeQuickSave : 'false' + def syncUserPreferencesFlag = (synchronizeUserPreferences != null) ? synchronizeUserPreferences : 'false' + def deleteGroupFlag = (deleteGroup != null) ? deleteGroup : 'false' + + def groupSettings = [synchronizeVacation: "${syncVacationFlag}", synchronizeSchedule: "${syncScheduleFlag}", + synchronizeSystemMode: "${syncSystemModeFlag}", synchronizeAlerts: "${syncAlertsFlag}", + synchronizeQuickSave: "${syncQuickSaveFlag}", synchronizeUserPreferences: "${syncUserPreferencesFlag}" + ] + + log.trace "ecobeeManageGroup>deleteGroupFlag=${deleteGroupFlag}, groupName = ${groupName}, groupSettings= ${groupSettings}" + + if (deleteGroupFlag == 'true') { + ecobee.deleteGroup(null, groupName) + send("ecobeeManageGroup>groupName=${groupName} deleted") + + } else if ((groupName != "") && (groupName != null)) { + + ecobee.createGroup(groupName, null, groupSettings) + send("ecobeeManageGroup>groupName=${groupName} created") + } else { + ecobee.iterateUpdateGroup(null, groupSettings) + send("ecobeeManageGroup>all groups associated with thermostat updated with ${groupSettings}") + } + +} + +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 +} diff --git a/third-party/ecobeeManageVacation.groovy b/third-party/ecobeeManageVacation.groovy new file mode 100755 index 0000000..a529aa7 --- /dev/null +++ b/third-party/ecobeeManageVacation.groovy @@ -0,0 +1,151 @@ +/*** + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * Manage Vacation events at Ecobee Thermostat(s) + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ + +// Automatically generated. Make future change here. +definition( + name: "ecobeeManageVacation", + namespace: "yracine", + author: "Yves Racine", + description: "manages your ecobee vacation settings ['creation', 'update', 'delete']", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + section("About") { + paragraph "ecobeeManageVacation, the smartapp that manages your ecobee vacation settings ['creation', 'update', 'delete']" + paragraph "Version 1.9.3" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + + section("For the Ecobee thermostat(s)") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee Thermostat", multiple:true + } + section("Create this Vacation Name") { + input "vacationName", "text", title: "Vacation Name" + } + + section("Or delete the vacation [default=false]") { + input "deleteVacation", "Boolean", title: "delete?", metadata: [values: ["true", "false"]], required: false + } + section("Cool Temp for vacation, [default = 80°F/27°C]") { + input "givenCoolTemp", "decimal", title: "Cool Temp", required: false + } + section("Heat Temp for vacation [default= 60°F/14°C]") { + input "givenHeatTemp", "decimal", title: "Heat Temp", required: false + } + section("Start date for the vacation, [format = DD-MM-YYYY]") { + input "givenStartDate", "text", title: "Beginning Date" + } + section("Start time for the vacation [HH:MM 24HR]") { + input "givenStartTime", "text", title: "Beginning time" + } + section("End date for the vacation [format = DD-MM-YYYY]") { + input "givenEndDate", "text", title: "End Date" + } + section("End time for the vacation [HH:MM 24HR]") { + input "givenEndTime", "text", title: "End time" + } + + +} + + + +def installed() { + + subscribe(app, appTouch) + takeAction() + +} + + +def updated() { + + unsubscribe() + subscribe(app, appTouch) + takeAction() + + +} + +def appTouch(evt) { + takeAction() +} +def takeAction() { + log.debug "ecobeeManageVacation> about to take actions" + def minHeatTemp, minCoolTemp + def scale = getTemperatureScale() + if (scale == 'C') { + minHeatTemp = givenHeatTemp ?: 14 // by default, 14°C is the minimum heat temp + minCoolTemp = givenCoolTemp ?: 27 // by default, 27°C is the minimum cool temp + } else { + minHeatTemp = givenHeatTemp ?: 60 // by default, 60°F is the minimum heat temp + minCoolTemp = givenCoolTemp ?: 80 // by default, 80°F is the minimum cool temp + } + def vacationStartDateTime = null + String dateTime = null + + dateTime = givenStartDate + " " + givenStartTime + log.debug("Start datetime= ${datetime}") + vacationStartDateTime = new Date().parse('d-M-yyyy H:m', dateTime) + + dateTime = givenEndDate + " " + givenEndTime + log.debug("End datetime= ${datetime}") + def vacationEndDateTime = new Date().parse('d-M-yyyy H:m', dateTime) + + if (deleteVacation == 'false') { + // You may want to change to ecobee.createVacation('serial number list',....) if you own EMS thermostat(s) + + log.debug("About to call iterateCreateVacation for ${vacationName}") + ecobee.each { + it.createVacation("", vacationName, minCoolTemp, minHeatTemp, vacationStartDateTime, + vacationEndDateTime) + } + send("ecobeeManageVacation> vacationName ${vacationName} created at ${ecobee}") + } else { + ecobee.each { + it.deleteVacation("", vacationName) + } + send("ecobeeManageVacation> vacationName ${vacationName} deleted at ${ecobee}") + + } + +} + + +private send(msg) { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + + log.debug msg +} diff --git a/third-party/ecobeeResumeProg.groovy b/third-party/ecobeeResumeProg.groovy new file mode 100755 index 0000000..4ee5265 --- /dev/null +++ b/third-party/ecobeeResumeProg.groovy @@ -0,0 +1,216 @@ +/*** + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * Resume Ecobee's Program when people arrive or there is been recent motion at home + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ + +// Automatically generated. Make future change here. +definition( + name: "ecobeeResumeProg", + namespace: "yracine", + author: "Yves Racine", + description: "resumes your ecobee's scheduled program when a presence is back home or when motion is detected or when a ST hello mode is changed", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + +preferences { + + page(name: "About", title: "About", install: false , uninstall: true, nextPage: "selectThermostats") { + section("About") { + paragraph "ecobeeResumeProg, the smartapp that resumes your ecobee's scheduled program when a presence is back home,or when motion is detected or when a ST hello mode is changed" + paragraph "Version 2.1.7" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + } + page(name: "selectThermostats", title: "Thermostats", install: false , uninstall: false, nextPage: "selectModes") { + section("Resume Program at the ecobee thermostat(s)") { + input "ecobee", "device.myEcobeeDevice", title: "Ecobee Thermostat(s)", multiple: true + } + section("When one of these people arrive at home") { + input "people", "capability.presenceSensor", multiple: true, required:false + } + section("Or there is motion at home on these sensors [optional]") { + input "motions", "capability.motionSensor", title: "Where?", multiple: true, required: false + } + section("False alarm threshold [defaults = 3 minutes]") { + input "falseAlarmThreshold", "decimal", title: "Number of minutes", required: false + } + } + page(name: "selectModes", title: "Select Hello ST modes", content: "selectModes") + page(name: "Notifications", title: "Notifications Options", install: true, uninstall: false) { + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: + false + input "phone", "phone", title: "Send a Text Message?", required: false + } + section([mobileOnly:true]) { + label title: "Assign a name for this SmartApp", required: false + } + } + +} + + + +def selectModes() { + def enumModes=[] + location.modes.each { + enumModes << it.name + } + + return dynamicPage(name: "selectModes", title: "Select Modes", install: false, uninstall: false, nextPage: + "Notifications") { + section("Or when SmartThings' hello home mode changes to (ex.'Home')[optional]") { + input "newMode", "enum", options: enumModes, multiple:true, required: false + } + } +} + +def appTouch(evt) { + log.debug ("appTouch>location.mode= $location.mode, about to takeAction (manual appTouch)") + + takeActions() +} + + +def installed() { + log.debug "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + unsubscribe() + initialize() +} + +def initialize() { + log.debug "Current mode = ${location.mode}, people = ${people.collect{it.label + ': ' + it.currentPresence}}" + subscribe(location, changeMode) + subscribe(app, appTouch) + subscribe(people, "presence", presence) + subscribe(motions, "motion", motionEvtHandler) + +} + +def motionEvtHandler(evt) { + if ((evt.value == "active") && residentsHaveJustBeenActive()) { + def message = "EcobeeResumeProg>Recent motion just detected at home, do it" + log.info message + send(message) + takeActions() + } +} + +private residentsHaveJustBeenActive() { + def threshold = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? (falseAlarmThreshold*60*1000) as Long : 3*60*1000L + def result = true + def t0 = new Date(now() - threshold) + for (sensor in motions) { + def recentStates = sensor.statesSince("motion", t0) + if (recentStates.find {it.value == "active"}) { + result = false + break + } + } + log.debug "residentsHaveJustBeenActive: $result" + return result +} + +def changeMode(evt) { + + Boolean foundMode=false + newMode.each { + + if (it==location.mode) { + foundMode=true + } + } + + if (!foundMode) { + log.debug "changeMode>location.mode= $location.mode, newMode=${newMode},foundMode=${foundMode}, not resuming program" + return + } + def message = "EcobeeResumeProg>${newMode} has just been triggered, about to take actions.." + log.info message + send(message) + takeActions() +} + + +def presence(evt) { + log.debug "evt.name: $evt.value" + def threshold = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? (falseAlarmThreshold*60*1000) as Long : 3*60*1000L + def message + + def t0 = new Date(now() - threshold) + if (evt.value == "present") { + + def person = getPerson(evt) + if (person != null) { + def recentNotPresent = person.statesSince("presence", t0).find { + it.value == "not present" + } + if (!recentNotPresent) { + message = "EcobeeResumeProg>${person.displayName} just arrived,take actions.." + log.info message + send(message) + takeActions() + } + } else { + message = "EcobeeResumeProg>somebody just arrived at home, but she/he is not been selected for resuming the ecobee program." + log.info message + send(message) + + } + } +} + +def takeActions() { + def message = "EcobeeResumeProg>resumed program at ${ecobee} thermostats..." + ecobee.each { + it.resumeThisTstat() + } + send(message) +} + + +private getPerson(evt) { + people.find { + evt.deviceId == it.id + } +} + +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 +} diff --git a/third-party/ecobeeSetClimate.groovy b/third-party/ecobeeSetClimate.groovy new file mode 100755 index 0000000..f5760dd --- /dev/null +++ b/third-party/ecobeeSetClimate.groovy @@ -0,0 +1,210 @@ +/** + * ecobeeSetClimate + * + * Copyright 2014 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * You may want to create multiple instances of this smartapp (and rename them in SmartSetup) for each time + * you want to set a different Climate at a given day and time during the week.* + * + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +definition( + name: "ecobeeSetClimate", + namespace: "yracine", + author: "Yves Racine", + description: "This script allows an ecobee user to set a Climate at a given day & time", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + + + +preferences { + + page(name: "selectThermostats", title: "Thermostats", install: false, uninstall: true, nextPage: "selectProgram") { + section("About") { + paragraph "ecobeeSetClimate, the smartapp that sets your ecobee thermostat to a given climate at a given day & time" + paragraph "Version 1.2" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + section("Set the ecobee thermostat(s)") { + input "ecobee", "device.myEcobeeDevice", title: "Which ecobee thermostat(s)?", multiple: true + + } + section("Configuration") { + input "dayOfWeek", "enum", + title: "Which day of the week?", + multiple: false, + metadata: [ + values: [ + 'All Week', + 'Monday to Friday', + 'Saturday & Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday' + ] + ] + input "begintime", "time", title: "Beginning time" + } + + } + page(name: "selectProgram", title: "Ecobee Programs", content: "selectProgram") + page(name: "Notifications", title: "Notifications Options", install: true, uninstall: true) { + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: + false + input "phone", "phone", title: "Send a Text Message?", required: false + } + section([mobileOnly:true]) { + label title: "Assign a name for this SmartApp", required: false + mode title: "Set for specific mode(s)", required: false + } + } +} + + +def selectProgram() { + def ecobeePrograms = ecobee.currentClimateList.toString().minus('[').minus(']').tokenize(',') + log.debug "programs: $ecobeePrograms" + + + return dynamicPage(name: "selectProgram", title: "Select Ecobee Program", install: false, uninstall: true, nextPage: + "Notifications") { + section("Select Program") { + input "givenClimate", "enum", title: "Which program?", options: ecobeePrograms, required: true + } + } +} + + + +def installed() { + // subscribe to these events + initialize() +} + +def updated() { + // we have had an update + // remove everything and reinstall + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + + log.debug "Scheduling setClimate for day " + dayOfWeek + " at begin time " + begintime + subscribe(ecobee, "climateList", climateListHandler) + + schedule(begintime, setClimate) + subscribe(app, setClimateNow) + +} +def climateListHandler(evt) { + log.debug "thermostat's Climates List: $evt.value, $settings" +} + +def setClimateNow(evt) { + setClimate() +} + +def setClimate() { + def climateName = (givenClimate ?: 'Home').capitalize() + + + def doChange = IsRightDayForChange() + + // If we have hit the condition to schedule this then lets do it + + if (doChange == true) { + log.debug "setClimate, location.mode = $location.mode, newMode = $newMode, location.modes = $location.modes" + + ecobee.each { + it.setThisTstatClimate(climateName) + } + send("ecobeeSetClimate>set ${ecobee} to ${climateName} program as requested") + } else { + log.debug "climate change to ${climateName} not scheduled for today." + } + log.debug "End of Fcn" +} + + +def IsRightDayForChange() { + + def makeChange = false + String currentDay = new Date().format("E", location.timeZone) + + // Check the condition under which we want this to run now + // This set allows the most flexibility. + if (dayOfWeek == 'All Week') { + makeChange = true + } else if ((dayOfWeek == 'Monday' || dayOfWeek == 'Monday to Friday') && currentDay == 'Mon') { + makeChange = true + } else if ((dayOfWeek == 'Tuesday' || dayOfWeek == 'Monday to Friday') && currentDay == 'Tue') { + makeChange = true + } else if ((dayOfWeek == 'Wednesday' || dayOfWeek == 'Monday to Friday') && currentDay == 'Wed') { + makeChange = true + } else if ((dayOfWeek == 'Thursday' || dayOfWeek == 'Monday to Friday') && currentDay == 'Thu') { + makeChange = true + } else if ((dayOfWeek == 'Friday' || dayOfWeek == 'Monday to Friday') && currentDay == 'Fri') { + makeChange = true + } else if ((dayOfWeek == 'Saturday' || dayOfWeek == 'Saturday & Sunday') && currentDay == 'Sat') { + makeChange = true + } else if ((dayOfWeek == 'Sunday' || dayOfWeek == 'Saturday & Sunday') && currentDay == 'Sun' ) { + makeChange = true + } + + + // some debugging in order to make sure things are working correclty + log.debug "Calendar DOW: " + currentDay + log.debug "SET DOW: " + dayOfWeek + + return makeChange +} + + +private send(msg) { + if (sendPushMessage != "No") { + log.debug("sending push message") + sendPush(msg) + } + + if (phoneNumber) { + log.debug("sending text message") + sendSms(phoneNumber, msg) + } + + log.debug msg +} + + + +// catchall +def event(evt) { + log.debug "value: $evt.value, event: $evt, settings: $settings, handlerName: ${evt.handlerName}" +} diff --git a/third-party/ecobeeSetFanMinOnTime.groovy b/third-party/ecobeeSetFanMinOnTime.groovy new file mode 100755 index 0000000..b786ab0 --- /dev/null +++ b/third-party/ecobeeSetFanMinOnTime.groovy @@ -0,0 +1,120 @@ +/** + * ecobeeSetFanMinOnTime + * + * Copyright 2015 Yves Racine + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * N.B. Requires MyEcobee device available at + * http://www.ecomatiqhomes.com/#!store/tc3yr + */ +definition( + name: "ecobeeSetFanMinOnTime", + namespace: "yracine", + author: "Yves Racine", + description: "ecobeeSetFanMinOnTime, the smartapp that sets your ecobee's fan to circulate for a minimum time (in minutes) per hour", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) +preferences { + + + page(name: "selectThermostats", title: "Thermostats", install: false , uninstall: true, nextPage: "selectProgram") { + section("About") { + paragraph "ecobeeSetFanMinOnTime, the smartapp that sets your ecobee's fan to circulate for a minimum time (in minutes) per hour." + paragraph "Version 1.2" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes" + title:"Paypal donation..." + paragraph "Copyright©2015 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine/device-type.myecobee/blob/master/README.md" + } + section("Change the following ecobee thermostat(s)...") { + input "thermostats", "device.myEcobeeDevice", title: "Which thermostat(s)", multiple: true + } + } + page(name: "selectProgram", title: "Ecobee Programs", content: "selectProgram") + page(name: "Notifications", title: "Notifications Options", install: true, uninstall: true) { + section("Notifications") { + input "sendPushMessage", "enum", title: "Send a push notification?", metadata: [values: ["Yes", "No"]], required: + false + input "phone", "phone", title: "Send a Text Message?", required: false + } + section([mobileOnly:true]) { + label title: "Assign a name for this SmartApp", required: false + } + } +} + + +def selectProgram() { + def ecobeePrograms = thermostats[0].currentClimateList.toString().minus('[').minus(']').tokenize(',') + log.debug "programs: $ecobeePrograms" + + return dynamicPage(name: "selectProgram", title: "Select Ecobee Program", install: false, uninstall: true, nextPage: + "Notifications") { + section("Select Program") { + input "givenClimate", "enum", title: "When change to this ecobee program?", options: ecobeePrograms, required: true + } + section("Set the minimum fan runtime per hour for this program [default: 10 min. per hour]") { + input "givenFanMinTime", "number", title: "Minimum fan runtime in minutes", required: false + } + + + } +} + + +def installed() { + subscribe(thermostats,"climateName",changeFanMinOnTime) + subscribe(app, changeFanMinOnTime) +} + +def updated() { + unsubscribe() + subscribe(thermostats,"climateName",changeFanMinOnTime) + subscribe(app, changeFanMinOnTime) +} + + +def changeFanMinOnTime(evt) { + + + if ((evt.value != givenClimate) && (evt.value != 'touch')) { + log.debug ("changeFanMinOnTime>not right climate (${evt.value}), doing nothing...") + return + } + Integer min_fan_time = (givenFanMinTime != null) ? givenFanMinTime : 10 // 10 min. fan time per hour by default + + def message = "ecobeeSetFanMinOnTime>changing fanMinOnTime to ${min_fan_time} at ${thermostats}.." + send(message) + + thermostats.each { + it?.setThermostatSettings("", ['fanMinOnTime': "${min_fan_time}"]) + } +} + +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 +} diff --git a/third-party/evohome-connect.groovy b/third-party/evohome-connect.groovy new file mode 100755 index 0000000..0852be4 --- /dev/null +++ b/third-party/evohome-connect.groovy @@ -0,0 +1,1361 @@ +/** + * Copyright 2016 David Lomas (codersaur) + * + * Name: Evohome (Connect) + * + * Author: David Lomas (codersaur) + * + * Date: 2016-04-05 + * + * Version: 0.08 + * + * Description: + * - Connect your Honeywell Evohome System to SmartThings. + * - Requires the Evohome Heating Zone device handler. + * - For latest documentation see: https://github.com/codersaur/SmartThings + * + * Version History: + * + * 2016-04-05: v0.08 + * - New 'Update Refresh Time' setting to control polling after making an update. + * - poll() - If onlyZoneId is 0, this will force a status update for all zones. + * + * 2016-04-04: v0.07 + * - Additional info log messages. + * + * 2016-04-03: v0.06 + * - Initial Beta Release + * + * To Do: + * - Add support for hot water zones (new device handler). + * - Tidy up settings: See: http://docs.smartthings.com/en/latest/smartapp-developers-guide/preferences-and-settings.html + * - Allow Evohome zones to be (de)selected as part of the setup process. + * - Enable notifications if connection to Evohome cloud fails. + * - Expose whether thremoStatMode is permanent or temporary, and if temporary for how long. Get from 'tcs.systemModeStatus.*'. i.e thermostatModeUntil + * - Investigate if Evohome supports registing a callback so changes in Evohome are pushed to SmartThings instantly (instead of relying on polling). + * + * License: + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + */ +definition( + name: "Evohome (Connect)", + namespace: "codersaur", + author: "David Lomas (codersaur)", + description: "Connect your Honeywell Evohome System to SmartThings.", + category: "My Apps", + iconUrl: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + iconX2Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + iconX3Url: "http://cdn.device-icons.smartthings.com/Home/home1-icn.png", + singleInstance: true +) + +preferences { + + section ("Evohome:") { + input "prefEvohomeUsername", "text", title: "Username", required: true, displayDuringSetup: true + input "prefEvohomePassword", "password", title: "Password", required: true, displayDuringSetup: true + input title: "Advanced Settings:", displayDuringSetup: true, type: "paragraph", element: "paragraph", description: "Change these only if needed" + input "prefEvohomeStatusPollInterval", "number", title: "Polling Interval (minutes)", range: "1..60", defaultValue: 5, required: true, displayDuringSetup: true, description: "Poll Evohome every n minutes" + input "prefEvohomeUpdateRefreshTime", "number", title: "Update Refresh Time (seconds)", range: "2..60", defaultValue: 3, required: true, displayDuringSetup: true, description: "Wait n seconds after an update before polling" + input "prefEvohomeWindowFuncTemp", "decimal", title: "Window Function Temperature", range: "0..100", defaultValue: 5.0, required: true, displayDuringSetup: true, description: "Must match Evohome controller setting" + input title: "Thermostat Modes", description: "Configure how long thermostat modes are applied for by default. Set to zero to apply modes permanently.", displayDuringSetup: true, type: "paragraph", element: "paragraph" + input 'prefThermostatModeDuration', 'number', title: 'Away/Custom/DayOff Mode (days):', range: "0..99", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply thermostat modes for this many days' + input 'prefThermostatEconomyDuration', 'number', title: 'Economy Mode (hours):', range: "0..24", defaultValue: 0, required: true, displayDuringSetup: true, description: 'Apply economy mode for this many hours' + } + + section("General:") { + input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + } + +} + +/********************************************************************** + * Setup and Configuration Commands: + **********************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + * + **/ +def installed() { + + atomicState.installedAt = now() + log.debug "${app.label}: Installed with settings: ${settings}" + +} + + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + * + **/ +def uninstalled() { + if(getChildDevices()) { + removeChildDevices(getChildDevices()) + } +} + + +/** + * updated() + * + * Runs when app settings are changed. + * + **/ +void updated() { + + if (atomicState.debug) log.debug "${app.label}: Updating with settings: ${settings}" + + // General: + atomicState.debug = settings.prefDebugMode + + // Evohome: + atomicState.evohomeEndpoint = 'https://tccna.honeywell.com' + atomicState.evohomeAuth = [tokenLifetimePercentThreshold : 50] // Auth Token will be refreshed when down to 50% of its lifetime. + atomicState.evohomeStatusPollInterval = settings.prefEvohomeStatusPollInterval // Poll interval for status updates (minutes). + atomicState.evohomeSchedulePollInterval = 60 // Hardcoded to 1hr (minutes). + atomicState.evohomeUpdateRefreshTime = settings.prefEvohomeUpdateRefreshTime // Wait this many seconds after an update before polling. + + + // Thermostat Mode Durations: + atomicState.thermostatModeDuration = settings.prefThermostatModeDuration + atomicState.thermostatEconomyDuration = settings.prefThermostatEconomyDuration + + // Force Authentication: + authenticate() + + // Refresh Subscriptions and Schedules: + manageSubscriptions() + manageSchedules() + + // Refresh child device configuration: + getEvohomeConfig() + updateChildDeviceConfig() + + // Run a poll, but defer it so that updated() returns sooner: + runIn(5, "poll") + +} + + +/********************************************************************** + * Management Commands: + **********************************************************************/ + +/** + * manageSchedules() + * + * Check scheduled tasks have not stalled, and re-schedule if necessary. + * Generates a random offset (seconds) for each scheduled task. + * + * Schedules: + * - manageAuth() - every 5 mins. + * - poll() - every minute. + * + **/ +void manageSchedules() { + + if (atomicState.debug) log.debug "${app.label}: manageSchedules()" + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // manageAuth (every 5 mins): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling manageAuth()" + try { + unschedule(manageAuth) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/5 * * * ?", "manageAuth") + } + + // poll(): + if (1==1) { // To Do: Test if schedule has actually stalled. + if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Re-scheduling poll()" + try { + unschedule(poll) + } + catch(e) { + //if (atomicState.debug) log.debug "${app.label}: manageSchedules(): Unschedule failed" + } + randomOffset = rand.nextInt(60) + schedule("${randomOffset} 0/1 * * * ?", "poll") + } + +} + + +/** + * manageSubscriptions() + * + * Unsubscribe/Subscribe. + **/ +void manageSubscriptions() { + + if (atomicState.debug) log.debug "${app.label}: manageSubscriptions()" + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + +} + + +/** + * manageAuth() + * + * Ensures authenication token is valid. + * Refreshes Auth Token if lifetime has exceeded evohomeAuthTokenLifetimePercentThreshold. + * Re-authenticates if Auth Token has expired completely. + * Otherwise, done nothing. + * + * Should be scheduled to run every 1-5 minutes. + **/ +void manageAuth() { + + if (atomicState.debug) log.debug "${app.label}: manageAuth()" + + // Check if Auth Token is valid, if not authenticate: + if (!atomicState.evohomeAuth.authToken) { + + log.info "${app.label}: manageAuth(): No Auth Token. Authenticating..." + authenticate() + } + else if (atomicState.evohomeAuthFailed) { + + log.info "${app.label}: manageAuth(): Auth has failed. Authenticating..." + authenticate() + } + else if (!atomicState.evohomeAuth.expiresAt.isNumber() || now() >= atomicState.evohomeAuth.expiresAt) { + + log.info "${app.label}: manageAuth(): Auth Token has expired. Authenticating..." + authenticate() + } + else { + // Check if Auth Token should be refreshed: + def refreshAt = atomicState.evohomeAuth.expiresAt - ( 1000 * (atomicState.evohomeAuth.tokenLifetime * atomicState.evohomeAuth.tokenLifetimePercentThreshold / 100)) + + if (now() >= refreshAt) { + log.info "${app.label}: manageAuth(): Auth Token needs to be refreshed before it expires." + refreshAuthToken() + } + else { + log.info "${app.label}: manageAuth(): Auth Token is okay." + } + } + +} + + +/** + * poll(onlyZoneId=-1) + * + * This is the main command that co-ordinates retrieval of information from the Evohome API + * and its dissemination to child devices. It should be scheduled to run every minute. + * + * Different types of information are collected on different schedules: + * - Zone status information is polled according to ${evohomeStatusPollInterval}. + * - Zone schedules are polled according to ${evohomeSchedulePollInterval}. + * + * poll() can be called by a child device when an update has been made, in which case + * onlyZoneId will be specified, and only that zone will be updated. + * + * If onlyZoneId is 0, this will force a status update for all zones, igonoring the poll + * interval. This should only be used after setThremostatMode() call. + * + * If onlyZoneId is not specified all zones are updated, but only if the relevent poll + * interval has been exceeded. + * + **/ +void poll(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: poll(${onlyZoneId})" + + // Check if there's been an authentication failure: + if (atomicState.evohomeAuthFailed) { + manageAuth() + } + + if (onlyZoneId == 0) { // Force a status update for all zones (used after a thermostatMode update): + getEvohomeStatus() + updateChildDevice() + } + else if (onlyZoneId != -1) { // A zoneId has been specified, so just get the status and update the relevent device: + getEvohomeStatus(onlyZoneId) + updateChildDevice(onlyZoneId) + } + else { // Get status and schedule for all zones, but only if the relevent poll interval has been exceeded: + + // Adjust intervals to allow for poll() execution time: + def evohomeStatusPollThresh = (atomicState.evohomeStatusPollInterval * 60) - 30 + def evohomeSchedulePollThresh = (atomicState.evohomeSchedulePollInterval * 60) - 30 + + // Get zone status: + if (!atomicState.evohomeStatusUpdatedAt || atomicState.evohomeStatusUpdatedAt + (1000 * evohomeStatusPollThresh) < now()) { + getEvohomeStatus() + } + + // Get zone schedules: + if (!atomicState.evohomeSchedulesUpdatedAt || atomicState.evohomeSchedulesUpdatedAt + (1000 * evohomeSchedulePollThresh) < now()) { + getEvohomeSchedules() + } + + // Update all child devices: + updateChildDevice() + } + +} + + +/********************************************************************** + * Event Handlers: + **********************************************************************/ + + +/** + * handleAppTouch(evt) + * + * App touch event handler. + * Used for testing and debugging. + * + **/ +void handleAppTouch(evt) { + + if (atomicState.debug) log.debug "${app.label}: handleAppTouch()" + + //manageAuth() + //manageSchedules() + + //getEvohomeConfig() + //updateChildDeviceConfig() + + poll() + +} + + +/********************************************************************** + * SmartApp-Child Interface Commands: + **********************************************************************/ + +/** + * updateChildDeviceConfig() + * + * Add/Remove/Update Child Devices based on atomicState.evohomeConfig + * and update their internal state. + * + **/ +void updateChildDeviceConfig() { + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig()" + + // Build list of active DNIs, any existing children with DNIs not in here will be deleted. + def activeDnis = [] + + // Iterate through evohomeConfig, adding new Evohome Heating Zone devices where necessary. + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + activeDnis << dni + + def values = [ + 'debug': atomicState.debug, + 'updateRefreshTime': atomicState.evohomeUpdateRefreshTime, + 'minHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.minHeatSetpoint), + 'maxHeatingSetpoint': formatTemperature(zone?.heatSetpointCapabilities?.maxHeatSetpoint), + 'temperatureResolution': zone?.heatSetpointCapabilities?.valueResolution, + 'windowFunctionTemperature': formatTemperature(settings.prefEvohomeWindowFuncTemp), + 'zoneType': zone?.zoneType, + 'locationId': loc.locationInfo.locationId, + 'gatewayId': gateway.gatewayInfo.gatewayId, + 'systemId': tcs.systemId, + 'zoneId': zone.zoneId + ] + + def d = getChildDevice(dni) + if(!d) { + try { + values.put('label', "${zone.name} Heating Zone (Evohome)") + log.info "${app.label}: updateChildDeviceConfig(): Creating device: Name: ${values.label}, DNI: ${dni}" + d = addChildDevice(app.namespace, "Evohome Heating Zone", dni, null, values) + } catch (e) { + log.error "${app.label}: updateChildDeviceConfig(): Error creating device: Name: ${values.label}, DNI: ${dni}, Error: ${e}" + } + } + + if(d) { + d.generateEvent(values) + } + } + } + } + } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Active DNIs: ${activeDnis}" + + // Delete Devices: + def delete = getChildDevices().findAll { !activeDnis.contains(it.deviceNetworkId) } + + if (atomicState.debug) log.debug "${app.label}: updateChildDeviceConfig(): Found ${delete.size} devices to delete." + + delete.each { + log.info "${app.label}: updateChildDeviceConfig(): Deleting device with DNI: ${it.deviceNetworkId}" + try { + deleteChildDevice(it.deviceNetworkId) + } + catch(e) { + log.error "${app.label}: updateChildDeviceConfig(): Error deleting device with DNI: ${it.deviceNetworkId}. Error: ${e}" + } + } +} + + + +/** + * updateChildDevice(onlyZoneId=-1) + * + * Update the attributes of a child device from atomicState.evohomeStatus + * and atomicState.evohomeSchedules. + * + * If onlyZoneId is not specified, then all zones are updated. + * + * Recalculates scheduledSetpoint, nextScheduledSetpoint, and nextScheduledTime. + * + **/ +void updateChildDevice(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(${onlyZoneId})" + + atomicState.evohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == -1 || onlyZoneId == zone.zoneId) { // Filter on zoneId if one has been specified. + + def dni = generateDni(loc.locationId, gateway.gatewayId, tcs.systemId, zone.zoneId) + def d = getChildDevice(dni) + if(d) { + def schedule = atomicState.evohomeSchedules.find { it.dni == dni} + def currSw = getCurrentSwitchpoint(schedule.schedule) + def nextSw = getNextSwitchpoint(schedule.schedule) + + def values = [ + 'temperature': formatTemperature(zone?.temperatureStatus?.temperature), + //'isTemperatureAvailable': zone?.temperatureStatus?.isAvailable, + 'heatingSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpoint': formatTemperature(zone?.heatSetpointStatus?.targetTemperature), + 'thermostatSetpointMode': formatSetpointMode(zone?.heatSetpointStatus?.setpointMode), + 'thermostatSetpointUntil': zone?.heatSetpointStatus?.until, + 'thermostatMode': formatThermostatMode(tcs?.systemModeStatus?.mode), + 'scheduledSetpoint': formatTemperature(currSw.temperature), + 'nextScheduledSetpoint': formatTemperature(nextSw.temperature), + 'nextScheduledTime': nextSw.time + ] + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Updating Device with DNI: ${dni} with data: ${values}" + d.generateEvent(values) + } else { + if (atomicState.debug) log.debug "${app.label}: updateChildDevice(): Device with DNI: ${dni} does not exist, so skipping status update." + } + } + } + } + } + } +} + + +/********************************************************************** + * Evohome API Commands: + **********************************************************************/ + +/** + * authenticate() + * + * Authenticate to Evohome. + * + **/ +private authenticate() { + + if (atomicState.debug) log.debug "${app.label}: authenticate()" + + def requestParams = [ + method: 'POST', + uri: 'https://tccna.honeywell.com', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'password', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'Username': settings.prefEvohomeUsername, + 'Password': settings.prefEvohomePassword + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: authenticate(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: authenticate(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: authenticate(): No Data. Response Status: ${resp.status}" + atomicState.evohomeAuthFailed = true + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: authenticate(): Error: e.statusCode ${e.statusCode}" + atomicState.evohomeAuthFailed = true + } + +} + + +/** + * refreshAuthToken() + * + * Refresh Auth Token. + * If token refresh fails, then authenticate() is called. + * Request is simlar to authenticate, but with grant_type = 'refresh_token' and 'refresh_token'. + * + **/ +private refreshAuthToken() { + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken()" + + def requestParams = [ + method: 'POST', + uri: 'https://tccna.honeywell.com', + path: '/Auth/OAuth/Token', + headers: [ + 'Authorization': 'Basic YjAxM2FhMjYtOTcyNC00ZGJkLTg4OTctMDQ4YjlhYWRhMjQ5OnRlc3Q=', + 'Accept': 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml', + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + ], + body: [ + 'grant_type': 'refresh_token', + 'scope': 'EMEA-V1-Basic EMEA-V1-Anonymous EMEA-V1-Get-Current-User-Account', + 'refresh_token': atomicState.evohomeAuth.refreshToken + ] + ] + + try { + httpPost(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + // Update evohomeAuth: + // We can't just '.put' or '<<' with atomicState, we have to make a temp copy, edit, and then re-assign. + def tmpAuth = atomicState.evohomeAuth ?: [:] + tmpAuth.put('lastUpdated' , now()) + tmpAuth.put('authToken' , resp?.data?.access_token) + tmpAuth.put('tokenLifetime' , resp?.data?.expires_in.toInteger() ?: 0) + tmpAuth.put('expiresAt' , now() + (tmpAuth.tokenLifetime * 1000)) + tmpAuth.put('refreshToken' , resp?.data?.refresh_token) + atomicState.evohomeAuth = tmpAuth + atomicState.evohomeAuthFailed = false + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeAuth: ${atomicState.evohomeAuth}" + def exp = new Date(tmpAuth.expiresAt) + log.info "${app.label}: refreshAuthToken(): New Auth Token Expires At: ${exp}" + + // Update evohomeHeaders: + def tmpHeaders = atomicState.evohomeHeaders ?: [:] + tmpHeaders.put('Authorization',"bearer ${atomicState.evohomeAuth.authToken}") + tmpHeaders.put('applicationId', 'b013aa26-9724-4dbd-8897-048b9aada249') + tmpHeaders.put('Accept', 'application/json, application/xml, text/json, text/x-json, text/javascript, text/xml') + atomicState.evohomeHeaders = tmpHeaders + + if (atomicState.debug) log.debug "${app.label}: refreshAuthToken(): New evohomeHeaders: ${atomicState.evohomeHeaders}" + + // Now get User Account info: + getEvohomeUserAccount() + } + else { + log.error "${app.label}: refreshAuthToken(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: refreshAuthToken(): Error: e.statusCode ${e.statusCode}" + // If Unauthorized (401) then re-authenticate: + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + authenticate() + } + } + +} + + +/** + * getEvohomeUserAccount() + * + * Gets user account info and stores in atomicState.evohomeUserAccount. + * + **/ +private getEvohomeUserAccount() { + + log.info "${app.label}: getEvohomeUserAccount(): Getting user account information." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/userAccount', + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + atomicState.evohomeUserAccount = resp.data + if (atomicState.debug) log.debug "${app.label}: getEvohomeUserAccount(): Data: ${atomicState.evohomeUserAccount}" + } + else { + log.error "${app.label}: getEvohomeUserAccount(): No Data. Response Status: ${resp.status}" + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeUserAccount(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + } +} + + + +/** + * getEvohomeConfig() + * + * Gets Evohome configuration for all locations and stores in atomicState.evohomeConfig. + * + **/ +private getEvohomeConfig() { + + log.info "${app.label}: getEvohomeConfig(): Getting configuration for all locations." + + def requestParams = [ + method: 'GET', + uri: atomicState.evohomeEndpoint, + path: '/WebAPI/emea/api/v1/location/installationInfo', + query: [ + 'userId': atomicState.evohomeUserAccount.userId, + 'includeTemperatureControlSystems': 'True' + ], + headers: atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if (resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeConfig(): Data: ${resp.data}" + atomicState.evohomeConfig = resp.data + atomicState.evohomeConfigUpdatedAt = now() + return null + } + else { + log.error "${app.label}: getEvohomeConfig(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeConfig(): Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * getEvohomeStatus(onlyZoneId=-1) + * + * Gets Evohome Status for specified zone and stores in atomicState.evohomeStatus. + * If onlyZoneId is not specified, all zones are updated. + * + **/ +private getEvohomeStatus(onlyZoneId=-1) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeStatus(${onlyZoneId})" + + def newEvohomeStatus = [] + + if (onlyZoneId == -1) { // Update all zones (which can be obtained en-masse for each location): + + log.info "${app.label}: getEvohomeStatus(): Getting status for all zones." + + atomicState.evohomeConfig.each { loc -> + def locStatus = getEvohomeLocationStatus(loc.locationInfo.locationId) + if (locStatus) { + newEvohomeStatus << locStatus + } + } + + if (newEvohomeStatus) { + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + atomicState.evohomeStatusUpdatedAt = now() + } + } + else { // Only update the specified zone: + + log.info "${app.label}: getEvohomeStatus(): Getting status for zone ID: ${onlyZoneId}" + + def newZoneStatus = getEvohomeZoneStatus(onlyZoneId) + if (newZoneStatus) { + // Get existing evohomeStatus and update only the specified zone, preserving data for other zones: + // Have to do this as atomicState.evohomeStatus can only be written in its entirety (as using atomicstate). + // If mutiple zones are requesting updates at the same time this could cause loss of new data, but + // the worse case is having out-of-date data for a few minutes... + newEvohomeStatus = atomicState.evohomeStatus + newEvohomeStatus.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + if (onlyZoneId == zone.zoneId) { // This is the zone that must be updated: + zone.activeFaults = newZoneStatus.activeFaults + zone.heatSetpointStatus = newZoneStatus.heatSetpointStatus + zone.temperatureStatus = newZoneStatus.temperatureStatus + } + } + } + } + } + // Write out newEvohomeStatus back to atomicState: + atomicState.evohomeStatus = newEvohomeStatus + // Note: atomicState.evohomeStatusUpdatedAt is NOT updated. + } + } +} + + +/** + * getEvohomeLocationStatus(locationId) + * + * Gets the status for a specific location and returns data as a map. + * + * Called by getEvohomeStatus(). + **/ +private getEvohomeLocationStatus(locationId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Location ID: ${locationId}" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/location/${locationId}/status", + 'query': [ 'includeTemperatureControlSystems': 'True'], + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeLocationStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeLocationStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeLocationStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeZoneStatus(zoneId) + * + * Gets the status for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneStatus(zoneId) { + + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/status", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneStatus: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneStatus: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneStatus: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * getEvohomeSchedules() + * + * Gets Evohome Schedule for each zone and stores in atomicState.evohomeSchedules. + * + **/ +private getEvohomeSchedules() { + + log.info "${app.label}: getEvohomeSchedules(): Getting schedules for all zones." + + def evohomeSchedules = [] + + atomicState.evohomeConfig.each { loc -> + loc.gateways.each { gateway -> + gateway.temperatureControlSystems.each { tcs -> + tcs.zones.each { zone -> + def dni = generateDni(loc.locationInfo.locationId, gateway.gatewayInfo.gatewayId, tcs.systemId, zone.zoneId ) + def schedule = getEvohomeZoneSchedule(zone.zoneId) + if (schedule) { + evohomeSchedules << ['zoneId': zone.zoneId, 'dni': dni, 'schedule': schedule] + } + } + } + } + } + + if (evohomeSchedules) { + // Write out complete schedules to state: + atomicState.evohomeSchedules = evohomeSchedules + atomicState.evohomeSchedulesUpdatedAt = now() + } + + return evohomeSchedules +} + + +/** + * getEvohomeZoneSchedule(zoneId) + * + * Gets the schedule for a specific zone and returns data as a map. + * + **/ +private getEvohomeZoneSchedule(zoneId) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule(${zoneId})" + + def requestParams = [ + 'method': 'GET', + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/schedule", + 'headers': atomicState.evohomeHeaders + ] + + try { + httpGet(requestParams) { resp -> + if(resp.status == 200 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: getEvohomeZoneSchedule: Data: ${resp.data}" + return resp.data + } + else { + log.error "${app.label}: getEvohomeZoneSchedule: No Data. Response Status: ${resp.status}" + return false + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: getEvohomeZoneSchedule: Error: e.statusCode ${e.statusCode}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return false + } +} + + +/** + * setThermostatMode(systemId, mode, until) + * + * Set thermostat mode for specified controller, until specified time. + * + * systemId: SystemId of temperatureControlSystem. E.g.: 123456 + * + * mode: String. Either: "auto", "off", "economy", "away", "dayOff", "custom". + * + * until: (Optional) Time to apply mode until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * - Number: Duration in hours if mode is 'economy', or in days if mode is 'away'/'dayOff'/'custom'. + * Duration will be rounded down to align with Midnight in the local timezone + * (e.g. a duration of 1 day will end at midnight tonight). If 0, mode is permanent. + * If 'until' is not specified, a default value is used from the SmartApp settings. + * + * Notes: 'Auto' and 'Off' modes are always permanent. + * Thermostat mode is a property of the temperatureControlSystem (i.e. Evohome controller). + * Therefore changing the thermostatMode will affect all zones associated with the same controller. + * + * + * Example usage: + * setThermostatMode(123456, 'auto') // Set auto mode permanently, for controller 123456. + * setThermostatMode(123456, 'away','2016-04-01T00:00:00Z') // Set away mode until 1st April, for controller 123456. + * setThermostatMode(123456, 'dayOff','permanent') // Set dayOff mode permanently, for controller 123456. + * setThermostatMode(123456, 'dayOff', 2) // Set dayOff mode for 2 days (ends tomorrow night), for controller 123456. + * setThermostatMode(123456, 'economy', 2) // Set economy mode for 2 hours, for controller 123456. + * + **/ +def setThermostatMode(systemId, mode, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): SystemID: ${systemId}, Mode: ${mode}, Until: ${until}" + + // Clean mode (translate to index): + mode = mode.toLowerCase() + int modeIndex + switch (mode) { + case 'auto': + modeIndex = 0 + break + case 'off': + modeIndex = 1 + break + case 'economy': + modeIndex = 2 + break + case 'away': + modeIndex = 3 + break + case 'dayoff': + modeIndex = 4 + break + case 'custom': + modeIndex = 6 + break + default: + log.error "${app.label}: setThermostatMode(): Mode: ${mode} is not supported!" + modeIndex = 999 + break + } + + // Clean until: + def untilRes + + // until has not been specified, so determine behaviour from settings: + if (-1 == until && 'economy' == mode) { + until = atomicState.thermostatEconomyDuration ?: 0 // Use Default duration for economy mode (hours): + } + else if (-1 == until && ( 'away' == mode ||'dayoff' == mode ||'custom' == mode )) { + until = atomicState.thermostatModeDuration ?: 0 // Use Default duration for other modes (days): + } + + // Convert to date (or 0): + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && 'economy' == mode) { // until is a duration in hours: + untilRes = new Date( now() + (Math.round(until) * 3600000) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until.isNumber() && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { // until is a duration in days: + untilRes = new Date( now() + (Math.round(until) * 86400000) ).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) // Round down to midnight in the LOCAL timezone. + } + else { + log.warn "${device.label}: setThermostatMode(): until value could not be parsed. Mode will be applied permanently." + untilRes = 0 + } + + // If mode is away/dayOff/custom the date needs to be rounded down to midnight in the local timezone, then converted back to string again: + if (0 != untilRes && ('away' == mode ||'dayoff' == mode ||'custom' == mode )) { + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", untilRes).format("yyyy-MM-dd'T'00:00:00XX", location.timeZone) ).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) + } + + // Build request: + def body + if (0 == untilRes || 'off' == mode || 'auto' == mode) { // Mode is permanent: + body = ['SystemMode': modeIndex, 'TimeUntil': null, 'Permanent': 'True'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: True" + } + else { // Mode is temporary: + body = ['SystemMode': modeIndex, 'TimeUntil': untilRes, 'Permanent': 'False'] + log.info "${app.label}: setThermostatMode(): System ID: ${systemId}, Mode: ${mode}, Permanent: False, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureControlSystem/${systemId}/mode", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setThermostatMode(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setThermostatMode(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setThermostatMode(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + + /** + * setHeatingSetpoint(zoneId, setpoint, until=-1) + * + * Set heatingSetpoint for specified zoneId, until specified time. + * + * zoneId: Zone ID of zone, e.g.: "123456" + * + * setpoint: Setpoint temperature, e.g.: "21.5". Can be a number or string. + * + * until: (Optional) Time to apply setpoint until, can be either: + * - Date: date object representing when override should end. + * - ISO-8601 date string, in format "yyyy-MM-dd'T'HH:mm:ssXX", e.g.: "2016-04-01T00:00:00Z". + * - String: 'permanent'. + * If not specified, setpoint will be applied permanently. + * + * Example usage: + * setHeatingSetpoint(123456, 21.0) // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, 'permanent') // Set temp of 21.0 permanently, for zone 123456. + * setHeatingSetpoint(123456, 21.0, '2016-04-01T00:00:00Z') // Set until specific time, for zone 123456. + * + **/ +def setHeatingSetpoint(zoneId, setpoint, until=-1) { + + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${until}" + + // Clean setpoint: + setpoint = formatTemperature(setpoint) + + // Clean until: + def untilRes + if ('permanent' == until || 0 == until || -1 == until) { + untilRes = 0 + } + else if (until instanceof Date) { + untilRes = until.format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else if (until ==~ /\d+.*T.*/) { // until is a ISO-8601 date string already, but we'll re-format it anyway to ensure it's in UTC: + untilRes = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", until).format("yyyy-MM-dd'T'HH:mm:00XX", TimeZone.getTimeZone('UTC')) // Round to nearest minute. + } + else { + log.warn "${device.label}: setHeatingSetpoint(): until value could not be parsed. Setpoint will be applied permanently." + untilRes = 0 + } + + // Build request: + def body + if (0 == untilRes) { // Permanent: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 1, 'TimeUntil': null] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: Permanent" + } + else { // Temporary: + body = ['HeatSetpointValue': setpoint, 'SetpointMode': 2, 'TimeUntil': untilRes] + log.info "${app.label}: setHeatingSetpoint(): Zone ID: ${zoneId}, Setpoint: ${setpoint}, Until: ${untilRes}" + } + + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': body, + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: setHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: setHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: setHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/** + * clearHeatingSetpoint(zoneId) + * + * Clear the heatingSetpoint for specified zoneId. + * zoneId: Zone ID of zone, e.g.: "123456" + **/ +def clearHeatingSetpoint(zoneId) { + + log.info "${app.label}: clearHeatingSetpoint(): Zone ID: ${zoneId}" + + // Build request: + def requestParams = [ + 'uri': atomicState.evohomeEndpoint, + 'path': "/WebAPI/emea/api/v1/temperatureZone/${zoneId}/heatSetpoint", + 'body': ['HeatSetpointValue': 0.0, 'SetpointMode': 0, 'TimeUntil': null], + 'headers': atomicState.evohomeHeaders + ] + + // Make request: + try { + httpPutJson(requestParams) { resp -> + if(resp.status == 201 && resp.data) { + if (atomicState.debug) log.debug "${app.label}: clearHeatingSetpoint(): Response: ${resp.data}" + return null + } + else { + log.error "${app.label}: clearHeatingSetpoint(): No Data. Response Status: ${resp.status}" + return 'error' + } + } + } catch (groovyx.net.http.HttpResponseException e) { + log.error "${app.label}: clearHeatingSetpoint(): Error: ${e}" + if (e.statusCode == 401) { + atomicState.evohomeAuthFailed = true + } + return e + } +} + + +/********************************************************************** + * Helper Commands: + **********************************************************************/ + + /** + * generateDni(locId,gatewayId,systemId,deviceId) + * + * Generate a device Network ID. + * Uses the same format as the official Evohome App, but with a prefix of "Evohome." + **/ +private generateDni(locId,gatewayId,systemId,deviceId) { + return 'Evohome.' + [ locId, gatewayId, systemId, deviceId ].join('.') +} + + +/** + * formatTemperature(t) + * + * Format temperature value to one decimal place. + * t: can be string, float, bigdecimal... + * Returns as string. + **/ +private formatTemperature(t) { + return Float.parseFloat("${t}").round(1).toString() +} + + + /** + * formatSetpointMode(mode) + * + * Format Evohome setpointMode values to SmartThings values: + * + **/ +private formatSetpointMode(mode) { + + switch (mode) { + case 'FollowSchedule': + mode = 'followSchedule' + break + case 'PermanentOverride': + mode = 'permanentOverride' + break + case 'TemporaryOverride': + mode = 'temporaryOverride' + break + default: + log.error "${app.label}: formatSetpointMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * formatThermostatMode(mode) + * + * Translate Evohome thermostatMode values to SmartThings values. + * + **/ +private formatThermostatMode(mode) { + + switch (mode) { + case 'Auto': + mode = 'auto' + break + case 'AutoWithEco': + mode = 'economy' + break + case 'Away': + mode = 'away' + break + case 'Custom': + mode = 'custom' + break + case 'DayOff': + mode = 'dayOff' + break + case 'HeatingOff': + mode = 'off' + break + default: + log.error "${app.label}: formatThermostatMode(): Mode: ${mode} unknown!" + mode = mode.toLowerCase() + break + } + + return mode +} + + +/** + * getCurrentSwitchpoint(schedule) + * + * Returns the current active switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getCurrentSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + ScheduleToday.switchpoints.reverse(true) + def currentSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay < c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!currentSwitchPoint) { + // There are no current switchpoints today, so we must look for the last Switchpoint yesterday. + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): No current switchpoints today, so must look to yesterday's schedule." + c.add(Calendar.DATE, -1 ) // Subtract one DAY. + def ScheduleYesterday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleYesterday.switchpoints.sort {it.timeOfDay} + ScheduleYesterday.switchpoints.reverse(true) + currentSwitchPoint = ScheduleYesterday.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + currentSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + currentSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getCurrentSwitchpoint(): Current Switchpoint: ${currentSwitchPoint}" + + return currentSwitchPoint +} + + +/** + * getNextSwitchpoint(schedule) + * + * Returns the next switchpoint in the given schedule. + * e.g. [timeOfDay:"23:00:00", temperature:"15.0000"] + * + **/ +private getNextSwitchpoint(schedule) { + + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint()" + + Calendar c = new GregorianCalendar() + def ScheduleToday = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + + // Sort and find next switchpoint: + ScheduleToday.switchpoints.sort {it.timeOfDay} + def nextSwitchPoint = ScheduleToday.switchpoints.find {it.timeOfDay > c.getTime().format("HH:mm:ss", location.timeZone)} + + if (!nextSwitchPoint) { + // There are no switchpoints left today, so we must look for the first Switchpoint tomorrow. + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): No more switchpoints today, so must look to tomorrow's schedule." + c.add(Calendar.DATE, 1 ) // Add one DAY. + def ScheduleTmrw = schedule.dailySchedules.find { it.dayOfWeek = c.getTime().format("EEEE", location.timeZone) } + ScheduleTmrw.switchpoints.sort {it.timeOfDay} + nextSwitchPoint = ScheduleTmrw.switchpoints[0] // There will always be one. + } + + // Now construct the switchpoint time as a full ISO-8601 format date string in UTC: + def localDateStr = c.getTime().format("yyyy-MM-dd'T'", location.timeZone) + nextSwitchPoint.timeOfDay + c.getTime().format("XX", location.timeZone) // Switchpoint in local timezone. + def isoDateStr = new Date().parse("yyyy-MM-dd'T'HH:mm:ssXX", localDateStr).format("yyyy-MM-dd'T'HH:mm:ssXX", TimeZone.getTimeZone('UTC')) // Parse and re-format to UTC timezone. + nextSwitchPoint << [ 'time': isoDateStr ] + if (atomicState.debug) log.debug "${app.label}: getNextSwitchpoint(): Next Switchpoint: ${nextSwitchPoint}" + + return nextSwitchPoint +} + \ No newline at end of file diff --git a/third-party/garage-switch.groovy b/third-party/garage-switch.groovy new file mode 100755 index 0000000..0708b91 --- /dev/null +++ b/third-party/garage-switch.groovy @@ -0,0 +1,77 @@ +/** + * Control garage with switch + * + * Copyright 2014 ObyCode + * + * 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: "Control garage with switch", + namespace: "com.obycode", + author: "ObyCode", + description: "Use a z-wave light switch to control your garage. When the switch is pressed down, the garage door will close (if its not already), and likewise, it will open when up is pressed on the switch. Additionally, the indicator light on the switch will tell you if the garage door is open or closed.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png" +) + + +preferences { + section("Use this switch...") { + input "theSwitch", "capability.switch", multiple: false, required: true + } + section("to control this garage door...") { + input "theOpener", "capability.momentary", multiple: false, required: true + } + section("whose status is given by this sensor...") { + input "theSensor", "capability.threeAxis", multiple: false, required: true + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + subscribe(theSwitch, "switch", switchHit) + subscribe(theSensor, "status", statusChanged) +} + +def switchHit(evt) { + log.debug "in switchHit: " + evt.value + def current = theSensor.currentState("status") + if (evt.value == "on") { + if (current.value == "closed") { + theOpener.push() + } + } else { + if (current.value == "open") { + theOpener.push() + } + } +} + +def statusChanged(evt) { + if (evt.value == "open") { + theSwitch.on() + } else if (evt.value == "closed") { + theSwitch.off() + } +} diff --git a/third-party/groveStreams.groovy b/third-party/groveStreams.groovy new file mode 100755 index 0000000..b4c5a3b --- /dev/null +++ b/third-party/groveStreams.groovy @@ -0,0 +1,447 @@ +/** + * groveStreams + * + * Copyright 2014 Yves Racine + * + * LinkedIn profile: ca.linkedin.com/pub/yves-racine-m-sc-a/0/406/4b/ + * + * Developer retains all right, title, copyright, and interest, including all copyright, patent rights, trade secret + * in the Background technology. May be subject to consulting fees under the Agreement between the Developer and the Customer. + * Developer grants a non exclusive perpetual license to use the Background technology in the Software developed for and delivered + * to Customer under this Agreement. However, the Customer shall make no commercial use of the Background technology without + * Developer's written consent. + * + * 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. + * Software Distribution is restricted and shall be done only with Developer's written approval. + * + * Based on code from Jason Steele & Minollo + * Adapted to be compatible with MyEcobee and My Automatic devices which are available at my store: + * http://www.ecomatiqhomes.com/#!store/tc3yr + * + */ +definition( + name: "groveStreams", + namespace: "yracine", + author: "Yves Racine", + description: "Log to groveStreams and send data streams based on devices selection", + category: "My Apps", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/ecobee@2x.png" +) + + + +preferences { + section("About") { + paragraph "groveStreams, the smartapp that sends your device states to groveStreams for data correlation" + paragraph "Version 2.2.2" + paragraph "If you like this smartapp, please support the developer via PayPal and click on the Paypal link below " + href url: "https://www.paypal.me/ecomatiqhomes", + title:"Paypal donation..." + paragraph "Copyright©2014 Yves Racine" + href url:"http://github.com/yracine/device-type.myecobee", style:"embedded", required:false, title:"More information..." + description: "http://github.com/yracine" + } + section("Log devices...") { + input "temperatures", "capability.temperatureMeasurement", title: "Temperatures", required: false, multiple: true + input "thermostats", "capability.thermostat", title: "Thermostats", required: false, multiple: true + input "ecobees", "device.myEcobeeDevice", title: "Ecobees", required: false, multiple: true + input "automatic", "capability.presenceSensor", title: "Automatic Connected Device(s)", required: false, multiple: true + input "detectors", "capability.smokeDetector", title: "Smoke/CarbonMonoxide Detectors", required: false, multiple: true + input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity sensors", required: false, multiple: true + input "waters", "capability.waterSensor", title: "Water sensors", required: false, multiple: true + input "illuminances", "capability.IlluminanceMeasurement", title: "Illuminance sensor", required: false, multiple: true + input "locks", "capability.lock", title: "Locks", required: false, multiple: true + input "contacts", "capability.contactSensor", title: "Doors open/close", required: false, multiple: true + input "accelerations", "capability.accelerationSensor", title: "Accelerations", required: false, multiple: true + input "motions", "capability.motionSensor", title: "Motions", required: false, multiple: true + input "presence", "capability.presenceSensor", title: "Presence", required: false, multiple: true + input "switches", "capability.switch", title: "Switches", required: false, multiple: true + input "dimmerSwitches", "capability.switchLevel", title: "Dimmer Switches", required: false, multiple: true + input "batteries", "capability.battery", title: "Battery-powered devices", required: false, multiple: true + input "powers", "capability.powerMeter", title: "Power Meters", required: false, multiple: true + input "energys", "capability.energyMeter", title: "Energy Meters", required: false, multiple: true + + } + + section("GroveStreams Feed PUT API key...") { + input "channelKey", "text", title: "API key" + } + section("Sending data at which interval in minutes (default=5)?") { + input "givenInterval", "number", title: 'Send Data Interval', required: false + } +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + subscribe(temperatures, "temperature", handleTemperatureEvent) + subscribe(humidities, "humidity", handleHumidityEvent) + subscribe(waters, "water", handleWaterEvent) + subscribe(waters, "water", handleWaterEvent) + subscribe(detectors, "smoke", handleSmokeEvent) + subscribe(detectors, "carbonMonoxide", handleCarbonMonoxideEvent) + subscribe(illuminances, "illuminance", handleIlluminanceEvent) + subscribe(contacts, "contact", handleContactEvent) + subscribe(locks, "lock", handleLockEvent) + subscribe(accelerations, "acceleration", handleAccelerationEvent) + subscribe(motions, "motion", handleMotionEvent) + subscribe(presence, "presence", handlePresenceEvent) + subscribe(switches, "switch", handleSwitchEvent) + subscribe(dimmerSwitches, "switch", handleSwitchEvent) + subscribe(dimmerSwitches, "level", handleSetLevelEvent) + subscribe(batteries, "battery", handleBatteryEvent) + subscribe(powers, "power", handlePowerEvent) + subscribe(energys, "energy", handleEnergyEvent) + subscribe(energys, "cost", handleCostEvent) + subscribe(thermostats, "heatingSetpoint", handleHeatingSetpointEvent) + subscribe(thermostats, "coolingSetpoint", handleCoolingSetpointEvent) + subscribe(thermostats, "thermostatMode", handleThermostatModeEvent) + subscribe(thermostats, "fanMode", handleFanModeEvent) + subscribe(thermostats, "thermostatOperatingState", handleThermostatOperatingStateEvent) + subscribe(ecobees, "dehumidifierMode", handleDehumidifierModeEvent) + subscribe(ecobees, "equipmentStatus", handleEquipmentStatusEvent) + subscribe(ecobees, "dehumidifierLevel", handleDehumidifierLevelEvent) + subscribe(ecobees, "humidifierMode", handleHumidifierModeEvent) + subscribe(ecobees, "humidifierLevel", handleHumidifierLevelEvent) + subscribe(ecobees, "fanMinOnTime", handleFanMinOnTimeEvent) + subscribe(ecobees, "ventilatorMode", handleVentilatorModeEvent) + subscribe(ecobees, "ventilatorMinOnTime", handleVentilatorMinOnTimeEvent) + subscribe(ecobees, "programScheduleName", handleProgramNameEvent) + subscribe(ecobees, "auxHeat1RuntimeDaily", handleDailyStats) + subscribe(ecobees, "auxHeat2RuntimeDaily", handleDailyStats) + subscribe(ecobees, "auxHeat3RuntimeDaily", handleDailyStats) + subscribe(ecobees, "compCool1RuntimeDaily", handleDailyStats) + subscribe(ecobees, "compCool2RuntimeDaily", handleDailyStats) + subscribe(ecobees, "fanRuntimeDaily", handleDailyStats) + subscribe(ecobees, "humidifierRuntimeDaily", handleDailyStats) + subscribe(ecobees, "dehumidifierRuntimeDaily", handleDailyStats) + subscribe(ecobees, "ventilatorRuntimeDaily", handleDailyStats) + subscribe(ecobees, "presence", handlePresenceEvent) + subscribe(ecobees, "compCool2RuntimeDaily", handleDailyStats) + subscribe(automatic, "yesterdayTripsAvgAverageKmpl",handleDailyStats) + subscribe(automatic, "yesterdayTripsAvgDistanceM",handleDailyStats) + subscribe(automatic, "yesterdayTripsAvgDurationS",handleDailyStats) + subscribe(automatic, "yesterdayTotalDistanceM",handleDailyStats) + subscribe(automatic, "yesterdayTripsAvgFuelVolumeL",handleDailyStats) + subscribe(automatic, "yesterdayTotalFuelVolumeL",handleDailyStats) + subscribe(automatic, "yesterdayTotalDurationS:",handleDailyStats) + subscribe(automatic, "yesterdayTotalNbTrips",handleDailyStats) + subscribe(automatic, "yesterdayTotalHardAccels",handleDailyStats) + subscribe(automatic, "yesterdayTotalHardBrakes:",handleDailyStats) + subscribe(automatic, "yesterdayTripsAvgScoreSpeeding",handleDailyStats) + subscribe(automatic, "yesterdayTripsAvgScoreEvents",handleDailyStats) + def queue = [] + atomicState.queue=queue + + if (atomicState.queue==null) { + atomicState.queue = [] + } + atomicState?.poll = [ last: 0, rescheduled: now() ] + + Integer delay = givenInterval ?: 5 // By default, schedule processQueue every 5 min. + log.debug "initialize>scheduling processQueue every ${delay} minutes" + + //Subscribe to different events (ex. sunrise and sunset events) to trigger rescheduling if needed + subscribe(location, "sunrise", rescheduleIfNeeded) + subscribe(location, "sunset", rescheduleIfNeeded) + subscribe(location, "mode", rescheduleIfNeeded) + subscribe(location, "sunriseTime", rescheduleIfNeeded) + subscribe(location, "sunsetTime", rescheduleIfNeeded) + subscribe(app, appTouch) + + rescheduleIfNeeded() +} + +def appTouch(evt) { + rescheduleIfNeeded() + processQueue() + def queue = [] + atomicState.queue=queue +} + + +def rescheduleIfNeeded(evt) { + if (evt) log.debug("rescheduleIfNeeded>$evt.name=$evt.value") + Integer delay = givenInterval ?: 5 // By default, schedule processQueue every 5 min. + BigDecimal currentTime = now() + BigDecimal lastPollTime = (currentTime - (atomicState?.poll["last"]?:0)) + if (lastPollTime != currentTime) { + Double lastPollTimeInMinutes = (lastPollTime/60000).toDouble().round(1) + log.info "rescheduleIfNeeded>last poll was ${lastPollTimeInMinutes.toString()} minutes ago" + } + if (((atomicState?.poll["last"]?:0) + (delay * 60000) < currentTime) && canSchedule()) { + log.info "rescheduleIfNeeded>scheduling processQueue in ${delay} minutes.." + unschedule + schedule("0 0/${delay} * * * ?", processQueue) + } + // Update rescheduled state + + if (!evt) { + atomicState.poll["rescheduled"] = now() + } +} + +def handleTemperatureEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handleHumidityEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handleHeatingSetpointEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleCoolingSetpointEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handleThermostatModeEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleFanModeEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleHumidifierModeEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleHumidifierLevelEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleDehumidifierModeEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleDehumidifierLevelEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleVentilatorModeEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleFanMinOnTimeEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleVentilatorMinOnTimeEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handleThermostatOperatingStateEvent(evt) { + queueValue(evt) { + it == "idle" ? 0 : (it == 'fan only') ? 1 : (it == 'heating') ? 2 : 3 + } + +} +def handleDailyStats(evt) { + queueValue(evt) { + it.toString() + } + +} +def handleEquipmentStatusEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handleProgramNameEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handleWaterEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleSmokeEvent(evt) { + queueValue(evt) { + it.toString() + } +} +def handleCarbonMonoxideEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handleIlluminanceEvent(evt) { + log.debug ("handleIlluminanceEvent> $evt.name= $evt.value") + queueValue(evt) { + it.toString() + } +} + +def handleLockEvent(evt) { + queueValue(evt) { + it == "locked" ? 1 : 0 + } +} + +def handleBatteryEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handleContactEvent(evt) { + queueValue(evt) { + it == "open" ? 1 : 0 + } +} + +def handleAccelerationEvent(evt) { + queueValue(evt) { + it == "active" ? 1 : 0 + } +} + +def handleMotionEvent(evt) { + queueValue(evt) { + it == "active" ? 1 : 0 + } +} + +def handlePresenceEvent(evt) { + queueValue(evt) { + it == "present" ? 1 : 0 + } +} + +def handleSwitchEvent(evt) { + queueValue(evt) { + it == "on" ? 1 : 0 + } +} + +def handleSetLevelEvent(evt) { + queueValue(evt) { + it.toString() + } +} + +def handlePowerEvent(evt) { + if (evt.value) { + queueValue(evt) { + it.toString() + } + } +} + +def handleEnergyEvent(evt) { + if (evt.value) { + queueValue(evt) { + it.toString() + } + } +} +def handleCostEvent(evt) { + if (evt.value) { + queueValue(evt) { + it.toString() + } + } +} + +private queueValue(evt, Closure convert) { + def MAX_QUEUE_SIZE=95000 + def jsonPayload = [compId: evt.displayName, streamId: evt.name, data: convert(evt.value), time: now()] + def queue + + queue = atomicState.queue + queue << jsonPayload + atomicState.queue = queue + def queue_size = queue.toString().length() + def last_item_in_queue = queue[queue.size() -1] + log.debug "queueValue>queue size in chars=${queue_size}, appending ${jsonPayload} to queue, last item in queue= $last_item_in_queue" + if (queue_size > MAX_QUEUE_SIZE) { + processQueue() + } +} + +def processQueue() { + Integer delay = givenInterval ?: 5 // By default, schedule processQueue every 5 min. + atomicState?.poll["last"] = now() + + if (((atomicState?.poll["rescheduled"]?:0) + (delay * 60000)) < now()) { + log.info "processQueue>scheduling rescheduleIfNeeded() in ${delay} minutes.." + schedule("0 0/${delay} * * * ?", rescheduleIfNeeded) + // Update rescheduled state + atomicState?.poll["rescheduled"] = now() + } + + def queue = atomicState.queue + + + def url = "https://grovestreams.com/api/feed?api_key=${channelKey}" + log.debug "processQueue" + if (queue != []) { + log.debug "Events to be sent to groveStreams: ${queue}" + + try { + httpPutJson([uri: url, body: queue]) {response -> + if (response.status != 200) { + log.debug "GroveStreams logging failed, status = ${response.status}" + } else { + log.debug "GroveStreams accepted event(s)" + // reset the queue + queue =[] + atomicState.queue = queue + } + } + } catch (groovyx.net.http.ResponseParseException e) { + // ignore error 200, bogus exception + if (e.statusCode != 200) { + log.error "Grovestreams: ${e}" + } else { + log.debug "GroveStreams accepted event(s)" + } + // reset the queue + queue =[] + atomicState.queue = queue + + } catch (e) { + def errorInfo = "Error sending value: ${e}" + log.error errorInfo + // reset the queue + queue =[] + atomicState.queue = queue + } + } + +} diff --git a/third-party/hue-lights-and-groups-and-scenes-oh-my.groovy b/third-party/hue-lights-and-groups-and-scenes-oh-my.groovy new file mode 100755 index 0000000..d227374 --- /dev/null +++ b/third-party/hue-lights-and-groups-and-scenes-oh-my.groovy @@ -0,0 +1,2383 @@ +/** + * Hue Lights and Groups and Scenes (OH MY) - new Hue Service Manager + * + * Version 1.4: Added ability to create / modify / delete Hue Hub Scenes directly from SmartApp + * Added ability to create / modify / delete Hue Hub Groups directly from SmartApp + * Overhauled communications to / from Hue Hub + * Revised child app functions + * + * Authors: Anthony Pastor (infofiend) and Clayton (claytonjn) + * + */ + +definition( + name: "Hue Lights and Groups and Scenes (OH MY)", + namespace: "info_fiend", + author: "Anthony Pastor", + description: "Allows you to connect your Philips Hue lights with SmartThings and control them from your Things area or Dashboard in the SmartThings Mobile app. Adjust colors by going to the Thing detail screen for your Hue lights (tap the gear on Hue tiles).\n\nPlease update your Hue Bridge first, outside of the SmartThings app, using the Philips Hue app.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Partner/hue.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Partner/hue@2x.png", + singleInstance: true +) + +preferences { + page(name:"mainPage", title:"Hue Device Setup", content:"mainPage", refreshTimeout:5) + page(name:"bridgeDiscovery", title:"Hue Bridge Discovery", content:"bridgeDiscovery", refreshTimeout:5) + page(name:"bridgeBtnPush", title:"Linking with your Hue", content:"bridgeLinking", refreshTimeout:5) + page(name:"bulbDiscovery", title:"Bulb Discovery", content:"bulbDiscovery", refreshTimeout:5) + page(name:"groupDiscovery", title:"Group Discovery", content:"groupDiscovery", refreshTimeout:5) + page(name:"sceneDiscovery", title:"Scene Discovery", content:"sceneDiscovery", refreshTimeout:5) + page(name:"defaultTransition", title:"Default Transition", content:"defaultTransition", refreshTimeout:5) + page(name:"createScene", title:"Create A New Scene", content:"createScene") + page(name:"changeSceneName", title:"Change Name Of An Existing Scene", content:"changeSceneName") + page(name:"modifyScene", title:"Modify An Existing Scene", content:"modifyScene") + page(name:"removeScene", title:"Delete An Existing Scene", content:"removeScene") + page(name:"createGroup", title:"Create A New Group", content:"createGroup") + page(name:"modifyGroup", title:"Modify An Existing Group", content:"modifyGroup") + page(name:"removeGroup", title:"Delete An Existing Group", content:"removeGroup") +} + +def mainPage() { + def bridges = bridgesDiscovered() + if (state.username && bridges) { + return bulbDiscovery() + } else { + return bridgeDiscovery() + } +} + +def bridgeDiscovery(params=[:]) +{ + def bridges = bridgesDiscovered() + int bridgeRefreshCount = !state.bridgeRefreshCount ? 0 : state.bridgeRefreshCount as int + state.bridgeRefreshCount = bridgeRefreshCount + 1 + def refreshInterval = 3 + + def options = bridges ?: [] + def numFound = options.size() ?: 0 + + if(!state.subscribe) { + subscribe(location, null, locationHandler, [filterEvents:false]) + state.subscribe = true + } + + //bridge discovery request every 15 //25 seconds + if((bridgeRefreshCount % 5) == 0) { + discoverBridges() + } + + //setup.xml request every 3 seconds except on discoveries + if(((bridgeRefreshCount % 3) == 0) && ((bridgeRefreshCount % 5) != 0)) { + verifyHueBridges() + } + + return dynamicPage(name:"bridgeDiscovery", title:"Discovery Started!", nextPage:"bridgeBtnPush", refreshInterval:refreshInterval, uninstall: true) { + section("Please wait while we discover your Hue Bridge. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedHue", "enum", required:false, title:"Select Hue Bridge (${numFound} found)", multiple:false, options:options + } + } +} + +def bridgeLinking() +{ + int linkRefreshcount = !state.linkRefreshcount ? 0 : state.linkRefreshcount as int + state.linkRefreshcount = linkRefreshcount + 1 + def refreshInterval = 3 + + def nextPage = "" + def title = "Linking with your Hue" + def paragraphText = "Press the button on your Hue Bridge to setup a link." + if (state.username) { //if discovery worked + nextPage = "bulbDiscovery" + title = "Success! - click 'Next'" + paragraphText = "Linking to your hub was a success! Please click 'Next'!" + } + + if((linkRefreshcount % 2) == 0 && !state.username) { + sendDeveloperReq() + } + + return dynamicPage(name:"bridgeBtnPush", title:title, nextPage:nextPage, refreshInterval:refreshInterval) { + section("Button Press") { + paragraph """${paragraphText}""" + } + } +} + +def bulbDiscovery() { + + if (selectedHue) { + def bridge = getChildDevice(selectedHue) + subscribe(bridge, "bulbList", bulbListHandler) + } + + int bulbRefreshCount = !state.bulbRefreshCount ? 0 : state.bulbRefreshCount as int + state.bulbRefreshCount = bulbRefreshCount + 1 + def refreshInterval = 3 + + def optionsBulbs = bulbsDiscovered() ?: [] + state.optBulbs = optionsBulbs + def numFoundBulbs = optionsBulbs.size() ?: 0 + + if((bulbRefreshCount % 3) == 0) { + log.debug "START BULB DISCOVERY" + discoverHueBulbs() + log.debug "END BULB DISCOVERY" + } + + return dynamicPage(name:"bulbDiscovery", title:"Bulb Discovery Started!", nextPage:"groupDiscovery", refreshInterval:refreshInterval, uninstall: true) { + section("Please wait while we discover your Hue Bulbs. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedBulbs", "enum", required:false, title:"Select Hue Bulbs (${numFoundBulbs} found)", multiple:true, options:optionsBulbs + } + section { + def title = bridgeDni ? "Hue bridge (${bridgeHostname})" : "Find bridges" + href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true] + + } + } +} + +def groupDiscovery() { + + if (selectedHue) { + def bridge = getChildDevice(selectedHue) + subscribe(bridge, "groupList", groupListHandler) + } + + int groupRefreshCount = !state.groupRefreshCount ? 0 : state.groupRefreshCount as int + state.groupRefreshCount = groupRefreshCount + 1 + + def refreshInterval = 3 + if (state.gChange) { + refreshInterval = 1 + state.gChange = false + } + + def optionsGroups = [] + def numFoundGroups = [] + optionsGroups = groupsDiscovered() ?: [] + numFoundGroups = optionsGroups.size() ?: 0 + +// if (numFoundGroups == 0) +// app.updateSetting("selectedGroups", "") + + if((groupRefreshCount % 3) == 0) { + log.debug "START GROUP DISCOVERY" + discoverHueGroups() + log.debug "END GROUP DISCOVERY" + } + + return dynamicPage(name:"groupDiscovery", title:"Group Discovery Started!", nextPage:"sceneDiscovery", refreshInterval:refreshInterval, install: false, uninstall: isInitComplete) { + section("Please wait while we discover your Hue Groups. Discovery can take a few minutes, so sit back and relax! Select your device below once discovered.") { + input "selectedGroups", "enum", required:false, title:"Select Hue Groups (${numFoundGroups} found)", multiple:true, options:optionsGroups + + } + section { + def title = bridgeDni ? "Hue bridge (${bridgeHostname})" : "Find bridges" + href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true] + + } + if (state.initialized) { + + section { + href "createGroup", title: "Create a Group", description: "Create A New Group On Hue Hub", state: selectedHue ? "complete" : "incomplete" + + } + section { + href "modifyGroup", title: "Modify a Group", description: "Modify The Lights Contained In Existing Group On Hue Hub", state: selectedHue ? "complete" : "incomplete" + + } + section { + href "removeGroup", title: "Delete a Group", description: "Delete An Existing Group From Hue Hub", state: selectedHue ? "complete" : "incomplete" + } + + } + } +} + + +def createGroup(params=[:]) { + + def theBulbs = state.bulbs + def creBulbsNames = [] + def bulbID + def theBulbName + theBulbs?.each { + bulbID = it.key + theBulbName = state.bulbs[bulbID].name as String + creBulbsNames << [name: theBulbName, id:bulbID] + } + + state.creBulbNames = creBulbsNames + log.trace "creBulbsNames = ${creBulbsNames}." + + def creTheLightsID = [] + if (creTheLights) { + + creTheLights.each { v -> + creBulbsNames.each { m -> + if (m.name == v) { + creTheLightsID << m.id + } + } + } + } + + if (creTheLightsID) {log.debug "The Selected Lights ${creTheLights} have ids of ${creTheLightsID}."} + + if (creTheLightsID && creGroupName) { + + def body = [name: creGroupName, lights: creTheLightsID] + + log.debug "***************The body for createNewGroup() will be ${body}." + + if ( creGroupConfirmed == "Yes" ) { + + // create new group on Hue Hub + createNewGroup(body) + + // reset page + app.updateSetting("creGroupConfirmed", null) + app.updateSetting("creGroupName", null) + app.updateSetting("creTheLights", null) + app.updateSetting("creTheLightsID", null) + app.updateSetting("creGroup", null) + + + state.gChange = true + + } + } + + + + return dynamicPage(name:"createGroup", title:"Create Group", nextPage:"groupDiscovery", refreshInterval:3, install:false, uninstall: false) { + section("Choose Name for New Group") { + input "creGroupName", "text", title: "Group Name: (hit enter when done)", required: true, submitOnChange: true + + if (creGroupName) { + + input "creTheLights", "enum", title: "Choose The Lights You Want In This Scene", required: true, multiple: true, submitOnChange: true, options:creBulbsNames.name.sort() + + if (creTheLights) { + + paragraph "ATTENTION: Clicking Yes below will IMMEDIATELY create a new group on the Hue Hub called ${creGroupName} using the selected lights." + + input "creGroupConfirmed", "enum", title: "Are you sure?", required: true, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true + + } + } + } + } +} + +def modifyGroup(params=[:]) { + + + def theGroups = [] + theGroups = state.groups // scenesDiscovered() ?: [] + + def modGroupOptions = [] + def grID + def theGroupName + theGroups?.each { + grID = it.key + theGroupName = state.groups[grID].name as String + modGroupOptions << [name:theGroupName, id:grID] + } + + state.modGroupOptions = modGroupOptions + log.trace "modGroupOptions = ${modGroupOptions}." + + def modGroupID + if (modGroup) { + state.modGroupOptions.each { m -> + if (modGroup == m.name) { + modGroupID = m.id + log.debug "The selected group ${modGroup} has an id of ${modGroupID}." + } + } + } + + def theBulbs = state.bulbs + def modBulbsNames = [] + def bulbID + def theBulbName + theBulbs?.each { + bulbID = it.key + theBulbName = state.bulbs[bulbID].name as String + modBulbsNames << [name: theBulbName, id:bulbID] + } + + state.modBulbNames = modBulbsNames + log.trace "modBulbsNames = ${modBulbsNames}." + + def modTheLightsID = [] + if (modTheLights) { + + modTheLights.each { v -> + modBulbsNames.each { m -> +// log.debug "m.name is ${m.name} and v is ${v}." + if (m.name == v) { +// log.debug "m.id is ${m.id}." + modTheLightsID << m.id // myBulbs.find{it.name == v} + } + } + } + } + + if (modTheLightsID) {log.debug "The Selected Lights ${modTheLights} have ids of ${modTheLightsID}."} + + if (modTheLightsID && modGroupID) { + + def body = [groupID: modGroupID] + if (modGroupName) {body.name = modGroupName} + if (modTheLightsID) {body.lights = modTheLightsID} + + log.debug "***************The body for updateGroup() will be ${body}." + + if ( modGroupConfirmed == "Yes" ) { + + // modify Group lights (and optionally name) on Hue Hub + updateGroup(body) + + // modify Group lights within ST + def dni = app.id + "/" + modGroupID + "g" + if (modTheLightsID) { sendEvent(dni, [name: "lights", value: modTheLights]) } + + // reset page + app.updateSetting("modGroupConfirmed", null) + app.updateSetting("modGroupName", "") + app.updateSetting("modTheLights", null) + app.updateSetting("modTheLightsID", null) + app.updateSetting("modGroup", null) + app.updateSetting("modGroupID", null) + + state.gChange = true + } + } + + + + return dynamicPage(name:"modifyGroup", title:"Modify Group", nextPage:"groupDiscovery", refreshInterval:3, install:false, uninstall: false) { + section("Choose Group to Modify") { + input "modGroup", "enum", title: "Modify Group:", required: true, multiple: false, submitOnChange: true, options:modGroupOptions.name.sort() {it.value} + + if (modGroup) { + + input "modTheLights", "enum", title: "Choose The Lights You Want In This Group (reflected in Hue Hub and within ST device)", required: true, multiple: true, submitOnChange: true, options:modBulbsNames.name.sort() + + if (modTheLights) { + input "modGroupName", "text", title: "Change Group Name (Reflected ONLY within Hue Hub - ST only allows name / label change via mobile app or IDE ). ", required: false, submitOnChange: true + + paragraph "ATTENTION: Clicking Yes below will IMMEDIATELY set the ${modGroup} group to the selected lights." + + input "modGroupConfirmed", "enum", title: "Are you sure?", required: true, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true + + } + } + } + } +} + + + + +def removeGroup(params=[:]) { + + def theGroups = [] + theGroups = state.groups + + def remGroupOptions = [] + def grID + def theGroupName + theGroups?.each { + grID = it.key + theGroupName = state.groups[grID].name as String + remGroupOptions << [name:theGroupName, id:grID] + } + + state.remGroupOptions = remGroupOptions + log.trace "remGroupOptions = ${remGroupOptions}." + + def remGroupID + if (remGroup) { + state.remGroupOptions.each { m -> + if (remGroup == m.name) { + remGroupID = m.id + log.debug "The selected group ${remGroup} has an id of ${remGroupID}." + } + } + } + + if (remGroupID) { + + def body = [groupID: remGroupID] + + log.debug "***************The body for deleteGroup() will be ${body}." + + if ( remGroupConfirmed == "Yes" ) { + + // delete group from hub + deleteGroup(body) + + // try deleting group from ST (if exists) + def dni = app.id + "/" + remGroupID + "g" + log.debug "remGroup: dni = ${dni}." + try { + deleteChildDevice(dni) + log.trace "${remGroup} found and successfully deleted from ST." + } catch (e) { + log.debug "${remGroup} not found within ST - no action taken." + } + + // reset page + app.updateSetting("remGroupConfirmed", null) + app.updateSetting("remGroup", null) + app.updateSetting("remGroupID", null) + + state.gChange = true + } + } + + + + return dynamicPage(name:"removeGroup", title:"Delete Group", nextPage:"groupDiscovery", refreshInterval:3, install:false, uninstall: false) { + section("Choose Group to DELETE") { + input "remGroup", "enum", title: "Delete Group:", required: true, multiple: false, submitOnChange: true, options:remGroupOptions.name.sort() {it.value} + + if (remGroup) { + + paragraph "ATTENTION: Clicking Yes below will IMMEDIATELY DELETE the ${remGroup} group FOREVER!!!" + + input "remGroupConfirmed", "enum", title: "Are you sure?", required: true, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true + + } + + } + } +} + + + + +def sceneDiscovery() { + + def isInitComplete = initComplete() == "complete" + + state.inItemDiscovery = true + + if (selectedHue) { + def bridge = getChildDevice(selectedHue) + subscribe(bridge, "sceneList", sceneListHandler) + } + + def toDo = "" + + int sceneRefreshCount = !state.sceneRefreshCount ? 0 : state.sceneRefreshCount as int + state.sceneRefreshCount = sceneRefreshCount + 1 + + def refreshInterval = 3 + if (state.sChange) { + refreshInterval = 1 + state.sChange = false + } + + def optionsScenes = scenesDiscovered() ?: [] + def numFoundScenes = optionsScenes.size() ?: 0 + +// if (numFoundScenes == 0) +// app.updateSetting("selectedScenes", "") + + if((sceneRefreshCount % 3) == 0) { + log.debug "START HUE SCENE DISCOVERY" + discoverHueScenes() + log.debug "END HUE SCENE DISCOVERY" + } + + return dynamicPage(name:"sceneDiscovery", title:"Scene Discovery Started!", nextPage:toDo, refreshInterval:refreshInterval, install: true, uninstall: isInitComplete) { + section("Please wait while we discover your Hue Scenes. Discovery can take a few minutes, so sit back and relax! Select your device below once discovered.") { + input "selectedScenes", "enum", required:false, title:"Select Hue Scenes (${numFoundScenes} found)", multiple:true, options:optionsScenes.sort {it.value} + } + section { + def title = getBridgeIP() ? "Hue bridge (${getBridgeIP()})" : "Find bridges" + href "bridgeDiscovery", title: title, description: "", state: selectedHue ? "complete" : "incomplete", params: [override: true] + + } + if (state.initialized) { + + section { + href "createScene", title: "Create a Scene", description: "Create A New Scene On Hue Hub", state: selectedHue ? "complete" : "incomplete" + + } + section { + href "changeSceneName", title: "Change the Name of a Scene", description: "Change Scene Name On Hue Hub", state: selectedHue ? "complete" : "incomplete" + + } + section { + href "modifyScene", title: "Modify a Scene", description: "Modify The Lights And / Or The Light Settings For An Existing Scene On Hue Hub", state: selectedHue ? "complete" : "incomplete" + + } + section { + href "removeScene", title: "Delete a Scene", description: "Delete An Existing Scene From Hue Hub", state: selectedHue ? "complete" : "incomplete" + } + + } + } +} + +def createScene(params=[:]) { + + def theBulbs = state.bulbs + def creBulbsNames = [] + def bulbID + def theBulbName + theBulbs?.each { + bulbID = it.key + theBulbName = state.bulbs[bulbID].name as String + creBulbsNames << [name: theBulbName, id:bulbID] + } + + state.creBulbNames = creBulbsNames + log.trace "creBulbsNames = ${creBulbsNames}." + + def creTheLightsID = [] + if (creTheLights) { + + creTheLights.each { v -> + creBulbsNames.each { m -> + if (m.name == v) { + creTheLightsID << m.id // myBulbs.find{it.name == v} + } + } + } + } + + if (creTheLightsID) {log.debug "The Selected Lights ${creTheLights} have ids of ${creTheLightsID}."} + + if (creTheLightsID && creSceneName) { + + def body = [name: creSceneName, lights: creTheLightsID] + + log.debug "***************The body for createNewScene() will be ${body}." + + if ( creSceneConfirmed == "Yes" ) { + + // create new scene on Hue Hub + createNewScene(body) + + // refresh page + app.updateSetting("creSceneConfirmed", null) + app.updateSetting("creSceneName", "") + app.updateSetting("creTheLights", null) + app.updateSetting("creTheLightsID", null) + app.updateSetting("creScene", null) + + state.sChange = true + } + } + + + + return dynamicPage(name:"createScene", title:"Create Scene", nextPage:"sceneDiscovery", refreshInterval:3, install:false, uninstall: false) { + section("Choose Name for New Scene") { + input "creSceneName", "text", title: "New Scene Name:", required: true, submitOnChange: true + + if (creSceneName) { + + input "creTheLights", "enum", title: "Choose The Lights You Want In This Scene", required: true, multiple: true, submitOnChange: true, options:creBulbsNames.name.sort() + + if (creTheLights) { + + paragraph "ATTENTION: Clicking Yes below will IMMEDIATELY create a new scene on the Hue Hub called ${creSceneName} using the selected lights' current configuration." + + input "creSceneConfirmed", "enum", title: "Are you sure?", required: true, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true + + } + } + } + } +} + + + +def modifyScene(params=[:]) { + + def theScenes = [] + theScenes = state.scenes // scenesDiscovered() ?: [] + + def modSceneOptions = [] + def scID + def theSceneName + theScenes?.each { + scID = it.key + theSceneName = state.scenes[scID].name as String + modSceneOptions << [name:theSceneName, id:scID] + } + + state.modSceneOptions = modSceneOptions + log.trace "modSceneOptions = ${modSceneOptions}." + + def modSceneID + if (modScene) { + state.modSceneOptions.each { m -> + if (modScene == m.name) { + modSceneID = m.id + log.debug "The selected scene ${modScene} has an id of ${modSceneID}." + } + } + } + + def theBulbs = state.bulbs + def modBulbsNames = [] + def bulbID + def theBulbName + theBulbs?.each { + bulbID = it.key + theBulbName = state.bulbs[bulbID].name as String + modBulbsNames << [name: theBulbName, id:bulbID] + } + + state.modBulbNames = modBulbsNames + log.trace "modBulbsNames = ${modBulbsNames}." + + def modTheLightsID = [] + if (modTheLights) { + + modTheLights.each { v -> + modBulbsNames.each { m -> + if (m.name == v) { + modTheLightsID << m.id + } + } + } + } + + if (modTheLightsID) {log.debug "The Selected Lights ${modTheLights} have ids of ${modTheLightsID}."} + + if (modTheLightsID && modSceneID) { + + def body = [sceneID: modSceneID] + if (modSceneName) {body.name = modSceneName} + if (modTheLightsID) {body.lights = modTheLightsID} + + log.debug "***************The body for updateScene() will be ${body}." + + if ( modSceneConfirmed == "Yes" ) { + + // update Scene lights and settings on Hue Hub + updateScene(body) + + // update Scene lights on ST device + def dni = app.id + "/" + modSceneID + "s" + if (modTheLightsID) { sendEvent(dni, [name: "lights", value: modTheLightsID]) } + + // reset page + + app.updateSetting("modSceneConfirmed", null) + app.updateSetting("modSceneName", "") + app.updateSetting("modTheLights", null) + app.updateSetting("modScene", null) + // pause(300) + // app.updateSetting("modSceneID", null) + // app.updateSetting("modTheLightsID", null) + + state.sChange = true + } + } + + + + return dynamicPage(name:"modifyScene", title:"Modify Scene", nextPage:"sceneDiscovery", refreshInterval:3, install:false, uninstall: false) { + section("Choose Scene to Modify") { + input "modScene", "enum", title: "Modify Scene:", required: true, multiple: false, submitOnChange: true, options:modSceneOptions.name.sort() {it.value} + + if (modScene) { + + input "modTheLights", "enum", title: "Choose The Lights You Want In This Scene (reflected within Hue Hub and on ST Scene device", required: true, multiple: true, submitOnChange: true, options:modBulbsNames.name.sort() + + if (modTheLights) { + input "modSceneName", "text", title: "Change Scene Name (Reflected ONLY within Hue Hub - ST only allows name / label change via mobile app or IDE ).", required: false, submitOnChange: true + + paragraph "ATTENTION: Clicking Yes below will IMMEDIATELY set the ${modScene} scene to selected lights' current configuration." + + input "modSceneConfirmed", "enum", title: "Are you sure?", required: true, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true + + } + } + } + } +} + +def changeSceneName(params=[:]) { + + + def theScenes = [] + theScenes = state.scenes + + def renameSceneOptions = [] + def scID + def theSceneName + theScenes?.each { + scID = it.key + theSceneName = state.scenes[scID].name as String + renameSceneOptions << [name:theSceneName, id:scID] + } + + state.renameSceneOptions = renameSceneOptions + log.trace "renameSceneOptions = ${renameSceneOptions}." + + def renameSceneID + if (oldSceneName) { + state.renameSceneOptions.each { m -> + if (oldSceneName == m.name) { + renameSceneID = m.id + log.debug "The selected scene ${oldSceneName} has an id of ${renameSceneID}." + } + } + } + + if (renameSceneID && newSceneName) { + + def body = [sceneID: renameSceneID, name: newSceneName] + + log.debug "***************The body for renameScene() will be ${body}." + + if ( renameSceneConfirmed == "Yes" ) { + + // rename scene on Hue Hub + renameScene(body) + + // refresh page + app.updateSetting("renameSceneConfirmed", null) + app.updateSetting("newSceneName", "") + app.updateSetting("oldSceneName", null) + app.updateSetting("renameSceneID", null) + + state.sChange = true + } + } + + + + return dynamicPage(name:"changeSceneName", title:"Change Scene Name", nextPage:"sceneDiscovery", refreshInterval:3, install:false, uninstall: false) { + section("Change NAME of which Scene ") { + input "oldSceneName", "enum", title: "Change Scene:", required: true, multiple: false, submitOnChange: true, options:renameSceneOptions.name.sort() {it.value} + + if (oldSceneName) { + + input "newSceneName", "text", title: "Change Scene Name to (click enter when done): ", required: false, submitOnChange: true + + paragraph "ATTENTION: Clicking Yes below will IMMEDIATELY CHANGE the name of ${oldSceneName} to ${newSceneName}!!!" + + input "renameSceneConfirmed", "enum", title: "Are you sure?", required: true, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true + + } + + } + } +} + + + + + +def removeScene(params=[:]) { + + + def theScenes = [] + theScenes = state.scenes // scenesDiscovered() ?: [] + + def remSceneOptions = [] + def scID + def theSceneName + theScenes?.each { + scID = it.key + theSceneName = state.scenes[scID].name as String + remSceneOptions << [name:theSceneName, id:scID] + } + + state.remSceneOptions = remSceneOptions + log.trace "remSceneOptions = ${remSceneOptions}." + + def remSceneID + if (remScene) { + state.remSceneOptions.each { m -> + if (remScene == m.name) { + remSceneID = m.id + log.debug "The selected scene ${remScene} has an id of ${remSceneID}." + } + } + } + + if (remSceneID) { + + def body = [sceneID: remSceneID] + + log.debug "***************The body for deleteScene() will be ${body}." + + if ( remSceneConfirmed == "Yes" ) { + + // delete scene on Hue Buh + deleteScene(body) + log.trace "${remScene} found and deleted from Hue Hub." + + // try deleting child scene from ST + def dni = app.id + "/" + renameSceneID + "s" + try { + deleteChildDevice(dni) + log.trace "${remScene} found and successfully deleted from ST." + } catch (e) { + log.debug "${remScene} not found within ST - no action taken." + } + + // refresh page + app.updateSetting("remSceneConfirmed", null) + app.updateSetting("remScene", null) + app.updateSetting("remSceneID", null) + + state.sChange = true + } + } + + + + return dynamicPage(name:"removeScene", title:"Delete Scene", nextPage:"sceneDiscovery", refreshInterval:3, install:false, uninstall: false) { + section("Choose Scene to DELETE") { + input "remScene", "enum", title: "Delete Scene:", required: true, multiple: false, submitOnChange: true, options:remSceneOptions.name.sort() {it.value} + + if (remScene) { + + paragraph "ATTENTION: Clicking Yes below will IMMEDIATELY DELETE the ${remScene} scene FOREVER!!!" + + input "remSceneConfirmed", "enum", title: "Are you sure?", required: true, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true + + } + + } + } +} + + + +def initComplete(){ + if (state.initialized){ + return "complete" + } else { + return null + } +} + + +def defaultTransition() +{ + int sceneRefreshCount = !state.sceneRefreshCount ? 0 : state.sceneRefreshCount as int + state.sceneRefreshCount = sceneRefreshCount + 1 + def refreshInterval = 3 + + return dynamicPage(name:"defaultTransition", title:"Default Transition", nextPage:"", refreshInterval:refreshInterval, install:true, uninstall: true) { + section("Choose how long bulbs should take to transition between on/off and color changes. This can be modified per-device.") { + input "selectedTransition", "number", required:true, title:"Transition Time (seconds)", value: 1 + } + } +} + + + + + + +private discoverBridges() { + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:basic:1", physicalgraph.device.Protocol.LAN)) +} + +private sendDeveloperReq() { + def token = app.id + sendHubCommand(new physicalgraph.device.HubAction([ + method: "POST", + path: "/api", + headers: [ + HOST: bridgeHostnameAndPort + ], + body: [devicetype: "$token-0"]], bridgeDni)) +} + +private discoverHueBulbs() { + log.trace "discoverHueBulbs REACHED" + def host = getBridgeIP() + sendHubCommand(new physicalgraph.device.HubAction([ + method: "GET", + path: "/api/${state.username}/lights", + headers: [ + HOST: bridgeHostnameAndPort + ]], bridgeDni)) +} + +private discoverHueGroups() { + log.trace "discoverHueGroups REACHED" + def host = getBridgeIP() + sendHubCommand(new physicalgraph.device.HubAction([ + method: "GET", + path: "/api/${state.username}/groups", + headers: [ + HOST: bridgeHostnameAndPort + ]], "${selectedHue}")) +} + +private discoverHueScenes() { + log.trace "discoverHueScenes REACHED" + def host = getBridgeIP() + sendHubCommand(new physicalgraph.device.HubAction([ + method: "GET", + path: "/api/${state.username}/scenes", + headers: [ + HOST: bridgeHostnameAndPort + ]], "${selectedHue}")) +} + +private verifyHueBridge(String deviceNetworkId) { + log.trace "verifyHueBridge($deviceNetworkId)" + sendHubCommand(new physicalgraph.device.HubAction([ + method: "GET", + path: "/description.xml", + headers: [ + HOST: ipAddressFromDni(deviceNetworkId) + ]], "${selectedHue}")) +} + +private verifyHueBridges() { + def devices = getHueBridges().findAll { it?.value?.verified != true } + log.debug "UNVERIFIED BRIDGES!: $devices" + devices.each { + verifyHueBridge((it?.value?.ip + ":" + it?.value?.port)) + } +} + +Map bridgesDiscovered() { + def vbridges = getVerifiedHueBridges() + def map = [:] + vbridges.each { + def value = "${it.value.name}" + def key = it.value.ip + ":" + it.value.port + map["${key}"] = value + } + map +} + +Map bulbsDiscovered() { + def bulbs = getHueBulbs() + def map = [:] + if (bulbs instanceof java.util.Map) { + bulbs.each { + def value = "${it?.value?.name}" + def key = app.id +"/"+ it?.value?.id + map["${key}"] = value + } + } else { //backwards compatable + bulbs.each { + def value = "${it?.name}" + def key = app.id +"/"+ it?.id + map["${key}"] = value + } + } + map +} + +Map groupsDiscovered() { + def groups = getHueGroups() + def map = [:] + if (groups instanceof java.util.Map) { + groups.each { + def value = "${it?.value?.name}" + def key = app.id +"/"+ it?.value?.id + "g" + map["${key}"] = value + } + } else { //backwards compatable + groups.each { + def value = "${it?.name}" + def key = app.id +"/"+ it?.id + "g" + map["${key}"] = value + } + } + map +} + +Map scenesDiscovered() { + def scenes = getHueScenes() + def map = [:] + if (scenes instanceof java.util.Map) { + scenes.each { + def value = "${it?.value?.name}" + def key = app.id +"/"+ it?.value?.id + "s" + map["${key}"] = value + } + } else { //backwards compatable + scenes.each { + def value = "${it?.name}" + def key = app.id +"/"+ it?.id + "s" + map["${key}"] = value + } + } + map +} + +def getHueBulbs() { + + log.debug "HUE BULBS:" + state.bulbs = state.bulbs ?: [:] // discoverHueBulbs() // +} + +def getHueGroups() { + + log.debug "HUE GROUPS:" + state.groups = state.groups ?: [:] // discoverHueGroups() // +} + +def getHueScenes() { + + log.debug "HUE SCENES:" + state.scenes = state.scenes ?: [:] // discoverHueScenes() // +} + +def getHueBridges() { + state.bridges = state.bridges ?: [:] +} + +def getVerifiedHueBridges() { + getHueBridges().findAll{ it?.value?.verified == true } +} + +def installed() { + log.trace "Installed with settings: ${settings}" + initialize() +} + +def updated() { + log.trace "Updated with settings: ${settings}" + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + // remove location subscription aftwards + log.debug "INITIALIZE" + state.initialized = true + state.subscribe = false + state.bridgeSelectedOverride = false + + if (selectedHue) { + addBridge() + } + if (selectedBulbs) { + addBulbs() + } + if (selectedGroups) + { + addGroups() + } + if (selectedScenes) + { + addScenes() + } +/** if (selectedHue) { + def bridge = getChildDevice(selectedHue) + subscribe(bridge, "bulbList", bulbListHandler) + subscribe(bridge, "groupList", groupListHandler) + subscribe(bridge, "sceneList", sceneListHandler) + } +**/ + runEvery5Minutes("doDeviceSync") + doDeviceSync() +} + +def manualRefresh() { + unschedule() + unsubscribe() + doDeviceSync() + runEvery5Minutes("doDeviceSync") +} + +def uninstalled(){ + state.bridges = [:] + state.username = null +} + +// Handles events to add new bulbs +def bulbListHandler(evt) { + def bulbs = [:] + log.trace "Adding bulbs to state..." + //state.bridgeProcessedLightList = true + evt.jsonData.each { k,v -> + log.trace "$k: $v" + if (v instanceof Map) { + bulbs[k] = [id: k, name: v.name, type: v.type, hub:evt.value] + } + } + state.bulbs = bulbs + log.info "${bulbs.size()} bulbs found" +} + +def groupListHandler(evt) { + def groups =[:] + log.trace "Adding groups to state..." + state.bridgeProcessedGroupList = true + evt.jsonData.each { k,v -> + log.trace "$k: $v" + if (v instanceof Map) { + groups[k] = [id: k, name: v.name, type: v.type, lights: v.lights, hub:evt.value] + } + } + state.groups = groups + log.info "${groups.size()} groups found" +} + +def sceneListHandler(evt) { + def scenes =[:] + log.trace "Adding scenes to state..." + state.bridgeProcessedSceneList = true + evt.jsonData.each { k,v -> + log.trace "$k: $v" + if (v instanceof Map) { + scenes[k] = [id: k, name: v.name, type: "Scene", lights: v.lights, hub:evt.value] + } + } + state.scenes = scenes + log.info "${scenes.size()} scenes found" +} + +def addBulbs() { + def bulbs = getHueBulbs() + selectedBulbs.each { dni -> + def d = getChildDevice(dni) + if(!d) { + def newHueBulb + if (bulbs instanceof java.util.Map) { + newHueBulb = bulbs.find { (app.id + "/" + it.value.id) == dni } + if (newHueBulb?.value?.type?.equalsIgnoreCase("Dimmable light")) { + d = addChildDevice("info_fiend", "AP Hue Lux Bulb", dni, newHueBulb?.value.hub, ["label":newHueBulb?.value.name]) + d.initialize(newHueBulb?.value.id) + } else { + d = addChildDevice("info_fiend", "AP Hue Bulb", dni, newHueBulb?.value.hub, ["label":newHueBulb?.value.name]) + d.initialize(newHueBulb?.value.id) + } + d.refresh() + } else { + //backwards compatable + newHueBulb = bulbs.find { (app.id + "/" + it.id) == dni } + d = addChildDevice("info_fiend", "AP Hue Bulb", dni, newHueBulb?.hub, ["label":newHueBulb?.name]) + d.initialize(newHueBulb?.id) + } + + log.debug "created ${d.displayName} with id $dni" + d.refresh() + } else { + log.debug "found ${d.displayName} with id $dni already exists, type: '$d.typeName'" + if (bulbs instanceof java.util.Map) { + def newHueBulb = bulbs.find { (app.id + "/" + it.value.id) == dni } + if (newHueBulb?.value?.type?.equalsIgnoreCase("Dimmable light") && d.typeName == "Hue Bulb") { + d.setDeviceType("AP Hue Lux Bulb") + d.initialize(newHueBulb?.value.id) + } + } + } + } +} + +def addGroups() { + def groups = getHueGroups() + selectedGroups.each { dni -> + def d = getChildDevice(dni) + if(!d) + { + def newHueGroup + if (groups instanceof java.util.Map) + { + newHueGroup = groups.find { (app.id + "/" + it.value.id + "g") == dni } + d = addChildDevice("info_fiend", "AP Hue Group", dni, newHueGroup?.value.hub, ["label":newHueGroup?.value.name, "groupID":newHueGroup?.value.id]) + d.initialize(newHueGroup?.value.id) + } + + log.debug "created ${d.displayName} with id $dni" + d.refresh() + } + else + { + log.debug "found ${d.displayName} with id $dni already exists, type: '$d.typeName'" + } + } +} + +def addScenes() { + def scenes = getHueScenes() + selectedScenes.each { dni -> + def d = getChildDevice(dni) + if(!d) + { + def newHueScene + if (scenes instanceof java.util.Map) + { + newHueScene = scenes.find { (app.id + "/" + it.value.id + "s") == dni } + d = addChildDevice("info_fiend", "AP Hue Scene", dni, newHueScene?.value.hub, ["label":newHueScene?.value.name, "sceneID":newHueScene?.value.id]) + } + + log.debug "created ${d.displayName} with id $dni" + d.refresh() + } + else + { + log.debug "found ${d.displayName} with id $dni already exists, type: 'Scene'" + } + } +} + +def addBridge() { + def vbridges = getVerifiedHueBridges() + def vbridge = vbridges.find {(it.value.ip + ":" + it.value.port) == selectedHue} + + if(vbridge) { + def d = getChildDevice(selectedHue) + if(!d) { + d = addChildDevice("info_fiend", "AP Hue Bridge", selectedHue, vbridge.value.hub, ["data":["mac": vbridge.value.mac]]) // ["preferences":["ip": vbridge.value.ip, "port":vbridge.value.port, "path":vbridge.value.ssdpPath, "term":vbridge.value.ssdpTerm]] + + log.debug "created ${d.displayName} with id ${d.deviceNetworkId}" + + sendEvent(d.deviceNetworkId, [name: "networkAddress", value: convertHexToIP(vbridge.value.ip) + ":" + convertHexToInt(vbridge.value.port)]) + sendEvent(d.deviceNetworkId, [name: "serialNumber", value: vbridge.value.serialNumber]) + } + else + { + log.debug "found ${d.displayName} with id $dni already exists" + } + } +} + +def locationHandler(evt) { + log.info "LOCATION HANDLER: $evt.description" + def description = evt.description + def hub = evt?.hubId + + def parsedEvent = parseEventMessage(description) + parsedEvent << ["hub":hub] + + if (parsedEvent?.ssdpTerm?.contains("urn:schemas-upnp-org:device:basic:1")) + { //SSDP DISCOVERY EVENTS + log.trace "SSDP DISCOVERY EVENTS" + def bridges = getHueBridges() + + if (!(bridges."${parsedEvent.ssdpUSN.toString()}")) + { //bridge does not exist + log.trace "Adding bridge ${parsedEvent.ssdpUSN}" + bridges << ["${parsedEvent.ssdpUSN.toString()}":parsedEvent] + } + else + { // update the values + + log.debug "Device was already found in state..." + + def d = bridges."${parsedEvent.ssdpUSN.toString()}" + def host = parsedEvent.ip + ":" + parsedEvent.port + if(d.ip != parsedEvent.ip || d.port != parsedEvent.port || host != state.hostname) { + + log.debug "Device's port or ip changed..." + state.hostname = host + d.ip = parsedEvent.ip + d.port = parsedEvent.port + d.name = "Philips hue ($bridgeHostname)" + + app.updateSetting("selectedHue", host) + + childDevices.each { + if (it.getDeviceDataByName("mac") == parsedEvent.mac) { + log.debug "updating dni for device ${it} with mac ${parsedEvent.mac}" + it.setDeviceNetworkId((parsedEvent.ip + ":" + parsedEvent.port)) //could error if device with same dni already exists + doDeviceSync() + } + } + } + } + } + else if (parsedEvent.headers && parsedEvent.body) + { // HUE BRIDGE RESPONSES + log.trace "HUE BRIDGE RESPONSES" + def headerString = new String(parsedEvent.headers.decodeBase64()) + def bodyString = new String(parsedEvent.body.decodeBase64()) + def type = (headerString =~ /Content-type:.*/) ? (headerString =~ /Content-type:.*/)[0] : null + def body + + if (type?.contains("xml")) + { // description.xml response (application/xml) + body = new XmlSlurper().parseText(bodyString) + + if (body?.device?.modelName?.text().startsWith("Philips hue bridge")) + { + def bridges = getHueBridges() + def bridge = bridges.find {it?.key?.contains(body?.device?.UDN?.text())} + if (bridge) + { + bridge.value << [name:body?.device?.friendlyName?.text(), serialNumber:body?.device?.serialNumber?.text(), verified: true] + } + else + { + log.error "/description.xml returned a bridge that didn't exist" + } + } + } + else if(type?.contains("json") && isValidSource(parsedEvent.mac)) + { //(application/json) + body = new groovy.json.JsonSlurper().parseText(bodyString) + + if (body?.success != null) + { //POST /api response (application/json) + if (body?.success?.username) + { + state.username = body.success.username[0] + state.hostname = selectedHue + } + } + else if (body.error != null) + { + //TODO: handle retries... + log.error "ERROR: application/json ${body.error}" + } + else + { //GET /api/${state.username}/lights response (application/json) + log.debug "HERE" + if (!body.action) + { //check if first time poll made it here by mistake + + if(!body?.type?.equalsIgnoreCase("LightGroup") || !body?.type?.equalsIgnoreCase("Room")) + { + log.debug "LIGHT GROUP!!!" + } + + def bulbs = getHueBulbs() + def groups = getHueGroups() + def scenes = getHueScenes() + + log.debug "Adding bulbs, groups, and scenes to state!" + body.each { k,v -> + log.debug v.type + if(v.type == "LightGroup" || v.type == "Room") + { + groups[k] = [id: k, name: v.name, type: v.type, hub:parsedEvent.hub] + } + else if (v.type == "Extended color light" || v.type == "Color light" || v.type == "Dimmable light" ) + { + bulbs[k] = [id: k, name: v.name, type: v.type, hub:parsedEvent.hub] + } + else + { + scenes[k] = [id: k, name: v.name, type: "Scene", hub:parsedEvent.hub] + } + } + } + } + } + } + else { + log.trace "NON-HUE EVENT $evt.description" + } +} + +private def parseEventMessage(Map event) { + //handles bridge attribute events + return event +} + +private def parseEventMessage(String description) { + def event = [:] + def parts = description.split(',') + parts.each { part -> + part = part.trim() + if (part.startsWith('devicetype:')) { + def valueString = part.split(":")[1].trim() + event.devicetype = valueString + } + else if (part.startsWith('mac:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.mac = valueString + } + } + else if (part.startsWith('networkAddress:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.ip = valueString + } + } + else if (part.startsWith('deviceAddress:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.port = valueString + } + } + else if (part.startsWith('ssdpPath:')) { + def valueString = part.split(":")[1].trim() + if (valueString) { + event.ssdpPath = valueString + } + } + else if (part.startsWith('ssdpUSN:')) { + part -= "ssdpUSN:" + def valueString = part.trim() + if (valueString) { + event.ssdpUSN = valueString + } + } + else if (part.startsWith('ssdpTerm:')) { + part -= "ssdpTerm:" + def valueString = part.trim() + if (valueString) { + event.ssdpTerm = valueString + } + } + else if (part.startsWith('headers')) { + part -= "headers:" + def valueString = part.trim() + if (valueString) { + event.headers = valueString + } + } + else if (part.startsWith('body')) { + part -= "body:" + def valueString = part.trim() + if (valueString) { + event.body = valueString + } + } + } + + event +} + +def doDeviceSync(){ + log.trace "Doing Hue Device Sync!" + + //shrink the large bulb lists + convertBulbListToMap() + convertGroupListToMap() + convertSceneListToMap() + + poll() + try { + subscribe(location, null, locationHandler, [filterEvents:false]) + } catch (all) { + log.trace "Subscription already exist" + } + discoverBridges() +} + +def isValidSource(macAddress) { + def vbridges = getVerifiedHueBridges() + return (vbridges?.find {"${it.value.mac}" == macAddress}) != null +} + +///////////////////////////////////// +//CHILD DEVICE METHODS +///////////////////////////////////// + +def parse(childDevice, description) { + def parsedEvent = parseEventMessage(description) + + if (parsedEvent.headers && parsedEvent.body) { + def headerString = new String(parsedEvent.headers.decodeBase64()) + def bodyString = new String(parsedEvent.body.decodeBase64()) + childDevice?.log "parse() - ${bodyString}" + def body = new groovy.json.JsonSlurper().parseText(bodyString) +// childDevice?.log "BODY - $body" + + if (body instanceof java.util.HashMap) { // POLL RESPONSE + + def devices = getChildDevices() + + // BULBS + for (bulb in body) { + def d = devices.find{it.deviceNetworkId == "${app.id}/${bulb.key}"} + if (d) { + if (bulb.value.type == "Extended color light" || bulb.value.type == "Color light" || bulb.value.type == "Dimmable light") { + log.trace "Reading Poll for Lights" + if (bulb.value.state.reachable) { + sendEvent(d.deviceNetworkId, [name: "switch", value: bulb.value?.state?.on ? "on" : "off"]) + sendEvent(d.deviceNetworkId, [name: "level", value: Math.round(bulb.value?.state?.bri * 100 / 255)]) + if (bulb.value.state.sat) { + def hue = Math.min(Math.round(bulb.value?.state?.hue * 100 / 65535), 65535) as int + def sat = Math.round(bulb.value?.state?.sat * 100 / 255) as int + def hex = colorUtil.hslToHex(hue, sat) + sendEvent(d.deviceNetworkId, [name: "color", value: hex]) + sendEvent(d.deviceNetworkId, [name: "hue", value: hue]) + sendEvent(d.deviceNetworkId, [name: "saturation", value: sat]) + } + if (bulb.value.state.ct) { + def ct = mireksToKelvin(bulb.value?.state?.ct) as int + sendEvent(d.deviceNetworkId, [name: "colorTemperature", value: ct]) + } + if (bulb.value.state.effect) { sendEvent(d.deviceNetworkId, [name: "effect", value: bulb.value?.state?.effect]) } + if (bulb.value.state.colormode) { sendEvent(d.deviceNetworkId, [name: "colormode", value: bulb.value?.state?.colormode]) } + + } else { // Bulb not reachable + sendEvent(d.deviceNetworkId, [name: "switch", value: "off"]) + sendEvent(d.deviceNetworkId, [name: "level", value: 100]) + def hue = 23 + def sat = 56 + def hex = colorUtil.hslToHex(23, 56) + sendEvent(d.deviceNetworkId, [name: "color", value: hex]) + sendEvent(d.deviceNetworkId, [name: "hue", value: hue]) + sendEvent(d.deviceNetworkId, [name: "saturation", value: sat]) + def ct = 2710 + sendEvent(d.deviceNetworkId, [name: "colorTemperature", value: ct]) + sendEvent(d.deviceNetworkId, [name: "effect", value: "none"]) + sendEvent(d.deviceNetworkId, [name: "colormode", value: hs] ) + } + } + } + } + +// devices = getChildDevices() + + // GROUPS + for (bulb in body) { + def g = devices.find{it.deviceNetworkId == "${app.id}/${bulb.key}g"} + if (g) { + if(bulb.value.type == "LightGroup" || bulb.value.type == "Room" || bulb.value.type == "Luminaire" || bulb.value.type == "Lightsource" ) { + log.trace "Reading Poll for Groups" + + sendEvent(g.deviceNetworkId, [name: "name", value: bulb.value?.name ]) + sendEvent(g.deviceNetworkId, [name: "switch", value: bulb.value?.action?.on ? "on" : "off"]) + sendEvent(g.deviceNetworkId, [name: "level", value: Math.round(bulb.value?.action?.bri * 100 / 255)]) + sendEvent(g.deviceNetworkId, [name: "lights", value: bulb.value?.lights ]) + + if (bulb.value.action.sat) { + def hue = Math.min(Math.round(bulb.value?.action?.hue * 100 / 65535), 65535) as int + def sat = Math.round(bulb.value?.action?.sat * 100 / 255) as int + def hex = colorUtil.hslToHex(hue, sat) + sendEvent(g.deviceNetworkId, [name: "color", value: hex]) + sendEvent(g.deviceNetworkId, [name: "hue", value: hue]) + sendEvent(g.deviceNetworkId, [name: "saturation", value: sat]) + } + + if (bulb.value.action.ct) { + def ct = mireksToKelvin(bulb.value?.action?.ct) as int + sendEvent(g.deviceNetworkId, [name: "colorTemperature", value: ct]) + } + + if (bulb.value.action.effect) { sendEvent(g.deviceNetworkId, [name: "effect", value: bulb.value?.action?.effect]) } + if (bulb.value.action.alert) { sendEvent(g.deviceNetworkId, [name: "alert", value: bulb.value?.action?.alert]) } + if (bulb.value.action.transitiontime) { sendEvent(g.deviceNetworkId, [name: "transitiontime", value: bulb.value?.action?.transitiontime ?: 0]) } + if (bulb.value.action.colormode) { sendEvent(g.deviceNetworkId, [name: "colormode", value: bulb.value?.action?.colormode]) } + } + } + } + + // SCENES + for (bulb in body) { + def sc = devices.find{it.deviceNetworkId == "${app.id}/${bulb.key}s"} + if (sc) { + if ( !bulb.value.type || bulb.value.type == "Scene" || bulb.value.recycle ) { + log.trace "Reading Poll for Scene" + sendEvent(sc.deviceNetworkId, [ name: "name", value: bulb.value?.name ]) + sendEvent(sc.deviceNetworkId, [ name: "lights", value: bulb.value?.lights ]) + } + } + } + + } else { // PUT RESPONSE + def hsl = [:] + body.each { payload -> + childDevice?.log $payload + if (payload?.success) { + + def childDeviceNetworkId = app.id + "/" + def eventType + body?.success[0].each { k,v -> + log.trace "********************************************************" + log.trace "********************************************************" + if (k.split("/")[1] == "groups") { + childDeviceNetworkId += k.split("/")[2] + "g" + } else if (k.split("/")[1] == "scenes") { + childDeviceNetworkId += k.split("/")[2] + "s" + } else { + childDeviceNetworkId += k.split("/")[2] + } + + if (!hsl[childDeviceNetworkId]) hsl[childDeviceNetworkId] = [:] + + eventType = k.split("/")[4] + childDevice?.log "eventType: $eventType" + switch(eventType) { + case "on": + sendEvent(childDeviceNetworkId, [name: "switch", value: (v == true) ? "on" : "off"]) + break + case "bri": + sendEvent(childDeviceNetworkId, [name: "level", value: Math.round(v * 100 / 255)]) + break + case "sat": + hsl[childDeviceNetworkId].saturation = Math.round(v * 100 / 255) as int + break + case "hue": + hsl[childDeviceNetworkId].hue = Math.min(Math.round(v * 100 / 65535), 65535) as int + break + case "ct": + sendEvent(childDeviceNetworkId, [name: "colorTemperature", value: mireksToKelvin(v)]) + break + case "effect": + sendEvent(childDeviceNetworkId, [name: "effect", value: v]) + break + case "colormode": + sendEvent(childDeviceNetworkId, [name: "colormode", value: v]) + break + case "lights": + sendEvent(childDeviceNetworkId, [name: "lights", value: v]) + break + case "transitiontime": + sendEvent(childDeviceNetworkId, [name: "transitiontime", value: v ]) // ?: getSelectedTransition() + break + } + } + + } else if (payload.error) { + childDevice?.error "JSON error - ${body?.error}" + } + + } + + hsl.each { childDeviceNetworkId, hueSat -> + if (hueSat.hue && hueSat.saturation) { + def hex = colorUtil.hslToHex(hueSat.hue, hueSat.saturation) + childDevice?.log "sending ${hueSat} for ${childDeviceNetworkId} as ${hex}" + sendEvent(hsl.childDeviceNetworkId, [name: "color", value: hex]) + } + } + } + } else { // SOME OTHER RESPONSE + childDevice?.log "parse - got something other than headers,body..." + return [] + } +} + + +def hubVerification(bodytext) { + childDevice?.trace "Bridge sent back description.xml for verification" + def body = new XmlSlurper().parseText(bodytext) + if (body?.device?.modelName?.text().startsWith("Philips hue bridge")) { + def bridges = getHueBridges() + def bridge = bridges.find {it?.key?.contains(body?.device?.UDN?.text())} + if (bridge) { + bridge.value << [name:body?.device?.friendlyName?.text(), serialNumber:body?.device?.serialNumber?.text(), verified: true] + } else { + childDevice?.error "/description.xml returned a bridge that didn't exist" + } + } +} +def setTransitionTime ( childDevice, transitionTime, deviceType ) { + childDevice?.log "HLGS: Executing 'setTransitionTime'" + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = "groups" + deviceID = deviceID - "g" + } + def path = dType + "/" + deviceID + "/" + api + childDevice?.log "HLGS: 'transitionTime' path is $path" + + def value = [transitiontime: transitionTime * 10] + + childDevice?.log "HLGS: sending ${value}." + put( path, value ) + +} + +def on( childDevice, percent, transitionTime, deviceType ) { + childDevice?.log "HLGS: Executing 'on'" + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = "groups" + deviceID = deviceID - "g" + } + def level = Math.min(Math.round(percent * 255 / 100), 255) + + def path = dType + "/" + deviceID + "/" + api + childDevice?.log "HLGS: 'on' path is $path" + + def value = [on: true, bri: level, transitiontime: transitionTime * 10 ] + + childDevice?.log "HLGS: sending 'on' using ${value}." + put( path, value ) +} + +def off( childDevice, transitionTime, deviceType ) { + childDevice?.log "HLGS: Executing 'off'" + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = "groups" + deviceID = deviceID - "g" + } + def path = dType + "/" + deviceID + "/" + api + childDevice?.log "HLGS: 'off' path is ${path}." + def value = [on: false, transitiontime: transitionTime * 10 ] + childDevice?.log "HLGS: sending 'off' using ${value}." + + put( path, value ) +} + +def setLevel( childDevice, percent, transitionTime, deviceType ) { + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = deviceType + deviceID = deviceID - "g" + } + def level = Math.min(Math.round(percent * 255 / 100), 255) + def value = [bri: level, on: percent > 0, transitiontime: transitionTime * 10 ] + def path = dType + "/" + deviceID + "/" + api + childDevice?.log "HLGS: 'on' path is $path" + childDevice?.log "HLGS: Executing 'setLevel($percent).'" + put( path, value) +} + +def setSaturation(childDevice, percent, transitionTime, deviceType) { + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + + if(deviceType == "groups") { + api = "action" + dType = deviceType + deviceID = deviceID - "g" + } + def path = dType + "/" + deviceID + "/" + api + + def level = Math.min(Math.round(percent * 255 / 100), 255) + + childDevice?.log "HLGS: Executing 'setSaturation($percent).'" + put( path, [sat: level, transitiontime: transitionTime * 10 ]) +} + +def setHue(childDevice, percent, transitionTime, deviceType ) { + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = "groups" + deviceID = deviceID - "g" + } + + def path = dType + "/" + deviceID + "/" + api + + childDevice?.log "HLGS: Executing 'setHue($percent)'" + def level = Math.min(Math.round(percent * 65535 / 100), 65535) + put( path, [hue: level, transitiontime: transitionTime * 10 ]) +} + +def setColorTemperature(childDevice, huesettings, transitionTime, deviceType ) { + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = "groups" + deviceID = deviceID - "g" + } + + def path = dType + "/" + deviceID + "/" + api + + childDevice?.log "HLGS: Executing 'setColorTemperature($huesettings)'" + def value = [on: true, ct: kelvinToMireks(huesettings), transitiontime: transitionTime * 10 ] + + put( path, value ) +} + +def setColor(childDevice, huesettings, transitionTime, deviceType ) { + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = deviceType + deviceID = deviceID - "g" + } + def path = dType + "/" + deviceID + "/" + api + + def value = [:] + def hue = null + def sat = null + def xy = null + + if (huesettings.hex != null) { + value.xy = getHextoXY(huesettings.hex) + } else { + if (huesettings.hue != null) + value.hue = Math.min(Math.round(huesettings.hue * 65535 / 100), 65535) + if (huesettings.saturation != null) + value.sat = Math.min(Math.round(huesettings.saturation * 255 / 100), 255) + } + + if (huesettings.level > 0 || huesettings.switch == "on") { + value.on = true + } else if (huesettings.switch == "off") { + value.on = false + } + value.transitiontime = transitionTime * 10 + value.bri = Math.min(Math.round(huesettings.level * 255 / 100), 255) + value.alert = huesettings.alert ? huesettings.alert : "none" + + childDevice?.log "HLGS: Executing 'setColor($value).'" + put( path, value) + +// return "Color set to $value" +} + +def setGroupScene(childDevice, Number inGroupID) { + childDevice?.log "HLGS: Executing setGroupScene with inGroupID of ${inGroupID}." + def sceneID = getId(childDevice) // - "s" + def groupID = inGroupID ?: "0" + childDevice?.log "HLGS: setGroupScene scene is ${sceneID} " + String path = "groups/${groupID}/action/" + + childDevice?.log "Path = ${path} " + + put( path, [scene: sceneID]) +} + +def setToGroup(childDevice, Number inGroupID ) { + childDevice?.log "HLGS: setToGroup with inGroupID of ${inGroupID}." + def sceneID = getId(childDevice) - "s" + def groupID = inGroupID ?: "0" + + childDevice?.log "HLGS: setToGroup: sceneID = ${sceneID} " + String gPath = "groups/${groupID}/action/" + + childDevice?.log "Group path = ${gPath} " + + put( gPath, [scene: sceneID]) +} + +/** +def nextLevel(childDevice) { + def level = device.latestValue("level") as Integer ?: 0 + if (level < 100) { + level = Math.min(25 * (Math.round(level / 25) + 1), 100) as Integer + } else { level = 25 } + setLevel(childDevice, level) +} +**/ + +def getId(childDevice) { + childDevice?.log "HLGS: Executing getId" + if (childDevice.device?.deviceNetworkId?.startsWith("Hue") || childDevice.device?.deviceNetworkId?.startsWith("AP Hue") ) { + childDevice?.log "Device ID returned is ${childDevice.device?.deviceNetworkId[3..-1]}." + return childDevice.device?.deviceNetworkId[3..-1] + } else { + childDevice?.log "Device ID returned (based on SPLIT '/') is ${childDevice.device?.deviceNetworkId.split("/")[-1]}." + return childDevice.device?.deviceNetworkId.split("/")[-1] + } +} + + +def updateSceneFromDevice(childDevice) { + childDevice?.log "HLGS: updateSceneFromDevice: Scene ${childDevice} requests scene use current light states." + def sceneID = getId(childDevice) - "s" + + childDevice?.log "HLGS: updateScene: sceneID = ${sceneID} " + String path = "scenes/${sceneID}/" + + def value = [storelightstate: true] + childDevice?.log "Path = ${path} " + + put( path, value ) + +} + +def updateScene(body) { + log.trace "HLGS: updateScene " + def sceneID = body.sceneID + + String path = "scenes/${sceneID}/" + def value = [lights: body.lights, storelightstate: true] + + if (body.name) { + value.name = body.name + } + + log.trace "HLGS: updateScene: Path = ${path} & body = ${value}" + + put( path, value ) + + app.updateSetting("modSceneConfirmed", null) + app.updateSetting("modSceneName", "") + app.updateSetting("modTheLights", []) + app.updateSetting("modScene", null) + state.updateScene == null +// state.scenes = [] + +} + +def renameScene(body) { + log.trace "HLGS: renameScene " + def sceneID = body.sceneID + + String path = "scenes/${sceneID}/" + + def value = body.name + + + log.trace "HLGS: renameScene: Path = ${path} & body = ${value}" + + put( path, value ) + + app.updateSetting("renameSceneConfirmed", null) + app.updateSetting("renameSceneName", "") + app.updateSetting("renameScene", null) + state.renameScene == null + +// state.scenes = [] +} + + + +def deleteScene(body) { + log.trace "HLGS: deleteScene " + def host = getBridgeIP() + + + def sceneID = body.sceneID + String path = "scenes/${sceneID}/" + def uri = "/api/${state.username}/$path" + + log.trace "HLGS: deleteScene: uri = $uri" + + + sendHubCommand(new physicalgraph.device.HubAction([ + method: "DELETE", + path: uri, + headers: [ + HOST: host + ] + ],"${selectedHue}")) + + app.updateSetting("remSceneConfirmed", null) + app.updateSetting("remScene", null) + state.deleteScene == null +// state.scenes = [] + +} + +def createNewScene(body) { + log.trace "HLGS: createNewScene " + def host = getBridgeIP() + + String path = "scenes/" + def uri = "/api/${state.username}/$path" + + def newBody = [name: body.name, lights: body.lights] + + def bodyJSON = new groovy.json.JsonBuilder(newBody).toString() + + log.trace "HLGS: createNewScene: POST: $uri" + log.trace "HLGS: createNewScene: BODY: $bodyJSON" + + + sendHubCommand(new physicalgraph.device.HubAction([ + method: "POST", + path: uri, + headers: [ + HOST: host + ], body: bodyJSON],"${selectedHue}")) + + app.updateSetting("creSceneConfirmed", null) + app.updateSetting("creSceneName", "") + app.updateSetting("creTheLights", []) + app.updateSetting("creScene", null) + state.createScene == null +// state.scenes = [] + +} + +def updateGroup(body) { + log.trace "HLGS: updateGroup " + def groupID = body.groupID + + String path = "groups/${groupID}/" + def value = [lights: body.lights] + + if (body.name) { + value.name = body.name + } + + log.trace "HLGS: updateGroup: Path = ${path} & body = ${value}" + + put( path, value ) + + app.updateSetting("modGroupConfirmed", null) + app.updateSetting("modGroupName", "") + app.updateSetting("modTheLights", []) + app.updateSetting("modGroup", null) + state.updateGroup == null +// state.groups = [] +} + +def renameGroup(body) { + log.trace "HLGS: renameGroup " + def groupID = body.groupID + + String path = "groups/${groupID}/" + def value = body.name + + + log.trace "HLGS: renameGroup: Path = ${path} & body = ${value}" + + put( path, value ) + + app.updateSetting("renameGroupConfirmed", null) + app.updateSetting("renameGroupName", "") + app.updateSetting("renameGroup", null) + state.renameGroup == null + +// state.groups = [] +} + + +def deleteGroup(body) { + log.trace "HLGS: deleteGroup " + def host = getBridgeIP() + + + def groupID = body.groupID + String path = "groups/${groupID}/" + def uri = "/api/${state.username}/$path" + + log.trace "HLGS: deleteGroup: uri = $uri" + + + sendHubCommand(new physicalgraph.device.HubAction([ + method: "DELETE", + path: uri, + headers: [ + HOST: host + ] + ],"${selectedHue}")) + + app.updateSetting("remGroupConfirmed", null) + app.updateSetting("remGroup", null) + state.deleteGroup == null +// state.groups = [] +} + +def createNewGroup(body) { + log.trace "HLGS: createNewGroup " + def host = getBridgeIP() + + String path = "groups/" + def uri = "/api/${state.username}/$path" + + body.type = "LightGroup" + def bodyJSON = new groovy.json.JsonBuilder(body).toString() + + log.trace "HLGS: createNewGroup: POST: $uri" + log.trace "HLGS: createNewGroup: BODY: $bodyJSON" + + + sendHubCommand(new physicalgraph.device.HubAction([ + method: "POST", + path: uri, + headers: [ + HOST: host + ], body: bodyJSON],"${selectedHue}")) + + app.updateSetting("creGroupConfirmed", null) + app.updateSetting("creGroupName", "") + app.updateSetting("creTheLights", []) + app.updateSetting("creGroup", null) + state.createGroup == null +// state.groups = [] + +} + + +private poll() { + def host = getBridgeIP() + + def uris = ["/api/${state.username}/lights/", "/api/${state.username}/groups/", "/api/${state.username}/scenes/"] + for (uri in uris) { + try { + sendHubCommand(new physicalgraph.device.HubAction("""GET ${uri} HTTP/1.1 + HOST: ${host} + """, physicalgraph.device.Protocol.LAN, selectedHue)) + } catch (all) { + log.warn "Parsing Body failed - trying again..." + doDeviceSync() + } + } +} + + + +private put(path, body) { + childDevice?.log "HLGS: put: path = ${path}." + def host = getBridgeIP() + def uri = "/api/${state.username}/$path" // "lights" + + if ( path.startsWith("groups") || path.startsWith("scenes")) { + uri = "/api/${state.username}/$path"[0..-1] + } + +// if (path.startsWith("scenes")) { +// uri = "/api/${state.username}/$path"[0..-1] +// } + + def bodyJSON = new groovy.json.JsonBuilder(body).toString() + + childDevice?.log "PUT: $uri" + childDevice?.log "BODY: $bodyJSON" // ${body} ? + + + sendHubCommand(new physicalgraph.device.HubAction([ + method: "PUT", + path: uri, + headers: [ + HOST: host + ], + body: bodyJSON], "${selectedHue}")) + +} + +def getBridgeDni() { + state.hostname +} + +def getBridgeHostname() { + def dni = state.hostname + if (dni) { + def segs = dni.split(":") + convertHexToIP(segs[0]) + } else { + null + } +} + +def getBridgeHostnameAndPort() { + def result = null + def dni = state.hostname + if (dni) { + def segs = dni.split(":") + result = convertHexToIP(segs[0]) + ":" + convertHexToInt(segs[1]) + } + log.trace "result = $result" + result +} + +private getBridgeIP() { + def host = null + if (selectedHue) { + def d = getChildDevice(selectedHue) + if (d) { + if (d.getDeviceDataByName("networkAddress")) + host = d.getDeviceDataByName("networkAddress") + else + host = d.latestState('networkAddress').stringValue + } + if (host == null || host == "") { + def serialNumber = selectedHue + def bridge = getHueBridges().find { it?.value?.serialNumber?.equalsIgnoreCase(serialNumber) }?.value + if (!bridge) { + bridge = getHueBridges().find { it?.value?.mac?.equalsIgnoreCase(serialNumber) }?.value + } + if (bridge?.ip && bridge?.port) { + if (bridge?.ip.contains(".")) + host = "${bridge?.ip}:${bridge?.port}" + else + host = "${convertHexToIP(bridge?.ip)}:${convertHexToInt(bridge?.port)}" + } else if (bridge?.networkAddress && bridge?.deviceAddress) + host = "${convertHexToIP(bridge?.networkAddress)}:${convertHexToInt(bridge?.deviceAddress)}" + } + log.trace "Bridge: $selectedHue - Host: $host" + } + return host +} + +private getHextoXY(String colorStr) { + // For the hue bulb the corners of the triangle are: + // -Red: 0.675, 0.322 + // -Green: 0.4091, 0.518 + // -Blue: 0.167, 0.04 + + def cred = Integer.valueOf( colorStr.substring( 1, 3 ), 16 ) + def cgreen = Integer.valueOf( colorStr.substring( 3, 5 ), 16 ) + def cblue = Integer.valueOf( colorStr.substring( 5, 7 ), 16 ) + + double[] normalizedToOne = new double[3]; + normalizedToOne[0] = (cred / 255); + normalizedToOne[1] = (cgreen / 255); + normalizedToOne[2] = (cblue / 255); + float red, green, blue; + + // Make red more vivid + if (normalizedToOne[0] > 0.04045) { + red = (float) Math.pow( + (normalizedToOne[0] + 0.055) / (1.0 + 0.055), 2.4); + } else { + red = (float) (normalizedToOne[0] / 12.92); + } + + // Make green more vivid + if (normalizedToOne[1] > 0.04045) { + green = (float) Math.pow((normalizedToOne[1] + 0.055) / (1.0 + 0.055), 2.4); + } else { + green = (float) (normalizedToOne[1] / 12.92); + } + + // Make blue more vivid + if (normalizedToOne[2] > 0.04045) { + blue = (float) Math.pow((normalizedToOne[2] + 0.055) / (1.0 + 0.055), 2.4); + } else { + blue = (float) (normalizedToOne[2] / 12.92); + } + + float X = (float) (red * 0.649926 + green * 0.103455 + blue * 0.197109); + float Y = (float) (red * 0.234327 + green * 0.743075 + blue * 0.022598); + float Z = (float) (red * 0.0000000 + green * 0.053077 + blue * 1.035763); + + float x = (X != 0 ? X / (X + Y + Z) : 0); + float y = (Y != 0 ? Y / (X + Y + Z) : 0); + + double[] xy = new double[2]; + xy[0] = x; + xy[1] = y; + return xy; +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +def convertBulbListToMap() { + log.debug "CONVERT BULB LIST" + try { + if (state.bulbs instanceof java.util.List) { + def map = [:] + state.bulbs.unique {it.id}.each { bulb -> + map << ["${bulb.id}":["id":bulb.id, "name":bulb.name, "type": bulb.type, "hub":bulb.hub]] + } + state.bulbs = map + } + } catch(Exception e) { + log.error "Caught error attempting to convert bulb list to map: $e" + } +} + +def convertGroupListToMap() { + log.debug "CONVERT GROUP LIST" + try { + if (state.groups instanceof java.util.List) { + def map = [:] + state.groups.unique {it.id}.each { group -> + map << ["${group.id}g":["id":group.id+"g", "name":group.name, "type": group.type, "lights": group.lights, "hub":group.hub]] + } + state.group = map + } + } + catch(Exception e) { + log.error "Caught error attempting to convert group list to map: $e" + } +} + +def convertSceneListToMap() { + log.debug "CONVERT SCENE LIST" + try { + if (state.scenes instanceof java.util.List) { + def map = [:] + state.scenes.unique {it.id}.each { scene -> + map << ["${scene.id}s":["id":scene.id+"s", "name":scene.name, "type": group.type, "lights": group.lights, "hub":scene.hub]] + } + state.scene = map + } + } + catch(Exception e) { + log.error "Caught error attempting to convert scene list to map: $e" + } +} + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +private Boolean hasAllHubsOver(String desiredFirmware) +{ + return realHubFirmwareVersions.every { fw -> fw >= desiredFirmware } +} + +private List getRealHubFirmwareVersions() +{ + return location.hubs*.firmwareVersionString.findAll { it } +} + +def ipAddressFromDni(dni) { + if (dni) { + def segs = dni.split(":") + convertHexToIP(segs[0]) + ":" + convertHexToInt(segs[1]) + } else { + null + } +} + +def getSelectedTransition() { + return settings.selectedTransition +} + +def setAlert(childDevice, effect, transitionTime, deviceType ) { + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = deviceType + deviceID = deviceID - "g" + } + def path = dType + "/" + deviceID + "/" + api + + + if(effect != "none" && effect != "select" && effect != "lselect") { childDevice?.log "Invalid alert value!" } + else { + def value = [alert: effect, transitiontime: transitionTime * 10 ] + childDevice?.log "setAlert: Alert ${effect}." + put( path, value ) + } +} + +def setEffect(childDevice, effect, transitionTime, deviceType) { + def api = "state" + def dType = "lights" + def deviceID = getId(childDevice) + if(deviceType == "groups") { + api = "action" + dType = deviceType + deviceID = deviceID - "g" + } + def path = dType + "/" + deviceID + "/" + api + + + def value = [effect: effect, transitiontime: transitionTime * 10 ] + childDevice?.log "setEffect: Effect ${effect}." + put( path, value ) +} + + +int kelvinToMireks(kelvin) { + return 1000000 / kelvin //https://en.wikipedia.org/wiki/Mired +} + +int mireksToKelvin(mireks) { + return 1000000 / mireks //https://en.wikipedia.org/wiki/Mired +} diff --git a/third-party/hvac-auto-off.smartapp.groovy b/third-party/hvac-auto-off.smartapp.groovy new file mode 100755 index 0000000..eecb6e8 --- /dev/null +++ b/third-party/hvac-auto-off.smartapp.groovy @@ -0,0 +1,92 @@ +/** + * HVAC Auto Off + * + * Author: dianoga7@3dgo.net + * Date: 2013-07-21 + */ + +// Automatically generated. Make future change here. +definition( + name: "Thermostat Auto Off", + namespace: "dianoga", + author: "dianoga7@3dgo.net", + description: "Automatically turn off thermostat when windows/doors open. Turn it back on when everything is closed up.", + category: "Green Living", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience%402x.png", + oauth: true +) + +preferences { + section("Control") { + input("thermostat", "capability.thermostat", title: "Thermostat") + } + + section("Open/Close") { + input("sensors", "capability.contactSensor", title: "Sensors", multiple: true) + input("delay", "number", title: "Delay (seconds) before turning thermostat off") + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + state.changed = false + subscribe(sensors, 'contact', "sensorChange") +} + +def sensorChange(evt) { + log.debug "Desc: $evt.value , $state" + if(evt.value == 'open' && !state.changed) { + log.debug "Scheduling turn off in $delay seconds" + state.scheduled = true; + runIn(delay, 'turnOff') + } else if(evt.value == 'closed' && (state.changed || state.scheduled)) { + if(!isOpen()) { + log.debug "Everything is closed, restoring thermostat" + state.scheduled = false; + unschedule('turnOff') + restore() + } else { + log.debug "Something is still open." + } + } +} + +def isOpen() { + def result = sensors.find() { it.currentValue('contact') == 'open'; } + log.debug "isOpen results: $result" + + return result +} + +def turnOff() { + log.debug "Preparing to turn off thermostat due to contact open" + if(isOpen()) { + log.debug "It's safe. Turning it off." + state.thermostatMode = thermostat.currentValue("thermostatMode") + state.changed = true + thermostat.off() + log.debug "State: $state" + } else { + log.debug "Just kidding. The platform did something bad." + } +} + +def restore() { + log.debug "Setting thermostat to $state.thermostatMode" + thermostat.setThermostatMode(state.thermostatMode) + state.changed = false +} diff --git a/third-party/influxdb-logger.groovy b/third-party/influxdb-logger.groovy new file mode 100755 index 0000000..3cba6e3 --- /dev/null +++ b/third-party/influxdb-logger.groovy @@ -0,0 +1,797 @@ +/***************************************************************************************************************** + * Copyright David Lomas (codersaur) + * + * Name: InfluxDB Logger + * + * Date: 2017-04-03 + * + * Version: 1.11 + * + * Source: https://github.com/codersaur/SmartThings/tree/master/smartapps/influxdb-logger + * + * Author: David Lomas (codersaur) + * + * Description: A SmartApp to log SmartThings device states to an InfluxDB database. + * + * For full information, including installation instructions, exmples, and version history, see: + * https://github.com/codersaur/SmartThings/tree/master/smartapps/influxdb-logger + * + * IMPORTANT - To enable the resolution of groupNames (i.e. room names), you must manually insert the group IDs + * into the getGroupName() command code at the end of this file. + * + * License: + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + *****************************************************************************************************************/ +definition( + name: "InfluxDB Logger", + namespace: "codersaur", + author: "David Lomas (codersaur)", + description: "Log SmartThings device states to InfluxDB", + 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 { + + section("General:") { + //input "prefDebugMode", "bool", title: "Enable debug logging?", defaultValue: true, displayDuringSetup: true + input ( + name: "configLoggingLevelIDE", + title: "IDE Live Logging Level:\nMessages with this level and higher will be logged to the IDE.", + type: "enum", + options: [ + "0" : "None", + "1" : "Error", + "2" : "Warning", + "3" : "Info", + "4" : "Debug", + "5" : "Trace" + ], + defaultValue: "3", + displayDuringSetup: true, + required: false + ) + } + + section ("InfluxDB Database:") { + input "prefDatabaseHost", "text", title: "Host", defaultValue: "10.10.10.10", required: true + input "prefDatabasePort", "text", title: "Port", defaultValue: "8086", required: true + input "prefDatabaseName", "text", title: "Database Name", defaultValue: "", required: true + input "prefDatabaseUser", "text", title: "Username", required: false + input "prefDatabasePass", "text", title: "Password", required: false + } + + section("Polling:") { + input "prefSoftPollingInterval", "number", title:"Soft-Polling interval (minutes)", defaultValue: 10, required: true + } + + section("System Monitoring:") { + input "prefLogModeEvents", "bool", title:"Log Mode Events?", defaultValue: true, required: true + input "prefLogHubProperties", "bool", title:"Log Hub Properties?", defaultValue: true, required: true + input "prefLogLocationProperties", "bool", title:"Log Location Properties?", defaultValue: true, required: true + } + + section("Devices To Monitor:") { + input "accelerometers", "capability.accelerationSensor", title: "Accelerometers", multiple: true, required: false + input "alarms", "capability.alarm", title: "Alarms", multiple: true, required: false + input "batteries", "capability.battery", title: "Batteries", multiple: true, required: false + input "beacons", "capability.beacon", title: "Beacons", multiple: true, required: false + input "buttons", "capability.button", title: "Buttons", multiple: true, required: false + input "cos", "capability.carbonMonoxideDetector", title: "Carbon Monoxide Detectors", multiple: true, required: false + input "co2s", "capability.carbonDioxideMeasurement", title: "Carbon Dioxide Detectors", multiple: true, required: false + input "colors", "capability.colorControl", title: "Color Controllers", multiple: true, required: false + input "consumables", "capability.consumable", title: "Consumables", multiple: true, required: false + input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false + input "doorsControllers", "capability.doorControl", title: "Door Controllers", multiple: true, required: false + input "energyMeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false + input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity Meters", multiple: true, required: false + input "illuminances", "capability.illuminanceMeasurement", title: "Illuminance Meters", multiple: true, required: false + input "locks", "capability.lock", title: "Locks", multiple: true, required: false + input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Music Players", multiple: true, required: false + input "peds", "capability.stepSensor", title: "Pedometers", multiple: true, required: false + input "phMeters", "capability.pHMeasurement", title: "pH Meters", multiple: true, required: false + input "powerMeters", "capability.powerMeter", title: "Power Meters", multiple: true, required: false + input "presences", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false + input "pressures", "capability.sensor", title: "Pressure Sensors", multiple: true, required: false + input "shockSensors", "capability.shockSensor", title: "Shock Sensors", multiple: true, required: false + input "signalStrengthMeters", "capability.signalStrength", title: "Signal Strength Meters", multiple: true, required: false + input "sleepSensors", "capability.sleepSensor", title: "Sleep Sensors", multiple: true, required: false + input "smokeDetectors", "capability.smokeDetector", title: "Smoke Detectors", multiple: true, required: false + input "soundSensors", "capability.soundSensor", title: "Sound Sensors", multiple: true, required: false + input "spls", "capability.soundPressureLevel", title: "Sound Pressure Level Sensors", multiple: true, required: false + input "switches", "capability.switch", title: "Switches", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Switch Levels", multiple: true, required: false + input "tamperAlerts", "capability.tamperAlert", title: "Tamper Alerts", multiple: true, required: false + input "temperatures", "capability.temperatureMeasurement", title: "Temperature Sensors", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Thermostats", multiple: true, required: false + input "threeAxis", "capability.threeAxis", title: "Three-axis (Orientation) Sensors", multiple: true, required: false + input "touchs", "capability.touchSensor", title: "Touch Sensors", multiple: true, required: false + input "uvs", "capability.ultravioletIndex", title: "UV Sensors", multiple: true, required: false + input "valves", "capability.valve", title: "Valves", multiple: true, required: false + input "volts", "capability.voltageMeasurement", title: "Voltage Meters", multiple: true, required: false + input "waterSensors", "capability.waterSensor", title: "Water Sensors", multiple: true, required: false + input "windowShades", "capability.windowShade", title: "Window Shades", multiple: true, required: false + } + +} + + +/***************************************************************************************************************** + * SmartThings System Commands: + *****************************************************************************************************************/ + +/** + * installed() + * + * Runs when the app is first installed. + **/ +def installed() { + state.installedAt = now() + state.loggingLevelIDE = 5 + log.debug "${app.label}: Installed with settings: ${settings}" +} + +/** + * uninstalled() + * + * Runs when the app is uninstalled. + **/ +def uninstalled() { + logger("uninstalled()","trace") +} + +/** + * updated() + * + * Runs when app settings are changed. + * + * Updates device.state with input values and other hard-coded values. + * Builds state.deviceAttributes which describes the attributes that will be monitored for each device collection + * (used by manageSubscriptions() and softPoll()). + * Refreshes scheduling and subscriptions. + **/ +def updated() { + logger("updated()","trace") + + // Update internal state: + state.loggingLevelIDE = (settings.configLoggingLevelIDE) ? settings.configLoggingLevelIDE.toInteger() : 3 + + // Database config: + state.databaseHost = settings.prefDatabaseHost + state.databasePort = settings.prefDatabasePort + state.databaseName = settings.prefDatabaseName + state.databaseUser = settings.prefDatabaseUser + state.databasePass = settings.prefDatabasePass + + state.path = "/write?db=${state.databaseName}" + state.headers = [:] + state.headers.put("HOST", "${state.databaseHost}:${state.databasePort}") + state.headers.put("Content-Type", "application/x-www-form-urlencoded") + if (state.databaseUser && state.databasePass) { + state.headers.put("Authorization", encodeCredentialsBasic(state.databaseUser, state.databasePass)) + } + + // Build array of device collections and the attributes we want to report on for that collection: + // Note, the collection names are stored as strings. Adding references to the actual collection + // objects causes major issues (possibly memory issues?). + state.deviceAttributes = [] + state.deviceAttributes << [ devices: 'accelerometers', attributes: ['acceleration']] + state.deviceAttributes << [ devices: 'alarms', attributes: ['alarm']] + state.deviceAttributes << [ devices: 'batteries', attributes: ['battery']] + state.deviceAttributes << [ devices: 'beacons', attributes: ['presence']] + state.deviceAttributes << [ devices: 'buttons', attributes: ['button']] + state.deviceAttributes << [ devices: 'cos', attributes: ['carbonMonoxide']] + state.deviceAttributes << [ devices: 'co2s', attributes: ['carbonDioxide']] + state.deviceAttributes << [ devices: 'colors', attributes: ['hue','saturation','color']] + state.deviceAttributes << [ devices: 'consumables', attributes: ['consumableStatus']] + state.deviceAttributes << [ devices: 'contacts', attributes: ['contact']] + state.deviceAttributes << [ devices: 'doorsControllers', attributes: ['door']] + state.deviceAttributes << [ devices: 'energyMeters', attributes: ['energy']] + state.deviceAttributes << [ devices: 'humidities', attributes: ['humidity']] + state.deviceAttributes << [ devices: 'illuminances', attributes: ['illuminance']] + state.deviceAttributes << [ devices: 'locks', attributes: ['lock']] + state.deviceAttributes << [ devices: 'motions', attributes: ['motion']] + state.deviceAttributes << [ devices: 'musicPlayers', attributes: ['status','level','trackDescription','trackData','mute']] + state.deviceAttributes << [ devices: 'peds', attributes: ['steps','goal']] + state.deviceAttributes << [ devices: 'phMeters', attributes: ['pH']] + state.deviceAttributes << [ devices: 'powerMeters', attributes: ['power','voltage','current','powerFactor']] + state.deviceAttributes << [ devices: 'presences', attributes: ['presence']] + state.deviceAttributes << [ devices: 'pressures', attributes: ['pressure']] + state.deviceAttributes << [ devices: 'shockSensors', attributes: ['shock']] + state.deviceAttributes << [ devices: 'signalStrengthMeters', attributes: ['lqi','rssi']] + state.deviceAttributes << [ devices: 'sleepSensors', attributes: ['sleeping']] + state.deviceAttributes << [ devices: 'smokeDetectors', attributes: ['smoke']] + state.deviceAttributes << [ devices: 'soundSensors', attributes: ['sound']] + state.deviceAttributes << [ devices: 'spls', attributes: ['soundPressureLevel']] + state.deviceAttributes << [ devices: 'switches', attributes: ['switch']] + state.deviceAttributes << [ devices: 'switchLevels', attributes: ['level']] + state.deviceAttributes << [ devices: 'tamperAlerts', attributes: ['tamper']] + state.deviceAttributes << [ devices: 'temperatures', attributes: ['temperature']] + state.deviceAttributes << [ devices: 'thermostats', attributes: ['temperature','heatingSetpoint','coolingSetpoint','thermostatSetpoint','thermostatMode','thermostatFanMode','thermostatOperatingState','thermostatSetpointMode','scheduledSetpoint','optimisation','windowFunction']] + state.deviceAttributes << [ devices: 'threeAxis', attributes: ['threeAxis']] + state.deviceAttributes << [ devices: 'touchs', attributes: ['touch']] + state.deviceAttributes << [ devices: 'uvs', attributes: ['ultravioletIndex']] + state.deviceAttributes << [ devices: 'valves', attributes: ['contact']] + state.deviceAttributes << [ devices: 'volts', attributes: ['voltage']] + state.deviceAttributes << [ devices: 'waterSensors', attributes: ['water']] + state.deviceAttributes << [ devices: 'windowShades', attributes: ['windowShade']] + + // Configure Scheduling: + state.softPollingInterval = settings.prefSoftPollingInterval.toInteger() + manageSchedules() + + // Configure Subscriptions: + manageSubscriptions() +} + +/***************************************************************************************************************** + * Event Handlers: + *****************************************************************************************************************/ + +/** + * handleAppTouch(evt) + * + * Used for testing. + **/ +def handleAppTouch(evt) { + logger("handleAppTouch()","trace") + + softPoll() +} + +/** + * handleModeEvent(evt) + * + * Log Mode changes. + **/ +def handleModeEvent(evt) { + logger("handleModeEvent(): Mode changed to: ${evt.value}","info") + + def locationId = escapeStringForInfluxDB(location.id) + def locationName = escapeStringForInfluxDB(location.name) + def mode = '"' + escapeStringForInfluxDB(evt.value) + '"' + def data = "_stMode,locationId=${locationId},locationName=${locationName} mode=${mode}" + postToInfluxDB(data) +} + +/** + * handleEvent(evt) + * + * Builds data to send to InfluxDB. + * - Escapes and quotes string values. + * - Calculates logical binary values where string values can be + * represented as binary values (e.g. contact: closed = 1, open = 0) + * + * Useful references: + * - http://docs.smartthings.com/en/latest/capabilities-reference.html + * - https://docs.influxdata.com/influxdb/v0.10/guides/writing_data/ + **/ +def handleEvent(evt) { + logger("handleEvent(): $evt.displayName($evt.name:$evt.unit) $evt.value","info") + + // Build data string to send to InfluxDB: + // Format: [,=] field= + // If value is an integer, it must have a trailing "i" + // If value is a string, it must be enclosed in double quotes. + def measurement = evt.name + // tags: + def deviceId = escapeStringForInfluxDB(evt.deviceId) + def deviceName = escapeStringForInfluxDB(evt.displayName) + def groupId = escapeStringForInfluxDB(evt?.device.device.groupId) + def groupName = escapeStringForInfluxDB(getGroupName(evt?.device.device.groupId)) + def hubId = escapeStringForInfluxDB(evt?.device.device.hubId) + def hubName = escapeStringForInfluxDB(evt?.device.device.hub.toString()) + // Don't pull these from the evt.device as the app itself will be associated with one location. + def locationId = escapeStringForInfluxDB(location.id) + def locationName = escapeStringForInfluxDB(location.name) + + def unit = escapeStringForInfluxDB(evt.unit) + def value = escapeStringForInfluxDB(evt.value) + def valueBinary = '' + + def data = "${measurement},deviceId=${deviceId},deviceName=${deviceName},groupId=${groupId},groupName=${groupName},hubId=${hubId},hubName=${hubName},locationId=${locationId},locationName=${locationName}" + + // Unit tag and fields depend on the event type: + // Most string-valued attributes can be translated to a binary value too. + if ('acceleration' == evt.name) { // acceleration: Calculate a binary value (active = 1, inactive = 0) + unit = 'acceleration' + value = '"' + value + '"' + valueBinary = ('active' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('alarm' == evt.name) { // alarm: Calculate a binary value (strobe/siren/both = 1, off = 0) + unit = 'alarm' + value = '"' + value + '"' + valueBinary = ('off' == evt.value) ? '0i' : '1i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('button' == evt.name) { // button: Calculate a binary value (held = 1, pushed = 0) + unit = 'button' + value = '"' + value + '"' + valueBinary = ('pushed' == evt.value) ? '0i' : '1i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('carbonMonoxide' == evt.name) { // carbonMonoxide: Calculate a binary value (detected = 1, clear/tested = 0) + unit = 'carbonMonoxide' + value = '"' + value + '"' + valueBinary = ('detected' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('consumableStatus' == evt.name) { // consumableStatus: Calculate a binary value ("good" = 1, "missing"/"replace"/"maintenance_required"/"order" = 0) + unit = 'consumableStatus' + value = '"' + value + '"' + valueBinary = ('good' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('contact' == evt.name) { // contact: Calculate a binary value (closed = 1, open = 0) + unit = 'contact' + value = '"' + value + '"' + valueBinary = ('closed' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('door' == evt.name) { // door: Calculate a binary value (closed = 1, open/opening/closing/unknown = 0) + unit = 'door' + value = '"' + value + '"' + valueBinary = ('closed' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('lock' == evt.name) { // door: Calculate a binary value (locked = 1, unlocked = 0) + unit = 'lock' + value = '"' + value + '"' + valueBinary = ('locked' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('motion' == evt.name) { // Motion: Calculate a binary value (active = 1, inactive = 0) + unit = 'motion' + value = '"' + value + '"' + valueBinary = ('active' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('mute' == evt.name) { // mute: Calculate a binary value (muted = 1, unmuted = 0) + unit = 'mute' + value = '"' + value + '"' + valueBinary = ('muted' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('presence' == evt.name) { // presence: Calculate a binary value (present = 1, not present = 0) + unit = 'presence' + value = '"' + value + '"' + valueBinary = ('present' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('shock' == evt.name) { // shock: Calculate a binary value (detected = 1, clear = 0) + unit = 'shock' + value = '"' + value + '"' + valueBinary = ('detected' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('sleeping' == evt.name) { // sleeping: Calculate a binary value (sleeping = 1, not sleeping = 0) + unit = 'sleeping' + value = '"' + value + '"' + valueBinary = ('sleeping' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('smoke' == evt.name) { // smoke: Calculate a binary value (detected = 1, clear/tested = 0) + unit = 'smoke' + value = '"' + value + '"' + valueBinary = ('detected' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('sound' == evt.name) { // sound: Calculate a binary value (detected = 1, not detected = 0) + unit = 'sound' + value = '"' + value + '"' + valueBinary = ('detected' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('switch' == evt.name) { // switch: Calculate a binary value (on = 1, off = 0) + unit = 'switch' + value = '"' + value + '"' + valueBinary = ('on' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('tamper' == evt.name) { // tamper: Calculate a binary value (detected = 1, clear = 0) + unit = 'tamper' + value = '"' + value + '"' + valueBinary = ('detected' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('thermostatMode' == evt.name) { // thermostatMode: Calculate a binary value ( = 1, off = 0) + unit = 'thermostatMode' + value = '"' + value + '"' + valueBinary = ('off' == evt.value) ? '0i' : '1i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('thermostatFanMode' == evt.name) { // thermostatFanMode: Calculate a binary value ( = 1, off = 0) + unit = 'thermostatFanMode' + value = '"' + value + '"' + valueBinary = ('off' == evt.value) ? '0i' : '1i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('thermostatOperatingState' == evt.name) { // thermostatOperatingState: Calculate a binary value (heating = 1, = 0) + unit = 'thermostatOperatingState' + value = '"' + value + '"' + valueBinary = ('heating' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('thermostatSetpointMode' == evt.name) { // thermostatSetpointMode: Calculate a binary value (followSchedule = 0, = 1) + unit = 'thermostatSetpointMode' + value = '"' + value + '"' + valueBinary = ('followSchedule' == evt.value) ? '0i' : '1i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('threeAxis' == evt.name) { // threeAxis: Format to x,y,z values. + unit = 'threeAxis' + def valueXYZ = evt.value.split(",") + def valueX = valueXYZ[0] + def valueY = valueXYZ[1] + def valueZ = valueXYZ[2] + data += ",unit=${unit} valueX=${valueX}i,valueY=${valueY}i,valueZ=${valueZ}i" // values are integers. + } + else if ('touch' == evt.name) { // touch: Calculate a binary value (touched = 1, "" = 0) + unit = 'touch' + value = '"' + value + '"' + valueBinary = ('touched' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('optimisation' == evt.name) { // optimisation: Calculate a binary value (active = 1, inactive = 0) + unit = 'optimisation' + value = '"' + value + '"' + valueBinary = ('active' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('windowFunction' == evt.name) { // windowFunction: Calculate a binary value (active = 1, inactive = 0) + unit = 'windowFunction' + value = '"' + value + '"' + valueBinary = ('active' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('touch' == evt.name) { // touch: Calculate a binary value (touched = 1, = 0) + unit = 'touch' + value = '"' + value + '"' + valueBinary = ('touched' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('water' == evt.name) { // water: Calculate a binary value (wet = 1, dry = 0) + unit = 'water' + value = '"' + value + '"' + valueBinary = ('wet' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + else if ('windowShade' == evt.name) { // windowShade: Calculate a binary value (closed = 1, = 0) + unit = 'windowShade' + value = '"' + value + '"' + valueBinary = ('closed' == evt.value) ? '1i' : '0i' + data += ",unit=${unit} value=${value},valueBinary=${valueBinary}" + } + // Catch any other event with a string value that hasn't been handled: + else if (evt.value ==~ /.*[^0-9\.,-].*/) { // match if any characters are not digits, period, comma, or hyphen. + logger("handleEvent(): Found a string value that's not explicitly handled: Device Name: ${deviceName}, Event Name: ${evt.name}, Value: ${evt.value}","warn") + value = '"' + value + '"' + data += ",unit=${unit} value=${value}" + } + // Catch any other general numerical event (carbonDioxide, power, energy, humidity, level, temperature, ultravioletIndex, voltage, etc). + else { + data += ",unit=${unit} value=${value}" + } + + // Post data to InfluxDB: + postToInfluxDB(data) + +} + + +/***************************************************************************************************************** + * Main Commands: + *****************************************************************************************************************/ + +/** + * softPoll() + * + * Executed by schedule. + * + * Forces data to be posted to InfluxDB (even if an event has not been triggered). + * Doesn't poll devices, just builds a fake event to pass to handleEvent(). + * + * Also calls LogSystemProperties(). + **/ +def softPoll() { + logger("softPoll()","trace") + + logSystemProperties() + + // Iterate over each attribute for each device, in each device collection in deviceAttributes: + def devs // temp variable to hold device collection. + state.deviceAttributes.each { da -> + devs = settings."${da.devices}" + if (devs && (da.attributes)) { + devs.each { d -> + da.attributes.each { attr -> + if (d.hasAttribute(attr) && d.latestState(attr)?.value != null) { + logger("softPoll(): Softpolling device ${d} for attribute: ${attr}","info") + // Send fake event to handleEvent(): + handleEvent([ + name: attr, + value: d.latestState(attr)?.value, + unit: d.latestState(attr)?.unit, + device: d, + deviceId: d.id, + displayName: d.displayName + ]) + } + } + } + } + } + +} + +/** + * logSystemProperties() + * + * Generates measurements for SmartThings system (hubs and locations) properties. + **/ +def logSystemProperties() { + logger("logSystemProperties()","trace") + + def locationId = '"' + escapeStringForInfluxDB(location.id) + '"' + def locationName = '"' + escapeStringForInfluxDB(location.name) + '"' + + // Location Properties: + if (prefLogLocationProperties) { + try { + def tz = '"' + escapeStringForInfluxDB(location.timeZone.ID) + '"' + def mode = '"' + escapeStringForInfluxDB(location.mode) + '"' + def hubCount = location.hubs.size() + def times = getSunriseAndSunset() + def srt = '"' + times.sunrise.format("HH:mm", location.timeZone) + '"' + def sst = '"' + times.sunset.format("HH:mm", location.timeZone) + '"' + + def data = "_stLocation,locationId=${locationId},locationName=${locationName},latitude=${location.latitude},longitude=${location.longitude},timeZone=${tz} mode=${mode},hubCount=${hubCount}i,sunriseTime=${srt},sunsetTime=${sst}" + postToInfluxDB(data) + } catch (e) { + logger("logSystemProperties(): Unable to log Location properties: ${e}","error") + } + } + + // Hub Properties: + if (prefLogHubProperties) { + location.hubs.each { h -> + try { + def hubId = '"' + escapeStringForInfluxDB(h.id) + '"' + def hubName = '"' + escapeStringForInfluxDB(h.name) + '"' + def hubIP = '"' + escapeStringForInfluxDB(h.localIP) + '"' + def hubStatus = '"' + escapeStringForInfluxDB(h.status) + '"' + def batteryInUse = ("false" == h.hub.getDataValue("batteryInUse")) ? "0i" : "1i" + def hubUptime = h.hub.getDataValue("uptime") + 'i' + def zigbeePowerLevel = h.hub.getDataValue("zigbeePowerLevel") + 'i' + def zwavePowerLevel = '"' + escapeStringForInfluxDB(h.hub.getDataValue("zwavePowerLevel")) + '"' + def firmwareVersion = '"' + escapeStringForInfluxDB(h.firmwareVersionString) + '"' + + def data = "_stHub,locationId=${locationId},locationName=${locationName},hubId=${hubId},hubName=${hubName},hubIP=${hubIP} " + data += "status=${hubStatus},batteryInUse=${batteryInUse},uptime=${hubUptime},zigbeePowerLevel=${zigbeePowerLevel},zwavePowerLevel=${zwavePowerLevel},firmwareVersion=${firmwareVersion}" + postToInfluxDB(data) + } catch (e) { + logger("logSystemProperties(): Unable to log Hub properties: ${e}","error") + } + } + + } + +} + +/** + * postToInfluxDB() + * + * Posts data to InfluxDB. + * + * Uses hubAction instead of httpPost() in case InfluxDB server is on the same LAN as the Smartthings Hub. + **/ +def postToInfluxDB(data) { + logger("postToInfluxDB(): Posting data to InfluxDB: Host: ${state.databaseHost}, Port: ${state.databasePort}, Database: ${state.databaseName}, Data: [${data}]","debug") + + try { + def hubAction = new physicalgraph.device.HubAction( + [ + method: "POST", + path: state.path, + body: data, + headers: state.headers + ], + null, + [ callback: handleInfluxResponse ] + ) + + sendHubCommand(hubAction) + } + catch (Exception e) { + logger("postToInfluxDB(): Exception ${e} on ${hubAction}","error") + } + + // For reference, code that could be used for WAN hosts: + // def url = "http://${state.databaseHost}:${state.databasePort}/write?db=${state.databaseName}" + // try { + // httpPost(url, data) { response -> + // if (response.status != 999 ) { + // log.debug "Response Status: ${response.status}" + // log.debug "Response data: ${response.data}" + // log.debug "Response contentType: ${response.contentType}" + // } + // } + // } catch (e) { + // logger("postToInfluxDB(): Something went wrong when posting: ${e}","error") + // } +} + +/** + * handleInfluxResponse() + * + * Handles response from post made in postToInfluxDB(). + **/ +def handleInfluxResponse(physicalgraph.device.HubResponse hubResponse) { + if(hubResponse.status >= 400) { + logger("postToInfluxDB(): Something went wrong! Response from InfluxDB: Headers: ${hubResponse.headers}, Body: ${hubResponse.body}","error") + } +} + + +/***************************************************************************************************************** + * Private Helper Functions: + *****************************************************************************************************************/ + +/** + * manageSchedules() + * + * Configures/restarts scheduled tasks: + * softPoll() - Run every {state.softPollingInterval} minutes. + **/ +private manageSchedules() { + logger("manageSchedules()","trace") + + // Generate a random offset (1-60): + Random rand = new Random(now()) + def randomOffset = 0 + + // softPoll: + try { + unschedule(softPoll) + } + catch(e) { + // logger("manageSchedules(): Unschedule failed!","error") + } + + if (state.softPollingInterval > 0) { + randomOffset = rand.nextInt(60) + logger("manageSchedules(): Scheduling softpoll to run every ${state.softPollingInterval} minutes (offset of ${randomOffset} seconds).","trace") + schedule("${randomOffset} 0/${state.softPollingInterval} * * * ?", "softPoll") + } + +} + +/** + * manageSubscriptions() + * + * Configures subscriptions. + **/ +private manageSubscriptions() { + logger("manageSubscriptions()","trace") + + // Unsubscribe: + unsubscribe() + + // Subscribe to App Touch events: + subscribe(app,handleAppTouch) + + // Subscribe to mode events: + if (prefLogModeEvents) subscribe(location, "mode", handleModeEvent) + + // Subscribe to device attributes (iterate over each attribute for each device collection in state.deviceAttributes): + def devs // dynamic variable holding device collection. + state.deviceAttributes.each { da -> + devs = settings."${da.devices}" + if (devs && (da.attributes)) { + da.attributes.each { attr -> + logger("manageSubscriptions(): Subscribing to attribute: ${attr}, for devices: ${da.devices}","info") + // There is no need to check if all devices in the collection have the attribute. + subscribe(devs, attr, handleEvent) + } + } + } +} + +/** + * logger() + * + * Wrapper function for all logging. + **/ +private logger(msg, level = "debug") { + + switch(level) { + case "error": + if (state.loggingLevelIDE >= 1) log.error msg + break + + case "warn": + if (state.loggingLevelIDE >= 2) log.warn msg + break + + case "info": + if (state.loggingLevelIDE >= 3) log.info msg + break + + case "debug": + if (state.loggingLevelIDE >= 4) log.debug msg + break + + case "trace": + if (state.loggingLevelIDE >= 5) log.trace msg + break + + default: + log.debug msg + break + } +} + +/** + * encodeCredentialsBasic() + * + * Encode credentials for HTTP Basic authentication. + **/ +private encodeCredentialsBasic(username, password) { + return "Basic " + "${username}:${password}".encodeAsBase64().toString() +} + +/** + * escapeStringForInfluxDB() + * + * Escape values to InfluxDB. + * + * If a tag key, tag value, or field key contains a space, comma, or an equals sign = it must + * be escaped using the backslash character \. Backslash characters do not need to be escaped. + * Commas and spaces will also need to be escaped for measurements, though equals signs = do not. + * + * Further info: https://docs.influxdata.com/influxdb/v0.10/write_protocols/write_syntax/ + **/ +private escapeStringForInfluxDB(str) { + if (str) { + str = str.replaceAll(" ", "\\\\ ") // Escape spaces. + str = str.replaceAll(",", "\\\\,") // Escape commas. + str = str.replaceAll("=", "\\\\=") // Escape equal signs. + str = str.replaceAll("\"", "\\\\\"") // Escape double quotes. + //str = str.replaceAll("'", "_") // Replace apostrophes with underscores. + } + else { + str = 'null' + } + return str +} + +/** + * getGroupName() + * + * Get the name of a 'Group' (i.e. Room) from its ID. + * + * This is done manually as there does not appear to be a way to enumerate + * groups from a SmartApp currently. + * + * GroupIds can be obtained from the SmartThings IDE under 'My Locations'. + * + * See: https://community.smartthings.com/t/accessing-group-within-a-smartapp/6830 + **/ +private getGroupName(id) { + + if (id == null) {return 'Home'} + else if (id == 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX') {return 'Kitchen'} + else if (id == 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX') {return 'Lounge'} + else if (id == 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX') {return 'Office'} + else {return 'Unknown'} +} diff --git a/third-party/initial-state-event-sender.groovy b/third-party/initial-state-event-sender.groovy new file mode 100755 index 0000000..5fa0ae9 --- /dev/null +++ b/third-party/initial-state-event-sender.groovy @@ -0,0 +1,376 @@ +/** + * Initial State Event Streamer + * + * Copyright 2016 David Sulpy + * + * 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. + * + * SmartThings data is sent from this SmartApp to Initial State. This is event data only for + * devices for which the user has authorized. Likewise, Initial State's services call this + * SmartApp on the user's behalf to configure Initial State specific parameters. The ToS and + * Privacy Policy for Initial State can be found here: https://www.initialstate.com/terms + */ + +definition( + name: "Initial State Event Streamer", + namespace: "initialstate.events", + author: "David Sulpy", + description: "A SmartThings SmartApp to allow SmartThings events to be viewable inside an Initial State Event Bucket in your https://www.initialstate.com account.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertica_small.png", + iconX2Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + iconX3Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + oauth: [displayName: "Initial State", displayLink: "https://www.initialstate.com"]) + +import groovy.json.JsonSlurper + +preferences { + section("Choose which devices to monitor...") { + input "accelerometers", "capability.accelerationSensor", title: "Accelerometers", multiple: true, required: false + input "alarms", "capability.alarm", title: "Alarms", multiple: true, required: false + input "batteries", "capability.battery", title: "Batteries", multiple: true, required: false + input "beacons", "capability.beacon", title: "Beacons", multiple: true, required: false + input "cos", "capability.carbonMonoxideDetector", title: "Carbon Monoxide Detectors", multiple: true, required: false + input "colors", "capability.colorControl", title: "Color Controllers", multiple: true, required: false + input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false + input "doorsControllers", "capability.doorControl", title: "Door Controllers", multiple: true, required: false + input "energyMeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false + input "illuminances", "capability.illuminanceMeasurement", title: "Illuminance Meters", multiple: true, required: false + input "locks", "capability.lock", title: "Locks", multiple: true, required: false + input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Music Players", multiple: true, required: false + input "powerMeters", "capability.powerMeter", title: "Power Meters", multiple: true, required: false + input "presences", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false + input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity Meters", multiple: true, required: false + input "relaySwitches", "capability.relaySwitch", title: "Relay Switches", multiple: true, required: false + input "sleepSensors", "capability.sleepSensor", title: "Sleep Sensors", multiple: true, required: false + input "smokeDetectors", "capability.smokeDetector", title: "Smoke Detectors", multiple: true, required: false + input "peds", "capability.stepSensor", title: "Pedometers", multiple: true, required: false + input "switches", "capability.switch", title: "Switches", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Switch Levels", multiple: true, required: false + input "temperatures", "capability.temperatureMeasurement", title: "Temperature Sensors", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Thermostats", multiple: true, required: false + input "valves", "capability.valve", title: "Valves", multiple: true, required: false + input "waterSensors", "capability.waterSensor", title: "Water Sensors", multiple: true, required: false + } +} + +mappings { + path("/access_key") { + action: [ + GET: "getAccessKey", + PUT: "setAccessKey" + ] + } + path("/bucket") { + action: [ + GET: "getBucketKey", + PUT: "setBucketKey" + ] + } +} + +def getAccessKey() { + log.trace "get access key" + if (atomicState.accessKey == null) { + httpError(404, "Access Key Not Found") + } else { + [ + accessKey: atomicState.accessKey + ] + } +} + +def getBucketKey() { + log.trace "get bucket key" + if (atomicState.bucketKey == null) { + httpError(404, "Bucket key Not Found") + } else { + [ + bucketKey: atomicState.bucketKey, + bucketName: atomicState.bucketName + ] + } +} + +def setBucketKey() { + log.trace "set bucket key" + def newBucketKey = request.JSON?.bucketKey + def newBucketName = request.JSON?.bucketName + + log.debug "bucket name: $newBucketName" + log.debug "bucket key: $newBucketKey" + + if (newBucketKey && (newBucketKey != atomicState.bucketKey || newBucketName != atomicState.bucketName)) { + atomicState.bucketKey = "$newBucketKey" + atomicState.bucketName = "$newBucketName" + atomicState.isBucketCreated = false + } + + tryCreateBucket() +} + +def setAccessKey() { + log.trace "set access key" + def newAccessKey = request.JSON?.accessKey + def newGrokerSubdomain = request.JSON?.grokerSubdomain + + if (newGrokerSubdomain && newGrokerSubdomain != "" && newGrokerSubdomain != atomicState.grokerSubdomain) { + atomicState.grokerSubdomain = "$newGrokerSubdomain" + atomicState.isBucketCreated = false + } + + if (newAccessKey && newAccessKey != atomicState.accessKey) { + atomicState.accessKey = "$newAccessKey" + atomicState.isBucketCreated = false + } +} + +def subscribeToEvents() { + if (accelerometers != null) { + subscribe(accelerometers, "acceleration", genericHandler) + } + if (alarms != null) { + subscribe(alarms, "alarm", genericHandler) + } + if (batteries != null) { + subscribe(batteries, "battery", genericHandler) + } + if (beacons != null) { + subscribe(beacons, "presence", genericHandler) + } + + if (cos != null) { + subscribe(cos, "carbonMonoxide", genericHandler) + } + if (colors != null) { + subscribe(colors, "hue", genericHandler) + subscribe(colors, "saturation", genericHandler) + subscribe(colors, "color", genericHandler) + } + if (contacts != null) { + subscribe(contacts, "contact", genericHandler) + } + if (energyMeters != null) { + subscribe(energyMeters, "energy", genericHandler) + } + if (illuminances != null) { + subscribe(illuminances, "illuminance", genericHandler) + } + if (locks != null) { + subscribe(locks, "lock", genericHandler) + } + if (motions != null) { + subscribe(motions, "motion", genericHandler) + } + if (musicPlayers != null) { + subscribe(musicPlayers, "status", genericHandler) + subscribe(musicPlayers, "level", genericHandler) + subscribe(musicPlayers, "trackDescription", genericHandler) + subscribe(musicPlayers, "trackData", genericHandler) + subscribe(musicPlayers, "mute", genericHandler) + } + if (powerMeters != null) { + subscribe(powerMeters, "power", genericHandler) + } + if (presences != null) { + subscribe(presences, "presence", genericHandler) + } + if (humidities != null) { + subscribe(humidities, "humidity", genericHandler) + } + if (relaySwitches != null) { + subscribe(relaySwitches, "switch", genericHandler) + } + if (sleepSensors != null) { + subscribe(sleepSensors, "sleeping", genericHandler) + } + if (smokeDetectors != null) { + subscribe(smokeDetectors, "smoke", genericHandler) + } + if (peds != null) { + subscribe(peds, "steps", genericHandler) + subscribe(peds, "goal", genericHandler) + } + if (switches != null) { + subscribe(switches, "switch", genericHandler) + } + if (switchLevels != null) { + subscribe(switchLevels, "level", genericHandler) + } + if (temperatures != null) { + subscribe(temperatures, "temperature", genericHandler) + } + if (thermostats != null) { + subscribe(thermostats, "temperature", genericHandler) + subscribe(thermostats, "heatingSetpoint", genericHandler) + subscribe(thermostats, "coolingSetpoint", genericHandler) + subscribe(thermostats, "thermostatSetpoint", genericHandler) + subscribe(thermostats, "thermostatMode", genericHandler) + subscribe(thermostats, "thermostatFanMode", genericHandler) + subscribe(thermostats, "thermostatOperatingState", genericHandler) + } + if (valves != null) { + subscribe(valves, "contact", genericHandler) + } + if (waterSensors != null) { + subscribe(waterSensors, "water", genericHandler) + } +} + +def installed() { + atomicState.version = "1.1.0" + + atomicState.isBucketCreated = false + atomicState.grokerSubdomain = "groker" + + subscribeToEvents() + + atomicState.isBucketCreated = false + atomicState.grokerSubdomain = "groker" + + log.debug "installed (version $atomicState.version)" +} + +def updated() { + atomicState.version = "1.1.0" + unsubscribe() + + if (atomicState.bucketKey != null && atomicState.accessKey != null) { + atomicState.isBucketCreated = false + } + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + atomicState.grokerSubdomain = "groker" + } + + subscribeToEvents() + + log.debug "updated (version $atomicState.version)" +} + +def uninstalled() { + log.debug "uninstalled (version $atomicState.version)" +} + +def tryCreateBucket() { + + // can't ship events if there is no grokerSubdomain + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + + // if the bucket has already been created, no need to continue + if (atomicState.isBucketCreated) { + return + } + + if (!atomicState.bucketName) { + atomicState.bucketName = atomicState.bucketKey + } + if (!atomicState.accessKey) { + return + } + def bucketName = "${atomicState.bucketName}" + def bucketKey = "${atomicState.bucketKey}" + def accessKey = "${atomicState.accessKey}" + + def bucketCreateBody = new JsonSlurper().parseText("{\"bucketKey\": \"$bucketKey\", \"bucketName\": \"$bucketName\"}") + + def bucketCreatePost = [ + uri: "https://${atomicState.grokerSubdomain}.initialstate.com/api/buckets", + headers: [ + "Content-Type": "application/json", + "X-IS-AccessKey": accessKey + ], + body: bucketCreateBody + ] + + log.debug bucketCreatePost + + try { + // Create a bucket on Initial State so the data has a logical grouping + httpPostJson(bucketCreatePost) { resp -> + log.debug "bucket posted" + if (resp.status >= 400) { + log.error "bucket not created successfully" + } else { + atomicState.isBucketCreated = true + } + } + } catch (e) { + log.error "bucket creation error: $e" + } + +} + +def genericHandler(evt) { + log.trace "$evt.displayName($evt.name:$evt.unit) $evt.value" + + def key = "$evt.displayName($evt.name)" + if (evt.unit != null) { + key = "$evt.displayName(${evt.name}_$evt.unit)" + } + def value = "$evt.value" + + tryCreateBucket() + + eventHandler(key, value) +} + +def eventHandler(name, value) { + def epoch = now() / 1000 + + def event = new JsonSlurper().parseText("{\"key\": \"$name\", \"value\": \"$value\", \"epoch\": \"$epoch\"}") + + tryShipEvents(event) + + log.debug "Shipped Event: " + event +} + +def tryShipEvents(event) { + + def grokerSubdomain = atomicState.grokerSubdomain + // can't ship events if there is no grokerSubdomain + if (grokerSubdomain == null || grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + def accessKey = atomicState.accessKey + def bucketKey = atomicState.bucketKey + // can't ship if access key and bucket key are null, so finish trying + if (accessKey == null || bucketKey == null) { + return + } + + def eventPost = [ + uri: "https://${grokerSubdomain}.initialstate.com/api/events", + headers: [ + "Content-Type": "application/json", + "X-IS-BucketKey": "${bucketKey}", + "X-IS-AccessKey": "${accessKey}", + "Accept-Version": "0.0.2" + ], + body: event + ] + + try { + // post the events to initial state + httpPostJson(eventPost) { resp -> + log.debug "shipped events and got ${resp.status}" + if (resp.status >= 400) { + log.error "shipping failed... ${resp.data}" + } + } + } catch (e) { + log.error "shipping events failed: $e" + } + +} \ No newline at end of file diff --git a/third-party/initialstate-smart-app-v1.2.0.groovy b/third-party/initialstate-smart-app-v1.2.0.groovy new file mode 100755 index 0000000..0c8e76c --- /dev/null +++ b/third-party/initialstate-smart-app-v1.2.0.groovy @@ -0,0 +1,414 @@ +/** + * Initial State Event Streamer + * + * Copyright 2016-2017 David Sulpy + * + * 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. + * + * SmartThings data is sent from this SmartApp to Initial State. This is event data only for + * devices for which the user has authorized. Likewise, Initial State's services call this + * SmartApp on the user's behalf to configure Initial State specific parameters. The ToS and + * Privacy Policy for Initial State can be found here: https://www.initialstate.com/terms + */ + +definition( + name: "Initial State Event Streamer", + namespace: "initialstate.events", + author: "David Sulpy", + description: "A SmartThings SmartApp to allow SmartThings events to be viewable inside an Initial State Event Bucket in your https://www.initialstate.com account.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertica_small.png", + iconX2Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + iconX3Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + oauth: [displayName: "Initial State", displayLink: "https://www.initialstate.com"]) + +import groovy.json.JsonSlurper + +preferences { + section("Choose which devices to monitor...") { + input "accelerometers", "capability.accelerationSensor", title: "Accelerometers", multiple: true, required: false + input "alarms", "capability.alarm", title: "Alarms", multiple: true, required: false + input "batteries", "capability.battery", title: "Batteries", multiple: true, required: false + input "beacons", "capability.beacon", title: "Beacons", multiple: true, required: false + input "cos", "capability.carbonMonoxideDetector", title: "Carbon Monoxide Detectors", multiple: true, required: false + input "colors", "capability.colorControl", title: "Color Controllers", multiple: true, required: false + input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false + input "doorsControllers", "capability.doorControl", title: "Door Controllers", multiple: true, required: false + input "energyMeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false + input "illuminances", "capability.illuminanceMeasurement", title: "Illuminance Meters", multiple: true, required: false + input "locks", "capability.lock", title: "Locks", multiple: true, required: false + input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Music Players", multiple: true, required: false + input "powerMeters", "capability.powerMeter", title: "Power Meters", multiple: true, required: false + input "presences", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false + input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity Meters", multiple: true, required: false + input "relaySwitches", "capability.relaySwitch", title: "Relay Switches", multiple: true, required: false + input "sleepSensors", "capability.sleepSensor", title: "Sleep Sensors", multiple: true, required: false + input "smokeDetectors", "capability.smokeDetector", title: "Smoke Detectors", multiple: true, required: false + input "peds", "capability.stepSensor", title: "Pedometers", multiple: true, required: false + input "switches", "capability.switch", title: "Switches", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Switch Levels", multiple: true, required: false + input "temperatures", "capability.temperatureMeasurement", title: "Temperature Sensors", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Thermostats", multiple: true, required: false + input "valves", "capability.valve", title: "Valves", multiple: true, required: false + input "waterSensors", "capability.waterSensor", title: "Water Sensors", multiple: true, required: false + } +} + +mappings { + path("/access_key") { + action: [ + GET: "getAccessKey", + PUT: "setAccessKey" + ] + } + path("/bucket") { + action: [ + GET: "getBucketKey", + PUT: "setBucketKey" + ] + } + path("/disable") { + action: [ + POST: "disableIntegration" + ] + } + path("/enable") { + action: [ + POST: "enableIntegration" + ] + } +} + +def disableIntegration() { + log.trace "disabling integration" + + atomicState.isDisabled = true + + [ isDisabled: atomicState.isDisabled ] +} + +def enableIntegration() { + log.trace "enable integration" + + atomicState.isDisabled = false + + [ isDisabled: atomicState.isDisabled] +} + +def getAccessKey() { + log.trace "get access key" + if (atomicState.accessKey == null) { + httpError(404, "Access Key Not Found") + } else { + [ + accessKey: atomicState.accessKey + ] + } +} + +def getBucketKey() { + log.trace "get bucket key" + if (atomicState.bucketKey == null) { + httpError(404, "Bucket key Not Found") + } else { + [ + bucketKey: atomicState.bucketKey, + bucketName: atomicState.bucketName + ] + } +} + +def setBucketKey() { + log.trace "set bucket key" + def newBucketKey = request.JSON?.bucketKey + def newBucketName = request.JSON?.bucketName + + log.debug "bucket name: $newBucketName" + log.debug "bucket key: $newBucketKey" + + if (newBucketKey && (newBucketKey != atomicState.bucketKey || newBucketName != atomicState.bucketName)) { + atomicState.bucketKey = "$newBucketKey" + atomicState.bucketName = "$newBucketName" + atomicState.isBucketCreated = false + } + + tryCreateBucket() +} + +def setAccessKey() { + log.trace "set access key" + def newAccessKey = request.JSON?.accessKey + def newGrokerSubdomain = request.JSON?.grokerSubdomain + + if (newGrokerSubdomain && newGrokerSubdomain != "" && newGrokerSubdomain != atomicState.grokerSubdomain) { + atomicState.grokerSubdomain = "$newGrokerSubdomain" + atomicState.isBucketCreated = false + } + + if (newAccessKey && newAccessKey != atomicState.accessKey) { + atomicState.accessKey = "$newAccessKey" + atomicState.isBucketCreated = false + } +} + +def subscribeToEvents() { + if (accelerometers != null) { + subscribe(accelerometers, "acceleration", genericHandler) + } + if (alarms != null) { + subscribe(alarms, "alarm", genericHandler) + } + if (batteries != null) { + subscribe(batteries, "battery", genericHandler) + } + if (beacons != null) { + subscribe(beacons, "presence", genericHandler) + } + + if (cos != null) { + subscribe(cos, "carbonMonoxide", genericHandler) + } + if (colors != null) { + subscribe(colors, "hue", genericHandler) + subscribe(colors, "saturation", genericHandler) + subscribe(colors, "color", genericHandler) + } + if (contacts != null) { + subscribe(contacts, "contact", genericHandler) + } + if (energyMeters != null) { + subscribe(energyMeters, "energy", genericHandler) + } + if (illuminances != null) { + subscribe(illuminances, "illuminance", genericHandler) + } + if (locks != null) { + subscribe(locks, "lock", genericHandler) + } + if (motions != null) { + subscribe(motions, "motion", genericHandler) + } + if (musicPlayers != null) { + subscribe(musicPlayers, "status", genericHandler) + subscribe(musicPlayers, "level", genericHandler) + subscribe(musicPlayers, "trackDescription", genericHandler) + subscribe(musicPlayers, "trackData", genericHandler) + subscribe(musicPlayers, "mute", genericHandler) + } + if (powerMeters != null) { + subscribe(powerMeters, "power", genericHandler) + } + if (presences != null) { + subscribe(presences, "presence", genericHandler) + } + if (humidities != null) { + subscribe(humidities, "humidity", genericHandler) + } + if (relaySwitches != null) { + subscribe(relaySwitches, "switch", genericHandler) + } + if (sleepSensors != null) { + subscribe(sleepSensors, "sleeping", genericHandler) + } + if (smokeDetectors != null) { + subscribe(smokeDetectors, "smoke", genericHandler) + } + if (peds != null) { + subscribe(peds, "steps", genericHandler) + subscribe(peds, "goal", genericHandler) + } + if (switches != null) { + subscribe(switches, "switch", genericHandler) + } + if (switchLevels != null) { + subscribe(switchLevels, "level", genericHandler) + } + if (temperatures != null) { + subscribe(temperatures, "temperature", genericHandler) + } + if (thermostats != null) { + subscribe(thermostats, "temperature", genericHandler) + subscribe(thermostats, "heatingSetpoint", genericHandler) + subscribe(thermostats, "coolingSetpoint", genericHandler) + subscribe(thermostats, "thermostatSetpoint", genericHandler) + subscribe(thermostats, "thermostatMode", genericHandler) + subscribe(thermostats, "thermostatFanMode", genericHandler) + subscribe(thermostats, "thermostatOperatingState", genericHandler) + } + if (valves != null) { + subscribe(valves, "contact", genericHandler) + } + if (waterSensors != null) { + subscribe(waterSensors, "water", genericHandler) + } +} + +def installed() { + atomicState.version = "1.2.0" + + atomicState.isBucketCreated = false + atomicState.grokerSubdomain = "groker" + atomicState.isDisabled = false + + subscribeToEvents() + + atomicState.isBucketCreated = false + atomicState.grokerSubdomain = "groker" + + log.debug "installed (version $atomicState.version)" +} + +def updated() { + atomicState.version = "1.2.0" + unsubscribe() + + if (atomicState.bucketKey != null && atomicState.accessKey != null) { + atomicState.isBucketCreated = false + } + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + atomicState.grokerSubdomain = "groker" + } + + subscribeToEvents() + + log.debug "updated (version $atomicState.version)" +} + +def uninstalled() { + log.debug "uninstalled (version $atomicState.version)" +} + +def tryCreateBucket() { + // if the integration has been disabled, don't do anything + if (atomicState.isDisabled) { + log.debug "integration is disabled" + return + } + + // can't ship events if there is no grokerSubdomain + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + + // if the bucket has already been created, no need to continue + if (atomicState.isBucketCreated) { + return + } + + if (!atomicState.bucketName) { + atomicState.bucketName = atomicState.bucketKey + } + if (!atomicState.accessKey) { + return + } + def bucketName = "${atomicState.bucketName}" + def bucketKey = "${atomicState.bucketKey}" + def accessKey = "${atomicState.accessKey}" + + def bucketCreateBody = new JsonSlurper().parseText("{\"bucketKey\": \"$bucketKey\", \"bucketName\": \"$bucketName\"}") + + def bucketCreatePost = [ + uri: "https://${atomicState.grokerSubdomain}.initialstate.com/api/buckets", + headers: [ + "Content-Type": "application/json", + "X-IS-AccessKey": accessKey + ], + body: bucketCreateBody + ] + + log.debug bucketCreatePost + + try { + // Create a bucket on Initial State so the data has a logical grouping + httpPostJson(bucketCreatePost) { resp -> + log.debug "bucket posted" + if (resp.status >= 400) { + log.error "bucket not created successfully" + } else { + atomicState.isBucketCreated = true + } + } + } catch (e) { + log.error "bucket creation error: $e" + } + +} + +def genericHandler(evt) { + log.trace "$evt.displayName($evt.name:$evt.unit) $evt.value" + + def key = "$evt.displayName($evt.name)" + if (evt.unit != null) { + key = "$evt.displayName(${evt.name}_$evt.unit)" + } + def value = "$evt.value" + + tryCreateBucket() + + eventHandler(key, value) +} + +def eventHandler(name, value) { + def epoch = now() / 1000 + + def event = new JsonSlurper().parseText("{\"key\": \"$name\", \"value\": \"$value\", \"epoch\": \"$epoch\"}") + + tryShipEvents(event) + + log.debug "Shipped Event: " + event +} + +def tryShipEvents(event) { + + // if the integration is not enabled, then don't attempt to send any data + if (atomicState.isDisabled) { + log.debug "not shipping, integration is disabled" + return + } + + def grokerSubdomain = atomicState.grokerSubdomain + // can't ship events if there is no grokerSubdomain + if (grokerSubdomain == null || grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + def accessKey = atomicState.accessKey + def bucketKey = atomicState.bucketKey + // can't ship if access key and bucket key are null, so finish trying + if (accessKey == null || bucketKey == null) { + return + } + + def eventPost = [ + uri: "https://${grokerSubdomain}.initialstate.com/api/events", + headers: [ + "Content-Type": "application/json", + "X-IS-BucketKey": "${bucketKey}", + "X-IS-AccessKey": "${accessKey}", + "Accept-Version": "0.0.2" + ], + body: event + ] + + try { + // post the events to initial state + httpPostJson(eventPost) { resp -> + log.debug "shipped events and got ${resp.status}" + if (resp.status >= 400) { + log.error "shipping failed... ${resp.data}" + } + } + } catch (e) { + log.error "shipping events failed: $e" + } + +} \ No newline at end of file diff --git a/third-party/loft.groovy b/third-party/loft.groovy new file mode 100755 index 0000000..bacb2c6 --- /dev/null +++ b/third-party/loft.groovy @@ -0,0 +1,274 @@ +/** + * Copyright 2015 Jesse Newland + * + * 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: "Loft", + namespace: "jnewland", + author: "Jesse Newland", + description: "All the business logic for my crappy loft lives here", + category: "SmartThings Labs", + 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: selectThings) +} + +def selectThings() { + dynamicPage(name: "selectThings", title: "Select Things", install: true) { + section("Webhook URL"){ + input "url", "text", title: "Webhook URL", description: "Your webhook URL", required: true + } + section("Things to monitor for events") { + input "monitor_switches", "capability.switch", title: "Switches", multiple: true, required: false + input "monitor_motion", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + input "monitor_presence", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false + } + section("Things to control") { + input "hues", "capability.colorControl", title: "Hue Bulbs", multiple: true, required: false + input "switches", "capability.switch", title: "Switches", multiple: true, required: false + input "dimmers", "capability.switchLevel", title: "Dimmers", multiple: true, required: false + } + section("Voice") { + input "voice", "capability.speechSynthesis", title: "Voice", required: false + } + } +} + + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + subscribe(monitor_switches, "switch", eventHandler) + subscribe(monitor_motion, "motion", eventHandler) + subscribe(monitor_presence, "presence", eventHandler) + subscribe(location, "mode", eventHandler) + subscribe(location, "sunset", eventHandler) + subscribe(location, "sunrise", eventHandler) + tick() +} + +def eventHandler(evt) { + def everyone_here = presense_is_after(monitor_presence, "present", 10) + def everyone_gone = presense_is_after(monitor_presence, "not present", 10) + def current_count = monitor_presence.findAll { it.currentPresence == "present" }.size() + webhook([ + displayName: evt.displayName, + value: evt.value, + daytime: is_daytime(), + mode: location.mode, + current_count: current_count, + everyone_here: everyone_here, + everyone_gone: everyone_gone + ]) + + if (mode == "Pause") { + webhook([ at: 'paused' ]) + log.info("No actions taken in Pause mode") + } else { + def lights = [switches, hues].flatten() + // turn on lights near stairs when motion is detected + if (evt.displayName == "motion@stairs" && evt.value == "active") { + webhook([ at: 'stair_motion_lights' ]) + lights.findAll { s -> + s.displayName == "stairs" || + s.displayName == "loft" || + s.displayName == "entry" + }.findAll { s -> + s.currentSwitch == "off" + }.each { s -> + if ("setLevel" in s.supportedCommands.collect { it.name }) { + if (location.mode == "Sleep") { + s.setLevel(50) + } else if (location.mode == "Home / Night") { + s.setLevel(75) + } else { + s.setLevel(100) + } + } + s.on() + } + } + + // Turn on some lights if one of us is up + if (evt.value == "Yawn") { + webhook([ at: 'yawn' ]) + lights.findAll { s -> + s.displayName == "loft" || + s.displayName == "entry" || + s.displayName == "chandelier" + }.findAll { s -> + s.currentSwitch == "off" + }.each { s -> + if ("setLevel" in s.supportedCommands.collect { it.name }) { + s.setLevel(75) + } + s.on() + } + } + + // turn on all lights when entering day mode + if (evt.value == "Home / Day") { + webhook([ at: 'home_day' ]) + lights.each { s -> + if (s.currentSwitch == "off") { + s.on() + } + if ("setLevel" in s.supportedCommands.collect { it.name }) { + s.setLevel(100) + } + } + } + + // turn on night mode at sunset + if (evt.displayName == "sunset" && current_count > 0 && location.mode == "Home / Day") { + webhook([ at: 'sunset' ]) + changeMode("Home / Night") + } + + // dim lights at night + if (evt.value == "Home / Night") { + webhook([ at: 'home_night' ]) + lights.findAll { s -> + "setLevel" in s.supportedCommands.collect { it.name } + }.each { s -> + log.info("Night mode enabled, dimming ${s.displayName}") + s.setLevel(75) + } + + // turn off that light by the door downstairs entirely + lights.findAll { s -> + s.displayName == "downstairs door" + }.each { s -> + log.info("Night mode enabled, turning off ${s.displayName}") + s.off() + } + } + + // turn off all lights when entering sleep mode + if (evt.value == "Sleep") { + webhook([ at: 'sleep' ]) + lights.findAll { s -> + s.currentSwitch == "on" + }.each { s -> + log.info("Sleep mode enabled, turning off ${s.displayName}") + s.off() + } + } + + // turn off all lights when everyone goes away + if (everyone_gone && location.mode != "Away") { + webhook([ at: 'away' ]) + changeMode("Away") + lights.findAll { s -> + s.currentSwitch == "on" + }.each { s -> + log.info("Away mode enabled, turning off ${s.displayName}") + s.off() + } + } + + // switch mode to Home when we return + if (current_count > 0 && location.mode == "Away") { + webhook([ at: 'home' ]) + changeMode("Home") + } + + // Make home mode specific based on day / night + if (evt.value == "Home") { + webhook([ at: 'home_day_night' ]) + if (is_daytime()) { + changeMode("Home / Day") + } else { + changeMode("Home / Night") + } + } + + if (canSchedule()) { + runIn(61, tick) + } else { + webhook([ can_schedule: 'false' ]) + log.error("can_schedule=false") + } + } +} + +def changeMode(mode) { + //voice?.speak("changing mode to ${mode}") + setLocationMode(mode) + eventHandler([ + displayName: "changeMode", + value: mode + ]) +} + +def tick() { + eventHandler([ + displayName: "tick", + value: "tock" + ]) +} + +def webhook(map) { + def successClosure = { response -> + log.debug "Request was successful, $response" + } + + def json_params = [ + uri: settings.url, + success: successClosure, + body: map + ] + httpPostJson(json_params) +} + + +private is_daytime() { + def data = getWeatherFeature("astronomy") + def sunset = "${data.moon_phase.sunset.hour}${data.moon_phase.sunset.minute}" + def sunrise = "${data.moon_phase.sunrise.hour}${data.moon_phase.sunrise.minute}" + def current = "${data.moon_phase.current_time.hour}${data.moon_phase.current_time.minute}" + if (current.toInteger() > sunrise.toInteger() && current.toInteger() < sunset.toInteger()) { + return true + } + else { + return false + } +} + +private presense_is_after(people, presence, minutes) { + def result = true + for (person in people) { + if (person.currentPresence != presence) { + result = false + break + } else { + def threshold = 1000 * 60 * minutes + def elapsed = now() - person.currentState("presence").rawDateCreated.time + if (elapsed < threshold) { + result = false + break + } + } + } + return result +} diff --git a/third-party/unbuffered-event-sender.groovy b/third-party/unbuffered-event-sender.groovy new file mode 100755 index 0000000..b834419 --- /dev/null +++ b/third-party/unbuffered-event-sender.groovy @@ -0,0 +1,306 @@ +/** + * Initial State Event Streamer (non-buffered) + * + * Copyright 2016 David Sulpy + * + * 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. + * + * SmartThings data is sent from this SmartApp to Initial State. This is event data only for + * devices for which the user has authorized. Likewise, Initial State's services call this + * SmartApp on the user's behalf to configure Initial State specific parameters. The ToS and + * Privacy Policy for Initial State can be found here: https://www.initialstate.com/terms + */ + +definition( + name: "DIY Initial State Event Streamer", + namespace: "initialstate.events", + author: "David Sulpy", + description: "A SmartThings SmartApp to allow SmartThings events to be viewable inside an Initial State Event Bucket in your https://www.initialstate.com account.", + category: "SmartThings Labs", + iconUrl: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertica_small.png", + iconX2Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + iconX3Url: "https://s3.amazonaws.com/initialstate-web-cdn/IS-wordmark-vertical.png", + oauth: [displayName: "Initial State", displayLink: "https://www.initialstate.com"]) + +import groovy.json.JsonSlurper + +preferences { + section("Choose which devices to monitor...") { + input "accelerometers", "capability.accelerationSensor", title: "Accelerometers", multiple: true, required: false + input "alarms", "capability.alarm", title: "Alarms", multiple: true, required: false + input "batteries", "capability.battery", title: "Batteries", multiple: true, required: false + input "beacons", "capability.beacon", title: "Beacons", multiple: true, required: false + input "cos", "capability.carbonMonoxideDetector", title: "Carbon Monoxide Detectors", multiple: true, required: false + input "colors", "capability.colorControl", title: "Color Controllers", multiple: true, required: false + input "contacts", "capability.contactSensor", title: "Contact Sensors", multiple: true, required: false + input "doorsControllers", "capability.doorControl", title: "Door Controllers", multiple: true, required: false + input "energyMeters", "capability.energyMeter", title: "Energy Meters", multiple: true, required: false + input "illuminances", "capability.illuminanceMeasurement", title: "Illuminance Meters", multiple: true, required: false + input "locks", "capability.lock", title: "Locks", multiple: true, required: false + input "motions", "capability.motionSensor", title: "Motion Sensors", multiple: true, required: false + input "musicPlayers", "capability.musicPlayer", title: "Music Players", multiple: true, required: false + input "powerMeters", "capability.powerMeter", title: "Power Meters", multiple: true, required: false + input "presences", "capability.presenceSensor", title: "Presence Sensors", multiple: true, required: false + input "humidities", "capability.relativeHumidityMeasurement", title: "Humidity Meters", multiple: true, required: false + input "relaySwitches", "capability.relaySwitch", title: "Relay Switches", multiple: true, required: false + input "sleepSensors", "capability.sleepSensor", title: "Sleep Sensors", multiple: true, required: false + input "smokeDetectors", "capability.smokeDetector", title: "Smoke Detectors", multiple: true, required: false + input "peds", "capability.stepSensor", title: "Pedometers", multiple: true, required: false + input "switches", "capability.switch", title: "Switches", multiple: true, required: false + input "switchLevels", "capability.switchLevel", title: "Switch Levels", multiple: true, required: false + input "temperatures", "capability.temperatureMeasurement", title: "Temperature Sensors", multiple: true, required: false + input "thermostats", "capability.thermostat", title: "Thermostats", multiple: true, required: false + input "valves", "capability.valve", title: "Valves", multiple: true, required: false + input "waterSensors", "capability.waterSensor", title: "Water Sensors", multiple: true, required: false + } +} + +def subscribeToEvents() { + if (accelerometers != null) { + subscribe(accelerometers, "acceleration", genericHandler) + } + if (alarms != null) { + subscribe(alarms, "alarm", genericHandler) + } + if (batteries != null) { + subscribe(batteries, "battery", genericHandler) + } + if (beacons != null) { + subscribe(beacons, "presence", genericHandler) + } + + if (cos != null) { + subscribe(cos, "carbonMonoxide", genericHandler) + } + if (colors != null) { + subscribe(colors, "hue", genericHandler) + subscribe(colors, "saturation", genericHandler) + subscribe(colors, "color", genericHandler) + } + if (contacts != null) { + subscribe(contacts, "contact", genericHandler) + } + if (energyMeters != null) { + subscribe(energyMeters, "energy", genericHandler) + } + if (illuminances != null) { + subscribe(illuminances, "illuminance", genericHandler) + } + if (locks != null) { + subscribe(locks, "lock", genericHandler) + } + if (motions != null) { + subscribe(motions, "motion", genericHandler) + } + if (musicPlayers != null) { + subscribe(musicPlayers, "status", genericHandler) + subscribe(musicPlayers, "level", genericHandler) + subscribe(musicPlayers, "trackDescription", genericHandler) + subscribe(musicPlayers, "trackData", genericHandler) + subscribe(musicPlayers, "mute", genericHandler) + } + if (powerMeters != null) { + subscribe(powerMeters, "power", genericHandler) + } + if (presences != null) { + subscribe(presences, "presence", genericHandler) + } + if (humidities != null) { + subscribe(humidities, "humidity", genericHandler) + } + if (relaySwitches != null) { + subscribe(relaySwitches, "switch", genericHandler) + } + if (sleepSensors != null) { + subscribe(sleepSensors, "sleeping", genericHandler) + } + if (smokeDetectors != null) { + subscribe(smokeDetectors, "smoke", genericHandler) + } + if (peds != null) { + subscribe(peds, "steps", genericHandler) + subscribe(peds, "goal", genericHandler) + } + if (switches != null) { + subscribe(switches, "switch", genericHandler) + } + if (switchLevels != null) { + subscribe(switchLevels, "level", genericHandler) + } + if (temperatures != null) { + subscribe(temperatures, "temperature", genericHandler) + } + if (thermostats != null) { + subscribe(thermostats, "temperature", genericHandler) + subscribe(thermostats, "heatingSetpoint", genericHandler) + subscribe(thermostats, "coolingSetpoint", genericHandler) + subscribe(thermostats, "thermostatSetpoint", genericHandler) + subscribe(thermostats, "thermostatMode", genericHandler) + subscribe(thermostats, "thermostatFanMode", genericHandler) + subscribe(thermostats, "thermostatOperatingState", genericHandler) + } + if (valves != null) { + subscribe(valves, "contact", genericHandler) + } + if (waterSensors != null) { + subscribe(waterSensors, "water", genericHandler) + } +} + +def installed() { + atomicState.version = "1.0.18 (unbuffered)" + + atomicState.bucketKey = "SmartThings" //change if needed + atomicState.bucketName = "SmartThings" //change if wanted + atomicState.accessKey = "YOUR_ACCESS_KEY" //MUST CHANGE + + subscribeToEvents() + + atomicState.isBucketCreated = false + atomicState.grokerSubdomain = "groker" + + log.debug "installed (version $atomicState.version)" +} + +def updated() { + atomicState.version = "1.0.18 (unbuffered)" + unsubscribe() + + if (atomicState.bucketKey != null && atomicState.accessKey != null) { + atomicState.isBucketCreated = false + } + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + atomicState.grokerSubdomain = "groker" + } + + subscribeToEvents() + + log.debug "updated (version $atomicState.version)" +} + +def uninstalled() { + log.debug "uninstalled (version $atomicState.version)" +} + +def tryCreateBucket() { + + // can't ship events if there is no grokerSubdomain + if (atomicState.grokerSubdomain == null || atomicState.grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + + // if the bucket has already been created, no need to continue + if (atomicState.isBucketCreated) { + return + } + + if (!atomicState.bucketName) { + atomicState.bucketName = atomicState.bucketKey + } + if (!atomicState.accessKey) { + return + } + def bucketName = "${atomicState.bucketName}" + def bucketKey = "${atomicState.bucketKey}" + def accessKey = "${atomicState.accessKey}" + + def bucketCreateBody = new JsonSlurper().parseText("{\"bucketKey\": \"$bucketKey\", \"bucketName\": \"$bucketName\"}") + + def bucketCreatePost = [ + uri: "https://${atomicState.grokerSubdomain}.initialstate.com/api/buckets", + headers: [ + "Content-Type": "application/json", + "X-IS-AccessKey": accessKey + ], + body: bucketCreateBody + ] + + log.debug bucketCreatePost + + try { + // Create a bucket on Initial State so the data has a logical grouping + httpPostJson(bucketCreatePost) { resp -> + log.debug "bucket posted" + if (resp.status >= 400) { + log.error "bucket not created successfully" + } else { + atomicState.isBucketCreated = true + } + } + } catch (e) { + log.error "bucket creation error: $e" + } + +} + +def genericHandler(evt) { + log.trace "$evt.displayName($evt.name:$evt.unit) $evt.value" + + def key = "$evt.displayName($evt.name)" + if (evt.unit != null) { + key = "$evt.displayName(${evt.name}_$evt.unit)" + } + def value = "$evt.value" + + tryCreateBucket() + + eventHandler(key, value) +} + +def eventHandler(name, value) { + def epoch = now() / 1000 + + def event = new JsonSlurper().parseText("{\"key\": \"$name\", \"value\": \"$value\", \"epoch\": \"$epoch\"}") + + tryShipEvents(event) + + log.debug "Shipped Event: " + event +} + +def tryShipEvents(event) { + + def grokerSubdomain = atomicState.grokerSubdomain + // can't ship events if there is no grokerSubdomain + if (grokerSubdomain == null || grokerSubdomain == "") { + log.error "streaming url is currently null" + return + } + def accessKey = atomicState.accessKey + def bucketKey = atomicState.bucketKey + // can't ship if access key and bucket key are null, so finish trying + if (accessKey == null || bucketKey == null) { + return + } + + def eventPost = [ + uri: "https://${grokerSubdomain}.initialstate.com/api/events", + headers: [ + "Content-Type": "application/json", + "X-IS-BucketKey": "${bucketKey}", + "X-IS-AccessKey": "${accessKey}", + "Accept-Version": "0.0.2" + ], + body: event + ] + + try { + // post the events to initial state + httpPostJson(eventPost) { resp -> + log.debug "shipped events and got ${resp.status}" + if (resp.status >= 400) { + log.error "shipping failed... ${resp.data}" + } + } + } catch (e) { + log.error "shipping events failed: $e" + } + +} \ No newline at end of file