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 start with lowercase 'q'
89        if input.starts_with(b"q") {
90            // Format: qXX,IGATECALL — but in the via list each element is comma-split
91            // so a Q-construct is just the "qXX" token; the following callsign is
92            // the next element. We store them together for fidelity.
93            // In practice the APRS-IS format encodes them as separate comma elements,
94            // so this case handles a bare qXX token.
95            return Ok(Digipeater::QConstruct(
96                QConstruct::from_bytes(input),
97                Callsign::decode_textual(b"UNKNOWN").unwrap(), // placeholder; see parse_via
98            ));
99        }
100
101        // Strip the heard flag
102        let (call_bytes, heard) = if input.ends_with(b"*") {
103            (&input[..input.len() - 1], true)
104        } else {
105            (input, false)
106        };
107
108        let callsign = Callsign::decode_textual(call_bytes)
109            .map_err(|_| AprsError::InvalidVia { raw: input.to_vec() })?;
110
111        Ok(Digipeater::Callsign(callsign, heard))
112    }
113
114    /// Write this digipeater element in textual APRS format.
115    pub fn encode_textual(&self, out: &mut Vec<u8>) {
116        match self {
117            Digipeater::Callsign(call, heard) => {
118                call.encode_textual(out);
119                if *heard {
120                    out.push(b'*');
121                }
122            }
123            Digipeater::QConstruct(q, gw) => {
124                out.extend_from_slice(q.as_bytes());
125                out.push(b',');
126                gw.encode_textual(out);
127            }
128        }
129    }
130}
131
132impl fmt::Display for Digipeater {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Digipeater::Callsign(call, heard) => {
136                write!(f, "{call}")?;
137                if *heard {
138                    write!(f, "*")?;
139                }
140                Ok(())
141            }
142            Digipeater::QConstruct(q, gw) => write!(f, "{q},{gw}"),
143        }
144    }
145}
146
147/// Parse the full via list (the comma-separated portion between `>TO` and `:`).
148///
149/// Handles the APRS-IS Q-construct pairing: `qAR,IGATE` appears as two consecutive
150/// elements where the second is the gateway callsign.
151pub(crate) fn parse_via(bytes: &[u8]) -> Result<Vec<Digipeater>, AprsError> {
152    if bytes.is_empty() {
153        return Ok(Vec::new());
154    }
155
156    let mut result = Vec::new();
157    let mut iter = bytes.split(|&b| b == b',').peekable();
158
159    while let Some(element) = iter.next() {
160        if element.starts_with(b"q") {
161            let q = QConstruct::from_bytes(element);
162            // The next element is the gateway callsign
163            let gw = if let Some(next) = iter.next() {
164                Callsign::decode_textual(next)
165                    .map_err(|_| AprsError::InvalidVia { raw: next.to_vec() })?
166            } else {
167                Callsign::decode_textual(b"UNKNOWN").unwrap()
168            };
169            result.push(Digipeater::QConstruct(q, gw));
170        } else {
171            result.push(Digipeater::decode_textual(element)?);
172        }
173    }
174
175    Ok(result)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn callsign_no_heard() {
184        let d = Digipeater::decode_textual(b"WIDE2-2").unwrap();
185        assert!(matches!(d, Digipeater::Callsign(_, false)));
186    }
187
188    #[test]
189    fn callsign_heard() {
190        let d = Digipeater::decode_textual(b"RELAY*").unwrap();
191        assert!(matches!(d, Digipeater::Callsign(_, true)));
192    }
193
194    #[test]
195    fn via_list_simple() {
196        let via = parse_via(b"WIDE1-1,WIDE2-2").unwrap();
197        assert_eq!(via.len(), 2);
198    }
199
200    #[test]
201    fn via_list_with_q_construct() {
202        let via = parse_via(b"RELAY*,qAR,KD9ABC").unwrap();
203        assert_eq!(via.len(), 2);
204        assert!(matches!(&via[1], Digipeater::QConstruct(QConstruct::Ar, _)));
205    }
206
207    #[test]
208    fn via_list_empty() {
209        let via = parse_via(b"").unwrap();
210        assert!(via.is_empty());
211    }
212
213    #[test]
214    fn encode_round_trip() {
215        let via = parse_via(b"WIDE1-1,RELAY*").unwrap();
216        let mut out = Vec::new();
217        for (i, d) in via.iter().enumerate() {
218            if i > 0 {
219                out.push(b',');
220            }
221            d.encode_textual(&mut out);
222        }
223        assert_eq!(out, b"WIDE1-1,RELAY*");
224    }
225}