dstar_gateway_core/slowdata/
text_collector.rs

1//! D-STAR slow-data text-message collector.
2//!
3//! Assembles four 5-character text blocks into a complete 20-character
4//! message. Unlike [`SlowDataAssembler`], which treats the low nibble
5//! of the type byte as a variable payload length, this collector uses
6//! the fixed-block-index protocol defined in
7//! `ircDDBGateway/Common/TextCollector.cpp`:
8//!
9//! - Type byte high nibble `0x4` identifies a text block.
10//! - Type byte low nibble (`0x0..=0x3`) is the block index.
11//! - Each block carries exactly 5 text characters in byte positions 1..=5.
12//! - Four blocks compose a 20-character message (4 × 5 = 20).
13//!
14//! Each 6-byte block is transmitted as two consecutive 3-byte slow-data
15//! halves in voice-frame slow-data fields. Sync frames (frame index 0)
16//! carry `[0x55, 0x55, 0x55]` filler and must not be fed into the
17//! collector — they break half-block alignment. Callers either skip
18//! them or pass `frame_index == 0` to trigger automatic resync.
19//!
20//! [`SlowDataAssembler`]: super::SlowDataAssembler
21
22use super::scrambler::descramble;
23
24/// Fixed block count in a complete text message.
25const TEXT_BLOCK_COUNT: u8 = 4;
26
27/// Fixed text-char count per block.
28const TEXT_CHARS_PER_BLOCK: usize = 5;
29
30/// Assembled text message length (4 blocks × 5 chars).
31pub const MAX_MESSAGE_LEN: usize = 20;
32
33/// Upper nibble of a text-block type byte.
34const TEXT_BLOCK_TYPE: u8 = 0x40;
35
36/// Upper-nibble mask.
37const SLOW_DATA_TYPE_MASK: u8 = 0xF0;
38
39/// Phase of the 6-byte block assembly (two 3-byte halves per block).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum HalfPhase {
42    /// Next frame is bytes 0..3 of a new block.
43    First,
44    /// Next frame is bytes 3..6 completing the current block.
45    Second,
46}
47
48/// D-STAR slow-data text-message collector.
49///
50/// See the module docs for wire-format details. Feed each voice-frame
51/// slow-data payload via [`Self::push`]. When all four indexed blocks
52/// have been seen, [`Self::take_message`] returns the 20-character
53/// message; the collector then rearms for the next message.
54#[derive(Debug, Clone)]
55pub struct SlowDataTextCollector {
56    /// Current 6-byte block being assembled (half1 in [0..3], half2 in [3..6]).
57    block_buffer: [u8; 6],
58    /// Which half of the block we expect next.
59    phase: HalfPhase,
60    /// Four 5-char text slots, one per block index (0..=3).
61    slots: [[u8; TEXT_CHARS_PER_BLOCK]; 4],
62    /// Bit i set ⇒ slot i has been filled.
63    seen_mask: u8,
64    /// Last frame index observed (for sync-frame detection in `push`).
65    last_frame_index: u8,
66}
67
68impl Default for SlowDataTextCollector {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl SlowDataTextCollector {
75    /// Create a new, empty collector.
76    #[must_use]
77    pub const fn new() -> Self {
78        Self {
79            block_buffer: [0u8; 6],
80            phase: HalfPhase::First,
81            slots: [[b' '; TEXT_CHARS_PER_BLOCK]; 4],
82            seen_mask: 0,
83            last_frame_index: 1,
84        }
85    }
86
87    /// Feed one voice frame's slow-data payload.
88    ///
89    /// `frame_index == 0` marks a D-STAR superframe sync frame: the
90    /// partial half-block state is discarded and the next `push` is
91    /// treated as the first half of a fresh block.
92    pub fn push(&mut self, fragment: [u8; 3], frame_index: u8) {
93        if frame_index == 0 {
94            self.phase = HalfPhase::First;
95            self.last_frame_index = 0;
96            return;
97        }
98        self.last_frame_index = frame_index;
99
100        let plain = descramble(fragment);
101        match self.phase {
102            HalfPhase::First => {
103                self.block_buffer[0] = plain[0];
104                self.block_buffer[1] = plain[1];
105                self.block_buffer[2] = plain[2];
106                self.phase = HalfPhase::Second;
107            }
108            HalfPhase::Second => {
109                self.block_buffer[3] = plain[0];
110                self.block_buffer[4] = plain[1];
111                self.block_buffer[5] = plain[2];
112                self.phase = HalfPhase::First;
113                self.commit_block();
114            }
115        }
116    }
117
118    /// Process a completed 6-byte block.
119    fn commit_block(&mut self) {
120        let type_byte = self.block_buffer[0];
121        if type_byte & SLOW_DATA_TYPE_MASK != TEXT_BLOCK_TYPE {
122            return;
123        }
124        let block_index = type_byte & 0x0F;
125        if block_index >= TEXT_BLOCK_COUNT {
126            return;
127        }
128        let slot_idx = usize::from(block_index);
129        let Some(slot) = self.slots.get_mut(slot_idx) else {
130            return;
131        };
132        let Some(src) = self.block_buffer.get(1..=TEXT_CHARS_PER_BLOCK) else {
133            return;
134        };
135        for (dst, s) in slot.iter_mut().zip(src.iter()) {
136            *dst = *s;
137        }
138        self.seen_mask |= 1u8 << block_index;
139        // Diagnostic: log every accepted text block so non-standard
140        // slow-data streams (e.g. AMBEserver custom encodings that
141        // happen to mimic 0x40-0x43 sub-codes) can be reverse-
142        // engineered by inspecting which raw bytes triggered a commit.
143        tracing::trace!(
144            target: "dstar_gateway_core::slowdata::text_collector",
145            slot = slot_idx,
146            seen_mask = format_args!("{:#06b}", self.seen_mask),
147            type_byte = format_args!("{:#04x}", type_byte),
148            block = format_args!(
149                "{:02X} {:02X} {:02X} {:02X} {:02X} {:02X}",
150                self.block_buffer[0], self.block_buffer[1], self.block_buffer[2],
151                self.block_buffer[3], self.block_buffer[4], self.block_buffer[5]
152            ),
153            chars = format_args!("{:?}", String::from_utf8_lossy(src)),
154            "text block accepted"
155        );
156    }
157
158    /// Return the complete 20-char message if all four blocks have been seen.
159    #[must_use]
160    pub fn message(&self) -> Option<[u8; MAX_MESSAGE_LEN]> {
161        if self.seen_mask != 0b1111 {
162            return None;
163        }
164        let mut out = [0u8; MAX_MESSAGE_LEN];
165        for (i, slot) in self.slots.iter().enumerate() {
166            let start = i * TEXT_CHARS_PER_BLOCK;
167            let end = start + TEXT_CHARS_PER_BLOCK;
168            let dst = out.get_mut(start..end)?;
169            dst.copy_from_slice(slot);
170        }
171        Some(out)
172    }
173
174    /// Consume the complete message and rearm the collector.
175    pub fn take_message(&mut self) -> Option<[u8; MAX_MESSAGE_LEN]> {
176        let msg = self.message()?;
177        self.rearm();
178        Some(msg)
179    }
180
181    /// Clear all state. Call at stream boundaries (EOT, new header).
182    pub const fn reset(&mut self) {
183        self.rearm();
184        self.phase = HalfPhase::First;
185        self.block_buffer = [0u8; 6];
186    }
187
188    const fn rearm(&mut self) {
189        self.slots = [[b' '; TEXT_CHARS_PER_BLOCK]; 4];
190        self.seen_mask = 0;
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::super::scrambler::scramble;
197    use super::*;
198
199    type TestResult = Result<(), Box<dyn std::error::Error>>;
200
201    /// Feed a sequence of already-descrambled logical halves by scrambling
202    /// them first (so the collector sees real wire form) and assigning
203    /// non-zero frame indices.
204    fn feed(collector: &mut SlowDataTextCollector, halves: &[[u8; 3]]) {
205        for (i, h) in halves.iter().enumerate() {
206            #[allow(clippy::cast_possible_truncation)]
207            let idx = (i as u8).wrapping_add(1);
208            collector.push(scramble(*h), idx);
209        }
210    }
211
212    #[test]
213    fn four_text_blocks_assemble_20_char_message() -> TestResult {
214        let mut c = SlowDataTextCollector::new();
215        feed(
216            &mut c,
217            &[
218                [0x40, b'C', b'Q'],
219                [b' ', b'w', b'o'],
220                [0x41, b'r', b'k'],
221                [b'i', b'n', b'g'],
222                [0x42, b' ', b' '],
223                [b' ', b' ', b' '],
224                [0x43, b' ', b' '],
225                [b' ', b' ', b' '],
226            ],
227        );
228        let msg = c.take_message().ok_or("complete message")?;
229        assert_eq!(&msg[..], b"CQ working          ");
230        Ok(())
231    }
232
233    #[test]
234    fn out_of_order_blocks_still_assemble() -> TestResult {
235        let mut c = SlowDataTextCollector::new();
236        feed(
237            &mut c,
238            &[
239                [0x42, b'C', b'C'],
240                [b'C', b'C', b'C'],
241                [0x40, b'A', b'A'],
242                [b'A', b'A', b'A'],
243                [0x43, b'D', b'D'],
244                [b'D', b'D', b'D'],
245                [0x41, b'B', b'B'],
246                [b'B', b'B', b'B'],
247            ],
248        );
249        let msg = c.take_message().ok_or("complete message")?;
250        assert_eq!(&msg[..], b"AAAAABBBBBCCCCCDDDDD");
251        Ok(())
252    }
253
254    #[test]
255    fn partial_message_returns_none() {
256        let mut c = SlowDataTextCollector::new();
257        feed(
258            &mut c,
259            &[
260                [0x40, b'A', b'B'],
261                [b'C', b'D', b'E'],
262                [0x41, b'F', b'G'],
263                [b'H', b'I', b'J'],
264            ],
265        );
266        assert!(c.message().is_none());
267        assert!(c.take_message().is_none());
268    }
269
270    #[test]
271    fn reset_discards_partial_state() {
272        let mut c = SlowDataTextCollector::new();
273        feed(
274            &mut c,
275            &[
276                [0x40, b'X', b'Y'],
277                [b'Z', b'!', b'!'],
278                [0x41, b'1', b'2'],
279                [b'3', b'4', b'5'],
280            ],
281        );
282        c.reset();
283        feed(
284            &mut c,
285            &[
286                [0x40, b'N', b'E'],
287                [b'W', b' ', b' '],
288                [0x41, b' ', b' '],
289                [b' ', b' ', b' '],
290            ],
291        );
292        assert!(c.message().is_none());
293    }
294
295    #[test]
296    fn sync_frame_resyncs_without_corrupting_state() -> TestResult {
297        let mut c = SlowDataTextCollector::new();
298        c.push(scramble([0x40, b'A', b'A']), 1);
299        c.push([0x55, 0x55, 0x55], 0);
300        feed(
301            &mut c,
302            &[
303                [0x40, b'H', b'I'],
304                [b'!', b'!', b'!'],
305                [0x41, b' ', b' '],
306                [b' ', b' ', b' '],
307                [0x42, b' ', b' '],
308                [b' ', b' ', b' '],
309                [0x43, b' ', b' '],
310                [b' ', b' ', b' '],
311            ],
312        );
313        let msg = c.take_message().ok_or("message after resync")?;
314        assert_eq!(&msg[..], b"HI!!!               ");
315        Ok(())
316    }
317
318    #[test]
319    fn non_text_blocks_are_ignored() {
320        let mut c = SlowDataTextCollector::new();
321        feed(
322            &mut c,
323            &[
324                [0x55, b'V', b'E'],
325                [b'3', b'O', b'E'],
326                [0x35, b'$', b'G'],
327                [b'P', b'G', b'G'],
328                [0xC0, 0x12, 0x34],
329                [0x56, 0x78, 0x9A],
330            ],
331        );
332        assert!(c.message().is_none());
333    }
334
335    #[test]
336    fn reserved_text_sub_codes_are_ignored() {
337        let mut c = SlowDataTextCollector::new();
338        feed(
339            &mut c,
340            &[
341                [0x44, b'X', b'X'],
342                [b'X', b'X', b'X'],
343                [0x4F, b'Y', b'Y'],
344                [b'Y', b'Y', b'Y'],
345            ],
346        );
347        assert!(c.message().is_none());
348    }
349
350    #[test]
351    fn take_message_rearms_collector() -> TestResult {
352        let mut c = SlowDataTextCollector::new();
353        feed(
354            &mut c,
355            &[
356                [0x40, b'H', b'e'],
357                [b'l', b'l', b'o'],
358                [0x41, b' ', b'w'],
359                [b'o', b'r', b'l'],
360                [0x42, b'd', b' '],
361                [b' ', b' ', b' '],
362                [0x43, b' ', b' '],
363                [b' ', b' ', b' '],
364            ],
365        );
366        let taken = c.take_message().ok_or("message ready")?;
367        assert_eq!(&taken[..], b"Hello world         ");
368        assert!(c.message().is_none());
369        Ok(())
370    }
371
372    #[test]
373    fn default_creates_empty_collector() {
374        let c = SlowDataTextCollector::default();
375        assert!(c.message().is_none());
376    }
377}