Skip to main content

libaprs_engine/
lib.rs

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