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}