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.
Using the blink task on multiple pins
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:
- every request command starts with a 2 byte OpCode followed by n bytes for the command parameters
- If a response is expected, a second access with m bytes at 0 allows to read the response (m being the number of expected byte of the response). A busy pin driven by the chip will go low when the chip is ready to provide a response
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 OpCode is 0x125
- one byte parameter containing:
- the source of the temperature (bits 5:4) : two internals, one external with an NTC if mounted on board)
- the output format (b3) : either a raw value to convert manually or one in degree Celsius)
- The resolution (bits 2:0) in number of fractional bits, up to 5
The response is again 4 bytes, the temperature being on 2 bytes.
The code update is trivial:
- Replace the wait on button pressed by a timer wait
- change the request to
[0x01,0x25,0x0D]
to get the temperature in degree Celsius - Display the temperature:
info!("Temp = {}.{:02}", buf_rsp[2], (buf_rsp[3] as u16 * 100) >> 8);
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.