Skip to main content

grafton_visca/command/
image.rs

1//! Image adjustment commands for VISCA cameras.
2//!
3//! This module provides commands for controlling various image quality settings,
4//! including backlight compensation, noise reduction, image flip, picture effects,
5//! brightness (luminance), contrast, gamma, and sharpness adjustments.
6
7use grafton_visca_macros::ViscaEnum;
8
9use std::borrow::Cow;
10
11use crate::{
12    command::{encode::ViscaCommand, resolution::PictureEffectMode},
13    error::Error,
14    timeout::CommandCategory,
15    types::{
16        ContrastLevel, GammaLevel, LuminanceLevel, NoiseReduction2DLevel, NoiseReduction3DLevel,
17    },
18    visca_command,
19};
20
21/// Sharpness control modes.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, ViscaEnum)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
25#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
26#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
27pub enum SharpnessMode {
28    /// Automatic sharpness adjustment based on scene content.
29    Auto = 0x02,
30    /// Manual sharpness control.
31    Manual = 0x03,
32}
33
34/// Noise reduction modes.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, ViscaEnum)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
38#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
39#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
40pub enum NoiseReductionMode {
41    /// Noise reduction disabled.
42    Off = 0x02,
43    /// Noise reduction enabled.
44    On = 0x03,
45}
46
47/// Noise reduction speed settings.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, ViscaEnum)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
51#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
52#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
53pub enum NoiseReductionSpeed {
54    /// Slow noise reduction processing.
55    Slow = 0x00,
56    /// Normal noise reduction processing.
57    Normal = 0x01,
58    /// Fast noise reduction processing.
59    Fast = 0x02,
60}
61
62/// Black and white mode settings.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, ViscaEnum)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
66#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
67#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
68pub enum BlackWhiteMode {
69    /// Color mode (normal operation).
70    Color = 0x02,
71    /// Black and white mode.
72    BlackWhite = 0x03,
73}
74
75/// Sharpness control commands.
76///
77/// Controls edge enhancement to make images appear more or less sharp.
78/// Higher sharpness values enhance edges but may introduce artifacts.
79#[derive(Debug, Copy, Clone)]
80pub enum Sharpness {
81    /// Set sharpness mode (auto or manual).
82    Mode(SharpnessMode),
83    /// Reset sharpness to default value.
84    Reset,
85    /// Increase sharpness by one step.
86    Up,
87    /// Decrease sharpness by one step.
88    Down,
89    /// Set sharpness to specific value (0-15).
90    SetLevel {
91        /// Sharpness value (0 = minimum, 15 = maximum).
92        value: u8,
93    },
94}
95
96impl ViscaCommand for Sharpness {
97    const MAX_SIZE: usize = 9;
98    const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Custom;
99
100    fn write_into(
101        &self,
102        camera_id: crate::camera_id::CameraId,
103        buffer: &mut [u8],
104    ) -> Result<usize, Error> {
105        use crate::command::bytes::{constants, ConstCommandBuilder};
106
107        match self {
108            Self::Mode(mode) => {
109                let mode_byte = match mode {
110                    SharpnessMode::Auto => 0x02,
111                    SharpnessMode::Manual => 0x03,
112                };
113                let mut builder = ConstCommandBuilder::<6>::new();
114                builder.append_mut(constants::image::SHARPNESS_MODE_PREFIX);
115                builder.push_mut(mode_byte);
116                builder
117                    .with_camera_id(camera_id)
118                    .terminate()
119                    .build_into(buffer)
120            }
121            Self::Reset => {
122                let mut builder = ConstCommandBuilder::<6>::new();
123                builder.append_mut(constants::image::SHARPNESS_CONTROL_PREFIX);
124                builder.push_mut(0x00);
125                builder
126                    .with_camera_id(camera_id)
127                    .terminate()
128                    .build_into(buffer)
129            }
130            Self::Up => {
131                let mut builder = ConstCommandBuilder::<6>::new();
132                builder.append_mut(constants::image::SHARPNESS_CONTROL_PREFIX);
133                builder.push_mut(0x02);
134                builder
135                    .with_camera_id(camera_id)
136                    .terminate()
137                    .build_into(buffer)
138            }
139            Self::Down => {
140                let mut builder = ConstCommandBuilder::<6>::new();
141                builder.append_mut(constants::image::SHARPNESS_CONTROL_PREFIX);
142                builder.push_mut(0x03);
143                builder
144                    .with_camera_id(camera_id)
145                    .terminate()
146                    .build_into(buffer)
147            }
148            Self::SetLevel { value } => {
149                if *value > 15 {
150                    return Err(Error::InvalidParameter {
151                        parameter: "value",
152                        value: Cow::Owned(value.to_string()),
153                        reason: Cow::Borrowed("Sharpness value must be in the range 0..=15"),
154                    });
155                }
156                let mut builder = ConstCommandBuilder::<9>::new();
157                builder.append_mut(constants::image::SHARPNESS_LEVEL_PREFIX);
158                builder.push_nibble_pair_mut(*value as u16);
159                builder
160                    .with_camera_id(camera_id)
161                    .terminate()
162                    .build_into(buffer)
163            }
164        }
165    }
166}
167
168visca_command! {
169        /// Command to set the luminance (brightness) level.
170    pub struct Luminance { value: LuminanceLevel };
171    prefix = [0x01, 0x04, 0xA1, 0x00, 0x00, 0x00];
172    param = value.value();
173    max_param_size = 1;
174    category = CommandCategory::Quick;
175}
176
177impl Luminance {
178    /// Create a new luminance command.
179    pub fn new(value: LuminanceLevel) -> Self {
180        Self { value }
181    }
182}
183
184visca_command! {
185        /// Command to set the contrast level.
186    pub struct Contrast { value: ContrastLevel };
187    prefix = [0x01, 0x04, 0xA2, 0x00, 0x00, 0x00];
188    param = value.value();
189    max_param_size = 1;
190    category = CommandCategory::Quick;
191}
192
193impl Contrast {
194    /// Create a new contrast command.
195    pub fn new(value: ContrastLevel) -> Self {
196        Self { value }
197    }
198}
199
200visca_command! {
201        /// Command to set the gamma curve.
202    ///
203    /// Selects the gamma correction curve for the camera's image output.
204    /// Gamma affects the overall brightness curve and tonal response.
205    /// Value 0 is typically standard gamma, while values 1-4 select
206    /// different gamma curves depending on the camera model.
207    ///
208    /// Use [`GammaInquiryControl::gamma`] to query the current value.
209    ///
210    /// [`GammaInquiryControl::gamma`]: crate::GammaInquiryControl::gamma
211    pub struct GammaCommand { level: GammaLevel };
212    prefix = [0x01, 0x04, 0x5B];
213    param = level.value();
214    max_param_size = 1;
215    category = CommandCategory::Custom;
216}
217
218impl GammaCommand {
219    /// Create a new gamma command.
220    pub fn new(level: GammaLevel) -> Self {
221        Self { level }
222    }
223}
224
225visca_command! {
226        /// Backlight compensation command.
227    ///
228    /// Enables or disables backlight compensation, which helps properly expose
229    /// subjects that are backlit (have a bright light source behind them).
230    pub struct BacklightCommand { enabled: bool };
231    prefix = [0x01, 0x04, 0x33];
232    param = if *enabled { 0x02 } else { 0x03 };
233    max_param_size = 1;
234    category = CommandCategory::Quick;
235}
236
237impl BacklightCommand {
238    /// Create a new backlight compensation command.
239    pub fn new(enabled: bool) -> Self {
240        Self { enabled }
241    }
242}
243
244visca_command! {
245        /// 2D Noise Reduction command.
246    ///
247    /// Reduces spatial noise in individual frames by analyzing and smoothing
248    /// pixel variations. Higher levels provide more noise reduction but may
249    /// reduce fine detail.
250    pub struct NoiseReduction2D { level: Option<NoiseReduction2DLevel> };
251    prefix = [0x01, 0x04, 0x53];
252    param = match level { None => 0x00, Some(l) => l.value() };
253    max_param_size = 1;
254    category = CommandCategory::Custom;
255}
256
257impl NoiseReduction2D {
258    /// Disable 2D noise reduction.
259    pub const fn off() -> Self {
260        Self { level: None }
261    }
262
263    /// Set 2D noise reduction to a specific level.
264    pub const fn with_level(level: NoiseReduction2DLevel) -> Self {
265        Self { level: Some(level) }
266    }
267}
268
269visca_command! {
270        /// 3D Noise Reduction command.
271    ///
272    /// Reduces temporal noise by analyzing multiple frames over time.
273    /// This is effective for reducing noise in video streams while preserving
274    /// motion detail. Higher levels provide more noise reduction.
275    pub struct NoiseReduction3D { level: Option<NoiseReduction3DLevel> };
276    prefix = [0x01, 0x04, 0x54];
277    param = match level { None => 0x00, Some(l) => l.value() };
278    max_param_size = 1;
279    category = CommandCategory::Custom;
280}
281
282impl NoiseReduction3D {
283    /// Disable 3D noise reduction.
284    pub const fn off() -> Self {
285        Self { level: None }
286    }
287
288    /// Set 3D noise reduction to a specific level.
289    pub const fn with_level(level: NoiseReduction3DLevel) -> Self {
290        Self { level: Some(level) }
291    }
292}
293
294/// Combined Image Flip modes.
295///
296/// Allows flipping the image horizontally, vertically, or both.
297/// Useful for when cameras are mounted upside down or need mirror effects.
298#[derive(Debug, Copy, Clone)]
299#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
300#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
301#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
302#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS), ts(export))]
303pub enum ImageFlipMode {
304    /// No image flipping.
305    Off,
306    /// Flip image horizontally (mirror).
307    Horizontal,
308    /// Flip image vertically (upside down).
309    Vertical,
310    /// Flip image both horizontally and vertically (180° rotation).
311    Both,
312}
313
314visca_command! {
315        /// Command to set the combined image flip mode.
316    pub struct ImageFlipCombinedCommand { mode: ImageFlipMode };
317    prefix = [0x01, 0x04, 0xA4];
318    param = match mode {
319        ImageFlipMode::Off => 0x00,
320        ImageFlipMode::Horizontal => 0x01,
321        ImageFlipMode::Vertical => 0x02,
322        ImageFlipMode::Both => 0x03,
323    };
324    max_param_size = 1;
325    category = CommandCategory::Custom;
326}
327
328impl ImageFlipCombinedCommand {
329    /// Create a new image flip combined command.
330    pub fn new(mode: ImageFlipMode) -> Self {
331        Self { mode }
332    }
333}
334
335/// Command to set picture effect mode.
336///
337/// The built-in reference validates the standard off command and PTZOptics
338/// black-and-white command. Use `PictureEffectMode::Unknown(value)` for raw
339/// model-specific picture-effect values outside that source-backed surface.
340#[derive(Debug, Copy, Clone)]
341pub struct PictureEffectCommand {
342    /// Picture-effect mode to send.
343    pub mode: PictureEffectMode,
344}
345
346impl ViscaCommand for PictureEffectCommand {
347    const MAX_SIZE: usize = 6;
348    const TIMEOUT_CATEGORY: CommandCategory = CommandCategory::Quick;
349
350    fn write_into(
351        &self,
352        camera_id: crate::camera_id::CameraId,
353        buffer: &mut [u8],
354    ) -> Result<usize, Error> {
355        use crate::command::bytes::ConstCommandBuilder;
356
357        let effect = match self.mode {
358            PictureEffectMode::Off => 0x00,
359            PictureEffectMode::BlackAndWhite => 0x04,
360            PictureEffectMode::Unknown(value) => value,
361            PictureEffectMode::Negative
362            | PictureEffectMode::Sepia
363            | PictureEffectMode::Sketch
364            | PictureEffectMode::Emboss
365            | PictureEffectMode::Mosaic => {
366                return Err(Error::InvalidParameter {
367                    parameter: "mode",
368                    value: Cow::Owned(format!("{:?}", self.mode)),
369                    reason: Cow::Borrowed(
370                        "picture effect mode is not validated for built-in VISCA profiles; use Unknown(value) for model-specific raw values",
371                    ),
372                });
373            }
374        };
375
376        let mut builder = ConstCommandBuilder::<6>::new();
377        builder.push_mut(camera_id.to_address_byte());
378        builder.append_mut(&[0x01, 0x04, 0x63]);
379        builder.push_mut(effect);
380        builder.terminate().build_into(buffer)
381    }
382}
383
384#[cfg(test)]
385#[allow(
386    clippy::unwrap_used,
387    clippy::uninlined_format_args,
388    clippy::panic,
389    clippy::match_wildcard_for_single_variants
390)]
391mod tests {
392    use super::*;
393    use crate::{
394        command::{bytes::VISCA_TERMINATOR, encode::ViscaCommand},
395        macros::test_utils::visca_test,
396        timeout::{CommandCategory, CommandTimeout},
397    };
398
399    visca_test!(
400        BacklightCommand,
401        test_backlight_on,
402        BacklightCommand::new(true),
403        &[0x81, 0x01, 0x04, 0x33, 0x02, VISCA_TERMINATOR]
404    );
405
406    visca_test!(
407        BacklightCommand,
408        test_backlight_off,
409        BacklightCommand::new(false),
410        &[0x81, 0x01, 0x04, 0x33, 0x03, VISCA_TERMINATOR]
411    );
412
413    #[test]
414    fn test_backlight_command_properties() {
415        let cmd = BacklightCommand::new(true);
416        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
417        assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
418    }
419
420    visca_test!(
421        NoiseReduction2D,
422        test_noise_reduction_2d_off,
423        NoiseReduction2D::off(),
424        &[0x81, 0x01, 0x04, 0x53, 0x00, VISCA_TERMINATOR]
425    );
426
427    visca_test!(
428        NoiseReduction2D,
429        test_noise_reduction_2d_level_1,
430        NoiseReduction2D::with_level(NoiseReduction2DLevel::new(1).unwrap()),
431        &[0x81, 0x01, 0x04, 0x53, 0x01, VISCA_TERMINATOR]
432    );
433
434    visca_test!(
435        NoiseReduction2D,
436        test_noise_reduction_2d_level_3,
437        NoiseReduction2D::with_level(NoiseReduction2DLevel::new(3).unwrap()),
438        &[0x81, 0x01, 0x04, 0x53, 0x03, VISCA_TERMINATOR]
439    );
440
441    visca_test!(
442        NoiseReduction2D,
443        test_noise_reduction_2d_level_5,
444        NoiseReduction2D::with_level(NoiseReduction2DLevel::new(5).unwrap()),
445        &[0x81, 0x01, 0x04, 0x53, 0x05, VISCA_TERMINATOR]
446    );
447
448    #[test]
449    fn test_noise_reduction_2d_properties() {
450        let cmd = NoiseReduction2D::off();
451        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
452        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
453    }
454
455    visca_test!(
456        NoiseReduction3D,
457        test_noise_reduction_3d_off,
458        NoiseReduction3D::off(),
459        &[0x81, 0x01, 0x04, 0x54, 0x00, VISCA_TERMINATOR]
460    );
461
462    visca_test!(
463        NoiseReduction3D,
464        test_noise_reduction_3d_level_1,
465        NoiseReduction3D::with_level(NoiseReduction3DLevel::new(1).unwrap()),
466        &[0x81, 0x01, 0x04, 0x54, 0x01, VISCA_TERMINATOR]
467    );
468
469    visca_test!(
470        NoiseReduction3D,
471        test_noise_reduction_3d_level_4,
472        NoiseReduction3D::with_level(NoiseReduction3DLevel::new(4).unwrap()),
473        &[0x81, 0x01, 0x04, 0x54, 0x04, VISCA_TERMINATOR]
474    );
475
476    visca_test!(
477        NoiseReduction3D,
478        test_noise_reduction_3d_level_8,
479        NoiseReduction3D::with_level(NoiseReduction3DLevel::new(8).unwrap()),
480        &[0x81, 0x01, 0x04, 0x54, 0x08, VISCA_TERMINATOR]
481    );
482
483    #[test]
484    fn test_noise_reduction_3d_properties() {
485        let cmd = NoiseReduction3D::off();
486        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
487        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
488    }
489
490    visca_test!(
491        ImageFlipCombinedCommand,
492        test_image_flip_off,
493        ImageFlipCombinedCommand::new(ImageFlipMode::Off),
494        &[0x81, 0x01, 0x04, 0xA4, 0x00, VISCA_TERMINATOR]
495    );
496
497    visca_test!(
498        ImageFlipCombinedCommand,
499        test_image_flip_horizontal,
500        ImageFlipCombinedCommand::new(ImageFlipMode::Horizontal),
501        &[0x81, 0x01, 0x04, 0xA4, 0x01, VISCA_TERMINATOR]
502    );
503
504    visca_test!(
505        ImageFlipCombinedCommand,
506        test_image_flip_vertical,
507        ImageFlipCombinedCommand::new(ImageFlipMode::Vertical),
508        &[0x81, 0x01, 0x04, 0xA4, 0x02, VISCA_TERMINATOR]
509    );
510
511    visca_test!(
512        ImageFlipCombinedCommand,
513        test_image_flip_both,
514        ImageFlipCombinedCommand::new(ImageFlipMode::Both),
515        &[0x81, 0x01, 0x04, 0xA4, 0x03, VISCA_TERMINATOR]
516    );
517
518    #[test]
519    fn test_image_flip_properties() {
520        let cmd = ImageFlipCombinedCommand::new(ImageFlipMode::Off);
521        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
522        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
523    }
524
525    #[test]
526    fn test_command_traits() {
527        // Test Debug trait
528        let cmds: Vec<Box<dyn std::fmt::Debug>> = vec![
529            Box::new(BacklightCommand::new(true)),
530            Box::new(NoiseReduction2D::off()),
531            Box::new(NoiseReduction3D::off()),
532            Box::new(ImageFlipCombinedCommand::new(ImageFlipMode::Off)),
533        ];
534
535        for cmd in cmds {
536            let _ = format!("{cmd:?}");
537        }
538
539        // Test Clone
540        let backlight_cmd1 = BacklightCommand::new(true);
541        let backlight_cmd2 = backlight_cmd1;
542        // Verify commands produce same bytes
543        assert_eq!(
544            backlight_cmd1
545                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
546                .map(|b| b.to_vec())
547                .unwrap(),
548            backlight_cmd2
549                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
550                .map(|b| b.to_vec())
551                .unwrap()
552        );
553
554        let flip_cmd1 = ImageFlipCombinedCommand::new(ImageFlipMode::Horizontal);
555        let flip_cmd2 = flip_cmd1;
556        // Verify the command was copied correctly
557        assert_eq!(
558            flip_cmd2
559                .to_bytes(crate::camera_id::CameraId::CAMERA_1)
560                .map(|b| b.to_vec())
561                .unwrap(),
562            vec![0x81, 0x01, 0x04, 0xA4, 0x01, VISCA_TERMINATOR]
563        );
564    }
565
566    #[test]
567    fn test_response_type_none() {
568        // Verify all commands return None for response_type
569        assert!(
570            BacklightCommand::new(true).behavior().command_kind()
571                == crate::command::CommandKind::Command
572        );
573        assert!(
574            NoiseReduction2D::off().behavior().command_kind()
575                == crate::command::CommandKind::Command
576        );
577        assert!(
578            NoiseReduction3D::off().behavior().command_kind()
579                == crate::command::CommandKind::Command
580        );
581        assert!(
582            ImageFlipCombinedCommand::new(ImageFlipMode::Off)
583                .behavior()
584                .command_kind()
585                == crate::command::CommandKind::Command
586        );
587    }
588
589    #[test]
590    fn test_image_flip_mode_debug() {
591        let mode = ImageFlipMode::Off;
592        assert!(format!("{mode:?}").contains("Off"));
593        let mode = ImageFlipMode::Horizontal;
594        assert!(format!("{mode:?}").contains("Horizontal"));
595        let mode = ImageFlipMode::Vertical;
596        assert!(format!("{mode:?}").contains("Vertical"));
597        let mode = ImageFlipMode::Both;
598        assert!(format!("{mode:?}").contains("Both"));
599    }
600
601    #[test]
602    fn test_image_flip_mode_clone() {
603        let mode1 = ImageFlipMode::Horizontal;
604        let mode2 = mode1;
605        match (mode1, mode2) {
606            (ImageFlipMode::Horizontal, ImageFlipMode::Horizontal) => {}
607            _ => panic!("Copy didn't preserve mode"),
608        }
609    }
610
611    #[test]
612    fn test_command_consistency() {
613        // Test that creating commands with the same parameters produces identical bytes
614        let cmd1 = BacklightCommand::new(true);
615        let cmd2 = BacklightCommand::new(true);
616        assert_eq!(
617            cmd1.to_bytes(crate::camera_id::CameraId::CAMERA_1)
618                .map(|b| b.to_vec())
619                .unwrap(),
620            cmd2.to_bytes(crate::camera_id::CameraId::CAMERA_1)
621                .map(|b| b.to_vec())
622                .unwrap()
623        );
624
625        let level = NoiseReduction2DLevel::new(3).unwrap();
626        let cmd1 = NoiseReduction2D::with_level(level);
627        let cmd2 = NoiseReduction2D::with_level(level);
628        assert_eq!(
629            cmd1.to_bytes(crate::camera_id::CameraId::CAMERA_1)
630                .map(|b| b.to_vec())
631                .unwrap(),
632            cmd2.to_bytes(crate::camera_id::CameraId::CAMERA_1)
633                .map(|b| b.to_vec())
634                .unwrap()
635        );
636    }
637
638    #[test]
639    fn test_noise_reduction_2d_struct_creation() {
640        // Test off creation
641        let off_cmd = NoiseReduction2D::off();
642        assert!(off_cmd.level.is_none());
643
644        // Test with level creation
645        let level = NoiseReduction2DLevel::new(3).unwrap();
646        let level_cmd = NoiseReduction2D::with_level(level);
647        assert_eq!(level_cmd.level.unwrap().value(), 3);
648    }
649
650    #[test]
651    fn test_noise_reduction_3d_struct_creation() {
652        // Test off creation
653        let off_cmd = NoiseReduction3D::off();
654        assert!(off_cmd.level.is_none());
655
656        // Test with level creation
657        let level = NoiseReduction3DLevel::new(5).unwrap();
658        let level_cmd = NoiseReduction3D::with_level(level);
659        assert_eq!(level_cmd.level.unwrap().value(), 5);
660    }
661
662    #[test]
663    fn test_command_categories() {
664        // Test that BacklightCommand and BlackWhiteCommand use Quick category
665        let cmd = BacklightCommand::new(true);
666        assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
667
668        // Test that noise reduction and flip commands use Custom category
669        let cmd = NoiseReduction2D::off();
670        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
671
672        let cmd = NoiseReduction3D::off();
673        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
674
675        let cmd = ImageFlipCombinedCommand::new(ImageFlipMode::Off);
676        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
677    }
678
679    #[test]
680    fn test_backlight_command_debug() {
681        let cmd = BacklightCommand::new(true);
682        let debug_str = format!("{cmd:?}");
683        assert!(debug_str.contains("BacklightCommand"));
684        // The debug output will show the field name
685        assert!(debug_str.contains("enabled"));
686
687        let cmd = BacklightCommand::new(false);
688        let debug_str = format!("{cmd:?}");
689        assert!(debug_str.contains("BacklightCommand"));
690    }
691
692    visca_test!(
693        PictureEffectCommand,
694        test_picture_effect_off,
695        PictureEffectCommand {
696            mode: PictureEffectMode::Off
697        },
698        &[0x81, 0x01, 0x04, 0x63, 0x00, VISCA_TERMINATOR]
699    );
700
701    visca_test!(
702        PictureEffectCommand,
703        test_picture_effect_black_white,
704        PictureEffectCommand {
705            mode: PictureEffectMode::BlackAndWhite
706        },
707        &[0x81, 0x01, 0x04, 0x63, 0x04, VISCA_TERMINATOR]
708    );
709
710    visca_test!(
711        PictureEffectCommand,
712        test_picture_effect_unknown_raw_value,
713        PictureEffectCommand {
714            mode: PictureEffectMode::Unknown(0x05)
715        },
716        &[0x81, 0x01, 0x04, 0x63, 0x05, VISCA_TERMINATOR]
717    );
718
719    #[test]
720    fn test_picture_effect_rejects_unvalidated_named_modes() {
721        for mode in [
722            PictureEffectMode::Negative,
723            PictureEffectMode::Sepia,
724            PictureEffectMode::Sketch,
725            PictureEffectMode::Emboss,
726            PictureEffectMode::Mosaic,
727        ] {
728            let cmd = PictureEffectCommand { mode };
729            let mut buffer = [0; PictureEffectCommand::MAX_SIZE];
730            let error = match cmd.write_into(crate::camera_id::CameraId::default(), &mut buffer) {
731                Err(error) => error,
732                Ok(size) => panic!("unvalidated named picture effect encoded {size} bytes"),
733            };
734            assert!(matches!(
735                error,
736                Error::InvalidParameter {
737                    parameter: "mode",
738                    ..
739                }
740            ));
741        }
742    }
743
744    #[test]
745    fn test_picture_effect_properties() {
746        let cmd = PictureEffectCommand {
747            mode: PictureEffectMode::Off,
748        };
749        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
750        assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
751    }
752
753    visca_test!(
754        Sharpness,
755        test_sharpness_mode_auto,
756        Sharpness::Mode(SharpnessMode::Auto),
757        &[0x81, 0x01, 0x04, 0x05, 0x02, VISCA_TERMINATOR]
758    );
759
760    visca_test!(
761        Sharpness,
762        test_sharpness_mode_manual,
763        Sharpness::Mode(SharpnessMode::Manual),
764        &[0x81, 0x01, 0x04, 0x05, 0x03, VISCA_TERMINATOR]
765    );
766
767    #[test]
768    fn test_sharpness_properties() {
769        let cmd = Sharpness::Mode(SharpnessMode::Auto);
770        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
771        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
772    }
773
774    visca_test!(
775        Sharpness,
776        test_sharpness_reset,
777        Sharpness::Reset,
778        &[0x81, 0x01, 0x04, 0x02, 0x00, VISCA_TERMINATOR]
779    );
780
781    visca_test!(
782        Sharpness,
783        test_sharpness_up,
784        Sharpness::Up,
785        &[0x81, 0x01, 0x04, 0x02, 0x02, VISCA_TERMINATOR]
786    );
787    visca_test!(
788        Sharpness,
789        test_sharpness_down,
790        Sharpness::Down,
791        &[0x81, 0x01, 0x04, 0x02, 0x03, VISCA_TERMINATOR]
792    );
793    visca_test!(
794        Sharpness,
795        test_sharpness_level_0,
796        Sharpness::SetLevel { value: 0 },
797        &[
798            0x81,
799            0x01,
800            0x04,
801            0x42,
802            0x00,
803            0x00,
804            0x00,
805            0x00,
806            VISCA_TERMINATOR
807        ]
808    );
809
810    visca_test!(
811        Sharpness,
812        test_sharpness_level_5,
813        Sharpness::SetLevel { value: 5 },
814        &[
815            0x81,
816            0x01,
817            0x04,
818            0x42,
819            0x00,
820            0x00,
821            0x00,
822            0x05,
823            VISCA_TERMINATOR
824        ]
825    );
826    visca_test!(
827        Sharpness,
828        test_sharpness_level_11,
829        Sharpness::SetLevel { value: 11 },
830        &[
831            0x81,
832            0x01,
833            0x04,
834            0x42,
835            0x00,
836            0x00,
837            0x00,
838            0x0B,
839            VISCA_TERMINATOR
840        ]
841    );
842    visca_test!(
843        Sharpness,
844        test_sharpness_level_15,
845        Sharpness::SetLevel { value: 15 },
846        &[
847            0x81,
848            0x01,
849            0x04,
850            0x42,
851            0x00,
852            0x00,
853            0x00,
854            0x0F,
855            VISCA_TERMINATOR
856        ]
857    );
858
859    #[test]
860    fn test_sharpness_valid_values() {
861        use crate::types::SharpnessLevel;
862
863        // Test valid values (hardware-validated: PTZOptics G2 accepts 0-15)
864        for value in 0..=15 {
865            let _cmd = Sharpness::SetLevel { value };
866        }
867
868        // Test invalid value
869        // SharpnessLevel enforces valid range 0-15, so we can't create value 16
870        let result = SharpnessLevel::new(16);
871        assert!(result.is_err());
872    }
873
874    visca_test!(
875        Luminance,
876        test_luminance_level_0,
877        Luminance::new(LuminanceLevel::new(0).unwrap()),
878        &[
879            0x81,
880            0x01,
881            0x04,
882            0xA1,
883            0x00,
884            0x00,
885            0x00,
886            0x00,
887            VISCA_TERMINATOR
888        ]
889    );
890
891    visca_test!(
892        Luminance,
893        test_luminance_level_7,
894        Luminance::new(LuminanceLevel::new(7).unwrap()),
895        &[
896            0x81,
897            0x01,
898            0x04,
899            0xA1,
900            0x00,
901            0x00,
902            0x00,
903            0x07,
904            VISCA_TERMINATOR
905        ]
906    );
907
908    visca_test!(
909        Luminance,
910        test_luminance_level_14,
911        Luminance::new(LuminanceLevel::new(14).unwrap()),
912        &[
913            0x81,
914            0x01,
915            0x04,
916            0xA1,
917            0x00,
918            0x00,
919            0x00,
920            0x0E,
921            VISCA_TERMINATOR
922        ]
923    );
924
925    #[test]
926    fn test_luminance_properties() {
927        let cmd = Luminance::new(LuminanceLevel::new(7).unwrap());
928        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
929        assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
930    }
931
932    #[test]
933    fn test_luminance_valid_values() {
934        // Test valid values
935        for value in 0..=14 {
936            let level = LuminanceLevel::new(value)
937                .unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
938            let _cmd = Luminance::new(level);
939        }
940    }
941
942    visca_test!(
943        Contrast,
944        test_contrast_level_0,
945        Contrast::new(ContrastLevel::new(0).unwrap()),
946        &[
947            0x81,
948            0x01,
949            0x04,
950            0xA2,
951            0x00,
952            0x00,
953            0x00,
954            0x00,
955            VISCA_TERMINATOR
956        ]
957    );
958
959    visca_test!(
960        Contrast,
961        test_contrast_level_7,
962        Contrast::new(ContrastLevel::new(7).unwrap()),
963        &[
964            0x81,
965            0x01,
966            0x04,
967            0xA2,
968            0x00,
969            0x00,
970            0x00,
971            0x07,
972            VISCA_TERMINATOR
973        ]
974    );
975
976    visca_test!(
977        Contrast,
978        test_contrast_level_14,
979        Contrast::new(ContrastLevel::new(14).unwrap()),
980        &[
981            0x81,
982            0x01,
983            0x04,
984            0xA2,
985            0x00,
986            0x00,
987            0x00,
988            0x0E,
989            VISCA_TERMINATOR
990        ]
991    );
992
993    #[test]
994    fn test_contrast_properties() {
995        let cmd = Contrast::new(ContrastLevel::new(7).unwrap());
996        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
997        assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
998    }
999
1000    #[test]
1001    fn test_contrast_valid_values() {
1002        // Test valid values
1003        for value in 0..=14 {
1004            let level = ContrastLevel::new(value)
1005                .unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1006            let _cmd = Contrast::new(level);
1007        }
1008    }
1009
1010    visca_test!(
1011        GammaCommand,
1012        test_gamma_level_0,
1013        GammaCommand::new(GammaLevel::new(0).unwrap()),
1014        &[0x81, 0x01, 0x04, 0x5B, 0x00, VISCA_TERMINATOR]
1015    );
1016
1017    visca_test!(
1018        GammaCommand,
1019        test_gamma_level_2,
1020        GammaCommand::new(GammaLevel::new(2).unwrap()),
1021        &[0x81, 0x01, 0x04, 0x5B, 0x02, VISCA_TERMINATOR]
1022    );
1023
1024    visca_test!(
1025        GammaCommand,
1026        test_gamma_level_4,
1027        GammaCommand::new(GammaLevel::new(4).unwrap()),
1028        &[0x81, 0x01, 0x04, 0x5B, 0x04, VISCA_TERMINATOR]
1029    );
1030
1031    #[test]
1032    fn test_gamma_properties() {
1033        let cmd = GammaCommand::new(GammaLevel::new(0).unwrap());
1034        assert!(cmd.behavior().command_kind() == crate::command::CommandKind::Command);
1035        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
1036    }
1037
1038    #[test]
1039    fn test_gamma_valid_values() {
1040        for value in 0..=4 {
1041            let level =
1042                GammaLevel::new(value).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1043            let _cmd = GammaCommand::new(level);
1044        }
1045    }
1046
1047    #[test]
1048    fn test_sharpness_mode_equality() {
1049        assert_eq!(SharpnessMode::Auto, SharpnessMode::Auto);
1050        assert_eq!(SharpnessMode::Manual, SharpnessMode::Manual);
1051        assert_ne!(SharpnessMode::Auto, SharpnessMode::Manual);
1052    }
1053
1054    #[test]
1055    fn test_additional_command_traits() {
1056        // Test Debug trait
1057        let cmds: Vec<Box<dyn std::fmt::Debug>> = vec![
1058            Box::new(Sharpness::Reset),
1059            Box::new(Sharpness::Mode(SharpnessMode::Auto)),
1060            Box::new(Sharpness::SetLevel { value: 5 }),
1061            Box::new(Luminance::new(
1062                LuminanceLevel::new(7).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}")),
1063            )),
1064            Box::new(Contrast::new(
1065                ContrastLevel::new(7).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}")),
1066            )),
1067            Box::new(GammaCommand::new(
1068                GammaLevel::new(2).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}")),
1069            )),
1070        ];
1071
1072        for cmd in cmds {
1073            let _ = format!("{cmd:?}");
1074        }
1075
1076        // Test Clone
1077        let sharp_cmd1 = Sharpness::SetLevel { value: 5 };
1078        let sharp_cmd2 = sharp_cmd1;
1079        match (sharp_cmd1, sharp_cmd2) {
1080            (Sharpness::SetLevel { value: v1 }, Sharpness::SetLevel { value: v2 }) => {
1081                assert_eq!(v1, v2);
1082            }
1083            _ => panic!("Clone didn't preserve variant"),
1084        }
1085    }
1086
1087    #[test]
1088    fn test_edge_cases_extended() {
1089        // Test boundary values for sharpness
1090        let cmd = Sharpness::SetLevel { value: 0 };
1091        assert!(cmd
1092            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1093            .map(|b| b.to_vec())
1094            .is_ok());
1095
1096        let cmd = Sharpness::SetLevel { value: 15 };
1097        assert!(cmd
1098            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1099            .map(|b| b.to_vec())
1100            .is_ok());
1101
1102        // Test boundary values for luminance
1103        let level =
1104            LuminanceLevel::new(0).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1105        let cmd = Luminance { value: level };
1106        assert!(cmd
1107            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1108            .map(|b| b.to_vec())
1109            .is_ok());
1110
1111        let level =
1112            LuminanceLevel::new(14).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1113        let cmd = Luminance { value: level };
1114        assert!(cmd
1115            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1116            .map(|b| b.to_vec())
1117            .is_ok());
1118
1119        // Test boundary values for contrast
1120        let level =
1121            ContrastLevel::new(0).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1122        let cmd = Contrast { value: level };
1123        assert!(cmd
1124            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1125            .map(|b| b.to_vec())
1126            .is_ok());
1127
1128        let level =
1129            ContrastLevel::new(14).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1130        let cmd = Contrast { value: level };
1131        assert!(cmd
1132            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1133            .map(|b| b.to_vec())
1134            .is_ok());
1135    }
1136
1137    #[test]
1138    fn test_nibble_encoding_sharpness() {
1139        // Test that SetLevel command properly encodes value as nibbles
1140        let cmd = Sharpness::SetLevel { value: 0x0B };
1141        let bytes = cmd
1142            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1143            .map(|b| b.to_vec())
1144            .unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1145        assert_eq!(bytes[6], 0x00); // High nibble
1146        assert_eq!(bytes[7], 0x0B); // Low nibble
1147
1148        let cmd = Sharpness::SetLevel { value: 0x05 };
1149        let bytes = cmd
1150            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1151            .map(|b| b.to_vec())
1152            .unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1153        assert_eq!(bytes[6], 0x00); // High nibble
1154        assert_eq!(bytes[7], 0x05); // Low nibble
1155
1156        let cmd = Sharpness::SetLevel { value: 0x0F };
1157        let bytes = cmd
1158            .to_bytes(crate::camera_id::CameraId::CAMERA_1)
1159            .map(|b| b.to_vec())
1160            .unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1161        assert_eq!(bytes[6], 0x00);
1162        assert_eq!(bytes[7], 0x0F);
1163    }
1164
1165    #[test]
1166    fn test_response_type_none_extended() {
1167        // Verify all commands return None for response_type
1168        assert!(Sharpness::Reset.behavior().command_kind() == crate::command::CommandKind::Command);
1169        assert!(
1170            Sharpness::Mode(SharpnessMode::Auto)
1171                .behavior()
1172                .command_kind()
1173                == crate::command::CommandKind::Command
1174        );
1175        assert!(Sharpness::Up.behavior().command_kind() == crate::command::CommandKind::Command);
1176        assert!(Sharpness::Down.behavior().command_kind() == crate::command::CommandKind::Command);
1177        assert!(
1178            Sharpness::SetLevel { value: 5 }.behavior().command_kind()
1179                == crate::command::CommandKind::Command
1180        );
1181        assert!(
1182            Luminance::new(
1183                LuminanceLevel::new(7).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"))
1184            )
1185            .behavior()
1186            .command_kind()
1187                == crate::command::CommandKind::Command
1188        );
1189        assert!(
1190            Contrast::new(
1191                ContrastLevel::new(7).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"))
1192            )
1193            .behavior()
1194            .command_kind()
1195                == crate::command::CommandKind::Command
1196        );
1197        assert!(
1198            GammaCommand::new(
1199                GammaLevel::new(2).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"))
1200            )
1201            .behavior()
1202            .command_kind()
1203                == crate::command::CommandKind::Command
1204        );
1205    }
1206
1207    #[test]
1208    fn test_command_categories_extended() {
1209        // Test Sharpness uses Custom category
1210        let sharpness_cmds = vec![
1211            Sharpness::Reset,
1212            Sharpness::Mode(SharpnessMode::Auto),
1213            Sharpness::Up,
1214            Sharpness::Down,
1215            Sharpness::SetLevel { value: 5 },
1216        ];
1217
1218        for cmd in sharpness_cmds {
1219            assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
1220        }
1221
1222        // Test Luminance and Contrast use Quick category
1223        let level =
1224            LuminanceLevel::new(7).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1225        let cmd = Luminance { value: level };
1226        assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
1227
1228        let level =
1229            ContrastLevel::new(7).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1230        let cmd = Contrast { value: level };
1231        assert!(matches!(cmd.timeout_class(), CommandCategory::Quick));
1232
1233        // Test GammaCommand uses Custom category
1234        let level = GammaLevel::new(2).unwrap_or_else(|e| panic!("Test assertion failed: {e:?}"));
1235        let cmd = GammaCommand { level };
1236        assert!(matches!(cmd.timeout_class(), CommandCategory::Custom));
1237    }
1238}