Skip to main content

aprs_decode/types/
symbol.rs

1/// An APRS position symbol, consisting of a symbol table identifier and a symbol code.
2///
3/// - `table == '/'`: primary symbol table
4/// - `table == '\\'`: alternate symbol table
5/// - `table` is `'A'..='Z'` or `'0'..='9'`: alternate table with an alphanumeric overlay
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub struct Symbol {
9    pub table: char,
10    pub code: char,
11}
12
13impl Symbol {
14    pub fn new(table: char, code: char) -> Self {
15        Self { table, code }
16    }
17
18    pub fn is_primary_table(self) -> bool {
19        self.table == '/'
20    }
21
22    pub fn is_alternate_table(self) -> bool {
23        self.table == '\\'
24    }
25
26    /// Returns the overlay character if this symbol uses an alphanumeric overlay.
27    pub fn overlay(self) -> Option<char> {
28        if self.table.is_ascii_alphanumeric() && self.table != '/' {
29            Some(self.table)
30        } else {
31            None
32        }
33    }
34
35    /// Look up the human-readable description of this symbol.
36    ///
37    /// Returns `None` for reserved, overlay-only, or TNC-internal codes.
38    pub fn description(self) -> Option<&'static str> {
39        let idx = self.code as usize;
40        if !(33..=126).contains(&idx) {
41            return None;
42        }
43        let i = idx - 33; // 0-based index into the tables
44        if self.is_primary_table() {
45            PRIMARY[i]
46        } else {
47            // Overlays use the same alternate table as the `\` table
48            ALTERNATE[i]
49        }
50    }
51}
52
53// ─── Symbol tables ────────────────────────────────────────────────────────────
54// Each array is indexed by (code as usize - 33), covering ASCII 33 ('!') to 126 ('~').
55// Sources: APRS101.pdf, aprs.org/symbols, aprs.fi symbol reference.
56
57/// Primary symbol table (table ID = `/`).
58#[rustfmt::skip]
59const PRIMARY: [Option<&'static str>; 94] = [
60    Some("Police, Sheriff"),            // ! 33
61    None,                               // " 34  (no symbol)
62    Some("Digipeater"),                 // # 35
63    Some("Phone"),                      // $ 36
64    Some("DX Cluster"),                 // % 37
65    Some("HF Gateway"),                 // & 38
66    Some("Small Aircraft"),             // ' 39
67    Some("Mobile Satellite Station"),   // ( 40
68    Some("Wheelchair, Handicapped"),    // ) 41
69    Some("Snowflake"),                  // * 42
70    Some("Red Cross"),                  // + 43
71    Some("Boy Scouts"),                 // , 44
72    Some("House"),                      // - 45
73    Some("X"),                          // . 46
74    Some("Dot"),                        // / 47
75    Some("Circle 0"),                   // 0 48
76    Some("Circle 1"),                   // 1 49
77    Some("Circle 2"),                   // 2 50
78    Some("Circle 3"),                   // 3 51
79    Some("Circle 4"),                   // 4 52
80    Some("Circle 5"),                   // 5 53
81    Some("Circle 6"),                   // 6 54
82    Some("Circle 7"),                   // 7 55
83    Some("Circle 8"),                   // 8 56
84    Some("Circle 9"),                   // 9 57
85    Some("Fire"),                       // : 58
86    Some("Campground, Tent"),           // ; 59
87    Some("Motorcycle"),                 // < 60
88    Some("Railroad Engine"),            // = 61
89    Some("Car"),                        // > 62
90    Some("File Server"),                // ? 63
91    Some("Hurricane, Tropical Storm"),  // @ 64
92    None, None, None, None, None,       // A-E 65-69 (overlay)
93    None, None, None, None, None,       // F-J 70-74 (overlay)
94    None, None, None, None, None,       // K-O 75-79 (overlay)
95    None, None, None, None, None,       // P-T 80-84 (overlay)
96    None, None, None, None, None,       // U-Y 85-89 (overlay)
97    None,                               // Z 90  (overlay)
98    Some("Jogger"),                     // [ 91
99    Some("Triangle"),                   // \ 92
100    Some("PBBS"),                       // ] 93
101    Some("Large Aircraft"),             // ^ 94
102    Some("Weather Station"),            // _ 95
103    Some("Satellite Dish"),             // ` 96
104    Some("Ambulance"),                  // a 97
105    Some("Bike"),                       // b 98
106    Some("Incident Command Post"),      // c 99
107    Some("Fire Dept"),                  // d 100
108    Some("Horse, Equestrian"),          // e 101
109    Some("Fire Truck"),                 // f 102
110    Some("Glider"),                     // g 103
111    Some("Hospital"),                   // h 104
112    Some("IOTA"),                       // i 105
113    Some("Jeep"),                       // j 106
114    Some("Truck"),                      // k 107
115    Some("Laptop"),                     // l 108
116    Some("Mic-E Repeater"),             // m 109
117    Some("Node"),                       // n 110
118    Some("EOC"),                        // o 111
119    Some("Rover, Dog"),                 // p 112
120    Some("Grid Square"),                // q 113
121    Some("Antenna"),                    // r 114
122    Some("Power Boat"),                 // s 115
123    Some("Truck Stop"),                 // t 116
124    Some("18-Wheeler"),                 // u 117
125    Some("Van"),                        // v 118
126    Some("Water Station"),              // w 119
127    Some("APRS"),                       // x 120
128    Some("Yagi Antenna"),               // y 121
129    Some("Shelter"),                    // z 122
130    None,                               // { 123
131    None,                               // | 124  (TNC stream switch)
132    None,                               // } 125
133    None,                               // ~ 126  (TNC stream switch)
134];
135
136/// Alternate symbol table (table ID = `\` or any overlay character).
137#[rustfmt::skip]
138const ALTERNATE: [Option<&'static str>; 94] = [
139    Some("Emergency"),                  // ! 33
140    None,                               // " 34
141    Some("Digipeater (numbered)"),      // # 35
142    Some("ATM, Bank"),                  // $ 36
143    Some("Accident Scene"),             // % 37
144    Some("Haze"),                       // & 38
145    Some("Flash"),                      // ' 39
146    Some("Cloud"),                      // ( 40
147    Some("Sunny, Partly Cloudy"),       // ) 41
148    Some("Snow"),                       // * 42
149    Some("Church"),                     // + 43
150    Some("Girl Scouts"),                // , 44
151    Some("House, Shack"),               // - 45
152    Some("X"),                          // . 46
153    Some("Circle"),                     // / 47
154    Some("Circle 0 (overlay)"),         // 0 48
155    Some("Circle 1 (overlay)"),         // 1 49
156    Some("Circle 2 (overlay)"),         // 2 50
157    Some("Circle 3 (overlay)"),         // 3 51
158    Some("Circle 4 (overlay)"),         // 4 52
159    Some("Circle 5 (overlay)"),         // 5 53
160    Some("Circle 6 (overlay)"),         // 6 54
161    Some("Circle 7 (overlay)"),         // 7 55
162    Some("Circle 8 (overlay)"),         // 8 56
163    Some("Circle 9 (overlay)"),         // 9 57
164    Some("Hail"),                       // : 58
165    Some("Park, Picnic"),               // ; 59
166    Some("NWS Advisory"),               // < 60
167    Some("Railroad Station"),           // = 61
168    Some("Info Kiosk"),                 // > 62
169    Some("Work Zone"),                  // ? 63
170    Some("Tornado"),                    // @ 64
171    None, None, None, None, None,       // A-E 65-69 (overlay)
172    None, None, None, None, None,       // F-J 70-74 (overlay)
173    None, None, None, None, None,       // K-O 75-79 (overlay)
174    None, None, None, None, None,       // P-T 80-84 (overlay)
175    None, None, None, None, None,       // U-Y 85-89 (overlay)
176    None,                               // Z 90  (overlay)
177    Some("Wall Cloud"),                 // [ 91
178    Some("Misc Aircraft"),              // \ 92
179    Some("Rocket Launch"),              // ] 93
180    Some("Jet Aircraft"),               // ^ 94
181    Some("Funnel Cloud"),               // _ 95
182    Some("Rain Shower"),                // ` 96
183    Some("ARES"),                       // a 97
184    Some("Blowing Snow"),               // b 98
185    Some("Coast Guard"),                // c 99
186    Some("Drizzle"),                    // d 100
187    Some("Smoke"),                      // e 101
188    Some("Freezing Rain"),              // f 102
189    Some("Snow Shower"),                // g 103
190    Some("Haze"),                       // h 104
191    Some("Rain Shower"),                // i 105
192    Some("Lightning"),                  // j 106
193    Some("Kenwood Radio"),              // k 107
194    Some("Lighthouse"),                 // l 108
195    Some("MARS"),                       // m 109
196    Some("Navigation Buoy"),            // n 110
197    Some("Rocket"),                     // o 111
198    Some("Parking"),                    // p 112
199    Some("Earthquake"),                 // q 113
200    Some("Restaurant"),                 // r 114
201    Some("Satellite"),                  // s 115
202    Some("Thunderstorm"),               // t 116
203    Some("Sunny"),                      // u 117
204    Some("VORTAC, Nav Aid"),            // v 118
205    Some("NWS Site"),                   // w 119
206    Some("Pharmacy"),                   // x 120
207    Some("Radiosonde"),                 // y 121
208    Some("Shelter"),                    // z 122
209    Some("Fog"),                        // { 123
210    None,                               // | 124  (TNC stream switch)
211    None,                               // } 125
212    None,                               // ~ 126  (TNC stream switch)
213];
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn primary_car() {
221        let s = Symbol::new('/', '>');
222        assert_eq!(s.description(), Some("Car"));
223    }
224
225    #[test]
226    fn primary_house() {
227        let s = Symbol::new('/', '-');
228        assert_eq!(s.description(), Some("House"));
229    }
230
231    #[test]
232    fn primary_weather_station() {
233        let s = Symbol::new('/', '_');
234        assert_eq!(s.description(), Some("Weather Station"));
235    }
236
237    #[test]
238    fn alternate_tornado() {
239        let s = Symbol::new('\\', '@');
240        assert_eq!(s.description(), Some("Tornado"));
241    }
242
243    #[test]
244    fn overlay_uses_alternate_table() {
245        // Numeric overlay (e.g. '3') uses alternate table descriptions
246        let s = Symbol::new('3', '>');
247        assert_eq!(s.description(), Some("Info Kiosk"));
248    }
249
250    #[test]
251    fn reserved_overlay_code_returns_none() {
252        // A-Z codes in both tables are overlay-only
253        let s = Symbol::new('/', 'A');
254        assert_eq!(s.description(), None);
255    }
256
257    #[test]
258    fn out_of_range_code_returns_none() {
259        let s = Symbol::new('/', '\x01');
260        assert_eq!(s.description(), None);
261    }
262
263    #[test]
264    fn all_primary_entries_no_panic() {
265        for code in '!'..='~' {
266            let _ = Symbol::new('/', code).description();
267        }
268    }
269
270    #[test]
271    fn all_alternate_entries_no_panic() {
272        for code in '!'..='~' {
273            let _ = Symbol::new('\\', code).description();
274        }
275    }
276}