This post is the last of the series on creating a platform-agnostic driver for the MAX7219 LED Driver IC. This brings us to step 5 for the planned series of posts:
- Create simple code to configure and test the MAX7219 with a simple application. Link to Post.
- Refactor and optimize the code in the first step by adding functions. This step would also create a driver that isn't platform agnostic. Link to post.
- Refactor code in the second step to incorporate embedded-hal traits and create a platform-agnostic driver. Link to Post.
- Register the driver in crates.io and publish its documentation. Link to Post.
- Add advanced features to the driver and introduce a new version of the crate.
If you find this post useful, and if Embedded Rust interests you, stay in the know and skyrocket your learning curve by subscribing to The Embedded Rustacean newsletter:
Subscribe Now to The Embedded Rustacean
In this post, we're going to tackle four items:
- Expand the driver to accommodate multiple matrix displays: The first version of the driver was designed to accommodate one matrix display. However, the MAX7219 supports the chaining of devices to accommodate a larger screen. Also instead of chaining manually, the MAX7219 8x8 dot matrix module is available commercially as a 4-in-1 display option. You can find some on AliExpress.
-
Add embedded graphics support: Embedded graphics is a Rust 2D graphics library that is focused on memory-constrained embedded devices. As the documentation states: "the goal of embedded graphics is to draw graphics without using any buffers; the crate is
no_std
compatible and works without a dynamic memory allocator, and without pre-allocating large chunks of memory." As a result, introducing this feature would enable users to draw basic shapes on the display with ease. Even potentially doing special effects like a marquee without much trouble. -
Refactor and Fix Code Bugs: Ever since publishing the first crate, I've found that there was a bug. The
clear_display
function was not actually clearing the display. Additionally, thanks to a past comment from one of our awesome blog readers @Michael Kefeder, a few parts of the code can be refactored to reduce verbosity. - Publish updated crate: After all these changes, the crate needs to be versioned and updated! We'll go through the process of what needs to be done for that.
Lets Start!
1️⃣ Adding Multiple Display Support 📺
The driver we created the last time around essentially supported controlling/driving a single MAX7219 device. However, the MAX7219 allows for chaining multiple devices together. This would widen the display LEDs to multiples of 8, and keep the height the same as before which was 8 pixels/LEDs. In order to avoid breaking changes to the past published driver, we can create a new driver struct. As a reminder, the existing driver struct looks as follows:
pub struct MAX7219<SPI, CS> {
spi: SPI,
cs: CS,
}
Now the added struct looks as follows:
pub struct MAX7219LedMat<SPI, CS, const BUFLEN: usize, const COUNT: usize> {
spi: SPI,
cs: CS,
framebuffer: [u8; BUFLEN],
}
Notice that there are four main differences between the two. First is the struct name which now is MAX7219LedMat
. Second is the addition of the BUFLEN
constant. This is a constant that will serve to define the needed framebuffer
length. The third is the COUNT
constant which serves to define the number of cascaded displays. Fourth is the framebuffer
struct member which is a u8
array type of length BUFLEN
. We'll see later that framebuffer
is required by the embedded graphics core crate to buffer the frame that will be generated for display.
For the added MAX7219LedMat
struct, another impl
block would be needed, that now looks like this:
impl<SPI, CS, const BUFLEN: usize, const COUNT: usize> MAX7219LedMat<SPI, CS, BUFLEN, COUNT>
where
SPI: Write<u8>,
CS: OutputPin,
{
// Block Functions
}
In the added impl
block, except for the new
function, we can copy over all the existing functions from the existing MAX7219
struct implementation. The new
function only needs to be modified for the MAX7219LedMat
and now looks like this:
pub fn new(spi: SPI, cs: CS) -> Result<Self, DriverError> {
let max7219 = MAX7219LedMat::<SPI, CS, BUFLEN, COUNT> {
spi: spi,
cs: cs,
framebuffer: [0; BUFLEN],
};
Ok(max7219)
}
2️⃣ Adding the Embedded Graphics Core 🖼
To implement embedded graphics support for the MAX7219 driver (or any other display for that matter), the embedded graphics documentation states the following:
To add support for embedded-graphics to a display driver,
DrawTarget
from embedded-graphics-core must be implemented. This allows all embedded-graphics items to be rendered by the display. See theDrawTarget
documentation for implementation details.
DrawTarget
is a trait that is used to add embedded-graphics support to a display (or target as the name implies) driver. In turn, going to the DrawTarget
documentation, the documentation states that in implementing the DrawTarget
trait for a driver, one has to make sure at least to implement the draw_iter
method and the Dimensions
trait. Through the above methods and traits, what will happen is that when drawing a desired shape through the embedded graphics library, a frame (or framebuffer
) will be generated. The framebuffer
would be an array that reflects the information that will be displayed on each pixel in the display. At that point, the framebuffer
data is still in our controller memory but not shown on the display yet. This means that there would be an extra step where the framebuffer
data needs to be propagated to the display. To do that, the documentation states introducing a flush
method to update the display.
This sounds like a lot 😵💫 and it probably is in the beginning. Luckily, the documentation provides an example of implementing all of this for a mock 64x64 display! 🥳
What I'm going to be implementing here is similar to the DrawTarget
documentation example with modifications for our driver. I'd recommend the interested reader to look at the documentation example here to see how it compares. Also, I'm going to break down what I've done into four steps.
Step 1: Imports 📥
Of course, we'd need to import the embedded_graphics
crate to our library to be able to use it. At a minimum to access the needed traits and display color information, as such, the following two imports are needed at minimum:
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
BinaryColor
is an enum that represents the pixel color information. BinaryColor
is used for displays that have only two possible color states. For us that is the case since the LEDs can be either on or off, there is no additional color information.
Step 2: Implement the OriginDimensions
Trait 📏
As mentioned earlier, the documentation requires that a Dimensions
trait is implemented for our display. This is what the implementation looks like:
impl<SPI, CS, const BUFLEN: usize, const COUNT: usize> OriginDimensions
for MAX7219LedMat<SPI, CS, BUFLEN, COUNT>
{
fn size(&self) -> Size {
Size::new(COUNT as u32 * 8, 8)
}
}
Examining the code, the trait implements a size
method that returns a Size
type. Size
is a type used to define the width and height of a 2D object. The 2D object is our display which means here that we would be defining the display size. As can be seen, the width is defined as COUNT as u32 * 8
, where COUNT
is the newly introduced driver constant reflecting the number of displays. Also, the height is defined as 8
. Recall, by cascading displays, the number of LEDs horizontally becomes a multiple of 8. On the other hand, the number of LEDs vertically is always fixed to 8 as we are expanding horizontally only.
Step 3: Implement the DrawTarget
Trait 🎨
The following code implements the DrawTarget
trait for the MAX7219LedMat
struct:
impl<SPI, CS, const BUFLEN: usize, const COUNT: usize> DrawTarget
for MAX7219LedMat<SPI, CS, BUFLEN, COUNT>
{
type Color = BinaryColor;
type Error = core::convert::Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
let bb = self.bounding_box();
pixels
.into_iter()
.filter(|Pixel(pos, _color)| bb.contains(*pos))
.for_each(|Pixel(pos, color)| {
let index: u32 = pos.x as u32 + pos.y as u32 * 8;
self.framebuffer[index as usize] = color.is_on() as u8;
});
Ok(())
}
}
Note how the implementation encompasses the draw_iter
function that is required to be implemented. Examining draw_iter
further, the function receives pixels
each with coordinates x
and y
, and iterates over them to store in framebuffer
. Also for each pixel, there is color
information that needs to be reflected in the framebuffer
. Since we're using BinaryColor
all that needs to be done is reflect if the LED is on or not through the is_on()
method. This all happens in the following lines:
.for_each(|Pixel(pos, color)| {
let index: u32 = pos.x as u32 + pos.y as u32 * 8;
self.framebuffer[index as usize] = color.is_on() as u8;
Essentially the draw_iter
function can be described as "drawing" the desired shape and providing the result in a memory array framebuffer
.
For the draw_iter
implementation what is also required is to check if the pixels are within the display bounds. Recall also that framebuffer
is of size BUFLEN
corresponding to the display number of pixels, so if any pixel is out of bounds then it doesn't have a place. This is ensured through a couple of lines. First is the let bb = self.bounding_box();
line that provides the boundaries of the display (through the OriginDimensions
trait). Second the .filter(|Pixel(pos, _color)| bb.contains(*pos))
method "filters" out any pixels that are outside of the bounding box bb
.
Step 4: Implement the flush
Method 🚽
From the previous step, every time we draw a shape a new framebuffer
will be generated. Though framebuffer
will be sitting in the controller memory without appearing on the display. This means that the display needs to be updated with the framebuffer
contents by sending the information over. This is done through implementing a flush
method for the MAX7219LedMat
struct. Here's the implementation of flush
:
pub fn flush(&mut self) -> Result<(), DriverError> {
// Iterate over all row addresses
for addr in 0..8 {
// Prepare device to accept data
self.cs.set_low().map_err(|_| DriverError::PinError)?;
// Send frame data for each Display
for disp in (0..COUNT).rev() {
let base = ((disp + addr) * COUNT) * 8;
let arr = &self.framebuffer[base..base + 8];
// Convert each row in the framebuffer to a decimal num
let mut res: u8 = 0;
for i in 0..arr.len() {
res |= arr[i] << arr.len() - 1 - i;
}
self.spi
.write(&[addr as u8 + 1, res])
.map_err(|_| DriverError::SpiError)?;
}
// Latch Data Sent to Device(s)
self.cs.set_high().map_err(|_| DriverError::PinError)?;
}
Ok(())
}
In flush
the display(s) are updated row by row in the outer for addr in 0..8
loop for a total of 8 rows. Iterating over each row the cs
pin needs to be driven low before sending any data through the self.cs.set_low().map_err(|_| DriverError::PinError)?;
statement. Then in the inner for disp in (0..COUNT).rev()
loop the row data is sent to each display, iterating over the number of displays COUNT
. Each display needs a row address and a value for that row. A challenge here though is that framebuffer
represents pixel information bit by bit (on or off), however, the SPI write
method we use accepts an array of u8
. This means that chunks of 8 bits in the framebuffer
need to be converted to u8
values to update row information (rather than bit information). This happens in the following lines:
let base = ((disp + addr) * COUNT) * 8;
let arr = &self.framebuffer[base..base + 8];
let mut res: u8 = 0;
for i in 0..arr.len() {
res |= arr[i] << arr.len() - 1 - i;
}
After the conversion is complete the display data is sent over in the self.spi.write(&[addr as u8 + 1, res]).map_err(|_| DriverError::SpiError)?;
statement. Once the for disp in (0..COUNT).rev()
loop is done, that means that the data for a single row for all displays has been pushed out on the bus and is ready to be latched in. Latching the data happens by asserting the cs
pin in the following statement self.cs.set_high().map_err(|_| DriverError::PinError)?;
.
🚨 Important Note
Note how I used
.rev()
infor disp in (0..COUNT).rev()
. This is because the physical display order is reversed when connected. Say if we had 3 displays, then the first address and row data that is pushed out will belong to the 3rd display followed by the 2nd, and so on.
3️⃣ Code Refactoring and Bug Fixing 🪲
🪲 Bug Fix: The clear_display
function
In the first version of the driver the clear display function looked something like this:
pub fn clear_display(&mut self) -> () {
for i in 1..9 {
self.transmit_raw_data(&[i]).unwrap();
}
}
Which was not clearing the display. The issue was that no row data was being sent to clear the rows. This is fixed in the following code, by sending a zero with every row address:
pub fn clear_display(&mut self) -> () {
for i in 1..9 {
self.transmit_raw_data(&[i, 0]).unwrap();
}
}
🔧 Code Refactor: Use the repr
attribute
In one of the posts in the series, we had to implement a DigitRowAddress
enum (and other enums) that looked like this:
pub enum DigitRowAddress {
Digit0 = 0x01,
Digit1 = 0x02,
Digit2 = 0x03,
Digit3 = 0x04,
Digit4 = 0x05,
Digit5 = 0x06,
Digit6 = 0x07,
Digit7 = 0x08,
}
As a result, when I wanted to retrieve a particular variant, I did an exhaustive match
. That looked like this:
let addr: u8 = match digit_addr {
DigitRowAddress::Digit0 => 0x01,
DigitRowAddress::Digit1 => 0x02,
DigitRowAddress::Digit2 => 0x03,
DigitRowAddress::Digit3 => 0x04,
DigitRowAddress::Digit4 => 0x05,
DigitRowAddress::Digit5 => 0x06,
DigitRowAddress::Digit6 => 0x07,
DigitRowAddress::Digit7 => 0x08,
};
let send_array: [u8; 2] = [digit_addr, led_data];
Thanks to a tip from @Michael Kefeder, instead a less verbose option is to type-cast by adding the #[repr(u8)]
attribute to the DigitRowAddress
enum and skip the whole match
resulting in something like this:
let send_array: [u8; 2] = [digit_addr as u8, led_data];
Note the type-case with the as u8
which now would return the number of the variant in the DigitRowAddress
enum. A similar thing can be done with other enums as well.
🔧 Code Refactor: Replace implementation of TryFrom
For the DigitRowAddress
enum as well, there was a case where we needed to iterate over the enum values one by one using an integer value. As a result, to facilitate that, we had to implement a TryFrom
trait for the DigitRowAddress
enum that takes a u8
and returns a corresponding enum option. It resulted in the following code:
impl TryFrom<u8> for DigitRowAddress {
type Error = u8;
fn try_from(value: u8) -> Result<Self, Self::Error> {
use DigitRowAddress::*;
Ok(match value {
0x01 => Digit0,
0x02 => Digit1,
0x03 => Digit2,
0x04 => Digit3,
0x05 => Digit4,
0x06 => Digit5,
0x07 => Digit6,
0x08 => Digit7,
invalid => return Err(invalid),
})
}
}
Again, thanks to @Michael Kefeder, there exists a num_enum::TryFromPrimitiveCopy
macro that can facilitate that instead. In order to use the new macro, the following imports need to be added:
use num_enum::TryFromPrimitive;
use std::convert::TryFrom;
Now the resulting DigitRowAddress
enum, with the addition from the previous refactor, looks like this:
#[repr(u8)]
#[derive(Debug, TryFromPrimitive)]
pub enum DigitRowAddress {
Digit0 = 0x01,
Digit1 = 0x02,
Digit2 = 0x03,
Digit3 = 0x04,
Digit4 = 0x05,
Digit5 = 0x06,
Digit6 = 0x07,
Digit7 = 0x08,
}
and the old TryFrom
implementation we had can be removed completely.
🔧 Code Refactor: Remove exhaustive matches
In prior code where we were doing match
over various enums, there was a _
arm added. As highlighted by @Michael Kefeder, and a compiler warning that I ignored 🙄, using _
was not necessary since an exhaustive match was already happening. Keeping the _
match arm would only introduce trouble if the enum is modified/expanded in the future.
4️⃣ Publish Updated Crate 📦
Now that the changes are finished. The crate on crates.io needs to be updated. Ahead of doing that, there are a few things that need to be taken care of as follows:
- 📚 Update Documentation: This is something we should have made sure of doing during writing the code. Also in the create description, it wouldn't hurt to add an example for the usage of the newly introduced struct.
- 🏷 Update Package Metadata: Now that we've made changes some of the metadata needs to be updated. At a minimum, we need to version up the crate to "0.2.0".
-
📦 Reduce Crate Size: I noticed when publishing the driver crate the first time around that the crate size is necessarily large (around 5MB). It turns out that there are more files being uploaded than needed. These were many of the build-generated files that don't need to be there. This can be resolved through the
exclude
attribute explained in the last post or simply deleting any generated files. -
📢 Publish Crate: Now that we're done, the crate can be published as before using the
cargo publish
command!
And we're done!
Here's a link To view the driver on crates.io.
Example Code 👨💻
Below is an excerpt of an example code implementing various shapes. Note how the flush
method is used after drawing a certain shape. The full code example can be found here.
// BUFLEN value is number of pixels for a single 8x8 display that results in a 64 element buffer
// COUNT value reflects the number of cascaded displays
let mut max7219: MAX7219LedMat<_, _, 64, 1> = MAX7219LedMat::new(spi, cs).unwrap();
// Initialize Display
max7219.init_display(true);
// Example Drawing a Circle
// Circle::new(Point::new(0, 0), 4)
// .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
// .draw(&mut max7219)
// .unwrap();
// Example Drawing a Point
// Pixel(Point::new(0, 1), BinaryColor::On)
// .draw(&mut max7219)
// .unwrap();
// Example Drawing a Triangle
// Triangle::new(Point::new(3, 0), Point::new(0, 4), Point::new(7, 4))
// .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
// .draw(&mut max7219)
// .unwrap();
// Example Drawing a Character
let txtstyle = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
Text::new("Y", Point::new(0, 7), txtstyle)
.draw(&mut max7219)
.unwrap();
// Update the Display
max7219.flush().unwrap(); // Example Drawing a Circle
Conclusion
This post was the last step in a series of creating and updating a Rust platform agnostic driver for the MAX7219 device. In this post, the driver was expanded to accommodate cascaded displays and the Rust embedded-graphics crate. The embedded graphics crate enables the user to generate different shapes and text using a set of special methods. The updated driver was also published to crates.io.
Have any questions or thoughts? Please post them in the comments below 👇.
Top comments (0)