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