Skip to main content

aprs_decode/
callsign.rs

1use crate::error::AprsError;
2use std::fmt;
3
4/// Maximum length of an APRS callsign (base call only, without SSID).
5/// AX.25 limits to 6; APRS-IS allows 9 for internet-only stations.
6const MAX_CALL_LEN: usize = 9;
7
8/// Fixed-capacity stack-allocated ASCII string for the base callsign.
9#[derive(Clone, PartialEq, Eq, Hash)]
10struct CallBuf {
11    bytes: [u8; MAX_CALL_LEN],
12    len: u8,
13}
14
15impl CallBuf {
16    fn as_str(&self) -> &str {
17        std::str::from_utf8(&self.bytes[..self.len as usize]).expect("callsign is always ASCII")
18    }
19}
20
21/// Maximum length of an SSID in textual form. AX.25 SSIDs are a single digit
22/// 0–15; APRS-IS and D-STAR gateways additionally use short alphanumeric SSIDs
23/// (e.g. `-S`, `-B`, `-C`). A small bound keeps storage allocation-free.
24const MAX_SSID_LEN: usize = 6;
25
26/// Fixed-capacity stack-allocated ASCII string for an alphanumeric SSID.
27#[derive(Clone, PartialEq, Eq, Hash)]
28struct SsidBuf {
29    bytes: [u8; MAX_SSID_LEN],
30    len: u8,
31}
32
33impl SsidBuf {
34    fn from_uppercased(src: &[u8]) -> Self {
35        let mut bytes = [0u8; MAX_SSID_LEN];
36        for (i, &b) in src.iter().enumerate() {
37            bytes[i] = b.to_ascii_uppercase();
38        }
39        SsidBuf {
40            bytes,
41            len: src.len() as u8,
42        }
43    }
44
45    fn as_str(&self) -> &str {
46        std::str::from_utf8(&self.bytes[..self.len as usize]).expect("ssid is always ASCII")
47    }
48}
49
50/// An APRS callsign with an optional SSID.
51///
52/// The SSID is usually a numeric 0–15 (as in AX.25) but APRS-IS and D-STAR
53/// gateways also use short alphanumeric SSIDs such as `-S` or `-B`. Both the
54/// base call and SSID are stored as uppercase ASCII in fixed-size inline
55/// buffers — no heap allocation.
56#[derive(Clone, PartialEq, Eq, Hash)]
57pub struct Callsign {
58    call: CallBuf,
59    ssid: Option<SsidBuf>,
60}
61
62#[cfg(feature = "serde")]
63impl serde::Serialize for Callsign {
64    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
65        s.serialize_str(&self.to_string())
66    }
67}
68
69#[cfg(feature = "serde")]
70impl<'de> serde::Deserialize<'de> for Callsign {
71    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
72        let s = String::deserialize(d)?;
73        Callsign::decode_textual(s.as_bytes()).map_err(serde::de::Error::custom)
74    }
75}
76
77impl Callsign {
78    /// Parse a textual callsign (e.g. `W1AW-9`, or a D-STAR `K0HRV-S`).
79    pub fn decode_textual(input: &[u8]) -> Result<Self, AprsError> {
80        let (call_bytes, ssid) = if let Some(pos) = input.iter().position(|&b| b == b'-') {
81            // `parse_ssid` returns `None` when invalid, `Some(None)` when the SSID
82            // normalizes to "no SSID" (e.g. `-0`, matching AX.25), and `Some(Some)`
83            // otherwise.
84            let ssid = parse_ssid(&input[pos + 1..]).ok_or_else(|| AprsError::InvalidCallsign {
85                raw: input.to_vec(),
86            })?;
87            (&input[..pos], ssid)
88        } else {
89            (input, None)
90        };
91
92        let call = parse_call(call_bytes).ok_or_else(|| AprsError::InvalidCallsign {
93            raw: input.to_vec(),
94        })?;
95
96        Ok(Callsign { call, ssid })
97    }
98
99    /// Decode a 7-byte AX.25 address field.
100    ///
101    /// AX.25 stores each character left-shifted by one bit. The SSID byte encodes
102    /// the SSID in bits 1–4 and the end-of-address (EOA) flag in bit 0.
103    /// Returns `(callsign, eoa)`.
104    pub fn decode_ax25(bytes: &[u8]) -> Result<(Self, bool), AprsError> {
105        if bytes.len() < 7 {
106            return Err(AprsError::TruncatedPacket {
107                expected: 7,
108                got: bytes.len(),
109            });
110        }
111        let mut raw_call = [b' '; 6];
112        for i in 0..6 {
113            let shifted = bytes[i];
114            // LSB of each call byte must be 0 in valid AX.25
115            if shifted & 0x01 != 0 {
116                return Err(AprsError::InvalidCallsign {
117                    raw: bytes[..7].to_vec(),
118                });
119            }
120            raw_call[i] = shifted >> 1;
121        }
122        // Trim trailing spaces
123        let end = raw_call
124            .iter()
125            .rposition(|&b| b != b' ')
126            .map(|p| p + 1)
127            .unwrap_or(0);
128        let call = parse_call(&raw_call[..end]).ok_or_else(|| AprsError::InvalidCallsign {
129            raw: bytes[..7].to_vec(),
130        })?;
131
132        let ssid_byte = bytes[6];
133        let ssid_val = (ssid_byte >> 1) & 0x0F;
134        // AX.25 SSIDs are always numeric; store the canonical decimal form.
135        let ssid = if ssid_val == 0 {
136            None
137        } else {
138            let mut tmp = [0u8; 2];
139            let s: &[u8] = if ssid_val >= 10 {
140                tmp[0] = b'0' + ssid_val / 10;
141                tmp[1] = b'0' + ssid_val % 10;
142                &tmp[..2]
143            } else {
144                tmp[0] = b'0' + ssid_val;
145                &tmp[..1]
146            };
147            Some(SsidBuf::from_uppercased(s))
148        };
149        let eoa = ssid_byte & 0x01 != 0;
150
151        Ok((Callsign { call, ssid }, eoa))
152    }
153
154    pub fn as_str(&self) -> &str {
155        self.call.as_str()
156    }
157
158    /// The SSID in textual form (e.g. `"9"`, `"S"`), or `None` for no SSID.
159    pub fn ssid(&self) -> Option<&str> {
160        self.ssid.as_ref().map(SsidBuf::as_str)
161    }
162
163    /// The SSID as a number, if it is numeric (0–15). Returns `None` for no SSID
164    /// or for an alphanumeric (e.g. D-STAR) SSID.
165    pub fn ssid_numeric(&self) -> Option<u8> {
166        self.ssid()
167            .and_then(|s| s.parse::<u8>().ok())
168            .filter(|v| *v <= 15)
169    }
170
171    /// Write this callsign in textual APRS format.
172    pub fn encode_textual(&self, out: &mut Vec<u8>) {
173        out.extend_from_slice(self.call.as_str().as_bytes());
174        if let Some(ref ssid) = self.ssid {
175            out.push(b'-');
176            out.extend_from_slice(ssid.as_str().as_bytes());
177        }
178    }
179
180    /// Write this callsign as a 7-byte AX.25 address field.
181    ///
182    /// AX.25 can only represent numeric SSIDs 0–15; an alphanumeric SSID (only
183    /// valid in textual/APRS-IS form) is encoded as SSID 0.
184    pub fn encode_ax25(&self, out: &mut Vec<u8>, eoa: bool) {
185        let call = self.call.as_str().as_bytes();
186        for i in 0..6 {
187            let b = if i < call.len() { call[i] } else { b' ' };
188            out.push(b << 1);
189        }
190        let ssid_val = self.ssid_numeric().unwrap_or(0) & 0x0F;
191        let eoa_bit: u8 = if eoa { 0x01 } else { 0x00 };
192        // Bits 5 and 7 must be 1 per AX.25 spec (reserved, set to 1)
193        out.push(0x60 | (ssid_val << 1) | eoa_bit);
194    }
195}
196
197impl fmt::Debug for Callsign {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        write!(f, "{self}")
200    }
201}
202
203impl fmt::Display for Callsign {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        write!(f, "{}", self.call.as_str())?;
206        if let Some(ref ssid) = self.ssid {
207            write!(f, "-{}", ssid.as_str())?;
208        }
209        Ok(())
210    }
211}
212
213// --- private helpers ---
214
215fn parse_call(bytes: &[u8]) -> Option<CallBuf> {
216    if bytes.is_empty() || bytes.len() > MAX_CALL_LEN {
217        return None;
218    }
219    let mut buf = [0u8; MAX_CALL_LEN];
220    for (i, &b) in bytes.iter().enumerate() {
221        if !b.is_ascii_alphanumeric() {
222            return None;
223        }
224        buf[i] = b.to_ascii_uppercase();
225    }
226    Some(CallBuf {
227        bytes: buf,
228        len: bytes.len() as u8,
229    })
230}
231
232/// Parse the SSID portion (the bytes after `-`).
233///
234/// Returns `None` for an invalid SSID, `Some(None)` when it normalizes to
235/// "no SSID" (`-0`, matching AX.25), and `Some(Some(..))` otherwise. A purely
236/// numeric SSID is validated against the AX.25 0–15 range and canonicalized
237/// (no leading zeros); a short alphanumeric SSID (e.g. D-STAR `-S`, `-B`) is
238/// accepted as-is.
239fn parse_ssid(bytes: &[u8]) -> Option<Option<SsidBuf>> {
240    if bytes.is_empty() || bytes.len() > MAX_SSID_LEN {
241        return None;
242    }
243    if bytes.iter().all(u8::is_ascii_digit) {
244        // Numeric SSID: enforce the AX.25 0–15 range and canonicalize.
245        let mut val: u8 = 0;
246        for &b in bytes {
247            val = val.checked_mul(10)?.checked_add(b - b'0')?;
248        }
249        if val > 15 {
250            return None;
251        }
252        if val == 0 {
253            return Some(None);
254        }
255        let mut tmp = [0u8; 2];
256        let s: &[u8] = if val >= 10 {
257            tmp[0] = b'0' + val / 10;
258            tmp[1] = b'0' + val % 10;
259            &tmp[..2]
260        } else {
261            tmp[0] = b'0' + val;
262            &tmp[..1]
263        };
264        return Some(Some(SsidBuf::from_uppercased(s)));
265    }
266    // Alphanumeric SSID (APRS-IS / D-STAR), e.g. `-S`, `-B`, `-RPT`.
267    if !bytes.iter().all(u8::is_ascii_alphanumeric) {
268        return None;
269    }
270    Some(Some(SsidBuf::from_uppercased(bytes)))
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn textual_no_ssid() {
279        let c = Callsign::decode_textual(b"W1AW").unwrap();
280        assert_eq!(c.as_str(), "W1AW");
281        assert_eq!(c.ssid(), None);
282    }
283
284    #[test]
285    fn textual_with_ssid() {
286        let c = Callsign::decode_textual(b"W1AW-9").unwrap();
287        assert_eq!(c.as_str(), "W1AW");
288        assert_eq!(c.ssid(), Some("9"));
289        assert_eq!(c.ssid_numeric(), Some(9));
290    }
291
292    #[test]
293    fn textual_ssid_15() {
294        let c = Callsign::decode_textual(b"N0CALL-15").unwrap();
295        assert_eq!(c.ssid(), Some("15"));
296        assert_eq!(c.ssid_numeric(), Some(15));
297    }
298
299    #[test]
300    fn textual_ssid_16_invalid() {
301        assert!(Callsign::decode_textual(b"N0CALL-16").is_err());
302    }
303
304    #[test]
305    fn textual_ssid_0_normalized_to_none() {
306        let c = Callsign::decode_textual(b"W1AW-0").unwrap();
307        assert_eq!(c.ssid(), None);
308        assert_eq!(c.to_string(), "W1AW");
309    }
310
311    #[test]
312    fn textual_alphanumeric_ssid_dstar() {
313        // D-STAR gateways use alphanumeric SSIDs (`-S`, `-B`, `-C`).
314        let c = Callsign::decode_textual(b"K0HRV-S").unwrap();
315        assert_eq!(c.as_str(), "K0HRV");
316        assert_eq!(c.ssid(), Some("S"));
317        assert_eq!(c.ssid_numeric(), None);
318        assert_eq!(c.to_string(), "K0HRV-S");
319    }
320
321    #[test]
322    fn textual_lowercase_normalized() {
323        let c = Callsign::decode_textual(b"w1aw").unwrap();
324        assert_eq!(c.as_str(), "W1AW");
325    }
326
327    #[test]
328    fn textual_empty_invalid() {
329        assert!(Callsign::decode_textual(b"").is_err());
330    }
331
332    #[test]
333    fn display_no_ssid() {
334        let c = Callsign::decode_textual(b"W1AW").unwrap();
335        assert_eq!(c.to_string(), "W1AW");
336    }
337
338    #[test]
339    fn display_with_ssid() {
340        let c = Callsign::decode_textual(b"W1AW-9").unwrap();
341        assert_eq!(c.to_string(), "W1AW-9");
342    }
343
344    #[test]
345    fn ax25_round_trip() {
346        let original = Callsign::decode_textual(b"W1AW-9").unwrap();
347        let mut encoded = Vec::new();
348        original.encode_ax25(&mut encoded, true);
349        assert_eq!(encoded.len(), 7);
350        let (decoded, eoa) = Callsign::decode_ax25(&encoded).unwrap();
351        assert_eq!(decoded, original);
352        assert!(eoa);
353    }
354
355    #[test]
356    fn encode_textual_round_trip() {
357        let original = Callsign::decode_textual(b"KD9ABC-3").unwrap();
358        let mut out = Vec::new();
359        original.encode_textual(&mut out);
360        let decoded = Callsign::decode_textual(&out).unwrap();
361        assert_eq!(decoded, original);
362    }
363}