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    AprsData::MicE(MicE {
1208        identifier: identifier.as_byte(),
1209        destination,
1210        body: information,
1211        status: decode_mic_e_status(destination),
1212        latitude_digits: decode_mic_e_latitude_digits(destination),
1213    })
1214}
1215
1216fn parse_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1217    if is_compressed_position(information) {
1218        return parse_compressed_position(messaging, identifier, information);
1219    }
1220
1221    if information.len() < 19 {
1222        return AprsData::Malformed {
1223            identifier,
1224            information,
1225        };
1226    }
1227
1228    let latitude = &information[..8];
1229    let symbol_table = information[8];
1230    let longitude = &information[9..18];
1231    let symbol_code = information[18];
1232    let comment = &information[19..];
1233
1234    if !is_latitude(latitude)
1235        || !is_symbol_table_identifier(symbol_table)
1236        || !is_longitude(longitude)
1237        || !is_printable_ascii(symbol_code)
1238    {
1239        return AprsData::Malformed {
1240            identifier,
1241            information,
1242        };
1243    }
1244
1245    AprsData::Position(Position {
1246        messaging,
1247        latitude,
1248        symbol_table,
1249        longitude,
1250        symbol_code,
1251        comment,
1252    })
1253}
1254
1255fn coordinates_from_position_body(body: &[u8]) -> Option<Coordinates> {
1256    if is_compressed_position(body) {
1257        let AprsData::CompressedPosition(position) = parse_compressed_position(false, b'!', body)
1258        else {
1259            return None;
1260        };
1261        return position.coordinates();
1262    }
1263
1264    let AprsData::Position(position) = parse_position(false, b'!', body) else {
1265        return None;
1266    };
1267    position.coordinates()
1268}
1269
1270fn parse_timestamped_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1271    if information.len() < 8 {
1272        return AprsData::Malformed {
1273            identifier,
1274            information,
1275        };
1276    }
1277
1278    let timestamp = &information[..7];
1279    if !is_timestamp(timestamp) {
1280        return AprsData::Malformed {
1281            identifier,
1282            information,
1283        };
1284    }
1285
1286    match parse_position(messaging, identifier, &information[7..]) {
1287        AprsData::Position(position) => AprsData::TimestampedPosition(TimestampedPosition {
1288            messaging,
1289            timestamp,
1290            position,
1291        }),
1292        AprsData::CompressedPosition(position) => AprsData::CompressedPosition(position),
1293        _ => AprsData::Malformed {
1294            identifier,
1295            information,
1296        },
1297    }
1298}
1299
1300fn parse_compressed_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1301    if information.len() < 13 {
1302        return AprsData::Malformed {
1303            identifier,
1304            information,
1305        };
1306    }
1307
1308    let symbol_table = information[0];
1309    let compressed_latitude = &information[1..5];
1310    let compressed_longitude = &information[5..9];
1311    let symbol_code = information[9];
1312    let extension = &information[10..12];
1313    let compression_type = information[12];
1314    let comment = &information[13..];
1315
1316    if !is_symbol_table_identifier(symbol_table)
1317        || !compressed_latitude.iter().all(|byte| is_base91(*byte))
1318        || !compressed_longitude.iter().all(|byte| is_base91(*byte))
1319        || !is_printable_ascii(symbol_code)
1320        || !extension.iter().all(|byte| is_base91(*byte))
1321        || !is_base91(compression_type)
1322    {
1323        return AprsData::Malformed {
1324            identifier,
1325            information,
1326        };
1327    }
1328
1329    AprsData::CompressedPosition(CompressedPosition {
1330        messaging,
1331        symbol_table,
1332        compressed_latitude,
1333        compressed_longitude,
1334        symbol_code,
1335        extension,
1336        compression_type,
1337        comment,
1338    })
1339}
1340
1341fn parse_object(information: &[u8]) -> AprsData<'_> {
1342    if information.len() < 17 || !matches!(information[9], b'*' | b'_') {
1343        return AprsData::Malformed {
1344            identifier: b';',
1345            information,
1346        };
1347    }
1348
1349    AprsData::Object(Object {
1350        name: &information[..9],
1351        live: information[9] == b'*',
1352        timestamp: &information[10..17],
1353        body: &information[17..],
1354    })
1355}
1356
1357fn parse_item(information: &[u8]) -> AprsData<'_> {
1358    let Some(separator) = information
1359        .iter()
1360        .position(|byte| matches!(*byte, b'!' | b'_'))
1361    else {
1362        return AprsData::Malformed {
1363            identifier: b')',
1364            information,
1365        };
1366    };
1367
1368    if separator == 0 || separator > 9 {
1369        return AprsData::Malformed {
1370            identifier: b')',
1371            information,
1372        };
1373    }
1374
1375    AprsData::Item(Item {
1376        name: &information[..separator],
1377        live: information[separator] == b'!',
1378        body: &information[separator + 1..],
1379    })
1380}
1381
1382fn parse_message(information: &[u8]) -> AprsData<'_> {
1383    if information.len() < 10 || information[9] != b':' {
1384        return AprsData::Malformed {
1385            identifier: b':',
1386            information,
1387        };
1388    }
1389
1390    let addressee = &information[..9];
1391    let body = &information[10..];
1392    if let Some(kind) = classify_telemetry_metadata_kind(addressee) {
1393        return AprsData::TelemetryMetadata(TelemetryMetadata {
1394            addressee,
1395            kind,
1396            body,
1397        });
1398    }
1399
1400    let (text, id) = match body.iter().position(|byte| *byte == b'{') {
1401        Some(separator) => (&body[..separator], Some(&body[separator + 1..])),
1402        None => (body, None),
1403    };
1404    let kind = classify_message_kind(addressee, text);
1405
1406    AprsData::Message(Message {
1407        addressee,
1408        kind,
1409        text,
1410        id,
1411    })
1412}
1413
1414fn parse_telemetry(information: &[u8]) -> AprsData<'_> {
1415    if !information.starts_with(b"#") {
1416        return AprsData::Malformed {
1417            identifier: b'T',
1418            information,
1419        };
1420    }
1421
1422    let fields: Vec<&[u8]> = information[1..].split(|byte| *byte == b',').collect();
1423    if fields.len() < 6 || fields[..6].iter().any(|field| field.is_empty()) {
1424        return AprsData::Malformed {
1425            identifier: b'T',
1426            information,
1427        };
1428    }
1429
1430    AprsData::Telemetry(Telemetry {
1431        sequence: fields[0],
1432        analog: [fields[1], fields[2], fields[3], fields[4], fields[5]],
1433        digital: fields.get(6).copied().filter(|field| !field.is_empty()),
1434    })
1435}
1436
1437fn parse_maidenhead(information: &[u8]) -> AprsData<'_> {
1438    if information.len() < 6 {
1439        return AprsData::Malformed {
1440            identifier: b'[',
1441            information,
1442        };
1443    }
1444
1445    AprsData::Maidenhead(Maidenhead {
1446        locator: &information[..6],
1447        comment: &information[6..],
1448    })
1449}
1450
1451fn parse_user_defined(information: &[u8]) -> AprsData<'_> {
1452    if information.len() < 2 {
1453        return AprsData::Malformed {
1454            identifier: b'{',
1455            information,
1456        };
1457    }
1458
1459    AprsData::UserDefined(UserDefined {
1460        user_id: information[0],
1461        packet_type: information[1],
1462        body: &information[2..],
1463    })
1464}
1465
1466fn classify_telemetry_metadata_kind(addressee: &[u8]) -> Option<TelemetryMetadataKind> {
1467    match addressee.get(..5)? {
1468        b"PARM." => Some(TelemetryMetadataKind::ParameterNames),
1469        b"UNIT." => Some(TelemetryMetadataKind::Units),
1470        b"EQNS." => Some(TelemetryMetadataKind::Equations),
1471        b"BITS." => Some(TelemetryMetadataKind::BitSense),
1472        _ => None,
1473    }
1474}
1475
1476fn classify_message_kind(addressee: &[u8], text: &[u8]) -> MessageKind {
1477    if text.starts_with(b"ack") {
1478        MessageKind::Ack
1479    } else if text.starts_with(b"rej") {
1480        MessageKind::Reject
1481    } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_digit) {
1482        MessageKind::Bulletin
1483    } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_uppercase)
1484    {
1485        MessageKind::Announcement
1486    } else {
1487        MessageKind::Message
1488    }
1489}
1490
1491fn is_latitude(value: &[u8]) -> bool {
1492    value.len() == 8
1493        && value[0].is_ascii_digit()
1494        && value[1].is_ascii_digit()
1495        && value[2].is_ascii_digit()
1496        && value[3].is_ascii_digit()
1497        && value[4] == b'.'
1498        && value[5].is_ascii_digit()
1499        && value[6].is_ascii_digit()
1500        && matches!(value[7], b'N' | b'S')
1501}
1502
1503fn is_longitude(value: &[u8]) -> bool {
1504    value.len() == 9
1505        && value[0].is_ascii_digit()
1506        && value[1].is_ascii_digit()
1507        && value[2].is_ascii_digit()
1508        && value[3].is_ascii_digit()
1509        && value[4].is_ascii_digit()
1510        && value[5] == b'.'
1511        && value[6].is_ascii_digit()
1512        && value[7].is_ascii_digit()
1513        && matches!(value[8], b'E' | b'W')
1514}
1515
1516fn is_symbol_table_identifier(value: u8) -> bool {
1517    matches!(value, b'/' | b'\\') || value.is_ascii_alphanumeric()
1518}
1519
1520fn is_printable_ascii(value: u8) -> bool {
1521    (0x20..=0x7e).contains(&value)
1522}
1523
1524fn is_base91(value: u8) -> bool {
1525    (b'!'..=b'{').contains(&value)
1526}
1527
1528fn is_compressed_position(information: &[u8]) -> bool {
1529    information
1530        .first()
1531        .is_some_and(|byte| !byte.is_ascii_digit() && is_symbol_table_identifier(*byte))
1532        && information
1533            .get(1..13)
1534            .is_some_and(|bytes| bytes.iter().all(|byte| is_base91(*byte)))
1535}
1536
1537fn is_timestamp(value: &[u8]) -> bool {
1538    value.len() == 7
1539        && value[..6].iter().all(u8::is_ascii_digit)
1540        && matches!(value[6], b'z' | b'/' | b'h')
1541}
1542
1543fn decode_latitude(value: &[u8]) -> Option<f64> {
1544    if !is_latitude(value) {
1545        return None;
1546    }
1547
1548    let degrees = parse_u16(&value[..2])? as f64;
1549    let minutes = parse_fixed_minutes(&value[2..7])?;
1550    let sign = match value[7] {
1551        b'N' => 1.0,
1552        b'S' => -1.0,
1553        _ => return None,
1554    };
1555
1556    Some(sign * (degrees + minutes / 60.0))
1557}
1558
1559fn decode_longitude(value: &[u8]) -> Option<f64> {
1560    if !is_longitude(value) {
1561        return None;
1562    }
1563
1564    let degrees = parse_u16(&value[..3])? as f64;
1565    let minutes = parse_fixed_minutes(&value[3..8])?;
1566    let sign = match value[8] {
1567        b'E' => 1.0,
1568        b'W' => -1.0,
1569        _ => return None,
1570    };
1571
1572    Some(sign * (degrees + minutes / 60.0))
1573}
1574
1575fn parse_fixed_minutes(value: &[u8]) -> Option<f64> {
1576    if value.len() != 5 || value[2] != b'.' || !value[..2].iter().all(u8::is_ascii_digit) {
1577        return None;
1578    }
1579
1580    let whole = parse_u16(&value[..2])? as f64;
1581    let fraction = parse_u16(&value[3..])? as f64 / 100.0;
1582    Some(whole + fraction)
1583}
1584
1585fn decode_base91(value: &[u8]) -> Option<u32> {
1586    if value.len() != 4 || !value.iter().all(|byte| is_base91(*byte)) {
1587        return None;
1588    }
1589
1590    let mut decoded = 0u32;
1591    for byte in value {
1592        decoded = decoded * 91 + u32::from(byte - b'!');
1593    }
1594
1595    Some(decoded)
1596}
1597
1598fn parse_u16(value: &[u8]) -> Option<u16> {
1599    if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
1600        return None;
1601    }
1602
1603    let mut parsed = 0u16;
1604    for digit in value {
1605        parsed = parsed.checked_mul(10)?;
1606        parsed = parsed.checked_add(u16::from(digit - b'0'))?;
1607    }
1608
1609    Some(parsed)
1610}
1611
1612fn parse_i16(value: &[u8]) -> Option<i16> {
1613    if value.is_empty() {
1614        return None;
1615    }
1616
1617    let (sign, digits) = match value[0] {
1618        b'-' => (-1, &value[1..]),
1619        b'+' => (1, &value[1..]),
1620        _ => (1, value),
1621    };
1622
1623    let unsigned = parse_u16(digits)?;
1624    i16::try_from(unsigned).ok()?.checked_mul(sign)
1625}
1626
1627fn parse_hex_byte(value: &[u8]) -> Option<u8> {
1628    if value.len() != 2 {
1629        return None;
1630    }
1631
1632    Some(hex_value(value[0])? * 16 + hex_value(value[1])?)
1633}
1634
1635fn hex_value(value: u8) -> Option<u8> {
1636    match value {
1637        b'0'..=b'9' => Some(value - b'0'),
1638        b'A'..=b'F' => Some(value - b'A' + 10),
1639        b'a'..=b'f' => Some(value - b'a' + 10),
1640        _ => None,
1641    }
1642}
1643
1644fn parse_tagged_u16(report: &[u8], tag: u8, width: usize) -> Option<u16> {
1645    parse_tagged(report, tag, width).and_then(parse_u16)
1646}
1647
1648fn parse_tagged_i16(report: &[u8], tag: u8, width: usize) -> Option<i16> {
1649    parse_tagged(report, tag, width).and_then(parse_i16)
1650}
1651
1652fn parse_tagged(report: &[u8], tag: u8, width: usize) -> Option<&[u8]> {
1653    let start = report.iter().position(|byte| *byte == tag)? + 1;
1654    report.get(start..start + width)
1655}
1656
1657fn decode_mic_e_status(destination: &[u8]) -> Option<MicEStatus> {
1658    if destination.len() != 6 {
1659        return None;
1660    }
1661
1662    let bytes = destination.get(..3)?;
1663    Some(MicEStatus::Custom([
1664        mic_e_status_bit(bytes[0])?,
1665        mic_e_status_bit(bytes[1])?,
1666        mic_e_status_bit(bytes[2])?,
1667    ]))
1668}
1669
1670fn decode_mic_e_message_code(destination: &[u8]) -> Option<MicEMessageCode> {
1671    if destination.len() != 6 {
1672        return None;
1673    }
1674
1675    let mut bits = [MicEMessageBit::Zero; 3];
1676    for (index, byte) in destination[..3].iter().copied().enumerate() {
1677        bits[index] = mic_e_message_bit(byte)?;
1678    }
1679
1680    let code = message_code_number([
1681        !matches!(bits[0], MicEMessageBit::Zero),
1682        !matches!(bits[1], MicEMessageBit::Zero),
1683        !matches!(bits[2], MicEMessageBit::Zero),
1684    ]);
1685
1686    if code == 7 {
1687        return Some(MicEMessageCode::Emergency);
1688    }
1689
1690    let has_standard = bits
1691        .iter()
1692        .any(|bit| matches!(bit, MicEMessageBit::StandardOne));
1693    let has_custom = bits
1694        .iter()
1695        .any(|bit| matches!(bit, MicEMessageBit::CustomOne));
1696
1697    if has_standard && !has_custom {
1698        return standard_mic_e_message(code).map(MicEMessageCode::Standard);
1699    }
1700
1701    if has_custom && !has_standard {
1702        return Some(MicEMessageCode::Custom(code));
1703    }
1704
1705    None
1706}
1707
1708#[derive(Clone, Copy)]
1709enum MicEMessageBit {
1710    Zero,
1711    StandardOne,
1712    CustomOne,
1713}
1714
1715fn mic_e_message_bit(byte: u8) -> Option<MicEMessageBit> {
1716    match byte {
1717        b'0'..=b'9' | b'L' => Some(MicEMessageBit::Zero),
1718        b'A'..=b'K' => Some(MicEMessageBit::StandardOne),
1719        b'P'..=b'Z' => Some(MicEMessageBit::CustomOne),
1720        _ => None,
1721    }
1722}
1723
1724fn message_code_number(bits: [bool; 3]) -> u8 {
1725    match bits {
1726        [true, true, true] => 0,
1727        [true, true, false] => 1,
1728        [true, false, true] => 2,
1729        [true, false, false] => 3,
1730        [false, true, true] => 4,
1731        [false, true, false] => 5,
1732        [false, false, true] => 6,
1733        [false, false, false] => 7,
1734    }
1735}
1736
1737fn standard_mic_e_message(code: u8) -> Option<MicEStandardMessage> {
1738    match code {
1739        0 => Some(MicEStandardMessage::OffDuty),
1740        1 => Some(MicEStandardMessage::EnRoute),
1741        2 => Some(MicEStandardMessage::InService),
1742        3 => Some(MicEStandardMessage::Returning),
1743        4 => Some(MicEStandardMessage::Committed),
1744        5 => Some(MicEStandardMessage::Special),
1745        6 => Some(MicEStandardMessage::Priority),
1746        _ => None,
1747    }
1748}
1749
1750fn mic_e_status_bit(byte: u8) -> Option<bool> {
1751    match byte {
1752        b'0'..=b'9' | b'L' => Some(false),
1753        b'A'..=b'K' | b'P'..=b'Z' => Some(true),
1754        _ => None,
1755    }
1756}
1757
1758fn decode_mic_e_latitude_digits(destination: &[u8]) -> Option<[u8; 6]> {
1759    if destination.len() != 6 {
1760        return None;
1761    }
1762
1763    let mut digits = [0u8; 6];
1764    for (index, byte) in destination.iter().copied().enumerate() {
1765        digits[index] = mic_e_latitude_digit(byte)?;
1766    }
1767
1768    Some(digits)
1769}
1770
1771fn mic_e_latitude_digit(byte: u8) -> Option<u8> {
1772    match byte {
1773        b'0'..=b'9' => Some(byte - b'0'),
1774        b'A'..=b'J' => Some(byte - b'A'),
1775        b'P'..=b'Y' => Some(byte - b'P'),
1776        b'K' | b'L' | b'Z' => Some(0),
1777        _ => None,
1778    }
1779}
1780
1781fn decode_mic_e_latitude(destination: &[u8]) -> Option<f64> {
1782    let digits = decode_mic_e_latitude_digits(destination)?;
1783    let degrees = u16::from(digits[0]) * 10 + u16::from(digits[1]);
1784    let minutes = u16::from(digits[2]) * 10 + u16::from(digits[3]);
1785    let hundredths = u16::from(digits[4]) * 10 + u16::from(digits[5]);
1786    if degrees > 90 || minutes > 59 {
1787        return None;
1788    }
1789
1790    let sign = if mic_e_north(destination[3])? {
1791        1.0
1792    } else {
1793        -1.0
1794    };
1795    Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
1796}
1797
1798fn decode_mic_e_longitude(destination: &[u8], body: &[u8]) -> Option<f64> {
1799    if destination.len() != 6 || body.len() < 3 {
1800        return None;
1801    }
1802
1803    let mut degrees = i16::from(mic_e_body_value(body[0])?);
1804    if mic_e_longitude_offset(destination[4])? {
1805        degrees += 100;
1806    }
1807    if (180..=189).contains(&degrees) {
1808        degrees -= 80;
1809    } else if (190..=199).contains(&degrees) {
1810        degrees -= 190;
1811    }
1812
1813    let minutes = mic_e_body_value(body[1])?;
1814    let hundredths = mic_e_body_value(body[2])?;
1815    if !(0..=179).contains(&degrees) || minutes > 59 || hundredths > 99 {
1816        return None;
1817    }
1818
1819    let sign = if mic_e_west(destination[5])? {
1820        -1.0
1821    } else {
1822        1.0
1823    };
1824    Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
1825}
1826
1827fn decode_mic_e_speed_course(body: &[u8]) -> Option<MicESpeedCourse> {
1828    if body.len() < 6 {
1829        return None;
1830    }
1831
1832    let speed_tens = u16::from(mic_e_body_value(body[3])?);
1833    let speed_units_course_hundreds = u16::from(mic_e_body_value(body[4])?);
1834    let course_remainder = u16::from(mic_e_body_value(body[5])?);
1835    let mut speed_knots = speed_tens * 10 + speed_units_course_hundreds / 10;
1836    if speed_knots >= 800 {
1837        speed_knots -= 800;
1838    }
1839
1840    Some(MicESpeedCourse {
1841        speed_knots,
1842        course_degrees: (speed_units_course_hundreds % 10) * 100 + course_remainder,
1843    })
1844}
1845
1846fn mic_e_body_value(byte: u8) -> Option<u8> {
1847    let value = byte.checked_sub(28)?;
1848    (value <= 99).then_some(value)
1849}
1850
1851fn mic_e_north(byte: u8) -> Option<bool> {
1852    match byte {
1853        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1854        b'P'..=b'Z' => Some(true),
1855        _ => None,
1856    }
1857}
1858
1859fn mic_e_longitude_offset(byte: u8) -> Option<bool> {
1860    match byte {
1861        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1862        b'P'..=b'Z' => Some(true),
1863        _ => None,
1864    }
1865}
1866
1867fn mic_e_west(byte: u8) -> Option<bool> {
1868    match byte {
1869        b'0'..=b'9' | b'A'..=b'L' => Some(false),
1870        b'P'..=b'Z' => Some(true),
1871        _ => None,
1872    }
1873}
1874
1875/// Fail-closed packet parse errors.
1876#[derive(Clone, Debug, Eq, PartialEq)]
1877pub enum ParseError {
1878    /// No bytes were supplied.
1879    Empty,
1880    /// Packet exceeds [`MAX_PACKET_LEN`].
1881    Oversized,
1882    /// Packet does not contain the required APRS `>` and `:` separators.
1883    MissingSeparator,
1884    /// Packet contains an empty source, path, or payload segment.
1885    EmptySegment,
1886    /// Packet source or path contains bytes outside the conservative address set.
1887    InvalidAddress,
1888}
1889
1890impl ParseError {
1891    /// Returns a stable parse error code for logs and external systems.
1892    #[must_use]
1893    pub fn code(&self) -> &'static str {
1894        match self {
1895            Self::Empty => "parse.empty",
1896            Self::Oversized => "parse.oversized",
1897            Self::MissingSeparator => "parse.missing_separator",
1898            Self::EmptySegment => "parse.empty_segment",
1899            Self::InvalidAddress => "parse.invalid_address",
1900        }
1901    }
1902}
1903
1904/// Parses an APRS packet from untrusted bytes.
1905///
1906/// This parser intentionally validates only the minimal frame shape for the
1907/// skeleton: `source>path:payload`. Payload bytes are opaque and may be invalid
1908/// UTF-8.
1909pub fn parse_packet(input: &[u8]) -> Result<ParsedPacket, ParseError> {
1910    parse_packet_with_options(input, ParseOptions::default())
1911}
1912
1913/// Parses an APRS packet from untrusted bytes with explicit codec options.
1914pub fn parse_packet_with_options(
1915    input: &[u8],
1916    options: ParseOptions,
1917) -> Result<ParsedPacket, ParseError> {
1918    if input.is_empty() {
1919        return Err(ParseError::Empty);
1920    }
1921
1922    if input.len() > options.max_packet_len {
1923        return Err(ParseError::Oversized);
1924    }
1925
1926    let source_end = input
1927        .iter()
1928        .position(|byte| *byte == b'>')
1929        .ok_or(ParseError::MissingSeparator)?;
1930    let payload_separator = input[source_end + 1..]
1931        .iter()
1932        .position(|byte| *byte == b':')
1933        .map(|offset| source_end + 1 + offset)
1934        .ok_or(ParseError::MissingSeparator)?;
1935
1936    let path_start = source_end + 1;
1937    let path_end = payload_separator;
1938    let payload_start = payload_separator + 1;
1939
1940    if source_end == 0 || path_start == path_end || payload_start == input.len() {
1941        return Err(ParseError::EmptySegment);
1942    }
1943
1944    let Some(path_components) = path_component_ranges(input, path_start, path_end) else {
1945        return Err(ParseError::InvalidAddress);
1946    };
1947
1948    if !is_ax25_like_source(&input[..source_end])
1949        || !path_components
1950            .iter()
1951            .all(|(start, end)| is_ax25_like_path_component(&input[*start..*end]))
1952    {
1953        return Err(ParseError::InvalidAddress);
1954    }
1955
1956    Ok(ParsedPacket {
1957        raw: RawPacket {
1958            bytes: input.to_vec(),
1959        },
1960        source_end,
1961        path_start,
1962        path_end,
1963        path_components,
1964        payload_start,
1965    })
1966}
1967
1968fn path_component_ranges(
1969    input: &[u8],
1970    path_start: usize,
1971    path_end: usize,
1972) -> Option<Vec<(usize, usize)>> {
1973    let mut components = Vec::new();
1974    let mut component_start = path_start;
1975
1976    for (offset, byte) in input[path_start..path_end].iter().enumerate() {
1977        if *byte == b',' {
1978            let index = path_start + offset;
1979            if component_start == index {
1980                return None;
1981            }
1982            components.push((component_start, index));
1983            component_start = index + 1;
1984        }
1985    }
1986
1987    if component_start == path_end {
1988        return None;
1989    }
1990
1991    components.push((component_start, path_end));
1992    Some(components)
1993}
1994
1995fn is_ax25_like_source(source: &[u8]) -> bool {
1996    is_ax25_like_address(source, false)
1997}
1998
1999fn is_ax25_like_path_component(component: &[u8]) -> bool {
2000    is_ax25_like_address(component, true)
2001}
2002
2003fn is_ax25_like_address(address: &[u8], allow_repeated_marker: bool) -> bool {
2004    let address = if allow_repeated_marker {
2005        address.strip_suffix(b"*").unwrap_or(address)
2006    } else {
2007        address
2008    };
2009
2010    if address.is_empty() || address.contains(&b'*') {
2011        return false;
2012    }
2013
2014    let (callsign, ssid) = match address.iter().position(|byte| *byte == b'-') {
2015        Some(separator) => (&address[..separator], Some(&address[separator + 1..])),
2016        None => (address, None),
2017    };
2018
2019    is_ax25_like_callsign(callsign) && ssid.map_or(true, is_ax25_like_ssid)
2020}
2021
2022fn is_ax25_like_callsign(callsign: &[u8]) -> bool {
2023    (1..=6).contains(&callsign.len())
2024        && callsign
2025            .iter()
2026            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
2027}
2028
2029fn is_ax25_like_ssid(ssid: &[u8]) -> bool {
2030    if ssid.is_empty() || ssid.len() > 2 || !ssid.iter().all(u8::is_ascii_digit) {
2031        return false;
2032    }
2033
2034    let mut value = 0u8;
2035    for digit in ssid {
2036        value = value * 10 + (digit - b'0');
2037    }
2038
2039    value <= 15
2040}