Skip to main content

nmea_kit/nmea/
field.rs

1//! Field parsing and formatting helpers for NMEA sentence fields.
2//!
3//! [`FieldReader`] reads fields sequentially from a parsed frame.
4//! [`FieldWriter`] builds fields sequentially for encoding.
5
6/// Sequential field reader for NMEA sentence parsing.
7///
8/// Wraps a slice of `&str` fields and reads them in order,
9/// advancing an internal index after each read.
10pub struct FieldReader<'a> {
11    fields: &'a [&'a str],
12    idx: usize,
13}
14
15impl<'a> FieldReader<'a> {
16    pub fn new(fields: &'a [&'a str]) -> Self {
17        Self { fields, idx: 0 }
18    }
19
20    /// Read an optional f32 and advance.
21    pub fn f32(&mut self) -> Option<f32> {
22        let val = self.fields.get(self.idx).and_then(|f| {
23            if f.is_empty() {
24                None
25            } else {
26                f.parse::<f32>().ok()
27            }
28        });
29        self.idx += 1;
30        val
31    }
32
33    /// Read an optional f64 and advance.
34    pub fn f64(&mut self) -> Option<f64> {
35        let val = self.fields.get(self.idx).and_then(|f| {
36            if f.is_empty() {
37                None
38            } else {
39                f.parse::<f64>().ok()
40            }
41        });
42        self.idx += 1;
43        val
44    }
45
46    /// Read an optional u8 and advance.
47    pub fn u8(&mut self) -> Option<u8> {
48        let val = self.fields.get(self.idx).and_then(|f| {
49            if f.is_empty() {
50                None
51            } else {
52                f.parse::<u8>().ok()
53            }
54        });
55        self.idx += 1;
56        val
57    }
58
59    /// Read an optional u32 and advance.
60    pub fn u32(&mut self) -> Option<u32> {
61        let val = self.fields.get(self.idx).and_then(|f| {
62            if f.is_empty() {
63                None
64            } else {
65                f.parse::<u32>().ok()
66            }
67        });
68        self.idx += 1;
69        val
70    }
71
72    /// Read an optional i8 and advance.
73    pub fn i8(&mut self) -> Option<i8> {
74        let val = self.fields.get(self.idx).and_then(|f| {
75            if f.is_empty() {
76                None
77            } else {
78                f.parse::<i8>().ok()
79            }
80        });
81        self.idx += 1;
82        val
83    }
84
85    /// Read an optional single character and advance.
86    pub fn char(&mut self) -> Option<char> {
87        let val = self
88            .fields
89            .get(self.idx)
90            .and_then(|f| f.chars().next().filter(|_| !f.is_empty()));
91        self.idx += 1;
92        val
93    }
94
95    /// Read an optional non-empty string and advance.
96    pub fn string(&mut self) -> Option<String> {
97        let val = self.fields.get(self.idx).and_then(|f| {
98            if f.is_empty() {
99                None
100            } else {
101                Some((*f).to_string())
102            }
103        });
104        self.idx += 1;
105        val
106    }
107
108    /// Skip one field (fixed indicator) and advance.
109    pub fn skip(&mut self) {
110        self.idx += 1;
111    }
112}
113
114/// Sequential field writer for NMEA sentence encoding.
115///
116/// Builds a `Vec<String>` of field values in wire order.
117pub struct FieldWriter {
118    fields: Vec<String>,
119}
120
121impl FieldWriter {
122    pub fn new() -> Self {
123        Self { fields: Vec::new() }
124    }
125
126    /// Write an optional f32. `None` → empty field.
127    pub fn f32(&mut self, value: Option<f32>) {
128        self.fields.push(match value {
129            Some(v) => format!("{v}"),
130            None => String::new(),
131        });
132    }
133
134    /// Write an optional f64. `None` → empty field.
135    pub fn f64(&mut self, value: Option<f64>) {
136        self.fields.push(match value {
137            Some(v) => format!("{v}"),
138            None => String::new(),
139        });
140    }
141
142    /// Write an optional u8. `None` → empty field.
143    pub fn u8(&mut self, value: Option<u8>) {
144        self.fields.push(match value {
145            Some(v) => v.to_string(),
146            None => String::new(),
147        });
148    }
149
150    /// Write an optional i8. `None` → empty field.
151    pub fn i8(&mut self, value: Option<i8>) {
152        self.fields.push(match value {
153            Some(v) => v.to_string(),
154            None => String::new(),
155        });
156    }
157
158    /// Write an optional u32. `None` → empty field.
159    pub fn u32(&mut self, value: Option<u32>) {
160        self.fields.push(match value {
161            Some(v) => v.to_string(),
162            None => String::new(),
163        });
164    }
165
166    /// Write an optional char. `None` → empty field.
167    pub fn char(&mut self, value: Option<char>) {
168        self.fields.push(match value {
169            Some(c) => c.to_string(),
170            None => String::new(),
171        });
172    }
173
174    /// Write a fixed indicator character (always emitted).
175    pub fn fixed(&mut self, c: char) {
176        self.fields.push(c.to_string());
177    }
178
179    /// Write an optional string. `None` → empty field.
180    pub fn string(&mut self, value: Option<&str>) {
181        self.fields.push(value.unwrap_or("").to_string());
182    }
183
184    /// Consume and return the built field list.
185    pub fn finish(self) -> Vec<String> {
186        self.fields
187    }
188}
189
190impl Default for FieldWriter {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196/// Trait for NMEA sentence types that can be encoded to wire format.
197///
198/// Provides `SENTENCE_TYPE` and `encode()` — the `to_sentence()` default
199/// method combines them with [`encode_frame()`](crate::encode_frame) to
200/// produce a complete NMEA sentence with checksum.
201///
202/// # Standard sentences
203///
204/// ```
205/// use nmea_kit::nmea::{NmeaEncodable, sentences::Dpt};
206///
207/// let dpt = Dpt { depth: Some(4.1), offset: Some(0.0), rangescale: None };
208/// let sentence = dpt.to_sentence("II");
209/// assert!(sentence.starts_with("$IIDPT,"));
210/// ```
211///
212/// # Proprietary sentences
213///
214/// Proprietary types set [`PROPRIETARY_ID`](Self::PROPRIETARY_ID) to the full
215/// address (e.g. `"PASHR"`, `"PSKPDPT"`) and use
216/// [`to_proprietary_sentence()`](Self::to_proprietary_sentence) instead of
217/// `to_sentence()`.
218pub trait NmeaEncodable {
219    /// The 3-character sentence type identifier (e.g. `"MWD"`, `"RMC"`).
220    const SENTENCE_TYPE: &str;
221
222    /// Full proprietary address identifier (e.g. `"PASHR"`, `"PSKPDPT"`).
223    /// Empty for standard sentences.
224    const PROPRIETARY_ID: &str = "";
225
226    /// Encode fields into a `Vec` of strings in wire order.
227    fn encode(&self) -> Vec<String>;
228
229    /// Encode into a complete standard NMEA 0183 sentence with checksum and `\r\n`.
230    fn to_sentence(&self, talker: &str) -> String {
231        let fields = self.encode();
232        let field_refs: Vec<&str> = fields.iter().map(|s| s.as_str()).collect();
233        crate::encode_frame('$', talker, Self::SENTENCE_TYPE, &field_refs)
234    }
235
236    /// Encode into a complete proprietary NMEA 0183 sentence with checksum and `\r\n`.
237    ///
238    /// Uses [`PROPRIETARY_ID`](Self::PROPRIETARY_ID) as the full address
239    /// (no separate talker).
240    fn to_proprietary_sentence(&self) -> String {
241        let fields = self.encode();
242        let field_refs: Vec<&str> = fields.iter().map(|s| s.as_str()).collect();
243        crate::encode_frame('$', "", Self::PROPRIETARY_ID, &field_refs)
244    }
245}
246
247/// Convert an NMEA `DDMM.MMMM` coordinate to decimal degrees.
248///
249/// NMEA sentences encode latitude as `DDMM.MMMM` (degrees + minutes) and
250/// longitude as `DDDMM.MMMM`. AIS and most application code use decimal degrees.
251/// The sign (N/S, E/W) is not part of `ddmm` — apply it after conversion.
252///
253/// # Example
254///
255/// ```
256/// use nmea_kit::nmea::ddmm_to_decimal;
257///
258/// // 4807.038 → 48°07.038′ → 48.1173°
259/// let lat = ddmm_to_decimal(4807.038);
260/// assert!((lat - 48.1173).abs() < 0.0001);
261/// ```
262pub fn ddmm_to_decimal(ddmm: f64) -> f64 {
263    let degrees = (ddmm / 100.0).floor();
264    let minutes = ddmm - degrees * 100.0;
265    degrees + minutes / 60.0
266}
267
268/// Convert decimal degrees to an NMEA `DDMM.MMMM` coordinate.
269///
270/// This is the inverse of [`ddmm_to_decimal`]. The sign is not encoded —
271/// strip it before calling and re-apply the N/S or E/W indicator separately.
272///
273/// # Example
274///
275/// ```
276/// use nmea_kit::nmea::decimal_to_ddmm;
277///
278/// // 48.1173° → 48°07.038′ → 4807.038
279/// let ddmm = decimal_to_ddmm(48.1173);
280/// assert!((ddmm - 4807.038).abs() < 0.001);
281/// ```
282pub fn decimal_to_ddmm(decimal: f64) -> f64 {
283    let degrees = decimal.floor();
284    let minutes = (decimal - degrees) * 60.0;
285    degrees * 100.0 + minutes
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn reader_char() {
294        let fields = &["T", "", "AB"];
295        let mut r = FieldReader::new(fields);
296        assert_eq!(r.char(), Some('T'));
297        assert_eq!(r.char(), None);
298        assert_eq!(r.char(), Some('A')); // takes first char
299    }
300
301    #[test]
302    fn reader_f32() {
303        let fields = &["270.0", "", "abc"];
304        let mut r = FieldReader::new(fields);
305        assert_eq!(r.f32(), Some(270.0));
306        assert_eq!(r.f32(), None);
307        assert_eq!(r.f32(), None); // invalid
308    }
309
310    #[test]
311    fn reader_past_end() {
312        let fields: &[&str] = &[];
313        let mut r = FieldReader::new(fields);
314        assert_eq!(r.f32(), None);
315        assert_eq!(r.char(), None);
316    }
317
318    #[test]
319    fn reader_skip() {
320        let fields = &["10.0", "T", "20.0"];
321        let mut r = FieldReader::new(fields);
322        assert_eq!(r.f32(), Some(10.0));
323        r.skip();
324        assert_eq!(r.f32(), Some(20.0));
325    }
326
327    #[test]
328    fn reader_string() {
329        let fields = &["DEST", ""];
330        let mut r = FieldReader::new(fields);
331        assert_eq!(r.string(), Some("DEST".to_string()));
332        assert_eq!(r.string(), None);
333    }
334
335    #[test]
336    fn writer_roundtrip() {
337        let mut w = FieldWriter::new();
338        w.f32(Some(270.0));
339        w.fixed('T');
340        w.f32(None);
341        w.fixed('M');
342        let fields = w.finish();
343        assert_eq!(fields, vec!["270", "T", "", "M"]);
344    }
345
346    #[test]
347    fn ddmm_to_decimal_lat() {
348        // 4807.038 → 48°07.038′ → 48.1173°
349        let result = ddmm_to_decimal(4807.038);
350        assert!((result - 48.1173).abs() < 0.0001);
351    }
352
353    #[test]
354    fn ddmm_to_decimal_lon() {
355        // 01131.000 → 11°31.000′ → 11.5167°
356        let result = ddmm_to_decimal(1131.0);
357        assert!((result - 11.5167).abs() < 0.0001);
358    }
359
360    #[test]
361    fn decimal_to_ddmm_lat() {
362        // 48.1173° → 4807.038
363        let result = decimal_to_ddmm(48.1173);
364        assert!((result - 4807.038).abs() < 0.001);
365    }
366
367    #[test]
368    fn decimal_to_ddmm_roundtrip() {
369        let original = 5132.5200_f64;
370        let roundtrip = decimal_to_ddmm(ddmm_to_decimal(original));
371        assert!((roundtrip - original).abs() < 0.0001);
372    }
373}