dstar_gateway_core/codec/dextra/
encode.rs

1//! `DExtra` packet encoders.
2//!
3//! Every encoder writes into a caller-supplied `&mut [u8]` and returns
4//! the number of bytes written. Hot-path encoding is allocation-free.
5
6use crate::error::EncodeError;
7use crate::header::DStarHeader;
8use crate::types::{Callsign, Module, StreamId};
9use crate::voice::{AMBE_SILENCE, DSTAR_SYNC_BYTES, VoiceFrame};
10
11use super::consts::{
12    CONNECT_ACK_TAG, CONNECT_LEN, CONNECT_NAK_TAG, CONNECT_REPLY_LEN, DSVT_MAGIC, POLL_LEN,
13    VOICE_DATA_LEN, VOICE_EOT_LEN, VOICE_HEADER_LEN,
14};
15
16/// Encode an 11-byte LINK connect request.
17///
18/// Layout per `ircDDBGateway/Common/ConnectData.cpp:278-296`
19/// (`getDExtraData CT_LINK1`):
20/// - `[0..7]`: first 7 chars of the callsign (from `data[0..7]`)
21/// - `[7]`: space padding (from `memset(data, ' ', 8)`)
22/// - `[8]`: 8th char of the callsign slot — the client module
23///   letter per the ircDDBGateway convention
24/// - `[9]`: reflector module letter
25/// - `[10]`: `0x00`
26///
27/// # Errors
28///
29/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 11`.
30///
31/// # See also
32///
33/// `ircDDBGateway/Common/ConnectData.cpp:278-296` (`getDExtraData`
34/// `CT_LINK1` branch) for the reference encoder this function
35/// mirrors.
36pub fn encode_connect_link(
37    out: &mut [u8],
38    callsign: &Callsign,
39    reflector_module: Module,
40    client_module: Module,
41) -> Result<usize, EncodeError> {
42    write_connect_common(out, *callsign, client_module, reflector_module.as_byte())?;
43    Ok(CONNECT_LEN)
44}
45
46/// Encode an 11-byte UNLINK request.
47///
48/// Same shape as LINK but byte `[9]` is `b' '` (space) instead of the
49/// reflector module.
50///
51/// # Errors
52///
53/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 11`.
54///
55/// # See also
56///
57/// `ircDDBGateway/Common/ConnectData.cpp:298-300` (`getDExtraData`
58/// `CT_UNLINK` branch).
59pub fn encode_unlink(
60    out: &mut [u8],
61    callsign: &Callsign,
62    client_module: Module,
63) -> Result<usize, EncodeError> {
64    write_connect_common(out, *callsign, client_module, b' ')?;
65    Ok(CONNECT_LEN)
66}
67
68/// Encode a 14-byte connect ACK reply.
69///
70/// Layout per `ircDDBGateway/Common/ConnectData.cpp:302-308`
71/// (`getDExtraData CT_ACK`):
72/// - `[0..7]`: first 7 chars of the echoed callsign
73/// - `[7]`: space padding
74/// - `[8]`: 8th char of the echoed callsign (the repeater/client
75///   module letter from the original LINK request)
76/// - `[9]`: reflector module letter
77/// - `[10..13]`: `b"ACK"`
78/// - `[13]`: `0x00` (NUL terminator)
79///
80/// # Errors
81///
82/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 14`.
83///
84/// # See also
85///
86/// `ircDDBGateway/Common/ConnectData.cpp:302-308` (`getDExtraData`
87/// `CT_ACK` branch) for the reference encoder.
88pub fn encode_connect_ack(
89    out: &mut [u8],
90    callsign: &Callsign,
91    reflector_module: Module,
92) -> Result<usize, EncodeError> {
93    write_connect_reply(out, *callsign, reflector_module, CONNECT_ACK_TAG)?;
94    Ok(CONNECT_REPLY_LEN)
95}
96
97/// Encode a 14-byte connect NAK reply.
98///
99/// Same as [`encode_connect_ack`] but the 3-byte tag is `b"NAK"`.
100///
101/// # Errors
102///
103/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 14`.
104///
105/// # See also
106///
107/// `ircDDBGateway/Common/ConnectData.cpp:310-316` (`getDExtraData`
108/// `CT_NAK` branch).
109pub fn encode_connect_nak(
110    out: &mut [u8],
111    callsign: &Callsign,
112    reflector_module: Module,
113) -> Result<usize, EncodeError> {
114    write_connect_reply(out, *callsign, reflector_module, CONNECT_NAK_TAG)?;
115    Ok(CONNECT_REPLY_LEN)
116}
117
118/// Encode a 9-byte keepalive poll.
119///
120/// Layout per `ircDDBGateway/Common/PollData.cpp:155-168`
121/// (`getDExtraData`):
122/// - `[0..8]`: 8-byte space-padded callsign
123/// - `[8]`: `0x00`
124///
125/// # Errors
126///
127/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 9`.
128///
129/// # See also
130///
131/// `ircDDBGateway/Common/PollData.cpp:155-168` (`getDExtraData`)
132/// for the reference keepalive encoder.
133pub fn encode_poll(out: &mut [u8], callsign: &Callsign) -> Result<usize, EncodeError> {
134    if out.len() < POLL_LEN {
135        return Err(EncodeError::BufferTooSmall {
136            need: POLL_LEN,
137            have: out.len(),
138        });
139    }
140    if let Some(dst) = out.get_mut(..8) {
141        dst.copy_from_slice(callsign.as_bytes());
142    }
143    if let Some(b) = out.get_mut(8) {
144        *b = 0x00;
145    }
146    Ok(POLL_LEN)
147}
148
149/// Encode a 9-byte poll echo (server-side reply to a client poll).
150///
151/// Same shape as [`encode_poll`]: callsign at `[0..8]` then `0x00`.
152///
153/// # Errors
154///
155/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 9`.
156pub fn encode_poll_echo(out: &mut [u8], callsign: &Callsign) -> Result<usize, EncodeError> {
157    encode_poll(out, callsign)
158}
159
160/// Encode a 56-byte voice header.
161///
162/// Layout per `ircDDBGateway/Common/HeaderData.cpp:590-635`
163/// (`getDExtraData`):
164/// - `[0..4]`: `b"DSVT"` (NOT preceded by a length prefix — unlike `DPlus`)
165/// - `[4]`: `0x10` (header indicator)
166/// - `[5..8]`: `0x00` (reserved)
167/// - `[8]`: `0x20` (config)
168/// - `[9..12]`: `0x00 0x01 0x02` (band1/band2/band3)
169/// - `[12..14]`: `stream_id` little-endian
170/// - `[14]`: `0x80` (header indicator)
171/// - `[15..56]`: [`DStarHeader::encode_for_dsvt`] (41 bytes: 3 zero flag
172///   bytes + RPT2 + RPT1 + YOUR + MY + `MY_SUFFIX` + CRC)
173///
174/// # Errors
175///
176/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 56`.
177///
178/// # See also
179///
180/// `ircDDBGateway/Common/HeaderData.cpp:590-635` (`getDExtraData`)
181/// for the reference encoder. The 41-byte trailer is shared with
182/// [`encode_voice_data`] below.
183pub fn encode_voice_header(
184    out: &mut [u8],
185    stream_id: StreamId,
186    header: &DStarHeader,
187) -> Result<usize, EncodeError> {
188    if out.len() < VOICE_HEADER_LEN {
189        return Err(EncodeError::BufferTooSmall {
190            need: VOICE_HEADER_LEN,
191            have: out.len(),
192        });
193    }
194    if let Some(dst) = out.get_mut(..4) {
195        dst.copy_from_slice(&DSVT_MAGIC);
196    }
197    if let Some(b) = out.get_mut(4) {
198        *b = 0x10;
199    }
200    if let Some(b) = out.get_mut(5) {
201        *b = 0x00;
202    }
203    if let Some(b) = out.get_mut(6) {
204        *b = 0x00;
205    }
206    if let Some(b) = out.get_mut(7) {
207        *b = 0x00;
208    }
209    if let Some(b) = out.get_mut(8) {
210        *b = 0x20;
211    }
212    if let Some(b) = out.get_mut(9) {
213        *b = 0x00;
214    }
215    if let Some(b) = out.get_mut(10) {
216        *b = 0x01;
217    }
218    if let Some(b) = out.get_mut(11) {
219        *b = 0x02;
220    }
221    let sid = stream_id.get().to_le_bytes();
222    if let Some(b) = out.get_mut(12) {
223        *b = sid[0];
224    }
225    if let Some(b) = out.get_mut(13) {
226        *b = sid[1];
227    }
228    if let Some(b) = out.get_mut(14) {
229        *b = 0x80;
230    }
231    let encoded = header.encode_for_dsvt();
232    if let Some(dst) = out.get_mut(15..56) {
233        dst.copy_from_slice(&encoded);
234    }
235    Ok(VOICE_HEADER_LEN)
236}
237
238/// Encode a 27-byte voice data packet.
239///
240/// Layout per `ircDDBGateway/Common/AMBEData.cpp:317-345`
241/// (`getDExtraData`):
242/// - `[0..4]`: `b"DSVT"`
243/// - `[4]`: `0x20` (voice type)
244/// - `[5..8]`: `0x00` (reserved)
245/// - `[8]`: `0x20` (config)
246/// - `[9..12]`: `0x00 0x01 0x02` (bands)
247/// - `[12..14]`: `stream_id` LE
248/// - `[14]`: `seq`
249/// - `[15..24]`: 9 AMBE bytes
250/// - `[24..27]`: 3 slow data bytes
251///
252/// # Errors
253///
254/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 27`.
255///
256/// # See also
257///
258/// `ircDDBGateway/Common/AMBEData.cpp:317-345` (`getDExtraData`)
259/// for the reference encoder this function mirrors.
260pub fn encode_voice_data(
261    out: &mut [u8],
262    stream_id: StreamId,
263    seq: u8,
264    frame: &VoiceFrame,
265) -> Result<usize, EncodeError> {
266    if out.len() < VOICE_DATA_LEN {
267        return Err(EncodeError::BufferTooSmall {
268            need: VOICE_DATA_LEN,
269            have: out.len(),
270        });
271    }
272    write_voice_prefix(out, stream_id, seq);
273    if let Some(dst) = out.get_mut(15..24) {
274        dst.copy_from_slice(&frame.ambe);
275    }
276    if let Some(dst) = out.get_mut(24..27) {
277        dst.copy_from_slice(&frame.slow_data);
278    }
279    Ok(VOICE_DATA_LEN)
280}
281
282/// Encode a 27-byte voice EOT packet.
283///
284/// Same shape as [`encode_voice_data`] but:
285/// - `[14]`: `seq | 0x40` (EOT bit set)
286/// - `[15..24]`: [`AMBE_SILENCE`]
287/// - `[24..27]`: [`DSTAR_SYNC_BYTES`]
288///
289/// # Errors
290///
291/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 27`.
292///
293/// # See also
294///
295/// `ircDDBGateway/Common/AMBEData.cpp:317-345` — the
296/// `getDExtraData` encoder produces the same 27-byte layout regardless
297/// of `isEnd()`; the caller is expected to set `m_outSeq |= 0x40` and
298/// fill `m_data` with silence + sync bytes before invoking it.
299pub fn encode_voice_eot(
300    out: &mut [u8],
301    stream_id: StreamId,
302    seq: u8,
303) -> Result<usize, EncodeError> {
304    if out.len() < VOICE_EOT_LEN {
305        return Err(EncodeError::BufferTooSmall {
306            need: VOICE_EOT_LEN,
307            have: out.len(),
308        });
309    }
310    write_voice_prefix(out, stream_id, seq | 0x40);
311    if let Some(dst) = out.get_mut(15..24) {
312        dst.copy_from_slice(&AMBE_SILENCE);
313    }
314    if let Some(dst) = out.get_mut(24..27) {
315        dst.copy_from_slice(&DSTAR_SYNC_BYTES);
316    }
317    Ok(VOICE_EOT_LEN)
318}
319
320/// Internal helper: write the shared 15-byte prefix common to voice
321/// data and voice EOT packets.
322fn write_voice_prefix(out: &mut [u8], stream_id: StreamId, seq: u8) {
323    if let Some(dst) = out.get_mut(..4) {
324        dst.copy_from_slice(&DSVT_MAGIC);
325    }
326    if let Some(b) = out.get_mut(4) {
327        *b = 0x20;
328    }
329    if let Some(b) = out.get_mut(5) {
330        *b = 0x00;
331    }
332    if let Some(b) = out.get_mut(6) {
333        *b = 0x00;
334    }
335    if let Some(b) = out.get_mut(7) {
336        *b = 0x00;
337    }
338    if let Some(b) = out.get_mut(8) {
339        *b = 0x20;
340    }
341    if let Some(b) = out.get_mut(9) {
342        *b = 0x00;
343    }
344    if let Some(b) = out.get_mut(10) {
345        *b = 0x01;
346    }
347    if let Some(b) = out.get_mut(11) {
348        *b = 0x02;
349    }
350    let sid = stream_id.get().to_le_bytes();
351    if let Some(b) = out.get_mut(12) {
352        *b = sid[0];
353    }
354    if let Some(b) = out.get_mut(13) {
355        *b = sid[1];
356    }
357    if let Some(b) = out.get_mut(14) {
358        *b = seq;
359    }
360}
361
362/// Internal helper: write the 11-byte LINK/UNLINK skeleton, with a
363/// caller-supplied byte at position `[9]` (reflector module for LINK,
364/// space for UNLINK).
365///
366/// Mirrors `ircDDBGateway/Common/ConnectData.cpp:278-300`
367/// (`getDExtraData`):
368/// ```text
369/// memset(data, ' ', 8)                          // out[0..8] = spaces
370/// for i in 0..min(repeater.Len(), 7)            // out[0..7] = first 7 chars
371///     data[i] = repeater.GetChar(i)
372/// data[8] = repeater.GetChar(7)                 // out[8] = client module
373/// data[9] = reflector.GetChar(7) or ' '          // out[9] = byte9
374/// data[10] = 0x00
375/// ```
376fn write_connect_common(
377    out: &mut [u8],
378    callsign: Callsign,
379    client_module: Module,
380    byte9: u8,
381) -> Result<(), EncodeError> {
382    if out.len() < CONNECT_LEN {
383        return Err(EncodeError::BufferTooSmall {
384            need: CONNECT_LEN,
385            have: out.len(),
386        });
387    }
388    // Mirror the reference `memset(data, ' ', LONG_CALLSIGN_LENGTH)`
389    // by filling the first 11 bytes with spaces before writing the
390    // per-position overrides. This ensures out[7] ends up as ' '
391    // (the pad slot between the first 7 chars of the repeater
392    // callsign and the module letter at out[8]).
393    if let Some(region) = out.get_mut(..CONNECT_LEN) {
394        region.fill(b' ');
395    }
396    // out[0..7] = first 7 chars of the callsign. Loop `i < 7` in
397    // the reference, so we intentionally stop at 7 — never touch
398    // out[7].
399    let cs = callsign.as_bytes();
400    if let Some(dst) = out.get_mut(..7)
401        && let Some(src) = cs.get(..7)
402    {
403        dst.copy_from_slice(src);
404    }
405    // out[8] = client module letter (= 8th char of repeater per
406    // the reference convention).
407    if let Some(b) = out.get_mut(8) {
408        *b = client_module.as_byte();
409    }
410    // out[9] = reflector module letter for LINK, or space for
411    // UNLINK (handed in by the caller).
412    if let Some(b) = out.get_mut(9) {
413        *b = byte9;
414    }
415    // out[10] = 0x00 NUL terminator.
416    if let Some(b) = out.get_mut(10) {
417        *b = 0x00;
418    }
419    Ok(())
420}
421
422/// Internal helper: write a 14-byte connect reply (ACK or NAK).
423///
424/// Layout per `ircDDBGateway/Common/ConnectData.cpp:302-316`
425/// (`getDExtraData CT_ACK`/`CT_NAK`):
426/// - `[0..7]` first 7 chars of the echoed callsign
427/// - `[7]` space padding (from `memset`)
428/// - `[8]` 8th char of the echoed callsign — the client's module
429///   from the original LINK request (`m_repeater.GetChar(7)`)
430/// - `[9]` reflector module letter (`m_reflector.GetChar(7)`)
431/// - `[10..13]` `tag` (`b"ACK"` or `b"NAK"`)
432/// - `[13]` `0x00` NUL terminator
433fn write_connect_reply(
434    out: &mut [u8],
435    callsign: Callsign,
436    reflector_module: Module,
437    tag: [u8; 3],
438) -> Result<(), EncodeError> {
439    if out.len() < CONNECT_REPLY_LEN {
440        return Err(EncodeError::BufferTooSmall {
441            need: CONNECT_REPLY_LEN,
442            have: out.len(),
443        });
444    }
445    // Mirror `memset(data, ' ', 8)` + the rest of the trailing
446    // positions. Filling with spaces ensures out[7] is a space —
447    // and any bytes we don't explicitly overwrite stay as spaces
448    // rather than leaking arbitrary prior contents.
449    if let Some(region) = out.get_mut(..CONNECT_REPLY_LEN) {
450        region.fill(b' ');
451    }
452    // out[0..7] = first 7 chars of the callsign.
453    let cs = callsign.as_bytes();
454    if let Some(dst) = out.get_mut(..7)
455        && let Some(src) = cs.get(..7)
456    {
457        dst.copy_from_slice(src);
458    }
459    // out[8] = 8th char of the callsign (the repeater/client
460    // module letter from the original LINK request we're echoing).
461    if let Some(b) = out.get_mut(8) {
462        *b = cs.get(7).copied().unwrap_or(b' ');
463    }
464    // out[9] = reflector module letter.
465    if let Some(b) = out.get_mut(9) {
466        *b = reflector_module.as_byte();
467    }
468    // out[10..13] = 3-byte tag.
469    if let Some(dst) = out.get_mut(10..13) {
470        dst.copy_from_slice(&tag);
471    }
472    // out[13] = 0x00 NUL terminator.
473    if let Some(b) = out.get_mut(13) {
474        *b = 0x00;
475    }
476    Ok(())
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::types::Suffix;
483
484    type TestResult = Result<(), Box<dyn std::error::Error>>;
485
486    const fn cs(bytes: [u8; 8]) -> Callsign {
487        Callsign::from_wire_bytes(bytes)
488    }
489
490    #[expect(clippy::unwrap_used, reason = "compile-time validated: n != 0")]
491    const fn sid(n: u16) -> StreamId {
492        StreamId::new(n).unwrap()
493    }
494
495    const fn test_header() -> DStarHeader {
496        DStarHeader {
497            flag1: 0,
498            flag2: 0,
499            flag3: 0,
500            rpt2: Callsign::from_wire_bytes(*b"XRF030 G"),
501            rpt1: Callsign::from_wire_bytes(*b"XRF030 C"),
502            ur_call: Callsign::from_wire_bytes(*b"CQCQCQ  "),
503            my_call: Callsign::from_wire_bytes(*b"W1AW    "),
504            my_suffix: Suffix::EMPTY,
505        }
506    }
507
508    // ─── Connect (LINK) tests ──────────────────────────────────
509    #[test]
510    fn encode_connect_link_writes_11_bytes() -> TestResult {
511        let mut buf = [0u8; 16];
512        let n = encode_connect_link(&mut buf, &cs(*b"W1AW    "), Module::C, Module::B)?;
513        assert_eq!(n, 11);
514        // First 7 chars of the callsign at [0..7]. "W1AW" pads to
515        // "W1AW   " — 4 chars + 3 spaces.
516        assert_eq!(&buf[..7], b"W1AW   ", "callsign chars 0..7");
517        // Position 7 stays as the space padding (from memset).
518        assert_eq!(buf[7], b' ', "pad slot before module letter");
519        // Position 8 holds the client module letter.
520        assert_eq!(buf[8], b'B', "client module");
521        // Position 9 holds the reflector module letter.
522        assert_eq!(buf[9], b'C', "reflector module");
523        assert_eq!(buf[10], 0x00, "trailing null");
524        Ok(())
525    }
526
527    #[test]
528    fn encode_connect_link_rejects_small_buffer() -> TestResult {
529        let mut buf = [0u8; 10];
530        let Err(err) = encode_connect_link(&mut buf, &cs(*b"W1AW    "), Module::C, Module::B)
531        else {
532            return Err("expected too small".into());
533        };
534        assert_eq!(err, EncodeError::BufferTooSmall { need: 11, have: 10 });
535        Ok(())
536    }
537
538    // ─── Unlink tests ──────────────────────────────────────────
539    #[test]
540    fn encode_unlink_writes_11_bytes_with_space_at_9() -> TestResult {
541        let mut buf = [0u8; 16];
542        let n = encode_unlink(&mut buf, &cs(*b"W1AW    "), Module::B)?;
543        assert_eq!(n, 11);
544        assert_eq!(&buf[..7], b"W1AW   ", "callsign chars 0..7");
545        assert_eq!(buf[7], b' ', "pad slot before module letter");
546        assert_eq!(buf[8], b'B', "client module");
547        assert_eq!(buf[9], b' ', "space where reflector module would be");
548        assert_eq!(buf[10], 0x00);
549        Ok(())
550    }
551
552    #[test]
553    fn encode_unlink_rejects_small_buffer() -> TestResult {
554        let mut buf = [0u8; 5];
555        let Err(err) = encode_unlink(&mut buf, &cs(*b"W1AW    "), Module::B) else {
556            return Err("expected too small".into());
557        };
558        assert_eq!(err, EncodeError::BufferTooSmall { need: 11, have: 5 });
559        Ok(())
560    }
561
562    // ─── Connect ACK/NAK tests ─────────────────────────────────
563    #[test]
564    fn encode_connect_ack_writes_14_bytes() -> TestResult {
565        let mut buf = [0u8; 16];
566        // Test case: callsign "XRF030 C" so the 8th char of the
567        // callsign is 'C' — the client/repeater module letter we
568        // want echoed at byte [8]. The reflector module is 'B'.
569        let n = encode_connect_ack(&mut buf, &cs(*b"XRF030 C"), Module::B)?;
570        assert_eq!(n, 14);
571        // First 7 chars of callsign: "XRF030 " (6 chars + trailing space).
572        assert_eq!(&buf[..7], b"XRF030 ", "callsign chars 0..7");
573        // Pad slot.
574        assert_eq!(buf[7], b' ', "pad slot");
575        // Repeater/client module letter (8th char of callsign).
576        assert_eq!(buf[8], b'C', "echoed repeater module");
577        // Reflector module letter.
578        assert_eq!(buf[9], b'B', "reflector module");
579        // Tag at [10..13], NUL at [13].
580        assert_eq!(&buf[10..13], b"ACK");
581        assert_eq!(buf[13], 0x00, "NUL terminator");
582        Ok(())
583    }
584
585    #[test]
586    fn encode_connect_nak_writes_14_bytes() -> TestResult {
587        let mut buf = [0u8; 16];
588        let n = encode_connect_nak(&mut buf, &cs(*b"XRF030 C"), Module::B)?;
589        assert_eq!(n, 14);
590        assert_eq!(&buf[..7], b"XRF030 ");
591        assert_eq!(buf[7], b' ');
592        assert_eq!(buf[8], b'C');
593        assert_eq!(buf[9], b'B');
594        assert_eq!(&buf[10..13], b"NAK");
595        assert_eq!(buf[13], 0x00);
596        Ok(())
597    }
598
599    #[test]
600    fn encode_connect_ack_rejects_small_buffer() -> TestResult {
601        let mut buf = [0u8; 13];
602        let Err(err) = encode_connect_ack(&mut buf, &cs(*b"XRF030  "), Module::C) else {
603            return Err("expected too small".into());
604        };
605        assert_eq!(err, EncodeError::BufferTooSmall { need: 14, have: 13 });
606        Ok(())
607    }
608
609    // ─── Poll / poll echo tests ────────────────────────────────
610    #[test]
611    fn encode_poll_writes_9_bytes() -> TestResult {
612        let mut buf = [0u8; 16];
613        let n = encode_poll(&mut buf, &cs(*b"W1AW    "))?;
614        assert_eq!(n, 9);
615        assert_eq!(&buf[..8], b"W1AW    ");
616        assert_eq!(buf[8], 0x00);
617        Ok(())
618    }
619
620    #[test]
621    fn encode_poll_echo_matches_poll() -> TestResult {
622        let mut poll_buf = [0u8; 16];
623        let mut echo_buf = [0u8; 16];
624        let n1 = encode_poll(&mut poll_buf, &cs(*b"XRF030  "))?;
625        let n2 = encode_poll_echo(&mut echo_buf, &cs(*b"XRF030  "))?;
626        assert_eq!(n1, n2);
627        assert_eq!(
628            poll_buf.get(..n1).ok_or("n1 within poll_buf")?,
629            echo_buf.get(..n2).ok_or("n2 within echo_buf")?,
630        );
631        Ok(())
632    }
633
634    #[test]
635    fn encode_poll_rejects_small_buffer() -> TestResult {
636        let mut buf = [0u8; 8];
637        let Err(err) = encode_poll(&mut buf, &cs(*b"W1AW    ")) else {
638            return Err("expected too small".into());
639        };
640        assert_eq!(err, EncodeError::BufferTooSmall { need: 9, have: 8 });
641        Ok(())
642    }
643
644    // ─── Voice header tests ────────────────────────────────────
645    #[test]
646    fn encode_voice_header_writes_56_bytes() -> TestResult {
647        let mut buf = [0u8; 64];
648        let n = encode_voice_header(&mut buf, sid(0xCAFE), &test_header())?;
649        assert_eq!(n, 56);
650        assert_eq!(&buf[..4], b"DSVT", "magic at offset 0 (no DPlus prefix)");
651        assert_eq!(buf[4], 0x10, "header type");
652        assert_eq!(buf[5], 0x00);
653        assert_eq!(buf[6], 0x00);
654        assert_eq!(buf[7], 0x00);
655        assert_eq!(buf[8], 0x20, "config");
656        assert_eq!(buf[9], 0x00, "band1");
657        assert_eq!(buf[10], 0x01, "band2");
658        assert_eq!(buf[11], 0x02, "band3");
659        assert_eq!(buf[12], 0xFE, "stream id LE low byte");
660        assert_eq!(buf[13], 0xCA, "stream id LE high byte");
661        assert_eq!(buf[14], 0x80, "header indicator");
662        assert_eq!(buf[15], 0, "flag1 zeroed by encode_for_dsvt");
663        assert_eq!(buf[16], 0, "flag2 zeroed");
664        assert_eq!(buf[17], 0, "flag3 zeroed");
665        Ok(())
666    }
667
668    #[test]
669    fn encode_voice_header_rejects_small_buffer() -> TestResult {
670        let mut buf = [0u8; 32];
671        let Err(err) = encode_voice_header(&mut buf, sid(0x1234), &test_header()) else {
672            return Err("expected too small".into());
673        };
674        assert_eq!(err, EncodeError::BufferTooSmall { need: 56, have: 32 });
675        Ok(())
676    }
677
678    // ─── Voice data tests ──────────────────────────────────────
679    #[test]
680    fn encode_voice_data_writes_27_bytes() -> TestResult {
681        let mut buf = [0u8; 64];
682        let frame = VoiceFrame {
683            ambe: [0x11; 9],
684            slow_data: [0x22; 3],
685        };
686        let n = encode_voice_data(&mut buf, sid(0x1234), 5, &frame)?;
687        assert_eq!(n, 27);
688        assert_eq!(&buf[..4], b"DSVT");
689        assert_eq!(buf[4], 0x20, "voice type");
690        assert_eq!(buf[5], 0x00);
691        assert_eq!(buf[6], 0x00);
692        assert_eq!(buf[7], 0x00);
693        assert_eq!(buf[8], 0x20, "config");
694        assert_eq!(buf[9], 0x00);
695        assert_eq!(buf[10], 0x01);
696        assert_eq!(buf[11], 0x02);
697        assert_eq!(buf[12], 0x34, "stream id LE low byte");
698        assert_eq!(buf[13], 0x12, "stream id LE high byte");
699        assert_eq!(buf[14], 5, "seq");
700        assert_eq!(&buf[15..24], &[0x11; 9]);
701        assert_eq!(&buf[24..27], &[0x22; 3]);
702        Ok(())
703    }
704
705    #[test]
706    fn encode_voice_data_rejects_small_buffer() -> TestResult {
707        let mut buf = [0u8; 10];
708        let frame = VoiceFrame {
709            ambe: [0; 9],
710            slow_data: [0; 3],
711        };
712        let Err(err) = encode_voice_data(&mut buf, sid(0x1234), 0, &frame) else {
713            return Err("expected too small".into());
714        };
715        assert_eq!(err, EncodeError::BufferTooSmall { need: 27, have: 10 });
716        Ok(())
717    }
718
719    // ─── Voice EOT tests ───────────────────────────────────────
720    #[test]
721    fn encode_voice_eot_writes_27_bytes() -> TestResult {
722        let mut buf = [0u8; 64];
723        let n = encode_voice_eot(&mut buf, sid(0x1234), 7)?;
724        assert_eq!(n, 27);
725        assert_eq!(&buf[..4], b"DSVT");
726        assert_eq!(buf[4], 0x20);
727        assert_eq!(buf[14] & 0x40, 0x40, "EOT bit set");
728        assert_eq!(buf[14] & 0x3F, 7, "low bits preserve seq");
729        assert_eq!(&buf[15..24], &AMBE_SILENCE);
730        assert_eq!(&buf[24..27], &DSTAR_SYNC_BYTES);
731        Ok(())
732    }
733
734    #[test]
735    fn encode_voice_eot_rejects_small_buffer() -> TestResult {
736        let mut buf = [0u8; 20];
737        let Err(err) = encode_voice_eot(&mut buf, sid(0x1234), 0) else {
738            return Err("expected too small".into());
739        };
740        assert_eq!(err, EncodeError::BufferTooSmall { need: 27, have: 20 });
741        Ok(())
742    }
743}