On today’s episode of “Adventures in C++”: sending commands between two Arduinos running the same software. Not just data, but also instructions on how to process and execute that data. Even more, these commands have to be small enough for each Arduino to package, send, receive, unpack, and execute in less than 1/10th of a second.
A Bit of Background
This episode started with a simple question: how do I get a program running on one Arduino to control another Arduino running the same program? Armed with a somewhat decent knowledge of networking, I went in with the following requirements:
- The data had to be compiled into distinct packets, where each packet represents a command
- Each packet needed to contain information that told the receiver how to process the data in the packet
- The framework needed to support 50+ existing commands and had to be flexible enough to support new commands
- The framework needed to be fast enough to run multiple commands per second on an 8MHz Arduino
I searched, I trialed and tested different methods, and I left with my tail between my legs. I was either limited by the resource constraints of the Arduino (e.g. limited access to helper libraries), or the framework grew more unwieldy the more commands I added to it (or I just plain didn’t know what I was doing). So I shelved the idea and shifted my focus elsewhere.
My solution manifested itself while I was working on PixelMaestro. After what was essentially a complete rewrite of the core library, I had the idea for a text-based approach to data transfer. The idea was simple: each packet was split into distinct chunks. Each of these chunks contained an identifier and a value. The identifier told the receiving end which component was affected by the chunk, and the value contained parameters on how to modify that component. As the receiver stepped through each part of the chunk, it would create a chain of commands that resulted in some action being performed. On the receiver, this calls the appropriate PixelMaestro function (the following is from my original notes):
Goal: create and assign a new animation on the receiver.
Command (long form): Section 1 animation plasma size 5 resolution 16
Command (short form): S1APS5R16
On the receiving side, this translates to:
maestro.get_section(1)->set_animation(new PlasmaAnimation(colors, num_colors, 5, 16));
This was great in theory, but not so much in practice. For one, chunks could vary in length. Values larger than 255 would have to span multiple bytes, and I would have to track the number of bytes used by each chunk. Two, I would have to use a ton of branching trees to identify every possible combination of identifiers. Three, since each byte can only hold values from 0-255, it was incredibly difficult to flag the end of a chunk, since there was no way of knowing if the next byte was a flag or a value that happened to match a flag.
Queuing Up Cues
After some Googling, I came up with my next approach: what if instead of storing either a flag or a value in each byte, simply store values and use the position of the byte as the flag? Using the above example, the Section index would always be in the same position in the packet, as would the
draw_text() command, the
Font, and the text. For values that went above or below 0 – 255, I would have to split the value across multiple bytes, but both the sender and receiver would always know how many bytes the value spanned based on the values that came before it.
This idea developed into
Cues. At its heart, a Cue is just an array of bytes. Each byte has a special meaning depending on its location in the Cue: for example, the first three characters always contain “PMC”, which identifies the string as a PixelMaestro Cue. The following bytes contain metadata about the packet itself, and after that is the command itself converted into byte form. What you end up with is something like the following:
Original Command: maestro.get_section(0)->set_dimensions(62, 9);
String form: “PMC:\r\003\003\0>\0\t”
Decimal form: 80 77 67 58 13 3 3 0 0 0 62 0 9
- PMC (80, 77, 67): the header, which identifies the start of the Cue
- : (58): the checksum, which verifies the integrity of the Cue when it’s sent to another device
- \r (13): the size of the command, which in turn determines the total size of the packet. Since 5 bytes are reserved for the header, checksum, and size, this leaves 250 bytes for command-specific options (the largest command as of this writing, ColorCanvas::draw_triangle(), is 20 bytes total).
- \003 (3): the PixelMaestro component that this Cue modifies. Components are listed in a special enum, and
Sectionis the fourth item (third index) in the enum.
- \003 (again): the Action to perform. Each component also lists the Cue actions in a separate enum, and
SetDimensionshappens to be third in that enum.
- \0 (0): the index of the Section to modify. This corresponds to the Section at index 0 of the
- \0 (0): the index of the Overlay to modify. This indicates the depth of the Overlay originating from the Section. For example, a value of 1 points to the Section’s Overlay, and a value of 2 points to the Overlay’s Overlay.
- \0>\0\t (0 62 0 9): the parameters that get passed to the command, which in this case is
set_dimensions()accepts values larger than 255, we split each value from an unsigned short into two bytes, then reconstruct it into an int on the receiving side. That’s why each number has a 0 preceding it in string and decimal form.
Each PixelMaestro class has its own corresponding
CueHandler. CueHandlers contain the methods responsible for generating new Cues and translating incoming Cues back into PixelMaestro commands. The branching requirements for running incoming Cues is limited to two levels (three at most): one to identify the CueHandler, and another to identify the Action. The third level is for classes with multiple child classes, such as an Animation type or Canvas type.
All Cues pass through a
CueController, which is owned by the Maestro. The CueController is responsible for initializing CueHandlers, providing temporary storage, packaging outgoing Cues for transit, reading and validating incoming Cues, and delegating incoming Cues to different CueHandlers. It also defines the layout of the packet header so that other CueControllers can read and accept the packet.
While this format isn’t the most efficient, it allows you to run almost any command with any number of parameters on low-bandwidth devices, including relatively complex actions like copying color arrays. As a side bonus, you can store multiple Cues at once to create a custom Maestro configuration, then execute those Cues all at once to restore the configuration. You can send Cues over USB, Bluetooth, WiFi, NFC, or any other serial communication protocol.
Knowing the vast resources available in the C++ and programming communities, there’s probably a faster and easier implementation of this. But in the meantime, it was a fun experiment in designing a portable data structure.