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