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}