Radio Waves to Flight Data: Building a Mode S Decoder

February 1, 2025

If you ask me how to get started with software-defined radio, I will probably tell you to start by stalking planesLegally stalking planes, that is. The FAA requires aircraft to broadcast this data specifically so we can track them.. Every commercial aircraft broadcasts its position, altitude, and other flight data using protocols called Mode S and ADS-B. These broadcasts are perfect for learning radio. The signals are strong, the protocols are well-documented, and importantly, if your code screws up you won't crash any planesWe're just passively listening. The planes have no idea we exist..

I recently built my own decoder to understand these protocols betterI know dump1090 exists and is probably better in every way. But as Richard Feynman famously said, "What I cannot create, I do not understand." Implementing things from scratch is how we learn.. While I'm still working through theory (PySDR is great for this), this project was about getting my hands dirty with real signals. I built the whole pipeline in Haskell using an RTL-SDR dongle and these rtlsdr bindings. Here's what I learned along the way.

A Brief History of Plane Detection

Before diving into Mode S, we should talk about how we got here. The original radar systems were basically just radio wave bouncersThink of it like echolocation: send out radio waves, measure what bounces back. Great for figuring out something's there, terrible for figuring out what that something is.. They'd send out radio pulses and measure what came back, giving you nice blips on a screen. Problem was, these blips told you almost nothing about the aircraft - no altitude, no identity, nothing.

The solution? Just ask the planes who they areThis was arguably one of the first IoT systems where we put transponders in everything so they could identify themselves. Mode A/C transponders are still required equipment in most controlled airspace today.. This system, called Secondary Surveillance Radar (SSR), worked like a game of Marco Polo. Ground stations would send out interrogation signals, and aircraft would respond with their identity (Mode A) or altitude (Mode C). Each plane had a unique 4-digit "squawk" code that air traffic control would assign, making it possible to actually track specific aircraft.

Mode S came later as an upgrade to this system, adding the ability to selectively interrogate specific aircraftWith Mode A/C, every plane would respond to every interrogation. As air traffic increased, this created a lot of radio congestion. Mode S solved this by letting ground stations target specific aircraft.. This reduced radio congestion and laid the groundwork for the modern ADS-B system we'll be working with.

Enter ADS-B

Mode S worked well, but having ground stations constantly interrogate aircraft was still inefficient. Someone eventually had a simpler idea: what if planes just constantly broadcast their information to anyone listeningThe broadcast nature of these signals means anyone with a basic USB receiver can track planes. They're literally shouting their location into the void 24/7.? And there you have ADS-B.

The name is an acronym that tells you exactly what's happeningAutomatic (it happens without intervention), Dependent (on GPS), Surveillance (tracking), Broadcast (to everyone).. It's automatic because planes broadcast without being asked. It's dependent because planes use their own GPS to know their position instead of relying on ground radar. And it's broadcast because these messages go out to anyone who cares to listen.

ADS-B messages are just a special type of Mode S messageThis backwards compatibility was crucial. Airports could keep using their existing Mode S equipment while planes gradually upgraded to ADS-B.. When we build our decoder, we'll see how ADS-B messages are marked with a special identifier (DF17) that tells receivers "hey, this is position data!" while still following the same basic Mode S format.

The FAA has mandated ADS-B Out (transmitting) for most aircraft since 2020Which is great news for us. It means almost every plane you see overhead is broadcasting data we can decode.. This makes it the perfect protocol to learn with since there's plenty of signal traffic to practice on, and the format is well-documented because it has to be implemented consistently across all aviation.

Before we dive into implementation details, I should mention that if you want to really understand Mode S and ADS-B, check out the 1090 MHz RiddleSeriously, it's the most digestible technical documentation I've found on this stuff. Everything in this post is basically a dumbed-down version of what you'll find there.. It's an incredible resource that explains everything from the protocol details to the math behind position decoding. What follows here is just my experience implementing a small part of what they describe.

The Anatomy of a Mode S Message

Before we dive into code, here's the structure we need to decode. Mode S messages come in two sizes: short (56 bits) and long (112 bits). Each one begins with a distinctive preamble patternThis preamble is essential for our decoder. It's how we find the start of messages in the endless stream of radio noise. More on this when we get to the demodulation code. followed by the actual data.

The first 5 bits of the data, the Downlink Format (DF), tell us what kind of message we're dealing with. Think of it like a version number that tells us how to interpret everything that followsMost of the time we'll be looking for DF17, which marks ADS-B messages, but our decoder needs to handle all types to be useful.. The format of the rest of the message depends entirely on these 5 bits.

For example, DF17 messages (the ADS-B ones we care about most) break down like this:

Mode S Message Structure

That middle "ME" section reveals another layer of complexityAircraft position data being particularly interesting. They use a special encoding scheme called CPR that manages to pack precise lat/long coordinates into just a few bits by alternating between "even" and "odd" messages. We'll talk more about that later.. The first 5 bits tell us what kind of data follows (position, velocity, aircraft ID, etc.), and then we have 51 bits of actual payload data. The last 24 bits are used for error detection, which proves to be vital when you're trying to decode radio signals from planes dozens of miles away.

Building the Decoder: A Three-Stage Pipeline

Processing radio waves into data is like playing a game of telephone, but whether or not you keep the message intact has real consequencesThis is why each stage has its own error checking and recovery mechanisms.. Our decoder breaks this down into three stages: demodulation (turning radio waves into bits), verification (making sure those bits make sense), and decoding (turning valid bits into actual flight data).

If you remember high school physics, radio waves are just sine waves moving through space. The trick is finding our message in this sea of wavesBetween atmospheric noise, other radio signals, and the inverse square law weakening signals from distant aircraft, there's a lot that can go wrong here.. Mode S makes this easier by using a dead-simple modulation scheme where each bit is just a pulse of high or low amplitude at specific time intervalsNo need for FFTs or complex signal processing here. If the signal is above a threshold, it's a 1; if it's below, it's a 0. This simplicity is deliberate: aviation protocols prioritize reliability over efficiency..

Demodulation: From Radio Waves to Bits

Let's start with demodulation. Our radio gives us a stream of I/Q samples (pairs of numbers that represent the amplitude and phase of the radio wave)I/Q sampling is a clever way to capture both the amplitude and phase of a wave using real numbers. The I component is "in-phase" and Q is "quadrature" (90 degrees out of phase). With these two numbers, we can reconstruct the original signal.. Our first job is finding Mode S messages in this stream.

The code is deceptively simple:

process :: ByteString -> [Message]
process = detectMessages . computeMagnitudeVector

Here's where the signal processing begins: we need to convert those I/Q pairs into a single number representing signal strengthAs mentioned in the previous side note, we can use the I and Q values to reconstruct the original signal. Here, we use them to compute the magnitude.:

computeMagnitudeVector :: ByteString -> Vector Word16
computeMagnitudeVector bs = V.generate numSamples getMagnitude
 where
  numSamples = BS.length bs `div` 2
  getMagnitude idx =
    let i = fromIntegral (BSU.unsafeIndex bs (idx * 2)) - 127
        q = fromIntegral (BSU.unsafeIndex bs (idx * 2 + 1)) - 127
        magnitude = sqrt $ fromIntegral (i * i + q * q)
     in round $ magnitude * 360

Next challenge: finding actual messages in this stream of magnitudes. Remember that distinctive preamble pattern I mentioned earlier? Here's how we look for it:

detectPreamble :: Vector Word16 -> Int -> Bool
detectPreamble mags pos
    | pos + 14 >= V.length mags = False
    | otherwise = 
        let 
            (mag0, mag1, mag2, mag3) = (mags V.! pos, mags V.! (pos+1), mags V.! (pos+2), mags V.! (pos+3))
            (mag4, mag5, mag6, mag7) = (mags V.! (pos+4), mags V.! (pos+5), mags V.! (pos+6), mags V.! (pos+7))
            (mag8, mag9) = (mags V.! (pos+8), mags V.! (pos+9))

            threshold = (fromIntegral (mag0 + mag2 + mag7 + mag9) :: Int) `div` 6

            patternCheck = mag0 > mag1 && mag1 < mag2 && mag2 > mag3 && 
                          mag3 < mag0 && mag4 < mag0 && mag5 < mag0 &&
                          mag6 < mag0 && mag7 > mag8 && mag8 < mag9 && mag9 > mag6

            spikeCheck = fromIntegral mag4 < threshold && fromIntegral mag5 < threshold

            spaceCheck = all (< threshold) $ map (fromIntegral . (mags V.!)) [pos+11..pos+14]

         in patternCheck && spikeCheck && spaceCheck

That code is kind of ugly, but it's just searching for a specific up-down-up-down pattern in our signalThe preamble consists of four precise pulses, each 0.5 microseconds long. With our 2MHz sampling rate (one sample every 0.5 μs), we're looking for strong signals at 0, 2, 7, and 9 samples into our data, with relative quiet between them. This pattern is nearly impossible to confuse with noise or other signals.. Think of it like finding the start of a sentence. This pattern tells us "hey, a Mode S message is about to begin!"

Preamble Pattern

Once we find a preamble, we start the actual work of decoding bits. The first chunk tells us if we're dealing with a short message (56 bits) or long message (112 bits):

determineMessageLength :: [Bool] -> Maybe MessageLength
determineMessageLength bits
  | length bits < 8 = Nothing
  | otherwise = let df = bitsToWord8 (take 8 bits) `shiftR` 3
                 in Just $ if df < 16 then ShortMessage else LongMessage

If we mess up the message length, everything that follows is garbage. But even when we get the length right, we still need to validate the message contents. That's where the next stage of our pipeline comes in.

Verification: Making Sure Our Bits Make Sense

At this point, we have bits. But having bits and having the right bits are two very different things. Think of radio signals like throwing a ball into a bin: the further away you are and the more obstacles in the way, the harder it is to get the ball in cleanlyAnd we've got lots of obstacles: other radio signals bouncing around, physical objects reflecting our signals, and the simple fact that radio waves get weaker the further they travel.. Mode S uses a 24-bit CRC checksum tacked onto the end of each message to let us know when our bits have gone astray. CRC is usually only for error detection, not correction, but there's a twist that makes things interesting.

Most protocols just use CRC for error detection. If the checksum doesn't match, you discard the messageA protocol like IP can afford to do this because TCP will just request a retry. But we're just passive listeners here. We can't tell a plane "hey, could you repeat that?" So we need to be more clever about recovering corrupted messages.. Mode S does something trickierThis is a brilliant space-saving hack. Instead of wasting bits on both a checksum and an aircraft ID, they combined them. The spec calls this "address/parity," which is a fancy way of saying "two things for the price of one.". The checksum field doubles as the aircraft's unique identifier in certain message types.

verify :: Message -> IcaoCache -> IO (Maybe (VerifiedMessage, IcaoCache))
verify msg cache = case verify' msg of
    Nothing -> return Nothing
    Just verifiedMsg -> do
        finalMsg <- if verifiedParity verifiedMsg == InvalidChecksum
            then do
                mAddr <- recoverAddress msg verifiedMsg cache
                return $ case mAddr of
                    Just addr -> verifiedMsg
                        { verifiedICAO = addr
                        , verifiedParity = Valid
                        }
                    Nothing -> verifiedMsg
            else return verifiedMsg

The clever trick comes in handling messages with bad checksums. Sometimes we can actually recover the correct data by using a cache of recently seen aircraft addressesAircraft typically send multiple messages per second. Once we've seen a valid message from an aircraft, we can use that knowledge to recover corrupted messages from the same plane.. This significantly improves our decoder's reliability, especially for distant aircraft with weaker signals:

recoverAddress :: Message -> VerifiedMessage -> IcaoCache -> IO (Maybe Word32)
recoverAddress msg dm cache
  | dfRequiresBruteForce (verifiedDF dm) = do
    let crc = calculateChecksum bytes numBits
        recoveredAddr =
            (fromIntegral (bytes !! (lastByte-2)) `xor` 
             ((crc `shiftR` 16) .&. 0xff)) `shiftL` 16 .|.
            (fromIntegral (bytes !! (lastByte-1)) `xor` 
             ((crc `shiftR` 8) .&. 0xff)) `shiftL` 8 .|.
            (fromIntegral (bytes !! lastByte) `xor` (crc .&. 0xff))
    inCache <- checkCache recoveredAddr cache
    return $ if inCache then Just recoveredAddr else Nothing
  | otherwise = return Nothing

XOR magic usually looks mysterious, but this code just reverses what the aircraft did when it created the messageXOR has a neat property: if A XOR B = C, then C XOR B = A. We're using this to recover the original aircraft ID that was XORed with the CRC value.. If the recovered address matches one we've seen before, we can be reasonably confident we've correctly decoded the message despite the checksum failure.

Decoding: Turning Bits Into Flight Data

Now we're finally at the fun part: transforming verified bits into actual flight dataThis is the part where numbers start turning into things you can visualize: aircraft positions, velocities, callsigns. It's incredibly satisfying when you first see a plane overhead match the data you're decoding.. Each message type has its own format, and we need to handle them all:

decode :: VerifiedMessage -> DecodedMessage
decode msg = DecodedMessage
    { msgFormat = verifiedDF msg
    , msgCommon = decodeCommonFields msg
    , msgSpecific = decodeSpecificFields msg
    }

A big challenge comes in decoding aircraft positions. If you look at a Mode S message carrying position data, you might notice something odd: the position values don't make any sense on their ownPosition messages come in pairs: "even" and "odd" frames. You need both to get an accurate position. This is part of the Compact Position Reporting (CPR) scheme that lets Mode S pack precise coordinates into very few bits.. Here's how we handle this:

decodePosition :: [Word8] -> Position
decodePosition payload = 
    let coords = decodeCPRCoordinates payload
        meField = getMEField payload
        f = testBit (meField !! 2) 2  -- F bit (odd/even) at bit 2 
        t = testBit (meField !! 2) 3  -- T bit (UTC sync) at bit 3
    in Position
        { posCoordinates = coords
        , posOddFormat = f      
        , posUTCSync = t        
        }

The magic lies in CPR (Compact Position Reporting). Instead of sending absolute coordinates, aircraft alternate between sending two different position encodingsThink of it like overlaying two slightly offset grids on the Earth. Each message tells you where in its grid the plane is. By combining two messages, you can pinpoint the exact location (like how GPS uses multiple satellites to triangulate position).. Each encoding gives us a rough position, but when you combine an odd and even message, you get precise coordinates using way fewer bits than a standard latitude/longitude encoding would need.

I'm barely scratching the surface of CPR here. The full implementation involves modular arithmetic, zone calculations, and handling edge cases near latitude/longitude discontinuitiesThis was honestly the trickiest part of the whole project. You can see the full implementation in my GitHub repo, but I'd recommend reading the 1090 MHz Riddle's chapter on airborne position decoding first.. It's one of those elegant engineering tradeoffs where added protocol complexity buys you better bandwidth usage.

Putting It All Together

Here's how all these pieces fit together. We end up with a pipeline that takes raw radio samples and turns them into a stream of aircraft dataThe key word here is "stream". Planes are constantly broadcasting, so our decoder needs to handle a continuous flow of data, not just single messages.:

processModeSData :: BS.ByteString -> IcaoCache -> IO ([DecodedMessage], IcaoCache)
processModeSData input cache = do
    -- Demod raw data into messages
    let messages = process input
    -- Process each message through verification and decoding
    foldM processMessage ([], cache) messages
  where
    processMessage (decodedMsgs, currentCache) msg = do
        -- Verify the message and update cache
        verificationResult <- verify msg currentCache
        case verificationResult of
            Just (verifiedMsg, newCache) ->
                -- Only include messages with valid parity
                if verifiedParity verifiedMsg == Valid
                then return (decode verifiedMsg : decodedMsgs, newCache)
                else return (decodedMsgs, newCache)
            Nothing ->
                return (decodedMsgs, currentCache)

This is where all our careful error handling pays offOne corrupted message shouldn't break our entire decoder. Each stage can fail gracefully, and we only pass along messages we're confident about.. We take raw I/Q samples from our radio, find potential messages in the noise, verify them, and finally decode the ones that pass our checks. The ICAO cache helps us recover from errors, and the whole thing runs as a continuous process, converting radio waves into a steady stream of flight data.

If any of this seems overengineered, remember that we're dealing with safety-critical aviation dataWhile our decoder is just for learning, the protocol itself needs to be rock-solid. Aviation systems can't afford to mix up aircraft positions or misinterpret altitudes.. The protocol's design reflects this: from the simple modulation scheme to the clever use of CRC for both error detection and aircraft identification, everything is built to be reliable first, efficient second.

The Testing Journey

I learned a fundamental lesson about testing the hard wayThis is one of those lessons that should be obvious but can be surprisingly hard to see when you're deep in the code on a hobby project.. My first attempt was to build and test the entire pipeline at once: from raw radio waves straight through to decoded flight data. Every time something went wrong (which was constantly), I had no idea where in the chain it was failing. Was my demodulation off? Was I misinterpreting the protocol? Were the planes just not cooperating that day?

Breaking it down into testable modules changed everything. Each layer - demodulation, message detection, verification, and decoding - could be tested independently with known inputs and expected outputs. But the real game-changer was building a test suite with captured I/Q sample files containing known messages. Instead of poking an antenna out the window and hoping to pick up a good signal, I could reliably test against the same data over and over.

This methodical approach saved my sanity. When you're dealing with radio signals, there are already enough variables in play: signal strength, noise, interference. Adding "is my code wrong?" to that mix makes debugging pretty annoying. Being able to say "this module passes all tests with known data" lets you isolate problems much easier, and eliminate a point of failure.

Closing Thoughts

Aviation and software don't often mix. When they do, it usually requires bulletproof reliability and years of testing. But building hobbyist radio tools like this decoder lets us peek into that world of high-reliability protocolsAnd do it safely. We're just passive listeners getting publicly broadcast data. No planes were endangered in the making of this decoder..

The protocol's design choices reveal their brilliance when you implement them yourself. Simple modulation for reliability. Clever bit-packing to save bandwidth. Built-in error detection and recovery. Even the seemingly odd choice to make planes broadcast data that anyone can read was deliberate since it makes the whole system more resilientWith everyone able to receive these signals, there's massive redundancy in tracking coverage. If one receiver misses a message, another probably caught it..

Sure, I could've just downloaded dump1090 and started tracking planes immediately. Instead, I built the whole system from radio waves to terminal UIThe full project includes a terminal-based display for aircraft positions and data. Not as polished as dump1090's web interface, but it shows every step working together.. There's something deeply satisfying about watching data fly into your screen and update in real time knowing exactly how each everything got there. If you're looking to learn about radio protocols, you could do worse than by stalking planes.