Platform Agnostic Drivers in Rust: MAX7219 Naive Code Refactoring
Tags: rust, embedded, tutorial, beginners
Published: 2022-10-03 20:07:12

## Introduction

In this post, I continue the work I started in the [previous post](https://apollolabsblog.hashnode.dev/stm32f4-embedded-rust-at-the-hal-spi-with-the-max7219-led-dot-matrix) where I created a simple SPI application driving an LED dot matrix through the MAX7219 LED Driver IC. The goal ultimately was to create a platform agnostic driver for the MAX7219. To reach that goal, as a reminder, here are the steps I had laid out for the series:

1. Create simple code to configure and test the MAX7219 with a simple application. [Link to post](https://dev.to/apollolabsbin/stm32f4-embedded-rust-at-the-hal-spi-with-the-max7219-led-dot-matrix-3627).
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.**
3. Refactor code in the second step to incorporate embedded-hal traits and create a platform agnostic driver.
4. Register the driver in [crates.io](https://crates.io/) and publish its documentation.
5. Add advanced features to the driver and introduce a new version of the crate. 

Step 2 in **bold** is where we stand now in the series. So this means that right now the goal is to replace repetitive code with functions that can be utilized to drive the MAX7219 instead. Note that here in what I am going to be doing here, I'm taking sort of the typical approach one would maybe in other languages like C. Though due to certain patterns in embedded Rust, like the singleton pattern, it will show that the code is still not going to be optimal. As a matter of fact, the code here will also be less flexible (not platform agnostic) as we'll see. So why am I doing this? hopefully to make a point and better explain the proper way of how doing things in Rust looks like. I figure that rather than presenting a solution right away, showing the problem would help explain why the solution is the way it is.

#### 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%} 

Additionally, all the code presented in this post in addition to instructions for the environment and toolchain setup are available on the [apollolabsdev Nucleo-F401RE](https://github.com/apollolabsdev/stm32-nucleo-f401re) git repo. Note that if the code on the git repo is slightly different then it means that it was modified to enhance the code quality or accommodate any HAL/Rust updates.

## Driver Functions
In creating my initial swipe at the driver code I want to list out the basic functions that the driver is going to provide. The functions would be ones that would enable one to initialize and control all possible device functionality and be able to light up any LED. This is all information provided by the datasheet. Any additional special features/functions like drawing specific characters or supporting larger displays I reserve for step 5 in the series. Those would be functions that require more algorithmic involvement. As such, here's the list of functions that will be created for now:

1️⃣ A `transmit_raw_data` function that will serve to transmit data to the device whenever needed. It's called `raw` since the function will not have any knowledge of the kind of data that is being sent.

2️⃣ A `config_power_mode` function to configure the MAX7219 power mode.

3️⃣ A `config_decode_mode` function to configure the MAX7219 decode mode.

4️⃣ A `config_intensity` function to configure the LED intensity.

5️⃣ A `config_scan_limit` function to configure the MAX7219 scan limit.

6️⃣ A `display_test` function to test the display connected to the MAX7219.

7️⃣ A `draw_row_or_digit` function that activates LEDs to draw a row if the MAX7219 is driving an 8x8 dot matrix, or a digit if the MAX7219 is driving seven segment displays. Although I am connecting an 8x8 dot matrix, I named the function in a generic way since the driver is not restricted only to dot matrix operation.

8️⃣ A `init_display` function to initialize the display connected to the MAX7219.

9️⃣ A `clear_display` function to clear the display connected to the MAX7219.

## Encoding Configuration Options
In configuring the MAX7219 device, or any other device for that matter, typically several configuration options would exist. These configuration options present themselves in special values or codes that would be hard to memorize or remember without constantly referring to the datasheet. Consequently, it would be often convenient to encode the special values with names instead. This can be done using enums. Enumerations are such a powerful construct that when you get used to them, they're hard to let go of! 

For the sake of brevity, I'll cite one example and then list the rest of the enums I created. The reader can refer to the code for the implementation details on the [apollolabsdev Nucleo-F401RE](https://github.com/apollolabsdev/stm32-nucleo-f401re) git repo. For example, table 4 in the datasheet lists the different decode modes available in the MAX7219.


![max7219 Decode modes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g1mbikrutooju97egftp.png)

This table corresponds to the `DecodeMode` enum in the code that looks as follows:

```rust
enum DecodeMode {
    NoDecode = 0x00,
    CodeB0 = 0x01,
    CodeB30 = 0x0F,
    CodeB70 = 0xFF,
}
```
Other enums I created in the code include `Shutdown`, `SevenSegCharacter`, `Intensity`, `ScanLimit`, and `DisplayTest` each corresponding to a particular configuration table in the MAX7219 datasheet.

## Encoding Commands & LED Data
In driving the MAX7219 functionality, as explained in the device block diagram, a 16-bit value is sent. The 16-bit value is divided into two parts, data, and addresses, or commands, if more appropriate to call them that way. The full list of addresses is shown in Table 2 in the MAX7219 datasheet. 


![max7219 address map](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/08qynbla0w8o0w3zx7rj.png)

I decided to not encode the full address map in one enum for two reasons. One is for code readability and usability. Another is that there are certain implementations that I will do to part of the addresses that I won't do to the others (more detail will follow). As such, I created two enums, one is a `Command` enum that encodes only the "command" portion for configuring the device and looks as follows:

```rust
enum Command {
    NoOp = 0x00,
    DecodeMode = 0x09,
    Intensity = 0x0A,
    ScanLimit = 0x0B,
    Shutdown = 0x0C,
    DisplayTest = 0x0F,
}
``` 
The other enum is the `DigitRowAddress` enum that encodes the addresses alternating the digit/row data and looks as follows:

```rust
enum DigitRowAddress {
    Digit0 = 0x01,
    Digit1 = 0x02,
    Digit2 = 0x03,
    Digit3 = 0x04,
    Digit4 = 0x05,
    Digit5 = 0x06,
    Digit6 = 0x07,
    Digit7 = 0x08,
}
```
Now a challenge here is that from the code we are refactoring, we'll need to somehow extract from the`DigitRowAddress` enum an option that corresponds to a value to pass it to the `draw_row_or_digit` function. Iterating over the enum could be an approach though, by default, there isn't a way to iterate over enums. Another way around it is to implement the `TryFrom` trait for the `DigitRowAddress` enum as follows: 

```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),
        })
    }
}
```
Here what we're doing is that a `u8` value is passed in for a possible match with one of the `DigitRowAddress` enum options. If a match is found then the value of that option is returned wrapped in a `Result`. If no match is found an error is returned.

## Function Implementation

### The `transmit_raw_data` function
I started with `transmit_raw_data` because its a function that all other subsequent functions rely on. `transmit_raw_data` is a function that performs a task that has been done repetitively in the past code after preparing the data. Its the core function performing data transmission, as the name obviously suggests. It was represented by the following lines:

```rust
    cs.set_low();
    spi.write(&send_array).unwrap();
    cs.set_high();
```
As such, `transmit_raw_data` encapsulates these lines as follows:

```rust
fn transmit_raw_data(
    arr: &[u8],
    per: &mut Spi<
        SPI1,
        (
            Pin<'A', 5_u8, Alternate<5_u8>>,
            NoPin,
            Pin<'A', 7_u8, Alternate<5_u8>>,
        ),
        TransferModeNormal,
    >,
    cs: &mut Pin<'A', 6_u8, Output>,
) -> Result<(), stm32f4xx_hal::spi::Error> {
    cs.set_low();
    let transfer = per.write(&arr);
    cs.set_high();
    transfer
}
```
We can see here that the function takes a slice of `u8` in addition to two other parameters. The other two parameters are a mutable instance of `SPI1` and a mutable instance of pin `PA6`.  But why? it's because instances of peripherals are created, only one instance exists (singleton pattern) and these instances are not naturally globally accessible. This means as we would need to access a peripheral instance, it would need to be passed around (borrowed and returned) from function to function. This is obviously an issue, as it makes the code more verbose adding parameters equal to the number of peripherals needed for all functions that need to access the peripheral. Another issue that might be obvious, is that the parameters are specific to a certain instance, meaning specific to `SPI1` and `PA6`. This restricts the driver code usage, what if one would want to use `SPI2` instead or even a different pin for `cs`. Here they can't! Making the code very restrictive. To do that the driver source would need to be altered which is not realistic.

So, is there a solution? absolutely! with some interesting refactoring use of the embedded-hal which will be covered in the next post. 

Finally note that I am returning `transfer` in the end of the function, this is to allow the error from the SPI instance to propagate.

### The `config` functions

There are several functions that we create for device configuration. Essentially, one that corresponds to each configuration enum. As a result, configuration functions have a similar type of signature. I will cover `config_scan_limit` as an example here and list the others that have a similar type of signature/approach. `config_scan_limit` will need the instances of the SPI peripheral and CS pin as well. This is because `config_scan_limit` will call `transmit_raw_data` to transmit the configuration info. Additionally, as a third parameter, `config_scan_limit` will need the configuration `mode` which is in the `ScanLimit` enum. This results in the following code:

```rust
fn config_scan_limit(
    per: &mut Spi<
        SPI1,
        (
            Pin<'A', 5_u8, Alternate<5_u8>>,
            NoPin,
            Pin<'A', 7_u8, Alternate<5_u8>>,
        ),
        TransferModeNormal,
    >,
    cs: &mut Pin<'A', 6_u8, Output>,
    mode: ScanLimit,
) -> () {
    // match mode to option in ScanLimit
    let data: u8 = match mode {
        ScanLimit::Display0Only => 0x00,
        ScanLimit::Display0And1 => 0x01,
        ScanLimit::Display0To2 => 0x02,
        ScanLimit::Display0To3 => 0x03,
        ScanLimit::Display0To4 => 0x04,
        ScanLimit::Display0To5 => 0x05,
        ScanLimit::Display0To6 => 0x06,
        ScanLimit::Display0To7 => 0x07,
    };
    // Package into array to pass to SPI write method
    // Write method will grab array and send all data in it
    let send_array: [u8; 2] = [Command::ScanLimit as u8, data];
    // Transmit Data
    transmit_raw_data(&send_array, per, cs).unwrap();
}
```
In the function itself you can see that the `transmit_raw_data` function created earlier is also used to send the data. `transmit_raw_data` takes in `send_array` as one of the arguments to transmit. In `send_array` the first element is the `ScanLimit` command from the `Command` enum. The second element is one of the display options from the `ScanLimit` enum. 

If you examine the rest of the `config` functions, you'll see that they have a very similar footprint. The main difference being that the enum used corresponds to the feature that is being configured. This includes the functions `config_power_mode`, `config_decode_mode`,  `config_intensity`,  and `display_test`.

### The `draw_row_or_digit` function
This function is meant to light up LEDs row by row, or alternatively if seven segment display are hooked up it would light up different segments per digit. Lets examine the function:

```rust
fn draw_row_or_digit(
    per: &mut Spi<
        SPI1,
        (
            Pin<'A', 5_u8, Alternate<5_u8>>,
            NoPin,
            Pin<'A', 7_u8, Alternate<5_u8>>,
        ),
        TransferModeNormal,
    >,
    cs: &mut Pin<'A', 6_u8, Output>,
    digit_addr: DigitRowAddress,
    led_data: u8,
) -> Result<(), AddressError> {
    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,
        _ => return Err(AddressError::AddressNotValid),
    };
    let send_array: [u8; 2] = [addr, led_data];
    transmit_raw_data(&send_array, per, cs).unwrap();
    Ok(())
}
```
You can see that quite similar to the `config` functions with minor differences. Here there are the usual suspects of the `per`  and `cs` parameters, in addition  to another two `digit_addr` and `led_data`. `digit_addr` is the `DigitRowAddress` enum where the address of the digit or row is selected, and `led_data` is a `u8` containing the information for which LEDs to light up. 

### The `clear_display` function
This function is meant to clear the display, the function simply iterates over all rows of the device sending a value of zero to each row. Again, `per` and `cs` had to be passed at least as parameters. For the sake of simplicity, I skipped using the `DigitRowAddress` enum and used the addresses directly.

```rust
fn clear_display(
    per: &mut Spi<
        SPI1,
        (
            Pin<'A', 5_u8, Alternate<5_u8>>,
            NoPin,
            Pin<'A', 7_u8, Alternate<5_u8>>,
        ),
        TransferModeNormal,
    >,
    cs: &mut Pin<'A', 6_u8, Output>,
) -> () {
    for i in 1..9 {
        transmit_raw_data(&[i, 0_u8], per, cs).unwrap();
    }
}
```

### The `init_display` function
This function was let for last since it depends on all other functions created earlier. `init_display` initializes the display for usage. The function steps through the configurations required by the datasheet so that the display can be used. These are the same steps as the previous post, only packaged in functions now. A `clr_display` `bool` type parameter was added as giving an option to clear the display after initializing it.

```rust
fn init_display(
    per: &mut Spi<
        SPI1,
        (
            Pin<'A', 5_u8, Alternate<5_u8>>,
            NoPin,
            Pin<'A', 7_u8, Alternate<5_u8>>,
        ),
        TransferModeNormal,
    >,
    cs: &mut Pin<'A', 6_u8, Output>,
    clr_display: bool,
) -> () {
    // 1.a) Power Up Device
    config_power_mode(per, cs, Shutdown::NormalOperation);
    // 1.b) Set up Decode Mode
    config_decode_mode(per, cs, DecodeMode::NoDecode);
    // 1.c) Configure Scan Limit
    config_scan_limit(per, cs, ScanLimit::Display0To7);
    // 1.d) Configure Intensity
    config_intensity(per, cs, Intensity::Ratio15_32);
    // 1.e) Optional Screen Clear on Init
    if clr_display {
        clear_display(per, cs);
    }
}
```

## Refactoring Application Code

In the application code from the past post, the code iterated over each row of the device activating LED diagonally. Instead of the code earlier, now we should be able to leverage the newly created `draw_row_or_digit` function resulting in the following code:

```rust
let mut data: u8 = 1;
for addr in 1..9 {
    data = data << 1;
    draw_row_or_digit(
        &mut spi,
        &mut cs,
        DigitRowAddress::try_from(addr).unwrap(),
        data,
    )
    .unwrap();
    delay.delay_ms(500_u32);
}
```
Note here the usage of the `try_from` trait mentioned & implemented earlier in this post. Since `draw_row_or_digit` takes `DigitRowAddress` as a parameter, it wouldn't be possible to pass `addr`, an integer type. This is where `try_from` fills in the gap, it converts the `u8` to a `DigitRowAddress` enum option. 

## Conclusion
In this post, the first step in creating a Rust platform agnostic driver for the MAX7219 device was taken. This was done by refactoring the code from the [previous post](https://apollolabsblog.hashnode.dev/stm32f4-embedded-rust-at-the-hal-spi-with-the-max7219-led-dot-matrix) and packaging it into functions. It turns out that based on the approach taken in this post, the newly created code was actually verbose and not as flexible as desired. In the following post, the code will be refactored again creating inching further toward a platform-agnostic driver.

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%}