Skip to main content

grafton_visca/command/
encode.rs

1//! Unified trait for encoding VISCA commands.
2//!
3//! This module provides the `ViscaCommand` trait which unifies the previous
4//! `Command` and `ViscaCommand` traits into a single interface with zero-allocation
5//! encoding support.
6
7use bytes::Bytes;
8use smallvec::SmallVec;
9
10use crate::{error::Error, timeout::CommandCategory, CameraId};
11
12use super::{bytes::FixedCommandBytes, response::InquiryKind};
13
14/// Command kind classification for VISCA protocol.
15///
16/// This enum distinguishes between command and inquiry messages,
17/// which have different response patterns and encapsulation requirements.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CommandKind {
20    /// A command that performs an action and returns ACK/Completion
21    Command,
22    /// An inquiry that retrieves data and returns a data response
23    Inquiry,
24}
25
26/// Complete command behavior metadata used by the runtime.
27///
28/// This is the single source of truth for whether a VISCA request is an action
29/// command or an inquiry, and for how inquiry data replies should be routed.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CommandBehavior {
32    /// A command that performs an action and returns ACK/Completion.
33    Command,
34    /// An inquiry that returns a data reply routed by the included response spec.
35    Inquiry(InquiryResponseSpec),
36}
37
38impl CommandBehavior {
39    /// Return the VISCA transport command kind derived from this behavior.
40    #[inline]
41    pub const fn command_kind(self) -> CommandKind {
42        match self {
43            Self::Command => CommandKind::Command,
44            Self::Inquiry(_) => CommandKind::Inquiry,
45        }
46    }
47
48    /// Return the inquiry response spec when this behavior is an inquiry.
49    #[inline]
50    pub const fn inquiry_response_spec(self) -> Option<InquiryResponseSpec> {
51        match self {
52            Self::Command => None,
53            Self::Inquiry(spec) => Some(spec),
54        }
55    }
56}
57
58/// Routing metadata for inquiry data replies.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum InquiryResponseSpec {
61    /// Decode the reply through the built-in, profile-aware inquiry decoder.
62    Builtin(InquiryKind),
63    /// Deliver the raw VISCA data payload to the command's `ResponseParser`.
64    Raw,
65}
66
67/// Checks that a VISCA command buffer has the proper terminator.
68///
69/// This function ensures that commands are properly terminated with 0xFF,
70/// a critical safety invariant for the VISCA protocol.
71///
72/// # Returns
73///
74/// Returns `Ok(())` if the buffer has valid terminator, or an error if not.
75#[inline]
76fn check_terminator(buffer: &[u8], len: usize) -> Result<(), Error> {
77    if len > 0 && buffer[len - 1] != crate::command::bytes::VISCA_TERMINATOR {
78        return Err(Error::InvalidRequest(
79            format!(
80                "VISCA command missing 0xFF terminator at position {pos}. Command bytes: {bytes:02X?}",
81                pos = len - 1,
82                bytes = &buffer[..len]
83            )
84            .into(),
85        ));
86    }
87    Ok(())
88}
89
90/// Checks that a VISCA command buffer has valid structure.
91///
92/// This function ensures:
93/// - Commands have proper terminator (0xFF)
94/// - Commands have valid camera address byte (0x81-0x88)
95/// - Commands have minimum required length
96///
97/// These are critical safety invariants for the VISCA protocol.
98///
99/// # Returns
100///
101/// Returns `Ok(())` if the buffer meets all requirements, or an error if not.
102#[inline]
103fn check_command_structure(buffer: &[u8], len: usize) -> Result<(), Error> {
104    // Validate minimum length (at least address + terminator)
105    if len < 2 {
106        return Err(Error::InvalidRequest(
107            format!(
108                "VISCA command too short: {len} bytes. Minimum is 2 bytes. Command bytes: {bytes:02X?}",
109                bytes = &buffer[..len]
110            )
111            .into(),
112        ));
113    }
114
115    // Validate camera address byte (0x81-0x88 for cameras 1-8)
116    if len > 0 && (buffer[0] < 0x81 || buffer[0] > 0x88) {
117        return Err(Error::InvalidRequest(
118            format!(
119                "Invalid VISCA camera address byte: 0x{addr:02X}. Must be 0x81-0x88. Command bytes: {bytes:02X?}",
120                addr = buffer[0],
121                bytes = &buffer[..len]
122            )
123            .into(),
124        ));
125    }
126
127    // Validate terminator
128    check_terminator(buffer, len)?;
129    Ok(())
130}
131
132/// Unified trait for all VISCA commands.
133///
134/// This trait combines the functionality of the previous `Command` and `ViscaCommand`
135/// traits, providing both zero-allocation encoding and convenient heap-allocated methods.
136///
137/// # Example Implementation
138/// ```ignore
139/// # use grafton_visca::timeout::CommandCategory;
140/// # use grafton_visca::CameraId;
141/// # use grafton_visca::Error;
142/// struct MyCommand;
143///
144/// impl ViscaCommand for MyCommand {
145///     const MAX_SIZE: usize = 6;
146///     const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
147///
148///     fn write_into(&self, camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error> {
149///         // Check buffer size
150///         if buffer.len() < 6 {
151///             return Err(Error::BufferTooSmall { required: 6, actual: buffer.len() });
152///         }
153///
154///         // Write VISCA command bytes
155///         buffer[0] = camera_id.to_address_byte();  // Dynamic camera ID
156///         buffer[1] = 0x01;
157///         buffer[2] = 0x04;
158///         buffer[3] = 0x00;
159///         buffer[4] = 0x02;
160///         buffer[5] = 0xFF;
161///         Ok(6)
162///     }
163///
164///     // Action commands use the default CommandBehavior::Command behavior.
165/// }
166/// ```
167pub trait ViscaCommand: Send + Sync {
168    /// Maximum size in bytes that this command can encode to.
169    const MAX_SIZE: usize;
170
171    /// The timeout category for this command.
172    ///
173    /// This constant determines the appropriate timeout duration for the command
174    /// based on its expected execution time. Defaults to `CommandCategory::Custom`
175    /// which uses the default timeout duration.
176    const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Custom;
177
178    /// Exact encoded byte length for *this instance* (default: MAX_SIZE).
179    ///
180    /// This method enables exact-size buffer allocation, avoiding over-allocation
181    /// when the actual encoded size is smaller than MAX_SIZE. Commands with
182    /// variable-length parameters should override this to return the precise size.
183    #[inline]
184    fn encoded_size(&self) -> usize {
185        Self::MAX_SIZE
186    }
187
188    /// Writes the command into the provided buffer.
189    ///
190    /// This is the primary method for zero-allocation encoding. The buffer must
191    /// be at least `MAX_SIZE` bytes. Returns the number of bytes written.
192    ///
193    /// # Arguments
194    ///
195    /// * `camera_id` - The camera ID to address the command to
196    /// * `buffer` - The buffer to write the command bytes into
197    ///
198    /// # Returns
199    ///
200    /// The number of bytes written to the buffer
201    ///
202    /// # Errors
203    ///
204    /// * `Error::BufferTooSmall` if the buffer is smaller than required
205    /// * `Error::InvalidParameter` if the command contains invalid parameters
206    fn write_into(&self, camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error>;
207
208    /// Encodes the command to a fixed-size, length-aware buffer.
209    ///
210    /// This method provides stack-allocated encoding for compile-time known sizes,
211    /// returning a [`FixedCommandBytes`] that carries both the bytes and the actual
212    /// encoded length. This prevents accidental transmission of trailing bytes.
213    ///
214    /// # Arguments
215    ///
216    /// * `camera_id` - The camera ID to address the command to
217    ///
218    /// # Returns
219    ///
220    /// A [`FixedCommandBytes<N>`] containing the encoded command. Use [`as_slice()`](FixedCommandBytes::as_slice)
221    /// or [`AsRef<[u8]>`](AsRef) to access only the meaningful bytes.
222    ///
223    /// # Errors
224    ///
225    /// * `Error::BufferTooSmall` if N is smaller than the encoded size
226    /// * `Error::InvalidParameter` if the command contains invalid parameters
227    fn to_fixed_bytes<const N: usize>(
228        &self,
229        camera_id: CameraId,
230    ) -> Result<FixedCommandBytes<N>, Error> {
231        let mut buffer = [0u8; N];
232        let size = self.write_into(camera_id, &mut buffer)?;
233        if size > N {
234            return Err(Error::BufferTooSmall {
235                required: size,
236                actual: N,
237            });
238        }
239
240        // Validate command structure
241        check_command_structure(&buffer, size)?;
242
243        Ok(FixedCommandBytes::new(buffer, size))
244    }
245
246    /// Encodes the command to a `bytes::Bytes` buffer.
247    ///
248    /// This method provides zero-copy reference-counted buffers via `bytes::Bytes`,
249    /// encoding into a BytesMut buffer, validating, and freezing the result. This is
250    /// optimal for runtime usage where commands are sent through queues and retried,
251    /// avoiding extra allocations and copies.
252    ///
253    /// Uses `encoded_size()` for exact-size allocation, avoiding over-allocation.
254    ///
255    /// # Arguments
256    ///
257    /// * `camera_id` - The camera ID to address the command to
258    ///
259    /// # Errors
260    ///
261    /// * `Error::InvalidParameter` if the command contains invalid parameters
262    /// * `Error::InvalidRequest` if command structure validation fails
263    fn to_bytes(&self, camera_id: CameraId) -> Result<Bytes, Error> {
264        let need = self.encoded_size();
265        let mut buf = bytes::BytesMut::with_capacity(need);
266        // Give write_into a full mutable slice
267        buf.resize(need, 0);
268
269        let len = self.write_into(camera_id, &mut buf)?;
270
271        // Validate command structure before freezing
272        check_command_structure(&buf, len)?;
273
274        // Truncate to actual size and freeze
275        buf.truncate(len);
276        Ok(buf.freeze())
277    }
278
279    /// Returns the complete behavior and response routing metadata for this command.
280    ///
281    /// Action commands use the default [`CommandBehavior::Command`] behavior.
282    /// Built-in inquiries should return
283    /// `CommandBehavior::Inquiry(InquiryResponseSpec::Builtin(...))`.
284    /// Custom inquiries that parse their own data replies should return
285    /// `CommandBehavior::Inquiry(InquiryResponseSpec::Raw)`.
286    #[inline(always)]
287    fn behavior(&self) -> CommandBehavior {
288        CommandBehavior::Command
289    }
290}
291
292/// Inline buffer size for encoded commands - 24 bytes covers most commands without heap allocation.
293///
294/// Maximum VISCA command size is 15 bytes, so 24 bytes provides headroom for common cases.
295pub(crate) const INLINE_COMMAND_SIZE: usize = 24;
296
297/// Pre-encoded command that stores the VISCA bytes inline for zero-allocation sends.
298///
299/// This struct replaces `PreparedCommand` and uses `SmallVec` to store command bytes
300/// inline on the stack for common command sizes, eliminating heap allocations in the
301/// hot send path.
302///
303#[derive(Debug, Clone)]
304pub(crate) struct EncodedCommand {
305    /// The encoded VISCA bytes stored inline for zero-allocation.
306    /// Uses SmallVec with inline capacity of 24 bytes (covers most commands).
307    pub(crate) payload: SmallVec<[u8; INLINE_COMMAND_SIZE]>,
308    /// Complete command behavior and inquiry response routing metadata.
309    pub(crate) behavior: CommandBehavior,
310    /// The timeout category for this command.
311    pub(crate) category: CommandCategory,
312}
313
314impl EncodedCommand {
315    /// Create a new EncodedCommand from a ViscaCommand implementation.
316    ///
317    /// This encodes the command once using `write_into` and stores the bytes
318    /// inline for repeated zero-allocation sends.
319    ///
320    /// # Arguments
321    ///
322    /// * `cmd` - A reference to the command to encode
323    /// * `camera_id` - The camera ID to address the command to
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if encoding fails or command structure is invalid.
328    ///
329    /// # Note
330    ///
331    /// This method takes a reference to the command, eliminating the need for
332    /// `Clone` bounds on command types and avoiding unnecessary deep copies
333    /// (particularly important for heap-backed commands like `RawCommand`).
334    pub(crate) fn new<C: ViscaCommand>(cmd: &C, camera_id: CameraId) -> Result<Self, Error> {
335        // Allocate inline buffer sized for the command
336        let size = cmd.encoded_size();
337        let mut payload = SmallVec::with_capacity(size);
338
339        // Resize to provide mutable slice for write_into
340        payload.resize(size, 0);
341
342        // Encode directly into the inline buffer
343        let len = cmd.write_into(camera_id, &mut payload)?;
344
345        // Validate command structure
346        check_command_structure(&payload, len)?;
347
348        // Truncate to actual size
349        payload.truncate(len);
350
351        // Extract metadata from the command
352        let behavior = cmd.behavior();
353        let category = C::TIMEOUT_CATEGORY;
354
355        Ok(Self {
356            payload,
357            behavior,
358            category,
359        })
360    }
361
362    /// Get the encoded command bytes as a slice.
363    ///
364    /// This provides zero-copy access to the inline buffer for framing operations.
365    #[inline]
366    pub(crate) fn as_slice(&self) -> &[u8] {
367        &self.payload
368    }
369
370    /// Get the transport command kind derived from stored behavior metadata.
371    #[inline]
372    pub(crate) fn kind(&self) -> CommandKind {
373        self.behavior.command_kind()
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    struct DummyInvalidAddr;
382
383    impl ViscaCommand for DummyInvalidAddr {
384        const MAX_SIZE: usize = 2;
385        const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
386
387        fn write_into(&self, _camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error> {
388            buffer[0] = 0x01; // Invalid address byte (must be 0x81..=0x88)
389            buffer[1] = crate::command::bytes::VISCA_TERMINATOR;
390            Ok(2)
391        }
392    }
393
394    struct DummyTooShort;
395
396    impl ViscaCommand for DummyTooShort {
397        const MAX_SIZE: usize = 2;
398        const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
399
400        fn write_into(&self, camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error> {
401            buffer[0] = camera_id.to_address_byte();
402            // Intentionally omit terminator and return len 1
403            Ok(1)
404        }
405    }
406
407    struct DummyMissingTerminator;
408
409    impl ViscaCommand for DummyMissingTerminator {
410        const MAX_SIZE: usize = 3;
411        const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
412
413        fn write_into(&self, camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error> {
414            buffer[0] = camera_id.to_address_byte();
415            buffer[1] = 0x01;
416            buffer[2] = 0x02; // Missing terminator
417            Ok(3)
418        }
419    }
420
421    #[test]
422    fn to_fixed_bytes_validates_command_structure() {
423        use crate::command::bytes::FixedCommandBytes;
424
425        let cmd = DummyInvalidAddr;
426        let result: Result<FixedCommandBytes<2>, Error> = cmd.to_fixed_bytes(CameraId::CAMERA_1);
427        assert!(result.is_err(), "Expected error for invalid address");
428
429        let cmd2 = DummyMissingTerminator;
430        let result2: Result<FixedCommandBytes<3>, Error> = cmd2.to_fixed_bytes(CameraId::CAMERA_1);
431        assert!(result2.is_err(), "Expected error for missing terminator");
432    }
433
434    #[test]
435    #[allow(clippy::unwrap_used)]
436    fn to_fixed_bytes_returns_correct_length() {
437        // Create a valid command for testing
438        struct DummyValid;
439
440        impl ViscaCommand for DummyValid {
441            const MAX_SIZE: usize = 6;
442            const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
443
444            fn write_into(&self, camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error> {
445                buffer[0] = camera_id.to_address_byte();
446                buffer[1] = 0x01;
447                buffer[2] = 0x04;
448                buffer[3] = 0x00;
449                buffer[4] = crate::command::bytes::VISCA_TERMINATOR;
450                Ok(5)
451            }
452        }
453
454        let cmd = DummyValid;
455        let result = cmd.to_fixed_bytes::<8>(CameraId::CAMERA_1);
456        assert!(result.is_ok());
457
458        let fixed = result.unwrap();
459        assert_eq!(fixed.len(), 5);
460        assert_eq!(
461            fixed.as_slice(),
462            &[
463                0x81,
464                0x01,
465                0x04,
466                0x00,
467                crate::command::bytes::VISCA_TERMINATOR
468            ]
469        );
470        assert_eq!(
471            fixed.as_slice().last(),
472            Some(&crate::command::bytes::VISCA_TERMINATOR)
473        );
474
475        // Verify that the underlying array may be larger
476        assert_eq!(fixed.as_array().len(), 8);
477    }
478
479    #[test]
480    fn to_bytes_validates_command_structure() {
481        let cmd = DummyTooShort;
482        let result = cmd.to_bytes(CameraId::CAMERA_1);
483        assert!(result.is_err(), "Expected error for too short command");
484        assert!(
485            matches!(result, Err(Error::InvalidRequest(ref msg)) if msg.contains("VISCA command too short")),
486            "Expected InvalidRequest error with too short message, got: {:?}",
487            result
488        );
489    }
490
491    /// A non-Clone command type that holds owned data.
492    ///
493    /// This test type verifies that the API doesn't require Clone.
494    /// We use a simple struct with no Clone derive to prove the point.
495    /// The struct is naturally Send+Sync since it only contains primitive data.
496    struct NonCloneCommand {
497        /// Some data field.
498        value: u8,
499    }
500
501    // Note: NonCloneCommand does NOT derive Clone, proving that
502    // EncodedCommand::new doesn't require Clone on the command type.
503
504    impl ViscaCommand for NonCloneCommand {
505        const MAX_SIZE: usize = 6;
506        const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
507
508        fn write_into(&self, camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error> {
509            buffer[0] = camera_id.to_address_byte();
510            buffer[1] = 0x01;
511            buffer[2] = 0x04;
512            buffer[3] = self.value;
513            buffer[4] = crate::command::bytes::VISCA_TERMINATOR;
514            Ok(5)
515        }
516    }
517
518    #[test]
519    #[allow(clippy::unwrap_used)]
520    fn encoded_command_works_with_non_clone_types() {
521        // Create a non-Clone command
522        let cmd = NonCloneCommand { value: 42 };
523
524        // This test proves that EncodedCommand::new accepts a reference
525        // without requiring Clone. If Clone were required, this would
526        // fail to compile since NonCloneCommand doesn't implement Clone.
527        let result = EncodedCommand::new(&cmd, CameraId::CAMERA_1);
528        assert!(result.is_ok(), "Should encode non-Clone command");
529
530        let encoded = result.unwrap();
531        assert_eq!(encoded.as_slice().len(), 5);
532        assert_eq!(encoded.category, CommandCategory::Quick);
533
534        // Verify the value was encoded
535        assert_eq!(encoded.as_slice()[3], 42);
536    }
537
538    #[test]
539    #[allow(clippy::unwrap_used)]
540    fn encoded_command_keeps_derived_inquiries_inline() {
541        use crate::command::bytes::VISCA_TERMINATOR;
542        use crate::command::inquiry_structs::{PowerInquiry, TallyGreenInquiry};
543
544        let power = EncodedCommand::new(&PowerInquiry, CameraId::CAMERA_1).unwrap();
545        assert!(
546            !power.payload.spilled(),
547            "PowerInquiry should fit in EncodedCommand inline storage"
548        );
549        assert_eq!(
550            power.as_slice(),
551            &[0x81, 0x09, 0x04, 0x00, VISCA_TERMINATOR]
552        );
553        assert_eq!(power.kind(), CommandKind::Inquiry);
554        assert_eq!(power.category, CommandCategory::Quick);
555        assert_eq!(
556            power.behavior.inquiry_response_spec(),
557            Some(InquiryResponseSpec::Builtin(InquiryKind::Power))
558        );
559
560        let tally = EncodedCommand::new(&TallyGreenInquiry, CameraId::CAMERA_1).unwrap();
561        assert!(
562            !tally.payload.spilled(),
563            "TallyGreenInquiry should fit in EncodedCommand inline storage"
564        );
565        assert_eq!(
566            tally.as_slice(),
567            &[0x81, 0x09, 0x7E, 0x04, 0x1A, 0x00, VISCA_TERMINATOR]
568        );
569        assert_eq!(tally.kind(), CommandKind::Inquiry);
570        assert_eq!(tally.category, CommandCategory::Quick);
571        assert_eq!(
572            tally.behavior.inquiry_response_spec(),
573            Some(InquiryResponseSpec::Builtin(InquiryKind::TallyGreen))
574        );
575    }
576
577    #[test]
578    #[allow(clippy::unwrap_used)]
579    fn encoded_command_stores_complete_behavior_for_raw_inquiry() {
580        struct RawStatusInquiry;
581
582        impl ViscaCommand for RawStatusInquiry {
583            const MAX_SIZE: usize = 5;
584            const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
585
586            fn write_into(&self, camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error> {
587                buffer[..5].copy_from_slice(&[
588                    camera_id.to_address_byte(),
589                    0x09,
590                    0x7E,
591                    0x55,
592                    crate::command::bytes::VISCA_TERMINATOR,
593                ]);
594                Ok(5)
595            }
596
597            fn behavior(&self) -> CommandBehavior {
598                CommandBehavior::Inquiry(InquiryResponseSpec::Raw)
599            }
600        }
601
602        let encoded = EncodedCommand::new(&RawStatusInquiry, CameraId::CAMERA_1).unwrap();
603        assert_eq!(encoded.kind(), CommandKind::Inquiry);
604        assert_eq!(
605            encoded.behavior,
606            CommandBehavior::Inquiry(InquiryResponseSpec::Raw)
607        );
608        assert!(!encoded.payload.spilled());
609    }
610
611    /// A command that wraps a vector (heap-allocated, non-Copy).
612    ///
613    /// This test type verifies that commands with heap-allocated data
614    /// work correctly with the reference-based API.
615    struct HeapCommand {
616        /// Heap-allocated data. Send+Sync is derived automatically for Vec<u8>.
617        data: Vec<u8>,
618    }
619
620    // Note: HeapCommand does NOT derive Clone, proving that
621    // EncodedCommand::new doesn't require Clone on heap-backed commands.
622
623    impl ViscaCommand for HeapCommand {
624        const MAX_SIZE: usize = 32;
625        const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Custom;
626
627        fn write_into(&self, camera_id: CameraId, buffer: &mut [u8]) -> Result<usize, Error> {
628            let len = 2 + self.data.len() + 1;
629            if len > buffer.len() {
630                return Err(Error::InvalidRequest("Buffer too small".into()));
631            }
632            buffer[0] = camera_id.to_address_byte();
633            buffer[1] = 0x01;
634            buffer[2..2 + self.data.len()].copy_from_slice(&self.data);
635            buffer[2 + self.data.len()] = crate::command::bytes::VISCA_TERMINATOR;
636            Ok(len)
637        }
638    }
639
640    #[test]
641    #[allow(clippy::unwrap_used)]
642    fn encoded_command_works_with_heap_backed_data() {
643        // Create a heap-backed command (Vec allocates on heap)
644        let cmd = HeapCommand {
645            data: vec![0x04, 0x00, 0x03],
646        };
647
648        // EncodedCommand::new should accept &cmd without requiring Clone.
649        // This is important because cloning Vec<u8> would allocate.
650        let result = EncodedCommand::new(&cmd, CameraId::CAMERA_1);
651        assert!(result.is_ok(), "Should encode heap-backed command");
652
653        let encoded = result.unwrap();
654        // 1 (addr) + 1 (0x01) + 3 (data) + 1 (terminator) = 6
655        assert_eq!(encoded.as_slice().len(), 6);
656
657        // Verify the encoded bytes contain the data
658        let bytes = encoded.as_slice();
659        assert_eq!(bytes[0], 0x81); // Camera 1 address
660        assert_eq!(bytes[1], 0x01);
661        assert_eq!(&bytes[2..5], &[0x04, 0x00, 0x03]);
662        assert_eq!(bytes[5], crate::command::bytes::VISCA_TERMINATOR);
663    }
664
665    #[test]
666    #[allow(clippy::unwrap_used)]
667    fn encoded_command_captures_data_not_reference() {
668        // This test verifies that EncodedCommand captures the encoded bytes,
669        // not a reference to the original command. This ensures the encoded
670        // command remains valid even after the original command is dropped.
671
672        let encoded = {
673            let cmd = HeapCommand {
674                data: vec![0x04, 0x00],
675            };
676
677            // Encode the command - this should capture the bytes
678            EncodedCommand::new(&cmd, CameraId::CAMERA_1).unwrap()
679            // `cmd` and its Vec are dropped here
680        };
681
682        // The encoded command should still be valid and contain the correct bytes
683        assert_eq!(encoded.as_slice().len(), 5);
684        let bytes = encoded.as_slice();
685        assert_eq!(bytes[0], 0x81); // Camera 1 address
686        assert_eq!(bytes[1], 0x01);
687        assert_eq!(bytes[2], 0x04);
688        assert_eq!(bytes[3], 0x00);
689        assert_eq!(bytes[4], crate::command::bytes::VISCA_TERMINATOR);
690    }
691}