Skip to main content

nmea_kit/ais/
mod.rs

1//! AIS (Automatic Identification System) message decoding.
2//!
3//! Read-only: decodes AIVDM/AIVDO messages from `!`-prefixed NMEA frames.
4//! Transmitting AIS requires certified hardware.
5//!
6//! # Usage
7//!
8//! ```
9//! use nmea_kit::ais::{AisParser, AisMessage};
10//! use nmea_kit::parse_frame;
11//!
12//! let mut parser = AisParser::new();
13//!
14//! // Single-fragment message
15//! let frame = parse_frame("!AIVDM,1,1,,A,13aEOK?P00PD2wVMdLDRhgvL289?,0*26").expect("valid");
16//! if let Some(msg) = parser.decode(&frame) {
17//!     match msg {
18//!         AisMessage::Position(pos) => println!("MMSI: {}, lat: {:?}", pos.mmsi, pos.latitude),
19//!         _ => {}
20//!     }
21//! }
22//! ```
23
24pub mod armor;
25pub mod fragments;
26pub mod messages;
27
28pub use messages::*;
29
30use armor::decode_armor;
31use fragments::FragmentCollector;
32
33use crate::NmeaFrame;
34
35/// Unified AIS message enum.
36#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq)]
38pub enum AisMessage {
39    /// Types 1, 2, 3 (Class A), 18 (Class B), 19 (Class B+) position reports.
40    Position(PositionReport),
41    /// Type 5: static and voyage related data (Class A).
42    StaticVoyage(StaticVoyageData),
43    /// Type 24: static data report (Class B), Part A or Part B.
44    StaticReport(StaticDataReport),
45    /// Unsupported message type.
46    Unknown { msg_type: u8 },
47}
48
49/// Stateful AIS parser with multi-fragment reassembly.
50///
51/// Maintains fragment buffers for concurrent multi-part messages.
52/// Feed it frames from `parse_frame()` — it returns decoded messages.
53pub struct AisParser {
54    collector: FragmentCollector,
55}
56
57impl AisParser {
58    pub fn new() -> Self {
59        Self {
60            collector: FragmentCollector::new(),
61        }
62    }
63
64    /// Clear all in-progress fragment buffers.
65    ///
66    /// Useful when switching data sources or recovering from a corrupted stream.
67    pub fn reset(&mut self) {
68        self.collector = FragmentCollector::new();
69    }
70
71    /// Decode an AIS frame. Returns `Some(AisMessage)` for complete messages,
72    /// `None` for incomplete fragments, parse errors, or non-AIS frames.
73    pub fn decode(&mut self, frame: &NmeaFrame<'_>) -> Option<AisMessage> {
74        // Only handle VDM and VDO sentences
75        if frame.prefix != '!' || (frame.sentence_type != "VDM" && frame.sentence_type != "VDO") {
76            return None;
77        }
78
79        // Reassemble fragments
80        let payload = self.collector.process(&frame.fields)?;
81
82        // Decode armor
83        let bits = decode_armor(&payload.payload, payload.fill_bits)?;
84
85        // Extract message type (first 6 bits)
86        let msg_type = armor::extract_u32(&bits, 0, 6)? as u8;
87
88        // Dispatch to message decoder
89        match msg_type {
90            1..=3 => PositionReport::decode_class_a(&bits).map(AisMessage::Position),
91            5 => StaticVoyageData::decode(&bits).map(AisMessage::StaticVoyage),
92            18 => PositionReport::decode_class_b(&bits).map(AisMessage::Position),
93            19 => PositionReport::decode_class_b_extended(&bits).map(AisMessage::Position),
94            24 => StaticDataReport::decode(&bits).map(AisMessage::StaticReport),
95            _ => Some(AisMessage::Unknown { msg_type }),
96        }
97    }
98}
99
100impl Default for AisParser {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::parse_frame;
110
111    #[test]
112    fn ignores_nmea_sentences() {
113        let mut parser = AisParser::new();
114        let frame =
115            parse_frame("$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*77")
116                .expect("valid");
117        assert!(parser.decode(&frame).is_none());
118    }
119
120    #[test]
121    fn sentinel_values_filtered() {
122        let mut parser = AisParser::new();
123        let frame = parse_frame("!AIVDM,1,1,,A,13aEOK?P00PD2wVMdLDRhgvL289?,0*26").expect("valid");
124        let msg = parser.decode(&frame).expect("decoded");
125        if let AisMessage::Position(pos) = msg {
126            assert!(pos.heading.is_none() || pos.heading.expect("heading") < 360);
127        }
128    }
129
130    #[test]
131    fn type_18_class_b() {
132        let mut parser = AisParser::new();
133        let frame = parse_frame("!AIVDM,1,1,,A,B6CdCm0t3`tba35f@V9faHi7kP06,0*58").expect("valid");
134        let msg = parser.decode(&frame);
135        // This might be a type 18 or might not decode depending on exact payload
136        // At minimum it shouldn't panic
137        if let Some(AisMessage::Position(pos)) = &msg {
138            assert_eq!(pos.ais_class, AisClass::B);
139        }
140    }
141
142    #[test]
143    fn type_19_class_b_extended() {
144        let mut parser = AisParser::new();
145        // GPSD fixture: Type 19 Class B+ extended position report
146        let frame =
147            parse_frame("!AIVDM,1,1,,B,C5N3SRgPEnJGEBT>NhWAwwo862PaLELTBJ:V00000000S0D:R220,0*0B")
148                .expect("valid type 19 frame");
149        let msg = parser.decode(&frame).expect("decode type 19");
150        if let AisMessage::Position(pos) = msg {
151            assert_eq!(pos.msg_type, 19);
152            assert!(pos.mmsi > 0);
153            assert!(pos.latitude.is_some());
154            assert!(pos.longitude.is_some());
155            assert_eq!(pos.ais_class, AisClass::BPlus);
156        } else {
157            panic!("expected Position (type 19), got {msg:?}");
158        }
159    }
160
161    #[test]
162    fn type_1_position_report() {
163        let mut parser = AisParser::new();
164        let frame = parse_frame("!AIVDM,1,1,,A,13aEOK?P00PD2wVMdLDRhgvL289?,0*26").expect("valid");
165        let msg = parser.decode(&frame).expect("decoded");
166        if let AisMessage::Position(pos) = msg {
167            assert_eq!(pos.msg_type, 1);
168            assert!(pos.mmsi > 0);
169            assert!(pos.latitude.is_some());
170            assert!(pos.longitude.is_some());
171            assert_eq!(pos.ais_class, AisClass::A);
172            // Verify f64 precision
173            let lat = pos.latitude.expect("valid");
174            let lon = pos.longitude.expect("valid");
175            assert!((-90.0..=90.0).contains(&lat));
176            assert!((-180.0..=180.0).contains(&lon));
177        } else {
178            panic!("expected Position, got {msg:?}");
179        }
180    }
181
182    #[test]
183    fn type_24_static_data_report() {
184        let mut parser = AisParser::new();
185        // Type 24 Part A: vessel name
186        let frame = parse_frame("!AIVDM,1,1,,A,H52N>V@T2rNVPJ2000000000000,2*29")
187            .expect("valid type 24 frame");
188        let msg = parser.decode(&frame).expect("decode type 24");
189        if let AisMessage::StaticReport(report) = msg {
190            match report {
191                StaticDataReport::PartA { mmsi, vessel_name } => {
192                    assert!(mmsi > 0);
193                    // Vessel name may be all padding (@) — trimmed to empty
194                    let _ = vessel_name;
195                }
196                StaticDataReport::PartB { mmsi, .. } => {
197                    assert!(mmsi > 0);
198                }
199            }
200        } else {
201            panic!("expected StaticReport (type 24), got {msg:?}");
202        }
203    }
204
205    #[test]
206    fn type_5_multi_fragment() {
207        let mut parser = AisParser::new();
208
209        // GPSD sample.aivdm Type 5 fixture
210        let f1 = parse_frame(
211            "!AIVDM,2,1,1,A,55?MbV02;H;s<HtKR20EHE:0@T4@Dn2222222216L961O5Gf0NSQEp6ClRp8,0*1C",
212        )
213        .expect("valid frag1");
214        assert!(parser.decode(&f1).is_none()); // incomplete
215
216        let f2 = parse_frame("!AIVDM,2,2,1,A,88888888880,2*25").expect("valid frag2");
217        let msg = parser.decode(&f2).expect("decoded");
218        if let AisMessage::StaticVoyage(svd) = msg {
219            assert!(svd.mmsi > 0);
220            assert!(!svd.vessel_name.is_empty());
221            assert_eq!(svd.ais_class, AisClass::A);
222        } else {
223            panic!("expected StaticVoyage, got {msg:?}");
224        }
225    }
226
227    #[test]
228    fn reset_clears_pending_fragments() {
229        let mut parser = AisParser::new();
230        // Send fragment 1 of 2
231        let f1 = parse_frame(
232            "!AIVDM,2,1,1,A,55?MbV02;H;s<HtKR20EHE:0@T4@Dn2222222216L961O5Gf0NSQEp6ClRp8,0*1C",
233        )
234        .expect("valid");
235        assert!(parser.decode(&f1).is_none());
236        // Reset clears the pending fragment
237        parser.reset();
238        // Fragment 2 alone should not produce a message
239        let f2 = parse_frame("!AIVDM,2,2,1,A,88888888880,2*25").expect("valid");
240        assert!(parser.decode(&f2).is_none());
241    }
242
243    #[test]
244    fn unknown_message_type() {
245        let mut parser = AisParser::new();
246        // Type 8 binary broadcast — should return Unknown
247        let frame = parse_frame("!AIVDM,1,1,,A,85Mv070j2d>=<e<<=PQhhg`59P00,0*26").expect("valid");
248        let msg = parser.decode(&frame);
249        if let Some(AisMessage::Unknown { msg_type }) = msg {
250            assert_eq!(msg_type, 8);
251        } else {
252            panic!("expected Unknown type 8, got {msg:?}");
253        }
254    }
255}