Skip to main content

rfham_core/
callsigns.rs

1//! Amateur radio callsign parsing and validation.
2//!
3//! A callsign follows the ITU pattern `[ancillary-prefix/]PREFIX N SUFFIX[/ancillary-suffix]`:
4//!
5//! - **Prefix** — one to three letters (or digit + letters), e.g. `K`, `VE`, `OE3`
6//! - **Separator** — a single digit `0`–`9`
7//! - **Suffix** — one to ten alphanumeric characters ending in a letter
8//! - **Ancillary prefix/suffix** — optional portable / operating-context qualifiers
9//!
10//! ## Ancillary suffixes
11//!
12//! | Suffix | Meaning |
13//! |--------|---------|
14//! | `/P`   | Portable |
15//! | `/M`   | Mobile |
16//! | `/AM`  | Aeronautical mobile |
17//! | `/MM`  | Maritime mobile |
18//! | `/A`   | Alternate location |
19//! | `/QRP` | Low-power (≤5 W) operation |
20//! | `/AG`, `/AE` | FCC licence pending upgrade |
21//!
22//! # Examples
23//!
24//! ```rust
25//! use rfham_core::callsign::CallSign;
26//!
27//! let cs: CallSign = "K7SKJ".parse().unwrap();
28//! assert_eq!(cs.prefix(), "K");
29//! assert_eq!(cs.separator_numeral(), 7);
30//! assert_eq!(cs.suffix(), "SKJ");
31//! assert!(!cs.is_mobile());
32//! ```
33//!
34//! Ancillary qualifiers round-trip through `Display`:
35//!
36//! ```rust
37//! use rfham_core::callsign::CallSign;
38//!
39//! let cs: CallSign = "LM9L40Y/P".parse().unwrap();
40//! assert!(cs.is_portable());
41//! assert_eq!(cs.to_string(), "LM9L40Y/P");
42//! ```
43//!
44//! Invalid callsigns return an error:
45//!
46//! ```rust
47//! use rfham_core::callsign::CallSign;
48//!
49//! assert!(CallSign::is_valid("K7SKJ"));
50//! assert!(!CallSign::is_valid("NODIGIT"));
51//! assert!("NODIGIT".parse::<CallSign>().is_err());
52//! ```
53
54use crate::error::CoreError;
55use regex::Regex;
56use serde_with::{DeserializeFromStr, SerializeDisplay};
57use std::{fmt::Display, str::FromStr, sync::LazyLock};
58
59// ------------------------------------------------------------------------------------------------
60// Public Macros
61// ------------------------------------------------------------------------------------------------
62
63// ------------------------------------------------------------------------------------------------
64// Public Types
65// ------------------------------------------------------------------------------------------------
66
67/// In general an amateur radio callsign is of one of these forms where:
68///
69/// * *P* – prefix character (letter or numeral, subject to exclusions below). Prefixes can be
70///   formed using one-letter, two-letters, a digit and a letter, a letter and a digit, or in
71///   rare cases a digit and two letters. There is no ITU allocation of digit-only prefixes.
72///   Letter-digit-letter prefixes are possible but there are no known cases of them being
73///   issued by national bodies.
74/// * *N* – a single numeral which separates prefix from suffix (any digit from 0 to 9).
75///   Often a cross-hatched Ø is used for the numeral zero to distinguish it from the letter O.
76/// * *S* – suffix character (letter or numeral, last character must be a letter). Digits are
77///   in practise used sparingly in suffixes and almost always for special events. This avoids
78///   confusion with separating numerals and digits in prefixes in regularly issued call signs.
79///
80///   From [Wikipedia](https://en.wikipedia.org/wiki/Amateur_radio_call_signs)
81#[derive(Clone, Debug, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
82pub struct CallSign {
83    ancillary_prefix: Option<String>,
84    prefix: String,
85    separator: u8,
86    suffix: String,
87    ancillary_suffix: Option<String>,
88}
89
90// ------------------------------------------------------------------------------------------------
91// Implementations
92// ------------------------------------------------------------------------------------------------
93
94static CALLSIGN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
95    Regex::new(
96        r"(?x)
97    ^
98        (?:(?<aprefix>[A-Z0-9]+)\/)?
99        (?<prefix>(?:[A-Z][0-9][A-Z]?)|(?:[0-9][A-Z]{0,2})|(?:[A-Z]{1,3}))
100        (?<sep>[0-9])
101        (?<suffix>[A-Z0-9]{1,10})
102        (?:\/(?<asuffix>[A-Z0-9]+))?
103    $",
104    )
105    .unwrap()
106});
107
108const ODD_CALLSIGN_PREFIXES: &[&str; 16] = &[
109    "1A", // is used by the Sovereign Military Order of Malta
110    "1B", // is used by the Turkish Republic of Northern Cyprus
111    "1C", "1X", // are occasionally used by separatists in the Chechnya
112    "1S", // is sometimes used on the Spratly Islands in the South China Sea
113    "1Z", // has been used in Kawthoolei, an unrecognized breakaway region of Myanmar
114    "D0",
115    "1C",  // were used in 2014, allegedly from the unrecognized Donetsk People's Republic
116    "S0",  // is a prefix used in the Western Sahara
117    "S1A", // is used by the Principality of Sealand
118    "T1",  // has appeared as a callsign from Transnistria
119    "T0", "0S", "1P",
120    "T89", // have occasionally been used by operators in the Principality of Seborga
121    "Z6",  // was chosen by the Telecommunications Regulatory Authority of the Republic of Kosovo
122];
123
124// ------------------------------------------------------------------------------------------------
125
126impl Display for CallSign {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        write!(
129            f,
130            "{}{}{}{}{}",
131            if let Some(ancillary_prefix) = &self.ancillary_prefix {
132                format!("{ancillary_prefix}/")
133            } else {
134                String::default()
135            },
136            self.prefix,
137            self.separator,
138            self.suffix,
139            if let Some(ancillary_suffix) = &self.ancillary_suffix {
140                format!("/{ancillary_suffix}")
141            } else {
142                String::default()
143            },
144        )
145    }
146}
147
148impl FromStr for CallSign {
149    type Err = CoreError;
150
151    fn from_str(s: &str) -> Result<Self, Self::Err> {
152        let captures = CALLSIGN_REGEX.captures(s);
153        if let Some(captures) = captures {
154            let result = CallSign::new(
155                captures.name("prefix").unwrap().as_str(),
156                u8::from_str(captures.name("sep").unwrap().as_str())
157                    .map_err(|_| CoreError::InvalidValueFromStr(s.to_string(), "CallSign"))?,
158                captures.name("suffix").unwrap().as_str(),
159            );
160            let result = if let Some(a_prefix) = captures.name("aprefix") {
161                result.with_ancillary_prefix(a_prefix.as_str())
162            } else {
163                result
164            };
165            let result = if let Some(a_suffix) = captures.name("asuffix") {
166                result.with_ancillary_suffix(a_suffix.as_str())
167            } else {
168                result
169            };
170            Ok(result)
171        } else {
172            Err(CoreError::InvalidValueFromStr(s.to_string(), "CallSign"))
173        }
174    }
175}
176
177impl CallSign {
178    pub fn new<S1: Into<String>, N: Into<u8>, S2: Into<String>>(
179        prefix: S1,
180        separator: N,
181        suffix: S2,
182    ) -> Self {
183        Self {
184            ancillary_prefix: None,
185            prefix: prefix.into(),
186            separator: separator.into(),
187            suffix: suffix.into(),
188            ancillary_suffix: None,
189        }
190    }
191
192    pub fn with_ancillary_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
193        self.ancillary_prefix = Some(prefix.into());
194        self
195    }
196
197    pub fn with_ancillary_suffix<S: Into<String>>(mut self, suffix: S) -> Self {
198        self.ancillary_suffix = Some(suffix.into());
199        self
200    }
201
202    pub fn ancillary_prefix(&self) -> Option<&String> {
203        self.ancillary_prefix.as_ref()
204    }
205
206    pub fn prefix(&self) -> &String {
207        &self.prefix
208    }
209
210    pub fn separator_numeral(&self) -> u8 {
211        self.separator
212    }
213
214    pub fn suffix(&self) -> &String {
215        &self.suffix
216    }
217
218    pub fn ancillary_suffix(&self) -> Option<&String> {
219        self.ancillary_suffix.as_ref()
220    }
221
222    /// Returns `true` if `s` matches the ITU callsign pattern.
223    pub fn is_valid(s: &str) -> bool {
224        CALLSIGN_REGEX.is_match(s)
225    }
226
227    /// Returns `true` if this is a special-event or commemorative callsign — i.e. the suffix
228    /// is longer than four characters or ends with a digit.
229    pub fn is_special(&self) -> bool {
230        self.suffix.len() > 4 || self.suffix.chars().last().unwrap().is_ascii_digit()
231    }
232
233    /// Returns `true` if the prefix appears in the list of non-standard or
234    /// unrecognised-entity prefixes tracked by this library.
235    pub fn is_prefix_non_standard(&self) -> bool {
236        ODD_CALLSIGN_PREFIXES.contains(&self.prefix.as_str())
237    }
238
239    /// Returns `true` when the `/A` ancillary suffix indicates operation from an alternate
240    /// licensed location.
241    pub fn is_at_alternate_location(&self) -> bool {
242        self.ancillary_suffix()
243            .map(|s| s.eq_ignore_ascii_case("A"))
244            .unwrap_or_default()
245    }
246
247    /// Returns `true` when the `/P` ancillary suffix indicates portable operation.
248    pub fn is_portable(&self) -> bool {
249        self.ancillary_suffix()
250            .map(|s| s.eq_ignore_ascii_case("P"))
251            .unwrap_or_default()
252    }
253
254    /// Returns `true` when the `/M` ancillary suffix indicates mobile operation.
255    pub fn is_mobile(&self) -> bool {
256        self.ancillary_suffix()
257            .map(|s| s.eq_ignore_ascii_case("M"))
258            .unwrap_or_default()
259    }
260
261    /// Returns `true` when the `/AM` ancillary suffix indicates aeronautical mobile operation.
262    pub fn is_aeronautical_mobile(&self) -> bool {
263        self.ancillary_suffix()
264            .map(|s| s.eq_ignore_ascii_case("AM"))
265            .unwrap_or_default()
266    }
267
268    /// Returns `true` when the `/MM` ancillary suffix indicates maritime mobile operation.
269    pub fn is_maritime_mobile(&self) -> bool {
270        self.ancillary_suffix()
271            .map(|s| s.eq_ignore_ascii_case("MM"))
272            .unwrap_or_default()
273    }
274
275    /// Returns `true` when the `/QRP` ancillary suffix indicates the station is operating
276    /// at QRP power levels (typically ≤5 W).
277    pub fn is_operating_qrp(&self) -> bool {
278        self.ancillary_suffix()
279            .map(|s| s.eq_ignore_ascii_case("QRP"))
280            .unwrap_or_default()
281    }
282
283    /// Returns `true` when the `/AG` or `/AE` ancillary suffix indicates a pending FCC
284    /// licence upgrade.
285    pub fn is_fcc_license_pending(&self) -> bool {
286        self.ancillary_suffix()
287            .map(|s| s.eq_ignore_ascii_case("AG") || s.eq_ignore_ascii_case("AE"))
288            .unwrap_or_default()
289    }
290}
291
292// ------------------------------------------------------------------------------------------------
293// Unit Tests
294// ------------------------------------------------------------------------------------------------
295
296#[cfg(test)]
297mod test {
298    use crate::callsigns::CallSign;
299    use pretty_assertions::assert_eq;
300    use std::str::FromStr;
301
302    const VALID: &[&str] = &[
303        "3DA0RS",
304        "4D71/N0NM",
305        "4X130RISHON",
306        "4X4AAA",
307        "9N38",
308        "A22A",
309        "AX3GAMES",
310        "B2AA",
311        "BV100",
312        "DA2MORSE",
313        "DB50FIRAC",
314        "DL50FRANCE",
315        "FBC5AGB",
316        "FBC5CWU",
317        "FBC5LMJ",
318        "FBC5NOD",
319        "FBC5YJ",
320        "FBC6HQP",
321        "GB50RSARS",
322        "HA80MRASZ",
323        "HB9STEVE",
324        "HG5FIRAC",
325        "HG80MRASZ",
326        "HL1AA",
327        "I2OOOOX",
328        "II050SCOUT",
329        "IP1METEO",
330        "J42004A",
331        "J42004Q",
332        "K4X",
333        "LM1814",
334        "LM2T70Y",
335        "LM9L40Y",
336        "LM9L40Y/P",
337        "M0A",
338        "N2ASD",
339        "OEM2BZL",
340        "OEM3SGU",
341        "OEM3SGU/3",
342        "OEM6CLD",
343        "OEM8CIQ",
344        "OM2011GOOOLY",
345        "ON1000NOTGER",
346        "ON70REDSTAR",
347        "PA09SHAPE",
348        "PA65VERON",
349        "PA90CORUS",
350        "PG50RNARS",
351        "PG540BUFFALO",
352        "S55CERKNO",
353        "TM380",
354        // How is this valid => "TX9",
355        "TYA11",
356        "U5ARTEK/A",
357        "V6T1",
358        "VB3Q70",
359        "VI2AJ2010",
360        "VI2FG30",
361        "VI4WIP50",
362        "VU3DJQF1",
363        "VX31763",
364        // How is this valid => "WD4",
365        "XUF2B",
366        "YI9B4E",
367        "YO1000LEANY",
368        "ZL4RUGBY",
369        "ZS9MADIBA",
370        "C6AFO",   // Bahamian
371        "C6AGB",   // Bahamian
372        "VE9COAL", // Canadian commemorative event
373    ];
374
375    #[test]
376    fn test_callsign_components() {
377        let callsign = CallSign::from_str("K7SKJ/M").unwrap();
378        assert_eq!(None, callsign.ancillary_prefix());
379        assert_eq!("K", callsign.prefix().as_str());
380        assert_eq!(7, callsign.separator_numeral());
381        assert_eq!("SKJ", callsign.suffix().as_str());
382        assert_eq!(Some("M"), callsign.ancillary_suffix().map(|s| s.as_str()));
383        assert!(!callsign.is_special());
384    }
385
386    #[test]
387    fn test_callsign_mobile_qualifiers() {
388        assert!("K7SKJ/M".parse::<CallSign>().unwrap().is_mobile());
389        assert!("K7SKJ/P".parse::<CallSign>().unwrap().is_portable());
390        assert!(
391            "K7SKJ/AM"
392                .parse::<CallSign>()
393                .unwrap()
394                .is_aeronautical_mobile()
395        );
396        assert!("K7SKJ/MM".parse::<CallSign>().unwrap().is_maritime_mobile());
397        assert!(
398            "K7SKJ/A"
399                .parse::<CallSign>()
400                .unwrap()
401                .is_at_alternate_location()
402        );
403        assert!("K7SKJ/QRP".parse::<CallSign>().unwrap().is_operating_qrp());
404    }
405
406    #[test]
407    fn test_callsign_fcc_pending() {
408        assert!(
409            "K7SKJ/AG"
410                .parse::<CallSign>()
411                .unwrap()
412                .is_fcc_license_pending()
413        );
414        assert!(
415            "K7SKJ/AE"
416                .parse::<CallSign>()
417                .unwrap()
418                .is_fcc_license_pending()
419        );
420        assert!(
421            !"K7SKJ/P"
422                .parse::<CallSign>()
423                .unwrap()
424                .is_fcc_license_pending()
425        );
426    }
427
428    #[test]
429    fn test_callsign_special() {
430        assert!("GB50RSARS".parse::<CallSign>().unwrap().is_special()); // long suffix
431        assert!(!"K7SKJ".parse::<CallSign>().unwrap().is_special()); // normal suffix
432    }
433
434    #[test]
435    fn test_callsign_no_qualifier_flags_false() {
436        let cs: CallSign = "K7SKJ".parse().unwrap();
437        assert!(!cs.is_mobile());
438        assert!(!cs.is_portable());
439        assert!(!cs.is_aeronautical_mobile());
440        assert!(!cs.is_maritime_mobile());
441        assert!(!cs.is_at_alternate_location());
442        assert!(!cs.is_operating_qrp());
443        assert!(!cs.is_fcc_license_pending());
444    }
445
446    #[test]
447    fn test_invalid_callsigns() {
448        assert!(!CallSign::is_valid("NODIGIT")); // no separator digit
449        assert!(!CallSign::is_valid("")); // empty
450        assert!(!CallSign::is_valid("K7SK!")); // invalid character
451        assert!("NODIGIT".parse::<CallSign>().is_err());
452    }
453
454    #[test]
455    fn test_callsign_display_roundtrip() {
456        for s in VALID {
457            assert_eq!(s.to_string(), CallSign::from_str(s).unwrap().to_string());
458        }
459    }
460}