1pub 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#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq)]
38pub enum AisMessage {
39 Position(PositionReport),
41 BaseStation(BaseStationReport),
43 StaticVoyage(StaticVoyageData),
45 StaticReport(StaticDataReport),
47 Safety(SafetyBroadcast),
49 AidToNavigation(AidToNavigation),
51 LongRangePosition(LongRangePosition),
53 Unknown { msg_type: u8 },
55}
56
57pub struct AisParser {
62 collector: FragmentCollector,
63}
64
65impl AisParser {
66 pub fn new() -> Self {
67 Self {
68 collector: FragmentCollector::new(),
69 }
70 }
71
72 pub fn reset(&mut self) {
76 self.collector = FragmentCollector::new();
77 }
78
79 pub fn decode(&mut self, frame: &NmeaFrame<'_>) -> Option<AisMessage> {
82 if frame.prefix != '!' || (frame.sentence_type != "VDM" && frame.sentence_type != "VDO") {
84 return None;
85 }
86
87 let payload = self.collector.process(&frame.fields)?;
89
90 let bits = decode_armor(&payload.payload, payload.fill_bits)?;
92
93 let msg_type = armor::extract_u32(&bits, 0, 6)? as u8;
95
96 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 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 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 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 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 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 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()); 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 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 parser.reset();
250 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 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 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 let frame = parse_frame("!AIVDM,1,1,,A,>5?Per1,0*64").expect("valid minimal type 14");
287 let _ = parser.decode(&frame);
289 }
290
291 #[test]
292 fn type_21_aid_to_navigation() {
293 let mut parser = AisParser::new();
294 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}