dstar_gateway_core/slowdata/
encoder.rs

1//! D-STAR slow-data text-message encoder.
2//!
3//! Encodes a text message into eight scrambled 3-byte fragments (four
4//! blocks × two halves each) suitable for embedding in voice-frame
5//! slow-data fields.
6//!
7//! Reference: `ircDDBGateway/Common/SlowDataEncoder.cpp`.
8
9use super::scrambler::scramble;
10
11/// Number of text blocks in a complete message.
12const TEXT_BLOCK_COUNT: u8 = 4;
13
14/// Characters per text block.
15const TEXT_CHARS_PER_BLOCK: usize = 5;
16
17/// Upper nibble of a text-block type byte.
18const TEXT_BLOCK_TYPE: u8 = 0x40;
19
20/// Fixed message length in characters (4 blocks × 5 chars).
21const MAX_MESSAGE_LEN: usize = TEXT_BLOCK_COUNT as usize * TEXT_CHARS_PER_BLOCK;
22
23/// Encode a text message into eight scrambled 3-byte slow-data payloads.
24///
25/// The output is always exactly 8 payloads (4 blocks × 2 halves) for
26/// any non-empty input. Empty input returns an empty vector.
27///
28/// Messages longer than 20 characters are truncated; shorter messages
29/// are right-padded with ASCII spaces.
30#[must_use]
31pub fn encode_text_message(text: &str) -> Vec<[u8; 3]> {
32    if text.is_empty() {
33        return Vec::new();
34    }
35
36    let bytes = text.as_bytes();
37    let len = bytes.len().min(MAX_MESSAGE_LEN);
38
39    let mut padded = [b' '; MAX_MESSAGE_LEN];
40    if let (Some(dst), Some(src)) = (padded.get_mut(..len), bytes.get(..len)) {
41        dst.copy_from_slice(src);
42    }
43
44    let mut out = Vec::with_capacity(8);
45    for block_index in 0u8..TEXT_BLOCK_COUNT {
46        let start = usize::from(block_index) * TEXT_CHARS_PER_BLOCK;
47        let end = start + TEXT_CHARS_PER_BLOCK;
48
49        let mut block = [0u8; 6];
50        block[0] = TEXT_BLOCK_TYPE | block_index;
51        let Some(chars) = padded.get(start..end) else {
52            continue;
53        };
54        let Some(block_chars) = block.get_mut(1..=TEXT_CHARS_PER_BLOCK) else {
55            continue;
56        };
57        block_chars.copy_from_slice(chars);
58
59        let half1 = scramble([block[0], block[1], block[2]]);
60        let half2 = scramble([block[3], block[4], block[5]]);
61        out.push(half1);
62        out.push(half2);
63    }
64    out
65}
66
67#[cfg(test)]
68mod tests {
69    use super::super::SlowDataTextCollector;
70    use super::super::scrambler::descramble;
71    use super::*;
72
73    type TestResult = Result<(), Box<dyn std::error::Error>>;
74
75    #[test]
76    fn empty_input_returns_empty_vec() {
77        assert!(encode_text_message("").is_empty());
78    }
79
80    #[test]
81    fn short_input_pads_with_spaces() -> TestResult {
82        let out = encode_text_message("Hi");
83        assert_eq!(out.len(), 8);
84
85        let mut c = SlowDataTextCollector::new();
86        for (i, h) in out.iter().enumerate() {
87            #[allow(clippy::cast_possible_truncation)]
88            let idx = (i as u8).wrapping_add(1);
89            c.push(*h, idx);
90        }
91        let msg = c.take_message().ok_or("complete")?;
92        assert_eq!(&msg[..], b"Hi                  ");
93        Ok(())
94    }
95
96    #[test]
97    fn exactly_20_chars_roundtrip() -> TestResult {
98        let out = encode_text_message("ABCDEFGHIJKLMNOPQRST");
99        let mut c = SlowDataTextCollector::new();
100        for (i, h) in out.iter().enumerate() {
101            #[allow(clippy::cast_possible_truncation)]
102            let idx = (i as u8).wrapping_add(1);
103            c.push(*h, idx);
104        }
105        let msg = c.take_message().ok_or("complete")?;
106        assert_eq!(&msg[..], b"ABCDEFGHIJKLMNOPQRST");
107        Ok(())
108    }
109
110    #[test]
111    fn long_input_truncates_to_20() -> TestResult {
112        let out = encode_text_message("1234567890ABCDEFGHIJKLMN");
113        let mut c = SlowDataTextCollector::new();
114        for (i, h) in out.iter().enumerate() {
115            #[allow(clippy::cast_possible_truncation)]
116            let idx = (i as u8).wrapping_add(1);
117            c.push(*h, idx);
118        }
119        let msg = c.take_message().ok_or("complete")?;
120        assert_eq!(&msg[..], b"1234567890ABCDEFGHIJ");
121        Ok(())
122    }
123
124    #[test]
125    fn output_is_always_eight_payloads() {
126        for text in &["A", "Hello", "Hello world", "X".repeat(20).as_str()] {
127            let out = encode_text_message(text);
128            assert_eq!(out.len(), 8, "text = {text:?}");
129        }
130    }
131
132    #[test]
133    fn descramble_reveals_block_index_and_text_chars() -> TestResult {
134        let out = encode_text_message("ABCDEFGHIJKLMNOPQRST");
135        for block in 0u8..4 {
136            let half1 = *out
137                .get(usize::from(block) * 2)
138                .ok_or("block half1 present")?;
139            let plain = descramble(half1);
140            assert_eq!(plain[0] & 0xF0, 0x40, "block {block} high nibble");
141            assert_eq!(plain[0] & 0x0F, block, "block {block} low nibble = index");
142        }
143        Ok(())
144    }
145}