Adafruit Protomatter RGB Matrix Library
2024-10-16 | By Adafruit Industries
License: See Original Project LED Matrix Arduino
Courtesy of Adafruit
Guide by Phillip Burgess
Overview
Adafruit_Protomatter is a C library (with Arduino ‎and CircuitPython front-ends) for driving RGB LED matrices ‎‎(colloquially called “HUB75” matrices but that’s a vague term and ‎not entirely accurate). We already have one such library —‎‎ RGBmatrixPanel for Arduino — it’s older code that works fine for ‎AVR chips (and a couple others) but has some limitations.‎
This guide was mostly to assist with porting Adafruit_Protomatter to ‎new hardware but has since been expanded with installation and ‎basic use.‎
‎“Protomatter” was intended as a stand-in name until something ‎better was decided upon, but we’d already moved in by that point.‎
In CircuitPython, it’s only seen by the sensible name rgbmatrix. But if ‎working with the underlying C code or the Arduino library…sorry, ‎you’re stuck with the awkward-to-type Protomatter name.‎
It originates in a quote from the character David Marcus in Star Trek ‎III: “I used protomatter in the Genesis matrix.” Hopefully, the library ‎doesn’t develop a similar reputation as “unstable” and “dangerously ‎unpredictable.”‎
Protomatter was planned with newer hardware in mind and ‎will not be back-ported to AVR — simply use the prior ‎RGBmatrixPanel there if you need it. The new library takes a casual ‎approach to some things and isn’t particularly RAM-efficient, a ‎crucial concern on AVRs. What Protomatter does provide includes:‎
More flexibility in matrix chain width and height ‎‎(RGBmatrixPanel only supports a few sizes)‎
More flexibility in pin selection (old code was specifically ‎designed for an Arduino UNO shield)‎
Configurable bit depth, up to the maximum “565” color format ‎used by Adafruit_GFX
Mostly doesn’t rely on esoteric peripherals — sticks with basic ‎‎“PORT” GPIO and a timer interrupt. Some exceptions were ‎made for ESP32-S3 and -S3‎
Mostly avoids cycle-counting
A likely candidate device for porting will have:‎
A reasonably fast 32-bit RISC core or similar (e.g., ARM, ESP32). ‎Minimum we’ve used is a 48 MHz Cortex-M0+‎
One or more timer peripherals, 16-bit or better, with ‎configurable period and with interrupts
GPIO with atomic bit-set and bit-clear registers, typically 32 bits ‎wide…ideally tolerating writes to individual sub-bytes or words
RAM usage depends on matrix size, bit depth and GPIO pin ‎selection. Minimum device we’ve used has 32 KB RAM (total for ‎device, not all consumed by the library)‎
As currently written, Protomatter eschews the use of DMA or special ‎peripherals beyond what’s described above. Goal is simply to get this ‎working on a variety of devices with a minimum of fuss. We can ‎tweak and optimize later.‎
Things You’ll Need
A datasheet or reference manual for the device being ported to
Hardware to test on. Having both a logic analyzer and a known-‎compatible-with-existing-devices RGB matrix is really helpful ‎to verify that all signals are doing the right things at the right ‎times
Text editor powered by tinymce.‎
To work with the Protomatter library in the Arduino IDE, access the ‎Library Manager from the Sketch menu…‎
Sketch→Include Library→Manage Libraries…‎
Type “protomatter” in the search field at the top-right of the Library ‎Manager window, then click Install for the Adafruit ‎Protomatter library.‎
The very latest versions of the Arduino IDE automatically install other ‎dependent libraries. If you’re on a slightly earlier version, search for ‎‎“gfx” and install Adafruit GFX Library manually…‎
Adafruit_GFX is the same library that drives ‎many of our LCD and OLED displays…if ‎you’ve done other graphics projects, you ‎might already be familiar! And if not, we ‎have a separate guide explaining all of the ‎available drawing functions. Most folks can ‎get a quick start by looking at the ‎Protomatter library’s “simple” and ‎‎“doublebuffer_scrolltext” examples and ‎tweaking these for their needs.‎
Some of the Protomatter examples rely on ‎the Adafruit_PixelDust, Adafruit_LIS3DH and/or AnimatedGIF libraries…so again, search for and install those if not already handled ‎automatically by Arduino.‎
If you’re using an Adafruit MatrixPortal board specifically, the ‎MatrixPortal guide has a whole page for all of the needed libraries.‎
For Developers
If your plan is to adapt Protomatter to new devices, not just use it ‎with existing boards, you’ll want to remove any Arduino-installed ‎version of the library and download it from GitHub instead. This way ‎you can submit pull requests for inclusion in future releases of the ‎code.‎
You’ll find the Protomatter library ‎at https://github.com/adafruit/Adafruit_Protomatter
and the GFX library, if any changes are needed, is ‎at https://github.com/adafruit/Adafruit-GFX-Library
Text editor powered by tinymce.‎
Let’s look at a minimal Arduino example for the Adafruit_Protomatter ‎library to illustrate how this works (this is pared down from the ‎‎“simple” example sketch):‎
#include <Adafruit_Protomatter.h>
uint8_t rgbPins[] = {7, 8, 9, 10, 11, 12};
uint8_t addrPins[] = {17, 18, 19, 20};
uint8_t clockPin = 14;
uint8_t latchPin = 15;
uint8_t oePin = 16;
Adafruit_Protomatter matrix(
64, 4, 1, rgbPins, 4, addrPins, clockPin, latchPin, oePin, false);
void setup(void) {
Serial.begin(9600);
// Initialize matrix...
ProtomatterStatus status = matrix.begin();
Serial.print("Protomatter begin() status: ");
Serial.println((int)status);
if(status != PROTOMATTER_OK) {
for(;;);
}
// Make four color bars (red, green, blue, white) with brightness ramp:
for(int x=0; x<matrix.width(); x++) {
uint8_t level = x * 256 / matrix.width(); // 0-255 brightness
matrix.drawPixel(x, matrix.height() - 4, matrix.color565(level, 0, 0));
matrix.drawPixel(x, matrix.height() - 3, matrix.color565(0, level, 0));
matrix.drawPixel(x, matrix.height() - 2, matrix.color565(0, 0, level));
matrix.drawPixel(x, matrix.height() - 1, matrix.color565(level, level, level));
}
// Simple shapes and text, showing GFX library calls:
matrix.drawCircle(12, 10, 9, matrix.color565(255, 0, 0)); // Red
matrix.drawRect(14, 6, 17, 17, matrix.color565(0, 255, 0)); // Green
matrix.drawTriangle(32, 9, 41, 27, 23, 27, matrix.color565(0, 0, 255)); // Blue
matrix.println("ADAFRUIT"); // Default text color is white
// AFTER DRAWING, A show() CALL IS REQUIRED TO UPDATE THE MATRIX!
matrix.show(); // Copy data to matrix buffers
}
void loop(void) {
Serial.print("Refresh FPS = ~");
Serial.println(matrix.getFrameCount());
delay(1000);
}Breaking it down into steps…‎
Include Protomatter Library
First is to #include the library’s header file. This in ‎turn #includes Adafruit_GFX.h, so you don’t have to.‎
#include <Adafruit_Protomatter.h>
Setting Up Matrix Pin Usage
The next few lines spell out the pin numbers being used. Using ‎variables for this isn’t entirely necessary…one could just pass the ‎same numeric values directly to functions…but it makes the code a ‎little more self-documenting (and easier to adapt the same sketch ‎for multiple boards — the full example code has #ifdefs for each ‎board with different pin assignments). These also could ‎be #defines or const if one wants to be all Proper™ about it.‎
Technical stuff for developers, skip this if you just want to use the ‎library:‎
This is the one part of the Arduino code where some knowledge of the ‎underlying hardware is required. rgbPins[] and clockPin must all be on the ‎same GPIO PORT peripheral (e.g., all PORTA, all PORTB, etc.). The other ‎pins have no such restrictions. Additionally, if the PORT has an atomic ‎bit-toggle register, RAM requirements are minimized ‎if rgbPins[] and clockPin are all within the same byte of that PORT*. ‎They do not need to be contiguous nor in any particular ‎sequence within that byte. If not within the same byte, next most ‎efficient has them in the same upper or lower 16-bit word of the ‎PORT. Scattered around a full 32-bit PORT still works but is the least ‎RAM-efficient option.‎
‎* For devices lacking an atomic bit-toggle register…clockPin does ‎not need to be in the same byte, but still must be in the same PORT. ‎Should still aim for rgbPins[] in a single byte or word though!‎
With those constraints in mind, here’s what the code looks like for an ‎Adafruit MatrixPortal M4 with a 64x32 pixel matrix:‎
uint8_t rgbPins[] = {7, 8, 9, 10, 11, 12};
uint8_t addrPins[] = {17, 18, 19, 20};
uint8_t clockPin = 14;
uint8_t latchPin = 15;
uint8_t oePin = 16;The full “simple” example sketch has setups for a number of different ‎boards and adapters.‎
Create the Protomatter Object
Next, still in the global area above setup(), we call the constructor. The ‎Arduino library can only drive one matrix at a time (or one chain of ‎matrices, where “out” from one is linked to “in” of the next), so we ‎just have one instance of an Adafruit_Protomatter object here, which we’ll ‎call matrix:‎
Adafruit_Protomatter matrix( 64, 4, 1, rgbPins, 4, addrPins, clockPin, latchPin, oePin, false);
The Adafruit_Protomatter constructor expects between 9 and 11 ‎arguments depending on the situation. The vital ones here, in order, ‎are:‎
‎64 — the total matrix chain width, in pixels. This will usually ‎be 64 or 32, the width of most common RGB LED matrices…but, ‎if you have some other size or multiple matrices chained ‎together, add up the total width here. For example, three ‎chained 32-pixel-wide matrices would be 96.‎
‎4 — the bit depth, in planes, from 1 to 6 (see below). More ‎bitplanes provides greater color fidelity at the expense of more ‎RAM. A value of 4 here (4 bits) provides 16 brightness levels ‎each for red, green, and blue — yielding 4,096 distinct colors ‎possible.‎
‎1 — the number of matrix chains in parallel. This will almost ‎always be 1, but the library could conceivably support up to ‎‎5, if the hardware driving it is set up precisely just so.‎
rgbPins — a uint8_t array of pin numbers, which issue the red, ‎green and blue data for the upper and lower half of the matrix ‎‎(sometimes labeled R1, G1, B1, R2, G2, B2 on the matrix input). ‎The array should contain six times the prior argument…so, ‎usually, six. If driving two chains in parallel, then 12 pin numbers ‎and so forth. Obviously 12 pins won’t fit in a single PORT byte, ‎and you should aim for the upper or lower 16-bit word in that ‎case, for best RAM utilization. Three or more chains, doesn’t ‎matter, but the pins all do still need to be in the same PORT.‎
‎4 — the number of row-select “address lines” used by the LED ‎matrix (sometimes labeled A, B, C, etc. on the matrix input). 16-‎pixel-tall matrices will be three row-select lines, 32-pixel will ‎have four, and 64-pixel will have five. Matrix height is ‎always inferred from this value, not passed explicitly like width.‎
addrPins — a uint8_t array of pin numbers, one for each row-‎select address line, starting from least-significant bit. These do ‎not need to be on the same PORT as rgbPins or each other…they ‎can be mixed about anywhere.‎
clockPin — pin number which drives the RGB clock (CLK on ‎matrix input). This must be on the same PORT register ‎as rgbPins, and in most cases should also try to be in the ‎same byte.‎
latchPin — pin number for “latch” signal (LAT on matrix input), ‎indicating end-of-data. Can be any output-capable pin, no ‎special constraints.‎
oePin — pin number for “!OE” signal (output-enable low, OE on ‎matrix input). Can be any output-capable pin, no special ‎constraints.‎
false — this flag indicates if the display should be double-‎buffered, better for animation at the expense of double the ‎RAM usage. Since the protomatter example isn’t using ‎animation, it passes false here…but if you look at ‎the doublebuffer_scrolltext example, it uses true. A double-‎buffered display only modifies the matrix between refreshes, ‎avoiding “tearing” artifacts. Optional. Default, if left unspecified, ‎is false.‎
Not used here, an optional 11th argument supports “tiling” of ‎matrices vertically. Horizontal tiling is already implicit in the ‎first argument — if you had two 64x32 matrices side-by-side, ‎you’d pass 128 there. But if you had four such matrices arranged ‎‎2x2, you’d still pass 128 for the first argument, but then add ‎either 2 here (if cabling is in a “progressive” order) or -2 (if a ‎‎“serpentine” order, where the second row of panels is rotated ‎‎180° relative to the first…the cabling is a little easier). The ‎‎“tiled.ino” example demonstrates this. The concept is explained ‎further in the CircuitPython LED Matrix guide…the same ‎principles apply to the Arduino library, the arguments are just a ‎little different here. Default if unspecified is 1 (no vertical tiling).‎
Also not used here, an optional 12th argument is a pointer to a ‎hardware-specific timer structure…this is super exceedingly ‎esoteric and not really used for now, but in principle would ‎allow the library to work with other timer peripherals than the ‎default.‎
Begin Protomatter Driver
Now, with the matrix object created, inside setup() we call ‎its begin() function. It’s pretty important to look at the value returned, ‎which is a ProtomatterStatus type:‎
ProtomatterStatus status = matrix.begin();
Possible return status values include:‎
PROTOMATTER_OK — everything is good, and the program can ‎proceed (otherwise it should stop…the example code is not a ‎good neighbor in this regard)‎
PROTOMATTER_ERR_PINS — the RGB data and clock pins are not all ‎on the same PORT. Can’t continue, the library requires these ‎pins in this layout
PROTOMATTER_ERR_MALLOC — couldn’t allocate enough memory for ‎display. Can’t continue. This is usually an error that happens in ‎the begin() function, but in extreme cases even the constructor ‎could hit an allocation problem, but you won’t get this response ‎until calling begin()‎
PROTOMATTER_ERR_ARG — some other bad input to function, distinct ‎from PROTOMATTER_ERR_PINS. Exceedingly rare, might only ‎happen if constructor failed
Draw Shapes & Text Using ‎Adafruit GFX
Then we draw some stuff on the display. Any graphics primitive ‎supported by the Adafruit_GFX library is available here.‎
Adafruit_GFX is the same library that drives ‎many of our LCD and OLED displays…if ‎you’ve done other graphics projects, you ‎might already be familiar! And if not, we ‎have a separate guide explaining all of the ‎available drawing functions. Most folks can ‎get a quick start by looking at the “simple” ‎and “doublebuffer_scrolltext” examples ‎and tweaking these for their needs.‎
Any color argument passed to a drawing function here is a 16-bit ‎value, with the highest 5 bits representing red brightness (0 to 31), ‎middle 6 bits for green (0 to 63), and least 5 bits for blue (0 to 31). It’s ‎just how Adafruit_GFX works and is a carryover from early PC ‎graphics and most small LCD/OLED displays.‎
The effect of bit depth on image quality. Color values are always ‎specified as full 16-bit “565” values but will quantize to coarser ‎representations at lower bit depths.‎
Sometimes you might want to avoid 6-bit depth even if RAM permits ‎it. Only green handles the full 6 bits, while red and blue are ‎quantized to 5 bits. This can result in some colors or gradients having ‎slight green or magenta tints to them. 5-bit depth is slightly blockier, ‎but colors are more predictable.‎
matrix.drawCircle(12, 10, 9, matrix.color565(255, 0, 0)); // Red matrix.drawRect(14, 6, 17, 17, matrix.color565(0, 255, 0)); // Green ...etc... matrix.show(); // Copy data to matrix buffers
Notice though the call to matrix.show() at the end. Drawing operations ‎have no immediate effect on the LED matrix, and instead are ‎working on a buffer in RAM behind the scenes. ‎Calling show() is required — it “pushes” the display data from that ‎buffer to the matrix. You can call it after each drawing function, or ‎group up a bunch of drawing commands with a ‎single show() afterward to all appear at once. If you’ve worked with ‎NeoPixel programming, it’s a similar phenomenon.‎
Since this program isn’t animating anything, it’s finished at that ‎point and loop() could be empty.‎
Check Refresh Rate
For the sake of curious information though, the example shows the ‎matrix refresh rate using getFrameCount(). This returns the number of ‎frames since the last call to the same function, not the refresh ‎rate…but if spaced about one second apart (delay(1000)), you get a fair ‎approximation of refresh rate:‎
Serial.println(matrix.getFrameCount()); delay(1000);
The matrix refresh rate is influenced by so many factors…processor ‎speed, matrix chain length, bit depth…that it’s difficult to accurately ‎predict ahead of time, so this is a way to see what you get when ‎changing different values in the constructor.‎
This is a subjective thing, but in broad terms 200 Hz or better should ‎provide a solid image…any less and it starts to become flickery, so ‎you might want a lower bit depth in that case. Conversely, refreshing ‎too fast would waste CPU cycles that you probably want for other ‎tasks like animation. The library does its best to throttle back and not ‎refresh faster than practically needed.‎
Text editor powered by tinymce.‎
The Adafruit_Protomatter repository on Github contains all source ‎code for the project. Create a new branch, to facilitate merging pull ‎requests later.‎
Files there include:‎
The remaining files in the repository are for GitHub automation, the ‎Arduino Library manager, Arduino examples and so forth. Probably ‎won’t need editing, except for an occasional bump to the version ‎number in library.properties.‎
Expanding arch.h For a New Part
Inside arch.h, you’ll find whole sections of code conditionally ‎compiled in #if defined (and corresponding #endif) statements. You’ll ‎want to add a new conditional check for your device — neither too ‎specific (there’s usually some #define that broadly relates to a family ‎of related devices), nor too vague that it accidentally compiles on an ‎incompatible chip.‎
For example, anything in the Microchip SAMD51 family is covered by ‎this:‎
#if defined(__SAMD51__) ...a whole bunch of code... #endif // end __SAMD51__
Within each device family section, it’s further divided by (usually) a ‎pair of #if defined checks, one to test if we’re compiling in the Arduino ‎environment, another if CircuitPython…perhaps others in the future:‎
#if defined(ARDUINO) ...a whole bunch of code... #elif defined(CIRCUITPY) ...a whole bunch of code... #endif
This is because each environment has different convenience ‎functions for certain operations. Arduino, for example, ‎provides digitalWrite() for GPIO output. It’s different in CircuitPython, ‎usually distinct to each architecture. The Protomatter code builds on ‎these common operations. Additionally, in the Arduino setting, the ‎library is usually attached to a specific timer/counter peripheral, set ‎at compile-time, whereas in CircuitPython timers are a dynamically ‎allocated resource…no telling what timer you’re using until run-time.‎
THEREFORE, each architecture and environment is expected to ‎establish a known and fixed set of macros or functions providing these ‎operations. core.c, which #includes arch.h, then goes about its ‎business using only those known function names, never having to ‎refer to device-specific hardware.‎
There are three groups of macros and functions: one related to GPIO, ‎one related to timers, and one miscellaneous category. With just a ‎few exceptions where noted, the following macros or functions ‎are required.‎
The macros/functions are all prefixed with ‎_PM_ (for Protomatter), ‎sort of a brute-force namespacing of things (to reduce likelihood of ‎collisions with user code) since we’re in simple C here.‎
GPIO-Related Macros/Functions
‎“Pin numbers,” as described here, refer to an environment’s ‎particular indexing of pins, which might not map directly to a ‎device’s PORTs and bits. Arduino digital and analog pins, for ‎example, might really be scattered all over the place, but are ‎exposed to the programmer as a tidy sequential list starting from ‎zero. Other environments may have their own numbering ‎system…but it’s always assumed there’s some numbering system. If ‎not, you’ll need to make one up.‎
Timer-Related Macros/Functions
The (void*) argument passed to these functions is some ‎implementation-specific representation of a timer peripheral. In ‎some cases (such as on SAMD microcontrollers) it’s simply a pointer ‎directly to a timer/counter peripheral’s register base address. If an ‎implementation requires more data associated alongside a ‎peripheral, this could instead be a pointer to a struct, or an integer ‎index.‎
A timer interrupt service routine is also required, syntax for which ‎varies between architectures.‎
Usually, the ISR needs to be related to a timer peripheral at compile-‎time, which is another reason why the Arduino implementation is ‎always tied to a specific timer…other libraries, and the Arduino core ‎itself, have their own ISRs for specific timers, we can’t take them all ‎and dole them out on request.‎
Miscellaneous Macros/Functions
If adapting to some environment that’s neither Arduino nor ‎CircuitPython: it’s easiest if the internal representation of an image in ‎RAM matches what Adafruit_GFX is using: one 16-bit unsigned word ‎per pixel, row major, no row padding. For example, a 64x32 pixel ‎matrix will use 64x32 uint16_ts, or 4 kilobytes. The first word ‎corresponds to pixel (0,0) at the top left.‎
Note that this is the drawing canvas into which points or lines or ‎other primitives are drawn, but it’s distinct from additional space ‎required by Protomatter, which must refresh the matrix plane-by-‎plane. Call the _PM_convert_565() function to process the simple canvas to ‎the shuffled matrix representation and update the display.‎
If a different canvas representation is used, you’ll have to provide ‎your own conversion function…_PM_convert_565() (and the functions it ‎calls in turn) might offer some insights there.‎
Arduino and CircuitPython implementations already handle this.‎
Insights and Surprises
Troubleshooting by just looking at an attached matrix probably ‎won’t yield much success. An oscilloscope or logic analyzer is really ‎helpful. Initially, take a good look at the clock signal…this is the ‎fastest signal the software has to generate (but mustn’t run too fast ‎for the matrix, hence _PM_clockHoldHigh and _PM_clockHoldLow, if ‎needed). Second, watch the !OE (output enable) signal…if the timer is ‎properly configured and working, you should see the time interval ‎between pulses double with each bitplane (e.g., N microseconds, N*2 ‎microseconds, N*4, etc.). You should also see an obvious sequential ‎bit-count among the row-select address lines.‎
A couple devices threw us for a loop…these problems were ‎surmountable, but worth specifically mentioning here as it may be ‎relevant to future porting efforts…‎
STM32‎
PORT bit-set and bit-clear registers do not correspond to 32-bit ports. ‎Instead, a single 32-bit register has 16-bit set and clear sections. At ‎least the bits are contiguous, and PMportSetRegister() ‎and PMportClearRegister() can just return pointers to the upper or ‎lower half of the register. Constrained to 16 bits, this does mean that ‎STM32 is limited to a maximum of two concurrent matrix chains.‎
ESP32‎
A little peculiar in that the bit-set and bit-clear registers aren’t ‎entirely atomic. If two set or clear operations occur in rapid sequence ‎‎(as happens in a couple places in the library), the second has no ‎effect. One solution would be adding NOP instructions, but this is ‎kludgey in that it doesn’t automatically handle faster CPU variants if ‎those come along in the future. Workaround was to always alternate ‎bit-set with bit-clear, using a bitmask of 0 for the second operation. ‎This waits for the first operation to “latch,” and the second has no ‎effect…we can follow up with another bit-set and it works reliably ‎now.‎
ESP32 needs the timer ISR function (and any sub-functions it calls) in ‎RAM rather than flash. This is done with an IRAM_ATTR attribute on a ‎function and broke our rule of “keep any device-specific code out ‎of core.c.” So…if any other devices also require in-RAM ISRs, and if ‎they use an attribute other than IRAM_ATTR…one should #define IRAM_ATTR to ‎whatever attribute is required there, so that section of the code will ‎handle either case.‎
ESP32-S2 and -S3‎
GPIO set/clear operations are somewhat slower than the original ‎ESP32, which would result in a flickery display, so these two make an ‎exception to the GPIO+timer rule and rely on chip-specific ‎peripherals. On the ESP32-S2, the Dedicated GPIO peripheral is used. ‎On ESP32-S3, the LCD controller peripheral. An interesting side effect ‎of this, because the ESP32 family has very flexible pin-MUXing ‎capabilities, is that any pins can be used to drive the matrix…there’s ‎no specific order or continuity required.‎
Text editor powered by tinymce.‎

