Design Pattern: Cascading Timers

Edit: Updates for OH 4

Please see Design Pattern: What is a Design Pattern and How Do I Use Them.

Problem Statement

There are many situations where one needs to cause a series of activities to take place in sequence with the option of being able to cancel the sequence of activities and be able to control how long each step takes to complete. One common use case is for controlling an irrigation system that consists of more than one zone.

Concept

image

Schedule a sequence of functions to be called with a set amount of time between them.

Blockly

This design pattern can be implemented using the openHAB Rules Tools implementation of Design Pattern: Gate Keeper. In Blockly it can look like this:

Gatekeeper supports cancelling scheduled commands.

JS Scripting:

var {Gatekeeper} = require('openhab_rules_tools');
var gk = cache.private.get('gatekeeper', () => Gatekeeper());
gk.addCommand('PT5M', () => {
  console.info('zone 1 start');
  items.IrrigationZone1.sendCommand('ON');
});
gk.addCommand('PT10M;, () => {
  console.info('zone 2 start');
  items.IrrigationZone1.sendCommand('OFF');
  items.IrrigationZone2.sendCommand('ON');
}
gk.addCommand('PT2M;, () => {
  console.info('zone 3 start');
  items.IrrigationZone2.sendCommand('OFF');
  items.IrrigationZone3.sendCommand('ON');
}
gk.addCommand('PT0S';, () => {
  console.info('irrigation done');
  items.IrrigationZone3.sendCommand('OFF');
}

Rules DSL Concept

The concept is to have a rule that gets triggered when one of the Items representing one of the steps in the series receives a command. This rule figures out what step in the sequence is currently running and based on that figures out what step to run next. A timer is set for the end of the activity which will cause the rule to run again and kick off the next step.

The example will use an irrigation use case. There are two or more zones that need to be irrigated in sequence. The amount of time to irrigate each zone can be different and the amount of time is controlled by an Item that can be edited on the sitemap. The irrigation is initially kicked off by a cron trigger, but could easily be kicked off by an Astro binding event, a Time of Day DP, and Alarm clock example.

Items

String Irrigation_Curr "The current active zone is [%s]"
Switch Irrigation_Auto "Automatic irrigation is [%s]"
Switch Irrigation_Manual "Irrigation state [%s]"

Group:Switch:OR(ON,OFF) gIrrigation "Irrigation is currently [%s]"
Group:Number:SUM gIrrigation_Times "Total Irrigation Time is [%d mins]"

Switch Irrigation_Zone_1      (gIrrigation)
Number Irrigation_Zone_1_Time (gIrrigation_Times)

Switch Irrigation_Zone_2      (gIrrigation)
Number Irrigation_Zone_2_Time (gIrrigation_Times)

Switch Irrigation_Zone_3      (gIrrigation)
Number Irrigation_Zone_3_Time (gIrrigation_Times)

Switch Irrigation_Zone_4      (gIrrigation)
Number Irrigation_Zone_4_Time (gIrrigation_Times)

Switch Irrigation_Zone_5      (gIrrigation)
Number Irrigation_Zone_5_Time (gIrrigation_Times)

Switch Irrigation_Zone_6      (gIrrigation)
Number Irrigation_Zone_6_Time (gIrrigation_Times)

Switch Irrigation_Zone_7      (gIrrigation)
Number Irrigation_Zone_7_Time (gIrrigation_Times)

Switch Irrigation_Zone_8      (gIrrigation)
Number Irrigation_Zone_8_Time (gIrrigation_Times)

OH 2.5 and OH 3.x Rules DSL

var Timer irrigationTimer = null

rule "Reset Irrigation at OH Start"
when
    System started
then
    // use this line to just turn everything off if OH happens to restart when irrigation is running
    gIrrigation.members.filter[valve|valve.state != OFF].forEach[valve| valve.sendCommand(OFF)]
    Irrigation_Curr.postUpdate("OFF")

    // use this line if you have persistence and want to reset the timer cascade when OH comes back online
    // sendCommand(Irrigation_Curr.state.toString, ON) // kicks off the cascade again on the last zone that was running
    // Irrigation_Curr.sendCommand(Irrigation_Curr.state)
end

rule "Start Irrigation at 08:00"
when
    Time cron "0 0 8 * * ?" or
    Item Irrigation_Manual received command ON
then
    if(Irrigation_Auto.state == ON || receivedCommand == ON){
        Irrigation_Manual.postUpdate(ON) // set it on if not already
        logInfo("Irrigation", "Irrigation started, turning on Zone 1")
        Irrigation_Curr.sendCommand(Irrigation_Zone_1.name)
    }
end

rule "Irrigation Cascade"
when
    Item Irrigation_Curr received command
then
    // get info for the current valve
    val currValve = gIrrigation.members.findFirst[ valve | valve.name == receivedCommand.toString]
    val currValveNum = Integer::parseInt(currValve.name.split("_").get(2))
    val currValveMins = gIrrigation_Times.members.findFirst[ t | t.name == currValve.name+"_Time" ].state as Number

    // get info for the next valve in the sequence
    val nextValveNum = currValveNum + 1
    val nextValveName = Irrigation_Zone_+nextValveNum
    val nextValve = gIrrigation.members.findFirst[ valve | valve.name == nextValveName] // null if there is no member by that name
    // TODO: You will probably want to add some error checking above in case Items are NULL or UNDEF

    // Turn on curr valve
    currValve.sendCommand(ON)

    // Create a timer to turn off curr valve and start the next valve
    irrigationTimer = createTimer(now.plusMinutes(currValveMins.intValue), [ |
        logInfo("Irrigation", "Turning off " + currValve.name)
        currValve.sendCommand(OFF)

        if(nextValve !== null) {
            logInfo("Irrigation", "Turning on " + nextValve.name)
            Irrigation_Curr.sendCommand(nextValve.name) // causes the Irrigation Cascade rule to trigger
        }
        else {
            logInfo("Irrigation", "Irrigation is complete")
            Irrigation_Manual.sendCommand(OFF) // causes the cancel rule to trigger for cleanup
        }
        irrigationTimer = null
    ])
end

rule "Cancel Irrigation"
when
    Item Irrigation_Manual received command OFF    
then
    // Cancel the timer if there is one, the ? will cause the line to be skipped if timer is null
    irrigationTimer?.cancel
    irrigationTimer = null

    // Turn off any open valves
    gIrrigation.members.filter[ valve | valve.state != OFF ].forEach[ valve | valve.sendCommand(OFF) ]

    // Update curr status
    Irrigation_Curr.postUpdate("off")
end

I leave the sitemap as an exercise to the student.

NOTE: the above code has not been tested. It hasn’t even been loaded into VSCode. I’m sure there are errors.

Theory of Operation:
When the starting event occurs (in this case 08:00 time or the Irrigation_Manual Switch receiving and ON command) the https://community.openhab.org/t/design-pattern-proxy-item/15991Irrigation_Curr is updated with the name of the first zone and the first zone in the sequence is started with an ON command.

This triggers the cascade rule which gets the runtime for the first zone, a reference to the next zone in the sequence, and creates a timer which expires after the first zone’s runtime.

When the timer expires it stops the first zone, sets the place holder to the name of the second zone and starts the second zone with an ON command. The ON command triggers the cascade rule again.

The cascade rule continues to run after the timer expires for each zone until we get to the end of the zones. At this point, Irrigation_Manual is sent an OFF command which causes the cancel rule to trigger.

The cancel rule gets triggered whenever the Irrigation_Manual Switch receives an OFF command. This rule cancels the timer, if it exists and is still running, closes all open valves, and updates the place holder Item to “off” to indicate irrigation is not running.

Related Design Patterns

Design Patterns How It’s Used
Design Pattern: Proxy Item Irrigation_Curr
Design Pattern: Working with Groups in Rules Lots of Group member operations
Design Pattern: Associated Items Obtaining the Irrigation Time Item
Design Pattern: Sensor Aggregation Adds up the total irrigation time
Design Pattern: Unbound Item (aka Virtual Item) Several Items including Irrigation_Curr
12 Likes

Hi,

I adjusted the rule code after checking in designer and fixed some minor errors. The cascading rule should only be triggered by ON commands, otherwise the OFF commands also trigger the rule which is creating a misbehaviour.

var Timer irrigationTimer = null

rule "Reset Irrigation at OH Start"
when
    System started
then
    // use this line to just turn everything off if OH happens to restart when irrigation is running
    gIrrigation.members.filter[valve|valve.state != OFF].forEach[valve| valve.sendCommand(OFF)]
end

rule "Start Irrigation"
when
    Item Irrigation_Auto received command ON
then
	logInfo("Irrigation", "Irrigation started, turning on Zone 1")
	Irrigation_Curr.postUpdate(Irrigation_Zone_1.name)
	Irrigation_Zone_1.sendCommand(ON)
end

rule "Irrigation Cascade"
when
    Item Irrigation_Zone_1 received command ON or
    Item Irrigation_Zone_2 received command ON or
    Item Irrigation_Zone_3 received command ON or
    Item Irrigation_Zone_4 received command ON or
    Item Irrigation_Zone_5 received command ON 

then
    // get info for the current valve
    val currValve = gIrrigation.members.filter[valve|valve.name == Irrigation_Curr.state.toString].head
    //logInfo("Irrigation", "currValue: " + currValve.name)
    val currValveNum = Integer::parseInt(currValve.name.split("_").get(2))
    //logInfo("Irrigation", "currValveNum: " + currValveNum)
    val currValveMins = gIrrigation_Times.members.filter[t|t.name == currValve.name+"_Time"].head.state as Number
	logInfo("Irrigation", "irrigation active for : " + currValveMins + " mins")

    // get info for the next valve in the sequence
    val nextValveNum = currValveNum + 1
    val nextValveName = "Irrigation_Zone_"+nextValveNum
    val nextValve = gIrrigation.members.filter[valve|valve.name == nextValveName].head // null if there is no member by that name
    
    // Create a timer to turn off curr valve and start the next valve
    irrigationTimer = createTimer(now.plusMinutes(currValveMins.intValue), [|
        logInfo("Irrigation", "Turning off " + currValve.name)
        currValve.sendCommand(OFF)

        if(nextValve != null) {
            logInfo("Irrigation", "Turning on " + nextValve.name)
            Irrigation_Curr.postUpdate(nextValve.name)
            nextValve.sendCommand(ON) // causes the Irrigation Cascade rule to trigger
        }
        else {
            logInfo("Irrigation", "Irrigation is complete")
            Irrigation_Auto.sendCommand(OFF) // causes the cancel rule to trigger for cleanup
        }
        irrigationTimer = null
    ])
end


rule "Cancel Irrigation"
when
    Item Irrigation_Auto received command OFF    
then
    // Cancel the timer if there is one, the ? will cause the line to be skipped if timer is null
    irrigationTimer?.cancel
    irrigationTimer = null

    // Turn off any open valves
    gIrrigation.members.filter[valve|valve.state != OFF].forEach[valve| valve.sendCommand(OFF)]

    // Update curr status
    Irrigation_Curr.postUpdate("OFF")
end
3 Likes

These design pattern posts and examples, wouldn’t it be reasonable to give these posts it’s own sub-category or at least pinned under the examples and tutorials or setup/configuration/rules ?
IMHO, these posts are brilliant (thank you for making these Rich!) and should almost be the first thing that you see when you load the forum page! Pretty sure a lot of the problems people post about could be solved by reading these…

My 2 cents…:wink:

1 Like

they are tagged with the “designpattern” and there is a reference to them in:
http://docs.openhab.org/tutorials/
(check the last 2 lines in that docs entry)

Yes, they are excellent :slight_smile:

Ps: I need to notify @ThomDietrich cause the last link gives 404 (it should be: https://community.openhab.org/c/tutorials-examples instead of https://community.openhab.org/c/setup-configuration-and-use/tutorials-examples)… I will open up a PR when I find some time

1 Like

Is it possible to use your python example with OH3 through “Scripts” in the WebUI? I’m currently trying to migrate my old rules from OH2.3 to something newer and this is exactly what I need.

I have the libraries installed like this:

root@raspberrypi:/etc/openhab# tree .
.
├── automation
│   ├── jsr223
│   │   └── python
│   │       └── test.py
│   └── lib
│       ├── javascript
│       │   └── community
│       │       └── timeUtils.js
│       └── python
│           └── community
│               ├── gatekeeper.py
│               └── time_utils.py

I also installed the Jython scripting language through the GUI.
However, the script isn’t triggering. I’m not sure if I missed something here.

I think it might work if you download and use the PR on the Helper Library for Jython first (and install the Jython add-on if you haven’t already). The Gatekeeper class depends on the Helper Library so you have to have that working for it to work. I’ve not yet written a JavaScript version of the class which will not require the Helper Libraries.

Note, the gatekeeper.py is a library. It doesn’t do anything on it’s own. You still need to write the rule that uses the Gatekeeper class. The Python code in the OP shows how to use the Gatekeeper class in a rule to implement cascading timers.

Oh you mean this one? OpenHAB 3.0 compatibility by jimtng · Pull Request #376 · openhab-scripters/openhab-helper-libraries · GitHub
Didn’t know that this one is also required. I’ll take a look at it.

Yeah sure, I took the python example from above and put it into a new script through the GUI, but it didn’t do anything.
I’ll try again with that helper lib installed. :slight_smile:

As it’s written or did you edit it to work as a UI rule. Remember, in the UI you create the Rule and the Triggers through the UI. So the @rule and @when and stuff is basically nonsense to put into a Script Action.

Another problem is that you can’t share the variable between different rules. The code above defines three separate rules and they all share the same gatekeeper. You can’t do that (or more correctly stated I don’t know how to do that yet) in the UI.

But why recreate it? Save the contents to a .py file as is (once you get the Helper Libraries working) and you are done.

I indeed toke the whole thing and put it into one script inside the UI. I didn’t really know that I actually have to define the triggers using the UI rules.

Oh that’s actually a good point then! I think I’ll go the “old” way through a .py file then.
Thanks a lot for that information. OH3 is still pretty new for me :slight_smile:

Alright, I got the helper libraries working now, but unfortunately the gatekeeper and time_utils libraries are no longer working because of the lack of joda time.

I changed the gatekepper to this for now:

        before = ZonedDateTime.now().toInstant().toEpochMilli()
        funct()
        after = ZonedDateTime.now().toInstant().toEpochMilli()

        # Calculate how long to sleep
        delta = after - before
        pause = to_datetime(cmd[0], output='Java')

Do you have any plans to update time_utils at some time?

There is already an update that someone else kindly submitted to time_utils.py awhile back. There is an output argument you can pass to the various functions that can be “Joda”, “Java”, or “Python”. By default it’s set to “Joda” but if you set output="Java" as an argument it will return a ZonedDateTime. That should work, though now that I think about it, I don’t know if I ever added the try/except around the import to Joda DateTime so it might still be broken.

It’s on my list of todos to update the Python libraries but it’s not at the top of my list:

  • Finish the Getting Started Tutorial
  • Finish porting all the rules tools to JavaScript without dependency on the Helper Libraries (rules_utils may not be transferred)
  • Finish rebuilding my OH 3 so it’s up to where my OH 2.5 was when I turned it off (I accidentally ran an update and my OH 3 was close enough for me not to mess with restoring from backup to undo the upgrade)

So it might be some time. I happily accept PRs though. :smiley:

Alright, I think I can get it working with an removed Joda import for now :slight_smile: Thanks a lot

1 Like

@rlkoshak , please tell me, what i’m doing wrong. Using your code i switch on all valves immediately.
Code is:

else if (currDayOnOff != "OFF") {
			logInfo(logName, "Opening {} for {} mins", currValve.name, valveOpenTime)
			currValve.sendCommand(ON)
			IrrigationSectionRemainingTime.postUpdate(valveOpenTime.intValue)
		
			// set the timer, after expiring turn off the current valve and turn on the next one
			irrigationTimer = createTimer(now.plusMinutes(valveOpenTime.intValue), [ |
			if (nextValve !== null) {
				// this will invoke cascading valves, "Irrigation - cascading" rule
				IrrigationCurrentValve.sendCommand(nextValve.name)
			}
			else {
				logInfo(logName, "Irrigation is complete")
			}

			// let's wait for propagating item values
			Thread::sleep(500)
			// turn off current valve
			logInfo(logName, "Closing " + currValve.name)
			currValve.sendCommand(OFF)

			irrigationTimer = null
			])
			}

And result is:

11:54:01.015 [INFO ] [.openhab.core.model.script.Irrigation] - Starting the irrigation sequence
11:54:01.025 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationCurrentValve' received command IrrigationValveZone1
11:54:01.030 [INFO ] [.openhab.core.model.script.Irrigation] - Current wind speed: 12,89 km/h
11:54:01.035 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationCurrentValve' changed from IrrigationValveZone4 to IrrigationValveZone1
11:54:01.046 [INFO ] [.openhab.core.model.script.Irrigation] - Past and forecasted average rain: 0,00 mm
11:54:01.071 [INFO ] [.openhab.core.model.script.Irrigation] - Current valve IrrigationValveZone1, duration 1
11:54:01.225 [INFO ] [.openhab.core.model.script.Irrigation] - Current rain correction OFF, irrigation of this zone today is ON
11:54:01.260 [INFO ] [.openhab.core.model.script.Irrigation] - Opening IrrigationValveZone1 for 0.9252010898662449 mins
11:54:01.267 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationValveZone1' received command ON
11:54:01.272 [INFO ] [openhab.event.ItemStatePredictedEvent] - Item 'IrrigationValveZone1' predicted to become ON
11:54:01.276 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationValveZone1' changed from OFF to ON
11:54:01.374 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationCurrentValve' received command IrrigationValveZone2
11:54:01.376 [INFO ] [.openhab.core.model.script.Irrigation] - Current wind speed: 12,89 km/h
11:54:01.385 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationCurrentValve' changed from IrrigationValveZone1 to IrrigationValveZone2
11:54:01.393 [INFO ] [.openhab.core.model.script.Irrigation] - Past and forecasted average rain: 0,00 mm
11:54:01.409 [INFO ] [.openhab.core.model.script.Irrigation] - Current valve IrrigationValveZone2, duration 1
11:54:01.553 [INFO ] [.openhab.core.model.script.Irrigation] - Current rain correction ON, irrigation of this zone today is ON
11:54:01.580 [INFO ] [.openhab.core.model.script.Irrigation] - Opening IrrigationValveZone2 for 0.9252010898662449 mins
11:54:01.588 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationValveZone2' received command ON
11:54:01.594 [INFO ] [openhab.event.ItemStatePredictedEvent] - Item 'IrrigationValveZone2' predicted to become ON
11:54:01.601 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationValveZone2' changed from OFF to ON
11:54:01.692 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationCurrentValve' received command IrrigationValveZone3
11:54:01.696 [INFO ] [.openhab.core.model.script.Irrigation] - Current wind speed: 12,89 km/h
11:54:01.699 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationCurrentValve' changed from IrrigationValveZone2 to IrrigationValveZone3
11:54:01.716 [INFO ] [.openhab.core.model.script.Irrigation] - Past and forecasted average rain: 0,00 mm
11:54:01.735 [INFO ] [.openhab.core.model.script.Irrigation] - Current valve IrrigationValveZone3, duration 1
11:54:01.874 [INFO ] [.openhab.core.model.script.Irrigation] - Closing IrrigationValveZone1
11:54:01.883 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationValveZone1' received command OFF
11:54:01.890 [INFO ] [openhab.event.ItemStatePredictedEvent] - Item 'IrrigationValveZone1' predicted to become OFF
11:54:01.897 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationValveZone1' changed from ON to OFF
11:54:01.908 [INFO ] [.openhab.core.model.script.Irrigation] - Current rain correction ON, irrigation of this zone today is ON
11:54:01.945 [INFO ] [.openhab.core.model.script.Irrigation] - Opening IrrigationValveZone3 for 0.9252010898662449 mins
11:54:01.951 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationValveZone3' received command ON
11:54:01.962 [INFO ] [openhab.event.ItemStatePredictedEvent] - Item 'IrrigationValveZone3' predicted to become ON
11:54:01.973 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationValveZone3' changed from OFF to ON
11:54:02.056 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationCurrentValve' received command IrrigationValveZone4
11:54:02.061 [INFO ] [.openhab.core.model.script.Irrigation] - Current wind speed: 12,89 km/h
11:54:02.071 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationCurrentValve' changed from IrrigationValveZone3 to IrrigationValveZone4
11:54:02.088 [INFO ] [.openhab.core.model.script.Irrigation] - Past and forecasted average rain: 0,00 mm
11:54:02.104 [INFO ] [.openhab.core.model.script.Irrigation] - Current valve IrrigationValveZone4, duration 2
11:54:02.192 [INFO ] [.openhab.core.model.script.Irrigation] - Closing IrrigationValveZone2
11:54:02.204 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationValveZone2' received command OFF
11:54:02.210 [INFO ] [openhab.event.ItemStatePredictedEvent] - Item 'IrrigationValveZone2' predicted to become OFF
11:54:02.218 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationValveZone2' changed from ON to OFF
11:54:02.300 [INFO ] [.openhab.core.model.script.Irrigation] - Current rain correction ON, irrigation of this zone today is ON
11:54:02.337 [INFO ] [.openhab.core.model.script.Irrigation] - Opening IrrigationValveZone4 for 1.8504021797324898 mins
11:54:02.343 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationValveZone4' received command ON
11:54:02.349 [INFO ] [openhab.event.ItemStatePredictedEvent] - Item 'IrrigationValveZone4' predicted to become ON
11:54:02.353 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationSectionRemainingTime' changed from 0 to 1
11:54:02.359 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationValveZone4' changed from OFF to ON
11:54:02.557 [INFO ] [.openhab.core.model.script.Irrigation] - Closing IrrigationValveZone3
11:54:02.570 [INFO ] [openhab.event.ItemCommandEvent       ] - Item 'IrrigationValveZone3' received command OFF
11:54:02.582 [INFO ] [openhab.event.ItemStatePredictedEvent] - Item 'IrrigationValveZone3' predicted to become OFF
11:54:02.597 [INFO ] [openhab.event.ItemStateChangedEvent  ] - Item 'IrrigationValveZone3' changed from ON to OFF

Calling .intValue on a number that’s 0.9252 is going to result in either a 0 or a 1. It’s looking like a 0. That means the timer is set of now.plusMinutes(0), so the timers are running immediately.

Ahh, spent a night looking for bugs in every corner and didn’t realized that my math differs from openhab math, and I was too hungry for testing, so I’ve set minimal possible time. Thank you.