Case Study: IOT with GPIO - deanrad/rx-helper GitHub Wiki
Click on the video below, for a demo of the application whose code we'll be describing in this post:
Startup
Establish A Known-Good State
Like in other case studies, we make use of an action named 'startup', upon which we can trigger various consequences.
const { agent } = require('rx-helper');
setupAgent();
agent.process({ type: 'startup' })
function setupAgent(){
// setup what is to be done on startup here..
}
Then, we ensure that the agent does some useful things on startup:
function setupAgent(){
agent.on('startup', () => {
rpio.init({ mapping: "gpio" });
rpio.pud(buttonPin, rpio.PULL_DOWN);
[statusPin, greenPin, redPin].forEach(pin => {
rpio.open(pin, rpio.OUTPUT, rpio.LOW);
});
setStatus(true);
setRed(false);
setGreen(false);
})
}
Here, we take care of some required initialization for rpio
, the node library we're using to communicate via gpio
to the pins of the raspberry pi. Exactly what a pull-down resistor does is a little out-of-scope, but our goal on startup here is to ensure the system and its 3 possible illuminations are correct.
Change Colors
The implementation of setRed(true|false)
is pretty straightforward, and we generalize the set*
functions by declaring a setColor
action type that we can handle like this:
agent.on("setColor", ({ action }) => {
const color = action.payload;
switch (color) {
case "red":
setRed(true);
break;
case "green":
setGreen(true);
break;
case "off":
setRed(false);
setGreen(false);
break;
}
});
This will allow us to set colors not just by function calls, but indirectly by telling the agent
to process
an action of type setColor
, whose payload is one of "red", "green", or "off". This will allow us to do the LED blinking on startup we call "The Startup Dance"
Do the Startup Dance
One of my favorite metaphors for RxJS Observables is a dance. A dance is a series of movements over time, and an Observable is a series of events over time. Upon startup, we blinked the lock LED red. This means we processed some "setColor" actions with a certain timing. The code is pretty transparent:
agent.on("start", () => {
return concat(
after(500, "red"),
after(500, "off"),
after(500, "red"),
after(500, "off"),
after(500, "red"),
after(500, "off"),
after(500, "red")
);
},
{ type: "setColor" }
);
Starting from the middle, you have the most elegant way of emitting items over time that I know of.
We return
an Observable that is the concatenation (aka sequence) of various colors, emitted after the specified number of milliseconds. How's that for readable? after
, the setTimeout I always wanted, returns an Observable of its second argument (or its second argument's return value if provided a function), after the specified amount of time. Then the RxJS concat
function specifies we want them one after another. The timings are not cumulative - they define a stages' timing with respect to the previous stage.
When a function passed to agent.on
returns an Observable, we can cause the emitted items to be processed back through the agent if we specify a type
for the actions that enclose these payloads. The config object containing { type: "setColor" }
is what specifies that as the Startup Dance Observable emits color names, they should be turned into processed actions of type setColor
.
See? Easy peasy! That's how we do the Startup Dance!
Unlocking With the Button
One of the mantras of Rx-Helper is that you should have an Observable of any outside input to your application, and a button press is one of these inputs. Another mantra is that you needn't grow functions when you can use multiple functions for different purposes. Even though initializing buttons is done on startup, we create a separate initButtons
event type, to which we respond by making each button down|up event get processed through the agent in an action of type buttonEvent
:
agent.on("initButtons", function buttonEventsAsObservable() {
return new Observable(notify => {
rpio.poll(buttonPin, pin => {
try {
/*
* Wait for a small period of time to avoid rapid changes which
* can't all be caught with the 1ms polling frequency. If the
* pin is no longer down after the wait then ignore it.
*/
rpio.msleep(20);
const state = rpio.read(pin);
notify.next({ pin, state });
} catch (ex) {
console.log("Button error: " + ex.message);
// notify.error(ex)
}
});
return () => rpio.close(buttonPin);
});
},
{ type: "buttonEvent" }
);
Like before, the arguments to agent.on()
are:
- When are we triggered
- What do we do
- How are the returned results treated?
Here we specify that
- Upon receiving an
initButtons
action - We should create an Observable of all down or up events for the GPIO pin
- And process each in an action of type
buttonEvent
What goes on inside the middle part - let's look at a simplified version:
function buttonEventsAsObservable() {
return new Observable(notify => {
rpio.poll(buttonPin, pin => {
const state = rpio.read(pin);
notify.next({ pin, state });
})
return () => rpio.close(buttonPin);
});
}
Inside the callback to rpio.poll
, we call the next
method on the argument notify
provided to the new Observable
constructor, passing the pin and state that we want to notify of. We also return a function which closes the rpio pin - this function will be called by Rx-Helper for cleanup purposes, when the Observable is disposed of.
Similar to promise-wrapping, the goal is to take some async code, potentially several layers deep, and enclose it inside a constructor (like new Promise
or new Observable
) that gives us a way to communicate outwards. Having our agent know only about an Observable of actions of type buttonEvent
means we can substitute in a different Observable during testing. It means we can test out our logic by substituting a sequence of button presses that is manually coded (see the Startup Dance for how) until we figure out the correct code to emit button presses and releases without causing the process to SegFault!
All that's left now to do is to respond to certain button presses with the unlocking logic.
buttonEvent
Respond to agent.on("buttonEvent", ({ action }) => {
const { pin, status } = action.payload;
if (!status && pin === buttonPin) {
return empty();
}
return concat(
after(0, "off"),
after(200, "green"),
after(2500, "off"),
after(200, "red")
);
},
{ concurrency: "mute", type: "setColor" }
);
We configure the agent to respond to buttonEvent
event types by emitting actions of type setColor
.
But in the case that the event is not status: true
or the pin of the button, we return an empty
Observable. The empty
Observable completes right away, therefore creating no setColor
actions. In the more interesting case, however, we create a dance of blinking like the Startup Dance, but which we use to indicate that a) we unlocked, and b) to revert to being locked shortly afterwards.
This is just like the buzzer system I had growing up, crossed with the current fob-based system they have at my kids' day care. It's a quite reasonable facsimilie of a real system! But could it be more real?
Thus, the logs look just as we saw in the video:
Real locking?
It's fine to blink an LED, but how would this change if we were to extend this to an IOT device that actually locked and unlocked along with the LED changes?
That's the beauty of this architecture, as prescribed by the Antares Protocol. You can rename events, and simple Find-And-Replace may turn events like setColor: red
to lockDoor: true
, and you can set up renderers for lockDoor: true
which emit notifications of type setColor: red
, and servoActivate: lock
, for example. It's easy to add a 2nd consequence to an action type by simply creating another handler function. This type of architecture scales really well as requirements evolve, and sophistication is added. Trust the protocol!
References
after
operator (the setTimeout you've always wanted) Test Suite- Rx-Helper README
The Code
Here you go, on Github