Light the Way: A powerful Groovy smart lighting solution for many lights and conditions

smartapp
smartlighting

(Ross Tyler) #1

I would like a smart lighting solution for the perimeter of my house to “Light the Way” for myself and my guests as we make our way around it.

For example, consider a simple scenario with three positions that require lighting: the driveway (where guests arrive), the walkway (to the front door) and the front porch. Using various sensors, I would like the lights to be brightest where the guest currently is and for the brightness of further lights to fade away by distance.

Unfortunately, I have not found an existing SmartThings solution to solve this simple problem – much less my real problem which involves many more lights, many more sensors and other conditions. I have had some success with webCoRE but found that I quickly outgrew its limitations and that maintenance was a nightmare.

It seems that I had to bite the bullet and write my own SmartApp. My objectives were to make it simple, powerful, extendable, readable and maintainable. I should be able to easily state any reasonable condition within expressions that, when evaluated, will update (as necessary) the lighting levels at all my positions. Adding and removing devices should be as simple as removing them from typed lists and considering them in these expressions. The rest of the code should be boilerplate.

Considering the example, this is how I might express the brightness level of my DriveWay Switch Level (dimming) capable device (DWSL).

    setLevel DWSL, findValue(
        {valueIf 0  , {sun}},
        {valueIf 0  , {findBrighter(64, DWLIM, DWRIM) && !ignoreIlluminance}},
        {valueIf 100, {findMotion       DWLMS, DWRMS}},
        {valueIf 100, {findOpen         GDCS}},
        {valueIf 50 , {findMotion       WWMS}},
        {valueIf 25 , {findMotion       FPMS}},
        {valueIf 25 , {findOpen         FDCS}},
        {valueIf 5  , {background}},
    )

This says the level should be

  • 0, if the sun is up; otherwise,
  • 0, if either the Left or Right DriveWay Illuminance Measurement (DWLIM, DWRIM) is brighter than 64 lux (and we should not ignoreIlluminance); otherwise,
  • 100, if either the Left or Right DriveWay Motion Sensor (DWLMS, DWRMS) has detected motion; otherwise,
  • 100, if the Garage Door Contact Sensor (GDCS) is open; otherwise,
  • 50, if the WalkWay Motion Sensor (WWMS) has detected motion; otherwise,
  • 25, if the Front Porch Motion Sensor (FPMS) has detected motion; otherwise,
  • 25, if the Front Door Contact Sensor (FDCS) is open; otherwise,
  • 5, if now is within the scheduled background lighting interval; otherwise
  • undefined

It should be easy to imagine how one might express the lighting conditions to consider for other positions.

The only other thing that is not boilerplate in my SmartApp are the property getters for the devices, by type

def getContactSensors() {[
    FDCS    : 'Front Door',
    GDCS    : 'Garage Door',
]}
def getIlluminanceMeasurements() {[
    DWLIM   : 'Driveway Left',
    DWRIM   : 'Driveway Right',
    FPIM    : 'Front Porch',
    WWIM    : 'Walkway',
]}
def getMotionSensors() {[
    DWLMS   : 'Driveway Left',
    DWRMS   : 'Driveway Right',
    FPMS    : 'Front Porch',
    WWMS    : 'Walkway',
]}
def getSwitches() {[
]}
def getSwitchLevels() {[
    DWSL    : 'Driveway',
    FPSL    : 'Front Porch',
    WWSL    : 'Walkway',
]}

It should be easy to imagine adding and removing such devices with their conditional lighting expressions.

That’s it! Initial setup and maintenance is easy (tend to the device lists and setLevel expressions). Expressions are simple, powerful, extendable and readable.

The rest is all boilerplate. Skip to the bottom for the whole thing.

The setLevel method only commands the device when it thinks it needs to be changed.

// set on/off and/or level of a Switch and/or Switch Level capable device
// if it is different from the state remembered before.
def setLevel(DeviceWrapper device, Integer value) {
    // signed integer values encode on or off (positive or not)
    // and level (magnitude)
    int         newValue    = value ?: 0
    boolean     newOn       = 0 < newValue
    int         newLevel    = Math.abs newValue
    String      id          = device.id
    Integer     oldValue    = state.get id
    // if (null == oldValue) {  // default values:
        Boolean oldOn       = null
        Integer oldLevel    = null
        boolean setOn       = true
        boolean setLevel    = true
    // } else {                 // override defaults:
    if (null != oldValue) {
                oldOn       = 0 < oldValue
                oldLevel    = Math.abs oldValue
                setOn       = newOn != oldOn
                setLevel    = newOn && newLevel != oldLevel
    }
    if (setOn || setLevel) {
        state.put id, newOn || null == oldLevel ? newValue : -oldLevel
        boolean hasSwitch       = device.hasCapability 'Switch'
        boolean hasSwitchLevel  = device.hasCapability 'Switch Level'
        if (setOn) {
            if (hasSwitch) {
                if (newOn)  {device.on()    ; log.info "⚪  ← $oldLevel $device"}
                else        {device.off()   ; log.info "⚫  ← $oldLevel $device"}
            } else {
                setLevel = true
            }
        }
        if (setLevel && hasSwitchLevel) {
            device.setLevel newLevel        ; log.info "↕ $newLevel ← $oldLevel $device"
        }
    }
}

The findValue and valueIf are simple helper functions that look like this

// return the value of the first closure that returns non-null
def findValue(Closure... closures) {
    def result
    closures.find {result = it(); null != result}
    result
}

// return the value if the predicate() is true; otherwise, null
def valueIf(value, Closure predicate) {predicate() ? value : null}

Note the Groovy capability to provide a variable number of Closure arguments makes writing such expressions simple.

The primitives used in these expressions are

// return the first contactSensor
// whose currentContact is open; otherwise, null
def findOpen(DeviceWrapper... contactSensors) {
    contactSensors.find {'open' == it.currentContact}
}

// return the first illuminanceMeasurement
// whose currentIlluminance is greater than threshold; otherwise, null
def findBrighter(Number threshold, DeviceWrapper... illuminanceMeasurements) {
    illuminanceMeasurements.find {threshold < it.currentIlluminance}
}

// return the first motionSensor
// whose currentMotion is active; otherwise, null
def findMotion(DeviceWrapper... motionSensors) {
    motionSensors.find {'active' == it.currentMotion}
}

// respond according to current state
def respond() {
    Date now = new Date()
    TimeZone timeZone = location.getTimeZone()

    // consider the sun if we should not ignore it and
    // now is within interval [sunrise, sunset)
    def sunriseAndSunset = getSunriseAndSunset()
    boolean sun = !ignoreSun && timeOfDayIsBetween(
        sunriseAndSunset.sunrise,
        new Date(sunriseAndSunset.sunset.getTime() - 1),
        now, timeZone)

    // consider retaining a background lighting level (as opposed to off)
    // if we are ignoring the on/off schedule, we don't have one or
    // now is within the [On, Off) interval
    boolean background = ignoreSchedule || !(On && Off) || timeOfDayIsBetween(
        nextOccurrence(On),
        new Date(nextOccurrence(Off).getTime() - 1),
        now, timeZone)

It should be easy to imagine writing more primitives for potential consideration in these expressions.

My event handlers log things in a pretty way and call the general respond() method

def respondToSun(EventWrapper e) {
    log.info indent + "☀️ $e.name $e.location"
    respond()
}

def respondToScheduleOn() {
    log.info indent + "⏰️  on schedule"
    respond()
}
def respondToScheduleOff() {
    log.info indent + "⏰️  off schedule"
    respond()
}

def respondToContact(EventWrapper e) {
    log.info indent + "⚡  $e.value $e.name $e.device"
    respond()
}

def respondToIlluminance(EventWrapper e) {
    log.info indent + "☼ $e.value $e.name $e.device"
    respond()
}

def respondToMotion(EventWrapper e) {
    log.info indent + "⚽  $e.value $e.name $e.device"
    respond()
}

def getIndent() {/* non-breaking space */ '\u00a0' * 8}

Initialization looks like this

def logState(DeviceWrapper device) {
    Integer value = state.get device.id
    if (null == value) {
        log.info "❓  null $device"
    } else {
        boolean on = 0 < value
        int level = Math.abs value
        log.info "${on ? '⚪ ' : '⚫ '} $level $device"
    }
}

def initialize() {
    if (clearState) {
        // state.clear  // this doesn't work. why? this does:
        (state.keySet() as String[]).each {state.remove it}
    }
    switches    .each {name, title -> logState settings.get(name)}
    switchLevels.each {name, title -> logState settings.get(name)}
    ['sunrise', 'sunset'].each {
        subscribe   location            , it            , respondToSun}
    if (On && Off) ['On', 'Off'].each {name ->
        schedule    settings.get(name)                  , 'respondToSchedule' + name}
    contactSensors.each {name, title ->
        subscribe   settings.get(name)  , 'contact'     , respondToContact}
    illuminanceMeasurements.each {name, title ->
        subscribe   settings.get(name)  , 'illuminance' , respondToIlluminance}
    motionSensors.each {name, title ->
        subscribe   settings.get(name)  , 'motion'      , respondToMotion}
    respond()
}

Preferences are written in terms of the device lists

preferences {
    section('Flags') {
        input 'clearState', 'bool', title: 'Clear State'
        ['Illuminance', 'Schedule', 'Sun'].each {name ->
            input 'ignore' + name   , 'bool'                                , title: 'Ignore ' + name}
    }
    section('Schedule') {
        ['On', 'Off'].each {name ->
            input name              , 'time'                                , title: name + ' Time', required: false}
    }
    section('Contact Sensors') {
        contactSensors.each {name, title ->
            input name              , 'capability.contactSensor'            , title: title + ' Contact Sensor'}
    }
    section('Illuminance Measurements') {
        illuminanceMeasurements.each {name, title ->
            input name              , 'capability.illuminanceMeasurement'   , title: title + ' Illuminance Measurement'}
    }
    section('Motion Sensors') {
        motionSensors.each {name, title ->
            input name              , 'capability.motionSensor'             , title: title + ' Motion Sensor'}
    }
    section('Switches') {
        switches.each {name, title ->
            input name              , 'capability.switch'                   , title: title + ' Switch'}
        switchLevels.each {name, title  ->
            input name              , 'capability.switchLevel'              , title: title + ' Switch Level'}
    }
}

def installed() {
    log.debug "installed with settings: ${settings}"
    initialize()
}

def updated() {
    log.debug "updated with settings: ${settings}"
    unsubscribe()
    unschedule()
    initialize()
}

Altogether, my “Light the Way” SmartApp looks like this

// vim: ts=4:sw=4:expandtab
/**
 * Light the Way
 *
 * Copyright 2018 Ross Tyler
 *
 * 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        : 'Light the Way',
    namespace   : 'rtyle',
    author      : 'Ross Tyler',
    description : 'Light the way around this location',
    category    : "Convenience",
    iconUrl     : 'http://cdn.device-icons.smartthings.com/Home/home5-icn.png',
    iconX2Url   : 'http://cdn.device-icons.smartthings.com/Home/home5-icn@2x.png',
    iconX3Url   : 'http://cdn.device-icons.smartthings.com/Home/home5-icn@3x.png',
)

import physicalgraph.app.DeviceWrapper
import physicalgraph.app.EventWrapper


def getContactSensors() {[
    FDCS    : 'Front Door',
    GDCS    : 'Garage Door',
]}
def getIlluminanceMeasurements() {[
    DWLIM   : 'Driveway Left',
    DWRIM   : 'Driveway Right',
    FPIM    : 'Front Porch',
    WWIM    : 'Walkway',
]}
def getMotionSensors() {[
    DWLMS   : 'Driveway Left',
    DWRMS   : 'Driveway Right',
    FPMS    : 'Front Porch',
    WWMS    : 'Walkway',
]}
def getSwitches() {[
]}
def getSwitchLevels() {[
    DWSL    : 'Driveway',
    FPSL    : 'Front Porch',
    WWSL    : 'Walkway',
]}

// respond according to current state
def respond() {
    Date now = new Date()
    TimeZone timeZone = location.getTimeZone()

    // consider the sun if we should not ignore it and
    // now is within interval [sunrise, sunset)
    def sunriseAndSunset = getSunriseAndSunset()
    boolean sun = !ignoreSun && timeOfDayIsBetween(
        sunriseAndSunset.sunrise,
        new Date(sunriseAndSunset.sunset.getTime() - 1),
        now, timeZone)

    // consider retaining a background lighting level (as opposed to off)
    // if we are ignoring the on/off schedule, we don't have one or
    // now is within the [On, Off) interval
    boolean background = ignoreSchedule || !(On && Off) || timeOfDayIsBetween(
        nextOccurrence(On),
        new Date(nextOccurrence(Off).getTime() - 1),
        now, timeZone)

    setLevel DWSL, findValue(
        {valueIf 0  , {sun}},
        {valueIf 0  , {findBrighter(64, DWLIM, DWRIM) && !ignoreIlluminance}},
        {valueIf 100, {findMotion       DWLMS, DWRMS}},
        {valueIf 100, {findOpen         GDCS}},
        {valueIf 50 , {findMotion       WWMS}},
        {valueIf 25 , {findMotion       FPMS}},
        {valueIf 25 , {findOpen         FDCS}},
        {valueIf 5  , {background}},
    )
    // ...
}

// set on/off and/or level of a Switch and/or Switch Level capable device
// if it is different from the state remembered before.
def setLevel(DeviceWrapper device, Integer value) {
    // signed integer values encode on or off (positive or not)
    // and level (magnitude)
    int         newValue    = value ?: 0
    boolean     newOn       = 0 < newValue
    int         newLevel    = Math.abs newValue
    String      id          = device.id
    Integer     oldValue    = state.get id
    // if (null == oldValue) {  // default values:
        Boolean oldOn       = null
        Integer oldLevel    = null
        boolean setOn       = true
        boolean setLevel    = true
    // } else {                 // override defaults:
    if (null != oldValue) {
                oldOn       = 0 < oldValue
                oldLevel    = Math.abs oldValue
                setOn       = newOn != oldOn
                setLevel    = newOn && newLevel != oldLevel
    }
    if (setOn || setLevel) {
        state.put id, newOn || null == oldLevel ? newValue : -oldLevel
        boolean hasSwitch       = device.hasCapability 'Switch'
        boolean hasSwitchLevel  = device.hasCapability 'Switch Level'
        if (setOn) {
            if (hasSwitch) {
                if (newOn)  {device.on()    ; log.info "⚪  ← $oldLevel $device"}
                else        {device.off()   ; log.info "⚫  ← $oldLevel $device"}
            } else {
                setLevel = true
            }
        }
        if (setLevel && hasSwitchLevel) {
            device.setLevel newLevel        ; log.info "↕ $newLevel ← $oldLevel $device"
        }
    }
}

// return the value of the first closure that returns non-null
def findValue(Closure... closures) {
    def result
    closures.find {result = it(); null != result}
    result
}

// return the value if the predicate() is true; otherwise, null
def valueIf(value, Closure predicate) {predicate() ? value : null}

// return the first contactSensor
// whose currentContact is open; otherwise, null
def findOpen(DeviceWrapper... contactSensors) {
    contactSensors.find {'open' == it.currentContact}
}

// return the first illuminanceMeasurement
// whose currentIlluminance is greater than threshold; otherwise, null
def findBrighter(Number threshold, DeviceWrapper... illuminanceMeasurements) {
    illuminanceMeasurements.find {threshold < it.currentIlluminance}
}

// return the first motionSensor
// whose currentMotion is active; otherwise, null
def findMotion(DeviceWrapper... motionSensors) {
    motionSensors.find {'active' == it.currentMotion}
}

def respondToSun(EventWrapper e) {
    log.info indent + "☀️ $e.name $e.location"
    respond()
}

def respondToScheduleOn() {
    log.info indent + "⏰️  on schedule"
    respond()
}
def respondToScheduleOff() {
    log.info indent + "⏰️  off schedule"
    respond()
}

def respondToContact(EventWrapper e) {
    log.info indent + "⚡  $e.value $e.name $e.device"
    respond()
}

def respondToIlluminance(EventWrapper e) {
    log.info indent + "☼ $e.value $e.name $e.device"
    respond()
}

def respondToMotion(EventWrapper e) {
    log.info indent + "⚽  $e.value $e.name $e.device"
    respond()
}

def getIndent() {/* non-breaking space */ '\u00a0' * 8}

def logState(DeviceWrapper device) {
    Integer value = state.get device.id
    if (null == value) {
        log.info "❓  null $device"
    } else {
        boolean on = 0 < value
        int level = Math.abs value
        log.info "${on ? '⚪ ' : '⚫ '} $level $device"
    }
}

def initialize() {
    if (clearState) {
        // state.clear  // this doesn't work. why? this does:
        (state.keySet() as String[]).each {state.remove it}
    }
    switches    .each {name, title -> logState settings.get(name)}
    switchLevels.each {name, title -> logState settings.get(name)}
    ['sunrise', 'sunset'].each {
        subscribe   location            , it            , respondToSun}
    if (On && Off) ['On', 'Off'].each {name ->
        schedule    settings.get(name)                  , 'respondToSchedule' + name}
    contactSensors.each {name, title ->
        subscribe   settings.get(name)  , 'contact'     , respondToContact}
    illuminanceMeasurements.each {name, title ->
        subscribe   settings.get(name)  , 'illuminance' , respondToIlluminance}
    motionSensors.each {name, title ->
        subscribe   settings.get(name)  , 'motion'      , respondToMotion}
    respond()
}

preferences {
    section('Flags') {
        input 'clearState', 'bool', title: 'Clear State'
        ['Illuminance', 'Schedule', 'Sun'].each {name ->
            input 'ignore' + name   , 'bool'                                , title: 'Ignore ' + name}
    }
    section('Schedule') {
        ['On', 'Off'].each {name ->
            input name              , 'time'                                , title: name + ' Time', required: false}
    }
    section('Contact Sensors') {
        contactSensors.each {name, title ->
            input name              , 'capability.contactSensor'            , title: title + ' Contact Sensor'}
    }
    section('Illuminance Measurements') {
        illuminanceMeasurements.each {name, title ->
            input name              , 'capability.illuminanceMeasurement'   , title: title + ' Illuminance Measurement'}
    }
    section('Motion Sensors') {
        motionSensors.each {name, title ->
            input name              , 'capability.motionSensor'             , title: title + ' Motion Sensor'}
    }
    section('Switches') {
        switches.each {name, title ->
            input name              , 'capability.switch'                   , title: title + ' Switch'}
        switchLevels.each {name, title  ->
            input name              , 'capability.switchLevel'              , title: title + ' Switch Level'}
    }
}

def installed() {
    log.debug "installed with settings: ${settings}"
    initialize()
}

def updated() {
    log.debug "updated with settings: ${settings}"
    unsubscribe()
    unschedule()
    initialize()
}

Lights turn on based on multiple criteria (AND)
SmartApp code gets a TimeoutException *only* when run part of a scheduled job