Skip to main content

aivdm/
lib.rs

1//! A `no_std`, allocation-free parser and encoder for AIS (Automatic
2//! Identification System) messages, per ITU-R M.1371, as carried over NMEA
3//! 0183 `!AIVDM`/`!AIVDO` sentences.
4//!
5//! # Layering
6//!
7//! - [`nmea`] parses and checksum-validates `!AIVDM`/`!AIVDO` sentences and
8//!   reassembles multi-fragment messages into a single armored payload.
9//! - [`bits`] reads and writes arbitrary-width fields directly against the
10//!   six-bit ASCII-armored payload bytes; there is no separate "unpacked
11//!   bitstream" buffer, since AIS fields do not align to byte boundaries.
12//! - [`message`] decodes/encodes the bit-packed payload into a typed
13//!   [`AisMessage`], one variant per ITU-R M.1371 message type.
14//!
15//! # No allocation
16//!
17//! Nothing in this crate uses `alloc`. Multi-fragment reassembly
18//! ([`nmea::FragmentAssembler`]) uses a fixed-capacity, caller-sized buffer
19//! instead of a growable one, so it works with no allocator at all.
20#![no_std]
21#![forbid(unsafe_code)]
22#![warn(missing_docs)]
23
24pub mod bits;
25pub mod error;
26pub mod message;
27pub mod nmea;
28pub mod string;
29
30pub use error::AisError;
31pub use message::AisMessage;
32pub use message::common::*;
33pub use nmea::{Channel, CompleteMessage, FragmentAssembler, Sentence};
34
35/// Decodes a single, complete NMEA line into a typed AIS message.
36///
37/// This only handles single-fragment sentences (`fragment_count == 1`),
38/// which cover the large majority of AIS traffic. For a sentence that is one
39/// fragment of a multi-part message, this returns
40/// [`AisError::IncompleteFragment`]; use [`LineDecoder`] instead if your feed
41/// mixes single- and multi-fragment sentences.
42///
43/// # Errors
44/// Returns an [`AisError`] if the line fails NMEA parsing, is one fragment
45/// of a multi-fragment sentence, or its payload fails to decode as a known
46/// message type.
47pub fn decode_line(line: &str) -> Result<AisMessage, AisError> {
48    let sentence = Sentence::parse(line)?;
49    if sentence.fragment_count != 1 {
50        return Err(AisError::IncompleteFragment);
51    }
52    decode_payload(sentence.payload, sentence.fill_bits)
53}
54
55/// Decodes a typed AIS message from an already-assembled six-bit
56/// ASCII-armored payload.
57///
58/// # Errors
59/// Returns an [`AisError`] if the payload fails to decode as a known message type.
60pub fn decode_payload(armored: &[u8], fill_bits: u8) -> Result<AisMessage, AisError> {
61    let mut reader = bits::BitReader::new(armored, fill_bits);
62    AisMessage::decode(&mut reader).map_err(AisError::from)
63}
64
65/// Encodes a typed AIS message into `buf` as a six-bit ASCII-armored payload.
66///
67/// Returns the armored slice of `buf` that was written, and the number of
68/// fill bits needed in the NMEA sentence's fill-bits field.
69///
70/// # Errors
71/// Returns an [`AisError`] if `buf` is too small to hold the encoded payload.
72pub fn encode_payload<'a>(
73    message: &AisMessage,
74    buf: &'a mut [u8],
75) -> Result<(&'a [u8], u8), AisError> {
76    let mut writer = bits::BitWriter::new(buf);
77    message.encode(&mut writer)?;
78    Ok(writer.finish()?)
79}
80
81/// The largest armored payload [`encode_line`] will encode a message into.
82/// Generous enough for every message type that realistically fits in a
83/// single NMEA sentence (see [`encode_line`]'s docs); comfortably smaller
84/// than the stack budget of even small embedded targets.
85const MAX_SINGLE_SENTENCE_PAYLOAD_BYTES: usize = 128;
86
87/// Encodes a typed AIS message into a single, complete `!AIVDM`/`!AIVDO`
88/// NMEA line on the given `channel`, writing into `buf` and returning the
89/// written slice. Set `is_own_ship` to emit `!AIVDO` (a simulated/own-ship
90/// report) rather than `!AIVDM`.
91///
92/// This only handles messages that fit in a single sentence, mirroring
93/// [`decode_line`]'s single-fragment restriction: large message types (5,
94/// 12, 22, 24, ...) that real-world transmitters split across multiple
95/// sentences are not supported by this convenience function. For those,
96/// encode the payload with [`encode_payload`], split it into chunks
97/// yourself, and format each fragment with [`Sentence::format`].
98///
99/// The returned line has no trailing line ending (e.g. `\r\n`); add your
100/// transport's convention yourself.
101///
102/// # Errors
103/// Returns an [`AisError`] if the message fails to encode, if its armored
104/// payload does not fit in a single sentence, or if `buf` is too small to
105/// hold the formatted line.
106pub fn encode_line<'a>(
107    message: &AisMessage,
108    channel: Channel,
109    is_own_ship: bool,
110    buf: &'a mut [u8],
111) -> Result<&'a [u8], AisError> {
112    let mut payload_buf = [0u8; MAX_SINGLE_SENTENCE_PAYLOAD_BYTES];
113    let (payload, fill_bits) = encode_payload(message, &mut payload_buf)?;
114
115    let sentence = Sentence {
116        is_own_ship,
117        fragment_count: 1,
118        fragment_number: 1,
119        seq_id: None,
120        channel,
121        payload,
122        fill_bits,
123    };
124    Ok(sentence.format(buf)?)
125}
126
127/// A stateful decoder that handles both single- and multi-fragment
128/// `!AIVDM`/`!AIVDO` sentences, so callers reading a live/mixed feed don't
129/// need to hand-roll the parse/assemble/decode state machine themselves.
130///
131/// `N` is the byte capacity of the internal reassembly buffer, exactly as
132/// with [`FragmentAssembler`]; pick it large enough for the biggest
133/// multi-fragment message your feed carries.
134#[derive(Debug)]
135pub struct LineDecoder<const N: usize> {
136    assembler: FragmentAssembler<N>,
137}
138
139impl<const N: usize> Default for LineDecoder<N> {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl<const N: usize> LineDecoder<N> {
146    /// Builds an empty decoder.
147    #[must_use]
148    pub const fn new() -> Self {
149        Self {
150            assembler: FragmentAssembler::new(),
151        }
152    }
153
154    /// Feeds one NMEA line into the decoder.
155    ///
156    /// Returns `Ok(Some(_))` when `line` completes a message (immediately,
157    /// for a single-fragment sentence, or once the last fragment of a
158    /// multi-part message arrives), `Ok(None)` while more fragments are
159    /// still expected, and `Err(_)` if the line fails to parse or its
160    /// fragment sequencing is invalid. An invalid fragment resets the
161    /// in-progress reassembly so the next fragment-1 sentence resynchronizes
162    /// cleanly.
163    ///
164    /// # Errors
165    /// Returns an [`AisError`] if the line fails NMEA parsing, its fragment
166    /// sequencing is invalid, or its payload fails to decode as a known
167    /// message type.
168    pub fn feed(&mut self, line: &str) -> Result<Option<AisMessage>, AisError> {
169        let sentence = Sentence::parse(line)?;
170        if sentence.fragment_count == 1 {
171            return decode_payload(sentence.payload, sentence.fill_bits).map(Some);
172        }
173
174        match self.assembler.push(&sentence) {
175            Ok(Some(complete)) => decode_payload(complete.armored, complete.fill_bits).map(Some),
176            Ok(None) => Ok(None),
177            Err(e) => {
178                self.assembler.reset();
179                Err(AisError::from(e))
180            }
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn decodes_a_real_position_report() {
191        let msg = decode_line("!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C").unwrap();
192        assert_eq!(msg.message_type(), 1);
193        let AisMessage::PositionReportClassA(p) = msg else {
194            panic!("expected PositionReportClassA");
195        };
196        assert_eq!(p.mmsi.raw(), 366_053_209);
197        assert_eq!(
198            p.navigation_status,
199            NavigationStatus::RestrictedManoeuvrability
200        );
201        assert!((p.longitude.as_degrees().unwrap() - (-122.341_618_333)).abs() < 1e-6);
202        assert!((p.latitude.as_degrees().unwrap() - 37.802_118_333).abs() < 1e-6);
203    }
204
205    #[test]
206    fn common_accessors_work_directly_on_ais_message() {
207        let msg = decode_line("!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C").unwrap();
208        assert_eq!(msg.mmsi().raw(), 366_053_209);
209        assert_eq!(
210            msg.navigation_status(),
211            Some(NavigationStatus::RestrictedManoeuvrability)
212        );
213        let position = msg.position().unwrap();
214        assert!((position.latitude - 37.802_118_333).abs() < 1e-6);
215        assert!((position.longitude - (-122.341_618_333)).abs() < 1e-6);
216    }
217
218    #[test]
219    fn multi_fragment_sentence_reports_incomplete() {
220        let err = decode_line("!AIVDM,2,1,7,B,15M67FC000,0*6C").unwrap_err();
221        assert_eq!(err, AisError::IncompleteFragment);
222    }
223
224    #[test]
225    fn encode_round_trips_a_real_position_report() {
226        let original_payload = b"15M67FC000G?ufbE`FepT@3n00Sa";
227        let msg = decode_payload(original_payload, 0).unwrap();
228
229        let mut buf = [0u8; 32];
230        let (armored, fill_bits) = encode_payload(&msg, &mut buf).unwrap();
231        assert_eq!(fill_bits, 0);
232        assert_eq!(armored, original_payload);
233
234        let re_decoded = decode_payload(armored, fill_bits).unwrap();
235        assert_eq!(re_decoded, msg);
236    }
237
238    #[test]
239    fn encode_line_round_trips_a_real_position_report() {
240        let line = "!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C";
241        let msg = decode_line(line).unwrap();
242
243        let mut buf = [0u8; 64];
244        let encoded = encode_line(&msg, Channel::B, false, &mut buf).unwrap();
245        assert_eq!(encoded, line.as_bytes());
246
247        let re_decoded = decode_line(core::str::from_utf8(encoded).unwrap()).unwrap();
248        assert_eq!(re_decoded, msg);
249    }
250
251    #[test]
252    fn encode_line_emits_aivdo_for_own_ship() {
253        let line = "!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C";
254        let msg = decode_line(line).unwrap();
255
256        let mut buf = [0u8; 64];
257        let encoded = encode_line(&msg, Channel::A, true, &mut buf).unwrap();
258        assert!(encoded.starts_with(b"!AIVDO,"));
259
260        let reparsed = Sentence::parse(core::str::from_utf8(encoded).unwrap()).unwrap();
261        assert!(reparsed.is_own_ship);
262        assert_eq!(reparsed.channel, Channel::A);
263    }
264
265    #[test]
266    fn line_decoder_decodes_single_fragment_immediately() {
267        let mut decoder = LineDecoder::<64>::new();
268        let msg = decoder
269            .feed("!AIVDM,1,1,,B,15M67FC000G?ufbE`FepT@3n00Sa,0*5C")
270            .unwrap()
271            .unwrap();
272        assert_eq!(msg.mmsi().raw(), 366_053_209);
273    }
274
275    #[test]
276    fn line_decoder_reassembles_multi_fragment_sentences() {
277        let mut decoder = LineDecoder::<64>::new();
278        assert_eq!(
279            decoder.feed("!AIVDM,2,1,7,B,15M67FC000,0*6C").unwrap(),
280            None
281        );
282        let msg = decoder
283            .feed("!AIVDM,2,2,7,B,0G?ufbE`FepT@3n00Sa,0*26")
284            .unwrap()
285            .unwrap();
286        assert_eq!(msg.mmsi().raw(), 366_053_209);
287    }
288
289    #[test]
290    fn line_decoder_resyncs_after_an_invalid_fragment() {
291        let mut decoder = LineDecoder::<64>::new();
292        // second fragment arrives with no first fragment in progress
293        let err = decoder
294            .feed("!AIVDM,2,2,7,B,0G?ufbE`FepT@3n00Sa,0*26")
295            .unwrap_err();
296        assert_eq!(
297            err,
298            AisError::Fragment(crate::error::FragmentError::OutOfOrder)
299        );
300
301        // a fresh sequence starting at fragment 1 still works afterwards
302        assert_eq!(
303            decoder.feed("!AIVDM,2,1,7,B,15M67FC000,0*6C").unwrap(),
304            None
305        );
306        let msg = decoder
307            .feed("!AIVDM,2,2,7,B,0G?ufbE`FepT@3n00Sa,0*26")
308            .unwrap()
309            .unwrap();
310        assert_eq!(msg.mmsi().raw(), 366_053_209);
311    }
312}