diff --git a/smartapps/imbrianj/nobody-home.src/nobody-home.groovy b/smartapps/imbrianj/nobody-home.src/nobody-home.groovy index 2588ea1..a7669ae 100644 --- a/smartapps/imbrianj/nobody-home.src/nobody-home.groovy +++ b/smartapps/imbrianj/nobody-home.src/nobody-home.groovy @@ -1,164 +1,427 @@ /** * Nobody Home * - * Author: brian@bevey.org - * Date: 12/19/14 + * Author: brian@bevey.org, raychi@gmail.com + * Date: 12/02/2015 * - * 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. + * 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: "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" +/** + * Monitors a set of presence sensors and trigger appropriate mode + * based on configured modes and sunrise/sunset time. + * + * - When everyone is away [Away] + * - When someone is home during the day [Home] + * - When someone is home at the night [Night] + */ + +// ********** App related functions ********** + +// The definition provides metadata about the App to SmartThings. +definition ( + name: "Nobody Home", + namespace: "imbrianj", + author: "brian@bevey.org", + description: "Automatically set Away/Home/Night mode based on a set of presence sensors and sunrise/sunset time.", + category: "Mode Magic", + 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" ) +// The preferences defines information the App needs from the user. 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) - } + section("Presence sensors to monitor") { + input "people", "capability.presenceSensor", multiple: true } - 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) + section("Mode setting") { + input "newAwayMode", "mode", title: "Everyone is away" + input "newSunriseMode", "mode", title: "Someone is home during the day" + input "newSunsetMode", "mode", title: "Someone is home at night" } - else { - log.debug("Mode is the same, not evaluating") + section("Mode change delay (minutes)") { + input "awayThreshold", "decimal", title: "Away delay [5m]", required: false + input "arrivalThreshold", "decimal", title: "Arrival delay [2m]", required: false } - } - else { - log.info("Somebody returned home before we set to '${newAwayMode}'") - } + section("Notifications") { + input "sendPushMessage", "bool", title: "Push notification", required:false + } } -private everyoneIsAway() { - def result = true - - if(people.findAll { it?.currentPresence == "present" }) { - result = false - } - - log.debug("everyoneIsAway: ${result}") - - return result +// called when the user installs the App +def installed() +{ + log.debug("installed() @${location.name}: ${settings}") + initialize(true) } -private anyoneIsHome() { - def result = false - - if(people.findAll { it?.currentPresence == "present" }) { - result = true - } - - log.debug("anyoneIsHome: ${result}") - - return result +// called when the user installs the app, or changes the App +// preference +def updated() +{ + log.debug("updated() @${location.name}: ${settings}") + unsubscribe() + initialize(false) } -private send(msg) { - if(sendPushMessage != "No") { - log.debug("Sending push message") - sendPush(msg) - } +def initialize(isInstall) +{ + // subscribe to all the events we care about + log.debug("Subscribing to events ...") - log.debug(msg) + // thing to subscribe, attribute/state we care about, and callback fn + subscribe(people, "presence", presenceHandler) + subscribe(location, "sunrise", sunriseHandler) + subscribe(location, "sunset", sunsetHandler) + + // set the optional parameter values. these are not available + // directly until the app has initialized (that is, + // installed/updated has returned). so here we access them through + // the settings object, as otherwise will get an exception. + + // store information we need in state object so we can get access + // to it later in our event handlers. + + // calculate the away threshold in seconds. can't use the simpler + // default falsy value, as value of 0 (no delay) is evaluated to + // false (not specified), but we want 0 to represent no delay. so + // we compare against null explicitly to see if the user has set a + // value or not. + if (settings.awayThreshold == null) { + settings.awayThreshold = 5 // default away 5 minute + } + state.awayDelay = (int) settings.awayThreshold * 60 + log.debug("awayThreshold set to " + state.awayDelay + " second(s)") + + if (settings.arrivalThreshold == null) { + settings.arrivalThreshold = 2 // default arrival 2 minute + } + state.arrivalDelay = (int) settings.arrivalThreshold * 60 + log.debug("arrivalThreshold set to " + state.arrivalDelay + " second(s)") + + // get push notification setting + state.isPush = settings.sendPushMessage ? true : false + log.debug("sendPushMessage set to " + state.isPush) + + // on install (not update), figure out what mode we should be in + // IF someone's home. This value is needed so that when a presence + // sensor is triggered, we know what mode to set the system to, as + // the sunrise/sunset event handler may not be triggered yet after + // a fresh install. + if (isInstall) { + // TODO: for now, we simply assume daytime. a better approach + // would be to figure out whether current time is day or + // night, and set it appropriately. However there + // doesn't seem to be a way to query this directly + // without a zip code. This will become the correct + // value at the next sunrise/sunset event. + log.debug("No sun info yet, assuming daytime") + state.modeIfHome = newSunriseMode + + // set keep a separate sun mode state so we can show a more + // helpful message when sun events are triggered. + state.currentSunMode = "sunUnknown" + + state.eventDevice = "" // last event device + + // device that triggered timer. This is not necessarily the + // eventDevice. For example, if A arrives, kick off timer, + // then b arrives before timer elapsed, we want the + // notification message to reference A, not B. + state.timerDevice = null + + // anything in flight? We use this to avoid scheduling + // duplicate timers (so we don't extend the timer). + state.pendingOp = "init" + + // now set the correct mode for the location. This way, we + // don't need to wait for the next sun/presence event. + + // we schedule this action to run after the app has fully + // initialized. This way, the app install is faster and the + // user customized app name is used in the notification. + runIn(7, "setInitialMode") + } + // On update, we don't change state.modeIfHome. This is so that we + // preserve the current sun rise/set state we obtained in earlier + // sunset/sunrise handler. This way the app remains in the correct + // sun state when the user reconfigures it. } + +def setInitialMode() +{ + changeSunMode(state.modeIfHome) + state.pendingOp = null +} + +// ********** sunrise/sunset handling ********** + +// event handler when the sunrise time is reached +def sunriseHandler(evt) +{ + // we store the mode we should be in, IF someone's home + state.modeIfHome = newSunriseMode + state.currentSunMode = "sunRise" + + // change mode if someone's home, otherwise set to away + changeSunMode(newSunriseMode) +} + +// event handler when the sunset time is reached +def sunsetHandler(evt) +{ + // we store the mode we should be in, IF someone's home + state.modeIfHome = newSunsetMode + state.currentSunMode = "sunSet" + + // change mode if someone's home, otherwise set to away + changeSunMode(newSunsetMode) +} + +def changeSunMode(newMode) +{ + // if everyone is away, we need to check and ensure the system is + // in away mode. + if (isEveryoneAway()) { + // this shouldn not happen normally as the mode should already + // be changed during presenceHandler, but in case this is not + // done, such as when app is initially installed while away, + // and system is not in away mode, then we toggle it to away + // at the sun rise/set event. + changeMode(newAwayMode, " because no one is present") + } else { + // someone is home, we update the mode depending on + // sunrise/sunset. + if (state.currentSunMode == "sunRise") { + changeMode(newMode, " because it's sunrise") + } else if (state.currentSunMode == "sunSet") { + changeMode(newMode, " because it's sunset") + } else { + changeMode(newMode) + } + } +} + +// ********** presence handling ********** + +// event handler when presence sensor changes state +def presenceHandler(evt) +{ + // get the device name that resulted in the change + state.eventDevice= evt.device?.displayName + + // is setInitialMode() still pending? + if (state.pendingOp == "init") { + log.debug("Pending ${state.pendingOp} op still in progress, ignoring presence event") + return + } + + if (evt.value == "not present") { + handleDeparture() + } else { + handleArrival() + } +} + +def handleDeparture() +{ + log.info("${state.eventDevice} left ${location.name}") + + // do nothing if someone's still home + if (!isEveryoneAway()) { + log.info("Someone is still present, no actions needed") + return + } + + // Now we set away mode. We perform the following actions even if + // home is already in away mode because an arrival timer may be + // pending, and scheduling delaySetMode() has the nice effect of + // canceling any previous pending timer, which is what we want to + // do. So we do this even if delay is 0. + log.info("Scheduling ${newAwayMode} mode in " + state.awayDelay + "s") + state.pendingOp = "away" + state.timerDevice = state.eventDevice + // we always use runIn(). This has the benefit of automatically + // replacing any pending arrival/away timer. if any arrival timer + // is active, it will be clobbered with this away timer. If any + // away timer is active, it will be extended with this new timeout + // (though normally it should not happen) + runIn(state.awayDelay, "delaySetMode") +} + +def handleArrival() +{ + // someone returned home, set home/night mode after delay + log.info("${state.eventDevice} arrived at ${location.name}") + + def numHome = isAnyoneHome() + if (!numHome) { + // no one home, do nothing for now (should NOT happen) + log.warn("${deviceName} arrived, but isAnyoneHome() returned false!") + return + } + + if (numHome > 1) { + // not the first one home, do nothing, as any action that + // should happen would've happened when the first sensor + // arrived. this is the opposite of isEveryoneAway() where we + // don't do anything if someone's still home. + log.debug("Someone is already present, no actions needed") + return + } + + // check if any pending arrival timer is already active. we want + // the timer to trigger when the 1st person arrives, but not + // extended when the 2nd person arrives later. this should not + // happen because of the >1 check above, but just in case. + if (state.pendingOp == "arrive") { + log.debug("Pending ${state.pendingOp} op already in progress, do nothing") + return + } + + // now we set home/night mode + log.info("Scheduling ${state.modeIfHome} mode in " + state.arrivalDelay + "s") + state.pendingOp = "arrive" + state.timerDevice = state.eventDevice + // if any away timer is active, it will be clobbered with + // this arrival timer + runIn(state.arrivalDelay, "delaySetMode") +} + + +// ********** helper functions ********** + +// change the system to the new mode, unless its already in that mode. +def changeMode(newMode, reason="") +{ + if (location.mode != newMode) { + // notification message + def message = "${location.name} changed mode from '${location.mode}' to '${newMode}'" + reason + setLocationMode(newMode) + send(message) // send message after changing mode + } else { + log.debug("${location.name} is already in ${newMode} mode, no actions needed") + } +} + +// create a useful departure/arrival reason string +def reasonStr(isAway, delaySec, delayMin) +{ + def reason + + // if we are invoked by timer, use the stored timer trigger + // device, otherwise use the last event device + if (state.timerDevice) { + reason = " because ${state.timerDevice} " + } else { + reason = " because ${state.eventDevice} " + } + + if (isAway) { + reason += "left" + } else { + reason += "arrived" + } + + if (delaySec) { + if (delaySec > 60) { + if (delayMin == null) { + delayMin = (int) delaySec / 60 + } + reason += " ${delayMin} minutes ago" + } else { + reason += " ${delaySec}s ago" + } + } + + return reason +} + +// http://docs.smartthings.com/en/latest/smartapp-developers-guide/scheduling.html#schedule-from-now +// +// By default, if a method is scheduled to run in the future, and then +// another call to runIn with the same method is made, the last one +// overwrites the previously scheduled method. +// +// We use the above property to schedule our arrval/departure delay +// using the same function so we don't have to worry about +// arrival/departure timer firing independently and complicating code. +def delaySetMode() +{ + def newMode = null + def reason = "" + + // timer has elapsed, check presence status to figure out what we + // need to do + if (isEveryoneAway()) { + reason = reasonStr(true, state.awayDelay, awayThreshold) + newMode = newAwayMode + if (state.pendingOp) { + log.debug("${state.pendingOp} timer elapsed: everyone is away") + } + } else { + reason = reasonStr(false, state.arrivalDelay, arrivalThreshold) + newMode = state.modeIfHome + if (state.pendingOp) { + log.debug("${state.pendingOp} timer elapsed: someone is home") + } + } + + // now change the mode + changeMode(newMode, reason); + + state.pendingOp = null + state.timerDevice = null +} + +private isEveryoneAway() +{ + def result = true + + if (people.findAll { it?.currentPresence == "present" }) { + result = false + } + // log.debug("isEveryoneAway: ${result}") + + return result +} + +// return the number of people that are home +private isAnyoneHome() +{ + def result = 0 + // iterate over our people variable that we defined + // in the preferences method + for (person in people) { + if (person.currentPresence == "present") { + result++ + } + } + return result +} + +private send(msg) +{ + if (state.isPush) { + log.debug("Sending push notification") + sendPush(msg) + } else { + log.debug("Sending notification") + sendNotificationEvent(msg) + } + log.info(msg) +} \ No newline at end of file