Design Pattern(?): Deferred Automated Actions

Problem Statement

You have an item you want to send a command to after a delay, but if there’s another rule or user interaction with that item in the meantime, cancel the automated action.

For example, open a blind at the front of the house 15 minutes after dawn could be achieved with:

rule "Open blind after dawn"
when
  Item DayNight changed from NIGHT to DAY
then
  createTimer(now.plusMinutes(15), [|
    LoungeBlind.sendCommand(UP)
  ])
end

However, if the “close blinds when playing a game” activity has triggered (becaused someone got up to play a game), you don’t want the blind to open.

Concept

Have a proxy item used to create the timer, and a generic rule that manages a map of timers. Both the proxy item and the target item are part of a group: when a member of the group is changed, the rule can cancel the timer.

Solution

In this example, the group gDeferredAction has been defined.

import org.openhab.model.script.actions.Timer
import org.eclipse.smarthome.core.library.items.StringItem
import org.eclipse.smarthome.model.script.ScriptServiceUtil
import java.util.LinkedHashMap
import java.util.Map
import java.util.regex.Pattern
import java.util.regex.Matcher
import org.joda.time.Period

/**
 * Store the timers.
 */
val Map<String, Timer> timers = new LinkedHashMap()

/**
 * Offset time.
 */
val Pattern timeOffset = Pattern.compile("^(?i)\\+((?:\\d+[hms])+)->(.*)")

/**
 * Specific time.
 */
val Pattern timeSpecific = Pattern.compile("^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2})->(.*)")


/**
 * Set a timer. Assumes the triggering item will be named
 * `TARGET_Timer`. The format is `TIME->COMMAND`. `TIME`
 * can be relative ("+1h5m30s") or absolute in ISO8601 format.
 */
rule "Set timer"
when
  Member of gDeferredAction changed
then
  if (triggeringItem instanceof StringItem && triggeringItem.state != "") {
    val String target = triggeringItem.name.replaceAll("(?i)_Timer$", "")

    /* Find the target item, checking it exists. There's no need for specific
     * error handling: if corresponding item isn't found as the exception is
     * handled well by OpenHAB.
     */
    val GenericItem targetItem = ScriptServiceUtil.getItemRegistry?.getItem(target) as GenericItem

    // -- Parse the time...
    //
    var DateTime triggerTime
    var Matcher matcher
    matcher = timeOffset.matcher(triggeringItem.state.toString())
    if (matcher.matches()) {
      triggerTime = now.plus(Period.parse("PT" + matcher.group(1).toUpperCase()))

    } else {
      matcher = timeSpecific.matcher(triggeringItem.state.toString())
      if (matcher.matches()) {
        triggerTime = DateTime.parse(matcher.group(1))

      } else {
        throw new RuntimeException("Invalid timer format [" + triggeringItem.state + "] from " + triggeringItem.name + ", try `+5m->OFF`")
      }
    }
    val String command = matcher.group(matcher.groupCount())

    // -- Check target time is in the past...
    //
    if (triggerTime.isBefore(now))
      throw new RuntimeException("Target time " + triggerTime + " from " + triggeringItem.name + " is in the past. Ignoring")


    // -- Check for an existing timer, and cancel it...
    //
    var Timer timer = timers.get(target)
    if (timer !== null) {
      logInfo("DeferredAction", "Cancelling existing timer [{}]", timer)
      timer.cancel()
    }

    // -- Create timer, and store it...
    //
    timer = createTimer(triggerTime, [|
      logInfo("DeferredAction", "Executing deferred action {} against {}", command, targetItem)
      timers.remove(target)
      targetItem.sendCommand(command)
    ])
    timers.put(target, timer)

    logInfo("DeferredAction", "Send {} to {} at {} through {}", command, targetItem, triggerTime, timer)
    triggeringItem.postUpdate("")
  }
end


/**
 * Remove a timer, if present.
 */
rule "Remove timer"
when
  Member of gDeferredAction changed
then
  val timer = timers.get(triggeringItem.name)
  if (timer !== null) {
    logInfo("DeferredAction", "Removing timer for {} [{}]", triggeringItem.name, timer);
    timer.cancel()
    timers.remove(triggeringItem.name)
  }
end

Simple Example

A new string item, LoungeBlind_Timer, is created and the initial example is now changed to:

rule "Open blind after dawn"
when
  Item DayNight changed from NIGHT to DAY
then
  LoungeBlind_Timer.sendCommand("+15m->UP")
end

If anyone’s got any improvements, or better ways of handling this, I’d love to hear them. Thanks!

For a tutorial maybe change the trigger item to Item.:wink:

rule "Open blind after dawn"
when
  item DayNight changed from NIGHT to DAY
then
  LoungeBlindTimer.sendCommand("+15m->UP")
end

To:

rule "Open blind after dawn"
when
  Item DayNight changed from NIGHT to DAY
then
  LoungeBlindTimer.sendCommand("+15m->UP")
end
1 Like

Done :slight_smile:

I’m going through all the Design Pattern posts and providing a Python version where applicable. Here is a Python version of the above.

Instead of implementing this with Rules I’m going to use a reusable module, the latest version of which you can find at https://github.com/rkoshak/openhab-rules-helper. In addition to handling commands, you can pass it an optional argument to have it issue updates as well.

—Old Version—
One difference is because it’s a callable module we don’t need to parse out the delay or date time string. Also, I’ve expanded the ability to define the time significantly based on the code I wrote for the Expire Binding replacement.

Stay tuned for a PR to the Helper Libraries.

"""
Author: Rich Koshak

Utility methods to easily schedule a deferred command to be sent to an Item. The
time can either be an absolute time or it can be an amount of time. 

License
=======
Copyright (c) contributors to the openHAB Scripters project
"""
import re
from datetime import timedelta
from core.actions import ScriptExecution
from core.jsr223.scope import events
from org.joda.time import DateTime

timers = {}

duration = re.compile(r'^((?P<days>[\.\d]+?)d)? *((?P<hours>[\.\d]+?)h)? *((?P<minutes>[\.\d]+?)m)? *((?P<seconds>[\.\d]+?)s)?$')

def parse_time(time_str):
    """
    Parse a time duration string e.g. (2h13m) into a timedelta object

    https://stackoverflow.com/questions/4628122/how-to-construct-a-timedelta-object-from-a-simple-string

    Arguments:
        - time_str: A string identifying a time duration.
            - d: days
            - h: hours
            - m: minutes
            - s: seconds
        All options are optional but at least one needs to be supplied. Float
        values are allowed (e.g. "1.5d" is the same as "1s12h"). Spaces between 
        each field is allowd. Examples:
            - 1h 30m 45s
            - 1h50s
            - 0.5s

    Returns:
        datetime.timedelta: A datetime.timedelta object representing the 
        supplied time duration

    Throws:
        AssertionError if the passed in string cannot be parsed
    """
    parts = duration.match(time_str)
    assert parts is not None, ("Could not parse any time information from `{}`."
                               " Examples of valid strings: '8h', '2d8h5m20s',"
                               " '2m4s'".format(time_str))
    time_params = {name: float(param) for name, param in parts.groupdict().items() if param}
    return timedelta(**time_params)

def timer_body(target, command, log):
    """
    Called when the differed action timer expires, sends the command to the 
    target Item.

    Arguments:
        - target: Item name to send the command to
        - command: Command to issue to the target Item
        - log: logger passed in from the Rule that is using this. 
    """
    log.info("Executing deferred action {} against {}".format(command, target))
    events.sendCommand(target, command)
    del timers[target]

def deferred(target, command, log, dt=None, delay=None):
    """
    Use this function to schedule a command to be sent to an Item at the 
    specified time or after the speficied delay. If the passed in time or delay
    ends up in the past, the command is sent immediately.

    Arguments:
        - target: Item name to send the command to
        - command: the command to send the Item
        - log: logger passed in from the Rule
        - dt: a DateTime or ISO 8601 formatted String for when to send the 
          command
        - delay: a time duration supporting days, hours, minutes, and seconds 
          (e.g. 2d5h23m7.5s)
        
        One of dt or delay must be passed in or the function will throw an 
        AssertionError.

    Throws:
        AssertionError if neither dt nor delay were supplied or delay was 
        supplied but it is not in a parsable format.
    """
    trigger_time = None

    assert dt is not None or delay is not None, ("One of dt or delay is "
                                                "required, both are None!")

    # Cancel existing timer
    if target in timers and not timers[target].hasTerminated():
        log.info("There is already a timer set for {}, cancelling and "
                 "rescheduling.".format(target))
        timers[target].cancel()
        del timers[target]


    # Determine when to send the deferred command
    if dt:
        trigger_time = DateTime(dt)
    else:
        td = parse_time(delay)
        trigger_time = (DateTime.now().plusDays(td.days)
                                 .plusSeconds(td.seconds)
                                 .plusMillis(int(td.microseconds/1000)))

    # If trigger_time is in the past, schedule for now
    if trigger_time.isBefore(DateTime.now()):
        trigger_time = DateTime.now()

    # Schedule the timer
    timers[target] = ScriptExecution.createTimer(trigger_time,
                                       lambda: timer_body(target, command, log))

def cancel(target):
    """
    Cancels the timer associated with target if it exists.

    Arguments:
        - target: the Item name whose timer is to be cancelled
    """
    if target in timers and not timers[target].hasTerminated():
        timers[target].cancel()
    del timers[target]

def cancel_all():
    """
    Cancels all timers.
    """
    for key in timers:
        timers[key].cancel()
        del timers[key]

An example of usage:

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

@rule("Open blind after dawn")
@when("Item DayNight changed from NIGHT to DAY")
def open_blind(event):
    deferred("LoungeBlind_Timer", "UP", open_blind.log, delay="15m")
1 Like