Rust Embedded Graphics with the MAX7219

Rust Embedded Graphics with the MAX7219

ยท

14 min read

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:

  1. Create simple code to configure and test the MAX7219 with a simple application. Link to Post.
  2. 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.
  3. Refactor code in the second step to incorporate embedded-hal traits and create a platform-agnostic driver. Link to Post.
  4. Register the driver in crates.io and publish its documentation. Link to Post.
  5. Add advanced features to the driver and introduce a new version of the crate.

In this post, we're going to tackle four items:

  1. 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.
  2. 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.
  3. 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.
  4. 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 the DrawTarget 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 MAX7219LedMatstruct. 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 u8values 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() in for 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. I chose the version to be "0.2.1".
  • ๐Ÿ“ฆ 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 ๐Ÿ‘‡.

Did you find this article valuable?

Support Omar Hiari by becoming a sponsor. Any amount is appreciated!

ย