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.iter().position(|&c| c == b']')
26            .ok_or(AprsError::UnsupportedPositionFormat)?;
27
28        let grid = body[..end].to_vec();
29
30        if grid.len() != 4 && grid.len() != 6 {
31            return Err(AprsError::UnsupportedPositionFormat);
32        }
33        if !grid[0].is_ascii_uppercase()
34            || !grid[1].is_ascii_uppercase()
35            || !grid[2].is_ascii_digit()
36            || !grid[3].is_ascii_digit()
37        {
38            return Err(AprsError::UnsupportedPositionFormat);
39        }
40        if grid.len() == 6 && (!grid[4].is_ascii_alphabetic() || !grid[5].is_ascii_alphabetic()) {
41            return Err(AprsError::UnsupportedPositionFormat);
42        }
43
44        let comment = body.get(end + 1..).unwrap_or_default().to_vec();
45        Ok(Self { grid, comment })
46    }
47
48    /// Convert the Maidenhead grid square to approximate lat/lon (center of the cell).
49    pub fn to_position(&self) -> Option<(Latitude, Longitude)> {
50        if self.grid.len() < 4 { return None; }
51
52        let fl = (self.grid[0] - b'A') as f64; // field longitude index
53        let fa = (self.grid[1] - b'A') as f64; // field latitude index
54        let sl = (self.grid[2] - b'0') as f64; // square longitude digit
55        let sa = (self.grid[3] - b'0') as f64; // square latitude digit
56
57        let (lon, lat) = if self.grid.len() >= 6 {
58            // 6-char: subsquare adds 1/24 of a square (5' for lon, 2.5' for lat)
59            let ssl = (self.grid[4].to_ascii_lowercase() - b'a') as f64;
60            let ssa = (self.grid[5].to_ascii_lowercase() - b'a') as f64;
61            let lon = fl * 20.0 + sl * 2.0 + ssl * (2.0 / 24.0) + (1.0 / 24.0) - 180.0;
62            let lat = fa * 10.0 + sa * 1.0 + ssa * (1.0 / 24.0) + (0.5 / 24.0) - 90.0;
63            (lon, lat)
64        } else {
65            // 4-char: center of the 2°×1° cell
66            let lon = fl * 20.0 + sl * 2.0 + 1.0 - 180.0;
67            let lat = fa * 10.0 + sa * 1.0 + 0.5 - 90.0;
68            (lon, lat)
69        };
70
71        Some((Latitude::new(lat)?, Longitude::new(lon)?))
72    }
73
74    pub fn encode(&self) -> Vec<u8> {
75        let mut out = vec![b'['];
76        out.extend_from_slice(&self.grid);
77        out.push(b']');
78        out.extend_from_slice(&self.comment);
79        out
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use approx::assert_relative_eq;
87
88    #[test]
89    fn parse_4char() {
90        let g = AprsGridLocator::parse(b"[IO91]").unwrap();
91        assert_eq!(g.grid, b"IO91");
92        assert!(g.comment.is_empty());
93    }
94
95    #[test]
96    fn parse_6char_with_comment() {
97        let g = AprsGridLocator::parse(b"[IO91SX]comment here").unwrap();
98        assert_eq!(g.grid, b"IO91SX");
99        assert_eq!(g.comment, b"comment here");
100    }
101
102    #[test]
103    fn parse_missing_bracket() {
104        assert!(AprsGridLocator::parse(b"[IO91SX").is_err());
105    }
106
107    #[test]
108    fn parse_bad_length() {
109        assert!(AprsGridLocator::parse(b"[IO9]").is_err());
110    }
111
112    #[test]
113    fn to_position_4char() {
114        let g = AprsGridLocator { grid: b"JO22".to_vec(), comment: vec![] };
115        let (lat, lon) = g.to_position().unwrap();
116        // JO22 center: lon = 9*20 + 2*2 + 1 - 180 = 5°, lat = 14*10 + 2 + 0.5 - 90 = 52.5°
117        assert_relative_eq!(lat.value(), 52.5, epsilon = 0.01);
118        assert_relative_eq!(lon.value(), 5.0, epsilon = 0.01);
119    }
120
121    #[test]
122    fn to_position_6char() {
123        let g = AprsGridLocator { grid: b"FN31pr".to_vec(), comment: vec![] };
124        let (lat, lon) = g.to_position().unwrap();
125        // FN31pr: near New York City area
126        assert!(lat.value() > 41.0 && lat.value() < 42.0);
127        assert!(lon.value() > -73.0 && lon.value() < -72.0);
128    }
129
130    #[test]
131    fn encode_round_trip() {
132        let raw = b"[IO91SX]Hello";
133        let g = AprsGridLocator::parse(raw).unwrap();
134        assert_eq!(g.encode().as_slice(), raw.as_slice());
135    }
136}