aprs_is/
login.rs

1//! APRS-IS login string + passcode computation.
2//!
3//! # Passcode
4//!
5//! The APRS-IS passcode is computed from the callsign (without SSID)
6//! using a well-known hash algorithm. Use [`aprs_is_passcode`] to
7//! compute it.
8
9/// APRS-IS authentication passcode.
10///
11/// Per the APRS-IS authentication spec, a valid passcode is a 15-bit
12/// positive integer computed from the callsign via the public
13/// [`aprs_is_passcode`] hash. Clients that don't hold a verified passcode
14/// (e.g. read-only monitors) may authenticate as "receive-only" which
15/// maps to the wire value `-1`.
16///
17/// This enum replaces the previous `i32` field that used `-1` as a magic
18/// sentinel, making illegal states unrepresentable.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Passcode {
21    /// A verified 15-bit passcode computed from the station callsign.
22    Verified(u16),
23    /// Receive-only connection — the server will accept incoming packets
24    /// from us but will not forward any packets we transmit to RF.
25    ReceiveOnly,
26}
27
28impl Passcode {
29    /// Compute the passcode for a callsign using the public APRS-IS
30    /// hash algorithm.
31    #[must_use]
32    pub fn for_callsign(callsign: &str) -> Self {
33        let value = aprs_is_passcode(callsign);
34        // `aprs_is_passcode` masks with 0x7FFF so the result fits in u16.
35        Self::Verified(u16::try_from(value).unwrap_or(0))
36    }
37
38    /// Convert to the wire representation used in the APRS-IS login
39    /// string. `Verified(n)` → `n`, `ReceiveOnly` → `-1`.
40    #[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/// APRS-IS (Internet Service) client configuration.
56///
57/// Connects to an APRS-IS server via TCP, authenticates with callsign
58/// and passcode, and allows sending/receiving APRS packets over the
59/// internet backbone.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AprsIsConfig {
62    /// Callsign with optional SSID (e.g., "N0CALL-10").
63    pub callsign: String,
64    /// APRS-IS passcode (computed from callsign, or `ReceiveOnly`).
65    pub passcode: Passcode,
66    /// Server hostname (e.g., "rotate.aprs2.net").
67    pub server: String,
68    /// Server port (default 14580).
69    pub port: u16,
70    /// APRS-IS filter string (e.g., "r/35.25/-97.75/100" for 100km radius).
71    pub filter: String,
72    /// Software name for login.
73    pub software_name: String,
74    /// Software version for login.
75    pub software_version: String,
76}
77
78impl AprsIsConfig {
79    /// Create a new APRS-IS configuration with sensible defaults.
80    ///
81    /// Computes the passcode automatically from the callsign. Defaults to
82    /// `rotate.aprs2.net:14580` with no filter.
83    #[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    /// Create a receive-only APRS-IS configuration for the given
97    /// callsign. The server will not forward our transmissions to RF.
98    #[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/// Compute the APRS-IS passcode from a callsign.
113///
114/// The algorithm is a simple hash of the callsign characters (without SSID).
115/// This is NOT cryptographic -- it is a well-known public algorithm used
116/// by all APRS software.
117///
118/// # Algorithm
119///
120/// 1. Strip SSID (everything from `-` onward).
121/// 2. Uppercase the base callsign.
122/// 3. Starting with hash = 0x73E2, XOR each pair of bytes: first byte
123///    shifted left 8 bits, second byte as-is. If the callsign has an odd
124///    number of characters, the last byte is XOR'd shifted left 8 bits.
125/// 4. Mask with 0x7FFF to produce a positive 15-bit value.
126#[must_use]
127pub fn aprs_is_passcode(callsign: &str) -> i32 {
128    // Strip SSID.
129    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/// Build the APRS-IS login string.
154///
155/// Format: `user CALL pass PASSCODE vers SOFTNAME SOFTVER filter FILTER\r\n`
156///
157/// If the filter is empty, the `filter` clause is omitted.
158#[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        // Well-known test vector: N0CALL -> 13023
181        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        // The result must always be a positive 15-bit value.
197        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        // 5-character callsign (odd length).
207        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        // W1AW -> computed via standard APRS-IS algorithm.
217        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}