In the past few months, I've blogged several times about controlling the Things Gateway with the Web Thing API using Python 3.6. In each one was a stand alone project, opening and managing Web Sockets in an asynchronous programming environment. By writing these projects, I've explored both functional and object oriented idioms to see how they compare. Now with some experience, I feel free to abstract some of the underlying common aspects to create a rule engine of my own.
Implementing a similar system based on parse trees is tempting for its flexibility, but usually results in a new chimera language halfway between the programming language used and the language represented in the parse tree. See the SQLAlchemy encapsulation of the SQL language in Python as an example. I'm less fond of this technique than I used to be. I think I can get away with a simpler implementation just using fairly straightforward Python.
In my last post, I discussed the differences between "While" rules and "If" rules in the GUI Rules System. Recall that the "While" style of rule takes an action and then undoes the action when the rule condition is no longer True. However, an "If" style of rule never undoes its action.
Here's an example of the "If" style rule from my last blog post:
class ExampleIfRule(Rule): def register_triggers(self): return (self.Philips_HUE_01,) def action(self, *args): if self.Philips_HUE_01.on: self.Philips_HUE_02.on = True self.Philips_HUE_03.on = True self.Philips_HUE_04.on = True(see this code in situ in the example_if_rule.py file in the pywot rule system demo directory)
Creating a rule starts by creating a class derived from the base class Rule. The programmer is responsible for implementing two methods: register_triggers and action. Optionally, a third method, initial_state, and a constructor can be included, too.
The register_triggers method is a callback function. It returns a tuple of objects responsible for triggering the rule's action method. This is generally a set of Things defined by the Things Gateway. Anytime one of the things in the registered_triggers tuple changes state, the action method will execute.
In this example, "Philips HUE 01" is specified as the trigger. Any time any property of "Philips HUE 01" changes, the action method decides what to do about it. It looks to see if the Philips HUE light is in the "on" state, and if so, turns on the other lights, too.
When an instance of the rule class is instantiated, all the Things known to the Things Gateway are added as attributes to the rule. That allows any Thing to be referenced in the code with standard member syntax: "self.Philips_HUE_01". Each of the properties of the Thing are available using the dot notation, too: "self.Philips_HUE_01.on". Changing the state of a thing's properties is done with assignment statements: "self.Philips_HUE_04.on = True". The attribute names are sanitized derivations of the name attribute of the Thing. Spaces and other characters not allowed in Python identifiers are replaced with the underscore. If the first character of the name is not allowed as a first character in an identifier, a leading underscore is added: "01 turns on 02, 03" becomes "_01_turns_on_02__03". It's not ideal, but reconciling language requirement differences can be complicated.
The "While" version of the rule could look like this:
class ExampleWhileRule(Rule): def register_triggers(self): return (self.Philips_HUE_01,) def action(self, the_triggering_thing, the_changed_property_name, the_new_value): if the_changed_property_name == 'on': self.Philips_HUE_02.on = the_new_value self.Philips_HUE_03.on = the_new_value self.Philips_HUE_04.on = the_new_value(see this code in situ in the example_while_rule.py file in the pywot rule system demo directory)
Notice in this code, I've expanded the parameters of the action method. Each time the action method is called, it receives a reference to the object that changed state, the name of the property that changed and the new value of the property.
To make the other lights follow the boolean value of Philips HUE 01's on state, all we have to do is assign the_new_value to the other lights' on property.
Since we've got the name of the changed property and its new value, we can implement the full functionality of the bonded_things.py example that I gave several weeks ago:
class BondedBulbsRule(Rule): def register_triggers(self): return ( self.Philips_HUE_01, self.Philips_HUE_02, self.Philips_HUE_03, self.Philips_HUE_04, ) def action(self, the_triggering_thing, the_changed_property_name, the_new_value): for a_thing in self.triggering_things.values(): setattr(a_thing, the_changed_property_name, the_new_value)(see this code in situ in the bonded_rule.py file in the pywot rule system demo directory)
In this example, any change to on/off state or color of one bulb will immediately be echoed by all the others. We start by registering all four bulbs in the list of triggers. This means that a change in property to any one of them will trigger the action method. All we have to do in the action is iterate through the list of triggering_things and change the property indicated by the_changed_property_name. Yes, the bulb that triggered the change doesn't need to have its property changed again, but it doesn't hurt to do so. The mechanism behind changing values can tell that the new and old values are the same, so it takes no action for that bulb.
Compare this rule based code with the original one-off version of the bonded things code. The encapsulations of the Rules System significantly improves the readability of the code.
Up to this point, I've only demonstrated using Things from the Things Gateway as triggers. However, any object can be written to asynchronously invoke the action method. Consider this class:
class HeartBeat(TimeBasedTrigger): def __init__( self, config, name, period_str # duration should be a integer in string form with an optional # H, h, M, m, S, s, D, d as a suffix to indicate units - default S ): super(HeartBeat, self).__init__(name) self.period = self.duration_str_to_seconds(period_str) async def trigger_dection_loop(self): logging.debug('Starting heartbeat timer %s', self.period) while True: await asyncio.sleep(self.period) logging.info('%s beats', self.name) self._apply_rules()(see this code in situ in the rule_triggers.py file in the pywot directory)
A triggering object can participate in more than one rule. The act of registering a triggering object in a rule means that the rule is added to an internal list of participating_rules within the triggering object. The method, _apply_rules, iterates through that collection and calls the action method for each rule. In the case of this HeartBeat trigger, it calls _apply_rules periodically as set by the period_str parameter of the constructor. This provides a heartbeat that can make a series of actions happen over time.
Using the Heartbeat class that beats every two seconds, this rule creates a scrolling rainbow with six Philps HUE lights:
the_rainbow_of_colors = deque([ '#ff0000', '#ffaa00', '#aaff00', '#00ff00', '#0000ff', '#aa00ff' ]) class RainbowRule(Rule): def initial_state(self): self.participating_bulbs = ( self.Philips_HUE_01, self.Philips_HUE_02, self.Philips_HUE_03, self.Philips_HUE_04, self.Philips_HUE_05, self.Philips_HUE_06, ) for a_bulb, initial_color in zip(self.participating_bulbs, the_rainbow_of_colors): a_bulb.on = True a_bulb.color = initial_color def register_triggers(self): self.heartbeat = HeartBeat(self.config, 'the heart', "2s") return (self.heartbeat, ) def action(self, *args): the_rainbow_of_colors.rotate(1) for a_bulb, new_color in zip(self.participating_bulbs, the_rainbow_of_colors): a_bulb.color = new_color(see this code in situ in the rainbow_rule.py file in the pywot rule system demo directory)
The intial_state callback function sets up the bulbs by turning them on and setting the initial colors. This time in register_triggers, a Heartbeat object is created with a period of two seconds. The Heartbeat will call the action method every two seconds. Finally, in the action, we rotate the list of colors by one and then assign new colors to each of the six bulbs.
By implementing the rule system within Python, rules can use the full power of the language. Rules could be formulated that respond to anything that the language can do. It wouldn't be difficult to have a Philips HUE bulb show red when your software testing system indicates a build error. You could even hook up a big red button to physically press when you want to deploy the latest release of your code. In a more close to home example, how about blinking the porch light green to guide the pizza delivery to the right door? The possibilities are both silly and endless.