aprs/
telemetry.rs

1//! APRS telemetry (APRS 1.0.1 ch. 13).
2
3use crate::error::AprsError;
4
5/// Parsed APRS telemetry report.
6///
7/// Format: `T#seq,val1,val2,val3,val4,val5,dddddddd`
8/// where vals are 0-999 analog values and d's are binary digits (8 bits).
9///
10/// Per APRS 1.0.1 Chapter 13, telemetry is used to transmit analog and
11/// digital sensor readings. Up to 5 analog channels are supported; each
12/// channel is stored as `Option<u16>` so callers can distinguish
13/// "channel not reported" from "channel reported as 0".
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct AprsTelemetry {
16    /// Telemetry sequence number (0-999 or "MIC").
17    pub sequence: String,
18    /// Analog values — exactly 5 channels per APRS 1.0.1 §13.1.
19    /// Channels omitted from the wire frame are `None`.
20    pub analog: [Option<u16>; 5],
21    /// Digital value (8 bits).
22    pub digital: u8,
23}
24
25/// Parse an APRS telemetry report (`T#seq,v1,v2,v3,v4,v5,dddddddd`).
26///
27/// Per APRS 1.0.1 §13.1 a telemetry frame has exactly 5 analog channels
28/// and 1 digital channel. We tolerate fewer analog channels (missing
29/// channels become `None`) but reject frames with more fields than the
30/// spec allows — those are almost certainly malformed.
31///
32/// # Errors
33///
34/// Returns [`AprsError::InvalidFormat`] for malformed input: missing
35/// `T#` prefix, non-integer analog values, non-binary digital digits,
36/// or more than 7 comma-separated fields.
37pub fn parse_aprs_telemetry(info: &[u8]) -> Result<AprsTelemetry, AprsError> {
38    // Minimum: T#seq,v (at least 5 bytes)
39    if info.first() != Some(&b'T') || info.get(1) != Some(&b'#') {
40        return Err(AprsError::InvalidFormat);
41    }
42
43    let body_bytes = info.get(2..).unwrap_or(&[]);
44    let body = String::from_utf8_lossy(body_bytes);
45    let parts: Vec<&str> = body.split(',').collect();
46    // Spec limit: sequence + 5 analog + 1 digital = 7 fields max.
47    if parts.is_empty() || parts.len() > 7 {
48        return Err(AprsError::InvalidFormat);
49    }
50
51    let sequence = parts.first().ok_or(AprsError::InvalidFormat)?.to_string();
52
53    // Parse analog values into a fixed-size [Option<u16>; 5].
54    let mut analog: [Option<u16>; 5] = [None, None, None, None, None];
55    let analog_end = std::cmp::min(parts.len(), 6); // indices 1..=5
56    let analog_parts = parts.get(1..analog_end).unwrap_or(&[]);
57    for (i, part) in analog_parts.iter().enumerate() {
58        let trimmed = part.trim();
59        if trimmed.is_empty() {
60            continue;
61        }
62        let val: u16 = trimmed.parse().map_err(|_| AprsError::InvalidFormat)?;
63        if let Some(slot) = analog.get_mut(i) {
64            *slot = Some(val);
65        }
66    }
67
68    // Parse digital value (8 binary digits) if present. Per APRS 1.0.1
69    // §13.1, the field is exactly 8 binary digits; malformed input is a
70    // parse error, not a silent zero.
71    let digital = if let Some(digi_raw) = parts.get(6) {
72        let digi_str = digi_raw.trim();
73        let digi_bits = digi_str.get(..8).unwrap_or(digi_str);
74        u8::from_str_radix(digi_bits, 2).map_err(|_| AprsError::InvalidFormat)?
75    } else {
76        0
77    };
78
79    Ok(AprsTelemetry {
80        sequence,
81        analog,
82        digital,
83    })
84}
85
86// ---------------------------------------------------------------------------
87// Tests
88// ---------------------------------------------------------------------------
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    type TestResult = Result<(), Box<dyn std::error::Error>>;
95
96    #[test]
97    fn parse_telemetry_full() -> TestResult {
98        let info = b"T#123,100,200,300,400,500,10101010";
99        let t = parse_aprs_telemetry(info)?;
100        assert_eq!(t.sequence, "123");
101        assert_eq!(
102            t.analog,
103            [Some(100), Some(200), Some(300), Some(400), Some(500)]
104        );
105        assert_eq!(t.digital, 0b1010_1010);
106        Ok(())
107    }
108
109    #[test]
110    fn parse_telemetry_mic_sequence() -> TestResult {
111        let info = b"T#MIC,001,002,003,004,005,11111111";
112        let t = parse_aprs_telemetry(info)?;
113        assert_eq!(t.sequence, "MIC");
114        assert_eq!(t.analog, [Some(1), Some(2), Some(3), Some(4), Some(5)]);
115        assert_eq!(t.digital, 0xFF);
116        Ok(())
117    }
118
119    #[test]
120    fn parse_telemetry_partial_analog() -> TestResult {
121        // Only 3 analog values, no digital.
122        let info = b"T#001,10,20,30";
123        let t = parse_aprs_telemetry(info)?;
124        assert_eq!(t.sequence, "001");
125        assert_eq!(t.analog, [Some(10), Some(20), Some(30), None, None]);
126        assert_eq!(t.digital, 0);
127        Ok(())
128    }
129
130    #[test]
131    fn parse_telemetry_zero_values() -> TestResult {
132        let info = b"T#000,0,0,0,0,0,00000000";
133        let t = parse_aprs_telemetry(info)?;
134        assert_eq!(t.sequence, "000");
135        assert_eq!(t.analog, [Some(0), Some(0), Some(0), Some(0), Some(0)]);
136        assert_eq!(t.digital, 0);
137        Ok(())
138    }
139
140    #[test]
141    fn parse_telemetry_rejects_too_many_fields() {
142        // 6 analog + 1 digital = 7 after the sequence = 8 fields total.
143        let info = b"T#001,1,2,3,4,5,6,00000000";
144        assert!(
145            matches!(parse_aprs_telemetry(info), Err(AprsError::InvalidFormat)),
146            "expected InvalidFormat for 8-field input",
147        );
148    }
149
150    #[test]
151    fn parse_telemetry_invalid_no_hash() {
152        let info = b"T123,1,2,3,4,5,00000000";
153        assert!(parse_aprs_telemetry(info).is_err(), "missing # rejected");
154    }
155
156    #[test]
157    fn parse_telemetry_invalid_digital_field_is_error() {
158        // Digital field must be exactly 8 binary digits — non-binary
159        // characters must fail parsing, not silently return 0.
160        let info = b"T#123,1,2,3,4,5,XXXXXXXX";
161        assert!(
162            matches!(parse_aprs_telemetry(info), Err(AprsError::InvalidFormat)),
163            "non-binary digital field must error",
164        );
165    }
166}