← Home

Reverse-Engineering the Insta360 Link

The Insta360 Link has a motorised gimbal, and the official macOS app can drive it just fine. Pan left, pan right, tilt up, zoom in. If you want to do the same thing from your own code, though, you’re on your own. There is no SDK, no documentation, no API reference, and no supported way to send the camera directly where you want it to go.

I needed exactly that for my aircraft tracker. I didn’t need a nicer UI or a better abstraction. I needed direct PTZ control over a device that officially exposes none. So this turned into a small reverse-engineering job. I read the descriptors, captured the vendor app’s traffic, worked out which bytes mattered, and kept eliminating wrong guesses until cam.set_pan_tilt_degrees(45, 20) worked.

What the Descriptor Says

Before sniffing any traffic I read the USB configuration descriptor. The Link shows up as a standard webcam, so it uses the USB Video Class specification. That at least gives you a vocabulary, because UVC devices expose control requests built from a bmRequestType, a request code, a selector, a unit ID, and a payload.

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 UVC spec defines those units like this:

The first two are documented. The third is opaque, vendor-defined, identified by a GUID. Unit 9 declares 30 selectors (1 through 30), which tells you there are up to 30 different controls in there. It tells you nothing about what any of them do, but it strongly suggests where the interesting parts are hiding.

Sniffing the Official App

At this point I had a plausible place to look, but not an actual command. So I installed Wireshark and USBPcap on a Windows VM, which has 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 the first real discovery was that pan/tilt absolute lives at extension unit, selector 0x1A. Not on the Camera Terminal where the UVC spec would normally put it. The standard PANTILT_ABSOLUTE at selector 0x0D on unit 1 accepts the request without error but does nothing on this camera. I lost some time there before accepting the capture because the official app never touches the standard selector. Insta360 moved gimbal control into the extension unit entirely, presumably to add features the spec doesn’t have room for.

Decoding the Payload

Finding the selector didn’t mean the payload made sense. The first few captures were mostly confusion. 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 moved the camera 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], with tilt first and pan second. That’s the opposite of the naming convention in the UVC spec, which lists pan before tilt. The unit was wrong too. It uses arc-seconds, not degrees.

Arc-seconds are 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 spent about an hour convinced I’d misread the captures before accepting that the firmware really does it that way.

The Rest of the Extension Unit

Once 0x1A made sense, the rest of the extension unit was mostly pattern-matching captures and verifying guesses.

Pan/tilt relative (0x16) turned out to be four bytes for direction and speed on 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, starting with the serial number from the Insta360 app’s device info screen.

Auto-exposure (0x1E) is a single byte. 0x02 means auto and 0x01 means manual. That one took longer than it should have 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

Working out the packets was only half the problem. The problem with 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, then 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 because 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 ideal. In practice the overhead is about 20ms per call and the camera’s gimbal response is smooth enough that it doesn’t matter because 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 and 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. That’s a misleading failure mode because it looks correct right up until the camera refuses to move. 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 because 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.

The end result is simple in the only way that matters. I can tell the camera where to point, read back where it is, control zoom, and pull device info from code that was never supposed to have any of that.


Get notified when I publish new writeups and progress updates.

← Back