Journey to JSR223 Python 5 of 9

This post is the conversion of my entryway Rules.

Garage Door Openers

I have a Proxy Item and a Rule that stands between the command to open the garage door openers and the MQTT message that causes it to open. I do this so I can generate an alert in case the device that controls the relays is offline to get some positive feedback that the command was received.

Rules DSL

rule "A Garage Opener was triggered"
when
  Member of aGarageOpeners received command
then
  var topic = triggeringItem.name + "_Cmd"
  sendCommand(topic, "ON")

  logInfo(logName, "Garage opener " + triggeringItem.name.substring(triggeringItem.name.length-1) + " was triggered.")
  if(vNetwork_cerberos.state == OFF) {
    aAlert.sendCommand("Attempting to trigger a garage opener but the controller is offline!")
  }
end

When the proxy Item receives a command, forward that on the the MQTT object and then send an alert if the garage controller is offline.

Python

from core.rules import rule
from core.triggers import when
from personal.util import send_alert, get_name

@rule("Garage opener triggered", description="One of the garage door openers was triggered", tags=["entry"])
@when("Member of aGarageOpeners received command")
def trigger_garage(event):
    # try to trigger the door opener
    events.sendCommand(event.itemName+"_Cmd", "ON")
    name = get_name(event.itemName)
    trigger_garage.log.info("{} was triggered".format(name))
    # alert if the opener is offline
    if items["vCerberos_SensorReporter_Online"] == OFF:
        send_alert("Attempting to trigger {} but the controller is offline!".format(name), trigger_garage.log)

I debated switching to using the mqttPublish Action for this but decided I like the flexibility having an Item to send the command to so if I change the topic or technology that issues the command to the relay I don’t have to change the Rule later.

Also notice how I’m using Item metadata again to get the human friendly name of the Item and I’ve moved the call to get an Item’s name from metadata to a function in my util.py library. I was uncomfortable having the namespace and key hard coded over and over across the Rules and decided to centralize that with a function call. I know that I am going to be consistent in how I define these names but the way I’m doing it right now may not be how I want to do it in the future. The method is a one liner:

def get_name(itemName): return get_key_value(itemName, "Static", "name") or itemName

I’ve changed the offline alerting Rule posted previously to use this as well.

Poll for Sensor Updates

I use a Python script that I wrote awhile back (I’m considering a refactor or rewrite) that I deploy to RPis to read their GPIO and do other stuff I can’t do from my OH server. When I initially wrote this script I wasn’t very good at MQTT and didn’t understand retained messages very well. So I coded these scripts to publish the most recent sensor readings when receiving a message on a given topic. These Rules let OH send that message so it can get the latest sensor readings from these devices.

Eventually I will change this so retained messages are used as appropriate so we don’t have to poll.

Rules DSL

rule "Request an update from the sensors"
when
    Item aSensorUpdate received command
then
    logInfo(logName, "Requesting sensor updates from MQTT sensors")
    val mqttAction = getActions("mqtt", "mqtt:broker:broker")
    mqttAction.publishMQTT("entry_sensors/getUpdate", "ON")
end

rule "A sensor device came back online, request an update from the sensors"
when
  System started or
  Member of sensorReporters changed from OFF to ON
then
  aSensorUpdate.sendCommand(ON)
end

I implemented this in two Rules because at one point I had more than one time and condition where I would poll for the updates. I’ve since found a better way and now there is no need to do it in two separate Rules.

Python

from core.rules import rule
from core.triggers import when

@rule("Update Sensors", description="For my self build sensors, publish a message to cause them to publish the latest", tags=["admin"])
@when("System started")
@when("Member of sensorReporters changed from OFF to ON")
def update_sensors(event):
    update_sensors.log.info("Requesting sensor updates from sensorReporters")
    actions.get("mqtt", "mqtt:broker:broker").publishMQTT("entry_sensors/getUpdate", "ON")

In this case I did use the MQTT 2.x Action to show how it’s used.

Alerts and reminders for doors

This set of Rules is at least as complex if not more complex than my offline alerting Rules posted previously. At a high level, when a door opens or closes I create an antiflapping Timer. If we determine that the door sensor isn’t flapping (I think I have a bad wire or something, the sensor will occasionally start flapping multiple times per second for a time when it gets cold) we record the time, set a reminder Timer, and generate an alert if necessary.

Rules DSL

rule "Keep track of the last time a door was opened or closed"
when
  Member of gDoorSensors changed
then
  if(previousState == NULL) return;

  val name = triggeringItem.name
  val state = triggeringItem.state

  if(state == UNDEF) {
    logWarn(logName, name + " is in an unknown state!")
    return;
  }

  // If the door changes within a second we treat it as flapping
  if(antiFlappingTimers.get(name) !== null) {
    logWarn(logName, name + " appears to be flapping, it's now " + state + ".")
    antiFlappingTimers.get(name).reschedule(now.plusSeconds(2))
    return;
  }

  // Set an antiflapping timer, only do the work if we prove we are not flapping
  antiFlappingTimers.put(name, createTimer(now.plusSeconds(2), [ |

    // Ignore the event if the door was flapping
    if(triggeringItem == previousState) logWarn(logName, name + " was flapping, false alarm!")

    else {
      // Update the time stamp
      postUpdate(name+"_LastUpdate", now.toString)

      // Set the repinder timer if the door is open, cancel if it is closed
      if(state == OPEN) sendCommand(name+"_Timer", "ON")
      else postUpdate(name+"_Timer", "OFF")

      // Set the message
      val msg = new StringBuilder
      msg.append(transform("MAP", "en.map", name) + " was ")
      msg.append(if(state == OPEN) "opened" else "closed")

      var alert = false
      if(vTimeOfDay.state.toString == "NIGHT" || vTimeOfDay.state.toString == "BED") {
        msg.append(" and it is night")
        alert = true
      }
      if(vPresent.state == OFF) {
        msg.append(" and no one is home")
        alert = true
      }
      msg.append("!")

      // Alert if necessary, log if we don't
      if(alert) aAlert.sendCommand(msg.toString)
      else logInfo(logName, msg.toString)
    }

    antiFlappingTimers.put(name, null)
  ]))

end

rule "Timer expired for a door"
when
  Member of gDoorsTimers received command OFF
then
  val door = ScriptServiceUtil.getItemRegistry.getItem(triggeringItem.name.replace("_Timer", ""))
  if(door.state != OPEN) {
    logWarn(logName, door.name + " open timer expired but door is not open!")
    return;
  }

  val doorName = transform("MAP", "en.map", triggeringItem.name)

  aAlert.sendCommand(doorName + " has been open for over an hour")

  if(vTimeOfDay.state.toString == "NIGHT" || vTimeOfDay.state.toString == "BED") {
      triggeringItem.sendCommand(ON) // reschedule the timer
  }
end

Notice I’m using regular Timers for the flapping and Design Pattern: Expire Binding Based Timers for the reminder.

Python

from core.rules import rule
from core.triggers import when
from personal.util import get_name, send_alert
from core.actions import ScriptExecution
from org.joda.time import DateTime
from core.log import log_traceback
from core.metadata import get_key_value

flappingTimers = {}
reminderTimers = {}

def is_night(): return items["vTimeOfDay"] == StringType("NIGHT") or items["vTimeOfDay"] == StringType("BED")

def create_timer(itemName, log):
    name = get_name(itemName)
    mins = get_key_value(itemName, "Static", "rem_mins").intValue() or 60
    timer = ScriptExecution.createTimer(DateTime.now().plusMinutes(mins), lambda: reminder_timer(itemName, name, mins, log))
    global timer
    return timer

@log_traceback
def reminder_timer(itemName, name, mins, log):
    if items[itemName] != OPEN:
        log.warning("{} open timer expired but door is not open!".format(name))
        return

    send_alert("{} has been open for {} minutes".format(name, mins), log)

    if is_night():
        log.info("Rescheduling timer because it is night")
        reminderTimers[itemName].reschedule(DateTime.now().plusMinutes(mins))
    else:
        del reminderTimers[itemName]

@log_traceback
def flapping_timer(itemName, origState, name, log):
    if items[itemName] != origState:
        log.warning("{} was flapping, false alarm!".format(name))
    else:

        # Update the timestamp
        events.postUpdate(itemName+"_LastUpdate", DateTime.now().toString())

        # Set a reminder timer
        mins = get_key_value(itemName, "Static", "rem_mins").intValue() or 60
        if itemName in reminderTimers and items[itemName] == OPEN:
            log.info("Reschedule the timer")
            reminderTimers[itemName].reschedule(DateTime.now().plusMinutes(mins))
        elif itemName in reminderTimers and items[itemName] == CLOSED:
            reminderTimers[itemName].cancel()
            del reminderTimers[itemName]
        else:
            reminderTimers[itemName] = create_timer(itemName, log)

        # Create the message to log and maybe alert
        alert = False
        change = "opened" if origState == OPEN else "closed"
        time = ""
        present = ""
        if is_night():
            time = " and it is night"
            alert = True
        if items["vPresent"] == OFF:
            present = " and no one is home"
            alert = True
        msg = "The {} was {}{}{}!".format(name, change, time, present)

        if alert: send_alert(msg, log)
        else: log.info(msg)

    # Remove the flapping timer
    del flappingTimers[itemName]


# TODO: rework, these reminders are not useful any longer
@rule("Door reminder", description="Track when doors change state and set an alert if they remain open for too long", tags=["entry"])
@when("Member of gDoorSensors changed")
def door_changed(event):
    if isinstance(event.oldItemState, UnDefType): return

    name = get_name(event.itemName)
    state = event.itemState

    if state == UNDEF:
        door_changed.log.warning("{} is in an unkown state!".format(name))
        return

    # TODO review the flapping code
    # If a door changes within a second we treat it as flapping
    if event.itemName in flappingTimers:
        door_changed.log.warning("{} appears to be flapping, it's now {}.".format(name, state))
        flappingTimers[event.itemName].reschedule(DateTime.now().plusSeconds(2))
        return

    # Set an antiflapping timer, ont do the work if we prove we are not flapping
    flappingTimers[event.itemName] = ScriptExecution.createTimer(DateTime.now().plusSeconds(2), lambda: flapping_timer(event.itemName, state, name,door_changed.log))

@rule("Reset timers for OPEN doors", description="Reschedules the Timers for doors that are open on restart", tags=["entry"])
@when("System started")
def reschedule_door_timers(event):
    for door in filter(lambda door: door.state == OPEN, ir.getItem("gDoorSensors").members):
        reschedule_door_timers.log.info("Rescheduling door reminder timer for {}".format(door.name))
        reminderTimers[door.name] = create_timer(door.name, reschedule_door_timers.log)

In the Python version, I decided to switch to regular openHAB Timers rather than a mix of Expire binding timers and Rule Timers. I’m also using openHAB Timers instead of Python Timers. I plan on going back and changing my alerting Rules as well.

Also, because I’ve lost the feature where my Timers get rescheduled on reboot (see previous Rule) I created a new Rule to recreate the Timers on restart for the open doors.

I decided that this is another case where using metadata makes sense to define how long to wait before reminding that the door was left open. I also use the name metadata approach.

Lessons Learned

  • Don’t be afraid to use helper functions. Unlike in Rules DSL, they do not have the same concurrency issues and are perfectly safe to use.

  • When you translate your code to JSR223, resist the urge to change it’s behavior until after you have it working like it was before. Too many changes all at once increases the chances that it will fail.

  • A lot of mistakes, typos, and other errors that would be caught by the Rules DSL parser will remain undiscovered until those lines in Python actually execute. It is vital that the code be thoroughly tested even to find some types of syntax errors.

NOTE: the above Rules have not be thoroughly been tested yet. Check back for changes over the coming days as I get a chance to test.

Previous: Journey to JSR223 Python 4 of 9
Next: Journey to Python 6 of?

Edit: Fixed numerous bugs in the code after having a chance to test it. I’m still not 100% positive the rescheudle of the timers is correct. I plan on totally reworking these Rules from scratch at some point so I’ll just keep an eye on my logs for errors.

2 Likes

Looks like you’re getting the hang of this. The only suggestion I have is to remove the reloads from this and others, or at least remind why they are in there, so that people don’t think they are required.

1 Like

Done. I left it in the first post of the series and added an explanation and removed it everywhere else.

1 Like