squitter 0.1.1

no_std, no_alloc parser and encoder for 1090ES/DF17 (ADS-B extended squitter) messages
Documentation
//! A `no_std`, allocation-free parser and encoder for ADS-B (Automatic
//! Dependent Surveillance-Broadcast) messages, per the Mode S extended
//! squitter format (Downlink Format 17), as commonly represented as raw hex
//! frames (e.g. dump1090's `*8D4840D6202CC371C32CE0576098;` format).
//!
//! # Layering
//!
//! - [`frame`] parses and CRC-24-validates a hex-encoded DF17 frame.
//! - [`bits`] reads and writes arbitrary-width fields directly against the
//!   frame's plain binary bytes.
//! - [`message`] decodes/encodes the bit-packed `ME` field into a typed
//!   [`AdsbMessage`], one variant per supported type-code family.
//!
//! # No allocation
//!
//! Nothing in this crate uses `alloc`.
#![no_std]
#![forbid(unsafe_code)]
#![warn(missing_docs)]

pub mod bits;
pub mod crc;
pub mod error;
pub mod frame;
pub mod message;
pub mod string;

pub use error::AdsbError;
pub use frame::Frame;
pub use message::AdsbMessage;

/// Decodes a single hex-encoded DF17 frame into a typed ADS-B message.
///
/// # Errors
/// Returns an [`AdsbError`] if the frame fails to parse/CRC-validate, or its
/// `ME` field's type code isn't one this crate decodes yet.
pub fn decode_line(line: &str) -> Result<AdsbMessage, AdsbError> {
    Frame::parse(line)?.decode_message()
}

/// Encodes a typed ADS-B message into a full DF17 [`Frame`], given the
/// transmitting aircraft's 24-bit ICAO address and transponder capability
/// level (only the low 24/3 bits of each are used, respectively).
///
/// # Errors
/// Returns an [`AdsbError`] if the message's `ME` payload doesn't fit in 56
/// bits (not possible for any message type this crate currently supports,
/// but kept fallible for forward compatibility).
#[allow(
    clippy::cast_possible_truncation,
    reason = "icao_address is documented as a 24-bit field; only the low 24 bits are used"
)]
pub fn encode_frame(
    message: &AdsbMessage,
    icao_address: u32,
    capability: u8,
) -> Result<Frame, AdsbError> {
    let mut me = [0u8; 7];
    let mut w = bits::BitWriter::new(&mut me);
    message.encode(&mut w)?;

    let mut bytes = [0u8; 14];
    bytes[0] = (17 << 3) | (capability & 0b111);
    bytes[1] = (icao_address >> 16) as u8;
    bytes[2] = (icao_address >> 8) as u8;
    bytes[3] = icao_address as u8;
    bytes[4..11].copy_from_slice(&me);
    Ok(Frame::from_data(bytes))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decodes_a_real_frame() {
        let msg = decode_line("8D406B902015A678D4D220AA4BDA").unwrap();
        let AdsbMessage::Identification(id) = msg else {
            panic!("expected Identification");
        };
        assert_eq!(id.type_code, 4);
    }

    #[test]
    fn decodes_and_recognizes_the_klm_callsign_fixture() {
        let msg = decode_line("8D4840D6202CC371C32CE0576098").unwrap();
        let AdsbMessage::Identification(id) = msg else {
            panic!("expected Identification");
        };
        assert_eq!(id.callsign.as_str(), "KLM1023");
    }

    #[test]
    fn decodes_a_real_airborne_position_frame() {
        let msg = decode_line("8D40058B58C901375147EFD09357").unwrap();
        let AdsbMessage::AirbornePosition(pos) = msg else {
            panic!("expected AirbornePosition");
        };
        assert_eq!(pos.altitude, message::Altitude::BarometricFeet(39_000));
        assert_eq!(pos.lat_cpr, 39848);
        assert_eq!(pos.lon_cpr, 83951);
    }

    #[test]
    fn encodes_and_decodes_a_surface_position_through_a_df17_frame() {
        // field values are real (from a captured DF18/TIS-B surface report
        // at LFBO, see message::surface_position's own tests), but that
        // capture is DF18, not DF17 -- this crate only accepts DF17, so
        // round-trip the same real values through a DF17 frame here to
        // exercise the full frame+message pipeline together.
        let original = AdsbMessage::SurfacePosition(message::SurfacePosition {
            type_code: 8,
            movement_raw: 38,
            track_status: true,
            track_raw: 35,
            utc_synced: true,
            cpr_format: message::CprFormat::Even,
            lat_cpr: 11052,
            lon_cpr: 86083,
        });
        let frame = encode_frame(&original, 0x003A_23FF, 5).unwrap();
        let mut buf = [0u8; 28];
        let hex = frame.write_hex(&mut buf);
        let re_decoded = decode_line(hex).unwrap();
        assert_eq!(re_decoded, original);
    }

    #[test]
    fn decodes_a_real_airborne_velocity_frame() {
        let msg = decode_line("8D485020994409940838175B284F").unwrap();
        let AdsbMessage::AirborneVelocity(vel) = msg else {
            panic!("expected AirborneVelocity");
        };
        assert_eq!(vel.vertical_rate_fpm, Some(-832));
        assert!((vel.ground_speed_knots().unwrap() - 159.2).abs() < 0.1);
    }

    #[test]
    fn encode_frame_round_trips() {
        let original = decode_line("8D4840D6202CC371C32CE0576098").unwrap();
        let frame = encode_frame(&original, 0x0048_40D6, 5).unwrap();

        let mut buf = [0u8; 28];
        let hex = frame.write_hex(&mut buf);
        let re_decoded = decode_line(hex).unwrap();
        assert_eq!(re_decoded, original);
    }
}