dstar_gateway_core/slowdata/
assembler.rs

1//! Stateful slow data block assembler.
2//!
3//! Accumulates 3-byte slow-data fragments across consecutive voice
4//! frames into complete typed blocks. The assembler descrambles each
5//! incoming fragment, then decodes the assembled payload into a
6//! [`SlowDataBlock`] based on the type byte's high nibble.
7
8use crate::header::{DStarHeader, ENCODED_LEN};
9
10use super::block::{SlowDataBlock, SlowDataBlockKind, SlowDataText};
11use super::scrambler::descramble;
12
13/// Maximum scratch size — slow data blocks are at most ~20 bytes,
14/// and we need headroom so that a 3-byte append on a nearly-full
15/// scratch buffer can be guarded cleanly.
16const SCRATCH_SIZE: usize = 48;
17
18/// Stateful slow data accumulator.
19///
20/// Feed 3-byte fragments via [`Self::push`]. Returns `Some(block)`
21/// when a complete block has assembled; returns `None` otherwise.
22///
23/// Internally holds at most one in-progress block (`SCRATCH_SIZE`
24/// bytes of scratch).
25#[derive(Debug)]
26pub struct SlowDataAssembler {
27    scratch: [u8; SCRATCH_SIZE],
28    cursor: usize,
29    type_byte: Option<u8>,
30    expected_len: Option<usize>,
31}
32
33impl Default for SlowDataAssembler {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl SlowDataAssembler {
40    /// Create a new, empty assembler.
41    #[must_use]
42    pub const fn new() -> Self {
43        Self {
44            scratch: [0u8; SCRATCH_SIZE],
45            cursor: 0,
46            type_byte: None,
47            expected_len: None,
48        }
49    }
50
51    /// Feed a single voice frame's 3-byte slow data into the assembler.
52    ///
53    /// Returns `Some(block)` when a complete block has assembled,
54    /// `None` otherwise. Resets internal state on completion (or
55    /// on overflow, silently dropping any partial block).
56    pub fn push(&mut self, fragment: [u8; 3]) -> Option<SlowDataBlock> {
57        let descrambled = descramble(fragment);
58
59        // Append the 3 bytes to scratch, guarding against overflow.
60        for &byte in &descrambled {
61            if self.cursor >= SCRATCH_SIZE {
62                self.reset();
63                return None;
64            }
65            if let Some(slot) = self.scratch.get_mut(self.cursor) {
66                *slot = byte;
67            }
68            self.cursor += 1;
69        }
70
71        // If we now have at least 1 byte, we know the type byte and
72        // expected length.
73        if self.type_byte.is_none() && self.cursor >= 1 {
74            let t = self.scratch.first().copied().unwrap_or(0);
75            self.type_byte = Some(t);
76            // Low nibble = number of *additional* payload bytes
77            // beyond the type byte itself. Reference:
78            // `ircDDBGateway/Common/SlowDataEncoder.cpp` — the
79            // encoder packs the byte count into the low nibble.
80            self.expected_len = Some(usize::from(t & 0x0F));
81        }
82
83        // Check for completion.
84        let expected = self.expected_len?;
85        if self.cursor > expected {
86            // We have a complete block (type byte at index 0 plus
87            // `expected` payload bytes, so cursor > expected means
88            // cursor >= 1 + expected).
89            let type_byte = self.type_byte.unwrap_or(0);
90            let block = self.decode_block(type_byte, expected);
91            self.reset();
92            return Some(block);
93        }
94
95        None
96    }
97
98    fn decode_block(&self, type_byte: u8, payload_len: usize) -> SlowDataBlock {
99        let kind = SlowDataBlockKind::from_type_byte(type_byte);
100        // Payload starts at index 1 of scratch.
101        let payload_end = 1 + payload_len;
102        let payload = self.scratch.get(1..payload_end).unwrap_or(&[]);
103
104        match kind {
105            SlowDataBlockKind::Gps => {
106                let text = String::from_utf8_lossy(payload).to_string();
107                SlowDataBlock::Gps(text)
108            }
109            SlowDataBlockKind::Text => {
110                let raw = String::from_utf8_lossy(payload).to_string();
111                let trimmed = raw.trim_end_matches([' ', '\0']).to_string();
112                SlowDataBlock::Text(SlowDataText { text: trimmed })
113            }
114            SlowDataBlockKind::HeaderRetx => {
115                // A D-STAR header is exactly 41 bytes. If the payload
116                // is shorter, fall back to Unknown.
117                if payload.len() >= ENCODED_LEN {
118                    let mut arr = [0u8; ENCODED_LEN];
119                    if let Some(src) = payload.get(..ENCODED_LEN) {
120                        arr.copy_from_slice(src);
121                    }
122                    let header = DStarHeader::decode(&arr);
123                    SlowDataBlock::HeaderRetx(header)
124                } else {
125                    SlowDataBlock::Unknown {
126                        type_byte,
127                        payload: payload.to_vec(),
128                    }
129                }
130            }
131            SlowDataBlockKind::FastData1 | SlowDataBlockKind::FastData2 => {
132                SlowDataBlock::FastData(payload.to_vec())
133            }
134            SlowDataBlockKind::Squelch => {
135                let code = payload.first().copied().unwrap_or(0);
136                SlowDataBlock::Squelch { code }
137            }
138            SlowDataBlockKind::Unknown { .. } => SlowDataBlock::Unknown {
139                type_byte,
140                payload: payload.to_vec(),
141            },
142        }
143    }
144
145    const fn reset(&mut self) {
146        self.cursor = 0;
147        self.type_byte = None;
148        self.expected_len = None;
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::super::scrambler::scramble;
155    use super::*;
156
157    type TestResult = Result<(), Box<dyn std::error::Error>>;
158
159    /// Helper: push a logical (already-descrambled) 3-byte fragment by
160    /// scrambling it first, so the assembler sees the "real wire" form.
161    fn push_descrambled(asm: &mut SlowDataAssembler, bytes: [u8; 3]) -> Option<SlowDataBlock> {
162        asm.push(scramble(bytes))
163    }
164
165    #[test]
166    fn empty_assembler_returns_zero_length_text_block() {
167        let mut asm = SlowDataAssembler::new();
168        // Zero-length text block: type 0x40 with length nibble 0.
169        // The assembler sees a complete block with zero payload bytes
170        // immediately after ingesting the first 3-byte fragment.
171        let block = push_descrambled(&mut asm, [0x40, 0x00, 0x00]);
172        assert!(block.is_some(), "zero-length text block should complete");
173        assert!(
174            matches!(&block, Some(SlowDataBlock::Text(t)) if t.text.is_empty()),
175            "expected Text with empty string, got {block:?}"
176        );
177    }
178
179    #[test]
180    fn text_block_assembles_across_two_frames() -> TestResult {
181        // Text block: byte 0 = 0x45 (text, length 5), payload = "HELLO"
182        let mut asm = SlowDataAssembler::new();
183        // Frame 1: [0x45, 'H', 'E'] — type byte + 2 payload bytes
184        assert!(push_descrambled(&mut asm, [0x45, b'H', b'E']).is_none());
185        // Frame 2: ['L', 'L', 'O'] — remaining 3 payload bytes
186        let block = push_descrambled(&mut asm, [b'L', b'L', b'O'])
187            .ok_or("expected block after second frame")?;
188        assert!(
189            matches!(&block, SlowDataBlock::Text(t) if t.text == "HELLO"),
190            "expected Text(\"HELLO\"), got {block:?}"
191        );
192        Ok(())
193    }
194
195    #[test]
196    fn gps_block_assembles() -> TestResult {
197        // GPS block: byte 0 = 0x34 (gps, length 4), payload = "TEST"
198        let mut asm = SlowDataAssembler::new();
199        assert!(push_descrambled(&mut asm, [0x34, b'T', b'E']).is_none());
200        let block = push_descrambled(&mut asm, [b'S', b'T', 0x00])
201            .ok_or("expected block after second frame")?;
202        // GPS doesn't trim — includes the exact 4 payload bytes.
203        assert!(
204            matches!(&block, SlowDataBlock::Gps(text) if text == "TEST"),
205            "expected Gps(\"TEST\"), got {block:?}"
206        );
207        Ok(())
208    }
209
210    #[test]
211    fn squelch_block_captures_code() -> TestResult {
212        // Squelch block: byte 0 = 0xC1 (squelch, length 1), byte 1 = 0x42
213        let mut asm = SlowDataAssembler::new();
214        let block =
215            push_descrambled(&mut asm, [0xC1, 0x42, 0x00]).ok_or("expected squelch block")?;
216        assert!(
217            matches!(block, SlowDataBlock::Squelch { code } if code == 0x42),
218            "expected Squelch {{ code: 0x42 }}, got {block:?}"
219        );
220        Ok(())
221    }
222
223    #[test]
224    fn unknown_kind_preserves_type_byte_and_payload() -> TestResult {
225        // Unknown kind: byte 0 = 0xA2, length 2, payload [0x11, 0x22]
226        let mut asm = SlowDataAssembler::new();
227        let block =
228            push_descrambled(&mut asm, [0xA2, 0x11, 0x22]).ok_or("expected unknown block")?;
229        assert!(
230            matches!(&block, SlowDataBlock::Unknown { type_byte, payload }
231                if *type_byte == 0xA2 && *payload == vec![0x11, 0x22]),
232            "expected Unknown {{ type_byte: 0xA2, payload: [0x11, 0x22] }}, got {block:?}"
233        );
234        Ok(())
235    }
236
237    #[test]
238    fn fast_data_block_two_frames() -> TestResult {
239        // FastData1: byte 0 = 0x83, length 3, payload [0xDE, 0xAD, 0xBE]
240        let mut asm = SlowDataAssembler::new();
241        assert!(push_descrambled(&mut asm, [0x83, 0xDE, 0xAD]).is_none());
242        let block =
243            push_descrambled(&mut asm, [0xBE, 0x00, 0x00]).ok_or("expected fast data block")?;
244        assert!(
245            matches!(&block, SlowDataBlock::FastData(payload) if *payload == vec![0xDE, 0xAD, 0xBE]),
246            "expected FastData([0xDE, 0xAD, 0xBE]), got {block:?}"
247        );
248        Ok(())
249    }
250}