dstar_gateway_core/dprs/
parser.rs

1//! DPRS sentence parser.
2
3use crate::types::Callsign;
4
5use super::coordinates::{Latitude, Longitude};
6use super::error::DprsError;
7
8/// A parsed DPRS position report.
9#[derive(Debug, Clone, PartialEq)]
10pub struct DprsReport {
11    /// Station callsign.
12    pub callsign: Callsign,
13    /// Latitude in decimal degrees.
14    pub latitude: Latitude,
15    /// Longitude in decimal degrees.
16    pub longitude: Longitude,
17    /// Symbol character (APRS symbol code).
18    pub symbol: char,
19    /// Optional comment text.
20    pub comment: Option<String>,
21}
22
23/// Parse a DPRS sentence into a [`DprsReport`].
24///
25/// The sentence must start with `$$CRC<4hex>,`. The CRC field is
26/// parsed but not validated here — callers that want to verify it
27/// should compute [`super::compute_crc`] over the body bytes after
28/// the comma and compare against the 4-hex value between `$$CRC`
29/// and `,`.
30///
31/// # Errors
32///
33/// - [`DprsError::MissingCrcPrefix`] if the sentence doesn't start with `$$CRC`
34/// - [`DprsError::TooShort`] if shorter than the minimum viable length
35/// - [`DprsError::MalformedCoordinates`] if lat/lon fields fail to parse
36/// - [`DprsError::LatitudeOutOfRange`] / [`DprsError::LongitudeOutOfRange`]
37/// - [`DprsError::InvalidCallsign`] if the callsign field is invalid
38///
39/// # See also
40///
41/// `ircDDBGateway/Common/APRSCollector.cpp:371-394` — the reference
42/// parser this decoder mirrors. CRC-CCITT uses reflected polynomial
43/// `0x8408`, initial value `0xFFFF`, final `~accumulator`.
44pub fn parse_dprs(sentence: &str) -> Result<DprsReport, DprsError> {
45    if !sentence.starts_with("$$CRC") {
46        return Err(DprsError::MissingCrcPrefix);
47    }
48    if sentence.len() < 40 {
49        return Err(DprsError::TooShort {
50            got: sentence.len(),
51        });
52    }
53
54    // Format:
55    //   "$$CRCXXXX,W1AW    *>APDPRS,DSTAR*:!DDMM.MMN/DDDMM.MMW#/comment"
56    //    0         10       18
57    //
58    // Skip past "$$CRC" + 4 hex digits + ",".
59    let after_crc = sentence.get(10..).ok_or(DprsError::MalformedCoordinates)?;
60
61    // Callsign is the first 8 bytes of `after_crc` (space-padded).
62    let cs_bytes = after_crc.get(..8).ok_or(DprsError::MalformedCoordinates)?;
63    let callsign =
64        Callsign::try_from_str(cs_bytes.trim_end()).map_err(|_| DprsError::InvalidCallsign {
65            reason: "not a valid D-STAR callsign",
66        })?;
67
68    // Skip to the '!' character that marks the start of position data.
69    let bang_pos = sentence.find('!').ok_or(DprsError::MalformedCoordinates)?;
70    let pos_data = sentence
71        .get(bang_pos + 1..)
72        .ok_or(DprsError::MalformedCoordinates)?;
73
74    // `pos_data` format: "DDMM.MMN/DDDMM.MMW#/comment"
75    //  index 0..8  = latitude (DDMM.MM + N/S)
76    //  index 8     = '/' separator
77    //  index 9..18 = longitude (DDDMM.MM + E/W)
78    //  index 18    = symbol-table overlay (e.g. '#')
79    //  index 19    = symbol glyph (e.g. '/')
80    //  index 20..  = optional comment
81    if pos_data.len() < 19 {
82        return Err(DprsError::MalformedCoordinates);
83    }
84
85    let lat_str = pos_data.get(..8).ok_or(DprsError::MalformedCoordinates)?;
86    let lat_hemi = lat_str
87        .chars()
88        .nth(7)
89        .ok_or(DprsError::MalformedCoordinates)?;
90    let lat_numeric = lat_str.get(..7).ok_or(DprsError::MalformedCoordinates)?;
91    let lat_deg = parse_aprs_degrees(lat_numeric, 2).ok_or(DprsError::MalformedCoordinates)?;
92    let lat_deg = if lat_hemi == 'S' { -lat_deg } else { lat_deg };
93    let latitude = Latitude::try_new(lat_deg)?;
94
95    let lon_str = pos_data.get(9..18).ok_or(DprsError::MalformedCoordinates)?;
96    let lon_hemi = lon_str
97        .chars()
98        .nth(8)
99        .ok_or(DprsError::MalformedCoordinates)?;
100    let lon_numeric = lon_str.get(..8).ok_or(DprsError::MalformedCoordinates)?;
101    let lon_deg = parse_aprs_degrees(lon_numeric, 3).ok_or(DprsError::MalformedCoordinates)?;
102    let lon_deg = if lon_hemi == 'W' { -lon_deg } else { lon_deg };
103    let longitude = Longitude::try_new(lon_deg)?;
104
105    // Symbol glyph is at [19]; absent when the sentence ends at the
106    // overlay (e.g. the `parse_without_comment` test case).
107    let symbol = pos_data.chars().nth(19).unwrap_or('/');
108    let comment_str = pos_data.get(20..).map(ToString::to_string);
109    let comment = comment_str.filter(|s| !s.is_empty());
110
111    Ok(DprsReport {
112        callsign,
113        latitude,
114        longitude,
115        symbol,
116        comment,
117    })
118}
119
120/// Parse an APRS "DDMM.MM" or "DDDMM.MM" numeric string into decimal degrees.
121///
122/// `degree_digits` is 2 for latitude (DDMM.MM) or 3 for longitude (DDDMM.MM).
123fn parse_aprs_degrees(s: &str, degree_digits: usize) -> Option<f64> {
124    if s.len() < degree_digits + 5 {
125        return None;
126    }
127    let deg_str = s.get(..degree_digits)?;
128    let min_str = s.get(degree_digits..)?;
129    let degrees = deg_str.parse::<f64>().ok()?;
130    let minutes = min_str.parse::<f64>().ok()?;
131    Some(degrees + minutes / 60.0)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    type TestResult = Result<(), Box<dyn std::error::Error>>;
139
140    #[test]
141    fn parse_w1aw_asheville() -> TestResult {
142        // Synthesized sentence for W1AW at 35.5N 82.55W
143        let sentence = "$$CRC0000,W1AW    *>APDPRS,DSTAR*:!3530.00N/08233.00W#/Asheville test";
144        let report = parse_dprs(sentence)?;
145        assert_eq!(report.callsign.as_str().trim(), "W1AW");
146        assert!((report.latitude.degrees() - 35.5).abs() < 0.001);
147        assert!((report.longitude.degrees() - (-82.55)).abs() < 0.001);
148        assert_eq!(report.symbol, '/');
149        assert_eq!(report.comment.as_deref(), Some("Asheville test"));
150        Ok(())
151    }
152
153    #[test]
154    fn parse_missing_crc_prefix_errors() {
155        let sentence = "HELLO,W1AW    *>APDPRS,DSTAR*:!3530.00N/08233.00W#/";
156        let result = parse_dprs(sentence);
157        assert!(
158            matches!(result, Err(DprsError::MissingCrcPrefix)),
159            "expected MissingCrcPrefix, got {result:?}"
160        );
161    }
162
163    #[test]
164    fn parse_too_short_errors() {
165        let sentence = "$$CRC1234,short";
166        let result = parse_dprs(sentence);
167        assert!(
168            matches!(result, Err(DprsError::TooShort { .. })),
169            "expected TooShort, got {result:?}"
170        );
171    }
172
173    #[test]
174    fn parse_southern_hemisphere() -> TestResult {
175        let sentence = "$$CRC0000,VK2ABC  *>APDPRS,DSTAR*:!3351.00S/15112.00E#/Sydney";
176        let report = parse_dprs(sentence)?;
177        assert!(
178            report.latitude.degrees() < 0.0,
179            "southern hemisphere is negative"
180        );
181        assert!(
182            report.longitude.degrees() > 0.0,
183            "eastern hemisphere is positive"
184        );
185        Ok(())
186    }
187
188    #[test]
189    fn parse_without_comment() -> TestResult {
190        let sentence = "$$CRC0000,W1AW    *>APDPRS,DSTAR*:!3530.00N/08233.00W#";
191        let report = parse_dprs(sentence)?;
192        assert!(report.comment.is_none());
193        Ok(())
194    }
195
196    #[test]
197    fn parse_extreme_latitude() -> TestResult {
198        // 90.00N
199        let sentence = "$$CRC0000,NORTH   *>APDPRS,DSTAR*:!9000.00N/00000.00E#/North Pole";
200        let report = parse_dprs(sentence)?;
201        assert!((report.latitude.degrees() - 90.0).abs() < 0.001);
202        Ok(())
203    }
204}