dstar_gateway_core/codec/dcs/
encode.rs

1//! `DCS` 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::VoiceFrame;
10
11use super::consts::{
12    CONNECT_ACK_TAG, CONNECT_NAK_TAG, CONNECT_REPLY_LEN, LINK_HTML_DONGLE, LINK_HTML_HOTSPOT,
13    LINK_HTML_REPEATER, LINK_HTML_STARNET, LINK_LEN, POLL_LEN, UNLINK_LEN, VOICE_EOT_MARKER,
14    VOICE_LEN, VOICE_MAGIC,
15};
16use super::packet::GatewayType;
17
18/// Encode a 519-byte LINK request.
19///
20/// Layout per `ircDDBGateway/Common/ConnectData.cpp:337-363`
21/// (`getDCSData CT_LINK1`):
22/// - `[0..7]`: first 7 chars of the client repeater callsign
23/// - `[7]`: space padding (from `memset(data, ' ', 8)`)
24/// - `[8]`: client module letter (8th char of the repeater)
25/// - `[9]`: reflector module letter
26/// - `[10]`: `0x00`
27/// - `[11..18]`: first 7 chars of the reflector callsign
28/// - `[18]`: space padding (from `memset(data + 11, ' ', 8)`)
29/// - `[19..519]`: 500-byte HTML banner identifying the gateway type,
30///   zero-padded. The receiving reflector logs this banner but does
31///   not parse it — any short ASCII payload that fits in 500 bytes
32///   is valid.
33///
34/// # Errors
35///
36/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 519`.
37///
38/// # See also
39///
40/// `ircDDBGateway/Common/ConnectData.cpp:337-363` (`getDCSData`
41/// `CT_LINK1` branch) for the reference encoder this function
42/// mirrors.
43pub fn encode_connect_link(
44    out: &mut [u8],
45    callsign: &Callsign,
46    client_module: Module,
47    reflector_module: Module,
48    reflector_callsign: &Callsign,
49    gateway_type: GatewayType,
50) -> Result<usize, EncodeError> {
51    if out.len() < LINK_LEN {
52        return Err(EncodeError::BufferTooSmall {
53            need: LINK_LEN,
54            have: out.len(),
55        });
56    }
57    // Zero-initialize the full 519-byte region so the HTML tail is
58    // zero-padded per the reference (`memset(data + 19U, 0x00U, 500U)`).
59    if let Some(region) = out.get_mut(..LINK_LEN) {
60        for b in region {
61            *b = 0x00;
62        }
63    }
64    write_connect_prefix(
65        out,
66        *callsign,
67        client_module.as_byte(),
68        reflector_module.as_byte(),
69    );
70    // Reflector callsign at [11..19]. Reference does
71    //   memset(data + 11, ' ', 8)
72    //   for i in 0..min(reflector.Len(), 7)
73    //       data[i + 11] = reflector.GetChar(i)
74    // so byte [18] stays as a space from the memset — we write
75    // only the first 7 chars.
76    if let Some(region) = out.get_mut(11..19) {
77        region.fill(b' ');
78    }
79    let rc = reflector_callsign.as_bytes();
80    if let Some(dst) = out.get_mut(11..18)
81        && let Some(src) = rc.get(..7)
82    {
83        dst.copy_from_slice(src);
84    }
85    // Copy the HTML banner at [19..]. Any trailing bytes are left as
86    // zeros from the memset above.
87    let html = html_for(gateway_type);
88    let copy_len = html.len().min(LINK_LEN - 19);
89    if let Some(dst) = out.get_mut(19..19 + copy_len)
90        && let Some(src) = html.get(..copy_len)
91    {
92        dst.copy_from_slice(src);
93    }
94    Ok(LINK_LEN)
95}
96
97/// Encode a 19-byte UNLINK request.
98///
99/// Layout per `ircDDBGateway/Common/ConnectData.cpp:366-372`
100/// (`getDCSData CT_UNLINK`):
101/// - `[0..7]`: first 7 chars of the client callsign
102/// - `[7]`: space padding
103/// - `[8]`: client module letter (8th char of the repeater)
104/// - `[9]`: `0x20` (space — the unlink marker)
105/// - `[10]`: `0x00`
106/// - `[11..18]`: first 7 chars of the reflector callsign
107/// - `[18]`: space padding
108///
109/// # Errors
110///
111/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 19`.
112///
113/// # See also
114///
115/// `ircDDBGateway/Common/ConnectData.cpp:366-372` (`getDCSData`
116/// `CT_UNLINK` branch).
117pub fn encode_connect_unlink(
118    out: &mut [u8],
119    callsign: &Callsign,
120    client_module: Module,
121    reflector_callsign: &Callsign,
122) -> Result<usize, EncodeError> {
123    if out.len() < UNLINK_LEN {
124        return Err(EncodeError::BufferTooSmall {
125            need: UNLINK_LEN,
126            have: out.len(),
127        });
128    }
129    write_connect_prefix(out, *callsign, client_module.as_byte(), b' ');
130    // Reflector callsign at [11..19] — first 7 chars + space pad
131    // matching the reference `memset + loop i<7`.
132    if let Some(region) = out.get_mut(11..19) {
133        region.fill(b' ');
134    }
135    let rc = reflector_callsign.as_bytes();
136    if let Some(dst) = out.get_mut(11..18)
137        && let Some(src) = rc.get(..7)
138    {
139        dst.copy_from_slice(src);
140    }
141    Ok(UNLINK_LEN)
142}
143
144/// Encode a 14-byte connect ACK reply.
145///
146/// Layout per `ircDDBGateway/Common/ConnectData.cpp:374-380`
147/// (`getDCSData CT_ACK`):
148/// - `[0..7]`: first 7 chars of the echoed callsign
149/// - `[7]`: space padding
150/// - `[8]`: 8th char of the echoed callsign (the repeater/client
151///   module letter)
152/// - `[9]`: reflector module letter
153/// - `[10..13]`: `b"ACK"`
154/// - `[13]`: `0x00` NUL terminator
155///
156/// # Errors
157///
158/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 14`.
159///
160/// # See also
161///
162/// `ircDDBGateway/Common/ConnectData.cpp:374-380` (`getDCSData`
163/// `CT_ACK` branch).
164pub fn encode_connect_ack(
165    out: &mut [u8],
166    callsign: &Callsign,
167    reflector_module: Module,
168) -> Result<usize, EncodeError> {
169    write_connect_reply(out, *callsign, reflector_module, CONNECT_ACK_TAG)?;
170    Ok(CONNECT_REPLY_LEN)
171}
172
173/// Encode a 14-byte connect NAK reply.
174///
175/// Same shape as [`encode_connect_ack`] but with `b"NAK"` at `[10..13]`.
176///
177/// # Errors
178///
179/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 14`.
180///
181/// # See also
182///
183/// `ircDDBGateway/Common/ConnectData.cpp:382-388` (`getDCSData`
184/// `CT_NAK` branch).
185pub fn encode_connect_nak(
186    out: &mut [u8],
187    callsign: &Callsign,
188    reflector_module: Module,
189) -> Result<usize, EncodeError> {
190    write_connect_reply(out, *callsign, reflector_module, CONNECT_NAK_TAG)?;
191    Ok(CONNECT_REPLY_LEN)
192}
193
194/// Encode a 17-byte poll (keepalive) request.
195///
196/// Layout per `ircDDBGateway/Common/PollData.cpp:170-186`
197/// (`getDCSData` direction `DIR_OUTGOING`):
198/// - `[0..8]`: space-padded client callsign
199/// - `[8]`: `0x00`
200/// - `[9..17]`: space-padded reflector callsign
201///
202/// # Errors
203///
204/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 17`.
205///
206/// # See also
207///
208/// `ircDDBGateway/Common/PollData.cpp:170-186` (`getDCSData`)
209/// for the reference keepalive encoder.
210pub fn encode_poll_request(
211    out: &mut [u8],
212    callsign: &Callsign,
213    reflector_callsign: &Callsign,
214) -> Result<usize, EncodeError> {
215    write_poll(out, *callsign, *reflector_callsign)?;
216    Ok(POLL_LEN)
217}
218
219/// Encode a 17-byte poll (keepalive) reply.
220///
221/// Identical to [`encode_poll_request`] — the server side of the poll
222/// is byte-for-byte symmetric in this codec (matching xlxd's 17-byte
223/// keepalive shape).
224///
225/// # Errors
226///
227/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 17`.
228pub fn encode_poll_reply(
229    out: &mut [u8],
230    callsign: &Callsign,
231    reflector_callsign: &Callsign,
232) -> Result<usize, EncodeError> {
233    write_poll(out, *callsign, *reflector_callsign)?;
234    Ok(POLL_LEN)
235}
236
237/// Encode a 100-byte voice frame.
238///
239/// Layout per `ircDDBGateway/Common/AMBEData.cpp:391-431`
240/// (`getDCSData`) combined with
241/// `ircDDBGateway/Common/HeaderData.cpp:515-529` (`getDCSData`
242/// embedding):
243/// - `[0..4]`: `b"0001"` magic
244/// - `[4..7]`: header flag bytes (flag1/flag2/flag3)
245/// - `[7..15]`: RPT2 callsign (gateway)
246/// - `[15..23]`: RPT1 callsign (access)
247/// - `[23..31]`: YOUR callsign
248/// - `[31..39]`: MY callsign
249/// - `[39..43]`: MY suffix (4 bytes)
250/// - `[43..45]`: stream id little-endian
251/// - `[45]`: frame seq byte
252/// - `[46..55]`: 9 AMBE bytes
253/// - `[55..58]`: slow data (or `0x55 0x55 0x55` if `is_end`)
254/// - `[58..61]`: 3-byte repeater sequence counter (always zero in this
255///   codec — clients that care can layer counters on top)
256/// - `[61]`: `0x01`
257/// - `[62]`: `0x00`
258/// - `[63]`: `0x21`
259/// - `[64..84]`: 20-byte text field (zero-filled)
260/// - `[84..100]`: 16 bytes of zero padding
261///
262/// # Errors
263///
264/// Returns [`EncodeError::BufferTooSmall`] if `out.len() < 100`.
265///
266/// # See also
267///
268/// `ircDDBGateway/Common/AMBEData.cpp:391-431` (`getDCSData`) and
269/// `ircDDBGateway/Common/HeaderData.cpp:515-529` (`getDCSData`
270/// embedding) for the reference encoders this function combines
271/// into a single 100-byte DCS voice frame.
272pub fn encode_voice(
273    out: &mut [u8],
274    header: &DStarHeader,
275    stream_id: StreamId,
276    seq: u8,
277    frame: &VoiceFrame,
278    is_end: bool,
279) -> Result<usize, EncodeError> {
280    if out.len() < VOICE_LEN {
281        return Err(EncodeError::BufferTooSmall {
282            need: VOICE_LEN,
283            have: out.len(),
284        });
285    }
286    // Zero-initialize the 100-byte region (AMBEData.cpp:396
287    // `memset(data, 0x00U, 100U)`).
288    if let Some(region) = out.get_mut(..VOICE_LEN) {
289        for b in region {
290            *b = 0x00;
291        }
292    }
293    // Magic "0001" at [0..4].
294    if let Some(dst) = out.get_mut(..4) {
295        dst.copy_from_slice(&VOICE_MAGIC);
296    }
297    // Embedded header at [4..43] per HeaderData.cpp:520-528.
298    if let Some(b) = out.get_mut(4) {
299        *b = header.flag1;
300    }
301    if let Some(b) = out.get_mut(5) {
302        *b = header.flag2;
303    }
304    if let Some(b) = out.get_mut(6) {
305        *b = header.flag3;
306    }
307    if let Some(dst) = out.get_mut(7..15) {
308        dst.copy_from_slice(header.rpt2.as_bytes());
309    }
310    if let Some(dst) = out.get_mut(15..23) {
311        dst.copy_from_slice(header.rpt1.as_bytes());
312    }
313    if let Some(dst) = out.get_mut(23..31) {
314        dst.copy_from_slice(header.ur_call.as_bytes());
315    }
316    if let Some(dst) = out.get_mut(31..39) {
317        dst.copy_from_slice(header.my_call.as_bytes());
318    }
319    if let Some(dst) = out.get_mut(39..43) {
320        dst.copy_from_slice(header.my_suffix.as_bytes());
321    }
322    // Stream id at [43..45] little-endian.
323    let sid = stream_id.get().to_le_bytes();
324    if let Some(b) = out.get_mut(43) {
325        *b = sid[0];
326    }
327    if let Some(b) = out.get_mut(44) {
328        *b = sid[1];
329    }
330    // Seq byte at [45]. The reference does NOT OR 0x40 for EOT in
331    // DCS — the EOT marker is in bytes [55..58] instead. xlxd DOES
332    // OR 0x40 (see cdcsprotocol.cpp:558), and the reference decoder
333    // checks for it (AMBEData.cpp shows the same). We mirror xlxd and
334    // set the bit when is_end is true, matching the protocol the
335    // decoder expects.
336    let seq_byte = if is_end { seq | 0x40 } else { seq };
337    if let Some(b) = out.get_mut(45) {
338        *b = seq_byte;
339    }
340    // AMBE at [46..55].
341    if let Some(dst) = out.get_mut(46..55) {
342        dst.copy_from_slice(&frame.ambe);
343    }
344    // Slow data at [55..58], or EOT sentinel if is_end.
345    if is_end {
346        if let Some(dst) = out.get_mut(55..58) {
347            dst.copy_from_slice(&VOICE_EOT_MARKER);
348        }
349    } else if let Some(dst) = out.get_mut(55..58) {
350        dst.copy_from_slice(&frame.slow_data);
351    }
352    // [58..61] rpt seq counter — zero for now.
353    // [61] = 0x01, [62] = 0x00, [63] = 0x21 per AMBEData.cpp:420-423.
354    if let Some(b) = out.get_mut(61) {
355        *b = 0x01;
356    }
357    if let Some(b) = out.get_mut(62) {
358        *b = 0x00;
359    }
360    if let Some(b) = out.get_mut(63) {
361        *b = 0x21;
362    }
363    // [64..100] already zeroed above (text + trailing padding).
364    Ok(VOICE_LEN)
365}
366
367/// Internal helper: pick the HTML banner for a given `GatewayType`.
368const fn html_for(gateway_type: GatewayType) -> &'static [u8] {
369    match gateway_type {
370        GatewayType::Repeater => LINK_HTML_REPEATER,
371        GatewayType::Hotspot => LINK_HTML_HOTSPOT,
372        GatewayType::Dongle => LINK_HTML_DONGLE,
373        GatewayType::StarNet => LINK_HTML_STARNET,
374    }
375}
376
377/// Internal helper: write the 11-byte LINK/UNLINK prefix shared by
378/// `encode_connect_link` and `encode_connect_unlink`.
379///
380/// Mirrors `ircDDBGateway/Common/ConnectData.cpp:323-393`
381/// (`getDCSData`) which uses the same layout as `DExtra` for the
382/// first 11 bytes:
383/// ```text
384/// memset(data, ' ', 8)                       // out[0..8] = spaces
385/// for i in 0..min(repeater.Len(), 7)         // out[0..7] = first 7 chars
386///     data[i] = repeater.GetChar(i)
387/// data[8] = repeater.GetChar(7)              // out[8] = client module
388/// data[9] = reflector.GetChar(7) or 0x20     // out[9] = byte9
389/// data[10] = 0x00
390/// ```
391fn write_connect_prefix(out: &mut [u8], callsign: Callsign, byte8: u8, byte9: u8) {
392    // Fill the first 11 bytes with spaces so out[7] stays as the
393    // memset pad slot regardless of what's written later.
394    if let Some(region) = out.get_mut(..11) {
395        region.fill(b' ');
396    }
397    // out[0..7] = first 7 chars of the client/repeater callsign.
398    // The reference loop uses `i < 7`, so we never touch 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.
406    if let Some(b) = out.get_mut(8) {
407        *b = byte8;
408    }
409    // out[9] = reflector module letter (or 0x20 for UNLINK).
410    if let Some(b) = out.get_mut(9) {
411        *b = byte9;
412    }
413    // out[10] = 0x00.
414    if let Some(b) = out.get_mut(10) {
415        *b = 0x00;
416    }
417}
418
419/// Internal helper: write a 14-byte connect reply (ACK or NAK).
420///
421/// Layout per `ircDDBGateway/Common/ConnectData.cpp:374-393`
422/// (`getDCSData CT_ACK`/`CT_NAK`):
423/// - `[0..7]` first 7 chars of the echoed callsign
424/// - `[7]` space padding (from `memset`)
425/// - `[8]` 8th char of the echoed callsign — the repeater/client
426///   module letter (`m_repeater.GetChar(7)`)
427/// - `[9]` reflector module letter
428/// - `[10..13]` `tag` (`b"ACK"` or `b"NAK"`)
429/// - `[13]` `0x00` NUL terminator
430fn write_connect_reply(
431    out: &mut [u8],
432    callsign: Callsign,
433    reflector_module: Module,
434    tag: [u8; 3],
435) -> Result<(), EncodeError> {
436    if out.len() < CONNECT_REPLY_LEN {
437        return Err(EncodeError::BufferTooSmall {
438            need: CONNECT_REPLY_LEN,
439            have: out.len(),
440        });
441    }
442    // Fill the first 14 bytes with spaces so out[7] is the pad
443    // slot and any unwritten positions default to space.
444    if let Some(region) = out.get_mut(..CONNECT_REPLY_LEN) {
445        region.fill(b' ');
446    }
447    // out[0..7] = first 7 chars of the callsign.
448    let cs = callsign.as_bytes();
449    if let Some(dst) = out.get_mut(..7)
450        && let Some(src) = cs.get(..7)
451    {
452        dst.copy_from_slice(src);
453    }
454    // out[8] = 8th char of the callsign (the repeater/client
455    // module letter from the original LINK request we're echoing).
456    if let Some(b) = out.get_mut(8) {
457        *b = cs.get(7).copied().unwrap_or(b' ');
458    }
459    // out[9] = reflector module letter.
460    if let Some(b) = out.get_mut(9) {
461        *b = reflector_module.as_byte();
462    }
463    // out[10..13] = 3-byte tag.
464    if let Some(dst) = out.get_mut(10..13) {
465        dst.copy_from_slice(&tag);
466    }
467    // out[13] = 0x00 NUL terminator.
468    if let Some(b) = out.get_mut(13) {
469        *b = 0x00;
470    }
471    Ok(())
472}
473
474/// Internal helper: write a 17-byte poll packet.
475fn write_poll(
476    out: &mut [u8],
477    callsign: Callsign,
478    reflector_callsign: Callsign,
479) -> Result<(), EncodeError> {
480    if out.len() < POLL_LEN {
481        return Err(EncodeError::BufferTooSmall {
482            need: POLL_LEN,
483            have: out.len(),
484        });
485    }
486    if let Some(dst) = out.get_mut(..8) {
487        dst.copy_from_slice(callsign.as_bytes());
488    }
489    if let Some(b) = out.get_mut(8) {
490        *b = 0x00;
491    }
492    if let Some(dst) = out.get_mut(9..17) {
493        dst.copy_from_slice(reflector_callsign.as_bytes());
494    }
495    Ok(())
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::types::Suffix;
502
503    type TestResult = Result<(), Box<dyn std::error::Error>>;
504
505    const fn cs(bytes: [u8; 8]) -> Callsign {
506        Callsign::from_wire_bytes(bytes)
507    }
508
509    #[expect(clippy::unwrap_used, reason = "compile-time validated: n != 0")]
510    const fn sid(n: u16) -> StreamId {
511        StreamId::new(n).unwrap()
512    }
513
514    const fn test_header() -> DStarHeader {
515        DStarHeader {
516            flag1: 0,
517            flag2: 0,
518            flag3: 0,
519            rpt2: Callsign::from_wire_bytes(*b"DCS001 G"),
520            rpt1: Callsign::from_wire_bytes(*b"DCS001 C"),
521            ur_call: Callsign::from_wire_bytes(*b"CQCQCQ  "),
522            my_call: Callsign::from_wire_bytes(*b"W1AW    "),
523            my_suffix: Suffix::EMPTY,
524        }
525    }
526
527    // ─── LINK tests ──────────────────────────────────────────
528    #[test]
529    fn encode_connect_link_writes_519_bytes() -> TestResult {
530        let mut buf = [0u8; 600];
531        let n = encode_connect_link(
532            &mut buf,
533            &cs(*b"W1AW    "),
534            Module::B,
535            Module::C,
536            &cs(*b"DCS001  "),
537            GatewayType::Repeater,
538        )?;
539        assert_eq!(n, 519);
540        // First 7 chars of client callsign at [0..7], pad at [7],
541        // client module at [8], reflector module at [9], NUL at [10].
542        assert_eq!(&buf[..7], b"W1AW   ", "client callsign chars 0..7");
543        assert_eq!(buf[7], b' ', "pad slot");
544        assert_eq!(buf[8], b'B', "client module at [8]");
545        assert_eq!(buf[9], b'C', "reflector module at [9]");
546        assert_eq!(buf[10], 0x00, "null at [10]");
547        // Reflector callsign: first 7 chars at [11..18], space at [18].
548        assert_eq!(&buf[11..18], b"DCS001 ", "reflector callsign chars 0..7");
549        assert_eq!(buf[18], b' ', "reflector pad slot");
550        // HTML at [19..]
551        assert!(
552            buf[19..].iter().any(|&b| b != 0),
553            "HTML region not all zero"
554        );
555        Ok(())
556    }
557
558    #[test]
559    fn encode_connect_link_rejects_small_buffer() -> TestResult {
560        let mut buf = [0u8; 500];
561        let Err(err) = encode_connect_link(
562            &mut buf,
563            &cs(*b"W1AW    "),
564            Module::B,
565            Module::C,
566            &cs(*b"DCS001  "),
567            GatewayType::Repeater,
568        ) else {
569            return Err("expected BufferTooSmall error".into());
570        };
571        assert_eq!(
572            err,
573            EncodeError::BufferTooSmall {
574                need: 519,
575                have: 500,
576            }
577        );
578        Ok(())
579    }
580
581    #[test]
582    fn encode_connect_link_hotspot_banner_differs_from_repeater() -> TestResult {
583        let mut buf_rep = [0u8; 600];
584        let mut buf_hot = [0u8; 600];
585        let _n1 = encode_connect_link(
586            &mut buf_rep,
587            &cs(*b"W1AW    "),
588            Module::B,
589            Module::C,
590            &cs(*b"DCS001  "),
591            GatewayType::Repeater,
592        )?;
593        let _n2 = encode_connect_link(
594            &mut buf_hot,
595            &cs(*b"W1AW    "),
596            Module::B,
597            Module::C,
598            &cs(*b"DCS001  "),
599            GatewayType::Hotspot,
600        )?;
601        assert_ne!(
602            &buf_rep[19..519],
603            &buf_hot[19..519],
604            "HTML region should differ by gateway type"
605        );
606        Ok(())
607    }
608
609    // ─── UNLINK tests ────────────────────────────────────────
610    #[test]
611    fn encode_connect_unlink_writes_19_bytes() -> TestResult {
612        let mut buf = [0u8; 32];
613        let n = encode_connect_unlink(&mut buf, &cs(*b"W1AW    "), Module::B, &cs(*b"DCS001  "))?;
614        assert_eq!(n, 19);
615        assert_eq!(&buf[..7], b"W1AW   ");
616        assert_eq!(buf[7], b' ', "pad slot");
617        assert_eq!(buf[8], b'B', "client module");
618        assert_eq!(buf[9], b' ', "space (unlink marker)");
619        assert_eq!(buf[10], 0x00);
620        assert_eq!(&buf[11..18], b"DCS001 ", "reflector callsign chars 0..7");
621        assert_eq!(buf[18], b' ', "reflector pad slot");
622        Ok(())
623    }
624
625    #[test]
626    fn encode_connect_unlink_rejects_small_buffer() -> TestResult {
627        let mut buf = [0u8; 10];
628        let Err(err) =
629            encode_connect_unlink(&mut buf, &cs(*b"W1AW    "), Module::B, &cs(*b"DCS001  "))
630        else {
631            return Err("expected BufferTooSmall error".into());
632        };
633        assert_eq!(err, EncodeError::BufferTooSmall { need: 19, have: 10 });
634        Ok(())
635    }
636
637    // ─── ACK/NAK tests ───────────────────────────────────────
638    #[test]
639    fn encode_connect_ack_writes_14_bytes() -> TestResult {
640        let mut buf = [0u8; 32];
641        // Test callsign "DCS001 C" so the 8th char is 'C' — the
642        // client/repeater module letter echoed at byte [8].
643        let n = encode_connect_ack(&mut buf, &cs(*b"DCS001 C"), Module::B)?;
644        assert_eq!(n, 14);
645        assert_eq!(&buf[..7], b"DCS001 ", "callsign chars 0..7");
646        assert_eq!(buf[7], b' ', "pad slot");
647        assert_eq!(buf[8], b'C', "echoed repeater module");
648        assert_eq!(buf[9], b'B', "reflector module");
649        assert_eq!(&buf[10..13], b"ACK");
650        assert_eq!(buf[13], 0x00, "NUL terminator");
651        Ok(())
652    }
653
654    #[test]
655    fn encode_connect_nak_writes_14_bytes() -> TestResult {
656        let mut buf = [0u8; 32];
657        let n = encode_connect_nak(&mut buf, &cs(*b"DCS001 C"), Module::B)?;
658        assert_eq!(n, 14);
659        assert_eq!(&buf[..7], b"DCS001 ");
660        assert_eq!(buf[7], b' ');
661        assert_eq!(buf[8], b'C');
662        assert_eq!(buf[9], b'B');
663        assert_eq!(&buf[10..13], b"NAK");
664        assert_eq!(buf[13], 0x00);
665        Ok(())
666    }
667
668    #[test]
669    fn encode_connect_ack_rejects_small_buffer() -> TestResult {
670        let mut buf = [0u8; 13];
671        let Err(err) = encode_connect_ack(&mut buf, &cs(*b"DCS001  "), Module::C) else {
672            return Err("expected BufferTooSmall error".into());
673        };
674        assert_eq!(err, EncodeError::BufferTooSmall { need: 14, have: 13 });
675        Ok(())
676    }
677
678    // ─── Poll tests ─────────────────────────────────────────
679    #[test]
680    fn encode_poll_request_writes_17_bytes() -> TestResult {
681        let mut buf = [0u8; 32];
682        let n = encode_poll_request(&mut buf, &cs(*b"W1AW    "), &cs(*b"DCS001  "))?;
683        assert_eq!(n, 17);
684        assert_eq!(&buf[..8], b"W1AW    ");
685        assert_eq!(buf[8], 0x00);
686        assert_eq!(&buf[9..17], b"DCS001  ");
687        Ok(())
688    }
689
690    #[test]
691    fn encode_poll_reply_matches_poll_request() -> TestResult {
692        let mut req = [0u8; 17];
693        let mut reply = [0u8; 17];
694        let n1 = encode_poll_request(&mut req, &cs(*b"W1AW    "), &cs(*b"DCS001  "))?;
695        let n2 = encode_poll_reply(&mut reply, &cs(*b"W1AW    "), &cs(*b"DCS001  "))?;
696        assert_eq!(n1, n2);
697        assert_eq!(req, reply);
698        Ok(())
699    }
700
701    #[test]
702    fn encode_poll_request_rejects_small_buffer() -> TestResult {
703        let mut buf = [0u8; 16];
704        let Err(err) = encode_poll_request(&mut buf, &cs(*b"W1AW    "), &cs(*b"DCS001  ")) else {
705            return Err("expected BufferTooSmall error".into());
706        };
707        assert_eq!(err, EncodeError::BufferTooSmall { need: 17, have: 16 });
708        Ok(())
709    }
710
711    // ─── Voice tests ────────────────────────────────────────
712    #[test]
713    fn encode_voice_writes_100_bytes() -> TestResult {
714        let mut buf = [0u8; 128];
715        let frame = VoiceFrame {
716            ambe: [0x11; 9],
717            slow_data: [0x22; 3],
718        };
719        let n = encode_voice(&mut buf, &test_header(), sid(0xCAFE), 5, &frame, false)?;
720        assert_eq!(n, 100);
721        assert_eq!(&buf[..4], b"0001", "magic");
722        assert_eq!(buf[4], 0, "flag1");
723        assert_eq!(buf[5], 0, "flag2");
724        assert_eq!(buf[6], 0, "flag3");
725        assert_eq!(&buf[7..15], b"DCS001 G", "rpt2");
726        assert_eq!(&buf[15..23], b"DCS001 C", "rpt1");
727        assert_eq!(&buf[23..31], b"CQCQCQ  ", "ur");
728        assert_eq!(&buf[31..39], b"W1AW    ", "my");
729        assert_eq!(&buf[39..43], b"    ", "suffix");
730        assert_eq!(buf[43], 0xFE, "stream id LE low");
731        assert_eq!(buf[44], 0xCA, "stream id LE high");
732        assert_eq!(buf[45], 5, "seq");
733        assert_eq!(&buf[46..55], &[0x11; 9], "AMBE");
734        assert_eq!(&buf[55..58], &[0x22; 3], "slow data");
735        assert_eq!(buf[61], 0x01);
736        assert_eq!(buf[62], 0x00);
737        assert_eq!(buf[63], 0x21);
738        Ok(())
739    }
740
741    #[test]
742    fn encode_voice_eot_sets_marker_and_seq_bit() -> TestResult {
743        let mut buf = [0u8; 128];
744        let frame = VoiceFrame {
745            ambe: [0x11; 9],
746            slow_data: [0x22; 3],
747        };
748        let n = encode_voice(&mut buf, &test_header(), sid(0xCAFE), 7, &frame, true)?;
749        assert_eq!(n, 100);
750        assert_eq!(buf[45] & 0x40, 0x40, "EOT bit set");
751        assert_eq!(buf[45] & 0x3F, 7, "low bits preserve seq");
752        assert_eq!(
753            &buf[55..58],
754            &VOICE_EOT_MARKER,
755            "EOT marker replaces slow data"
756        );
757        Ok(())
758    }
759
760    #[test]
761    fn encode_voice_rejects_small_buffer() -> TestResult {
762        let mut buf = [0u8; 64];
763        let frame = VoiceFrame {
764            ambe: [0; 9],
765            slow_data: [0; 3],
766        };
767        let Err(err) = encode_voice(&mut buf, &test_header(), sid(1), 0, &frame, false) else {
768            return Err("expected BufferTooSmall error".into());
769        };
770        assert_eq!(
771            err,
772            EncodeError::BufferTooSmall {
773                need: 100,
774                have: 64,
775            }
776        );
777        Ok(())
778    }
779
780    #[test]
781    fn encode_voice_embeds_flag_bytes_verbatim() -> TestResult {
782        let mut buf = [0u8; 128];
783        let header = DStarHeader {
784            flag1: 0xAA,
785            flag2: 0xBB,
786            flag3: 0xCC,
787            ..test_header()
788        };
789        let frame = VoiceFrame {
790            ambe: [0; 9],
791            slow_data: [0; 3],
792        };
793        let _n = encode_voice(&mut buf, &header, sid(1), 0, &frame, false)?;
794        assert_eq!(buf[4], 0xAA, "flag1 verbatim (not zeroed like DSVT)");
795        assert_eq!(buf[5], 0xBB);
796        assert_eq!(buf[6], 0xCC);
797        Ok(())
798    }
799}