aprs_is/
q_construct.rs

1//! APRS-IS Q-construct classification and `IGate` path rewriting.
2
3/// APRS-IS Q-construct tag (path identifier that records how a packet
4/// entered the APRS-IS network).
5///
6/// Per <http://www.aprs-is.net/q.aspx>, every packet seen by an APRS-IS
7/// server has exactly one Q-construct inserted into its path. Servers
8/// that relay packets propagate the construct unchanged; servers that
9/// originate packets add one based on the packet's source.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum QConstruct {
12    /// `qAC` — client-owned, server verified the login.
13    QAC,
14    /// `qAX` — client-owned, server did *not* verify the login.
15    QAX,
16    /// `qAU` — client-owned, received via UDP submit.
17    QAU,
18    /// `qAo` — server-owned, received from a different server.
19    QAo,
20    /// `qAO` — server-owned, originated on RF (`IGATE`).
21    QAO,
22    /// `qAS` — server-owned, received from a peer.
23    QAS,
24    /// `qAr` — gated from RF with no callsign substitution.
25    QAr,
26    /// `qAR` — gated from RF by a verified login.
27    QAR,
28    /// `qAZ` — not gated (server-added as a diagnostic).
29    QAZ,
30}
31
32impl QConstruct {
33    /// Wire form of the construct (the exact 3-character token inserted
34    /// into the APRS path).
35    #[must_use]
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Self::QAC => "qAC",
39            Self::QAX => "qAX",
40            Self::QAU => "qAU",
41            Self::QAo => "qAo",
42            Self::QAO => "qAO",
43            Self::QAS => "qAS",
44            Self::QAr => "qAr",
45            Self::QAR => "qAR",
46            Self::QAZ => "qAZ",
47        }
48    }
49
50    /// Parse a path element as a Q-construct if it matches one of the
51    /// well-known forms. Returns `None` otherwise.
52    #[must_use]
53    pub fn from_path_element(s: &str) -> Option<Self> {
54        match s {
55            "qAC" => Some(Self::QAC),
56            "qAX" => Some(Self::QAX),
57            "qAU" => Some(Self::QAU),
58            "qAo" => Some(Self::QAo),
59            "qAO" => Some(Self::QAO),
60            "qAS" => Some(Self::QAS),
61            "qAr" => Some(Self::QAr),
62            "qAR" => Some(Self::QAR),
63            "qAZ" => Some(Self::QAZ),
64            _ => None,
65        }
66    }
67}
68
69impl std::fmt::Display for QConstruct {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(self.as_str())
72    }
73}
74
75/// Format an APRS-IS packet with an explicit Q-construct.
76///
77/// Injects the Q-construct just before the gate callsign — the form
78/// required for packets originated by a client application. Per the
79/// APRS-IS spec, clients add `qAC` or `qAX` depending on whether they
80/// authenticated.
81#[must_use]
82pub fn format_is_packet_with_qconstruct(
83    source: &str,
84    destination: &str,
85    path: &[&str],
86    qconstruct: QConstruct,
87    gate_callsign: &str,
88    data: &str,
89) -> String {
90    let mut packet = format!("{source}>{destination}");
91    for p in path {
92        packet.push(',');
93        packet.push_str(p);
94    }
95    packet.push(',');
96    packet.push_str(qconstruct.as_str());
97    packet.push(',');
98    packet.push_str(gate_callsign);
99    packet.push(':');
100    packet.push_str(data);
101    packet.push_str("\r\n");
102    packet
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn qconstruct_round_trip() {
111        let all = [
112            QConstruct::QAC,
113            QConstruct::QAX,
114            QConstruct::QAU,
115            QConstruct::QAo,
116            QConstruct::QAO,
117            QConstruct::QAS,
118            QConstruct::QAr,
119            QConstruct::QAR,
120            QConstruct::QAZ,
121        ];
122        for q in all {
123            assert_eq!(
124                QConstruct::from_path_element(q.as_str()),
125                Some(q),
126                "round-trip failed for {q:?}"
127            );
128        }
129        assert_eq!(QConstruct::from_path_element("WIDE1-1"), None);
130    }
131
132    #[test]
133    fn format_is_packet_with_qconstruct_injects_tag() {
134        let pkt = format_is_packet_with_qconstruct(
135            "N0CALL",
136            "APK005",
137            &["WIDE1-1"],
138            QConstruct::QAC,
139            "N0CALL",
140            "!4903.50N/07201.75W-",
141        );
142        assert_eq!(
143            pkt,
144            "N0CALL>APK005,WIDE1-1,qAC,N0CALL:!4903.50N/07201.75W-\r\n"
145        );
146    }
147}