Part 2 which was more interesting than expected!!!

warn

This is experimental and shouldn't be used in production. Don't transmit random stuff on ISM bands without understanding local duty cycle limits, ERP restrictions and other regulations!

In this cute post :3
  • Understanding the sub-GHz radio's SPI interface
  • Implementing low-level SPI communication with the radio
  • Sending a LoRa packet from scratch!

Introduction

Yesterday I struggled with a simple blinky example, so I was sure the next part, which is getting the subGHz radio to work properly, would be even harder. But nope! It got all easy and comfy :>

The first thing I did was to compile and flash lora-rs example that sends LoRa packets. It was straightforward, worked well, and I was honestly kinda sad that it did. It's what I'm trying to achieve, I looked at commit amount in the repo, it was over 800! But such a small, insignificant trifle had NO POWER to bother me, so I started working!

How to talk to radio FREE tutorial OwO

First of all, I looked at the official STM32WLE5JC datasheet. I already knew that the radio is embedded in the MCU itself and it uses internal SPI lines to talk with the main Cortex-M4 core. The thing I missed was how to actually talk to it in code - I couldn't use a normal SpiDevice trait, since it was not connected via normal SPI. I decided to look at the RM0461 reference manual and found a chapter describing how the sub-GHz radio should be used. I also looked at the lora-rs repo to seek inspiration - seems like they're using a wrapper for SPI comms, so that it's treated like a regular SPI device.

The key was to create a struct for the sub-GHz device and provide an impl of the SpiDevice trait, so that the custom, internal SPI bus was exposed as a normal SPI in other places of the program. That was an important first step!

/// Wrapper for the sub-GHz SPI device
struct SubGhzSpiDevice<T>(T);

impl<T: SpiBus> ErrorType for SubGhzSpiDevice<T> {
    type Error = T::Error;
}

/// This works as a translation layer between normal SPI transactions and sub-GHz device SPI
/// transactions. Everything above this layer sees it like a normal SPI device!
impl<T: SpiBus> SpiDevice for SubGhzSpiDevice<T> {
    /// Perform a transaction on the sub-GHz device
    async fn transaction(&mut self, operations: &mut [Operation<'_, u8>]) -> Result<(), Self::Error> {
        // Pull NSS low to allow SPI comms
        pac::PWR.subghzspicr().modify(|w| w.set_nss(false));
        trace!("NSS low");
        for operation in operations {
            match operation {
                Operation::Read(buf) => {
                    self.0.read(buf).await?;
                    trace!("Read {:x}", buf);
                },
                Operation::Write(buf) => {
                    self.0.write(buf).await?;
                    trace!("Wrote {:x}", buf);
                },
                Operation::Transfer(read, write) => {
                    self.0.transfer(read, write).await?;
                    trace!("Read {:x} wrote {:x}", read, write);
                },
                Operation::TransferInPlace(buf) => {
                    self.0.transfer_in_place(buf).await?;
                    trace!("Read+wrote {:x}", buf);
                },
                Operation::DelayNs(_) => {}
            }
        }
        // Pull NSS high
        pac::PWR.subghzspicr().modify(|w| w.set_nss(true));
        trace!("NSS high");
        // Poll BUSY flag until it's done
        while pac::PWR.sr2().read().rfbusys() {}
        trace!("BUSY flag clear");
        Ok(())
    }
}

After that was done, I consulted the RM0461 reference manual again, to see how to prepare the radio after the MCU boots. In section 4.7 it was explained in detail how should I wake up this lazy ass! The key was to use a Set_Standby() command over SPI bus, which I did simply by writing [0x80, 0x00] over the bus. Before that happens though, it was necessary to also restart the radio, so that it was in a known state before trying to communicate with it. I found the necessary information in section 6.1.4 - the way to do it was to simply pull the RFRST to high for a short while and then poll the RFBUSY signal until it was clear. The minimal code worked well (didn't hang on any operation lol) and I felt I was ready to go to the next phase.

async fn reset_radio() {
    debug!("Resetting the radio");
    pac::RCC.csr().modify(|w| w.set_rfrst(true));
    trace!("RFRST high");
    Timer::after_millis(1).await;
    pac::RCC.csr().modify(|w| w.set_rfrst(false));
    trace!("RFRST low");
    Timer::after_millis(1).await;
    while pac::PWR.sr2().read().rfbusys() {}
    debug!("Radio reset finished");
}

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let mut config = Config::default();
    {
        config.rcc.msi = Some(MSIRange::RANGE48M);
        config.rcc.sys = Sysclk::MSI;
        config.rcc.mux.rngsel = mux::Rngsel::MSI;
        config.enable_debug_during_sleep = true;
    }
    let p = embassy_stm32::init(config);

    reset_radio().await;

    let mut spi = SubGhzSpiDevice(Spi::new_subghz(p.SUBGHZSPI, p.DMA1_CH1, p.DMA1_CH2));
    debug!("Writing SetStandby");
    let _ = spi.write(&[0x80, 0x00]).await;
    debug!("Radio in standby!");

    loop {
        Timer::after(Duration::from_secs(1)).await;
    }
}

Lo(l)Ra(wr) :3

The next part was TEDIOUS and KINDA BORING, but SO NICE IN THE END!

In the beloved RM0461 reference manual, in section 4.9.1, there's almost everything you need to know how to get started and send your first packet! The sequence looks like this:

The sub-GHz radio can be set in LoRa, (G)MSK or (G)FSK transmit operation mode with the following steps:

  1. Define the location of the transmit payload data in the data buffer, with Set_BufferBaseAddress().
  2. Write the payload data to the transmit data buffer with Write_Buffer().
  3. Select the packet type (generic or LoRa) with Set_PacketType().
  4. Define the frame format with Set_PacketParams().
  5. Define synchronization word in the associated packet type SUBGHZ_xSYNCR(n) with Write_Register().
  6. Define the RF frequency with Set_RfFrequency().
  7. Define the PA configuration with Set_PaConfig().
  8. Define the PA output power and ramping with Set_TxParams().
  9. Define the modulation parameters with Set_ModulationParams().
  10. Enable TxDone and timeout interrupts by configuring IRQ with Cfg_DioIrq().
  11. Start the transmission by setting the sub-GHz radio in TX mode with Set_Tx(). After the transmission is finished, the sub-GHz radio enters automatically the Standby mode.
  12. Wait for sub-GHz radio IRQ interrupt and read interrupt status with Get_IrqStatus():
  • On a TxDone interrupt, the packet is successfully sent
  • On a timeout interrupt, the transmission is timeout.
  1. Clear interrupt with Clr_IrqStatus().
  2. Optionally, send a Set_Sleep() command to force the sub-GHz radio in Sleep mode.

It seemed like a big scary list at first, but I thought I might just as well start and stop worrying about it. I went instruction by instruction, typing it manually like so:

    // SetBufferBaseAddress
    let _ = spi.write(&[0x8f, 0x00, 0x00]).await;
    // WriteBuffer (max 255 bytes, wraps around after that)
    let _ = spi.write(&[0x0e, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05]).await;
    // SetPacketType to LoRa
    let _ = spi.write(&[0x8a, 0x01]).await;

And so on, until I was finished. Some instructions were really straightforward to understand, others were a bit more confusing (like the Set_PaConfig() one), but in the end I spent like an hour going from the list, to function descriptions (though for some reason I used the SX1262 datasheet, even though the RM0461 actually HAS all the functions explained well, and it does NOT lack the additional stuff, like BPSK!). I fired up the finished program with all of the functions set up, and... nothing! I double-checked everything, but everything seemed correct, including the frequency calculation (which looks stupid wtffff).

(868_100_000 * 33_554_432) / 32_000_000 = 910_268_825
hex(910_268_825) = 0x36419999

It turns out, everything was MOSTLY correct, except for two things. First of all, I missed additional necessary instructions that should happen right after the radio is in Standby mode! The functions in question were SetDIO3AsTCXOCtrl, Calibrate and CalibrateImage. They're used (in order) for the following things:

  1. Send power to the temperature-compensated crystal oscillator - without it the radio has no clock reference
  2. Calibrate all the radio's internal analog blocks, including the oscillators or PLL
  3. Calibrate image rejection for a specific frequency band the radio is supposed to be working with

This though was not enough! The last thing I was missing was something to actually let the signals go outside of the radio. To do that, I just had to set the RF switch to a proper TX mode (I found the necessary code in lora-rs repo, it seems to match my LoRa E5-mini board so that's nice):

    // Enable RF switch before tx
    let _ctrl1 = Output::new(p.PC4, Level::High, Speed::High); // TX
    let _ctrl2 = Output::new(p.PC5, Level::Low, Speed::High);  // RX
    let _ctrl3 = Output::new(p.PC3, Level::High, Speed::High); // EN

Wormks UwU

This was the last change I needed for the program to work! I saw a pretty LoRa signal on the SDR waterfall, beautifully centered on the exact frequency I wanted :3

SDR output
lsjfdjdfgklsvlkcslklsl yaaaaaay :3 :3 :3

I was SOOOO happy and really surprised it was this easy. I gained a lot of knowledge today and made a nice foundation which I'll refractor soon, to make base for all the other cool modulation types, all the possible configs, everything!

The working code is available here.

Byeeee :>

References