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:
- DF: the downlink format on 5 bits
- CA: transponder capability on 3 bits
- ICAO: the aircraft identifier on 24 bits
- ME: the message on 56 bits
- PI: Parity/interrogator ID, which is a check sum on 24 bits.
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:
- Set the RF frequency
- Since this is a dual band chip, we need to select a RX path
- Run calibration to ensure maximum performances
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:
- Set the packet type (amongst LoRa, FSK, FLRC, …): in this case Ook.
- Configure the main modulation parameters: bit-rate, the bandwidth of the receiver, pulse shaping (when TX is used)
- Configure the packet structure: length of preamble, sync word value if any, the header format, the CRC, …
- Optionnaly configure some extra parameters in the receivers to optimize performances: this can be threshold settings, enabling some tracking algorithm , …
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(())
}
set_ook_modulation
: modulation is set at 2Mb/s with a bandwidth of around 3MHz. This bandwidth is anyway the maximum bandwidth supported by the chip, but in genral for OOK a good rule of thumb wold be to set it to something between 1.5 and 3 times the data-rate. Of course the maximum supported frequency offset needs to be taken into account.set_ook_packet
: this is the packet format shared between the TX and RX part of the modem. So here we basically follows the acket description from the first paragraph: a 16b preamble, no address filtering, a fixed length packet of 11 bytes followed by 3 bytes of CRC and an encoding of inverted Manchesterset_ook_syncword
: since there is no syncword, the length are set to 0.set_ook_detector
: this configure how the OOK modem will detect packet. For ADS-B it is quite straight-forward: we search a pattern 0x0285 (provided LSB first) on 16 bits (value given n-1) with no repetition and no Start of Frame Delimiter (SFD)set_ook_crc
: finally we configure the CRC polynom and the init value
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:
- A short press will display some RX statistics (number of detection CRC error, …) on the debug link
- A long press will re-run the threshold calibration
- A double press will alternate between the two possible RF channel: in practice I have not seen anyone on the 978MHz channel, and in my case the channel was a lot more noisy.
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 :)