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