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,4 @@
|
|||||||
|
/target/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.swp
|
||||||
Generated
+4793
File diff suppressed because it is too large.
Load diff
+30
@@ -0,0 +1,30 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/insta360-link-protocol",
|
||||||
|
"crates/insta360-link-cli",
|
||||||
|
"crates/insta360-link-gui",
|
||||||
|
"crates/insta360-link-server",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
authors = ["Insta360 Link Open Controller Contributors"]
|
||||||
|
repository = "https://github.com/example/insta360-link-controller"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
thiserror = "2.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
bitflags = { version = "2.6", features = ["serde"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
nusb = "0.1"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
eframe = "0.29"
|
||||||
|
egui = "0.29"
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Insta360 Link Open Controller
|
||||||
|
|
||||||
|
A pure-Rust, cross-platform (**Linux**, **Windows**, **macOS**) hardware controller suite for the **Insta360 Link**, **Insta360 Link 2**, **Insta360 Link 2C**, and **Insta360 Link 2 Pro (PUC3)** 4K AI webcams.
|
||||||
|
|
||||||
|
Built by reverse-engineering the proprietary USB & UVC Extension Unit (`XU1` and `XU2`) protocol from the official Insta360 Link Controller desktop binary, this workspace provides full hardware control without proprietary drivers, telemetry, or OS lock-in—including first-class **Linux** support.
|
||||||
|
|
||||||
|
📄 **Protocol Specification**: See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the complete byte-level UVC Extension Unit documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workspace Crates
|
||||||
|
|
||||||
|
| Crate / Binary | Description |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **`insta360-link-protocol`** | Core Rust library implementing USB device discovery, UVC VideoControl descriptor parsing, standard UVC `CT_*`/`PU_*` controls, proprietary `XU1`/`XU2` codecs, `NusbTransport` (pure-Rust USB via `nusb`), and a stateful `MockCameraTransport` simulator. |
|
||||||
|
| **`insta360-link-ctl`** (`insta360-link-cli`) | Command-line tool for scripting PTZ movement, presets, video modes (`Normal`, `AutoTracking`, `WhiteBoard`, `OverheadShot`, `DeskView`, `AutoFraming`, `SmartWhiteBoard`), exposure/ISO, white balance, audio beamforming modes, filters, and extended hardware switches. |
|
||||||
|
| **`insta360-link-server`** | Asynchronous HTTP REST & WebSocket daemon (`axum` + `tokio`) for remote control, Stream Deck / OBS integration, and real-time state synchronization. |
|
||||||
|
| **`insta360-link-gui`** | Native cross-platform desktop GUI (`egui` / `eframe`) featuring a virtual gimbal PTZ joystick, preset slots, AI mode switcher, image adjustment sliders, audio & filter controls, and built-in simulated camera mode. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Hardware
|
||||||
|
|
||||||
|
All cameras under Arashi Vision USB Vendor ID `0x2e1a`:
|
||||||
|
|
||||||
|
| Model | USB `idVendor:idProduct` | Physical Gimbal | Supported Features |
|
||||||
|
| :--- | :--- | :---: | :--- |
|
||||||
|
| **Insta360 Link** (1st Gen) | `2e1a:4c01` | 3-Axis | PTZ, Roll, AI Tracking, Whiteboard, Overhead, DeskView, Gestures, HDR, Exposure Curve |
|
||||||
|
| **Insta360 Link 2** | `2e1a:4c04` | 2-Axis | PTZ, AI Tracking, Auto Framing, Smart Whiteboard, DeskView, Overhead, Audio Modes, Filters, Templates, Horizon Leveling, Forbidden Areas |
|
||||||
|
| **Insta360 Link 2C** | `2e1a:4c03` | Fixed (Digital Host PTZ) | Digital Host PTZ, Auto Framing, Smart Whiteboard, DeskView, Audio Modes, Filters, Templates, Privacy Mode |
|
||||||
|
| **Insta360 Link 2 Pro (PUC3)** | `2e1a:4c02` / `4c05` / `4c06` | 2-Axis / Fixed | Full Link 2 / 2C feature set + Sound Wall (`AudioCaptureMode::SoundWall`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
Requires Rust `1.78+` (no C/C++ `libusb` system dependency required—uses pure-Rust [`nusb`](https://docs.rs/nusb)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiled binaries will be placed in `target/release/`:
|
||||||
|
- `target/release/insta360-link-ctl`
|
||||||
|
- `target/release/insta360-link-server`
|
||||||
|
- `target/release/insta360-link-gui`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform Setup Instructions
|
||||||
|
|
||||||
|
### Linux (`udev` Rules)
|
||||||
|
|
||||||
|
On Linux, `nusb` communicates directly with the kernel `usbfs` device node (`/dev/bus/usb/BBB/DDD`) and detaches/claims only the UVC VideoControl interface while leaving the V4L2 video streaming interface (`/dev/video*`) untouched for OBS, Zoom, or browsers.
|
||||||
|
|
||||||
|
To run the CLI, server, and GUI without `sudo`, install the bundled `udev` rules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger
|
||||||
|
```
|
||||||
|
|
||||||
|
Unplug and reconnect your Insta360 Link camera if it is already connected.
|
||||||
|
|
||||||
|
### Windows (`WinUSB` / Zadig Note)
|
||||||
|
|
||||||
|
On Windows, the standard `usbvideo.sys` UVC driver claims the composite video interfaces. To allow direct USB control transfers via `nusb`:
|
||||||
|
- Use [Zadig](https://zadig.akeo.ie/) (Options → **List All Devices**, uncheck **Ignore Hubs or Composite Parents**) to bind the camera's **UVC VideoControl interface (Interface 0)** to the **WinUSB** driver while leaving the VideoStreaming interface on `usbvideo.sys`, **or**
|
||||||
|
- Run with `--mock` to test the CLI, server, and GUI using the built-in hardware simulator.
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
On macOS, `nusb` uses `IOKit` to send UVC class-specific control requests directly to Endpoint 0 without detaching the macOS `UVCAssistant` / `AVFoundation` camera stream. No kernel extensions or extra permissions are required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### 1. Command-Line Interface (`insta360-link-ctl`)
|
||||||
|
|
||||||
|
> **Tip**: Pass `--mock` (and optionally `--mock-model link2`, `link2c`, or `link`) to test any command against the simulated camera without physical hardware attached.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List connected Insta360 Link cameras and discovered UVC unit IDs
|
||||||
|
cargo run -p insta360-link-cli -- list
|
||||||
|
|
||||||
|
# Show complete camera status, firmware version, PTZ angles, and active features
|
||||||
|
cargo run -p insta360-link-cli -- status
|
||||||
|
|
||||||
|
# Move gimbal to absolute pan/tilt angles (in degrees) and set zoom (100 = 1.0x, 200 = 2.0x)
|
||||||
|
cargo run -p insta360-link-cli -- ptz --pan 15.0 --tilt -10.0 --zoom 150
|
||||||
|
|
||||||
|
# Center / home the gimbal
|
||||||
|
cargo run -p insta360-link-cli -- home
|
||||||
|
|
||||||
|
# Save and recall gimbal preset slots (0..5)
|
||||||
|
cargo run -p insta360-link-cli -- preset save 0
|
||||||
|
cargo run -p insta360-link-cli -- preset recall 0
|
||||||
|
|
||||||
|
# Switch AI Video Mode (normal, tracking, whiteboard, overhead, deskview, auto-framing, smart-whiteboard)
|
||||||
|
cargo run -p insta360-link-cli -- mode tracking
|
||||||
|
cargo run -p insta360-link-cli -- tracking --speed fast --composition half-body
|
||||||
|
|
||||||
|
# Configure image settings (brightness, contrast, saturation, sharpness, white balance, HDR)
|
||||||
|
cargo run -p insta360-link-cli -- image --brightness 60 --contrast 55 --hdr on --wb-kelvin 5600
|
||||||
|
|
||||||
|
# Configure microphone beamforming mode (voice-focus, noise-suppression, music-balance, original, sound-wall)
|
||||||
|
cargo run -p insta360-link-cli -- audio --mode voice-focus --denoise on
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. HTTP & WebSocket Server (`insta360-link-server`)
|
||||||
|
|
||||||
|
Start the daemon on `127.0.0.1:8360` (use `--mock` if no physical camera is connected):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p insta360-link-server -- --bind 127.0.0.1:8360 --mock
|
||||||
|
```
|
||||||
|
|
||||||
|
Example REST API calls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List discovered cameras
|
||||||
|
curl http://127.0.0.1:8360/api/cameras
|
||||||
|
|
||||||
|
# Fetch full camera state snapshot
|
||||||
|
curl http://127.0.0.1:8360/api/state
|
||||||
|
|
||||||
|
# Set PTZ pan, tilt, or zoom
|
||||||
|
curl -X POST http://127.0.0.1:8360/api/ptz \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"pan_deg": 20.0, "tilt_deg": -5.0, "zoom": 180}'
|
||||||
|
|
||||||
|
# Switch video mode
|
||||||
|
curl -X POST http://127.0.0.1:8360/api/mode \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"mode": "DeskView"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect to `ws://127.0.0.1:8360/ws` over WebSockets to receive real-time JSON state broadcasts and send low-latency PTZ/control commands.
|
||||||
|
|
||||||
|
### 3. Desktop GUI (`insta360-link-gui`)
|
||||||
|
|
||||||
|
Launch the native cross-platform GUI (automatically connects to a physical Insta360 Link camera if found, or falls back to the interactive simulator):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p insta360-link-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap / TODO
|
||||||
|
|
||||||
|
- [ ] **PTZ Gimbal Presets**: Save, recall, rename, and delete up to 6 hardware gimbal preset positions (`XU1 0x0e` / `0x11` & `0x1a`) with live thumbnail preview cards in the right sidebar.
|
||||||
|
- [ ] **Privacy Mode / Sleep Mode**: Hardware privacy sleep toggle (`XU2 0x0f`) and Mute in Privacy Mode (`XU1 0x1b` `UltimatePrivacyMode` bit 11) with gimbal-down standby synchronization.
|
||||||
|
- [ ] **Smart Composition**: AI Zoom (`XU1 0x1b` bit 0) and Head / Half Body / Whole Body / Custom composition framing (`XU1 0x13` & `0x18`) during AI Tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Protocol Documentation
|
||||||
|
|
||||||
|
For full reverse-engineering details—including disassembly addresses from `Webcam-desktop` v2.0.2, mixed-endian UVC Extension Unit GUIDs, `XU1` and `XU2` selector tables, `ExtendFunction` bitmasks, and the USB firmware upgrade state machine—see **[`docs/PROTOCOL.md`](docs/PROTOCOL.md)**.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under either of **MIT** or **Apache-2.0** at your option.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "insta360-link-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Cross-platform CLI controller for Insta360 Link, Link 2, and Link 2C cameras"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "insta360-link-ctl"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
insta360-link-protocol = { path = "../insta360-link-protocol" }
|
||||||
|
clap.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "insta360-link-protocol"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Pure-Rust cross-platform UVC & Extension Unit protocol library for Insta360 Link, Link 2, and Link 2C webcams"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
bitflags.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
nusb.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
use insta360_link_protocol::*;
|
||||||
|
|
||||||
|
/// Builds a synthetic USB Configuration Descriptor containing:
|
||||||
|
/// - Standard Interface Descriptor for UVC VideoControl (`bInterfaceNumber = 2`, `CC_VIDEO = 0x0e`, `SC_VIDEOCONTROL = 0x01`)
|
||||||
|
/// - `VC_INPUT_TERMINAL` (`bDescriptorSubtype = 0x02`, `bTerminalID = 3`, `wTerminalType = 0x0201` `ITT_CAMERA`)
|
||||||
|
/// - `VC_PROCESSING_UNIT` (`bDescriptorSubtype = 0x05`, `bUnitID = 5`)
|
||||||
|
/// - `VC_EXTENSION_UNIT` (`bDescriptorSubtype = 0x06`, `bUnitID = 11`) with `XU1_GUID_BYTES`
|
||||||
|
/// - `VC_EXTENSION_UNIT` (`bDescriptorSubtype = 0x06`, `bUnitID = 12`) with `XU2_GUID_BYTES`
|
||||||
|
/// - Standard Interface Descriptor for UVC VideoStreaming (`bInterfaceNumber = 3`, `CC_VIDEO = 0x0e`, `SC_VIDEOSTREAMING = 0x02`)
|
||||||
|
/// followed by a dummy `CS_INTERFACE` descriptor that should be ignored by `parse_uvc_vc_descriptors`.
|
||||||
|
#[test]
|
||||||
|
fn test_parse_uvc_vc_descriptors_synthetic() {
|
||||||
|
let mut desc = Vec::new();
|
||||||
|
|
||||||
|
// 1. Standard Interface Descriptor (USB_DT_INTERFACE = 0x04): bInterfaceNumber = 2, CC_VIDEO (0x0e), SC_VIDEOCONTROL (0x01)
|
||||||
|
desc.extend_from_slice(&[
|
||||||
|
9, // bLength
|
||||||
|
0x04, // bDescriptorType = USB_DT_INTERFACE
|
||||||
|
2, // bInterfaceNumber = 2
|
||||||
|
0, // bAlternateSetting
|
||||||
|
1, // bNumEndpoints
|
||||||
|
0x0e, // bInterfaceClass = CC_VIDEO
|
||||||
|
0x01, // bInterfaceSubClass = SC_VIDEOCONTROL
|
||||||
|
0x00, // bInterfaceProtocol
|
||||||
|
0, // iInterface
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. VC_INPUT_TERMINAL (CS_INTERFACE = 0x24, subtype = 0x02, bTerminalID = 3, wTerminalType = 0x0201 ITT_CAMERA)
|
||||||
|
desc.extend_from_slice(&[
|
||||||
|
18, // bLength
|
||||||
|
0x24, // bDescriptorType = CS_INTERFACE
|
||||||
|
0x02, // bDescriptorSubtype = VC_INPUT_TERMINAL
|
||||||
|
3, // bTerminalID = 3
|
||||||
|
0x01, 0x02, // wTerminalType = 0x0201 (ITT_CAMERA)
|
||||||
|
0, // bAssocTerminal
|
||||||
|
0, // iTerminal
|
||||||
|
0, 0, 0, 0, 0, 0, 3, 0xff, 0xff, 0x0f,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. VC_PROCESSING_UNIT (CS_INTERFACE = 0x24, subtype = 0x05, bUnitID = 5)
|
||||||
|
desc.extend_from_slice(&[
|
||||||
|
13, // bLength
|
||||||
|
0x24, // bDescriptorType = CS_INTERFACE
|
||||||
|
0x05, // bDescriptorSubtype = VC_PROCESSING_UNIT
|
||||||
|
5, // bUnitID = 5
|
||||||
|
3, // bSourceID = 3
|
||||||
|
0, 0, // wMaxMultiplier
|
||||||
|
3, // bControlSize
|
||||||
|
0xff, 0xff, 0x00, // bmControls
|
||||||
|
0, // iProcessing
|
||||||
|
0, // bmVideoStandards
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. VC_EXTENSION_UNIT for XU1 (CS_INTERFACE = 0x24, subtype = 0x06, bUnitID = 11, guid = XU1_GUID_BYTES)
|
||||||
|
let mut xu1_desc = vec![
|
||||||
|
24, // bLength
|
||||||
|
0x24, // bDescriptorType = CS_INTERFACE
|
||||||
|
0x06, // bDescriptorSubtype = VC_EXTENSION_UNIT
|
||||||
|
11, // bUnitID = 11
|
||||||
|
];
|
||||||
|
xu1_desc.extend_from_slice(&XU1_GUID_BYTES);
|
||||||
|
xu1_desc.extend_from_slice(&[15, 1, 5, 0]);
|
||||||
|
assert_eq!(xu1_desc.len(), 24);
|
||||||
|
desc.extend_from_slice(&xu1_desc);
|
||||||
|
|
||||||
|
// 5. VC_EXTENSION_UNIT for XU2 (CS_INTERFACE = 0x24, subtype = 0x06, bUnitID = 12, guid = XU2_GUID_BYTES)
|
||||||
|
let mut xu2_desc = vec![
|
||||||
|
24, // bLength
|
||||||
|
0x24, // bDescriptorType = CS_INTERFACE
|
||||||
|
0x06, // bDescriptorSubtype = VC_EXTENSION_UNIT
|
||||||
|
12, // bUnitID = 12
|
||||||
|
];
|
||||||
|
xu2_desc.extend_from_slice(&XU2_GUID_BYTES);
|
||||||
|
xu2_desc.extend_from_slice(&[15, 1, 11, 0]);
|
||||||
|
assert_eq!(xu2_desc.len(), 24);
|
||||||
|
desc.extend_from_slice(&xu2_desc);
|
||||||
|
|
||||||
|
// 6. Standard Interface Descriptor for VideoStreaming (SC_VIDEOSTREAMING = 0x02)
|
||||||
|
// Descriptors inside VideoStreaming must NOT overwrite VC unit IDs.
|
||||||
|
desc.extend_from_slice(&[
|
||||||
|
9, // bLength
|
||||||
|
0x04, // bDescriptorType = USB_DT_INTERFACE
|
||||||
|
3, // bInterfaceNumber = 3
|
||||||
|
0, // bAlternateSetting
|
||||||
|
0, // bNumEndpoints
|
||||||
|
0x0e, // bInterfaceClass = CC_VIDEO
|
||||||
|
0x02, // bInterfaceSubClass = SC_VIDEOSTREAMING
|
||||||
|
0x00, // bInterfaceProtocol
|
||||||
|
0, // iInterface
|
||||||
|
]);
|
||||||
|
desc.extend_from_slice(&[
|
||||||
|
6, // bLength
|
||||||
|
0x24, // CS_INTERFACE inside VideoStreaming (should be ignored)
|
||||||
|
0x05, // subtype 0x05
|
||||||
|
99, // would be unit 99 if not ignored
|
||||||
|
0, 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let parsed = parse_uvc_vc_descriptors(&desc);
|
||||||
|
assert_eq!(parsed.control_interface, 2);
|
||||||
|
assert_eq!(parsed.input_terminal_id, 3);
|
||||||
|
assert_eq!(parsed.processing_unit_id, 5);
|
||||||
|
assert_eq!(parsed.extension_unit_id, 11);
|
||||||
|
assert_eq!(parsed.extension_unit2_id, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_camera_models_pids_capabilities_and_video_mode_len() {
|
||||||
|
// Verify Vendor ID and device filter
|
||||||
|
assert_eq!(INSTA360_VENDOR_ID, 0x2e1a);
|
||||||
|
for &pid in &[
|
||||||
|
pids::LINK_1,
|
||||||
|
pids::PUC3_GIMBAL_A,
|
||||||
|
pids::LINK_2C,
|
||||||
|
pids::LINK_2,
|
||||||
|
pids::PUC3_HOST,
|
||||||
|
pids::PUC3_GIMBAL_B,
|
||||||
|
pids::LINK_SIMPLE_CLASS,
|
||||||
|
] {
|
||||||
|
assert!(is_insta360_link_device(INSTA360_VENDOR_ID, pid));
|
||||||
|
assert!(!is_insta360_link_device(0x1234, pid));
|
||||||
|
}
|
||||||
|
assert!(!is_insta360_link_device(INSTA360_VENDOR_ID, 0x9999));
|
||||||
|
|
||||||
|
// Verify PID -> CameraModel mappings, internal codes, firmware filenames, and SetVideoMode payload lengths
|
||||||
|
let link1 = CameraModel::from_pid(pids::LINK_1);
|
||||||
|
assert_eq!(link1, CameraModel::Link);
|
||||||
|
assert_eq!(link1.pid(), 0x4c01);
|
||||||
|
assert_eq!(link1.internal_code(), "link1");
|
||||||
|
assert_eq!(link1.firmware_filename(), Some(r"a:\Insta360WebCamFW.bin"));
|
||||||
|
assert_eq!(link1.video_mode_payload_len(), 8);
|
||||||
|
|
||||||
|
let puc3_a = CameraModel::from_pid(pids::PUC3_GIMBAL_A);
|
||||||
|
assert_eq!(puc3_a, CameraModel::Puc3Gimbal(0x4c02));
|
||||||
|
assert_eq!(puc3_a.pid(), 0x4c02);
|
||||||
|
assert_eq!(puc3_a.internal_code(), "link2_gimbal");
|
||||||
|
assert_eq!(puc3_a.firmware_filename(), Some(r"a:\Insta360PUC3FW_GIMBAL.bin"));
|
||||||
|
assert_eq!(puc3_a.video_mode_payload_len(), 8);
|
||||||
|
|
||||||
|
let link2c = CameraModel::from_pid(pids::LINK_2C);
|
||||||
|
assert_eq!(link2c, CameraModel::Link2C);
|
||||||
|
assert_eq!(link2c.pid(), 0x4c03);
|
||||||
|
assert_eq!(link2c.internal_code(), "link2_main");
|
||||||
|
assert_eq!(link2c.firmware_filename(), Some(r"a:\Insta360LINK2CFW_HOST.bin"));
|
||||||
|
assert_eq!(link2c.video_mode_payload_len(), 36);
|
||||||
|
|
||||||
|
let link2 = CameraModel::from_pid(pids::LINK_2);
|
||||||
|
assert_eq!(link2, CameraModel::Link2);
|
||||||
|
assert_eq!(link2.pid(), 0x4c04);
|
||||||
|
assert_eq!(link2.internal_code(), "link2_gimbal");
|
||||||
|
assert_eq!(link2.firmware_filename(), Some(r"a:\Insta360LINK2FW_GIMBAL.bin"));
|
||||||
|
assert_eq!(link2.video_mode_payload_len(), 8);
|
||||||
|
|
||||||
|
let puc3_host = CameraModel::from_pid(pids::PUC3_HOST);
|
||||||
|
assert_eq!(puc3_host, CameraModel::Puc3Host);
|
||||||
|
assert_eq!(puc3_host.pid(), 0x4c05);
|
||||||
|
assert_eq!(puc3_host.internal_code(), "link2_main");
|
||||||
|
assert_eq!(puc3_host.firmware_filename(), Some(r"a:\Insta360PUC3FW_HOST.bin"));
|
||||||
|
assert_eq!(puc3_host.video_mode_payload_len(), 36);
|
||||||
|
|
||||||
|
let puc3_b = CameraModel::from_pid(pids::PUC3_GIMBAL_B);
|
||||||
|
assert_eq!(puc3_b, CameraModel::Puc3Gimbal(0x4c06));
|
||||||
|
assert_eq!(puc3_b.pid(), 0x4c06);
|
||||||
|
assert_eq!(puc3_b.internal_code(), "link2_gimbal");
|
||||||
|
assert_eq!(puc3_b.firmware_filename(), Some(r"a:\Insta360PUC3FW_GIMBAL.bin"));
|
||||||
|
assert_eq!(puc3_b.video_mode_payload_len(), 8);
|
||||||
|
|
||||||
|
let dfu = CameraModel::from_pid(pids::LINK_SIMPLE_CLASS);
|
||||||
|
assert_eq!(dfu, CameraModel::LinkSimpleClass);
|
||||||
|
assert_eq!(dfu.pid(), 0x00c2);
|
||||||
|
assert_eq!(dfu.internal_code(), "link1");
|
||||||
|
assert_eq!(dfu.firmware_filename(), Some(r"a:\Insta360WebCamFW.bin"));
|
||||||
|
assert_eq!(dfu.video_mode_payload_len(), 36);
|
||||||
|
|
||||||
|
// Capability matrix assertions (`Webcam::CameraDeviceSupporter::UpdateUIStatus` at `0x10004a8b8`)
|
||||||
|
let cap_link1 = DeviceCapabilities::for_pid(pids::LINK_1);
|
||||||
|
assert!(cap_link1.has_physical_gimbal);
|
||||||
|
assert!(cap_link1.support_roll_adjustment);
|
||||||
|
assert!(!cap_link1.support_auto_framing);
|
||||||
|
assert!(!cap_link1.support_privacy_mode);
|
||||||
|
assert!(!cap_link1.support_audio_capture_mode);
|
||||||
|
assert!(!cap_link1.support_horizontal_correction);
|
||||||
|
assert!(!cap_link1.support_sound_wall);
|
||||||
|
|
||||||
|
let cap_link2 = DeviceCapabilities::for_pid(pids::LINK_2);
|
||||||
|
assert!(cap_link2.has_physical_gimbal);
|
||||||
|
assert!(!cap_link2.support_roll_adjustment);
|
||||||
|
assert!(cap_link2.support_auto_framing);
|
||||||
|
assert!(cap_link2.support_privacy_mode);
|
||||||
|
assert!(cap_link2.support_area_track);
|
||||||
|
assert!(cap_link2.support_speak_tracking);
|
||||||
|
assert!(cap_link2.support_audio_capture_mode);
|
||||||
|
assert!(cap_link2.support_horizontal_correction);
|
||||||
|
assert!(!cap_link2.support_sound_wall);
|
||||||
|
|
||||||
|
let cap_link2c = DeviceCapabilities::for_pid(pids::LINK_2C);
|
||||||
|
assert!(!cap_link2c.has_physical_gimbal);
|
||||||
|
assert!(!cap_link2c.support_ptz_drag);
|
||||||
|
assert!(!cap_link2c.support_track_speed);
|
||||||
|
assert!(cap_link2c.support_auto_framing);
|
||||||
|
assert!(cap_link2c.support_image_template);
|
||||||
|
assert!(cap_link2c.support_privacy_mode);
|
||||||
|
assert!(cap_link2c.support_audio_capture_mode);
|
||||||
|
assert!(!cap_link2c.support_area_track);
|
||||||
|
assert!(!cap_link2c.support_horizontal_correction);
|
||||||
|
|
||||||
|
let cap_puc3_b = DeviceCapabilities::for_pid(pids::PUC3_GIMBAL_B);
|
||||||
|
assert!(cap_puc3_b.has_physical_gimbal);
|
||||||
|
assert!(cap_puc3_b.support_area_track);
|
||||||
|
assert!(cap_puc3_b.support_speak_tracking);
|
||||||
|
assert!(cap_puc3_b.support_sound_wall);
|
||||||
|
assert!(!cap_puc3_b.support_horizontal_correction);
|
||||||
|
|
||||||
|
let cap_puc3_host = DeviceCapabilities::for_pid(pids::PUC3_HOST);
|
||||||
|
assert!(!cap_puc3_host.has_physical_gimbal);
|
||||||
|
assert!(cap_puc3_host.support_sound_wall);
|
||||||
|
assert!(cap_puc3_host.support_auto_framing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_controller_ptz_presets_and_gimbal_commands() {
|
||||||
|
let mock = MockCameraTransport::new(CameraModel::Link2);
|
||||||
|
let ctrl = Insta360CameraController::new(mock.clone());
|
||||||
|
|
||||||
|
// 1. Absolute Pan/Tilt & Zoom
|
||||||
|
ctrl.set_pantilt_absolute(36000, -18000).unwrap();
|
||||||
|
assert_eq!(ctrl.get_pantilt_absolute().unwrap(), (36000, -18000));
|
||||||
|
|
||||||
|
ctrl.set_zoom(250).unwrap();
|
||||||
|
assert_eq!(ctrl.get_zoom().unwrap(), 250);
|
||||||
|
let zoom_range = ctrl.get_zoom_range().unwrap();
|
||||||
|
assert_eq!((zoom_range.min, zoom_range.max, zoom_range.default), (100, 400, 100));
|
||||||
|
|
||||||
|
// 2. Relative Pan/Tilt (XU1 0x0b: [pan_speed, pan_dir, tilt_speed, tilt_dir])
|
||||||
|
ctrl.set_pantilt_relative_ext(5, -1, 3, 1).unwrap();
|
||||||
|
ctrl.stop_pantilt_relative().unwrap();
|
||||||
|
|
||||||
|
// 3. Presets (XU1 0x01: [sub_op, index, 0, 0])
|
||||||
|
ctrl.add_preset_position(4).unwrap();
|
||||||
|
let flags = ctrl.get_preset_flags().unwrap();
|
||||||
|
assert_ne!(flags.slot_mask & (1 << 4), 0);
|
||||||
|
assert_eq!(flags.current_slot, 4);
|
||||||
|
|
||||||
|
ctrl.recall_preset_position(1).unwrap();
|
||||||
|
assert_eq!(ctrl.get_preset_flags().unwrap().current_slot, 1);
|
||||||
|
|
||||||
|
ctrl.delete_preset_position(4).unwrap();
|
||||||
|
assert_eq!(ctrl.get_preset_flags().unwrap().slot_mask & (1 << 4), 0);
|
||||||
|
|
||||||
|
// 4. Composition bias & Reset Gimbal
|
||||||
|
ctrl.set_composition_bias(-120, 80).unwrap();
|
||||||
|
ctrl.reset_composition_bias().unwrap();
|
||||||
|
ctrl.reset_gimbal().unwrap();
|
||||||
|
assert_eq!(ctrl.get_pantilt_absolute().unwrap(), (0, 0));
|
||||||
|
assert_eq!(ctrl.get_zoom().unwrap(), 100);
|
||||||
|
|
||||||
|
// 5. Host Digital PTZ (XU2 0x09)
|
||||||
|
let host_crop = HostPtzInfo {
|
||||||
|
x: 500,
|
||||||
|
y: 600,
|
||||||
|
width: 2570,
|
||||||
|
height: 2570,
|
||||||
|
};
|
||||||
|
ctrl.set_host_ptz(&host_crop).unwrap();
|
||||||
|
|
||||||
|
// Inspect command log for exact unit IDs, selectors, and payload bytes
|
||||||
|
let log = mock.command_log();
|
||||||
|
assert_eq!(
|
||||||
|
log[0],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_INPUT_TERMINAL_ID,
|
||||||
|
ct_selectors::PANTILT_ABSOLUTE,
|
||||||
|
[36000i32.to_le_bytes(), (-18000i32).to_le_bytes()].concat()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[1],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_INPUT_TERMINAL_ID,
|
||||||
|
ct_selectors::ZOOM_ABSOLUTE,
|
||||||
|
250u16.to_le_bytes().to_vec()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[2],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::PANTILT_RELATIVE_EXT,
|
||||||
|
vec![0xff, 5, 1, 3]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[3],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::PANTILT_RELATIVE_EXT,
|
||||||
|
vec![0, 1, 0, 1]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[4],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::SYSTEM_AND_PRESET_CMD,
|
||||||
|
vec![0x01, 4, 0x00, 0x00]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[5],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::SYSTEM_AND_PRESET_CMD,
|
||||||
|
vec![0x00, 1, 0x00, 0x00]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[6],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::SYSTEM_AND_PRESET_CMD,
|
||||||
|
vec![0x02, 4, 0x00, 0x00]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[7],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::SYSTEM_AND_PRESET_CMD,
|
||||||
|
vec![0x06, (-120i16).to_le_bytes()[0], (-120i16).to_le_bytes()[1], 80, 0]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[8],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::SYSTEM_AND_PRESET_CMD,
|
||||||
|
vec![0x06, 0xff, 0x7f, 0xff, 0x7f]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[9],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::SYSTEM_AND_PRESET_CMD,
|
||||||
|
vec![0x03, 0x00, 0x00, 0x00]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
log[10],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT2_ID,
|
||||||
|
xu2_selectors::HOST_PTZ,
|
||||||
|
[
|
||||||
|
500u16.to_le_bytes(),
|
||||||
|
600u16.to_le_bytes(),
|
||||||
|
2570u16.to_le_bytes(),
|
||||||
|
2570u16.to_le_bytes()
|
||||||
|
]
|
||||||
|
.concat()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_video_mode_8byte_vs_36byte_and_tracking_gestures() {
|
||||||
|
// 1. Link 2 uses 8-byte SetVideoMode payload
|
||||||
|
let mock_link2 = MockCameraTransport::new(CameraModel::Link2);
|
||||||
|
let ctrl_link2 = Insta360CameraController::new(mock_link2.clone());
|
||||||
|
let aux = VideoModeAuxData {
|
||||||
|
flags: 1,
|
||||||
|
rect: [10, 20, 300, 400],
|
||||||
|
pitch: -450,
|
||||||
|
zoom: 150,
|
||||||
|
extra: [0xaa; 14],
|
||||||
|
};
|
||||||
|
ctrl_link2.set_video_mode(VideoMode::DeskView, &aux).unwrap();
|
||||||
|
let log2 = mock_link2.command_log();
|
||||||
|
assert_eq!(log2.len(), 1);
|
||||||
|
assert_eq!(log2[0].1, DEFAULT_EXTENSION_UNIT_ID);
|
||||||
|
assert_eq!(log2[0].2, xu1_selectors::VIDEO_MODE);
|
||||||
|
assert_eq!(log2[0].3.len(), 8);
|
||||||
|
assert_eq!(log2[0].3[0], VideoMode::DeskView as u8);
|
||||||
|
assert_eq!(log2[0].3[1], 1);
|
||||||
|
|
||||||
|
// 2. Link 2C uses 36-byte SetVideoMode payload
|
||||||
|
let mock_link2c = MockCameraTransport::new(CameraModel::Link2C);
|
||||||
|
let ctrl_link2c = Insta360CameraController::new(mock_link2c.clone());
|
||||||
|
ctrl_link2c.set_video_mode(VideoMode::AutoFraming, &aux).unwrap();
|
||||||
|
let log2c = mock_link2c.command_log();
|
||||||
|
assert_eq!(log2c.len(), 1);
|
||||||
|
assert_eq!(log2c[0].1, DEFAULT_EXTENSION_UNIT_ID);
|
||||||
|
assert_eq!(log2c[0].2, xu1_selectors::VIDEO_MODE);
|
||||||
|
assert_eq!(log2c[0].3.len(), 36);
|
||||||
|
let (decoded_mode, decoded_aux) = ctrl_link2c.get_video_mode().unwrap();
|
||||||
|
assert_eq!(decoded_mode, VideoMode::AutoFraming);
|
||||||
|
assert_eq!(decoded_aux.rect, [10, 20, 300, 400]);
|
||||||
|
assert_eq!(decoded_aux.pitch, -450);
|
||||||
|
assert_eq!(decoded_aux.zoom, 150);
|
||||||
|
|
||||||
|
// 3. Tracking speed, composition style, and gestures on XU1 0x03
|
||||||
|
ctrl_link2.set_track_speed(TrackSpeed::Fast).unwrap();
|
||||||
|
assert_eq!(ctrl_link2.get_track_speed().unwrap(), TrackSpeed::Fast);
|
||||||
|
|
||||||
|
ctrl_link2.set_composition_style(CompositionStyle::WholeBody).unwrap();
|
||||||
|
assert_eq!(ctrl_link2.get_composition_style().unwrap(), CompositionStyle::WholeBody);
|
||||||
|
|
||||||
|
ctrl_link2.set_gesture_enabled(Gesture::VShape, false).unwrap();
|
||||||
|
let gestures = ctrl_link2.get_gesture_status().unwrap();
|
||||||
|
assert_eq!(gestures[3], (Gesture::VShape, false));
|
||||||
|
assert_eq!(gestures[0], (Gesture::Ok, true));
|
||||||
|
|
||||||
|
ctrl_link2.set_all_gestures_enabled(false).unwrap();
|
||||||
|
|
||||||
|
let log2_after = mock_link2.command_log();
|
||||||
|
// TrackSpeed::Fast -> [5, 2, 0..0] (16 bytes)
|
||||||
|
let mut expected_speed = vec![0u8; 16];
|
||||||
|
expected_speed[0] = 0x05;
|
||||||
|
expected_speed[1] = TrackSpeed::Fast as u8;
|
||||||
|
assert_eq!(
|
||||||
|
log2_after[1],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::TRACKING_AND_GESTURE,
|
||||||
|
expected_speed
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// CompositionStyle::WholeBody -> [6, 2, 0..0] (16 bytes)
|
||||||
|
let mut expected_style = vec![0u8; 16];
|
||||||
|
expected_style[0] = 0x06;
|
||||||
|
expected_style[1] = CompositionStyle::WholeBody as u8;
|
||||||
|
assert_eq!(
|
||||||
|
log2_after[2],
|
||||||
|
(
|
||||||
|
UvcRequest::SetCur,
|
||||||
|
DEFAULT_EXTENSION_UNIT_ID,
|
||||||
|
xu1_selectors::TRACKING_AND_GESTURE,
|
||||||
|
expected_style
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_image_controls_exposure_wb_audio_filters_templates_and_extend_functions() {
|
||||||
|
let mock = MockCameraTransport::new(CameraModel::Link2);
|
||||||
|
let ctrl = Insta360CameraController::new(mock.clone());
|
||||||
|
|
||||||
|
// 1. Standard Processing Unit (PU) controls
|
||||||
|
ctrl.set_brightness(72).unwrap();
|
||||||
|
assert_eq!(ctrl.get_brightness().unwrap(), 72);
|
||||||
|
|
||||||
|
ctrl.set_contrast(64).unwrap();
|
||||||
|
assert_eq!(ctrl.get_contrast().unwrap(), 64);
|
||||||
|
|
||||||
|
ctrl.set_saturation(80).unwrap();
|
||||||
|
assert_eq!(ctrl.get_saturation().unwrap(), 80);
|
||||||
|
|
||||||
|
ctrl.set_sharpness(45).unwrap();
|
||||||
|
assert_eq!(ctrl.get_sharpness().unwrap(), 45);
|
||||||
|
|
||||||
|
ctrl.set_auto_white_balance(false).unwrap();
|
||||||
|
assert!(!ctrl.get_auto_white_balance().unwrap());
|
||||||
|
|
||||||
|
ctrl.set_white_balance_temperature(6500).unwrap();
|
||||||
|
assert_eq!(ctrl.get_white_balance_temperature().unwrap(), 6500);
|
||||||
|
|
||||||
|
ctrl.set_power_line_frequency(PowerLineFrequency::Hz50).unwrap();
|
||||||
|
assert_eq!(ctrl.get_power_line_frequency().unwrap(), PowerLineFrequency::Hz50);
|
||||||
|
|
||||||
|
// 2. Standard Camera Terminal (CT) focus, roll, shutter
|
||||||
|
ctrl.set_auto_focus(false).unwrap();
|
||||||
|
assert!(!ctrl.get_auto_focus().unwrap());
|
||||||
|
ctrl.set_focus(85).unwrap();
|
||||||
|
assert_eq!(ctrl.get_focus().unwrap(), 85);
|
||||||
|
ctrl.set_roll(-150).unwrap();
|
||||||
|
assert_eq!(ctrl.get_roll().unwrap(), -150);
|
||||||
|
ctrl.set_shutter_speed(500).unwrap();
|
||||||
|
assert_eq!(ctrl.get_shutter_speed().unwrap(), 500);
|
||||||
|
|
||||||
|
// 3. Exposure Curve / Auto Exposure / ISO (XU1 0x07, 259 bytes) & EV Compensation (XU1 0x08, 4 bytes)
|
||||||
|
ctrl.set_auto_exposure(false).unwrap();
|
||||||
|
assert!(!ctrl.get_auto_exposure().unwrap());
|
||||||
|
ctrl.set_iso(800).unwrap();
|
||||||
|
assert_eq!(ctrl.get_iso().unwrap(), 800);
|
||||||
|
ctrl.set_exposure_compensation(-1.5).unwrap();
|
||||||
|
assert!((ctrl.get_exposure_compensation().unwrap() - (-1.5)).abs() < 1e-6);
|
||||||
|
|
||||||
|
// 4. Audio Control (XU1 0x04, 2 bytes)
|
||||||
|
ctrl.set_audio_noise_reduction(false).unwrap();
|
||||||
|
assert!(!ctrl.get_audio_noise_reduction().unwrap());
|
||||||
|
ctrl.set_audio_capture_mode(AudioCaptureMode::MusicBalance).unwrap();
|
||||||
|
assert_eq!(ctrl.get_audio_capture_mode().unwrap(), AudioCaptureMode::MusicBalance);
|
||||||
|
|
||||||
|
// 5. Filter Mode (XU2 0x06, 1 byte)
|
||||||
|
ctrl.set_filter(FilterMode::Amber).unwrap();
|
||||||
|
assert_eq!(ctrl.get_filter().unwrap(), FilterMode::Amber);
|
||||||
|
|
||||||
|
// 6. Privacy Mode (XU2 0x08, 4 bytes) & Horizontal Correction (XU2 0x0d, 1 byte)
|
||||||
|
ctrl.set_privacy_mode(true).unwrap();
|
||||||
|
let (_, priv_enabled) = ctrl.get_privacy_mode().unwrap();
|
||||||
|
assert!(priv_enabled);
|
||||||
|
assert!(ctrl.get_device_status().unwrap().contains(DeviceStatus::PRIVACY_SLEEP));
|
||||||
|
|
||||||
|
ctrl.set_horizontal_correction(false).unwrap();
|
||||||
|
assert!(!ctrl.get_horizontal_correction().unwrap());
|
||||||
|
|
||||||
|
// 7. Image Templates (XU2 0x04 & 0x05)
|
||||||
|
ctrl.set_image_template(3, ImageOperation::SaveOrUpdate).unwrap();
|
||||||
|
ctrl.set_image_template_name(3, "Podcast Look").unwrap();
|
||||||
|
let tmpl_info = ctrl.get_image_template_info().unwrap();
|
||||||
|
assert_eq!(tmpl_info.current_id, 3);
|
||||||
|
assert_ne!(tmpl_info.bitmask & (1 << 3), 0);
|
||||||
|
ctrl.delete_image_template(3).unwrap();
|
||||||
|
assert_eq!(ctrl.get_image_template_info().unwrap().bitmask & (1 << 3), 0);
|
||||||
|
|
||||||
|
// 8. Extended Function Bitmask (XU1 0x0a, 8 bytes: [mask_u32_le, values_u32_le])
|
||||||
|
for &func in &ExtendFunction::ALL {
|
||||||
|
ctrl.set_extend_function(func, true).unwrap();
|
||||||
|
let statuses = ctrl.get_extend_function_status().unwrap();
|
||||||
|
let is_on = statuses.into_iter().find(|(f, _)| *f == func).unwrap().1;
|
||||||
|
assert!(is_on, "expected {:?} to be enabled", func);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify specific wire payloads in command_log()
|
||||||
|
let log = mock.command_log();
|
||||||
|
// Find EV compensation command
|
||||||
|
let ev_cmd = log
|
||||||
|
.iter()
|
||||||
|
.find(|(_, u, s, _)| *u == DEFAULT_EXTENSION_UNIT_ID && *s == xu1_selectors::EXPOSURE_COMPENSATION)
|
||||||
|
.expect("EV compensation command logged");
|
||||||
|
assert_eq!(ev_cmd.3, (-1.5f32).to_le_bytes().to_vec());
|
||||||
|
|
||||||
|
// Find Privacy Mode command
|
||||||
|
let priv_cmd = log
|
||||||
|
.iter()
|
||||||
|
.find(|(_, u, s, _)| *u == DEFAULT_EXTENSION_UNIT2_ID && *s == xu2_selectors::PRIVACY_AND_FILL_LIGHT)
|
||||||
|
.expect("Privacy mode command logged");
|
||||||
|
assert_eq!(priv_cmd.3, vec![0x02, 0x01, 0x00, 0x00]);
|
||||||
|
|
||||||
|
// Find Filter Mode command
|
||||||
|
let filter_cmd = log
|
||||||
|
.iter()
|
||||||
|
.find(|(_, u, s, _)| *u == DEFAULT_EXTENSION_UNIT2_ID && *s == xu2_selectors::FILTER_MODE)
|
||||||
|
.expect("Filter mode command logged");
|
||||||
|
assert_eq!(filter_cmd.3, vec![FilterMode::Amber as u8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_system_info_tracking_rects_and_mode_switch_commands() {
|
||||||
|
let mock = MockCameraTransport::new(CameraModel::Link2);
|
||||||
|
let ctrl = Insta360CameraController::new(mock.clone());
|
||||||
|
|
||||||
|
// Device Info (XU2 0x03, 150 bytes)
|
||||||
|
let dev_info = ctrl.get_device_info().unwrap();
|
||||||
|
assert_eq!(dev_info.device_name, "Insta360 Link 2");
|
||||||
|
assert_eq!(dev_info.firmware_version, "v2.5.8.8_build1");
|
||||||
|
assert_eq!(dev_info.serial_number, "INSTA360SIM0001");
|
||||||
|
|
||||||
|
// PTZ Version & Angles (XU1 0x0d, 30 bytes)
|
||||||
|
ctrl.set_pantilt_absolute(3600, -7200).unwrap();
|
||||||
|
let ptz_info = ctrl.get_ptz_version_and_angles().unwrap();
|
||||||
|
assert_eq!(ptz_info.version, "v1.3.1.0_build0");
|
||||||
|
assert_eq!(ptz_info.pan, 10);
|
||||||
|
assert_eq!(ptz_info.tilt, -20);
|
||||||
|
assert_eq!(ptz_info.zoom, 100);
|
||||||
|
|
||||||
|
// Select Tracked Object (XU1 0x05, 10 bytes)
|
||||||
|
let target = UvcRect {
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
width: 300,
|
||||||
|
height: 400,
|
||||||
|
track_id: 7,
|
||||||
|
};
|
||||||
|
ctrl.select_track_object(&target).unwrap();
|
||||||
|
|
||||||
|
// Default Track Mode (XU2 0x0c, 1 byte)
|
||||||
|
ctrl.set_default_track_mode(1).unwrap();
|
||||||
|
assert_eq!(ctrl.get_default_track_mode().unwrap(), 1);
|
||||||
|
|
||||||
|
// System Timestamp, IQ Switch, Factory Restore (XU2 0x02, 10 bytes)
|
||||||
|
ctrl.set_timestamp(0x0102_0304_0506_0708).unwrap();
|
||||||
|
ctrl.set_iq_switch(1, true, 0x01).unwrap();
|
||||||
|
ctrl.restore_factory_settings(true).unwrap();
|
||||||
|
ctrl.restore_factory_settings(false).unwrap();
|
||||||
|
|
||||||
|
// Factory Default (XU1 0x01 sub-opcode 4) & MSC Switch (XU1 0x0f sub-opcode 2)
|
||||||
|
ctrl.load_factory_default().unwrap();
|
||||||
|
ctrl.switch_msc_readonly_class().unwrap();
|
||||||
|
|
||||||
|
let log = mock.command_log();
|
||||||
|
assert!(log.iter().any(|(_, u, s, p)| {
|
||||||
|
*u == DEFAULT_EXTENSION_UNIT_ID
|
||||||
|
&& *s == xu1_selectors::TRACK_OBJECT_SELECT
|
||||||
|
&& p == &encode_track_rect(&target)
|
||||||
|
}));
|
||||||
|
assert!(log.iter().any(|(_, u, s, p)| {
|
||||||
|
*u == DEFAULT_EXTENSION_UNIT2_ID
|
||||||
|
&& *s == xu2_selectors::SYSTEM_TIME_AND_IQ
|
||||||
|
&& p[0] == 0x01
|
||||||
|
&& &p[1..9] == &0x0102_0304_0506_0708u64.to_le_bytes()
|
||||||
|
}));
|
||||||
|
assert!(log.iter().any(|(_, u, s, p)| {
|
||||||
|
*u == DEFAULT_EXTENSION_UNIT2_ID
|
||||||
|
&& *s == xu2_selectors::SYSTEM_TIME_AND_IQ
|
||||||
|
&& p == &[0x02, 0x03, 0x00, 0x00, 0x00, 0, 0, 0, 0, 0]
|
||||||
|
}));
|
||||||
|
assert!(log.iter().any(|(_, u, s, p)| {
|
||||||
|
*u == DEFAULT_EXTENSION_UNIT_ID
|
||||||
|
&& *s == xu1_selectors::USB_MODE_SWITCH
|
||||||
|
&& p == &[0x02, 0x00, 0x00, 0x00]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Gesture bindings (XU2 selector 0x01)
|
||||||
|
ctrl.set_gesture_binding_func(Gesture::Palm, GestureFunction::AutoFraming)
|
||||||
|
.unwrap();
|
||||||
|
let bindings = ctrl.get_gesture_binding_map().unwrap();
|
||||||
|
assert_eq!(bindings[1], (Gesture::Palm, GestureFunction::AutoFraming));
|
||||||
|
|
||||||
|
// Tracking Forbidden Areas (XU1 selector 0x01 opcodes 7/8 + XU2 selector 0x0b)
|
||||||
|
ctrl.enter_track_forbidden_area_setting().unwrap();
|
||||||
|
let areas = vec![
|
||||||
|
ForbiddenAreaInfo {
|
||||||
|
enabled: true,
|
||||||
|
index: 0,
|
||||||
|
pan_min: -300,
|
||||||
|
pan_max: -100,
|
||||||
|
tilt_min: -150,
|
||||||
|
tilt_max: 150,
|
||||||
|
},
|
||||||
|
ForbiddenAreaInfo {
|
||||||
|
enabled: false,
|
||||||
|
index: 1,
|
||||||
|
pan_min: 100,
|
||||||
|
pan_max: 300,
|
||||||
|
tilt_min: -100,
|
||||||
|
tilt_max: 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ctrl.set_track_forbidden_areas(&areas).unwrap();
|
||||||
|
let read_areas = ctrl.get_track_forbidden_areas().unwrap();
|
||||||
|
assert_eq!(read_areas, areas);
|
||||||
|
ctrl.exit_track_forbidden_area_setting().unwrap();
|
||||||
|
|
||||||
|
// Real zoom & Fill light param
|
||||||
|
ctrl.set_zoom(250).unwrap();
|
||||||
|
assert_eq!(ctrl.get_real_zoom_value().unwrap(), 250);
|
||||||
|
assert_eq!(ctrl.get_fill_light_param().unwrap(), (0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "insta360-link-server"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Cross-platform HTTP REST & WebSocket remote control server for Insta360 Link cameras"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "insta360-link-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
insta360-link-protocol = { path = "../insta360-link-protocol" }
|
||||||
|
clap.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
|||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use insta360_link_protocol::{
|
||||||
|
CameraModel, Insta360CameraController, NusbTransport, UvcControlTransport,
|
||||||
|
};
|
||||||
|
use insta360_link_server::{build_router, new_mock_state, AppState, ServerTransport};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "insta360-link-server",
|
||||||
|
version,
|
||||||
|
about = "Cross-platform HTTP REST & WebSocket remote control server for Insta360 Link cameras"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Bind address for HTTP and WebSocket server
|
||||||
|
#[arg(long, default_value = "127.0.0.1:9360", value_name = "ADDR")]
|
||||||
|
bind: SocketAddr,
|
||||||
|
|
||||||
|
/// Run against an in-memory simulated camera model instead of physical USB
|
||||||
|
#[arg(long, value_enum, value_name = "MODEL")]
|
||||||
|
mock: Option<MockModelArg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
|
pub enum MockModelArg {
|
||||||
|
#[value(name = "link", alias = "link1")]
|
||||||
|
Link,
|
||||||
|
#[value(name = "link2")]
|
||||||
|
Link2,
|
||||||
|
#[value(name = "link2c")]
|
||||||
|
Link2c,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MockModelArg> for CameraModel {
|
||||||
|
fn from(val: MockModelArg) -> Self {
|
||||||
|
match val {
|
||||||
|
MockModelArg::Link => CameraModel::Link,
|
||||||
|
MockModelArg::Link2 => CameraModel::Link2,
|
||||||
|
MockModelArg::Link2c => CameraModel::Link2C,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "insta360_link_server=info,tower_http=info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let state: AppState = match cli.mock {
|
||||||
|
Some(mock_arg) => {
|
||||||
|
let model: CameraModel = mock_arg.into();
|
||||||
|
tracing::info!(
|
||||||
|
"Initializing in-memory simulated camera: {} (PID: 0x{:04x})",
|
||||||
|
model.display_name(),
|
||||||
|
model.pid()
|
||||||
|
);
|
||||||
|
new_mock_state(model)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::info!("Discovering and connecting to physical Insta360 Link camera via USB...");
|
||||||
|
match NusbTransport::open_first() {
|
||||||
|
Ok(usb) => {
|
||||||
|
let info = usb.camera_info().clone();
|
||||||
|
tracing::info!(
|
||||||
|
"Connected to physical camera: {} (Bus {} Addr {} • PID 0x{:04x})",
|
||||||
|
info.name,
|
||||||
|
info.bus_number,
|
||||||
|
info.device_address,
|
||||||
|
info.product_id
|
||||||
|
);
|
||||||
|
let ctrl = Insta360CameraController::new(ServerTransport::Usb(usb));
|
||||||
|
Arc::new(Mutex::new(ctrl))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Could not open physical Insta360 Link camera: {}. Falling back to simulated Link 2 camera. Pass '--mock <link|link2|link2c>' to select a specific model.",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
new_mock_state(CameraModel::Link2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let router = build_router(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
||||||
|
tracing::info!("Insta360 Link Server listening on http://{}", cli.bind);
|
||||||
|
tracing::info!("Web Remote UI: http://{}", cli.bind);
|
||||||
|
tracing::info!("WebSocket stream: ws://{}/ws", cli.bind);
|
||||||
|
tracing::info!("REST State API: http://{}/api/state", cli.bind);
|
||||||
|
|
||||||
|
axum::serve(listener, router).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use insta360_link_protocol::{
|
||||||
|
AudioCaptureMode, CameraModel, CompositionStyle, ExtendFunction, FilterMode, Gesture,
|
||||||
|
GestureFunction, TrackSpeed, VideoMode,
|
||||||
|
};
|
||||||
|
use insta360_link_server::{
|
||||||
|
build_router, new_mock_state, AudioRequest, CameraStateSnapshot, ImageRequest, ModeRequest,
|
||||||
|
PresetRequest, PtzRequest, SystemRequest, TrackingRequest,
|
||||||
|
};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
/// Binds an ephemeral TCP listener on 127.0.0.1:0, attaches the axum router
|
||||||
|
/// powered by `new_mock_state(model)`, spawns the server onto the tokio runtime,
|
||||||
|
/// and returns the bound socket address.
|
||||||
|
async fn start_test_server(model: CameraModel) -> SocketAddr {
|
||||||
|
let state = new_mock_state(model);
|
||||||
|
let app = build_router(state);
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("Failed to bind ephemeral TCP listener");
|
||||||
|
let addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("Failed to retrieve local socket address");
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = axum::serve(listener, app).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends an HTTP/1.1 request directly over a raw TCP connection (`TcpStream`),
|
||||||
|
/// eliminating external HTTP client crate requirements.
|
||||||
|
///
|
||||||
|
/// Handles chunked reading, `Content-Length` framing, and connection termination.
|
||||||
|
/// Returns `(status_code, headers, body)`.
|
||||||
|
async fn send_http_request(
|
||||||
|
addr: SocketAddr,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
) -> (u16, HashMap<String, String>, String) {
|
||||||
|
let mut stream = TcpStream::connect(addr)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect TCP stream to test server");
|
||||||
|
|
||||||
|
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n");
|
||||||
|
if let Some(b) = body {
|
||||||
|
req.push_str("Content-Type: application/json\r\n");
|
||||||
|
req.push_str(&format!("Content-Length: {}\r\n", b.len()));
|
||||||
|
}
|
||||||
|
req.push_str("\r\n");
|
||||||
|
if let Some(b) = body {
|
||||||
|
req.push_str(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
stream
|
||||||
|
.write_all(req.as_bytes())
|
||||||
|
.await
|
||||||
|
.expect("Failed to write HTTP request to TCP stream");
|
||||||
|
stream.flush().await.expect("Failed to flush TCP stream");
|
||||||
|
|
||||||
|
let mut response_buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
let n = stream
|
||||||
|
.read(&mut chunk)
|
||||||
|
.await
|
||||||
|
.expect("Failed to read HTTP response chunk from TCP stream");
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
response_buf.extend_from_slice(&chunk[..n]);
|
||||||
|
|
||||||
|
if let Some(pos) = response_buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||||
|
let header_str = String::from_utf8_lossy(&response_buf[..pos]);
|
||||||
|
let mut content_len = None;
|
||||||
|
for line in header_str.split("\r\n") {
|
||||||
|
if let Some((k, v)) = line.split_once(':') {
|
||||||
|
if k.trim().eq_ignore_ascii_case("content-length") {
|
||||||
|
if let Ok(cl) = v.trim().parse::<usize>() {
|
||||||
|
content_len = Some(cl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cl) = content_len {
|
||||||
|
if response_buf.len() >= pos + 4 + cl {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let header_end = response_buf
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == b"\r\n\r\n")
|
||||||
|
.expect("Malformed HTTP response: missing header delimiter CRLF CRLF");
|
||||||
|
let header_bytes = &response_buf[..header_end];
|
||||||
|
let body_bytes = &response_buf[header_end + 4..];
|
||||||
|
|
||||||
|
let header_str = std::str::from_utf8(header_bytes).expect("Non-UTF8 HTTP headers");
|
||||||
|
let mut lines = header_str.split("\r\n");
|
||||||
|
let status_line = lines.next().expect("Missing HTTP status line");
|
||||||
|
let status_code: u16 = status_line
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.expect("Missing status code in HTTP status line")
|
||||||
|
.parse()
|
||||||
|
.expect("Invalid integer status code in HTTP response");
|
||||||
|
|
||||||
|
let mut headers = HashMap::new();
|
||||||
|
for line in lines {
|
||||||
|
if let Some((k, v)) = line.split_once(':') {
|
||||||
|
headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_str = String::from_utf8_lossy(body_bytes).to_string();
|
||||||
|
(status_code, headers, body_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper for GET requests.
|
||||||
|
async fn send_http_get(addr: SocketAddr, path: &str) -> (u16, HashMap<String, String>, String) {
|
||||||
|
send_http_request(addr, "GET", path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper for JSON POST requests.
|
||||||
|
async fn send_http_json(
|
||||||
|
addr: SocketAddr,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> (u16, HashMap<String, String>, String) {
|
||||||
|
send_http_request(addr, method, path, Some(body)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_structs_exports() {
|
||||||
|
// Verify that all documented request structs deserialize properly
|
||||||
|
let _ptz: PtzRequest = serde_json::from_str(
|
||||||
|
r#"{"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}"#,
|
||||||
|
)
|
||||||
|
.expect("PtzRequest deserialization failed");
|
||||||
|
|
||||||
|
let _mode: ModeRequest = serde_json::from_str(
|
||||||
|
r#"{"mode": "DeskView", "pitch": -450, "zoom": 150}"#,
|
||||||
|
)
|
||||||
|
.expect("ModeRequest deserialization failed");
|
||||||
|
|
||||||
|
let _preset: PresetRequest = serde_json::from_str(
|
||||||
|
r#"{"action": "save", "index": 3}"#,
|
||||||
|
)
|
||||||
|
.expect("PresetRequest deserialization failed");
|
||||||
|
|
||||||
|
let _img: ImageRequest = serde_json::from_str(
|
||||||
|
r#"{"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}"#,
|
||||||
|
)
|
||||||
|
.expect("ImageRequest deserialization failed");
|
||||||
|
|
||||||
|
let _track: TrackingRequest = serde_json::from_str(
|
||||||
|
r#"{"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}"#,
|
||||||
|
)
|
||||||
|
.expect("TrackingRequest deserialization failed");
|
||||||
|
|
||||||
|
let _audio: AudioRequest = serde_json::from_str(
|
||||||
|
r#"{"audio_mode": "VoiceSuppression"}"#,
|
||||||
|
)
|
||||||
|
.or_else(|_| serde_json::from_str(r#"{"mode": "VoiceSuppression"}"#))
|
||||||
|
.expect("AudioRequest deserialization failed");
|
||||||
|
|
||||||
|
let _sys: SystemRequest = serde_json::from_str(
|
||||||
|
r#"{"privacy_mode": true, "horizontal_correction": true}"#,
|
||||||
|
)
|
||||||
|
.expect("SystemRequest deserialization failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1. GET / returns 200 OK with text/html containing the embedded Web Remote Control UI.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_web_remote_control_ui() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
let (status, headers, body) = send_http_get(addr, "/").await;
|
||||||
|
|
||||||
|
assert_eq!(status, 200, "GET / did not return 200 OK");
|
||||||
|
let content_type = headers
|
||||||
|
.get("content-type")
|
||||||
|
.expect("Missing Content-Type header on GET /");
|
||||||
|
assert!(
|
||||||
|
content_type.contains("text/html"),
|
||||||
|
"Content-Type should be text/html, got '{content_type}'"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("<!DOCTYPE html>")
|
||||||
|
|| body.contains("<!doctype html>")
|
||||||
|
|| body.contains("<html")
|
||||||
|
|| body.contains("Insta360"),
|
||||||
|
"GET / body did not contain expected HTML UI content. Body preview: {}",
|
||||||
|
&body[..body.len().min(300)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 2. GET /api/state returns 200 OK and deserializes cleanly into CameraStateSnapshot
|
||||||
|
/// with CameraModel::Link2 and zoom == 100.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_api_state_initial() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
let (status, headers, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
|
||||||
|
assert_eq!(status, 200, "GET /api/state did not return 200 OK");
|
||||||
|
let content_type = headers
|
||||||
|
.get("content-type")
|
||||||
|
.expect("Missing Content-Type header on GET /api/state");
|
||||||
|
assert!(
|
||||||
|
content_type.contains("application/json"),
|
||||||
|
"Content-Type should be application/json, got '{content_type}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||||
|
.unwrap_or_else(|e| panic!("Failed to deserialize CameraStateSnapshot: {e}\nPayload: {body}"));
|
||||||
|
|
||||||
|
assert_eq!(snapshot.model, CameraModel::Link2);
|
||||||
|
assert_eq!(snapshot.zoom, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3. POST /api/ptz with {"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}
|
||||||
|
/// updates pan_arcsec, tilt_arcsec, zoom, and real_zoom.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_post_api_ptz() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/ptz",
|
||||||
|
r#"{"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200, "POST /api/ptz failed");
|
||||||
|
|
||||||
|
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||||
|
.expect("Failed to deserialize CameraStateSnapshot after PTZ update");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.pan_arcsec, 36000);
|
||||||
|
assert_eq!(snapshot.tilt_arcsec, -18000);
|
||||||
|
assert_eq!(snapshot.zoom, 250);
|
||||||
|
assert_eq!(snapshot.real_zoom, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 4. POST /api/mode with {"mode": "DeskView", "pitch": -450, "zoom": 150}
|
||||||
|
/// switches video_mode to VideoMode::DeskView and updates video_aux.pitch to -450.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_post_api_mode() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/mode",
|
||||||
|
r#"{"mode": "DeskView", "pitch": -450, "zoom": 150}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200, "POST /api/mode failed");
|
||||||
|
|
||||||
|
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||||
|
.expect("Failed to deserialize CameraStateSnapshot after mode update");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.video_mode, VideoMode::DeskView);
|
||||||
|
assert_eq!(snapshot.video_aux.pitch, -450);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 5. POST /api/image with {"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}
|
||||||
|
/// updates all image parameters and sets ExtendFunction::Hdr to true.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_post_api_image() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/image",
|
||||||
|
r#"{"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200, "POST /api/image failed");
|
||||||
|
|
||||||
|
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||||
|
.expect("Failed to deserialize CameraStateSnapshot after image update");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.brightness, 75);
|
||||||
|
assert_eq!(snapshot.contrast, 65);
|
||||||
|
assert_eq!(snapshot.wb_auto, false);
|
||||||
|
assert_eq!(snapshot.wb_kelvin, 5600);
|
||||||
|
assert_eq!(snapshot.filter_mode, FilterMode::Warm);
|
||||||
|
|
||||||
|
let hdr_enabled = snapshot
|
||||||
|
.extend_functions
|
||||||
|
.iter()
|
||||||
|
.any(|(f, en)| *f == ExtendFunction::Hdr && *en);
|
||||||
|
assert!(hdr_enabled, "ExtendFunction::Hdr was not set to true in snapshot.extend_functions");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 6. POST /api/tracking with {"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}
|
||||||
|
/// updates tracking speed, composition style, and gesture bindings.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_post_api_tracking() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/tracking",
|
||||||
|
r#"{"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200, "POST /api/tracking failed");
|
||||||
|
|
||||||
|
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||||
|
.expect("Failed to deserialize CameraStateSnapshot after tracking update");
|
||||||
|
|
||||||
|
assert_eq!(snapshot.track_speed, TrackSpeed::Fast);
|
||||||
|
assert_eq!(snapshot.composition_style, CompositionStyle::WholeBody);
|
||||||
|
|
||||||
|
let palm_binding = snapshot
|
||||||
|
.gesture_bindings
|
||||||
|
.iter()
|
||||||
|
.find(|(g, _)| *g == Gesture::Palm);
|
||||||
|
assert_eq!(
|
||||||
|
palm_binding.map(|(_, f)| *f),
|
||||||
|
Some(GestureFunction::AutoFraming),
|
||||||
|
"Expected Palm gesture binding to be AutoFraming"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 7. POST /api/preset with {"action": "save", "index": 3} sets bit 3 in preset_flags.slot_mask.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_post_api_preset() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/preset",
|
||||||
|
r#"{"action": "save", "index": 3}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200, "POST /api/preset failed");
|
||||||
|
|
||||||
|
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||||
|
.expect("Failed to deserialize CameraStateSnapshot after preset update");
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
snapshot.preset_flags.slot_mask & (1 << 3),
|
||||||
|
0,
|
||||||
|
"Bit 3 in preset_flags.slot_mask should be set after saving preset slot 3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 8. POST /api/audio and POST /api/system update audio_mode, privacy_mode, and horizontal_correction.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_post_api_audio_and_system() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
|
||||||
|
// Audio update
|
||||||
|
let mut audio_res = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/audio",
|
||||||
|
r#"{"audio_mode": "VoiceSuppression"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if audio_res.0 != 200 {
|
||||||
|
audio_res = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/audio",
|
||||||
|
r#"{"mode": "VoiceSuppression"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
assert_eq!(audio_res.0, 200, "POST /api/audio failed");
|
||||||
|
|
||||||
|
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||||
|
.expect("Failed to deserialize CameraStateSnapshot after audio update");
|
||||||
|
assert_eq!(snapshot.audio_mode, AudioCaptureMode::VoiceSuppression);
|
||||||
|
|
||||||
|
// System update
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/system",
|
||||||
|
r#"{"privacy_mode": true, "horizontal_correction": true}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200, "POST /api/system failed");
|
||||||
|
|
||||||
|
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||||
|
.expect("Failed to deserialize CameraStateSnapshot after system update");
|
||||||
|
assert!(snapshot.privacy_mode, "privacy_mode should be true");
|
||||||
|
assert!(
|
||||||
|
snapshot.horizontal_correction,
|
||||||
|
"horizontal_correction should be true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete end-to-end integration flow testing all 8 steps sequentially on a single
|
||||||
|
/// server instance over TCP sockets.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_server_full_integration_flow() {
|
||||||
|
let addr = start_test_server(CameraModel::Link2).await;
|
||||||
|
|
||||||
|
// 1. GET / returns 200 OK with text/html UI
|
||||||
|
let (status, headers, body) = send_http_get(addr, "/").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert!(headers.get("content-type").unwrap().contains("text/html"));
|
||||||
|
assert!(
|
||||||
|
body.contains("<!DOCTYPE html>")
|
||||||
|
|| body.contains("<!doctype html>")
|
||||||
|
|| body.contains("<html")
|
||||||
|
|| body.contains("Insta360")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. GET /api/state initial state
|
||||||
|
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||||
|
assert_eq!(snapshot.model, CameraModel::Link2);
|
||||||
|
assert_eq!(snapshot.zoom, 100);
|
||||||
|
|
||||||
|
// 3. POST /api/ptz
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/ptz",
|
||||||
|
r#"{"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||||
|
assert_eq!(snapshot.pan_arcsec, 36000);
|
||||||
|
assert_eq!(snapshot.tilt_arcsec, -18000);
|
||||||
|
assert_eq!(snapshot.zoom, 250);
|
||||||
|
assert_eq!(snapshot.real_zoom, 250);
|
||||||
|
|
||||||
|
// 4. POST /api/mode
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/mode",
|
||||||
|
r#"{"mode": "DeskView", "pitch": -450, "zoom": 150}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||||
|
assert_eq!(snapshot.video_mode, VideoMode::DeskView);
|
||||||
|
assert_eq!(snapshot.video_aux.pitch, -450);
|
||||||
|
|
||||||
|
// 5. POST /api/image
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/image",
|
||||||
|
r#"{"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||||
|
assert_eq!(snapshot.brightness, 75);
|
||||||
|
assert_eq!(snapshot.contrast, 65);
|
||||||
|
assert_eq!(snapshot.wb_auto, false);
|
||||||
|
assert_eq!(snapshot.wb_kelvin, 5600);
|
||||||
|
assert_eq!(snapshot.filter_mode, FilterMode::Warm);
|
||||||
|
assert!(snapshot
|
||||||
|
.extend_functions
|
||||||
|
.iter()
|
||||||
|
.any(|(f, en)| *f == ExtendFunction::Hdr && *en));
|
||||||
|
|
||||||
|
// 6. POST /api/tracking
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/tracking",
|
||||||
|
r#"{"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||||
|
assert_eq!(snapshot.track_speed, TrackSpeed::Fast);
|
||||||
|
assert_eq!(snapshot.composition_style, CompositionStyle::WholeBody);
|
||||||
|
let palm_binding = snapshot
|
||||||
|
.gesture_bindings
|
||||||
|
.iter()
|
||||||
|
.find(|(g, _)| *g == Gesture::Palm);
|
||||||
|
assert_eq!(
|
||||||
|
palm_binding.map(|(_, f)| *f),
|
||||||
|
Some(GestureFunction::AutoFraming)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. POST /api/preset
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/preset",
|
||||||
|
r#"{"action": "save", "index": 3}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||||
|
assert_ne!(snapshot.preset_flags.slot_mask & (1 << 3), 0);
|
||||||
|
|
||||||
|
// 8. POST /api/audio and POST /api/system
|
||||||
|
let mut audio_res = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/audio",
|
||||||
|
r#"{"audio_mode": "VoiceSuppression"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if audio_res.0 != 200 {
|
||||||
|
audio_res = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/audio",
|
||||||
|
r#"{"mode": "VoiceSuppression"}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
assert_eq!(audio_res.0, 200);
|
||||||
|
|
||||||
|
let (status, _, _) = send_http_json(
|
||||||
|
addr,
|
||||||
|
"POST",
|
||||||
|
"/api/system",
|
||||||
|
r#"{"privacy_mode": true, "horizontal_correction": true}"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||||
|
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||||
|
assert_eq!(snapshot.audio_mode, AudioCaptureMode::VoiceSuppression);
|
||||||
|
assert!(snapshot.privacy_mode);
|
||||||
|
assert!(snapshot.horizontal_correction);
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
# Insta360 Link / Link 2 / Link 2C USB & UVC Protocol Specification
|
||||||
|
|
||||||
|
This document provides the complete, byte-accurate hardware control protocol specification for the **Insta360 Link**, **Insta360 Link 2**, **Insta360 Link 2C**, and **Insta360 Link 2 Pro (PUC3)** webcam family.
|
||||||
|
|
||||||
|
All protocol structures, USB descriptors, UVC Extension Unit GUIDs, control selectors, sub-opcodes, payload layouts, and state machines documented here were reverse-engineered directly from the official macOS binary:
|
||||||
|
|
||||||
|
- **Binary Path**: `/Applications/Insta360 Link Controller.app/Contents/MacOS/Webcam-desktop`
|
||||||
|
- **Application Version**: `2.0.2` (`arm64` Mach-O executable)
|
||||||
|
- **SHA-256 Digest**: `41db6e352223f06fa2fd70989a4d2faf622fd9c14b16a84da2aff39c0dcff22e`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. USB Device Identification & Firmware Images
|
||||||
|
|
||||||
|
All Insta360 Link family cameras enumerate under Arashi Vision's USB Vendor ID (`0x2e1a`). Device discovery is implemented in `MacUVCCameraControlImp::GetUVCCameraList` (`0x100191478`) and `UVCCameraDeviceWatcherImp::StartWatch` (`0x10019e29c`).
|
||||||
|
|
||||||
|
- **USB Vendor ID (`idVendor`)**: `0x2e1a` (`11802` decimal — Arashi Vision Inc. / Insta360)
|
||||||
|
|
||||||
|
### 1.1 Product IDs (`idProduct`) and Firmware Targets
|
||||||
|
|
||||||
|
Verified at `0x100191480` (`0x4c01..=0x4c06`), `0x100191664` (`0x00c2`), `Webcam::CameraInsta::GetCameraModel()` (`0x10005f92c`), `UVCCameraExtendControllerImp::FirmwareUpgrade` jump table (`0x10286f37d`), and firmware path table (`0x1001a0a0c`):
|
||||||
|
|
||||||
|
| PID (`idProduct`) | Camera Model | Internal Code | Physical Gimbal | `SetVideoMode` Payload | Virtual MSC Firmware Target Path |
|
||||||
|
| :--- | :--- | :--- | :---: | :---: | :--- |
|
||||||
|
| `0x4c01` | Insta360 Link (1st Gen) | `link1` | Yes (3-axis) | 8 bytes | `a:\Insta360WebCamFW.bin` |
|
||||||
|
| `0x4c02` | Insta360 Link 2 Pro (PUC3 Gimbal A) | `link2_gimbal` | Yes (2-axis) | 8 bytes | `a:\Insta360PUC3FW_GIMBAL.bin` |
|
||||||
|
| `0x4c03` | Insta360 Link 2C (Fixed-Lens 4K) | `link2_main` | No | 36 bytes | `a:\Insta360LINK2CFW_HOST.bin` |
|
||||||
|
| `0x4c04` | Insta360 Link 2 (2-Axis Gimbal 4K) | `link2_gimbal` | Yes (2-axis) | 8 bytes | `a:\Insta360LINK2FW_GIMBAL.bin` |
|
||||||
|
| `0x4c05` | Insta360 Link 2C Pro (PUC3 Host) | `link2_main` | No | 36 bytes | `a:\Insta360PUC3FW_HOST.bin` |
|
||||||
|
| `0x4c06` | Insta360 Link 2 Pro (PUC3 Gimbal B) | `link2_gimbal` | Yes (2-axis) | 8 bytes | `a:\Insta360PUC3FW_GIMBAL.bin` |
|
||||||
|
| `0x00c2` | Insta360 Link Simple-Class / DFU | `link1` | — | 36 bytes | `a:\Insta360WebCamFW.bin` |
|
||||||
|
|
||||||
|
> **`SetVideoMode` Payload Length Note (`0x1001970f4`)**:
|
||||||
|
> In `MacUVCCameraExtendController::SetVideoMode`, when a 36-byte (`0x24`) `VideoModeAuxiliaryData` structure is supplied and `pid` is in `0x4c01..=0x4c06`, the binary tests bit `(pid - 0x4c01)` against the bitmask `0x2b` (`0b101011`, matching PIDs `0x4c01`, `0x4c02`, `0x4c04`, `0x4c06`). If the bit is set, the transfer length is truncated to **8 bytes** (`w4 = 8`). For gimbal-less models (`0x4c03` Link 2C and `0x4c05` PUC3 Host), the full **36-byte** (`0x24`) buffer is transmitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Per-Model Hardware Capability Matrix
|
||||||
|
|
||||||
|
Derived from `Webcam::CameraDeviceSupporter::UpdateUIStatus` (`0x10004a8b8`), the controller determines per-device feature support directly from the USB Product ID:
|
||||||
|
|
||||||
|
| Capability Flag | Condition in Binary (`0x10004a8b8`) | `0x4c01` Link | `0x4c02` PUC3 Gimbal A | `0x4c03` Link 2C | `0x4c04` Link 2 | `0x4c05` PUC3 Host | `0x4c06` PUC3 Gimbal B |
|
||||||
|
| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||||
|
| `has_physical_gimbal` | `pid != 0x4c03 && pid != 0x4c05` | Yes | Yes | No | Yes | No | Yes |
|
||||||
|
| `support_ptz_drag` | `pid != 0x4c03 && pid != 0x4c05` | Yes | Yes | No | Yes | No | Yes |
|
||||||
|
| `support_auto_framing` | `pid != 0x4c01` | No | Yes | Yes | Yes | Yes | Yes |
|
||||||
|
| `support_highlighting` | `pid != 0x4c01` | No | Yes | Yes | Yes | Yes | Yes |
|
||||||
|
| `support_mirror_vertically` | `pid != 0x4c01` | No | Yes | Yes | Yes | Yes | Yes |
|
||||||
|
| `support_track_speed` | `pid != 0x4c03 && pid != 0x4c05` | Yes | Yes | No | Yes | No | Yes |
|
||||||
|
| `support_image_template` | `pid != 0x4c01` | No | Yes | Yes | Yes | Yes | Yes |
|
||||||
|
| `support_privacy_mode` | `pid != 0x4c01` | No | Yes | Yes | Yes | Yes | Yes |
|
||||||
|
| `support_area_track` | `(pid & 0xfffd) == 0x4c04` | No | No | No | Yes | No | Yes |
|
||||||
|
| `support_roll_adjustment` | `pid == 0x4c01` | Yes | No | No | No | No | No |
|
||||||
|
| `support_ai_denoise` | `pid != 0x4c01` | No | Yes | Yes | Yes | Yes | Yes |
|
||||||
|
| `support_sound_wall` | `pid == 0x4c05 \|\| pid == 0x4c06` | No | No | No | No | Yes | Yes |
|
||||||
|
| `support_speak_tracking` | `(pid & 0xfffd) == 0x4c04` | No | No | No | Yes | No | Yes |
|
||||||
|
| `support_auto_track` | `pid != 0x4c03 && pid != 0x4c05` | Yes | Yes | No | Yes | No | Yes |
|
||||||
|
| `support_smart_adjustment` | `pid != 0x4c03 && pid != 0x4c05` | Yes | Yes | No | Yes | No | Yes |
|
||||||
|
| `support_audio_capture_mode` | `pid != 0x4c01` | No | Yes | Yes | Yes | Yes | Yes |
|
||||||
|
| `support_horizontal_correction` | `pid == 0x4c04` | No | No | No | Yes | No | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UVC Descriptor Discovery & Extension Unit GUIDs
|
||||||
|
|
||||||
|
In `FindControllerInterfaceForServiceObject` (`0x100190fe0`), the host walks the USB Configuration Descriptor (`bDescriptorType = 0x04` Interface Descriptor) looking for the UVC VideoControl interface:
|
||||||
|
|
||||||
|
- `bInterfaceClass = 0x0e` (`CC_VIDEO`)
|
||||||
|
- `bInterfaceSubclass = 0x01` (`SC_VIDEOCONTROL`)
|
||||||
|
|
||||||
|
Within the VideoControl interface, class-specific interface descriptors (`bDescriptorType = 0x24`, `CS_INTERFACE`) are parsed by `bDescriptorSubtype`:
|
||||||
|
|
||||||
|
1. **`VC_INPUT_TERMINAL` (`0x02`)**:
|
||||||
|
- Checks `wTerminalType == 0x0201` (`ITT_CAMERA` at byte offset `4..6`) and records `bTerminalID` (`desc[3]`).
|
||||||
|
2. **`VC_PROCESSING_UNIT` (`0x05`)**:
|
||||||
|
- Records `bUnitID` (`desc[3]`).
|
||||||
|
3. **`VC_EXTENSION_UNIT` (`0x06`)**:
|
||||||
|
- Compares the 16-byte `guidExtensionCode` (`desc[4..20]`) against two proprietary Insta360 Extension Unit GUIDs.
|
||||||
|
|
||||||
|
### 3.1 Proprietary Extension Unit GUIDs
|
||||||
|
|
||||||
|
| Extension Unit | Canonical UUID String (`0x102a9d098` / `0x102a9d0b8`) | Mixed-Endian 16-Byte USB `guidExtensionCode` (`desc[4..20]`) | Default `bUnitID` |
|
||||||
|
| :--- | :--- | :--- | :---: |
|
||||||
|
| **Extension Unit 1 (`XU1`)** | `FAF1672D-B71B-4793-8C91-7B1C9B7F95F8` | `2d 67 f1 fa 1b b7 93 47 8c 91 7b 1c 9b 7f 95 f8` | `9` (`0x09`) |
|
||||||
|
| **Extension Unit 2 (`XU2`)** | `E307E649-4618-A3FF-82FC-2D8B5F216773` | `49 e6 07 e3 18 46 ff a3 82 fc 2d 8b 5f 21 67 73` | `10` (`0x0a`) |
|
||||||
|
|
||||||
|
> **Mixed-Endian UUID Conversion (`0x100191140` & `0x100191168`)**:
|
||||||
|
> Per the USB Video Class specification, the first three fields of `guidExtensionCode` (`Data1: u32`, `Data2: u16`, `Data3: u16`) are encoded in **little-endian** byte order, while `Data4: [u8; 8]` is encoded in **big-endian** (network) byte order:
|
||||||
|
> - `FAF1672D-B71B-4793-8C91-7B1C9B7F95F8` → `2D 67 F1 FA` | `1B B7` | `93 47` | `8C 91` | `7B 1C 9B 7F 95 F8`
|
||||||
|
> - `E307E649-4618-A3FF-82FC-2D8B5F216773` → `49 E6 07 E3` | `18 46` | `FF A3` | `82 FC` | `2D 8B 5F 21 67 73`
|
||||||
|
|
||||||
|
### 3.2 Default UVC Unit IDs
|
||||||
|
|
||||||
|
When UVC descriptors are not overridden, Insta360 Link family cameras use the following standard unit IDs:
|
||||||
|
|
||||||
|
| Unit / Interface | Constant | Default ID |
|
||||||
|
| :--- | :--- | :---: |
|
||||||
|
| UVC VideoControl Interface | `DEFAULT_CONTROL_INTERFACE` | `0` (`0x00`) |
|
||||||
|
| Camera Input Terminal (`CT`) | `DEFAULT_INPUT_TERMINAL_ID` | `1` (`0x01`) |
|
||||||
|
| Processing Unit (`PU`) | `DEFAULT_PROCESSING_UNIT_ID` | `2` (`0x02`) |
|
||||||
|
| Insta360 Extension Unit 1 (`XU1`) | `DEFAULT_EXTENSION_UNIT_ID` | `9` (`0x09`) |
|
||||||
|
| Insta360 Extension Unit 2 (`XU2`) | `DEFAULT_EXTENSION_UNIT2_ID` | `10` (`0x0a`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UVC Control Request Framing
|
||||||
|
|
||||||
|
All standard and extension unit commands are transmitted as USB Control Transfers to Endpoint 0 targeting the UVC VideoControl interface (`MacUVCCameraControl::SetData` at `0x100192880` and `MacUVCCameraControl::GetData` at `0x10019282c`).
|
||||||
|
|
||||||
|
### 4.1 USB Setup Packet Layout
|
||||||
|
|
||||||
|
| Field | Size | Value / Formula | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `bmRequestType` | `u8` | `0x21` (`SET_CUR`) or `0xa1` (`GET_*`) | Class-specific (`0x20`), Interface recipient (`0x01`), Host-to-Device (`0x00`) or Device-to-Host (`0x80`) |
|
||||||
|
| `bRequest` | `u8` | `0x01` or `0x81..0x87` | UVC Request Code (see table below) |
|
||||||
|
| `wValue` | `u16` (LE) | `(selector as u16) << 8` | Control Selector (`CS`) in high byte, `0x00` in low byte |
|
||||||
|
| `wIndex` | `u16` (LE) | `((unit_id as u16) << 8) \| (interface_num as u16)` | Entity/Unit/Terminal ID in high byte, VideoControl `bInterfaceNumber` in low byte |
|
||||||
|
| `wLength` | `u16` (LE) | `data.len() as u16` | Payload length in bytes |
|
||||||
|
|
||||||
|
### 4.2 UVC Request Codes (`bRequest`)
|
||||||
|
|
||||||
|
| Request Name | `bmRequestType` | `bRequest` | Direction | Usage |
|
||||||
|
| :--- | :---: | :---: | :---: | :--- |
|
||||||
|
| `SET_CUR` | `0x21` | `0x01` | OUT (Host → Camera) | Write control value or execute command |
|
||||||
|
| `GET_CUR` | `0xa1` | `0x81` | IN (Camera → Host) | Read current control value or status |
|
||||||
|
| `GET_MIN` | `0xa1` | `0x82` | IN (Camera → Host) | Read minimum supported value |
|
||||||
|
| `GET_MAX` | `0xa1` | `0x83` | IN (Camera → Host) | Read maximum supported value |
|
||||||
|
| `GET_RES` | `0xa1` | `0x84` | IN (Camera → Host) | Read step resolution |
|
||||||
|
| `GET_LEN` | `0xa1` | `0x85` | IN (Camera → Host) | Read payload length (`u16` LE, 2 bytes) for variable-length XU selectors |
|
||||||
|
| `GET_INFO` | `0xa1` | `0x86` | IN (Camera → Host) | Read control capability flags (1 byte) |
|
||||||
|
| `GET_DEF` | `0xa1` | `0x87` | IN (Camera → Host) | Read factory default value |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Standard UVC Camera Terminal (`CT_*`) & Processing Unit (`PU_*`) Controls
|
||||||
|
|
||||||
|
Implemented in `MacUVCCameraController` (`0x100193694`–`0x100195800`).
|
||||||
|
|
||||||
|
### 5.1 Camera Terminal Controls (`unit_id = input_terminal_id`, default `1`)
|
||||||
|
|
||||||
|
| Selector | UVC Control Name | Size | Type (LE) | Description & Typical Values |
|
||||||
|
| :---: | :--- | :---: | :---: | :--- |
|
||||||
|
| `0x02` | `CT_AE_MODE_CONTROL` | 1B | `u8` | Auto-Exposure mode bitmap: `1` = Manual, `2` = Auto, `4` = Shutter Priority, `8` = Aperture Priority |
|
||||||
|
| `0x04` | `CT_EXPOSURE_TIME_ABSOLUTE_CONTROL` | 4B | `u32` | Shutter speed / exposure duration in `100 µs` units (e.g. `10..1000`, default `200`) |
|
||||||
|
| `0x06` | `CT_FOCUS_ABSOLUTE_CONTROL` | 2B | `u16` | Manual focus distance (`0..100`, step `1`, default `50`) |
|
||||||
|
| `0x08` | `CT_FOCUS_AUTO_CONTROL` | 1B | `u8` | Auto-focus enable: `0` = Manual Focus, `1` = Auto Focus |
|
||||||
|
| `0x0b` | `CT_ZOOM_ABSOLUTE_CONTROL` | 2B | `u16` | Digital zoom multiplier (`100` = 1.0x .. `400` = 4.0x, step `1`, default `100`) |
|
||||||
|
| `0x0d` | `CT_PANTILT_ABSOLUTE_CONTROL` | 8B | `(i32, i32)` | `[0..4]` Pan angle in arcseconds (`-468000..+468000`), `[4..8]` Tilt angle in arcseconds (`-324000..+324000`); `1° = 3600 arcsec` |
|
||||||
|
| `0x0e` | `CT_PANTILT_RELATIVE_CONTROL` | 4B | `[i8, u8, i8, u8]` | `[0]` `pan_dir` (`-1`, `0`, `+1`), `[1]` `pan_speed`, `[2]` `tilt_dir` (`-1`, `0`, `+1`), `[3]` `tilt_speed` |
|
||||||
|
| `0x0f` | `CT_ROLL_ABSOLUTE_CONTROL` | 2B | `i16` | Gimbal roll angle in tenths of a degree (Link 1 `0x4c01` only, e.g. `-1800..+1800`) |
|
||||||
|
|
||||||
|
### 5.2 Processing Unit Controls (`unit_id = processing_unit_id`, default `2`)
|
||||||
|
|
||||||
|
| Selector | UVC Control Name | Size | Type (LE) | Description & Typical Values |
|
||||||
|
| :---: | :--- | :---: | :---: | :--- |
|
||||||
|
| `0x02` | `PU_BRIGHTNESS_CONTROL` | 2B | `i16` | Image brightness (`0..100`, step `1`, default `50`) |
|
||||||
|
| `0x03` | `PU_CONTRAST_CONTROL` | 2B | `u16` | Image contrast (`0..100`, step `1`, default `50`) |
|
||||||
|
| `0x05` | `PU_POWER_LINE_FREQUENCY_CONTROL` | 1B | `u8` | Anti-flicker frequency: `0` = Disabled, `1` = 50 Hz, `2` = 60 Hz, `3` = Auto |
|
||||||
|
| `0x07` | `PU_SATURATION_CONTROL` | 2B | `u16` | Color saturation (`0..100`, step `1`, default `50`) |
|
||||||
|
| `0x08` | `PU_SHARPNESS_CONTROL` | 2B | `u16` | Image sharpness (`0..100`, step `1`, default `50`) |
|
||||||
|
| `0x0a` | `PU_WHITE_BALANCE_TEMPERATURE_CONTROL` | 2B | `u16` | Color temperature in Kelvin (`2000..10000`, step `50`, default `5000`) |
|
||||||
|
| `0x0c` | `PU_WHITE_BALANCE_TEMPERATURE_AUTO_CONTROL` | 1B | `u8` | Auto White Balance (AWB): `0` = Manual, `1` = Auto |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Proprietary Extension Unit 1 (`XU1`) Selectors (`0x01` – `0x0f`)
|
||||||
|
|
||||||
|
- **GUID**: `FAF1672D-B71B-4793-8C91-7B1C9B7F95F8`
|
||||||
|
- **Default Unit ID**: `9` (`0x09`)
|
||||||
|
- **Binary Implementation**: `MacUVCCameraExtendController` (`0x100195a18`–`0x10019bd00`)
|
||||||
|
|
||||||
|
### Summary Table of `XU1` Selectors
|
||||||
|
|
||||||
|
| Selector | Constant Name | Size | Requests | Primary Function |
|
||||||
|
| :---: | :--- | :---: | :---: | :--- |
|
||||||
|
| `0x01` | `SYSTEM_AND_PRESET_CMD` | 4B / 5B | `SET_CUR` | Preset recall/add/delete, Gimbal reset, Factory default, Composition bias, Forbidden area mode |
|
||||||
|
| `0x02` | `VIDEO_MODE` | 8B / 36B | `SET_CUR`, `GET_CUR` | Video mode (`Normal`, `AutoTracking`, `WhiteBoard`, `OverheadShot`, `DeskView`, `AutoFraming`, `SmartWhiteBoard`) & ROI/pitch/zoom |
|
||||||
|
| `0x03` | `TRACKING_AND_GESTURE` | 6B / 16B | `SET_CUR`, `GET_CUR` | Hand gesture enables, AI tracking speed, Composition style, Smart Whiteboard force |
|
||||||
|
| `0x04` | `AUDIO_CONTROL` | 2B | `SET_CUR`, `GET_CUR` | Audio AI noise reduction (`0..1`) & Audio capture mode (`1..5`) |
|
||||||
|
| `0x05` | `TRACK_OBJECT_SELECT` | 10B | `SET_CUR` | Select target person/object bounding box (`UVCRect`) for AI tracking |
|
||||||
|
| `0x06` | `TRACK_OBJECT_LIST_V1` | `N * 10B` | `GET_LEN`, `GET_CUR` | Detected trackable objects bounding box array (Insta360 Link 1 `0x4c01`) |
|
||||||
|
| `0x07` | `EXPOSURE_CURVE_AND_ISO` | 259B | `SET_CUR`, `GET_CUR` | Auto/Manual exposure mode (`u8`), Manual ISO (`u16`), and 128-point tone curve (`[u16; 128]`) |
|
||||||
|
| `0x08` | `EXPOSURE_COMPENSATION` | 4B | `SET_CUR`, `GET_CUR` | Exposure compensation EV (`f32` IEEE-754 little-endian) |
|
||||||
|
| `0x09` | `WHITE_BALANCE_EXT` | 129B | `SET_CUR`, `GET_CUR` | Extended white balance mode (`u8`) and 32-channel gain array (`[u32; 32]`) |
|
||||||
|
| `0x0a` | `EXTEND_FUNC_BITMASK` | 8B | `SET_CUR`, `GET_CUR` | 15-bit Extended Function bitmask (`u32 mask`, `u32 values`) |
|
||||||
|
| `0x0b` | `PANTILT_RELATIVE_EXT` | 4B | `SET_CUR`, `GET_CUR` | Proprietary gimbal relative pan/tilt (`[pan_speed, pan_dir, tilt_speed, tilt_dir]`) |
|
||||||
|
| `0x0c` | `DEVICE_STATUS` | 4B | `GET_CUR` | Runtime device status bitflags (`u32`) |
|
||||||
|
| `0x0d` | `PTZ_VERSION_AND_ABS` | 30B | `GET_CUR` | Gimbal firmware version string (`[0..20]`) and current pan/tilt/zoom (`[20..27]`) |
|
||||||
|
| `0x0e` | `PRESET_POSITION_FLAGS` | 8B | `GET_CUR` | Populated gimbal preset slot bitmask, active slot, and max slots |
|
||||||
|
| `0x0f` | `USB_MODE_SWITCH` | 4B | `SET_CUR` | Switch to Read-Only Mass Storage Class (`[2, 0, 0, 0]`) or camera file download (`[1, 0, 0, 0]`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Detailed `XU1` Selector Payloads
|
||||||
|
|
||||||
|
#### `XU1 0x01` — `SYSTEM_AND_PRESET_CMD` (4 or 5 Bytes, `SET_CUR`)
|
||||||
|
Multiplexed command register dispatched by sub-opcode in `byte[0]`:
|
||||||
|
|
||||||
|
| Sub-Opcode (`byte[0]`) | Method in Binary | Address | Length | Wire Payload (Hex / Layout) | Description |
|
||||||
|
| :---: | :--- | :--- | :---: | :--- | :--- |
|
||||||
|
| `0x00` | `SetCurPresetPosition(u8 index)` | `0x100199918` | 4B | `[0x00, index, 0x00, 0x00]` | Recall gimbal preset slot `index` (`0..=5`) |
|
||||||
|
| `0x01` | `AddPresetPosition(u8 index)` | `0x1001999b4` | 4B | `[0x01, index, 0x00, 0x00]` | Save current gimbal PTZ to slot `index` (`0..=5`) |
|
||||||
|
| `0x02` | `DeletePresetPosition(u8 index)` | `0x100199a54` | 4B | `[0x02, index, 0x00, 0x00]` | Delete gimbal preset slot `index` (`0..=5`) |
|
||||||
|
| `0x03` | `ResetGimbal()` | `0x100196fb0` | 4B | `[0x03, 0x00, 0x00, 0x00]` | Center/home gimbal pan, tilt, roll, and zoom |
|
||||||
|
| `0x04` | `LoadFactoryDefault()` | `0x10019701c` | 4B | `[0x04, 0x00, 0x00, 0x00]` | Reset image/camera parameters to factory defaults |
|
||||||
|
| `0x06` | `SetCompositionBias(i16 x, i16 y)` | `0x10019933c` | 5B | `[0x06, x_lo, x_hi, y_lo, y_hi]` | Set AI tracking composition offset (`x = 0x7fff, y = 0x7fff` resets bias) |
|
||||||
|
| `0x07` | `EnterTrackForbiddenAreaSetting()` | `0x10019bf74` | 4B | `[0x07, 0x01, 0x00, 0x00]` | Enter interactive tracking forbidden-area calibration mode |
|
||||||
|
| `0x08` | `ExitTrackForbiddenAreaSetting()` | `0x10019bfb4` | 4B | `[0x08, 0x01, 0x00, 0x00]` | Exit tracking forbidden-area calibration mode |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x02` — `VIDEO_MODE` (8 or 36 Bytes)
|
||||||
|
Verified in `SetVideoMode` (`0x100197088`) and `GetVideoMode` (`0x1001978b8`).
|
||||||
|
|
||||||
|
**`VideoMode` Enum Values (`byte[0]`, verified in `CameraInsta` at `0x1000601d4`–`0x100060234`)**:
|
||||||
|
| Value | Variant | UI Display Name | Description |
|
||||||
|
| :---: | :--- | :--- | :--- |
|
||||||
|
| `0` | `Normal` | Normal | Standard webcam mode |
|
||||||
|
| `1` | `AutoTracking` | AI Tracking | Single-person gimbal/digital AI tracking |
|
||||||
|
| `4` | `WhiteBoard` | Whiteboard | Corner-marker whiteboard keystone enhancement |
|
||||||
|
| `5` | `OverheadShot` | Overhead | Gimbal pitched straight down (-90°) for top-down view |
|
||||||
|
| `6` | `DeskView` | DeskView | Tilted trapezoid perspective correction for desktop view |
|
||||||
|
| `7` | `AutoFraming` | Auto Framing | Multi-person group framing (Link 2 / Link 2C / PUC3) |
|
||||||
|
| `8` (or `10`) | `SmartWhiteBoard` | Smart Whiteboard | Markerless AI whiteboard detection & perspective correction |
|
||||||
|
|
||||||
|
**Wire Layout (36-Byte `VideoModeAuxiliaryData`)**:
|
||||||
|
| Byte Offset | Type | Field | Description |
|
||||||
|
| :---: | :---: | :--- | :--- |
|
||||||
|
| `0` | `u8` | `mode` | `VideoMode` enum value (`0, 1, 4, 5, 6, 7, 8`) |
|
||||||
|
| `1` | `u8` | `flags` | Auxiliary mode flags (`0` by default) |
|
||||||
|
| `2..6` | `i32` LE | `rect[0]` (`x`) | ROI X or corner coordinate |
|
||||||
|
| `6..10` | `i32` LE | `rect[1]` (`y`) | ROI Y or corner coordinate |
|
||||||
|
| `10..14` | `i32` LE | `rect[2]` (`w`) | ROI Width or corner coordinate |
|
||||||
|
| `14..18` | `i32` LE | `rect[3]` (`h`) | ROI Height or corner coordinate |
|
||||||
|
| `18..20` | `i16` LE | `pitch` | DeskView / Host pitch angle (`-900..0` or `-61926` sentinel) |
|
||||||
|
| `20..22` | `u16` LE | `zoom` | Mode zoom percentage (default `100`) |
|
||||||
|
| `22..36` | `[u8; 14]` | `extra` | Mode-specific auxiliary bytes |
|
||||||
|
|
||||||
|
*Note*: On Link (`0x4c01`), Link 2 (`0x4c04`), and PUC3 Gimbal (`0x4c02`, `0x4c06`), `SET_CUR` sends only the first **8 bytes** (`[0..8]`). On Link 2C (`0x4c03`) and PUC3 Host (`0x4c05`), `SET_CUR` sends all **36 bytes**. `GET_CUR` always reads **36 bytes**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x03` — `TRACKING_AND_GESTURE` (6 or 16 Bytes)
|
||||||
|
Multiplexed control for hand gestures, tracking speed, composition style, and Smart Whiteboard:
|
||||||
|
|
||||||
|
1. **`SET_CUR` (16 Bytes)** — Dispatched by `byte[0]`:
|
||||||
|
- `byte[0] == 0x00` (`EnableAllGesture` at `0x100197b54`): `[0x00, enable_u8, 0x00, ...]` (also toggles `ExtendFunction::AllGestures` bit 4 in `XU1 0x0a`).
|
||||||
|
- `byte[0] == 0x01` (`EnableGestureWork` at `0x100197bc8`): Reads 6 bytes `[all, ok, palm, l, v, rock]` from `GET_CUR`, sets `buf[0] = 0x01` and `buf[gesture_idx + 1] = enable_u8`, padded with zeros to 16 bytes.
|
||||||
|
- `byte[0] == 0x03` (`ForceSetSmatrWhiteBoard` at `0x1001980d0`): `[0x03, mode_u8, 0x00, ...]`.
|
||||||
|
- `byte[0] == 0x05` (`SetTrackSpeed` at `0x1001975bc`): `[0x05, speed_u8, 0x00, ...]`.
|
||||||
|
- `byte[0] == 0x06` (`SetCompositionStyle` at `0x100197498`): `[0x06, style_u8, 0x00, ...]`.
|
||||||
|
|
||||||
|
2. **`GET_CUR` (6 or 16 Bytes)**:
|
||||||
|
- `byte[0]`: Master gesture enable (`0` or `1`)
|
||||||
|
- `byte[1]`: `Gesture::Ok` (`0`) enable (`0` or `1`)
|
||||||
|
- `byte[2]`: `Gesture::Palm` (`1`) enable (`0` or `1`)
|
||||||
|
- `byte[3]`: `Gesture::LShape` (`2`) enable (`0` or `1`)
|
||||||
|
- `byte[4]`: `Gesture::VShape` (`3`) enable (`0` or `1`)
|
||||||
|
- `byte[5]`: `Gesture::Rock` (`4`) enable (`0` or `1`)
|
||||||
|
- `byte[6]`: `TrackSpeed` (`GetTrackSpeed` at `0x100197630`: `0` = `Slow`, `1` = `Normal`, `2` = `Fast`)
|
||||||
|
- `byte[7]`: `CompositionStyle` (`GetCompositionStyle` at `0x10019750c`: `0` = `Head`, `1` = `HalfBody`, `2` = `WholeBody`, `3` = `Custom`)
|
||||||
|
|
||||||
|
**`Gesture` Indices (`0x100197bc8` & `setUseGesture*` at `0x100067f58`–`0x100068888`)**:
|
||||||
|
| Index | Enum Variant | Hand Gesture | Default Action |
|
||||||
|
| :---: | :--- | :--- | :--- |
|
||||||
|
| `0` | `Gesture::Ok` | 👌 OK Sign | Toggle AI Tracking / Auto Framing (`byte[1]`) |
|
||||||
|
| `1` | `Gesture::Palm` | ✋ Open Palm | Toggle AI Tracking / Auto Framing (`byte[2]`) |
|
||||||
|
| `2` | `Gesture::LShape` | 👆 L-Shape | Dynamic Zoom In/Out (`byte[3]`) |
|
||||||
|
| `3` | `Gesture::VShape` | ✌️ V-Shape (Peace) | Toggle Whiteboard Mode (`byte[4]`) |
|
||||||
|
| `4` | `Gesture::Rock` | 🤘 Rock Sign | Custom / Reserved (`byte[5]`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x04` — `AUDIO_CONTROL` (2 Bytes)
|
||||||
|
Verified in `EnableAudioNoiseReduction` (`0x100197798`), `GetAudioNoiseStatus` (`0x100197808`), `SetAudioCaptureMode` (`0x10019bf54`), and `GetAudioCaptureMode` (`0x10019bf64`).
|
||||||
|
- **Payload**: `[value_u8, 0x00]` (2 bytes)
|
||||||
|
- For Link 1 noise reduction: `value_u8` is `0` (Disabled) or `1` (Enabled).
|
||||||
|
- For Link 2 / Link 2C / PUC3 `AudioCaptureMode` (`0x1000702a8`):
|
||||||
|
|
||||||
|
| Value | Variant | UI Display Name | Description |
|
||||||
|
| :---: | :--- | :--- | :--- |
|
||||||
|
| `1` | `VoiceFocus` | Voice Focus | Directional beamforming microphone mode |
|
||||||
|
| `2` | `VoiceSuppression` | Noise Suppression | Wide-angle background noise cancellation |
|
||||||
|
| `3` | `MusicBalance` | Music Balance | Balanced voice and ambient music fidelity |
|
||||||
|
| `4` | `Original` | Original Sound | Unprocessed raw microphone audio |
|
||||||
|
| `5` | `SoundWall` | Sound Wall | Virtual audio fence (PUC3 `0x4c05` / `0x4c06` only) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x05` & `0x06` — `TRACK_OBJECT_SELECT` & `TRACK_OBJECT_LIST_V1` (10 Bytes per `UVCRect`)
|
||||||
|
Verified in `SetTrackObj` (`0x100198a38`) and `GetTrackObjLists` (`0x100198adc`).
|
||||||
|
Each `UVCRect` entry is 10 bytes (5 × `i16` little-endian):
|
||||||
|
|
||||||
|
| Byte Offset | Type | Field | Description |
|
||||||
|
| :---: | :---: | :--- | :--- |
|
||||||
|
| `0..2` | `i16` LE | `x` | Normalized bounding box X coordinate |
|
||||||
|
| `2..4` | `i16` LE | `y` | Normalized bounding box Y coordinate |
|
||||||
|
| `4..6` | `i16` LE | `width` | Normalized bounding box width |
|
||||||
|
| `6..8` | `i16` LE | `height` | Normalized bounding box height |
|
||||||
|
| `8..10` | `i16` LE | `track_id` | Object tracking identifier |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x07` — `EXPOSURE_CURVE_AND_ISO` (259 Bytes)
|
||||||
|
Verified in `GetExpouseCurve` (`0x10019aaa0`), `SetExpouseCurve` (`0x10019ab74`), `EnableAutoExposure` (`0x10019ac90`), `GetAutoExposureStatus` (`0x10019ad98`), `SetISOValue` (`0x10019ae24`), and `GetISOValue` (`0x10019af50`).
|
||||||
|
|
||||||
|
| Byte Offset | Type | Field | Description |
|
||||||
|
| :---: | :---: | :--- | :--- |
|
||||||
|
| `0` | `u8` | `mode` | Exposure mode: `0` = Manual Exposure, `1` = Auto Exposure |
|
||||||
|
| `1..3` | `u16` LE | `iso` | Manual ISO sensitivity (e.g. `100..=3200`) |
|
||||||
|
| `3..259` | `[u16; 128]` LE | `points` | 128-point tone/exposure lookup curve (`256` bytes) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x08` — `EXPOSURE_COMPENSATION` (4 Bytes)
|
||||||
|
Verified in `SetExposureCompensation` (`0x100198f34`) and `GetExposureCompensation` (`0x100198fc0`).
|
||||||
|
- **Payload**: 4-byte IEEE-754 single-precision float (`f32` little-endian) representing EV compensation (e.g., `-3.0` to `+3.0`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x09` — `WHITE_BALANCE_EXT` (129 Bytes)
|
||||||
|
Verified in `SetWhiteBalanceParam` (`0x10019afe0`) and `GetWhiteBalanceParam` (`0x10019b264`).
|
||||||
|
- `byte[0]`: `u8` White balance mode
|
||||||
|
- `byte[1..129]`: `[u32; 32]` Little-endian channel calibration gains (`128` bytes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x0a` — `EXTEND_FUNC_BITMASK` (8 Bytes)
|
||||||
|
Verified in `EnableExtendFuncWork` (`0x100199afc`), `GetExtendFuncStatus` (`0x100199dd0`), `EnableVerticalScreen` (`0x10019b7d0`), `EnableLowResolution` (`0x10019b854`), and the `CameraInsta::Init` jump table at `0x102864a20`.
|
||||||
|
|
||||||
|
**Wire Layout (8 Bytes)**:
|
||||||
|
| Byte Offset | Type | Field | Description |
|
||||||
|
| :---: | :---: | :--- | :--- |
|
||||||
|
| `0..4` | `u32` LE | `mask` | Bitmask of feature bit(s) being modified (`1u32 << bit_index` on `SET_CUR`) |
|
||||||
|
| `4..8` | `u32` LE | `values` | Full 32-bit state bitmask of all enabled extended functions |
|
||||||
|
|
||||||
|
**Complete `ExtendFunction` Bit Definitions (`0..14`, verified at `0x102864a20`)**:
|
||||||
|
| Bit Index | Bitmask (`1 << bit`) | Enum Variant | Internal Field in `CameraInsta` | Description |
|
||||||
|
| :---: | :---: | :--- | :--- | :--- |
|
||||||
|
| `0` | `0x0000_0001` | `AiZoom` | `ai_zoom_checked_` | Automatic AI Zoom during tracking |
|
||||||
|
| `1` | `0x0000_0002` | `Reserved1` | — | Reserved |
|
||||||
|
| `2` | `0x0000_0004` | `Hdr` | `enable_hdr_` | Hardware HDR mode |
|
||||||
|
| `3` | `0x0000_0008` | `HorizontalMirror` | `mirror_checked_` | Horizontal image flip / mirror |
|
||||||
|
| `4` | `0x0000_0010` | `AllGestures` | `enable_all_gestures_` | Master hand-gesture recognition flag |
|
||||||
|
| `5` | `0x0000_0020` | `VerticalMode` | `enable_vertical_mode_` | 9:16 Vertical output resolution |
|
||||||
|
| `6` | `0x0000_0040` | `StartupParams` | `enable_startup_params_` | Persist startup parameters in camera NVRAM |
|
||||||
|
| `7` | `0x0000_0080` | `SingleTapTracking` | `enable_single_tap_tracking_` | Single-click target selection tracking |
|
||||||
|
| `8` | `0x0000_0100` | `AutoTracking` | `enable_auto_tracking_` | Automatic human tracking enable |
|
||||||
|
| `9` | `0x0000_0200` | `SmartAdjustment` | `smart_adjustment_checked_` | Smart exposure/frame adjustment |
|
||||||
|
| `10` | `0x0000_0400` | `ForcedVertical` | `forced_vertical_` | Uncropped 9:16 portrait mode (physical 90° rotation) |
|
||||||
|
| `11` | `0x0000_0800` | `UltimatePrivacyMode` | `enable_privacy_mode_` | 10-second inactivity sleep / gimbal tilt-down privacy |
|
||||||
|
| `12` | `0x0000_1000` | `VerticalMirror` | `enable_vertically_mirror_` | Vertical image flip (upside-down mount) |
|
||||||
|
| `13` | `0x0000_2000` | `LowResolution` | `enable_low_resolution_` | Low-resolution compatibility mode |
|
||||||
|
| `14` | `0x0000_4000` | `TrackForbiddenArea` | `enable_track_forbidden_area_` | Enforce tracking forbidden zones |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x0b` — `PANTILT_RELATIVE_EXT` (4 Bytes)
|
||||||
|
Verified in `SetPanTiltRelative` (`0x1001990f4`) and `GetPanTiltRelative`.
|
||||||
|
- **Payload**: `[pan_speed: u8, pan_dir: i8, tilt_speed: u8, tilt_dir: i8]`
|
||||||
|
- Note that the byte order (`speed` then `dir`) is reversed compared to standard UVC `CT_PANTILT_RELATIVE_CONTROL` (`0x0e`, which uses `dir` then `speed`).
|
||||||
|
- `dir`: `0` = Stop, `1` = Positive (Right / Up), `-1` (`0xff`) = Negative (Left / Down).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x0c` — `DEVICE_STATUS` (4 Bytes, `GET_CUR`)
|
||||||
|
Verified in `GetDeviceStatus` (`0x10019b8dc`). Returns a 32-bit little-endian status bitmask (`DeviceStatus`):
|
||||||
|
- Bit 0 (`0x01`): `TRACKING_ACTIVE`
|
||||||
|
- Bit 1 (`0x02`): `WHITEBOARD_ACTIVE`
|
||||||
|
- Bit 2 (`0x04`): `GESTURE_DETECTED`
|
||||||
|
- Bit 3 (`0x08`): `PRIVACY_SLEEP`
|
||||||
|
- Bit 4 (`0x10`): `OVERHEAD_MODE`
|
||||||
|
- Bit 5 (`0x20`): `DESKVIEW_MODE`
|
||||||
|
- Bit 6 (`0x40`): `UPGRADING`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x0d` — `PTZ_VERSION_AND_ABS` (30 Bytes, `GET_CUR`)
|
||||||
|
Verified in `GetPTZVersionInfo` (`0x1001993bc`) and `GetPanTiltAbsoluteValue` (`0x1001996cc`).
|
||||||
|
- `byte[0..20]`: Null-terminated ASCII gimbal firmware version string (e.g. `"v1.3.1.0_build0"`).
|
||||||
|
- `byte[20..22]`: `i16` LE current Pan angle (in `0.1°` units, `1° = 10` units = `3600 arcsec`).
|
||||||
|
- `byte[22..24]`: `i16` LE current Tilt angle (in `0.1°` units).
|
||||||
|
- `byte[24..26]`: `u16` LE current Zoom ratio (`100..400`).
|
||||||
|
- `byte[26]`: `u8` Gimbal status byte.
|
||||||
|
- `byte[27..30]`: Reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x0e` — `PRESET_POSITION_FLAGS` (8 Bytes, `GET_CUR`)
|
||||||
|
Verified in `GetCurPresetPositionFlag` (`0x1001997c0`).
|
||||||
|
- `byte[0]`: `slot_mask` (`u8` bitmask of saved presets, bits `0..5`).
|
||||||
|
- `byte[1]`: `current_slot` (`u8` index of active preset).
|
||||||
|
- `byte[2]`: `max_slots` (`u8`, typically `6`).
|
||||||
|
- `byte[3..8]`: Reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU1 0x0f` — `USB_MODE_SWITCH` (4 Bytes, `SET_CUR`)
|
||||||
|
Verified in `SwitchMscReadOnlyClass` (`0x10019b998`) and `DownloadFileFromCamera` (`0x10019b928`):
|
||||||
|
- `[0x01, 0x00, 0x00, 0x00]`: Trigger diagnostic log / file download mode.
|
||||||
|
- `[0x02, 0x00, 0x00, 0x00]`: Switch camera USB personality to Read-Only Mass Storage Class (MSC) / Simple-Class mode for firmware upgrade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Proprietary Extension Unit 2 (`XU2`) Selectors (`0x01` – `0x0f`)
|
||||||
|
|
||||||
|
- **GUID**: `E307E649-4618-A3FF-82FC-2D8B5F216773`
|
||||||
|
- **Default Unit ID**: `10` (`0x0a`)
|
||||||
|
- **Binary Implementation**: `MacUVCCameraExtendController` (`0x100195a18`–`0x10019c478`)
|
||||||
|
|
||||||
|
### Summary Table of `XU2` Selectors
|
||||||
|
|
||||||
|
| Selector | Constant Name | Size | Requests | Primary Function |
|
||||||
|
| :---: | :--- | :---: | :---: | :--- |
|
||||||
|
| `0x01` | `GESTURE_BINDING_MAP` | 16B | `SET_CUR`, `GET_CUR` | Map hand gestures (`0..4`) to `GestureFunction` actions (`0..4`) |
|
||||||
|
| `0x02` | `SYSTEM_TIME_AND_IQ` | 10B | `SET_CUR` | Sync host UTC timestamp, set IQ switches (`D0`/`D1`), or restore factory settings |
|
||||||
|
| `0x03` | `DEVICE_INFO` | 150B | `GET_CUR` | Read device name (`[0..50]`), firmware version (`[50..100]`), and serial number (`[100..150]`) |
|
||||||
|
| `0x04` | `IMAGE_TEMPLATE_INFO` | 11B | `SET_CUR`, `GET_CUR` | Image preset template save/apply/delete & populated 64-bit slot bitmask |
|
||||||
|
| `0x05` | `IMAGE_TEMPLATE_NAME` | 65B | `SET_CUR`, `GET_CUR` | Read/write 64-byte ASCII name for image template `id` (`byte[0]`) |
|
||||||
|
| `0x06` | `FILTER_MODE` | 1B | `SET_CUR`, `GET_CUR` | Hardware LUT color filter (`0..=8`) |
|
||||||
|
| `0x07` | *(Reserved)* | — | — | Unused in v2.0.2 |
|
||||||
|
| `0x08` | `PRIVACY_AND_FILL_LIGHT` | 4B | `SET_CUR`, `GET_CUR` | Hardware privacy mode (`[2, enable, 0, 0]`) and fill light parameters |
|
||||||
|
| `0x09` | `HOST_PTZ` | 8B | `SET_CUR` | Digital crop box on gimbal-less cameras (`u16 x, y, width, height`, normalized `0..5140`) |
|
||||||
|
| `0x0a` | `TRACK_OBJECT_LIST_V2` | `N * 10B` | `GET_LEN`, `GET_CUR` | Detected trackable objects bounding box array (`GetNewTrackObjLists` for Link 2 / 2C) |
|
||||||
|
| `0x0b` | `TRACK_FORBIDDEN_AREA` | 40B | `SET_CUR`, `GET_CUR` | Configure/enable gimbal tracking exclusion zones (`ForbiddenAreaInfo_s`) |
|
||||||
|
| `0x0c` | `DEFAULT_TRACK_MODE` | 1B | `SET_CUR`, `GET_CUR` | Default AI tracking mode (`0` = Single Person, `1` = Group / Auto Framing) |
|
||||||
|
| `0x0d` | `HORIZONTAL_CORRECTION` | 1B | `SET_CUR`, `GET_CUR` | Gimbal horizon leveling (`0` = Off, `1` = On; Link 2 `0x4c04`) |
|
||||||
|
| `0x0e` | `REAL_ZOOM_AND_UDISK` | 4B | `SET_CUR`, `GET_CUR` | Query real optical/digital zoom value (`u16` LE) and UDisk open counter |
|
||||||
|
| `0x0f` | `REPORT_DATA` | Variable | `GET_LEN`, `GET_CUR` | Telemetry and diagnostic report buffer (`GetReportData`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Detailed `XU2` Selector Payloads
|
||||||
|
|
||||||
|
#### `XU2 0x01` — `GESTURE_BINDING_MAP` (16 Bytes)
|
||||||
|
Verified in `GetGestureBindingMap` (`0x100198384`) and `SetGestureBindingFunc` (`0x10019874c`).
|
||||||
|
Maps each `Gesture` (`0..4`) to a `GestureFunction`:
|
||||||
|
- `0` = `None`
|
||||||
|
- `1` = `AutoTracking`
|
||||||
|
- `2` = `Zoom`
|
||||||
|
- `3` = `WhiteBoard`
|
||||||
|
- `4` = `AutoFraming`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x02` — `SYSTEM_TIME_AND_IQ` (10 Bytes, `SET_CUR`)
|
||||||
|
Multiplexed system control dispatched by `byte[0]`:
|
||||||
|
|
||||||
|
| Sub-Opcode (`byte[0]`) | Method in Binary | Address | Wire Payload (10 Bytes) | Description |
|
||||||
|
| :---: | :--- | :--- | :--- | :--- |
|
||||||
|
| `0x01` | `SetTimestamp(u64 ms)` | `0x10019be40` | `[0x01, ts_le[0..8], 0x00]` | Synchronize camera RTC with 64-bit host timestamp (ms) |
|
||||||
|
| `0x02` | `SetIQSwitch(int bit, bool en)` | `0x100199ce0` | `[0x02, mask_u32_le[0..4], 0..]` | Toggle ISP Image Quality switch bit (`0` = `D0`, `1` = `D1`) |
|
||||||
|
| `0x04` | `RestoreFactorySet(true)` | `0x10019c1ac` | `[0x04, 0x01, 0x00, ...]` | Full factory reset and reboot camera |
|
||||||
|
| `0x05` | `RestoreFactorySet(false)` | `0x10019c1ac` | `[0x05, 0x01, 0x00, ...]` | Restore factory settings without rebooting |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x03` — `DEVICE_INFO` (150 Bytes, `GET_CUR`)
|
||||||
|
Verified in `GetDeviceInfo` (`0x100196e74`) and `GetSerialNumber` (`0x100196d10`).
|
||||||
|
Contains three fixed 50-byte null-terminated ASCII strings:
|
||||||
|
- `byte[0..50]`: `device_name` (e.g. `"Insta360 Link 2"`)
|
||||||
|
- `byte[50..100]`: `firmware_version` (e.g. `"v2.5.8.8_build1"`)
|
||||||
|
- `byte[100..150]`: `serial_number` (e.g. `"INK2A240912345"`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x04` & `0x05` — `IMAGE_TEMPLATE_INFO` (11 Bytes) & `IMAGE_TEMPLATE_NAME` (65 Bytes)
|
||||||
|
Verified in `GetImageTemplateInfo` (`0x10019ba4c`), `SetImageTemplate` (`0x10019bb08`), `DeleteTemplate` (`0x10019bbc8`), `GetImageTemplateName` (`0x10019bc74`), and `SetImageTemplateName` (`0x10019bd14`).
|
||||||
|
|
||||||
|
**`XU2 0x04` (`IMAGE_TEMPLATE_INFO`, 11 Bytes)**:
|
||||||
|
| Byte Offset | Type | Field | Description |
|
||||||
|
| :---: | :---: | :--- | :--- |
|
||||||
|
| `0` | `i8` | `current_id` | Active template slot ID (`0..63`, or `-1` if none active) |
|
||||||
|
| `1` | `u8` | `operation` | `ImageOperation`: `0` = `None`, `1` = `SaveOrUpdate`, `2` = `Apply` |
|
||||||
|
| `2..10` | `u64` LE | `bitmask` | 64-bit mask of populated template slots (`bit N = 1` if slot `N` exists) |
|
||||||
|
| `10` | `u8` | `max_templates` | Maximum supported templates (typically `10`) |
|
||||||
|
|
||||||
|
**`XU2 0x05` (`IMAGE_TEMPLATE_NAME`, 65 Bytes)**:
|
||||||
|
- `byte[0]`: `i8` `template_id` (`0..63`)
|
||||||
|
- `byte[1..65]`: 64-byte null-terminated UTF-8/ASCII template name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x06` — `FILTER_MODE` (1 Byte)
|
||||||
|
Verified in `SetFilter` (`0x10019bdb0`), `GetFilter` (`0x10019bdf8`), and UI mapping at `0x100096f94`:
|
||||||
|
| Value | Variant | Filter Name |
|
||||||
|
| :---: | :--- | :--- |
|
||||||
|
| `0` | `FilterMode::None` | None (Original) |
|
||||||
|
| `1` | `FilterMode::Style1` | Style 1 |
|
||||||
|
| `2` | `FilterMode::Style2` | Style 2 |
|
||||||
|
| `3` | `FilterMode::Fresh` | Fresh |
|
||||||
|
| `4` | `FilterMode::Amber` | Amber |
|
||||||
|
| `5` | `FilterMode::Film1` | Film 1 |
|
||||||
|
| `6` | `FilterMode::Film2` | Film 2 |
|
||||||
|
| `7` | `FilterMode::Cool` | Cool |
|
||||||
|
| `8` | `FilterMode::Warm` | Warm |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x08` — `PRIVACY_AND_FILL_LIGHT` (4 Bytes)
|
||||||
|
Verified in `SetPrivacyMode` (`0x10019c108`) and `GetPrivacyMode` (`0x10019c168`):
|
||||||
|
- **`SET_CUR` (4 Bytes)**: `[0x02, enable_u8, 0x00, 0x00]` where `enable_u8` is `1` (Privacy On) or `0` (Privacy Off).
|
||||||
|
- **`GET_CUR` (4 Bytes)**: `byte[0]` = status code (`i8`), `byte[1]` = `1` if privacy mode is enabled else `0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x09` — `HOST_PTZ` (8 Bytes, `SET_CUR`)
|
||||||
|
Verified in `SetHostPTZ` (`0x10019bec0`). Used on gimbal-less models (Link 2C `0x4c03` and PUC3 Host `0x4c05`) for digital pan/tilt/zoom cropping within a `5140 × 5140` coordinate space:
|
||||||
|
- `byte[0..2]`: `u16` LE `x` (`0..5140`)
|
||||||
|
- `byte[2..4]`: `u16` LE `y` (`0..5140`)
|
||||||
|
- `byte[4..6]`: `u16` LE `width` (default `5140`)
|
||||||
|
- `byte[6..8]`: `u16` LE `height` (default `5140`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x0b` — `TRACK_FORBIDDEN_AREA` (40 Bytes)
|
||||||
|
Verified in `SetTrackForbiddenArea` (`0x10019bff4`), `GetTrackForbiddenArea` (`0x10019c038`), and `EnableTrackForbiddenArea`. Stores up to 4 `ForbiddenAreaInfo_s` entries (10 bytes each: `u8 enabled, u8 index, i16 pan_min, i16 pan_max, i16 tilt_min, i16 tilt_max`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x0c` — `DEFAULT_TRACK_MODE` (1 Byte)
|
||||||
|
Verified in `SetDefaultTrackMode` (`0x10019c220`) and `GetDefaultTrackMode` (`0x10019c268`).
|
||||||
|
- `byte[0]`: `0` = Single-person tracking, `1` = Multi-person / Group tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x0d` — `HORIZONTAL_CORRECTION` (1 Byte)
|
||||||
|
Verified in `EnableHorizontalCorrection` (`0x10019c2b0`) and `GetEnableHorizontalCorrection` (`0x10019c2f8`).
|
||||||
|
- `byte[0]`: `0` = Disabled, `1` = Enabled (supported on Insta360 Link 2 `0x4c04`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `XU2 0x0e` — `REAL_ZOOM_AND_UDISK` (4 Bytes)
|
||||||
|
Verified in `GetRealZoomValue` (`0x10019c340`), `GetUDiskOpenCount`, and `SetUDiskOpenCount`.
|
||||||
|
- `byte[0..2]`: `u16` LE real zoom factor (`100..400`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Firmware Upgrade Protocol (`UVCCameraExtendControllerImp::FirmwareUpgrade`)
|
||||||
|
|
||||||
|
Verified in `UVCCameraExtendControllerImp::FirmwareUpgrade` (`0x10019fc88`) and `MacSimpleClassControlImp` (`0x1001a0a0c`).
|
||||||
|
|
||||||
|
Insta360 cameras support two firmware delivery paths depending on the active USB mode:
|
||||||
|
1. **Read-Only Mass Storage Class (MSC) Mode**: Triggered via `XU1` selector `0x0f` (`[0x02, 0x00, 0x00, 0x00]`).
|
||||||
|
2. **USB Vendor Simple-Class Mode (`PID 0x00c2` or vendor interface)**: Transfers the firmware image over raw USB Vendor Control + Bulk OUT transfers.
|
||||||
|
|
||||||
|
### 8.1 Step-by-Step USB Simple-Class Upgrade Sequence (`0x10019fc88`)
|
||||||
|
|
||||||
|
1. **Initiate Firmware Transfer (Vendor Control Transfer OUT)**:
|
||||||
|
- `bmRequestType = 0x41` (Host-to-Device, Vendor, Interface)
|
||||||
|
- `bRequest = 0x01`
|
||||||
|
- `wValue = 0x0004`
|
||||||
|
- `wIndex = 0x0000`
|
||||||
|
- `wLength = 137` (`0x0089` bytes)
|
||||||
|
- **137-Byte Header Payload**:
|
||||||
|
- `byte[0..8]`: `u64` LE total firmware file size in bytes
|
||||||
|
- `byte[8]`: `0x5c` (`'\\'` path separator sentinel)
|
||||||
|
- `byte[9..137]`: 128-byte null-padded ASCII target path on camera flash (from `0x1001a0a0c`, e.g. `a:\Insta360WebCamFW.bin`, `a:\Insta360LINK2FW_GIMBAL.bin`, `a:\Insta360LINK2CFW_HOST.bin`, `a:\Insta360PUC3FW_GIMBAL.bin`, or `a:\Insta360PUC3FW_HOST.bin`).
|
||||||
|
|
||||||
|
2. **Stream Firmware Image in 512 KB Bulk Chunks**:
|
||||||
|
- **Endpoint**: Bulk OUT Endpoint `0x01`
|
||||||
|
- **Chunk Size**: `0x80000` bytes (`524,288` bytes = 512 KB) per bulk transfer until the entire firmware binary is transmitted.
|
||||||
|
|
||||||
|
3. **Verify Firmware Reception (Vendor Control Transfer IN)**:
|
||||||
|
- `bmRequestType = 0xc1` (Device-to-Host, Vendor, Interface)
|
||||||
|
- `bRequest = 0x81`
|
||||||
|
- `wValue = 0x0004`
|
||||||
|
- `wIndex = 0x0000`
|
||||||
|
- `wLength = 1`
|
||||||
|
- **Response**: 1 byte (`0x00` indicates checksum/transfer verification succeeded).
|
||||||
|
|
||||||
|
4. **Reboot into Normal UVC Mode (Vendor Control Transfer OUT)**:
|
||||||
|
- `bmRequestType = 0x41` (Host-to-Device, Vendor, Interface)
|
||||||
|
- `bRequest = 0x01`
|
||||||
|
- `wValue = 0x0002`
|
||||||
|
- `wIndex = 0x0000`
|
||||||
|
- `wLength = 1`
|
||||||
|
- **Payload**: `[0x01]`
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Linux udev rules for Insta360 Link / Link 2 / Link 2C / Link 2 Pro (PUC3) webcams
|
||||||
|
#
|
||||||
|
# Grants non-root desktop users (via systemd-logind uaccess and plugdev/video groups)
|
||||||
|
# direct read/write access to the USB device nodes so `insta360-link-ctl`,
|
||||||
|
# `insta360-link-server`, and `insta360-link-gui` can issue UVC Extension Unit
|
||||||
|
# control transfers without requiring `sudo`.
|
||||||
|
#
|
||||||
|
# Installation:
|
||||||
|
# sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules
|
||||||
|
# sudo udevadm control --reload-rules
|
||||||
|
# sudo udevadm trigger
|
||||||
|
|
||||||
|
ACTION!="add|change", GOTO="insta360_link_end"
|
||||||
|
SUBSYSTEM!="usb", GOTO="insta360_link_end"
|
||||||
|
|
||||||
|
# Insta360 Link (1st Gen - 0x4c01)
|
||||||
|
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c01", MODE="0660", GROUP="plugdev", TAG+="uaccess"
|
||||||
|
|
||||||
|
# Insta360 Link 2 Pro / PUC3 Gimbal A (0x4c02)
|
||||||
|
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c02", MODE="0660", GROUP="plugdev", TAG+="uaccess"
|
||||||
|
|
||||||
|
# Insta360 Link 2C (Fixed-Lens 4K - 0x4c03)
|
||||||
|
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c03", MODE="0660", GROUP="plugdev", TAG+="uaccess"
|
||||||
|
|
||||||
|
# Insta360 Link 2 (2-Axis Gimbal 4K - 0x4c04)
|
||||||
|
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c04", MODE="0660", GROUP="plugdev", TAG+="uaccess"
|
||||||
|
|
||||||
|
# Insta360 Link 2C Pro / PUC3 Host (0x4c05)
|
||||||
|
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c05", MODE="0660", GROUP="plugdev", TAG+="uaccess"
|
||||||
|
|
||||||
|
# Insta360 Link 2 Pro / PUC3 Gimbal B (0x4c06)
|
||||||
|
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c06", MODE="0660", GROUP="plugdev", TAG+="uaccess"
|
||||||
|
|
||||||
|
# Insta360 Link Simple-Class / Firmware Upgrade Mode (0x00c2)
|
||||||
|
ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="00c2", MODE="0660", GROUP="plugdev", TAG+="uaccess"
|
||||||
|
|
||||||
|
LABEL="insta360_link_end"
|
||||||
Reference in new issue
Block a user