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
645impl Object<'_> {
646    /// Returns object coordinates when the object body starts with a supported
647    /// APRS position encoding.
648    #[must_use]
649    pub fn coordinates(&self) -> Option<Coordinates> {
650        coordinates_from_position_body(self.body)
651    }
652}
653
654/// APRS item report fields.
655#[derive(Clone, Copy, Debug, Eq, PartialEq)]
656pub struct Item<'a> {
657    /// Item name bytes.
658    pub name: &'a [u8],
659    /// Whether the item is live (`!`) rather than killed (`_`).
660    pub live: bool,
661    /// Remaining item body bytes.
662    pub body: &'a [u8],
663}
664
665impl Item<'_> {
666    /// Returns item coordinates when the item body starts with a supported APRS
667    /// position encoding.
668    #[must_use]
669    pub fn coordinates(&self) -> Option<Coordinates> {
670        coordinates_from_position_body(self.body)
671    }
672}
673
674/// APRS weather report bytes.
675#[derive(Clone, Copy, Debug, Eq, PartialEq)]
676pub struct Weather<'a> {
677    /// Weather report bytes after the `_` data type identifier.
678    pub report: &'a [u8],
679}
680
681impl Weather<'_> {
682    /// Extracts common numeric weather fields when present.
683    #[must_use]
684    pub fn fields(&self) -> WeatherFields<'_> {
685        WeatherFields {
686            timestamp: self
687                .report
688                .get(..6)
689                .filter(|value| value.iter().all(u8::is_ascii_digit)),
690            wind_direction_degrees: parse_tagged_u16(self.report, b'c', 3),
691            wind_speed_mph: parse_tagged_u16(self.report, b's', 3),
692            wind_gust_mph: parse_tagged_u16(self.report, b'g', 3),
693            temperature_fahrenheit: parse_tagged_i16(self.report, b't', 3),
694            rain_last_hour_hundredths_inch: parse_tagged_u16(self.report, b'r', 3),
695            rain_last_24_hours_hundredths_inch: parse_tagged_u16(self.report, b'p', 3),
696            rain_since_midnight_hundredths_inch: parse_tagged_u16(self.report, b'P', 3),
697            humidity_percent: parse_tagged_u16(self.report, b'h', 2).map(|value| {
698                if value == 0 {
699                    100
700                } else {
701                    value
702                }
703            }),
704            pressure_tenths_hpa: parse_tagged_u16(self.report, b'b', 5),
705            luminosity_watts_per_square_meter: parse_tagged_u16(self.report, b'L', 3),
706            luminosity_1000_plus_watts_per_square_meter: parse_tagged_u16(self.report, b'l', 3)
707                .map(|value| value + 1000),
708            snow_last_24_hours_inches: parse_tagged_u16(self.report, b'S', 3),
709            raw_rain_counter: parse_tagged_u16(self.report, b'#', 3),
710        }
711    }
712}
713
714/// Extracted numeric weather fields.
715#[derive(Clone, Copy, Debug, Eq, PartialEq)]
716pub struct WeatherFields<'a> {
717    /// Optional six-byte timestamp prefix.
718    pub timestamp: Option<&'a [u8]>,
719    /// Wind direction in degrees.
720    pub wind_direction_degrees: Option<u16>,
721    /// Sustained wind speed in miles per hour.
722    pub wind_speed_mph: Option<u16>,
723    /// Wind gust speed in miles per hour.
724    pub wind_gust_mph: Option<u16>,
725    /// Temperature in degrees Fahrenheit.
726    pub temperature_fahrenheit: Option<i16>,
727    /// Rain in the last hour, in hundredths of an inch.
728    pub rain_last_hour_hundredths_inch: Option<u16>,
729    /// Rain in the last 24 hours, in hundredths of an inch.
730    pub rain_last_24_hours_hundredths_inch: Option<u16>,
731    /// Rain since midnight, in hundredths of an inch.
732    pub rain_since_midnight_hundredths_inch: Option<u16>,
733    /// Relative humidity percent.
734    pub humidity_percent: Option<u16>,
735    /// Barometric pressure in tenths of hPa.
736    pub pressure_tenths_hpa: Option<u16>,
737    /// Luminosity in watts per square meter from `Lnnn`.
738    pub luminosity_watts_per_square_meter: Option<u16>,
739    /// Luminosity in watts per square meter from `lnnn`, representing 1000+.
740    pub luminosity_1000_plus_watts_per_square_meter: Option<u16>,
741    /// Snowfall in the last 24 hours, in inches.
742    pub snow_last_24_hours_inches: Option<u16>,
743    /// Raw rain counter value from `#nnn`.
744    pub raw_rain_counter: Option<u16>,
745}
746
747/// APRS telemetry report fields.
748#[derive(Clone, Copy, Debug, Eq, PartialEq)]
749pub struct Telemetry<'a> {
750    /// Telemetry sequence bytes.
751    pub sequence: &'a [u8],
752    /// Five analog telemetry value fields.
753    pub analog: [&'a [u8]; 5],
754    /// Optional eight-bit digital telemetry field.
755    pub digital: Option<&'a [u8]>,
756}
757
758impl Telemetry<'_> {
759    /// Returns the numeric telemetry sequence number.
760    #[must_use]
761    pub fn sequence_number(&self) -> Option<u16> {
762        parse_u16(self.sequence)
763    }
764
765    /// Returns the five numeric analog telemetry values.
766    #[must_use]
767    pub fn analog_values(&self) -> Option<[u16; 5]> {
768        Some([
769            parse_u16(self.analog[0])?,
770            parse_u16(self.analog[1])?,
771            parse_u16(self.analog[2])?,
772            parse_u16(self.analog[3])?,
773            parse_u16(self.analog[4])?,
774        ])
775    }
776
777    /// Returns eight digital telemetry bits.
778    #[must_use]
779    pub fn digital_bits(&self) -> Option<[bool; 8]> {
780        let digital = self.digital?;
781        if digital.len() != 8 {
782            return None;
783        }
784
785        let mut bits = [false; 8];
786        for (index, byte) in digital.iter().enumerate() {
787            bits[index] = match byte {
788                b'0' => false,
789                b'1' => true,
790                _ => return None,
791            };
792        }
793
794        Some(bits)
795    }
796}
797
798/// APRS telemetry metadata packet carried in an APRS message.
799#[derive(Clone, Copy, Debug, Eq, PartialEq)]
800pub struct TelemetryMetadata<'a> {
801    /// Nine-byte telemetry metadata addressee field.
802    pub addressee: &'a [u8],
803    /// Classified telemetry metadata subtype.
804    pub kind: TelemetryMetadataKind,
805    /// Metadata body bytes after the message separator.
806    pub body: &'a [u8],
807}
808
809impl<'a> TelemetryMetadata<'a> {
810    /// Returns comma-separated metadata fields without lossy conversion.
811    #[must_use]
812    pub fn fields(&self) -> Vec<&'a [u8]> {
813        self.body.split(|byte| *byte == b',').collect()
814    }
815}
816
817/// APRS telemetry metadata subtype.
818#[derive(Clone, Copy, Debug, Eq, PartialEq)]
819pub enum TelemetryMetadataKind {
820    /// `PARM.` parameter-name metadata.
821    ParameterNames,
822    /// `UNIT.` unit metadata.
823    Units,
824    /// `EQNS.` calibration/equation metadata.
825    Equations,
826    /// `BITS.` bit-sense/project metadata.
827    BitSense,
828}
829
830/// APRS query packet bytes.
831#[derive(Clone, Copy, Debug, Eq, PartialEq)]
832pub struct Query<'a> {
833    /// Query bytes after the `?` data type identifier.
834    pub query: &'a [u8],
835}
836
837/// APRS station capabilities packet bytes.
838#[derive(Clone, Copy, Debug, Eq, PartialEq)]
839pub struct Capability<'a> {
840    /// Capability body bytes after the `<` data type identifier.
841    pub body: &'a [u8],
842}
843
844/// APRS NMEA packet bytes.
845#[derive(Clone, Copy, Debug, Eq, PartialEq)]
846pub struct Nmea<'a> {
847    /// NMEA sentence bytes after the `$` data type identifier.
848    pub sentence: &'a [u8],
849}
850
851impl Nmea<'_> {
852    /// Returns the NMEA talker ID from the sentence address field.
853    #[must_use]
854    pub fn talker_id(&self) -> Option<&[u8]> {
855        let address = self.address_field()?;
856        (address.len() >= 2).then_some(&address[..2])
857    }
858
859    /// Returns the NMEA sentence formatter ID from the sentence address field.
860    #[must_use]
861    pub fn sentence_id(&self) -> Option<&[u8]> {
862        let address = self.address_field()?;
863        (address.len() >= 5).then_some(&address[2..5])
864    }
865
866    /// Returns data fields after the NMEA address field without the checksum.
867    #[must_use]
868    pub fn data_fields(&self) -> Vec<&[u8]> {
869        let body = self.body_without_checksum();
870        let mut fields = body.split(|byte| *byte == b',');
871        let _address = fields.next();
872        fields.collect()
873    }
874
875    /// Returns checksum validation details when the sentence has `*HH` syntax.
876    #[must_use]
877    pub fn checksum(&self) -> Option<NmeaChecksum> {
878        let separator = self.sentence.iter().rposition(|byte| *byte == b'*')?;
879        let checksum = self.sentence.get(separator + 1..separator + 3)?;
880        if checksum.len() != 2 || self.sentence.get(separator + 3).is_some() {
881            return None;
882        }
883
884        let expected = parse_hex_byte(checksum)?;
885        let calculated = self.sentence[..separator]
886            .iter()
887            .fold(0u8, |accumulator, byte| accumulator ^ byte);
888
889        Some(NmeaChecksum {
890            expected,
891            calculated,
892            valid: expected == calculated,
893        })
894    }
895
896    fn address_field(&self) -> Option<&[u8]> {
897        let body = self.body_without_checksum();
898        let end = body
899            .iter()
900            .position(|byte| *byte == b',')
901            .unwrap_or(body.len());
902        let address = &body[..end];
903        (address.len() >= 5 && address.iter().all(u8::is_ascii_alphanumeric)).then_some(address)
904    }
905
906    fn body_without_checksum(&self) -> &[u8] {
907        match self.sentence.iter().rposition(|byte| *byte == b'*') {
908            Some(separator) => &self.sentence[..separator],
909            None => self.sentence,
910        }
911    }
912}
913
914/// NMEA checksum validation details.
915#[derive(Clone, Copy, Debug, Eq, PartialEq)]
916pub struct NmeaChecksum {
917    /// Checksum value supplied by the packet.
918    pub expected: u8,
919    /// Checksum calculated over bytes before `*`.
920    pub calculated: u8,
921    /// Whether supplied and calculated checksums match.
922    pub valid: bool,
923}
924
925/// APRS Mic-E packet bytes.
926#[derive(Clone, Copy, Debug, Eq, PartialEq)]
927pub struct MicE<'a> {
928    /// Original Mic-E data type identifier byte.
929    pub identifier: u8,
930    /// Destination address bytes that carry Mic-E latitude/status data.
931    pub destination: &'a [u8],
932    /// Mic-E body bytes.
933    pub body: &'a [u8],
934    /// Destination-derived Mic-E status bits when the destination permits decoding.
935    pub status: Option<MicEStatus>,
936    /// Destination-derived six latitude digit nibbles when decodable.
937    pub latitude_digits: Option<[u8; 6]>,
938}
939
940impl MicE<'_> {
941    /// Returns decoded Mic-E coordinates when destination and body bytes permit it.
942    #[must_use]
943    pub fn coordinates(&self) -> Option<Coordinates> {
944        Some(Coordinates {
945            latitude: decode_mic_e_latitude(self.destination)?,
946            longitude: decode_mic_e_longitude(self.destination, self.body)?,
947        })
948    }
949
950    /// Returns decoded Mic-E speed and course when body bytes permit it.
951    #[must_use]
952    pub fn speed_course(&self) -> Option<MicESpeedCourse> {
953        decode_mic_e_speed_course(self.body)
954    }
955
956    /// Returns the Mic-E destination-derived message code when decodable.
957    #[must_use]
958    pub fn message_code(&self) -> Option<MicEMessageCode> {
959        decode_mic_e_message_code(self.destination)
960    }
961}
962
963/// Mic-E destination-derived status bits.
964#[derive(Clone, Copy, Debug, Eq, PartialEq)]
965pub enum MicEStatus {
966    /// Standard/custom status bit tuple from the first three destination bytes.
967    Custom([bool; 3]),
968}
969
970/// Mic-E destination-derived message code.
971#[derive(Clone, Copy, Debug, Eq, PartialEq)]
972pub enum MicEMessageCode {
973    /// Standard Mic-E message code.
974    Standard(MicEStandardMessage),
975    /// Custom Mic-E message code number from 0 through 6.
976    Custom(u8),
977    /// Emergency message code.
978    Emergency,
979}
980
981/// Standard Mic-E message code.
982#[derive(Clone, Copy, Debug, Eq, PartialEq)]
983pub enum MicEStandardMessage {
984    /// M0: Off Duty.
985    OffDuty,
986    /// M1: En Route.
987    EnRoute,
988    /// M2: In Service.
989    InService,
990    /// M3: Returning.
991    Returning,
992    /// M4: Committed.
993    Committed,
994    /// M5: Special.
995    Special,
996    /// M6: Priority.
997    Priority,
998}
999
1000/// Mic-E speed/course extension.
1001#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1002pub struct MicESpeedCourse {
1003    /// Speed in knots.
1004    pub speed_knots: u16,
1005    /// Course in degrees as encoded by Mic-E.
1006    pub course_degrees: u16,
1007}
1008
1009/// APRS Maidenhead locator packet bytes.
1010#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1011pub struct Maidenhead<'a> {
1012    /// Six-byte Maidenhead locator.
1013    pub locator: &'a [u8],
1014    /// Remaining comment bytes.
1015    pub comment: &'a [u8],
1016}
1017
1018/// APRS user-defined packet fields.
1019#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1020pub struct UserDefined<'a> {
1021    /// One-byte user ID.
1022    pub user_id: u8,
1023    /// One-byte user-defined packet type.
1024    pub packet_type: u8,
1025    /// User-defined body bytes.
1026    pub body: &'a [u8],
1027}
1028
1029/// APRS third-party traffic packet bytes.
1030#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1031pub struct ThirdParty<'a> {
1032    /// Encapsulated third-party traffic bytes.
1033    pub body: &'a [u8],
1034}
1035
1036impl ThirdParty<'_> {
1037    /// Explicitly parses the encapsulated packet through the same codec boundary.
1038    pub fn nested_packet(&self) -> Result<ParsedPacket, ParseError> {
1039        parse_packet(self.body)
1040    }
1041}
1042
1043/// APRS data type identifier from the first payload byte.
1044#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1045pub enum DataTypeIdentifier {
1046    /// `!`: position without timestamp, no APRS messaging.
1047    PositionNoTimestamp,
1048    /// `=`: position without timestamp, APRS messaging supported.
1049    PositionNoTimestampMessaging,
1050    /// `/`: position with timestamp, no APRS messaging.
1051    PositionWithTimestamp,
1052    /// `@`: position with timestamp, APRS messaging supported.
1053    PositionWithTimestampMessaging,
1054    /// `>`: status.
1055    Status,
1056    /// `?`: query.
1057    Query,
1058    /// `<`: station capabilities.
1059    Capability,
1060    /// `:`: message, bulletin, or announcement.
1061    Message,
1062    /// `;`: object.
1063    Object,
1064    /// `)`: item.
1065    Item,
1066    /// `_`: weather report without position.
1067    Weather,
1068    /// `T`: telemetry.
1069    Telemetry,
1070    /// `$`: NMEA sentence.
1071    Nmea,
1072    /// ``` ` ```: current Mic-E data.
1073    MicECurrent,
1074    /// `'`: old Mic-E data.
1075    MicEOld,
1076    /// `[`: Maidenhead locator.
1077    Maidenhead,
1078    /// `{`: user-defined data.
1079    UserDefined,
1080    /// `}`: third-party traffic.
1081    ThirdParty,
1082    /// Any currently unclassified identifier byte.
1083    Unknown(u8),
1084}
1085
1086impl DataTypeIdentifier {
1087    fn from_byte(byte: u8) -> Self {
1088        match byte {
1089            b'!' => Self::PositionNoTimestamp,
1090            b'=' => Self::PositionNoTimestampMessaging,
1091            b'/' => Self::PositionWithTimestamp,
1092            b'@' => Self::PositionWithTimestampMessaging,
1093            b'>' => Self::Status,
1094            b'?' => Self::Query,
1095            b'<' => Self::Capability,
1096            b':' => Self::Message,
1097            b';' => Self::Object,
1098            b')' => Self::Item,
1099            b'_' => Self::Weather,
1100            b'T' => Self::Telemetry,
1101            b'$' => Self::Nmea,
1102            b'`' => Self::MicECurrent,
1103            b'\'' => Self::MicEOld,
1104            b'[' => Self::Maidenhead,
1105            b'{' => Self::UserDefined,
1106            b'}' => Self::ThirdParty,
1107            other => Self::Unknown(other),
1108        }
1109    }
1110
1111    fn as_byte(self) -> u8 {
1112        match self {
1113            Self::PositionNoTimestamp => b'!',
1114            Self::PositionNoTimestampMessaging => b'=',
1115            Self::PositionWithTimestamp => b'/',
1116            Self::PositionWithTimestampMessaging => b'@',
1117            Self::Status => b'>',
1118            Self::Query => b'?',
1119            Self::Capability => b'<',
1120            Self::Message => b':',
1121            Self::Object => b';',
1122            Self::Item => b')',
1123            Self::Weather => b'_',
1124            Self::Telemetry => b'T',
1125            Self::Nmea => b'$',
1126            Self::MicECurrent => b'`',
1127            Self::MicEOld => b'\'',
1128            Self::Maidenhead => b'[',
1129            Self::UserDefined => b'{',
1130            Self::ThirdParty => b'}',
1131            Self::Unknown(value) => value,
1132        }
1133    }
1134
1135    /// Returns a stable data type identifier name for diagnostics.
1136    #[must_use]
1137    pub fn name(self) -> &'static str {
1138        match self {
1139            Self::PositionNoTimestamp => "position_no_timestamp",
1140            Self::PositionNoTimestampMessaging => "position_no_timestamp_messaging",
1141            Self::PositionWithTimestamp => "position_with_timestamp",
1142            Self::PositionWithTimestampMessaging => "position_with_timestamp_messaging",
1143            Self::Status => "status",
1144            Self::Query => "query",
1145            Self::Capability => "capability",
1146            Self::Message => "message",
1147            Self::Object => "object",
1148            Self::Item => "item",
1149            Self::Weather => "weather",
1150            Self::Telemetry => "telemetry",
1151            Self::Nmea => "nmea",
1152            Self::MicECurrent => "mic_e_current",
1153            Self::MicEOld => "mic_e_old",
1154            Self::Maidenhead => "maidenhead",
1155            Self::UserDefined => "user_defined",
1156            Self::ThirdParty => "third_party",
1157            Self::Unknown(_) => "unknown",
1158        }
1159    }
1160}
1161
1162fn parse_aprs_data<'a>(
1163    identifier: DataTypeIdentifier,
1164    information: &'a [u8],
1165    destination: &'a [u8],
1166) -> AprsData<'a> {
1167    match identifier {
1168        DataTypeIdentifier::Status => AprsData::Status { text: information },
1169        DataTypeIdentifier::PositionNoTimestamp => parse_position(false, b'!', information),
1170        DataTypeIdentifier::PositionNoTimestampMessaging => parse_position(true, b'=', information),
1171        DataTypeIdentifier::PositionWithTimestamp => {
1172            parse_timestamped_position(false, b'/', information)
1173        }
1174        DataTypeIdentifier::PositionWithTimestampMessaging => {
1175            parse_timestamped_position(true, b'@', information)
1176        }
1177        DataTypeIdentifier::Message => parse_message(information),
1178        DataTypeIdentifier::Object => parse_object(information),
1179        DataTypeIdentifier::Item => parse_item(information),
1180        DataTypeIdentifier::Weather => AprsData::Weather(Weather {
1181            report: information,
1182        }),
1183        DataTypeIdentifier::Telemetry => parse_telemetry(information),
1184        DataTypeIdentifier::Query => AprsData::Query(Query { query: information }),
1185        DataTypeIdentifier::Capability => AprsData::Capability(Capability { body: information }),
1186        DataTypeIdentifier::Nmea => AprsData::Nmea(Nmea {
1187            sentence: information,
1188        }),
1189        DataTypeIdentifier::MicECurrent | DataTypeIdentifier::MicEOld => {
1190            parse_mic_e(identifier, information, destination)
1191        }
1192        DataTypeIdentifier::Maidenhead => parse_maidenhead(information),
1193        DataTypeIdentifier::UserDefined => parse_user_defined(information),
1194        DataTypeIdentifier::ThirdParty => AprsData::ThirdParty(ThirdParty { body: information }),
1195        other => AprsData::Unsupported {
1196            identifier: other.as_byte(),
1197            information,
1198        },
1199    }
1200}
1201
1202fn parse_mic_e<'a>(
1203    identifier: DataTypeIdentifier,
1204    information: &'a [u8],
1205    destination: &'a [u8],
1206) -> AprsData<'a> {
1207    if information.len() < 3 {
1208        return AprsData::Malformed {
1209            identifier: identifier.as_byte(),
1210            information,
1211        };
1212    }
1213
1214    AprsData::MicE(MicE {
1215        identifier: identifier.as_byte(),
1216        destination,
1217        body: information,
1218        status: decode_mic_e_status(destination),
1219        latitude_digits: decode_mic_e_latitude_digits(destination),
1220    })
1221}
1222
1223fn parse_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1224    if is_compressed_position(information) {
1225        return parse_compressed_position(messaging, identifier, information);
1226    }
1227
1228    if information.len() < 19 {
1229        return AprsData::Malformed {
1230            identifier,
1231            information,
1232        };
1233    }
1234
1235    let latitude = &information[..8];
1236    let symbol_table = information[8];
1237    let longitude = &information[9..18];
1238    let symbol_code = information[18];
1239    let comment = &information[19..];
1240
1241    if !is_latitude(latitude)
1242        || !is_symbol_table_identifier(symbol_table)
1243        || !is_longitude(longitude)
1244        || !is_printable_ascii(symbol_code)
1245    {
1246        return AprsData::Malformed {
1247            identifier,
1248            information,
1249        };
1250    }
1251
1252    AprsData::Position(Position {
1253        messaging,
1254        latitude,
1255        symbol_table,
1256        longitude,
1257        symbol_code,
1258        comment,
1259    })
1260}
1261
1262fn coordinates_from_position_body(body: &[u8]) -> Option<Coordinates> {
1263    if is_compressed_position(body) {
1264        let AprsData::CompressedPosition(position) = parse_compressed_position(false, b'!', body)
1265        else {
1266            return None;
1267        };
1268        return position.coordinates();
1269    }
1270
1271    let AprsData::Position(position) = parse_position(false, b'!', body) else {
1272        return None;
1273    };
1274    position.coordinates()
1275}
1276
1277fn parse_timestamped_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1278    if information.len() < 8 {
1279        return AprsData::Malformed {
1280            identifier,
1281            information,
1282        };
1283    }
1284
1285    let timestamp = &information[..7];
1286    if !is_timestamp(timestamp) {
1287        return AprsData::Malformed {
1288            identifier,
1289            information,
1290        };
1291    }
1292
1293    match parse_position(messaging, identifier, &information[7..]) {
1294        AprsData::Position(position) => AprsData::TimestampedPosition(TimestampedPosition {
1295            messaging,
1296            timestamp,
1297            position,
1298        }),
1299        AprsData::CompressedPosition(position) => AprsData::CompressedPosition(position),
1300        _ => AprsData::Malformed {
1301            identifier,
1302            information,
1303        },
1304    }
1305}
1306
1307fn parse_compressed_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1308    if information.len() < 13 {
1309        return AprsData::Malformed {
1310            identifier,
1311            information,
1312        };
1313    }
1314
1315    let symbol_table = information[0];
1316    let compressed_latitude = &information[1..5];
1317    let compressed_longitude = &information[5..9];
1318    let symbol_code = information[9];
1319    let extension = &information[10..12];
1320    let compression_type = information[12];
1321    let comment = &information[13..];
1322
1323    if !is_symbol_table_identifier(symbol_table)
1324        || !compressed_latitude.iter().all(|byte| is_base91(*byte))
1325        || !compressed_longitude.iter().all(|byte| is_base91(*byte))
1326        || !is_printable_ascii(symbol_code)
1327        || !extension.iter().all(|byte| is_base91(*byte))
1328        || !is_base91(compression_type)
1329    {
1330        return AprsData::Malformed {
1331            identifier,
1332            information,
1333        };
1334    }
1335
1336    AprsData::CompressedPosition(CompressedPosition {
1337        messaging,
1338        symbol_table,
1339        compressed_latitude,
1340        compressed_longitude,
1341        symbol_code,
1342        extension,
1343        compression_type,
1344        comment,
1345    })
1346}
1347
1348fn parse_object(information: &[u8]) -> AprsData<'_> {
1349    if information.len() < 17
1350        || !matches!(information[9], b'*' | b'_')
1351        || !is_timestamp(&information[10..17])
1352    {
1353        return AprsData::Malformed {
1354            identifier: b';',
1355            information,
1356        };
1357    }
1358
1359    AprsData::Object(Object {
1360        name: &information[..9],
1361        live: information[9] == b'*',
1362        timestamp: &information[10..17],
1363        body: &information[17..],
1364    })
1365}
1366
1367fn parse_item(information: &[u8]) -> AprsData<'_> {
1368    let Some(separator) = information
1369        .iter()
1370        .position(|byte| matches!(*byte, b'!' | b'_'))
1371    else {
1372        return AprsData::Malformed {
1373            identifier: b')',
1374            information,
1375        };
1376    };
1377
1378    if separator == 0 || separator > 9 {
1379        return AprsData::Malformed {
1380            identifier: b')',
1381            information,
1382        };
1383    }
1384
1385    AprsData::Item(Item {
1386        name: &information[..separator],
1387        live: information[separator] == b'!',
1388        body: &information[separator + 1..],
1389    })
1390}
1391
1392fn parse_message(information: &[u8]) -> AprsData<'_> {
1393    if information.len() < 10 || information[9] != b':' {
1394        return AprsData::Malformed {
1395            identifier: b':',
1396            information,
1397        };
1398    }
1399
1400    let addressee = &information[..9];
1401    let body = &information[10..];
1402    if let Some(kind) = classify_telemetry_metadata_kind(addressee) {
1403        return AprsData::TelemetryMetadata(TelemetryMetadata {
1404            addressee,
1405            kind,
1406            body,
1407        });
1408    }
1409
1410    let (text, id) = match body.iter().position(|byte| *byte == b'{') {
1411        Some(separator) => (&body[..separator], Some(&body[separator + 1..])),
1412        None => (body, None),
1413    };
1414    let kind = classify_message_kind(addressee, text);
1415
1416    AprsData::Message(Message {
1417        addressee,
1418        kind,
1419        text,
1420        id,
1421    })
1422}
1423
1424fn parse_telemetry(information: &[u8]) -> AprsData<'_> {
1425    if !information.starts_with(b"#") {
1426        return AprsData::Malformed {
1427            identifier: b'T',
1428            information,
1429        };
1430    }
1431
1432    let fields: Vec<&[u8]> = information[1..].split(|byte| *byte == b',').collect();
1433    if fields.len() < 6 || fields[..6].iter().any(|field| field.is_empty()) {
1434        return AprsData::Malformed {
1435            identifier: b'T',
1436            information,
1437        };
1438    }
1439
1440    AprsData::Telemetry(Telemetry {
1441        sequence: fields[0],
1442        analog: [fields[1], fields[2], fields[3], fields[4], fields[5]],
1443        digital: fields.get(6).copied().filter(|field| !field.is_empty()),
1444    })
1445}
1446
1447fn parse_maidenhead(information: &[u8]) -> AprsData<'_> {
1448    if information.len() < 6 || !is_maidenhead_locator(&information[..6]) {
1449        return AprsData::Malformed {
1450            identifier: b'[',
1451            information,
1452        };
1453    }
1454
1455    AprsData::Maidenhead(Maidenhead {
1456        locator: &information[..6],
1457        comment: &information[6..],
1458    })
1459}
1460
1461fn parse_user_defined(information: &[u8]) -> AprsData<'_> {
1462    if information.len() < 2 {
1463        return AprsData::Malformed {
1464            identifier: b'{',
1465            information,
1466        };
1467    }
1468
1469    AprsData::UserDefined(UserDefined {
1470        user_id: information[0],
1471        packet_type: information[1],
1472        body: &information[2..],
1473    })
1474}
1475
1476fn classify_telemetry_metadata_kind(addressee: &[u8]) -> Option<TelemetryMetadataKind> {
1477    match addressee.get(..5)? {
1478        b"PARM." => Some(TelemetryMetadataKind::ParameterNames),
1479        b"UNIT." => Some(TelemetryMetadataKind::Units),
1480        b"EQNS." => Some(TelemetryMetadataKind::Equations),
1481        b"BITS." => Some(TelemetryMetadataKind::BitSense),
1482        _ => None,
1483    }
1484}
1485
1486fn classify_message_kind(addressee: &[u8], text: &[u8]) -> MessageKind {
1487    if text.starts_with(b"ack") {
1488        MessageKind::Ack
1489    } else if text.starts_with(b"rej") {
1490        MessageKind::Reject
1491    } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_digit) {
1492        MessageKind::Bulletin
1493    } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_uppercase)
1494    {
1495        MessageKind::Announcement
1496    } else {
1497        MessageKind::Message
1498    }
1499}
1500
1501fn is_latitude(value: &[u8]) -> bool {
1502    if !(value.len() == 8
1503        && value[0].is_ascii_digit()
1504        && value[1].is_ascii_digit()
1505        && value[2].is_ascii_digit()
1506        && value[3].is_ascii_digit()
1507        && value[4] == b'.'
1508        && value[5].is_ascii_digit()
1509        && value[6].is_ascii_digit()
1510        && matches!(value[7], b'N' | b'S'))
1511    {
1512        return false;
1513    }
1514
1515    coordinate_in_range(&value[..2], &value[2..7], 90)
1516}
1517
1518fn is_longitude(value: &[u8]) -> bool {
1519    if !(value.len() == 9
1520        && value[0].is_ascii_digit()
1521        && value[1].is_ascii_digit()
1522        && value[2].is_ascii_digit()
1523        && value[3].is_ascii_digit()
1524        && value[4].is_ascii_digit()
1525        && value[5] == b'.'
1526        && value[6].is_ascii_digit()
1527        && value[7].is_ascii_digit()
1528        && matches!(value[8], b'E' | b'W'))
1529    {
1530        return false;
1531    }
1532
1533    coordinate_in_range(&value[..3], &value[3..8], 180)
1534}
1535
1536fn coordinate_in_range(degrees: &[u8], minutes: &[u8], max_degrees: u16) -> bool {
1537    let Some(degrees) = parse_u16(degrees) else {
1538        return false;
1539    };
1540    let Some(minutes) = parse_fixed_minutes(minutes) else {
1541        return false;
1542    };
1543
1544    degrees < max_degrees || (degrees == max_degrees && minutes == 0.0)
1545}
1546
1547fn is_symbol_table_identifier(value: u8) -> bool {
1548    matches!(value, b'/' | b'\\') || value.is_ascii_alphanumeric()
1549}
1550
1551fn is_printable_ascii(value: u8) -> bool {
1552    (0x20..=0x7e).contains(&value)
1553}
1554
1555fn is_base91(value: u8) -> bool {
1556    (b'!'..=b'{').contains(&value)
1557}
1558
1559fn is_compressed_position(information: &[u8]) -> bool {
1560    information
1561        .first()
1562        .is_some_and(|byte| !byte.is_ascii_digit() && is_symbol_table_identifier(*byte))
1563        && information
1564            .get(1..13)
1565            .is_some_and(|bytes| bytes.iter().all(|byte| is_base91(*byte)))
1566}
1567
1568fn is_timestamp(value: &[u8]) -> bool {
1569    value.len() == 7
1570        && value[..6].iter().all(u8::is_ascii_digit)
1571        && matches!(value[6], b'z' | b'/' | b'h')
1572}
1573
1574fn is_maidenhead_locator(value: &[u8]) -> bool {
1575    value.len() == 6
1576        && is_ascii_alpha_range(value[0], b'A', b'R')
1577        && is_ascii_alpha_range(value[1], b'A', b'R')
1578        && value[2].is_ascii_digit()
1579        && value[3].is_ascii_digit()
1580        && is_ascii_alpha_range(value[4], b'A', b'X')
1581        && is_ascii_alpha_range(value[5], b'A', b'X')
1582}
1583
1584fn is_ascii_alpha_range(value: u8, start: u8, end: u8) -> bool {
1585    let uppercase = value.to_ascii_uppercase();
1586    (start..=end).contains(&uppercase)
1587}
1588
1589fn decode_latitude(value: &[u8]) -> Option<f64> {
1590    if !is_latitude(value) {
1591        return None;
1592    }
1593
1594    let degrees = parse_u16(&value[..2])? as f64;
1595    let minutes = parse_fixed_minutes(&value[2..7])?;
1596    let sign = match value[7] {
1597        b'N' => 1.0,
1598        b'S' => -1.0,
1599        _ => return None,
1600    };
1601
1602    Some(sign * (degrees + minutes / 60.0))
1603}
1604
1605fn decode_longitude(value: &[u8]) -> Option<f64> {
1606    if !is_longitude(value) {
1607        return None;
1608    }
1609
1610    let degrees = parse_u16(&value[..3])? as f64;
1611    let minutes = parse_fixed_minutes(&value[3..8])?;
1612    let sign = match value[8] {
1613        b'E' => 1.0,
1614        b'W' => -1.0,
1615        _ => return None,
1616    };
1617
1618    Some(sign * (degrees + minutes / 60.0))
1619}
1620
1621fn parse_fixed_minutes(value: &[u8]) -> Option<f64> {
1622    if value.len() != 5 || value[2] != b'.' || !value[..2].iter().all(u8::is_ascii_digit) {
1623        return None;
1624    }
1625
1626    let whole = parse_u16(&value[..2])? as f64;
1627    let fraction = parse_u16(&value[3..])? as f64 / 100.0;
1628    Some(whole + fraction)
1629}
1630
1631fn decode_base91(value: &[u8]) -> Option<u32> {
1632    if value.len() != 4 || !value.iter().all(|byte| is_base91(*byte)) {
1633        return None;
1634    }
1635
1636    let mut decoded = 0u32;
1637    for byte in value {
1638        decoded = decoded * 91 + u32::from(byte - b'!');
1639    }
1640
1641    Some(decoded)
1642}
1643
1644fn parse_u16(value: &[u8]) -> Option<u16> {
1645    if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
1646        return None;
1647    }
1648
1649    let mut parsed = 0u16;
1650    for digit in value {
1651        parsed = parsed.checked_mul(10)?;
1652        parsed = parsed.checked_add(u16::from(digit - b'0'))?;
1653    }
1654
1655    Some(parsed)
1656}
1657
1658fn parse_i16(value: &[u8]) -> Option<i16> {
1659    if value.is_empty() {
1660        return None;
1661    }
1662
1663    let (sign, digits) = match value[0] {
1664        b'-' => (-1, &value[1..]),
1665        b'+' => (1, &value[1..]),
1666        _ => (1, value),
1667    };
1668
1669    let unsigned = parse_u16(digits)?;
1670    i16::try_from(unsigned).ok()?.checked_mul(sign)
1671}
1672
1673fn parse_hex_byte(value: &[u8]) -> Option<u8> {
1674    if value.len() != 2 {
1675        return None;
1676    }
1677
1678    Some(hex_value(value[0])? * 16 + hex_value(value[1])?)
1679}
1680
1681fn hex_value(value: u8) -> Option<u8> {
1682    match value {
1683        b'0'..=b'9' => Some(value - b'0'),
1684        b'A'..=b'F' => Some(value - b'A' + 10),
1685        b'a'..=b'f' => Some(value - b'a' + 10),
1686        _ => None,
1687    }
1688}
1689
1690fn parse_tagged_u16(report: &[u8], tag: u8, width: usize) -> Option<u16> {
1691    parse_tagged(report, tag, width).and_then(parse_u16)
1692}
1693
1694fn parse_tagged_i16(report: &[u8], tag: u8, width: usize) -> Option<i16> {
1695    parse_tagged(report, tag, width).and_then(parse_i16)
1696}
1697
1698fn parse_tagged(report: &[u8], tag: u8, width: usize) -> Option<&[u8]> {
1699    let start = report.iter().position(|byte| *byte == tag)? + 1;
1700    report.get(start..start + width)
1701}
1702
1703fn decode_mic_e_status(destination: &[u8]) -> Option<MicEStatus> {
1704    if destination.len() != 6 {
1705        return None;
1706    }
1707
1708    let bytes = destination.get(..3)?;
1709    Some(MicEStatus::Custom([
1710        mic_e_status_bit(bytes[0])?,
1711        mic_e_status_bit(bytes[1])?,
1712        mic_e_status_bit(bytes[2])?,
1713    ]))
1714}
1715
1716fn decode_mic_e_message_code(destination: &[u8]) -> Option<MicEMessageCode> {
1717    if destination.len() != 6 {
1718        return None;
1719    }
1720
1721    let mut bits = [MicEMessageBit::Zero; 3];
1722    for (index, byte) in destination[..3].iter().copied().enumerate() {
1723        bits[index] = mic_e_message_bit(byte)?;
1724    }
1725
1726    let code = message_code_number([
1727        !matches!(bits[0], MicEMessageBit::Zero),
1728        !matches!(bits[1], MicEMessageBit::Zero),
1729        !matches!(bits[2], MicEMessageBit::Zero),
1730    ]);
1731
1732    if code == 7 {
1733        return Some(MicEMessageCode::Emergency);
1734    }
1735
1736    let has_standard = bits
1737        .iter()
1738        .any(|bit| matches!(bit, MicEMessageBit::StandardOne));
1739    let has_custom = bits
1740        .iter()
1741        .any(|bit| matches!(bit, MicEMessageBit::CustomOne));
1742
1743    if has_standard && !has_custom {
1744        return standard_mic_e_message(code).map(MicEMessageCode::Standard);
1745    }
1746
1747    if has_custom && !has_standard {
1748        return Some(MicEMessageCode::Custom(code));
1749    }
1750
1751    None
1752}
1753
1754#[derive(Clone, Copy)]
1755enum MicEMessageBit {
1756    Zero,
1757    StandardOne,
1758    CustomOne,
1759}
1760
1761fn mic_e_message_bit(byte: u8) -> Option<MicEMessageBit> {
1762    match byte {
1763        b'0'..=b'9' | b'L' => Some(MicEMessageBit::Zero),
1764        b'A'..=b'K' => Some(MicEMessageBit::StandardOne),
1765        b'P'..=b'Z' => Some(MicEMessageBit::CustomOne),
1766        _ => None,
1767    }
1768}
1769
1770fn message_code_number(bits: [bool; 3]) -> u8 {
1771    match bits {
1772        [true, true, true] => 0,
1773        [true, true, false] => 1,
1774        [true, false, true] => 2,
1775        [true, false, false] => 3,
1776        [false, true, true] => 4,
1777        [false, true, false] => 5,
1778        [false, false, true] => 6,
1779        [false, false, false] => 7,
1780    }
1781}
1782
1783fn standard_mic_e_message(code: u8) -> Option<MicEStandardMessage> {
1784    match code {
1785        0 => Some(MicEStandardMessage::OffDuty),
1786        1 => Some(MicEStandardMessage::EnRoute),
1787        2 => Some(MicEStandardMessage::InService),
1788        3 => Some(MicEStandardMessage::Returning),
1789        4 => Some(MicEStandardMessage::Committed),
1790        5 => Some(MicEStandardMessage::Special),
1791        6 => Some(MicEStandardMessage::Priority),
1792        _ => None,
1793    }
1794}
1795
1796fn mic_e_status_bit(byte: u8) -> Option<bool> {
1797    match byte {
1798        b'0'..=b'9' | b'L' => Some(false),
1799        b'A'..=b'K' | b'P'..=b'Z' => Some(true),
1800        _ => None,
1801    }
1802}
1803
1804fn decode_mic_e_latitude_digits(destination: &[u8]) -> Option<[u8; 6]> {
1805    if destination.len() != 6 {
1806        return None;
1807    }
1808
1809    let mut digits = [0u8; 6];
1810    for (index, byte) in destination.iter().copied().enumerate() {
1811        digits[index] = mic_e_latitude_digit(byte)?;
1812    }
1813
1814    Some(digits)
1815}
1816
1817fn mic_e_latitude_digit(byte: u8) -> Option<u8> {
1818    match byte {
1819        b'0'..=b'9' => Some(byte - b'0'),
1820        b'A'..=b'J' => Some(byte - b'A'),
1821        b'P'..=b'Y' => Some(byte - b'P'),
1822        b'K' | b'L' | b'Z' => Some(0),
1823        _ => None,
1824    }
1825}
1826
1827fn decode_mic_e_latitude(destination: &[u8]) -> Option<f64> {
1828    let digits = decode_mic_e_latitude_digits(destination)?;
1829    let degrees = u16::from(digits[0]) * 10 + u16::from(digits[1]);
1830    let minutes = u16::from(digits[2]) * 10 + u16::from(digits[3]);
1831    let hundredths = u16::from(digits[4]) * 10 + u16::from(digits[5]);
1832    if degrees > 90 || minutes > 59 {
1833        return None;
1834    }
1835
1836    let sign = if mic_e_north(destination[3])? {
1837        1.0
1838    } else {
1839        -1.0
1840    };
1841    Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
1842}
1843
1844fn decode_mic_e_longitude(destination: &[u8], body: &[u8]) -> Option<f64> {
1845    if destination.len() != 6 || body.len() < 3 {
1846        return None;
1847    }
1848
1849    let mut degrees = i16::from(mic_e_body_value(body[0])?);
1850    if mic_e_longitude_offset(destination[4])? {
1851        degrees += 100;
1852    }
1853    if (180..=189).contains(&degrees) {
1854        degrees -= 80;
1855    } else if (190..=199).contains(&degrees) {
1856        degrees -= 190;
1857    }
1858
1859    let minutes = mic_e_body_value(body[1])?;
1860    let hundredths = mic_e_body_value(body[2])?;
1861    if !(0..=179).contains(&degrees) || minutes > 59 || hundredths > 99 {
1862        return None;
1863    }
1864
1865    let sign = if mic_e_west(destination[5])? {
1866        -1.0
1867    } else {
1868        1.0
1869    };
1870    Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
1871}
1872
1873fn decode_mic_e_speed_course(body: &[u8]) -> Option<MicESpeedCourse> {
1874    if body.len() < 6 {
1875        return None;
1876    }
1877
1878    let speed_tens = u16::from(mic_e_body_value(body[3])?);
1879    let speed_units_course_hundreds = u16::from(mic_e_body_value(body[4])?);
1880    let course_remainder = u16::from(mic_e_body_value(body[5])?);
1881    let mut speed_knots = speed_tens * 10 + speed_units_course_hundreds / 10;
1882    if speed_knots >= 800 {
1883        speed_knots -= 800;
1884    }
1885
1886    Some(MicESpeedCourse {
1887        speed_knots,
1888        course_degrees: (speed_units_course_hundreds % 10) * 100 + course_remainder,
1889    })
1890}
1891
1892fn mic_e_body_value(byte: u8) -> Option<u8> {
1893    let value = byte.checked_sub(28)?;
1894    (value <= 99).then_some(value)
1895}
1896
1897fn mic_e_north(byte: u8) -> Option<bool> {
1898    match byte {
1899        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1900        b'P'..=b'Z' => Some(true),
1901        _ => None,
1902    }
1903}
1904
1905fn mic_e_longitude_offset(byte: u8) -> Option<bool> {
1906    match byte {
1907        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1908        b'P'..=b'Z' => Some(true),
1909        _ => None,
1910    }
1911}
1912
1913fn mic_e_west(byte: u8) -> Option<bool> {
1914    match byte {
1915        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1916        b'P'..=b'Z' => Some(true),
1917        _ => None,
1918    }
1919}
1920
1921/// Fail-closed packet parse errors.
1922#[derive(Clone, Debug, Eq, PartialEq)]
1923pub enum ParseError {
1924    /// No bytes were supplied.
1925    Empty,
1926    /// Packet exceeds [`MAX_PACKET_LEN`].
1927    Oversized,
1928    /// Packet does not contain the required APRS `>` and `:` separators.
1929    MissingSeparator,
1930    /// Packet contains an empty source, path, or payload segment.
1931    EmptySegment,
1932    /// Packet source or path contains bytes outside the conservative address set.
1933    InvalidAddress,
1934}
1935
1936impl ParseError {
1937    /// Returns a stable parse error code for logs and external systems.
1938    #[must_use]
1939    pub fn code(&self) -> &'static str {
1940        match self {
1941            Self::Empty => "parse.empty",
1942            Self::Oversized => "parse.oversized",
1943            Self::MissingSeparator => "parse.missing_separator",
1944            Self::EmptySegment => "parse.empty_segment",
1945            Self::InvalidAddress => "parse.invalid_address",
1946        }
1947    }
1948}
1949
1950/// Parses an APRS packet from untrusted bytes.
1951///
1952/// This parser intentionally validates only the minimal frame shape for the
1953/// skeleton: `source>path:payload`. Payload bytes are opaque and may be invalid
1954/// UTF-8.
1955pub fn parse_packet(input: &[u8]) -> Result<ParsedPacket, ParseError> {
1956    parse_packet_with_options(input, ParseOptions::default())
1957}
1958
1959/// Parses an APRS packet from untrusted bytes with explicit codec options.
1960pub fn parse_packet_with_options(
1961    input: &[u8],
1962    options: ParseOptions,
1963) -> Result<ParsedPacket, ParseError> {
1964    if input.is_empty() {
1965        return Err(ParseError::Empty);
1966    }
1967
1968    if input.len() > options.max_packet_len {
1969        return Err(ParseError::Oversized);
1970    }
1971
1972    let source_end = input
1973        .iter()
1974        .position(|byte| *byte == b'>')
1975        .ok_or(ParseError::MissingSeparator)?;
1976    let payload_separator = input[source_end + 1..]
1977        .iter()
1978        .position(|byte| *byte == b':')
1979        .map(|offset| source_end + 1 + offset)
1980        .ok_or(ParseError::MissingSeparator)?;
1981
1982    let path_start = source_end + 1;
1983    let path_end = payload_separator;
1984    let payload_start = payload_separator + 1;
1985
1986    if source_end == 0 || path_start == path_end || payload_start == input.len() {
1987        return Err(ParseError::EmptySegment);
1988    }
1989
1990    let Some(path_components) = path_component_ranges(input, path_start, path_end) else {
1991        return Err(ParseError::InvalidAddress);
1992    };
1993
1994    if !is_ax25_like_source(&input[..source_end])
1995        || !path_components
1996            .iter()
1997            .all(|(start, end)| is_ax25_like_path_component(&input[*start..*end]))
1998    {
1999        return Err(ParseError::InvalidAddress);
2000    }
2001
2002    Ok(ParsedPacket {
2003        raw: RawPacket {
2004            bytes: input.to_vec(),
2005        },
2006        source_end,
2007        path_start,
2008        path_end,
2009        path_components,
2010        payload_start,
2011    })
2012}
2013
2014fn path_component_ranges(
2015    input: &[u8],
2016    path_start: usize,
2017    path_end: usize,
2018) -> Option<Vec<(usize, usize)>> {
2019    let mut components = Vec::new();
2020    let mut component_start = path_start;
2021
2022    for (offset, byte) in input[path_start..path_end].iter().enumerate() {
2023        if *byte == b',' {
2024            let index = path_start + offset;
2025            if component_start == index {
2026                return None;
2027            }
2028            components.push((component_start, index));
2029            component_start = index + 1;
2030        }
2031    }
2032
2033    if component_start == path_end {
2034        return None;
2035    }
2036
2037    components.push((component_start, path_end));
2038    Some(components)
2039}
2040
2041fn is_ax25_like_source(source: &[u8]) -> bool {
2042    is_ax25_like_address(source, false)
2043}
2044
2045fn is_ax25_like_path_component(component: &[u8]) -> bool {
2046    is_ax25_like_address(component, true)
2047}
2048
2049fn is_ax25_like_address(address: &[u8], allow_repeated_marker: bool) -> bool {
2050    let address = if allow_repeated_marker {
2051        address.strip_suffix(b"*").unwrap_or(address)
2052    } else {
2053        address
2054    };
2055
2056    if address.is_empty() || address.contains(&b'*') {
2057        return false;
2058    }
2059
2060    let (callsign, ssid) = match address.iter().position(|byte| *byte == b'-') {
2061        Some(separator) => (&address[..separator], Some(&address[separator + 1..])),
2062        None => (address, None),
2063    };
2064
2065    is_ax25_like_callsign(callsign) && ssid.map_or(true, is_ax25_like_ssid)
2066}
2067
2068fn is_ax25_like_callsign(callsign: &[u8]) -> bool {
2069    (1..=6).contains(&callsign.len())
2070        && callsign
2071            .iter()
2072            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
2073}
2074
2075fn is_ax25_like_ssid(ssid: &[u8]) -> bool {
2076    if ssid.is_empty() || ssid.len() > 2 || !ssid.iter().all(u8::is_ascii_digit) {
2077        return false;
2078    }
2079
2080    let mut value = 0u8;
2081    for digit in ssid {
2082        value = value * 10 + (digit - b'0');
2083    }
2084
2085    value <= 15
2086}