Skip to main content

aprs_decode/
grid.rs

1use crate::error::AprsError;
2use crate::types::lonlat::{Latitude, Longitude};
3
4/// An APRS Maidenhead Grid Locator report.
5///
6/// DTI: `[`
7///
8/// Format: `[GGGG]` or `[GGGGSS]` where GGGG is a 4-char grid square
9/// (2 uppercase letters + 2 digits) and SS is an optional 2-char subsquare
10/// (2 letters, case-insensitive). The comment follows the closing `]`.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct AprsGridLocator {
14    /// Grid locator: 4 or 6 characters (e.g. `IO91` or `IO91SX`).
15    pub grid: Vec<u8>,
16    pub comment: Vec<u8>,
17}
18
19impl AprsGridLocator {
20    /// Decode from the information field (including the leading `[` DTI byte).
21    pub(crate) fn parse(info: &[u8]) -> Result<Self, AprsError> {
22        // info[0] = '[', then grid chars, then ']', then comment
23        let body = info.get(1..).unwrap_or_default();
24
25        let end = body
26            .iter()
27            .position(|&c| c == b']')
28            .ok_or(AprsError::UnsupportedPositionFormat)?;
29
30        let grid = body[..end].to_vec();
31
32        if grid.len() != 4 && grid.len() != 6 {
33            return Err(AprsError::UnsupportedPositionFormat);
34        }
35        if !grid[0].is_ascii_uppercase()
36            || !grid[1].is_ascii_uppercase()
37            || !grid[2].is_ascii_digit()
38            || !grid[3].is_ascii_digit()
39        {
40            return Err(AprsError::UnsupportedPositionFormat);
41        }
42        if grid.len() == 6 && (!grid[4].is_ascii_alphabetic() || !grid[5].is_ascii_alphabetic()) {
43            return Err(AprsError::UnsupportedPositionFormat);
44        }
45
46        let comment = body.get(end + 1..).unwrap_or_default().to_vec();
47        Ok(Self { grid, comment })
48    }
49
50    /// Convert the Maidenhead grid square to approximate lat/lon (center of the cell).
51    pub fn to_position(&self) -> Option<(Latitude, Longitude)> {
52        if self.grid.len() < 4 {
53            return None;
54        }
55
56        let fl = (self.grid[0] - b'A') as f64; // field longitude index
57        let fa = (self.grid[1] - b'A') as f64; // field latitude index
58        let sl = (self.grid[2] - b'0') as f64; // square longitude digit
59        let sa = (self.grid[3] - b'0') as f64; // square latitude digit
60
61        let (lon, lat) = if self.grid.len() >= 6 {
62            // 6-char: subsquare adds 1/24 of a square (5' for lon, 2.5' for lat)
63            let ssl = (self.grid[4].to_ascii_lowercase() - b'a') as f64;
64            let ssa = (self.grid[5].to_ascii_lowercase() - b'a') as f64;
65            let lon = fl * 20.0 + sl * 2.0 + ssl * (2.0 / 24.0) + (1.0 / 24.0) - 180.0;
66            let lat = fa * 10.0 + sa * 1.0 + ssa * (1.0 / 24.0) + (0.5 / 24.0) - 90.0;
67            (lon, lat)
68        } else {
69            // 4-char: center of the 2°×1° cell
70            let lon = fl * 20.0 + sl * 2.0 + 1.0 - 180.0;
71            let lat = fa * 10.0 + sa * 1.0 + 0.5 - 90.0;
72            (lon, lat)
73        };
74
75        Some((Latitude::new(lat)?, Longitude::new(lon)?))
76    }
77
78    pub fn encode(&self) -> Vec<u8> {
79        let mut out = vec![b'['];
80        out.extend_from_slice(&self.grid);
81        out.push(b']');
82        out.extend_from_slice(&self.comment);
83        out
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use approx::assert_relative_eq;
91
92    #[test]
93    fn parse_4char() {
94        let g = AprsGridLocator::parse(b"[IO91]").unwrap();
95        assert_eq!(g.grid, b"IO91");
96        assert!(g.comment.is_empty());
97    }
98
99    #[test]
100    fn parse_6char_with_comment() {
101        let g = AprsGridLocator::parse(b"[IO91SX]comment here").unwrap();
102        assert_eq!(g.grid, b"IO91SX");
103        assert_eq!(g.comment, b"comment here");
104    }
105
106    #[test]
107    fn parse_missing_bracket() {
108        assert!(AprsGridLocator::parse(b"[IO91SX").is_err());
109    }
110
111    #[test]
112    fn parse_bad_length() {
113        assert!(AprsGridLocator::parse(b"[IO9]").is_err());
114    }
115
116    #[test]
117    fn to_position_4char() {
118        let g = AprsGridLocator {
119            grid: b"JO22".to_vec(),
120            comment: vec![],
121        };
122        let (lat, lon) = g.to_position().unwrap();
123        // JO22 center: lon = 9*20 + 2*2 + 1 - 180 = 5°, lat = 14*10 + 2 + 0.5 - 90 = 52.5°
124        assert_relative_eq!(lat.value(), 52.5, epsilon = 0.01);
125        assert_relative_eq!(lon.value(), 5.0, epsilon = 0.01);
126    }
127
128    #[test]
129    fn to_position_6char() {
130        let g = AprsGridLocator {
131            grid: b"FN31pr".to_vec(),
132            comment: vec![],
133        };
134        let (lat, lon) = g.to_position().unwrap();
135        // FN31pr: near New York City area
136        assert!(lat.value() > 41.0 && lat.value() < 42.0);
137        assert!(lon.value() > -73.0 && lon.value() < -72.0);
138    }
139
140    #[test]
141    fn encode_round_trip() {
142        let raw = b"[IO91SX]Hello";
143        let g = AprsGridLocator::parse(raw).unwrap();
144        assert_eq!(g.encode().as_slice(), raw.as_slice());
145    }
146}