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()
}