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