Initial commit: pure-Rust cross-platform Insta360 Link controller suite

This commit is contained in:
Lucas Oskorep committed 2026-10-19 17:08:35 -04:00
commit f468c15262
27 files changed
+22165

No files matched your search

@@ -0,0 +1,662 @@
use insta360_link_protocol::*;
/// Builds a synthetic USB Configuration Descriptor containing:
/// - Standard Interface Descriptor for UVC VideoControl (`bInterfaceNumber = 2`, `CC_VIDEO = 0x0e`, `SC_VIDEOCONTROL = 0x01`)
/// - `VC_INPUT_TERMINAL` (`bDescriptorSubtype = 0x02`, `bTerminalID = 3`, `wTerminalType = 0x0201` `ITT_CAMERA`)
/// - `VC_PROCESSING_UNIT` (`bDescriptorSubtype = 0x05`, `bUnitID = 5`)
/// - `VC_EXTENSION_UNIT` (`bDescriptorSubtype = 0x06`, `bUnitID = 11`) with `XU1_GUID_BYTES`
/// - `VC_EXTENSION_UNIT` (`bDescriptorSubtype = 0x06`, `bUnitID = 12`) with `XU2_GUID_BYTES`
/// - Standard Interface Descriptor for UVC VideoStreaming (`bInterfaceNumber = 3`, `CC_VIDEO = 0x0e`, `SC_VIDEOSTREAMING = 0x02`)
/// followed by a dummy `CS_INTERFACE` descriptor that should be ignored by `parse_uvc_vc_descriptors`.
#[test]
fn test_parse_uvc_vc_descriptors_synthetic() {
let mut desc = Vec::new();
// 1. Standard Interface Descriptor (USB_DT_INTERFACE = 0x04): bInterfaceNumber = 2, CC_VIDEO (0x0e), SC_VIDEOCONTROL (0x01)
desc.extend_from_slice(&[
9, // bLength
0x04, // bDescriptorType = USB_DT_INTERFACE
2, // bInterfaceNumber = 2
0, // bAlternateSetting
1, // bNumEndpoints
0x0e, // bInterfaceClass = CC_VIDEO
0x01, // bInterfaceSubClass = SC_VIDEOCONTROL
0x00, // bInterfaceProtocol
0, // iInterface
]);
// 2. VC_INPUT_TERMINAL (CS_INTERFACE = 0x24, subtype = 0x02, bTerminalID = 3, wTerminalType = 0x0201 ITT_CAMERA)
desc.extend_from_slice(&[
18, // bLength
0x24, // bDescriptorType = CS_INTERFACE
0x02, // bDescriptorSubtype = VC_INPUT_TERMINAL
3, // bTerminalID = 3
0x01, 0x02, // wTerminalType = 0x0201 (ITT_CAMERA)
0, // bAssocTerminal
0, // iTerminal
0, 0, 0, 0, 0, 0, 3, 0xff, 0xff, 0x0f,
]);
// 3. VC_PROCESSING_UNIT (CS_INTERFACE = 0x24, subtype = 0x05, bUnitID = 5)
desc.extend_from_slice(&[
13, // bLength
0x24, // bDescriptorType = CS_INTERFACE
0x05, // bDescriptorSubtype = VC_PROCESSING_UNIT
5, // bUnitID = 5
3, // bSourceID = 3
0, 0, // wMaxMultiplier
3, // bControlSize
0xff, 0xff, 0x00, // bmControls
0, // iProcessing
0, // bmVideoStandards
]);
// 4. VC_EXTENSION_UNIT for XU1 (CS_INTERFACE = 0x24, subtype = 0x06, bUnitID = 11, guid = XU1_GUID_BYTES)
let mut xu1_desc = vec![
24, // bLength
0x24, // bDescriptorType = CS_INTERFACE
0x06, // bDescriptorSubtype = VC_EXTENSION_UNIT
11, // bUnitID = 11
];
xu1_desc.extend_from_slice(&XU1_GUID_BYTES);
xu1_desc.extend_from_slice(&[15, 1, 5, 0]);
assert_eq!(xu1_desc.len(), 24);
desc.extend_from_slice(&xu1_desc);
// 5. VC_EXTENSION_UNIT for XU2 (CS_INTERFACE = 0x24, subtype = 0x06, bUnitID = 12, guid = XU2_GUID_BYTES)
let mut xu2_desc = vec![
24, // bLength
0x24, // bDescriptorType = CS_INTERFACE
0x06, // bDescriptorSubtype = VC_EXTENSION_UNIT
12, // bUnitID = 12
];
xu2_desc.extend_from_slice(&XU2_GUID_BYTES);
xu2_desc.extend_from_slice(&[15, 1, 11, 0]);
assert_eq!(xu2_desc.len(), 24);
desc.extend_from_slice(&xu2_desc);
// 6. Standard Interface Descriptor for VideoStreaming (SC_VIDEOSTREAMING = 0x02)
// Descriptors inside VideoStreaming must NOT overwrite VC unit IDs.
desc.extend_from_slice(&[
9, // bLength
0x04, // bDescriptorType = USB_DT_INTERFACE
3, // bInterfaceNumber = 3
0, // bAlternateSetting
0, // bNumEndpoints
0x0e, // bInterfaceClass = CC_VIDEO
0x02, // bInterfaceSubClass = SC_VIDEOSTREAMING
0x00, // bInterfaceProtocol
0, // iInterface
]);
desc.extend_from_slice(&[
6, // bLength
0x24, // CS_INTERFACE inside VideoStreaming (should be ignored)
0x05, // subtype 0x05
99, // would be unit 99 if not ignored
0, 0,
]);
let parsed = parse_uvc_vc_descriptors(&desc);
assert_eq!(parsed.control_interface, 2);
assert_eq!(parsed.input_terminal_id, 3);
assert_eq!(parsed.processing_unit_id, 5);
assert_eq!(parsed.extension_unit_id, 11);
assert_eq!(parsed.extension_unit2_id, 12);
}
#[test]
fn test_camera_models_pids_capabilities_and_video_mode_len() {
// Verify Vendor ID and device filter
assert_eq!(INSTA360_VENDOR_ID, 0x2e1a);
for &pid in &[
pids::LINK_1,
pids::PUC3_GIMBAL_A,
pids::LINK_2C,
pids::LINK_2,
pids::PUC3_HOST,
pids::PUC3_GIMBAL_B,
pids::LINK_SIMPLE_CLASS,
] {
assert!(is_insta360_link_device(INSTA360_VENDOR_ID, pid));
assert!(!is_insta360_link_device(0x1234, pid));
}
assert!(!is_insta360_link_device(INSTA360_VENDOR_ID, 0x9999));
// Verify PID -> CameraModel mappings, internal codes, firmware filenames, and SetVideoMode payload lengths
let link1 = CameraModel::from_pid(pids::LINK_1);
assert_eq!(link1, CameraModel::Link);
assert_eq!(link1.pid(), 0x4c01);
assert_eq!(link1.internal_code(), "link1");
assert_eq!(link1.firmware_filename(), Some(r"a:\Insta360WebCamFW.bin"));
assert_eq!(link1.video_mode_payload_len(), 8);
let puc3_a = CameraModel::from_pid(pids::PUC3_GIMBAL_A);
assert_eq!(puc3_a, CameraModel::Puc3Gimbal(0x4c02));
assert_eq!(puc3_a.pid(), 0x4c02);
assert_eq!(puc3_a.internal_code(), "link2_gimbal");
assert_eq!(puc3_a.firmware_filename(), Some(r"a:\Insta360PUC3FW_GIMBAL.bin"));
assert_eq!(puc3_a.video_mode_payload_len(), 8);
let link2c = CameraModel::from_pid(pids::LINK_2C);
assert_eq!(link2c, CameraModel::Link2C);
assert_eq!(link2c.pid(), 0x4c03);
assert_eq!(link2c.internal_code(), "link2_main");
assert_eq!(link2c.firmware_filename(), Some(r"a:\Insta360LINK2CFW_HOST.bin"));
assert_eq!(link2c.video_mode_payload_len(), 36);
let link2 = CameraModel::from_pid(pids::LINK_2);
assert_eq!(link2, CameraModel::Link2);
assert_eq!(link2.pid(), 0x4c04);
assert_eq!(link2.internal_code(), "link2_gimbal");
assert_eq!(link2.firmware_filename(), Some(r"a:\Insta360LINK2FW_GIMBAL.bin"));
assert_eq!(link2.video_mode_payload_len(), 8);
let puc3_host = CameraModel::from_pid(pids::PUC3_HOST);
assert_eq!(puc3_host, CameraModel::Puc3Host);
assert_eq!(puc3_host.pid(), 0x4c05);
assert_eq!(puc3_host.internal_code(), "link2_main");
assert_eq!(puc3_host.firmware_filename(), Some(r"a:\Insta360PUC3FW_HOST.bin"));
assert_eq!(puc3_host.video_mode_payload_len(), 36);
let puc3_b = CameraModel::from_pid(pids::PUC3_GIMBAL_B);
assert_eq!(puc3_b, CameraModel::Puc3Gimbal(0x4c06));
assert_eq!(puc3_b.pid(), 0x4c06);
assert_eq!(puc3_b.internal_code(), "link2_gimbal");
assert_eq!(puc3_b.firmware_filename(), Some(r"a:\Insta360PUC3FW_GIMBAL.bin"));
assert_eq!(puc3_b.video_mode_payload_len(), 8);
let dfu = CameraModel::from_pid(pids::LINK_SIMPLE_CLASS);
assert_eq!(dfu, CameraModel::LinkSimpleClass);
assert_eq!(dfu.pid(), 0x00c2);
assert_eq!(dfu.internal_code(), "link1");
assert_eq!(dfu.firmware_filename(), Some(r"a:\Insta360WebCamFW.bin"));
assert_eq!(dfu.video_mode_payload_len(), 36);
// Capability matrix assertions (`Webcam::CameraDeviceSupporter::UpdateUIStatus` at `0x10004a8b8`)
let cap_link1 = DeviceCapabilities::for_pid(pids::LINK_1);
assert!(cap_link1.has_physical_gimbal);
assert!(cap_link1.support_roll_adjustment);
assert!(!cap_link1.support_auto_framing);
assert!(!cap_link1.support_privacy_mode);
assert!(!cap_link1.support_audio_capture_mode);
assert!(!cap_link1.support_horizontal_correction);
assert!(!cap_link1.support_sound_wall);
let cap_link2 = DeviceCapabilities::for_pid(pids::LINK_2);
assert!(cap_link2.has_physical_gimbal);
assert!(!cap_link2.support_roll_adjustment);
assert!(cap_link2.support_auto_framing);
assert!(cap_link2.support_privacy_mode);
assert!(cap_link2.support_area_track);
assert!(cap_link2.support_speak_tracking);
assert!(cap_link2.support_audio_capture_mode);
assert!(cap_link2.support_horizontal_correction);
assert!(!cap_link2.support_sound_wall);
let cap_link2c = DeviceCapabilities::for_pid(pids::LINK_2C);
assert!(!cap_link2c.has_physical_gimbal);
assert!(!cap_link2c.support_ptz_drag);
assert!(!cap_link2c.support_track_speed);
assert!(cap_link2c.support_auto_framing);
assert!(cap_link2c.support_image_template);
assert!(cap_link2c.support_privacy_mode);
assert!(cap_link2c.support_audio_capture_mode);
assert!(!cap_link2c.support_area_track);
assert!(!cap_link2c.support_horizontal_correction);
let cap_puc3_b = DeviceCapabilities::for_pid(pids::PUC3_GIMBAL_B);
assert!(cap_puc3_b.has_physical_gimbal);
assert!(cap_puc3_b.support_area_track);
assert!(cap_puc3_b.support_speak_tracking);
assert!(cap_puc3_b.support_sound_wall);
assert!(!cap_puc3_b.support_horizontal_correction);
let cap_puc3_host = DeviceCapabilities::for_pid(pids::PUC3_HOST);
assert!(!cap_puc3_host.has_physical_gimbal);
assert!(cap_puc3_host.support_sound_wall);
assert!(cap_puc3_host.support_auto_framing);
}
#[test]
fn test_controller_ptz_presets_and_gimbal_commands() {
let mock = MockCameraTransport::new(CameraModel::Link2);
let ctrl = Insta360CameraController::new(mock.clone());
// 1. Absolute Pan/Tilt & Zoom
ctrl.set_pantilt_absolute(36000, -18000).unwrap();
assert_eq!(ctrl.get_pantilt_absolute().unwrap(), (36000, -18000));
ctrl.set_zoom(250).unwrap();
assert_eq!(ctrl.get_zoom().unwrap(), 250);
let zoom_range = ctrl.get_zoom_range().unwrap();
assert_eq!((zoom_range.min, zoom_range.max, zoom_range.default), (100, 400, 100));
// 2. Relative Pan/Tilt (XU1 0x0b: [pan_speed, pan_dir, tilt_speed, tilt_dir])
ctrl.set_pantilt_relative_ext(5, -1, 3, 1).unwrap();
ctrl.stop_pantilt_relative().unwrap();
// 3. Presets (XU1 0x01: [sub_op, index, 0, 0])
ctrl.add_preset_position(4).unwrap();
let flags = ctrl.get_preset_flags().unwrap();
assert_ne!(flags.slot_mask & (1 << 4), 0);
assert_eq!(flags.current_slot, 4);
ctrl.recall_preset_position(1).unwrap();
assert_eq!(ctrl.get_preset_flags().unwrap().current_slot, 1);
ctrl.delete_preset_position(4).unwrap();
assert_eq!(ctrl.get_preset_flags().unwrap().slot_mask & (1 << 4), 0);
// 4. Composition bias & Reset Gimbal
ctrl.set_composition_bias(-120, 80).unwrap();
ctrl.reset_composition_bias().unwrap();
ctrl.reset_gimbal().unwrap();
assert_eq!(ctrl.get_pantilt_absolute().unwrap(), (0, 0));
assert_eq!(ctrl.get_zoom().unwrap(), 100);
// 5. Host Digital PTZ (XU2 0x09)
let host_crop = HostPtzInfo {
x: 500,
y: 600,
width: 2570,
height: 2570,
};
ctrl.set_host_ptz(&host_crop).unwrap();
// Inspect command log for exact unit IDs, selectors, and payload bytes
let log = mock.command_log();
assert_eq!(
log[0],
(
UvcRequest::SetCur,
DEFAULT_INPUT_TERMINAL_ID,
ct_selectors::PANTILT_ABSOLUTE,
[36000i32.to_le_bytes(), (-18000i32).to_le_bytes()].concat()
)
);
assert_eq!(
log[1],
(
UvcRequest::SetCur,
DEFAULT_INPUT_TERMINAL_ID,
ct_selectors::ZOOM_ABSOLUTE,
250u16.to_le_bytes().to_vec()
)
);
assert_eq!(
log[2],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::PANTILT_RELATIVE_EXT,
vec![0xff, 5, 1, 3]
)
);
assert_eq!(
log[3],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::PANTILT_RELATIVE_EXT,
vec![0, 1, 0, 1]
)
);
assert_eq!(
log[4],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::SYSTEM_AND_PRESET_CMD,
vec![0x01, 4, 0x00, 0x00]
)
);
assert_eq!(
log[5],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::SYSTEM_AND_PRESET_CMD,
vec![0x00, 1, 0x00, 0x00]
)
);
assert_eq!(
log[6],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::SYSTEM_AND_PRESET_CMD,
vec![0x02, 4, 0x00, 0x00]
)
);
assert_eq!(
log[7],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::SYSTEM_AND_PRESET_CMD,
vec![0x06, (-120i16).to_le_bytes()[0], (-120i16).to_le_bytes()[1], 80, 0]
)
);
assert_eq!(
log[8],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::SYSTEM_AND_PRESET_CMD,
vec![0x06, 0xff, 0x7f, 0xff, 0x7f]
)
);
assert_eq!(
log[9],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::SYSTEM_AND_PRESET_CMD,
vec![0x03, 0x00, 0x00, 0x00]
)
);
assert_eq!(
log[10],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT2_ID,
xu2_selectors::HOST_PTZ,
[
500u16.to_le_bytes(),
600u16.to_le_bytes(),
2570u16.to_le_bytes(),
2570u16.to_le_bytes()
]
.concat()
)
);
}
#[test]
fn test_video_mode_8byte_vs_36byte_and_tracking_gestures() {
// 1. Link 2 uses 8-byte SetVideoMode payload
let mock_link2 = MockCameraTransport::new(CameraModel::Link2);
let ctrl_link2 = Insta360CameraController::new(mock_link2.clone());
let aux = VideoModeAuxData {
flags: 1,
rect: [10, 20, 300, 400],
pitch: -450,
zoom: 150,
extra: [0xaa; 14],
};
ctrl_link2.set_video_mode(VideoMode::DeskView, &aux).unwrap();
let log2 = mock_link2.command_log();
assert_eq!(log2.len(), 1);
assert_eq!(log2[0].1, DEFAULT_EXTENSION_UNIT_ID);
assert_eq!(log2[0].2, xu1_selectors::VIDEO_MODE);
assert_eq!(log2[0].3.len(), 8);
assert_eq!(log2[0].3[0], VideoMode::DeskView as u8);
assert_eq!(log2[0].3[1], 1);
// 2. Link 2C uses 36-byte SetVideoMode payload
let mock_link2c = MockCameraTransport::new(CameraModel::Link2C);
let ctrl_link2c = Insta360CameraController::new(mock_link2c.clone());
ctrl_link2c.set_video_mode(VideoMode::AutoFraming, &aux).unwrap();
let log2c = mock_link2c.command_log();
assert_eq!(log2c.len(), 1);
assert_eq!(log2c[0].1, DEFAULT_EXTENSION_UNIT_ID);
assert_eq!(log2c[0].2, xu1_selectors::VIDEO_MODE);
assert_eq!(log2c[0].3.len(), 36);
let (decoded_mode, decoded_aux) = ctrl_link2c.get_video_mode().unwrap();
assert_eq!(decoded_mode, VideoMode::AutoFraming);
assert_eq!(decoded_aux.rect, [10, 20, 300, 400]);
assert_eq!(decoded_aux.pitch, -450);
assert_eq!(decoded_aux.zoom, 150);
// 3. Tracking speed, composition style, and gestures on XU1 0x03
ctrl_link2.set_track_speed(TrackSpeed::Fast).unwrap();
assert_eq!(ctrl_link2.get_track_speed().unwrap(), TrackSpeed::Fast);
ctrl_link2.set_composition_style(CompositionStyle::WholeBody).unwrap();
assert_eq!(ctrl_link2.get_composition_style().unwrap(), CompositionStyle::WholeBody);
ctrl_link2.set_gesture_enabled(Gesture::VShape, false).unwrap();
let gestures = ctrl_link2.get_gesture_status().unwrap();
assert_eq!(gestures[3], (Gesture::VShape, false));
assert_eq!(gestures[0], (Gesture::Ok, true));
ctrl_link2.set_all_gestures_enabled(false).unwrap();
let log2_after = mock_link2.command_log();
// TrackSpeed::Fast -> [5, 2, 0..0] (16 bytes)
let mut expected_speed = vec![0u8; 16];
expected_speed[0] = 0x05;
expected_speed[1] = TrackSpeed::Fast as u8;
assert_eq!(
log2_after[1],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::TRACKING_AND_GESTURE,
expected_speed
)
);
// CompositionStyle::WholeBody -> [6, 2, 0..0] (16 bytes)
let mut expected_style = vec![0u8; 16];
expected_style[0] = 0x06;
expected_style[1] = CompositionStyle::WholeBody as u8;
assert_eq!(
log2_after[2],
(
UvcRequest::SetCur,
DEFAULT_EXTENSION_UNIT_ID,
xu1_selectors::TRACKING_AND_GESTURE,
expected_style
)
);
}
#[test]
fn test_image_controls_exposure_wb_audio_filters_templates_and_extend_functions() {
let mock = MockCameraTransport::new(CameraModel::Link2);
let ctrl = Insta360CameraController::new(mock.clone());
// 1. Standard Processing Unit (PU) controls
ctrl.set_brightness(72).unwrap();
assert_eq!(ctrl.get_brightness().unwrap(), 72);
ctrl.set_contrast(64).unwrap();
assert_eq!(ctrl.get_contrast().unwrap(), 64);
ctrl.set_saturation(80).unwrap();
assert_eq!(ctrl.get_saturation().unwrap(), 80);
ctrl.set_sharpness(45).unwrap();
assert_eq!(ctrl.get_sharpness().unwrap(), 45);
ctrl.set_auto_white_balance(false).unwrap();
assert!(!ctrl.get_auto_white_balance().unwrap());
ctrl.set_white_balance_temperature(6500).unwrap();
assert_eq!(ctrl.get_white_balance_temperature().unwrap(), 6500);
ctrl.set_power_line_frequency(PowerLineFrequency::Hz50).unwrap();
assert_eq!(ctrl.get_power_line_frequency().unwrap(), PowerLineFrequency::Hz50);
// 2. Standard Camera Terminal (CT) focus, roll, shutter
ctrl.set_auto_focus(false).unwrap();
assert!(!ctrl.get_auto_focus().unwrap());
ctrl.set_focus(85).unwrap();
assert_eq!(ctrl.get_focus().unwrap(), 85);
ctrl.set_roll(-150).unwrap();
assert_eq!(ctrl.get_roll().unwrap(), -150);
ctrl.set_shutter_speed(500).unwrap();
assert_eq!(ctrl.get_shutter_speed().unwrap(), 500);
// 3. Exposure Curve / Auto Exposure / ISO (XU1 0x07, 259 bytes) & EV Compensation (XU1 0x08, 4 bytes)
ctrl.set_auto_exposure(false).unwrap();
assert!(!ctrl.get_auto_exposure().unwrap());
ctrl.set_iso(800).unwrap();
assert_eq!(ctrl.get_iso().unwrap(), 800);
ctrl.set_exposure_compensation(-1.5).unwrap();
assert!((ctrl.get_exposure_compensation().unwrap() - (-1.5)).abs() < 1e-6);
// 4. Audio Control (XU1 0x04, 2 bytes)
ctrl.set_audio_noise_reduction(false).unwrap();
assert!(!ctrl.get_audio_noise_reduction().unwrap());
ctrl.set_audio_capture_mode(AudioCaptureMode::MusicBalance).unwrap();
assert_eq!(ctrl.get_audio_capture_mode().unwrap(), AudioCaptureMode::MusicBalance);
// 5. Filter Mode (XU2 0x06, 1 byte)
ctrl.set_filter(FilterMode::Amber).unwrap();
assert_eq!(ctrl.get_filter().unwrap(), FilterMode::Amber);
// 6. Privacy Mode (XU2 0x08, 4 bytes) & Horizontal Correction (XU2 0x0d, 1 byte)
ctrl.set_privacy_mode(true).unwrap();
let (_, priv_enabled) = ctrl.get_privacy_mode().unwrap();
assert!(priv_enabled);
assert!(ctrl.get_device_status().unwrap().contains(DeviceStatus::PRIVACY_SLEEP));
ctrl.set_horizontal_correction(false).unwrap();
assert!(!ctrl.get_horizontal_correction().unwrap());
// 7. Image Templates (XU2 0x04 & 0x05)
ctrl.set_image_template(3, ImageOperation::SaveOrUpdate).unwrap();
ctrl.set_image_template_name(3, "Podcast Look").unwrap();
let tmpl_info = ctrl.get_image_template_info().unwrap();
assert_eq!(tmpl_info.current_id, 3);
assert_ne!(tmpl_info.bitmask & (1 << 3), 0);
ctrl.delete_image_template(3).unwrap();
assert_eq!(ctrl.get_image_template_info().unwrap().bitmask & (1 << 3), 0);
// 8. Extended Function Bitmask (XU1 0x0a, 8 bytes: [mask_u32_le, values_u32_le])
for &func in &ExtendFunction::ALL {
ctrl.set_extend_function(func, true).unwrap();
let statuses = ctrl.get_extend_function_status().unwrap();
let is_on = statuses.into_iter().find(|(f, _)| *f == func).unwrap().1;
assert!(is_on, "expected {:?} to be enabled", func);
}
// Verify specific wire payloads in command_log()
let log = mock.command_log();
// Find EV compensation command
let ev_cmd = log
.iter()
.find(|(_, u, s, _)| *u == DEFAULT_EXTENSION_UNIT_ID && *s == xu1_selectors::EXPOSURE_COMPENSATION)
.expect("EV compensation command logged");
assert_eq!(ev_cmd.3, (-1.5f32).to_le_bytes().to_vec());
// Find Privacy Mode command
let priv_cmd = log
.iter()
.find(|(_, u, s, _)| *u == DEFAULT_EXTENSION_UNIT2_ID && *s == xu2_selectors::PRIVACY_AND_FILL_LIGHT)
.expect("Privacy mode command logged");
assert_eq!(priv_cmd.3, vec![0x02, 0x01, 0x00, 0x00]);
// Find Filter Mode command
let filter_cmd = log
.iter()
.find(|(_, u, s, _)| *u == DEFAULT_EXTENSION_UNIT2_ID && *s == xu2_selectors::FILTER_MODE)
.expect("Filter mode command logged");
assert_eq!(filter_cmd.3, vec![FilterMode::Amber as u8]);
}
#[test]
fn test_system_info_tracking_rects_and_mode_switch_commands() {
let mock = MockCameraTransport::new(CameraModel::Link2);
let ctrl = Insta360CameraController::new(mock.clone());
// Device Info (XU2 0x03, 150 bytes)
let dev_info = ctrl.get_device_info().unwrap();
assert_eq!(dev_info.device_name, "Insta360 Link 2");
assert_eq!(dev_info.firmware_version, "v2.5.8.8_build1");
assert_eq!(dev_info.serial_number, "INSTA360SIM0001");
// PTZ Version & Angles (XU1 0x0d, 30 bytes)
ctrl.set_pantilt_absolute(3600, -7200).unwrap();
let ptz_info = ctrl.get_ptz_version_and_angles().unwrap();
assert_eq!(ptz_info.version, "v1.3.1.0_build0");
assert_eq!(ptz_info.pan, 10);
assert_eq!(ptz_info.tilt, -20);
assert_eq!(ptz_info.zoom, 100);
// Select Tracked Object (XU1 0x05, 10 bytes)
let target = UvcRect {
x: 100,
y: 200,
width: 300,
height: 400,
track_id: 7,
};
ctrl.select_track_object(&target).unwrap();
// Default Track Mode (XU2 0x0c, 1 byte)
ctrl.set_default_track_mode(1).unwrap();
assert_eq!(ctrl.get_default_track_mode().unwrap(), 1);
// System Timestamp, IQ Switch, Factory Restore (XU2 0x02, 10 bytes)
ctrl.set_timestamp(0x0102_0304_0506_0708).unwrap();
ctrl.set_iq_switch(1, true, 0x01).unwrap();
ctrl.restore_factory_settings(true).unwrap();
ctrl.restore_factory_settings(false).unwrap();
// Factory Default (XU1 0x01 sub-opcode 4) & MSC Switch (XU1 0x0f sub-opcode 2)
ctrl.load_factory_default().unwrap();
ctrl.switch_msc_readonly_class().unwrap();
let log = mock.command_log();
assert!(log.iter().any(|(_, u, s, p)| {
*u == DEFAULT_EXTENSION_UNIT_ID
&& *s == xu1_selectors::TRACK_OBJECT_SELECT
&& p == &encode_track_rect(&target)
}));
assert!(log.iter().any(|(_, u, s, p)| {
*u == DEFAULT_EXTENSION_UNIT2_ID
&& *s == xu2_selectors::SYSTEM_TIME_AND_IQ
&& p[0] == 0x01
&& &p[1..9] == &0x0102_0304_0506_0708u64.to_le_bytes()
}));
assert!(log.iter().any(|(_, u, s, p)| {
*u == DEFAULT_EXTENSION_UNIT2_ID
&& *s == xu2_selectors::SYSTEM_TIME_AND_IQ
&& p == &[0x02, 0x03, 0x00, 0x00, 0x00, 0, 0, 0, 0, 0]
}));
assert!(log.iter().any(|(_, u, s, p)| {
*u == DEFAULT_EXTENSION_UNIT_ID
&& *s == xu1_selectors::USB_MODE_SWITCH
&& p == &[0x02, 0x00, 0x00, 0x00]
}));
// Gesture bindings (XU2 selector 0x01)
ctrl.set_gesture_binding_func(Gesture::Palm, GestureFunction::AutoFraming)
.unwrap();
let bindings = ctrl.get_gesture_binding_map().unwrap();
assert_eq!(bindings[1], (Gesture::Palm, GestureFunction::AutoFraming));
// Tracking Forbidden Areas (XU1 selector 0x01 opcodes 7/8 + XU2 selector 0x0b)
ctrl.enter_track_forbidden_area_setting().unwrap();
let areas = vec![
ForbiddenAreaInfo {
enabled: true,
index: 0,
pan_min: -300,
pan_max: -100,
tilt_min: -150,
tilt_max: 150,
},
ForbiddenAreaInfo {
enabled: false,
index: 1,
pan_min: 100,
pan_max: 300,
tilt_min: -100,
tilt_max: 100,
},
];
ctrl.set_track_forbidden_areas(&areas).unwrap();
let read_areas = ctrl.get_track_forbidden_areas().unwrap();
assert_eq!(read_areas, areas);
ctrl.exit_track_forbidden_area_setting().unwrap();
// Real zoom & Fill light param
ctrl.set_zoom(250).unwrap();
assert_eq!(ctrl.get_real_zoom_value().unwrap(), 250);
assert_eq!(ctrl.get_fill_light_param().unwrap(), (0, 0));
}