Aircraft overhead are constantly sending short digital messages to their airlines — position reports, engine data, gate times, free text from the cockpit. This tutorial receives those messages on a $30 RTL-SDR, decodes them to readable text, and then takes the radio signal apart stage by stage to show exactly how 150 milliseconds of VHF becomes a sentence.
ACARS (Aircraft Communications Addressing and Reporting System) is the aviation industry's text-messaging service. It started in the 1970s on plain AM, and today most of its traffic in a busy terminal area rides on VDL Mode 2 — a digital VHF link running at 31 500 bit/s. Every VDL2-equipped aircraft listens on the Common Signalling Channel at 136.975 MHz, and transmits on it without anyone in the cockpit doing anything.
That last property is what makes this experiment work where voice does not. Airband voice exists only while a human is pressing a key, and the ground stations that transmit continuously — tower, ATIS, approach — are typically tens of kilometres away with an obstructed path. An aircraft passing overhead at a few thousand feet has a clear line of sight and a transmitter a few hundred metres away, and it sends ACARS whether or not anyone is talking. On the COSMOS grid, with nothing more than an indoor whip at a window, that difference is the difference between hearing nothing at all and decoding a flight's position report.
The tutorial has two halves and you can stop after the first:
dumpvdl2, listen across all ten VDL2 channels at once, and read the messages that come out — aircraft registration, flight number, route, and the message body.Nothing is transmitted. One node, one dongle, one antenna, no licence, no spectrum coordination.
After completing this tutorial you will be able to:
| Difficulty | Intermediate |
| Estimated time | 20 min (plus a 10 min listen) |
| Domain / sandbox | grid.cosmos-lab.org |
| Topic group | SDR & GNU Radio |
| Last verified | 2026-09-24 |
Background knowledge
Account & access
Devices / nodes
| Resource | Role | Qty | Notes |
|---|---|---|---|
| node20-18.grid | receiver | 1 | RTL-SDR (Nooelec NESDR SMArt v5, R820T2) with a VHF whip at the window |
The node is specified because the antenna is: a ~55 cm quarter-wave whip with a view of the sky. Any node with an RTL-SDR and a comparable antenna will work; a node whose dongle carries a 1090 MHz ADS-B stub will not, because a 7 cm whip is about 0.03 of a wavelength at 137 MHz.
Disk images
| Image | Load onto | Provides |
|---|---|---|
| baseline-sdr.ndz | node20-18 | rtl-sdr tools, GNU Radio 3.10, numpy, matplotlib |
Software components
| Component | Version | Source |
|---|---|---|
| dumpvdl2 | 2.7.0 | built by the tutorial from github.com/szpajder/dumpvdl2 |
| libacars | 2.2.1 | built by the tutorial from github.com/szpajder/libacars |
| rtl-sdr | preinstalled | baseline-sdr.ndz |
The decoder is not baked into the image. The tutorial builds it once per imaging (about two minutes) and skips the build on every run after that.
Spectrum / RF / special
Receive only. VDL Mode 2 occupies ten 25 kHz channels between 136.650 and 136.975 MHz; all ten fit inside one 2.1 MS/s capture, so the receiver watches them simultaneously.
One node. The dongle plugs into USB, the whip goes to the dongle's MCX connector, and the antenna needs to be at a window — the signal is coming from the sky.
aircraft overhead
(~25 W, 136.975 MHz, clear line of sight)
|
v
~55 cm whip at the window
|
MCX coax
|
RTL-SDR (R820T2 + RTL2832U) --USB--> node20-18.grid --control net--> console.grid
ssh <username>@console.grid.cosmos-lab.org
omf tell -a offh -t node20-18
omf load -i baseline-sdr.ndz -r 0 -t node20-18
omf tell -a on -t node20-18
omf stat -t node20-18
# on the console
ssh root@node20-18
# 1. build the decoder (once per image, about two minutes)
apt-get update
apt-get install -y cmake build-essential librtlsdr-dev libglib2.0-dev \
pkg-config zlib1g-dev libxml2-dev libjansson-dev
cd /root
git clone --depth 1 https://github.com/szpajder/libacars
cd libacars && mkdir build && cd build && cmake .. && make -j$(nproc) && make install && ldconfig
cd /root
git clone --depth 1 https://github.com/szpajder/dumpvdl2
cd dumpvdl2 && mkdir build && cd build && cmake .. && make -j$(nproc) && make install
# 2. check the dongle AND its gain list before you rely on a number
rtl_test -t
Found Rafael Micro R820T tuner
Supported gain values (29): 0.0 0.9 ... 43.9 44.5 48.0 49.6
An RTL-SDR does not clamp a gain it does not have — rtl_sdr exits and the capture is empty, which looks exactly like a quiet sky. An R820T2 stops at 49.6; an E4000 stops at 42.0.
# 3. listen to all ten VDL2 channels at once
dumpvdl2 --rtlsdr 0 --gain 49.6 --oversample 20 --centerfreq 136812500 \
136650000 136700000 136725000 136750000 136775000 \
136800000 136825000 136875000 136925000 136975000
Leave it running. Messages appear as aircraft transmit.
export OMF_NODES="node20-18"
cosmos-tutorial run acars
The run builds the decoder if needed, checks the dongle, listens for ten minutes, parses the output into a result contract, and then demonstrates the receive chain on the shipped sample burst.
The same experiment is available two other ways. The commands differ because the notebook has its own verb rather than a --mode:
cosmos-tutorial run acars --mode manual -- node20-18 # the hand commands, scripted
cosmos-tutorial jupyter acars # the notebook, with the figure inline
All three read the same acars_params.yml and emit their result through the same parser, which is what makes the parity check below meaningful.
[2026-09-24 12:48:26 UTC] [136.975] [-5.6/-32.6 dBFS] [27.0 dB] [-1.3 ppm]
A3578A (Aircraft, Airborne) -> 261C37 (Ground station): Command
AVLC type: I sseq: 3 rseq: 3 poll: 0
ACARS:
Reg: .N314VB Flight: AA1264
Mode: 2 Label: H1 Blk id: 1 More: 0 Ack: ! Msg num: D35A
Message:
TRP KORD KLGA 8 4
/A4 123723 40.4706 -74.6913 95 145.5 289
/A5 124023 40.4616 -74.4104 71 145.4 280
Read it as: at 12:48:26 UTC an aircraft whose ICAO address is A3578A — registration N314VB, operating AA1264 from KORD (Chicago O'Hare) to KLGA (LaGuardia) — sent a position report to ground station 261C37, at 27 dB signal-to-noise. The /A4 and /A5 lines are timestamped waypoints: 40.4616 N, 74.4104 W is a few kilometres from the lab.
A ten-minute listen over this part of New Jersey returned ten frames from four aircraft on 2026-09-24 (the contract is in Verification below). That is a rate, not a guarantee — see Troubleshooting.
Everything above treats the decoder as a black box. This section opens it.

The blue stages are the physical layer, and the tutorial runs every one of them in front of you on a recorded burst. The green stages are the link layer, which dumpvdl2 does in C. Each layer throws away anything that fails its own check, which is why a decoded frame is strong evidence and a peak in a spectrum is not.
| Parameter | Value | Why it matters |
|---|---|---|
| Modulation | D8PSK, 3 bits/symbol | 8 phase states, differentially encoded |
| Symbol rate | 10 500 baud | → 31 500 bit/s on the air |
| Pulse shaping | raised cosine, α = 0.6 | signal is flat to ±2.1 kHz, zero beyond ±8.4 kHz |
| Sample rate | 105 kS/s per channel | exactly 10 samples per symbol |
| Preamble | 16 symbols | gives symbol timing and the frequency offset |
| FEC | Reed–Solomon (255, 249) | 6 parity bytes; corrects up to 3 corrupted bytes |
| Frame check | AVLC FCS | fail it and the frame is discarded, not guessed |
The tutorial ships a real 152 ms burst recorded from an aircraft overhead, so this works regardless of what is flying right now:
cd /run/cosmos/acars
python3 acars_dsp.py --iq vdl2_136975_burst.cf32 --out chain --compare

1. burst 151.6 ms, ~1592 symbols, 18.2 dB over the channel floor
2. spectrum 11.8 kHz wide at -10 dB
3. timing phase 26 of 40 (1/40 symbol), cluster score 0.889
4. differential 1591 symbols; residual rotation -4.9 deg/symbol
= -141 Hz = -1.03 ppm at 136.975 MHz
5. constellation rms phase error 3.7 deg from the 8 ideal points
(random phases would give 13.0 deg)
6. symbol histogram over the 8 states: [167, 198, 215, 195, 216, 183, 220, 197]
The point of the whole exercise is panels 3 and 4. Panel 3 plots the symbols as they arrive: a ring. The carrier phase is unknown and drifting, so no individual symbol means anything. Panel 4 multiplies each symbol by the conjugate of the one before it — and eight clean clusters appear, 45° apart.
That is differential detection. The data was never in the absolute phase; it is in the phase change from one symbol to the next. A receiver with no idea what the carrier phase is can still read every bit. It is also why the leftover frequency error shows up as a single fixed rotation of all eight points rather than as a smear: correcting it is one multiplication, and the residual (−1.03 ppm above) agrees with the figure dumpvdl2 reports independently for the same burst (−1.0 ppm).
The symbol histogram is flat across all eight states, which is what scrambled data should look like. A histogram with a hole in it means the timing is off.
The reflex when demodulating PSK is to apply a root-raised-cosine matched filter. Here it is actively harmful. --compare measures it on the same burst:
| Receive filter | Cluster score | rms phase error |
|---|---|---|
| Low-pass 12 kHz | 0.889 | 3.7° |
No filter at all (what dumpvdl2 does) |
0.786 | 5.0° |
| RRC α = 0.6 "matched filter" | 0.136 | 11.9° |
| (random phases, for reference) | — | 13.0° |
The RRC result is barely better than random. The reason is in the specification: VDL2's transmitter applies the full raised cosine, so the pulse is already Nyquist-shaped when it reaches you. Convolving with another root adds inter-symbol interference instead of removing it. The matched-filter rule assumes the transmitter used a root; check that assumption before reaching for the filter.
A run is good when:
dumpvdl2 prints only frames that pass it, so a printed frame is a checked frame.N.The result contract from a verified run:
{
"frames": 10,
"channels": {"136.650": 8, "136.975": 2},
"aircraft": ["AA72B1", "AA7DD6", "AA7F4E", "ABAE62"],
"flights": {"N77598": "UA0216"},
"snr_db": {"min": 17.7, "max": 22.9, "mean": 20.8}
}
All three modes are checked against each other by the parity harness:
cosmos-tutorial run acars --mode parity -- node20-18
A verified run, 2026-09-24:
mode frames channels aircraft SNR mean
ansible 2 136.975:2 1 17.6
manual 3 136.975:3 1 23.0
jupyter 3 136.975:3 1 23.0
config identical across modes: yes
frames decoded, all modes: 8
frame counts NOT compared: each mode hears a different sky
PARITY: PASS
Look at the frame counts: 2, 3, 3. The three modes ran the identical experiment five minutes apart and heard different amounts of traffic, because different aircraft transmitted in each window.
⚠️ This harness deliberately does not compare frame counts. The three modes run one after another and each hears whichever aircraft happened to transmit during its own window, so their counts cannot match. What it does assert is that all three used an identical configuration — same channels, same gain, same listen time, same decoder build — that each produced a well-formed contract, and that the chain decoded something overall. A harness that demanded matching counts would fail on a quiet sky and pass on a broken receiver at a busy moment, which is exactly backwards.
cosmos-tutorial run acars --mode teardown
omf tell -a offh -t node20-18
The teardown stops the listener by its systemd unit name. It never uses pkill: the node is shared, and a kill by process name would take out another user's decoder as readily as yours. Anything still holding the dongle is reported with its pid rather than killed.
No frames decoded. This is the common case and it is usually not a fault. In order:
acars_dsp.py on the shipped burst. If that produces eight clean clusters, everything from the file inwards works and the question is only whether a signal arrived.rtl_test -t lists them. An unsupported value does not get clamped — rtl_sdr exits and the capture is empty, which is indistinguishable from silence. R820T2 → 49.6, E4000 → 42.0.rtl_sdr: usb_claim_interface error -6. The kernel DVB driver has grabbed the dongle, or a previous run is still holding it. Run the teardown, then rmmod dvb_usb_rtl28xxu if needed.
[R82XX] PLL not locked! — benign. It prints at frequencies that decode perfectly. It is not a lead.
Frames decode but the message body is a block of base64-looking text. Normal. Airlines compress and sometimes encrypt their own payloads; the link layer decoded correctly, which is what the frame header and CRC already told you.
Tutorial: cosmos-tutorial run acars · package cosmos-experiments-acars · verified on grid node20-18, 2026-09-24.