The Rusty Clams

Rust adventures !

Rust Embassy (P3) - A basic LR2021 Driver

07 Aug 2025

Now that all the building block are in place to communicate with the chip, it is time to finally create an actual driver to provide some abstractions.

First step: basic packaging

For the moment I’ll keep the development of the driver in the same crate as the application, but to be sure I can easily convert it to an independant crate, I’ll start by adding a module to the crate, named lr2021 in which I declare a struct to hold all the pins related to the LR2021:

pub struct Lr2021 {
    // Pins
    nreset: Output<'static>,
    busy: Input<'static>,
    irq: ExtiInput<'static>,
    spi: Spi<'static, Blocking>,
    nss: Output<'static>,
    /// Last chip status
    status: Status,
}

Since every command return some status, I also include a field to hold it.

One interesting point with the ownership system in Rust, is that the driver owns the pins: this guaranties that no other part of the application will be able to access it until it drop the driver, and so avoid many potential issue we could get as we build a more complex application.

Every time we interact with the hardware, there is chance of failure, so in the driver it is important to handle properly the different error cases:

#[derive(Debug,Format)]
pub enum Lr2021Error {
    /// Unable to use SPI
    Spi,
    /// Last command failed
    CmdFail,
    /// Last command was invalid
    CmdErr,
    /// Unknown error
    Unknown,
}

And we can update the Status structure to return a Result based on the command status:

    pub fn check(&self) -> Result<(), Lr2021Error> {
        match self.cmd() {
            CmdStatus::Unknown => Err(Lr2021Error::Unknown),
            CmdStatus::Fail => Err(Lr2021Error::CmdFail),
            CmdStatus::PErr => Err(Lr2021Error::CmdErr),
            CmdStatus::Ok   |
            CmdStatus::Data => Ok(()),
        }
    }

Let’s add methods to read/write command. It is basically a copy/paste of what we were doing in the for-loop of our application, with just a better error handling.

    /// Write a command
    pub async fn cmd_wr(&mut self, req: &[u8]) -> Result<(), Lr2021Error> {
        self.nss.set_low();
        self.spi
            .blocking_transfer(self.status.as_mut(), req)
            .map_err(|_| Lr2021Error::Spi)?;
        self.nss.set_high();
        if !self.status.is_ok() {
            error!("Request failed: => {=[u8]:x} =< {}", self.status.as_bytes(), self.status);
        }
        self.status.check()
    }

    /// Write a command and read response
    /// Rsp must be n bytes equal to 0 where n is the number of expected byte
    pub async fn cmd_rd(&mut self, req: &[u8], rsp: &mut [u8]) -> Result<(), Lr2021Error> {
        self.cmd_wr(req).await?;
        // Wait for busy to go down before reading the response
        // TODO: add a timeout to avoid deadlock
        while self.busy.is_high() {}
        self.nss.set_low();
        self.spi
            .blocking_transfer_in_place(rsp)
            .map_err(|_| Lr2021Error::Spi)?;
        self.nss.set_high();
        self.status.updt(&rsp[..2]);
        if !self.status.is_ok() {
            error!("Response => {=[u8]:x} =< {}", self.status.as_bytes(), self.status);
        }
        self.status.check()
    }

Since I get some warning that I am not using the reset and IRQ pins, let’s also add some methods for reset and waiting on IRQ:

    /// Reset the chip
    pub async fn reset(&mut self) {
        self.nreset.set_low();
        Timer::after_millis(10).await;
        self.nreset.set_high();
        Timer::after_millis(10).await;
        debug!("Reset done : busy = {}", self.busy.is_high());
    }

    /// Wait for an interrupt
    pub async fn wait_irq(&mut self) {
        if !self.irq.is_high() {
            self.irq.wait_for_rising_edge().await;
        }
    }

Finally we can update our application reading temperature:

    let mut lr2021 = Lr2021::new(nreset, busy, irq, spi, nss);
    lr2021.reset().await;

    let cmd = [0x01,0x25,5|8];
    loop {
        Timer::after_secs(15).await;
        let mut buf_rsp = [0x00;4];
        match lr2021.cmd_rd(&cmd, &mut buf_rsp).await {
            Ok(_) => info!("Temp = {}.{:02}", buf_rsp[2], (buf_rsp[3] as u16 * 100) >> 8),
            Err(e) => error!("{}", e),
        }
    }

We can later add some function to geneate the commands instead of having to create an array by hand, but this starts to look like a usable driver.

Second step: generic types

There is one big issue with this driver: all the pin types are actually defined in embassy_stm32: this means this driver only works with an STM32 which is not really practical, we don’t want to rewrite all this code for each MCU on the market.

What we need to use are generics bounded by traits defined in the embedded-hal crate:

Since the Spi type in the STM32 does not control the NSS, I will use the SpiBus trait.

Now my Lr2021 struct looks like this:

pub struct Lr2021<I,O,IRQ,SPI> {
    // Pins
    nreset: O,
    busy: I,
    irq: IRQ,
    spi: SPI,
    nss: O,
    status: Status,
}

And the impl like this:

impl<I,O,IRQ,SPI> Lr2021<I,O,IRQ,SPI> where
    I: InputPin, O: OutputPin, IRQ: InputPin + Wait, SPI: SpiBus<u8>

And that’s it ! Wait, there are errors everywhere :(

First issue is that the pin methods (set_low(), is_high(), …) are now faillibe. It make sense when you want to be generic. It is easily fixed using .map_err and the ? operator. The reset function now looks like this:

    pub async fn reset(&mut self) -> Result<(), Lr2021Error> {
        self.nreset.set_low().map_err(|_| Lr2021Error::Pin)?;
        Timer::after_millis(10).await;
        self.nreset.set_high().map_err(|_| Lr2021Error::Pin)?;
        Timer::after_millis(10).await;
        debug!("Reset done");
        Ok(())
    }

Second issue is related to the SPI type not implementing the SpiBus trait: there are two flavor of the SPI concrete type (blocking or non-blocking) and similarly two SpiBus defined in the embedded_hal_1::spi::SpiBus or embedded_hal_async::spi::SpiBus. Since I want to go full async, I chose embedded_hal_async, meaning that I need to create a non-blocking SPI.

The non-blocking SPI requires two DMA channels (TX and RX), so I updated the SPI instance like this:

let spi = Spi::new(p.SPI1, p.PA5, p.PA7, p.PA6, p.DMA1_CH3, p.DMA1_CH2, spi_config);

And if you wonder how I found which channel to use, the answer is simple: the compiler told me :) I did not had to look at the documentation, I just selected the first two DMA channel, and the compiler kindly told me that DMA1_CH1 cannot be used with SPI1, but DMA1_CH3 can. Not only did the strong type system allowed me to get the error at compile-time, but Rust goes further with great care on error messaging so that I do not even have to look at the doc.

No more compilation error, but this time I get a cryptic run-time error:

0.000091 [INFO ] Starting get_temp (v2) (get_temp src/bin/get_temp.rs:29)
0.021026 [DEBUG] Reset done (lr2021_apps src/lr2021/mod.rs:66)
0.021575 [ERROR] panicked at 'assertion failed: `(left == right)`'
diff < left / right >
<6
>2 (embassy_stm32 src/spi/mod.rs:829)
0.022338 [ERROR] panicked at C:\Users\C\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\defmt-1.0.1\src\lib.rs:385:5:
explicit panic (panic_probe panic-probe-1.0.0/src/lib.rs:104)

At least there is a reference to the error: and looking at the code it appears that the change in the SPI type has also changed the behavior of the transfer function: before it was possible to have an output buffer with a different size than the input, but now there is an assert ensuring that both buffer have the same size. Since the SPI is now using DMAs it kind of make sense. When you understand the error the fix is simple: just update the cmd_wr method to get a slice on the Status buffer with the same length as the request. I’ll need to update this later to support command longer than 6 bytes (the size of Status), but for now this will do.

Conclusion

While I have not tested it with other MCUs, there is no reason to think it would not work: the embedded-hal traits should guaranty that this driver is portable and looking at the different crate I see that the traits I am using are implemented for STM32, NRF, Raspberry Pi and ESP: that’s already a significant chunk of existing MCU for IoT, and it should not be that difficult to add support for other chip if needed.

The driver is very low-level so now comes the boring part: adding helper function to build all the possible command to access the chip, creating types for the responses and adding high level function to easily set up the radio.