Initial commit: pure-Rust cross-platform Insta360 Link controller suite
This commit is contained in:
27 files changed
+22165
No files matched your search
@@ -0,0 +1,721 @@
|
||||
use crate::types::*;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error, Clone, PartialEq)]
|
||||
pub enum CodecError {
|
||||
#[error("payload too short: expected at least {expected} bytes, got {actual}")]
|
||||
PayloadTooShort { expected: usize, actual: usize },
|
||||
#[error("invalid argument: {0}")]
|
||||
InvalidArgument(&'static str),
|
||||
}
|
||||
|
||||
/// Encodes UVC `bmRequestType`, `bRequest`, `wValue`, `wIndex` for a UVC control transfer
|
||||
/// matching `MacUVCCameraControl::SetData` (`0x100192880`) and `GetData` (`0x10019282c`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UvcControlPacketHeader {
|
||||
pub bm_request_type: u8,
|
||||
pub b_request: u8,
|
||||
pub w_value: u16,
|
||||
pub w_index: u16,
|
||||
}
|
||||
|
||||
impl UvcControlPacketHeader {
|
||||
pub fn new(req: UvcRequest, control_selector: u8, unit_or_terminal_id: u8, interface_num: u8) -> Self {
|
||||
let bm_request_type = match req {
|
||||
UvcRequest::SetCur => 0x21, // Host-to-device, Class, Interface (`0x0121` in little-endian u16 with bRequest=0x01)
|
||||
_ => 0xa1, // Device-to-host, Class, Interface
|
||||
};
|
||||
Self {
|
||||
bm_request_type,
|
||||
b_request: req as u8,
|
||||
w_value: (control_selector as u16) << 8,
|
||||
w_index: ((unit_or_terminal_id as u16) << 8) | (interface_num as u16),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x01: System, Presets, Gimbal Reset, Composition Bias
|
||||
// ============================================================================
|
||||
|
||||
/// `SetCurPresetPosition(unsigned char)` (`0x100199918`): `[0, index, 0, 0]`
|
||||
pub fn encode_set_cur_preset_position(index: u8) -> [u8; 4] {
|
||||
[0x00, index, 0x00, 0x00]
|
||||
}
|
||||
|
||||
/// `AddPresetPosition(unsigned char)` (`0x1001999b4`): `[1, index, 0, 0]`
|
||||
pub fn encode_add_preset_position(index: u8) -> [u8; 4] {
|
||||
[0x01, index, 0x00, 0x00]
|
||||
}
|
||||
|
||||
/// `DeletePresetPosition(unsigned char)` (`0x100199a54`): `[2, index, 0, 0]`
|
||||
pub fn encode_delete_preset_position(index: u8) -> [u8; 4] {
|
||||
[0x02, index, 0x00, 0x00]
|
||||
}
|
||||
|
||||
/// `ResetGimbal()` (`0x100196fb0`): `[3, 0, 0, 0]`
|
||||
pub fn encode_reset_gimbal() -> [u8; 4] {
|
||||
[0x03, 0x00, 0x00, 0x00]
|
||||
}
|
||||
|
||||
/// `LoadFactoryDefault()` (`0x10019701c`): `[4, 0, 0, 0]`
|
||||
pub fn encode_load_factory_default() -> [u8; 4] {
|
||||
[0x04, 0x00, 0x00, 0x00]
|
||||
}
|
||||
|
||||
/// `SetCompositionBias(short, short)` (`0x10019933c`): `[6, x_lo, x_hi, y_lo, y_hi]`
|
||||
pub fn encode_set_composition_bias(x: i16, y: i16) -> [u8; 5] {
|
||||
let xb = x.to_le_bytes();
|
||||
let yb = y.to_le_bytes();
|
||||
[0x06, xb[0], xb[1], yb[0], yb[1]]
|
||||
}
|
||||
|
||||
/// `EnterTrackForbiddenAreaSetting()` (`0x10019bf74`): `[7, 1, 0, 0]`
|
||||
pub fn encode_enter_track_forbidden_area_setting() -> [u8; 4] {
|
||||
[0x07, 0x01, 0x00, 0x00]
|
||||
}
|
||||
|
||||
/// `ExitTrackForbiddenAreaSetting()` (`0x10019bfb4`): `[8, 1, 0, 0]`
|
||||
pub fn encode_exit_track_forbidden_area_setting() -> [u8; 4] {
|
||||
[0x08, 0x01, 0x00, 0x00]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x02: Video Mode & Auxiliary Data (`0x100197088`, `0x1001978b8`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_set_video_mode(mode: VideoMode, aux: &VideoModeAuxData, payload_len: usize) -> Vec<u8> {
|
||||
let len = payload_len.clamp(8, 36);
|
||||
let mut buf = vec![0u8; 36];
|
||||
buf[0] = mode as u8;
|
||||
buf[1] = aux.flags;
|
||||
for (i, &val) in aux.rect.iter().enumerate() {
|
||||
let off = 2 + i * 4;
|
||||
buf[off..off + 4].copy_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
buf[18..20].copy_from_slice(&aux.pitch.to_le_bytes());
|
||||
buf[20..22].copy_from_slice(&aux.zoom.to_le_bytes());
|
||||
buf[22..36].copy_from_slice(&aux.extra);
|
||||
if len == 8 && aux.rect == [0, 0, 0, 0] {
|
||||
buf[2..4].copy_from_slice(&aux.pitch.to_le_bytes());
|
||||
buf[4..6].copy_from_slice(&aux.zoom.to_le_bytes());
|
||||
}
|
||||
buf.truncate(len);
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn decode_get_video_mode(data: &[u8]) -> Result<(VideoMode, VideoModeAuxData), CodecError> {
|
||||
if data.len() < 8 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 8,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
let mode = VideoMode::from_u8(data[0]);
|
||||
let mut aux = VideoModeAuxData {
|
||||
flags: data[1],
|
||||
..Default::default()
|
||||
};
|
||||
if data.len() >= 22 {
|
||||
for i in 0..4 {
|
||||
let off = 2 + i * 4;
|
||||
aux.rect[i] = i32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
|
||||
}
|
||||
aux.pitch = i16::from_le_bytes([data[18], data[19]]);
|
||||
aux.zoom = u16::from_le_bytes([data[20], data[21]]);
|
||||
}
|
||||
if data.len() >= 36 {
|
||||
aux.extra.copy_from_slice(&data[22..36]);
|
||||
}
|
||||
Ok((mode, aux))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x03: Tracking Speed, Composition Style, Gestures, Smart Whiteboard
|
||||
// ============================================================================
|
||||
|
||||
/// `SetCompositionStyle` (`0x100197498`): 16-byte buffer `[6, style_u8, 0..]`
|
||||
pub fn encode_set_composition_style(style: CompositionStyle) -> [u8; 16] {
|
||||
let mut buf = [0u8; 16];
|
||||
buf[0] = 0x06;
|
||||
buf[1] = style as u8;
|
||||
buf
|
||||
}
|
||||
|
||||
/// `GetCompositionStyle` (`0x10019750c`): reads `data[7]` from 16-byte response
|
||||
pub fn decode_get_composition_style(data: &[u8]) -> Result<CompositionStyle, CodecError> {
|
||||
if data.len() < 8 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 8,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok(CompositionStyle::from_u8(data[7]))
|
||||
}
|
||||
|
||||
/// `SetTrackSpeed` (`0x1001975bc`): 16-byte buffer `[5, speed_u8, 0..]`
|
||||
pub fn encode_set_track_speed(speed: TrackSpeed) -> [u8; 16] {
|
||||
let mut buf = [0u8; 16];
|
||||
buf[0] = 0x05;
|
||||
buf[1] = speed as u8;
|
||||
buf
|
||||
}
|
||||
|
||||
/// `GetTrackSpeed` (`0x100197630`): reads `data[6]` from 16-byte response
|
||||
pub fn decode_get_track_speed(data: &[u8]) -> Result<TrackSpeed, CodecError> {
|
||||
if data.len() < 7 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 7,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok(TrackSpeed::from_u8(data[6]))
|
||||
}
|
||||
|
||||
/// `ForceSetSmatrWhiteBoard` (`0x1001980d0`): 16-byte buffer `[3, mode_u8, 0..]`
|
||||
pub fn encode_force_smart_whiteboard(mode: u8) -> [u8; 16] {
|
||||
let mut buf = [0u8; 16];
|
||||
buf[0] = 0x03;
|
||||
buf[1] = mode;
|
||||
buf
|
||||
}
|
||||
|
||||
/// `EnableAllGesture` (`0x100197b54`): 16-byte buffer `[0, enable_u8, 0..]`
|
||||
pub fn encode_enable_all_gestures(enable: bool) -> [u8; 16] {
|
||||
let mut buf = [0u8; 16];
|
||||
buf[0] = 0x00;
|
||||
buf[1] = u8::from(enable);
|
||||
buf
|
||||
}
|
||||
|
||||
/// `EnableGestureWork` (`0x100197bc8`):
|
||||
/// Reads 6 bytes `[all, ok, palm, l, v, rock]` from selector `0x03`, updates `data[gesture + 1]`,
|
||||
/// and writes `[data[0], data[1], data[2], data[3], data[4], data[5], 0..]` (16B).
|
||||
pub fn encode_enable_gesture_work(current_6b: &[u8], gesture: Gesture, enable: bool) -> [u8; 16] {
|
||||
let mut buf = [0u8; 16];
|
||||
for i in 0..current_6b.len().min(6) {
|
||||
buf[i] = current_6b[i];
|
||||
}
|
||||
buf[0] = 0x01; // master gesture enable
|
||||
let idx = (gesture as usize) + 1;
|
||||
if idx < 6 {
|
||||
buf[idx] = u8::from(enable);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// `GetGestureStatus` (`0x100198144`): decodes `[all, ok, palm, l, v, rock]` from selector `0x03`
|
||||
pub fn decode_gesture_status(data: &[u8]) -> Result<[(Gesture, bool); 5], CodecError> {
|
||||
if data.len() < 6 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 6,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok([
|
||||
(Gesture::Ok, data[1] != 0),
|
||||
(Gesture::Palm, data[2] != 0),
|
||||
(Gesture::LShape, data[3] != 0),
|
||||
(Gesture::VShape, data[4] != 0),
|
||||
(Gesture::Rock, data[5] != 0),
|
||||
])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x04: Audio Noise Reduction & Audio Capture Mode (`0x100197798`, `0x10019bf54`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_audio_control(value: u8) -> [u8; 2] {
|
||||
[value, 0x00]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x05 & 0x06 / XU2 Selector 0x0a: Track Object Rectangles (`0x100198a38`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_track_rect(rect: &UvcRect) -> [u8; 10] {
|
||||
let mut buf = [0u8; 10];
|
||||
buf[0..2].copy_from_slice(&rect.x.to_le_bytes());
|
||||
buf[2..4].copy_from_slice(&rect.y.to_le_bytes());
|
||||
buf[4..6].copy_from_slice(&rect.width.to_le_bytes());
|
||||
buf[6..8].copy_from_slice(&rect.height.to_le_bytes());
|
||||
buf[8..10].copy_from_slice(&rect.track_id.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn decode_track_rect_list(data: &[u8]) -> Vec<UvcRect> {
|
||||
let mut rects = Vec::new();
|
||||
for chunk in data.chunks_exact(10) {
|
||||
let x = i16::from_le_bytes([chunk[0], chunk[1]]);
|
||||
let y = i16::from_le_bytes([chunk[2], chunk[3]]);
|
||||
let width = i16::from_le_bytes([chunk[4], chunk[5]]);
|
||||
let height = i16::from_le_bytes([chunk[6], chunk[7]]);
|
||||
let track_id = i16::from_le_bytes([chunk[8], chunk[9]]);
|
||||
if width > 0 || height > 0 {
|
||||
rects.push(UvcRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
track_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x07: Exposure Curve, Auto Exposure, ISO (`0x10019aaa0`–`0x10019b7d0`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_exposure_curve(curve: &ExposureCurve) -> [u8; 259] {
|
||||
let mut buf = [0u8; 259];
|
||||
buf[0] = curve.mode;
|
||||
buf[1..3].copy_from_slice(&curve.iso.to_le_bytes());
|
||||
for (i, &pt) in curve.points.iter().take(128).enumerate() {
|
||||
let off = 3 + i * 2;
|
||||
buf[off..off + 2].copy_from_slice(&pt.to_le_bytes());
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn decode_exposure_curve(data: &[u8]) -> Result<ExposureCurve, CodecError> {
|
||||
if data.len() < 3 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 3,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
let mode = data[0];
|
||||
let iso = u16::from_le_bytes([data[1], data[2]]);
|
||||
let mut points = Vec::with_capacity(128);
|
||||
for chunk in data[3..].chunks_exact(2).take(128) {
|
||||
points.push(u16::from_le_bytes([chunk[0], chunk[1]]));
|
||||
}
|
||||
Ok(ExposureCurve { mode, iso, points })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x08: Exposure Compensation (`0x100198f34`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_exposure_compensation(ev: f32) -> [u8; 4] {
|
||||
ev.to_le_bytes()
|
||||
}
|
||||
|
||||
pub fn decode_exposure_compensation(data: &[u8]) -> Result<f32, CodecError> {
|
||||
if data.len() < 4 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 4,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok(f32::from_le_bytes([data[0], data[1], data[2], data[3]]))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x0a: Extended Function Bitmask (`0x100199afc`–`0x10019a530`)
|
||||
// ============================================================================
|
||||
|
||||
/// Encodes `(mask: u32, values: u32)` into the 8-byte payload for XU1 selector `0x0a`.
|
||||
/// Verified at `EnableExtendFuncWork` (`0x100199afc`), `EnableVerticalScreen` (`0x10019b7d0`),
|
||||
/// and `EnableLowResolution` (`0x10019b854`).
|
||||
pub fn encode_extend_func_bitmask(func: ExtendFunction, enable: bool, current_values: u32) -> [u8; 8] {
|
||||
let bit = 1u32 << (func as u8);
|
||||
let new_values = if enable {
|
||||
current_values | bit
|
||||
} else {
|
||||
current_values & !bit
|
||||
};
|
||||
let mut buf = [0u8; 8];
|
||||
buf[0..4].copy_from_slice(&bit.to_le_bytes());
|
||||
buf[4..8].copy_from_slice(&new_values.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Decodes the response from `GetExtendFuncStatus` (`0x100199a20`, XU1 selector `0x1b` or `0x0a`).
|
||||
/// Hardware returns a 2-byte `u16` LE bitmask at `data[0..2]` (or 8-byte `(mask, values)` in 8B format).
|
||||
pub fn decode_extend_func_status(data: &[u8]) -> Result<Vec<(ExtendFunction, bool)>, CodecError> {
|
||||
if data.len() < 2 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 2,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
let bits = if data.len() >= 8 && (data[4] != 0 || data[5] != 0 || (data[0] == 0xff && data[1] == 0xff)) {
|
||||
u32::from_le_bytes([data[4], data[5], data[6], data[7]])
|
||||
} else {
|
||||
u16::from_le_bytes([data[0], data[1]]) as u32
|
||||
};
|
||||
let mut out = Vec::with_capacity(ExtendFunction::ALL.len());
|
||||
for func in ExtendFunction::ALL {
|
||||
let enabled = (bits & (1u32 << (func as u8))) != 0;
|
||||
out.push((func, enabled));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x0b: Relative Pan/Tilt (`0x1001990f4`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_pantilt_relative_ext(pan_speed: u8, pan_dir: i8, tilt_speed: u8, tilt_dir: i8) -> [u8; 4] {
|
||||
let p_dir: u8 = if pan_dir == 0 || pan_speed == 0 {
|
||||
0
|
||||
} else if pan_dir > 0 {
|
||||
1
|
||||
} else {
|
||||
0xff
|
||||
};
|
||||
let t_dir: u8 = if tilt_dir == 0 || tilt_speed == 0 {
|
||||
0
|
||||
} else if tilt_dir > 0 {
|
||||
1
|
||||
} else {
|
||||
0xff
|
||||
};
|
||||
let p_spd = if p_dir == 0 { 1 } else { pan_speed.clamp(1, 10) };
|
||||
let t_spd = if t_dir == 0 { 1 } else { tilt_speed.clamp(1, 10) };
|
||||
[p_dir, p_spd, t_dir, t_spd]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x0d: PTZ Version & Gimbal Absolute Angles (`0x1001993bc`, `0x1001996cc`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn decode_ptz_version_and_abs(data: &[u8]) -> Result<PtzVersionInfo, CodecError> {
|
||||
if data.len() < 30 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 30,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
let end = data[..20].iter().position(|&b| b == 0).unwrap_or(20);
|
||||
let version = String::from_utf8_lossy(&data[..end]).into_owned();
|
||||
let pan = i16::from_le_bytes([data[20], data[21]]);
|
||||
let tilt = i16::from_le_bytes([data[22], data[23]]);
|
||||
let zoom = u16::from_le_bytes([data[24], data[25]]);
|
||||
let status = data[26];
|
||||
Ok(PtzVersionInfo {
|
||||
version,
|
||||
pan,
|
||||
tilt,
|
||||
zoom,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU1 Selector 0x0e: Preset Position Flags (`0x1001997c0`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn decode_preset_flags(data: &[u8]) -> Result<PresetFlag, CodecError> {
|
||||
if data.len() < 8 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 8,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
let mut raw = [0u8; 8];
|
||||
raw.copy_from_slice(&data[..8]);
|
||||
Ok(PresetFlag {
|
||||
slot_mask: raw[0],
|
||||
current_slot: raw[1],
|
||||
max_slots: raw[2].max(6),
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU2 Selector 0x02: Timestamp, IQ Switch, Factory Restore (`0x10019bd00`–`0x10019c1ac`)
|
||||
// ============================================================================
|
||||
|
||||
/// `SetTimestamp(unsigned long long)` (`0x10019be40`): `[1, ts_le[0..8], 0..]` (10 bytes)
|
||||
pub fn encode_set_timestamp(timestamp_ms: u64) -> [u8; 10] {
|
||||
let mut buf = [0u8; 10];
|
||||
buf[0] = 0x01;
|
||||
buf[1..9].copy_from_slice(×tamp_ms.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// `SetIQSwitch(int, bool)` (`0x100199ce0`): `[2, mask_u32_le[0..4], 0..]` (10 bytes)
|
||||
pub fn encode_set_iq_switch(bit_index: u8, enable: bool, current_mask: u32) -> Result<[u8; 10], CodecError> {
|
||||
if bit_index >= 32 {
|
||||
return Err(CodecError::InvalidArgument("bitset argument out of range"));
|
||||
}
|
||||
let bit = 1u32 << bit_index;
|
||||
let next = if enable {
|
||||
current_mask | bit
|
||||
} else {
|
||||
current_mask & !bit
|
||||
};
|
||||
let mut buf = [0u8; 10];
|
||||
buf[0] = 0x02;
|
||||
buf[1..5].copy_from_slice(&next.to_le_bytes());
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// `RestoreFactorySet(bool)` (`0x10019c1ac`): `[4, 1, 0..]` if `reboot` else `[5, 1, 0..]` (10 bytes)
|
||||
pub fn encode_restore_factory_set(full_reboot: bool) -> [u8; 10] {
|
||||
let mut buf = [0u8; 10];
|
||||
buf[0] = if full_reboot { 0x04 } else { 0x05 };
|
||||
buf[1] = 0x01;
|
||||
buf
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU2 Selector 0x03: DeviceInfo (`GetDeviceInfo` at `0x100196e74`, 150 bytes)
|
||||
// ============================================================================
|
||||
|
||||
fn extract_cstr(slice: &[u8]) -> String {
|
||||
let end = slice.iter().position(|&b| b == 0).unwrap_or(slice.len());
|
||||
String::from_utf8_lossy(&slice[..end]).trim().to_string()
|
||||
}
|
||||
|
||||
pub fn decode_device_info(data: &[u8]) -> Result<DeviceInfo, CodecError> {
|
||||
if data.len() < 150 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 150,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
// Hardware layout (`MacUVCCameraExtendController::GetDeviceInfo` at `0x100196904`, >= 161 bytes):
|
||||
// [0x00..0x20] = serial_num (32B, e.g. "0AB586531221284"), [0x20] = need_update (1B),
|
||||
// [0x21..0x61] = device_uuid (64B), [0x61..0xa1] = cur_version (64B, e.g. "v1.4.5.8_build1")
|
||||
if data.len() >= 0xa1 {
|
||||
let sn = extract_cstr(&data[0x00..0x20]);
|
||||
let uuid = extract_cstr(&data[0x21..0x61]);
|
||||
let fw = extract_cstr(&data[0x61..0xa1]);
|
||||
if !fw.is_empty() || !sn.is_empty() || !uuid.is_empty() {
|
||||
return Ok(DeviceInfo {
|
||||
device_name: uuid,
|
||||
firmware_version: fw,
|
||||
serial_number: sn,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(DeviceInfo {
|
||||
device_name: extract_cstr(&data[0..50]),
|
||||
firmware_version: extract_cstr(&data[50..100]),
|
||||
serial_number: extract_cstr(&data[100..150]),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU2 Selector 0x04 & 0x05: Image Templates (`0x10019ba4c`–`0x10019bd90`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn decode_image_template_info(data: &[u8]) -> Result<ImageTemplateInfo, CodecError> {
|
||||
if data.len() < 11 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 11,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
let current_id = data[0] as i8;
|
||||
let operation = data[1];
|
||||
let bitmask = u64::from_le_bytes([
|
||||
data[2], data[3], data[4], data[5], data[6], data[7], data[8], data[9],
|
||||
]);
|
||||
let max_templates = data[10];
|
||||
Ok(ImageTemplateInfo {
|
||||
current_id,
|
||||
operation,
|
||||
bitmask,
|
||||
max_templates,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_set_image_template(template_id: i8, op: ImageOperation, current_mask: u64, max_templates: u8) -> Result<[u8; 11], CodecError> {
|
||||
if template_id < 0 || (template_id as u8) >= 64 {
|
||||
return Err(CodecError::InvalidArgument("template_id out of range (0..64)"));
|
||||
}
|
||||
let next_mask = match op {
|
||||
ImageOperation::SaveOrUpdate => current_mask | (1u64 << (template_id as u8)),
|
||||
_ => current_mask,
|
||||
};
|
||||
let mut buf = [0u8; 11];
|
||||
buf[0] = template_id as u8;
|
||||
buf[1] = op as u8;
|
||||
buf[2..10].copy_from_slice(&next_mask.to_le_bytes());
|
||||
buf[10] = max_templates;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub fn encode_delete_image_template(template_id: i8, current: &ImageTemplateInfo) -> Result<[u8; 11], CodecError> {
|
||||
if template_id < 0 || (template_id as u8) >= 64 {
|
||||
return Err(CodecError::InvalidArgument("template_id out of range (0..64)"));
|
||||
}
|
||||
let next_mask = current.bitmask & !(1u64 << (template_id as u8));
|
||||
let mut buf = [0u8; 11];
|
||||
buf[0] = current.current_id as u8;
|
||||
buf[1] = current.operation;
|
||||
buf[2..10].copy_from_slice(&next_mask.to_le_bytes());
|
||||
buf[10] = current.max_templates;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub fn encode_image_template_name(template_id: i8, name: &str) -> [u8; 65] {
|
||||
let mut buf = [0u8; 65];
|
||||
buf[0] = template_id as u8;
|
||||
let bytes = name.as_bytes();
|
||||
let copy_len = bytes.len().min(63);
|
||||
buf[1..1 + copy_len].copy_from_slice(&bytes[..copy_len]);
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn decode_image_template_name(data: &[u8]) -> Result<(i8, String), CodecError> {
|
||||
if data.len() < 2 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 2,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
let id = data[0] as i8;
|
||||
let name = extract_cstr(&data[1..]);
|
||||
Ok((id, name))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU2 Selector 0x08: Privacy Mode & Fill Light (`0x10019c0a8`–`0x10019c168`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_set_privacy_mode(enable: bool) -> [u8; 4] {
|
||||
[0x02, u8::from(enable), 0x00, 0x00]
|
||||
}
|
||||
|
||||
pub fn decode_privacy_mode(data: &[u8]) -> Result<(i32, bool), CodecError> {
|
||||
if data.len() < 2 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 2,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok((data[0] as i32, data[1] != 0))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU2 Selector 0x09: Host Digital PTZ (`SetHostPTZ` at `0x10019bec0`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_host_ptz(info: &HostPtzInfo) -> [u8; 8] {
|
||||
let mut buf = [0u8; 8];
|
||||
buf[0..2].copy_from_slice(&info.x.to_le_bytes());
|
||||
buf[2..4].copy_from_slice(&info.y.to_le_bytes());
|
||||
buf[4..6].copy_from_slice(&info.width.to_le_bytes());
|
||||
buf[6..8].copy_from_slice(&info.height.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU2 Selector 0x01: Gesture Binding Map (`GetGestureBindingMap` / `SetGestureBindingFunc` at `0x100198384`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_gesture_binding(gesture: Gesture, func: GestureFunction) -> [u8; 16] {
|
||||
let mut buf = [0u8; 16];
|
||||
buf[0] = gesture as u8;
|
||||
buf[1] = func as u8;
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn decode_gesture_binding_map(data: &[u8]) -> Result<[(Gesture, GestureFunction); 5], CodecError> {
|
||||
if data.len() < 5 {
|
||||
return Err(CodecError::PayloadTooShort {
|
||||
expected: 5,
|
||||
actual: data.len(),
|
||||
});
|
||||
}
|
||||
Ok([
|
||||
(Gesture::Ok, GestureFunction::from_u8(data[0])),
|
||||
(Gesture::Palm, GestureFunction::from_u8(data[1])),
|
||||
(Gesture::LShape, GestureFunction::from_u8(data[2])),
|
||||
(Gesture::VShape, GestureFunction::from_u8(data[3])),
|
||||
(Gesture::Rock, GestureFunction::from_u8(data[4])),
|
||||
])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// XU2 Selector 0x0b: Tracking Forbidden Areas (`SetTrackForbiddenArea` / `GetTrackForbiddenArea` at `0x10019bff4`)
|
||||
// ============================================================================
|
||||
|
||||
pub fn encode_forbidden_areas(areas: &[ForbiddenAreaInfo]) -> Vec<u8> {
|
||||
let count = areas.len().clamp(1, 4);
|
||||
let mut buf = vec![0u8; count * 10];
|
||||
for (i, area) in areas.iter().take(count).enumerate() {
|
||||
let off = i * 10;
|
||||
buf[off] = u8::from(area.enabled);
|
||||
buf[off + 1] = area.index;
|
||||
buf[off + 2..off + 4].copy_from_slice(&area.pan_min.to_le_bytes());
|
||||
buf[off + 4..off + 6].copy_from_slice(&area.pan_max.to_le_bytes());
|
||||
buf[off + 6..off + 8].copy_from_slice(&area.tilt_min.to_le_bytes());
|
||||
buf[off + 8..off + 10].copy_from_slice(&area.tilt_max.to_le_bytes());
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn decode_forbidden_areas(data: &[u8]) -> Vec<ForbiddenAreaInfo> {
|
||||
let mut out = Vec::new();
|
||||
for chunk in data.chunks_exact(10) {
|
||||
out.push(ForbiddenAreaInfo {
|
||||
enabled: chunk[0] != 0,
|
||||
index: chunk[1],
|
||||
pan_min: i16::from_le_bytes([chunk[2], chunk[3]]),
|
||||
pan_max: i16::from_le_bytes([chunk[4], chunk[5]]),
|
||||
tilt_min: i16::from_le_bytes([chunk[6], chunk[7]]),
|
||||
tilt_max: i16::from_le_bytes([chunk[8], chunk[9]]),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ids::CameraModel;
|
||||
|
||||
#[test]
|
||||
fn test_uvc_header_encoding() {
|
||||
let set_hdr = UvcControlPacketHeader::new(UvcRequest::SetCur, 0x02, 0x09, 0x00);
|
||||
assert_eq!(set_hdr.bm_request_type, 0x21);
|
||||
assert_eq!(set_hdr.b_request, 0x01);
|
||||
assert_eq!(set_hdr.w_value, 0x0200);
|
||||
assert_eq!(set_hdr.w_index, 0x0900);
|
||||
|
||||
let get_hdr = UvcControlPacketHeader::new(UvcRequest::GetCur, 0x03, 0x0a, 0x00);
|
||||
assert_eq!(get_hdr.bm_request_type, 0xa1);
|
||||
assert_eq!(get_hdr.b_request, 0x81);
|
||||
assert_eq!(get_hdr.w_value, 0x0300);
|
||||
assert_eq!(get_hdr.w_index, 0x0a00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_video_mode_payload_lengths() {
|
||||
assert_eq!(CameraModel::Link.video_mode_payload_len(), 8);
|
||||
assert_eq!(CameraModel::Link2.video_mode_payload_len(), 8);
|
||||
assert_eq!(CameraModel::Link2C.video_mode_payload_len(), 36);
|
||||
assert_eq!(CameraModel::Puc3Host.video_mode_payload_len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extend_func_bitmask_roundtrip() {
|
||||
let encoded = encode_extend_func_bitmask(ExtendFunction::Hdr, true, 0);
|
||||
assert_eq!(u32::from_le_bytes(encoded[0..4].try_into().unwrap()), 1 << 2);
|
||||
assert_eq!(u32::from_le_bytes(encoded[4..8].try_into().unwrap()), 1 << 2);
|
||||
|
||||
let decoded = decode_extend_func_status(&encoded).unwrap();
|
||||
let hdr_state = decoded.into_iter().find(|(f, _)| *f == ExtendFunction::Hdr).unwrap().1;
|
||||
assert!(hdr_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_info_decode() {
|
||||
let mut raw = [0u8; 150];
|
||||
raw[0..15].copy_from_slice(b"Insta360 Link 2");
|
||||
raw[50..65].copy_from_slice(b"v2.5.8.8_build1");
|
||||
raw[100..114].copy_from_slice(b"INK2A240912345");
|
||||
let info = decode_device_info(&raw).unwrap();
|
||||
assert_eq!(info.device_name, "Insta360 Link 2");
|
||||
assert_eq!(info.firmware_version, "v2.5.8.8_build1");
|
||||
assert_eq!(info.serial_number, "INK2A240912345");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,291 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Arashi Vision / Insta360 USB Vendor ID (`0x2e1a` = 11802)
|
||||
/// Verified at `MacUVCCameraControlImp::GetUVCCameraList` (`0x100191478`)
|
||||
/// and `UVCCameraDeviceWatcherImp::StartWatch` (`0x10019e29c`).
|
||||
pub const INSTA360_VENDOR_ID: u16 = 0x2e1a;
|
||||
|
||||
/// Supported USB Product IDs for Insta360 Link family cameras.
|
||||
/// Verified at `0x100191480` (`0x4c01..=0x4c06`) and `0x100191664` (`0x00c2`),
|
||||
/// as well as `UVCCameraExtendControllerImp::FirmwareUpgrade` jump table at `0x10286f37d`.
|
||||
pub mod pids {
|
||||
/// Insta360 Link (1st Generation) - `link1`, firmware `a:\Insta360WebCamFW.bin`
|
||||
pub const LINK_1: u16 = 0x4c01;
|
||||
/// Insta360 PUC3 Gimbal - firmware `a:\Insta360PUC3FW_GIMBAL.bin`
|
||||
pub const PUC3_GIMBAL_A: u16 = 0x4c02;
|
||||
/// Insta360 Link 2C (Fixed-lens 4K webcam) - `link2_main`, firmware `a:\Insta360LINK2CFW_HOST.bin`
|
||||
pub const LINK_2C: u16 = 0x4c03;
|
||||
/// Insta360 Link 2 (2nd Gen 2-axis gimbal 4K webcam) - `link2_gimbal`, firmware `a:\Insta360LINK2FW_GIMBAL.bin`
|
||||
pub const LINK_2: u16 = 0x4c04;
|
||||
/// Insta360 PUC3 Host - firmware `a:\Insta360PUC3FW_HOST.bin`
|
||||
pub const PUC3_HOST: u16 = 0x4c05;
|
||||
/// Insta360 PUC3 Gimbal Variant - firmware `a:\Insta360PUC3FW_GIMBAL.bin`
|
||||
pub const PUC3_GIMBAL_B: u16 = 0x4c06;
|
||||
/// Insta360 Link Simple-Class / Firmware Upgrade Mode
|
||||
pub const LINK_SIMPLE_CLASS: u16 = 0x00c2;
|
||||
}
|
||||
|
||||
/// Extension Unit 1 GUID: `FAF1672D-B71B-4793-8C91-7B1C9B7F95F8`
|
||||
/// Verified at CFString `0x102a9d098` and `FindControllerInterfaceForServiceObject` (`0x100191140`).
|
||||
/// Mixed-endian USB UVC `guidExtensionCode` 16-byte representation:
|
||||
/// `2d 67 f1 fa 1b b7 93 47 8c 91 7b 1c 9b 7f 95 f8`
|
||||
pub const XU1_GUID_STR: &str = "FAF1672D-B71B-4793-8C91-7B1C9B7F95F8";
|
||||
pub const XU1_GUID_BYTES: [u8; 16] = [
|
||||
0x2d, 0x67, 0xf1, 0xfa, 0x1b, 0xb7, 0x93, 0x47,
|
||||
0x8c, 0x91, 0x7b, 0x1c, 0x9b, 0x7f, 0x95, 0xf8,
|
||||
];
|
||||
|
||||
/// Extension Unit 2 GUID: `E307E649-4618-A3FF-82FC-2D8B5F216773`
|
||||
/// Verified at CFString `0x102a9d0b8` and `FindControllerInterfaceForServiceObject` (`0x100191168`).
|
||||
/// Mixed-endian USB UVC `guidExtensionCode` 16-byte representation:
|
||||
/// `49 e6 07 e3 18 46 ff a3 82 fc 2d 8b 5f 21 67 73`
|
||||
pub const XU2_GUID_STR: &str = "E307E649-4618-A3FF-82FC-2D8B5F216773";
|
||||
pub const XU2_GUID_BYTES: [u8; 16] = [
|
||||
0x49, 0xe6, 0x07, 0xe3, 0x18, 0x46, 0xff, 0xa3,
|
||||
0x82, 0xfc, 0x2d, 0x8b, 0x5f, 0x21, 0x67, 0x73,
|
||||
];
|
||||
|
||||
/// Default UVC unit IDs observed on Insta360 Link family cameras when descriptors are not manually overridden.
|
||||
pub const DEFAULT_CONTROL_INTERFACE: u8 = 0;
|
||||
pub const DEFAULT_INPUT_TERMINAL_ID: u8 = 1;
|
||||
pub const DEFAULT_PROCESSING_UNIT_ID: u8 = 2;
|
||||
pub const DEFAULT_EXTENSION_UNIT_ID: u8 = 9;
|
||||
pub const DEFAULT_EXTENSION_UNIT2_ID: u8 = 10;
|
||||
|
||||
/// Identified Insta360 Link camera model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum CameraModel {
|
||||
/// Insta360 Link (`0x4c01`)
|
||||
Link,
|
||||
/// Insta360 Link 2 (`0x4c04`)
|
||||
Link2,
|
||||
/// Insta360 Link 2C (`0x4c03`, gimbal-less 4K)
|
||||
Link2C,
|
||||
/// Insta360 PUC3 Gimbal (`0x4c02` / `0x4c06`)
|
||||
Puc3Gimbal(u16),
|
||||
/// Insta360 PUC3 Host (`0x4c05`)
|
||||
Puc3Host,
|
||||
/// Insta360 Link Simple-Class / DFU (`0x00c2`)
|
||||
LinkSimpleClass,
|
||||
/// Unknown Insta360 PID
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
impl CameraModel {
|
||||
pub fn from_pid(pid: u16) -> Self {
|
||||
match pid {
|
||||
pids::LINK_1 => Self::Link,
|
||||
pids::LINK_2C => Self::Link2C,
|
||||
pids::LINK_2 => Self::Link2,
|
||||
pids::PUC3_GIMBAL_A | pids::PUC3_GIMBAL_B => Self::Puc3Gimbal(pid),
|
||||
pids::PUC3_HOST => Self::Puc3Host,
|
||||
pids::LINK_SIMPLE_CLASS => Self::LinkSimpleClass,
|
||||
other => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pid(&self) -> u16 {
|
||||
match *self {
|
||||
Self::Link => pids::LINK_1,
|
||||
Self::Link2C => pids::LINK_2C,
|
||||
Self::Link2 => pids::LINK_2,
|
||||
Self::Puc3Gimbal(pid) => pid,
|
||||
Self::Puc3Host => pids::PUC3_HOST,
|
||||
Self::LinkSimpleClass => pids::LINK_SIMPLE_CLASS,
|
||||
Self::Unknown(pid) => pid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the internal model identifier used by `Webcam::CameraInsta::GetCameraModel()` (`0x10005f92c`).
|
||||
pub fn internal_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Link | Self::LinkSimpleClass => "link1",
|
||||
Self::Link2C | Self::Puc3Host => "link2_main",
|
||||
Self::Link2 | Self::Puc3Gimbal(_) => "link2_gimbal",
|
||||
Self::Unknown(_) => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the human-readable display name.
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Link => "Insta360 Link",
|
||||
Self::Link2 => "Insta360 Link 2",
|
||||
Self::Link2C => "Insta360 Link 2C",
|
||||
Self::Puc3Gimbal(_) => "Insta360 Link 2 Pro (Gimbal)",
|
||||
Self::Puc3Host => "Insta360 Link 2C Pro (Host)",
|
||||
Self::LinkSimpleClass => "Insta360 Link (Firmware Mode)",
|
||||
Self::Unknown(_) => "Insta360 UVC Camera",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the target firmware filename on the camera's virtual MSC drive (`0x1001a0a0c`).
|
||||
pub fn firmware_filename(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Link | Self::LinkSimpleClass => Some(r"a:\Insta360WebCamFW.bin"),
|
||||
Self::Link2 => Some(r"a:\Insta360LINK2FW_GIMBAL.bin"),
|
||||
Self::Link2C => Some(r"a:\Insta360LINK2CFW_HOST.bin"),
|
||||
Self::Puc3Gimbal(_) => Some(r"a:\Insta360PUC3FW_GIMBAL.bin"),
|
||||
Self::Puc3Host => Some(r"a:\Insta360PUC3FW_HOST.bin"),
|
||||
Self::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this camera uses 36-byte extended `SetVideoMode` payloads (`0x1001970f4`: bitmask `0x2b` for `pid - 0x4c01`).
|
||||
/// Wait: in `SetVideoMode` (`0x1001970f4`), if `len == 0x24` and `((1 << (pid - 0x4c01)) & 0x2b) != 0`
|
||||
/// (`0x4c01`, `0x4c02`, `0x4c04`, `0x4c06` = bits 0, 1, 3, 5 = `0b101011` = `0x2b`),
|
||||
/// it sends 8 bytes (`w4 = 8`) instead of 36 bytes (`0x24`), whereas `0x4c03` (Link 2C) and `0x4c05` (PUC3 Host)
|
||||
/// send the full 36-byte `SetVideoMode` buffer!
|
||||
pub fn video_mode_payload_len(&self) -> usize {
|
||||
let pid = self.pid();
|
||||
if (0x4c01..=0x4c06).contains(&pid) {
|
||||
let bit = 1u32 << (pid - 0x4c01);
|
||||
if (bit & 0x2b) != 0 {
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
36
|
||||
}
|
||||
}
|
||||
|
||||
/// Hardware capability matrix derived from `Webcam::CameraDeviceSupporter::UpdateUIStatus` (`0x10004a8b8`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DeviceCapabilities {
|
||||
pub has_physical_gimbal: bool,
|
||||
pub support_ptz_drag: bool,
|
||||
pub support_auto_framing: bool,
|
||||
pub support_highlighting: bool,
|
||||
pub support_mirror_vertically: bool,
|
||||
pub support_track_speed: bool,
|
||||
pub support_image_template: bool,
|
||||
pub support_privacy_mode: bool,
|
||||
pub support_area_track: bool,
|
||||
pub support_roll_adjustment: bool,
|
||||
pub support_ai_denoise: bool,
|
||||
pub support_sound_wall: bool,
|
||||
pub support_speak_tracking: bool,
|
||||
pub support_auto_track: bool,
|
||||
pub support_smart_adjustment: bool,
|
||||
pub support_audio_capture_mode: bool,
|
||||
pub support_horizontal_correction: bool,
|
||||
}
|
||||
|
||||
impl DeviceCapabilities {
|
||||
pub fn for_pid(pid: u16) -> Self {
|
||||
let is_not_link2c_or_host = pid != pids::LINK_2C && pid != pids::PUC3_HOST;
|
||||
let is_not_link1 = pid != pids::LINK_1;
|
||||
let is_link2_or_puc3_gimbal = (pid & 0xfffd) == pids::LINK_2; // 0x4c04 or 0x4c06
|
||||
let is_puc3 = pid == pids::PUC3_HOST || pid == pids::PUC3_GIMBAL_B;
|
||||
|
||||
Self {
|
||||
has_physical_gimbal: is_not_link2c_or_host,
|
||||
support_ptz_drag: is_not_link2c_or_host,
|
||||
support_auto_framing: is_not_link1,
|
||||
support_highlighting: is_not_link1,
|
||||
support_mirror_vertically: is_not_link1,
|
||||
support_track_speed: is_not_link2c_or_host,
|
||||
support_image_template: is_not_link1,
|
||||
support_privacy_mode: is_not_link1,
|
||||
support_area_track: is_link2_or_puc3_gimbal,
|
||||
support_roll_adjustment: pid == pids::LINK_1,
|
||||
support_ai_denoise: is_not_link1,
|
||||
support_sound_wall: is_puc3,
|
||||
support_speak_tracking: is_link2_or_puc3_gimbal,
|
||||
support_auto_track: is_not_link2c_or_host,
|
||||
support_smart_adjustment: is_not_link2c_or_host,
|
||||
support_audio_capture_mode: is_not_link1,
|
||||
support_horizontal_correction: pid == pids::LINK_2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovered UVC unit IDs and interface number for an Insta360 Link camera.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UvcUnitIds {
|
||||
pub control_interface: u8,
|
||||
pub input_terminal_id: u8,
|
||||
pub processing_unit_id: u8,
|
||||
pub extension_unit_id: u8,
|
||||
pub extension_unit2_id: u8,
|
||||
}
|
||||
|
||||
impl Default for UvcUnitIds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
control_interface: DEFAULT_CONTROL_INTERFACE,
|
||||
input_terminal_id: DEFAULT_INPUT_TERMINAL_ID,
|
||||
processing_unit_id: DEFAULT_PROCESSING_UNIT_ID,
|
||||
extension_unit_id: DEFAULT_EXTENSION_UNIT_ID,
|
||||
extension_unit2_id: DEFAULT_EXTENSION_UNIT2_ID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses raw USB configuration descriptor bytes to extract UVC Input Terminal, Processing Unit,
|
||||
/// and Insta360 Extension Unit 1 & 2 IDs (`FindControllerInterfaceForServiceObject` at `0x100190fe0`).
|
||||
pub fn parse_uvc_vc_descriptors(config_desc: &[u8]) -> UvcUnitIds {
|
||||
let mut ids = UvcUnitIds::default();
|
||||
let mut offset = 0usize;
|
||||
let mut in_vc_interface = false;
|
||||
|
||||
while offset + 2 <= config_desc.len() {
|
||||
let b_length = config_desc[offset] as usize;
|
||||
let b_descriptor_type = config_desc[offset + 1];
|
||||
if b_length < 2 || offset + b_length > config_desc.len() {
|
||||
break;
|
||||
}
|
||||
let desc = &config_desc[offset..offset + b_length];
|
||||
|
||||
// USB_DT_INTERFACE = 0x04
|
||||
if b_descriptor_type == 0x04 && desc.len() >= 9 {
|
||||
let b_interface_number = desc[2];
|
||||
let b_interface_class = desc[5];
|
||||
let b_interface_subclass = desc[6];
|
||||
// CC_VIDEO = 0x0e, SC_VIDEOCONTROL = 0x01
|
||||
if b_interface_class == 0x0e && b_interface_subclass == 0x01 {
|
||||
in_vc_interface = true;
|
||||
ids.control_interface = b_interface_number;
|
||||
} else {
|
||||
in_vc_interface = false;
|
||||
}
|
||||
} else if in_vc_interface && b_descriptor_type == 0x24 && desc.len() >= 4 {
|
||||
// CS_INTERFACE = 0x24
|
||||
let b_descriptor_subtype = desc[2];
|
||||
let unit_or_terminal_id = desc[3];
|
||||
match b_descriptor_subtype {
|
||||
// VC_INPUT_TERMINAL = 0x02
|
||||
0x02 if desc.len() >= 6 => {
|
||||
let w_terminal_type = u16::from_le_bytes([desc[4], desc[5]]);
|
||||
// ITT_CAMERA = 0x0201
|
||||
if w_terminal_type == 0x0201 || ids.input_terminal_id == DEFAULT_INPUT_TERMINAL_ID {
|
||||
ids.input_terminal_id = unit_or_terminal_id;
|
||||
}
|
||||
}
|
||||
// VC_PROCESSING_UNIT = 0x05
|
||||
0x05 => {
|
||||
ids.processing_unit_id = unit_or_terminal_id;
|
||||
}
|
||||
// VC_EXTENSION_UNIT = 0x06
|
||||
0x06 if desc.len() >= 20 => {
|
||||
let guid = &desc[4..20];
|
||||
if guid == XU1_GUID_BYTES {
|
||||
ids.extension_unit_id = unit_or_terminal_id;
|
||||
} else if guid == XU2_GUID_BYTES {
|
||||
ids.extension_unit2_id = unit_or_terminal_id;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
offset += b_length;
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
/// Returns true if a `(vendor_id, product_id)` pair is an Insta360 Link family device.
|
||||
pub fn is_insta360_link_device(vid: u16, pid: u16) -> bool {
|
||||
vid == INSTA360_VENDOR_ID
|
||||
&& ((pids::LINK_1..=pids::PUC3_GIMBAL_B).contains(&pid) || pid == pids::LINK_SIMPLE_CLASS)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pub mod codec;
|
||||
pub mod controller;
|
||||
pub mod ids;
|
||||
pub mod transport;
|
||||
pub mod types;
|
||||
|
||||
pub use codec::*;
|
||||
pub use controller::Insta360CameraController;
|
||||
pub use ids::*;
|
||||
pub use transport::{list_connected_cameras, MockCameraTransport, NusbTransport, TransportError, UvcControlTransport};
|
||||
pub use types::*;
|
||||
@@ -0,0 +1,976 @@
|
||||
use crate::codec::UvcControlPacketHeader;
|
||||
use crate::ids::*;
|
||||
use crate::types::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TransportError {
|
||||
#[error("USB error: {0}")]
|
||||
Usb(String),
|
||||
#[error("camera not found")]
|
||||
DeviceNotFound,
|
||||
#[error("UVC control unit {0} not available on this device")]
|
||||
MissingUnit(&'static str),
|
||||
#[error("codec error: {0}")]
|
||||
Codec(#[from] crate::codec::CodecError),
|
||||
}
|
||||
|
||||
/// Abstract cross-platform UVC Control Transfer transport.
|
||||
pub trait UvcControlTransport: Send + Sync {
|
||||
/// Returns the discovered metadata for the connected camera.
|
||||
fn camera_info(&self) -> &DiscoveredCamera;
|
||||
|
||||
/// Performs a UVC `SET_CUR` (`bmRequestType = 0x21`, `bRequest = 0x01`) control transfer.
|
||||
fn set_cur(&self, unit_id: u8, selector: u8, data: &[u8]) -> Result<(), TransportError>;
|
||||
|
||||
/// Performs a UVC `GET_*` (`bmRequestType = 0xa1`, `bRequest = 0x81..0x87`) control transfer.
|
||||
fn get_req(
|
||||
&self,
|
||||
req: UvcRequest,
|
||||
unit_id: u8,
|
||||
selector: u8,
|
||||
len: usize,
|
||||
) -> Result<Vec<u8>, TransportError>;
|
||||
|
||||
/// Queries `GET_LEN` (`0x85`) for a variable-length UVC control selector.
|
||||
fn get_len(&self, unit_id: u8, selector: u8) -> Result<u16, TransportError> {
|
||||
let bytes = self.get_req(UvcRequest::GetLen, unit_id, selector, 2)?;
|
||||
if bytes.len() < 2 {
|
||||
return Ok(0);
|
||||
}
|
||||
Ok(u16::from_le_bytes([bytes[0], bytes[1]]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerates all connected Insta360 Link family cameras across Linux, Windows, and macOS using `nusb`.
|
||||
pub fn list_connected_cameras() -> Result<Vec<DiscoveredCamera>, TransportError> {
|
||||
let devices = nusb::list_devices().map_err(|e| TransportError::Usb(e.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for dev in devices {
|
||||
let vid = dev.vendor_id();
|
||||
let pid = dev.product_id();
|
||||
if !is_insta360_link_device(vid, pid) {
|
||||
continue;
|
||||
}
|
||||
let model = CameraModel::from_pid(pid);
|
||||
let name = dev
|
||||
.product_string()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| model.display_name().to_string());
|
||||
let bus = dev.bus_number();
|
||||
let addr = dev.device_address();
|
||||
let unique_id = format!("0x{:02x}{:02x}{:04x}{:04x}", bus, addr, vid, pid);
|
||||
|
||||
let mut unit_ids = UvcUnitIds::default();
|
||||
if let Ok(opened) = dev.open() {
|
||||
if let Ok(active_cfg) = opened.active_configuration() {
|
||||
// Parse raw configuration descriptor bytes to find exact UVC unit IDs
|
||||
let raw: &[u8] = active_cfg.descriptors().as_bytes();
|
||||
unit_ids = parse_uvc_vc_descriptors(raw);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(DiscoveredCamera {
|
||||
vendor_id: vid,
|
||||
product_id: pid,
|
||||
model,
|
||||
name,
|
||||
unique_id,
|
||||
bus_number: bus,
|
||||
device_address: addr,
|
||||
unit_ids,
|
||||
capabilities: DeviceCapabilities::for_pid(pid),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Pure-Rust cross-platform USB hardware transport backed by `nusb`
|
||||
/// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + `usbfs`, Windows `WinUSB`, macOS `IOKit`).
|
||||
pub struct NusbTransport {
|
||||
info: DiscoveredCamera,
|
||||
device: nusb::Device,
|
||||
interface: Option<nusb::Interface>,
|
||||
#[cfg(target_os = "linux")]
|
||||
v4l_file: Option<std::fs::File>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_linux_v4l2_node(vid: u16, pid: u16) -> Option<std::fs::File> {
|
||||
let vid_hex = format!("{:04x}", vid);
|
||||
let pid_hex = format!("{:04x}", pid);
|
||||
let entries = std::fs::read_dir("/sys/class/video4linux").ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("video") {
|
||||
continue;
|
||||
}
|
||||
let dev_link = entry.path().join("device");
|
||||
let Ok(canon) = std::fs::canonicalize(&dev_link) else {
|
||||
continue;
|
||||
};
|
||||
// Parent directory of the USB interface (`B-P:1.0`) holds `idVendor` and `idProduct`.
|
||||
if let Some(usb_dev_dir) = canon.parent() {
|
||||
let v = std::fs::read_to_string(usb_dev_dir.join("idVendor"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let p = std::fs::read_to_string(usb_dev_dir.join("idProduct"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if v == vid_hex && p == pid_hex {
|
||||
let dev_path = format!("/dev/{}", name);
|
||||
if let Ok(file) = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&dev_path)
|
||||
{
|
||||
return Some(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[repr(C)]
|
||||
struct LinuxUvcXuControlQuery {
|
||||
unit: u8,
|
||||
selector: u8,
|
||||
query: u8,
|
||||
size: u16,
|
||||
data: *mut u8,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const UVCIOC_CTRL_QUERY: libc::c_ulong = 0xC010_7521;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_uvcioc_ctrl_query(
|
||||
file: &std::fs::File,
|
||||
unit: u8,
|
||||
selector: u8,
|
||||
query: u8,
|
||||
buf: &mut [u8],
|
||||
) -> Result<(), TransportError> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let mut q = LinuxUvcXuControlQuery {
|
||||
unit,
|
||||
selector,
|
||||
query,
|
||||
size: buf.len() as u16,
|
||||
data: buf.as_mut_ptr(),
|
||||
};
|
||||
let rc = unsafe { libc::ioctl(file.as_raw_fd(), UVCIOC_CTRL_QUERY, &mut q) };
|
||||
if rc < 0 {
|
||||
Err(TransportError::Usb(
|
||||
std::io::Error::last_os_error().to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl NusbTransport {
|
||||
pub fn open_first() -> Result<Self, TransportError> {
|
||||
let cameras = list_connected_cameras()?;
|
||||
let first = cameras.into_iter().next().ok_or(TransportError::DeviceNotFound)?;
|
||||
Self::open_by_info(first)
|
||||
}
|
||||
|
||||
pub fn open_by_info(mut info: DiscoveredCamera) -> Result<Self, TransportError> {
|
||||
let dev_info = nusb::list_devices()
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))?
|
||||
.find(|d| {
|
||||
d.vendor_id() == info.vendor_id
|
||||
&& d.product_id() == info.product_id
|
||||
&& d.bus_number() == info.bus_number
|
||||
&& d.device_address() == info.device_address
|
||||
})
|
||||
.ok_or(TransportError::DeviceNotFound)?;
|
||||
|
||||
let device = dev_info.open().map_err(|e| TransportError::Usb(e.to_string()))?;
|
||||
if let Ok(cfg) = device.active_configuration() {
|
||||
let raw: &[u8] = cfg.descriptors().as_bytes();
|
||||
info.unit_ids = parse_uvc_vc_descriptors(raw);
|
||||
}
|
||||
|
||||
// Claim the UVC VideoControl interface without detaching the OS kernel UVC video streaming
|
||||
// driver (`uvcvideo` on Linux, `UVCAssistant` on macOS, `usbvideo.sys` on Windows) so that
|
||||
// live webcam video capture and UVC/XU control transfers operate simultaneously.
|
||||
let interface = device
|
||||
.claim_interface(info.unit_ids.control_interface)
|
||||
.ok();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let v4l_file = open_linux_v4l2_node(info.vendor_id, info.product_id);
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
device,
|
||||
interface,
|
||||
#[cfg(target_os = "linux")]
|
||||
v4l_file,
|
||||
timeout: Duration::from_millis(1500),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl UvcControlTransport for NusbTransport {
|
||||
fn camera_info(&self) -> &DiscoveredCamera {
|
||||
&self.info
|
||||
}
|
||||
|
||||
fn set_cur(&self, unit_id: u8, selector: u8, data: &[u8]) -> Result<(), TransportError> {
|
||||
let is_xu = unit_id == self.info.unit_ids.extension_unit_id
|
||||
|| unit_id == self.info.unit_ids.extension_unit2_id;
|
||||
|
||||
// Match `MacUVCCameraExtendController::SendCommand` (`0x100195db4`): query `GET_LEN (0x85)`
|
||||
// for Extension Unit selectors and zero-pad the payload to the descriptor's exact wLength.
|
||||
let padded_buf: Vec<u8>;
|
||||
let payload: &[u8] = if is_xu {
|
||||
if let Ok(uvc_len) = self.get_len(unit_id, selector) {
|
||||
let target_len = uvc_len as usize;
|
||||
if target_len > 0 && target_len != data.len() {
|
||||
let mut b = vec![0u8; target_len];
|
||||
let copy_n = data.len().min(target_len);
|
||||
b[..copy_n].copy_from_slice(&data[..copy_n]);
|
||||
padded_buf = b;
|
||||
&padded_buf
|
||||
} else {
|
||||
data
|
||||
}
|
||||
} else {
|
||||
data
|
||||
}
|
||||
} else {
|
||||
data
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(v4l) = &self.v4l_file {
|
||||
let mut buf = payload.to_vec();
|
||||
if linux_uvcioc_ctrl_query(v4l, unit_id, selector, UvcRequest::SetCur as u8, &mut buf)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let hdr = UvcControlPacketHeader::new(
|
||||
UvcRequest::SetCur,
|
||||
selector,
|
||||
unit_id,
|
||||
self.info.unit_ids.control_interface,
|
||||
);
|
||||
let control = nusb::transfer::Control {
|
||||
control_type: nusb::transfer::ControlType::Class,
|
||||
recipient: nusb::transfer::Recipient::Interface,
|
||||
request: hdr.b_request,
|
||||
value: hdr.w_value,
|
||||
index: hdr.w_index,
|
||||
};
|
||||
if let Some(iface) = &self.interface {
|
||||
iface
|
||||
.control_out_blocking(control, payload, self.timeout)
|
||||
.map(|_| ())
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))
|
||||
} else {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
self.device
|
||||
.control_out_blocking(control, payload, self.timeout)
|
||||
.map(|_| ())
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Err(TransportError::Usb(
|
||||
"UVC control interface could not be claimed on Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_req(
|
||||
&self,
|
||||
req: UvcRequest,
|
||||
unit_id: u8,
|
||||
selector: u8,
|
||||
len: usize,
|
||||
) -> Result<Vec<u8>, TransportError> {
|
||||
let is_xu = unit_id == self.info.unit_ids.extension_unit_id
|
||||
|| unit_id == self.info.unit_ids.extension_unit2_id;
|
||||
|
||||
// Match `MacUVCCameraExtendController::GetCommand` (`0x1001963d0`): query `GET_LEN (0x85)`
|
||||
// first on Extension Unit `GET_CUR` requests so the buffer matches the firmware's wLength.
|
||||
let actual_len = if is_xu && req == UvcRequest::GetCur {
|
||||
match self.get_len(unit_id, selector) {
|
||||
Ok(uvc_len) if uvc_len > 0 => uvc_len as usize,
|
||||
_ => len,
|
||||
}
|
||||
} else {
|
||||
len
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(v4l) = &self.v4l_file {
|
||||
let mut buf = vec![0u8; actual_len];
|
||||
if linux_uvcioc_ctrl_query(v4l, unit_id, selector, req as u8, &mut buf).is_ok() {
|
||||
return Ok(buf);
|
||||
}
|
||||
}
|
||||
|
||||
let hdr = UvcControlPacketHeader::new(
|
||||
req,
|
||||
selector,
|
||||
unit_id,
|
||||
self.info.unit_ids.control_interface,
|
||||
);
|
||||
let mut buf = vec![0u8; actual_len];
|
||||
let control = nusb::transfer::Control {
|
||||
control_type: nusb::transfer::ControlType::Class,
|
||||
recipient: nusb::transfer::Recipient::Interface,
|
||||
request: hdr.b_request,
|
||||
value: hdr.w_value,
|
||||
index: hdr.w_index,
|
||||
};
|
||||
let n = if let Some(iface) = &self.interface {
|
||||
iface
|
||||
.control_in_blocking(control, &mut buf, self.timeout)
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))?
|
||||
} else {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
self.device
|
||||
.control_in_blocking(control, &mut buf, self.timeout)
|
||||
.map_err(|e| TransportError::Usb(e.to_string()))?
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return Err(TransportError::Usb(
|
||||
"UVC control interface could not be claimed on Windows".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
buf.truncate(n);
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful in-memory simulated Insta360 Link / Link 2 / Link 2C camera transport.
|
||||
/// Ideal for testing, CI, and interactive GUI exploration when no physical webcam is plugged in.
|
||||
#[derive(Clone)]
|
||||
pub struct MockCameraTransport {
|
||||
info: DiscoveredCamera,
|
||||
state: Arc<Mutex<MockState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MockState {
|
||||
registers: HashMap<(u8, u8), Vec<u8>>,
|
||||
pan_arcsec: i32,
|
||||
tilt_arcsec: i32,
|
||||
zoom: u16,
|
||||
roll: i16,
|
||||
focus: u16,
|
||||
auto_focus: bool,
|
||||
brightness: i16,
|
||||
contrast: u16,
|
||||
saturation: u16,
|
||||
sharpness: u16,
|
||||
wb_kelvin: u16,
|
||||
wb_auto: bool,
|
||||
power_line: u8,
|
||||
shutter: u32,
|
||||
extend_bits: u32,
|
||||
video_mode: u8,
|
||||
video_aux: [u8; 36],
|
||||
track_speed: u8,
|
||||
composition_style: u8,
|
||||
gestures: [u8; 6],
|
||||
audio_denoise: bool,
|
||||
audio_mode: u8,
|
||||
ev_comp: f32,
|
||||
iso: u16,
|
||||
auto_exposure: bool,
|
||||
filter_mode: u8,
|
||||
privacy_mode: bool,
|
||||
horiz_correction: bool,
|
||||
default_track_mode: u8,
|
||||
preset_mask: u8,
|
||||
current_preset: u8,
|
||||
template_mask: u64,
|
||||
current_template: i8,
|
||||
template_names: HashMap<i8, String>,
|
||||
gesture_bindings: [u8; 5],
|
||||
forbidden_areas: Vec<u8>,
|
||||
command_log: Vec<(UvcRequest, u8, u8, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl MockCameraTransport {
|
||||
pub fn new(model: CameraModel) -> Self {
|
||||
let pid = model.pid();
|
||||
let unit_ids = UvcUnitIds::default();
|
||||
let info = DiscoveredCamera {
|
||||
vendor_id: INSTA360_VENDOR_ID,
|
||||
product_id: pid,
|
||||
model,
|
||||
name: format!("{} (Simulated)", model.display_name()),
|
||||
unique_id: format!("mock-0x{:04x}-0x{:04x}", INSTA360_VENDOR_ID, pid),
|
||||
bus_number: 1,
|
||||
device_address: 2,
|
||||
unit_ids,
|
||||
capabilities: DeviceCapabilities::for_pid(pid),
|
||||
};
|
||||
let mut video_aux = [0u8; 36];
|
||||
video_aux[20..22].copy_from_slice(&100u16.to_le_bytes());
|
||||
let state = MockState {
|
||||
registers: HashMap::new(),
|
||||
pan_arcsec: 0,
|
||||
tilt_arcsec: 0,
|
||||
zoom: 100,
|
||||
roll: 0,
|
||||
focus: 50,
|
||||
auto_focus: true,
|
||||
brightness: 50,
|
||||
contrast: 50,
|
||||
saturation: 50,
|
||||
sharpness: 50,
|
||||
wb_kelvin: 5000,
|
||||
wb_auto: true,
|
||||
power_line: 2, // 60Hz
|
||||
shutter: 200,
|
||||
extend_bits: (1 << (ExtendFunction::AutoTracking as u8))
|
||||
| (1 << (ExtendFunction::AllGestures as u8)),
|
||||
video_mode: VideoMode::Normal as u8,
|
||||
video_aux,
|
||||
track_speed: TrackSpeed::Normal as u8,
|
||||
composition_style: CompositionStyle::HalfBody as u8,
|
||||
gestures: [1, 1, 1, 1, 1, 0],
|
||||
audio_denoise: true,
|
||||
audio_mode: AudioCaptureMode::VoiceFocus as u8,
|
||||
ev_comp: 0.0,
|
||||
iso: 100,
|
||||
auto_exposure: true,
|
||||
filter_mode: 0,
|
||||
privacy_mode: false,
|
||||
horiz_correction: pid == pids::LINK_2,
|
||||
default_track_mode: 0,
|
||||
preset_mask: 0b0000_0011,
|
||||
current_preset: 0,
|
||||
template_mask: 0b0000_0010,
|
||||
current_template: 1,
|
||||
template_names: HashMap::from([(1, "Studio Warm".to_string())]),
|
||||
gesture_bindings: [
|
||||
GestureFunction::None as u8,
|
||||
GestureFunction::AutoTracking as u8,
|
||||
GestureFunction::Zoom as u8,
|
||||
GestureFunction::WhiteBoard as u8,
|
||||
GestureFunction::AutoFraming as u8,
|
||||
],
|
||||
forbidden_areas: vec![0u8; 40],
|
||||
command_log: Vec::new(),
|
||||
};
|
||||
Self {
|
||||
info,
|
||||
state: Arc::new(Mutex::new(state)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_log(&self) -> Vec<(UvcRequest, u8, u8, Vec<u8>)> {
|
||||
self.state.lock().unwrap().command_log.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl UvcControlTransport for MockCameraTransport {
|
||||
fn camera_info(&self) -> &DiscoveredCamera {
|
||||
&self.info
|
||||
}
|
||||
|
||||
fn set_cur(&self, unit_id: u8, selector: u8, data: &[u8]) -> Result<(), TransportError> {
|
||||
let mut st = self.state.lock().unwrap();
|
||||
st.command_log
|
||||
.push((UvcRequest::SetCur, unit_id, selector, data.to_vec()));
|
||||
st.registers.insert((unit_id, selector), data.to_vec());
|
||||
|
||||
let ids = self.info.unit_ids;
|
||||
if unit_id == ids.input_terminal_id {
|
||||
match selector {
|
||||
ct_selectors::ZOOM_ABSOLUTE if data.len() >= 2 => {
|
||||
st.zoom = u16::from_le_bytes([data[0], data[1]]);
|
||||
}
|
||||
ct_selectors::PANTILT_ABSOLUTE if data.len() >= 8 => {
|
||||
st.pan_arcsec = i32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
st.tilt_arcsec = i32::from_le_bytes([data[4], data[5], data[6], data[7]]);
|
||||
}
|
||||
ct_selectors::PANTILT_RELATIVE if data.len() >= 4 => {
|
||||
let pan_dir = data[0] as i8;
|
||||
let pan_spd = data[1] as i32;
|
||||
let tilt_dir = data[2] as i8;
|
||||
let tilt_spd = data[3] as i32;
|
||||
st.pan_arcsec = (st.pan_arcsec + (pan_dir as i32) * pan_spd * 3600).clamp(-468000, 468000);
|
||||
st.tilt_arcsec = (st.tilt_arcsec + (tilt_dir as i32) * tilt_spd * 3600).clamp(-324000, 324000);
|
||||
}
|
||||
ct_selectors::ROLL_ABSOLUTE if data.len() >= 2 => {
|
||||
st.roll = i16::from_le_bytes([data[0], data[1]]);
|
||||
}
|
||||
ct_selectors::FOCUS_ABSOLUTE if data.len() >= 2 => {
|
||||
st.focus = u16::from_le_bytes([data[0], data[1]]);
|
||||
}
|
||||
ct_selectors::FOCUS_AUTO if !data.is_empty() => {
|
||||
st.auto_focus = data[0] != 0;
|
||||
}
|
||||
ct_selectors::EXPOSURE_TIME_ABSOLUTE if data.len() >= 4 => {
|
||||
st.shutter = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if unit_id == ids.processing_unit_id {
|
||||
match selector {
|
||||
pu_selectors::BRIGHTNESS if data.len() >= 2 => {
|
||||
st.brightness = i16::from_le_bytes([data[0], data[1]]);
|
||||
}
|
||||
pu_selectors::CONTRAST if data.len() >= 2 => {
|
||||
st.contrast = u16::from_le_bytes([data[0], data[1]]);
|
||||
}
|
||||
pu_selectors::SATURATION if data.len() >= 2 => {
|
||||
st.saturation = u16::from_le_bytes([data[0], data[1]]);
|
||||
}
|
||||
pu_selectors::SHARPNESS if data.len() >= 2 => {
|
||||
st.sharpness = u16::from_le_bytes([data[0], data[1]]);
|
||||
}
|
||||
pu_selectors::WHITE_BALANCE_TEMPERATURE if data.len() >= 2 => {
|
||||
st.wb_kelvin = u16::from_le_bytes([data[0], data[1]]);
|
||||
}
|
||||
pu_selectors::WHITE_BALANCE_TEMPERATURE_AUTO if !data.is_empty() => {
|
||||
st.wb_auto = data[0] != 0;
|
||||
}
|
||||
pu_selectors::POWER_LINE_FREQUENCY if !data.is_empty() => {
|
||||
st.power_line = data[0];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if unit_id == ids.extension_unit_id {
|
||||
match selector {
|
||||
xu1_selectors::SYSTEM_AND_PRESET_CMD if data.len() >= 2 => match data[0] {
|
||||
0 => st.current_preset = data[1],
|
||||
1 => {
|
||||
st.preset_mask |= 1 << (data[1] & 7);
|
||||
st.current_preset = data[1];
|
||||
}
|
||||
2 => st.preset_mask &= !(1 << (data[1] & 7)),
|
||||
3 => {
|
||||
st.pan_arcsec = 0;
|
||||
st.tilt_arcsec = 0;
|
||||
st.zoom = 100;
|
||||
st.roll = 0;
|
||||
}
|
||||
4 => {
|
||||
st.brightness = 50;
|
||||
st.contrast = 50;
|
||||
st.saturation = 50;
|
||||
st.sharpness = 50;
|
||||
st.wb_auto = true;
|
||||
st.auto_exposure = true;
|
||||
st.video_mode = 0;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
xu1_selectors::VIDEO_MODE if !data.is_empty() => {
|
||||
st.video_mode = data[0];
|
||||
if data.len() == 8 {
|
||||
st.video_aux[0] = data[0];
|
||||
st.video_aux[1] = data[1];
|
||||
st.video_aux[18..20].copy_from_slice(&data[2..4]);
|
||||
st.video_aux[20..22].copy_from_slice(&data[4..6]);
|
||||
} else {
|
||||
let copy_len = data.len().min(36);
|
||||
st.video_aux[..copy_len].copy_from_slice(&data[..copy_len]);
|
||||
}
|
||||
}
|
||||
xu1_selectors::TRACKING_AND_GESTURE if data.len() >= 2 => match data[0] {
|
||||
0 => st.gestures[0] = data[1],
|
||||
1 if data.len() >= 6 => st.gestures.copy_from_slice(&data[..6]),
|
||||
5 => st.track_speed = data[1],
|
||||
6 => st.composition_style = data[1],
|
||||
_ => {}
|
||||
},
|
||||
xu1_selectors::AUDIO_CONTROL if !data.is_empty() => {
|
||||
st.audio_mode = data[0];
|
||||
if data[0] <= 1 {
|
||||
st.audio_denoise = data[0] != 0;
|
||||
}
|
||||
}
|
||||
xu1_selectors::EXPOSURE_CURVE_AND_ISO if data.len() >= 3 => {
|
||||
st.auto_exposure = data[0] != 0;
|
||||
st.iso = u16::from_le_bytes([data[1], data[2]]);
|
||||
}
|
||||
xu1_selectors::EXPOSURE_COMPENSATION if data.len() >= 4 => {
|
||||
st.ev_comp = f32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
}
|
||||
xu1_selectors::EXTEND_FUNC_BITMASK if data.len() >= 8 => {
|
||||
st.extend_bits = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
|
||||
}
|
||||
xu1_selectors::PANTILT_RELATIVE_EXT if data.len() >= 4 => {
|
||||
let pan_spd = data[0] as i32;
|
||||
let pan_dir = data[1] as i8 as i32;
|
||||
let tilt_spd = data[2] as i32;
|
||||
let tilt_dir = data[3] as i8 as i32;
|
||||
st.pan_arcsec = (st.pan_arcsec + pan_dir * pan_spd * 3600).clamp(-468000, 468000);
|
||||
st.tilt_arcsec = (st.tilt_arcsec + tilt_dir * tilt_spd * 3600).clamp(-324000, 324000);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if unit_id == ids.extension_unit2_id {
|
||||
match selector {
|
||||
xu2_selectors::GESTURE_BINDING_MAP if data.len() >= 2 => {
|
||||
let g = data[0] as usize;
|
||||
if g < 5 {
|
||||
st.gesture_bindings[g] = data[1];
|
||||
}
|
||||
}
|
||||
xu2_selectors::TRACK_FORBIDDEN_AREA if !data.is_empty() => {
|
||||
st.forbidden_areas = data.to_vec();
|
||||
}
|
||||
xu2_selectors::IMAGE_TEMPLATE_INFO if data.len() >= 11 => {
|
||||
st.current_template = data[0] as i8;
|
||||
st.template_mask = u64::from_le_bytes(data[2..10].try_into().unwrap());
|
||||
}
|
||||
xu2_selectors::IMAGE_TEMPLATE_NAME if data.len() >= 2 => {
|
||||
let id = data[0] as i8;
|
||||
let end = data[1..].iter().position(|&b| b == 0).unwrap_or(data.len() - 1);
|
||||
let name = String::from_utf8_lossy(&data[1..1 + end]).to_string();
|
||||
st.template_names.insert(id, name);
|
||||
}
|
||||
xu2_selectors::FILTER_MODE if !data.is_empty() => {
|
||||
st.filter_mode = data[0];
|
||||
}
|
||||
xu2_selectors::PRIVACY_AND_FILL_LIGHT if data.len() >= 2 => {
|
||||
if data[0] == 2 {
|
||||
st.privacy_mode = data[1] != 0;
|
||||
}
|
||||
}
|
||||
xu2_selectors::DEFAULT_TRACK_MODE if !data.is_empty() => {
|
||||
st.default_track_mode = data[0];
|
||||
}
|
||||
xu2_selectors::HORIZONTAL_CORRECTION if !data.is_empty() => {
|
||||
st.horiz_correction = data[0] != 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_req(
|
||||
&self,
|
||||
req: UvcRequest,
|
||||
unit_id: u8,
|
||||
selector: u8,
|
||||
len: usize,
|
||||
) -> Result<Vec<u8>, TransportError> {
|
||||
let st = self.state.lock().unwrap();
|
||||
let ids = self.info.unit_ids;
|
||||
|
||||
if req == UvcRequest::GetLen {
|
||||
let l: u16 = if unit_id == ids.extension_unit_id {
|
||||
match selector {
|
||||
xu1_selectors::SYSTEM_AND_PRESET_CMD => 4,
|
||||
xu1_selectors::VIDEO_MODE => 36,
|
||||
xu1_selectors::TRACKING_AND_GESTURE => 16,
|
||||
xu1_selectors::AUDIO_CONTROL => 2,
|
||||
xu1_selectors::TRACK_OBJECT_SELECT => 10,
|
||||
xu1_selectors::TRACK_OBJECT_LIST_V1 => 20,
|
||||
xu1_selectors::EXPOSURE_CURVE_AND_ISO => 259,
|
||||
xu1_selectors::EXPOSURE_COMPENSATION => 4,
|
||||
xu1_selectors::WHITE_BALANCE_EXT => 129,
|
||||
xu1_selectors::EXTEND_FUNC_BITMASK => 8,
|
||||
xu1_selectors::PANTILT_RELATIVE_EXT => 4,
|
||||
xu1_selectors::DEVICE_STATUS => 4,
|
||||
xu1_selectors::PTZ_VERSION_AND_ABS => 30,
|
||||
xu1_selectors::PRESET_POSITION_FLAGS => 8,
|
||||
_ => len as u16,
|
||||
}
|
||||
} else if unit_id == ids.extension_unit2_id {
|
||||
match selector {
|
||||
xu2_selectors::GESTURE_BINDING_MAP => 16,
|
||||
xu2_selectors::SYSTEM_TIME_AND_IQ => 10,
|
||||
xu2_selectors::DEVICE_INFO => 150,
|
||||
xu2_selectors::IMAGE_TEMPLATE_INFO => 11,
|
||||
xu2_selectors::IMAGE_TEMPLATE_NAME => 65,
|
||||
xu2_selectors::FILTER_MODE => 1,
|
||||
xu2_selectors::PRIVACY_AND_FILL_LIGHT => 4,
|
||||
xu2_selectors::HOST_PTZ => 8,
|
||||
xu2_selectors::TRACK_OBJECT_LIST_V2 => 20,
|
||||
xu2_selectors::TRACK_FORBIDDEN_AREA => 40,
|
||||
xu2_selectors::DEFAULT_TRACK_MODE => 1,
|
||||
xu2_selectors::HORIZONTAL_CORRECTION => 1,
|
||||
xu2_selectors::REAL_ZOOM_AND_UDISK => 4,
|
||||
_ => len as u16,
|
||||
}
|
||||
} else {
|
||||
len as u16
|
||||
};
|
||||
return Ok(l.to_le_bytes().to_vec());
|
||||
}
|
||||
|
||||
if unit_id == ids.input_terminal_id {
|
||||
match selector {
|
||||
ct_selectors::ZOOM_ABSOLUTE => {
|
||||
let v: u16 = match req {
|
||||
UvcRequest::GetMin => 100,
|
||||
UvcRequest::GetMax => 400,
|
||||
UvcRequest::GetRes => 1,
|
||||
UvcRequest::GetDef => 100,
|
||||
_ => st.zoom,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
ct_selectors::PANTILT_ABSOLUTE => {
|
||||
let (p, t): (i32, i32) = match req {
|
||||
UvcRequest::GetMin => (-468000, -324000),
|
||||
UvcRequest::GetMax => (468000, 324000),
|
||||
UvcRequest::GetRes => (3600, 3600),
|
||||
UvcRequest::GetDef => (0, 0),
|
||||
_ => (st.pan_arcsec, st.tilt_arcsec),
|
||||
};
|
||||
let mut out = [0u8; 8];
|
||||
out[0..4].copy_from_slice(&p.to_le_bytes());
|
||||
out[4..8].copy_from_slice(&t.to_le_bytes());
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
ct_selectors::ROLL_ABSOLUTE => {
|
||||
let v: i16 = match req {
|
||||
UvcRequest::GetMin => -1800,
|
||||
UvcRequest::GetMax => 1800,
|
||||
UvcRequest::GetRes => 1,
|
||||
UvcRequest::GetDef => 0,
|
||||
_ => st.roll,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
ct_selectors::FOCUS_ABSOLUTE => {
|
||||
let v: u16 = match req {
|
||||
UvcRequest::GetMin => 0,
|
||||
UvcRequest::GetMax => 100,
|
||||
UvcRequest::GetRes => 1,
|
||||
UvcRequest::GetDef => 50,
|
||||
_ => st.focus,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
ct_selectors::FOCUS_AUTO => {
|
||||
return Ok(vec![u8::from(st.auto_focus)]);
|
||||
}
|
||||
ct_selectors::EXPOSURE_TIME_ABSOLUTE => {
|
||||
let v: u32 = match req {
|
||||
UvcRequest::GetMin => 10,
|
||||
UvcRequest::GetMax => 1000,
|
||||
UvcRequest::GetRes => 1,
|
||||
UvcRequest::GetDef => 200,
|
||||
_ => st.shutter,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if unit_id == ids.processing_unit_id {
|
||||
match selector {
|
||||
pu_selectors::BRIGHTNESS => {
|
||||
let v: i16 = match req {
|
||||
UvcRequest::GetMin => 0,
|
||||
UvcRequest::GetMax => 100,
|
||||
UvcRequest::GetRes => 1,
|
||||
UvcRequest::GetDef => 50,
|
||||
_ => st.brightness,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
pu_selectors::CONTRAST => {
|
||||
let v: u16 = match req {
|
||||
UvcRequest::GetMin => 0,
|
||||
UvcRequest::GetMax => 100,
|
||||
UvcRequest::GetRes => 1,
|
||||
UvcRequest::GetDef => 50,
|
||||
_ => st.contrast,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
pu_selectors::SATURATION => {
|
||||
let v: u16 = match req {
|
||||
UvcRequest::GetMin => 0,
|
||||
UvcRequest::GetMax => 100,
|
||||
UvcRequest::GetRes => 1,
|
||||
UvcRequest::GetDef => 50,
|
||||
_ => st.saturation,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
pu_selectors::SHARPNESS => {
|
||||
let v: u16 = match req {
|
||||
UvcRequest::GetMin => 0,
|
||||
UvcRequest::GetMax => 100,
|
||||
UvcRequest::GetRes => 1,
|
||||
UvcRequest::GetDef => 50,
|
||||
_ => st.sharpness,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
pu_selectors::WHITE_BALANCE_TEMPERATURE => {
|
||||
let v: u16 = match req {
|
||||
UvcRequest::GetMin => 2000,
|
||||
UvcRequest::GetMax => 10000,
|
||||
UvcRequest::GetRes => 50,
|
||||
UvcRequest::GetDef => 5000,
|
||||
_ => st.wb_kelvin,
|
||||
};
|
||||
return Ok(v.to_le_bytes().to_vec());
|
||||
}
|
||||
pu_selectors::WHITE_BALANCE_TEMPERATURE_AUTO => {
|
||||
return Ok(vec![u8::from(st.wb_auto)]);
|
||||
}
|
||||
pu_selectors::POWER_LINE_FREQUENCY => {
|
||||
return Ok(vec![st.power_line]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if unit_id == ids.extension_unit_id {
|
||||
match selector {
|
||||
xu1_selectors::VIDEO_MODE => {
|
||||
let mut out = st.video_aux;
|
||||
out[0] = st.video_mode;
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
xu1_selectors::TRACKING_AND_GESTURE => {
|
||||
let mut out = [0u8; 16];
|
||||
out[..6].copy_from_slice(&st.gestures);
|
||||
out[6] = st.track_speed;
|
||||
out[7] = st.composition_style;
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
xu1_selectors::AUDIO_CONTROL => {
|
||||
return Ok(vec![st.audio_mode, u8::from(st.audio_denoise)]);
|
||||
}
|
||||
xu1_selectors::EXPOSURE_CURVE_AND_ISO => {
|
||||
let mut out = vec![0u8; 259];
|
||||
out[0] = u8::from(st.auto_exposure);
|
||||
out[1..3].copy_from_slice(&st.iso.to_le_bytes());
|
||||
return Ok(out);
|
||||
}
|
||||
xu1_selectors::EXPOSURE_COMPENSATION => {
|
||||
return Ok(st.ev_comp.to_le_bytes().to_vec());
|
||||
}
|
||||
xu1_selectors::EXTEND_FUNC_BITMASK => {
|
||||
let mut out = [0u8; 8];
|
||||
out[0..4].copy_from_slice(&0xffff_ffffu32.to_le_bytes());
|
||||
out[4..8].copy_from_slice(&st.extend_bits.to_le_bytes());
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
xu1_selectors::PANTILT_RELATIVE_EXT => {
|
||||
return Ok(vec![1, 10, 1, 10]);
|
||||
}
|
||||
xu1_selectors::DEVICE_STATUS => {
|
||||
let mut flags = 0u32;
|
||||
if st.video_mode == VideoMode::AutoTracking as u8 {
|
||||
flags |= DeviceStatus::TRACKING_ACTIVE.bits();
|
||||
}
|
||||
if st.privacy_mode {
|
||||
flags |= DeviceStatus::PRIVACY_SLEEP.bits();
|
||||
}
|
||||
return Ok(flags.to_le_bytes().to_vec());
|
||||
}
|
||||
xu1_selectors::PTZ_VERSION_AND_ABS => {
|
||||
let mut out = [0u8; 30];
|
||||
let ver = b"v1.3.1.0_build0";
|
||||
out[..ver.len()].copy_from_slice(ver);
|
||||
let pan_deg10 = (st.pan_arcsec / 360) as i16;
|
||||
let tilt_deg10 = (st.tilt_arcsec / 360) as i16;
|
||||
out[20..22].copy_from_slice(&pan_deg10.to_le_bytes());
|
||||
out[22..24].copy_from_slice(&tilt_deg10.to_le_bytes());
|
||||
out[24..26].copy_from_slice(&st.zoom.to_le_bytes());
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
xu1_selectors::PRESET_POSITION_FLAGS => {
|
||||
let mut out = [0u8; 8];
|
||||
out[0] = st.preset_mask;
|
||||
out[1] = st.current_preset;
|
||||
out[2] = 6;
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if unit_id == ids.extension_unit2_id {
|
||||
match selector {
|
||||
xu2_selectors::GESTURE_BINDING_MAP => {
|
||||
let mut out = [0u8; 16];
|
||||
out[..5].copy_from_slice(&st.gesture_bindings);
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
xu2_selectors::TRACK_FORBIDDEN_AREA => {
|
||||
return Ok(st.forbidden_areas.clone());
|
||||
}
|
||||
xu2_selectors::DEVICE_INFO => {
|
||||
let mut out = [0u8; 150];
|
||||
let name = self.info.model.display_name().as_bytes();
|
||||
out[..name.len().min(49)].copy_from_slice(&name[..name.len().min(49)]);
|
||||
let fw = b"v2.5.8.8_build1";
|
||||
out[50..50 + fw.len()].copy_from_slice(fw);
|
||||
let sn = b"INSTA360SIM0001";
|
||||
out[100..100 + sn.len()].copy_from_slice(sn);
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
xu2_selectors::IMAGE_TEMPLATE_INFO => {
|
||||
let mut out = [0u8; 11];
|
||||
out[0] = st.current_template as u8;
|
||||
out[1] = 0;
|
||||
out[2..10].copy_from_slice(&st.template_mask.to_le_bytes());
|
||||
out[10] = 10;
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
xu2_selectors::IMAGE_TEMPLATE_NAME => {
|
||||
let mut out = [0u8; 65];
|
||||
out[0] = st.current_template as u8;
|
||||
if let Some(name) = st.template_names.get(&st.current_template) {
|
||||
let b = name.as_bytes();
|
||||
out[1..1 + b.len().min(63)].copy_from_slice(&b[..b.len().min(63)]);
|
||||
}
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
xu2_selectors::FILTER_MODE => {
|
||||
return Ok(vec![st.filter_mode]);
|
||||
}
|
||||
xu2_selectors::PRIVACY_AND_FILL_LIGHT => {
|
||||
return Ok(vec![0, u8::from(st.privacy_mode), 0, 0]);
|
||||
}
|
||||
xu2_selectors::DEFAULT_TRACK_MODE => {
|
||||
return Ok(vec![st.default_track_mode]);
|
||||
}
|
||||
xu2_selectors::HORIZONTAL_CORRECTION => {
|
||||
return Ok(vec![u8::from(st.horiz_correction)]);
|
||||
}
|
||||
xu2_selectors::REAL_ZOOM_AND_UDISK => {
|
||||
let mut out = [0u8; 4];
|
||||
out[0..2].copy_from_slice(&st.zoom.to_le_bytes());
|
||||
return Ok(out.to_vec());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(saved) = st.registers.get(&(unit_id, selector)) {
|
||||
let mut v = saved.clone();
|
||||
v.resize(len, 0);
|
||||
return Ok(v);
|
||||
}
|
||||
|
||||
Ok(vec![0u8; len])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
use bitflags::bitflags;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::ids::{CameraModel, DeviceCapabilities, UvcUnitIds};
|
||||
|
||||
/// UVC Class-Specific Request Codes (`MacUVCCameraControl::SetData` / `GetData` at `0x10019282c`)
|
||||
/// - `SET_CUR`: `bmRequestType = 0x21`, `bRequest = 0x01` (`w10 = 0x0121`)
|
||||
/// - `GET_*`: `bmRequestType = 0xa1`, `bRequest = 0x81..0x87`
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum UvcRequest {
|
||||
SetCur = 0x01,
|
||||
GetCur = 0x81,
|
||||
GetMin = 0x82,
|
||||
GetMax = 0x83,
|
||||
GetRes = 0x84,
|
||||
GetLen = 0x85,
|
||||
GetInfo = 0x86,
|
||||
GetDef = 0x87,
|
||||
}
|
||||
|
||||
/// Standard UVC Input Terminal (Camera Terminal) Control Selectors (`MacUVCCameraController`)
|
||||
pub mod ct_selectors {
|
||||
/// `CT_AE_MODE_CONTROL` (`0x02`, 1 byte: 1=Manual, 2=Auto, 4=ShutterPriority, 8=AperturePriority)
|
||||
pub const AE_MODE: u8 = 0x02;
|
||||
/// `CT_EXPOSURE_TIME_ABSOLUTE_CONTROL` (`0x04`, 4 bytes)
|
||||
pub const EXPOSURE_TIME_ABSOLUTE: u8 = 0x04;
|
||||
/// `CT_FOCUS_ABSOLUTE_CONTROL` (`0x06`, 2 bytes)
|
||||
pub const FOCUS_ABSOLUTE: u8 = 0x06;
|
||||
/// `CT_FOCUS_AUTO_CONTROL` (`0x08`, 1 byte: 0 or 1)
|
||||
pub const FOCUS_AUTO: u8 = 0x08;
|
||||
/// `CT_ZOOM_ABSOLUTE_CONTROL` (`0x0b`, 2 bytes, typically 100..400)
|
||||
pub const ZOOM_ABSOLUTE: u8 = 0x0b;
|
||||
/// `CT_PANTILT_ABSOLUTE_CONTROL` (`0x0d`, 8 bytes: i32 pan_arcsec, i32 tilt_arcsec)
|
||||
pub const PANTILT_ABSOLUTE: u8 = 0x0d;
|
||||
/// `CT_PANTILT_RELATIVE_CONTROL` (`0x0e`, 4 bytes: i8 pan_dir, u8 pan_speed, i8 tilt_dir, u8 tilt_speed)
|
||||
pub const PANTILT_RELATIVE: u8 = 0x0e;
|
||||
/// `CT_ROLL_ABSOLUTE_CONTROL` (`0x0f`, 2 bytes: i16 roll)
|
||||
pub const ROLL_ABSOLUTE: u8 = 0x0f;
|
||||
}
|
||||
|
||||
/// Standard UVC Processing Unit Control Selectors (`MacUVCCameraController`)
|
||||
pub mod pu_selectors {
|
||||
/// `PU_BRIGHTNESS_CONTROL` (`0x02`, 2 bytes: i16)
|
||||
pub const BRIGHTNESS: u8 = 0x02;
|
||||
/// `PU_CONTRAST_CONTROL` (`0x03`, 2 bytes: u16)
|
||||
pub const CONTRAST: u8 = 0x03;
|
||||
/// `PU_POWER_LINE_FREQUENCY_CONTROL` (`0x05`, 1 byte: 0=Disabled, 1=50Hz, 2=60Hz, 3=Auto)
|
||||
pub const POWER_LINE_FREQUENCY: u8 = 0x05;
|
||||
/// `PU_SATURATION_CONTROL` (`0x07`, 2 bytes: u16)
|
||||
pub const SATURATION: u8 = 0x07;
|
||||
/// `PU_SHARPNESS_CONTROL` (`0x08`, 2 bytes: u16)
|
||||
pub const SHARPNESS: u8 = 0x08;
|
||||
/// `PU_WHITE_BALANCE_TEMPERATURE_CONTROL` (`0x0a`, 2 bytes: u16 Kelvin)
|
||||
pub const WHITE_BALANCE_TEMPERATURE: u8 = 0x0a;
|
||||
/// `PU_WHITE_BALANCE_TEMPERATURE_AUTO_CONTROL` (`0x0b`, 1 byte: 0 or 1)
|
||||
pub const WHITE_BALANCE_TEMPERATURE_AUTO: u8 = 0x0b;
|
||||
}
|
||||
|
||||
/// Insta360 Extension Unit 1 (`FAF1672D-B71B-4793-8C91-7B1C9B7F95F8`) Control Selectors
|
||||
/// Verified across `MacUVCCameraExtendController::*` (`0x100195a18`–`0x10019bd00`).
|
||||
pub mod xu1_selectors {
|
||||
/// `0x01`: Preset / Gimbal / PTZ / Factory Reset / Composition Bias / Forbidden Area multiplexed control
|
||||
/// - `[0, index, 0, 0]` (4B): SetCurPresetPosition
|
||||
/// - `[1, index, 0, 0]` (4B): AddPresetPosition
|
||||
/// - `[2, index, 0, 0]` (4B): DeletePresetPosition
|
||||
/// - `[3, 0, 0, 0]` (4B): ResetGimbal (PTZ home)
|
||||
/// - `[4, 0, 0, 0]` (4B): LoadFactoryDefault
|
||||
/// - `[6, x_lo, x_hi, y_lo, y_hi]` (5B): SetCompositionBias (x: i16, y: i16; 0x7fff = reset)
|
||||
/// - `[7, 1, 0, 0]` (4B): EnterTrackForbiddenAreaSetting
|
||||
/// - `[8, 1, 0, 0]` (4B): ExitTrackForbiddenAreaSetting
|
||||
pub const SYSTEM_AND_PRESET_CMD: u8 = 0x01;
|
||||
|
||||
/// `0x02`: Video Mode & Auxiliary Data (`SetVideoMode` / `GetVideoMode`)
|
||||
/// - Set: 8 bytes (Link/Link 2) or 36 bytes (Link 2C/PUC3 Host)
|
||||
/// - Get: 36 bytes (`[mode_u8, flags_u8, x_i32, y_i32, w_i32, h_i32, pitch_i16, zoom_u16, ...]`)
|
||||
pub const VIDEO_MODE: u8 = 0x02;
|
||||
|
||||
/// `0x03`: AI Tracking & Gesture Control
|
||||
/// - `GetGestureStatus` / `EnableAllGesture` / `EnableGestureWork`: 6-byte status (`[all, ok, palm, l, v, rock]`)
|
||||
/// - `ForceSetSmartWhiteBoard(mode)` (`[3, mode_u8, ...]`, 16B)
|
||||
/// - `SetTrackSpeed` / `GetTrackSpeed`: (`[5, speed_u8, ...]`, 16B)
|
||||
/// - `SetCompositionStyle` / `GetCompositionStyle`: (`[6, style_u8, ...]`, 16B)
|
||||
pub const TRACKING_AND_GESTURE: u8 = 0x03;
|
||||
|
||||
/// `0x04`: Audio Noise Reduction & Audio Capture Mode (`2` bytes)
|
||||
/// - `EnableAudioNoiseReduction` / `GetAudioNoiseStatus`: `buf[0] = 0/1`
|
||||
/// - `SetAudioCaptureMode` / `GetAudioCaptureMode`: `buf[0] = mode (1..=5)`
|
||||
pub const AUDIO_CONTROL: u8 = 0x04;
|
||||
|
||||
/// `0x05`: Select Tracked Person / Object Bounding Box (`SetTrackObj`, 10 bytes: `i16 x, y, w, h, id`)
|
||||
pub const TRACK_OBJECT_SELECT: u8 = 0x05;
|
||||
|
||||
/// `0x06`: Detected Trackable Objects List (Link 1: `GetTrackObjLists`, N * 10 bytes of `UVCRect`)
|
||||
pub const TRACK_OBJECT_LIST_V1: u8 = 0x06;
|
||||
|
||||
/// `0x07`: Exposure Curve & Manual Exposure (`GetExpouseCurve` / `SetExpouseCurve` / `EnableAutoExposure` / `GetAutoExposureStatus` / `SetISOValue` / `GetISOValue`, 259 bytes)
|
||||
pub const EXPOSURE_CURVE_AND_ISO: u8 = 0x07;
|
||||
|
||||
/// `0x08`: Exposure Compensation (`SetExposureCompensation` / `GetExposureCompensation`, 4 bytes: `f32` EV)
|
||||
pub const EXPOSURE_COMPENSATION: u8 = 0x08;
|
||||
|
||||
/// `0x09`: White Balance Extended Parameters (`SetWhiteBalanceParam` / `GetWhiteBalanceParam`, 129 bytes)
|
||||
pub const WHITE_BALANCE_EXT: u8 = 0x09;
|
||||
|
||||
/// `0x0a`: Extended Function Bitmask (`EnableExtendFuncWork` / `GetExtendFuncStatus` / `EnableVerticalScreen` / `EnableLowResolution`, 8 bytes: `u32 mask, u32 values`)
|
||||
pub const EXTEND_FUNC_BITMASK: u8 = 0x0a;
|
||||
|
||||
/// `0x0b`: Gimbal Relative Pan/Tilt (`SetPanTiltRelative` / `GetPanTiltRelative`, 4 bytes: `[pan_speed, pan_dir, tilt_speed, tilt_dir]`)
|
||||
pub const PANTILT_RELATIVE_EXT: u8 = 0x0b;
|
||||
|
||||
/// `0x0c`: Device Status Flags (`GetDeviceStatus`, 4 bytes: `u32` bitflags)
|
||||
pub const DEVICE_STATUS: u8 = 0x0c;
|
||||
|
||||
/// `0x0d`: PTZ Version & Gimbal Absolute Position (`GetPTZVersionInfo` / `GetPanTiltAbsoluteValue`, 30 bytes)
|
||||
pub const PTZ_VERSION_AND_ABS: u8 = 0x0d;
|
||||
|
||||
/// `0x0e`: Preset Position Bitflags (`GetCurPresetPositionFlag`, 8 bytes)
|
||||
pub const PRESET_POSITION_FLAGS: u8 = 0x0e;
|
||||
|
||||
/// `0x0f`: Switch to Read-Only Mass Storage Class (`SwitchMscReadOnlyClass`, 4 bytes: `[2, 0, 0, 0]`)
|
||||
/// Also used by `DownloadFileFromCamera` (`[1, 0, 0, 0]`).
|
||||
pub const USB_MODE_SWITCH: u8 = 0x0f;
|
||||
}
|
||||
|
||||
/// Insta360 Extension Unit 2 (`E307E649-4618-A3FF-82FC-2D8B5F216773`) Control Selectors
|
||||
/// Verified across `MacUVCCameraExtendController::*` (`0x100195a18`–`0x10019c478`).
|
||||
pub mod xu2_selectors {
|
||||
/// `0x01`: Gesture Binding Map (`GetGestureBindingMap` / `SetGestureBindingFunc`)
|
||||
pub const GESTURE_BINDING_MAP: u8 = 0x01;
|
||||
|
||||
/// `0x02`: Camera System & Timestamp (`SetTimestamp`, `SetIQSwitch`, `RestoreFactorySet`)
|
||||
/// - `[1, ts_le_bytes[0..8], ...]`: SetTimestamp
|
||||
/// - `[2, iq_bitmask_u32_le, ...]`: SetIQSwitch (D0=bit 0, D1=bit 1)
|
||||
/// - `[4, 1, ...]`: RestoreFactorySet(true) (reboot/full)
|
||||
/// - `[5, 1, ...]`: RestoreFactorySet(false) (settings only)
|
||||
pub const SYSTEM_TIME_AND_IQ: u8 = 0x02;
|
||||
|
||||
/// `0x03`: Device Info & Serial Number (`GetDeviceInfo` / `GetSerialNumber`, 150 bytes:
|
||||
/// `[0..50]` device_name, `[50..100]` fw_version, `[100..150]` serial_number)
|
||||
pub const DEVICE_INFO: u8 = 0x03;
|
||||
|
||||
/// `0x04`: Image Template Management (`GetImageTemplateInfo`, `SetImageTemplate`, `DeleteTemplate`, 11 bytes)
|
||||
pub const IMAGE_TEMPLATE_INFO: u8 = 0x04;
|
||||
|
||||
/// `0x05`: Image Template Name (`GetImageTemplateName` / `SetImageTemplateName`, 65 bytes: `[id_i8, name[64]]`)
|
||||
pub const IMAGE_TEMPLATE_NAME: u8 = 0x05;
|
||||
|
||||
/// `0x06`: Image Filter Mode (`SetFilter` / `GetFilter`, 1 byte: `0..=8`)
|
||||
pub const FILTER_MODE: u8 = 0x06;
|
||||
|
||||
/// `0x08`: Fill Light & Privacy Mode (`GetFillLightParam`, `SetPrivacyMode`, `GetPrivacyMode`, 4 bytes)
|
||||
/// - `SetPrivacyMode(enable)`: `[2, enable_u8, 0, 0]`
|
||||
/// - `GetPrivacyMode`: `status = buf[0]`, `enabled = buf[1] != 0`
|
||||
pub const PRIVACY_AND_FILL_LIGHT: u8 = 0x08;
|
||||
|
||||
/// `0x09`: Host Digital PTZ Crop (`SetHostPTZ`, 8 bytes: `u16 x, u16 y, u16 w, u16 h`)
|
||||
pub const HOST_PTZ: u8 = 0x09;
|
||||
|
||||
/// `0x0a`: Detected Trackable Objects List V2 (Link 2 / Link 2C: `GetNewTrackObjLists`)
|
||||
pub const TRACK_OBJECT_LIST_V2: u8 = 0x0a;
|
||||
|
||||
/// `0x0b`: Tracking Forbidden Areas (`SetTrackForbiddenArea`, `GetTrackForbiddenArea`, `EnableTrackForbiddenArea`)
|
||||
pub const TRACK_FORBIDDEN_AREA: u8 = 0x0b;
|
||||
|
||||
/// `0x0c`: Default Track Mode (`SetDefaultTrackMode` / `GetDefaultTrackMode`, 1 byte)
|
||||
pub const DEFAULT_TRACK_MODE: u8 = 0x0c;
|
||||
|
||||
/// `0x0d`: Horizontal Correction (`EnableHorizontalCorrection` / `GetEnableHorizontalCorrection`, 1 byte)
|
||||
pub const HORIZONTAL_CORRECTION: u8 = 0x0d;
|
||||
|
||||
/// `0x0e`: Real Zoom & UDisk Open Count (`GetRealZoomValue`, `GetUDiskOpenCount`, `SetUDiskOpenCount`)
|
||||
pub const REAL_ZOOM_AND_UDISK: u8 = 0x0e;
|
||||
|
||||
/// `0x0f`: Diagnostic Report Data (`GetReportData`)
|
||||
pub const REPORT_DATA: u8 = 0x0f;
|
||||
}
|
||||
|
||||
/// Discovered Insta360 camera metadata (`UVCCameraInfo` at `0x100191844`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DiscoveredCamera {
|
||||
pub vendor_id: u16,
|
||||
pub product_id: u16,
|
||||
pub model: CameraModel,
|
||||
pub name: String,
|
||||
pub unique_id: String,
|
||||
pub bus_number: u8,
|
||||
pub device_address: u8,
|
||||
pub unit_ids: UvcUnitIds,
|
||||
pub capabilities: DeviceCapabilities,
|
||||
}
|
||||
|
||||
/// Range and default metadata for a standard UVC control (`CameraControlInfo` at `0x100193694`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct ControlRange {
|
||||
pub min: i64,
|
||||
pub max: i64,
|
||||
pub step: i64,
|
||||
pub default: i64,
|
||||
pub flags: i64,
|
||||
}
|
||||
|
||||
/// Relative pan/tilt speed and direction (`CameraControlRelativeInfo` at `0x1001990f4`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct RelativeControl {
|
||||
pub min_speed: u8,
|
||||
pub max_speed: u8,
|
||||
pub step_speed: u8,
|
||||
pub default_speed: u8,
|
||||
pub current_speed: u8,
|
||||
/// Direction: `0` = stop, `1` = positive (right/up), `-1` (or `0xff`) = negative (left/down)
|
||||
pub direction: i8,
|
||||
}
|
||||
|
||||
/// Camera Video Mode (`VideoMode` in `CameraInsta` at `0x1000601d4`–`0x100060234`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum VideoMode {
|
||||
#[default]
|
||||
Normal = 0,
|
||||
AutoTracking = 1,
|
||||
WhiteBoard = 4,
|
||||
OverheadShot = 5,
|
||||
DeskView = 6,
|
||||
AutoFraming = 7,
|
||||
SmartWhiteBoard = 8,
|
||||
}
|
||||
|
||||
impl VideoMode {
|
||||
pub fn from_u8(val: u8) -> Self {
|
||||
match val {
|
||||
0 => Self::Normal,
|
||||
1 => Self::AutoTracking,
|
||||
4 => Self::WhiteBoard,
|
||||
5 => Self::OverheadShot,
|
||||
6 => Self::DeskView,
|
||||
7 => Self::AutoFraming,
|
||||
8 | 10 => Self::SmartWhiteBoard,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "Normal",
|
||||
Self::AutoTracking => "AI Tracking",
|
||||
Self::WhiteBoard => "Whiteboard",
|
||||
Self::OverheadShot => "Overhead",
|
||||
Self::DeskView => "DeskView",
|
||||
Self::AutoFraming => "Auto Framing",
|
||||
Self::SmartWhiteBoard => "Smart Whiteboard",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auxiliary data sent/received with `VideoMode` (`VideoModeAuxiliaryData` at `0x100197088` & `0x1001978b8`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VideoModeAuxData {
|
||||
pub flags: u8,
|
||||
/// Optional 4-corner or bounding rectangle coordinates (`x, y, width, height` or whiteboard corners)
|
||||
pub rect: [i32; 4],
|
||||
/// DeskView / Host pitch angle (`i16`, e.g., -900..0 or -61926 sentinel)
|
||||
pub pitch: i16,
|
||||
/// Zoom factor (`u16`, default `100`)
|
||||
pub zoom: u16,
|
||||
/// Extra mode-specific bytes (up to 36 bytes total wire payload)
|
||||
pub extra: [u8; 14],
|
||||
}
|
||||
|
||||
impl Default for VideoModeAuxData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flags: 0,
|
||||
rect: [0, 0, 0, 0],
|
||||
pitch: 0,
|
||||
zoom: 100,
|
||||
extra: [0u8; 14],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI Tracking / Framing Composition Style (`CompositionStyle` at `0x100197900` & `PTZControl.qml` line 944).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum CompositionStyle {
|
||||
Custom = 0,
|
||||
#[default]
|
||||
Head = 1,
|
||||
HalfBody = 2,
|
||||
WholeBody = 3,
|
||||
}
|
||||
|
||||
impl CompositionStyle {
|
||||
pub fn from_u8(v: u8) -> Self {
|
||||
match v {
|
||||
1 => Self::Head,
|
||||
2 => Self::HalfBody,
|
||||
3 => Self::WholeBody,
|
||||
_ => Self::Head,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gimbal AI Tracking Speed (`TrackSpeed` at `0x1001979e4` & `PTZControl.qml` line 1051).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum TrackSpeed {
|
||||
Fast = 0,
|
||||
#[default]
|
||||
Normal = 1,
|
||||
Slow = 2,
|
||||
}
|
||||
|
||||
impl TrackSpeed {
|
||||
pub fn from_u8(v: u8) -> Self {
|
||||
match v {
|
||||
0 => Self::Fast,
|
||||
1 => Self::Normal,
|
||||
_ => Self::Slow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extended Function bit indices in XU1 selector `0x0a` (`ExtendFuction` jump table at `0x102864a20`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum ExtendFunction {
|
||||
/// Bit 0: `ai_zoom_checked_`
|
||||
AiZoom = 0,
|
||||
/// Bit 1: Reserved
|
||||
Reserved1 = 1,
|
||||
/// Bit 2: `enable_hdr_`
|
||||
Hdr = 2,
|
||||
/// Bit 3: `mirror_checked_` (Horizontal Mirror)
|
||||
HorizontalMirror = 3,
|
||||
/// Bit 4: `enable_all_gestures_`
|
||||
AllGestures = 4,
|
||||
/// Bit 5: `enable_vertical_mode_` (9:16 Vertical Resolution)
|
||||
VerticalMode = 5,
|
||||
/// Bit 6: `enable_startup_params_` (Save Startup Parameters to Camera)
|
||||
StartupParams = 6,
|
||||
/// Bit 7: `enable_single_tap_tracking_`
|
||||
SingleTapTracking = 7,
|
||||
/// Bit 8: `enable_auto_tracking_`
|
||||
AutoTracking = 8,
|
||||
/// Bit 9: `smart_adjustment_checked_`
|
||||
SmartAdjustment = 9,
|
||||
/// Bit 10: `forced_vertical_` (Uncropped 9:16 Portrait)
|
||||
ForcedVertical = 10,
|
||||
/// Bit 11: `enable_privacy_mode_` (Ultimate Privacy / 10s Sleep)
|
||||
UltimatePrivacyMode = 11,
|
||||
/// Bit 12: `enable_vertically_mirror_` (Vertical Flip)
|
||||
VerticalMirror = 12,
|
||||
/// Bit 13: `enable_low_resolution_`
|
||||
LowResolution = 13,
|
||||
/// Bit 14: `enable_track_forbidden_area_`
|
||||
TrackForbiddenArea = 14,
|
||||
}
|
||||
|
||||
impl ExtendFunction {
|
||||
pub const ALL: [ExtendFunction; 14] = [
|
||||
Self::AiZoom,
|
||||
Self::Hdr,
|
||||
Self::HorizontalMirror,
|
||||
Self::AllGestures,
|
||||
Self::VerticalMode,
|
||||
Self::StartupParams,
|
||||
Self::SingleTapTracking,
|
||||
Self::AutoTracking,
|
||||
Self::SmartAdjustment,
|
||||
Self::ForcedVertical,
|
||||
Self::UltimatePrivacyMode,
|
||||
Self::VerticalMirror,
|
||||
Self::LowResolution,
|
||||
Self::TrackForbiddenArea,
|
||||
];
|
||||
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(Self::AiZoom),
|
||||
1 => Some(Self::Reserved1),
|
||||
2 => Some(Self::Hdr),
|
||||
3 => Some(Self::HorizontalMirror),
|
||||
4 => Some(Self::AllGestures),
|
||||
5 => Some(Self::VerticalMode),
|
||||
6 => Some(Self::StartupParams),
|
||||
7 => Some(Self::SingleTapTracking),
|
||||
8 => Some(Self::AutoTracking),
|
||||
9 => Some(Self::SmartAdjustment),
|
||||
10 => Some(Self::ForcedVertical),
|
||||
11 => Some(Self::UltimatePrivacyMode),
|
||||
12 => Some(Self::VerticalMirror),
|
||||
13 => Some(Self::LowResolution),
|
||||
14 => Some(Self::TrackForbiddenArea),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand Gesture types (`EnableGestureWork` at `0x100197bc8` and `setUseGesture*` at `0x100067f58`–`0x100068888`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum Gesture {
|
||||
Ok = 0,
|
||||
Palm = 1,
|
||||
LShape = 2,
|
||||
VShape = 3,
|
||||
Rock = 4,
|
||||
}
|
||||
|
||||
impl Gesture {
|
||||
pub const ALL: [Gesture; 5] = [
|
||||
Self::Ok,
|
||||
Self::Palm,
|
||||
Self::LShape,
|
||||
Self::VShape,
|
||||
Self::Rock,
|
||||
];
|
||||
}
|
||||
|
||||
/// Gesture-bound action (`GestureFunction` in `GetGestureBindingMap` at `0x100198384`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum GestureFunction {
|
||||
#[default]
|
||||
None = 0,
|
||||
AutoTracking = 1,
|
||||
Zoom = 2,
|
||||
WhiteBoard = 3,
|
||||
AutoFraming = 4,
|
||||
}
|
||||
|
||||
impl GestureFunction {
|
||||
pub fn from_u8(v: u8) -> Self {
|
||||
match v {
|
||||
1 => Self::AutoTracking,
|
||||
2 => Self::Zoom,
|
||||
3 => Self::WhiteBoard,
|
||||
4 => Self::AutoFraming,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio Capture / Microphone Mode (`AudioCaptureMode` at `0x10019bf54` & `0x1000702a8`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum AudioCaptureMode {
|
||||
/// Voice Focus (Directional beamforming)
|
||||
#[default]
|
||||
VoiceFocus = 1,
|
||||
/// Voice Suppression / Noise Canceling (Wide)
|
||||
VoiceSuppression = 2,
|
||||
/// Original / Music Balance
|
||||
MusicBalance = 3,
|
||||
/// Original Sound
|
||||
Original = 4,
|
||||
/// Sound Wall / Audio Fence (PUC3)
|
||||
SoundWall = 5,
|
||||
}
|
||||
|
||||
impl AudioCaptureMode {
|
||||
pub fn from_u8(v: u8) -> Self {
|
||||
match v {
|
||||
1 => Self::VoiceFocus,
|
||||
2 => Self::VoiceSuppression,
|
||||
3 => Self::MusicBalance,
|
||||
4 => Self::Original,
|
||||
5 => Self::SoundWall,
|
||||
_ => Self::VoiceFocus,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::VoiceFocus => "Voice Focus",
|
||||
Self::VoiceSuppression => "Noise Suppression",
|
||||
Self::MusicBalance => "Music Balance",
|
||||
Self::Original => "Original Sound",
|
||||
Self::SoundWall => "Sound Wall",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Power Line Anti-Flicker Frequency (`PowerLineFrequency` at `0x100194ecc`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum PowerLineFrequency {
|
||||
#[default]
|
||||
Disabled = 0,
|
||||
Hz50 = 1,
|
||||
Hz60 = 2,
|
||||
Auto = 3,
|
||||
}
|
||||
|
||||
impl PowerLineFrequency {
|
||||
pub fn from_u8(v: u8) -> Self {
|
||||
match v {
|
||||
1 => Self::Hz50,
|
||||
2 => Self::Hz60,
|
||||
3 => Self::Auto,
|
||||
_ => Self::Disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hardware Filter Mode (`FilterMode` at `0x10019bdb0` & `0x100096f94`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum FilterMode {
|
||||
#[default]
|
||||
None = 0,
|
||||
Style1 = 1,
|
||||
Style2 = 2,
|
||||
Fresh = 3,
|
||||
Amber = 4,
|
||||
Film1 = 5,
|
||||
Film2 = 6,
|
||||
Cool = 7,
|
||||
Warm = 8,
|
||||
}
|
||||
|
||||
impl FilterMode {
|
||||
pub fn from_u8(v: u8) -> Self {
|
||||
match v {
|
||||
1 => Self::Style1,
|
||||
2 => Self::Style2,
|
||||
3 => Self::Fresh,
|
||||
4 => Self::Amber,
|
||||
5 => Self::Film1,
|
||||
6 => Self::Film2,
|
||||
7 => Self::Cool,
|
||||
8 => Self::Warm,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposure Curve & Manual ISO/Exposure state (`ExpouseCurve` at `0x10019aaa0`–`0x10019b7d0`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ExposureCurve {
|
||||
/// `0` = Manual Exposure, `1` = Auto Exposure
|
||||
pub mode: u8,
|
||||
/// Manual ISO value (`u16`, e.g. 100..=3200)
|
||||
pub iso: u16,
|
||||
/// 128-point tone/exposure curve (`[u16; 128]`)
|
||||
pub points: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Default for ExposureCurve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: 1,
|
||||
iso: 100,
|
||||
points: vec![0; 128],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extended White Balance Parameters (`WhiteBalanceParam` at `0x10019afe0`–`0x10019b264`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WhiteBalanceParam {
|
||||
pub mode: u8,
|
||||
pub gains: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Camera Device Information (`DeviceInfo` at `0x100196e74`, 150 bytes across three 50-byte ASCII fields).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub device_name: String,
|
||||
pub firmware_version: String,
|
||||
pub serial_number: String,
|
||||
}
|
||||
|
||||
/// PTZ Gimbal Firmware Version & Absolute Pan/Tilt Angles (`PTZVerInfo` at `0x1001993bc`, 30 bytes).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct PtzVersionInfo {
|
||||
pub version: String,
|
||||
pub pan: i16,
|
||||
pub tilt: i16,
|
||||
pub zoom: u16,
|
||||
pub status: u8,
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
/// Device runtime status flags (`GetDeviceStatus` at `0x10019b8dc`, 4-byte `u32`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct DeviceStatus: u32 {
|
||||
const TRACKING_ACTIVE = 1 << 0;
|
||||
const WHITEBOARD_ACTIVE = 1 << 1;
|
||||
const GESTURE_DETECTED = 1 << 2;
|
||||
const PRIVACY_SLEEP = 1 << 3;
|
||||
const OVERHEAD_MODE = 1 << 4;
|
||||
const DESKVIEW_MODE = 1 << 5;
|
||||
const UPGRADING = 1 << 6;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounding rectangle for tracked person/object (`UVCRect` at `0x100198a38`, 10 bytes).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct UvcRect {
|
||||
pub x: i16,
|
||||
pub y: i16,
|
||||
pub width: i16,
|
||||
pub height: i16,
|
||||
pub track_id: i16,
|
||||
}
|
||||
|
||||
/// Digital Host PTZ crop box (`HostPTZInfo` at `0x10019bec0`, 8 bytes).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HostPtzInfo {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
impl Default for HostPtzInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 5140,
|
||||
height: 5140,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracking Forbidden Area entry (`ForbiddenAreaInfo_s` at `0x10019bff4`, 10 bytes per entry).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct ForbiddenAreaInfo {
|
||||
pub enabled: bool,
|
||||
pub index: u8,
|
||||
pub pan_min: i16,
|
||||
pub pan_max: i16,
|
||||
pub tilt_min: i16,
|
||||
pub tilt_max: i16,
|
||||
}
|
||||
|
||||
/// Preset slot flags (`PresetFlag` at `0x1001997c0`, 8 bytes).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct PresetFlag {
|
||||
pub slot_mask: u8,
|
||||
pub current_slot: u8,
|
||||
pub max_slots: u8,
|
||||
pub raw: [u8; 8],
|
||||
}
|
||||
|
||||
/// Image Template Info (`ImageTemplateInfo` at `0x10019ba4c`, 11 bytes).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct ImageTemplateInfo {
|
||||
pub current_id: i8,
|
||||
pub operation: u8,
|
||||
pub bitmask: u64,
|
||||
pub max_templates: u8,
|
||||
}
|
||||
|
||||
/// Image Template Operation (`ImageOperation` at `0x10019bb08`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum ImageOperation {
|
||||
None = 0,
|
||||
SaveOrUpdate = 1,
|
||||
Apply = 2,
|
||||
}
|
||||
Reference in new issue
Block a user