1#![forbid(unsafe_code)]
2
3mod 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
19pub const MAX_PACKET_LEN: usize = 512;
21
22pub const DEFAULT_PARSE_OPTIONS: ParseOptions = ParseOptions {
24 max_packet_len: MAX_PACKET_LEN,
25};
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub struct ParseOptions {
33 pub max_packet_len: usize,
35}
36
37impl ParseOptions {
38 #[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#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct RawPacket {
54 bytes: Vec<u8>,
55}
56
57impl RawPacket {
58 #[must_use]
60 pub fn as_bytes(&self) -> &[u8] {
61 &self.bytes
62 }
63}
64
65#[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 #[must_use]
79 pub fn raw(&self) -> &RawPacket {
80 &self.raw
81 }
82
83 #[must_use]
85 pub fn source(&self) -> &[u8] {
86 &self.raw.bytes[..self.source_end]
87 }
88
89 #[must_use]
91 pub fn path(&self) -> &[u8] {
92 &self.raw.bytes[self.path_start..self.path_end]
93 }
94
95 #[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 #[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 #[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 #[must_use]
122 pub fn payload(&self) -> &[u8] {
123 &self.raw.bytes[self.payload_start..]
124 }
125
126 #[must_use]
128 pub fn data_type_identifier(&self) -> DataTypeIdentifier {
129 DataTypeIdentifier::from_byte(self.raw.bytes[self.payload_start])
130 }
131
132 #[must_use]
134 pub fn information(&self) -> &[u8] {
135 &self.raw.bytes[self.payload_start + 1..]
136 }
137
138 #[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 #[must_use]
150 pub fn summary(&self) -> PacketSummary<'_> {
151 PacketSummary::from_packet(self)
152 }
153
154 #[must_use]
156 pub fn to_json(&self) -> String {
157 diagnostic::packet_to_json(self)
158 }
159}
160
161#[derive(Clone, Copy, Debug, PartialEq)]
163pub struct PacketSummary<'a> {
164 pub source: &'a [u8],
166 pub destination: &'a [u8],
168 pub data_type: &'static str,
170 pub semantic: &'static str,
172 pub coordinates: Option<Coordinates>,
174 pub nmea_checksum: Option<NmeaChecksum>,
176 pub telemetry_sequence: Option<u16>,
178 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
200pub enum DiagnosticLayer {
201 Parse,
203 Policy,
205 Transport,
207}
208
209impl DiagnosticLayer {
210 #[must_use]
212 pub const fn code(self) -> &'static str {
213 match self {
214 Self::Parse => "parse",
215 Self::Policy => "policy",
216 Self::Transport => "transport",
217 }
218 }
219}
220
221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub struct ErrorDiagnostic {
224 pub layer: DiagnosticLayer,
226 pub code: &'static str,
228 pub name: &'static str,
230 pub description: &'static str,
232 pub remediation: &'static str,
234}
235
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
238pub enum SupportStatus {
239 Supported,
241 Partial,
243 Unsupported,
245}
246
247impl SupportStatus {
248 #[must_use]
250 pub const fn code(self) -> &'static str {
251 match self {
252 Self::Supported => "supported",
253 Self::Partial => "partial",
254 Self::Unsupported => "unsupported",
255 }
256 }
257}
258
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261pub struct SupportItem {
262 pub kind: &'static str,
264 pub status: SupportStatus,
266 pub notes: &'static str,
268}
269
270#[derive(Clone, Copy, Debug, Eq, PartialEq)]
272pub struct TransportSupport {
273 pub crate_name: &'static str,
275 pub boundary: &'static str,
277 pub status: SupportStatus,
279 pub notes: &'static str,
281}
282
283#[derive(Clone, Copy, Debug, Eq, PartialEq)]
285pub struct SupportMatrix {
286 pub schema_version: u8,
288 pub semantic_families: &'static [SupportItem],
290 pub transport_adapters: &'static [TransportSupport],
292 pub diagnostic_layers: &'static [DiagnosticLayer],
294}
295
296#[must_use]
298pub const fn support_matrix() -> SupportMatrix {
299 SupportMatrix {
300 schema_version: 1,
301 semantic_families: SEMANTIC_SUPPORT,
302 transport_adapters: TRANSPORT_SUPPORT,
303 diagnostic_layers: DIAGNOSTIC_LAYERS,
304 }
305}
306
307const DIAGNOSTIC_LAYERS: &[DiagnosticLayer] = &[
308 DiagnosticLayer::Parse,
309 DiagnosticLayer::Policy,
310 DiagnosticLayer::Transport,
311];
312
313const SEMANTIC_SUPPORT: &[SupportItem] = &[
314 SupportItem {
315 kind: "status",
316 status: SupportStatus::Supported,
317 notes: "status text bytes are preserved",
318 },
319 SupportItem {
320 kind: "position",
321 status: SupportStatus::Supported,
322 notes: "uncompressed and compressed coordinates are decoded where valid",
323 },
324 SupportItem {
325 kind: "message",
326 status: SupportStatus::Supported,
327 notes:
328 "messages, acknowledgements, rejections, bulletins, and announcements are classified",
329 },
330 SupportItem {
331 kind: "object",
332 status: SupportStatus::Supported,
333 notes: "object name, liveness, timestamp, body, and supported coordinates are exposed",
334 },
335 SupportItem {
336 kind: "item",
337 status: SupportStatus::Supported,
338 notes: "item name, liveness, body, and supported coordinates are exposed",
339 },
340 SupportItem {
341 kind: "weather",
342 status: SupportStatus::Partial,
343 notes: "common weather fields are extracted and malformed optional fields are ignored",
344 },
345 SupportItem {
346 kind: "telemetry",
347 status: SupportStatus::Supported,
348 notes: "sequence, analogue values, digital bits, and metadata packets are exposed",
349 },
350 SupportItem {
351 kind: "nmea",
352 status: SupportStatus::Supported,
353 notes: "sentence identifiers and checksum diagnostics are exposed",
354 },
355 SupportItem {
356 kind: "mic_e",
357 status: SupportStatus::Partial,
358 notes: "destination-derived status, latitude digits, speed, and course helpers are exposed",
359 },
360 SupportItem {
361 kind: "third_party",
362 status: SupportStatus::Partial,
363 notes: "nested packet bytes can be parsed explicitly by callers",
364 },
365 SupportItem {
366 kind: "unsupported",
367 status: SupportStatus::Supported,
368 notes: "unknown identifiers remain explicit and byte-preserving",
369 },
370 SupportItem {
371 kind: "malformed",
372 status: SupportStatus::Supported,
373 notes: "codec-valid but semantically malformed packets remain visible to policy",
374 },
375];
376
377const TRANSPORT_SUPPORT: &[TransportSupport] = &[
378 TransportSupport {
379 crate_name: "aprs-transport-file",
380 boundary: "newline-separated files and stdin-style byte streams",
381 status: SupportStatus::Supported,
382 notes: "bounded file and packet-line reads",
383 },
384 TransportSupport {
385 crate_name: "aprs-transport-tcp",
386 boundary: "blocking TCP or Read packet streams",
387 status: SupportStatus::Supported,
388 notes: "caller owns socket timeouts and reconnect behavior",
389 },
390 TransportSupport {
391 crate_name: "aprs-transport-aprs-is",
392 boundary: "APRS-IS login framing and server line filtering",
393 status: SupportStatus::Supported,
394 notes: "authentication and reconnect loops stay application-owned",
395 },
396 TransportSupport {
397 crate_name: "aprs-transport-kiss",
398 boundary: "KISS frame encoding and decoding",
399 status: SupportStatus::Supported,
400 notes: "invalid escapes and oversized frames fail closed",
401 },
402 TransportSupport {
403 crate_name: "aprs-transport-serial",
404 boundary: "serial-like byte readers",
405 status: SupportStatus::Supported,
406 notes: "serial configuration stays application-owned",
407 },
408 TransportSupport {
409 crate_name: "aprs-transport-udp",
410 boundary: "UDP datagram payloads",
411 status: SupportStatus::Supported,
412 notes: "datagram length is bounded before parsing",
413 },
414 TransportSupport {
415 crate_name: "aprs-transport-http",
416 boundary: "HTTP request body bytes",
417 status: SupportStatus::Supported,
418 notes: "body and packet-line limits are enforced by helpers",
419 },
420 TransportSupport {
421 crate_name: "aprs-transport-file-watch",
422 boundary: "append-only packet logs",
423 status: SupportStatus::Supported,
424 notes: "appended byte ranges and packet lines are bounded",
425 },
426 TransportSupport {
427 crate_name: "aprs-transport-mqtt",
428 boundary: "MQTT topics and payload copies",
429 status: SupportStatus::Supported,
430 notes: "broker sessions, authentication, and reconnects stay application-owned",
431 },
432 TransportSupport {
433 crate_name: "aprs-transport-ax25",
434 boundary: "AX.25 UI frames",
435 status: SupportStatus::Supported,
436 notes: "oversized UI frames fail closed before payload extraction",
437 },
438 TransportSupport {
439 crate_name: "aprs-transport-corpus",
440 boundary: "fixture and corpus replay",
441 status: SupportStatus::Supported,
442 notes: "stable ordering and per-file limits for tests",
443 },
444 TransportSupport {
445 crate_name: "aprs-transport-channel",
446 boundary: "in-process packet channels",
447 status: SupportStatus::Supported,
448 notes: "caller-owned channel capacity controls backpressure",
449 },
450 TransportSupport {
451 crate_name: "aprs-transport-async",
452 boundary: "runtime-neutral async byte splitting",
453 status: SupportStatus::Supported,
454 notes: "runtime, timeouts, and cancellation stay caller-owned",
455 },
456];
457
458#[derive(Clone, Debug, Eq, PartialEq)]
460pub struct Engine {
461 policy: Policy,
462 counters: Counters,
463}
464
465impl Engine {
466 #[must_use]
468 pub fn new(policy: Policy) -> Self {
469 Self {
470 policy,
471 counters: Counters::default(),
472 }
473 }
474
475 pub fn process(&mut self, input: &[u8]) -> EngineResult {
477 match parse_packet(input) {
478 Ok(packet) => {
479 let semantic = packet.aprs_data();
480 match self.policy.evaluate(&packet, &semantic) {
481 PolicyDecision::Accept => {
482 self.counters.accepted = self.counters.accepted.saturating_add(1);
483 EngineResult::Accepted { packet }
484 }
485 PolicyDecision::Reject(reason) => {
486 self.counters.rejected = self.counters.rejected.saturating_add(1);
487 EngineResult::Rejected { packet, reason }
488 }
489 }
490 }
491 Err(error) => {
492 self.counters.malformed = self.counters.malformed.saturating_add(1);
493 EngineResult::ParseError(error)
494 }
495 }
496 }
497
498 pub fn process_packets<I, P>(&mut self, packets: I) -> Vec<EngineResult>
500 where
501 I: IntoIterator<Item = P>,
502 P: AsRef<[u8]>,
503 {
504 packets
505 .into_iter()
506 .map(|packet| self.process(packet.as_ref()))
507 .collect()
508 }
509
510 pub fn process_source<S>(&mut self, source: &mut S) -> Result<Vec<EngineResult>, S::Error>
512 where
513 S: PacketSource,
514 {
515 Ok(self.process_packets(source.recv_packets()?))
516 }
517
518 #[must_use]
520 pub fn counters(&self) -> Counters {
521 self.counters
522 }
523}
524
525impl Default for Engine {
526 fn default() -> Self {
527 Self::new(Policy::default())
528 }
529}
530
531#[derive(Clone, Debug, PartialEq)]
533pub enum EngineResult {
534 Accepted {
536 packet: ParsedPacket,
538 },
539 Rejected {
541 packet: ParsedPacket,
543 reason: PolicyRejection,
545 },
546 ParseError(ParseError),
548}
549
550#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
552pub struct Counters {
553 pub accepted: u64,
555 pub rejected: u64,
557 pub malformed: u64,
559}
560
561#[derive(Clone, Debug, Eq, PartialEq)]
563pub struct Policy {
564 pub allow_unsupported: bool,
566 pub allow_malformed_semantics: bool,
568 pub reject_invalid_nmea_checksum: bool,
570 pub max_path_components: usize,
572}
573
574impl Policy {
575 #[must_use]
577 pub fn strict() -> Self {
578 Self::default()
579 }
580
581 #[must_use]
583 pub fn permissive() -> Self {
584 Self {
585 allow_unsupported: true,
586 allow_malformed_semantics: true,
587 reject_invalid_nmea_checksum: false,
588 max_path_components: 9,
589 }
590 }
591
592 #[must_use]
594 pub fn evaluate(&self, packet: &ParsedPacket, semantic: &AprsData<'_>) -> PolicyDecision {
595 if packet.path_components.len() > self.max_path_components {
596 return PolicyDecision::Reject(PolicyRejection::PathTooLong);
597 }
598
599 if self.reject_invalid_nmea_checksum
600 && matches!(
601 semantic,
602 AprsData::Nmea(nmea) if nmea.checksum().is_some_and(|checksum| !checksum.valid)
603 )
604 {
605 return PolicyDecision::Reject(PolicyRejection::InvalidNmeaChecksum);
606 }
607
608 match semantic {
609 AprsData::Malformed { .. } if !self.allow_malformed_semantics => {
610 PolicyDecision::Reject(PolicyRejection::MalformedSemantics)
611 }
612 AprsData::Unsupported { .. } if !self.allow_unsupported => {
613 PolicyDecision::Reject(PolicyRejection::UnsupportedSemantics)
614 }
615 _ => PolicyDecision::Accept,
616 }
617 }
618}
619
620impl Default for Policy {
621 fn default() -> Self {
622 Self {
623 allow_unsupported: false,
624 allow_malformed_semantics: false,
625 reject_invalid_nmea_checksum: false,
626 max_path_components: 9,
627 }
628 }
629}
630
631#[derive(Clone, Copy, Debug, Eq, PartialEq)]
633pub enum PolicyDecision {
634 Accept,
636 Reject(PolicyRejection),
638}
639
640#[derive(Clone, Copy, Debug, Eq, PartialEq)]
642pub enum PolicyRejection {
643 PathTooLong,
645 MalformedSemantics,
647 UnsupportedSemantics,
649 InvalidNmeaChecksum,
651}
652
653impl PolicyRejection {
654 #[must_use]
656 pub fn code(self) -> &'static str {
657 match self {
658 Self::PathTooLong => "policy.path_too_long",
659 Self::MalformedSemantics => "policy.malformed_semantics",
660 Self::UnsupportedSemantics => "policy.unsupported_semantics",
661 Self::InvalidNmeaChecksum => "policy.nmea_checksum_mismatch",
662 }
663 }
664
665 #[must_use]
667 pub fn diagnostic(self) -> ErrorDiagnostic {
668 match self {
669 Self::PathTooLong => ErrorDiagnostic {
670 layer: DiagnosticLayer::Policy,
671 code: self.code(),
672 name: "path_too_long",
673 description: "packet path contains more components than policy permits",
674 remediation: "raise Policy::max_path_components only after reviewing path abuse risk",
675 },
676 Self::MalformedSemantics => ErrorDiagnostic {
677 layer: DiagnosticLayer::Policy,
678 code: self.code(),
679 name: "malformed_semantics",
680 description: "packet passed codec validation but the APRS semantic payload is malformed",
681 remediation: "inspect the preserved raw bytes and keep strict policy enabled for untrusted inputs",
682 },
683 Self::UnsupportedSemantics => ErrorDiagnostic {
684 layer: DiagnosticLayer::Policy,
685 code: self.code(),
686 name: "unsupported_semantics",
687 description: "packet uses an unsupported APRS semantic family or identifier",
688 remediation: "use permissive policy only for corpus collection or add explicit support before accepting",
689 },
690 Self::InvalidNmeaChecksum => ErrorDiagnostic {
691 layer: DiagnosticLayer::Policy,
692 code: self.code(),
693 name: "nmea_checksum_mismatch",
694 description: "NMEA sentence has a present checksum that does not match the calculated value",
695 remediation: "treat the packet as untrusted and investigate upstream data corruption or spoofing",
696 },
697 }
698 }
699}
700
701#[derive(Clone, Copy, Debug, Eq, PartialEq)]
703pub enum AprsData<'a> {
704 Status {
706 text: &'a [u8],
708 },
709 Position(Position<'a>),
711 TimestampedPosition(TimestampedPosition<'a>),
713 CompressedPosition(CompressedPosition<'a>),
715 Message(Message<'a>),
717 Object(Object<'a>),
719 Item(Item<'a>),
721 Weather(Weather<'a>),
723 Telemetry(Telemetry<'a>),
725 TelemetryMetadata(TelemetryMetadata<'a>),
727 Query(Query<'a>),
729 Capability(Capability<'a>),
731 Nmea(Nmea<'a>),
733 MicE(MicE<'a>),
735 Maidenhead(Maidenhead<'a>),
737 UserDefined(UserDefined<'a>),
739 ThirdParty(ThirdParty<'a>),
741 Unsupported {
743 identifier: u8,
745 information: &'a [u8],
747 },
748 Malformed {
750 identifier: u8,
752 information: &'a [u8],
754 },
755}
756
757impl AprsData<'_> {
758 #[must_use]
760 pub fn kind_name(&self) -> &'static str {
761 match self {
762 Self::Status { .. } => "status",
763 Self::Position(_) => "position",
764 Self::TimestampedPosition(_) => "timestamped_position",
765 Self::CompressedPosition(_) => "compressed_position",
766 Self::Message(_) => "message",
767 Self::Object(_) => "object",
768 Self::Item(_) => "item",
769 Self::Weather(_) => "weather",
770 Self::Telemetry(_) => "telemetry",
771 Self::TelemetryMetadata(_) => "telemetry_metadata",
772 Self::Query(_) => "query",
773 Self::Capability(_) => "capability",
774 Self::Nmea(_) => "nmea",
775 Self::MicE(_) => "mic_e",
776 Self::Maidenhead(_) => "maidenhead",
777 Self::UserDefined(_) => "user_defined",
778 Self::ThirdParty(_) => "third_party",
779 Self::Unsupported { .. } => "unsupported",
780 Self::Malformed { .. } => "malformed",
781 }
782 }
783}
784
785fn summary_coordinates(data: AprsData<'_>) -> Option<Coordinates> {
786 match data {
787 AprsData::Position(position) => position.coordinates(),
788 AprsData::TimestampedPosition(position) => position.position.coordinates(),
789 AprsData::CompressedPosition(position) => position.coordinates(),
790 AprsData::MicE(mic_e) => mic_e.coordinates(),
791 _ => None,
792 }
793}
794
795fn summary_nmea_checksum(data: AprsData<'_>) -> Option<NmeaChecksum> {
796 match data {
797 AprsData::Nmea(nmea) => nmea.checksum(),
798 _ => None,
799 }
800}
801
802fn summary_telemetry_sequence(data: AprsData<'_>) -> Option<u16> {
803 match data {
804 AprsData::Telemetry(telemetry) => telemetry.sequence_number(),
805 _ => None,
806 }
807}
808
809fn summary_mic_e_speed_course(data: AprsData<'_>) -> Option<MicESpeedCourse> {
810 match data {
811 AprsData::MicE(mic_e) => mic_e.speed_course(),
812 _ => None,
813 }
814}
815
816#[derive(Clone, Copy, Debug, Eq, PartialEq)]
818pub struct Position<'a> {
819 pub messaging: bool,
821 pub latitude: &'a [u8],
823 pub symbol_table: u8,
825 pub longitude: &'a [u8],
827 pub symbol_code: u8,
829 pub comment: &'a [u8],
831}
832
833impl Position<'_> {
834 #[must_use]
836 pub fn coordinates(&self) -> Option<Coordinates> {
837 Some(Coordinates {
838 latitude: decode_latitude(self.latitude)?,
839 longitude: decode_longitude(self.longitude)?,
840 })
841 }
842}
843
844#[derive(Clone, Copy, Debug, PartialEq)]
846pub struct Coordinates {
847 pub latitude: f64,
849 pub longitude: f64,
851}
852
853#[derive(Clone, Copy, Debug, Eq, PartialEq)]
855pub struct TimestampedPosition<'a> {
856 pub messaging: bool,
858 pub timestamp: &'a [u8],
860 pub position: Position<'a>,
862}
863
864#[derive(Clone, Copy, Debug, Eq, PartialEq)]
866pub struct CompressedPosition<'a> {
867 pub messaging: bool,
869 pub symbol_table: u8,
871 pub compressed_latitude: &'a [u8],
873 pub compressed_longitude: &'a [u8],
875 pub symbol_code: u8,
877 pub extension: &'a [u8],
879 pub compression_type: u8,
881 pub comment: &'a [u8],
883}
884
885impl CompressedPosition<'_> {
886 #[must_use]
888 pub fn coordinates(&self) -> Option<Coordinates> {
889 let y = decode_base91(self.compressed_latitude)?;
890 let x = decode_base91(self.compressed_longitude)?;
891
892 Some(Coordinates {
893 latitude: 90.0 - (y as f64 / 380_926.0),
894 longitude: -180.0 + (x as f64 / 190_463.0),
895 })
896 }
897}
898
899#[derive(Clone, Copy, Debug, Eq, PartialEq)]
901pub struct Message<'a> {
902 pub addressee: &'a [u8],
904 pub kind: MessageKind,
906 pub text: &'a [u8],
908 pub id: Option<&'a [u8]>,
910}
911
912#[derive(Clone, Copy, Debug, Eq, PartialEq)]
914pub enum MessageKind {
915 Message,
917 Ack,
919 Reject,
921 Bulletin,
923 Announcement,
925}
926
927#[derive(Clone, Copy, Debug, Eq, PartialEq)]
929pub struct Object<'a> {
930 pub name: &'a [u8],
932 pub live: bool,
934 pub timestamp: &'a [u8],
936 pub body: &'a [u8],
938}
939
940impl Object<'_> {
941 #[must_use]
944 pub fn coordinates(&self) -> Option<Coordinates> {
945 coordinates_from_position_body(self.body)
946 }
947}
948
949#[derive(Clone, Copy, Debug, Eq, PartialEq)]
951pub struct Item<'a> {
952 pub name: &'a [u8],
954 pub live: bool,
956 pub body: &'a [u8],
958}
959
960impl Item<'_> {
961 #[must_use]
964 pub fn coordinates(&self) -> Option<Coordinates> {
965 coordinates_from_position_body(self.body)
966 }
967}
968
969#[derive(Clone, Copy, Debug, Eq, PartialEq)]
971pub struct Weather<'a> {
972 pub report: &'a [u8],
974}
975
976impl Weather<'_> {
977 #[must_use]
979 pub fn fields(&self) -> WeatherFields<'_> {
980 WeatherFields {
981 timestamp: self
982 .report
983 .get(..6)
984 .filter(|value| value.iter().all(u8::is_ascii_digit)),
985 wind_direction_degrees: parse_tagged_u16(self.report, b'c', 3),
986 wind_speed_mph: parse_tagged_u16(self.report, b's', 3),
987 wind_gust_mph: parse_tagged_u16(self.report, b'g', 3),
988 temperature_fahrenheit: parse_tagged_i16(self.report, b't', 3),
989 rain_last_hour_hundredths_inch: parse_tagged_u16(self.report, b'r', 3),
990 rain_last_24_hours_hundredths_inch: parse_tagged_u16(self.report, b'p', 3),
991 rain_since_midnight_hundredths_inch: parse_tagged_u16(self.report, b'P', 3),
992 humidity_percent: parse_tagged_u16(self.report, b'h', 2).map(|value| {
993 if value == 0 {
994 100
995 } else {
996 value
997 }
998 }),
999 pressure_tenths_hpa: parse_tagged_u16(self.report, b'b', 5),
1000 luminosity_watts_per_square_meter: parse_tagged_u16(self.report, b'L', 3),
1001 luminosity_1000_plus_watts_per_square_meter: parse_tagged_u16(self.report, b'l', 3)
1002 .map(|value| value + 1000),
1003 snow_last_24_hours_inches: parse_tagged_u16(self.report, b'S', 3),
1004 raw_rain_counter: parse_tagged_u16(self.report, b'#', 3),
1005 }
1006 }
1007}
1008
1009#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1011pub struct WeatherFields<'a> {
1012 pub timestamp: Option<&'a [u8]>,
1014 pub wind_direction_degrees: Option<u16>,
1016 pub wind_speed_mph: Option<u16>,
1018 pub wind_gust_mph: Option<u16>,
1020 pub temperature_fahrenheit: Option<i16>,
1022 pub rain_last_hour_hundredths_inch: Option<u16>,
1024 pub rain_last_24_hours_hundredths_inch: Option<u16>,
1026 pub rain_since_midnight_hundredths_inch: Option<u16>,
1028 pub humidity_percent: Option<u16>,
1030 pub pressure_tenths_hpa: Option<u16>,
1032 pub luminosity_watts_per_square_meter: Option<u16>,
1034 pub luminosity_1000_plus_watts_per_square_meter: Option<u16>,
1036 pub snow_last_24_hours_inches: Option<u16>,
1038 pub raw_rain_counter: Option<u16>,
1040}
1041
1042#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1044pub struct Telemetry<'a> {
1045 pub sequence: &'a [u8],
1047 pub analog: [&'a [u8]; 5],
1049 pub digital: Option<&'a [u8]>,
1051}
1052
1053impl Telemetry<'_> {
1054 #[must_use]
1056 pub fn sequence_number(&self) -> Option<u16> {
1057 parse_u16(self.sequence)
1058 }
1059
1060 #[must_use]
1062 pub fn analog_values(&self) -> Option<[u16; 5]> {
1063 Some([
1064 parse_u16(self.analog[0])?,
1065 parse_u16(self.analog[1])?,
1066 parse_u16(self.analog[2])?,
1067 parse_u16(self.analog[3])?,
1068 parse_u16(self.analog[4])?,
1069 ])
1070 }
1071
1072 #[must_use]
1074 pub fn digital_bits(&self) -> Option<[bool; 8]> {
1075 let digital = self.digital?;
1076 if digital.len() != 8 {
1077 return None;
1078 }
1079
1080 let mut bits = [false; 8];
1081 for (index, byte) in digital.iter().enumerate() {
1082 bits[index] = match byte {
1083 b'0' => false,
1084 b'1' => true,
1085 _ => return None,
1086 };
1087 }
1088
1089 Some(bits)
1090 }
1091}
1092
1093#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1095pub struct TelemetryMetadata<'a> {
1096 pub addressee: &'a [u8],
1098 pub kind: TelemetryMetadataKind,
1100 pub body: &'a [u8],
1102}
1103
1104impl<'a> TelemetryMetadata<'a> {
1105 #[must_use]
1107 pub fn fields(&self) -> Vec<&'a [u8]> {
1108 self.body.split(|byte| *byte == b',').collect()
1109 }
1110}
1111
1112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1114pub enum TelemetryMetadataKind {
1115 ParameterNames,
1117 Units,
1119 Equations,
1121 BitSense,
1123}
1124
1125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1127pub struct Query<'a> {
1128 pub query: &'a [u8],
1130}
1131
1132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1134pub struct Capability<'a> {
1135 pub body: &'a [u8],
1137}
1138
1139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1141pub struct Nmea<'a> {
1142 pub sentence: &'a [u8],
1144}
1145
1146impl Nmea<'_> {
1147 #[must_use]
1149 pub fn talker_id(&self) -> Option<&[u8]> {
1150 let address = self.address_field()?;
1151 (address.len() >= 2).then_some(&address[..2])
1152 }
1153
1154 #[must_use]
1156 pub fn sentence_id(&self) -> Option<&[u8]> {
1157 let address = self.address_field()?;
1158 (address.len() >= 5).then_some(&address[2..5])
1159 }
1160
1161 #[must_use]
1163 pub fn data_fields(&self) -> Vec<&[u8]> {
1164 let body = self.body_without_checksum();
1165 let mut fields = body.split(|byte| *byte == b',');
1166 let _address = fields.next();
1167 fields.collect()
1168 }
1169
1170 #[must_use]
1172 pub fn checksum(&self) -> Option<NmeaChecksum> {
1173 let separator = self.sentence.iter().rposition(|byte| *byte == b'*')?;
1174 let checksum = self.sentence.get(separator + 1..separator + 3)?;
1175 if checksum.len() != 2 || self.sentence.get(separator + 3).is_some() {
1176 return None;
1177 }
1178
1179 let expected = parse_hex_byte(checksum)?;
1180 let calculated = self.sentence[..separator]
1181 .iter()
1182 .fold(0u8, |accumulator, byte| accumulator ^ byte);
1183
1184 Some(NmeaChecksum {
1185 expected,
1186 calculated,
1187 valid: expected == calculated,
1188 })
1189 }
1190
1191 fn address_field(&self) -> Option<&[u8]> {
1192 let body = self.body_without_checksum();
1193 let end = body
1194 .iter()
1195 .position(|byte| *byte == b',')
1196 .unwrap_or(body.len());
1197 let address = &body[..end];
1198 (address.len() >= 5 && address.iter().all(u8::is_ascii_alphanumeric)).then_some(address)
1199 }
1200
1201 fn body_without_checksum(&self) -> &[u8] {
1202 match self.sentence.iter().rposition(|byte| *byte == b'*') {
1203 Some(separator) => &self.sentence[..separator],
1204 None => self.sentence,
1205 }
1206 }
1207}
1208
1209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1211pub struct NmeaChecksum {
1212 pub expected: u8,
1214 pub calculated: u8,
1216 pub valid: bool,
1218}
1219
1220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1222pub struct MicE<'a> {
1223 pub identifier: u8,
1225 pub destination: &'a [u8],
1227 pub body: &'a [u8],
1229 pub status: Option<MicEStatus>,
1231 pub latitude_digits: Option<[u8; 6]>,
1233}
1234
1235impl MicE<'_> {
1236 #[must_use]
1238 pub fn coordinates(&self) -> Option<Coordinates> {
1239 Some(Coordinates {
1240 latitude: decode_mic_e_latitude(self.destination)?,
1241 longitude: decode_mic_e_longitude(self.destination, self.body)?,
1242 })
1243 }
1244
1245 #[must_use]
1247 pub fn speed_course(&self) -> Option<MicESpeedCourse> {
1248 decode_mic_e_speed_course(self.body)
1249 }
1250
1251 #[must_use]
1253 pub fn message_code(&self) -> Option<MicEMessageCode> {
1254 decode_mic_e_message_code(self.destination)
1255 }
1256}
1257
1258#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1260pub enum MicEStatus {
1261 Custom([bool; 3]),
1263}
1264
1265#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1267pub enum MicEMessageCode {
1268 Standard(MicEStandardMessage),
1270 Custom(u8),
1272 Emergency,
1274}
1275
1276#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1278pub enum MicEStandardMessage {
1279 OffDuty,
1281 EnRoute,
1283 InService,
1285 Returning,
1287 Committed,
1289 Special,
1291 Priority,
1293}
1294
1295#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1297pub struct MicESpeedCourse {
1298 pub speed_knots: u16,
1300 pub course_degrees: u16,
1302}
1303
1304#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1306pub struct Maidenhead<'a> {
1307 pub locator: &'a [u8],
1309 pub comment: &'a [u8],
1311}
1312
1313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1315pub struct UserDefined<'a> {
1316 pub user_id: u8,
1318 pub packet_type: u8,
1320 pub body: &'a [u8],
1322}
1323
1324#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1326pub struct ThirdParty<'a> {
1327 pub body: &'a [u8],
1329}
1330
1331impl ThirdParty<'_> {
1332 pub fn nested_packet(&self) -> Result<ParsedPacket, ParseError> {
1334 parse_packet(self.body)
1335 }
1336}
1337
1338#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1340pub enum DataTypeIdentifier {
1341 PositionNoTimestamp,
1343 PositionNoTimestampMessaging,
1345 PositionWithTimestamp,
1347 PositionWithTimestampMessaging,
1349 Status,
1351 Query,
1353 Capability,
1355 Message,
1357 Object,
1359 Item,
1361 Weather,
1363 Telemetry,
1365 Nmea,
1367 MicECurrent,
1369 MicEOld,
1371 Maidenhead,
1373 UserDefined,
1375 ThirdParty,
1377 Unknown(u8),
1379}
1380
1381impl DataTypeIdentifier {
1382 fn from_byte(byte: u8) -> Self {
1383 match byte {
1384 b'!' => Self::PositionNoTimestamp,
1385 b'=' => Self::PositionNoTimestampMessaging,
1386 b'/' => Self::PositionWithTimestamp,
1387 b'@' => Self::PositionWithTimestampMessaging,
1388 b'>' => Self::Status,
1389 b'?' => Self::Query,
1390 b'<' => Self::Capability,
1391 b':' => Self::Message,
1392 b';' => Self::Object,
1393 b')' => Self::Item,
1394 b'_' => Self::Weather,
1395 b'T' => Self::Telemetry,
1396 b'$' => Self::Nmea,
1397 b'`' => Self::MicECurrent,
1398 b'\'' => Self::MicEOld,
1399 b'[' => Self::Maidenhead,
1400 b'{' => Self::UserDefined,
1401 b'}' => Self::ThirdParty,
1402 other => Self::Unknown(other),
1403 }
1404 }
1405
1406 fn as_byte(self) -> u8 {
1407 match self {
1408 Self::PositionNoTimestamp => b'!',
1409 Self::PositionNoTimestampMessaging => b'=',
1410 Self::PositionWithTimestamp => b'/',
1411 Self::PositionWithTimestampMessaging => b'@',
1412 Self::Status => b'>',
1413 Self::Query => b'?',
1414 Self::Capability => b'<',
1415 Self::Message => b':',
1416 Self::Object => b';',
1417 Self::Item => b')',
1418 Self::Weather => b'_',
1419 Self::Telemetry => b'T',
1420 Self::Nmea => b'$',
1421 Self::MicECurrent => b'`',
1422 Self::MicEOld => b'\'',
1423 Self::Maidenhead => b'[',
1424 Self::UserDefined => b'{',
1425 Self::ThirdParty => b'}',
1426 Self::Unknown(value) => value,
1427 }
1428 }
1429
1430 #[must_use]
1432 pub fn name(self) -> &'static str {
1433 match self {
1434 Self::PositionNoTimestamp => "position_no_timestamp",
1435 Self::PositionNoTimestampMessaging => "position_no_timestamp_messaging",
1436 Self::PositionWithTimestamp => "position_with_timestamp",
1437 Self::PositionWithTimestampMessaging => "position_with_timestamp_messaging",
1438 Self::Status => "status",
1439 Self::Query => "query",
1440 Self::Capability => "capability",
1441 Self::Message => "message",
1442 Self::Object => "object",
1443 Self::Item => "item",
1444 Self::Weather => "weather",
1445 Self::Telemetry => "telemetry",
1446 Self::Nmea => "nmea",
1447 Self::MicECurrent => "mic_e_current",
1448 Self::MicEOld => "mic_e_old",
1449 Self::Maidenhead => "maidenhead",
1450 Self::UserDefined => "user_defined",
1451 Self::ThirdParty => "third_party",
1452 Self::Unknown(_) => "unknown",
1453 }
1454 }
1455}
1456
1457fn parse_aprs_data<'a>(
1458 identifier: DataTypeIdentifier,
1459 information: &'a [u8],
1460 destination: &'a [u8],
1461) -> AprsData<'a> {
1462 match identifier {
1463 DataTypeIdentifier::Status => AprsData::Status { text: information },
1464 DataTypeIdentifier::PositionNoTimestamp => parse_position(false, b'!', information),
1465 DataTypeIdentifier::PositionNoTimestampMessaging => parse_position(true, b'=', information),
1466 DataTypeIdentifier::PositionWithTimestamp => {
1467 parse_timestamped_position(false, b'/', information)
1468 }
1469 DataTypeIdentifier::PositionWithTimestampMessaging => {
1470 parse_timestamped_position(true, b'@', information)
1471 }
1472 DataTypeIdentifier::Message => parse_message(information),
1473 DataTypeIdentifier::Object => parse_object(information),
1474 DataTypeIdentifier::Item => parse_item(information),
1475 DataTypeIdentifier::Weather => AprsData::Weather(Weather {
1476 report: information,
1477 }),
1478 DataTypeIdentifier::Telemetry => parse_telemetry(information),
1479 DataTypeIdentifier::Query => AprsData::Query(Query { query: information }),
1480 DataTypeIdentifier::Capability => AprsData::Capability(Capability { body: information }),
1481 DataTypeIdentifier::Nmea => AprsData::Nmea(Nmea {
1482 sentence: information,
1483 }),
1484 DataTypeIdentifier::MicECurrent | DataTypeIdentifier::MicEOld => {
1485 parse_mic_e(identifier, information, destination)
1486 }
1487 DataTypeIdentifier::Maidenhead => parse_maidenhead(information),
1488 DataTypeIdentifier::UserDefined => parse_user_defined(information),
1489 DataTypeIdentifier::ThirdParty => AprsData::ThirdParty(ThirdParty { body: information }),
1490 other => AprsData::Unsupported {
1491 identifier: other.as_byte(),
1492 information,
1493 },
1494 }
1495}
1496
1497fn parse_mic_e<'a>(
1498 identifier: DataTypeIdentifier,
1499 information: &'a [u8],
1500 destination: &'a [u8],
1501) -> AprsData<'a> {
1502 if information.len() < 3 {
1503 return AprsData::Malformed {
1504 identifier: identifier.as_byte(),
1505 information,
1506 };
1507 }
1508
1509 AprsData::MicE(MicE {
1510 identifier: identifier.as_byte(),
1511 destination,
1512 body: information,
1513 status: decode_mic_e_status(destination),
1514 latitude_digits: decode_mic_e_latitude_digits(destination),
1515 })
1516}
1517
1518fn parse_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1519 if is_compressed_position(information) {
1520 return parse_compressed_position(messaging, identifier, information);
1521 }
1522
1523 if information.len() < 19 {
1524 return AprsData::Malformed {
1525 identifier,
1526 information,
1527 };
1528 }
1529
1530 let latitude = &information[..8];
1531 let symbol_table = information[8];
1532 let longitude = &information[9..18];
1533 let symbol_code = information[18];
1534 let comment = &information[19..];
1535
1536 if !is_latitude(latitude)
1537 || !is_symbol_table_identifier(symbol_table)
1538 || !is_longitude(longitude)
1539 || !is_printable_ascii(symbol_code)
1540 {
1541 return AprsData::Malformed {
1542 identifier,
1543 information,
1544 };
1545 }
1546
1547 AprsData::Position(Position {
1548 messaging,
1549 latitude,
1550 symbol_table,
1551 longitude,
1552 symbol_code,
1553 comment,
1554 })
1555}
1556
1557fn coordinates_from_position_body(body: &[u8]) -> Option<Coordinates> {
1558 if is_compressed_position(body) {
1559 let AprsData::CompressedPosition(position) = parse_compressed_position(false, b'!', body)
1560 else {
1561 return None;
1562 };
1563 return position.coordinates();
1564 }
1565
1566 let AprsData::Position(position) = parse_position(false, b'!', body) else {
1567 return None;
1568 };
1569 position.coordinates()
1570}
1571
1572fn parse_timestamped_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1573 if information.len() < 8 {
1574 return AprsData::Malformed {
1575 identifier,
1576 information,
1577 };
1578 }
1579
1580 let timestamp = &information[..7];
1581 if !is_timestamp(timestamp) {
1582 return AprsData::Malformed {
1583 identifier,
1584 information,
1585 };
1586 }
1587
1588 match parse_position(messaging, identifier, &information[7..]) {
1589 AprsData::Position(position) => AprsData::TimestampedPosition(TimestampedPosition {
1590 messaging,
1591 timestamp,
1592 position,
1593 }),
1594 AprsData::CompressedPosition(position) => AprsData::CompressedPosition(position),
1595 _ => AprsData::Malformed {
1596 identifier,
1597 information,
1598 },
1599 }
1600}
1601
1602fn parse_compressed_position(messaging: bool, identifier: u8, information: &[u8]) -> AprsData<'_> {
1603 if information.len() < 13 {
1604 return AprsData::Malformed {
1605 identifier,
1606 information,
1607 };
1608 }
1609
1610 let symbol_table = information[0];
1611 let compressed_latitude = &information[1..5];
1612 let compressed_longitude = &information[5..9];
1613 let symbol_code = information[9];
1614 let extension = &information[10..12];
1615 let compression_type = information[12];
1616 let comment = &information[13..];
1617
1618 if !is_symbol_table_identifier(symbol_table)
1619 || !compressed_latitude.iter().all(|byte| is_base91(*byte))
1620 || !compressed_longitude.iter().all(|byte| is_base91(*byte))
1621 || !is_printable_ascii(symbol_code)
1622 || !extension.iter().all(|byte| is_base91(*byte))
1623 || !is_base91(compression_type)
1624 {
1625 return AprsData::Malformed {
1626 identifier,
1627 information,
1628 };
1629 }
1630
1631 AprsData::CompressedPosition(CompressedPosition {
1632 messaging,
1633 symbol_table,
1634 compressed_latitude,
1635 compressed_longitude,
1636 symbol_code,
1637 extension,
1638 compression_type,
1639 comment,
1640 })
1641}
1642
1643fn parse_object(information: &[u8]) -> AprsData<'_> {
1644 if information.len() < 17
1645 || !matches!(information[9], b'*' | b'_')
1646 || !is_timestamp(&information[10..17])
1647 {
1648 return AprsData::Malformed {
1649 identifier: b';',
1650 information,
1651 };
1652 }
1653
1654 AprsData::Object(Object {
1655 name: &information[..9],
1656 live: information[9] == b'*',
1657 timestamp: &information[10..17],
1658 body: &information[17..],
1659 })
1660}
1661
1662fn parse_item(information: &[u8]) -> AprsData<'_> {
1663 let Some(separator) = information
1664 .iter()
1665 .position(|byte| matches!(*byte, b'!' | b'_'))
1666 else {
1667 return AprsData::Malformed {
1668 identifier: b')',
1669 information,
1670 };
1671 };
1672
1673 if separator == 0 || separator > 9 {
1674 return AprsData::Malformed {
1675 identifier: b')',
1676 information,
1677 };
1678 }
1679
1680 AprsData::Item(Item {
1681 name: &information[..separator],
1682 live: information[separator] == b'!',
1683 body: &information[separator + 1..],
1684 })
1685}
1686
1687fn parse_message(information: &[u8]) -> AprsData<'_> {
1688 if information.len() < 10 || information[9] != b':' {
1689 return AprsData::Malformed {
1690 identifier: b':',
1691 information,
1692 };
1693 }
1694
1695 let addressee = &information[..9];
1696 let body = &information[10..];
1697 if let Some(kind) = classify_telemetry_metadata_kind(addressee) {
1698 return AprsData::TelemetryMetadata(TelemetryMetadata {
1699 addressee,
1700 kind,
1701 body,
1702 });
1703 }
1704
1705 let (text, id) = match body.iter().position(|byte| *byte == b'{') {
1706 Some(separator) => (&body[..separator], Some(&body[separator + 1..])),
1707 None => (body, None),
1708 };
1709 let kind = classify_message_kind(addressee, text);
1710
1711 AprsData::Message(Message {
1712 addressee,
1713 kind,
1714 text,
1715 id,
1716 })
1717}
1718
1719fn parse_telemetry(information: &[u8]) -> AprsData<'_> {
1720 if !information.starts_with(b"#") {
1721 return AprsData::Malformed {
1722 identifier: b'T',
1723 information,
1724 };
1725 }
1726
1727 let fields: Vec<&[u8]> = information[1..].split(|byte| *byte == b',').collect();
1728 if fields.len() < 6 || fields[..6].iter().any(|field| field.is_empty()) {
1729 return AprsData::Malformed {
1730 identifier: b'T',
1731 information,
1732 };
1733 }
1734
1735 AprsData::Telemetry(Telemetry {
1736 sequence: fields[0],
1737 analog: [fields[1], fields[2], fields[3], fields[4], fields[5]],
1738 digital: fields.get(6).copied().filter(|field| !field.is_empty()),
1739 })
1740}
1741
1742fn parse_maidenhead(information: &[u8]) -> AprsData<'_> {
1743 if information.len() < 6 || !is_maidenhead_locator(&information[..6]) {
1744 return AprsData::Malformed {
1745 identifier: b'[',
1746 information,
1747 };
1748 }
1749
1750 AprsData::Maidenhead(Maidenhead {
1751 locator: &information[..6],
1752 comment: &information[6..],
1753 })
1754}
1755
1756fn parse_user_defined(information: &[u8]) -> AprsData<'_> {
1757 if information.len() < 2 {
1758 return AprsData::Malformed {
1759 identifier: b'{',
1760 information,
1761 };
1762 }
1763
1764 AprsData::UserDefined(UserDefined {
1765 user_id: information[0],
1766 packet_type: information[1],
1767 body: &information[2..],
1768 })
1769}
1770
1771fn classify_telemetry_metadata_kind(addressee: &[u8]) -> Option<TelemetryMetadataKind> {
1772 match addressee.get(..5)? {
1773 b"PARM." => Some(TelemetryMetadataKind::ParameterNames),
1774 b"UNIT." => Some(TelemetryMetadataKind::Units),
1775 b"EQNS." => Some(TelemetryMetadataKind::Equations),
1776 b"BITS." => Some(TelemetryMetadataKind::BitSense),
1777 _ => None,
1778 }
1779}
1780
1781fn classify_message_kind(addressee: &[u8], text: &[u8]) -> MessageKind {
1782 if text.starts_with(b"ack") {
1783 MessageKind::Ack
1784 } else if text.starts_with(b"rej") {
1785 MessageKind::Reject
1786 } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_digit) {
1787 MessageKind::Bulletin
1788 } else if addressee.starts_with(b"BLN") && addressee.get(3).is_some_and(u8::is_ascii_uppercase)
1789 {
1790 MessageKind::Announcement
1791 } else {
1792 MessageKind::Message
1793 }
1794}
1795
1796fn is_latitude(value: &[u8]) -> bool {
1797 if !(value.len() == 8
1798 && value[0].is_ascii_digit()
1799 && value[1].is_ascii_digit()
1800 && value[2].is_ascii_digit()
1801 && value[3].is_ascii_digit()
1802 && value[4] == b'.'
1803 && value[5].is_ascii_digit()
1804 && value[6].is_ascii_digit()
1805 && matches!(value[7], b'N' | b'S'))
1806 {
1807 return false;
1808 }
1809
1810 coordinate_in_range(&value[..2], &value[2..7], 90)
1811}
1812
1813fn is_longitude(value: &[u8]) -> bool {
1814 if !(value.len() == 9
1815 && value[0].is_ascii_digit()
1816 && value[1].is_ascii_digit()
1817 && value[2].is_ascii_digit()
1818 && value[3].is_ascii_digit()
1819 && value[4].is_ascii_digit()
1820 && value[5] == b'.'
1821 && value[6].is_ascii_digit()
1822 && value[7].is_ascii_digit()
1823 && matches!(value[8], b'E' | b'W'))
1824 {
1825 return false;
1826 }
1827
1828 coordinate_in_range(&value[..3], &value[3..8], 180)
1829}
1830
1831fn coordinate_in_range(degrees: &[u8], minutes: &[u8], max_degrees: u16) -> bool {
1832 let Some(degrees) = parse_u16(degrees) else {
1833 return false;
1834 };
1835 let Some(minutes) = parse_fixed_minutes(minutes) else {
1836 return false;
1837 };
1838
1839 degrees < max_degrees || (degrees == max_degrees && minutes == 0.0)
1840}
1841
1842fn is_symbol_table_identifier(value: u8) -> bool {
1843 matches!(value, b'/' | b'\\') || value.is_ascii_alphanumeric()
1844}
1845
1846fn is_printable_ascii(value: u8) -> bool {
1847 (0x20..=0x7e).contains(&value)
1848}
1849
1850fn is_base91(value: u8) -> bool {
1851 (b'!'..=b'{').contains(&value)
1852}
1853
1854fn is_compressed_position(information: &[u8]) -> bool {
1855 information
1856 .first()
1857 .is_some_and(|byte| !byte.is_ascii_digit() && is_symbol_table_identifier(*byte))
1858 && information
1859 .get(1..13)
1860 .is_some_and(|bytes| bytes.iter().all(|byte| is_base91(*byte)))
1861}
1862
1863fn is_timestamp(value: &[u8]) -> bool {
1864 value.len() == 7
1865 && value[..6].iter().all(u8::is_ascii_digit)
1866 && matches!(value[6], b'z' | b'/' | b'h')
1867}
1868
1869fn is_maidenhead_locator(value: &[u8]) -> bool {
1870 value.len() == 6
1871 && is_ascii_alpha_range(value[0], b'A', b'R')
1872 && is_ascii_alpha_range(value[1], b'A', b'R')
1873 && value[2].is_ascii_digit()
1874 && value[3].is_ascii_digit()
1875 && is_ascii_alpha_range(value[4], b'A', b'X')
1876 && is_ascii_alpha_range(value[5], b'A', b'X')
1877}
1878
1879fn is_ascii_alpha_range(value: u8, start: u8, end: u8) -> bool {
1880 let uppercase = value.to_ascii_uppercase();
1881 (start..=end).contains(&uppercase)
1882}
1883
1884fn decode_latitude(value: &[u8]) -> Option<f64> {
1885 if !is_latitude(value) {
1886 return None;
1887 }
1888
1889 let degrees = parse_u16(&value[..2])? as f64;
1890 let minutes = parse_fixed_minutes(&value[2..7])?;
1891 let sign = match value[7] {
1892 b'N' => 1.0,
1893 b'S' => -1.0,
1894 _ => return None,
1895 };
1896
1897 Some(sign * (degrees + minutes / 60.0))
1898}
1899
1900fn decode_longitude(value: &[u8]) -> Option<f64> {
1901 if !is_longitude(value) {
1902 return None;
1903 }
1904
1905 let degrees = parse_u16(&value[..3])? as f64;
1906 let minutes = parse_fixed_minutes(&value[3..8])?;
1907 let sign = match value[8] {
1908 b'E' => 1.0,
1909 b'W' => -1.0,
1910 _ => return None,
1911 };
1912
1913 Some(sign * (degrees + minutes / 60.0))
1914}
1915
1916fn parse_fixed_minutes(value: &[u8]) -> Option<f64> {
1917 if value.len() != 5 || value[2] != b'.' || !value[..2].iter().all(u8::is_ascii_digit) {
1918 return None;
1919 }
1920
1921 let whole = parse_u16(&value[..2])? as f64;
1922 let fraction = parse_u16(&value[3..])? as f64 / 100.0;
1923 Some(whole + fraction)
1924}
1925
1926fn decode_base91(value: &[u8]) -> Option<u32> {
1927 if value.len() != 4 || !value.iter().all(|byte| is_base91(*byte)) {
1928 return None;
1929 }
1930
1931 let mut decoded = 0u32;
1932 for byte in value {
1933 decoded = decoded * 91 + u32::from(byte - b'!');
1934 }
1935
1936 Some(decoded)
1937}
1938
1939fn parse_u16(value: &[u8]) -> Option<u16> {
1940 if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
1941 return None;
1942 }
1943
1944 let mut parsed = 0u16;
1945 for digit in value {
1946 parsed = parsed.checked_mul(10)?;
1947 parsed = parsed.checked_add(u16::from(digit - b'0'))?;
1948 }
1949
1950 Some(parsed)
1951}
1952
1953fn parse_i16(value: &[u8]) -> Option<i16> {
1954 if value.is_empty() {
1955 return None;
1956 }
1957
1958 let (sign, digits) = match value[0] {
1959 b'-' => (-1, &value[1..]),
1960 b'+' => (1, &value[1..]),
1961 _ => (1, value),
1962 };
1963
1964 let unsigned = parse_u16(digits)?;
1965 i16::try_from(unsigned).ok()?.checked_mul(sign)
1966}
1967
1968fn parse_hex_byte(value: &[u8]) -> Option<u8> {
1969 if value.len() != 2 {
1970 return None;
1971 }
1972
1973 Some(hex_value(value[0])? * 16 + hex_value(value[1])?)
1974}
1975
1976fn hex_value(value: u8) -> Option<u8> {
1977 match value {
1978 b'0'..=b'9' => Some(value - b'0'),
1979 b'A'..=b'F' => Some(value - b'A' + 10),
1980 b'a'..=b'f' => Some(value - b'a' + 10),
1981 _ => None,
1982 }
1983}
1984
1985fn parse_tagged_u16(report: &[u8], tag: u8, width: usize) -> Option<u16> {
1986 parse_tagged(report, tag, width).and_then(parse_u16)
1987}
1988
1989fn parse_tagged_i16(report: &[u8], tag: u8, width: usize) -> Option<i16> {
1990 parse_tagged(report, tag, width).and_then(parse_i16)
1991}
1992
1993fn parse_tagged(report: &[u8], tag: u8, width: usize) -> Option<&[u8]> {
1994 let start = report.iter().position(|byte| *byte == tag)? + 1;
1995 report.get(start..start + width)
1996}
1997
1998fn decode_mic_e_status(destination: &[u8]) -> Option<MicEStatus> {
1999 if destination.len() != 6 {
2000 return None;
2001 }
2002
2003 let bytes = destination.get(..3)?;
2004 Some(MicEStatus::Custom([
2005 mic_e_status_bit(bytes[0])?,
2006 mic_e_status_bit(bytes[1])?,
2007 mic_e_status_bit(bytes[2])?,
2008 ]))
2009}
2010
2011fn decode_mic_e_message_code(destination: &[u8]) -> Option<MicEMessageCode> {
2012 if destination.len() != 6 {
2013 return None;
2014 }
2015
2016 let mut bits = [MicEMessageBit::Zero; 3];
2017 for (index, byte) in destination[..3].iter().copied().enumerate() {
2018 bits[index] = mic_e_message_bit(byte)?;
2019 }
2020
2021 let code = message_code_number([
2022 !matches!(bits[0], MicEMessageBit::Zero),
2023 !matches!(bits[1], MicEMessageBit::Zero),
2024 !matches!(bits[2], MicEMessageBit::Zero),
2025 ]);
2026
2027 if code == 7 {
2028 return Some(MicEMessageCode::Emergency);
2029 }
2030
2031 let has_standard = bits
2032 .iter()
2033 .any(|bit| matches!(bit, MicEMessageBit::StandardOne));
2034 let has_custom = bits
2035 .iter()
2036 .any(|bit| matches!(bit, MicEMessageBit::CustomOne));
2037
2038 if has_standard && !has_custom {
2039 return standard_mic_e_message(code).map(MicEMessageCode::Standard);
2040 }
2041
2042 if has_custom && !has_standard {
2043 return Some(MicEMessageCode::Custom(code));
2044 }
2045
2046 None
2047}
2048
2049#[derive(Clone, Copy)]
2050enum MicEMessageBit {
2051 Zero,
2052 StandardOne,
2053 CustomOne,
2054}
2055
2056fn mic_e_message_bit(byte: u8) -> Option<MicEMessageBit> {
2057 match byte {
2058 b'0'..=b'9' | b'L' => Some(MicEMessageBit::Zero),
2059 b'A'..=b'K' => Some(MicEMessageBit::StandardOne),
2060 b'P'..=b'Z' => Some(MicEMessageBit::CustomOne),
2061 _ => None,
2062 }
2063}
2064
2065fn message_code_number(bits: [bool; 3]) -> u8 {
2066 match bits {
2067 [true, true, true] => 0,
2068 [true, true, false] => 1,
2069 [true, false, true] => 2,
2070 [true, false, false] => 3,
2071 [false, true, true] => 4,
2072 [false, true, false] => 5,
2073 [false, false, true] => 6,
2074 [false, false, false] => 7,
2075 }
2076}
2077
2078fn standard_mic_e_message(code: u8) -> Option<MicEStandardMessage> {
2079 match code {
2080 0 => Some(MicEStandardMessage::OffDuty),
2081 1 => Some(MicEStandardMessage::EnRoute),
2082 2 => Some(MicEStandardMessage::InService),
2083 3 => Some(MicEStandardMessage::Returning),
2084 4 => Some(MicEStandardMessage::Committed),
2085 5 => Some(MicEStandardMessage::Special),
2086 6 => Some(MicEStandardMessage::Priority),
2087 _ => None,
2088 }
2089}
2090
2091fn mic_e_status_bit(byte: u8) -> Option<bool> {
2092 match byte {
2093 b'0'..=b'9' | b'L' => Some(false),
2094 b'A'..=b'K' | b'P'..=b'Z' => Some(true),
2095 _ => None,
2096 }
2097}
2098
2099fn decode_mic_e_latitude_digits(destination: &[u8]) -> Option<[u8; 6]> {
2100 if destination.len() != 6 {
2101 return None;
2102 }
2103
2104 let mut digits = [0u8; 6];
2105 for (index, byte) in destination.iter().copied().enumerate() {
2106 digits[index] = mic_e_latitude_digit(byte)?;
2107 }
2108
2109 Some(digits)
2110}
2111
2112fn mic_e_latitude_digit(byte: u8) -> Option<u8> {
2113 match byte {
2114 b'0'..=b'9' => Some(byte - b'0'),
2115 b'A'..=b'J' => Some(byte - b'A'),
2116 b'P'..=b'Y' => Some(byte - b'P'),
2117 b'K' | b'L' | b'Z' => Some(0),
2118 _ => None,
2119 }
2120}
2121
2122fn decode_mic_e_latitude(destination: &[u8]) -> Option<f64> {
2123 let digits = decode_mic_e_latitude_digits(destination)?;
2124 let degrees = u16::from(digits[0]) * 10 + u16::from(digits[1]);
2125 let minutes = u16::from(digits[2]) * 10 + u16::from(digits[3]);
2126 let hundredths = u16::from(digits[4]) * 10 + u16::from(digits[5]);
2127 if degrees > 90 || minutes > 59 {
2128 return None;
2129 }
2130
2131 let sign = if mic_e_north(destination[3])? {
2132 1.0
2133 } else {
2134 -1.0
2135 };
2136 Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
2137}
2138
2139fn decode_mic_e_longitude(destination: &[u8], body: &[u8]) -> Option<f64> {
2140 if destination.len() != 6 || body.len() < 3 {
2141 return None;
2142 }
2143
2144 let mut degrees = i16::from(mic_e_body_value(body[0])?);
2145 if mic_e_longitude_offset(destination[4])? {
2146 degrees += 100;
2147 }
2148 if (180..=189).contains(°rees) {
2149 degrees -= 80;
2150 } else if (190..=199).contains(°rees) {
2151 degrees -= 190;
2152 }
2153
2154 let minutes = mic_e_body_value(body[1])?;
2155 let hundredths = mic_e_body_value(body[2])?;
2156 if !(0..=179).contains(°rees) || minutes > 59 || hundredths > 99 {
2157 return None;
2158 }
2159
2160 let sign = if mic_e_west(destination[5])? {
2161 -1.0
2162 } else {
2163 1.0
2164 };
2165 Some(sign * (f64::from(degrees) + (f64::from(minutes) + f64::from(hundredths) / 100.0) / 60.0))
2166}
2167
2168fn decode_mic_e_speed_course(body: &[u8]) -> Option<MicESpeedCourse> {
2169 if body.len() < 6 {
2170 return None;
2171 }
2172
2173 let speed_tens = u16::from(mic_e_body_value(body[3])?);
2174 let speed_units_course_hundreds = u16::from(mic_e_body_value(body[4])?);
2175 let course_remainder = u16::from(mic_e_body_value(body[5])?);
2176 let mut speed_knots = speed_tens * 10 + speed_units_course_hundreds / 10;
2177 if speed_knots >= 800 {
2178 speed_knots -= 800;
2179 }
2180
2181 Some(MicESpeedCourse {
2182 speed_knots,
2183 course_degrees: (speed_units_course_hundreds % 10) * 100 + course_remainder,
2184 })
2185}
2186
2187fn mic_e_body_value(byte: u8) -> Option<u8> {
2188 let value = byte.checked_sub(28)?;
2189 (value <= 99).then_some(value)
2190}
2191
2192fn mic_e_north(byte: u8) -> Option<bool> {
2193 match byte {
2194 b'0'..=b'9' | b'A'..=b'L' => Some(false),
2195 b'P'..=b'Z' => Some(true),
2196 _ => None,
2197 }
2198}
2199
2200fn mic_e_longitude_offset(byte: u8) -> Option<bool> {
2201 match byte {
2202 b'0'..=b'9' | b'A'..=b'L' => Some(false),
2203 b'P'..=b'Z' => Some(true),
2204 _ => None,
2205 }
2206}
2207
2208fn mic_e_west(byte: u8) -> Option<bool> {
2209 match byte {
2210 b'0'..=b'9' | b'A'..=b'L' => Some(false),
2211 b'P'..=b'Z' => Some(true),
2212 _ => None,
2213 }
2214}
2215
2216#[derive(Clone, Debug, Eq, PartialEq)]
2218pub enum ParseError {
2219 Empty,
2221 Oversized,
2223 MissingSeparator,
2225 EmptySegment,
2227 InvalidAddress,
2229}
2230
2231impl ParseError {
2232 #[must_use]
2234 pub fn code(&self) -> &'static str {
2235 match self {
2236 Self::Empty => "parse.empty",
2237 Self::Oversized => "parse.oversized",
2238 Self::MissingSeparator => "parse.missing_separator",
2239 Self::EmptySegment => "parse.empty_segment",
2240 Self::InvalidAddress => "parse.invalid_address",
2241 }
2242 }
2243
2244 #[must_use]
2246 pub fn diagnostic(&self) -> ErrorDiagnostic {
2247 match self {
2248 Self::Empty => ErrorDiagnostic {
2249 layer: DiagnosticLayer::Parse,
2250 code: self.code(),
2251 name: "empty",
2252 description: "no packet bytes were supplied to the codec boundary",
2253 remediation: "drop empty transport records before calling parse_packet",
2254 },
2255 Self::Oversized => ErrorDiagnostic {
2256 layer: DiagnosticLayer::Parse,
2257 code: self.code(),
2258 name: "oversized",
2259 description: "packet exceeds the configured parser byte limit",
2260 remediation: "reject the input or lower upstream batch sizes before parsing",
2261 },
2262 Self::MissingSeparator => ErrorDiagnostic {
2263 layer: DiagnosticLayer::Parse,
2264 code: self.code(),
2265 name: "missing_separator",
2266 description: "packet is missing the required source>path:payload separators",
2267 remediation: "only send source>path:payload APRS packet bytes into the codec",
2268 },
2269 Self::EmptySegment => ErrorDiagnostic {
2270 layer: DiagnosticLayer::Parse,
2271 code: self.code(),
2272 name: "empty_segment",
2273 description: "packet contains an empty source, path, or payload segment",
2274 remediation: "reject the input and inspect upstream framing before retrying",
2275 },
2276 Self::InvalidAddress => ErrorDiagnostic {
2277 layer: DiagnosticLayer::Parse,
2278 code: self.code(),
2279 name: "invalid_address",
2280 description:
2281 "packet source or path contains bytes outside the conservative address set",
2282 remediation:
2283 "preserve the raw bytes for review and reject malformed address metadata",
2284 },
2285 }
2286 }
2287}
2288
2289pub fn parse_packet(input: &[u8]) -> Result<ParsedPacket, ParseError> {
2295 parse_packet_with_options(input, ParseOptions::default())
2296}
2297
2298pub fn parse_packet_with_options(
2300 input: &[u8],
2301 options: ParseOptions,
2302) -> Result<ParsedPacket, ParseError> {
2303 if input.is_empty() {
2304 return Err(ParseError::Empty);
2305 }
2306
2307 if input.len() > options.max_packet_len {
2308 return Err(ParseError::Oversized);
2309 }
2310
2311 let source_end = input
2312 .iter()
2313 .position(|byte| *byte == b'>')
2314 .ok_or(ParseError::MissingSeparator)?;
2315 let payload_separator = input[source_end + 1..]
2316 .iter()
2317 .position(|byte| *byte == b':')
2318 .map(|offset| source_end + 1 + offset)
2319 .ok_or(ParseError::MissingSeparator)?;
2320
2321 let path_start = source_end + 1;
2322 let path_end = payload_separator;
2323 let payload_start = payload_separator + 1;
2324
2325 if source_end == 0 || path_start == path_end || payload_start == input.len() {
2326 return Err(ParseError::EmptySegment);
2327 }
2328
2329 let Some(path_components) = path_component_ranges(input, path_start, path_end) else {
2330 return Err(ParseError::InvalidAddress);
2331 };
2332
2333 if !is_ax25_like_source(&input[..source_end])
2334 || !path_components
2335 .iter()
2336 .all(|(start, end)| is_ax25_like_path_component(&input[*start..*end]))
2337 {
2338 return Err(ParseError::InvalidAddress);
2339 }
2340
2341 Ok(ParsedPacket {
2342 raw: RawPacket {
2343 bytes: input.to_vec(),
2344 },
2345 source_end,
2346 path_start,
2347 path_end,
2348 path_components,
2349 payload_start,
2350 })
2351}
2352
2353fn path_component_ranges(
2354 input: &[u8],
2355 path_start: usize,
2356 path_end: usize,
2357) -> Option<Vec<(usize, usize)>> {
2358 let mut components = Vec::new();
2359 let mut component_start = path_start;
2360
2361 for (offset, byte) in input[path_start..path_end].iter().enumerate() {
2362 if *byte == b',' {
2363 let index = path_start + offset;
2364 if component_start == index {
2365 return None;
2366 }
2367 components.push((component_start, index));
2368 component_start = index + 1;
2369 }
2370 }
2371
2372 if component_start == path_end {
2373 return None;
2374 }
2375
2376 components.push((component_start, path_end));
2377 Some(components)
2378}
2379
2380fn is_ax25_like_source(source: &[u8]) -> bool {
2381 is_ax25_like_address(source, false)
2382}
2383
2384fn is_ax25_like_path_component(component: &[u8]) -> bool {
2385 is_ax25_like_address(component, true)
2386}
2387
2388fn is_ax25_like_address(address: &[u8], allow_repeated_marker: bool) -> bool {
2389 let address = if allow_repeated_marker {
2390 address.strip_suffix(b"*").unwrap_or(address)
2391 } else {
2392 address
2393 };
2394
2395 if address.is_empty() || address.contains(&b'*') {
2396 return false;
2397 }
2398
2399 let (callsign, ssid) = match address.iter().position(|byte| *byte == b'-') {
2400 Some(separator) => (&address[..separator], Some(&address[separator + 1..])),
2401 None => (address, None),
2402 };
2403
2404 is_ax25_like_callsign(callsign) && ssid.map_or(true, is_ax25_like_ssid)
2405}
2406
2407fn is_ax25_like_callsign(callsign: &[u8]) -> bool {
2408 (1..=6).contains(&callsign.len())
2409 && callsign
2410 .iter()
2411 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
2412}
2413
2414fn is_ax25_like_ssid(ssid: &[u8]) -> bool {
2415 if ssid.is_empty() || ssid.len() > 2 || !ssid.iter().all(u8::is_ascii_digit) {
2416 return false;
2417 }
2418
2419 let mut value = 0u8;
2420 for digit in ssid {
2421 value = value * 10 + (digit - b'0');
2422 }
2423
2424 value <= 15
2425}