Skip to main content

aprs_decode/
telemetry.rs

1//! APRS telemetry report parsing and encoding.
2//!
3//! # Data packet
4//! Format: `T#SSS,V1,V2,V3,V4,V5,BBBBBBBB[,comment]`
5//!
6//! # Metadata (sent as messages to a specific station)
7//! - `PARM.n1,n2,...` — parameter names (5 analog + up to 8 digital)
8//! - `UNIT.u1,u2,...` — unit labels
9//! - `EQNS.a1,b1,c1,a2,...` — equation coefficients for linear conversion
10//! - `BITS.bbbbbbbbb,project` — bit sense flags + project name
11
12use crate::error::AprsError;
13
14// ─── AprsTelemetry ───────────────────────────────────────────────────────────
15
16/// An APRS telemetry data packet.
17///
18/// DTI: `T` (followed by `#`)
19///
20/// The APRS spec defines exactly 5 analog channels and 8 digital bits.
21/// Using fixed-size arrays avoids heap allocation and makes the type encoding
22/// match the protocol constraint.
23#[derive(Debug, Clone, PartialEq)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub struct AprsTelemetry {
26    /// Sequence number (000–999, or MIC-E style alphanumeric).
27    pub sequence: Vec<u8>,
28    /// Five analog channel values. `None` means the field was absent or unparseable.
29    pub analog: [Option<f32>; 5],
30    /// Eight digital channel bits packed into a byte (bit 7 = channel 1).
31    pub digital: u8,
32    pub comment: Vec<u8>,
33}
34
35impl AprsTelemetry {
36    /// Decode from the information field (including the leading `T` DTI byte).
37    pub(crate) fn parse(info: &[u8]) -> Result<Self, AprsError> {
38        // info[0] = 'T', info[1] must be '#'
39        if info.len() < 2 || info[1] != b'#' {
40            return Err(AprsError::TruncatedPacket {
41                expected: 2,
42                got: info.len(),
43            });
44        }
45        let body = &info[2..]; // skip "T#"
46
47        // Split by commas: [seq, v1, v2, v3, v4, v5, bits, ...comment]
48        let parts: Vec<&[u8]> = body.split(|&c| c == b',').collect();
49
50        let sequence = parts.first().unwrap_or(&b"".as_slice()).to_vec();
51
52        let mut analog = [None; 5];
53        for (i, slot) in analog.iter_mut().enumerate() {
54            if let Some(part) = parts.get(i + 1) {
55                *slot = std::str::from_utf8(part)
56                    .ok()
57                    .and_then(|s| s.trim().parse::<f32>().ok());
58            }
59        }
60
61        let digital = parts
62            .get(6)
63            .and_then(|part| {
64                if part.len() >= 8 && part[..8].iter().all(|&c| c == b'0' || c == b'1') {
65                    let mut val = 0u8;
66                    for &bit in &part[..8] {
67                        val = (val << 1) | (bit - b'0');
68                    }
69                    Some(val)
70                } else {
71                    None
72                }
73            })
74            .unwrap_or(0);
75
76        let comment = if parts.len() > 7 {
77            let mut c = Vec::new();
78            for (i, part) in parts[7..].iter().enumerate() {
79                if i > 0 {
80                    c.push(b',');
81                }
82                c.extend_from_slice(part);
83            }
84            c
85        } else {
86            vec![]
87        };
88
89        Ok(Self {
90            sequence,
91            analog,
92            digital,
93            comment,
94        })
95    }
96
97    pub fn encode(&self) -> Vec<u8> {
98        let mut out = b"T#".to_vec();
99        out.extend_from_slice(&self.sequence);
100        for val in &self.analog {
101            out.push(b',');
102            if let Some(v) = val {
103                // Prefer integer formatting when the value is a whole number
104                if *v == v.trunc() && v.is_finite() {
105                    out.extend_from_slice(format!("{}", *v as i64).as_bytes());
106                } else {
107                    out.extend_from_slice(format!("{}", v).as_bytes());
108                }
109            }
110        }
111        out.push(b',');
112        for i in (0..8).rev() {
113            out.push(b'0' + ((self.digital >> i) & 1));
114        }
115        if !self.comment.is_empty() {
116            out.push(b',');
117            out.extend_from_slice(&self.comment);
118        }
119        out
120    }
121}
122
123// ─── TelemetryMetadata ───────────────────────────────────────────────────────
124
125/// Equation coefficients for one analog channel: `value = a + b*raw + c*raw²`.
126#[derive(Debug, Clone, PartialEq)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128pub struct TelemetryEquation {
129    pub a: f32,
130    pub b: f32,
131    pub c: f32,
132}
133
134impl TelemetryEquation {
135    /// Apply the equation to a raw ADC value.
136    pub fn apply(&self, raw: f32) -> f32 {
137        self.a + self.b * raw + self.c * raw * raw
138    }
139}
140
141/// Telemetry metadata assembled from PARM./UNIT./EQNS./BITS. message texts.
142///
143/// Each field corresponds to one of the four message types that APRS uses to
144/// describe a station's telemetry channels. They are sent as directed messages
145/// to the station whose telemetry they describe.
146#[derive(Debug, Clone, PartialEq, Default)]
147#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
148pub struct TelemetryMetadata {
149    /// Channel names: up to 5 analog + up to 8 digital = 13 entries.
150    pub param_names: Vec<Option<Vec<u8>>>,
151    /// Unit labels: same layout as param_names.
152    pub unit_labels: Vec<Option<Vec<u8>>>,
153    /// Equation coefficients for each of the 5 analog channels.
154    pub equations: Vec<TelemetryEquation>,
155    /// Bit sense flags: bit N (MSB first) is `true` if a `1` means "on".
156    pub bit_sense: u8,
157    /// Project name (from BITS. message, after the sense flags).
158    pub project_name: Vec<u8>,
159}
160
161impl TelemetryMetadata {
162    /// Parse the body of a `PARM.` message (text after the `PARM.` prefix).
163    ///
164    /// Returns up to 13 comma-separated names (5 analog + 8 digital).
165    pub fn parse_parm(text: &[u8]) -> Vec<Option<Vec<u8>>> {
166        parse_csv_fields(text, 13)
167    }
168
169    /// Parse the body of a `UNIT.` message.
170    pub fn parse_unit(text: &[u8]) -> Vec<Option<Vec<u8>>> {
171        parse_csv_fields(text, 13)
172    }
173
174    /// Parse the body of an `EQNS.` message.
175    ///
176    /// Format: `a1,b1,c1,a2,b2,c2,...` (5 triples, 15 values total).
177    pub fn parse_eqns(text: &[u8]) -> Vec<TelemetryEquation> {
178        let parts: Vec<&[u8]> = text.split(|&c| c == b',').collect();
179        let mut result = Vec::with_capacity(5);
180        for i in 0..5 {
181            let a = parts.get(i * 3).and_then(|p| parse_f32(p)).unwrap_or(0.0);
182            let b = parts
183                .get(i * 3 + 1)
184                .and_then(|p| parse_f32(p))
185                .unwrap_or(1.0);
186            let c = parts
187                .get(i * 3 + 2)
188                .and_then(|p| parse_f32(p))
189                .unwrap_or(0.0);
190            result.push(TelemetryEquation { a, b, c });
191        }
192        result
193    }
194
195    /// Parse the body of a `BITS.` message.
196    ///
197    /// Format: `BBBBBBBB,Project Name` where B is `0` or `1` (MSB = channel 1).
198    pub fn parse_bits(text: &[u8]) -> (u8, Vec<u8>) {
199        let comma = text.iter().position(|&b| b == b',');
200        let sense_bytes = match comma {
201            Some(pos) => &text[..pos],
202            None => text,
203        };
204        let project = match comma {
205            Some(pos) => text.get(pos + 1..).unwrap_or_default().to_vec(),
206            None => vec![],
207        };
208        let mut sense = 0u8;
209        for (i, &b) in sense_bytes.iter().enumerate().take(8) {
210            if b == b'1' {
211                sense |= 0x80 >> i;
212            }
213        }
214        (sense, project)
215    }
216}
217
218// ─── Helpers ─────────────────────────────────────────────────────────────────
219
220fn parse_csv_fields(text: &[u8], max: usize) -> Vec<Option<Vec<u8>>> {
221    text.split(|&c| c == b',')
222        .take(max)
223        .map(|part| {
224            let trimmed: Vec<u8> = part.iter().copied().skip_while(|&b| b == b' ').collect();
225            if trimmed.is_empty() {
226                None
227            } else {
228                Some(trimmed)
229            }
230        })
231        .collect()
232}
233
234fn parse_f32(b: &[u8]) -> Option<f32> {
235    std::str::from_utf8(b).ok()?.trim().parse().ok()
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn parse_basic_telemetry() {
244        let t = AprsTelemetry::parse(b"T#001,100,200,300,400,500,10101010").unwrap();
245        assert_eq!(t.sequence, b"001");
246        assert_eq!(t.analog[0], Some(100.0));
247        assert_eq!(t.analog[4], Some(500.0));
248        assert_eq!(t.digital, 0b10101010);
249        assert!(t.comment.is_empty());
250    }
251
252    #[test]
253    fn parse_telemetry_with_comment() {
254        let t = AprsTelemetry::parse(b"T#001,100,200,300,400,500,11110000,Hello World").unwrap();
255        assert_eq!(t.digital, 0b11110000);
256        assert_eq!(t.comment, b"Hello World");
257    }
258
259    #[test]
260    fn encode_round_trip() {
261        let raw = b"T#001,100,200,300,400,500,10101010,Test";
262        let t = AprsTelemetry::parse(raw).unwrap();
263        assert_eq!(t.encode().as_slice(), raw.as_slice());
264    }
265
266    #[test]
267    fn parse_parm_names() {
268        let names = TelemetryMetadata::parse_parm(b"Bat1,Bat2,Temp,Hum,Pres,LED1,LED2");
269        assert_eq!(names[0].as_deref(), Some(b"Bat1".as_slice()));
270        assert_eq!(names[4].as_deref(), Some(b"Pres".as_slice()));
271        assert_eq!(names[5].as_deref(), Some(b"LED1".as_slice()));
272    }
273
274    #[test]
275    fn parse_eqns() {
276        let eqns = TelemetryMetadata::parse_eqns(b"0,0.01,0,0,0.01,0,0,1,0,0,1,0,0,1,0");
277        assert_eq!(eqns.len(), 5);
278        assert!((eqns[0].b - 0.01).abs() < 0.001);
279        assert!((eqns[0].c).abs() < 0.001);
280    }
281
282    #[test]
283    fn equation_apply() {
284        let eq = TelemetryEquation {
285            a: 0.0,
286            b: 0.01,
287            c: 0.0,
288        };
289        assert!((eq.apply(100.0) - 1.0).abs() < 0.001);
290    }
291
292    #[test]
293    fn parse_bits() {
294        let (sense, project) = TelemetryMetadata::parse_bits(b"11111111,My Station");
295        assert_eq!(sense, 0xFF);
296        assert_eq!(project, b"My Station");
297    }
298
299    #[test]
300    fn parse_bits_mixed() {
301        let (sense, _) = TelemetryMetadata::parse_bits(b"10110100,Test");
302        assert_eq!(sense, 0b10110100);
303    }
304
305    #[test]
306    fn missing_digital_bits_defaults_to_zero() {
307        // If the 8th field is missing or not binary, default digital to 0
308        let t = AprsTelemetry::parse(b"T#001,100,200,300,400,500").unwrap();
309        assert_eq!(t.digital, 0);
310    }
311}