The Rusty Clams

Rust adventures !

Using LR2021 as an ADS-B receiver

20 Aug 2025

Introduction to ADS-B

ADS-B stands for Automatic Dependent Surveillance-Broadcast: it is a signal sends by most aircraft around the world to broadcast their position and other related data.

It uses On-Off Keying (OOK) modulation at 1090 MHz (and more recently at 978MHz as-well): the modulation runs at 2Mb/s.

It starts with a 16b preamble (8us) with the pattern “1010_0001_0100_0000” (given in the order of transmission) and is followed by a fixed message of 112 bits encoded using an inverted Manchester scheme (i.e 0 is transmitted as 01 and 1 as 10).

The payload is divided in 5 fields:

Due to its simplicity and its world-wide use, this protocol is a classic for people wanting to play with wireless communication: so let’s just do that using the LR2021 and our brand new Rust driver.

Configuring LR2021 for ADS-B

The way to initialize and configure the LR2021 is quite consistent across all protocols.

The driver needs to be provided with a SPI peripheral, a reset and nss output pins, and a busy input pin:

    let mut lr2021 = Lr2021::new(nreset, busy, spi, nss);
    lr2021.reset().await.expect("Resetting chip !");

First, we need to configure the radio part:

With the rust drivers it looks like this:

    lr2021.set_rf(1090_000_000).await.expect("SetRF");
    lr2021.set_rx_path(RxPath::LfPath, 7).await.expect("SetRxPath");
    lr2021.calib_fe(&[]).await.expect("Front-End calibration");

The RX path selected is LF, i.e. Low-Frequency: this is the best path for signal below ~1.2GHz. The second argument allows to set a boost level to improve performances at the cost of power consumption: let’s set it to the max.

The calibration function accept up to 3 frequencies when you need to regularly switch between channels and do not wat to lose time running calibration. Leaving the list empty, means the calibration runs on the current RF channel.

Once this is done, we need to configure the modulation and packet format to properly demodulate and decode packets:

Conveniently the Rust driver implements the whole configuration with the function set_ook_adsb(), but let’s have a look at its content, in case we want to implement other OOK protocols.

    pub async fn set_ook_adsb(&mut self) -> Result<(), Lr2021Error>  {
        self.set_packet_type(PacketType::Ook).await.expect("Setting packet type to BLE");
        self.set_ook_modulation(2_000_000, RxBw::Bw3076, PulseShape::None).await?;
        self.set_ook_packet(16, AddrComp::Off, PktFormat::FixedLength, 11, Crc::Crc3Byte, Encoding::ManchesterInv).await?;
        self.set_ook_syncword(0, BitOrder::LsbFirst, 0).await?;
        self.set_ook_detector(0x285, 15, 0, false, SfdKind::FallingEdge, 0).await?;
        self.set_ook_crc(0x1FFF409, 0).await?;
        Ok(())
    }

For OOK modulation we also need to set a detection threshold: quite often the ambient noise is much higher that the noise figure of the radio and since OOK is a very basic modulation scheme simply based on the presence or abscence of power it can be quite sensitive to power vcariation when you are at noise level. So before starting the reception we can mesaure the ambient noise and add some margin on top of it to set the detection threshold.

    let rssi = lr2021.get_rssi_avg(Duration::from_micros(10)).await.expect("RssiAvg");
    let thr = 64 + RSSI_MARGIN - ((rssi>>1) as i8);
    lr2021.set_ook_thr(thr).await.expect("SetOokThr");

The RSSI_MARGIN is a value in dB for the detection threshold: a good value for OOK is typically between 10 to 15dB. The 64 simply correspond to the format of the threshold, and the division by 2 of the RSSI is due to the fact the tha RSSI is in 0.5dBm while the threshold is in dBm.

Note that here I am doing only one measurement, but in a more serious application, it could be a good idea to do 2-3 measurement in a row to ensure there was no signal and that the measure is representative of the ambient noise.

Now the LR2021 is fully configured to receive and demodulate ADS-B message, we can just start the receiver in continuous mode (i.e. always stays in RX). Since the ADS-B has a very short preamble and runs at a quite fast data-rate it is best to set manually the gain of the radio. And since we do not risk saturating the radio (aircraft are generally quite far from you :P) we can set the gain to the maximum (13):

    lr2021.set_rx_gain(13).await.expect("SetGain");
    lr2021.set_rx(0xFFFFFFFF, true).await.expect("SetRX");

Application on STM32

The application is done on an STM32 nucleo platform using the embassy-hal.

The idea is to use a single button to interact with our receiver:

Since there is two leds on the my LR2021 module, I’ll flash the red one on CRC error amd the green one of CRC OK.

And to be efficient we will use an interrupt based system to know when to read the RX FIFO containing the ADS-B messages:

    let mut irq = ExtiInput::new(p.PB0, p.EXTI0, Pull::None); // DIO7
    lr2021.set_dio_irq(7, Intr::new(IRQ_MASK_RX_DONE)).await.expect("Setting DIO7 as IRQ");

When a message is received we can read the payload and send it on the UART, and a program running on a PC can actually do the decoding or send it to ADSB Exchange to contribute to this fun project. The procedure to read the fifo is as follows:

    let pkt_status = lr2021.get_ook_packet_status().await.expect("PktStatus");
    let nb_byte = pkt_status.pkt_len().min(128) as usize;
    lr2021.rd_rx_fifo(&mut data[..nb_byte]).await.expect("RX FIFO Read");

The packet length could also be given by get_rx_pkt_len, but the packet status also provides the RSSI which is nice for statistics. In case of CRC error, we can simply clear the FIFO with the aptly name method clear_rx_fifo().

The main loop of the application simply wait for either a button press or an IRQ:

    loop {
        match select(button_press.changed(), irq.wait_for_high()).await {
            Either::First(press) => {
                match press {
                    // Short Press: show stats and clean it
                    ButtonPressKind::Short => {
                        let stats = lr2021.get_ook_rx_stats().await.expect("RxStats");
                        lr2021.clear_rx_stats().await.expect("ClearStats");
                        info!("RX Stats: nb={}, err={}", stats.pkt_rx(), stats.crc_error());
                    }
                    // Long press: measure RSSI and adjust detection threshold
                    ButtonPressKind::Long => auto_thr(&mut lr2021).await,
                    // Double press => change channel
                    ButtonPressKind::Double => {
                        chan.next();
                        info!("Switching to {}", chan);
                        lr2021.set_chip_mode(ChipMode::Fs).await.expect("SetFs");
                        lr2021.set_rf(chan.freq()).await.expect("SetRF");
                        lr2021.set_rx(0xFFFFFFFF, true).await.expect("SetRx");
                        auto_thr(&mut lr2021).await;
                    }
                }
            }
            // Interrupt
            Either::Second(_) => {
                // Clear all IRQs
                let intr = lr2021.get_and_clear_irq().await.expect("GetIrqs");
                // Make sure the FIFO contains data
                let lvl = lr2021.get_rx_fifo_lvl().await.expect("RxFifoLvl");
                if intr.crc_error() {
                    lr2021.clear_rx_fifo().await.unwrap();
                    LED_KO.signal(LedMode::Flash);
                }
                else if lvl > 0 && intr.rx_done() {
                    if let Some(pkt_status) = read_pkt(&mut lr2021, &mut data, intr).await {
                        let nb_byte = pkt_status.pkt_len().min(14) as usize;
                        let pkt = &data[..nb_byte];
                        let rssi_dbm = pkt_status.rssi_high()>>1;
                        LED_OK.signal(LedMode::Flash);
                        let mut s: String<128> = String::new();
                        for b in pkt {
                            core::write!(&mut s, "{b:02x}").ok();
                        }
                        core::write!(&mut s, " | -{}dBm\r\n", rssi_dbm).ok();
                        uart.write(s.as_bytes()).await.ok();
                    }
                }
            }
        }
    }

To read the UART messages and decode the received message, I wrote a small python script available in the github repository: it uses the PyModeS package to provide decoding. I had some fun just looking at the message received and checking on a live traffic map where exactly is the aircraft: I got a few more than 100km away which was quite nice :)