Please see Design Pattern: What is a Design Pattern and How Do I Use Them for how to read and use DPs.
Problem Statement
Often one finds a number of Rules that are very similar and that all work on similar Items resulting in a lot of duplicated code. One way to solve this problem is through the use of Groups.
Concept
This DP is not a standard DP in that it provides a single concrete template to follow to solve a specific problem. Instead this DP is more of a grand exploration of some of the things one can do with Groups. See the bottom for a list of related DPs that utilize some of the techniques discussed here in a more concrete way.
This DP presents a step-by-step tutorial for how to use Groups to consolidate and simplify an example set of several similar rules into one single rule.
The example used will be based on Contact Items that represent door and window sensors. Whenever any of these Contacts change to OPEN:
- a timer is set to alert when the Contact has been open for over an hour
- a timestamp of the change is recorded in a DateTime Item
- an alert is generated when the Contact is opened and no one is home
- an alert is generated when the Contact is opened and it is after dark
Important GroupItem Methods
Within Rules, a Group is represented using a GroupItem. The GroupItem has a method named members
which provides access to all the Items that are members of that Group as a Set.
JSR223 Python
-
ir.getItem("MyGroup").members
: get a list of all the members of the Group, GroupItems that are also members are of the list. -
ir.getItem("MyGroup").allMembers
: get a list of all members of the Group and members of subgroups; the subgroup GroupItems are not included, only their members. -
for i in ir.getItem("MyGroup").members: <code>
: Loop through all members of MyGroup and execute<code>
on each. -
filter(lambda item: item.state == ON, ir.getItem("MyGroup").members)
: returns a list of all the members of MyGroup that have the state ON -
[item for item in ir.getItem("MyGroup").members if item.state == ON]
: alternative to filter using Python list comprehension -
filter(lambda item: item.state == ON, ir.getItem("MyGroup").members)[0]
: Returns the first member of the Group that meets the condition defined in the lambda. -
[ item for item in ir.getItem("MyGroup").members if item.state == ON][0]
: Returns the first member of the Group that meets the condition defined in the lambda using Python list comprehension. -
sorted(item for item in ir.getItem("MyGroup").members , key = lambda item: item.state)
: Returns a list containing the members of MyGroup sorted by their state. -
ir.getItem("MyGroup").members[0:5]
: Returns a list containing the first five members of MyGroup. -
map(lambda item: item.state, ir.getItem("MyGoup"))
: Returns a list of the states of all the Items that are a member of MyGroup. The map lambda can be more involved (e.g. format a String for building a summary message). -
[item.state for item in ir.getItem("MyGroup")]
: Returns a list of the states of the Items in MyGroup using Python list comprehension. -
reduce(lambda: result, value: result.add(x), map(lambda item: item.state, ir.getItem("MyGroup").members))
: Generates a single sum of all the states of all the Items that are members of MyGroup.
Map and Reduce are great ways to create summaries on the members of the Group. For example, to build a String showing all the doors that are open:
msg = reduce(lambda: msg, name: "{}, {}".format(msg, name), # build the string
map(lambda: door.name, # extract the Item name
filter(lambda door: door.state == OPEN, # get the OPEN Items
ir.getItem("MyGroup").members))) # get the door Items from the Group
# Using list comprehensions
msg = reduce(lambda: msg, name: "{}, {}".format(msg, name),
[door.name for door in ir.getItem("MyGroup").members if door.state == OPEN])
Rules DSL
-
MyGroup.members
: get a Set of all the members of the Group, GroupItems that are also members are part of the set -
MyGroup.allMembers
: get a Set of all the members of the Group and all members of subgroups; the subgroup GroupItems are not included, only their members -
MyGroup.members.forEach[ i | <code> ]
: Execute<code>
on each member of MyGroup, e.g.MyGroup.members.forEach[ i | i.postUpdate(CLOSED) ]
-
MyGroup.members.filter[ i | <condition> ]
: Return a Set of all the members that meet the provided<condition>
-
MyGroup.members.findFirst [ i | <confition> ]
: Returns the first member that matches the provided<condition>
-
MyGroup.members.sortBy[ <method> ]
: Return a List of the members sorted by the value returned by<method>
(e.g. usingname
will sort the Items alphabetically by Item name,lastUpdate
by the last time they received an update which requires persistence) -
MyGroup.members.take(i)
: wherei
is an integer, returns a set containing the first i Items in members -
MyGroup.members.map[ <method> ]
: return a Set containing the result of calling<method>
on each member, for exampleMyGroup.members.map[ name ]
returns a Set of Strings containing the names of all the Items -
MyGroup.members.reduce[ <result>, <value> | <result> <operation> <value> ]
: returns the reduction of all the values in the Set by the<operation>
, for exampleMyGroup.members.reduce[ sum, v | sum + v ]
will return the sum of all members of MyGroup
A note on the use of ?
: a number of examples found on the wiki and on this forum will use a ?
between the Group name and .members
as in MyGroup?.members
. This means that if MyGroup.members === null ignore the line
. Put another way, it will not generate an error if MyGroup has no members. I do not recommend using this syntax as it can hide some types of errors.
A note on ( )
verses [ ]
. The Rules DSL has a concept of a lambda. A lambda is a function that you can treat as if it were like any other var or val. Lambdas are usually defined by surrounding the function in [ ]
(note there must be a space after the [ and before the ]). However, the Rules DSL allows parens to be used instead in some circumstances. I prefer to stick to [ ]
and will do so in the examples below. Do not be confused however if you ever see parens instead as that is also allowed. For example MyGroup.filter[ i|i.state==OPEN ]
and MyUpdate.filter(i|i.state==OPEN)
are both valid.
A note on var and vals. In the lambdas passed to these functions, only vals can be accessed. Unfortunately, that means it is impossible, for example, to calculate the sum of all the NumberItems in a Group using forEach
. That is where map
and reduce
come in. To get the sum of NumberItems one can use:
MyGroup.members.map[ state as Number ].reduce[ sum, v | sum + v ]
To build a String containing all the names of Items that are OPEN there are two approaches: use map/reduce or use a val StringBuilder.
// StringBuilder
val StringBuilder sb = new StringBuilder
MyGroup.members.filter[ i | i.state == OPEN].forEach[i | sb.append(", " + i.name) ]
// Map/Reduce
val str = MyGroup.members.filter[ i | i.state == OPEN ].map[ name ].reduce[ s, name | s + ", " + name ]
The StringBuild approach works because we can declare it as a val but still can call methods on it to change it.
Starting Point
This example has five Contacts, one for each external door. Binding configs for the sensors are left off. Each Contact has an associated DateTime and Timer. See Design Pattern: Associated Items for more explanation on the naming convention. See Design Pattern: Expire Binding Based Timers for more on the Timers. See Design Pattern: Human Readable Names in Messages for more on the use of MAP to get more human readable messages.
Items
Contact vGarageOpener1 "Garage Door Opener 1 is [%s]"
DateTime vGarageOpener1_LastUpdate "Garage Door Opener 1 [%1$tm/%1$td %1$tH:%1$tM]"
Switch vGarageOpener1_Timer { expire="1h,command=OFF" }
Contact vGarageOpener2 "Garage Door Opener 2 is [%s]"
DateTime vGarageOpener2_LastUpdate "Garage Door Opener 2 [%1$tm/%1$td %1$tH:%1$tM]"
Switch vGarageOpener2_Timer { expire="1h,command=OFF" }
Contact vFrontDoor "Front Door is [%s]"
DateTime vFrontDoor_LastUpdate "Front Door [%1$tm/%1$td %1$tH:%1$tM]"
Switch vFrontDoor_Timer { expire="1h,command=OFF" }
Contact vBackDoor "Back Door is [%s]"
DateTime vBackDoor_LastUpdate "Back Door [%1$tm/%1$td %1$tH:%1$tM]"
Switch vBackDoor_Timer { expire="1h,command=OFF" }
Contact vGarageDoor "Garage Door is [%s]"
DateTime vGarageDoor_LastUpdate "Garage Door [%1$tm/%1$td %1$tH:%1$tM]"
Switch vGarageDoor_Timer { expire="1h,command=OFF" }
Rules DSL Implementation not Using Groups
The following is a sampling of the rules. They are essentially almost identical for each contact:
rule "Garage Opener 1 changed"
when
Item vGarageOpener1 changed
then
// set timer
if(vGarageOpener1.state == OPEN) vGarageOpener1_Timer.sendCommand(ON)
else vGarageOpener1_Timer.postUpdate(OFF) // postUpdate(OFF) cancels the timer without triggering the rule below
// record event time
vGarageOpener1_LastUpdate.postUpdate(new DateTimeType)
// Alert
val StringBuilder msg = new StringBuilder
msg.append(transform("MAP", "entry.map", vGarageOpener1.name))
if(vGarageOpener1.state == OPEN) msg.append(" was opened") else msg.append(" was closed")
var alert = false
if(vTimeOfDay.state == "NIGHT" || vTimeOfDay.state == "BED"){
alert = true
msg.append(" and it is night")
}
if(vPresent.state == OFF){
alert = true
msg.append(" and no one is home")
}
if(alert){
msg.append("!")
aAlerts.sendCommand(msg.toString)
}
logInfo("Entry", msg.toString)
end
// repeat for the other four contacts
// The Expire binding will sendCommand OFF when the Timer expires
rule "Timer expired for vGarageOpener1"
when
Item vGarageOpener1_Timer received command OFF
then
aAlert(vGarageOpener1 + " has been open for over an hour!")
// If it is night time reschedule the timer to go off again
if(vTimeOfDay.state == "NIGHT" || vTimeOfDay.state == "BED") {
vGarageOpener1_Timer.sendCommand(ON)
}
end
// Repeat for the other four timers
Ugh, that is pretty ugly, pages and pages of repeated code. The above runs to over 250 lines of code! Let’s make it better with Groups.
Approach
- Put all the contacts into a Group
- Put all the LastUpdates into a Group (see Associated Items)
- Put all the Timers into a Group (see Associated Items)
- Create one Rule triggered by the Group of contacts using Member of
- Use triggeringItem/event.itemName and get a reference to the Associated Items, and generate the message.
Items
Group:Contact:OR(OPEN, CLOSED) gDoorsSensors "The doors are [%s]"
Group:DateTime:MAX gDoorsLast "The last door event was [%1$tm/%1$td %1$tH:%1$tM]"
Group:Switch:OR(ON, OFF) gDoorsTimers
Contact vGarageOpener1 "Garage Door Opener 1 is [%s]" (gDoorsSensors)
DateTime vGarageOpener1_LastUpdate "Garage Door Opener 1 [%1$tm/%1$td %1$tH:%1$tM]" (gDoorsLast)
Switch vGarageOpener1_Timer { expire="1h,command=OFF" } (gDoorsTimers)
Contact vGarageOpener2 "Garage Door Opener 2 is [%s]" (gDoorsSensors)
DateTime vGarageOpener2_LastUpdate "Garage Door Opener 2 [%1$tm/%1$td %1$tH:%1$tM]" (gDoorsLast)
Switch vGarageOpener2_Timer { expire="1h,command=OFF" } (gDoorsTimers)
Contact vFrontDoor "Front Door is [%s]" (gDoorsSensors)
DateTime vFrontDoor_LastUpdate "Front Door [%1$tm/%1$td %1$tH:%1$tM]" (gDoorsLast)
Switch vFrontDoor_Timer { expire="1h,command=OFF" } (gDoorsTimers)
Contact vBackDoor "Back Door is [%s]" (gDoorsSensors)
DateTime vBackDoor_LastUpdate "Back Door [%1$tm/%1$td %1$tH:%1$tM]" (gDoorsLast)
Switch vBackDoor_Timer { expire="1h,command=OFF" } (gDoorsTimers)
Contact vGarageDoor "Garage Door is [%s]" (gDoorsSensors)
DateTime vGarageDoor_LastUpdate "Garage Door [%1$tm/%1$td %1$tH:%1$tM]" (gDoorsLast)
Switch vGarageDoor_Timer { expire="1h,command=OFF" } (gDoorsTimers)
JSR223 Python
These Rules use the Helper Libraries.
from core.rules import rule
from core.triggers import when
from org.joda.time import DateTime
from core.actions import Transformation
@rule("A Door Sensor Changed")
@when("Member of gDoorsSensors changed")
def door(event):
door = event.itemName
# set timer
# Acquire the Timer (see Associated Items):
timer = [timer in ir.getItem("gDoorsTimers").members if timer.name == "{}_Timer".format(door)][0]
if items[door] == OPEN:
events.sendCommand(timer, ON)
else:
events.postUpdate(OFF) # postUpdate will cancel the Timer without triggering the rule below
# NOTE: the above is somewhat contrived to demonstrate the Group operations. A better
# implementation would be:
#
# timer = "{}_Timer".format(event.itemName)
# if event.itemState == OPEN:
# events.sendCommand(timer, "ON")
# else:
# events.postUpdate(timer, "OFF")
# Record the event time
# Acquire the LastUpdate (see Associated Items)
# - same steps as above
lastUpdate = [lu in ir.getItem("gDoorsLast").members if lu.name == "{}_LastUpdate".format(door)][0]
events.postUpdate(lastUpdate, str(DateTime.now()))
# Again, this example is contrived and a better implementation would be:
#
# events.postUpdate("{}_LastUpdate".format(door), str(DateTime.now()))
# Alert
# Create the message to log and maybe alert
alert = False
time = ""
present = ""
# In JSR223, a better approach is to use Item metadata instead of a Transformation for
# mapping the Item name to a human readable name.
name = Transformation.transform("MAP", "entry.map", door)
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)
@rule("Timer expired for door")
@when("Member of gDoorsTimers received command OFF")
def door_timer(event):
name = Transformation.transform("MAP", "entry.map", event.itemName.split("_")[0]
send_alert("{} has been open for over an hour!".format(name))
if items["vTimeOfDay"] == "NIGHT" or items["vTimeOfDay"] == "BED":
events.sendCommand(event.itemName, "ON")
Although somewhat contrived, the above shows how to use the list operations with the Associated Items DP to create a generic set of Rules to work on all doors.
What if we want a message with ALL the open doors send when any Timer goes off?
@rule("Timer expired for door")
@when("Member of gDoorsTimers received command OFF")
def door_timer(event):
name = Transformation.transform("MAP", "entry.map", event.itemName.split("_")[0]
lst = reduce(lambda lst, door: "{}, {}".format(lst, door),
[door.name in ir.getItem("gDoorsSensors").members if door.state == OPEN])
msg = "{} has been open for over an hour and {} are open too!".format(name, list)
send_alert(msg)
The above uses a filter, map and reduce to build the list of Items that are open to include in the message.
NOTE: see Design Pattern: Separation of Behaviors for details on send_alert
.
Rules DSL
rule "A Door Sensor Changed"
when
Member of gDoorsSensors changed
then
val door = triggeringItem
// set timer
// Acquire the Timer (see Associated Items):
// - use findFirst to get the Item whose name is the door.name with "_Timer" appended
// - use head to get the first (and only) Item in the Set
val timer= gDoorsTimers.members.findFirst[ t | t.name == door.name+"_Timer" ] as SwitchItem
if(door.state == OPEN) timer.sendCommand(ON)
else timer.postUpdate(OFF) // postUpdate(OFF) cancels the Timer without triggering the rule below
// This example is somewhat deprecated but illustrates find first. A better implementation would be
//
// if(door.state == OPEN) sendCommand(door.name+"_Timer", "ON")
// else postUpdate(door.name+"_Timer", "OFF")
//
// This is one case where the Actions are better.
// record event time
// Acquire the LastUpdate (see Associated Items)
// - same steps as above
val lastUpdate = gDoorsLast.members.findFirst[ dt | dt.name == door.name + "_LastUpdate" ] as DateTimeItem
lastUpdate.postUpdate(new DateTimeType)
// As with above, a better implementation would be:
//
// postUpdate(door.name+"_LastUpdate", now.toString)
// Alert
val StringBuilder msg = new StringBuilder
msg.append(transform("MAP", "entry.map", door.name)) // note a map and transform can convert from Item name to human friendly words
if(door.state == OPEN) msg.append(" was opened") else msg.append(" was closed")
var alert = false
if(door.state == "NIGHT" || vTimeOfDay.state == "BED"){
alert = true
msg.append(" and it is night")
}
if(vPresent.state == OFF){
alert = true
msg.append(" and no one is home")
}
if(alert){
msg.append("!")
aAlerts.sendCommand(msg.toString)
}
logInfo("Entry", msg.toString)
end
// The Expire binding will sendCommand OFF when the Timer expires
rule "Timer expired for a door"
when
Member of gDoorsTimers received command OFF
then
val timer = triggeringItem
aAlert(transform("MAP", "entry.map", timer.name.split("_").get(0)) + " has been open for over an hour!")
// If it is night time reschedule the timer to go off again
if(vTimeOfDay.state == "NIGHT" || vTimeOfDay.state == "BED") {
timer.sendCommand(ON)
}
end
Thus, by using Groups and Associated Items we reduce the lines of code from over 250 to around 75 (including comments), a reduction of over 2/3rds. Furthermore, since there is only one copy of the rule there is only one place to make updates, reducing the amount of work to maintain it and copy and paste errors.
Finally, what if we wanted to have a message with ALL the open doors sent when any Timer goes off?
// The Expire binding will sendCommand OFF when the Timer expires
rule "Timer expired for a door"
when
Member of gDoorsTimers received command OFF
then
val timer = triggeringItem
// I'll use a String so I can show off map/reduce, normally I'd probably use a StringBuilder and forEach as it is more effecient, though efficiency doesn't really matter here
var msg = transform("MAP", "entry.map", timer.name.split("_").get(0)) + " has been open for over an hour and "
msg = msg + gDoorsSensors.members.filter[ s|s.state == OPEN ].map[ name ].reduce[ result, name | result = result+", " + name ]
aAlert(msg)
// If it is night time reschedule the timer to go off again
if(vTimeOfDay.state == "NIGHT" || vTimeOfDay.state == "BED") {
timer.sendCommand(ON)
}
end
Related Design Patterns
Design Pattern | How It’s Used |
---|---|
Design Pattern: Time Of Day | For determining when it is NIGHT or BED time |
Design Pattern: Associated Items | For an explanation on how to pull out the Timer and DateTime Items associated with a given Contact |
Design Pattern: Group Based Persistence | For setting up persistence |
Design Pattern: Sensor Aggregation | For detecting presence |
Design Pattern: Expire Binding Based Timers | The Timers used |
Design Pattern: Human Readable Names in Messages | Converting Item names to a more friendly name for messages and alerts |
Design Pattern: Cascading Timers | Using Groups to create Timers that trigger other Timers |
Design Pattern: Group Based Persistence | Using Groups to specify how Items are persisted |
Design Patterns: Generic Is Alive | Using Groups to monitor whether a sensor or device is alive or hasn’t reported in awhile |
Edit: Updated to use triggeringItem and Member of Rule trigger instead of the persistence hack.