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