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

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

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
[package]
name = "insta360-link-gui"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Cross-platform pure-Rust desktop GUI controller for Insta360 Link, Link 2, and Link 2C"
[[bin]]
name = "insta360-link-gui"
path = "src/main.rs"
[dependencies]
insta360-link-protocol = { path = "../insta360-link-protocol" }
eframe.workspace = true
egui.workspace = true
image = { version = "0.25", default-features = false, features = ["png"] }
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+500
View File
@@ -0,0 +1,500 @@
#![allow(dead_code)]
use egui::{ColorImage, Pos2, TextureHandle};
pub use insta360_link_protocol::*;
use std::path::PathBuf;
use std::time::Instant;
/// UI mode matching `Webcam.Controller.curUIMode` (`MainWindow` vs `Toolbar`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UiMode {
MainWindow,
Toolbar,
}
/// Right sidebar tab matching `Settings.qml` (`View`, `Effects`, `More`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RightSidebarTab {
View,
Effects,
More,
}
/// Active popup in Compact Toolbar Mode (`Toolbar.qml`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolbarPopup {
None,
PtzPanel,
TrackMode,
VirtualBackground,
PictureSetting,
GestureCtrl,
RemoteCtrl,
MoreSetting,
}
/// Active tab in the Preferences modal (`preference/MoreSettings.qml`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreferenceTab {
General,
Hotkeys,
About,
GetLog,
}
/// Virtual background mode (`ImageBackground.qml`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackgroundEffectMode {
None,
StandardBlur,
NaturalBokeh,
CustomImage(usize),
}
/// Resolution & frame rate preset (`DefFormatCombo.qml`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoFormatPreset {
Uhd4k30,
Fhd1080p60,
Fhd1080p30,
Hd720p60,
Hd720p30,
Sd360p30,
Vertical1080x1920p30,
}
impl VideoFormatPreset {
pub const ALL: [VideoFormatPreset; 7] = [
Self::Uhd4k30,
Self::Fhd1080p60,
Self::Fhd1080p30,
Self::Hd720p60,
Self::Hd720p30,
Self::Sd360p30,
Self::Vertical1080x1920p30,
];
pub fn label(&self) -> &'static str {
match self {
Self::Uhd4k30 => "4K 30fps",
Self::Fhd1080p60 => "1080p 60fps",
Self::Fhd1080p30 => "1080p 30fps",
Self::Hd720p60 => "720p 60fps",
Self::Hd720p30 => "720p 30fps",
Self::Sd360p30 => "360p 30fps",
Self::Vertical1080x1920p30 => "1080x1920 30fps",
}
}
pub fn dimensions(&self) -> (u32, u32) {
match self {
Self::Uhd4k30 => (3840, 2160),
Self::Fhd1080p60 | Self::Fhd1080p30 => (1920, 1080),
Self::Hd720p60 | Self::Hd720p30 => (1280, 720),
Self::Sd360p30 => (640, 360),
Self::Vertical1080x1920p30 => (1088, 1920),
}
}
pub fn fps(&self) -> u32 {
match self {
Self::Fhd1080p60 | Self::Hd720p60 => 60,
_ => 30,
}
}
pub fn is_4k(&self) -> bool {
matches!(self, Self::Uhd4k30)
}
pub fn is_vertical(&self) -> bool {
matches!(self, Self::Vertical1080x1920p30)
}
}
/// Saved PTZ preset item (`PTZControl.qml` repeater item).
#[derive(Clone)]
pub struct PtzPresetSlot {
pub id: u8,
pub name: String,
pub saved: bool,
pub pan_deg: f32,
pub tilt_deg: f32,
pub zoom_x: f32,
pub host_x: f32,
pub host_y: f32,
pub mode: VideoMode,
pub thumbnail: Option<TextureHandle>,
pub raw_thumb: Option<ColorImage>,
pub renaming: bool,
}
/// Saved Color Preset item (`ImageScreen.qml` template repeater item).
#[derive(Clone)]
pub struct ColorPresetSlot {
pub id: i8,
pub name: String,
pub saved: bool,
pub brightness: i16,
pub contrast: u16,
pub saturation: u16,
pub sharpness: u16,
pub wb_auto: bool,
pub wb_kelvin: u16,
pub auto_exposure: bool,
pub ev_comp: f32,
pub iso: u16,
pub shutter: u32,
pub hdr: bool,
pub thumbnail: Option<TextureHandle>,
pub raw_thumb: Option<ColorImage>,
pub renaming: bool,
}
/// ISO discrete step values matching `Webcam.Controller.camera.isoList` (`ImageScreen.qml`).
pub const ISO_LIST: [u16; 16] = [
100, 125, 160, 200, 250, 320, 400, 500, 640, 800, 1000, 1250, 1600, 2000, 2500, 3200,
];
/// Shutter speed denominator list (`1/N` seconds) matching `Webcam.Controller.camera.shutterList`.
pub const SHUTTER_DENOM_LIST: [u32; 20] = [
30, 40, 50, 60, 80, 100, 120, 160, 200, 240, 320, 400, 500, 640, 800, 1000, 1600, 2000, 4000,
8000,
];
/// Shared UI & camera state used across `main.rs`, `right_panel.rs`, and `video_engine.rs`.
pub struct CameraAppState {
// Theme & window mode
pub is_light_mode: bool,
pub ui_mode: UiMode,
pub right_tab: RightSidebarTab,
pub toolbar_popup: ToolbarPopup,
pub show_preferences: bool,
pub preference_tab: PreferenceTab,
pub show_qr_dialog: bool,
pub show_virtual_cam_pop: bool,
pub show_camera_log_pop: bool,
pub show_factory_reset_pop: bool,
pub show_pause_track_overlay: bool,
pub show_deskview_pitch_pop: bool,
// Preview & recording state
pub preview_open: bool,
pub virtual_camera_enabled: bool,
pub format_preset: VideoFormatPreset,
pub is_recording: bool,
pub recording_started: Option<Instant>,
pub screenshot_dir: PathBuf,
pub recording_dir: PathBuf,
pub toast_message: Option<(String, Instant, bool)>,
// Device Info & Status
pub device_info: DeviceInfo,
pub ptz_info: PtzVersionInfo,
pub device_status: DeviceStatus,
// Gimbal / View / PTZ state
pub pan_deg: f32,
pub tilt_deg: f32,
pub zoom_x: f32, // 1.0 .. 4.0
pub roll_deg: f32, // -1.0 .. +1.0 (tenths of degree in hardware)
pub deskview_pitch: i16, // -900 .. 0
pub host_ptz_x: f32, // 0.0 .. 1.0 normalized center X (Link 2C)
pub host_ptz_y: f32, // 0.0 .. 1.0 normalized center Y (Link 2C)
pub bias_x: i16,
pub bias_y: i16,
pub ptz_presets: Vec<PtzPresetSlot>,
pub active_preset_id: Option<u8>,
pub open_more_positions: bool,
// Video Mode & Tracking state
pub video_mode: VideoMode,
pub default_track_mode: u8, // 0 = Single Tracking, 1 = Group Tracking (Link 2)
pub ai_zoom_enabled: bool, // Smart Composition switch
pub composition_style: CompositionStyle,
pub track_speed: TrackSpeed,
pub enable_auto_tracking: bool,
pub enable_single_tap_tracking: bool,
pub pause_track_enabled: bool,
pub forbidden_areas: [ForbiddenAreaInfo; 4],
pub tracked_targets: Vec<UvcRect>,
pub selected_target_id: i16,
pub whiteboard_corners: [Pos2; 4], // normalized [0..1] corners for Smart Whiteboard
// Color & Exposure state (`ImageScreen.qml`)
pub auto_exposure: bool,
pub ev_compensation: f32, // -3.0 .. +3.0
pub iso_index: usize, // index into ISO_LIST
pub shutter_index: usize, // index into SHUTTER_DENOM_LIST
pub spline_points: Vec<(f32, f32)>, // normalized (0..1, 0..1) control points for DefSplineCanvas
pub auto_focus: bool,
pub manual_focus: u16, // 0..100
pub auto_white_balance: bool,
pub wb_kelvin: u16, // 2000..10000
pub open_more_color: bool, // Show/Collapse in Color section
pub brightness: i16, // 0..100
pub contrast: u16, // 0..100
pub saturation: u16, // 0..100
pub sharpness: u16, // 0..100
pub hdr_enabled: bool,
pub anti_flicker: PowerLineFrequency,
pub startup_params_enabled: bool,
pub color_presets: Vec<ColorPresetSlot>,
pub active_color_preset_id: Option<i8>,
pub open_more_color_presets: bool,
// Background, Makeup & Filter state (`ImageBackground.qml`, `ImageBeauty.qml`, `ImageFilter.qml`)
pub background_mode: BackgroundEffectMode,
pub blur_intensity: u8, // 0..100
pub custom_backgrounds: Vec<String>,
pub makeup_enabled: bool,
pub makeup_intensity: u8, // 0..100
pub filter_dropdown_open: bool,
pub filter_mode: FilterMode,
// More Tab state (`MoreSettings.qml`)
pub enable_all_gestures: bool,
pub use_gesture_palm: bool,
pub use_gesture_v: bool,
pub use_gesture_l: bool,
pub use_gesture_rock: bool,
pub use_gesture_ok: bool,
pub audio_capture_mode: AudioCaptureMode,
pub audio_denoise: bool,
pub horizontal_mirror: bool,
pub vertical_mirror: bool,
pub smart_adjustment: bool,
pub horizontal_correction: bool,
pub ultimate_privacy: bool,
pub privacy_sleep: bool,
pub vertical_resolution_mode: bool,
pub forced_vertical_lock: bool,
pub low_resolution_mode: bool,
// Real-time hardware sync, continuous PTZ velocity, and AI target polling
pub stream_initialized: bool,
pub joystick_dragging: bool,
pub last_ptz_cmd: (u8, i8, u8, i8),
pub last_ptz_send_time: Option<Instant>,
pub nudge_stop_at: Option<Instant>,
pub pending_ptz_restore: Option<(Instant, i32, i32)>,
pub ready_auto_track: bool,
pub last_track_poll: Option<Instant>,
pub last_hw_sync: Option<Instant>,
}
impl Default for CameraAppState {
fn default() -> Self {
let mut ptz_presets = Vec::with_capacity(6);
for i in 0..6u8 {
ptz_presets.push(PtzPresetSlot {
id: i,
name: format!("Preset {}", i + 1),
saved: i < 2,
pan_deg: if i == 0 { 0.0 } else { 22.0 },
tilt_deg: if i == 0 { 0.0 } else { -8.0 },
zoom_x: if i == 0 { 1.0 } else { 1.5 },
host_x: 0.5,
host_y: 0.5,
mode: VideoMode::Normal,
thumbnail: None,
raw_thumb: None,
renaming: false,
});
}
let color_presets = vec![ColorPresetSlot {
id: 1,
name: "Studio Warm".to_string(),
saved: true,
brightness: 54,
contrast: 52,
saturation: 56,
sharpness: 50,
wb_auto: false,
wb_kelvin: 5600,
auto_exposure: true,
ev_comp: 0.3,
iso: 100,
shutter: 120,
hdr: false,
thumbnail: None,
raw_thumb: None,
renaming: false,
}];
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
Self {
is_light_mode: false,
ui_mode: UiMode::MainWindow,
right_tab: RightSidebarTab::View,
toolbar_popup: ToolbarPopup::None,
show_preferences: false,
preference_tab: PreferenceTab::General,
show_qr_dialog: false,
show_virtual_cam_pop: false,
show_camera_log_pop: false,
show_factory_reset_pop: false,
show_pause_track_overlay: false,
show_deskview_pitch_pop: false,
preview_open: true,
virtual_camera_enabled: false,
format_preset: VideoFormatPreset::Fhd1080p30,
is_recording: false,
recording_started: None,
screenshot_dir: home.join("Pictures"),
recording_dir: home.join("Movies"),
toast_message: None,
device_info: DeviceInfo {
device_name: "Insta360 Link 2".to_string(),
firmware_version: "v2.5.8.8_build1".to_string(),
serial_number: "INSTA360SIM0001".to_string(),
},
ptz_info: PtzVersionInfo {
version: "v1.3.1.0_build0".to_string(),
pan: 0,
tilt: 0,
zoom: 100,
status: 0,
},
device_status: DeviceStatus::empty(),
pan_deg: 0.0,
tilt_deg: 0.0,
zoom_x: 1.0,
roll_deg: 0.0,
deskview_pitch: 0,
host_ptz_x: 0.5,
host_ptz_y: 0.5,
bias_x: 0,
bias_y: 0,
ptz_presets,
active_preset_id: Some(0),
open_more_positions: false,
video_mode: VideoMode::Normal,
default_track_mode: 0,
ai_zoom_enabled: true,
composition_style: CompositionStyle::HalfBody,
track_speed: TrackSpeed::Normal,
enable_auto_tracking: true,
enable_single_tap_tracking: true,
pause_track_enabled: false,
forbidden_areas: [
ForbiddenAreaInfo {
enabled: false,
index: 0,
pan_min: -350,
pan_max: -150,
tilt_min: -200,
tilt_max: 200,
},
ForbiddenAreaInfo {
enabled: false,
index: 1,
pan_min: 150,
pan_max: 350,
tilt_min: -200,
tilt_max: 200,
},
ForbiddenAreaInfo {
enabled: false,
index: 2,
pan_min: -200,
pan_max: 200,
tilt_min: 100,
tilt_max: 300,
},
ForbiddenAreaInfo {
enabled: false,
index: 3,
pan_min: -200,
pan_max: 200,
tilt_min: -300,
tilt_max: -100,
},
],
tracked_targets: Vec::new(),
selected_target_id: 0,
whiteboard_corners: [
Pos2::new(0.18, 0.16),
Pos2::new(0.82, 0.16),
Pos2::new(0.82, 0.78),
Pos2::new(0.18, 0.78),
],
auto_exposure: true,
ev_compensation: 0.0,
iso_index: 0,
shutter_index: 5, // 1/100s
spline_points: vec![(0.0, 0.0), (0.33, 0.33), (0.67, 0.67), (1.0, 1.0)],
auto_focus: true,
manual_focus: 50,
auto_white_balance: true,
wb_kelvin: 5000,
open_more_color: false,
brightness: 50,
contrast: 50,
saturation: 50,
sharpness: 50,
hdr_enabled: false,
anti_flicker: PowerLineFrequency::Auto,
startup_params_enabled: false,
color_presets,
active_color_preset_id: None,
open_more_color_presets: false,
background_mode: BackgroundEffectMode::None,
blur_intensity: 60,
custom_backgrounds: vec![
"Modern Office".to_string(),
"Minimalist Studio".to_string(),
"Warm Bookshelf".to_string(),
],
makeup_enabled: false,
makeup_intensity: 45,
filter_dropdown_open: true,
filter_mode: FilterMode::None,
enable_all_gestures: true,
use_gesture_palm: true,
use_gesture_v: true,
use_gesture_l: true,
use_gesture_rock: false,
use_gesture_ok: false,
audio_capture_mode: AudioCaptureMode::VoiceFocus,
audio_denoise: true,
horizontal_mirror: false,
vertical_mirror: false,
smart_adjustment: false,
horizontal_correction: false,
ultimate_privacy: false,
privacy_sleep: false,
vertical_resolution_mode: false,
forced_vertical_lock: false,
low_resolution_mode: false,
stream_initialized: false,
joystick_dragging: false,
last_ptz_cmd: (0, 0, 0, 0),
last_ptz_send_time: None,
nudge_stop_at: None,
pending_ptz_restore: None,
ready_auto_track: false,
last_track_poll: None,
last_hw_sync: None,
}
}
}
impl CameraAppState {
pub fn show_toast(&mut self, msg: impl Into<String>, is_error: bool) {
self.toast_message = Some((msg.into(), Instant::now(), is_error));
}
}
+934
View File
@@ -0,0 +1,934 @@
#![allow(dead_code)]
use egui::{
Align, Align2, Button, Color32, Context, FontId, Frame, Layout, Margin, Pos2, Rect, Response,
RichText, Rounding, Sense, Stroke, Style, Ui, Vec2, Visuals, WidgetInfo, WidgetType,
};
/// Official Insta360 Link Controller v2.0.2 theme palette (`InsUI.qml`).
#[derive(Debug, Clone, Copy)]
pub struct InsTheme {
pub is_light_mode: bool,
pub brand_green: Color32,
pub brand_green_lighter: Color32,
pub brand_light: Color32,
pub brand_text: Color32,
pub bg01: Color32,
pub bg02: Color32,
pub bg03: Color32,
pub bg04: Color32,
pub bg05: Color32,
pub border01: Color32,
pub border02: Color32,
pub border03: Color32,
pub text01: Color32,
pub text02: Color32,
pub text03: Color32,
pub text04: Color32,
pub overlay01: Color32,
pub overlay02: Color32,
pub overlay03: Color32,
pub mask01: Color32,
pub error: Color32,
pub success: Color32,
pub warning: Color32,
}
impl InsTheme {
pub fn new(is_light_mode: bool) -> Self {
if is_light_mode {
Self {
is_light_mode: true,
brand_green: Color32::from_rgb(18, 219, 139),
brand_green_lighter: Color32::from_rgb(81, 245, 179),
brand_light: Color32::from_rgb(240, 255, 249),
brand_text: Color32::from_rgb(0, 178, 107),
bg01: Color32::from_rgb(229, 229, 229),
bg02: Color32::from_rgb(240, 240, 240),
bg03: Color32::from_rgb(250, 250, 250),
bg04: Color32::from_rgb(255, 255, 255),
bg05: Color32::from_rgb(255, 255, 255),
border01: Color32::from_rgba_unmultiplied(0, 0, 0, 26),
border02: Color32::from_rgba_unmultiplied(0, 0, 0, 46),
border03: Color32::from_rgba_unmultiplied(0, 0, 0, 66),
text01: Color32::from_rgb(41, 41, 41),
text02: Color32::from_rgb(82, 82, 82),
text03: Color32::from_rgb(153, 153, 153),
text04: Color32::from_rgb(191, 191, 191),
overlay01: Color32::from_rgba_unmultiplied(255, 255, 255, 255),
overlay02: Color32::from_rgba_unmultiplied(255, 255, 255, 255),
overlay03: Color32::from_rgba_unmultiplied(255, 255, 255, 255),
mask01: Color32::from_rgba_unmultiplied(0, 0, 0, 153),
error: Color32::from_rgb(255, 64, 77),
success: Color32::from_rgb(0, 209, 112),
warning: Color32::from_rgb(245, 217, 37),
}
} else {
Self {
is_light_mode: false,
brand_green: Color32::from_rgb(42, 235, 158),
brand_green_lighter: Color32::from_rgb(26, 255, 163),
brand_light: Color32::from_rgb(47, 51, 49),
brand_text: Color32::from_rgb(15, 184, 116),
bg01: Color32::from_rgb(33, 33, 33),
bg02: Color32::from_rgb(43, 43, 43),
bg03: Color32::from_rgb(51, 51, 51),
bg04: Color32::from_rgb(56, 56, 56),
bg05: Color32::from_rgb(71, 71, 71),
border01: Color32::from_rgba_unmultiplied(255, 255, 255, 20),
border02: Color32::from_rgba_unmultiplied(255, 255, 255, 36),
border03: Color32::from_rgba_unmultiplied(255, 255, 255, 51),
text01: Color32::from_rgb(235, 235, 235),
text02: Color32::from_rgb(184, 184, 184),
text03: Color32::from_rgb(138, 138, 138),
text04: Color32::from_rgb(89, 89, 89),
overlay01: Color32::from_rgba_unmultiplied(216, 231, 245, 15),
overlay02: Color32::from_rgba_unmultiplied(235, 245, 255, 26),
overlay03: Color32::from_rgba_unmultiplied(235, 245, 255, 38),
mask01: Color32::from_rgba_unmultiplied(0, 0, 0, 153),
error: Color32::from_rgb(255, 84, 96),
success: Color32::from_rgb(0, 209, 49),
warning: Color32::from_rgb(249, 232, 124),
}
}
}
}
impl Default for InsTheme {
fn default() -> Self {
Self::new(false)
}
}
/// Configures `egui::Style` and `egui::Visuals` with the official Insta360 Link Controller palette
/// (`bg01`, `bg02`, `bg03`, `brand_green` accents) and returns the configured `InsTheme`.
pub fn apply_ins_theme(ctx: &Context, is_light_mode: bool) -> InsTheme {
let theme = InsTheme::new(is_light_mode);
let mut style: Style = (*ctx.style()).clone();
let mut visuals = if is_light_mode {
Visuals::light()
} else {
Visuals::dark()
};
visuals.panel_fill = theme.bg01;
visuals.window_fill = theme.bg02;
visuals.extreme_bg_color = theme.bg01;
visuals.faint_bg_color = theme.bg03;
visuals.selection.bg_fill = theme.brand_green;
visuals.selection.stroke = Stroke::new(
1.0_f32,
if is_light_mode {
Color32::WHITE
} else {
theme.bg01
},
);
visuals.hyperlink_color = theme.brand_green;
let r6 = Rounding::same(6.0_f32);
let r8 = Rounding::same(8.0_f32);
visuals.widgets.noninteractive.bg_fill = theme.bg02;
visuals.widgets.noninteractive.weak_bg_fill = theme.bg02;
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, theme.border01);
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, theme.text01);
visuals.widgets.noninteractive.rounding = r8;
visuals.widgets.inactive.bg_fill = theme.bg03;
visuals.widgets.inactive.weak_bg_fill = theme.bg03;
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, theme.border01);
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, theme.text02);
visuals.widgets.inactive.rounding = r6;
visuals.widgets.hovered.bg_fill = theme.bg05;
visuals.widgets.hovered.weak_bg_fill = theme.bg05;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, theme.brand_green);
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, theme.text01);
visuals.widgets.hovered.rounding = r6;
visuals.widgets.active.bg_fill = theme.brand_green;
visuals.widgets.active.weak_bg_fill = theme.brand_green;
visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, theme.brand_green_lighter);
visuals.widgets.active.fg_stroke = Stroke::new(1.0_f32, Color32::WHITE);
visuals.widgets.active.rounding = r6;
visuals.widgets.open.bg_fill = theme.bg04;
visuals.widgets.open.weak_bg_fill = theme.bg04;
visuals.widgets.open.bg_stroke = Stroke::new(1.0_f32, theme.brand_green);
visuals.widgets.open.fg_stroke = Stroke::new(1.0_f32, theme.text01);
visuals.widgets.open.rounding = r6;
style.spacing.item_spacing = Vec2::new(8.0_f32, 6.0_f32);
style.spacing.button_padding = Vec2::new(8.0_f32, 5.0_f32);
style.visuals = visuals;
ctx.set_style(style);
theme
}
/// Renders a rounded card container with `fill(theme.bg02)`, `rounding(8.0)`,
/// and `inner_margin(Margin::symmetric(16.0, 12.0))`.
pub fn ins_card<R>(
ui: &mut Ui,
theme: &InsTheme,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> R {
Frame::none()
.fill(theme.bg02)
.rounding(Rounding::same(8.0_f32))
.inner_margin(Margin::symmetric(16.0_f32, 12.0_f32))
.show(ui, add_contents)
.inner
}
/// Renders a 32px-tall header row with `title` on the left (14.0 strong `theme.text01`)
/// and an optional `↺` reset button on the right (returns `true` if clicked).
pub fn ins_section_header(
ui: &mut Ui,
title: &str,
theme: &InsTheme,
reset_tooltip: Option<&str>,
) -> bool {
let mut reset_clicked = false;
ui.allocate_ui_with_layout(
Vec2::new(ui.available_width(), 32.0_f32),
Layout::left_to_right(Align::Center),
|ui| {
ui.label(
RichText::new(title)
.size(14.0_f32)
.strong()
.color(theme.text01),
);
if let Some(tooltip) = reset_tooltip {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let btn = ui.add(
Button::new(RichText::new("↺").size(14.0_f32).color(theme.text03))
.frame(false),
);
if !tooltip.is_empty() {
btn.clone().on_hover_text(tooltip);
}
if btn.clicked() {
reset_clicked = true;
}
});
}
},
);
reset_clicked
}
/// Draws a 34.0 x 18.0 pill toggle switch (`theme.brand_green` when `*checked`,
/// `theme.bg05` when false, white circle thumb) and returns `true` if toggled.
pub fn ins_switch(ui: &mut Ui, checked: &mut bool, enabled: bool, theme: &InsTheme) -> bool {
let desired_size = Vec2::new(34.0_f32, 18.0_f32);
let (rect, mut response) = ui.allocate_exact_size(
desired_size,
if enabled {
Sense::click()
} else {
Sense::hover()
},
);
if enabled && response.clicked() {
*checked = !*checked;
response.mark_changed();
}
response.widget_info(|| {
WidgetInfo::selected(
WidgetType::Checkbox,
ui.is_enabled() && enabled,
*checked,
"",
)
});
if ui.is_rect_visible(rect) {
let how_on = ui.ctx().animate_bool(response.id, *checked);
let track_rounding = Rounding::same(rect.height() / 2.0_f32);
let track_color = if enabled {
if *checked {
theme.brand_green
} else {
theme.bg05
}
} else if *checked {
Color32::from_rgba_unmultiplied(
theme.brand_green.r(),
theme.brand_green.g(),
theme.brand_green.b(),
100,
)
} else {
theme.border01
};
ui.painter().rect_filled(rect, track_rounding, track_color);
let thumb_radius = (rect.height() - 4.0_f32) / 2.0_f32;
let min_x = rect.min.x + 2.0_f32 + thumb_radius;
let max_x = rect.max.x - 2.0_f32 - thumb_radius;
let thumb_x = egui::lerp(min_x..=max_x, how_on);
let thumb_center = Pos2::new(thumb_x, rect.center().y);
let thumb_color = if enabled {
Color32::WHITE
} else {
theme.text04
};
ui.painter()
.circle_filled(thumb_center, thumb_radius, thumb_color);
}
response.changed()
}
/// Full-width row (28px height) with `label` (13.0 `theme.text01`) + optional `?` hover tooltip
/// on the left, and `ins_switch` on the right. Returns `true` if toggled.
pub fn ins_switch_row(
ui: &mut Ui,
label: &str,
tooltip: Option<&str>,
checked: &mut bool,
enabled: bool,
theme: &InsTheme,
) -> bool {
let row_height = 28.0_f32;
let (row_rect, row_response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), row_height),
if enabled {
Sense::click()
} else {
Sense::hover()
},
);
let mut toggled = false;
let switch_width = 34.0_f32;
let switch_height = 18.0_f32;
let switch_rect = Rect::from_min_size(
Pos2::new(
row_rect.max.x - switch_width,
row_rect.center().y - switch_height / 2.0_f32,
),
Vec2::new(switch_width, switch_height),
);
let switch_id = row_response.id.with("switch");
let switch_response = ui.interact(
switch_rect,
switch_id,
if enabled {
Sense::click()
} else {
Sense::hover()
},
);
if enabled {
if switch_response.clicked() {
*checked = !*checked;
toggled = true;
} else if row_response.clicked() {
*checked = !*checked;
toggled = true;
}
}
if ui.is_rect_visible(row_rect) {
let text_color = if enabled {
theme.text01
} else {
theme.text04
};
let text_pos = Pos2::new(row_rect.min.x, row_rect.center().y);
let label_galley = ui.painter().layout_no_wrap(
label.to_string(),
FontId::proportional(13.0_f32),
text_color,
);
let label_size = label_galley.size();
ui.painter().galley(
Pos2::new(text_pos.x, text_pos.y - label_size.y / 2.0_f32),
label_galley,
text_color,
);
if let Some(tt) = tooltip {
let tip_x = text_pos.x + label_size.x + 6.0_f32;
let tip_rect = Rect::from_min_size(
Pos2::new(tip_x, text_pos.y - 8.0_f32),
Vec2::new(16.0_f32, 16.0_f32),
);
let tip_resp = ui.interact(tip_rect, row_response.id.with("tt"), Sense::hover());
let tip_color = if tip_resp.hovered() {
theme.text01
} else {
theme.text03
};
ui.painter().text(
tip_rect.center(),
Align2::CENTER_CENTER,
"?",
FontId::proportional(12.0_f32),
tip_color,
);
if !tt.is_empty() {
tip_resp.on_hover_text(tt);
}
}
// Render switch
let how_on = ui.ctx().animate_bool(switch_id, *checked);
let track_rounding = Rounding::same(switch_rect.height() / 2.0_f32);
let track_color = if enabled {
if *checked {
theme.brand_green
} else {
theme.bg05
}
} else if *checked {
Color32::from_rgba_unmultiplied(
theme.brand_green.r(),
theme.brand_green.g(),
theme.brand_green.b(),
100,
)
} else {
theme.border01
};
ui.painter()
.rect_filled(switch_rect, track_rounding, track_color);
let thumb_radius = (switch_rect.height() - 4.0_f32) / 2.0_f32;
let min_x = switch_rect.min.x + 2.0_f32 + thumb_radius;
let max_x = switch_rect.max.x - 2.0_f32 - thumb_radius;
let thumb_x = egui::lerp(min_x..=max_x, how_on);
let thumb_center = Pos2::new(thumb_x, switch_rect.center().y);
let thumb_color = if enabled {
Color32::WHITE
} else {
theme.text04
};
ui.painter()
.circle_filled(thumb_center, thumb_radius, thumb_color);
}
toggled
}
/// Full-width row (28px height) with optional `icon_prefix` + `label` on the left
/// and a custom 16x16 rounded checkbox (`theme.brand_green` fill + checkmark when `*checked`)
/// on the right. Returns `true` if toggled.
pub fn ins_checkbox_row(
ui: &mut Ui,
label: &str,
icon_prefix: Option<&str>,
tooltip: Option<&str>,
checked: &mut bool,
enabled: bool,
theme: &InsTheme,
) -> bool {
let row_height = 28.0_f32;
let (row_rect, row_response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), row_height),
if enabled {
Sense::click()
} else {
Sense::hover()
},
);
let mut toggled = false;
let box_size = 16.0_f32;
let box_rect = Rect::from_min_size(
Pos2::new(
row_rect.max.x - box_size,
row_rect.center().y - box_size / 2.0_f32,
),
Vec2::new(box_size, box_size),
);
let box_id = row_response.id.with("cb");
let box_response = ui.interact(
box_rect,
box_id,
if enabled {
Sense::click()
} else {
Sense::hover()
},
);
if enabled && (box_response.clicked() || row_response.clicked()) {
*checked = !*checked;
toggled = true;
}
if ui.is_rect_visible(row_rect) {
let text_color = if enabled {
theme.text01
} else {
theme.text04
};
let mut cur_x = row_rect.min.x;
if let Some(prefix) = icon_prefix {
let prefix_galley = ui.painter().layout_no_wrap(
prefix.to_string(),
FontId::proportional(14.0_f32),
theme.text03,
);
let p_size = prefix_galley.size();
ui.painter().galley(
Pos2::new(cur_x, row_rect.center().y - p_size.y / 2.0_f32),
prefix_galley,
theme.text03,
);
cur_x += p_size.x + 6.0_f32;
}
let label_galley = ui.painter().layout_no_wrap(
label.to_string(),
FontId::proportional(13.0_f32),
text_color,
);
let label_size = label_galley.size();
ui.painter().galley(
Pos2::new(cur_x, row_rect.center().y - label_size.y / 2.0_f32),
label_galley,
text_color,
);
cur_x += label_size.x + 6.0_f32;
if let Some(tt) = tooltip {
let tip_rect = Rect::from_min_size(
Pos2::new(cur_x, row_rect.center().y - 8.0_f32),
Vec2::new(16.0_f32, 16.0_f32),
);
let tip_resp = ui.interact(tip_rect, row_response.id.with("cb_tt"), Sense::hover());
let tip_color = if tip_resp.hovered() {
theme.text01
} else {
theme.text03
};
ui.painter().text(
tip_rect.center(),
Align2::CENTER_CENTER,
"?",
FontId::proportional(12.0_f32),
tip_color,
);
if !tt.is_empty() {
tip_resp.on_hover_text(tt);
}
}
// Draw checkbox indicator
let r3 = Rounding::same(3.0_f32);
if *checked {
let fill = if enabled {
theme.brand_green
} else {
Color32::from_rgba_unmultiplied(
theme.brand_green.r(),
theme.brand_green.g(),
theme.brand_green.b(),
120,
)
};
ui.painter().rect_filled(box_rect, r3, fill);
// Dark checkmark
let check_color = if theme.is_light_mode {
Color32::WHITE
} else {
Color32::from_rgb(20, 20, 20)
};
let stroke = Stroke::new(1.8_f32, check_color);
let p1 = Pos2::new(box_rect.min.x + 3.5_f32, box_rect.min.y + 8.0_f32);
let p2 = Pos2::new(box_rect.min.x + 6.5_f32, box_rect.min.y + 11.5_f32);
let p3 = Pos2::new(box_rect.min.x + 12.5_f32, box_rect.min.y + 4.5_f32);
ui.painter().line_segment([p1, p2], stroke);
ui.painter().line_segment([p2, p3], stroke);
} else {
let bg = if enabled && box_response.hovered() {
theme.bg04
} else {
theme.bg03
};
let stroke_color = if enabled && box_response.hovered() {
theme.brand_green
} else {
theme.border02
};
ui.painter().rect_filled(box_rect, r3, bg);
ui.painter()
.rect_stroke(box_rect, r3, Stroke::new(1.0_f32, stroke_color));
}
}
toggled
}
/// Matches `DefSlider.qml`: top row has `label` on the left (if `Some`) and current value + `unit`
/// on the right; bottom row has a custom full-width slider track (`theme.brand_green` left of thumb,
/// `theme.bg05` right of thumb). Returns `true` if changed.
pub fn ins_slider_i32(
ui: &mut Ui,
label: Option<&str>,
value: &mut i32,
min: i32,
max: i32,
unit: &str,
enabled: bool,
theme: &InsTheme,
) -> bool {
let mut changed = false;
let initial_val = *value;
ui.vertical(|ui| {
// Top row
ui.horizontal(|ui| {
if let Some(lbl) = label {
ui.label(
RichText::new(lbl).size(13.0_f32).color(if enabled {
theme.text02
} else {
theme.text04
}),
);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let val_str = format!("{}{}", *value, unit);
ui.label(
RichText::new(val_str).size(12.0_f32).color(if enabled {
theme.text01
} else {
theme.text04
}),
);
});
});
// Bottom track
let track_height = 16.0_f32;
let (track_rect, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), track_height),
if enabled {
Sense::click_and_drag()
} else {
Sense::hover()
},
);
let thumb_radius = 6.0_f32;
let min_x = track_rect.min.x + thumb_radius;
let max_x = track_rect.max.x - thumb_radius;
let range = (max - min).max(1) as f32;
if enabled && (response.clicked() || response.dragged()) {
if let Some(pos) = response.interact_pointer_pos() {
let norm = ((pos.x - min_x) / (max_x - min_x).max(1.0_f32)).clamp(0.0_f32, 1.0_f32);
let new_val = min + (norm * range).round() as i32;
let clamped_val = new_val.clamp(min, max);
if clamped_val != *value {
*value = clamped_val;
changed = true;
}
}
}
if ui.is_rect_visible(track_rect) {
let norm = ((*value - min) as f32 / range).clamp(0.0_f32, 1.0_f32);
let thumb_x = min_x + norm * (max_x - min_x);
let bar_y = track_rect.center().y;
let bar_h = 3.0_f32;
let bar_rounding = Rounding::same(1.5_f32);
// Left track (brand_green)
let left_rect = Rect::from_min_max(
Pos2::new(track_rect.min.x, bar_y - bar_h / 2.0_f32),
Pos2::new(thumb_x, bar_y + bar_h / 2.0_f32),
);
ui.painter().rect_filled(
left_rect,
bar_rounding,
if enabled {
theme.brand_green
} else {
theme.border01
},
);
// Right track (bg05)
let right_rect = Rect::from_min_max(
Pos2::new(thumb_x, bar_y - bar_h / 2.0_f32),
Pos2::new(track_rect.max.x, bar_y + bar_h / 2.0_f32),
);
ui.painter().rect_filled(right_rect, bar_rounding, theme.bg05);
// Thumb
let thumb_center = Pos2::new(thumb_x, bar_y);
let thumb_fill = if enabled {
Color32::WHITE
} else {
theme.text04
};
ui.painter()
.circle_filled(thumb_center, thumb_radius, thumb_fill);
ui.painter()
.circle_stroke(thumb_center, thumb_radius, Stroke::new(1.0_f32, theme.border03));
}
});
changed || *value != initial_val
}
/// Matches `DefSlider.qml` for `f32`: top row has `label` and formatted value + `unit`;
/// bottom row has custom track. Returns `true` if changed.
pub fn ins_slider_f32(
ui: &mut Ui,
label: Option<&str>,
value: &mut f32,
min: f32,
max: f32,
step: f32,
decimals: usize,
unit: &str,
enabled: bool,
theme: &InsTheme,
) -> bool {
let mut changed = false;
let initial_val = *value;
ui.vertical(|ui| {
// Top row
ui.horizontal(|ui| {
if let Some(lbl) = label {
ui.label(
RichText::new(lbl).size(13.0_f32).color(if enabled {
theme.text02
} else {
theme.text04
}),
);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let val_str = format!("{:.decimals$}{}", *value, unit);
ui.label(
RichText::new(val_str).size(12.0_f32).color(if enabled {
theme.text01
} else {
theme.text04
}),
);
});
});
// Bottom track
let track_height = 16.0_f32;
let (track_rect, response) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), track_height),
if enabled {
Sense::click_and_drag()
} else {
Sense::hover()
},
);
let thumb_radius = 6.0_f32;
let min_x = track_rect.min.x + thumb_radius;
let max_x = track_rect.max.x - thumb_radius;
let range = (max - min).max(0.0001_f32);
if enabled && (response.clicked() || response.dragged()) {
if let Some(pos) = response.interact_pointer_pos() {
let norm = ((pos.x - min_x) / (max_x - min_x).max(1.0_f32)).clamp(0.0_f32, 1.0_f32);
let mut new_val = min + norm * range;
if step > 0.0_f32 {
new_val = (new_val / step).round() * step;
}
let clamped_val = new_val.clamp(min, max);
if (clamped_val - *value).abs() > 1e-6_f32 {
*value = clamped_val;
changed = true;
}
}
}
if ui.is_rect_visible(track_rect) {
let norm = ((*value - min) / range).clamp(0.0_f32, 1.0_f32);
let thumb_x = min_x + norm * (max_x - min_x);
let bar_y = track_rect.center().y;
let bar_h = 3.0_f32;
let bar_rounding = Rounding::same(1.5_f32);
// Left track (brand_green)
let left_rect = Rect::from_min_max(
Pos2::new(track_rect.min.x, bar_y - bar_h / 2.0_f32),
Pos2::new(thumb_x, bar_y + bar_h / 2.0_f32),
);
ui.painter().rect_filled(
left_rect,
bar_rounding,
if enabled {
theme.brand_green
} else {
theme.border01
},
);
// Right track (bg05)
let right_rect = Rect::from_min_max(
Pos2::new(thumb_x, bar_y - bar_h / 2.0_f32),
Pos2::new(track_rect.max.x, bar_y + bar_h / 2.0_f32),
);
ui.painter().rect_filled(right_rect, bar_rounding, theme.bg05);
// Thumb
let thumb_center = Pos2::new(thumb_x, bar_y);
let thumb_fill = if enabled {
Color32::WHITE
} else {
theme.text04
};
ui.painter()
.circle_filled(thumb_center, thumb_radius, thumb_fill);
ui.painter()
.circle_stroke(thumb_center, thumb_radius, Stroke::new(1.0_f32, theme.border03));
}
});
changed || (*value - initial_val).abs() > 1e-6_f32
}
/// Renders a tab button (`width x 32.0`, `rounding(6.0)`, `fill(if active { theme.bg03 } else { Color32::TRANSPARENT })`,
/// text color `if active { theme.brand_green } else { theme.text02 }`). Returns `true` if clicked.
pub fn ins_tab_button(
ui: &mut Ui,
label: &str,
active: bool,
width: f32,
theme: &InsTheme,
) -> bool {
let size = Vec2::new(width, 32.0_f32);
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
if ui.is_rect_visible(rect) {
let fill = if active {
theme.bg03
} else if response.hovered() {
theme.bg04
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(rect, Rounding::same(6.0_f32), fill);
let text_color = if active {
theme.brand_green
} else if response.hovered() {
theme.text01
} else {
theme.text02
};
ui.painter().text(
rect.center(),
Align2::CENTER_CENTER,
label,
FontId::proportional(13.0_f32),
text_color,
);
}
response.clicked()
}
/// Renders a 74.0 x 52.0 mode button for `BottomControl.qml` with `icon` (18px)
/// centered above `label` (11.5px), colored `theme.brand_green` when `active` or
/// `theme.text01` when inactive.
pub fn ins_bottom_mode_button(
ui: &mut Ui,
icon: &str,
label: &str,
active: bool,
enabled: bool,
theme: &InsTheme,
) -> Response {
let size = Vec2::new(74.0_f32, 52.0_f32);
let (rect, response) = ui.allocate_exact_size(
size,
if enabled {
Sense::click()
} else {
Sense::hover()
},
);
if ui.is_rect_visible(rect) {
let r6 = Rounding::same(6.0_f32);
let bg_fill = if active {
theme.brand_light
} else if enabled && response.hovered() {
theme.bg03
} else {
Color32::TRANSPARENT
};
ui.painter().rect_filled(rect, r6, bg_fill);
if active {
ui.painter()
.rect_stroke(rect, r6, Stroke::new(1.0_f32, theme.brand_green));
}
let main_color = if !enabled {
theme.text04
} else if active {
theme.brand_green
} else {
theme.text01
};
let label_color = if !enabled {
theme.text04
} else if active {
theme.brand_green
} else {
theme.text02
};
// Icon at top
let icon_pos = Pos2::new(rect.center().x, rect.min.y + 18.0_f32);
ui.painter().text(
icon_pos,
Align2::CENTER_CENTER,
icon,
FontId::proportional(18.0_f32),
main_color,
);
// Label below
let label_pos = Pos2::new(rect.center().x, rect.min.y + 38.0_f32);
ui.painter().text(
label_pos,
Align2::CENTER_CENTER,
label,
FontId::proportional(11.5_f32),
label_color,
);
}
response
}
+180
View File
@@ -0,0 +1,180 @@
#![allow(dead_code)]
use insta360_link_protocol::{
ct_selectors, pu_selectors, xu1_selectors, xu2_selectors, CameraModel, DiscoveredCamera,
MockCameraTransport, NusbTransport, TransportError, UvcControlPacketHeader,
UvcControlTransport, UvcRequest, UvcUnitIds,
};
/// Unified GUI transport wrapper allowing runtime switching between physical USB cameras
/// (`NusbTransport`) and stateful in-memory camera simulators (`MockCameraTransport`).
pub enum GuiTransport {
Usb(NusbTransport),
Mock(MockCameraTransport),
}
impl GuiTransport {
pub fn is_mock(&self) -> bool {
matches!(self, Self::Mock(_))
}
pub fn command_log(&self) -> Option<Vec<(UvcRequest, u8, u8, Vec<u8>)>> {
match self {
Self::Usb(_) => None,
Self::Mock(mock) => Some(mock.command_log()),
}
}
}
impl UvcControlTransport for GuiTransport {
fn camera_info(&self) -> &DiscoveredCamera {
match self {
Self::Usb(usb) => usb.camera_info(),
Self::Mock(mock) => mock.camera_info(),
}
}
fn set_cur(&self, unit_id: u8, selector: u8, data: &[u8]) -> Result<(), TransportError> {
match self {
Self::Usb(usb) => usb.set_cur(unit_id, selector, data),
Self::Mock(mock) => mock.set_cur(unit_id, selector, data),
}
}
fn get_req(
&self,
req: UvcRequest,
unit_id: u8,
selector: u8,
len: usize,
) -> Result<Vec<u8>, TransportError> {
match self {
Self::Usb(usb) => usb.get_req(req, unit_id, selector, len),
Self::Mock(mock) => mock.get_req(req, unit_id, selector, len),
}
}
}
/// Target device selection in the GUI header dropdown.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeviceSelection {
Usb(DiscoveredCamera),
Simulate(CameraModel),
}
impl DeviceSelection {
pub fn label(&self) -> String {
match self {
Self::Usb(cam) => format!(
"USB: {} (Bus {} Addr {} • 0x{:04x})",
cam.name, cam.bus_number, cam.device_address, cam.product_id
),
Self::Simulate(model) => format!(
"Simulate: {} (0x{:04x})",
model.display_name(),
model.pid()
),
}
}
}
/// Formats a human-readable UVC unit name for the protocol inspector.
pub fn describe_unit(unit_id: u8, ids: &UvcUnitIds) -> &'static str {
if unit_id == ids.input_terminal_id {
"IT (Camera Terminal)"
} else if unit_id == ids.processing_unit_id {
"PU (Processing Unit)"
} else if unit_id == ids.extension_unit_id {
"XU1 (Insta360 Ext 1)"
} else if unit_id == ids.extension_unit2_id {
"XU2 (Insta360 Ext 2)"
} else {
"Unknown Unit"
}
}
/// Formats a human-readable UVC control selector symbol for the protocol inspector.
pub fn describe_selector(unit_id: u8, selector: u8, ids: &UvcUnitIds) -> &'static str {
if unit_id == ids.input_terminal_id {
match selector {
ct_selectors::AE_MODE => "CT_AE_MODE_CONTROL",
ct_selectors::EXPOSURE_TIME_ABSOLUTE => "CT_EXPOSURE_TIME_ABSOLUTE",
ct_selectors::FOCUS_ABSOLUTE => "CT_FOCUS_ABSOLUTE",
ct_selectors::FOCUS_AUTO => "CT_FOCUS_AUTO",
ct_selectors::ZOOM_ABSOLUTE => "CT_ZOOM_ABSOLUTE",
ct_selectors::PANTILT_ABSOLUTE => "CT_PANTILT_ABSOLUTE",
ct_selectors::PANTILT_RELATIVE => "CT_PANTILT_RELATIVE",
ct_selectors::ROLL_ABSOLUTE => "CT_ROLL_ABSOLUTE",
_ => "CT_UNKNOWN",
}
} else if unit_id == ids.processing_unit_id {
match selector {
pu_selectors::BRIGHTNESS => "PU_BRIGHTNESS_CONTROL",
pu_selectors::CONTRAST => "PU_CONTRAST_CONTROL",
pu_selectors::POWER_LINE_FREQUENCY => "PU_POWER_LINE_FREQUENCY",
pu_selectors::SATURATION => "PU_SATURATION_CONTROL",
pu_selectors::SHARPNESS => "PU_SHARPNESS_CONTROL",
pu_selectors::WHITE_BALANCE_TEMPERATURE => "PU_WB_TEMPERATURE",
pu_selectors::WHITE_BALANCE_TEMPERATURE_AUTO => "PU_WB_TEMPERATURE_AUTO",
_ => "PU_UNKNOWN",
}
} else if unit_id == ids.extension_unit_id {
match selector {
xu1_selectors::SYSTEM_AND_PRESET_CMD => "XU1_SYSTEM_AND_PRESET_CMD",
xu1_selectors::VIDEO_MODE => "XU1_VIDEO_MODE",
xu1_selectors::TRACKING_AND_GESTURE => "XU1_TRACKING_AND_GESTURE",
xu1_selectors::AUDIO_CONTROL => "XU1_AUDIO_CONTROL",
xu1_selectors::TRACK_OBJECT_SELECT => "XU1_TRACK_OBJECT_SELECT",
xu1_selectors::TRACK_OBJECT_LIST_V1 => "XU1_TRACK_OBJECT_LIST_V1",
xu1_selectors::EXPOSURE_CURVE_AND_ISO => "XU1_EXPOSURE_CURVE_AND_ISO",
xu1_selectors::EXPOSURE_COMPENSATION => "XU1_EXPOSURE_COMPENSATION",
xu1_selectors::WHITE_BALANCE_EXT => "XU1_WHITE_BALANCE_EXT",
xu1_selectors::EXTEND_FUNC_BITMASK => "XU1_EXTEND_FUNC_BITMASK",
xu1_selectors::PANTILT_RELATIVE_EXT => "XU1_PANTILT_RELATIVE_EXT",
xu1_selectors::DEVICE_STATUS => "XU1_DEVICE_STATUS",
xu1_selectors::PTZ_VERSION_AND_ABS => "XU1_PTZ_VERSION_AND_ABS",
xu1_selectors::PRESET_POSITION_FLAGS => "XU1_PRESET_POSITION_FLAGS",
xu1_selectors::USB_MODE_SWITCH => "XU1_USB_MODE_SWITCH",
_ => "XU1_UNKNOWN",
}
} else if unit_id == ids.extension_unit2_id {
match selector {
xu2_selectors::GESTURE_BINDING_MAP => "XU2_GESTURE_BINDING_MAP",
xu2_selectors::SYSTEM_TIME_AND_IQ => "XU2_SYSTEM_TIME_AND_IQ",
xu2_selectors::DEVICE_INFO => "XU2_DEVICE_INFO",
xu2_selectors::IMAGE_TEMPLATE_INFO => "XU2_IMAGE_TEMPLATE_INFO",
xu2_selectors::IMAGE_TEMPLATE_NAME => "XU2_IMAGE_TEMPLATE_NAME",
xu2_selectors::FILTER_MODE => "XU2_FILTER_MODE",
xu2_selectors::PRIVACY_AND_FILL_LIGHT => "XU2_PRIVACY_AND_FILL_LIGHT",
xu2_selectors::HOST_PTZ => "XU2_HOST_PTZ",
xu2_selectors::TRACK_OBJECT_LIST_V2 => "XU2_TRACK_OBJECT_LIST_V2",
xu2_selectors::TRACK_FORBIDDEN_AREA => "XU2_TRACK_FORBIDDEN_AREA",
xu2_selectors::DEFAULT_TRACK_MODE => "XU2_DEFAULT_TRACK_MODE",
xu2_selectors::HORIZONTAL_CORRECTION => "XU2_HORIZONTAL_CORRECTION",
xu2_selectors::REAL_ZOOM_AND_UDISK => "XU2_REAL_ZOOM_AND_UDISK",
xu2_selectors::REPORT_DATA => "XU2_REPORT_DATA",
_ => "XU2_UNKNOWN",
}
} else {
"UNKNOWN_SELECTOR"
}
}
/// Formats a byte slice as space-separated lowercase hex.
pub fn format_hex(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{:02x}", b))
.collect::<Vec<_>>()
.join(" ")
}
/// Computes the UVC setup packet header for display in the Protocol Inspector.
pub fn packet_header(
req: UvcRequest,
unit_id: u8,
selector: u8,
ids: &UvcUnitIds,
) -> UvcControlPacketHeader {
UvcControlPacketHeader::new(req, selector, unit_id, ids.control_interface)
}
File diff suppressed because it is too large. Load diff