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