Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@ 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** |

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
Expand Down Expand Up @@ -240,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.

Expand Down
3 changes: 2 additions & 1 deletion contrib/99-insta360-link.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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"
14 changes: 12 additions & 2 deletions src/camera/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,32 @@ impl Camera {

/// Query a control's metadata; `UnsupportedControl` if absent.
pub fn query(&self, control: Control) -> Result<ControlInfo> {
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<Option<ControlInfo>> {
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<i64> {
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") => {
Expand Down
35 changes: 35 additions & 0 deletions src/camera/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -15,6 +17,9 @@ 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.
#[serde(rename = "link2c")]
Link2C,
/// Original Insta360 Link — recognised by VID/PID only; untested.
Link,
}
Expand All @@ -27,6 +32,7 @@ impl Model {
}
match pid {
LINK2_PID => Some(Model::Link2),
LINK2C_PID => Some(Model::Link2C),
LINK_PID => Some(Model::Link),
_ => None,
}
Expand All @@ -36,10 +42,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::Link2 | Model::Link)
}

/// 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)
Expand All @@ -59,11 +76,29 @@ 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());
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]
fn only_link2_is_tested() {
assert!(Model::Link2.is_tested());
Expand Down
7 changes: 3 additions & 4 deletions src/commands/tracking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -35,12 +34,12 @@ fn emit(ctx: &Context, state: link2::TrackingState, warning: Option<String>) {
);
}

/// 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
)));
}
Expand Down
41 changes: 28 additions & 13 deletions tests/hardware.rs
Original file line number Diff line number Diff line change
@@ -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:
//!
Expand Down Expand Up @@ -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}");
Expand All @@ -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()
Expand All @@ -67,32 +78,36 @@ 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}");
}

/// 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"]);
Expand Down
Loading