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/// Stable diagnostic layer for parser, policy, and transport failures.
199#[derive(Clone, Copy, Debug, Eq, PartialEq)]
200pub enum DiagnosticLayer {
201    /// Codec/parser validation at the APRS packet boundary.
202    Parse,
203    /// Operational policy after codec validation.
204    Policy,
205    /// Transport framing or I/O boundary before codec validation.
206    Transport,
207}
208
209impl DiagnosticLayer {
210    /// Returns a stable machine-readable layer code.
211    #[must_use]
212    pub const fn code(self) -> &'static str {
213        match self {
214            Self::Parse => "parse",
215            Self::Policy => "policy",
216            Self::Transport => "transport",
217        }
218    }
219}
220
221/// Structured diagnostic metadata for parser, policy, and transport errors.
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub struct ErrorDiagnostic {
224    /// Layer that produced the diagnostic.
225    pub layer: DiagnosticLayer,
226    /// Stable fully-qualified diagnostic code.
227    pub code: &'static str,
228    /// Stable short diagnostic name within the layer.
229    pub name: &'static str,
230    /// Human-readable diagnostic description for operators.
231    pub description: &'static str,
232    /// Human-readable remediation guidance.
233    pub remediation: &'static str,
234}
235
236/// Support status for documented APRS capabilities and integration surfaces.
237#[derive(Clone, Copy, Debug, Eq, PartialEq)]
238pub enum SupportStatus {
239    /// Supported by parser semantics or adapter helpers.
240    Supported,
241    /// Supported partially; callers should inspect documentation for limits.
242    Partial,
243    /// Intentionally unsupported in the current release line.
244    Unsupported,
245}
246
247impl SupportStatus {
248    /// Returns a stable machine-readable support status.
249    #[must_use]
250    pub const fn code(self) -> &'static str {
251        match self {
252            Self::Supported => "supported",
253            Self::Partial => "partial",
254            Self::Unsupported => "unsupported",
255        }
256    }
257}
258
259/// Support-matrix item exposed for documentation and machine-readable CLI output.
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub struct SupportItem {
262    /// Stable item kind.
263    pub kind: &'static str,
264    /// Current support status.
265    pub status: SupportStatus,
266    /// Short operational note.
267    pub notes: &'static str,
268}
269
270/// Transport adapter support entry.
271#[derive(Clone, Copy, Debug, Eq, PartialEq)]
272pub struct TransportSupport {
273    /// Published crate name.
274    pub crate_name: &'static str,
275    /// Boundary handled by the adapter.
276    pub boundary: &'static str,
277    /// Current support status.
278    pub status: SupportStatus,
279    /// Short operational note.
280    pub notes: &'static str,
281}
282
283/// Machine-readable support matrix for operator tooling and docs.
284#[derive(Clone, Copy, Debug, Eq, PartialEq)]
285pub struct SupportMatrix {
286    /// Schema version for machine-readable output.
287    pub schema_version: u8,
288    /// Documented APRS semantic families.
289    pub semantic_families: &'static [SupportItem],
290    /// Optional transport adapter crates.
291    pub transport_adapters: &'static [TransportSupport],
292    /// Diagnostic layers emitted by the project.
293    pub diagnostic_layers: &'static [DiagnosticLayer],
294}
295
296/// Returns the current support matrix for CLI and documentation consumers.
297#[must_use]
298pub const fn support_matrix() -> SupportMatrix {
299    SupportMatrix {
300        schema_version: 1,
301        semantic_families: SEMANTIC_SUPPORT,
302        transport_adapters: TRANSPORT_SUPPORT,
303        diagnostic_layers: DIAGNOSTIC_LAYERS,
304    }
305}
306
307const DIAGNOSTIC_LAYERS: &[DiagnosticLayer] = &[
308    DiagnosticLayer::Parse,
309    DiagnosticLayer::Policy,
310    DiagnosticLayer::Transport,
311];
312
313const SEMANTIC_SUPPORT: &[SupportItem] = &[
314    SupportItem {
315        kind: "status",
316        status: SupportStatus::Supported,
317        notes: "status text bytes are preserved",
318    },
319    SupportItem {
320        kind: "position",
321        status: SupportStatus::Supported,
322        notes: "uncompressed and compressed coordinates are decoded where valid",
323    },
324    SupportItem {
325        kind: "message",
326        status: SupportStatus::Supported,
327        notes:
328            "messages, acknowledgements, rejections, bulletins, and announcements are classified",
329    },
330    SupportItem {
331        kind: "object",
332        status: SupportStatus::Supported,
333        notes: "object name, liveness, timestamp, body, and supported coordinates are exposed",
334    },
335    SupportItem {
336        kind: "item",
337        status: SupportStatus::Supported,
338        notes: "item name, liveness, body, and supported coordinates are exposed",
339    },
340    SupportItem {
341        kind: "weather",
342        status: SupportStatus::Partial,
343        notes: "common weather fields are extracted and malformed optional fields are ignored",
344    },
345    SupportItem {
346        kind: "telemetry",
347        status: SupportStatus::Supported,
348        notes: "sequence, analogue values, digital bits, and metadata packets are exposed",
349    },
350    SupportItem {
351        kind: "nmea",
352        status: SupportStatus::Supported,
353        notes: "sentence identifiers and checksum diagnostics are exposed",
354    },
355    SupportItem {
356        kind: "mic_e",
357        status: SupportStatus::Partial,
358        notes: "destination-derived status, latitude digits, speed, and course helpers are exposed",
359    },
360    SupportItem {
361        kind: "third_party",
362        status: SupportStatus::Partial,
363        notes: "nested packet bytes can be parsed explicitly by callers",
364    },
365    SupportItem {
366        kind: "unsupported",
367        status: SupportStatus::Supported,
368        notes: "unknown identifiers remain explicit and byte-preserving",
369    },
370    SupportItem {
371        kind: "malformed",
372        status: SupportStatus::Supported,
373        notes: "codec-valid but semantically malformed packets remain visible to policy",
374    },
375];
376
377const TRANSPORT_SUPPORT: &[TransportSupport] = &[
378    TransportSupport {
379        crate_name: "aprs-transport-file",
380        boundary: "newline-separated files and stdin-style byte streams",
381        status: SupportStatus::Supported,
382        notes: "bounded file and packet-line reads",
383    },
384    TransportSupport {
385        crate_name: "aprs-transport-tcp",
386        boundary: "blocking TCP or Read packet streams",
387        status: SupportStatus::Supported,
388        notes: "caller owns socket timeouts and reconnect behavior",
389    },
390    TransportSupport {
391        crate_name: "aprs-transport-aprs-is",
392        boundary: "APRS-IS login framing and server line filtering",
393        status: SupportStatus::Supported,
394        notes: "authentication and reconnect loops stay application-owned",
395    },
396    TransportSupport {
397        crate_name: "aprs-transport-kiss",
398        boundary: "KISS frame encoding and decoding",
399        status: SupportStatus::Supported,
400        notes: "invalid escapes and oversized frames fail closed",
401    },
402    TransportSupport {
403        crate_name: "aprs-transport-serial",
404        boundary: "serial-like byte readers",
405        status: SupportStatus::Supported,
406        notes: "serial configuration stays application-owned",
407    },
408    TransportSupport {
409        crate_name: "aprs-transport-udp",
410        boundary: "UDP datagram payloads",
411        status: SupportStatus::Supported,
412        notes: "datagram length is bounded before parsing",
413    },
414    TransportSupport {
415        crate_name: "aprs-transport-http",
416        boundary: "HTTP request body bytes",
417        status: SupportStatus::Supported,
418        notes: "body and packet-line limits are enforced by helpers",
419    },
420    TransportSupport {
421        crate_name: "aprs-transport-file-watch",
422        boundary: "append-only packet logs",
423        status: SupportStatus::Supported,
424        notes: "appended byte ranges and packet lines are bounded",
425    },
426    TransportSupport {
427        crate_name: "aprs-transport-mqtt",
428        boundary: "MQTT topics and payload copies",
429        status: SupportStatus::Supported,
430        notes: "broker sessions, authentication, and reconnects stay application-owned",
431    },
432    TransportSupport {
433        crate_name: "aprs-transport-ax25",
434        boundary: "AX.25 UI frames",
435        status: SupportStatus::Supported,
436        notes: "oversized UI frames fail closed before payload extraction",
437    },
438    TransportSupport {
439        crate_name: "aprs-transport-corpus",
440        boundary: "fixture and corpus replay",
441        status: SupportStatus::Supported,
442        notes: "stable ordering and per-file limits for tests",
443    },
444    TransportSupport {
445        crate_name: "aprs-transport-channel",
446        boundary: "in-process packet channels",
447        status: SupportStatus::Supported,
448        notes: "caller-owned channel capacity controls backpressure",
449    },
450    TransportSupport {
451        crate_name: "aprs-transport-async",
452        boundary: "runtime-neutral async byte splitting",
453        status: SupportStatus::Supported,
454        notes: "runtime, timeouts, and cancellation stay caller-owned",
455    },
456];
457
458/// Parser and policy orchestration engine.
459#[derive(Clone, Debug, Eq, PartialEq)]
460pub struct Engine {
461    policy: Policy,
462    counters: Counters,
463}
464
465impl Engine {
466    /// Creates an engine with the provided policy.
467    #[must_use]
468    pub fn new(policy: Policy) -> Self {
469        Self {
470            policy,
471            counters: Counters::default(),
472        }
473    }
474
475    /// Processes one packet through codec, semantics, and policy.
476    pub fn process(&mut self, input: &[u8]) -> EngineResult {
477        match parse_packet(input) {
478            Ok(packet) => {
479                let semantic = packet.aprs_data();
480                match self.policy.evaluate(&packet, &semantic) {
481                    PolicyDecision::Accept => {
482                        self.counters.accepted = self.counters.accepted.saturating_add(1);
483                        EngineResult::Accepted { packet }
484                    }
485                    PolicyDecision::Reject(reason) => {
486                        self.counters.rejected = self.counters.rejected.saturating_add(1);
487                        EngineResult::Rejected { packet, reason }
488                    }
489                }
490            }
491            Err(error) => {
492                self.counters.malformed = self.counters.malformed.saturating_add(1);
493                EngineResult::ParseError(error)
494            }
495        }
496    }
497
498    /// Processes a caller-provided packet batch in order.
499    pub fn process_packets<I, P>(&mut self, packets: I) -> Vec<EngineResult>
500    where
501        I: IntoIterator<Item = P>,
502        P: AsRef<[u8]>,
503    {
504        packets
505            .into_iter()
506            .map(|packet| self.process(packet.as_ref()))
507            .collect()
508    }
509
510    /// Reads one bounded batch from a packet source and processes it in order.
511    pub fn process_source<S>(&mut self, source: &mut S) -> Result<Vec<EngineResult>, S::Error>
512    where
513        S: PacketSource,
514    {
515        Ok(self.process_packets(source.recv_packets()?))
516    }
517
518    /// Returns engine counters.
519    #[must_use]
520    pub fn counters(&self) -> Counters {
521        self.counters
522    }
523}
524
525impl Default for Engine {
526    fn default() -> Self {
527        Self::new(Policy::default())
528    }
529}
530
531/// Engine processing result.
532#[derive(Clone, Debug, PartialEq)]
533pub enum EngineResult {
534    /// Packet parsed and passed policy.
535    Accepted {
536        /// Parsed packet.
537        packet: ParsedPacket,
538    },
539    /// Packet parsed but failed policy.
540    Rejected {
541        /// Parsed packet.
542        packet: ParsedPacket,
543        /// Rejection reason.
544        reason: PolicyRejection,
545    },
546    /// Packet failed the codec boundary.
547    ParseError(ParseError),
548}
549
550/// Runtime counters.
551#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
552pub struct Counters {
553    /// Accepted packet count.
554    pub accepted: u64,
555    /// Policy-rejected packet count.
556    pub rejected: u64,
557    /// Codec-malformed packet count.
558    pub malformed: u64,
559}
560
561/// Policy options applied after parsing.
562#[derive(Clone, Debug, Eq, PartialEq)]
563pub struct Policy {
564    /// Allow semantic packets represented as unsupported.
565    pub allow_unsupported: bool,
566    /// Allow semantic packets represented as malformed.
567    pub allow_malformed_semantics: bool,
568    /// Reject NMEA sentences when a present checksum does not match.
569    pub reject_invalid_nmea_checksum: bool,
570    /// Maximum allowed path component count including destination.
571    pub max_path_components: usize,
572}
573
574impl Policy {
575    /// Strict policy: reject malformed semantics, unsupported formats, and long paths.
576    #[must_use]
577    pub fn strict() -> Self {
578        Self::default()
579    }
580
581    /// Permissive policy: accept unsupported and malformed semantic packets.
582    #[must_use]
583    pub fn permissive() -> Self {
584        Self {
585            allow_unsupported: true,
586            allow_malformed_semantics: true,
587            reject_invalid_nmea_checksum: false,
588            max_path_components: 9,
589        }
590    }
591
592    /// Evaluates a parsed packet and semantic view.
593    #[must_use]
594    pub fn evaluate(&self, packet: &ParsedPacket, semantic: &AprsData<'_>) -> PolicyDecision {
595        if packet.path_components.len() > self.max_path_components {
596            return PolicyDecision::Reject(PolicyRejection::PathTooLong);
597        }
598
599        if self.reject_invalid_nmea_checksum
600            && matches!(
601                semantic,
602                AprsData::Nmea(nmea) if nmea.checksum().is_some_and(|checksum| !checksum.valid)
603            )
604        {
605            return PolicyDecision::Reject(PolicyRejection::InvalidNmeaChecksum);
606        }
607
608        match semantic {
609            AprsData::Malformed { .. } if !self.allow_malformed_semantics => {
610                PolicyDecision::Reject(PolicyRejection::MalformedSemantics)
611            }
612            AprsData::Unsupported { .. } if !self.allow_unsupported => {
613                PolicyDecision::Reject(PolicyRejection::UnsupportedSemantics)
614            }
615            _ => PolicyDecision::Accept,
616        }
617    }
618}
619
620impl Default for Policy {
621    fn default() -> Self {
622        Self {
623            allow_unsupported: false,
624            allow_malformed_semantics: false,
625            reject_invalid_nmea_checksum: false,
626            max_path_components: 9,
627        }
628    }
629}
630
631/// Policy decision.
632#[derive(Clone, Copy, Debug, Eq, PartialEq)]
633pub enum PolicyDecision {
634    /// Packet is accepted.
635    Accept,
636    /// Packet is rejected with a reason.
637    Reject(PolicyRejection),
638}
639
640/// Policy rejection reason.
641#[derive(Clone, Copy, Debug, Eq, PartialEq)]
642pub enum PolicyRejection {
643    /// Path contains too many components.
644    PathTooLong,
645    /// Semantic payload is malformed.
646    MalformedSemantics,
647    /// Semantic payload is unsupported.
648    UnsupportedSemantics,
649    /// NMEA sentence has a present checksum that does not match.
650    InvalidNmeaChecksum,
651}
652
653impl PolicyRejection {
654    /// Returns a stable policy rejection code for logs and external systems.
655    #[must_use]
656    pub fn code(self) -> &'static str {
657        match self {
658            Self::PathTooLong => "policy.path_too_long",
659            Self::MalformedSemantics => "policy.malformed_semantics",
660            Self::UnsupportedSemantics => "policy.unsupported_semantics",
661            Self::InvalidNmeaChecksum => "policy.nmea_checksum_mismatch",
662        }
663    }
664
665    /// Returns structured policy rejection metadata for operator diagnostics.
666    #[must_use]
667    pub fn diagnostic(self) -> ErrorDiagnostic {
668        match self {
669            Self::PathTooLong => ErrorDiagnostic {
670                layer: DiagnosticLayer::Policy,
671                code: self.code(),
672                name: "path_too_long",
673                description: "packet path contains more components than policy permits",
674                remediation: "raise Policy::max_path_components only after reviewing path abuse risk",
675            },
676            Self::MalformedSemantics => ErrorDiagnostic {
677                layer: DiagnosticLayer::Policy,
678                code: self.code(),
679                name: "malformed_semantics",
680                description: "packet passed codec validation but the APRS semantic payload is malformed",
681                remediation: "inspect the preserved raw bytes and keep strict policy enabled for untrusted inputs",
682            },
683            Self::UnsupportedSemantics => ErrorDiagnostic {
684                layer: DiagnosticLayer::Policy,
685                code: self.code(),
686                name: "unsupported_semantics",
687                description: "packet uses an unsupported APRS semantic family or identifier",
688                remediation: "use permissive policy only for corpus collection or add explicit support before accepting",
689            },
690            Self::InvalidNmeaChecksum => ErrorDiagnostic {
691                layer: DiagnosticLayer::Policy,
692                code: self.code(),
693                name: "nmea_checksum_mismatch",
694                description: "NMEA sentence has a present checksum that does not match the calculated value",
695                remediation: "treat the packet as untrusted and investigate upstream data corruption or spoofing",
696            },
697        }
698    }
699}
700
701/// Semantic APRS information-field data.
702#[derive(Clone, Copy, Debug, Eq, PartialEq)]
703pub enum AprsData<'a> {
704    /// Status report.
705    Status {
706        /// Status text bytes.
707        text: &'a [u8],
708    },
709    /// Uncompressed position report.
710    Position(Position<'a>),
711    /// Timestamped uncompressed position report.
712    TimestampedPosition(TimestampedPosition<'a>),
713    /// Compressed position report.
714    CompressedPosition(CompressedPosition<'a>),
715    /// Message, bulletin, or announcement.
716    Message(Message<'a>),
717    /// Object report.
718    Object(Object<'a>),
719    /// Item report.
720    Item(Item<'a>),
721    /// Weather report without position.
722    Weather(Weather<'a>),
723    /// Telemetry report.
724    Telemetry(Telemetry<'a>),
725    /// Telemetry metadata carried as an APRS message.
726    TelemetryMetadata(TelemetryMetadata<'a>),
727    /// Query packet.
728    Query(Query<'a>),
729    /// Station capabilities packet.
730    Capability(Capability<'a>),
731    /// NMEA sentence packet.
732    Nmea(Nmea<'a>),
733    /// Mic-E packet.
734    MicE(MicE<'a>),
735    /// Maidenhead locator packet.
736    Maidenhead(Maidenhead<'a>),
737    /// User-defined data packet.
738    UserDefined(UserDefined<'a>),
739    /// Third-party traffic packet.
740    ThirdParty(ThirdParty<'a>),
741    /// Data format is validly framed but not implemented yet.
742    Unsupported {
743        /// Original data type identifier byte.
744        identifier: u8,
745        /// Remaining information-field bytes.
746        information: &'a [u8],
747    },
748    /// Data type is known, but its information bytes are malformed.
749    Malformed {
750        /// Original data type identifier byte.
751        identifier: u8,
752        /// Remaining information-field bytes.
753        information: &'a [u8],
754    },
755}
756
757impl AprsData<'_> {
758    /// Returns a stable semantic kind name for diagnostics.
759    #[must_use]
760    pub fn kind_name(&self) -> &'static str {
761        match self {
762            Self::Status { .. } => "status",
763            Self::Position(_) => "position",
764            Self::TimestampedPosition(_) => "timestamped_position",
765            Self::CompressedPosition(_) => "compressed_position",
766            Self::Message(_) => "message",
767            Self::Object(_) => "object",
768            Self::Item(_) => "item",
769            Self::Weather(_) => "weather",
770            Self::Telemetry(_) => "telemetry",
771            Self::TelemetryMetadata(_) => "telemetry_metadata",
772            Self::Query(_) => "query",
773            Self::Capability(_) => "capability",
774            Self::Nmea(_) => "nmea",
775            Self::MicE(_) => "mic_e",
776            Self::Maidenhead(_) => "maidenhead",
777            Self::UserDefined(_) => "user_defined",
778            Self::ThirdParty(_) => "third_party",
779            Self::Unsupported { .. } => "unsupported",
780            Self::Malformed { .. } => "malformed",
781        }
782    }
783}
784
785fn summary_coordinates(data: AprsData<'_>) -> Option<Coordinates> {
786    match data {
787        AprsData::Position(position) => position.coordinates(),
788        AprsData::TimestampedPosition(position) => position.position.coordinates(),
789        AprsData::CompressedPosition(position) => position.coordinates(),
790        AprsData::MicE(mic_e) => mic_e.coordinates(),
791        _ => None,
792    }
793}
794
795fn summary_nmea_checksum(data: AprsData<'_>) -> Option<NmeaChecksum> {
796    match data {
797        AprsData::Nmea(nmea) => nmea.checksum(),
798        _ => None,
799    }
800}
801
802fn summary_telemetry_sequence(data: AprsData<'_>) -> Option<u16> {
803    match data {
804        AprsData::Telemetry(telemetry) => telemetry.sequence_number(),
805        _ => None,
806    }
807}
808
809fn summary_mic_e_speed_course(data: AprsData<'_>) -> Option<MicESpeedCourse> {
810    match data {
811        AprsData::MicE(mic_e) => mic_e.speed_course(),
812        _ => None,
813    }
814}
815
816/// Uncompressed APRS position fields.
817#[derive(Clone, Copy, Debug, Eq, PartialEq)]
818pub struct Position<'a> {
819    /// Whether the data type identifier indicates APRS messaging support.
820    pub messaging: bool,
821    /// Latitude bytes in APRS `DDMM.mmN/S` form.
822    pub latitude: &'a [u8],
823    /// Symbol table identifier byte.
824    pub symbol_table: u8,
825    /// Longitude bytes in APRS `DDDMM.mmE/W` form.
826    pub longitude: &'a [u8],
827    /// Symbol code byte.
828    pub symbol_code: u8,
829    /// Optional comment bytes after the symbol code.
830    pub comment: &'a [u8],
831}
832
833impl Position<'_> {
834    /// Returns decimal latitude and longitude if both coordinate fields decode.
835    #[must_use]
836    pub fn coordinates(&self) -> Option<Coordinates> {
837        Some(Coordinates {
838            latitude: decode_latitude(self.latitude)?,
839            longitude: decode_longitude(self.longitude)?,
840        })
841    }
842}
843
844/// Decimal coordinates in signed degrees.
845#[derive(Clone, Copy, Debug, PartialEq)]
846pub struct Coordinates {
847    /// Latitude in signed decimal degrees.
848    pub latitude: f64,
849    /// Longitude in signed decimal degrees.
850    pub longitude: f64,
851}
852
853/// Timestamped uncompressed APRS position fields.
854#[derive(Clone, Copy, Debug, Eq, PartialEq)]
855pub struct TimestampedPosition<'a> {
856    /// Whether the data type identifier indicates APRS messaging support.
857    pub messaging: bool,
858    /// Seven-byte timestamp field.
859    pub timestamp: &'a [u8],
860    /// Position fields after the timestamp.
861    pub position: Position<'a>,
862}
863
864/// Compressed APRS position fields.
865#[derive(Clone, Copy, Debug, Eq, PartialEq)]
866pub struct CompressedPosition<'a> {
867    /// Whether the data type identifier indicates APRS messaging support.
868    pub messaging: bool,
869    /// Symbol table identifier byte.
870    pub symbol_table: u8,
871    /// Four-byte compressed latitude.
872    pub compressed_latitude: &'a [u8],
873    /// Four-byte compressed longitude.
874    pub compressed_longitude: &'a [u8],
875    /// Symbol code byte.
876    pub symbol_code: u8,
877    /// Two-byte compressed extension field.
878    pub extension: &'a [u8],
879    /// Compression type byte.
880    pub compression_type: u8,
881    /// Optional comment bytes after the compression type byte.
882    pub comment: &'a [u8],
883}
884
885impl CompressedPosition<'_> {
886    /// Returns decoded compressed-position coordinates.
887    #[must_use]
888    pub fn coordinates(&self) -> Option<Coordinates> {
889        let y = decode_base91(self.compressed_latitude)?;
890        let x = decode_base91(self.compressed_longitude)?;
891
892        Some(Coordinates {
893            latitude: 90.0 - (y as f64 / 380_926.0),
894            longitude: -180.0 + (x as f64 / 190_463.0),
895        })
896    }
897}
898
899/// APRS message fields.
900#[derive(Clone, Copy, Debug, Eq, PartialEq)]
901pub struct Message<'a> {
902    /// Nine-byte addressee field.
903    pub addressee: &'a [u8],
904    /// Classified message subtype.
905    pub kind: MessageKind,
906    /// Message text bytes before an optional message ID.
907    pub text: &'a [u8],
908    /// Optional message ID bytes after `{`.
909    pub id: Option<&'a [u8]>,
910}
911
912/// APRS message subtype.
913#[derive(Clone, Copy, Debug, Eq, PartialEq)]
914pub enum MessageKind {
915    /// Regular addressed message.
916    Message,
917    /// Message acknowledgement.
918    Ack,
919    /// Message rejection.
920    Reject,
921    /// Bulletin.
922    Bulletin,
923    /// Announcement.
924    Announcement,
925}
926
927/// APRS object report fields.
928#[derive(Clone, Copy, Debug, Eq, PartialEq)]
929pub struct Object<'a> {
930    /// Nine-byte object name.
931    pub name: &'a [u8],
932    /// Whether the object is live (`*`) rather than killed (`_`).
933    pub live: bool,
934    /// Seven-byte object timestamp.
935    pub timestamp: &'a [u8],
936    /// Remaining object body bytes.
937    pub body: &'a [u8],
938}
939
940impl Object<'_> {
941    /// Returns object coordinates when the object body starts with a supported
942    /// APRS position encoding.
943    #[must_use]
944    pub fn coordinates(&self) -> Option<Coordinates> {
945        coordinates_from_position_body(self.body)
946    }
947}
948
949/// APRS item report fields.
950#[derive(Clone, Copy, Debug, Eq, PartialEq)]
951pub struct Item<'a> {
952    /// Item name bytes.
953    pub name: &'a [u8],
954    /// Whether the item is live (`!`) rather than killed (`_`).
955    pub live: bool,
956    /// Remaining item body bytes.
957    pub body: &'a [u8],
958}
959
960impl Item<'_> {
961    /// Returns item coordinates when the item body starts with a supported APRS
962    /// position encoding.
963    #[must_use]
964    pub fn coordinates(&self) -> Option<Coordinates> {
965        coordinates_from_position_body(self.body)
966    }
967}
968
969/// APRS weather report bytes.
970#[derive(Clone, Copy, Debug, Eq, PartialEq)]
971pub struct Weather<'a> {
972    /// Weather report bytes after the `_` data type identifier.
973    pub report: &'a [u8],
974}
975
976impl Weather<'_> {
977    /// Extracts common numeric weather fields when present.
978    #[must_use]
979    pub fn fields(&self) -> WeatherFields<'_> {
980        WeatherFields {
981            timestamp: self
982                .report
983                .get(..6)
984                .filter(|value| value.iter().all(u8::is_ascii_digit)),
985            wind_direction_degrees: parse_tagged_u16(self.report, b'c', 3),
986            wind_speed_mph: parse_tagged_u16(self.report, b's', 3),
987            wind_gust_mph: parse_tagged_u16(self.report, b'g', 3),
988            temperature_fahrenheit: parse_tagged_i16(self.report, b't', 3),
989            rain_last_hour_hundredths_inch: parse_tagged_u16(self.report, b'r', 3),
990            rain_last_24_hours_hundredths_inch: parse_tagged_u16(self.report, b'p', 3),
991            rain_since_midnight_hundredths_inch: parse_tagged_u16(self.report, b'P', 3),
992            humidity_percent: parse_tagged_u16(self.report, b'h', 2).map(|value| {
993                if value == 0 {
994                    100
995                } else {
996                    value
997                }
998            }),
999            pressure_tenths_hpa: parse_tagged_u16(self.report, b'b', 5),
1000            luminosity_watts_per_square_meter: parse_tagged_u16(self.report, b'L', 3),
1001            luminosity_1000_plus_watts_per_square_meter: parse_tagged_u16(self.report, b'l', 3)
1002                .map(|value| value + 1000),
1003            snow_last_24_hours_inches: parse_tagged_u16(self.report, b'S', 3),
1004            raw_rain_counter: parse_tagged_u16(self.report, b'#', 3),
1005        }
1006    }
1007}
1008
1009/// Extracted numeric weather fields.
1010#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1011pub struct WeatherFields<'a> {
1012    /// Optional six-byte timestamp prefix.
1013    pub timestamp: Option<&'a [u8]>,
1014    /// Wind direction in degrees.
1015    pub wind_direction_degrees: Option<u16>,
1016    /// Sustained wind speed in miles per hour.
1017    pub wind_speed_mph: Option<u16>,
1018    /// Wind gust speed in miles per hour.
1019    pub wind_gust_mph: Option<u16>,
1020    /// Temperature in degrees Fahrenheit.
1021    pub temperature_fahrenheit: Option<i16>,
1022    /// Rain in the last hour, in hundredths of an inch.
1023    pub rain_last_hour_hundredths_inch: Option<u16>,
1024    /// Rain in the last 24 hours, in hundredths of an inch.
1025    pub rain_last_24_hours_hundredths_inch: Option<u16>,
1026    /// Rain since midnight, in hundredths of an inch.
1027    pub rain_since_midnight_hundredths_inch: Option<u16>,
1028    /// Relative humidity percent.
1029    pub humidity_percent: Option<u16>,
1030    /// Barometric pressure in tenths of hPa.
1031    pub pressure_tenths_hpa: Option<u16>,
1032    /// Luminosity in watts per square meter from `Lnnn`.
1033    pub luminosity_watts_per_square_meter: Option<u16>,
1034    /// Luminosity in watts per square meter from `lnnn`, representing 1000+.
1035    pub luminosity_1000_plus_watts_per_square_meter: Option<u16>,
1036    /// Snowfall in the last 24 hours, in inches.
1037    pub snow_last_24_hours_inches: Option<u16>,
1038    /// Raw rain counter value from `#nnn`.
1039    pub raw_rain_counter: Option<u16>,
1040}
1041
1042/// APRS telemetry report fields.
1043#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1044pub struct Telemetry<'a> {
1045    /// Telemetry sequence bytes.
1046    pub sequence: &'a [u8],
1047    /// Five analog telemetry value fields.
1048    pub analog: [&'a [u8]; 5],
1049    /// Optional eight-bit digital telemetry field.
1050    pub digital: Option<&'a [u8]>,
1051}
1052
1053impl Telemetry<'_> {
1054    /// Returns the numeric telemetry sequence number.
1055    #[must_use]
1056    pub fn sequence_number(&self) -> Option<u16> {
1057        parse_u16(self.sequence)
1058    }
1059
1060    /// Returns the five numeric analog telemetry values.
1061    #[must_use]
1062    pub fn analog_values(&self) -> Option<[u16; 5]> {
1063        Some([
1064            parse_u16(self.analog[0])?,
1065            parse_u16(self.analog[1])?,
1066            parse_u16(self.analog[2])?,
1067            parse_u16(self.analog[3])?,
1068            parse_u16(self.analog[4])?,
1069        ])
1070    }
1071
1072    /// Returns eight digital telemetry bits.
1073    #[must_use]
1074    pub fn digital_bits(&self) -> Option<[bool; 8]> {
1075        let digital = self.digital?;
1076        if digital.len() != 8 {
1077            return None;
1078        }
1079
1080        let mut bits = [false; 8];
1081        for (index, byte) in digital.iter().enumerate() {
1082            bits[index] = match byte {
1083                b'0' => false,
1084                b'1' => true,
1085                _ => return None,
1086            };
1087        }
1088
1089        Some(bits)
1090    }
1091}
1092
1093/// APRS telemetry metadata packet carried in an APRS message.
1094#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1095pub struct TelemetryMetadata<'a> {
1096    /// Nine-byte telemetry metadata addressee field.
1097    pub addressee: &'a [u8],
1098    /// Classified telemetry metadata subtype.
1099    pub kind: TelemetryMetadataKind,
1100    /// Metadata body bytes after the message separator.
1101    pub body: &'a [u8],
1102}
1103
1104impl<'a> TelemetryMetadata<'a> {
1105    /// Returns comma-separated metadata fields without lossy conversion.
1106    #[must_use]
1107    pub fn fields(&self) -> Vec<&'a [u8]> {
1108        self.body.split(|byte| *byte == b',').collect()
1109    }
1110}
1111
1112/// APRS telemetry metadata subtype.
1113#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1114pub enum TelemetryMetadataKind {
1115    /// `PARM.` parameter-name metadata.
1116    ParameterNames,
1117    /// `UNIT.` unit metadata.
1118    Units,
1119    /// `EQNS.` calibration/equation metadata.
1120    Equations,
1121    /// `BITS.` bit-sense/project metadata.
1122    BitSense,
1123}
1124
1125/// APRS query packet bytes.
1126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1127pub struct Query<'a> {
1128    /// Query bytes after the `?` data type identifier.
1129    pub query: &'a [u8],
1130}
1131
1132/// APRS station capabilities packet bytes.
1133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1134pub struct Capability<'a> {
1135    /// Capability body bytes after the `<` data type identifier.
1136    pub body: &'a [u8],
1137}
1138
1139/// APRS NMEA packet bytes.
1140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1141pub struct Nmea<'a> {
1142    /// NMEA sentence bytes after the `$` data type identifier.
1143    pub sentence: &'a [u8],
1144}
1145
1146impl Nmea<'_> {
1147    /// Returns the NMEA talker ID from the sentence address field.
1148    #[must_use]
1149    pub fn talker_id(&self) -> Option<&[u8]> {
1150        let address = self.address_field()?;
1151        (address.len() >= 2).then_some(&address[..2])
1152    }
1153
1154    /// Returns the NMEA sentence formatter ID from the sentence address field.
1155    #[must_use]
1156    pub fn sentence_id(&self) -> Option<&[u8]> {
1157        let address = self.address_field()?;
1158        (address.len() >= 5).then_some(&address[2..5])
1159    }
1160
1161    /// Returns data fields after the NMEA address field without the checksum.
1162    #[must_use]
1163    pub fn data_fields(&self) -> Vec<&[u8]> {
1164        let body = self.body_without_checksum();
1165        let mut fields = body.split(|byte| *byte == b',');
1166        let _address = fields.next();
1167        fields.collect()
1168    }
1169
1170    /// Returns checksum validation details when the sentence has `*HH` syntax.
1171    #[must_use]
1172    pub fn checksum(&self) -> Option<NmeaChecksum> {
1173        let separator = self.sentence.iter().rposition(|byte| *byte == b'*')?;
1174        let checksum = self.sentence.get(separator + 1..separator + 3)?;
1175        if checksum.len() != 2 || self.sentence.get(separator + 3).is_some() {
1176            return None;
1177        }
1178
1179        let expected = parse_hex_byte(checksum)?;
1180        let calculated = self.sentence[..separator]
1181            .iter()
1182            .fold(0u8, |accumulator, byte| accumulator ^ byte);
1183
1184        Some(NmeaChecksum {
1185            expected,
1186            calculated,
1187            valid: expected == calculated,
1188        })
1189    }
1190
1191    fn address_field(&self) -> Option<&[u8]> {
1192        let body = self.body_without_checksum();
1193        let end = body
1194            .iter()
1195            .position(|byte| *byte == b',')
1196            .unwrap_or(body.len());
1197        let address = &body[..end];
1198        (address.len() >= 5 && address.iter().all(u8::is_ascii_alphanumeric)).then_some(address)
1199    }
1200
1201    fn body_without_checksum(&self) -> &[u8] {
1202        match self.sentence.iter().rposition(|byte| *byte == b'*') {
1203            Some(separator) => &self.sentence[..separator],
1204            None => self.sentence,
1205        }
1206    }
1207}
1208
1209/// NMEA checksum validation details.
1210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1211pub struct NmeaChecksum {
1212    /// Checksum value supplied by the packet.
1213    pub expected: u8,
1214    /// Checksum calculated over bytes before `*`.
1215    pub calculated: u8,
1216    /// Whether supplied and calculated checksums match.
1217    pub valid: bool,
1218}
1219
1220/// APRS Mic-E packet bytes.
1221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1222pub struct MicE<'a> {
1223    /// Original Mic-E data type identifier byte.
1224    pub identifier: u8,
1225    /// Destination address bytes that carry Mic-E latitude/status data.
1226    pub destination: &'a [u8],
1227    /// Mic-E body bytes.
1228    pub body: &'a [u8],
1229    /// Destination-derived Mic-E status bits when the destination permits decoding.
1230    pub status: Option<MicEStatus>,
1231    /// Destination-derived six latitude digit nibbles when decodable.
1232    pub latitude_digits: Option<[u8; 6]>,
1233}
1234
1235impl MicE<'_> {
1236    /// Returns decoded Mic-E coordinates when destination and body bytes permit it.
1237    #[must_use]
1238    pub fn coordinates(&self) -> Option<Coordinates> {
1239        Some(Coordinates {
1240            latitude: decode_mic_e_latitude(self.destination)?,
1241            longitude: decode_mic_e_longitude(self.destination, self.body)?,
1242        })
1243    }
1244
1245    /// Returns decoded Mic-E speed and course when body bytes permit it.
1246    #[must_use]
1247    pub fn speed_course(&self) -> Option<MicESpeedCourse> {
1248        decode_mic_e_speed_course(self.body)
1249    }
1250
1251    /// Returns the Mic-E destination-derived message code when decodable.
1252    #[must_use]
1253    pub fn message_code(&self) -> Option<MicEMessageCode> {
1254        decode_mic_e_message_code(self.destination)
1255    }
1256}
1257
1258/// Mic-E destination-derived status bits.
1259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1260pub enum MicEStatus {
1261    /// Standard/custom status bit tuple from the first three destination bytes.
1262    Custom([bool; 3]),
1263}
1264
1265/// Mic-E destination-derived message code.
1266#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1267pub enum MicEMessageCode {
1268    /// Standard Mic-E message code.
1269    Standard(MicEStandardMessage),
1270    /// Custom Mic-E message code number from 0 through 6.
1271    Custom(u8),
1272    /// Emergency message code.
1273    Emergency,
1274}
1275
1276/// Standard Mic-E message code.
1277#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1278pub enum MicEStandardMessage {
1279    /// M0: Off Duty.
1280    OffDuty,
1281    /// M1: En Route.
1282    EnRoute,
1283    /// M2: In Service.
1284    InService,
1285    /// M3: Returning.
1286    Returning,
1287    /// M4: Committed.
1288    Committed,
1289    /// M5: Special.
1290    Special,
1291    /// M6: Priority.
1292    Priority,
1293}
1294
1295/// Mic-E speed/course extension.
1296#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1297pub struct MicESpeedCourse {
1298    /// Speed in knots.
1299    pub speed_knots: u16,
1300    /// Course in degrees as encoded by Mic-E.
1301    pub course_degrees: u16,
1302}
1303
1304/// APRS Maidenhead locator packet bytes.
1305#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1306pub struct Maidenhead<'a> {
1307    /// Six-byte Maidenhead locator.
1308    pub locator: &'a [u8],
1309    /// Remaining comment bytes.
1310    pub comment: &'a [u8],
1311}
1312
1313/// APRS user-defined packet fields.
1314#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1315pub struct UserDefined<'a> {
1316    /// One-byte user ID.
1317    pub user_id: u8,
1318    /// One-byte user-defined packet type.
1319    pub packet_type: u8,
1320    /// User-defined body bytes.
1321    pub body: &'a [u8],
1322}
1323
1324/// APRS third-party traffic packet bytes.
1325#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1326pub struct ThirdParty<'a> {
1327    /// Encapsulated third-party traffic bytes.
1328    pub body: &'a [u8],
1329}
1330
1331impl ThirdParty<'_> {
1332    /// Explicitly parses the encapsulated packet through the same codec boundary.
1333    pub fn nested_packet(&self) -> Result<ParsedPacket, ParseError> {
1334        parse_packet(self.body)
1335    }
1336}
1337
1338/// APRS data type identifier from the first payload byte.
1339#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1340pub enum DataTypeIdentifier {
1341    /// `!`: position without timestamp, no APRS messaging.
1342    PositionNoTimestamp,
1343    /// `=`: position without timestamp, APRS messaging supported.
1344    PositionNoTimestampMessaging,
1345    /// `/`: position with timestamp, no APRS messaging.
1346    PositionWithTimestamp,
1347    /// `@`: position with timestamp, APRS messaging supported.
1348    PositionWithTimestampMessaging,
1349    /// `>`: status.
1350    Status,
1351    /// `?`: query.
1352    Query,
1353    /// `<`: station capabilities.
1354    Capability,
1355    /// `:`: message, bulletin, or announcement.
1356    Message,
1357    /// `;`: object.
1358    Object,
1359    /// `)`: item.
1360    Item,
1361    /// `_`: weather report without position.
1362    Weather,
1363    /// `T`: telemetry.
1364    Telemetry,
1365    /// `$`: NMEA sentence.
1366    Nmea,
1367    /// ``` ` ```: current Mic-E data.
1368    MicECurrent,
1369    /// `'`: old Mic-E data.
1370    MicEOld,
1371    /// `[`: Maidenhead locator.
1372    Maidenhead,
1373    /// `{`: user-defined data.
1374    UserDefined,
1375    /// `}`: third-party traffic.
1376    ThirdParty,
1377    /// Any currently unclassified identifier byte.
1378    Unknown(u8),
1379}
1380
1381impl DataTypeIdentifier {
1382    fn from_byte(byte: u8) -> Self {
1383        match byte {
1384            b'!' => Self::PositionNoTimestamp,
1385            b'=' => Self::PositionNoTimestampMessaging,
1386            b'/' => Self::PositionWithTimestamp,
1387            b'@' => Self::PositionWithTimestampMessaging,
1388            b'>' => Self::Status,
1389            b'?' => Self::Query,
1390            b'<' => Self::Capability,
1391            b':' => Self::Message,
1392            b';' => Self::Object,
1393            b')' => Self::Item,
1394            b'_' => Self::Weather,
1395            b'T' => Self::Telemetry,
1396            b'$' => Self::Nmea,
1397            b'`' => Self::MicECurrent,
1398            b'\'' => Self::MicEOld,
1399            b'[' => Self::Maidenhead,
1400            b'{' => Self::UserDefined,
1401            b'}' => Self::ThirdParty,
1402            other => Self::Unknown(other),
1403        }
1404    }
1405
1406    fn as_byte(self) -> u8 {
1407        match self {
1408            Self::PositionNoTimestamp => b'!',
1409            Self::PositionNoTimestampMessaging => b'=',
1410            Self::PositionWithTimestamp => b'/',
1411            Self::PositionWithTimestampMessaging => b'@',
1412            Self::Status => b'>',
1413            Self::Query => b'?',
1414            Self::Capability => b'<',
1415            Self::Message => b':',
1416            Self::Object => b';',
1417            Self::Item => b')',
1418            Self::Weather => b'_',
1419            Self::Telemetry => b'T',
1420            Self::Nmea => b'$',
1421            Self::MicECurrent => b'`',
1422            Self::MicEOld => b'\'',
1423            Self::Maidenhead => b'[',
1424            Self::UserDefined => b'{',
1425            Self::ThirdParty => b'}',
1426            Self::Unknown(value) => value,
1427        }
1428    }
1429
1430    /// Returns a stable data type identifier name for diagnostics.
1431    #[must_use]
1432    pub fn name(self) -> &'static str {
1433        match self {
1434            Self::PositionNoTimestamp => "position_no_timestamp",
1435            Self::PositionNoTimestampMessaging => "position_no_timestamp_messaging",
1436            Self::PositionWithTimestamp => "position_with_timestamp",
1437            Self::PositionWithTimestampMessaging => "position_with_timestamp_messaging",
1438            Self::Status => "status",
1439            Self::Query => "query",
1440            Self::Capability => "capability",
1441            Self::Message => "message",
1442            Self::Object => "object",
1443            Self::Item => "item",
1444            Self::Weather => "weather",
1445            Self::Telemetry => "telemetry",
1446            Self::Nmea => "nmea",
1447            Self::MicECurrent => "mic_e_current",
1448            Self::MicEOld => "mic_e_old",
1449            Self::Maidenhead => "maidenhead",
1450            Self::UserDefined => "user_defined",
1451            Self::ThirdParty => "third_party",
1452            Self::Unknown(_) => "unknown",
1453        }
1454    }
1455}
1456
1457fn parse_aprs_data<'a>(
1458    identifier: DataTypeIdentifier,
1459    information: &'a [u8],
1460    destination: &'a [u8],
1461) -> AprsData<'a> {
1462    match identifier {
1463        DataTypeIdentifier::Status => AprsData::Status { text: information },
1464        DataTypeIdentifier::PositionNoTimestamp => parse_position(false, b'!', information),
1465        DataTypeIdentifier::PositionNoTimestampMessaging => parse_position(true, b'=', information),
1466        DataTypeIdentifier::PositionWithTimestamp => {
1467            parse_timestamped_position(false, b'/', information)
1468        }
1469        DataTypeIdentifier::PositionWithTimestampMessaging => {
1470            parse_timestamped_position(true, b'@', information)
1471        }
1472        DataTypeIdentifier::Message => parse_message(information),
1473        DataTypeIdentifier::Object => parse_object(information),
1474        DataTypeIdentifier::Item => parse_item(information),
1475        DataTypeIdentifier::Weather => AprsData::Weather(Weather {
1476            report: information,
1477        }),
1478        DataTypeIdentifier::Telemetry => parse_telemetry(information),
1479        DataTypeIdentifier::Query => AprsData::Query(Query { query: information }),
1480        DataTypeIdentifier::Capability => AprsData::Capability(Capability { body: information }),
1481        DataTypeIdentifier::Nmea => AprsData::Nmea(Nmea {
1482            sentence: information,
1483        }),
1484        DataTypeIdentifier::MicECurrent | DataTypeIdentifier::MicEOld => {
1485            parse_mic_e(identifier, information, destination)
1486        }
1487        DataTypeIdentifier::Maidenhead => parse_maidenhead(information),
1488        DataTypeIdentifier::UserDefined => parse_user_defined(information),
1489        DataTypeIdentifier::ThirdParty => AprsData::ThirdParty(ThirdParty { body: information }),
1490        other => AprsData::Unsupported {
1491            identifier: other.as_byte(),
1492            information,
1493        },
1494    }
1495}
1496
1497fn parse_mic_e<'a>(
1498    identifier: DataTypeIdentifier,
1499    information: &'a [u8],
1500    destination: &'a [u8],
1501) -> AprsData<'a> {
1502    if information.len() < 3 {
1503        return AprsData::Malformed {
1504            identifier: identifier.as_byte(),
1505            information,
1506        };
1507    }
1508
1509    AprsData::MicE(MicE {
1510        identifier: identifier.as_byte(),
1511        destination,
1512        body: information,
1513        status: decode_mic_e_status(destination),
1514        latitude_digits: decode_mic_e_latitude_digits(destination),
1515    })
1516}
1517
1518fn parse_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1519    if is_compressed_position(information) {
1520        return parse_compressed_position(messaging, identifier, information);
1521    }
1522
1523    if information.len() < 19 {
1524        return AprsData::Malformed {
1525            identifier,
1526            information,
1527        };
1528    }
1529
1530    let latitude = &information[..8];
1531    let symbol_table = information[8];
1532    let longitude = &information[9..18];
1533    let symbol_code = information[18];
1534    let comment = &information[19..];
1535
1536    if !is_latitude(latitude)
1537        || !is_symbol_table_identifier(symbol_table)
1538        || !is_longitude(longitude)
1539        || !is_printable_ascii(symbol_code)
1540    {
1541        return AprsData::Malformed {
1542            identifier,
1543            information,
1544        };
1545    }
1546
1547    AprsData::Position(Position {
1548        messaging,
1549        latitude,
1550        symbol_table,
1551        longitude,
1552        symbol_code,
1553        comment,
1554    })
1555}
1556
1557fn coordinates_from_position_body(body: &[u8]) -> Option<Coordinates> {
1558    if is_compressed_position(body) {
1559        let AprsData::CompressedPosition(position) = parse_compressed_position(false, b'!', body)
1560        else {
1561            return None;
1562        };
1563        return position.coordinates();
1564    }
1565
1566    let AprsData::Position(position) = parse_position(false, b'!', body) else {
1567        return None;
1568    };
1569    position.coordinates()
1570}
1571
1572fn parse_timestamped_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1573    if information.len() < 8 {
1574        return AprsData::Malformed {
1575            identifier,
1576            information,
1577        };
1578    }
1579
1580    let timestamp = &information[..7];
1581    if !is_timestamp(timestamp) {
1582        return AprsData::Malformed {
1583            identifier,
1584            information,
1585        };
1586    }
1587
1588    match parse_position(messaging, identifier, &information[7..]) {
1589        AprsData::Position(position) => AprsData::TimestampedPosition(TimestampedPosition {
1590            messaging,
1591            timestamp,
1592            position,
1593        }),
1594        AprsData::CompressedPosition(position) => AprsData::CompressedPosition(position),
1595        _ => AprsData::Malformed {
1596            identifier,
1597            information,
1598        },
1599    }
1600}
1601
1602fn parse_compressed_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1603    if information.len() < 13 {
1604        return AprsData::Malformed {
1605            identifier,
1606            information,
1607        };
1608    }
1609
1610    let symbol_table = information[0];
1611    let compressed_latitude = &information[1..5];
1612    let compressed_longitude = &information[5..9];
1613    let symbol_code = information[9];
1614    let extension = &information[10..12];
1615    let compression_type = information[12];
1616    let comment = &information[13..];
1617
1618    if !is_symbol_table_identifier(symbol_table)
1619        || !compressed_latitude.iter().all(|byte| is_base91(*byte))
1620        || !compressed_longitude.iter().all(|byte| is_base91(*byte))
1621        || !is_printable_ascii(symbol_code)
1622        || !extension.iter().all(|byte| is_base91(*byte))
1623        || !is_base91(compression_type)
1624    {
1625        return AprsData::Malformed {
1626            identifier,
1627            information,
1628        };
1629    }
1630
1631    AprsData::CompressedPosition(CompressedPosition {
1632        messaging,
1633        symbol_table,
1634        compressed_latitude,
1635        compressed_longitude,
1636        symbol_code,
1637        extension,
1638        compression_type,
1639        comment,
1640    })
1641}
1642
1643fn parse_object(information: &[u8]) -> AprsData<'_> {
1644    if information.len() < 17
1645        || !matches!(information[9], b'*' | b'_')
1646        || !is_timestamp(&information[10..17])
1647    {
1648        return AprsData::Malformed {
1649            identifier: b';',
1650            information,
1651        };
1652    }
1653
1654    AprsData::Object(Object {
1655        name: &information[..9],
1656        live: information[9] == b'*',
1657        timestamp: &information[10..17],
1658        body: &information[17..],
1659    })
1660}
1661
1662fn parse_item(information: &[u8]) -> AprsData<'_> {
1663    let Some(separator) = information
1664        .iter()
1665        .position(|byte| matches!(*byte, b'!' | b'_'))
1666    else {
1667        return AprsData::Malformed {
1668            identifier: b')',
1669            information,
1670        };
1671    };
1672
1673    if separator == 0 || separator > 9 {
1674        return AprsData::Malformed {
1675            identifier: b')',
1676            information,
1677        };
1678    }
1679
1680    AprsData::Item(Item {
1681        name: &information[..separator],
1682        live: information[separator] == b'!',
1683        body: &information[separator + 1..],
1684    })
1685}
1686
1687fn parse_message(information: &[u8]) -> AprsData<'_> {
1688    if information.len() < 10 || information[9] != b':' {
1689        return AprsData::Malformed {
1690            identifier: b':',
1691            information,
1692        };
1693    }
1694
1695    let addressee = &information[..9];
1696    let body = &information[10..];
1697    if let Some(kind) = classify_telemetry_metadata_kind(addressee) {
1698        return AprsData::TelemetryMetadata(TelemetryMetadata {
1699            addressee,
1700            kind,
1701            body,
1702        });
1703    }
1704
1705    let (text, id) = match body.iter().position(|byte| *byte == b'{') {
1706        Some(separator) => (&body[..separator], Some(&body[separator + 1..])),
1707        None => (body, None),
1708    };
1709    let kind = classify_message_kind(addressee, text);
1710
1711    AprsData::Message(Message {
1712        addressee,
1713        kind,
1714        text,
1715        id,
1716    })
1717}
1718
1719fn parse_telemetry(information: &[u8]) -> AprsData<'_> {
1720    if !information.starts_with(b"#") {
1721        return AprsData::Malformed {
1722            identifier: b'T',
1723            information,
1724        };
1725    }
1726
1727    let fields: Vec<&[u8]> = information[1..].split(|byte| *byte == b',').collect();
1728    if fields.len() < 6 || fields[..6].iter().any(|field| field.is_empty()) {
1729        return AprsData::Malformed {
1730            identifier: b'T',
1731            information,
1732        };
1733    }
1734
1735    AprsData::Telemetry(Telemetry {
1736        sequence: fields[0],
1737        analog: [fields[1], fields[2], fields[3], fields[4], fields[5]],
1738        digital: fields.get(6).copied().filter(|field| !field.is_empty()),
1739    })
1740}
1741
1742fn parse_maidenhead(information: &[u8]) -> AprsData<'_> {
1743    if information.len() < 6 || !is_maidenhead_locator(&information[..6]) {
1744        return AprsData::Malformed {
1745            identifier: b'[',
1746            information,
1747        };
1748    }
1749
1750    AprsData::Maidenhead(Maidenhead {
1751        locator: &information[..6],
1752        comment: &information[6..],
1753    })
1754}
1755
1756fn parse_user_defined(information: &[u8]) -> AprsData<'_> {
1757    if information.len() < 2 {
1758        return AprsData::Malformed {
1759            identifier: b'{',
1760            information,
1761        };
1762    }
1763
1764    AprsData::UserDefined(UserDefined {
1765        user_id: information[0],
1766        packet_type: information[1],
1767        body: &information[2..],
1768    })
1769}
1770
1771fn classify_telemetry_metadata_kind(addressee: &[u8]) -> Option<TelemetryMetadataKind> {
1772    match addressee.get(..5)? {
1773        b"PARM." => Some(TelemetryMetadataKind::ParameterNames),
1774        b"UNIT." => Some(TelemetryMetadataKind::Units),
1775        b"EQNS." => Some(TelemetryMetadataKind::Equations),
1776        b"BITS." => Some(TelemetryMetadataKind::BitSense),
1777        _ => None,
1778    }
1779}
1780
1781fn classify_message_kind(addressee: &[u8], text: &[u8]) -> MessageKind {
1782    if text.starts_with(b"ack") {
1783        MessageKind::Ack
1784    } else if text.starts_with(b"rej") {
1785        MessageKind::Reject
1786    } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_digit) {
1787        MessageKind::Bulletin
1788    } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_uppercase)
1789    {
1790        MessageKind::Announcement
1791    } else {
1792        MessageKind::Message
1793    }
1794}
1795
1796fn is_latitude(value: &[u8]) -> bool {
1797    if !(value.len() == 8
1798        && value[0].is_ascii_digit()
1799        && value[1].is_ascii_digit()
1800        && value[2].is_ascii_digit()
1801        && value[3].is_ascii_digit()
1802        && value[4] == b'.'
1803        && value[5].is_ascii_digit()
1804        && value[6].is_ascii_digit()
1805        && matches!(value[7], b'N' | b'S'))
1806    {
1807        return false;
1808    }
1809
1810    coordinate_in_range(&value[..2], &value[2..7], 90)
1811}
1812
1813fn is_longitude(value: &[u8]) -> bool {
1814    if !(value.len() == 9
1815        && value[0].is_ascii_digit()
1816        && value[1].is_ascii_digit()
1817        && value[2].is_ascii_digit()
1818        && value[3].is_ascii_digit()
1819        && value[4].is_ascii_digit()
1820        && value[5] == b'.'
1821        && value[6].is_ascii_digit()
1822        && value[7].is_ascii_digit()
1823        && matches!(value[8], b'E' | b'W'))
1824    {
1825        return false;
1826    }
1827
1828    coordinate_in_range(&value[..3], &value[3..8], 180)
1829}
1830
1831fn coordinate_in_range(degrees: &[u8], minutes: &[u8], max_degrees: u16) -> bool {
1832    let Some(degrees) = parse_u16(degrees) else {
1833        return false;
1834    };
1835    let Some(minutes) = parse_fixed_minutes(minutes) else {
1836        return false;
1837    };
1838
1839    degrees < max_degrees || (degrees == max_degrees && minutes == 0.0)
1840}
1841
1842fn is_symbol_table_identifier(value: u8) -> bool {
1843    matches!(value, b'/' | b'\\') || value.is_ascii_alphanumeric()
1844}
1845
1846fn is_printable_ascii(value: u8) -> bool {
1847    (0x20..=0x7e).contains(&value)
1848}
1849
1850fn is_base91(value: u8) -> bool {
1851    (b'!'..=b'{').contains(&value)
1852}
1853
1854fn is_compressed_position(information: &[u8]) -> bool {
1855    information
1856        .first()
1857        .is_some_and(|byte| !byte.is_ascii_digit() && is_symbol_table_identifier(*byte))
1858        && information
1859            .get(1..13)
1860            .is_some_and(|bytes| bytes.iter().all(|byte| is_base91(*byte)))
1861}
1862
1863fn is_timestamp(value: &[u8]) -> bool {
1864    value.len() == 7
1865        && value[..6].iter().all(u8::is_ascii_digit)
1866        && matches!(value[6], b'z' | b'/' | b'h')
1867}
1868
1869fn is_maidenhead_locator(value: &[u8]) -> bool {
1870    value.len() == 6
1871        && is_ascii_alpha_range(value[0], b'A', b'R')
1872        && is_ascii_alpha_range(value[1], b'A', b'R')
1873        && value[2].is_ascii_digit()
1874        && value[3].is_ascii_digit()
1875        && is_ascii_alpha_range(value[4], b'A', b'X')
1876        && is_ascii_alpha_range(value[5], b'A', b'X')
1877}
1878
1879fn is_ascii_alpha_range(value: u8, start: u8, end: u8) -> bool {
1880    let uppercase = value.to_ascii_uppercase();
1881    (start..=end).contains(&uppercase)
1882}
1883
1884fn decode_latitude(value: &[u8]) -> Option<f64> {
1885    if !is_latitude(value) {
1886        return None;
1887    }
1888
1889    let degrees = parse_u16(&value[..2])? as f64;
1890    let minutes = parse_fixed_minutes(&value[2..7])?;
1891    let sign = match value[7] {
1892        b'N' => 1.0,
1893        b'S' => -1.0,
1894        _ => return None,
1895    };
1896
1897    Some(sign * (degrees + minutes / 60.0))
1898}
1899
1900fn decode_longitude(value: &[u8]) -> Option<f64> {
1901    if !is_longitude(value) {
1902        return None;
1903    }
1904
1905    let degrees = parse_u16(&value[..3])? as f64;
1906    let minutes = parse_fixed_minutes(&value[3..8])?;
1907    let sign = match value[8] {
1908        b'E' => 1.0,
1909        b'W' => -1.0,
1910        _ => return None,
1911    };
1912
1913    Some(sign * (degrees + minutes / 60.0))
1914}
1915
1916fn parse_fixed_minutes(value: &[u8]) -> Option<f64> {
1917    if value.len() != 5 || value[2] != b'.' || !value[..2].iter().all(u8::is_ascii_digit) {
1918        return None;
1919    }
1920
1921    let whole = parse_u16(&value[..2])? as f64;
1922    let fraction = parse_u16(&value[3..])? as f64 / 100.0;
1923    Some(whole + fraction)
1924}
1925
1926fn decode_base91(value: &[u8]) -> Option<u32> {
1927    if value.len() != 4 || !value.iter().all(|byte| is_base91(*byte)) {
1928        return None;
1929    }
1930
1931    let mut decoded = 0u32;
1932    for byte in value {
1933        decoded = decoded * 91 + u32::from(byte - b'!');
1934    }
1935
1936    Some(decoded)
1937}
1938
1939fn parse_u16(value: &[u8]) -> Option<u16> {
1940    if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
1941        return None;
1942    }
1943
1944    let mut parsed = 0u16;
1945    for digit in value {
1946        parsed = parsed.checked_mul(10)?;
1947        parsed = parsed.checked_add(u16::from(digit - b'0'))?;
1948    }
1949
1950    Some(parsed)
1951}
1952
1953fn parse_i16(value: &[u8]) -> Option<i16> {
1954    if value.is_empty() {
1955        return None;
1956    }
1957
1958    let (sign, digits) = match value[0] {
1959        b'-' => (-1, &value[1..]),
1960        b'+' => (1, &value[1..]),
1961        _ => (1, value),
1962    };
1963
1964    let unsigned = parse_u16(digits)?;
1965    i16::try_from(unsigned).ok()?.checked_mul(sign)
1966}
1967
1968fn parse_hex_byte(value: &[u8]) -> Option<u8> {
1969    if value.len() != 2 {
1970        return None;
1971    }
1972
1973    Some(hex_value(value[0])? * 16 + hex_value(value[1])?)
1974}
1975
1976fn hex_value(value: u8) -> Option<u8> {
1977    match value {
1978        b'0'..=b'9' => Some(value - b'0'),
1979        b'A'..=b'F' => Some(value - b'A' + 10),
1980        b'a'..=b'f' => Some(value - b'a' + 10),
1981        _ => None,
1982    }
1983}
1984
1985fn parse_tagged_u16(report: &[u8], tag: u8, width: usize) -> Option<u16> {
1986    parse_tagged(report, tag, width).and_then(parse_u16)
1987}
1988
1989fn parse_tagged_i16(report: &[u8], tag: u8, width: usize) -> Option<i16> {
1990    parse_tagged(report, tag, width).and_then(parse_i16)
1991}
1992
1993fn parse_tagged(report: &[u8], tag: u8, width: usize) -> Option<&[u8]> {
1994    let start = report.iter().position(|byte| *byte == tag)? + 1;
1995    report.get(start..start + width)
1996}
1997
1998fn decode_mic_e_status(destination: &[u8]) -> Option<MicEStatus> {
1999    if destination.len() != 6 {
2000        return None;
2001    }
2002
2003    let bytes = destination.get(..3)?;
2004    Some(MicEStatus::Custom([
2005        mic_e_status_bit(bytes[0])?,
2006        mic_e_status_bit(bytes[1])?,
2007        mic_e_status_bit(bytes[2])?,
2008    ]))
2009}
2010
2011fn decode_mic_e_message_code(destination: &[u8]) -> Option<MicEMessageCode> {
2012    if destination.len() != 6 {
2013        return None;
2014    }
2015
2016    let mut bits = [MicEMessageBit::Zero; 3];
2017    for (index, byte) in destination[..3].iter().copied().enumerate() {
2018        bits[index] = mic_e_message_bit(byte)?;
2019    }
2020
2021    let code = message_code_number([
2022        !matches!(bits[0], MicEMessageBit::Zero),
2023        !matches!(bits[1], MicEMessageBit::Zero),
2024        !matches!(bits[2], MicEMessageBit::Zero),
2025    ]);
2026
2027    if code == 7 {
2028        return Some(MicEMessageCode::Emergency);
2029    }
2030
2031    let has_standard = bits
2032        .iter()
2033        .any(|bit| matches!(bit, MicEMessageBit::StandardOne));
2034    let has_custom = bits
2035        .iter()
2036        .any(|bit| matches!(bit, MicEMessageBit::CustomOne));
2037
2038    if has_standard && !has_custom {
2039        return standard_mic_e_message(code).map(MicEMessageCode::Standard);
2040    }
2041
2042    if has_custom && !has_standard {
2043        return Some(MicEMessageCode::Custom(code));
2044    }
2045
2046    None
2047}
2048
2049#[derive(Clone, Copy)]
2050enum MicEMessageBit {
2051    Zero,
2052    StandardOne,
2053    CustomOne,
2054}
2055
2056fn mic_e_message_bit(byte: u8) -> Option<MicEMessageBit> {
2057    match byte {
2058        b'0'..=b'9' | b'L' => Some(MicEMessageBit::Zero),
2059        b'A'..=b'K' => Some(MicEMessageBit::StandardOne),
2060        b'P'..=b'Z' => Some(MicEMessageBit::CustomOne),
2061        _ => None,
2062    }
2063}
2064
2065fn message_code_number(bits: [bool; 3]) -> u8 {
2066    match bits {
2067        [true, true, true] => 0,
2068        [true, true, false] => 1,
2069        [true, false, true] => 2,
2070        [true, false, false] => 3,
2071        [false, true, true] => 4,
2072        [false, true, false] => 5,
2073        [false, false, true] => 6,
2074        [false, false, false] => 7,
2075    }
2076}
2077
2078fn standard_mic_e_message(code: u8) -> Option<MicEStandardMessage> {
2079    match code {
2080        0 => Some(MicEStandardMessage::OffDuty),
2081        1 => Some(MicEStandardMessage::EnRoute),
2082        2 => Some(MicEStandardMessage::InService),
2083        3 => Some(MicEStandardMessage::Returning),
2084        4 => Some(MicEStandardMessage::Committed),
2085        5 => Some(MicEStandardMessage::Special),
2086        6 => Some(MicEStandardMessage::Priority),
2087        _ => None,
2088    }
2089}
2090
2091fn mic_e_status_bit(byte: u8) -> Option<bool> {
2092    match byte {
2093        b'0'..=b'9' | b'L' => Some(false),
2094        b'A'..=b'K' | b'P'..=b'Z' => Some(true),
2095        _ => None,
2096    }
2097}
2098
2099fn decode_mic_e_latitude_digits(destination: &[u8]) -> Option<[u8; 6]> {
2100    if destination.len() != 6 {
2101        return None;
2102    }
2103
2104    let mut digits = [0u8; 6];
2105    for (index, byte) in destination.iter().copied().enumerate() {
2106        digits[index] = mic_e_latitude_digit(byte)?;
2107    }
2108
2109    Some(digits)
2110}
2111
2112fn mic_e_latitude_digit(byte: u8) -> Option<u8> {
2113    match byte {
2114        b'0'..=b'9' => Some(byte - b'0'),
2115        b'A'..=b'J' => Some(byte - b'A'),
2116        b'P'..=b'Y' => Some(byte - b'P'),
2117        b'K' | b'L' | b'Z' => Some(0),
2118        _ => None,
2119    }
2120}
2121
2122fn decode_mic_e_latitude(destination: &[u8]) -> Option<f64> {
2123    let digits = decode_mic_e_latitude_digits(destination)?;
2124    let degrees = u16::from(digits[0]) * 10 + u16::from(digits[1]);
2125    let minutes = u16::from(digits[2]) * 10 + u16::from(digits[3]);
2126    let hundredths = u16::from(digits[4]) * 10 + u16::from(digits[5]);
2127    if degrees > 90 || minutes > 59 {
2128        return None;
2129    }
2130
2131    let sign = if mic_e_north(destination[3])? {
2132        1.0
2133    } else {
2134        -1.0
2135    };
2136    Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
2137}
2138
2139fn decode_mic_e_longitude(destination: &[u8], body: &[u8]) -> Option<f64> {
2140    if destination.len() != 6 || body.len() < 3 {
2141        return None;
2142    }
2143
2144    let mut degrees = i16::from(mic_e_body_value(body[0])?);
2145    if mic_e_longitude_offset(destination[4])? {
2146        degrees += 100;
2147    }
2148    if (180..=189).contains(&degrees) {
2149        degrees -= 80;
2150    } else if (190..=199).contains(&degrees) {
2151        degrees -= 190;
2152    }
2153
2154    let minutes = mic_e_body_value(body[1])?;
2155    let hundredths = mic_e_body_value(body[2])?;
2156    if !(0..=179).contains(&degrees) || minutes > 59 || hundredths > 99 {
2157        return None;
2158    }
2159
2160    let sign = if mic_e_west(destination[5])? {
2161        -1.0
2162    } else {
2163        1.0
2164    };
2165    Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
2166}
2167
2168fn decode_mic_e_speed_course(body: &[u8]) -> Option<MicESpeedCourse> {
2169    if body.len() < 6 {
2170        return None;
2171    }
2172
2173    let speed_tens = u16::from(mic_e_body_value(body[3])?);
2174    let speed_units_course_hundreds = u16::from(mic_e_body_value(body[4])?);
2175    let course_remainder = u16::from(mic_e_body_value(body[5])?);
2176    let mut speed_knots = speed_tens * 10 + speed_units_course_hundreds / 10;
2177    if speed_knots >= 800 {
2178        speed_knots -= 800;
2179    }
2180
2181    Some(MicESpeedCourse {
2182        speed_knots,
2183        course_degrees: (speed_units_course_hundreds % 10) * 100 + course_remainder,
2184    })
2185}
2186
2187fn mic_e_body_value(byte: u8) -> Option<u8> {
2188    let value = byte.checked_sub(28)?;
2189    (value <= 99).then_some(value)
2190}
2191
2192fn mic_e_north(byte: u8) -> Option<bool> {
2193    match byte {
2194        b'0'..=b'9' | b'A'..=b'L' => Some(false),
2195        b'P'..=b'Z' => Some(true),
2196        _ => None,
2197    }
2198}
2199
2200fn mic_e_longitude_offset(byte: u8) -> Option<bool> {
2201    match byte {
2202        b'0'..=b'9' | b'A'..=b'L' => Some(false),
2203        b'P'..=b'Z' => Some(true),
2204        _ => None,
2205    }
2206}
2207
2208fn mic_e_west(byte: u8) -> Option<bool> {
2209    match byte {
2210        b'0'..=b'9' | b'A'..=b'L' => Some(false),
2211        b'P'..=b'Z' => Some(true),
2212        _ => None,
2213    }
2214}
2215
2216/// Fail-closed packet parse errors.
2217#[derive(Clone, Debug, Eq, PartialEq)]
2218pub enum ParseError {
2219    /// No bytes were supplied.
2220    Empty,
2221    /// Packet exceeds [`MAX_PACKET_LEN`].
2222    Oversized,
2223    /// Packet does not contain the required APRS `>` and `:` separators.
2224    MissingSeparator,
2225    /// Packet contains an empty source, path, or payload segment.
2226    EmptySegment,
2227    /// Packet source or path contains bytes outside the conservative address set.
2228    InvalidAddress,
2229}
2230
2231impl ParseError {
2232    /// Returns a stable parse error code for logs and external systems.
2233    #[must_use]
2234    pub fn code(&self) -> &'static str {
2235        match self {
2236            Self::Empty => "parse.empty",
2237            Self::Oversized => "parse.oversized",
2238            Self::MissingSeparator => "parse.missing_separator",
2239            Self::EmptySegment => "parse.empty_segment",
2240            Self::InvalidAddress => "parse.invalid_address",
2241        }
2242    }
2243
2244    /// Returns structured parse error metadata for operator diagnostics.
2245    #[must_use]
2246    pub fn diagnostic(&self) -> ErrorDiagnostic {
2247        match self {
2248            Self::Empty => ErrorDiagnostic {
2249                layer: DiagnosticLayer::Parse,
2250                code: self.code(),
2251                name: "empty",
2252                description: "no packet bytes were supplied to the codec boundary",
2253                remediation: "drop empty transport records before calling parse_packet",
2254            },
2255            Self::Oversized => ErrorDiagnostic {
2256                layer: DiagnosticLayer::Parse,
2257                code: self.code(),
2258                name: "oversized",
2259                description: "packet exceeds the configured parser byte limit",
2260                remediation: "reject the input or lower upstream batch sizes before parsing",
2261            },
2262            Self::MissingSeparator => ErrorDiagnostic {
2263                layer: DiagnosticLayer::Parse,
2264                code: self.code(),
2265                name: "missing_separator",
2266                description: "packet is missing the required source>path:payload separators",
2267                remediation: "only send source>path:payload APRS packet bytes into the codec",
2268            },
2269            Self::EmptySegment => ErrorDiagnostic {
2270                layer: DiagnosticLayer::Parse,
2271                code: self.code(),
2272                name: "empty_segment",
2273                description: "packet contains an empty source, path, or payload segment",
2274                remediation: "reject the input and inspect upstream framing before retrying",
2275            },
2276            Self::InvalidAddress => ErrorDiagnostic {
2277                layer: DiagnosticLayer::Parse,
2278                code: self.code(),
2279                name: "invalid_address",
2280                description:
2281                    "packet source or path contains bytes outside the conservative address set",
2282                remediation:
2283                    "preserve the raw bytes for review and reject malformed address metadata",
2284            },
2285        }
2286    }
2287}
2288
2289/// Parses an APRS packet from untrusted bytes.
2290///
2291/// This parser intentionally validates only the minimal frame shape for the
2292/// skeleton: `source>path:payload`. Payload bytes are opaque and may be invalid
2293/// UTF-8.
2294pub fn parse_packet(input: &[u8]) -> Result<ParsedPacket, ParseError> {
2295    parse_packet_with_options(input, ParseOptions::default())
2296}
2297
2298/// Parses an APRS packet from untrusted bytes with explicit codec options.
2299pub fn parse_packet_with_options(
2300    input: &[u8],
2301    options: ParseOptions,
2302) -> Result<ParsedPacket, ParseError> {
2303    if input.is_empty() {
2304        return Err(ParseError::Empty);
2305    }
2306
2307    if input.len() > options.max_packet_len {
2308        return Err(ParseError::Oversized);
2309    }
2310
2311    let source_end = input
2312        .iter()
2313        .position(|byte| *byte == b'>')
2314        .ok_or(ParseError::MissingSeparator)?;
2315    let payload_separator = input[source_end + 1..]
2316        .iter()
2317        .position(|byte| *byte == b':')
2318        .map(|offset| source_end + 1 + offset)
2319        .ok_or(ParseError::MissingSeparator)?;
2320
2321    let path_start = source_end + 1;
2322    let path_end = payload_separator;
2323    let payload_start = payload_separator + 1;
2324
2325    if source_end == 0 || path_start == path_end || payload_start == input.len() {
2326        return Err(ParseError::EmptySegment);
2327    }
2328
2329    let Some(path_components) = path_component_ranges(input, path_start, path_end) else {
2330        return Err(ParseError::InvalidAddress);
2331    };
2332
2333    if !is_ax25_like_source(&input[..source_end])
2334        || !path_components
2335            .iter()
2336            .all(|(start, end)| is_ax25_like_path_component(&input[*start..*end]))
2337    {
2338        return Err(ParseError::InvalidAddress);
2339    }
2340
2341    Ok(ParsedPacket {
2342        raw: RawPacket {
2343            bytes: input.to_vec(),
2344        },
2345        source_end,
2346        path_start,
2347        path_end,
2348        path_components,
2349        payload_start,
2350    })
2351}
2352
2353fn path_component_ranges(
2354    input: &[u8],
2355    path_start: usize,
2356    path_end: usize,
2357) -> Option<Vec<(usize, usize)>> {
2358    let mut components = Vec::new();
2359    let mut component_start = path_start;
2360
2361    for (offset, byte) in input[path_start..path_end].iter().enumerate() {
2362        if *byte == b',' {
2363            let index = path_start + offset;
2364            if component_start == index {
2365                return None;
2366            }
2367            components.push((component_start, index));
2368            component_start = index + 1;
2369        }
2370    }
2371
2372    if component_start == path_end {
2373        return None;
2374    }
2375
2376    components.push((component_start, path_end));
2377    Some(components)
2378}
2379
2380fn is_ax25_like_source(source: &[u8]) -> bool {
2381    is_ax25_like_address(source, false)
2382}
2383
2384fn is_ax25_like_path_component(component: &[u8]) -> bool {
2385    is_ax25_like_address(component, true)
2386}
2387
2388fn is_ax25_like_address(address: &[u8], allow_repeated_marker: bool) -> bool {
2389    let address = if allow_repeated_marker {
2390        address.strip_suffix(b"*").unwrap_or(address)
2391    } else {
2392        address
2393    };
2394
2395    if address.is_empty() || address.contains(&b'*') {
2396        return false;
2397    }
2398
2399    let (callsign, ssid) = match address.iter().position(|byte| *byte == b'-') {
2400        Some(separator) => (&address[..separator], Some(&address[separator + 1..])),
2401        None => (address, None),
2402    };
2403
2404    is_ax25_like_callsign(callsign) && ssid.map_or(true, is_ax25_like_ssid)
2405}
2406
2407fn is_ax25_like_callsign(callsign: &[u8]) -> bool {
2408    (1..=6).contains(&callsign.len())
2409        && callsign
2410            .iter()
2411            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
2412}
2413
2414fn is_ax25_like_ssid(ssid: &[u8]) -> bool {
2415    if ssid.is_empty() || ssid.len() > 2 || !ssid.iter().all(u8::is_ascii_digit) {
2416        return false;
2417    }
2418
2419    let mut value = 0u8;
2420    for digit in ssid {
2421        value = value * 10 + (digit - b'0');
2422    }
2423
2424    value <= 15
2425}