squitter 0.1.1

no_std, no_alloc parser and encoder for 1090ES/DF17 (ADS-B extended squitter) messages
Documentation
//! Raw Mode S DF17 frame parsing: hex text to a checksum-validated,
//! 112-bit/14-byte frame. Analogous to `aivdm`'s `nmea::Sentence::parse`,
//! but the wire format here is a plain hex string rather than a
//! comma-delimited NMEA sentence with six-bit ASCII armor.

use crate::crc;
use crate::error::{AdsbError, FrameError};
use crate::message::AdsbMessage;

/// The downlink format this crate decodes: civil ADS-B extended squitter.
const DF17: u8 = 17;

/// A parsed, CRC-validated 112-bit DF17 (civil ADS-B extended squitter) frame.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Frame {
    bytes: [u8; 14],
}

impl Frame {
    /// Parses and CRC-validates a Mode S DF17 frame from a hex string.
    ///
    /// Accepts either bare hex (`8D4840D6202CC371C32CE0576098`) or
    /// dump1090's raw hex framing (`*8D4840D6202CC371C32CE0576098;`).
    ///
    /// # Errors
    /// Returns a [`FrameError`] if the hex payload is the wrong length, is
    /// not valid hexadecimal, has a downlink format other than 17, or fails
    /// its CRC-24 check.
    pub fn parse(line: &str) -> Result<Self, FrameError> {
        let line = line.trim();
        let hex = line
            .strip_prefix('*')
            .and_then(|s| s.strip_suffix(';'))
            .unwrap_or(line);

        if hex.len() != 28 {
            return Err(FrameError::WrongLength {
                expected: 28,
                actual: hex.len(),
            });
        }

        let mut bytes = [0u8; 14];
        for (i, byte) in bytes.iter_mut().enumerate() {
            let pair = hex.get(i * 2..i * 2 + 2).ok_or(FrameError::InvalidHex)?;
            *byte = u8::from_str_radix(pair, 16).map_err(|_| FrameError::InvalidHex)?;
        }

        let df = bytes[0] >> 3;
        if df != DF17 {
            return Err(FrameError::UnsupportedDownlinkFormat(df));
        }

        if !crc::verify(&bytes) {
            return Err(FrameError::ChecksumMismatch);
        }

        Ok(Self { bytes })
    }

    /// Builds a frame directly from raw DF17 bytes, computing and filling in
    /// the CRC-24 parity field. `bytes`'s trailing 3 bytes are overwritten.
    #[must_use]
    pub fn from_data(mut bytes: [u8; 14]) -> Self {
        crc::write_parity(&mut bytes);
        Self { bytes }
    }

    /// Transponder capability level (3-bit `CA` field).
    #[must_use]
    pub const fn capability(&self) -> u8 {
        self.bytes[0] & 0b111
    }

    /// The 24-bit ICAO aircraft address.
    #[must_use]
    pub const fn icao_address(&self) -> u32 {
        (self.bytes[1] as u32) << 16 | (self.bytes[2] as u32) << 8 | self.bytes[3] as u32
    }

    /// The 56-bit `ME` (message extended squitter) payload, the part that
    /// carries the type-code-dispatched message content.
    #[must_use]
    pub const fn me(&self) -> &[u8] {
        self.bytes.split_at(4).1.split_at(7).0
    }

    /// The full 14-byte frame, including the CRC-24 parity field.
    #[must_use]
    pub const fn bytes(&self) -> &[u8; 14] {
        &self.bytes
    }

    /// Decodes this frame's `ME` field into a typed ADS-B message.
    ///
    /// # Errors
    /// Returns an [`AdsbError`] if the type code isn't one this crate
    /// decodes yet.
    pub fn decode_message(&self) -> Result<AdsbMessage, AdsbError> {
        Ok(AdsbMessage::decode(self.me())?)
    }

    /// Formats this frame as a 28-character uppercase hex string, writing
    /// into `buf` (no allocation) and returning the written slice. Does not
    /// add dump1090's `*`/`;` framing; wrap the result yourself if needed.
    #[must_use]
    pub fn write_hex<'b>(&self, buf: &'b mut [u8; 28]) -> &'b str {
        const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";
        for (i, &byte) in self.bytes.iter().enumerate() {
            buf[i * 2] = HEX_DIGITS[(byte >> 4) as usize];
            buf[i * 2 + 1] = HEX_DIGITS[(byte & 0x0F) as usize];
        }
        // every byte maps to two ASCII hex digits, always valid UTF-8.
        core::str::from_utf8(buf).unwrap_or_default()
    }
}

#[cfg(test)]
mod tests {
    extern crate std;

    use core::fmt::Write as _;

    use std::format;
    use std::string::String;

    use super::*;

    const GOOD: &str = "8D406B902015A678D4D220AA4BDA";

    #[test]
    fn parses_bare_hex() {
        let f = Frame::parse(GOOD).unwrap();
        assert_eq!(f.icao_address(), 0x0040_6B90);
        assert_eq!(f.me().len(), 7);
    }

    #[test]
    fn parses_dump1090_framing() {
        let line = alloc_wrapped(GOOD);
        let f = Frame::parse(&line).unwrap();
        assert_eq!(f.icao_address(), 0x0040_6B90);
    }

    fn alloc_wrapped(hex: &str) -> String {
        format!("*{hex};")
    }

    #[test]
    fn rejects_wrong_length() {
        assert_eq!(
            Frame::parse("8D40").unwrap_err(),
            FrameError::WrongLength {
                expected: 28,
                actual: 4
            }
        );
    }

    #[test]
    fn rejects_bad_checksum() {
        let mut bad = String::from(GOOD);
        bad.replace_range(4..5, "0");
        assert_eq!(
            Frame::parse(&bad).unwrap_err(),
            FrameError::ChecksumMismatch
        );
    }

    #[test]
    fn rejects_non_df17() {
        // DF11 (all-call reply): top 5 bits = 01011 = 11, rest zeroed + valid CRC
        let mut frame = [0u8; 14];
        frame[0] = 11 << 3;
        crc::write_parity(&mut frame);
        let mut hex = String::new();
        for b in frame {
            write!(hex, "{b:02X}").unwrap();
        }
        assert_eq!(
            Frame::parse(&hex).unwrap_err(),
            FrameError::UnsupportedDownlinkFormat(11)
        );
    }

    #[test]
    fn from_data_computes_matching_parity() {
        let mut bytes = [0u8; 14];
        bytes[0] = DF17 << 3;
        let f = Frame::from_data(bytes);
        assert!(crc::verify(f.bytes()));
    }

    #[test]
    fn write_hex_round_trips_through_parse() {
        let original = Frame::parse(GOOD).unwrap();
        let mut buf = [0u8; 28];
        let hex = original.write_hex(&mut buf);
        assert_eq!(hex, GOOD);

        let reparsed = Frame::parse(hex).unwrap();
        assert_eq!(reparsed, original);
    }
}

// Frame::parse takes arbitrary text off the wire, so it needs to reject
// garbage rather than panic: wrong lengths, non-hex characters, and
// multi-byte UTF-8 (whose byte length doesn't match its character count)
// have all been sources of off-by-one panics in hand-written hex parsers
// elsewhere, so this fuzzes arbitrary Unicode strings, not just ASCII.
#[cfg(test)]
mod proptests {
    extern crate std;

    use proptest::prelude::*;
    use std::string::String;

    use super::Frame;

    proptest! {
        #[test]
        fn parse_never_panics(s: String) {
            let _ = Frame::parse(&s);
        }
    }
}