Rust Embedded Graphics with the MAX7219
Tags: rust, embedded, beginners, tutorial
Published: 2022-10-24 18:33:59

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](https://apollolabsblog.hashnode.dev/stm32f4-embedded-rust-at-the-hal-spi-with-the-max7219-led-dot-matrix).
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](https://apollolabsblog.hashnode.dev/platform-agnostic-drivers-in-rust-max7219-naive-code-refactoring).
3. Refactor code in the second step to incorporate embedded-hal traits and create a platform-agnostic driver. [Link to Post](https://apollolabsblog.hashnode.dev/platform-agnostic-drivers-in-rust-the-max7219-driver).
4. Register the driver in [crates.io](https://crates.io/) and publish its documentation. [Link to Post](https://apollolabsblog.hashnode.dev/platform-agnostic-drivers-in-rust-publishing-to-cratesio).
5. **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:

{% cta  http://www.theembeddedrustacean.com %} Subscribe Now to The Embedded Rustacean{% endcta%} 

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](https://s.click.aliexpress.com/e/_Dd1tLgB).
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](@mkfed), 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:

```rust
pub struct MAX7219<SPI, CS> {
    spi: SPI,
    cs: CS,
}
```
Now the added struct looks as follows:

```rust
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:

```rust
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:

```rust
    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](https://docs.rs/embedded-graphics/latest/embedded_graphics/) 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](https://docs.rs/embedded-graphics-core/latest/embedded_graphics_core/draw_target/trait.DrawTarget.html), 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](https://docs.rs/embedded-graphics-core/latest/embedded_graphics_core/draw_target/trait.DrawTarget.html) 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:

```rust
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:

```rust
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:

```rust
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:

```rust
.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`:

```rust
    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()` 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:

```rust
    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:

```rust
    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:

```rust
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:

```rust
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](@mkfed), 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:

```rust
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:

```rust
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](@mkfed), there exists a [`num_enum::TryFromPrimitiveCopy`](https://docs.rs/num_enum/latest/num_enum/derive.TryFromPrimitive.html) macro that can facilitate that instead. In order to use the new macro, the following imports need to be added:

```rust
use num_enum::TryFromPrimitive;
use std::convert::TryFrom;
```
Now the resulting `DigitRowAddress` enum, with the addition from the previous refactor, looks like this:

```rust
#[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](@mkfed), 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](https://apollolabsblog.hashnode.dev/platform-agnostic-drivers-in-rust-publishing-to-cratesio) 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](https://crates.io/crates/max7219-driver) 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](https://github.com/apollolabsdev/stm32-nucleo-f401re/tree/main/Drivers).

```rust
    // 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 👇. 

#### If you found 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:

{% cta  http://www.theembeddedrustacean.com %} Subscribe Now to The Embedded Rustacean{% endcta%}