1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Passcode {
21 Verified(u16),
23 ReceiveOnly,
26}
27
28impl Passcode {
29 #[must_use]
32 pub fn for_callsign(callsign: &str) -> Self {
33 let value = aprs_is_passcode(callsign);
34 Self::Verified(u16::try_from(value).unwrap_or(0))
36 }
37
38 #[must_use]
41 pub const fn as_wire(self) -> i32 {
42 match self {
43 Self::Verified(n) => n as i32,
44 Self::ReceiveOnly => -1,
45 }
46 }
47}
48
49impl std::fmt::Display for Passcode {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 self.as_wire().fmt(f)
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AprsIsConfig {
62 pub callsign: String,
64 pub passcode: Passcode,
66 pub server: String,
68 pub port: u16,
70 pub filter: String,
72 pub software_name: String,
74 pub software_version: String,
76}
77
78impl AprsIsConfig {
79 #[must_use]
84 pub fn new(callsign: &str) -> Self {
85 Self {
86 callsign: callsign.to_owned(),
87 passcode: Passcode::for_callsign(callsign),
88 server: "rotate.aprs2.net".to_owned(),
89 port: 14580,
90 filter: String::new(),
91 software_name: "aprs-is".to_owned(),
92 software_version: env!("CARGO_PKG_VERSION").to_owned(),
93 }
94 }
95
96 #[must_use]
99 pub fn receive_only(callsign: &str) -> Self {
100 Self {
101 callsign: callsign.to_owned(),
102 passcode: Passcode::ReceiveOnly,
103 server: "rotate.aprs2.net".to_owned(),
104 port: 14580,
105 filter: String::new(),
106 software_name: "aprs-is".to_owned(),
107 software_version: env!("CARGO_PKG_VERSION").to_owned(),
108 }
109 }
110}
111
112#[must_use]
127pub fn aprs_is_passcode(callsign: &str) -> i32 {
128 let base = callsign
130 .split('-')
131 .next()
132 .unwrap_or(callsign)
133 .to_uppercase();
134
135 let bytes = base.as_bytes();
136 let mut hash: u16 = 0x73E2;
137
138 let mut i = 0;
139 while i < bytes.len() {
140 let Some(first) = bytes.get(i) else {
141 break;
142 };
143 hash ^= u16::from(*first) << 8;
144 if let Some(second) = bytes.get(i + 1) {
145 hash ^= u16::from(*second);
146 }
147 i += 2;
148 }
149
150 i32::from(hash & 0x7FFF)
151}
152
153#[must_use]
159pub fn build_login_string(config: &AprsIsConfig) -> String {
160 let mut login = format!(
161 "user {} pass {} vers {} {}",
162 config.callsign, config.passcode, config.software_name, config.software_version,
163 );
164
165 if !config.filter.is_empty() {
166 login.push_str(" filter ");
167 login.push_str(&config.filter);
168 }
169
170 login.push_str("\r\n");
171 login
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn passcode_n0call() {
180 assert_eq!(aprs_is_passcode("N0CALL"), 13023);
182 }
183
184 #[test]
185 fn passcode_strips_ssid() {
186 assert_eq!(aprs_is_passcode("N0CALL-10"), aprs_is_passcode("N0CALL"));
187 }
188
189 #[test]
190 fn passcode_case_insensitive() {
191 assert_eq!(aprs_is_passcode("n0call"), aprs_is_passcode("N0CALL"));
192 }
193
194 #[test]
195 fn passcode_is_positive() {
196 let code = aprs_is_passcode("W1AW");
198 assert!(
199 (0..=0x7FFF).contains(&code),
200 "passcode out of 15-bit range: {code}"
201 );
202 }
203
204 #[test]
205 fn passcode_odd_length_callsign() {
206 let code = aprs_is_passcode("K1ABC");
208 assert!(
209 (0..=0x7FFF).contains(&code),
210 "passcode out of 15-bit range: {code}"
211 );
212 }
213
214 #[test]
215 fn passcode_w1aw() {
216 assert_eq!(aprs_is_passcode("W1AW"), 25988);
218 }
219
220 #[test]
221 fn config_defaults() {
222 let config = AprsIsConfig::new("N0CALL-10");
223 assert_eq!(config.callsign, "N0CALL-10");
224 assert_eq!(config.passcode, Passcode::Verified(13023));
225 assert_eq!(config.server, "rotate.aprs2.net");
226 assert_eq!(config.port, 14580);
227 assert!(config.filter.is_empty());
228 assert_eq!(config.software_name, "aprs-is");
229 }
230
231 #[test]
232 fn receive_only_config() {
233 let config = AprsIsConfig::receive_only("N0CALL");
234 assert_eq!(config.passcode, Passcode::ReceiveOnly);
235 assert_eq!(config.passcode.as_wire(), -1);
236 }
237
238 #[test]
239 fn passcode_for_callsign_matches_hash() {
240 assert_eq!(Passcode::for_callsign("N0CALL"), Passcode::Verified(13023));
241 assert_eq!(Passcode::for_callsign("W1AW"), Passcode::Verified(25988));
242 }
243
244 #[test]
245 fn passcode_receive_only_as_wire() {
246 assert_eq!(Passcode::ReceiveOnly.as_wire(), -1);
247 assert_eq!(Passcode::Verified(13023).as_wire(), 13023);
248 }
249
250 #[test]
251 fn login_string_no_filter() {
252 let config = AprsIsConfig::new("N0CALL");
253 let login = build_login_string(&config);
254 assert!(
255 login.starts_with("user N0CALL pass 13023 vers aprs-is "),
256 "unexpected login prefix: {login:?}"
257 );
258 assert!(login.ends_with("\r\n"), "missing CRLF: {login:?}");
259 assert!(
260 !login.contains("filter"),
261 "filter clause unexpectedly present: {login:?}"
262 );
263 }
264
265 #[test]
266 fn login_string_with_filter() {
267 let mut config = AprsIsConfig::new("N0CALL");
268 config.filter = "r/35.25/-97.75/100".to_owned();
269 let login = build_login_string(&config);
270 assert!(
271 login.contains("filter r/35.25/-97.75/100"),
272 "filter not in login string: {login:?}"
273 );
274 assert!(login.ends_with("\r\n"), "missing CRLF: {login:?}");
275 }
276}