diff --git a/README.md b/README.md index 1428fc5..83eae48 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,6 @@ This project includes a [`mise.toml`](mise.toml) configuration that manages the mise trust mise install -# Install Linux udev rules (one-time setup on Linux for non-root USB access) -mise run udev-install - # Build & launch the desktop GUI mise run gui @@ -70,28 +67,18 @@ The compiled binaries will be placed in `target/release/`: --- -## Platform Setup Instructions +## Platform Notes -### Linux (`udev` Rules) +### Linux (`V4L2` & `uvcvideo`) -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. +On Linux, `insta360-link-protocol` communicates directly with the primary V4L2 video capture node (`/dev/video*`) using `UVCIOC_CTRL_QUERY` for proprietary Insta360 Extension Unit (`XU1`/`XU2`) commands and standard V4L2 `VIDIOC_G_CTRL`/`VIDIOC_S_CTRL` ioctls for Processing Unit / Camera Terminal controls, while reading USB descriptors from sysfs. Because `systemd-logind` automatically grants the active desktop user session access to `/dev/video*`, **no custom `udev` rules or `sudo` privileges are required**. -To run the CLI, server, and GUI without `sudo`, install the bundled `udev` rules: +#### Arch Linux (AUR) ```bash -sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules -sudo udevadm control --reload-rules -sudo udevadm trigger +yay -S insta360-link-controller-bin ``` -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. diff --git a/assets/insta360-link-controller.desktop b/assets/insta360-link-controller.desktop new file mode 100644 index 0000000..dab6430 --- /dev/null +++ b/assets/insta360-link-controller.desktop @@ -0,0 +1,14 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Insta360 Link Controller +GenericName=Webcam Controller +Comment=Hardware PTZ, AI tracking, image settings, and live preview controller for Insta360 Link webcams +Exec=insta360-link-gui +TryExec=insta360-link-gui +Icon=insta360-link-controller +Terminal=false +Categories=AudioVideo;Video;Settings;HardwareSettings; +Keywords=insta360;link;webcam;camera;ptz;gimbal;uvc;video;tracking; +StartupNotify=true +StartupWMClass=insta360-link-controller diff --git a/assets/insta360-link-controller.svg b/assets/insta360-link-controller.svg new file mode 100644 index 0000000..ef3eb46 --- /dev/null +++ b/assets/insta360-link-controller.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/crates/insta360-link-gui/src/main.rs b/crates/insta360-link-gui/src/main.rs index ad11754..009dad6 100644 --- a/crates/insta360-link-gui/src/main.rs +++ b/crates/insta360-link-gui/src/main.rs @@ -2393,12 +2393,62 @@ impl eframe::App for Insta360App { } } +fn build_app_icon() -> egui::IconData { + let w = 64u32; + let h = 64u32; + let mut rgba = vec![0u8; (w * h * 4) as usize]; + for y in 0..h { + for x in 0..w { + let idx = ((y * w + x) * 4) as usize; + let dx = x as f32 - 31.5_f32; + let dy = y as f32 - 26.0_f32; + let dist = (dx * dx + dy * dy).sqrt(); + // Dark rounded card background + let in_card = x >= 4 && x < 60 && y >= 4 && y < 60; + if !in_card { + continue; + } + let (mut r, mut g, mut b) = (20u8, 24u8, 32u8); + // Green camera ring + if (10.5_f32..=14.0_f32).contains(&dist) { + r = 0; + g = 230; + b = 118; + } else if dist < 5.0_f32 { + r = 0; + g = 230; + b = 118; + } else if dist < 10.5_f32 { + r = 12; + g = 15; + b = 20; + } else if (18..=46).contains(&x) && (44..=52).contains(&y) { + // Gimbal base + r = 43; + g = 50; + b = 66; + } + rgba[idx] = r; + rgba[idx + 1] = g; + rgba[idx + 2] = b; + rgba[idx + 3] = 255; + } + } + egui::IconData { + rgba, + width: w, + height: h, + } +} + fn main() -> eframe::Result<()> { tracing_subscriber::fmt::init(); // Matching webcam.qml: width: 1440, height: 714, minimumWidth: 1120, minimumHeight: 536 let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() + .with_app_id("insta360-link-controller") + .with_icon(build_app_icon()) .with_inner_size([1440.0_f32, 714.0_f32]) .with_min_inner_size([1120.0_f32, 536.0_f32]) .with_title("Insta360 Link Controller"), diff --git a/crates/insta360-link-protocol/src/transport.rs b/crates/insta360-link-protocol/src/transport.rs index 05993ba..76c0a18 100644 --- a/crates/insta360-link-protocol/src/transport.rs +++ b/crates/insta360-link-protocol/src/transport.rs @@ -72,6 +72,12 @@ pub fn list_connected_cameras() -> Result, TransportError> unit_ids = parse_uvc_vc_descriptors(raw); } } + #[cfg(target_os = "linux")] + if unit_ids == UvcUnitIds::default() { + if let Some((_, parsed_ids)) = open_linux_v4l2_node(vid, pid, unit_ids.extension_unit_id) { + unit_ids = parsed_ids; + } + } out.push(DiscoveredCamera { vendor_id: vid, @@ -89,10 +95,10 @@ pub fn list_connected_cameras() -> Result, TransportError> } /// Pure-Rust cross-platform USB hardware transport backed by `nusb` -/// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + `usbfs`, Windows `WinUSB`, macOS `IOKit`). +/// (Linux `uvcvideo` `UVCIOC_CTRL_QUERY` + V4L2 ioctls, Windows `WinUSB`, macOS `IOKit`). pub struct NusbTransport { info: DiscoveredCamera, - device: nusb::Device, + device: Option, interface: Option, #[cfg(target_os = "linux")] v4l_file: Option, @@ -100,7 +106,7 @@ pub struct NusbTransport { } #[cfg(target_os = "linux")] -fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option { +fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option<(std::fs::File, UvcUnitIds)> { let vid_hex = format!("{:04x}", vid); let pid_hex = format!("{:04x}", pid); let entries = std::fs::read_dir("/sys/class/video4linux").ok()?; @@ -115,13 +121,13 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option } candidates.sort_by_key(|(num, _, _)| *num); - let mut fallback: Option = None; + let mut fallback: Option<(std::fs::File, UvcUnitIds)> = None; for (_, path, name) in candidates { let dev_link = 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`. + // Parent directory of the USB interface (`B-P:1.0`) holds `idVendor`, `idProduct`, and `descriptors`. if let Some(usb_dev_dir) = canon.parent() { let v = std::fs::read_to_string(usb_dev_dir.join("idVendor")) .unwrap_or_default() @@ -132,6 +138,23 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option .trim() .to_lowercase(); if v == vid_hex && p == pid_hex { + let parsed_ids = std::fs::read(usb_dev_dir.join("descriptors")) + .ok() + .map(|raw| { + // Skip the 18-byte USB Device Descriptor if present at offset 0 + let slice = if raw.len() > 18 && raw[0] == 18 && raw[1] == 1 { + &raw[18..] + } else { + &raw[..] + }; + parse_uvc_vc_descriptors(slice) + }) + .unwrap_or_default(); + let probe_xu1 = if parsed_ids.extension_unit_id != 0 { + parsed_ids.extension_unit_id + } else { + xu1_id + }; let dev_path = format!("/dev/{}", name); if let Ok(file) = std::fs::OpenOptions::new() .read(true) @@ -143,17 +166,17 @@ fn open_linux_v4l2_node(vid: u16, pid: u16, xu1_id: u8) -> Option let mut len_buf = [0u8; 2]; if linux_uvcioc_ctrl_query( &file, - xu1_id, + probe_xu1, 0x03, UvcRequest::GetLen as u8, &mut len_buf, ) .is_ok() { - return Some(file); + return Some((file, parsed_ids)); } if fallback.is_none() { - fallback = Some(file); + fallback = Some((file, parsed_ids)); } } } @@ -352,25 +375,49 @@ impl NusbTransport { }) .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); + let device = dev_info.open().ok(); + if let Some(ref dev) = device { + if let Ok(cfg) = dev.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(); + .as_ref() + .and_then(|d| d.claim_interface(info.unit_ids.control_interface).ok()); #[cfg(target_os = "linux")] - let v4l_file = open_linux_v4l2_node( + let v4l_file = match open_linux_v4l2_node( info.vendor_id, info.product_id, info.unit_ids.extension_unit_id, - ); + ) { + Some((f, parsed_ids)) => { + if device.is_none() { + info.unit_ids = parsed_ids; + } + Some(f) + } + None => None, + }; + + #[cfg(target_os = "linux")] + if device.is_none() && v4l_file.is_none() { + return Err(TransportError::Usb( + "Could not open V4L2 device node or USB device".to_string(), + )); + } + + #[cfg(not(target_os = "linux"))] + if device.is_none() { + return Err(TransportError::Usb( + "Could not open USB device".to_string(), + )); + } Ok(Self { info, @@ -486,18 +533,15 @@ impl UvcControlTransport for NusbTransport { .map_err(|e| TransportError::Usb(e.to_string())) } else { #[cfg(not(target_os = "windows"))] - { - self.device + if let Some(dev) = &self.device { + return dev .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(), - )) + .map_err(|e| TransportError::Usb(e.to_string())); } + Err(TransportError::Usb( + "UVC control transfer failed".to_string(), + )) } } @@ -590,17 +634,16 @@ impl UvcControlTransport for NusbTransport { .map_err(|e| TransportError::Usb(e.to_string()))? } else { #[cfg(not(target_os = "windows"))] - { - self.device + if let Some(dev) = &self.device { + let n = dev .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(), - )); + .map_err(|e| TransportError::Usb(e.to_string()))?; + buf.truncate(n); + return Ok(buf); } + return Err(TransportError::Usb( + "UVC control transfer failed".to_string(), + )); }; buf.truncate(n); Ok(buf) diff --git a/mise.toml b/mise.toml index 7450a32..ebf2f28 100644 --- a/mise.toml +++ b/mise.toml @@ -29,10 +29,6 @@ run = "cargo test --workspace" description = "Run the automated live hardware GUI verification sequence" run = "INSTA360_GUI_SELF_TEST=1 cargo run --release -p insta360-link-gui" -[tasks.udev-install] -description = "Install Linux udev rules for non-root USB access to Insta360 Link webcams" -run = "sudo cp udev/99-insta360-link.rules /etc/udev/rules.d/99-insta360-link.rules && sudo udevadm control --reload-rules && sudo udevadm trigger" - [tasks.package-release] description = "Build and package release binaries for a target (usage: mise run package-release [target] [version])" run = ''' @@ -96,7 +92,7 @@ else cp "target/${TARGET}/release/insta360-link-ctl" "$STAGE_DIR/" cp "target/${TARGET}/release/insta360-link-server" "$STAGE_DIR/" cp README.md "$STAGE_DIR/" - cp -r udev "$STAGE_DIR/" + cp -r assets "$STAGE_DIR/" tar -C dist -czf "dist/${STAGE_NAME}.tar.gz" "$STAGE_NAME" echo "Packaged dist/${STAGE_NAME}.tar.gz" fi diff --git a/udev/99-insta360-link.rules b/udev/99-insta360-link.rules deleted file mode 100644 index 1e3e11a..0000000 --- a/udev/99-insta360-link.rules +++ /dev/null @@ -1,37 +0,0 @@ -# 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"