squitter 0.1.1

no_std, no_alloc parser and encoder for 1090ES/DF17 (ADS-B extended squitter) messages
Documentation
//! Airborne Position message — type codes 9-18 (barometric altitude) and
//! 20-22 (GNSS height).

use super::cpr::{self, CprFormat};
use crate::bits::{BitReader, BitWriter};
use crate::error::{BitError, MessageError};

/// A position report's altitude, decoded per its type code's encoding.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Altitude {
    /// Barometric altitude in feet (type codes 9-18, Q-bit = 1: the modern,
    /// near-universal 25-foot-increment encoding).
    BarometricFeet(i32),
    /// Legacy Gillham/gray-code barometric encoding (type codes 9-18,
    /// Q-bit = 0), rare in modern traffic. Not decoded to feet; carries the
    /// raw 11-bit Gillham-coded value.
    Gillham(u16),
    /// GNSS height in meters (type codes 20-22): unlike the barometric
    /// case, this is the raw 12-bit field's plain decimal value, no Q-bit
    /// or 25-ft scaling involved.
    GnssMeters(u16),
    /// No altitude information available (raw field was all-zero).
    NotAvailable,
}

impl Altitude {
    fn decode(type_code: u8, raw12: u16) -> Self {
        if raw12 == 0 {
            return Self::NotAvailable;
        }
        if type_code >= 20 {
            return Self::GnssMeters(raw12);
        }
        let q_bit = (raw12 >> 4) & 1;
        let top7 = raw12 >> 5;
        let bottom4 = raw12 & 0xF;
        let n = (top7 << 4) | bottom4;
        if q_bit == 1 {
            Self::BarometricFeet(i32::from(n) * 25 - 1000)
        } else {
            Self::Gillham(n)
        }
    }

    fn to_raw(self) -> u16 {
        match self {
            Self::BarometricFeet(feet) => {
                #[allow(
                    clippy::cast_sign_loss,
                    clippy::cast_possible_truncation,
                    reason = "feet is (n*25 - 1000) for n in 0..=2047, so >= -1000; \
                              (feet+1000)/25 recovers n in 0..=2047, always fits u16"
                )]
                let n = ((feet + 1000) / 25) as u16;
                (n & 0b111_1111_0000) << 1 | 0b1_0000 | (n & 0xF)
            }
            Self::Gillham(n) => (n & 0b111_1111_0000) << 1 | (n & 0xF),
            Self::GnssMeters(m) => m,
            Self::NotAvailable => 0,
        }
    }
}

/// Airborne Position message (type codes 9-18, 20-22; 56 bits).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AirbornePosition {
    /// Type code (`9..=18` for barometric altitude, `20..=22` for GNSS height).
    pub type_code: u8,
    /// Surveillance status (raw 2-bit value; alert/SPI condition flags).
    pub surveillance_status: u8,
    /// Whether the transmitting station uses a single antenna.
    pub single_antenna: bool,
    /// Decoded altitude.
    pub altitude: Altitude,
    /// Whether the position was synchronized to a UTC time reference.
    pub utc_synced: bool,
    /// Which CPR encoding this frame's `lat_cpr`/`lon_cpr` use.
    pub cpr_format: CprFormat,
    /// Raw 17-bit CPR-encoded latitude.
    pub lat_cpr: u32,
    /// Raw 17-bit CPR-encoded longitude.
    pub lon_cpr: u32,
}

impl AirbornePosition {
    pub(crate) fn decode(type_code: u8, r: &mut BitReader<'_>) -> Result<Self, MessageError> {
        let surveillance_status = r.read_u8(2)?;
        let single_antenna = r.read_bool()?;
        let altitude = Altitude::decode(type_code, r.read_u16(12)?);
        let utc_synced = r.read_bool()?;
        let cpr_format = CprFormat::from_raw(r.read_u8(1)?);
        let lat_cpr = r.read_u32(17)?;
        let lon_cpr = r.read_u32(17)?;
        Ok(Self {
            type_code,
            surveillance_status,
            single_antenna,
            altitude,
            utc_synced,
            cpr_format,
            lat_cpr,
            lon_cpr,
        })
    }

    pub(crate) fn encode(&self, w: &mut BitWriter<'_>) -> Result<(), BitError> {
        w.write_bits(u64::from(self.type_code), 5)?;
        w.write_bits(u64::from(self.surveillance_status), 2)?;
        w.write_bool(self.single_antenna)?;
        w.write_bits(u64::from(self.altitude.to_raw()), 12)?;
        w.write_bool(self.utc_synced)?;
        w.write_bits(u64::from(self.cpr_format.to_raw()), 1)?;
        w.write_bits(u64::from(self.lat_cpr), 17)?;
        w.write_bits(u64::from(self.lon_cpr), 17)?;
        Ok(())
    }
}

/// Pairs one even-format and one odd-format [`AirbornePosition`] to resolve
/// an absolute latitude/longitude via CPR global decoding, mirroring the
/// push-returns-`Option` shape of a fragment reassembler: feed messages in
/// as they arrive, get a position back once both parities are present and
/// they resolve to a valid, same-zone pair.
#[derive(Debug, Clone, Copy, Default)]
pub struct PositionPair {
    even: Option<AirbornePosition>,
    odd: Option<AirbornePosition>,
}

impl PositionPair {
    /// Builds an empty pair.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            even: None,
            odd: None,
        }
    }

    /// Feeds one airborne position message, replacing any previous message
    /// of the same CPR format (so the pair always reflects the two most
    /// recently seen even/odd frames).
    ///
    /// Returns `Some((lat, lon))` once both parities are present and they
    /// resolve to a valid position (see [`cpr::global_decode`]); `None`
    /// otherwise. The just-fed message's format is treated as the newer one
    /// for the CPR latitude-zone selection.
    pub fn push(&mut self, msg: AirbornePosition) -> Option<(f64, f64)> {
        let even_is_newer = matches!(msg.cpr_format, CprFormat::Even);
        match msg.cpr_format {
            CprFormat::Even => self.even = Some(msg),
            CprFormat::Odd => self.odd = Some(msg),
        }
        let even = self.even?;
        let odd = self.odd?;
        cpr::global_decode(
            even.lat_cpr,
            even.lon_cpr,
            odd.lat_cpr,
            odd.lon_cpr,
            even_is_newer,
        )
    }
}

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

    fn round_trip(original: AirbornePosition) -> AirbornePosition {
        let mut me = [0u8; 7];
        let mut w = BitWriter::new(&mut me);
        original.encode(&mut w).unwrap();

        let mut r = BitReader::new(&me);
        let type_code = r.read_u8(5).unwrap();
        AirbornePosition::decode(type_code, &mut r).unwrap()
    }

    #[test]
    fn round_trips_barometric_altitude() {
        let original = AirbornePosition {
            type_code: 11,
            surveillance_status: 0,
            single_antenna: false,
            altitude: Altitude::BarometricFeet(39_000),
            utc_synced: false,
            cpr_format: CprFormat::Even,
            lat_cpr: 39848,
            lon_cpr: 83951,
        };
        assert_eq!(round_trip(original), original);
    }

    #[test]
    fn round_trips_gnss_height() {
        let original = AirbornePosition {
            type_code: 20,
            surveillance_status: 1,
            single_antenna: true,
            altitude: Altitude::GnssMeters(1234),
            utc_synced: true,
            cpr_format: CprFormat::Odd,
            lat_cpr: 12345,
            lon_cpr: 54321,
        };
        assert_eq!(round_trip(original), original);
    }

    #[test]
    fn altitude_not_available_round_trips() {
        let original = AirbornePosition {
            type_code: 9,
            surveillance_status: 0,
            single_antenna: false,
            altitude: Altitude::NotAvailable,
            utc_synced: false,
            cpr_format: CprFormat::Even,
            lat_cpr: 0,
            lon_cpr: 0,
        };
        assert_eq!(round_trip(original), original);
    }

    #[test]
    fn decodes_a_real_captured_message_with_expected_altitude() {
        // ME field of real hex frame 8D40058B58C901375147EFD09357: TC=11,
        // ss=0, saf=0, alt_raw=3216 (Q=1 -> 39000 ft), time=0, fmt=even,
        // lat_cpr=39848, lon_cpr=83951 -- independently cross-checked
        // against pyModeS's own CPR test fixtures.
        let me: [u8; 7] = [0x58, 0xC9, 0x01, 0x37, 0x51, 0x47, 0xEF];
        let mut r = BitReader::new(&me);
        let type_code = r.read_u8(5).unwrap();
        assert_eq!(type_code, 11);
        let msg = AirbornePosition::decode(type_code, &mut r).unwrap();
        assert_eq!(msg.altitude, Altitude::BarometricFeet(39_000));
        assert_eq!(msg.cpr_format, CprFormat::Even);
        assert_eq!(msg.lat_cpr, 39848);
        assert_eq!(msg.lon_cpr, 83951);
    }

    #[test]
    fn position_pair_resolves_a_real_captured_pair() {
        let even = AirbornePosition {
            type_code: 11,
            surveillance_status: 0,
            single_antenna: false,
            altitude: Altitude::BarometricFeet(39_000),
            utc_synced: false,
            cpr_format: CprFormat::Even,
            lat_cpr: 39848,
            lon_cpr: 83951,
        };
        let odd = AirbornePosition {
            cpr_format: CprFormat::Odd,
            lat_cpr: 21567,
            lon_cpr: 81965,
            ..even
        };

        let mut pair = PositionPair::new();
        assert_eq!(pair.push(even), None);
        let (lat, lon) = pair.push(odd).unwrap();
        assert!((lat - 49.817_55).abs() < 1e-3);
        assert!((lon - 6.084_42).abs() < 1e-3);
    }
}