Skip to main content

nmea_kit/ais/
mod.rs

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