Skip to main content

dshot_codec/
nrzi_frame.rs

1use core::ops::Deref;
2
3use super::{DshotError, DshotTelemetryFrame};
4
5/// **NRZI** stands for Non-Return-to-Zero, Inverted.
6/// 21-bit edge transition NRZI.
7/// Decodes to 20-bit binary GCR,
8/// which then decodes to 16-bit `DshotTelemetryFrame`.
9// See https://en.wikipedia.org/wiki/Run-length_limited#GCR:_(0,2)_RLL for details of the GCR encoding.
10#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, PartialOrd, Ord)]
11pub struct NrziFrame(u32);
12
13impl From<NrziFrame> for u32 {
14    #[inline]
15    fn from(frame: NrziFrame) -> Self {
16        frame.0
17    }
18}
19
20impl Deref for NrziFrame {
21    type Target = u32;
22
23    #[inline]
24    fn deref(&self) -> &Self::Target {
25        &self.0
26    }
27}
28
29impl NrziFrame {
30    // 5-bit GCR wire sequence mapped directly back to 4-bit nibbles.
31    // Invalid codes are marked with 255 (0xFF).
32    const QUINTET_TO_NIBBLE: [u16; 32] = [
33        255, 255, 255, 255, 255, 255, 255, 255, 255, 9, 10, 11, 255, 13, 14, 15, 255, 255, 2, 3, 255, 5, 6, 7, 255, 0,
34        8, 1, 255, 4, 12, 255,
35    ];
36
37    #[inline]
38    #[must_use]
39    pub const fn from_raw_21(raw_21: u32) -> Self {
40        // Mask explicitly to 21 bits (0x1F_FFFF) to preserve the full physical packet capacity
41        Self(raw_21 & 0x001F_FFFF)
42    }
43
44    #[inline]
45    #[must_use]
46    pub const fn raw_21(self) -> u32 {
47        self.0
48    }
49
50    /// Check if checksum is ok (XOR of all 4 nibbles must equal 0x0F).
51    /// Fast validation check on the raw telemetry framework layout.
52    #[inline]
53    #[must_use]
54    pub const fn is_valid(self) -> bool {
55        let value = self.0;
56        let checksum = (value ^ (value >> 4) ^ (value >> 8) ^ (value >> 12)) & 0x0F;
57        checksum == 0x0F
58    }
59
60    /// Converts the physical 21-bit NRZI transition buffer into a clean 20-bit GCR token.
61    /// Handles chronological line boundaries accurately by tracking state deltas.
62    #[inline]
63    #[must_use]
64    const fn nrzi21_to_gcr20(value: u32) -> u32 {
65        let mut gcr_output: u32 = 0;
66
67        // DShot telemetry packets are transmitted MSB-first.
68        // Bit 20 is the leading sync zero, establishing our initial wire level.
69        let mut previous_state = (value >> 20) & 0x01;
70
71        // Sequentially parse down through the remaining 20 bits of data payload
72        let mut ii = 19;
73        loop {
74            let current_state = (value >> ii) & 0x01;
75
76            // NRZI Decoder Core Rule: A state transition means 1, no change means 0.
77            let decoded_bit = current_state ^ previous_state;
78            gcr_output = (gcr_output << 1) | decoded_bit;
79
80            previous_state = current_state;
81
82            if ii == 0 {
83                break;
84            }
85            ii -= 1;
86        }
87
88        gcr_output
89    }
90
91    /// Maps the unified 20-bit GCR token into a standard 16-bit payload using the conversion matrix.
92    #[inline]
93    fn gcr20_to_erpm(gcr20: u32) -> Result<DshotTelemetryFrame, DshotError> {
94        let nibble0 = Self::QUINTET_TO_NIBBLE[(gcr20 & 0x1F) as usize];
95        let nibble1 = Self::QUINTET_TO_NIBBLE[((gcr20 >> 5) & 0x1F) as usize];
96        let nibble2 = Self::QUINTET_TO_NIBBLE[((gcr20 >> 10) & 0x1F) as usize];
97        let nibble3 = Self::QUINTET_TO_NIBBLE[((gcr20 >> 15) & 0x1F) as usize];
98
99        if nibble0 == 0xFF || nibble1 == 0xFF || nibble2 == 0xFF || nibble3 == 0xFF {
100            return Err(DshotError::InvalidNrziData);
101        }
102
103        let erpm_raw = nibble0 | (nibble1 << 4) | (nibble2 << 8) | (nibble3 << 12);
104        // `try_from` will fail if the checksum is invalid.
105        DshotTelemetryFrame::try_from(erpm_raw)
106    }
107
108    /// Public processing method to ingest raw incoming wire metrics.
109    /// # Errors
110    #[inline]
111    pub fn try_decode(self) -> Result<DshotTelemetryFrame, DshotError> {
112        // Optimization: Execute the fast raw validation check first.
113        // If line noise corrupted the layout, reject it immediately before calculating GCR lookups.
114        if !self.is_valid() {
115            return Err(DshotError::InvalidChecksum);
116        }
117
118        let gcr20 = Self::nrzi21_to_gcr20(self.0);
119        let erpm_telemetry_frame = Self::gcr20_to_erpm(gcr20)?;
120        if erpm_telemetry_frame.checksum_is_ok() {
121            Ok(erpm_telemetry_frame)
122        } else {
123            Err(DshotError::InvalidChecksum)
124        }
125    }
126}
127
128#[allow(unused)]
129impl NrziFrame {
130    const NRZI_BIT_LENGTHS: [u32; 17] = [0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5, 5, 5];
131    const NRZI_SET_BITS: [u32; 6] = [0b_00000, 0b_00001, 0b_00011, 0b_00111, 0b_01111, 0b_11111];
132
133    /// # Errors
134    #[inline]
135    fn nrzi21_to_erpm(nrzi21: u32) -> Result<DshotTelemetryFrame, DshotError> {
136        Self::gcr20_to_erpm(Self::nrzi21_to_gcr20(nrzi21))
137    }
138
139    /// Decode samples returned by Raspberry Pi PIO implementation.
140    /// 64-bit value gives 3x oversampling of NRZI21 code.
141    ///
142    /// Returns the value of the Extended Dshot Telemetry (EDT) frame (without the checksum).
143    /// # Errors `DshotError`
144    pub fn decode_samples(value: u64) -> Result<DshotTelemetryFrame, DshotError> {
145        // telemetry data must start with a 0, so if the first bit is high, we don't have any data
146        if (value & 0x8000_0000_0000_0000) != 0 {
147            return Err(DshotError::NoGcrData);
148        }
149
150        let mut consecutive_bit_count: usize = 1; // we always start with the MSB
151        let mut current_bit: u32 = 0;
152        let mut bit_count: u32 = 0;
153        let mut nrzi21_data: u32 = 0;
154
155        // starting at 2nd bit since we know our data starts with a 0
156        // 56 samples @ 0.917us sample rate = 51.33us sampled
157        // loop the mask from 2nd MSB to  LSB
158        let mut mask: u64 = 0x4000_0000_0000_0000;
159        while mask != 0 {
160            if ((value & mask) != 0) == (current_bit != 0) {
161                // if the masked bit match the current string of bits then increment consecutive_bit_count.
162                consecutive_bit_count += 1;
163                if consecutive_bit_count > 16 {
164                    // invalid run length at the current sample rate (outside of GCR_BIT_LENGTHS table)
165                    return Err(DshotError::InvalidRunLength);
166                }
167            } else {
168                // if the masked bit doesn't match the current string of bits then end the current string and flip current_bit
169                // bitshift gcr_result by N
170                nrzi21_data <<= Self::NRZI_BIT_LENGTHS[consecutive_bit_count];
171                // and then set N bits in gcr_result, if current_bit is 1
172                if current_bit != 0 {
173                    nrzi21_data |= Self::NRZI_SET_BITS[Self::NRZI_BIT_LENGTHS[consecutive_bit_count] as usize];
174                }
175                bit_count += Self::NRZI_BIT_LENGTHS[consecutive_bit_count];
176                // invert current_bit, and reset consecutive_bit_count
177                current_bit = !current_bit;
178                consecutive_bit_count = 1; // first bit found in the string is the one we just processed
179            }
180            mask >>= 1;
181        }
182
183        // outside the loop, we still need to account for the final bits if the string ends with 1s
184        // bitshift gcr_result by N, and
185        nrzi21_data <<= Self::NRZI_BIT_LENGTHS[consecutive_bit_count];
186        // then set set N bits in gcr_result, if current_bit is 1
187        if current_bit != 0 {
188            nrzi21_data |= Self::NRZI_SET_BITS[Self::NRZI_BIT_LENGTHS[consecutive_bit_count] as usize];
189        }
190        // count bit_count (for debugging)
191        bit_count += Self::NRZI_BIT_LENGTHS[consecutive_bit_count];
192
193        // NRZI data should be 21 bits
194        if bit_count < 21 {
195            return Err(DshotError::InvalidNrziData);
196        }
197
198        // chop the GCR data down to just the 21 most significant bits
199        nrzi21_data >>= bit_count - 21;
200
201        // convert 21-bit edge transition NRZI to 20-bit binary GCR
202        let erpm_telemetry_frame = Self::nrzi21_to_erpm(nrzi21_data)?;
203
204        Ok(erpm_telemetry_frame)
205    }
206}
207
208#[cfg(test)]
209mod test_traits {
210    use super::*;
211
212    fn is_full<T: Sized + Send + Sync + Unpin + Copy + Clone + Default + PartialEq>() {}
213
214    #[test]
215    fn normal_types() {
216        is_full::<NrziFrame>();
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn gcr_decode_rejects_invalid_input() {
226        // All zeros and all ones should fail
227        assert_eq!(Err(DshotError::InvalidChecksum), NrziFrame::from_raw_21(0).try_decode());
228        assert_eq!(Err(DshotError::InvalidChecksum), NrziFrame::from_raw_21(0x1FFFF).try_decode());
229    }
230    #[test]
231    fn valid() {
232        assert!(NrziFrame::from_raw_21(0xF000).is_valid()); // 0^0^0^F = F ✓
233        assert!(NrziFrame::from_raw_21(0x8421).is_valid()); // 1^2^4^8 = F ✓
234    }
235
236    #[test]
237    fn invalid() {
238        assert!(!NrziFrame::from_raw_21(0x1234).is_valid()); // 4^3^2^1 = 4 ✗
239        assert!(!NrziFrame::from_raw_21(0x0000).is_valid()); // 0^0^0^0 = 0 ✗
240    }
241}