Rust Embassy (P4) - LoRa communication with LR2021
10 Aug 2025
Adding commands to the driver
The current driver is very low level and requires manually building the commands and parsing the response: it works but this is not very user friendly.
Looking at the way I use this low level driver, I’m thinking to write function to build request command, and structure around byte array to parse the response.
For the temperature for example, I create some enum to represent the parameters:
/// ADC resolution for measurement
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdcRes {
Res8bit = 0,
Res9bit = 1,
Res10bit = 2,
Res11bit = 3,
Res12bit = 4,
Res13bit = 5,
}
/// Temperature sensor source
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TempSrc {
Vbe = 0,
Xosc = 1,
Ntc = 2,
}
Then the function to build the request can look like:
pub fn get_temp_req(temp_src: TempSrc, adc_res: AdcRes) -> [u8; 5] {
let mut cmd = [0u8; 5];
cmd[0] = 0x01;
cmd[1] = 0x25;
cmd[2] |= ((temp_src as u8) & 0x3) << 4;
cmd[2] |= 8; // Force format to Celsius
cmd[2] |= (adc_res as u8) & 0x7;
cmd
}
For the response, I can create a new type around an array of 4 bytes and add a function to return the temperature as a signed value for ease of use:
/// Response for GetTemp command
#[derive(Default)]
pub struct GetTempRsp([u8; 4]);
impl GetTempRsp {
/// Create a new response buffer (initialized to 0)
pub fn new() -> Self {
Self::default()
}
/// Return Status
pub fn status(&mut self) -> Status {
Status::from_slice(&self.0[..2])
}
/// Temperature in degrees Celsius (format=1)
pub fn value(&self) -> i16 {
let raw = (self.0[2] as u16) << 5 | ((self.0[3] as u16) >> 3);
raw as i16 - if (self.0[2] & 0x80) != 0 {1<<13} else {0}
}
}
Since the SPI command need access to a mutable array, we can simply implement the AsMut
trait:
impl AsMut<[u8]> for GetTempRsp {
fn as_mut(&mut self) -> &mut [u8] {
&mut self.0
}
}
And finally we can even implement the trait Format
from the defmt
crate for nice debug messages:
impl defmt::Format for GetTempRsp {
fn format(&self, fmt: defmt::Formatter) {
defmt::write!(fmt, "{}.{:02}", self.0[2] as i8, (self.0[3] as u16 * 100) >> 8);
}
}
I can now update my application measuring temperature like this:
// Create the GetTemp command once
let cmd = cmd_system::get_temp_req(TempSrc::Vbe, AdcRes::Res13bit);
// Get a temperature measurement every 15 seconds
loop {
Timer::after_secs(15).await;
let mut temp = cmd_system::GetTempRsp::new();
match lr2021.cmd_rd(&cmd, temp.as_mut()).await {
Ok(_) => info!("Temp = {}", temp),
Err(e) => error!("{}", e),
}
}
Later I can even add direct function to the LR2021 driver which take care of creating both request and response, but it looks like a good foundation for giving access to all the chip commands.
Implementing all commands
The chip has more than 100 possible commands, sometimes with optional parameters, often with enum definition. Implementing manually everything would take a long time and is not particularly interesting: so let’s try to automate the code generation.
My idea is to have a yaml description of all commands, providing the last of parameters, how they are mapped inside the byte stream and some enum definition when it make sense. To go from the PDF specification to this yaml, I’ll turn to an LLM (Claude in this instance). Anyone who used an LLM more than superficially knows how unreliable it can be, so to minimize as much as possible the errors I did the following:
- Extract commands chapter by chapter (one for LoRa, another for BLE, …): I clearly notice that output quality decreases as the chapter length increases …
- Second step is to ask Claude to review his own work, once again chapter by chapter: this cleaned-up a significant number of errors (invented parameters, missing commands, …).
- Final step: the manual check. There was still a few mistakes here and there (especially for the odd case where you have multiple variable on 9 bits with the LSB of all variable being grouped inside a byte), but overall a massive work was achieved while I could watch some series.
The resulting yaml file is available in the GitHub repository in case anyone wants to develop driver in another language.
Then I wrote a python script which create one file per category, with commands and response structure. And when I need to tweak the automatic implementation (like transforming the temperature as a signed integer, or providing Format implementation), I add some special case in the python script so that I can maintain a single source of truth for the commands.
Preparing the new application
Before looking at how to configure the chip for LoRa communication, I want to prepare the application: I would like a single binary that allows me to switch between TX and RX and send packet when I press the button.
The idea is simple:
- a long button press switch mode (TX/RX)
- a short button press either send a packet or clear the RX statistics, depending on the mode.
- Also i want to use the led to get some feedback: the led blinking should depends on the mode. And maybe later I could even do some faster blinking for a short while when I receive or transmit a packet
This will allow me to learn a bit more about using embassy.
Short or long button press
To find out if the button is press for a short or long time, I could measure the time between falling edge and rising edge. But a better approach would be to use a kind of timeout on the button going high: this way the long press is detected as soon as the duration is expired, giving immediate feedback to the user that a long press has been detected.
Embassy provides an async function with_timeout(duration, future)
, which return a result (Err for timeout, Ok for the future).
Embassy also provides a more generic version called select(f1,f2)
, when you want to wait on multiple futures and see which one finished first (there is also select3..6()
to wait on more than 2 futures).
This kind of stuff would be a pain to implement manually, and here we have a very clean code thanks to embassy:
#[embassy_executor::task]
async fn user_intf(mut button: ExtiInput<'static>) {
let mut role = BoardRole::Rx;
loop {
button.wait_for_falling_edge().await;
// Small wait to debounce button press
Timer::after_millis(5).await;
// Determine if this is a short or long press
match select(button.wait_for_high(), Timer::after_millis(500)).await {
// Short press
Either::First(_) => todo!(),
// Long press
Either::Second(_) => {
role.toggle();
BOARD_ROLE.store(role as u8, Ordering::Relaxed);
}
}
}
}
Just like in a previous app, BOARD_ROLE
is a simple AtomicU8
to be able to share a state amongst multiple tasks.
Sending event on multiple tasks
Now I also want the short press to trigger some action on the LR2021 and the led as well (when a led is not blinking it has to wait for the role to change for example).
For this kind of data sharing with one sender and multiple receiver, embassy provides the type Watch
.
A const generic parameter allows to define how many receivers can be created: in my case 3 (one per led and another for the main loop).
/// Generate event when the button is press with short (0) or long (1) duration
static BUTTON_PRESS: Watch<CriticalSectionRawMutex, u8, 3> = Watch::new();
In the user_intf
task, I get Sender with the sender()
method:
let button_press = BUTTON_PRESS.sender();
And I can send data with the send()
method:
Either::First(_) => button_press.send(0),
Either::Second(_) => {/*...*/ button_press.send(1)},
A receiver is created with receiver()
and to wait for an event just used the changed()
method.
My main loop looks like this:
let mut button_press = BUTTON_PRESS.receiver().unwrap();
loop {
let press = button_press.changed().await;
let role : BoardRole = BOARD_ROLE.load(Ordering::Relaxed).into();
match (press, role) {
// Short press in RX => clear stats
(0, BoardRole::Rx) => info!("[RX] Clearing stats"),
// Short press in TX => send a packet
(0, BoardRole::Tx) => {
info!("[TX] Sending packet {}", pkt_id);
pkt_id += 1;
}
(1, BoardRole::Rx) => info!(" -> Switching to RX"),
(1, BoardRole::Tx) => info!(" -> Switching to TX"),
(n,_) => warn!("Button press with value {} not implemented !", n),
}
}
Note: the receiver()
method return an option to handle the case where you try to create more receiver than the Watch
can handle.
LoRa communication
Finally we can start using the chip for wireless communication.
Configuring the chip for LoRa requires the following steps:
- Choosing an RF channel
- Running calibration
- Setting packet type to LoRa
- Configuring the LoRa modulation parameters: Spreading Factor, bandwidth, coding-rate
- Configuring the LoRa packet parameters: preamble length, header type, chirp direction, …
For transmission we also need to configure the PA (power-amplifier): output power, ramp-time, …
And then it is just a matter of calling the set_rx
or writing data inside the TX FIFO and calling set_tx
.
Adding high-level methods to the driver
This part is really basic Rust with nothing special.
Some command can be a direct call to cmd_wr
with the proper request:
/// Set LoRa Modulation parameters
pub async fn set_lora_modulation(&mut self, sf: Sf, bw: LoraBw, cr: LoraCr, ldro: Ldro) -> Result<(), Lr2021Error> {
let req = cmd_lora::set_lora_modulation_params_cmd(sf, bw, cr, ldro);
self.cmd_wr(&req).await
}
For other, like setting the chip mode, I feel the API would be better if there is only one function, instead of the 4 commands available in the SPI:
/// Set Tx power and ramp time
pub async fn set_chip_mode(&mut self, chip_mode: ChipMode) -> Result<(), Lr2021Error> {
match chip_mode {
ChipMode::DeepSleep => self.cmd_wr(&set_sleep_cmd(false, 0)).await,
ChipMode::Sleep(t) => self.cmd_wr(&set_sleep_adv_cmd(true, 0, t)).await,
ChipMode::Retention(r,t) => self.cmd_wr(&set_sleep_adv_cmd(true, r, t)).await,
ChipMode::StandbyRc => self.cmd_wr(&set_standby_cmd(StandbyMode::Rc)).await,
ChipMode::StandbyXosc => self.cmd_wr(&set_standby_cmd(StandbyMode::Xosc)).await,
ChipMode::Fs => self.cmd_wr(&set_fs_cmd()).await,
ChipMode::Tx => self.cmd_wr(&set_tx_cmd()).await,
ChipMode::Rx => self.cmd_wr(&set_rx_cmd()).await,
}
}
Or when setting a DIO as an IRQ pin, it might be practical to chain two commands: setting the pin function to IRQ followed by the interrupt mask:
/// Configure a pin as IRQ and enable interrupts for this pin
pub async fn set_dio_irq(&mut self, dio: u8, intr_en: Intr) -> Result<(), Lr2021Error> {
let sleep_pull = if dio > 6 {PullDrive::PullAuto} else {PullDrive::PullUp};
let req = cmd_system::set_dio_function_cmd(dio, DioFunc::Irq, sleep_pull);
self.cmd_wr(&req).await?;
let req = cmd_system::set_dio_irq_config_cmd(dio, intr_en.value());
self.cmd_wr(&req).await
}
Final Application
Once all relevant API methods are implemented, the initialization of the LR2021 for LoRa communication looks like this:
lr2021.set_rf(901_000_000).await.expect("Setting RF to 901MHz");
lr2021.set_rx_path(RxPath::LfPath, 0).await.expect("Setting RX path to LF");
lr2021.calib_fe(&[]).await.expect("Front-End calibration");
lr2021.set_packet_type(PacketType::Lora).await.expect("Setting packet type");
lr2021.set_lora_modulation(Sf::Sf5, LoraBw::Bw1000, LoraCr::Cr1Ham45Si, Ldro::Off).await.expect("Setting packet type");
// Packet Preamble 8 Symbols, 10 Byte payload, Explicit header with CRC and up-chirp
lr2021.set_lora_packet(8, 10, HeaderType::Explicit, true, false).await.expect("Setting packet parameters");
lr2021.set_tx_params(0, RampTime::Ramp8u).await.expect("Setting TX parameters");
// Start RX continuous
match lr2021.set_rx(0xFFFFFF, true).await {
Ok(_) => info!("[RX] Searching Preamble"),
Err(e) => error!("Fail while set_rx() : {}", e),
}
// Set DIO9 as IRQ for RX Done
lr2021.set_dio_irq(7, Intr::new(IRQ_MASK_RX_DONE)).await.expect("Setting DIO7 as IRQ");
This looks high-level enough for me with enough fine step to easily allow more complex application.
The application now wait on two events: a button press or an interrupt when a packet is received:
loop {
match select(button_press.changed(), irq.wait_for_rising_edge()).await {
Either::First(press) => {
let role: BoardRole = BOARD_ROLE.load(Ordering::Relaxed).into();
match (press, role) {
// Short press in RX => clear stats
(0, BoardRole::Rx) => show_and_clear_rx_stats(&mut lr2021).await,
// Short press in TX => send a packet
(0, BoardRole::Tx) => send_pkt(&mut lr2021, &mut pkt_id, &mut data).await,
// Long press: switch role TX/RX
(1, BoardRole::Rx) => switch_mode(&mut lr2021, true).await,
(1, BoardRole::Tx) => switch_mode(&mut lr2021, false).await,
(n, _) => warn!("Button press with value {} not implemented !", n),
}
}
// RX Interrupt
Either::Second(_) => {
let pkt_len = lr2021.get_rx_pkt_len().await.expect("RX Fifo level");
let nb_byte = pkt_len.min(16) as usize; // Make sure to not read more than the local buffer size
lr2021.rd_rx_fifo(&mut data[..nb_byte]).await.expect("RX FIFO Read");
let intr = lr2021.get_and_clear_irq().await.expect("Getting intr");
let status = lr2021.get_lora_packet_status().await.expect("RX status");
let snr = status.snr_pkt();
let snr_frac = (snr&3) * 25;
info!("[RX] Payload = {:02x} | intr={:08x} | RSSI=-{}dBm, SNR={}.{:02}, FEI={}",
data[..nb_byte],
intr.value(),
status.rssi_pkt()>>1,
snr>>2, snr_frac,
status.freq_offset()
);
}
}
}
And the three functions called on button press are:
type Lr2021Stm32 = Lr2021<Input<'static>,Output<'static>,Spi<'static, Async>>;
async fn show_and_clear_rx_stats(lr2021: &mut Lr2021Stm32) {
let stats = lr2021.get_lora_rx_stats().await.expect("RX stats");
info!("[RX] Clearing stats | RX={}, CRC Err={}, HdrErr={}, FalseSync={}",
stats.pkt_rx(),
stats.crc_errors(),
stats.header_errors(),
stats.false_synch(),
);
}
async fn send_pkt(lr2021: &mut Lr2021Stm32, pkt_id: &mut u8, data: &mut [u8]) {
info!("[TX] Sending packet {}", *pkt_id);
// Create payload and send it to the TX FIFO
for (i,d) in data.iter_mut().take(10).enumerate() {
*d = pkt_id.wrapping_add(i as u8);
}
lr2021.wr_tx_fifo(&mut data[..10]).await.expect("FIFO write");
lr2021.set_tx(0).await.expect("SetTx");
*pkt_id += 1;
}
async fn switch_mode(lr2021: &mut Lr2021Stm32, is_rx: bool) {
lr2021.set_chip_mode(ChipMode::Fs).await.expect("SetFs");
if is_rx {
lr2021.set_rx(0xFFFFFF, true).await.expect("SetRx");
info!(" -> Switched to RX");
} else {
info!(" -> Switching to FS: ready for TX");
}
}
Side-Note on probe-rs
Before setting up the two radio platforms, I was searching how to handle multiple probe connected to a same PC: and the answer is that there is nothing special to do ! If there is more than probe compatible with your target, when you try to flash the platform probe-rs simply ask you to select it in the list of available probes. Even in the embedded world, with Rust we get great tools, fully integrated in the standard Rust ecosystem: fantastic !
Conclusion
While there is still works to do on the driver (at the very least creating a crate to separate the driver from the STM32 applications), I think this concludes this small post series, because it now gets quite mechanical: add more methods and follow the right sequence to configure the radio.
I will likely continue implementing a few similar applications using the different protocols (BLE, FLRC, Zigbee and ZWave) and you’ll be able to look at them on last commit on Github.
I have been continuously impressed by how easy and clean the combination Rust Embedded HAL with Embassy is. Everything worked as advertised, having multiple task running in “parallel” was very easy to setup and the number of platform supported is quite surprising for such a new environment. A lot of things would have been way more complex in bare metal C and with a lot more occasion to shoot myself in the foot.