dstar_gateway_core/slowdata/
encoder.rs1use super::scrambler::scramble;
10
11const TEXT_BLOCK_COUNT: u8 = 4;
13
14const TEXT_CHARS_PER_BLOCK: usize = 5;
16
17const TEXT_BLOCK_TYPE: u8 = 0x40;
19
20const MAX_MESSAGE_LEN: usize = TEXT_BLOCK_COUNT as usize * TEXT_CHARS_PER_BLOCK;
22
23#[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}