LoRaWAN Applications in Rust

Rick
Software engineer
LoRaWAN Applications in Rust

Last September, at the start of my internship at Tweede Golf, my tutors gave me a LoRa-E5 Dev Board. My task was to do something that would make it easier to write applications for this device in Rust. Here's what I did.

The LoRa-E5 Dev Board is a prototyping board for LoRa applications. It features the STM32WLE5JC LoRaWAN transceiver. Check out this previous blog post for more info on LoRa!

Problem

Rust doesn't have stable libraries that make it easy to develop LoRa applications yet. The Hardware Abstraction Layer (HAL) of the STM32WLE5JC allows full control over the LoRa transceiver, so you could send your messages with it directly. But that's not exactly easy to do, and it becomes a real pain if you want to follow a specific protocol, such as LoRaWAN. Writing a communication stack is a lot of work, and using the HAL directly also means you have to rewrite your code if you decide to switch to another board. All in all, this is not a great fit.

I wanted to make it easier to write LoRaWAN applications for the LoRa-E5 Dev Board, but also for any other LoRa boards. The rust-lorawan repository contains two crates that aim to do just that:

  • lorawan-encoding, which allows you to easily generate and parse LoRaWAN packets
  • lorawan-device, which abstracts the details of the LoRaWAN protocol and the hardware you are using, and provides a high-level API to send and receive messages

Internally, lorawan-device uses traits that describe transceiver behavior, and expresses its own functionality in terms of those traits. As a result, you can use this library with any device of which the HAL implements these traits.

This is great in theory, but there are two problems with lorawan-device:

  1. The traits it uses are internal. Most embedded software in Rust follows the example of embedded-hal: there is one crate that mostly contains traits, and other crates either provide implementations of those traits, or describe the functionality they need in terms of those traits. That way, all those crates are compatible with each other. It would be easier to convince HAL maintainers to implement traits from a specialized radio trait crate, which can be used by any library that makes use of radios, rather than ask them to implement the traits of lorawan-device specifically.
  2. It's experimental. There is no documentation, it's difficult to get it working, and the exposed API can be quite confusing. I spent a lot of time trying to set this up, but I didn't manage to get it working at all. I had to conclude that this crate is not yet suitable for real-world usage.

Other than that, there are no promising open-source Rust initiatives related to the LoRaWAN protocol. The ecosystem needs a few changes to make it easier to develop LoRaWAN projects.

Planned Solution

While browsing for relevant libraries, I found that Rust's initial LoRaWAN ecosystem looks like this:

Initial LoRaWAN Rust ecosystem

On one end, we have the aforementioned lorawan-encoding and lorawan-device crates. They form an abstraction layer over the LoRaWAN protocol, but are difficult to use and likely require the user to implement their traits before they can use it. On the other end, we have the HAL crate of the LoRa board (not necessarily the stm32wlxx-hal), which forms an abstraction layer over the hardware. There is also radio, an embedded-hal-like crate filled with traits related to radio behavior. It is currently only used in a few of its maintainer's own projects, but it's under active development and already showing great potential.

To make LoRaWAN applications more approachable in Rust, I wanted to adapt this picture to look more like this:

Adapted LoRaWAN Rust ecosystem

This requires a few adaptations, namely:

  • Creating an alternative crate for lorawan-device: lorawan. It will express the required transceiver functionality in terms of radio traits. We can reuse the lorawan-encoding library, that saves a lot of work.
  • Implementing the radio traits for the HAL crate.
  • Making a demo project tglora to demonstrate how simple it is to create LoRaWAN applications with these adaptations

Implementation

lorawan

A LoRaWAN abstraction layer requires three things: a precise timer, a (pseudo-)random number generator, and a transceiver to actually send and receive messages. Internally, the LoRaRadio struct combines each of these:

pub struct LoRaRadio<RXTX, TIM, RNG> {
    radio: RXTX,
    tim: TIM,
    rng: RNG,
}

It features the lorawan_transmit method to transmit a message following the LoRaWAN protocol:

  1. Switch to the right transmission channel and data rate
  2. Transmit the message
  3. Switch to a random RX1 frequency with the right data rate
  4. Wait for one second
  5. Listen for a message
  6. If there is no response:
    a) Switch to the RX2 frequency with the right data rate
    b) Wait until two seconds after transmission
    c) Listen for a message again
  7. Optionally return the message

This method uses the trait bounds on the type parameters of the LoRaRadio struct for the hardware functionality. RXTX has to implement the radio traits, TIM the DelayUs trait from embedded-hal, and RNG the RngCore trait from rand_core. Ideally, whichever HAL you are using will already have peripherals for each of these. This is not currently the case for radio traits, but hopefully it will be in the future.

This crate's public API features the Device struct to wrap LoRaRadio:

pub struct Device<RXTX, TIM, RNG, STATE> {
    radio: LoRaRadio<RXTX, TIM, RNG>,
    state: STATE,
}

A Device can be used to transmit messages over LoRaWAN, but only if its STATE is Session, which indicates that the device has properly joined a network. Attempting to transmit a message from a device that hasn't joined a network will result in a compile-time error.

The GitHub repository for this library can be found here. Any feedback is much appreciated!

stm32wlxx-hal

Implementing the radio traits for the peripherals in the HAL is quite straightforward if you know what you're doing. I didn't know what I was doing. Thankfully, Alex — the maintainer of stm32wlxx-hal — was kind enough to help me out. The actual trait implementations will be wildly different for other HALs, but we'll go over what each trait does in general. We have to implement four traits in total: Transmit, Receive, Busy, and Channel .

Transmit and Receive are quite simple, they only need the right packet parameters to work. For an example take a look at the pull request here.

The Busy trait indicates whether the transceiver is currently busy receiving a valid packet. The LoRaWAN protocol involves listening on two different channels for a response, with a one-second delay in between. However, receiving LoRaWAN packets can take longer than a second, and we mustn't switch to the second channel if a valid packet is being sent to the first. As long as is_busy yields true, the radio will keep listening on the same channel.

Implementing Channel takes most of the work:

pub trait Channel {
    type Channel: Debug;
    type Error: Debug;

    fn set_channel(&mut self, channel: &Self::Channel) -> Result<(), Self::Error>;
}

The set_channel method should set up the radio to use the specified modulation type, as well as all necessary parameters for that type of modulation. That would include frequency, bandwidth, and spreading factor in the case of LoRa. To implement this trait for our own type, the radio crate expects us to follow the following pattern.


Since radios often support multiple modulation types (LoRa, GFSK...), the Channel associated type should be an enum wrapper around all of them, each with a struct that contains the modulation-specific parameters:

#[non_exhaustive]
pub enum Sx126xChannel {
    /// LoRa modulation.
    LoRa(radio::modulation::lora::LoRaChannel),
}

Sx126xChannel should implement From<LoRaChannel>, as well as From for any other supported modulation types:

impl From<LoRaChannel> for Channel {
    fn from(channel: LoRaChannel) -> Self {
        Channel::LoRa(channel)
    }
}

This allows crates such as lorawan to use LoRaChannel internally, and transform it into the expected Sx126xChannel with into:

impl<RXTX, ERR, CH> LoRaRadio<RXTX, ERR>
    where
        RXTX: Channel<Channel=CH, Error=ERR>,
        CH: From<LoRaChannel>,
{
    pub fn set_channel(&mut self, channel: LoRaChannel) {
        self.radio.set_channel(channel.into())
    }
}

Other than that, the set_channel implementation is basically a match over the supported modulation types. the HAL is used to properly configure the radio for each specific case.


Using an enum for modulation types and converting with the From trait works fine, but it introduces a lot of boilerplate code. Personally, I would prefer a parameterized Channel<M> trait, where M is the modulation type. That way, you can split the match arms into different impl blocks, and an enum wrapper and From implementations wouldn't be necessary. This is still up for discussion, feel free to join in here!

tglora

Now it's time to see the ecosystem adaptations in action! We start by creating the LoRaRadio. This will be a bit different depending on the board you use, but it should look something like this:

let radio = cortex_m::interrupt::free(|cs| {
    let mut dp: pac::Peripherals = defmt::unwrap!(pac::Peripherals::take());

    let gpioa = PortA::split(dp.GPIOA, & mut dp.RCC);
    let dma = AllDma::split(dp.DMAMUX, dp.DMA1, dp.DMA2, &mut dp.RCC);

    let radio = Radio::new(dp.SPI3, dma.d1.c1, dma.d2.c1, &mut dp.RCC);
    let tim2 = Tim2::new(dp.TIM2, & mut dp.RCC);
    let rng = Rng::new(dp.RNG, Clk::MSI, &mut dp.RCC);

    LoRaRadio::new(radio, tim2, rng)
});

We will also need LoRaWAN credentials. You can get these from any LoRaWAN network provider. In this case, I'm using The Things Network, which is free of charge and may already have registered LoRaWAN gateways in your area.

let credentials: Credentials = Credentials::new(APP_EUI, DEV_EUI, APP_KEY);

We can combine these to create a Device:

let device = Device::new_otaa(radio, credentials);

OTAA (Over The Air Activation) is one of the two supported LoRaWAN end node activation modes. It involves exchanging numbers with the network server to derive session keys. Alternatively, you can use ABP (Activation By Personalization) and inject session keys directly into the network and end node. OTAA is the recommended approach, as it allows you to refresh session keys when necessary.

The device we just created through OTAA is not connected to a network yet. As explained earlier, if we try to transmit a message with it, it will give a compile-time error. We first need to join the network with the join method:

let device = device.join::<EU868>()?;

The type in the turbofish indicates which frequency plan it should use. I live in Europe, so I chose the EU868 band, which is currently the only one that's supported. Fortunately, adding support for other frequency plans is trivial. You only have to copy the numbers from this webpage.

Almost there! The next step is to configure the device to exhibit class A behavior. LoRaWAN supports three classes of end node behavior:

  • class A will only listen for received messages right after it transmits one
  • class B will also periodically listen during specified time slots
  • class C listens all the time (unless it is currently transmitting)

lorawan only supports class A for now, but this should change in the future. In the meantime, we explicitly indicate that we want a class A device:

let device = device.into_class_a();

Now, all there is left to do is send a message. A properly configured device features the transmit method, which transmits the message in the first parameter, and stores the optional response in the second:

let mut buf = [0; MAX_PACKET_SIZE];

match device.transmit("Hello, world!".as_bytes(), &mut buf)? {
    Some((size, _)) => defmt::println!("response: {:?}", buf[0..size]),
    None => defmt::println!("no response"),
}

And there you have it, a LoRaWAN application with just a few lines of code. I have to admit that this looks a bit better than it currently is because there are still a few timing issues that I have yet to iron out. But, it's a proof of concept, and I'm happy with how it turned out.

The demo project can be found in this repository. It also contains a simple program that retrieves the messages sent by the end node from The Things Network, feel free to use it for any purpose. If you have any feedback or suggestions, I'd love to hear it!

Stay up-to-date

Stay up-to-date with our work and blog posts?

Related articles

It's time for another technical blog post about async Rust on embedded. This time we're going to pitch Embassy/Rust against FreeRTOS/C on an STM32F446 microcontroller.

In our last post, we've seen that async can help reduce power consumption in embedded programs. The async machinery is much more fine-grained at switching to a different task than we reasonably could be. Embassy schedules the work intelligently, which means the work is completed faster and we race to sleep. Our application actually gets more readable because we programmers mostly don't need to worry about breaking up our functions into tasks and switching between them. Any await is a possible switching point.

Now, we want to actually start using async in our programs. Sadly there are currently some limitations. In this post, we'll look at the current workarounds, the tradeoffs, and how the limitations might be partially resolved in the near future.

Previously we talked about conserving energy using async. This time we'll take a look at performing power consumption measurements. Our goal is first to get a feel for how much power is consumed, and then to measure the difference between a standard synchronous and an async implementation of the same application.