dstar_gateway_core/dprs/
encoder.rs

1//! DPRS sentence encoder.
2
3use std::fmt::Write as _;
4
5use super::crc::compute_crc;
6use super::error::DprsError;
7use super::parser::DprsReport;
8
9/// Encode a `DprsReport` into a DPRS sentence with a correct
10/// `$$CRC<hex>` checksum.
11///
12/// The output `String` is cleared first, then written in place. The
13/// CRC is computed over the sentence body (everything after the
14/// comma following `$$CRC<hex>`) using [`super::compute_crc`] —
15/// CRC-CCITT with reflected polynomial `0x8408`, initial value
16/// `0xFFFF`, final `~accumulator`, matching the ircDDBGateway
17/// reference.
18///
19/// # Errors
20///
21/// Returns [`DprsError::MalformedCoordinates`] if the report's lat/lon
22/// values can't be formatted. This should not happen with validated
23/// [`super::coordinates::Latitude`] / [`super::coordinates::Longitude`]
24/// newtypes.
25///
26/// # See also
27///
28/// `ircDDBGateway/Common/APRSCollector.cpp:371-394` for the
29/// reference CRC + sentence layout this encoder mirrors.
30pub fn encode_dprs(report: &DprsReport, out: &mut String) -> Result<(), DprsError> {
31    // Build the sentence body (everything that comes after the CRC
32    // prefix + comma) in a scratch buffer first so we can compute
33    // the CRC over it, then emit the whole sentence with the real
34    // CRC prefixed.
35    let mut body = String::new();
36
37    // Callsign (space-padded to 8 bytes) — read straight from the
38    // wire bytes so we don't depend on `Callsign::as_str()`'s
39    // trimming behaviour.
40    let cs_bytes = report.callsign.as_bytes();
41    for &b in cs_bytes {
42        body.push(char::from(b));
43    }
44
45    body.push_str("*>APDPRS,DSTAR*:!");
46
47    // Latitude DDMM.MM[NS]
48    let lat = report.latitude.degrees();
49    let lat_hemi = if lat < 0.0 { 'S' } else { 'N' };
50    let lat_abs = lat.abs();
51    let lat_int = lat_abs.trunc();
52    let lat_min = (lat_abs - lat_int) * 60.0;
53    // Width 2, leading zeros, precision 0 — prints e.g. "35" for
54    // 35.0. `lat_int` has already been truncated, so `{:.0}` does
55    // not round away from the integer-degree value.
56    write!(body, "{lat_int:02.0}{lat_min:05.2}{lat_hemi}")
57        .map_err(|_| DprsError::MalformedCoordinates)?;
58
59    body.push('/');
60
61    // Longitude DDDMM.MM[EW]
62    let lon = report.longitude.degrees();
63    let lon_hemi = if lon < 0.0 { 'W' } else { 'E' };
64    let lon_abs = lon.abs();
65    let lon_int = lon_abs.trunc();
66    let lon_min = (lon_abs - lon_int) * 60.0;
67    write!(body, "{lon_int:03.0}{lon_min:05.2}{lon_hemi}")
68        .map_err(|_| DprsError::MalformedCoordinates)?;
69
70    // Overlay '#' + symbol glyph.
71    body.push('#');
72    body.push(report.symbol);
73
74    if let Some(comment) = &report.comment {
75        body.push_str(comment);
76    }
77
78    // Now compute the CRC over the body and emit the final sentence.
79    let crc = compute_crc(body.as_bytes());
80    out.clear();
81    write!(out, "$$CRC{crc:04X},").map_err(|_| DprsError::MalformedCoordinates)?;
82    out.push_str(&body);
83
84    Ok(())
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::dprs::coordinates::{Latitude, Longitude};
91    use crate::types::Callsign;
92
93    type TestResult = Result<(), Box<dyn std::error::Error>>;
94
95    #[test]
96    fn encode_round_trips_through_parser() -> TestResult {
97        let original = DprsReport {
98            callsign: Callsign::from_wire_bytes(*b"W1AW    "),
99            latitude: Latitude::try_new(35.5)?,
100            longitude: Longitude::try_new(-82.55)?,
101            symbol: '/',
102            comment: Some("Asheville test".to_string()),
103        };
104        let mut encoded = String::new();
105        encode_dprs(&original, &mut encoded)?;
106        // Sentence must start with `$$CRC<4hex>,` (the hex is the
107        // real CRC-CCITT over the body, not a placeholder).
108        assert!(encoded.starts_with("$$CRC"));
109        assert_eq!(
110            encoded.as_bytes().get(9),
111            Some(&b','),
112            "comma after 4 hex digits"
113        );
114        // And the 4 CRC chars must be uppercase ASCII hex digits.
115        for b in encoded.as_bytes().get(5..9).unwrap_or(&[]) {
116            assert!(b.is_ascii_hexdigit(), "CRC byte {b:02X} is not hex");
117        }
118        assert!(encoded.contains("W1AW"));
119        assert!(encoded.contains("3530.00N"));
120        assert!(encoded.contains("08233.00W"));
121        assert!(encoded.contains("Asheville test"));
122        Ok(())
123    }
124
125    #[test]
126    fn encode_crc_matches_compute_crc_over_body() -> TestResult {
127        use super::super::crc::compute_crc;
128        let original = DprsReport {
129            callsign: Callsign::from_wire_bytes(*b"W1AW    "),
130            latitude: Latitude::try_new(35.5)?,
131            longitude: Longitude::try_new(-82.55)?,
132            symbol: '/',
133            comment: Some("Asheville test".to_string()),
134        };
135        let mut encoded = String::new();
136        encode_dprs(&original, &mut encoded)?;
137        // The body is everything after `$$CRC<4hex>,`.
138        let body = encoded.get(10..).ok_or("body after prefix")?;
139        let expected_crc = compute_crc(body.as_bytes());
140        let crc_str = encoded.get(5..9).ok_or("crc field")?;
141        let actual_crc = u16::from_str_radix(crc_str, 16)?;
142        assert_eq!(actual_crc, expected_crc);
143        Ok(())
144    }
145
146    #[test]
147    fn encode_then_parse_roundtrip() -> TestResult {
148        let original = DprsReport {
149            callsign: Callsign::from_wire_bytes(*b"W1AW    "),
150            latitude: Latitude::try_new(35.5)?,
151            longitude: Longitude::try_new(-82.55)?,
152            symbol: '/',
153            comment: None,
154        };
155        let mut encoded = String::new();
156        encode_dprs(&original, &mut encoded)?;
157        let parsed = super::super::parser::parse_dprs(&encoded)?;
158        assert_eq!(parsed.callsign, original.callsign);
159        assert!((parsed.latitude.degrees() - original.latitude.degrees()).abs() < 0.001);
160        assert!((parsed.longitude.degrees() - original.longitude.degrees()).abs() < 0.001);
161        Ok(())
162    }
163}