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