aprs/
packet.rs

1//! APRS packet wrapper, data enum, data extensions, and PHG field.
2//!
3//! This module contains the top-level [`AprsData`] enum (one variant per
4//! APRS data type identifier), the [`parse_aprs_data`] dispatcher, and a
5//! handful of shared primitive types: [`AprsDataExtension`], [`Phg`],
6//! [`PositionAmbiguity`], [`ParseContext`], [`AprsTimestamp`],
7//! [`TelemetryDefinition`], and [`TelemetryParameters`].
8
9use core::fmt;
10
11use crate::error::AprsError;
12use crate::item::{
13    AprsItem, AprsObject, AprsQuery, parse_aprs_item, parse_aprs_object, parse_aprs_query,
14};
15use crate::message::{AprsMessage, parse_aprs_message};
16use crate::position::{AprsPosition, parse_aprs_position};
17use crate::status::{AprsStatus, parse_aprs_status};
18use crate::telemetry::{AprsTelemetry, parse_aprs_telemetry};
19use crate::weather::{AprsWeather, parse_aprs_weather_positionless};
20
21// ---------------------------------------------------------------------------
22// ParseContext
23// ---------------------------------------------------------------------------
24
25/// Diagnostic context for a parse failure.
26///
27/// Carries the byte offset within the input where the parser stopped,
28/// alongside an error variant. Most parser entry points return the
29/// bare error type for backwards compatibility; use
30/// [`ParseContext::with_error`] to wrap one when richer diagnostics are
31/// useful (e.g. when reporting failures from a fuzz harness or when
32/// logging untrusted wire data).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ParseContext<E> {
35    /// Underlying error.
36    pub error: E,
37    /// Byte offset within the input where the parser noticed the
38    /// problem (0 if unknown).
39    pub offset: usize,
40    /// Optional human-readable name for the field that failed.
41    pub field: Option<&'static str>,
42}
43
44impl<E> ParseContext<E> {
45    /// Wrap an error with the given byte offset and optional field name.
46    pub const fn with_error(error: E, offset: usize, field: Option<&'static str>) -> Self {
47        Self {
48            error,
49            offset,
50            field,
51        }
52    }
53}
54
55impl<E: fmt::Display> fmt::Display for ParseContext<E> {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        if let Some(field) = self.field {
58            write!(
59                f,
60                "{} (at byte {} in field {field})",
61                self.error, self.offset
62            )
63        } else {
64            write!(f, "{} (at byte {})", self.error, self.offset)
65        }
66    }
67}
68
69// ---------------------------------------------------------------------------
70// PositionAmbiguity
71// ---------------------------------------------------------------------------
72
73/// APRS position ambiguity level (APRS 1.0.1 §8.1.6).
74///
75/// Stations can deliberately reduce their reported precision by
76/// replacing trailing latitude/longitude digits with spaces. Each level
77/// masks one more trailing digit:
78///
79/// | Level | Example               | Effective precision |
80/// |-------|-----------------------|---------------------|
81/// | 0     | `4903.50N`            | 0.01 minute         |
82/// | 1     | `4903.5 N`            | 0.1 minute          |
83/// | 2     | `4903.  N`            | 1 minute            |
84/// | 3     | `490 .  N`            | 10 minutes          |
85/// | 4     | `49  .  N`            | 1 degree            |
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum PositionAmbiguity {
88    /// No ambiguity — full DDMM.HH precision.
89    None,
90    /// Last digit of hundredths-of-a-minute masked (0.1' precision).
91    OneDigit,
92    /// Whole hundredths-of-a-minute masked (1' precision).
93    TwoDigits,
94    /// Tens of minutes masked (10' precision).
95    ThreeDigits,
96    /// Whole minutes masked (1° precision).
97    FourDigits,
98}
99
100// ---------------------------------------------------------------------------
101// AprsTimestamp
102// ---------------------------------------------------------------------------
103
104/// An APRS timestamp as used by object and position-with-timestamp
105/// reports (APRS 1.0.1 §6.1).
106///
107/// Four formats are defined on the wire:
108///
109/// | Suffix | Meaning | Digits |
110/// |--------|---------|--------|
111/// | `z`    | Day / hour / minute, zulu | DDHHMM |
112/// | `/`    | Day / hour / minute, local| DDHHMM |
113/// | `h`    | Hour / minute / second, zulu | HHMMSS |
114/// | (none) | Month / day / hour / minute, zulu (11 chars) | MDHM |
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub enum AprsTimestamp {
117    /// Day / hour / minute in Zulu (UTC) time. Format `DDHHMMz`.
118    DhmZulu {
119        /// Day of month, 1-31.
120        day: u8,
121        /// Hour, 0-23.
122        hour: u8,
123        /// Minute, 0-59.
124        minute: u8,
125    },
126    /// Day / hour / minute in local time. Format `DDHHMM/`.
127    DhmLocal {
128        /// Day of month, 1-31.
129        day: u8,
130        /// Hour, 0-23.
131        hour: u8,
132        /// Minute, 0-59.
133        minute: u8,
134    },
135    /// Hour / minute / second in Zulu (UTC) time. Format `HHMMSSh`.
136    Hms {
137        /// Hour, 0-23.
138        hour: u8,
139        /// Minute, 0-59.
140        minute: u8,
141        /// Second, 0-59.
142        second: u8,
143    },
144    /// Month / day / hour / minute in Zulu (UTC) time (no suffix).
145    /// Format `MMDDHHMM`.
146    Mdhm {
147        /// Month, 1-12.
148        month: u8,
149        /// Day of month, 1-31.
150        day: u8,
151        /// Hour, 0-23.
152        hour: u8,
153        /// Minute, 0-59.
154        minute: u8,
155    },
156}
157
158impl AprsTimestamp {
159    /// Format this timestamp as the exact 7-byte APRS wire representation
160    /// (or 8 bytes for `Mdhm`).
161    #[must_use]
162    pub fn to_wire_string(self) -> String {
163        match self {
164            Self::DhmZulu { day, hour, minute } => {
165                format!("{day:02}{hour:02}{minute:02}z")
166            }
167            Self::DhmLocal { day, hour, minute } => {
168                format!("{day:02}{hour:02}{minute:02}/")
169            }
170            Self::Hms {
171                hour,
172                minute,
173                second,
174            } => {
175                format!("{hour:02}{minute:02}{second:02}h")
176            }
177            Self::Mdhm {
178                month,
179                day,
180                hour,
181                minute,
182            } => {
183                format!("{month:02}{day:02}{hour:02}{minute:02}")
184            }
185        }
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Phg and AprsDataExtension
191// ---------------------------------------------------------------------------
192
193/// Power-Height-Gain-Directivity data (APRS101 Chapter 7).
194///
195/// PHG provides station RF characteristics for range circle calculations.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Phg {
198    /// Effective radiated power in watts.
199    pub power_watts: u32,
200    /// Antenna height above average terrain in feet.
201    pub height_feet: u32,
202    /// Antenna gain in dB.
203    pub gain_db: u8,
204    /// Antenna directivity in degrees (0 = omni).
205    pub directivity_deg: u16,
206}
207
208/// Parsed APRS data extensions from the position comment field.
209///
210/// Position reports can carry structured data in the comment string
211/// after the coordinates. This struct captures the extensions defined
212/// in APRS101 Chapters 6-7.
213#[derive(Debug, Clone, Default, PartialEq)]
214pub struct AprsDataExtension {
215    /// Course in degrees (0-360) and speed in knots, from CSE/SPD.
216    pub course_speed: Option<(u16, u16)>,
217    /// Power, Height, Gain, Directivity (PHG).
218    pub phg: Option<Phg>,
219    /// Altitude in feet (from `/A=NNNNNN` in comment).
220    pub altitude_ft: Option<i32>,
221    /// DAO precision extension (`!DAO!` for extra lat/lon digits).
222    pub dao: Option<(f64, f64)>,
223}
224
225/// Parse data extensions from an APRS position comment string.
226///
227/// Extracts CSE/SPD, PHG, altitude (`/A=NNNNNN`), and DAO (`!DAO!`)
228/// extensions per APRS101 Chapters 6-7.
229///
230/// # Parameters
231///
232/// - `comment`: The comment string after the APRS position fields.
233///
234/// # Returns
235///
236/// An [`AprsDataExtension`] with each field populated if found.
237#[must_use]
238pub fn parse_aprs_extensions(comment: &str) -> AprsDataExtension {
239    let course_speed = parse_cse_spd(comment);
240    let phg = parse_phg(comment);
241    let altitude_ft = parse_altitude(comment);
242    let dao = parse_dao(comment);
243
244    AprsDataExtension {
245        course_speed,
246        phg,
247        altitude_ft,
248        dao,
249    }
250}
251
252/// Parse CSE/SPD from the first 7 characters of the comment.
253///
254/// Format: `DDD/SSS` where DDD is 3-digit course (000-360) and SSS is
255/// 3-digit speed in knots. Per APRS101 Chapter 7, this must be at the
256/// start of the comment and use the exact `NNN/NNN` format.
257fn parse_cse_spd(comment: &str) -> Option<(u16, u16)> {
258    let bytes = comment.as_bytes();
259    let header = bytes.get(..7)?;
260    if header.get(3) != Some(&b'/') {
261        return None;
262    }
263    let dir_bytes = header.get(..3)?;
264    let spd_bytes = header.get(4..7)?;
265    if !dir_bytes.iter().all(u8::is_ascii_digit) || !spd_bytes.iter().all(u8::is_ascii_digit) {
266        return None;
267    }
268    let course: u16 = comment.get(0..3)?.parse().ok()?;
269    let speed: u16 = comment.get(4..7)?.parse().ok()?;
270    if course > 360 {
271        return None;
272    }
273    Some((course, speed))
274}
275
276/// PHG power codes: index^2 watts. Per APRS101 Table on p.28.
277const PHG_POWER: [u32; 10] = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81];
278/// PHG height codes: 10 * 2^N feet.
279const PHG_HEIGHT: [u32; 10] = [10, 20, 40, 80, 160, 320, 640, 1280, 2560, 5120];
280/// PHG directivity codes: 0=omni, then 20, 40, ..., 320 degrees.
281const PHG_DIR: [u16; 10] = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180];
282
283/// Parse a PHG extension from the comment string.
284///
285/// Format: `PHGNhgd` anywhere in the comment, where each of N, h, g, d
286/// is a single ASCII digit (0-9).
287fn parse_phg(comment: &str) -> Option<Phg> {
288    let idx = comment.find("PHG")?;
289    let rest = comment.get(idx + 3..)?;
290    let first_four = rest.get(..4)?.as_bytes();
291    if !first_four.iter().all(u8::is_ascii_digit) {
292        return None;
293    }
294    let p = (*first_four.first()? - b'0') as usize;
295    let h = (*first_four.get(1)? - b'0') as usize;
296    let g = *first_four.get(2)? - b'0';
297    let d = (*first_four.get(3)? - b'0') as usize;
298
299    Some(Phg {
300        power_watts: PHG_POWER.get(p).copied().unwrap_or(0),
301        height_feet: PHG_HEIGHT.get(h).copied().unwrap_or(10),
302        gain_db: g,
303        directivity_deg: PHG_DIR.get(d).copied().unwrap_or(0),
304    })
305}
306
307/// Parse altitude extension from the comment string.
308///
309/// Format: `/A=NNNNNN` anywhere in the comment (6-digit altitude in feet,
310/// can be negative with a leading minus sign in the 6-digit field).
311fn parse_altitude(comment: &str) -> Option<i32> {
312    let idx = comment.find("/A=")?;
313    let rest = comment.get(idx + 3..)?;
314    let val_str = rest.get(..6)?;
315    val_str.parse::<i32>().ok()
316}
317
318/// Parse a DAO extension from the comment string.
319///
320/// Format: `!DAO!` where D and O are extra precision digits for latitude
321/// and longitude respectively. The middle character indicates the encoding:
322/// - Uppercase letter (W): human-readable. D and O are ASCII digits (0-9)
323///   representing hundredths of a minute increment (divide by 60 for degrees).
324/// - Lowercase letter (w): base-91 encoded. D and O are base-91 characters
325///   giving finer precision.
326///
327/// Returns `(lat_correction, lon_correction)` in decimal degrees.
328fn parse_dao(comment: &str) -> Option<(f64, f64)> {
329    // Find `!` followed by 3 chars and another `!`.
330    let bytes = comment.as_bytes();
331    for i in 0..bytes.len().saturating_sub(4) {
332        let window = bytes.get(i..i + 5)?;
333        if window.first() != Some(&b'!') || window.get(4) != Some(&b'!') {
334            continue;
335        }
336        let d = *window.get(1)?;
337        let a = *window.get(2)?;
338        let o = *window.get(3)?;
339
340        if a.is_ascii_uppercase() {
341            // Human-readable: D and O are ASCII digits.
342            if d.is_ascii_digit() && o.is_ascii_digit() {
343                let lat_extra = f64::from(d - b'0') / 600.0;
344                let lon_extra = f64::from(o - b'0') / 600.0;
345                return Some((lat_extra, lon_extra));
346            }
347        } else if a.is_ascii_lowercase() {
348            // Base-91: D and O are base-91 chars (33-123).
349            if (33..=123).contains(&d) && (33..=123).contains(&o) {
350                let lat_extra = f64::from(d - 33) / (91.0 * 60.0);
351                let lon_extra = f64::from(o - 33) / (91.0 * 60.0);
352                return Some((lat_extra, lon_extra));
353            }
354        }
355    }
356    None
357}
358
359// ---------------------------------------------------------------------------
360// MessageKind
361// ---------------------------------------------------------------------------
362
363/// APRS message kind (per APRS 1.0.1 §14 and bulletin sections).
364///
365/// Distinguishes direct station-to-station messages from the various
366/// bulletin forms based on the addressee prefix.
367#[derive(Debug, Clone, PartialEq, Eq, Hash)]
368pub enum MessageKind {
369    /// Direct station-to-station message.
370    Direct,
371    /// Generic bulletin (addressee `BLN0`-`BLN9`).
372    Bulletin {
373        /// Bulletin number (0-9).
374        number: u8,
375    },
376    /// Group bulletin (addressee `BLN<group>` where group is an alpha
377    /// identifier, e.g. `BLNWX` for weather group).
378    GroupBulletin {
379        /// Group identifier (1-5 alphanumeric characters).
380        group: String,
381    },
382    /// National Weather Service bulletin (addressee `NWS-*`, `SKY-*`,
383    /// `CWA-*`, `BOM-*`).
384    NwsBulletin,
385    /// An APRS ack/rej control frame (text begins with `ack` or `rej`
386    /// followed by 1-5 alnum).
387    AckRej,
388}
389
390// ---------------------------------------------------------------------------
391// TelemetryDefinition / TelemetryParameters
392// ---------------------------------------------------------------------------
393
394/// Telemetry parameter definitions sent as APRS messages.
395///
396/// Per APRS 1.0.1 §13.2, a station that emits telemetry frames can send
397/// four additional parameter-definition messages to tell receivers how
398/// to interpret the analog and digital channels. These messages use the
399/// standard APRS message format (`:ADDRESSEE:PARM.…`) with a well-known
400/// keyword prefix.
401#[derive(Debug, Clone, PartialEq)]
402pub enum TelemetryDefinition {
403    /// `PARM.P1,P2,P3,P4,P5,B1,B2,B3,B4,B5,B6,B7,B8` — human-readable
404    /// names for 5 analog + 8 digital channels.
405    Parameters(TelemetryParameters),
406    /// `UNIT.U1,U2,U3,U4,U5,B1,B2,B3,B4,B5,B6,B7,B8` — unit labels.
407    Units(TelemetryParameters),
408    /// `EQNS.a1,b1,c1,a2,b2,c2,...` — calibration coefficients for the
409    /// 5 analog channels (`y = a*x² + b*x + c`, 15 values total).
410    Equations([Option<(f64, f64, f64)>; 5]),
411    /// `BITS.b1b2b3b4b5b6b7b8,project_title` — active-bit mask plus
412    /// project title.
413    Bits {
414        /// 8-character binary string specifying which digital bits are
415        /// "active" (`'1'`) vs "inactive" (`'0'`).
416        bits: String,
417        /// Free-form project title (up to 23 characters).
418        title: String,
419    },
420}
421
422/// 5 analog + 8 digital channel labels used by both `PARM.` and `UNIT.`.
423#[derive(Debug, Clone, PartialEq, Eq, Default)]
424pub struct TelemetryParameters {
425    /// Analog channel labels (5 entries, `None` when omitted).
426    pub analog: [Option<String>; 5],
427    /// Digital channel labels (8 entries, `None` when omitted).
428    pub digital: [Option<String>; 8],
429}
430
431impl TelemetryDefinition {
432    /// Try to parse a telemetry parameter-definition message from the
433    /// text portion of an [`AprsMessage`] (everything after the second
434    /// `:` in the wire frame).
435    ///
436    /// Returns `None` when the text doesn't start with a known keyword.
437    #[must_use]
438    pub fn from_text(text: &str) -> Option<Self> {
439        let trimmed = text.trim_end_matches(['\r', '\n']);
440        if let Some(rest) = trimmed.strip_prefix("PARM.") {
441            return Some(Self::Parameters(parse_telemetry_labels(rest)));
442        }
443        if let Some(rest) = trimmed.strip_prefix("UNIT.") {
444            return Some(Self::Units(parse_telemetry_labels(rest)));
445        }
446        if let Some(rest) = trimmed.strip_prefix("EQNS.") {
447            return Some(Self::Equations(parse_telemetry_equations(rest)));
448        }
449        if let Some(rest) = trimmed.strip_prefix("BITS.") {
450            let (bits, title) = rest.split_once(',').unwrap_or((rest, ""));
451            return Some(Self::Bits {
452                bits: bits.to_owned(),
453                title: title.to_owned(),
454            });
455        }
456        None
457    }
458}
459
460/// Parse a comma-separated label list for `PARM.` / `UNIT.`.
461fn parse_telemetry_labels(s: &str) -> TelemetryParameters {
462    let mut params = TelemetryParameters::default();
463    for (i, field) in s.split(',').enumerate() {
464        let field = field.trim();
465        if i < 5 {
466            if !field.is_empty()
467                && let Some(slot) = params.analog.get_mut(i)
468            {
469                *slot = Some(field.to_owned());
470            }
471        } else if i < 13 {
472            if !field.is_empty()
473                && let Some(slot) = params.digital.get_mut(i - 5)
474            {
475                *slot = Some(field.to_owned());
476            }
477        } else {
478            break;
479        }
480    }
481    params
482}
483
484/// Parse a `EQNS.` coefficient list into 5 `(a, b, c)` tuples.
485fn parse_telemetry_equations(s: &str) -> [Option<(f64, f64, f64)>; 5] {
486    let values: Vec<f64> = s
487        .split(',')
488        .map(str::trim)
489        .map(|v| v.parse::<f64>().unwrap_or(0.0))
490        .collect();
491    let mut out: [Option<(f64, f64, f64)>; 5] = [None, None, None, None, None];
492    for (i, slot) in out.iter_mut().enumerate() {
493        let base = i * 3;
494        if let (Some(&a), Some(&b), Some(&c)) =
495            (values.get(base), values.get(base + 1), values.get(base + 2))
496        {
497            *slot = Some((a, b, c));
498        }
499    }
500    out
501}
502
503// ---------------------------------------------------------------------------
504// AprsData and AprsPacket
505// ---------------------------------------------------------------------------
506
507/// A parsed APRS data frame, covering all major APRS data types.
508///
509/// Per APRS101.PDF, the data type is determined by the first byte of the
510/// AX.25 information field. This enum covers the types most relevant to
511/// the TH-D75's APRS implementation.
512#[derive(Debug, Clone, PartialEq)]
513pub enum AprsData {
514    /// Position report (uncompressed, compressed, or Mic-E).
515    Position(AprsPosition),
516    /// APRS message addressed to a specific station.
517    Message(AprsMessage),
518    /// Status report (free-form text, optionally with Maidenhead grid).
519    Status(AprsStatus),
520    /// Object report (named, with position and timestamp).
521    Object(AprsObject),
522    /// Item report (named, with position, no timestamp).
523    Item(AprsItem),
524    /// Weather report (temperature, wind, rain, pressure, humidity).
525    Weather(AprsWeather),
526    /// Telemetry report (analog values and digital status).
527    Telemetry(AprsTelemetry),
528    /// Query (position, status, message, or direction finding).
529    Query(AprsQuery),
530    /// Third-party traffic — a packet originating elsewhere and
531    /// forwarded by an intermediate station (APRS 1.0.1 §17). The
532    /// `header` carries the original `source>dest,path` and the
533    /// `payload` the original info field.
534    ThirdParty {
535        /// Raw `source>dest,path` header text from the third-party
536        /// wrapper.
537        header: String,
538        /// Original APRS info field as bytes (no further parsing).
539        payload: Vec<u8>,
540    },
541    /// Maidenhead grid locator (data type `[`). The string form is the
542    /// 4-6 character grid square, e.g. `"EM13qc"` or `"FM18lv"`.
543    Grid(String),
544    /// Raw GPS sentence / Ultimeter 2000 data (data type `$`).
545    ///
546    /// APRS 1.0.1 §5.2: anything starting with `$GP`, `$GN`, `$GL`,
547    /// `$GA` (GPS/GNSS NMEA) or other `$`-prefixed instrument data.
548    /// We store the full NMEA sentence minus the leading `$`.
549    RawGps(String),
550    /// Station capabilities report (data type `<`).
551    ///
552    /// APRS 1.0.1 §15.2: comma-separated `TOKEN=value` tuples
553    /// describing what the station supports (`IGATE`, `MSG_CNT`,
554    /// `LOC_CNT`, etc.). We store them as a map.
555    StationCapabilities(Vec<(String, String)>),
556    /// Agrelo `DFjr` (direction-finding) data (data type `%`).
557    ///
558    /// The library doesn't interpret the binary format; we preserve
559    /// the raw payload bytes for callers that do.
560    AgreloDfJr(Vec<u8>),
561    /// User-defined APRS data (data type `{`).
562    ///
563    /// APRS 1.0.1 §18: format is `{<experiment_id><type><data>` where
564    /// the experiment ID is one character. We split it out for
565    /// convenience; callers that understand the experiment can parse
566    /// the rest.
567    UserDefined {
568        /// One-character experiment identifier (immediately follows `{`).
569        experiment: char,
570        /// Everything after the experiment ID.
571        data: Vec<u8>,
572    },
573    /// Invalid/test frame (data type `,`).
574    ///
575    /// Used for test beacons and frames that should be ignored by
576    /// normal receivers. We preserve the payload for diagnostics.
577    InvalidOrTest(Vec<u8>),
578}
579
580/// A parsed APRS packet. Currently just a thin wrapper over [`AprsData`];
581/// future extensions may add envelope-level fields (source callsign,
582/// digipeater path) if the APRS layer ever owns the AX.25 context.
583#[derive(Debug, Clone, PartialEq)]
584pub struct AprsPacket {
585    /// Decoded APRS data payload.
586    pub data: AprsData,
587}
588
589// ---------------------------------------------------------------------------
590// parse_aprs_data dispatcher
591// ---------------------------------------------------------------------------
592
593/// Parse any APRS data frame from an AX.25 information field.
594///
595/// Dispatches based on the data type identifier (first byte) to the
596/// appropriate parser. For Mic-E positions, use
597/// [`crate::mic_e::parse_mice_position`] directly since it also requires
598/// the destination address.
599///
600/// **Prefer [`crate::mic_e::parse_aprs_data_full`] when the AX.25
601/// destination address is available** — it handles all data types
602/// including Mic-E.
603///
604/// # Supported data types
605///
606/// | Byte | Type | Parser |
607/// |------|------|--------|
608/// | `!`, `=` | Position (no timestamp) | [`parse_aprs_position`] |
609/// | `/`, `@` | Position (with timestamp) | [`parse_aprs_position`] |
610/// | `:` | Message | Inline |
611/// | `>` | Status | Inline |
612/// | `;` | Object | Inline |
613/// | `)` | Item | Inline |
614/// | `_` | Positionless weather | Inline |
615/// | `` ` ``, `'` | Mic-E | Returns error (use [`crate::mic_e::parse_mice_position`]) |
616///
617/// # Errors
618///
619/// Returns [`AprsError`] if the format is unrecognized or data is invalid.
620pub fn parse_aprs_data(info: &[u8]) -> Result<AprsData, AprsError> {
621    let first = *info.first().ok_or(AprsError::InvalidFormat)?;
622
623    match first {
624        // Position reports (uncompressed and compressed)
625        b'!' | b'=' | b'/' | b'@' => parse_aprs_position(info).map(AprsData::Position),
626        // Message
627        b':' => parse_aprs_message(info).map(AprsData::Message),
628        // Status
629        b'>' => parse_aprs_status(info).map(AprsData::Status),
630        // Object
631        b';' => parse_aprs_object(info).map(AprsData::Object),
632        // Item
633        b')' => parse_aprs_item(info).map(AprsData::Item),
634        // Positionless weather
635        b'_' => parse_aprs_weather_positionless(info).map(AprsData::Weather),
636        // Telemetry
637        b'T' => parse_aprs_telemetry(info).map(AprsData::Telemetry),
638        // Query
639        b'?' => parse_aprs_query(info).map(AprsData::Query),
640        // Third-party traffic (APRS 1.0.1 §17): `}source>dest,path:payload`
641        b'}' => parse_aprs_third_party(info),
642        // Maidenhead grid locator (APRS 1.0.1 §5.6): `[EM13qc`
643        b'[' => parse_aprs_grid(info),
644        // Raw GPS / NMEA / Ultimeter (APRS 1.0.1 §5.2): `$GPRMC,...`
645        b'$' => parse_aprs_raw_gps(info),
646        // Station capabilities (APRS 1.0.1 §15.2): `<IGATE,MSG_CNT=10,LOC_CNT=0`
647        b'<' => parse_aprs_capabilities(info),
648        // Agrelo DFjr direction-finding data (APRS 1.0.1 §5.5): `%...`
649        b'%' => Ok(AprsData::AgreloDfJr(info.get(1..).unwrap_or(&[]).to_vec())),
650        // User-defined data (APRS 1.0.1 §18): `{<expid><type><data>`
651        b'{' => parse_aprs_user_defined(info),
652        // Invalid/test data (APRS 1.0.1 §5.7): `,...`
653        b',' => Ok(AprsData::InvalidOrTest(
654            info.get(1..).unwrap_or(&[]).to_vec(),
655        )),
656        // Mic-E (` ' 0x1C 0x1D) needs destination address — use parse_mice_position().
657        b'`' | b'\'' | 0x1C | 0x1D => Err(AprsError::MicERequiresDestination),
658        // All other types are unrecognized.
659        _ => Err(AprsError::InvalidFormat),
660    }
661}
662
663/// Parse an APRS third-party traffic frame (data type `}`).
664///
665/// Format: `}source>dest,path:payload`. The outer envelope identifies
666/// the station that forwarded the packet, and the inner fields carry
667/// the original packet exactly as it appeared on its origin transport
668/// (typically APRS-IS).
669fn parse_aprs_third_party(info: &[u8]) -> Result<AprsData, AprsError> {
670    if info.first() != Some(&b'}') {
671        return Err(AprsError::InvalidFormat);
672    }
673    let body = info.get(1..).ok_or(AprsError::InvalidFormat)?;
674    let Some(colon) = body.iter().position(|&b| b == b':') else {
675        return Err(AprsError::InvalidFormat);
676    };
677    let header_bytes = body.get(..colon).ok_or(AprsError::InvalidFormat)?;
678    let payload = body
679        .get(colon + 1..)
680        .ok_or(AprsError::InvalidFormat)?
681        .to_vec();
682    let header = String::from_utf8_lossy(header_bytes).into_owned();
683    Ok(AprsData::ThirdParty { header, payload })
684}
685
686/// Parse an APRS Maidenhead grid locator frame (data type `[`).
687///
688/// Format: `[<4-6 chars>`. The locator is left-padded / right-trimmed.
689fn parse_aprs_grid(info: &[u8]) -> Result<AprsData, AprsError> {
690    if info.first() != Some(&b'[') {
691        return Err(AprsError::InvalidFormat);
692    }
693    let tail = info.get(1..).unwrap_or(&[]);
694    let body = String::from_utf8_lossy(tail)
695        .trim_end_matches(['\r', '\n', ' '])
696        .to_owned();
697    if !(4..=6).contains(&body.len()) {
698        return Err(AprsError::InvalidFormat);
699    }
700    let bytes = body.as_bytes();
701    // First two: letters A-R. Next two: digits 0-9. Last two (optional):
702    // letters a-x.
703    let b0 = *bytes.first().ok_or(AprsError::InvalidFormat)?;
704    let b1 = *bytes.get(1).ok_or(AprsError::InvalidFormat)?;
705    let b2 = *bytes.get(2).ok_or(AprsError::InvalidFormat)?;
706    let b3 = *bytes.get(3).ok_or(AprsError::InvalidFormat)?;
707    if !b0.is_ascii_uppercase()
708        || !b1.is_ascii_uppercase()
709        || !b2.is_ascii_digit()
710        || !b3.is_ascii_digit()
711        || b0 > b'R'
712        || b1 > b'R'
713    {
714        return Err(AprsError::InvalidFormat);
715    }
716    if bytes.len() == 6 {
717        let b4 = *bytes.get(4).ok_or(AprsError::InvalidFormat)?;
718        let b5 = *bytes.get(5).ok_or(AprsError::InvalidFormat)?;
719        if !b4.is_ascii_lowercase() || !b5.is_ascii_lowercase() || b4 > b'x' || b5 > b'x' {
720            return Err(AprsError::InvalidFormat);
721        }
722    }
723    Ok(AprsData::Grid(body))
724}
725
726/// Parse an APRS raw GPS / NMEA frame (data type `$`).
727///
728/// Per APRS 1.0.1 §5.2, the frame is a full NMEA sentence including the
729/// leading `$`. We preserve the body without the leading `$` (so the
730/// caller still sees `GPRMC,...` etc.).
731fn parse_aprs_raw_gps(info: &[u8]) -> Result<AprsData, AprsError> {
732    if info.first() != Some(&b'$') {
733        return Err(AprsError::InvalidFormat);
734    }
735    let tail = info.get(1..).unwrap_or(&[]);
736    let body = std::str::from_utf8(tail)
737        .map_err(|_| AprsError::InvalidFormat)?
738        .trim_end_matches(['\r', '\n'])
739        .to_owned();
740    Ok(AprsData::RawGps(body))
741}
742
743/// Parse an APRS station capabilities frame (data type `<`).
744///
745/// Per APRS 1.0.1 §15.2, the body is a comma-separated list of tokens,
746/// each of the form `KEY` (flag) or `KEY=value`. Whitespace around the
747/// delimiters is not permitted in the spec but we trim it anyway for
748/// tolerance.
749fn parse_aprs_capabilities(info: &[u8]) -> Result<AprsData, AprsError> {
750    if info.first() != Some(&b'<') {
751        return Err(AprsError::InvalidFormat);
752    }
753    let tail = info.get(1..).unwrap_or(&[]);
754    let body = std::str::from_utf8(tail)
755        .map_err(|_| AprsError::InvalidFormat)?
756        .trim_end_matches(['\r', '\n']);
757    let mut tokens: Vec<(String, String)> = Vec::new();
758    for entry in body.split(',') {
759        let entry = entry.trim();
760        if entry.is_empty() {
761            continue;
762        }
763        if let Some((k, v)) = entry.split_once('=') {
764            tokens.push((k.trim().to_owned(), v.trim().to_owned()));
765        } else {
766            tokens.push((entry.to_owned(), String::new()));
767        }
768    }
769    Ok(AprsData::StationCapabilities(tokens))
770}
771
772/// Parse an APRS user-defined frame (data type `{`).
773///
774/// Per APRS 1.0.1 §18, the frame is `{<experiment_id>[<type>]<data>`.
775/// The experiment ID is the first character after `{`.
776fn parse_aprs_user_defined(info: &[u8]) -> Result<AprsData, AprsError> {
777    if info.first() != Some(&b'{') {
778        return Err(AprsError::InvalidFormat);
779    }
780    let experiment = *info.get(1).ok_or(AprsError::InvalidFormat)? as char;
781    let data = info.get(2..).unwrap_or(&[]).to_vec();
782    Ok(AprsData::UserDefined { experiment, data })
783}
784
785// ---------------------------------------------------------------------------
786// Tests
787// ---------------------------------------------------------------------------
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    type TestResult = Result<(), Box<dyn std::error::Error>>;
794
795    // ---- parse_aprs_data dispatch tests ----
796
797    #[test]
798    fn dispatch_position() {
799        let info = b"!4903.50N/07201.75W-Test";
800        assert!(
801            matches!(parse_aprs_data(info), Ok(AprsData::Position(_))),
802            "expected Position variant",
803        );
804    }
805
806    #[test]
807    fn dispatch_message() {
808        let info = b":N0CALL   :Hello{1";
809        assert!(
810            matches!(parse_aprs_data(info), Ok(AprsData::Message(_))),
811            "expected Message variant",
812        );
813    }
814
815    #[test]
816    fn dispatch_status() {
817        let info = b">Status text";
818        assert!(
819            matches!(parse_aprs_data(info), Ok(AprsData::Status(_))),
820            "expected Status variant",
821        );
822    }
823
824    #[test]
825    fn dispatch_object() {
826        let info = b";OBJNAME  *092345z4903.50N/07201.75W-";
827        assert!(
828            matches!(parse_aprs_data(info), Ok(AprsData::Object(_))),
829            "expected Object variant",
830        );
831    }
832
833    #[test]
834    fn dispatch_item() {
835        let info = b")ITEM!4903.50N/07201.75W-";
836        assert!(
837            matches!(parse_aprs_data(info), Ok(AprsData::Item(_))),
838            "expected Item variant",
839        );
840    }
841
842    #[test]
843    fn dispatch_weather() {
844        let info = b"_01011234c180s005t072";
845        assert!(
846            matches!(parse_aprs_data(info), Ok(AprsData::Weather(_))),
847            "expected Weather variant",
848        );
849    }
850
851    #[test]
852    fn dispatch_third_party() -> TestResult {
853        let info = b"}W1AW>APK005,TCPIP:!4903.50N/07201.75W-from IS";
854        let result = parse_aprs_data(info)?;
855        assert!(
856            matches!(
857                &result,
858                AprsData::ThirdParty { header, payload }
859                    if header == "W1AW>APK005,TCPIP"
860                        && payload == b"!4903.50N/07201.75W-from IS"
861            ),
862            "expected ThirdParty, got {result:?}",
863        );
864        Ok(())
865    }
866
867    #[test]
868    fn dispatch_grid_locator() -> TestResult {
869        let info = b"[EM13qc";
870        let result = parse_aprs_data(info)?;
871        assert!(
872            matches!(&result, AprsData::Grid(g) if g == "EM13qc"),
873            "expected Grid, got {result:?}",
874        );
875        Ok(())
876    }
877
878    #[test]
879    fn dispatch_grid_4char() -> TestResult {
880        let info = b"[FM18";
881        let result = parse_aprs_data(info)?;
882        assert!(
883            matches!(&result, AprsData::Grid(g) if g == "FM18"),
884            "expected Grid, got {result:?}",
885        );
886        Ok(())
887    }
888
889    #[test]
890    fn dispatch_grid_invalid_rejected() {
891        assert!(parse_aprs_data(b"[XX12").is_err(), "X > R rejected");
892        assert!(parse_aprs_data(b"[AB").is_err(), "too short rejected");
893    }
894
895    #[test]
896    fn dispatch_raw_gps() -> TestResult {
897        let info = b"$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W";
898        let result = parse_aprs_data(info)?;
899        assert!(
900            matches!(
901                &result,
902                AprsData::RawGps(s) if s.starts_with("GPRMC,") && s.contains("4807.038")
903            ),
904            "expected RawGps, got {result:?}",
905        );
906        Ok(())
907    }
908
909    #[test]
910    fn dispatch_capabilities_parses_tokens() -> TestResult {
911        let info = b"<IGATE,MSG_CNT=10,LOC_CNT=42";
912        let result = parse_aprs_data(info)?;
913        let AprsData::StationCapabilities(tokens) = result else {
914            return Err("expected StationCapabilities".into());
915        };
916        assert_eq!(tokens.len(), 3);
917        assert_eq!(tokens.first(), Some(&("IGATE".to_owned(), String::new())));
918        assert_eq!(
919            tokens.get(1),
920            Some(&("MSG_CNT".to_owned(), "10".to_owned()))
921        );
922        assert_eq!(
923            tokens.get(2),
924            Some(&("LOC_CNT".to_owned(), "42".to_owned()))
925        );
926        Ok(())
927    }
928
929    #[test]
930    fn dispatch_agrelo_df() -> TestResult {
931        let info = b"%\x01\x02\x03\x04";
932        let result = parse_aprs_data(info)?;
933        assert!(
934            matches!(&result, AprsData::AgreloDfJr(bytes) if bytes == &vec![1u8, 2, 3, 4]),
935            "expected AgreloDfJr, got {result:?}",
936        );
937        Ok(())
938    }
939
940    #[test]
941    fn dispatch_user_defined() -> TestResult {
942        let info = b"{Adata payload";
943        let result = parse_aprs_data(info)?;
944        assert!(
945            matches!(
946                &result,
947                AprsData::UserDefined { experiment, data }
948                    if *experiment == 'A' && data == b"data payload"
949            ),
950            "expected UserDefined, got {result:?}",
951        );
952        Ok(())
953    }
954
955    #[test]
956    fn dispatch_invalid_or_test() -> TestResult {
957        let info = b",test frame";
958        let result = parse_aprs_data(info)?;
959        assert!(
960            matches!(&result, AprsData::InvalidOrTest(bytes) if bytes == b"test frame"),
961            "expected InvalidOrTest, got {result:?}",
962        );
963        Ok(())
964    }
965
966    #[test]
967    fn dispatch_mice_returns_error() {
968        // Mic-E needs destination address, can't parse from info alone
969        let info = &[0x60u8, 125, 73, 58, 40, 40, 40, b'>', b'/'];
970        assert!(
971            matches!(
972                parse_aprs_data(info),
973                Err(AprsError::MicERequiresDestination)
974            ),
975            "expected MicERequiresDestination",
976        );
977    }
978
979    // ---- ParseContext tests ----
980
981    #[test]
982    fn parse_context_display_with_field() {
983        let ctx = ParseContext::with_error(AprsError::InvalidFormat, 17, Some("addressee"));
984        let s = format!("{ctx}");
985        assert!(s.contains("byte 17"), "expected byte 17 in {s:?}");
986        assert!(s.contains("addressee"), "expected addressee in {s:?}");
987    }
988
989    #[test]
990    fn parse_context_display_without_field() {
991        let ctx = ParseContext::with_error(AprsError::InvalidCoordinates, 4, None);
992        let s = format!("{ctx}");
993        assert!(s.contains("byte 4"), "expected byte 4 in {s:?}");
994    }
995
996    // ---- Timestamp tests ----
997
998    #[test]
999    fn aprs_timestamp_dhm_zulu_format() {
1000        let ts = AprsTimestamp::DhmZulu {
1001            day: 9,
1002            hour: 23,
1003            minute: 45,
1004        };
1005        assert_eq!(ts.to_wire_string(), "092345z");
1006    }
1007
1008    #[test]
1009    fn aprs_timestamp_hms_format() {
1010        let ts = AprsTimestamp::Hms {
1011            hour: 12,
1012            minute: 0,
1013            second: 1,
1014        };
1015        assert_eq!(ts.to_wire_string(), "120001h");
1016    }
1017
1018    // ---- Extensions parser tests ----
1019
1020    #[test]
1021    fn parse_extensions_cse_spd() {
1022        let ext = parse_aprs_extensions("088/036");
1023        assert_eq!(ext.course_speed, Some((88, 36)));
1024        assert!(ext.phg.is_none());
1025        assert!(ext.altitude_ft.is_none());
1026        assert!(ext.dao.is_none());
1027    }
1028
1029    #[test]
1030    fn parse_extensions_cse_spd_with_comment() {
1031        let ext = parse_aprs_extensions("270/015via Mic-E");
1032        assert_eq!(ext.course_speed, Some((270, 15)));
1033    }
1034
1035    #[test]
1036    fn parse_extensions_cse_spd_invalid_course() {
1037        // Course 999 > 360 is invalid.
1038        let ext = parse_aprs_extensions("999/050");
1039        assert!(ext.course_speed.is_none());
1040    }
1041
1042    #[test]
1043    fn parse_extensions_cse_spd_not_at_start() {
1044        // CSE/SPD must be at position 0.
1045        let ext = parse_aprs_extensions("xx088/036");
1046        assert!(ext.course_speed.is_none());
1047    }
1048
1049    #[test]
1050    fn parse_extensions_phg() -> TestResult {
1051        let ext = parse_aprs_extensions("PHG5132");
1052        let phg = ext.phg.ok_or("phg missing")?;
1053        assert_eq!(phg.power_watts, 25);
1054        assert_eq!(phg.height_feet, 20);
1055        assert_eq!(phg.gain_db, 3);
1056        assert_eq!(phg.directivity_deg, 40);
1057        Ok(())
1058    }
1059
1060    #[test]
1061    fn parse_extensions_phg_omni() -> TestResult {
1062        let ext = parse_aprs_extensions("PHG2360");
1063        let phg = ext.phg.ok_or("phg missing")?;
1064        assert_eq!(phg.power_watts, 4);
1065        assert_eq!(phg.height_feet, 80);
1066        assert_eq!(phg.gain_db, 6);
1067        assert_eq!(phg.directivity_deg, 0);
1068        Ok(())
1069    }
1070
1071    #[test]
1072    fn parse_extensions_phg_in_comment() -> TestResult {
1073        let ext = parse_aprs_extensions("some text PHG5132 more text");
1074        let phg = ext.phg.ok_or("phg missing")?;
1075        assert_eq!(phg.power_watts, 25);
1076        Ok(())
1077    }
1078
1079    #[test]
1080    fn parse_extensions_altitude() {
1081        let ext = parse_aprs_extensions("some comment /A=001234 more");
1082        assert_eq!(ext.altitude_ft, Some(1234));
1083    }
1084
1085    #[test]
1086    fn parse_extensions_altitude_negative() {
1087        let ext = parse_aprs_extensions("/A=-00100");
1088        assert_eq!(ext.altitude_ft, Some(-100));
1089    }
1090
1091    #[test]
1092    fn parse_extensions_altitude_zeros() {
1093        let ext = parse_aprs_extensions("/A=000000");
1094        assert_eq!(ext.altitude_ft, Some(0));
1095    }
1096
1097    #[test]
1098    fn parse_extensions_dao_human_readable() -> TestResult {
1099        // !W5! — W is uppercase, so digits 5 and 5.
1100        let ext = parse_aprs_extensions("text !5W5! more");
1101        let (lat, lon) = ext.dao.ok_or("dao missing")?;
1102        let expected = 5.0 / 600.0;
1103        assert!((lat - expected).abs() < 1e-9, "lat={lat}");
1104        assert!((lon - expected).abs() < 1e-9, "lon={lon}");
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn parse_extensions_dao_base91() -> TestResult {
1110        // !w"! — w is lowercase, " is char 34, so base-91 value = 34-33 = 1
1111        let ext = parse_aprs_extensions("!\"w\"!");
1112        let (lat, lon) = ext.dao.ok_or("dao missing")?;
1113        let expected = 1.0 / (91.0 * 60.0);
1114        assert!((lat - expected).abs() < 1e-9, "lat={lat}");
1115        assert!((lon - expected).abs() < 1e-9, "lon={lon}");
1116        Ok(())
1117    }
1118
1119    #[test]
1120    fn parse_extensions_combined() {
1121        let ext = parse_aprs_extensions("088/036PHG5132/A=001234");
1122        assert_eq!(ext.course_speed, Some((88, 36)));
1123        assert!(ext.phg.is_some());
1124        assert_eq!(ext.altitude_ft, Some(1234));
1125    }
1126
1127    #[test]
1128    fn parse_extensions_empty() {
1129        let ext = parse_aprs_extensions("");
1130        assert!(ext.course_speed.is_none());
1131        assert!(ext.phg.is_none());
1132        assert!(ext.altitude_ft.is_none());
1133        assert!(ext.dao.is_none());
1134    }
1135
1136    // ---- TelemetryDefinition tests ----
1137
1138    #[test]
1139    fn telemetry_definition_parm() -> TestResult {
1140        let def =
1141            TelemetryDefinition::from_text("PARM.Volts,Temp,Humid,Wind,Rain,Door,Light,Heat,,,,,")
1142                .ok_or("missing")?;
1143        let TelemetryDefinition::Parameters(p) = def else {
1144            return Err("expected Parameters".into());
1145        };
1146        assert_eq!(p.analog.first().and_then(Option::as_deref), Some("Volts"));
1147        assert_eq!(p.analog.get(4).and_then(Option::as_deref), Some("Rain"));
1148        assert_eq!(p.digital.first().and_then(Option::as_deref), Some("Door"));
1149        assert_eq!(p.digital.get(2).and_then(Option::as_deref), Some("Heat"));
1150        Ok(())
1151    }
1152
1153    #[test]
1154    fn telemetry_definition_unit() -> TestResult {
1155        let def = TelemetryDefinition::from_text("UNIT.Vdc,C,%,mph,in,open,lit,on,,,,,")
1156            .ok_or("missing")?;
1157        let TelemetryDefinition::Units(p) = def else {
1158            return Err("expected Units".into());
1159        };
1160        assert_eq!(p.analog.get(1).and_then(Option::as_deref), Some("C"));
1161        Ok(())
1162    }
1163
1164    #[test]
1165    fn telemetry_definition_eqns() -> TestResult {
1166        let def = TelemetryDefinition::from_text("EQNS.0,0.1,0,0,0.5,0,0,1,0,0,2,0,0,3,0")
1167            .ok_or("missing")?;
1168        let TelemetryDefinition::Equations(eqs) = def else {
1169            return Err("expected Equations".into());
1170        };
1171        assert_eq!(eqs.first(), Some(&Some((0.0, 0.1, 0.0))));
1172        assert_eq!(eqs.get(1), Some(&Some((0.0, 0.5, 0.0))));
1173        assert_eq!(eqs.get(4), Some(&Some((0.0, 3.0, 0.0))));
1174        Ok(())
1175    }
1176
1177    #[test]
1178    fn telemetry_definition_bits() -> TestResult {
1179        let def = TelemetryDefinition::from_text("BITS.11111111,WX station telemetry")
1180            .ok_or("missing")?;
1181        let TelemetryDefinition::Bits { bits, title } = def else {
1182            return Err("expected Bits".into());
1183        };
1184        assert_eq!(bits, "11111111");
1185        assert_eq!(title, "WX station telemetry");
1186        Ok(())
1187    }
1188
1189    #[test]
1190    fn telemetry_definition_unknown_returns_none() {
1191        assert!(TelemetryDefinition::from_text("hello world").is_none());
1192    }
1193}