Rust Embedded Graphics with the MAX7219

Search for a command to run...

No comments yet. Be the first to comment.
A community giveaway program by The Embedded Rustacean × Seeed Studio to grow the embedded Rust ecosystem

Introduction Another year comes to an end; as always, it is an opportunity to reflect on the achievements of the past year and look forward to the next. It's been over 3.5 years since I embarked on my embedded Rust journey, and it only gets more exci...

Introduction Another year comes to an end, as such, this is an opportunity to reflect on the achievements of 2024 and look forward to 2025. It's been over 2.5 years since I embarked on my embedded Rust journey, and it remains as exciting as ever. If ...

Introduction 🚀 On Friday, May 17th, 2024, the release of Simplified Embedded Rust was announced marking a new kind of book in the embedded Rust learning space. I'm happy to say that this book, design

This post is the sixth of a multi-part series where I'm exploring the use of Bluetooth Low Energy along embedded Rust on the ESP32. Information in this post might rely on knowledge presented in past p

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:
In this post, we're going to tackle four items:
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.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. Lets Start!
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)
}
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,
DrawTargetfrom embedded-graphics-core must be implemented. This allows all embedded-graphics items to be rendered by the display. See theDrawTargetdocumentation 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()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.
🪲 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.
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:
exclude attribute explained in the last post or simply deleting any generated files.cargo publish command!And we're done!
Here's a link to view the driver on crates.io.
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
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 👇.