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