Skip to main content

aprs_decode/
digipeater.rs

1use crate::callsign::Callsign;
2use crate::error::AprsError;
3use std::fmt;
4
5/// A Q-construct used on APRS-IS to describe how a packet entered the internet.
6///
7/// Defined in the APRS-IS Q-construct specification.
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub enum QConstruct {
11    /// qAC — server login verified
12    Ac,
13    /// qAX — no verification (unverified login)
14    Ax,
15    /// qAO — heard via RF, originated on internet
16    Ao,
17    /// qAR — via bidirectional internet gateway
18    Ar,
19    /// qAS — via server without verification
20    As,
21    /// qAT — traced via internet
22    At,
23    /// qAI — server-generated packet
24    Ai,
25    /// qAo — heard directly via RF (lowercase o)
26    AoRf,
27    /// qAr — received from RF to internet
28    ArRf,
29    /// qAZ — zero hop (RF or direct)
30    Az,
31    /// Unknown Q-construct token
32    Unknown(String),
33}
34
35impl QConstruct {
36    fn from_bytes(bytes: &[u8]) -> Self {
37        match bytes {
38            b"qAC" => QConstruct::Ac,
39            b"qAX" => QConstruct::Ax,
40            b"qAO" => QConstruct::Ao,
41            b"qAR" => QConstruct::Ar,
42            b"qAS" => QConstruct::As,
43            b"qAT" => QConstruct::At,
44            b"qAI" => QConstruct::Ai,
45            b"qAo" => QConstruct::AoRf,
46            b"qAr" => QConstruct::ArRf,
47            b"qAZ" => QConstruct::Az,
48            other => QConstruct::Unknown(String::from_utf8_lossy(other).into_owned()),
49        }
50    }
51
52    fn as_bytes(&self) -> &[u8] {
53        match self {
54            QConstruct::Ac => b"qAC",
55            QConstruct::Ax => b"qAX",
56            QConstruct::Ao => b"qAO",
57            QConstruct::Ar => b"qAR",
58            QConstruct::As => b"qAS",
59            QConstruct::At => b"qAT",
60            QConstruct::Ai => b"qAI",
61            QConstruct::AoRf => b"qAo",
62            QConstruct::ArRf => b"qAr",
63            QConstruct::Az => b"qAZ",
64            QConstruct::Unknown(s) => s.as_bytes(),
65        }
66    }
67}
68
69impl fmt::Display for QConstruct {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "{}", String::from_utf8_lossy(self.as_bytes()))
72    }
73}
74
75/// One element of the APRS via path (the digipeater list).
76#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub enum Digipeater {
79    /// A callsign-based digipeater path element, with optional "has-been-heard" flag (`*`).
80    Callsign(Callsign, bool),
81    /// An APRS-IS Q-construct (e.g. `qAR,IGATE`).
82    QConstruct(QConstruct, Callsign),
83}
84
85impl Digipeater {
86    /// Parse one via element from its textual bytes (without the surrounding commas).
87    pub fn decode_textual(input: &[u8]) -> Result<Self, AprsError> {
88        // Q-constructs are `qA?` tokens (qAC, qAR, …); match the `qA` prefix so a
89        // genuine callsign isn't misclassified.
90        if input.starts_with(b"qA") {
91            // Format: qXX,IGATECALL — but in the via list each element is comma-split
92            // so a Q-construct is just the "qXX" token; the following callsign is
93            // the next element. We store them together for fidelity.
94            // In practice the APRS-IS format encodes them as separate comma elements,
95            // so this case handles a bare qXX token.
96            return Ok(Digipeater::QConstruct(
97                QConstruct::from_bytes(input),
98                Callsign::decode_textual(b"UNKNOWN").unwrap(), // placeholder; see parse_via
99            ));
100        }
101
102        // Strip the heard flag
103        let (call_bytes, heard) = if input.ends_with(b"*") {
104            (&input[..input.len() - 1], true)
105        } else {
106            (input, false)
107        };
108
109        let callsign = Callsign::decode_textual(call_bytes).map_err(|_| AprsError::InvalidVia {
110            raw: input.to_vec(),
111        })?;
112
113        Ok(Digipeater::Callsign(callsign, heard))
114    }
115
116    /// Write this digipeater element in textual APRS format.
117    pub fn encode_textual(&self, out: &mut Vec<u8>) {
118        match self {
119            Digipeater::Callsign(call, heard) => {
120                call.encode_textual(out);
121                if *heard {
122                    out.push(b'*');
123                }
124            }
125            Digipeater::QConstruct(q, gw) => {
126                out.extend_from_slice(q.as_bytes());
127                out.push(b',');
128                gw.encode_textual(out);
129            }
130        }
131    }
132}
133
134impl fmt::Display for Digipeater {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        match self {
137            Digipeater::Callsign(call, heard) => {
138                write!(f, "{call}")?;
139                if *heard {
140                    write!(f, "*")?;
141                }
142                Ok(())
143            }
144            Digipeater::QConstruct(q, gw) => write!(f, "{q},{gw}"),
145        }
146    }
147}
148
149/// Parse the full via list (the comma-separated portion between `>TO` and `:`).
150///
151/// Handles the APRS-IS Q-construct pairing: `qAR,IGATE` appears as two consecutive
152/// elements where the second is the gateway callsign.
153pub(crate) fn parse_via(bytes: &[u8]) -> Result<Vec<Digipeater>, AprsError> {
154    if bytes.is_empty() {
155        return Ok(Vec::new());
156    }
157
158    let mut result = Vec::new();
159    let mut iter = bytes.split(|&b| b == b',').peekable();
160
161    while let Some(element) = iter.next() {
162        if element.starts_with(b"qA") {
163            let q = QConstruct::from_bytes(element);
164            // The next element is the gateway callsign
165            let gw = if let Some(next) = iter.next() {
166                Callsign::decode_textual(next)
167                    .map_err(|_| AprsError::InvalidVia { raw: next.to_vec() })?
168            } else {
169                Callsign::decode_textual(b"UNKNOWN").unwrap()
170            };
171            result.push(Digipeater::QConstruct(q, gw));
172        } else {
173            result.push(Digipeater::decode_textual(element)?);
174        }
175    }
176
177    Ok(result)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn callsign_no_heard() {
186        let d = Digipeater::decode_textual(b"WIDE2-2").unwrap();
187        assert!(matches!(d, Digipeater::Callsign(_, false)));
188    }
189
190    #[test]
191    fn callsign_heard() {
192        let d = Digipeater::decode_textual(b"RELAY*").unwrap();
193        assert!(matches!(d, Digipeater::Callsign(_, true)));
194    }
195
196    #[test]
197    fn via_list_simple() {
198        let via = parse_via(b"WIDE1-1,WIDE2-2").unwrap();
199        assert_eq!(via.len(), 2);
200    }
201
202    #[test]
203    fn via_list_with_q_construct() {
204        let via = parse_via(b"RELAY*,qAR,KD9ABC").unwrap();
205        assert_eq!(via.len(), 2);
206        assert!(matches!(&via[1], Digipeater::QConstruct(QConstruct::Ar, _)));
207    }
208
209    #[test]
210    fn via_list_empty() {
211        let via = parse_via(b"").unwrap();
212        assert!(via.is_empty());
213    }
214
215    #[test]
216    fn encode_round_trip() {
217        let via = parse_via(b"WIDE1-1,RELAY*").unwrap();
218        let mut out = Vec::new();
219        for (i, d) in via.iter().enumerate() {
220            if i > 0 {
221                out.push(b',');
222            }
223            d.encode_textual(&mut out);
224        }
225        assert_eq!(out, b"WIDE1-1,RELAY*");
226    }
227}