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
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,48 @@ 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 2C | `2e1a:4c03` | image controls and zoom; experimental digital framing; 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.
### Link 2C digital framing

Link 2C has no gimbal. Use `frame` to move its digital crop rather than
`pan`, `tilt`, `left`, `right`, `up`, `down`, `move`, or `center`. Those
motor commands, and saving/loading pan/tilt presets, are unsupported on 2C
and exit with code 7 (`UnsupportedControl`), because presets require pan and tilt.

```sh
linkctl frame # read crop center and zoom
linkctl frame --zoom 2 --x 0.5 --y 0.5 # 2x zoom, centered
linkctl frame --x 0.6 # change X; preserve Y and zoom
linkctl frame --y 0.4 # change Y; preserve X and zoom
linkctl frame --center # recenter without changing zoom
linkctl frame --json # normalized x/y and zoom multiplier
```

Coordinates are normalized from 0 to 1, with 0.5 at the center; they are
not degrees. Zoom ranges from 1 to 4. At 1x there is no room to move the
crop, and firmware may clamp positions near the edges when zoomed in.
Writes require an active stream (or explicit `--force`) and validate the
extension-unit GUID, eight-byte payload length, and SET capability. Only
normal/tracking/Auto Framing mode-state layouts are decoded. Other modes
or idle firmware may report that framing state is unavailable.

Output reports readback, not merely the requested values, with a warning
if the camera has not confirmed the change. Auto Framing can override manual
positioning; disable it if the crop will not stay where requested.

Detection, coordinate readback, and command length/capability queries have
been verified on a physical Link 2C. Digital framing writes have also been
confirmed working by the camera owner in manual testing. Edge clamping,
direction polarity, and interaction with Auto Framing still need broader
validation. See [protocol details](docs/link2c-framing.md).

## Features

* Pan, tilt, zoom in human units (degrees, zoom factor) with device-reported
ranges, absolute and relative.
* Experimental Link 2C digital crop positioning with `frame --x/--y/--zoom`.
* Focus, white balance, brightness, contrast, saturation, sharpness, hue.
* Framing presets stored in a TOML config file.
* Resolution, pixel format (MJPEG/H.264) and frame rate: list what the
Expand Down Expand Up @@ -287,6 +320,7 @@ linkctl pan [DEGREES] absolute pan, negative = left; no arg reads
linkctl tilt [DEGREES] absolute tilt, negative = down; no arg reads
linkctl move --pan 30 --tilt -10

linkctl frame [--x X --y Y --zoom FACTOR | --center] Link 2C digital crop
linkctl zoom [FACTOR] 1 .. 4 on the Link 2
linkctl focus auto|VALUE
linkctl wb auto|KELVIN
Expand Down Expand Up @@ -487,7 +521,9 @@ Check your device permissions or group membership.

* Standard V4L2 controls are the normal path and are validated against the
device's own ranges.
* Vendor writes are limited to one documented, reversible, 1-byte control
* Vendor writes are limited to documented AI tracking and Link 2C digital
framing controls; see [protocol details](docs/link2c-framing.md).
* The AI tracking write is a reversible, 1-byte control
and go through read → validate (`GET_LEN`, `GET_INFO`, GUID) → write →
read-back.
* The kernel driver is never detached; libusb is not used at all.
Expand Down
136 changes: 136 additions & 0 deletions docs/link2c-framing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Link 2C digital framing protocol

Status: recovered by static analysis of the official Linux SDK and implemented
in `src/camera/insta360/link2c.rs` via the `frame` command. Readback and
GET_LEN/GET_INFO have been validated on a physical camera. The camera owner
subsequently confirmed that framing writes work in manual testing. That is
user-reported hardware validation, not an automated visual-motion test.
No SDK executable was run.

## Source and reproducibility

Official repository: https://github.com/Insta360Develop/Link-SDK

Analyzed revision: `2dd2c78186b0335a530042ae49accbef249d6a95`

Binary: `UVCCamera_Linux/bin/libUVCCamera.so` (x86-64 ELF)

SHA-256: `1caf4198fc10f8f16ace1f93263fa8627f7cfb60902688549faac48863798061`

Public declarations: `UVCCamera_Linux/include/uvc_common.h` (`HostPTZInfo`)
and `uvc_camera.h` (`GetHostPTZ` / `SetHostPTZ`). Example:
`UVCCamera_Linux/test/main.cc`, case 1108.

Inspect without executing the SDK:

```sh
nm -DC libUVCCamera.so | grep HostPTZ
objdump -dC -Mintel libUVCCamera.so > sdk.asm
```

## Write crop position and zoom

- Transport: `UVCIOC_CTRL_QUERY` on the video node, not libusb.
- Extension GUID: `E307E649-4618-A3FF-82FC-2D8B5F216773`.
- Unit ID on the attached Link 2C: **10**. Resolve/validate by GUID.
- Selector: **0x13**.
- Request: `UVC_SET_CUR` (**0x01**).
- Logical payload: **8 bytes**.

| Byte offset | Encoding | Meaning |
| --- | --- | --- |
| 0 | uint16 little-endian | Zoom ratio; example accepts 100..400 (1x..4x) |
| 2 | uint16 little-endian | Horizontal crop center, normalized X times 65535 |
| 4 | uint16 little-endian | Vertical crop center, normalized Y times 65535 |
| 6 | uint8 | Movement step; SDK example uses 20 |
| 7 | uint8 | Zoom step; SDK example uses 20 |

Encoding example, not a hardware test:

```python
import struct
payload = struct.pack('<HHHBB', 200, 32767, 32767, 20, 20)
assert payload.hex(' ') == 'c8 00 ff 7f ff 7f 14 14'
```

This corresponds to 2x zoom and approximately centered X/Y using the SDK's
truncating normalized-coordinate conversion. There is no separate left/right
opcode: change X or Y while preserving the other position and zoom values.
Screen-direction polarity, edge clamping, step timing, and interaction with
Auto Framing remain to be checked visually on hardware.

The SDK first sends `GET_LEN` (0x85, two-byte result) on the same unit/selector.
It allocates a zero-filled buffer of that returned length, copies the first
`min(length, 8)` payload bytes, and sends SET_CUR. If GET_LEN fails it falls
back to 8. A linkctl implementation should reject unexpected lengths rather
than blindly copying this truncation/zero-padding behavior.

There is no checksum, extra packet header, AI-mode write, or driver detach
in this setter's call chain.

Do not confuse this with **unit 9 / selector 0x13**, the head/half/whole-body
composition selector documented for other Link functionality.

### Static evidence

ELF virtual addresses in the analyzed binary:

- `0x70d82`: `LinuxUVCCameraExtendController::SetHostPTZ` builds 8 bytes.
- `0x70de3` through `0x70e5d`: copies three uint16 values and two bytes.
- `0x70e82`: selector argument is 0x13.
- `0x70e8a`: calls `SendCommand2`.
- `0x68b28`: `SendCommand2` resolves `GetExtensionUnit2Id`, obtains GET_LEN,
then sends the payload through `SetData`.
- `0x823fe`: descriptor parser matches `kExtUnit2Guid` at `0x8d8f0`.
Its raw GUID bytes are `49 e6 07 e3 18 46 ff a3 82 fc 2d 8b 5f 21 67 73`.
- `0x80e44`: `LinuxUVCDeviceImp::SetData` selects request 0x01.
- `0x80aa1`: `CtrlQuery` uses ioctl `0xc0107521` (UVCIOC_CTRL_QUERY on x86-64).

## Read crop position

The SDK's `GetHostPTZ` does **not** read unit 10 / selector 0x13. It calls
`GetVideoMode` and extracts coordinates from the mode-state payload:

- Extension GUID: `FAF1672D-B71B-4793-8C91-7B1C9B7F95F8`.
- Unit ID on the attached Link 2C: **9**. Resolve/validate by GUID.
- Selector: **0x02**.
- Request: `UVC_GET_CUR` (**0x81**), after GET_LEN.
- X: uint16 little-endian at byte offsets **38..39** (0x26).
- Y: uint16 little-endian at byte offsets **40..41** (0x28).

The SDK explicitly selects this decoding for PIDs 0x4c03 and 0x4c05.
The public `VideoMode` enum also identifies 0x07 as AutoFraming. The linkctl
implementation accepts mode 0x00, 0x01, and 0x07; it rejects special modes,
unknown modes, and the 0xFF idle/transition sentinel. It only
reaches these fields when the mode-state payload has at least 52 bytes;
46-byte special-mode payloads take a separate path. The attached camera
previously reported a 56-byte mode payload through linkctl info.

`GetHostPTZ` fills only zoomX and zoomY; it does not populate ratio or the
step fields. Do not assume a zero-initialized structure returned by it is
safe to pass directly to SetHostPTZ. Read/preserve zoom separately.

Static evidence: `GetHostPTZ` at `0x5f748`; `GetVideoMode` at `0x6a2e2`;
PID 0x4c03 comparison at `0x6a4d3`; X/Y extraction at `0x6a4ea` / `0x6a503`.

## Read-only hardware verification

On the connected Link 2C (2e1a:4c03, /dev/video0):

- Unit 10 / 0x13 GET_LEN returned `08 00` (8 bytes).
- Unit 10 / 0x13 GET_INFO returned `03` (GET and SET supported).
- Mode 0x07, 56-byte mode payload: frame readback was X=32767, Y=32767,
zoom=100 (approximately centered at 1x).

The CLI rounds normalized input to the nearest uint16 coordinate rather
than truncating as the SDK example does: 0.5 encodes to 32768. Unspecified
coordinates retain their exact raw readback values.

## Implementation boundary

The raw transport and encoding are identified, and framing writes have been
confirmed working by the camera owner. Edge behavior and Auto Framing interactions
still need broader validation. Keep Link 2 gimbal controls separate from Link 2C digital crop
controls, preserve the activity guard, validate GUIDs and lengths, and use
readback plus a visible stream to test the result. Never write to unit 9's
mode payload merely to move the crop.
19 changes: 11 additions & 8 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,20 @@ on the same video node. This has three consequences:
* other applications keep working while `linkctl` talks to the camera;
* the driver serialises our requests with regular control traffic.

Only one vendor control is written today: AI tracking on/off
(unit 11, selector 0x02, 1 byte). Every vendor write goes through
`V4l2Device::xu_write`, which requires a static `XuControl` descriptor and
enforces, in order:
Two vendor controls are written: AI tracking on/off (unit 11, selector
0x02, 1 byte) and Link 2C digital framing (unit 10, selector 0x13, 8 bytes;
see `link2c-framing.md`). Every vendor write goes through
`V4l2Device::xu_write`, which requires a static `XuControl` descriptor.
The write path and its callers enforce:

1. the payload length equals the documented constant;
2. the device's `GET_LEN` equals the documented constant;
3. the device's `GET_INFO` advertises SET support;
4. the unit id carries the expected GUID in the USB descriptors
(checked by the `tracking` command before any write);
5. the payload was obtained by reading the current value first
(read-modify-write);
(checked by the tracking/framing code before any write);
5. the payload preserves current values: tracking uses read-modify-write;
framing preserves unspecified coordinates from unit 9 mode-state readback
and zoom from V4L2, then encodes the fully documented Host PTZ payload;
6. the value is read back afterwards and a mismatch is reported.

Read-only vendor queries (`GET_LEN`, `GET_INFO`, `GET_CUR`) are used for
Expand Down Expand Up @@ -94,7 +96,8 @@ explicitly. Read commands and `preview` are always allowed.
* writes to unknown selectors, units or payload lengths;
* generic extension-unit scanning or fuzzing;
* writes to the 60/61-byte AI mode payload (unit 9, selector 0x02);
* constructing vendor payloads from scratch when a read is possible;
* constructing vendor payloads with unknown fields or discarding unspecified
user settings;
* detaching the kernel driver or resetting the USB device;
* changing udev rules, groups or ACLs on the user's behalf;
* installing software (e.g. FFmpeg for `preview`).
Expand Down
6 changes: 4 additions & 2 deletions src/camera/insta360/link2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ pub mod guid {
/// Extension unit ids as enumerated by the Link 2 descriptor. These are the
/// *expected* ids; [`resolve_units`] confirms them against the descriptor.
pub const XU_INFO_UNIT: u8 = 9;
#[allow(dead_code)] // documented for completeness; nothing on unit 10 is used yet
pub const XU_IMAGE_UNIT: u8 = 10;
pub const XU_AI_UNIT: u8 = 11;

Expand All @@ -65,7 +64,7 @@ pub const AI_TRACKING: XuControl = XuControl {

/// AI mode / status on unit 9, selector 0x02.
/// Byte 0: 0x00 normal, 0x01 tracking, 0x04 whiteboard, 0x05 overhead,
/// 0x06 deskview, 0xFF idle/transition. **Read-only in linkctl.**
/// 0x06 deskview, 0x07 auto framing, 0xFF idle/transition. **Read-only in linkctl.**
///
/// Length: csmarshall/link-ctl documents 61 bytes on the Link 2; the
/// development camera (firmware as of 2026-09) reports **60** via `GET_LEN`.
Expand All @@ -87,6 +86,7 @@ pub enum AiMode {
Whiteboard,
Overhead,
DeskView,
AutoFraming,
Idle,
Unknown(u8),
}
Expand All @@ -99,6 +99,7 @@ impl AiMode {
0x04 => AiMode::Whiteboard,
0x05 => AiMode::Overhead,
0x06 => AiMode::DeskView,
0x07 => AiMode::AutoFraming,
0xFF => AiMode::Idle,
other => AiMode::Unknown(other),
}
Expand All @@ -111,6 +112,7 @@ impl AiMode {
AiMode::Whiteboard => "whiteboard".into(),
AiMode::Overhead => "overhead".into(),
AiMode::DeskView => "deskview".into(),
AiMode::AutoFraming => "auto framing".into(),
AiMode::Idle => "idle".into(),
AiMode::Unknown(b) => format!("unknown (0x{b:02x})"),
}
Expand Down
Loading
Loading