This is an event-collecting module for building state-machine driven programs. It does the work of funneling polled or interrupt-generated events into a single queue for consumption by the main program. It uses a uniform interface for defining event-producing functions which are subsequently managed by this module. The main program is responsible for de-queueing the incoming events and mapping them with the current state of the machine to determine the next machine state. Because the dispatch loop is trivial, the library provides an optional helper method to do so, along with a simple mechanism to trace its execution.
An event can be as simple or complex as desired, although the functions synthesizing the event(s) should execute
It was originally created for MicroPython/RP2040 for the State Machine Lab(s) experiments for the ME/EE106 Introduction to Mechatronics course at San Jose State University. A (much) less-flexible/configurable implementaion of this library was used in the previous semester, which hard-coded event-generatos to components connected to specific pin on the RP2040 baseboard that we were using.
One of the major goals was to help minimize the amount of code required by students, mostly by providing event-checkers for the most common input components used in their projects. However, the design allows for adding custom event-checkers with the minimum code required to discern the event-generating conditions. It helps by providing a common interface for consuming and dispatching events of all types, simplifying the relationship between the higher and lower level units of code required for a complete application.
Alhough such an application could be implemented using asyncio
, this is well beyond the level of expertise of our particular
audience. It has yet to be determined if such a library as this makes much sense to exist in an asyncio world or not, but it's something
that I'm interested in looking into.
A program implemented as a state machine using this library is comprised of three parts: the main program, one (or theoretically more) eventer, and one or more eventoids connected to each eventer.
The main program creates an Eventer
from which all events are retrieved.
The eventer collects and queues the events generated by the eventoids internally in a Python list in the order in which they were collected.
Consumption of queued events usually result in state machine transitions encoded in program logic, outside of the concern of this library.
However, all of the examples demonsrate the most straight-forward structure of such programs and require little more than a single function
to process the events as they are consumed.
Eventoids do the work of identifying events to be generated are usually created at the start of the main program and registered with the Eventer. A diagram of how these three pieces relate to each other is shown below:
+----------> eventoid #1 (e.g. gpio, alarm, encoder, sensor, etc.)
register(),next()-> poll()-> |
main_program <-----------------> eventer <-------------+----------> [eventoid #2] ^ v <-add() | ... ^ v +----------> [eventoid #n] ^<<<<<< event_process()
Nearly all of the work to be done in implementing a state-machine-based program is in the state machine design itself, assuming that one already has a complete set of eventoids that are sufficient for the task at hand, and their instances created to map onto the resources that they monitor.
The only code left is that which perform the actions required in all of the transitions from state to state, and this
function is called event_process()
, discussed immediately below.
FIXME-MOVE Eventoids are chosen (or custom-implemented, if need be) based on the input event requirements of the system design. Were you designing a simple flashlight program, you would need only one eventoid that generates "press" events from the single input button. If your flashlight has additional functional requirements, other eventoids might be useful monitoring a potentiometer, a rotary encoder, or one for an additional button. Alternatively some type of custom button-press click pattern-matching eventoid could be implemented with the one button, or that logic could be designed into the state machine itself.
The main program often never needs to communicate directly with eventoids after creation and registration with the eventer. In some cases the behavior of eventoids may be alterable at a later time, for example, changing the period of a recurring timer, but reconfiguration tends to be the exception rather than the norm.
Because the types of input events are fairly common from system to system but the system's outputs and responses are unbounded, most types of input behaviors come from a relatively small group of (hopefully) general-purpose eventoids. No support is provided for output/actions because applications' behaviors have nothing to do with the functioning of the state machine, per se.
The post-initialization portion of the main program usually need only consist of two parts, a loop that repeatedly calls Eventer.next()
to retrieve the next event (or None
in the absence thereof), and a single function that takes as parameters the current state and the recieved event to process. Using the convention
in the provided examples, this function is called event_process()
and takes the two aforementioned parameters,
and returns the value of the state transitioned to in response to processing that event.
At its bare minimum, after everything is initialized, the
remainder of the running program executes only a loop similar to the following:
state = STATE_START
while True:
event = eventer.next()
state = event_process(state, event)
Because this basic loop is generally the same for every program, the eventer has an Eventer.loop()
method that can be used in lieu of
the main program supplying this boilerplate while true
loop. This function also provides optional state transition tracing to assist
in debugging with either basic or prettier output if called desired.
Later we will walk-through a complete, but bare-bones, example of an LED Blink program.
Eventoids are the modules that monitor the state of the conditions for which events are generated at the appropriate time. A simple eventoid might only generate rising-edge GPIO events, or after a pre-determined period of time has elapsed. A complex eventoid could watch for patterns of data combined from a variety of conditions or inputs.
Some evenoids may have both a polled and an interrupting version providing the same functionality, one of which may make more sense in a project or MCU than another.
If any eventoids require polling it is performed by the Eventer on just those eventoids, alleviating the main program from these chores. This requires the eventer to cycle through such eventoids with some application-specific frequency so as to not miss generating events as conditions change. How this happens can be in one of three ways:
- the main program can explicitly cause this to happen, at least as often is required and/or convenient, by calling
Eventer.poll()
- the main program can set an alarm callback to call
Eventer.poll()
at the required minimum frequency - the eventer can poll (possibly in a very tight loop) independently in a separate thread and/or on a separate core as is available on the RP2040.
Eventoids that are fully interrupt-driven do not require polling (by definition) and run autonomously as far as both the main program and the eventer are concerned. They typically queue their generated events from their (sometimes virtual) interrupt handlers.
In either a polled or non-polled/interrupt-driven eventoid, when conditions warrant generating an event for consumption, it is passed to the eventer
with Eventer.add()
. The Eventer is the single point of contact by the main program for all event types, making event receipt simple for the main
program. The eventer keeps the backlog of events in a list for retrieval by the main program by calling Eventer.next()
.