The Rusty Clams

Rust adventures !

Rust Embassy (P2) - SPI communication with LR2021

06 Aug 2025

After part 1, we now have a basic setup to compile and run a firmware on the Nucleo. Let’s start writing the basis for an LR2021 driver: using the SPI and controlling a few extra GPIO pin to communicate with our chip.

Looking at examples in the embassy repository, it looks like using the SPI is actually quite easy: there is already an abstraction available for the SPI with a simple method to transfer a buffer and collect the response.

Before starting, I noticed that the LR2021 module board had two LEDs, and to check that I have correctly understand how the module pins are connected to the STM32 I wanted to blink each led at a different speed using the blink task.

But I got the error : Too many instances of this task are already running. Check the `pool_size` attribute of the task. And so evidently when a task need to be spawned multiple time, it needs to be allocated statically. Good to know, and once again in Rust, great error message allowing to solve the problem quickly:

#[embassy_executor::task(pool_size = 2)]
async fn blink(mut led: Output<'static>, delay: u64)

Protocol description

First we need to understand how to communicate to the chip with the SPI.

The protocol is relatively simple:

Additionally, during a request the chip provide a status of 2 byte corresponding to the last SPI command (indicating if it succeed or not, as well as the chip status and an IRQ flag) followed by up to 4 bytes (depending on the request length) corresponding to the interrupts status.

Reading the version number

The command to get the version number is 0x101 with no parameters and the response is on 4 bytes: 2 status bytes, one byte for major version and one byte for minor version.

To create the SPI, we need a configuration (providing information such as speed, byte order, …), the SPI peripheral used and the three pins (SCK, MOSI, MISO).

    let mut spi_config = Config::default();
    spi_config.frequency = Hertz(4_000_000);
    let mut spi = Spi::new_blocking(p.SPI1, p.PA5, p.PA7, p.PA6, spi_config);

Trying to set the SPI clock higher leads to an error: I likely need to setup something during the init of the chip, since it should go up to 32MHz according to the datasheet. I’ll keep it at 4MHz, it is more than enough for the moment.

The NSS pin (used to select a slave peripheral on the SPI bus) is handled separately as a GPIO.

	let mut nss = Output::new(p.PA8, Level::High, Speed::VeryHigh);

I am setting the speed to the max to be sure there is no issue, but some test should allow to see how low we can go (having high drive on the pins can create noise on the board which can be an issue during a reception).

For the status I create new type around u16 and provides some enum for the different fields and methods to access them. It is worth spending time to make something clean here, since this will be integrated later in a proper driver.

pub struct Status(u16);

#[derive(Format, PartialEq)]
pub enum CmdStatus {
    Fail = 0, // Last Command could not be executed
    PErr = 1, // Last command had invalid parameters or the OpCode is unknown
    Ok   = 2, // Last Command succeed
    Data = 3, // Last command succeed and now streaming data
    Unknown = 8, // Unknown status
}
...

    pub fn is_ok(&self) -> bool {
        matches!(self.cmd(),CmdStatus::Ok | CmdStatus::Data)
    }

    /// Return true if an Interrupt is pending
    pub fn irq(&self) -> bool {
        (self.0 & 0x100) != 0
    }
...

And finally each time the button is pressed we can send the request and collect the response using two consecutive access. The wait on the busy pin is active (i.e. no await, just a loop waiting for the pin to go low) since we do not want to allow for example another task to use the SPI in between.

	// Send Request
    let mut buf_req = [0x01,0x01];
    nss.set_low();
    unwrap!(spi.blocking_transfer_in_place(&mut buf_req));
    nss.set_high();
    // Wait for chip ready
    while busy.is_high() {}
    // Collect response
    let mut buf_rsp = [0x00;4];
    nss.set_low();
    unwrap!(spi.blocking_transfer_in_place(&mut buf_rsp));
    nss.set_high();
    let status = Status::from_slice(&buf_rsp);
    info!("Response => {=[u8]:x} => {} | Version = {:02x}.{:02x}", buf_rsp, status, buf_rsp[2], buf_rsp[3]);

A real application

Let’s do something a bit more useful: using the chip as a temperature sensor !

Getting a temperature is actually not more difficult than getting a version number:

The response is again 4 bytes, the temperature being on 2 bytes.

The code update is trivial:

And voila !

0.000091 [INFO ] Starting get_temp (get_temp src/bin/get_temp.rs:29)
0.020690 [INFO ] Reset done : busy = false (get_temp src/bin/get_temp.rs:51)
15.022094 [INFO ] Temp = 23.53 (get_temp src/bin/get_temp.rs:90)
30.023590 [INFO ] Temp = 23.53 (get_temp src/bin/get_temp.rs:90)
45.025115 [INFO ] Temp = 23.43 (get_temp src/bin/get_temp.rs:90)
60.026641 [INFO ] Temp = 23.43 (get_temp src/bin/get_temp.rs:90)

Next step: starting to package those access in a struct providing high level methods to communicate with the chip.