Skip to main content

grafton_visca/command/
color.rs

1//! Color control commands for VISCA cameras.
2//!
3//! This module provides commands for controlling color-related settings
4//! including white balance tuning, saturation, and hue adjustments.
5
6use crate::{
7    command::{bytes::ConstCommandBuilder, encode::ViscaCommand},
8    error::Error,
9    timeout::CommandCategory,
10    types::{BlueTuning, HueLevel, RedTuning, SaturationLevel},
11    visca_command,
12};
13
14visca_command! {
15    /// One-Push White Balance Trigger command.
16    ///
17    /// Performs a one-time automatic white balance adjustment based on
18    /// the current scene. The camera will analyze the image and set the
19    /// white balance to achieve neutral colors.
20    pub struct OnePushTriggerCommand;
21    bytes = [0x01, 0x04, 0x10, 0x05];
22    category = CommandCategory::Quick;
23}
24
25impl Default for OnePushTriggerCommand {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl OnePushTriggerCommand {
32    /// Create a new one-push trigger command.
33    pub fn new() -> Self {
34        OnePushTriggerCommand
35    }
36}
37
38visca_command! {
39    /// Red Channel Tuning command.
40    ///
41    /// Fine-tunes the red channel gain for white balance adjustment.
42    /// This is typically used after setting a base white balance mode
43    /// to make small corrections.
44    pub struct RedTuningCommand { level: RedTuning };
45    prefix = [0x01, 0x04, 0x43, 0x00, 0x00];
46    param = {
47        // Convert -10..+10 to 0x00..0x14 (0x00 = -10, 0x0A = 0, 0x14 = +10)
48        // RedTuning is already validated at construction via visca_range_type!,
49        // so level_offset is guaranteed to be in 0..=20.
50        #[allow(clippy::cast_sign_loss)]
51        let encoded = (level.value() + 10) as u8;
52        [0x00, encoded]
53    };
54    max_param_size = 2;
55    category = CommandCategory::Quick;
56}
57
58impl RedTuningCommand {
59    /// Create a new red tuning command.
60    pub fn new(level: RedTuning) -> Self {
61        Self { level }
62    }
63}
64
65visca_command! {
66    /// Blue Channel Tuning command.
67    ///
68    /// Fine-tunes the blue channel gain for white balance adjustment.
69    /// This is typically used after setting a base white balance mode
70    /// to make small corrections.
71    pub struct BlueTuningCommand { level: BlueTuning };
72    prefix = [0x01, 0x04, 0x44, 0x00, 0x00];
73    param = {
74        // Convert -10..+10 to 0x00..0x14 (0x00 = -10, 0x0A = 0, 0x14 = +10)
75        // BlueTuning is already validated at construction via visca_range_type!,
76        // so level_offset is guaranteed to be in 0..=20.
77        #[allow(clippy::cast_sign_loss)]
78        let encoded = (level.value() + 10) as u8;
79        [0x00, encoded]
80    };
81    max_param_size = 2;
82    category = CommandCategory::Quick;
83}
84
85impl BlueTuningCommand {
86    /// Create a new blue tuning command.
87    pub fn new(level: BlueTuning) -> Self {
88        Self { level }
89    }
90}
91
92visca_command! {
93    /// Saturation control command.
94    ///
95    /// Adjusts the color saturation level of the image.
96    /// Lower values produce more muted colors, while higher values
97    /// produce more vivid colors.
98    pub struct SaturationCommand {
99        level: SaturationLevel
100    };
101    prefix = [0x01, 0x04, 0x49, 0x00, 0x00, 0x00];
102    param = level.value();
103    max_param_size = 1;
104    category = CommandCategory::Quick;
105}
106
107impl SaturationCommand {
108    /// Create a new saturation command.
109    pub fn new(level: SaturationLevel) -> Self {
110        Self { level }
111    }
112}
113
114visca_command! {
115    /// Hue adjustment command.
116    ///
117    /// Adjusts the hue (color phase) of the image, shifting all colors
118    /// around the color wheel. This can be used to correct color casts
119    /// or create artistic effects.
120    pub struct HueCommand {
121        level: HueLevel
122    };
123    prefix = [0x01, 0x04, 0x4F, 0x00, 0x00, 0x00];
124    param = level.value();
125    max_param_size = 1;
126    category = CommandCategory::Quick;
127}
128
129impl HueCommand {
130    /// Create a new hue command.
131    pub fn new(level: HueLevel) -> Self {
132        Self { level }
133    }
134}
135
136/// Color Temperature command.
137///
138/// Controls the color temperature setting when white balance is in color temperature mode.
139/// Color temperature is measured in Kelvin (K) and affects the warmth/coolness of the image.
140#[derive(Debug, Copy, Clone)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
144#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
145pub enum ColorTemperature {
146    /// Reset color temperature to default value.
147    Reset,
148    /// Increase color temperature (makes image cooler/bluer).
149    Up,
150    /// Decrease color temperature (makes image warmer/redder).
151    Down,
152    /// Set color temperature directly.
153    ///
154    /// Lower values produce warmer (more orange/red) colors,
155    /// higher values produce cooler (more blue) colors.
156    SetTemperature(crate::types::ColorTemp),
157}
158
159impl ViscaCommand for ColorTemperature {
160    const MAX_SIZE: usize = 8;
161    const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
162
163    fn write_into(
164        &self,
165        camera_id: crate::camera_id::CameraId,
166        buffer: &mut [u8],
167    ) -> Result<usize, Error> {
168        match self {
169            ColorTemperature::Reset => {
170                let builder = ConstCommandBuilder::<6>::from_prefix(
171                    crate::command::bytes::constants::color::TEMPERATURE_PREFIX,
172                )
173                .with_camera_id(camera_id)
174                .push(0x00);
175                builder.terminate().build_into(buffer)
176            }
177            ColorTemperature::Up => {
178                let builder = ConstCommandBuilder::<6>::from_prefix(
179                    crate::command::bytes::constants::color::TEMPERATURE_PREFIX,
180                )
181                .with_camera_id(camera_id)
182                .push(0x02);
183                builder.terminate().build_into(buffer)
184            }
185            ColorTemperature::Down => {
186                let builder = ConstCommandBuilder::<6>::from_prefix(
187                    crate::command::bytes::constants::color::TEMPERATURE_PREFIX,
188                )
189                .with_camera_id(camera_id)
190                .push(0x03);
191                builder.terminate().build_into(buffer)
192            }
193            ColorTemperature::SetTemperature(temp) => {
194                let builder = ConstCommandBuilder::<7>::from_prefix(
195                    crate::command::bytes::constants::color::TEMPERATURE_PREFIX,
196                )
197                .with_camera_id(camera_id)
198                .push_nibble_pair(temp.value());
199                builder.terminate().build_into(buffer)
200            }
201        }
202    }
203}
204
205/// Red Channel Direct command (different from tuning).
206///
207/// Controls the red channel gain in manual white balance mode.
208/// This provides direct control over the red color channel intensity.
209#[derive(Debug, Copy, Clone)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
211#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
212#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
213#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
214pub enum RedGain {
215    /// Reset red gain to default value.
216    Reset,
217    /// Increment red gain value by one step.
218    Up,
219    /// Decrement red gain value by one step.
220    Down,
221    /// Set red gain to a specific value.
222    ///
223    /// Higher values increase the intensity of red in the image.
224    SetValue(crate::types::RedChannel),
225}
226
227impl ViscaCommand for RedGain {
228    const MAX_SIZE: usize = 9;
229    const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
230
231    fn write_into(
232        &self,
233        camera_id: crate::camera_id::CameraId,
234        buffer: &mut [u8],
235    ) -> Result<usize, Error> {
236        match self {
237            RedGain::Reset => {
238                let mut builder = ConstCommandBuilder::<6>::from_prefix(
239                    crate::command::bytes::constants::color::RED_GAIN_CONTROL_PREFIX,
240                );
241                builder = builder.with_camera_id(camera_id).push(0x00);
242                builder.terminate().build_into(buffer)
243            }
244            RedGain::Up => {
245                let mut builder = ConstCommandBuilder::<6>::from_prefix(
246                    crate::command::bytes::constants::color::RED_GAIN_CONTROL_PREFIX,
247                );
248                builder = builder.with_camera_id(camera_id).push(0x02);
249                builder.terminate().build_into(buffer)
250            }
251            RedGain::Down => {
252                let mut builder = ConstCommandBuilder::<6>::from_prefix(
253                    crate::command::bytes::constants::color::RED_GAIN_CONTROL_PREFIX,
254                );
255                builder = builder.with_camera_id(camera_id).push(0x03);
256                builder.terminate().build_into(buffer)
257            }
258            RedGain::SetValue(value) => {
259                // Note: different command byte 0x43 for direct setting
260                let builder = ConstCommandBuilder::<9>::from_prefix(
261                    crate::command::bytes::constants::color::RED_GAIN_DIRECT_PREFIX,
262                )
263                .with_camera_id(camera_id)
264                .push_nibble_pair(u16::from(value.value()));
265                builder.terminate().build_into(buffer)
266            }
267        }
268    }
269}
270
271/// Blue Channel Direct command (different from tuning).
272///
273/// Controls the blue channel gain in manual white balance mode.
274/// This provides direct control over the blue color channel intensity.
275#[derive(Debug, Copy, Clone)]
276#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
277#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
278#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
279#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
280pub enum BlueGain {
281    /// Reset blue gain to default value.
282    Reset,
283    /// Increment blue gain value by one step.
284    Up,
285    /// Decrement blue gain value by one step.
286    Down,
287    /// Set blue gain to a specific value.
288    ///
289    /// Higher values increase the intensity of blue in the image.
290    SetValue(crate::types::BlueChannel),
291}
292
293impl ViscaCommand for BlueGain {
294    const MAX_SIZE: usize = 9;
295    const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
296
297    fn write_into(
298        &self,
299        camera_id: crate::camera_id::CameraId,
300        buffer: &mut [u8],
301    ) -> Result<usize, Error> {
302        match self {
303            BlueGain::Reset => {
304                let builder = ConstCommandBuilder::<6>::from_prefix(
305                    crate::command::bytes::constants::color::BLUE_GAIN_CONTROL_PREFIX,
306                )
307                .with_camera_id(camera_id)
308                .push(0x00);
309                builder.terminate().build_into(buffer)
310            }
311            BlueGain::Up => {
312                let builder = ConstCommandBuilder::<6>::from_prefix(
313                    crate::command::bytes::constants::color::BLUE_GAIN_CONTROL_PREFIX,
314                )
315                .with_camera_id(camera_id)
316                .push(0x02);
317                builder.terminate().build_into(buffer)
318            }
319            BlueGain::Down => {
320                let builder = ConstCommandBuilder::<6>::from_prefix(
321                    crate::command::bytes::constants::color::BLUE_GAIN_CONTROL_PREFIX,
322                )
323                .with_camera_id(camera_id)
324                .push(0x03);
325                builder.terminate().build_into(buffer)
326            }
327            BlueGain::SetValue(value) => {
328                // Note: different command byte 0x44 for direct setting
329                let builder = ConstCommandBuilder::<9>::from_prefix(
330                    crate::command::bytes::constants::color::BLUE_GAIN_DIRECT_PREFIX,
331                )
332                .with_camera_id(camera_id)
333                .push_nibble_pair(u16::from(value.value()));
334                builder.terminate().build_into(buffer)
335            }
336        }
337    }
338}
339
340#[cfg(test)]
341#[allow(
342    clippy::unwrap_used,
343    clippy::cast_sign_loss,
344    clippy::uninlined_format_args
345)]
346mod tests {
347    use super::*;
348    use crate::{
349        command::bytes::VISCA_TERMINATOR, macros::test_utils::visca_test, timeout::CommandTimeout,
350    };
351
352    visca_test!(
353        OnePushTriggerCommand,
354        test_one_push_trigger_command,
355        OnePushTriggerCommand::new(),
356        &[0x81, 0x01, 0x04, 0x10, 0x05, VISCA_TERMINATOR]
357    );
358
359    #[test]
360    fn test_red_tuning_command() {
361        // Test valid range
362        for level in -10..=10 {
363            let tuning = RedTuning::new(level).unwrap();
364            let cmd = RedTuningCommand::new(tuning);
365            let bytes = cmd
366                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
367                .map(|b| b.to_vec())
368                .unwrap();
369            assert_eq!(bytes.len(), 9);
370            assert_eq!(bytes[0..6], [0x81, 0x01, 0x04, 0x43, 0x00, 0x00]);
371            assert_eq!(bytes[6], 0x00);
372            assert_eq!(bytes[7], (level + 10) as u8);
373            assert_eq!(bytes[8], 0xFF);
374            assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
375            assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
376        }
377
378        // Test invalid values - can't create invalid RedTuning
379        assert!(RedTuning::new(-11).is_err());
380        assert!(RedTuning::new(11).is_err());
381    }
382
383    #[test]
384    fn test_blue_tuning_command() {
385        // Test valid range
386        for level in -10..=10 {
387            let tuning = BlueTuning::new(level).unwrap();
388            let cmd = BlueTuningCommand::new(tuning);
389            let bytes = cmd
390                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
391                .map(|b| b.to_vec())
392                .unwrap();
393            assert_eq!(bytes.len(), 9);
394            assert_eq!(bytes[0..6], [0x81, 0x01, 0x04, 0x44, 0x00, 0x00]);
395            assert_eq!(bytes[6], 0x00);
396            assert_eq!(bytes[7], (level + 10) as u8);
397            assert_eq!(bytes[8], 0xFF);
398            assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
399            assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
400        }
401
402        // Test invalid values - can't create invalid BlueTuning
403        assert!(BlueTuning::new(-11).is_err());
404        assert!(BlueTuning::new(11).is_err());
405    }
406
407    #[test]
408    fn test_saturation_command() {
409        // Test valid range
410        for level in 0x00..=0x0E {
411            let sat_level = SaturationLevel::new(level).unwrap();
412            let cmd = SaturationCommand::new(sat_level);
413            let bytes = cmd
414                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
415                .map(|b| b.to_vec())
416                .unwrap();
417            assert_eq!(
418                bytes,
419                vec![
420                    0x81,
421                    0x01,
422                    0x04,
423                    0x49,
424                    0x00,
425                    0x00,
426                    0x00,
427                    level,
428                    VISCA_TERMINATOR
429                ]
430            );
431            assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
432            assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
433        }
434
435        // Test invalid value - can't create invalid SaturationLevel
436        assert!(SaturationLevel::new(0x0F).is_err());
437    }
438
439    #[test]
440    fn test_hue_command() {
441        // Test valid range
442        for level in 0x00..=0x0E {
443            let hue_level = HueLevel::new(level).unwrap();
444            let cmd = HueCommand::new(hue_level);
445            let bytes = cmd
446                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
447                .map(|b| b.to_vec())
448                .unwrap();
449            assert_eq!(
450                bytes,
451                vec![
452                    0x81,
453                    0x01,
454                    0x04,
455                    0x4F,
456                    0x00,
457                    0x00,
458                    0x00,
459                    level,
460                    VISCA_TERMINATOR
461                ]
462            );
463            assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
464            assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
465        }
466
467        // Test invalid value - can't create invalid HueLevel
468        assert!(HueLevel::new(0x0F).is_err());
469    }
470
471    #[test]
472    fn test_color_temperature_command() {
473        // Test Reset
474        let cmd = ColorTemperature::Reset;
475        assert_eq!(
476            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
477                .map(|b| b.to_vec())
478                .unwrap(),
479            vec![0x81, 0x01, 0x04, 0x20, 0x00, VISCA_TERMINATOR]
480        );
481
482        // Test Up
483        let cmd = ColorTemperature::Up;
484        assert_eq!(
485            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
486                .map(|b| b.to_vec())
487                .unwrap(),
488            vec![0x81, 0x01, 0x04, 0x20, 0x02, VISCA_TERMINATOR]
489        );
490
491        // Test Down
492        let cmd = ColorTemperature::Down;
493        assert_eq!(
494            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
495                .map(|b| b.to_vec())
496                .unwrap(),
497            vec![0x81, 0x01, 0x04, 0x20, 0x03, VISCA_TERMINATOR]
498        );
499
500        // Test Direct with valid values
501        let test_values = vec![0x00, 0x10, 0x20, 0x37];
502        for temp in test_values {
503            let color_temp = crate::types::ColorTemp::new(temp).unwrap();
504            let cmd = ColorTemperature::SetTemperature(color_temp);
505            let bytes = cmd
506                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
507                .map(|b| b.to_vec())
508                .unwrap();
509            assert_eq!(bytes.len(), 7);
510            assert_eq!(bytes[0..4], [0x81, 0x01, 0x04, 0x20]);
511            assert_eq!(bytes[4], ((temp >> 4) & 0x0F) as u8);
512            assert_eq!(bytes[5], (temp & 0x0F) as u8);
513            assert_eq!(bytes[6], 0xFF);
514        }
515
516        // Test Direct with invalid value - can't create invalid ColorTemperature
517        assert!(crate::types::ColorTemp::new(0x38).is_err());
518    }
519
520    #[test]
521    fn test_red_gain_command() {
522        // Test Reset
523        let cmd = RedGain::Reset;
524        assert_eq!(
525            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
526                .map(|b| b.to_vec())
527                .unwrap(),
528            vec![0x81, 0x01, 0x04, 0x03, 0x00, VISCA_TERMINATOR]
529        );
530
531        // Test Up
532        let cmd = RedGain::Up;
533        assert_eq!(
534            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
535                .map(|b| b.to_vec())
536                .unwrap(),
537            vec![0x81, 0x01, 0x04, 0x03, 0x02, VISCA_TERMINATOR]
538        );
539
540        // Test Down
541        let cmd = RedGain::Down;
542        assert_eq!(
543            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
544                .map(|b| b.to_vec())
545                .unwrap(),
546            vec![0x81, 0x01, 0x04, 0x03, 0x03, VISCA_TERMINATOR]
547        );
548
549        // Test Direct with various values
550        let test_values = vec![0x00, 0x55, 0xAA, VISCA_TERMINATOR];
551        for gain in test_values {
552            let red_gain = crate::types::RedChannel::new(gain).unwrap();
553            let cmd = RedGain::SetValue(red_gain);
554            let bytes = cmd
555                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
556                .map(|b| b.to_vec())
557                .unwrap();
558            assert_eq!(bytes.len(), 9);
559            assert_eq!(bytes[0..5], [0x81, 0x01, 0x04, 0x43, 0x00]);
560            assert_eq!(bytes[5], 0x00);
561            assert_eq!(bytes[6], (gain >> 4) & 0x0F);
562            assert_eq!(bytes[7], gain & 0x0F);
563            assert_eq!(bytes[8], 0xFF);
564        }
565    }
566
567    #[test]
568    fn test_blue_gain_command() {
569        // Test Reset
570        let cmd = BlueGain::Reset;
571        assert_eq!(
572            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
573                .map(|b| b.to_vec())
574                .unwrap(),
575            vec![0x81, 0x01, 0x04, 0x04, 0x00, VISCA_TERMINATOR]
576        );
577
578        // Test Up
579        let cmd = BlueGain::Up;
580        assert_eq!(
581            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
582                .map(|b| b.to_vec())
583                .unwrap(),
584            vec![0x81, 0x01, 0x04, 0x04, 0x02, VISCA_TERMINATOR]
585        );
586
587        // Test Down
588        let cmd = BlueGain::Down;
589        assert_eq!(
590            cmd.to_bytes(crate::camera_id::CameraId::CAMERA_1)
591                .map(|b| b.to_vec())
592                .unwrap(),
593            vec![0x81, 0x01, 0x04, 0x04, 0x03, VISCA_TERMINATOR]
594        );
595
596        // Test Direct with various values
597        let test_values = vec![0x00, 0x55, 0xAA, VISCA_TERMINATOR];
598        for gain in test_values {
599            let blue_gain = crate::types::BlueChannel::new(gain).unwrap();
600            let cmd = BlueGain::SetValue(blue_gain);
601            let bytes = cmd
602                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
603                .map(|b| b.to_vec())
604                .unwrap();
605            assert_eq!(bytes.len(), 9);
606            assert_eq!(bytes[0..5], [0x81, 0x01, 0x04, 0x44, 0x00]);
607            assert_eq!(bytes[5], 0x00);
608            assert_eq!(bytes[6], (gain >> 4) & 0x0F);
609            assert_eq!(bytes[7], gain & 0x0F);
610            assert_eq!(bytes[8], 0xFF);
611        }
612    }
613
614    #[test]
615    fn test_command_traits() {
616        // Test that all commands implement Debug and Clone
617        let cmds: Vec<Box<dyn std::fmt::Debug>> = vec![
618            Box::new(OnePushTriggerCommand::new()),
619            Box::new(RedTuningCommand::new(RedTuning::NEUTRAL)),
620            Box::new(BlueTuningCommand::new(BlueTuning::NEUTRAL)),
621            Box::new(SaturationCommand::new(SaturationLevel::MIN)),
622            Box::new(HueCommand::new(HueLevel::MIN)),
623            Box::new(ColorTemperature::Reset),
624            Box::new(RedGain::Reset),
625            Box::new(BlueGain::Reset),
626        ];
627
628        for cmd in cmds {
629            let _ = format!("{:?}", cmd);
630        }
631
632        // Test Clone
633        let red_cmd1 = RedTuningCommand::new(RedTuning::new(5).unwrap());
634        let red_cmd2 = red_cmd1;
635        assert_eq!(
636            red_cmd1
637                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
638                .map(|b| b.to_vec())
639                .unwrap(),
640            red_cmd2
641                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
642                .map(|b| b.to_vec())
643                .unwrap()
644        );
645    }
646
647    #[test]
648    fn test_edge_cases() {
649        // Test boundary values for tuning commands
650        let red_min = RedTuningCommand::new(RedTuning::new(-10).unwrap());
651        assert!(red_min
652            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
653            .map(|b| b.to_vec())
654            .is_ok());
655        assert_eq!(
656            red_min
657                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
658                .map(|b| b.to_vec())
659                .unwrap()[7],
660            0x00
661        );
662
663        let red_max = RedTuningCommand::new(RedTuning::MAX);
664        assert!(red_max
665            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
666            .map(|b| b.to_vec())
667            .is_ok());
668        assert_eq!(
669            red_max
670                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
671                .map(|b| b.to_vec())
672                .unwrap()[7],
673            0x14
674        );
675
676        let blue_min = BlueTuningCommand::new(BlueTuning::new(-10).unwrap());
677        assert!(blue_min
678            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
679            .map(|b| b.to_vec())
680            .is_ok());
681        assert_eq!(
682            blue_min
683                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
684                .map(|b| b.to_vec())
685                .unwrap()[7],
686            0x00
687        );
688
689        let blue_max = BlueTuningCommand::new(BlueTuning::MAX);
690        assert!(blue_max
691            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
692            .map(|b| b.to_vec())
693            .is_ok());
694        assert_eq!(
695            blue_max
696                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
697                .map(|b| b.to_vec())
698                .unwrap()[7],
699            0x14
700        );
701
702        // Test boundary values for saturation and hue
703        let sat_min = SaturationCommand::new(SaturationLevel::new(0x00).unwrap());
704        assert!(sat_min
705            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
706            .map(|b| b.to_vec())
707            .is_ok());
708
709        let sat_max = SaturationCommand::new(SaturationLevel::new(0x0E).unwrap());
710        assert!(sat_max
711            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
712            .map(|b| b.to_vec())
713            .is_ok());
714
715        let hue_min = HueCommand::new(HueLevel::new(0x00).unwrap());
716        assert!(hue_min
717            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
718            .map(|b| b.to_vec())
719            .is_ok());
720
721        let hue_max = HueCommand::new(HueLevel::new(0x0E).unwrap());
722        assert!(hue_max
723            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
724            .map(|b| b.to_vec())
725            .is_ok());
726
727        // Test boundary value for color temperature
728        let temp_min =
729            ColorTemperature::SetTemperature(crate::types::ColorTemp::new(0x00).unwrap());
730        assert!(temp_min
731            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
732            .map(|b| b.to_vec())
733            .is_ok());
734
735        let temp_max =
736            ColorTemperature::SetTemperature(crate::types::ColorTemp::new(0x37).unwrap());
737        assert!(temp_max
738            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
739            .map(|b| b.to_vec())
740            .is_ok());
741    }
742
743    #[test]
744    fn test_nibble_encoding() {
745        // Test that Direct commands properly encode values as nibbles
746        let cmd = ColorTemperature::SetTemperature(crate::types::ColorTemp::new(0x25).unwrap());
747        let bytes = cmd
748            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
749            .map(|b| b.to_vec())
750            .unwrap();
751        assert_eq!(bytes[4], 0x02);
752        assert_eq!(bytes[5], 0x05);
753
754        let cmd = RedGain::SetValue(crate::types::RedChannel::new(0xAB).unwrap());
755        let bytes = cmd
756            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
757            .map(|b| b.to_vec())
758            .unwrap();
759        assert_eq!(bytes[6], 0x0A);
760        assert_eq!(bytes[7], 0x0B);
761
762        let cmd = BlueGain::SetValue(crate::types::BlueChannel::new(0xF0).unwrap());
763        let bytes = cmd
764            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
765            .map(|b| b.to_vec())
766            .unwrap();
767        assert_eq!(bytes[6], 0x0F);
768        assert_eq!(bytes[7], 0x00);
769    }
770
771    #[test]
772    #[allow(clippy::manual_range_contains)]
773    fn test_tuning_encoding_formula() {
774        // Verify the -10 to +10 => 0x00 to 0x14 conversion
775        for level in -10..=10 {
776            let expected = (level + 10) as u8;
777
778            let red_cmd = RedTuningCommand::new(RedTuning::new(level).unwrap());
779            let red_bytes = red_cmd
780                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
781                .map(|b| b.to_vec())
782                .unwrap();
783            assert_eq!(red_bytes[7], expected);
784
785            let blue_cmd = BlueTuningCommand::new(BlueTuning::new(level).unwrap());
786            let blue_bytes = blue_cmd
787                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
788                .map(|b| b.to_vec())
789                .unwrap();
790            assert_eq!(blue_bytes[7], expected);
791        }
792    }
793}