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