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 4: UTC time and position from base station (coast guard / port authority).
42    BaseStation(BaseStationReport),
43    /// Type 5: static and voyage related data (Class A).
44    StaticVoyage(StaticVoyageData),
45    /// Type 24: static data report (Class B), Part A or Part B.
46    StaticReport(StaticDataReport),
47    /// Type 14: safety-related broadcast message (text alert from shore/vessel).
48    Safety(SafetyBroadcast),
49    /// Type 21: aid-to-navigation report (buoy, beacon, lighthouse).
50    AidToNavigation(AidToNavigation),
51    /// Type 27: long-range position report (satellite AIS / Class D).
52    LongRangePosition(LongRangePosition),
53    /// Unsupported message type.
54    Unknown { msg_type: u8 },
55}
56
57/// Stateful AIS parser with multi-fragment reassembly.
58///
59/// Maintains fragment buffers for concurrent multi-part messages.
60/// Feed it frames from `parse_frame()` — it returns decoded messages.
61pub struct AisParser {
62    collector: FragmentCollector,
63}
64
65impl AisParser {
66    pub fn new() -> Self {
67        Self {
68            collector: FragmentCollector::new(),
69        }
70    }
71
72    /// Clear all in-progress fragment buffers.
73    ///
74    /// Useful when switching data sources or recovering from a corrupted stream.
75    pub fn reset(&mut self) {
76        self.collector = FragmentCollector::new();
77    }
78
79    /// Decode an AIS frame. Returns `Some(AisMessage)` for complete messages,
80    /// `None` for incomplete fragments, parse errors, or non-AIS frames.
81    pub fn decode(&mut self, frame: &NmeaFrame<'_>) -> Option<AisMessage> {
82        // Only handle VDM and VDO sentences
83        if frame.prefix != '!' || (frame.sentence_type != "VDM" && frame.sentence_type != "VDO") {
84            return None;
85        }
86
87        // Reassemble fragments
88        let payload = self.collector.process(&frame.fields)?;
89
90        // Decode armor
91        let bits = decode_armor(&payload.payload, payload.fill_bits)?;
92
93        // Extract message type (first 6 bits)
94        let msg_type = armor::extract_u32(&bits, 0, 6)? as u8;
95
96        // Dispatch to message decoder
97        match msg_type {
98            1..=3 => PositionReport::decode_class_a(&bits).map(AisMessage::Position),
99            4 => BaseStationReport::decode(&bits).map(AisMessage::BaseStation),
100            5 => StaticVoyageData::decode(&bits).map(AisMessage::StaticVoyage),
101            14 => SafetyBroadcast::decode(&bits).map(AisMessage::Safety),
102            18 => PositionReport::decode_class_b(&bits).map(AisMessage::Position),
103            19 => PositionReport::decode_class_b_extended(&bits).map(AisMessage::Position),
104            21 => AidToNavigation::decode(&bits).map(AisMessage::AidToNavigation),
105            24 => StaticDataReport::decode(&bits).map(AisMessage::StaticReport),
106            27 => LongRangePosition::decode(&bits).map(AisMessage::LongRangePosition),
107            _ => Some(AisMessage::Unknown { msg_type }),
108        }
109    }
110}
111
112impl Default for AisParser {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::parse_frame;
122
123    #[test]
124    fn ignores_nmea_sentences() {
125        let mut parser = AisParser::new();
126        let frame =
127            parse_frame("$GPRMC,175957.917,A,3857.1234,N,07705.1234,W,0.0,0.0,010100,,,A*77")
128                .expect("valid");
129        assert!(parser.decode(&frame).is_none());
130    }
131
132    #[test]
133    fn sentinel_values_filtered() {
134        let mut parser = AisParser::new();
135        let frame = parse_frame("!AIVDM,1,1,,A,13aEOK?P00PD2wVMdLDRhgvL289?,0*26").expect("valid");
136        let msg = parser.decode(&frame).expect("decoded");
137        if let AisMessage::Position(pos) = msg {
138            assert!(pos.heading.is_none() || pos.heading.expect("heading") < 360);
139        }
140    }
141
142    #[test]
143    fn type_18_class_b() {
144        let mut parser = AisParser::new();
145        let frame = parse_frame("!AIVDM,1,1,,A,B6CdCm0t3`tba35f@V9faHi7kP06,0*58").expect("valid");
146        let msg = parser.decode(&frame);
147        // This might be a type 18 or might not decode depending on exact payload
148        // At minimum it shouldn't panic
149        if let Some(AisMessage::Position(pos)) = &msg {
150            assert_eq!(pos.ais_class, AisClass::B);
151        }
152    }
153
154    #[test]
155    fn type_19_class_b_extended() {
156        let mut parser = AisParser::new();
157        // GPSD fixture: Type 19 Class B+ extended position report
158        let frame =
159            parse_frame("!AIVDM,1,1,,B,C5N3SRgPEnJGEBT>NhWAwwo862PaLELTBJ:V00000000S0D:R220,0*0B")
160                .expect("valid type 19 frame");
161        let msg = parser.decode(&frame).expect("decode type 19");
162        if let AisMessage::Position(pos) = msg {
163            assert_eq!(pos.msg_type, 19);
164            assert!(pos.mmsi > 0);
165            assert!(pos.latitude.is_some());
166            assert!(pos.longitude.is_some());
167            assert_eq!(pos.ais_class, AisClass::BPlus);
168        } else {
169            panic!("expected Position (type 19), got {msg:?}");
170        }
171    }
172
173    #[test]
174    fn type_1_position_report() {
175        let mut parser = AisParser::new();
176        let frame = parse_frame("!AIVDM,1,1,,A,13aEOK?P00PD2wVMdLDRhgvL289?,0*26").expect("valid");
177        let msg = parser.decode(&frame).expect("decoded");
178        if let AisMessage::Position(pos) = msg {
179            assert_eq!(pos.msg_type, 1);
180            assert!(pos.mmsi > 0);
181            assert!(pos.latitude.is_some());
182            assert!(pos.longitude.is_some());
183            assert_eq!(pos.ais_class, AisClass::A);
184            // Verify f64 precision
185            let lat = pos.latitude.expect("valid");
186            let lon = pos.longitude.expect("valid");
187            assert!((-90.0..=90.0).contains(&lat));
188            assert!((-180.0..=180.0).contains(&lon));
189        } else {
190            panic!("expected Position, got {msg:?}");
191        }
192    }
193
194    #[test]
195    fn type_24_static_data_report() {
196        let mut parser = AisParser::new();
197        // Type 24 Part A: vessel name
198        let frame = parse_frame("!AIVDM,1,1,,A,H52N>V@T2rNVPJ2000000000000,2*29")
199            .expect("valid type 24 frame");
200        let msg = parser.decode(&frame).expect("decode type 24");
201        if let AisMessage::StaticReport(report) = msg {
202            match report {
203                StaticDataReport::PartA { mmsi, vessel_name } => {
204                    assert!(mmsi > 0);
205                    // Vessel name may be all padding (@) — trimmed to empty
206                    let _ = vessel_name;
207                }
208                StaticDataReport::PartB { mmsi, .. } => {
209                    assert!(mmsi > 0);
210                }
211            }
212        } else {
213            panic!("expected StaticReport (type 24), got {msg:?}");
214        }
215    }
216
217    #[test]
218    fn type_5_multi_fragment() {
219        let mut parser = AisParser::new();
220
221        // GPSD sample.aivdm Type 5 fixture
222        let f1 = parse_frame(
223            "!AIVDM,2,1,1,A,55?MbV02;H;s<HtKR20EHE:0@T4@Dn2222222216L961O5Gf0NSQEp6ClRp8,0*1C",
224        )
225        .expect("valid frag1");
226        assert!(parser.decode(&f1).is_none()); // incomplete
227
228        let f2 = parse_frame("!AIVDM,2,2,1,A,88888888880,2*25").expect("valid frag2");
229        let msg = parser.decode(&f2).expect("decoded");
230        if let AisMessage::StaticVoyage(svd) = msg {
231            assert!(svd.mmsi > 0);
232            assert!(!svd.vessel_name.is_empty());
233            assert_eq!(svd.ais_class, AisClass::A);
234        } else {
235            panic!("expected StaticVoyage, got {msg:?}");
236        }
237    }
238
239    #[test]
240    fn reset_clears_pending_fragments() {
241        let mut parser = AisParser::new();
242        // Send fragment 1 of 2
243        let f1 = parse_frame(
244            "!AIVDM,2,1,1,A,55?MbV02;H;s<HtKR20EHE:0@T4@Dn2222222216L961O5Gf0NSQEp6ClRp8,0*1C",
245        )
246        .expect("valid");
247        assert!(parser.decode(&f1).is_none());
248        // Reset clears the pending fragment
249        parser.reset();
250        // Fragment 2 alone should not produce a message
251        let f2 = parse_frame("!AIVDM,2,2,1,A,88888888880,2*25").expect("valid");
252        assert!(parser.decode(&f2).is_none());
253    }
254
255    #[test]
256    fn unknown_message_type() {
257        let mut parser = AisParser::new();
258        // Type 8 binary broadcast — should return Unknown
259        let frame = parse_frame("!AIVDM,1,1,,A,85Mv070j2d>=<e<<=PQhhg`59P00,0*26").expect("valid");
260        let msg = parser.decode(&frame);
261        if let Some(AisMessage::Unknown { msg_type }) = msg {
262            assert_eq!(msg_type, 8);
263        } else {
264            panic!("expected Unknown type 8, got {msg:?}");
265        }
266    }
267
268    #[test]
269    fn type_14_safety_broadcast() {
270        let mut parser = AisParser::new();
271        // Type 14 safety broadcast — payload starts with '>' (val=14)
272        let frame =
273            parse_frame("!AIVDM,1,1,,A,>5?Per18=HB1U:1@E=B0m<L,0*53").expect("valid type 14 frame");
274        let msg = parser.decode(&frame).expect("decoded");
275        if let AisMessage::Safety(broadcast) = msg {
276            assert!(broadcast.mmsi > 0, "MMSI must be set");
277        } else {
278            panic!("expected Safety (type 14), got {msg:?}");
279        }
280    }
281
282    #[test]
283    fn type_14_empty_text_no_panic() {
284        let mut parser = AisParser::new();
285        // Minimal type 14: short payload, text portion may be empty
286        let frame = parse_frame("!AIVDM,1,1,,A,>5?Per1,0*64").expect("valid minimal type 14");
287        // Should decode (returns Safety with empty text) or return None — must not panic
288        let _ = parser.decode(&frame);
289    }
290
291    #[test]
292    fn type_21_aid_to_navigation() {
293        let mut parser = AisParser::new();
294        // Type 21 AtoN — 46-char payload (276 bits > 272 minimum), fill=4
295        // payload starts with 'E' (val=21 → msg_type=21)
296        let frame =
297            parse_frame("!AIVDM,1,1,,B,E>jCfrv2`0c2h0W:0a0h6220d5Du0`Htp00000l1@Dc2P0,4*3C")
298                .expect("valid type 21 frame");
299        let msg = parser.decode(&frame).expect("decoded");
300        if let AisMessage::AidToNavigation(aton) = msg {
301            assert!(aton.mmsi > 0, "MMSI must be set");
302            assert!(
303                aton.aid_type <= 31,
304                "aid_type must be 0–31, got {}",
305                aton.aid_type
306            );
307        } else {
308            panic!("expected AidToNavigation (type 21), got {msg:?}");
309        }
310    }
311
312    #[test]
313    fn type_21_position_in_range() {
314        let mut parser = AisParser::new();
315        let frame =
316            parse_frame("!AIVDM,1,1,,B,E>jCfrv2`0c2h0W:0a0h6220d5Du0`Htp00000l1@Dc2P0,4*3C")
317                .expect("valid type 21");
318        let msg = parser.decode(&frame).expect("decoded");
319        if let AisMessage::AidToNavigation(aton) = msg {
320            if let (Some(lat), Some(lon)) = (aton.lat, aton.lon) {
321                assert!((-90.0..=90.0).contains(&lat), "lat out of range: {lat}");
322                assert!((-180.0..=180.0).contains(&lon), "lon out of range: {lon}");
323            }
324        }
325    }
326}