Motion Sensor -> Light trigger in JRuby

Updated: 2023-09-19 - made it work with jruby helper library 5.x

The idea is simple: turn on some lights for a set duration when triggered by motion sensor(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 '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.on? }
  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.on? }
  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.state < 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/ruby/lib/personal/

#
# A quick and easy way to build motion trigger rules.
#
# Example rule:
#
# # Turn on the light for the default duration (2 minutes)
# rule 'Simple rule' do
#   trigger LivingRoom_Motion
#   turn_on LivingRoom_Light
# end
#
# rule 'MT: Laundry Room' do
#   trigger LaundryRoom_Motion
#   extender LaundryRoom_Motion2  # this sensor picks up motion outside the laundry room too
#   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
#
#

# Helper functions for motiontrigger_dsl
module MotionTriggers
  module DSL
    extend(OpenHAB::DSL)

    #
    # Defines the sensor(s) that will turn on the lights
    # When the sensor(s) received an update, turn on the item(s) (e.g. lights)
    # If they were already on, their timer will be extended
    #
    # @param [Item] items A list of items that will trigger the rule
    # @param [State] from match for trigger
    # @param [State] to match for trigger
    #
    def trigger(*item, from: nil, to: OPEN)
      if from
        changed(*item, from: from, to: to, attach: :trigger)
      else
        updated(*item, to: to, attach: :trigger)
      end
    end

    #
    # Defines the sensors that only extend the timers, but not turn on the lights.
    # 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: 3.second, &block)
      MotionTriggers.add_managed_item(item, manual)

      duration = binding.local_variable_get(:for)
      run do |event|
        if item.on?
          logger.debug "#{item} extended by #{event.item}"
          # item.ensure.on # force send the command
          timers.reschedule(item)
          timers.reschedule(MotionTriggers.manual_timer_id(item))
          next
        end

        next unless event.attachment == :trigger

        next if MotionTriggers.debounced(item, debounce)
        next if block_given? && !yield # Execute block before turning on the item

        logger.info("#{item} triggered by #{event.item} for #{duration}")
        OpenHAB::DSL.after(duration, id: item) do
          item.ensure.off
          logger.info("#{item} turned off automatically after being triggered")
          MotionTriggers.update_item_time(item)
        end
        item.on
      end
    end

    OpenHAB::DSL::Rules::BuilderDSL.prepend(self)
  end

  # Stores the Time when the item was switched off by any means
  @turned_off = Hash.new(Time.at(0))

  # The last time the item was turned off by a timer
  @turned_off_by_timer = Hash.new(Time.at(0))

  @managed_items = {}

  def self.add_managed_item(item, manual_duration) = @managed_items[item] = manual_duration

  def self.cancel_timers(item)
    OpenHAB::DSL.timers.cancel(item)
    OpenHAB::DSL.timers.cancel(manual_timer_id(item))
  end

  def self.update_item_time(item)
    @turned_off_by_timer[item] = Time.now
  end

  # Prevents the motion sensors from turning the light back on
  # right after it was turned off manually
  def self.debounced(item, debounce)
    return if debounce.nil?
    return if (@turned_off[item] - @turned_off_by_timer[item]) < 1 # Don't debounce if it was turned off by the timer
    return if (seconds_since_turned_off = Time.now - @turned_off[item]) > debounce.seconds

    logger.debug("Debounced #{item} because it was just turned off #{seconds_since_turned_off} seconds ago")
    true
  end

  def self.create_item_rules
    @managed_items.each { |item, duration| create_item_rule(item, duration) }
  end

  def self.create_item_rule(item, duration)
    OpenHAB::DSL.rule "Motion trigger Manual rule #{item.name}" do
      changed item, to: [ON, OFF]
      run do |event|
        if event.on?
          create_manual_activation_timer(item, duration)
        else
          cancel_timers(item)
          @turned_off[item] = Time.now if event.was_on?
        end
      end
    end

    create_manual_activation_timer(item, duration) if item.on?
  end

  def self.manual_timer_id(item) = { manual: item }

  def self.create_manual_activation_timer(item, duration)
    return if OpenHAB::DSL.timers.include?(item) || duration.nil? # skip if automatic timer is active

    logger.debug("#{item} was manually turned on. Creating a timer to turn it off after #{duration}")
    OpenHAB::DSL.after(duration, id: manual_timer_id(item)) do
      item.ensure.off
      logger.info("#{item} turned off automatically after manually turned on for #{duration}")
      @turned_off_by_timer[item] = Time.now
    end
  end

  OpenHAB::DSL.script_loaded { create_item_rules }
end
3 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 @.

1 Like