Skip to main content

dshot_codec/
dshot_telemetry_frame.rs

1use core::ops::Deref;
2
3use super::{DshotError, Telemetry};
4
5/// `DshotTelemetryFrame`: transmitted from the ESC to the Flight Controller (FC).
6///
7/// If Bidirectional `Dshot` is enabled and the Flight Controller sends a `DshotCommandFrame`
8/// with the telemetry request bit set, the FC shifts its signal pin to an input right after
9/// transmission finishes. The ESC then responds with a `DshotTelemetryFrame`.
10///
11/// This frame can be interpreted either as an `eRPM` (Electronic RPM) value, or an `EDT`
12/// (Extended Dshot Telemetry) sensor payload.
13///
14/// ## `eRPM` Interpretation
15/// When parsed as an `eRPM` frame, the 16-bit word uses the layout:
16///
17/// ```text
18/// eeem mmmm mmmm cccc
19/// ```
20/// * `e`: 3-bit exponent
21/// * `m`: 9-bit mantissa
22/// * `c`: 4-bit inverted XOR checksum
23///
24/// The 9-bit mantissa value (M) is shifted left by the exponent (E) to calculate the core
25/// commutation period in microseconds (M << E). This yields a period range of 1 µs to
26/// 65,408 µs, translating to a minimum e-frequency of 15.29 Hz (for a standard 14-pole motor
27/// with 7 pole-pairs, this represents a mechanical rotation frequency of 2.18 Hz).
28///
29/// ### `EDT` Interpretation
30/// When parsed as an `EDT` frame, the 16-bit word uses the layout:
31///
32/// ```text
33/// ttt0 dddd dddd cccc
34/// ```
35/// * `t`: 3-bit data type identifier (ie, 1 for Temperature, 2 for Voltage, etc)
36/// * `0`: Static zero bit (forces the 4-bit prefix to evaluate as an even number)
37/// * `d`: 8-bit sensor data payload
38/// * `c`: 4-bit inverted XOR checksum
39///
40/// ## Differentiating Frames via the Prefix
41/// The framework determines whether a packet represents an `eRPM` or `EDT` sequence by
42/// inspecting the highest 4 bits of the 16-bit word (bits 12–15), known as the `prefix`:
43///
44/// ```text
45/// pppp xxxx xxxx cccc
46/// ```
47/// This strategy capitalizes on GCR encoding redundancies, where a given commutation period
48/// can mathematically be written in multiple ways. To prevent collisions, the ESC normalizes
49/// `eRPM` values to guarantee an odd or zero prefix pattern.
50///
51/// * **It is an `eRPM` frame if:** The lower bit of the prefix is 1 (odd prefix), or the prefix evaluates to 0.
52/// * **It is an `EDT` frame if:** The prefix evaluates to a non-zero, even number (meaning its lower bit is 0).
53#[derive(Debug, Copy, Clone, Eq, PartialEq, PartialOrd, Ord)]
54pub struct DshotTelemetryFrame(u16);
55
56impl Default for DshotTelemetryFrame {
57    fn default() -> Self {
58        Self::from_raw_12(0)
59    }
60}
61
62impl TryFrom<u16> for DshotTelemetryFrame {
63    type Error = DshotError;
64
65    #[inline]
66    fn try_from(raw_16: u16) -> Result<Self, DshotError> {
67        Self::try_from_raw_16(raw_16)
68    }
69}
70
71impl From<DshotTelemetryFrame> for u16 {
72    #[inline]
73    fn from(frame: DshotTelemetryFrame) -> Self {
74        frame.raw_16()
75    }
76}
77
78impl Deref for DshotTelemetryFrame {
79    type Target = u16;
80
81    #[inline]
82    fn deref(&self) -> &Self::Target {
83        &self.0
84    }
85}
86
87impl DshotTelemetryFrame {
88    // Layout bitmasks matching the 16-bit word format: [eee mmmmmmmmm cccc]
89    const CHECKSUM_BITS: u16 = 0x000F;
90    const MANTISSA_BITS: u16 = 0x1FF0; // Bits 4 through 12
91    const EXPONENT_BITS: u16 = 0xE000; // Bits 13 through 15 (Top 3 bits are Exponent)
92    const ONE_MINUTE_IN_MICROSECONDS: u32 = 60_000_000;
93    const ONE_MINUTE_IN_MICROSECONDS_F32: f32 = 60_000_000.0;
94
95    #[inline]
96    #[must_use]
97    pub const fn from_raw_12(raw_12: u16) -> Self {
98        Self((raw_12 << 4) | Self::calculate_checksum(raw_12))
99    }
100
101    /// # Errors
102    pub fn try_from_raw_16(raw_16: u16) -> Result<Self, DshotError> {
103        let ret = Self(raw_16);
104        if ret.checksum_is_ok() {
105            Ok(ret)
106        } else {
107            Err(DshotError::InvalidChecksum)
108        }
109    }
110
111    #[inline]
112    #[must_use]
113    pub const fn raw_16(self) -> u16 {
114        self.0
115    }
116
117    #[must_use]
118    pub const fn calculate_checksum(raw_12: u16) -> u16 {
119        (!(raw_12 ^ (raw_12 >> 4) ^ (raw_12 >> 8))) & 0x0F
120    }
121
122    #[inline]
123    #[must_use]
124    pub const fn checksum(self) -> u16 {
125        self.0 & Self::CHECKSUM_BITS
126    }
127
128    /// Check if checksum is ok (XOR of all 4 nibbles must equal 0x0F).
129    #[inline]
130    #[must_use]
131    pub const fn checksum_is_ok(self) -> bool {
132        let checksum = (self.0 ^ (self.0 >> 4) ^ (self.0 >> 8) ^ (self.0 >> 12)) & 0x0F;
133        checksum == 0x0F
134    }
135
136    #[inline]
137    #[must_use]
138    pub const fn from_exponent_mantissa(exponent: u16, mantissa: u16) -> Self {
139        // Exponent is shifted up past the 9-bit mantissa block
140        let raw_12 = ((exponent & 0x07) << 9) | (mantissa & 0x01FF);
141        Self::from_raw_12(raw_12)
142    }
143
144    #[inline]
145    #[must_use]
146    pub fn from_type_value(data_type: u8, value: u8) -> Self {
147        let raw_12 = (u16::from(data_type & 0x07) << 9) | u16::from(value);
148        Self::from_raw_12(raw_12)
149    }
150
151    #[inline]
152    #[must_use]
153    pub const fn mantissa(self) -> u16 {
154        (self.0 & Self::MANTISSA_BITS) >> 4
155    }
156
157    #[inline]
158    #[must_use]
159    pub const fn exponent(self) -> u16 {
160        (self.0 & Self::EXPONENT_BITS) >> 13
161    }
162
163    #[inline]
164    #[must_use]
165    fn period_us(self) -> u32 {
166        u32::from(self.mantissa()) << self.exponent()
167    }
168
169    #[inline]
170    #[must_use]
171    pub fn erpm(self) -> u32 {
172        let raw_12 = (self.0 >> 4) & 0x0FFF;
173        // Edge cases: if raw payload is 0 or maxed out, motor is stopped or invalid
174        if raw_12 == 0 || raw_12 == 0x0FFF {
175            return 0;
176        }
177
178        let period = self.period_us();
179        if period == 0 {
180            return 0;
181        }
182        Self::ONE_MINUTE_IN_MICROSECONDS / period
183    }
184
185    #[inline]
186    #[must_use]
187    pub fn erpm_f32(self) -> f32 {
188        let raw_12 = (self.0 >> 4) & 0x0FFF;
189        // Edge cases: if raw payload is 0 or maxed out, motor is stopped or invalid
190        if raw_12 == 0 || raw_12 == 0x0FFF {
191            return 0.0;
192        }
193
194        let period = self.period_us();
195        if period == 0 {
196            return 0.0;
197        }
198        #[allow(clippy::cast_precision_loss)]
199        {
200            Self::ONE_MINUTE_IN_MICROSECONDS_F32 / (period as f32)
201        }
202    }
203
204    /// Helper method to safely identify whether the frame contains `eRPM` or `EDT` data.
205    #[inline]
206    #[must_use]
207    pub const fn is_erpm_frame(self) -> bool {
208        let raw_12 = (self.0 >> 4) & 0x0FFF;
209        let prefix = (raw_12 >> 8) & 0x0F;
210        (prefix == 0) || ((prefix & 0x01) != 0)
211    }
212
213    /// # Errors
214    #[inline]
215    pub fn try_decode_erpm(self) -> Result<u32, DshotError> {
216        if self.is_erpm_frame() {
217            Ok(self.erpm())
218        } else {
219            Err(DshotError::InvalidErpm)
220        }
221    }
222
223    /// # Errors
224    #[inline]
225    pub fn try_decode_telemetry(self) -> Result<Telemetry, DshotError> {
226        if self.is_erpm_frame() {
227            return Ok(Telemetry::Erpm(self.erpm()));
228        }
229
230        // `EDT` Escape Mode Processing
231        let raw_12 = (self.0 >> 4) & 0x0FFF;
232        let prefix = (raw_12 >> 8) & 0x0F;
233
234        let data_type = prefix >> 1;
235        let data = (raw_12 & 0xFF) as u8;
236
237        match data_type {
238            1 => Ok(Telemetry::Temperature(data)),
239            2 => Ok(Telemetry::Voltage(u32::from(data) * 250)),
240            3 => Ok(Telemetry::Current(u32::from(data) * 1000)),
241            4 => Ok(Telemetry::Debug1(data)),
242            5 => Ok(Telemetry::Debug2(data)),
243            6 => Ok(Telemetry::Debug3(data)),
244            7 => Ok(Telemetry::StateEvent(data)),
245            _ => Err(DshotError::InvalidTelemetry),
246        }
247    }
248}
249
250#[cfg(test)]
251mod test_traits {
252    use super::*;
253
254    fn is_full<T: Sized + Send + Sync + Unpin + Copy + Clone + Default + PartialEq>() {}
255
256    #[test]
257    fn normal_types() {
258        is_full::<DshotTelemetryFrame>();
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    #![allow(clippy::unwrap_used)]
265    use super::*;
266
267    #[test]
268    fn test_default_constructor() {
269        let frame = DshotTelemetryFrame::default();
270        assert_eq!(frame.raw_16(), 0x000F); // raw_12 = 0, checksum = 0x0F
271        assert!(frame.checksum_is_ok());
272        assert_eq!(frame.erpm(), 0);
273    }
274
275    #[test]
276    fn test_from_exponent_mantissa_packing() {
277        // Create an `eRPM` frame with:
278        // exponent = 2, mantissa = 0x5A (90 decimal)
279        // raw_12 = (2 << 9) | 90 = 1024 | 90 = 1114 = 0x45A
280        // expected checksum: !(0x4 ^ 0x5 ^ 0xA) & 0x0F = !0xB & 0x0F = 0x4
281        // raw_16 = (0x45A << 4) | 0x4 = 0x45A4
282        let frame = DshotTelemetryFrame::from_exponent_mantissa(2, 0x5A);
283
284        assert_eq!(frame.raw_16(), 0x45A4);
285        assert!(frame.checksum_is_ok());
286        assert_eq!(frame.exponent(), 2);
287        assert_eq!(frame.mantissa(), 0x5A);
288    }
289
290    #[test]
291    fn test_erpm_calculation_active_motor() {
292        // Using exponent = 1, mantissa = 300
293        // (Note: Mantissa must be >= 256 so its MSB sets the prefix to an odd number for `EDT` compatibility)
294        // raw_12 = (1 << 9) | 300 = 512 + 300 = 812 = 0x32C
295        // expected checksum: !(0x3 ^ 0x2 ^ 0xC) & 0x0F = !0xD & 0x0F = 0x2
296        // raw_16 = (0x32C << 4) | 0x2 = 0x32C2
297        let frame = DshotTelemetryFrame::from_exponent_mantissa(1, 300);
298
299        assert_eq!(frame.raw_16(), 0x32C2);
300        assert!(frame.checksum_is_ok());
301        assert!(frame.is_erpm_frame(), "Expected 0x32C to resolve as a valid eRPM prefix");
302
303        // period_us = 300 << 1 = 600 microseconds
304        // `eRPM` = 60_000_000 / 600 = 100_000 `eRPM`
305        assert_eq!(frame.erpm(), 100_000);
306
307        let decode_res = frame.try_decode_erpm();
308        assert_eq!(decode_res.unwrap(), 100_000);
309    }
310
311    #[test]
312    fn test_erpm_edge_cases_zero_and_max() {
313        // Case 1: Zero payload (stopped motor)
314        let zero_frame = DshotTelemetryFrame::from_raw_12(0);
315        assert_eq!(zero_frame.erpm(), 0);
316        assert_eq!(zero_frame.try_decode_erpm().unwrap(), 0);
317
318        // Case 2: Maximum payload 0x0FFF (often indicates timeout or bad value)
319        let max_frame = DshotTelemetryFrame::from_raw_12(0x0FFF);
320        assert_eq!(max_frame.erpm(), 0);
321        assert_eq!(max_frame.try_decode_erpm().unwrap(), 0);
322    }
323
324    #[test]
325    fn test_edt_temperature_decoding() {
326        // data_type = 1 (Temperature)
327        // value = 85 (representing 85 degrees Celsius)
328        // raw_12 = (1 << 9) | 85 = 512 | 85 = 597 = 0x255
329        let frame = DshotTelemetryFrame::from_type_value(1, 85);
330
331        assert!(!frame.is_erpm_frame());
332        assert!(frame.try_decode_erpm().is_err());
333
334        let telemetry = frame.try_decode_telemetry().unwrap();
335        assert_eq!(telemetry, Telemetry::Temperature(85));
336    }
337
338    #[test]
339    fn temperature() {
340        let frame = DshotTelemetryFrame::from_type_value(1, 25);
341        assert_eq!(frame.try_decode_telemetry(), Ok(Telemetry::Temperature(25)));
342        let frame = DshotTelemetryFrame::from_type_value(1, 100);
343        assert_eq!(frame.try_decode_telemetry(), Ok(Telemetry::Temperature(100)));
344        let frame = DshotTelemetryFrame::from_type_value(1, 255);
345        assert_eq!(frame.try_decode_telemetry(), Ok(Telemetry::Temperature(255)));
346    }
347    #[test]
348    fn test_edt_voltage_decoding() {
349        // data_type = 2 (Voltage)
350        // value = 64 (representing 64 * 250mV = 16,000mV = 16.0V)
351        // raw_12 = (2 << 9) | 64 = 1024 | 64 = 1088 = 0x440
352        let frame = DshotTelemetryFrame::from_type_value(2, 64);
353
354        let telemetry = frame.try_decode_telemetry().unwrap();
355        assert_eq!(telemetry, Telemetry::Voltage(16_000));
356    }
357
358    #[test]
359    fn test_edt_current_decoding() {
360        // data_type = 3 (Current)
361        // value = 25 (representing 25 * 1000mA = 25,000mA = 25A)
362        // raw_12 = (3 << 9) | 25 = 1536 | 25 = 1561 = 0x619
363        let frame = DshotTelemetryFrame::from_type_value(3, 25);
364
365        let telemetry = frame.try_decode_telemetry().unwrap();
366        assert_eq!(telemetry, Telemetry::Current(25_000));
367    }
368
369    #[test]
370    fn test_edt_debug_and_state_events() {
371        // Test data_type 4 (Debug1)
372        let d1_frame = DshotTelemetryFrame::from_type_value(4, 42);
373        assert_eq!(d1_frame.try_decode_telemetry().unwrap(), Telemetry::Debug1(42));
374
375        // Test data_type 7 (StateEvent)
376        let se_frame = DshotTelemetryFrame::from_type_value(7, 3);
377        assert_eq!(se_frame.try_decode_telemetry().unwrap(), Telemetry::StateEvent(3));
378    }
379
380    #[test]
381    fn test_invalid_checksum_rejection() {
382        // Take a valid frame structure (0x45A4) and corrupt the lowest nibble checksum bits
383        let corrupted_raw = 0x45A0;
384        let frame_res = DshotTelemetryFrame::try_from_raw_16(corrupted_raw);
385
386        assert!(frame_res.is_err(), "Expected constructor to reject invalid checksum structures");
387    }
388}