Thanks to the recently added features in JRuby OpenHAB Rules System, I’ve managed to simplify my motion trigger rules.
The idea is simple: turn on some lights for a set duration when triggered by motion detector(s).
But I want to tweak the conditions, and how the lights are turned on (brightness, color temp) depending on complex conditions, and not have to repeat the timer / reset logic. Also each light / room / sensors have their own peculiarities that writing a generic rule doesn’t work for me.
I want to be able to add more motion trigger rules easily and quickly for new sensors / lights.
Example rules:
require 'openhab'
require 'personal/motiontrigger_dsl'
# The simplest trigger:
# turn on Hallway_Light3 when ToolCupBoard_Motion is detected
# The default duration is 2 minutes, but when more motion is detected
# the timer is extended by another 2 minutes since last motion
rule 'MT: Tool Cupboard' do
trigger ToolCupBoard_Motion
turn_on Hallway_Light3
end
# Conditional duration, make two rules
rule 'MT: Wardrobe non sleep mode' do
trigger Wardrobe_Motion
not_if Sleep_Mode
turn_on Wardrobe_Light
end
# A different duration when Sleep_Mode is on
rule 'MT: Wardrobe sleep mode' do
trigger Wardrobe_Motion
only_if Sleep_Mode
turn_on Wardrobe_Light, for: 75.seconds
end
# Multiple sensors can trigger a light
rule 'MT: Front Porch' do
trigger FrontPorch_Motion
trigger FrontPorch_Camera_Motion
trigger Driveway_Camera_Motion
only_if { Sun_Elevation.negative? }
turn_on FrontPorch_Light, for: 20.minutes
end
# Extender: an additional motion sensor that doesn't trigger the light
# but it will extend the timer when motion is detected
# Also when the light is turned on manually (not by motion trigger),
# it will turn off automatically in 1 hour.
rule 'MT: Study Room' do
trigger StudyRoom_Motion2
extender StudyRoom_Motion
turn_on StudyRoom_Light, for: 30.minutes, manual: 1.hour
end
# The hallway is a long L shape, with multiple different sensors in various rooms
# set up to trigger the hallway light
# Demonstrates the use of a block to be executed prior to turning on the light
# to set the colour temperature based on Sun Elevation (i.e. day vs night)
rule 'MT: Hallway Lights NON sleep mode' do
trigger Hallway_Motion
trigger Hallway_Motion2
trigger LaundryRoom_Motion2
trigger LoungeRoom_Motion
turn_on(Hallway_Light_Power, for: 1.hour) do
Hallway_Light_Dimmer << 50
Hallway_Light_CT << (Sun_Elevation < 10 ? 100 : 0)
end
end
# automatically turn off lights when it was turned on manually
# No triggers, just extenders
# The extender can be any changes in the TV Volume / app / channel / mute/unmute/play/pause
# gLoungeRoomTV is a group to which all the TV items belong
rule 'MT: LoungeRoom' do
extender LoungeRoom_Motion
extender gLoungeRoomTV.members, to: nil
turn_on LoungeRoom_Light1, manual: 1.hour
turn_on LoungeRoom_Light2, manual: 1.hour
end
Features:
- Have multiple motion sensors (or door contact sensors) to trigger a light (or multiple lights)
- Not limited to a strict item naming convention / pattern. FrontPorch_Motion sensor can trigger the Garage_Light if you wish.
- The default duration is 2.minutes but it can be overridden for each trigger case
- Multiple conditions can be specified (using an actual code / proc) to determine whether the trigger should take place. E.g. Check for lux level, or time of day, sun elevation, etc.
- Pre-action can be specified for each light, e.g. to adjust dimmer level
- When the relevant light(s) were turned on manually (not by the trigger), specify a longer duration (I call it
manual_duration
for the lack of a better name) to turn it off. This avoids the annoying premature darkness when working in the room. - Optional debounce feature, to prevent immediate re-triggering after the light was turned off manually
Comments/suggestions both on the coding (I’m a Ruby beginner, so please don’t hesitate to criticise my coding style) and the general concept / functionality would be most welcome.
The motiontrigger_dsl.rb
is a library to be placed in conf/automation/lib/ruby/personal/
#
# Provides user-friendly methods for constructing motion trigger rules
#
# It consists of:
# * trigger ItemName # an item such as a motion sensor that will turn on the light
# * extender ItemName # an item such as a motion sensor that will only extend the timer, but not actually trigger
# * turn_on LightItem # an item to turn on when the trigger item / sensor received an update.
# Parameters:
# for: the duration to keep the light on after being triggered. Default: 2.minutes
# manual: the duration to keep the light on when manually turned on (not triggered)
# manually turned on lights will stay on indefinitely when this is nil
# Default: 1.hour
# debounce: the duration to ignore triggers after the light item turned off
# Default: nil (i.e. no debouncing)
# a block to execute before turning on the light
# This block can be used to adjust the light's brightness/colour
# If the block returns false or nil, don't turn on the item
#
# Multiple triggers, extenders, and turn_on can be specified within the same rule
# to have multiple sensors turn on multiple lights.
#
# NOTE: This will shadow / override the `trigger` method from openhab-jruby which does a custom trigger type!
#
# Example rule:
#
# rule 'Simple rule' do
# trigger LivingRoom_Motion
# turn_on LivingRoom_Light # Turn on the light for the default duration (2 minutes)
# end
#
# rule 'MT: Laundry Room' do
# trigger LaundryRoom_Motion
# extender LaundryRoom_Motion2 # this sensor picks up motion outside the laundry room too, so only use it as an extender
# only_if { Sun_Elevation.negative? || LaundryRoom_Light.on? || LaundryRoom_Lux.to_f < 15 }
# turn_on LaundryRoom_Light
# end
#
# rule 'MT: Hallway Lights NON sleep mode' do
# trigger Hallway_Motion
# trigger Hallway_Motion2
# trigger LaundryRoom_Motion2
# trigger LoungeRoom_Motion
# only_if { Sun_Elevation < 10 || ToolCupBoard_Lux.to_f < 10 }
# not_if Sleep_Mode
# turn_on(Hallway_Light_Power, for: 1.hour) do
# Hallway_Light_Dimmer << 50
# Hallway_Light_CT << (Sun_Elevation < 10 ? 100 : 0)
# end
# end
#
# rule 'MT: Hallway Lights sleep mode' do
# trigger Hallway_Motion
# trigger Hallway_Motion2
# trigger LaundryRoom_Motion2
# trigger LoungeRoom_Motion
# trigger BedRoom1_Motion
# only_if { Sun_Elevation < 10 }
# only_if Sleep_Mode
# turn_on(Hallway_Light_Power) do
# Hallway_Light_Dimmer << 1
# Hallway_Light_CT << 100
# end
# end
#
#
# TRIGGER DURATION
#
# By default, the light will turn on for 2 minutes
# To specify a different duration, pass a "for" argument to turn_on: e.g.
#
# turn_on LivingRoom_Light, for: 10.minutes
#
#
# WHEN THE LIGHTS WERE MANUALLY TURNED ON
#
# When the lights specified by turn_on gets turned on manually (e.g. from the wall switch)
# and not by the trigger sensors, they will remain on for a default of 1 hour.
# To override this, pass a "manual" argument to turn_on, e.g.
#
# turn_on LivingRoom_Light, manual: 30.minutes
#
# This will automatically turn off the light after 30 minutes, when it was turned on manually (e.g. at the wall switch)
# To prevent any automatic turn off, specify "manual: nil"
#
#
# TRIGGER CONDITIONS / GUARDS
#
# Any of the standard rule trigger guards can be used as well
# Example:
#
# Only automatically turn on the lights after 6pm
# rule 'Turn on light by motion sensor at night' do
# trigger LivingRoom_Motion
# only_if { TimeOfDay.now > '6pm' }
# turn_on LivingRoom_Light, for: 15.minutes
# end
#
#
require 'openhab'
# Helper functions for motiontrigger_dsl
module MotionTriggers
module DSL
#
# Create a trigger based on the given item(s).
# When a trigger item received an update, the turn_on item(s)
# (e.g. lights) will turn on for the specified trigger duration.
# If they were already on, their timer will be restarted
#
# @param [Array] items array to trigger the rule
# @param [State] to match for trigger
#
def trigger(*item, to: OPEN)
updated item, :to => to, :attach => :trigger
end
#
# Create a trigger for the extender item.
# When an extender item received an update, existing trigger timers will be restarted
# so that the controlled lights that were already on continue to stay on.
# An extender will not turn on lights that weren't already on.
#
# @param [Array] items acting as extenders
# @param [State] to match the trigger
#
def extender(*item, to: OPEN)
updated item, :to => to, :attach => :extender
end
def turn_on(item, for: 2.minutes, manual: 1.hour, debounce: nil, &block)
duration = binding.local_variable_get(:for)
create_trigger_handler(item, duration: duration, manual: manual, debounce: debounce, &block)
MotionTriggers.keep_track_of(item, manual)
end
private
def create_trigger_handler(item, duration:, manual:, debounce:, &block)
run do |event|
if item.on?
next if MotionTriggers.reschedule_item_timer(item)
# The item was turned on but there's no timer
# This could happen if openhab was restarted. We assume it's a manual activation
# TODO save trigger timers and resume it after an OH restart
MotionTriggers.create_timer_for_manual_activation(item, manual)
elsif event.attachment == :trigger
MotionTriggers.cancel_item_timer(item)
MotionTriggers.trigger_item(item, event.item, duration, debounce, &block)
end
end
end
end
@timers = {}
# Stores the Time when the item was switched off by any means
@last_on = Hash.new(Time.at(0))
# The last time the item was turned off by a timer
@last_timer = Hash.new(Time.at(0))
@manual_rules = {}
def self.cancel_item_timer(item)
# logger.error("Cancelling timer for #{item.name}")
@timers.delete(item)&.cancel
end
def self.reschedule_item_timer(item)
# logger.error("Rescheduling timer for #{item.name} for #{@timers[item]&.duration}")
@timers[item]&.reschedule
end
def self.trigger_item(item, by, duration, debounce)
return if debounced(item, debounce)
return if block_given? && !yield # Execute block before turning on the item
logger.error("#{item.name} triggered by #{by.name} for #{duration}")
create_trigger_timer(item, duration)
item.on
end
def self.debounced(item, debounce)
return if debounce.nil?
return if (@last_on[item] - @last_timer[item]) < 1
return if (Time.now - @last_on[item]) > debounce.seconds
logger.error("Debounced #{item.name} because it was just turned off #{Time.now - @last_on[item]} seconds ago")
true
end
def self.create_trigger_timer(item, duration)
@timers[item] = after(duration) do
item.ensure.off
@timers.delete(item)
logger.info("#{item.name} turned off automatically after being triggered")
@last_timer[item] = Time.now
end
end
def self.keep_track_of(item, manual)
@manual_rules[item] = manual
end
def self.create_manual_rules
logger.error('Creating rules for motion triggers to track of manual activations')
@manual_rules.each do |item, duration|
create_manual_rule(item, duration)
end
end
def self.create_manual_rule(item, manual)
manual_rule_definition(item, manual)
create_timer_for_manual_activation(item, manual) if item.on?
end
def self.manual_rule_definition(item, duration)
rule "Motion trigger Manual rule #{item.name}" do
changed item, to: [ON, OFF]
run do |event|
if event.state&.off?
cancel_item_timer(item)
@last_on[item] = Time.now if event.was&.on?
elsif !@timers[item]
logger.error("#{item.name} was manually turned on. Creating a timer to turn it off after #{duration}")
create_timer_for_manual_activation(item, duration).reschedule
end
end
end
end
def self.create_timer_for_manual_activation(item, duration)
@timers[item] ||= manual_activation_timer(item, duration)
end
def self.manual_activation_timer(item, duration)
return DummyTimer.new if duration.nil?
after(duration) do
item.ensure.off
logger.info("#{item.name} turned off automatically after manually turned on")
@last_timer[item] = Time.now
end
end
# A Dummy timer class that doesn't create a timer
# It is meant for an item to stay on when manually turned on
class DummyTimer
def reschedule
true
end
alias cancel reschedule
end
end
module OpenHAB
module DSL
module Rules
#
# Rule configuration for OpenHAB Rules engine
#
class RuleConfig
prepend MotionTriggers::DSL
end
end
module Timers
class Timer
attr_reader :duration
end
end
end
end
script_loaded { MotionTriggers.create_manual_rules }