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:
- basic input/output pin should implement traits
InputPin
/OutputPin
, providing methods such asset_low()
oris_high()
- for SPI there is either
SpiBus
for exclusive access orSpiDevice
when shared access is needed - for interrupt pin like the stm32
ExtiInput
concrete type, the traitWait
add support for methods likewait_for_rising_edge()
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.