← Home

Reverse-Engineering the Insta360 Link

The Insta360 Link has a motorised gimbal. Pan left, pan right, tilt up, zoom in. The official macOS app controls all of it through a slick UI. There’s no SDK, no documentation, no API reference. If you want to control the gimbal from your own code, you have to figure it out yourself.

I needed to do exactly that for my aircraft tracker. Here’s how I got from “I have no idea how this works” to cam.set_pan_tilt_degrees(45, 20).

USB Video Class

The Insta360 Link shows up as a standard webcam, which means it uses the USB Video Class specification. UVC is publicly documented. There’s a standard way to send control requests to a camera: a USB control transfer with a specific bmRequestType, a request code, a selector, a unit ID, and a payload.

The spec defines three kinds of units inside a UVC device:

The first two are documented. The third is opaque, vendor-defined, identified by a GUID. That’s where Insta360 put the gimbal control.

What the Descriptor Says

Before sniffing any traffic I read the USB configuration descriptor. macOS has system_profiler SPUSBDataType for a quick look, but I used lsusb -v on a Linux machine to dump the full descriptor.

The Insta360 Link comes back as VID 0x2E1A, PID 0x4C01. The Video Control interface has three units:

The extension unit declares 30 selectors (1 through 30), which tells you there are up to 30 different controls in there. It doesn’t tell you what any of them do.

Sniffing the Official App

I installed Wireshark and USBPcap (on a Windows VM - better USB capture tooling than macOS), started a capture, opened the official Insta360 Link Controller app, and clicked the pan/tilt buttons.

A UVC control transfer looks like this on the wire:

bmRequestType: 0x21  (host-to-device, class, interface)
bRequest:      0x01  (SET_CUR)
wValue:        0x1A00
wIndex:        0x0900
wLength:       8
Data:          00 00 00 00 40 42 0F 00

wValue encodes the selector in the high byte: 0x1A = selector 26. wIndex encodes the unit ID in the high byte and the interface number in the low byte: 0x09 = unit 9 (the extension unit), 0x00 = interface 0. Eight bytes of payload.

So pan/tilt absolute lives at extension unit, selector 0x1A. Not on the Camera Terminal where the UVC spec would put it - the standard PANTILT_ABSOLUTE at selector 0x0D on unit 1 doesn’t work on this camera. They’ve moved gimbal control to their own extension unit entirely, presumably to add features the spec doesn’t have room for.

Decoding the Payload

The first few captures I took were confusing. I’d pan right, and the data bytes would change in a way that looked like two 32-bit integers, but the sign was backwards from what I expected. I panned left and captured again. Then I panned to a known position using the on-screen degree display and worked backwards.

It turned out the byte order in the 8-byte payload is [tilt_i32_le, pan_i32_le] - tilt first, then pan. That’s the opposite of the naming convention in the UVC spec, which lists pan before tilt. And the unit isn’t degrees - it’s arc-seconds.

Arc-seconds: 3600 per degree. To pan 45° right you send 45 * 3600 = 162000, packed as a little-endian int32:

_ARCSECONDS_PER_DEGREE = 3600

def set_pan_tilt_degrees(self, pan_deg: float, tilt_deg: float) -> None:
    pan  = round(pan_deg  * self._ARCSECONDS_PER_DEGREE)
    tilt = round(tilt_deg * self._ARCSECONDS_PER_DEGREE)
    data = struct.pack("<ii", tilt, pan)   # tilt first - empirically verified
    self._eu_set(ExtensionUnit.PANTILT_ABSOLUTE, data)

The GET response uses the same byte order. Both set_pan_tilt and get_pan_tilt use [tilt, pan]. I was confused by this for about an hour, convinced I’d read the captures wrong, before accepting that it’s just how the firmware is.

The Other Selectors

With the approach confirmed for 0x1A, working out the others was mostly pattern-matching captures. Pan/tilt relative (0x16) turned out to be four bytes: direction and speed for each axis. Device info (0x03) is 255 bytes and contains the serial number, a UUID string, and the firmware version at fixed offsets I had to find empirically:

# Observed byte layout:
#   [  0- 15]  serial string, null-padded
#   [ 16- 31]  reserved (null bytes)
#   [ 32]      marker byte (0x01)
#   [ 33- 68]  UUID string (36 chars)
#   [ 97-111]  firmware string (e.g. "v1.3.1.8_build7")

I found the offsets by reading the raw bytes into a hex editor and looking for ASCII sequences I recognised - the serial number from the Insta360 app’s device info screen.

Auto-exposure (0x1E) is a single byte: 0x02 means auto, 0x01 means manual. That one took an embarrassingly long time to figure out because I kept assuming 0 was off and 1 was on.

Zoom is the one thing the Camera Terminal actually handles - it’s standard UVC ZOOM_ABSOLUTE at selector 0x0B, unit 1, two bytes unsigned. The range is 100 (1×) to 400 (4×).

Getting Access Without Root

The annoying thing about sending raw USB control transfers on macOS is that the kernel’s UVC driver already owns the device. To send a control request yourself you have to seize it, fire the request, and release it - and do that fast enough that the driver doesn’t notice anything went wrong.

The right API is IOKit’s USBDeviceOpenSeize, which grabs exclusive access and bumps whoever currently holds it. The camera driver gets it back when you close.

I can’t call IOKit directly from Python - there’s no usable binding for IOUSBDeviceInterface. So I embedded a small C helper in the Python package that gets compiled on first use:

ior = (*dev)->USBDeviceOpen(dev);
if (ior == kIOReturnExclusiveAccess)
    ior = (*dev)->USBDeviceOpenSeize(dev);

It takes arguments on the command line (selector, unit ID, interface, payload bytes), sends one control request, prints the response as hex to stdout, and exits. The Python layer calls it as a subprocess and parses the output.

This is awkward. A subprocess call per gimbal command, at 40 Hz, is not obviously going to work. In practice the overhead is about 20ms per call and the camera’s gimbal response is smooth enough that it doesn’t matter - the camera itself is the bottleneck.

There’s a transient failure mode where the driver reclaims the device between the Open and the request, especially under rapid-fire commands. The fix is a retry loop with a 100ms backoff, up to four attempts:

for _attempt in range(_retries + 1):
    result = subprocess.run(cmd, ...)

    if "OPEN_FAILED:0xe00002c5" not in result.stderr:
        break
    if _attempt < _retries:
        time.sleep(_retry_delay)

0xe00002c5 is kIOReturnExclusiveAccess. At 40 Hz this error appears occasionally but the retry catches it before it causes a visible tracking glitch.

What Didn’t Work

The standard Camera Terminal PANTILT_ABSOLUTE (0x0D on unit 1) accepts the request without error but does nothing. I spent a while on this before looking at the captures more carefully and noticing that the official app never sends to that selector.

GET_CUR on the extension unit’s pan/tilt selector (0x1A) correctly returns the current position. GET_MAX returns the limit. The standard GET_MIN / GET_RES codes return errors, which isn’t unusual for extension units.

The extension unit also has a PANTILT_RELATIVE selector (0x16) that starts continuous movement at a given speed. I don’t use it in the tracker - absolute position control at 40 Hz works better for flight tracking than a continuous slew - but it’s there if you want to jog the gimbal interactively.

The Result

cam = Insta360Link()
cam.set_pan_tilt_degrees(45, 20)   # 45° right, 20° up
cam.set_zoom(200)                   # 2× zoom

info = cam.get_device_info()
print(info.firmware_version)       # "v1.3.1.8_build7"

The library compiles its C helper on first import, and after that each call is just a subprocess invocation and some struct unpacking. No root, no kernel extension, no hacked drivers.


Get notified when I publish new writeups and progress updates.

← Back