From 6eccc7d6263924cf47fec5e1150c5b92b6da03bf Mon Sep 17 00:00:00 2001 From: pons Date: Sat, 12 Sep 2026 22:13:47 -0300 Subject: [PATCH 1/2] Add Insta360 Link 2C detection and model support --- README.md | 1 + contrib/99-insta360-link.rules | 3 ++- src/camera/mod.rs | 14 +++++++++++-- src/camera/model.rs | 31 ++++++++++++++++++++++++++++ src/commands/tracking.rs | 7 +++---- tests/hardware.rs | 37 ++++++++++++++++++++++------------ 6 files changed, 73 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9384ca4..c408d38 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Python, and never detaches the `uvcvideo` driver. | model | USB id | status | |-------|--------|--------| | Insta360 Link 2 | `2e1a:4c04` | validated on real hardware | +| Insta360 Link 2C | `2e1a:4c03` | zoom and image controls; detection and read-only checks validated; no motorized pan/tilt | | Insta360 Link (original) | `2e1a:4c01` | recognised, **not tested** | ## Features diff --git a/contrib/99-insta360-link.rules b/contrib/99-insta360-link.rules index 9427f6c..871ad52 100644 --- a/contrib/99-insta360-link.rules +++ b/contrib/99-insta360-link.rules @@ -8,6 +8,7 @@ # Install: sudo cp contrib/99-insta360-link.rules /etc/udev/rules.d/ # sudo udevadm control --reload && sudo udevadm trigger # -# Insta360 Link 2 (validated) and original Link (recognised, untested). +# Insta360 Link 2, Link 2C, and original Link (recognised, untested). +SUBSYSTEM=="video4linux", ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c03", TAG+="uaccess", GROUP="video", MODE="0660" SUBSYSTEM=="video4linux", ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c04", TAG+="uaccess", GROUP="video", MODE="0660" SUBSYSTEM=="video4linux", ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c01", TAG+="uaccess", GROUP="video", MODE="0660" diff --git a/src/camera/mod.rs b/src/camera/mod.rs index 866bb1d..4103c99 100644 --- a/src/camera/mod.rs +++ b/src/camera/mod.rs @@ -49,22 +49,32 @@ impl Camera { /// Query a control's metadata; `UnsupportedControl` if absent. pub fn query(&self, control: Control) -> Result { - self.dev - .query_control(control.id())? + self.try_query(control)? .ok_or(Error::UnsupportedControl(control.name())) } /// Query a control, returning `None` if unsupported. pub fn try_query(&self, control: Control) -> Result> { + if self.is_absent_gimbal_control(control) { + return Ok(None); + } self.dev.query_control(control.id()) } + fn is_absent_gimbal_control(&self, control: Control) -> bool { + !self.info.model.has_gimbal() + && matches!(control, Control::PanAbsolute | Control::TiltAbsolute) + } + pub fn supports(&self, control: Control) -> bool { matches!(self.try_query(control), Ok(Some(_))) } /// Read a raw control value. pub fn get_raw(&self, control: Control) -> Result { + if self.is_absent_gimbal_control(control) { + return Err(Error::UnsupportedControl(control.name())); + } match self.dev.get_control(control.id()) { Ok(v) => Ok(v as i64), Err(Error::Io(msg)) if msg.contains("EINVAL") || msg.contains("Invalid argument") => { diff --git a/src/camera/model.rs b/src/camera/model.rs index 0c4a506..7101106 100644 --- a/src/camera/model.rs +++ b/src/camera/model.rs @@ -6,6 +6,8 @@ use std::fmt; pub const INSTA360_VID: u16 = 0x2e1a; /// USB product id of the Insta360 Link 2 (validated on real hardware). pub const LINK2_PID: u16 = 0x4c04; +/// USB product id of the Insta360 Link 2C. +pub const LINK2C_PID: u16 = 0x4c03; /// USB product id of the original Insta360 Link (recognised, **not** tested). pub const LINK_PID: u16 = 0x4c01; @@ -15,6 +17,8 @@ pub const LINK_PID: u16 = 0x4c01; pub enum Model { /// Insta360 Link 2 — the primary, hardware-validated target. Link2, + /// Insta360 Link 2C - shares the Link 2 command protocol. + Link2C, /// Original Insta360 Link — recognised by VID/PID only; untested. Link, } @@ -27,6 +31,7 @@ impl Model { } match pid { LINK2_PID => Some(Model::Link2), + LINK2C_PID => Some(Model::Link2C), LINK_PID => Some(Model::Link), _ => None, } @@ -36,10 +41,21 @@ impl Model { pub fn name(&self) -> &'static str { match self { Model::Link2 => "Insta360 Link 2", + Model::Link2C => "Insta360 Link 2C", Model::Link => "Insta360 Link", } } + /// Whether pan/tilt controls represent a physical gimbal. + pub fn has_gimbal(&self) -> bool { + !matches!(self, Model::Link2C) + } + + /// Whether this model uses the Link 2 vendor command protocol. + pub fn supports_link2_protocol(&self) -> bool { + matches!(self, Model::Link2 | Model::Link2C) + } + /// Whether this model has been validated against physical hardware. pub fn is_tested(&self) -> bool { matches!(self, Model::Link2) @@ -59,11 +75,26 @@ mod tests { #[test] fn matches_known_ids() { assert_eq!(Model::from_usb_ids(0x2e1a, 0x4c04), Some(Model::Link2)); + assert_eq!(Model::from_usb_ids(0x2e1a, 0x4c03), Some(Model::Link2C)); assert_eq!(Model::from_usb_ids(0x2e1a, 0x4c01), Some(Model::Link)); + assert_eq!(Model::from_usb_ids(0x046d, 0x4c03), None); assert_eq!(Model::from_usb_ids(0x2e1a, 0x0000), None); assert_eq!(Model::from_usb_ids(0x046d, 0x4c04), None); } + #[test] + fn link2c_shares_link2_protocol() { + assert!(Model::Link2.supports_link2_protocol()); + assert!(Model::Link2C.supports_link2_protocol()); + assert!(!Model::Link.supports_link2_protocol()); + assert_eq!(Model::Link2C.name(), "Insta360 Link 2C"); + assert_eq!(Model::Link2C.to_string(), "Insta360 Link 2C"); + assert!(!Model::Link2C.is_tested()); + assert!(!Model::Link2C.has_gimbal()); + assert!(Model::Link2.has_gimbal()); + assert!(Model::Link.has_gimbal()); + } + #[test] fn only_link2_is_tested() { assert!(Model::Link2.is_tested()); diff --git a/src/commands/tracking.rs b/src/commands/tracking.rs index b6b34b6..85965f1 100644 --- a/src/commands/tracking.rs +++ b/src/commands/tracking.rs @@ -4,7 +4,6 @@ use serde::Serialize; use super::Context; use crate::camera::insta360::link2::{self, guid, XU_AI_UNIT}; -use crate::camera::model::Model; use crate::camera::Camera; use crate::cli::TrackingAction; use crate::error::{Error, Result}; @@ -35,12 +34,12 @@ fn emit(ctx: &Context, state: link2::TrackingState, warning: Option) { ); } -/// Make sure the device really is a Link 2 whose unit 11 carries the AI GUID. +/// Require a Link 2-compatible model whose unit 11 carries the AI GUID. fn check_unit(cam: &Camera) -> Result<()> { let info = cam.info(); - if info.model != Model::Link2 { + if !info.model.supports_link2_protocol() { return Err(Error::Vendor(format!( - "tracking is only implemented for the Insta360 Link 2 (found {})", + "tracking is only implemented for the Insta360 Link 2 and Link 2C (found {})", info.model ))); } diff --git a/tests/hardware.rs b/tests/hardware.rs index afaa538..cc7ace4 100644 --- a/tests/hardware.rs +++ b/tests/hardware.rs @@ -1,4 +1,4 @@ -//! Opt-in hardware tests against a physically connected Insta360 Link 2. +//! Opt-in hardware tests against a physically connected Insta360 Link 2 or 2C. //! //! These are never run by default: //! @@ -34,9 +34,15 @@ fn assert_ok(args: &[&str]) -> String { stdout } +fn is_link2c() -> bool { + let out = assert_ok(&["info", "--json"]); + let info: serde_json::Value = serde_json::from_str(&out).unwrap(); + info["usb"]["product_id"] == "4c03" +} + #[test] #[ignore] -fn readonly_devices_lists_link2() { +fn readonly_devices_lists_link_camera() { let out = assert_ok(&["devices"]); assert!(out.contains("Insta360 Link"), "{out}"); assert!(out.contains("2e1a:"), "{out}"); @@ -54,7 +60,12 @@ fn readonly_status_and_info() { let out = assert_ok(&["info", "--json"]); let v: serde_json::Value = serde_json::from_str(&out).unwrap(); assert_eq!(v["usb"]["vendor_id"], "2e1a"); - assert!(v["pan_range_degrees"].is_array()); + if v["usb"]["product_id"] == "4c03" { + assert!(v["pan_range_degrees"].is_null()); + assert!(v["tilt_range_degrees"].is_null()); + } else { + assert!(v["pan_range_degrees"].is_array()); + } assert!( v["extension_units"] .as_array() @@ -67,24 +78,24 @@ fn readonly_status_and_info() { #[test] #[ignore] fn readonly_reads_do_not_require_activity() { - for cmd in [ - "pan", - "tilt", - "zoom", - "focus", - "wb", - "brightness", - "tracking", - ] { + for cmd in ["zoom", "focus", "wb", "brightness", "tracking"] { assert_ok(&[cmd]); } + for cmd in ["pan", "tilt"] { + if is_link2c() { + let (code, _, err) = linkctl(&[cmd]); + assert_eq!(code, 7, "{err}"); + } else { + assert_ok(&[cmd]); + } + } } #[test] #[ignore] fn readonly_out_of_range_is_rejected_before_guard() { let (code, _, err) = linkctl(&["pan", "999"]); - assert_eq!(code, 11, "{err}"); + assert_eq!(code, if is_link2c() { 7 } else { 11 }, "{err}"); let (code, _, err) = linkctl(&["zoom", "9"]); assert_eq!(code, 11, "{err}"); } From 1c3abee8a1692fd61ccdef6c07fb16424d5c3363 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 1 Oct 2026 10:26:43 +0000 Subject: [PATCH 2/2] Fix Link 2C review notes on detection and unsupported controls Mark only the Link 2 as validated in the udev comment. Document that pan/tilt, movement commands, and preset save/load exit 7 on the Link 2C. Match has_gimbal positively, skip the movement hardware test on PID 4c03, and serialize the model id as link2c. Co-authored-by: Vincenzo Petrucci --- README.md | 8 ++++++-- contrib/99-insta360-link.rules | 2 +- src/camera/model.rs | 8 ++++++-- tests/hardware.rs | 4 ++++ 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c408d38..ceb32e7 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Python, and never detaches the `uvcvideo` driver. | Insta360 Link 2C | `2e1a:4c03` | zoom and image controls; detection and read-only checks validated; no motorized pan/tilt | | Insta360 Link (original) | `2e1a:4c01` | recognised, **not tested** | +On the Link 2C, pan/tilt, movement commands (`left`/`right`/`up`/`down`/`center`/`move`) and preset save/load are not supported and exit with code 7 (`UnsupportedControl`), because presets require pan and tilt. + ## Features * Pan, tilt, zoom in human units (degrees, zoom factor) with device-reported @@ -241,8 +243,10 @@ Use `--force` only when you intentionally want to bypass this safeguard: linkctl --force center ``` -Read-only commands (`status`, `info`, `devices`, `pan`/`tilt`/`zoom` with -no argument, `preset save`, `preset list`, `tracking status`) always work. +Read-only commands (`status`, `info`, `devices`, `zoom` with no argument, +`preset list`, `tracking status`) always work. Argument-less `pan`/`tilt` +and `preset save` also work on models with a gimbal; on the Link 2C they +are not supported and exit with code 7 (`UnsupportedControl`). See [docs/activity-detection.md](docs/activity-detection.md) for how activity is detected and where it can be wrong. diff --git a/contrib/99-insta360-link.rules b/contrib/99-insta360-link.rules index 871ad52..952580b 100644 --- a/contrib/99-insta360-link.rules +++ b/contrib/99-insta360-link.rules @@ -8,7 +8,7 @@ # Install: sudo cp contrib/99-insta360-link.rules /etc/udev/rules.d/ # sudo udevadm control --reload && sudo udevadm trigger # -# Insta360 Link 2, Link 2C, and original Link (recognised, untested). +# Insta360 Link 2 (validated); Link 2C and original Link (recognised, untested). SUBSYSTEM=="video4linux", ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c03", TAG+="uaccess", GROUP="video", MODE="0660" SUBSYSTEM=="video4linux", ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c04", TAG+="uaccess", GROUP="video", MODE="0660" SUBSYSTEM=="video4linux", ATTRS{idVendor}=="2e1a", ATTRS{idProduct}=="4c01", TAG+="uaccess", GROUP="video", MODE="0660" diff --git a/src/camera/model.rs b/src/camera/model.rs index 7101106..5513896 100644 --- a/src/camera/model.rs +++ b/src/camera/model.rs @@ -17,7 +17,8 @@ pub const LINK_PID: u16 = 0x4c01; pub enum Model { /// Insta360 Link 2 — the primary, hardware-validated target. Link2, - /// Insta360 Link 2C - shares the Link 2 command protocol. + /// Insta360 Link 2C — shares the Link 2 command protocol. + #[serde(rename = "link2c")] Link2C, /// Original Insta360 Link — recognised by VID/PID only; untested. Link, @@ -48,7 +49,7 @@ impl Model { /// Whether pan/tilt controls represent a physical gimbal. pub fn has_gimbal(&self) -> bool { - !matches!(self, Model::Link2C) + matches!(self, Model::Link2 | Model::Link) } /// Whether this model uses the Link 2 vendor command protocol. @@ -93,6 +94,9 @@ mod tests { assert!(!Model::Link2C.has_gimbal()); assert!(Model::Link2.has_gimbal()); assert!(Model::Link.has_gimbal()); + assert_eq!(serde_json::to_string(&Model::Link2).unwrap(), "\"link2\""); + assert_eq!(serde_json::to_string(&Model::Link2C).unwrap(), "\"link2c\""); + assert_eq!(serde_json::to_string(&Model::Link).unwrap(), "\"link\""); } #[test] diff --git a/tests/hardware.rs b/tests/hardware.rs index cc7ace4..d75e64e 100644 --- a/tests/hardware.rs +++ b/tests/hardware.rs @@ -101,9 +101,13 @@ fn readonly_out_of_range_is_rejected_before_guard() { } /// Requires an active camera (e.g. `linkctl preview` in another terminal). +/// Skipped on the Link 2C (PID 4c03), which has no motorized gimbal. #[test] #[ignore] fn movement_relative_and_center() { + if is_link2c() { + return; + } let (code, _, err) = linkctl(&["status", "--json"]); assert_eq!(code, 0, "{err}"); assert_ok(&["right", "5"]);