Skip to main content

libaprs_engine/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Protocol-first APRS engine core primitives.
4//!
5//! The codec boundary accepts untrusted bytes, preserves them exactly, and
6//! fails closed when the packet shape is malformed.
7
8mod diagnostic;
9mod transport;
10
11#[cfg(feature = "serde")]
12pub mod serde_support;
13
14pub use transport::LineTransport;
15
16/// Conservative upper bound for an APRS packet handled by this skeleton.
17pub const MAX_PACKET_LEN: usize = 512;
18
19/// Default parse options used by [`parse_packet`].
20pub const DEFAULT_PARSE_OPTIONS: ParseOptions = ParseOptions {
21    max_packet_len: MAX_PACKET_LEN,
22};
23
24/// Codec configuration for consumers that need a different envelope limit.
25///
26/// The parser remains fail-closed regardless of this setting. This value only
27/// changes the maximum accepted packet length.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub struct ParseOptions {
30    /// Maximum accepted packet length in bytes.
31    pub max_packet_len: usize,
32}
33
34impl ParseOptions {
35    /// Creates parse options with a custom maximum packet length.
36    #[must_use]
37    pub const fn new(max_packet_len: usize) -> Self {
38        Self { max_packet_len }
39    }
40}
41
42impl Default for ParseOptions {
43    fn default() -> Self {
44        DEFAULT_PARSE_OPTIONS
45    }
46}
47
48/// Original packet bytes retained without normalization or lossy conversion.
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct RawPacket {
51    bytes: Vec<u8>,
52}
53
54impl RawPacket {
55    /// Returns the original packet bytes exactly as supplied to the parser.
56    #[must_use]
57    pub fn as_bytes(&self) -> &[u8] {
58        &self.bytes
59    }
60}
61
62/// Structured packet view backed by the preserved raw packet bytes.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct ParsedPacket {
65    raw: RawPacket,
66    source_end: usize,
67    path_start: usize,
68    path_end: usize,
69    path_components: Vec<(usize, usize)>,
70    payload_start: usize,
71}
72
73impl ParsedPacket {
74    /// Returns the preserved raw packet.
75    #[must_use]
76    pub fn raw(&self) -> &RawPacket {
77        &self.raw
78    }
79
80    /// Returns the source callsign bytes before the `>` separator.
81    #[must_use]
82    pub fn source(&self) -> &[u8] {
83        &self.raw.bytes[..self.source_end]
84    }
85
86    /// Returns the destination/path bytes between `>` and `:`.
87    #[must_use]
88    pub fn path(&self) -> &[u8] {
89        &self.raw.bytes[self.path_start..self.path_end]
90    }
91
92    /// Returns the destination bytes, which are the first path component.
93    #[must_use]
94    pub fn destination(&self) -> &[u8] {
95        let (start, end) = self.path_components[0];
96        &self.raw.bytes[start..end]
97    }
98
99    /// Returns digipeater path component byte views after the destination.
100    #[must_use]
101    pub fn digipeaters(&self) -> Vec<&[u8]> {
102        self.path_components[1..]
103            .iter()
104            .map(|(start, end)| &self.raw.bytes[*start..*end])
105            .collect()
106    }
107
108    /// Returns all path component byte views, including destination first.
109    #[must_use]
110    pub fn path_components(&self) -> Vec<&[u8]> {
111        self.path_components
112            .iter()
113            .map(|(start, end)| &self.raw.bytes[*start..*end])
114            .collect()
115    }
116
117    /// Returns the payload bytes after the `:` separator.
118    #[must_use]
119    pub fn payload(&self) -> &[u8] {
120        &self.raw.bytes[self.payload_start..]
121    }
122
123    /// Returns the APRS data type identifier from the first payload byte.
124    #[must_use]
125    pub fn data_type_identifier(&self) -> DataTypeIdentifier {
126        DataTypeIdentifier::from_byte(self.raw.bytes[self.payload_start])
127    }
128
129    /// Returns payload bytes after the data type identifier.
130    #[must_use]
131    pub fn information(&self) -> &[u8] {
132        &self.raw.bytes[self.payload_start + 1..]
133    }
134
135    /// Returns a semantic view of the APRS information field.
136    #[must_use]
137    pub fn aprs_data(&self) -> AprsData<'_> {
138        parse_aprs_data(
139            self.data_type_identifier(),
140            self.information(),
141            self.destination(),
142        )
143    }
144
145    /// Returns a structured diagnostic summary for observability.
146    #[must_use]
147    pub fn summary(&self) -> PacketSummary<'_> {
148        PacketSummary::from_packet(self)
149    }
150
151    /// Serializes the parsed packet into a compact JSON diagnostic string.
152    #[must_use]
153    pub fn to_json(&self) -> String {
154        diagnostic::packet_to_json(self)
155    }
156}
157
158/// Structured packet diagnostic summary.
159#[derive(Clone, Copy, Debug, PartialEq)]
160pub struct PacketSummary<'a> {
161    /// Source address bytes.
162    pub source: &'a [u8],
163    /// Destination address bytes.
164    pub destination: &'a [u8],
165    /// APRS data type identifier name.
166    pub data_type: &'static str,
167    /// APRS semantic kind name.
168    pub semantic: &'static str,
169    /// Decoded coordinates when the semantic family supports them.
170    pub coordinates: Option<Coordinates>,
171    /// NMEA checksum details when present.
172    pub nmea_checksum: Option<NmeaChecksum>,
173    /// Telemetry sequence number when present and numeric.
174    pub telemetry_sequence: Option<u16>,
175    /// Mic-E speed/course details when present and decodable.
176    pub mic_e_speed_course: Option<MicESpeedCourse>,
177}
178
179impl<'a> PacketSummary<'a> {
180    fn from_packet(packet: &'a ParsedPacket) -> Self {
181        let data = packet.aprs_data();
182        Self {
183            source: packet.source(),
184            destination: packet.destination(),
185            data_type: packet.data_type_identifier().name(),
186            semantic: data.kind_name(),
187            coordinates: summary_coordinates(data),
188            nmea_checksum: summary_nmea_checksum(data),
189            telemetry_sequence: summary_telemetry_sequence(data),
190            mic_e_speed_course: summary_mic_e_speed_course(data),
191        }
192    }
193}
194
195/// Parser and policy orchestration engine.
196#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct Engine {
198    policy: Policy,
199    counters: Counters,
200}
201
202impl Engine {
203    /// Creates an engine with the provided policy.
204    #[must_use]
205    pub fn new(policy: Policy) -> Self {
206        Self {
207            policy,
208            counters: Counters::default(),
209        }
210    }
211
212    /// Processes one packet through codec, semantics, and policy.
213    pub fn process(&mut self, input: &[u8]) -> EngineResult {
214        match parse_packet(input) {
215            Ok(packet) => {
216                let semantic = packet.aprs_data();
217                match self.policy.evaluate(&packet, &semantic) {
218                    PolicyDecision::Accept => {
219                        self.counters.accepted = self.counters.accepted.saturating_add(1);
220                        EngineResult::Accepted { packet }
221                    }
222                    PolicyDecision::Reject(reason) => {
223                        self.counters.rejected = self.counters.rejected.saturating_add(1);
224                        EngineResult::Rejected { packet, reason }
225                    }
226                }
227            }
228            Err(error) => {
229                self.counters.malformed = self.counters.malformed.saturating_add(1);
230                EngineResult::ParseError(error)
231            }
232        }
233    }
234
235    /// Returns engine counters.
236    #[must_use]
237    pub fn counters(&self) -> Counters {
238        self.counters
239    }
240}
241
242impl Default for Engine {
243    fn default() -> Self {
244        Self::new(Policy::default())
245    }
246}
247
248/// Engine processing result.
249#[derive(Clone, Debug, PartialEq)]
250pub enum EngineResult {
251    /// Packet parsed and passed policy.
252    Accepted {
253        /// Parsed packet.
254        packet: ParsedPacket,
255    },
256    /// Packet parsed but failed policy.
257    Rejected {
258        /// Parsed packet.
259        packet: ParsedPacket,
260        /// Rejection reason.
261        reason: PolicyRejection,
262    },
263    /// Packet failed the codec boundary.
264    ParseError(ParseError),
265}
266
267/// Runtime counters.
268#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
269pub struct Counters {
270    /// Accepted packet count.
271    pub accepted: u64,
272    /// Policy-rejected packet count.
273    pub rejected: u64,
274    /// Codec-malformed packet count.
275    pub malformed: u64,
276}
277
278/// Policy options applied after parsing.
279#[derive(Clone, Debug, Eq, PartialEq)]
280pub struct Policy {
281    /// Allow semantic packets represented as unsupported.
282    pub allow_unsupported: bool,
283    /// Allow semantic packets represented as malformed.
284    pub allow_malformed_semantics: bool,
285    /// Maximum allowed path component count including destination.
286    pub max_path_components: usize,
287}
288
289impl Policy {
290    /// Strict policy: reject malformed semantics, unsupported formats, and long paths.
291    #[must_use]
292    pub fn strict() -> Self {
293        Self::default()
294    }
295
296    /// Permissive policy: accept unsupported and malformed semantic packets.
297    #[must_use]
298    pub fn permissive() -> Self {
299        Self {
300            allow_unsupported: true,
301            allow_malformed_semantics: true,
302            max_path_components: 9,
303        }
304    }
305
306    /// Evaluates a parsed packet and semantic view.
307    #[must_use]
308    pub fn evaluate(&self, packet: &ParsedPacket, semantic: &AprsData<'_>) -> PolicyDecision {
309        if packet.path_components.len() > self.max_path_components {
310            return PolicyDecision::Reject(PolicyRejection::PathTooLong);
311        }
312
313        match semantic {
314            AprsData::Malformed { .. } if !self.allow_malformed_semantics => {
315                PolicyDecision::Reject(PolicyRejection::MalformedSemantics)
316            }
317            AprsData::Unsupported { .. } if !self.allow_unsupported => {
318                PolicyDecision::Reject(PolicyRejection::UnsupportedSemantics)
319            }
320            _ => PolicyDecision::Accept,
321        }
322    }
323}
324
325impl Default for Policy {
326    fn default() -> Self {
327        Self {
328            allow_unsupported: false,
329            allow_malformed_semantics: false,
330            max_path_components: 9,
331        }
332    }
333}
334
335/// Policy decision.
336#[derive(Clone, Copy, Debug, Eq, PartialEq)]
337pub enum PolicyDecision {
338    /// Packet is accepted.
339    Accept,
340    /// Packet is rejected with a reason.
341    Reject(PolicyRejection),
342}
343
344/// Policy rejection reason.
345#[derive(Clone, Copy, Debug, Eq, PartialEq)]
346pub enum PolicyRejection {
347    /// Path contains too many components.
348    PathTooLong,
349    /// Semantic payload is malformed.
350    MalformedSemantics,
351    /// Semantic payload is unsupported.
352    UnsupportedSemantics,
353}
354
355impl PolicyRejection {
356    /// Returns a stable policy rejection code for logs and external systems.
357    #[must_use]
358    pub fn code(self) -> &'static str {
359        match self {
360            Self::PathTooLong => "policy.path_too_long",
361            Self::MalformedSemantics => "policy.malformed_semantics",
362            Self::UnsupportedSemantics => "policy.unsupported_semantics",
363        }
364    }
365}
366
367/// Semantic APRS information-field data.
368#[derive(Clone, Copy, Debug, Eq, PartialEq)]
369pub enum AprsData<'a> {
370    /// Status report.
371    Status {
372        /// Status text bytes.
373        text: &'a [u8],
374    },
375    /// Uncompressed position report.
376    Position(Position<'a>),
377    /// Timestamped uncompressed position report.
378    TimestampedPosition(TimestampedPosition<'a>),
379    /// Compressed position report.
380    CompressedPosition(CompressedPosition<'a>),
381    /// Message, bulletin, or announcement.
382    Message(Message<'a>),
383    /// Object report.
384    Object(Object<'a>),
385    /// Item report.
386    Item(Item<'a>),
387    /// Weather report without position.
388    Weather(Weather<'a>),
389    /// Telemetry report.
390    Telemetry(Telemetry<'a>),
391    /// Telemetry metadata carried as an APRS message.
392    TelemetryMetadata(TelemetryMetadata<'a>),
393    /// Query packet.
394    Query(Query<'a>),
395    /// Station capabilities packet.
396    Capability(Capability<'a>),
397    /// NMEA sentence packet.
398    Nmea(Nmea<'a>),
399    /// Mic-E packet.
400    MicE(MicE<'a>),
401    /// Maidenhead locator packet.
402    Maidenhead(Maidenhead<'a>),
403    /// User-defined data packet.
404    UserDefined(UserDefined<'a>),
405    /// Third-party traffic packet.
406    ThirdParty(ThirdParty<'a>),
407    /// Data format is validly framed but not implemented yet.
408    Unsupported {
409        /// Original data type identifier byte.
410        identifier: u8,
411        /// Remaining information-field bytes.
412        information: &'a [u8],
413    },
414    /// Data type is known, but its information bytes are malformed.
415    Malformed {
416        /// Original data type identifier byte.
417        identifier: u8,
418        /// Remaining information-field bytes.
419        information: &'a [u8],
420    },
421}
422
423impl AprsData<'_> {
424    /// Returns a stable semantic kind name for diagnostics.
425    #[must_use]
426    pub fn kind_name(&self) -> &'static str {
427        match self {
428            Self::Status { .. } => "status",
429            Self::Position(_) => "position",
430            Self::TimestampedPosition(_) => "timestamped_position",
431            Self::CompressedPosition(_) => "compressed_position",
432            Self::Message(_) => "message",
433            Self::Object(_) => "object",
434            Self::Item(_) => "item",
435            Self::Weather(_) => "weather",
436            Self::Telemetry(_) => "telemetry",
437            Self::TelemetryMetadata(_) => "telemetry_metadata",
438            Self::Query(_) => "query",
439            Self::Capability(_) => "capability",
440            Self::Nmea(_) => "nmea",
441            Self::MicE(_) => "mic_e",
442            Self::Maidenhead(_) => "maidenhead",
443            Self::UserDefined(_) => "user_defined",
444            Self::ThirdParty(_) => "third_party",
445            Self::Unsupported { .. } => "unsupported",
446            Self::Malformed { .. } => "malformed",
447        }
448    }
449}
450
451fn summary_coordinates(data: AprsData<'_>) -> Option<Coordinates> {
452    match data {
453        AprsData::Position(position) => position.coordinates(),
454        AprsData::TimestampedPosition(position) => position.position.coordinates(),
455        AprsData::CompressedPosition(position) => position.coordinates(),
456        AprsData::MicE(mic_e) => mic_e.coordinates(),
457        _ => None,
458    }
459}
460
461fn summary_nmea_checksum(data: AprsData<'_>) -> Option<NmeaChecksum> {
462    match data {
463        AprsData::Nmea(nmea) => nmea.checksum(),
464        _ => None,
465    }
466}
467
468fn summary_telemetry_sequence(data: AprsData<'_>) -> Option<u16> {
469    match data {
470        AprsData::Telemetry(telemetry) => telemetry.sequence_number(),
471        _ => None,
472    }
473}
474
475fn summary_mic_e_speed_course(data: AprsData<'_>) -> Option<MicESpeedCourse> {
476    match data {
477        AprsData::MicE(mic_e) => mic_e.speed_course(),
478        _ => None,
479    }
480}
481
482/// Uncompressed APRS position fields.
483#[derive(Clone, Copy, Debug, Eq, PartialEq)]
484pub struct Position<'a> {
485    /// Whether the data type identifier indicates APRS messaging support.
486    pub messaging: bool,
487    /// Latitude bytes in APRS `DDMM.mmN/S` form.
488    pub latitude: &'a [u8],
489    /// Symbol table identifier byte.
490    pub symbol_table: u8,
491    /// Longitude bytes in APRS `DDDMM.mmE/W` form.
492    pub longitude: &'a [u8],
493    /// Symbol code byte.
494    pub symbol_code: u8,
495    /// Optional comment bytes after the symbol code.
496    pub comment: &'a [u8],
497}
498
499impl Position<'_> {
500    /// Returns decimal latitude and longitude if both coordinate fields decode.
501    #[must_use]
502    pub fn coordinates(&self) -> Option<Coordinates> {
503        Some(Coordinates {
504            latitude: decode_latitude(self.latitude)?,
505            longitude: decode_longitude(self.longitude)?,
506        })
507    }
508}
509
510/// Decimal coordinates in signed degrees.
511#[derive(Clone, Copy, Debug, PartialEq)]
512pub struct Coordinates {
513    /// Latitude in signed decimal degrees.
514    pub latitude: f64,
515    /// Longitude in signed decimal degrees.
516    pub longitude: f64,
517}
518
519/// Timestamped uncompressed APRS position fields.
520#[derive(Clone, Copy, Debug, Eq, PartialEq)]
521pub struct TimestampedPosition<'a> {
522    /// Whether the data type identifier indicates APRS messaging support.
523    pub messaging: bool,
524    /// Seven-byte timestamp field.
525    pub timestamp: &'a [u8],
526    /// Position fields after the timestamp.
527    pub position: Position<'a>,
528}
529
530/// Compressed APRS position fields.
531#[derive(Clone, Copy, Debug, Eq, PartialEq)]
532pub struct CompressedPosition<'a> {
533    /// Whether the data type identifier indicates APRS messaging support.
534    pub messaging: bool,
535    /// Symbol table identifier byte.
536    pub symbol_table: u8,
537    /// Four-byte compressed latitude.
538    pub compressed_latitude: &'a [u8],
539    /// Four-byte compressed longitude.
540    pub compressed_longitude: &'a [u8],
541    /// Symbol code byte.
542    pub symbol_code: u8,
543    /// Two-byte compressed extension field.
544    pub extension: &'a [u8],
545    /// Compression type byte.
546    pub compression_type: u8,
547    /// Optional comment bytes after the compression type byte.
548    pub comment: &'a [u8],
549}
550
551impl CompressedPosition<'_> {
552    /// Returns decoded compressed-position coordinates.
553    #[must_use]
554    pub fn coordinates(&self) -> Option<Coordinates> {
555        let y = decode_base91(self.compressed_latitude)?;
556        let x = decode_base91(self.compressed_longitude)?;
557
558        Some(Coordinates {
559            latitude: 90.0 - (y as f64 / 380_926.0),
560            longitude: -180.0 + (x as f64 / 190_463.0),
561        })
562    }
563}
564
565/// APRS message fields.
566#[derive(Clone, Copy, Debug, Eq, PartialEq)]
567pub struct Message<'a> {
568    /// Nine-byte addressee field.
569    pub addressee: &'a [u8],
570    /// Classified message subtype.
571    pub kind: MessageKind,
572    /// Message text bytes before an optional message ID.
573    pub text: &'a [u8],
574    /// Optional message ID bytes after `{`.
575    pub id: Option<&'a [u8]>,
576}
577
578/// APRS message subtype.
579#[derive(Clone, Copy, Debug, Eq, PartialEq)]
580pub enum MessageKind {
581    /// Regular addressed message.
582    Message,
583    /// Message acknowledgement.
584    Ack,
585    /// Message rejection.
586    Reject,
587    /// Bulletin.
588    Bulletin,
589    /// Announcement.
590    Announcement,
591}
592
593/// APRS object report fields.
594#[derive(Clone, Copy, Debug, Eq, PartialEq)]
595pub struct Object<'a> {
596    /// Nine-byte object name.
597    pub name: &'a [u8],
598    /// Whether the object is live (`*`) rather than killed (`_`).
599    pub live: bool,
600    /// Seven-byte object timestamp.
601    pub timestamp: &'a [u8],
602    /// Remaining object body bytes.
603    pub body: &'a [u8],
604}
605
606/// APRS item report fields.
607#[derive(Clone, Copy, Debug, Eq, PartialEq)]
608pub struct Item<'a> {
609    /// Item name bytes.
610    pub name: &'a [u8],
611    /// Whether the item is live (`!`) rather than killed (`_`).
612    pub live: bool,
613    /// Remaining item body bytes.
614    pub body: &'a [u8],
615}
616
617/// APRS weather report bytes.
618#[derive(Clone, Copy, Debug, Eq, PartialEq)]
619pub struct Weather<'a> {
620    /// Weather report bytes after the `_` data type identifier.
621    pub report: &'a [u8],
622}
623
624impl Weather<'_> {
625    /// Extracts common numeric weather fields when present.
626    #[must_use]
627    pub fn fields(&self) -> WeatherFields<'_> {
628        WeatherFields {
629            timestamp: self
630                .report
631                .get(..6)
632                .filter(|value| value.iter().all(u8::is_ascii_digit)),
633            wind_direction_degrees: parse_tagged_u16(self.report, b'c', 3),
634            wind_speed_mph: parse_tagged_u16(self.report, b's', 3),
635            wind_gust_mph: parse_tagged_u16(self.report, b'g', 3),
636            temperature_fahrenheit: parse_tagged_i16(self.report, b't', 3),
637            rain_last_hour_hundredths_inch: parse_tagged_u16(self.report, b'r', 3),
638            rain_last_24_hours_hundredths_inch: parse_tagged_u16(self.report, b'p', 3),
639            rain_since_midnight_hundredths_inch: parse_tagged_u16(self.report, b'P', 3),
640            humidity_percent: parse_tagged_u16(self.report, b'h', 2).map(|value| {
641                if value == 0 {
642                    100
643                } else {
644                    value
645                }
646            }),
647            pressure_tenths_hpa: parse_tagged_u16(self.report, b'b', 5),
648        }
649    }
650}
651
652/// Extracted numeric weather fields.
653#[derive(Clone, Copy, Debug, Eq, PartialEq)]
654pub struct WeatherFields<'a> {
655    /// Optional six-byte timestamp prefix.
656    pub timestamp: Option<&'a [u8]>,
657    /// Wind direction in degrees.
658    pub wind_direction_degrees: Option<u16>,
659    /// Sustained wind speed in miles per hour.
660    pub wind_speed_mph: Option<u16>,
661    /// Wind gust speed in miles per hour.
662    pub wind_gust_mph: Option<u16>,
663    /// Temperature in degrees Fahrenheit.
664    pub temperature_fahrenheit: Option<i16>,
665    /// Rain in the last hour, in hundredths of an inch.
666    pub rain_last_hour_hundredths_inch: Option<u16>,
667    /// Rain in the last 24 hours, in hundredths of an inch.
668    pub rain_last_24_hours_hundredths_inch: Option<u16>,
669    /// Rain since midnight, in hundredths of an inch.
670    pub rain_since_midnight_hundredths_inch: Option<u16>,
671    /// Relative humidity percent.
672    pub humidity_percent: Option<u16>,
673    /// Barometric pressure in tenths of hPa.
674    pub pressure_tenths_hpa: Option<u16>,
675}
676
677/// APRS telemetry report fields.
678#[derive(Clone, Copy, Debug, Eq, PartialEq)]
679pub struct Telemetry<'a> {
680    /// Telemetry sequence bytes.
681    pub sequence: &'a [u8],
682    /// Five analog telemetry value fields.
683    pub analog: [&'a [u8]; 5],
684    /// Optional eight-bit digital telemetry field.
685    pub digital: Option<&'a [u8]>,
686}
687
688impl Telemetry<'_> {
689    /// Returns the numeric telemetry sequence number.
690    #[must_use]
691    pub fn sequence_number(&self) -> Option<u16> {
692        parse_u16(self.sequence)
693    }
694
695    /// Returns the five numeric analog telemetry values.
696    #[must_use]
697    pub fn analog_values(&self) -> Option<[u16; 5]> {
698        Some([
699            parse_u16(self.analog[0])?,
700            parse_u16(self.analog[1])?,
701            parse_u16(self.analog[2])?,
702            parse_u16(self.analog[3])?,
703            parse_u16(self.analog[4])?,
704        ])
705    }
706
707    /// Returns eight digital telemetry bits.
708    #[must_use]
709    pub fn digital_bits(&self) -> Option<[bool; 8]> {
710        let digital = self.digital?;
711        if digital.len() != 8 {
712            return None;
713        }
714
715        let mut bits = [false; 8];
716        for (index, byte) in digital.iter().enumerate() {
717            bits[index] = match byte {
718                b'0' => false,
719                b'1' => true,
720                _ => return None,
721            };
722        }
723
724        Some(bits)
725    }
726}
727
728/// APRS telemetry metadata packet carried in an APRS message.
729#[derive(Clone, Copy, Debug, Eq, PartialEq)]
730pub struct TelemetryMetadata<'a> {
731    /// Nine-byte telemetry metadata addressee field.
732    pub addressee: &'a [u8],
733    /// Classified telemetry metadata subtype.
734    pub kind: TelemetryMetadataKind,
735    /// Metadata body bytes after the message separator.
736    pub body: &'a [u8],
737}
738
739impl<'a> TelemetryMetadata<'a> {
740    /// Returns comma-separated metadata fields without lossy conversion.
741    #[must_use]
742    pub fn fields(&self) -> Vec<&'a [u8]> {
743        self.body.split(|byte| *byte == b',').collect()
744    }
745}
746
747/// APRS telemetry metadata subtype.
748#[derive(Clone, Copy, Debug, Eq, PartialEq)]
749pub enum TelemetryMetadataKind {
750    /// `PARM.` parameter-name metadata.
751    ParameterNames,
752    /// `UNIT.` unit metadata.
753    Units,
754    /// `EQNS.` calibration/equation metadata.
755    Equations,
756    /// `BITS.` bit-sense/project metadata.
757    BitSense,
758}
759
760/// APRS query packet bytes.
761#[derive(Clone, Copy, Debug, Eq, PartialEq)]
762pub struct Query<'a> {
763    /// Query bytes after the `?` data type identifier.
764    pub query: &'a [u8],
765}
766
767/// APRS station capabilities packet bytes.
768#[derive(Clone, Copy, Debug, Eq, PartialEq)]
769pub struct Capability<'a> {
770    /// Capability body bytes after the `<` data type identifier.
771    pub body: &'a [u8],
772}
773
774/// APRS NMEA packet bytes.
775#[derive(Clone, Copy, Debug, Eq, PartialEq)]
776pub struct Nmea<'a> {
777    /// NMEA sentence bytes after the `$` data type identifier.
778    pub sentence: &'a [u8],
779}
780
781impl Nmea<'_> {
782    /// Returns checksum validation details when the sentence has `*HH` syntax.
783    #[must_use]
784    pub fn checksum(&self) -> Option<NmeaChecksum> {
785        let separator = self.sentence.iter().rposition(|byte| *byte == b'*')?;
786        let checksum = self.sentence.get(separator + 1..separator + 3)?;
787        if checksum.len() != 2 || self.sentence.get(separator + 3).is_some() {
788            return None;
789        }
790
791        let expected = parse_hex_byte(checksum)?;
792        let calculated = self.sentence[..separator]
793            .iter()
794            .fold(0u8, |accumulator, byte| accumulator ^ byte);
795
796        Some(NmeaChecksum {
797            expected,
798            calculated,
799            valid: expected == calculated,
800        })
801    }
802}
803
804/// NMEA checksum validation details.
805#[derive(Clone, Copy, Debug, Eq, PartialEq)]
806pub struct NmeaChecksum {
807    /// Checksum value supplied by the packet.
808    pub expected: u8,
809    /// Checksum calculated over bytes before `*`.
810    pub calculated: u8,
811    /// Whether supplied and calculated checksums match.
812    pub valid: bool,
813}
814
815/// APRS Mic-E packet bytes.
816#[derive(Clone, Copy, Debug, Eq, PartialEq)]
817pub struct MicE<'a> {
818    /// Original Mic-E data type identifier byte.
819    pub identifier: u8,
820    /// Destination address bytes that carry Mic-E latitude/status data.
821    pub destination: &'a [u8],
822    /// Mic-E body bytes.
823    pub body: &'a [u8],
824    /// Destination-derived Mic-E status bits when the destination permits decoding.
825    pub status: Option<MicEStatus>,
826    /// Destination-derived six latitude digit nibbles when decodable.
827    pub latitude_digits: Option<[u8; 6]>,
828}
829
830impl MicE<'_> {
831    /// Returns decoded Mic-E coordinates when destination and body bytes permit it.
832    #[must_use]
833    pub fn coordinates(&self) -> Option<Coordinates> {
834        Some(Coordinates {
835            latitude: decode_mic_e_latitude(self.destination)?,
836            longitude: decode_mic_e_longitude(self.destination, self.body)?,
837        })
838    }
839
840    /// Returns decoded Mic-E speed and course when body bytes permit it.
841    #[must_use]
842    pub fn speed_course(&self) -> Option<MicESpeedCourse> {
843        decode_mic_e_speed_course(self.body)
844    }
845}
846
847/// Mic-E destination-derived status bits.
848#[derive(Clone, Copy, Debug, Eq, PartialEq)]
849pub enum MicEStatus {
850    /// Standard/custom status bit tuple from the first three destination bytes.
851    Custom([bool; 3]),
852}
853
854/// Mic-E speed/course extension.
855#[derive(Clone, Copy, Debug, Eq, PartialEq)]
856pub struct MicESpeedCourse {
857    /// Speed in knots.
858    pub speed_knots: u16,
859    /// Course in degrees as encoded by Mic-E.
860    pub course_degrees: u16,
861}
862
863/// APRS Maidenhead locator packet bytes.
864#[derive(Clone, Copy, Debug, Eq, PartialEq)]
865pub struct Maidenhead<'a> {
866    /// Six-byte Maidenhead locator.
867    pub locator: &'a [u8],
868    /// Remaining comment bytes.
869    pub comment: &'a [u8],
870}
871
872/// APRS user-defined packet fields.
873#[derive(Clone, Copy, Debug, Eq, PartialEq)]
874pub struct UserDefined<'a> {
875    /// One-byte user ID.
876    pub user_id: u8,
877    /// One-byte user-defined packet type.
878    pub packet_type: u8,
879    /// User-defined body bytes.
880    pub body: &'a [u8],
881}
882
883/// APRS third-party traffic packet bytes.
884#[derive(Clone, Copy, Debug, Eq, PartialEq)]
885pub struct ThirdParty<'a> {
886    /// Encapsulated third-party traffic bytes.
887    pub body: &'a [u8],
888}
889
890impl ThirdParty<'_> {
891    /// Explicitly parses the encapsulated packet through the same codec boundary.
892    pub fn nested_packet(&self) -> Result<ParsedPacket, ParseError> {
893        parse_packet(self.body)
894    }
895}
896
897/// APRS data type identifier from the first payload byte.
898#[derive(Clone, Copy, Debug, Eq, PartialEq)]
899pub enum DataTypeIdentifier {
900    /// `!`: position without timestamp, no APRS messaging.
901    PositionNoTimestamp,
902    /// `=`: position without timestamp, APRS messaging supported.
903    PositionNoTimestampMessaging,
904    /// `/`: position with timestamp, no APRS messaging.
905    PositionWithTimestamp,
906    /// `@`: position with timestamp, APRS messaging supported.
907    PositionWithTimestampMessaging,
908    /// `>`: status.
909    Status,
910    /// `?`: query.
911    Query,
912    /// `<`: station capabilities.
913    Capability,
914    /// `:`: message, bulletin, or announcement.
915    Message,
916    /// `;`: object.
917    Object,
918    /// `)`: item.
919    Item,
920    /// `_`: weather report without position.
921    Weather,
922    /// `T`: telemetry.
923    Telemetry,
924    /// `$`: NMEA sentence.
925    Nmea,
926    /// ``` ` ```: current Mic-E data.
927    MicECurrent,
928    /// `'`: old Mic-E data.
929    MicEOld,
930    /// `[`: Maidenhead locator.
931    Maidenhead,
932    /// `{`: user-defined data.
933    UserDefined,
934    /// `}`: third-party traffic.
935    ThirdParty,
936    /// Any currently unclassified identifier byte.
937    Unknown(u8),
938}
939
940impl DataTypeIdentifier {
941    fn from_byte(byte: u8) -> Self {
942        match byte {
943            b'!' => Self::PositionNoTimestamp,
944            b'=' => Self::PositionNoTimestampMessaging,
945            b'/' => Self::PositionWithTimestamp,
946            b'@' => Self::PositionWithTimestampMessaging,
947            b'>' => Self::Status,
948            b'?' => Self::Query,
949            b'<' => Self::Capability,
950            b':' => Self::Message,
951            b';' => Self::Object,
952            b')' => Self::Item,
953            b'_' => Self::Weather,
954            b'T' => Self::Telemetry,
955            b'$' => Self::Nmea,
956            b'`' => Self::MicECurrent,
957            b'\'' => Self::MicEOld,
958            b'[' => Self::Maidenhead,
959            b'{' => Self::UserDefined,
960            b'}' => Self::ThirdParty,
961            other => Self::Unknown(other),
962        }
963    }
964
965    fn as_byte(self) -> u8 {
966        match self {
967            Self::PositionNoTimestamp => b'!',
968            Self::PositionNoTimestampMessaging => b'=',
969            Self::PositionWithTimestamp => b'/',
970            Self::PositionWithTimestampMessaging => b'@',
971            Self::Status => b'>',
972            Self::Query => b'?',
973            Self::Capability => b'<',
974            Self::Message => b':',
975            Self::Object => b';',
976            Self::Item => b')',
977            Self::Weather => b'_',
978            Self::Telemetry => b'T',
979            Self::Nmea => b'$',
980            Self::MicECurrent => b'`',
981            Self::MicEOld => b'\'',
982            Self::Maidenhead => b'[',
983            Self::UserDefined => b'{',
984            Self::ThirdParty => b'}',
985            Self::Unknown(value) => value,
986        }
987    }
988
989    /// Returns a stable data type identifier name for diagnostics.
990    #[must_use]
991    pub fn name(self) -> &'static str {
992        match self {
993            Self::PositionNoTimestamp => "position_no_timestamp",
994            Self::PositionNoTimestampMessaging => "position_no_timestamp_messaging",
995            Self::PositionWithTimestamp => "position_with_timestamp",
996            Self::PositionWithTimestampMessaging => "position_with_timestamp_messaging",
997            Self::Status => "status",
998            Self::Query => "query",
999            Self::Capability => "capability",
1000            Self::Message => "message",
1001            Self::Object => "object",
1002            Self::Item => "item",
1003            Self::Weather => "weather",
1004            Self::Telemetry => "telemetry",
1005            Self::Nmea => "nmea",
1006            Self::MicECurrent => "mic_e_current",
1007            Self::MicEOld => "mic_e_old",
1008            Self::Maidenhead => "maidenhead",
1009            Self::UserDefined => "user_defined",
1010            Self::ThirdParty => "third_party",
1011            Self::Unknown(_) => "unknown",
1012        }
1013    }
1014}
1015
1016fn parse_aprs_data<'a>(
1017    identifier: DataTypeIdentifier,
1018    information: &'a [u8],
1019    destination: &'a [u8],
1020) -> AprsData<'a> {
1021    match identifier {
1022        DataTypeIdentifier::Status => AprsData::Status { text: information },
1023        DataTypeIdentifier::PositionNoTimestamp => parse_position(false, b'!', information),
1024        DataTypeIdentifier::PositionNoTimestampMessaging => parse_position(true, b'=', information),
1025        DataTypeIdentifier::PositionWithTimestamp => {
1026            parse_timestamped_position(false, b'/', information)
1027        }
1028        DataTypeIdentifier::PositionWithTimestampMessaging => {
1029            parse_timestamped_position(true, b'@', information)
1030        }
1031        DataTypeIdentifier::Message => parse_message(information),
1032        DataTypeIdentifier::Object => parse_object(information),
1033        DataTypeIdentifier::Item => parse_item(information),
1034        DataTypeIdentifier::Weather => AprsData::Weather(Weather {
1035            report: information,
1036        }),
1037        DataTypeIdentifier::Telemetry => parse_telemetry(information),
1038        DataTypeIdentifier::Query => AprsData::Query(Query { query: information }),
1039        DataTypeIdentifier::Capability => AprsData::Capability(Capability { body: information }),
1040        DataTypeIdentifier::Nmea => AprsData::Nmea(Nmea {
1041            sentence: information,
1042        }),
1043        DataTypeIdentifier::MicECurrent | DataTypeIdentifier::MicEOld => {
1044            parse_mic_e(identifier, information, destination)
1045        }
1046        DataTypeIdentifier::Maidenhead => parse_maidenhead(information),
1047        DataTypeIdentifier::UserDefined => parse_user_defined(information),
1048        DataTypeIdentifier::ThirdParty => AprsData::ThirdParty(ThirdParty { body: information }),
1049        other => AprsData::Unsupported {
1050            identifier: other.as_byte(),
1051            information,
1052        },
1053    }
1054}
1055
1056fn parse_mic_e<'a>(
1057    identifier: DataTypeIdentifier,
1058    information: &'a [u8],
1059    destination: &'a [u8],
1060) -> AprsData<'a> {
1061    AprsData::MicE(MicE {
1062        identifier: identifier.as_byte(),
1063        destination,
1064        body: information,
1065        status: decode_mic_e_status(destination),
1066        latitude_digits: decode_mic_e_latitude_digits(destination),
1067    })
1068}
1069
1070fn parse_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1071    if is_compressed_position(information) {
1072        return parse_compressed_position(messaging, identifier, information);
1073    }
1074
1075    if information.len() < 18 {
1076        return AprsData::Malformed {
1077            identifier,
1078            information,
1079        };
1080    }
1081
1082    let latitude = &information[..8];
1083    let symbol_table = information[8];
1084    let longitude = &information[9..18];
1085    let symbol_code = information[18];
1086    let comment = &information[19..];
1087
1088    if !is_latitude(latitude)
1089        || !is_symbol_table_identifier(symbol_table)
1090        || !is_longitude(longitude)
1091        || !is_printable_ascii(symbol_code)
1092    {
1093        return AprsData::Malformed {
1094            identifier,
1095            information,
1096        };
1097    }
1098
1099    AprsData::Position(Position {
1100        messaging,
1101        latitude,
1102        symbol_table,
1103        longitude,
1104        symbol_code,
1105        comment,
1106    })
1107}
1108
1109fn parse_timestamped_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1110    if information.len() < 8 {
1111        return AprsData::Malformed {
1112            identifier,
1113            information,
1114        };
1115    }
1116
1117    let timestamp = &information[..7];
1118    if !is_timestamp(timestamp) {
1119        return AprsData::Malformed {
1120            identifier,
1121            information,
1122        };
1123    }
1124
1125    match parse_position(messaging, identifier, &information[7..]) {
1126        AprsData::Position(position) => AprsData::TimestampedPosition(TimestampedPosition {
1127            messaging,
1128            timestamp,
1129            position,
1130        }),
1131        AprsData::CompressedPosition(position) => AprsData::CompressedPosition(position),
1132        _ => AprsData::Malformed {
1133            identifier,
1134            information,
1135        },
1136    }
1137}
1138
1139fn parse_compressed_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1140    if information.len() < 13 {
1141        return AprsData::Malformed {
1142            identifier,
1143            information,
1144        };
1145    }
1146
1147    let symbol_table = information[0];
1148    let compressed_latitude = &information[1..5];
1149    let compressed_longitude = &information[5..9];
1150    let symbol_code = information[9];
1151    let extension = &information[10..12];
1152    let compression_type = information[12];
1153    let comment = &information[13..];
1154
1155    if !is_symbol_table_identifier(symbol_table)
1156        || !compressed_latitude.iter().all(|byte| is_base91(*byte))
1157        || !compressed_longitude.iter().all(|byte| is_base91(*byte))
1158        || !is_printable_ascii(symbol_code)
1159        || !extension.iter().all(|byte| is_base91(*byte))
1160        || !is_base91(compression_type)
1161    {
1162        return AprsData::Malformed {
1163            identifier,
1164            information,
1165        };
1166    }
1167
1168    AprsData::CompressedPosition(CompressedPosition {
1169        messaging,
1170        symbol_table,
1171        compressed_latitude,
1172        compressed_longitude,
1173        symbol_code,
1174        extension,
1175        compression_type,
1176        comment,
1177    })
1178}
1179
1180fn parse_object(information: &[u8]) -> AprsData<'_> {
1181    if information.len() < 17 || !matches!(information[9], b'*' | b'_') {
1182        return AprsData::Malformed {
1183            identifier: b';',
1184            information,
1185        };
1186    }
1187
1188    AprsData::Object(Object {
1189        name: &information[..9],
1190        live: information[9] == b'*',
1191        timestamp: &information[10..17],
1192        body: &information[17..],
1193    })
1194}
1195
1196fn parse_item(information: &[u8]) -> AprsData<'_> {
1197    let Some(separator) = information
1198        .iter()
1199        .position(|byte| matches!(*byte, b'!' | b'_'))
1200    else {
1201        return AprsData::Malformed {
1202            identifier: b')',
1203            information,
1204        };
1205    };
1206
1207    if separator == 0 || separator > 9 {
1208        return AprsData::Malformed {
1209            identifier: b')',
1210            information,
1211        };
1212    }
1213
1214    AprsData::Item(Item {
1215        name: &information[..separator],
1216        live: information[separator] == b'!',
1217        body: &information[separator + 1..],
1218    })
1219}
1220
1221fn parse_message(information: &[u8]) -> AprsData<'_> {
1222    if information.len() < 10 || information[9] != b':' {
1223        return AprsData::Malformed {
1224            identifier: b':',
1225            information,
1226        };
1227    }
1228
1229    let addressee = &information[..9];
1230    let body = &information[10..];
1231    if let Some(kind) = classify_telemetry_metadata_kind(addressee) {
1232        return AprsData::TelemetryMetadata(TelemetryMetadata {
1233            addressee,
1234            kind,
1235            body,
1236        });
1237    }
1238
1239    let (text, id) = match body.iter().position(|byte| *byte == b'{') {
1240        Some(separator) => (&body[..separator], Some(&body[separator + 1..])),
1241        None => (body, None),
1242    };
1243    let kind = classify_message_kind(addressee, text);
1244
1245    AprsData::Message(Message {
1246        addressee,
1247        kind,
1248        text,
1249        id,
1250    })
1251}
1252
1253fn parse_telemetry(information: &[u8]) -> AprsData<'_> {
1254    if !information.starts_with(b"#") {
1255        return AprsData::Malformed {
1256            identifier: b'T',
1257            information,
1258        };
1259    }
1260
1261    let fields: Vec<&[u8]> = information[1..].split(|byte| *byte == b',').collect();
1262    if fields.len() < 6 || fields[..6].iter().any(|field| field.is_empty()) {
1263        return AprsData::Malformed {
1264            identifier: b'T',
1265            information,
1266        };
1267    }
1268
1269    AprsData::Telemetry(Telemetry {
1270        sequence: fields[0],
1271        analog: [fields[1], fields[2], fields[3], fields[4], fields[5]],
1272        digital: fields.get(6).copied().filter(|field| !field.is_empty()),
1273    })
1274}
1275
1276fn parse_maidenhead(information: &[u8]) -> AprsData<'_> {
1277    if information.len() < 6 {
1278        return AprsData::Malformed {
1279            identifier: b'[',
1280            information,
1281        };
1282    }
1283
1284    AprsData::Maidenhead(Maidenhead {
1285        locator: &information[..6],
1286        comment: &information[6..],
1287    })
1288}
1289
1290fn parse_user_defined(information: &[u8]) -> AprsData<'_> {
1291    if information.len() < 2 {
1292        return AprsData::Malformed {
1293            identifier: b'{',
1294            information,
1295        };
1296    }
1297
1298    AprsData::UserDefined(UserDefined {
1299        user_id: information[0],
1300        packet_type: information[1],
1301        body: &information[2..],
1302    })
1303}
1304
1305fn classify_telemetry_metadata_kind(addressee: &[u8]) -> Option<TelemetryMetadataKind> {
1306    match addressee.get(..5)? {
1307        b"PARM." => Some(TelemetryMetadataKind::ParameterNames),
1308        b"UNIT." => Some(TelemetryMetadataKind::Units),
1309        b"EQNS." => Some(TelemetryMetadataKind::Equations),
1310        b"BITS." => Some(TelemetryMetadataKind::BitSense),
1311        _ => None,
1312    }
1313}
1314
1315fn classify_message_kind(addressee: &[u8], text: &[u8]) -> MessageKind {
1316    if text.starts_with(b"ack") {
1317        MessageKind::Ack
1318    } else if text.starts_with(b"rej") {
1319        MessageKind::Reject
1320    } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_digit) {
1321        MessageKind::Bulletin
1322    } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_uppercase)
1323    {
1324        MessageKind::Announcement
1325    } else {
1326        MessageKind::Message
1327    }
1328}
1329
1330fn is_latitude(value: &[u8]) -> bool {
1331    value.len() == 8
1332        && value[0].is_ascii_digit()
1333        && value[1].is_ascii_digit()
1334        && value[2].is_ascii_digit()
1335        && value[3].is_ascii_digit()
1336        && value[4] == b'.'
1337        && value[5].is_ascii_digit()
1338        && value[6].is_ascii_digit()
1339        && matches!(value[7], b'N' | b'S')
1340}
1341
1342fn is_longitude(value: &[u8]) -> bool {
1343    value.len() == 9
1344        && value[0].is_ascii_digit()
1345        && value[1].is_ascii_digit()
1346        && value[2].is_ascii_digit()
1347        && value[3].is_ascii_digit()
1348        && value[4].is_ascii_digit()
1349        && value[5] == b'.'
1350        && value[6].is_ascii_digit()
1351        && value[7].is_ascii_digit()
1352        && matches!(value[8], b'E' | b'W')
1353}
1354
1355fn is_symbol_table_identifier(value: u8) -> bool {
1356    matches!(value, b'/' | b'\\') || value.is_ascii_alphanumeric()
1357}
1358
1359fn is_printable_ascii(value: u8) -> bool {
1360    (0x20..=0x7e).contains(&value)
1361}
1362
1363fn is_base91(value: u8) -> bool {
1364    (b'!'..=b'{').contains(&value)
1365}
1366
1367fn is_compressed_position(information: &[u8]) -> bool {
1368    information
1369        .first()
1370        .is_some_and(|byte| !byte.is_ascii_digit() && is_symbol_table_identifier(*byte))
1371        && information
1372            .get(1..13)
1373            .is_some_and(|bytes| bytes.iter().all(|byte| is_base91(*byte)))
1374}
1375
1376fn is_timestamp(value: &[u8]) -> bool {
1377    value.len() == 7
1378        && value[..6].iter().all(u8::is_ascii_digit)
1379        && matches!(value[6], b'z' | b'/' | b'h')
1380}
1381
1382fn decode_latitude(value: &[u8]) -> Option<f64> {
1383    if !is_latitude(value) {
1384        return None;
1385    }
1386
1387    let degrees = parse_u16(&value[..2])? as f64;
1388    let minutes = parse_fixed_minutes(&value[2..7])?;
1389    let sign = match value[7] {
1390        b'N' => 1.0,
1391        b'S' => -1.0,
1392        _ => return None,
1393    };
1394
1395    Some(sign * (degrees + minutes / 60.0))
1396}
1397
1398fn decode_longitude(value: &[u8]) -> Option<f64> {
1399    if !is_longitude(value) {
1400        return None;
1401    }
1402
1403    let degrees = parse_u16(&value[..3])? as f64;
1404    let minutes = parse_fixed_minutes(&value[3..8])?;
1405    let sign = match value[8] {
1406        b'E' => 1.0,
1407        b'W' => -1.0,
1408        _ => return None,
1409    };
1410
1411    Some(sign * (degrees + minutes / 60.0))
1412}
1413
1414fn parse_fixed_minutes(value: &[u8]) -> Option<f64> {
1415    if value.len() != 5 || value[2] != b'.' || !value[..2].iter().all(u8::is_ascii_digit) {
1416        return None;
1417    }
1418
1419    let whole = parse_u16(&value[..2])? as f64;
1420    let fraction = parse_u16(&value[3..])? as f64 / 100.0;
1421    Some(whole + fraction)
1422}
1423
1424fn decode_base91(value: &[u8]) -> Option<u32> {
1425    if value.len() != 4 || !value.iter().all(|byte| is_base91(*byte)) {
1426        return None;
1427    }
1428
1429    let mut decoded = 0u32;
1430    for byte in value {
1431        decoded = decoded * 91 + u32::from(byte - b'!');
1432    }
1433
1434    Some(decoded)
1435}
1436
1437fn parse_u16(value: &[u8]) -> Option<u16> {
1438    if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
1439        return None;
1440    }
1441
1442    let mut parsed = 0u16;
1443    for digit in value {
1444        parsed = parsed.checked_mul(10)?;
1445        parsed = parsed.checked_add(u16::from(digit - b'0'))?;
1446    }
1447
1448    Some(parsed)
1449}
1450
1451fn parse_i16(value: &[u8]) -> Option<i16> {
1452    if value.is_empty() {
1453        return None;
1454    }
1455
1456    let (sign, digits) = match value[0] {
1457        b'-' => (-1, &value[1..]),
1458        b'+' => (1, &value[1..]),
1459        _ => (1, value),
1460    };
1461
1462    let unsigned = parse_u16(digits)?;
1463    i16::try_from(unsigned).ok()?.checked_mul(sign)
1464}
1465
1466fn parse_hex_byte(value: &[u8]) -> Option<u8> {
1467    if value.len() != 2 {
1468        return None;
1469    }
1470
1471    Some(hex_value(value[0])? * 16 + hex_value(value[1])?)
1472}
1473
1474fn hex_value(value: u8) -> Option<u8> {
1475    match value {
1476        b'0'..=b'9' => Some(value - b'0'),
1477        b'A'..=b'F' => Some(value - b'A' + 10),
1478        b'a'..=b'f' => Some(value - b'a' + 10),
1479        _ => None,
1480    }
1481}
1482
1483fn parse_tagged_u16(report: &[u8], tag: u8, width: usize) -> Option<u16> {
1484    parse_tagged(report, tag, width).and_then(parse_u16)
1485}
1486
1487fn parse_tagged_i16(report: &[u8], tag: u8, width: usize) -> Option<i16> {
1488    parse_tagged(report, tag, width).and_then(parse_i16)
1489}
1490
1491fn parse_tagged(report: &[u8], tag: u8, width: usize) -> Option<&[u8]> {
1492    let start = report.iter().position(|byte| *byte == tag)? + 1;
1493    report.get(start..start + width)
1494}
1495
1496fn decode_mic_e_status(destination: &[u8]) -> Option<MicEStatus> {
1497    if destination.len() != 6 {
1498        return None;
1499    }
1500
1501    let bytes = destination.get(..3)?;
1502    Some(MicEStatus::Custom([
1503        mic_e_status_bit(bytes[0])?,
1504        mic_e_status_bit(bytes[1])?,
1505        mic_e_status_bit(bytes[2])?,
1506    ]))
1507}
1508
1509fn mic_e_status_bit(byte: u8) -> Option<bool> {
1510    match byte {
1511        b'0'..=b'9' | b'L' => Some(false),
1512        b'A'..=b'K' | b'P'..=b'Z' => Some(true),
1513        _ => None,
1514    }
1515}
1516
1517fn decode_mic_e_latitude_digits(destination: &[u8]) -> Option<[u8; 6]> {
1518    if destination.len() != 6 {
1519        return None;
1520    }
1521
1522    let mut digits = [0u8; 6];
1523    for (index, byte) in destination.iter().copied().enumerate() {
1524        digits[index] = mic_e_latitude_digit(byte)?;
1525    }
1526
1527    Some(digits)
1528}
1529
1530fn mic_e_latitude_digit(byte: u8) -> Option<u8> {
1531    match byte {
1532        b'0'..=b'9' => Some(byte - b'0'),
1533        b'A'..=b'J' => Some(byte - b'A'),
1534        b'P'..=b'Y' => Some(byte - b'P'),
1535        b'K' | b'L' | b'Z' => Some(0),
1536        _ => None,
1537    }
1538}
1539
1540fn decode_mic_e_latitude(destination: &[u8]) -> Option<f64> {
1541    let digits = decode_mic_e_latitude_digits(destination)?;
1542    let degrees = u16::from(digits[0]) * 10 + u16::from(digits[1]);
1543    let minutes = u16::from(digits[2]) * 10 + u16::from(digits[3]);
1544    let hundredths = u16::from(digits[4]) * 10 + u16::from(digits[5]);
1545    if degrees > 90 || minutes > 59 {
1546        return None;
1547    }
1548
1549    let sign = if mic_e_north(destination[3])? {
1550        1.0
1551    } else {
1552        -1.0
1553    };
1554    Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
1555}
1556
1557fn decode_mic_e_longitude(destination: &[u8], body: &[u8]) -> Option<f64> {
1558    if destination.len() != 6 || body.len() < 3 {
1559        return None;
1560    }
1561
1562    let mut degrees = i16::from(mic_e_body_value(body[0])?);
1563    if mic_e_longitude_offset(destination[4])? {
1564        degrees += 100;
1565    }
1566    if (180..=189).contains(&degrees) {
1567        degrees -= 80;
1568    } else if (190..=199).contains(&degrees) {
1569        degrees -= 190;
1570    }
1571
1572    let minutes = mic_e_body_value(body[1])?;
1573    let hundredths = mic_e_body_value(body[2])?;
1574    if !(0..=179).contains(&degrees) || minutes > 59 || hundredths > 99 {
1575        return None;
1576    }
1577
1578    let sign = if mic_e_west(destination[5])? {
1579        -1.0
1580    } else {
1581        1.0
1582    };
1583    Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
1584}
1585
1586fn decode_mic_e_speed_course(body: &[u8]) -> Option<MicESpeedCourse> {
1587    if body.len() < 6 {
1588        return None;
1589    }
1590
1591    let speed_tens = u16::from(mic_e_body_value(body[3])?);
1592    let speed_units_course_hundreds = u16::from(mic_e_body_value(body[4])?);
1593    let course_remainder = u16::from(mic_e_body_value(body[5])?);
1594    let mut speed_knots = speed_tens * 10 + speed_units_course_hundreds / 10;
1595    if speed_knots >= 800 {
1596        speed_knots -= 800;
1597    }
1598
1599    Some(MicESpeedCourse {
1600        speed_knots,
1601        course_degrees: (speed_units_course_hundreds % 10) * 100 + course_remainder,
1602    })
1603}
1604
1605fn mic_e_body_value(byte: u8) -> Option<u8> {
1606    let value = byte.checked_sub(28)?;
1607    (value <= 99).then_some(value)
1608}
1609
1610fn mic_e_north(byte: u8) -> Option<bool> {
1611    match byte {
1612        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1613        b'P'..=b'Z' => Some(true),
1614        _ => None,
1615    }
1616}
1617
1618fn mic_e_longitude_offset(byte: u8) -> Option<bool> {
1619    match byte {
1620        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1621        b'P'..=b'Z' => Some(true),
1622        _ => None,
1623    }
1624}
1625
1626fn mic_e_west(byte: u8) -> Option<bool> {
1627    match byte {
1628        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1629        b'P'..=b'Z' => Some(true),
1630        _ => None,
1631    }
1632}
1633
1634/// Fail-closed packet parse errors.
1635#[derive(Clone, Debug, Eq, PartialEq)]
1636pub enum ParseError {
1637    /// No bytes were supplied.
1638    Empty,
1639    /// Packet exceeds [`MAX_PACKET_LEN`].
1640    Oversized,
1641    /// Packet does not contain the required APRS `>` and `:` separators.
1642    MissingSeparator,
1643    /// Packet contains an empty source, path, or payload segment.
1644    EmptySegment,
1645    /// Packet source or path contains bytes outside the conservative address set.
1646    InvalidAddress,
1647}
1648
1649impl ParseError {
1650    /// Returns a stable parse error code for logs and external systems.
1651    #[must_use]
1652    pub fn code(&self) -> &'static str {
1653        match self {
1654            Self::Empty => "parse.empty",
1655            Self::Oversized => "parse.oversized",
1656            Self::MissingSeparator => "parse.missing_separator",
1657            Self::EmptySegment => "parse.empty_segment",
1658            Self::InvalidAddress => "parse.invalid_address",
1659        }
1660    }
1661}
1662
1663/// Parses an APRS packet from untrusted bytes.
1664///
1665/// This parser intentionally validates only the minimal frame shape for the
1666/// skeleton: `source>path:payload`. Payload bytes are opaque and may be invalid
1667/// UTF-8.
1668pub fn parse_packet(input: &[u8]) -> Result<ParsedPacket, ParseError> {
1669    parse_packet_with_options(input, ParseOptions::default())
1670}
1671
1672/// Parses an APRS packet from untrusted bytes with explicit codec options.
1673pub fn parse_packet_with_options(
1674    input: &[u8],
1675    options: ParseOptions,
1676) -> Result<ParsedPacket, ParseError> {
1677    if input.is_empty() {
1678        return Err(ParseError::Empty);
1679    }
1680
1681    if input.len() > options.max_packet_len {
1682        return Err(ParseError::Oversized);
1683    }
1684
1685    let source_end = input
1686        .iter()
1687        .position(|byte| *byte == b'>')
1688        .ok_or(ParseError::MissingSeparator)?;
1689    let payload_separator = input[source_end + 1..]
1690        .iter()
1691        .position(|byte| *byte == b':')
1692        .map(|offset| source_end + 1 + offset)
1693        .ok_or(ParseError::MissingSeparator)?;
1694
1695    let path_start = source_end + 1;
1696    let path_end = payload_separator;
1697    let payload_start = payload_separator + 1;
1698
1699    if source_end == 0 || path_start == path_end || payload_start == input.len() {
1700        return Err(ParseError::EmptySegment);
1701    }
1702
1703    let Some(path_components) = path_component_ranges(input, path_start, path_end) else {
1704        return Err(ParseError::InvalidAddress);
1705    };
1706
1707    if !is_ax25_like_source(&input[..source_end])
1708        || !path_components
1709            .iter()
1710            .all(|(start, end)| is_ax25_like_path_component(&input[*start..*end]))
1711    {
1712        return Err(ParseError::InvalidAddress);
1713    }
1714
1715    Ok(ParsedPacket {
1716        raw: RawPacket {
1717            bytes: input.to_vec(),
1718        },
1719        source_end,
1720        path_start,
1721        path_end,
1722        path_components,
1723        payload_start,
1724    })
1725}
1726
1727fn path_component_ranges(
1728    input: &[u8],
1729    path_start: usize,
1730    path_end: usize,
1731) -> Option<Vec<(usize, usize)>> {
1732    let mut components = Vec::new();
1733    let mut component_start = path_start;
1734
1735    for (offset, byte) in input[path_start..path_end].iter().enumerate() {
1736        if *byte == b',' {
1737            let index = path_start + offset;
1738            if component_start == index {
1739                return None;
1740            }
1741            components.push((component_start, index));
1742            component_start = index + 1;
1743        }
1744    }
1745
1746    if component_start == path_end {
1747        return None;
1748    }
1749
1750    components.push((component_start, path_end));
1751    Some(components)
1752}
1753
1754fn is_ax25_like_source(source: &[u8]) -> bool {
1755    is_ax25_like_address(source, false)
1756}
1757
1758fn is_ax25_like_path_component(component: &[u8]) -> bool {
1759    is_ax25_like_address(component, true)
1760}
1761
1762fn is_ax25_like_address(address: &[u8], allow_repeated_marker: bool) -> bool {
1763    let address = if allow_repeated_marker {
1764        address.strip_suffix(b"*").unwrap_or(address)
1765    } else {
1766        address
1767    };
1768
1769    if address.is_empty() || address.contains(&b'*') {
1770        return false;
1771    }
1772
1773    let (callsign, ssid) = match address.iter().position(|byte| *byte == b'-') {
1774        Some(separator) => (&address[..separator], Some(&address[separator + 1..])),
1775        None => (address, None),
1776    };
1777
1778    is_ax25_like_callsign(callsign) && ssid.map_or(true, is_ax25_like_ssid)
1779}
1780
1781fn is_ax25_like_callsign(callsign: &[u8]) -> bool {
1782    (1..=6).contains(&callsign.len())
1783        && callsign
1784            .iter()
1785            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
1786}
1787
1788fn is_ax25_like_ssid(ssid: &[u8]) -> bool {
1789    if ssid.is_empty() || ssid.len() > 2 || !ssid.iter().all(u8::is_ascii_digit) {
1790        return false;
1791    }
1792
1793    let mut value = 0u8;
1794    for digit in ssid {
1795        value = value * 10 + (digit - b'0');
1796    }
1797
1798    value <= 15
1799}