Motion Sensor -> Light trigger in JRuby

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 }
2 Likes

Two ruby things:

  • you should probably avoid @@ (class variables).
  • I’m not sure why you’re using duration = binding.local_variable_get(:for) instead of just directly accessing for.

I’m trying to come up with an easier way by using a groups, metadata (instead of passed params), and a single rule that triggers for any member of the group for the inner manual-on-timer rule. But I’ll admit it’s not coming easily when you combine this with the possibility of turning on the lights for different durations (or other arguments that your turn_on takes) depending on the rule that is actually turning things on, and how flexible those can be (i.e. depending on time of day, sleep mode, and/or sun elevation). I do worry that if you have multiple turn_on calls for the same lights from different outer rules, you’re going to end up with duplicate inner rules that will stomp on each other a bit.

In my own personal setup, I have a few layers of abstraction. My core light-triggering things is an Occupancy item. I have sensors that actually count how many people are in the room, so I care much less about how long a “motion event” might mean, nor remembering to turn things off after they’re manually turned on. But those sensors aren’t 100% reliable, and I still have rules that attempt to correct the occupancy setting based on motion or other hints, so it’s entirely feasible for the occupancy item to be a simple item with no backing channel. Anyhow, when occupancy is triggered, my main rule triggers. I have metadata on the occupancy item that says which lights to turn on, and depending on what time of day (technically I call it the house “mood” because it is also a completely separate item that has rules driving its state based on time of day, actual brightness outside, and manual input: Day, Evening, LateNight, Sleep). So a room is occupied, lights turn on. Room goes empty, lights turn off (after a metadata configurable delay). Room gets re-occupied, lights return to prior state (i.e. to what you had manually set them to).

Then, I have additional items that are considered “vacancy” items. They don’t have a linked channel, and have an expires on them that basically is your “duration” argument. Something can set a gVacancy item to OPEN, and after say 5 minutes, it’ll go to UNDEF. Then I have a single rule that for any gVacancy that goes to UNDEF, it finds its related occupancy item, and sets it to 0 (of CLOSED if it were a Contact instead of Number). Which then daisy chains to the lights turning off.

Vacancy sensors are another group. They have a single rule that if a VacancySensor changes to OPEN (i.e. a motion sensor), it goes and finds the related vacancy item, and updates it to OPEN.

Keep in mind my system was first built using the regular OpenHAB DSL, and is only now partially converted to ruby, so part of its design was “what’s possible using items and rules”. The key point I’m making here is that I have a separation between my “what’s a sensor” and its rules that interact with the next layer down “occupancy”, which then operates on the next layer down of actually turning lights on and off, so that I don’t need to deal with the individual details of how a room became occupied or not in my lowest level light rules. I can’t really say if it’s better or not than yours where you’re essentially defining the top level with a bunch of individual rules, and then pass that configuration through a method to do a bunch of dynamically defined rules. but it has been useful that I’m able to extend the system (i.e. adding automatic “correction” of vacant rooms) without having to touch my actual lighting rules. just food for thought.

Thanks for your detailed reply. I will need time to re-read and process it, but in the mean time, what kind of occupancy sensors do you have that can tell the number of people in the room and when they’ve left? I only have simple PIR motion sensors, albeit most of them are battery powered (Xiaomi Aqara motion sensor), and some are from my fixed house alarm motion sensors that I get to use as a general motion sensor.

I have been thinking of using internal cameras with AI that can tell me how many humans/people are in the room but that requires far too many PoE ports / ethernet cabling / fixed mounting.

Because for is a keyword, I can’t do for example if for == something. I copied this idea from the openhab-jruby library.

I don’t know if you can still buy them though. I think he stopped making them. I know the update server is no longer up.

Yeah, I have wired motion and contact sensors from my security system, and several PoE cameras around the house, but the latter are only in public areas (not bedrooms or bathrooms). I use them for “dumb” motion, but no AI for person identification yet. Maybe in the future.

:man_facepalming: duh

I don’t know that I’ve ever actually used a for loop in Ruby, so I forgot it was a keyword.

Ah yes, I only create one instance of it. Its sole purpose is to keep track of when it’s turned off (to cancel timers) and when it’s turned on (to see if it was turned on by the wall switch, not by the motion sensor trigger). When it’s turned on “manually” i.e. not by the motion sensor, start a timer to turn it off later (but a longer duration) - and later on motion sensors will extend it too. It’s also used to debounce the sensors so they don’t turn on the lights right after I’ve just turned it off on the wall and walked off.

I started out like this too - with groups and automatic inference of which light to turn on, how bright, etc. Then I started customising them with metadata to control which light(s) to trigger, for how long, what brightness, and they all depend on specific conditions unique to the individual room. “sleep mode” applies to bedrooms and hallway, but not to the living room. The living room has a different “curfew time” than the backporch, side lights, front porch. Furthermore the actions after “curfew” time is also different. I could try to parameterise everything, but It became too hard to manage, mainly because each room works differently.

What I didn’t like about groups / metadata is that I’d have to remember the group names, modify the items which are located in multiple .items files, and also remember which metadata to do what.

The “trigger dsl” that I came up with here, allows me to have a succinct description of all the individual cases that is easily understood at a glance, all in one place. I could also create a custom method to check for the conditions and give that method to only_if and the run block, if there are several areas that have the same sets of conditions.

It’s far from perfect, and after all this is just for fun, until I come up with or learnt a new way of doing it.

:+1:

@ccutrer I’ve moved all the code into a module, and designed it to be prepended into OpenHAB::DSL::Rules::RuleConfig so nothing goes out on the top-level scope. Code updated in the original post. Also changed all the @@ vars into @.