Skip to main content

CLI + Python library for the Insta360 Link webcam — USB-direct on macOS, WebSocket fallback on Windows, v4l2 on Linux

Project description

link-ctl

PyPI version Python License: MIT Buy Me a Coffee Support the EFF

Why this exists

The Insta360 Link (original) is a great webcam. The Link Controller desktop app is fine. But when Insta360 added first-party Stream Deck plugin support, it was only for the Link 2 — leaving original Link owners with a suggestion to use global hotkeys instead.

That's a reasonable workaround, but it felt like a miss for people who had invested in the original hardware. The Link Controller app already exposes a local WebSocket API for its mobile remote — the first version of this tool used that path.

In v2.0 the tool goes a step further: on macOS it talks to the camera directly over USB using the same UVC Extension Unit registers the desktop app itself uses. You no longer need the Link Controller app running at all for PTZ, presets, or image/AI settings. No WebSocket, no token, no sudo, no code signing.

If you have an OG Link and want proper automation, Stream Deck support, or scripting, this is for you. Connecting software you own to hardware you own is something worth protecting.


What's new in v2.0

  • USB-direct control on macOS. PTZ, presets, AI modes, image settings, and interactive joystick all work without the Link Controller desktop app running. The tool speaks directly to the camera's UVC Extension Unit registers via a small IOKit helper (tools/uvc-probe) that needs no sudo, no entitlements, and no code signing — thanks to the "never open the interface" pattern from jtfrey/uvc-util.
  • Interactive joystick mode. Full-screen curses UI: arrow keys pan/tilt, +/- zoom, f toggles 5× fast-mode, digits 0-9 recall presets, s/n/d save/ rename/delete presets, c centers. Built for quick manual framing.
  • Host-side presets. preset-save, preset-recall, preset-rename, preset-delete, preset-list store camera position in ~/.config/link-ctl/presets.json — 10 slots with user-named entries. Survives Link Controller restarts because they don't live in the app. See Presets for the file format.
  • Removed privacy command. On the original Link it was always theatre — the LED stayed on, the sensor kept streaming, other apps still saw the feed. Use a physical lens cover for real privacy, or the Link 2C's built-in shutter.
  • Fix: preset-save now actually saves (was silently success=0 reason=0 on empty slots in v1.x because it sent UPDATE instead of ADD).
  • New commands: preset-recall, preset-update, preset-list, joystick.

See CHANGELOG at top, or git history, for the full list.


Table of Contents


CLI tool to control an Insta360 Link (original) webcam.

On macOS the tool talks directly to the camera over USB — no app, no WebSocket. On Windows and as a fallback on macOS, it uses the Insta360 Link Controller desktop app's local WebSocket API. On Linux PTZ works via v4l2-ctl; image/AI settings still need the desktop app (which doesn't run on Linux today).

Newer models: Compatibility with the Link 2, Link 2C, Link 2 Pro, and Link 2C Pro is unverified — if you have one, give it a try and report back!


Feature Matrix

Legend: ✅ confirmed + validated · ⚠️ confirmed sent, limited/no readback · ❌ not supported

PTZ

UI Feature CLI Command USB-direct (macOS) WS fallback Notes
Zoom zoom N / zoom-rel ±N ✅ CT(1) sel 0x0B ✅ paramType 4 100–400, uint16 LE
Pan (absolute) pan N ✅ CT(1) sel 0x0D USB-direct only (WS is velocity-only)
Tilt (absolute) tilt N ✅ CT(1) sel 0x0D USB-direct only
Pan (relative) pan-rel N ✅ paramType 6/7 1 step = 3000 USB units
Tilt (relative) tilt-rel N ✅ paramType 6/7
Center/reset center ✅ paramType 3 pan=0, tilt=0, zoom=100
Interactive joystick joystick Full-screen curses UI

AI Modes

UI Feature CLI Command paramType validate.py Notes
Normal normal 5 Clears all AI modes
AI Tracking track on|off|toggle 5 ✅ track Smart toggle reads mode field
Overhead overhead on|off|toggle 5 ✅ overhead
DeskView deskview on|off|toggle 5 ✅ deskview
Whiteboard whiteboard on|off|toggle 5 ✅ whiteboard

Image Settings

UI Feature CLI Command paramType validate.py Notes
HDR hdr on|off|toggle 26 ✅ hdr
Auto Focus autofocus on|off 18 ✅ Confirmed from tshark; no DeviceInfo readback, explicit on/off required
Auto Exposure autoexposure on|off|toggle 17 ✅ autoexposure
Exposure Comp exposurecomp 0-100 16 ✅ exposurecomp 50 = 0 EV; only active when AE is off
Auto White Balance awb on|off|toggle 20 ✅ awb
WB Temperature wb-temp 2800-10000 21 ✅ wb-temp Kelvin; only active when AWB is off
Brightness brightness 0-100 22 ✅ brightness Default 50
Contrast contrast 0-100 23 ✅ contrast Default 50
Saturation saturation 0-100 24 ✅ saturation Default 50; 0 = greyscale
Sharpness sharpness 0-100 25 ✅ sharpness Default 50
Anti-Flicker anti-flicker auto|50hz|60hz 27 ✅ All three values confirmed
Horizontal Flip mirror on|off|toggle 2 ⚠️ DeviceInfo mirror field does not update; toggle defaults to on
Smart Composition smartcomposition on|off|toggle 11 ✅ Confirmed from capture; requires AI tracking on
Smart Comp Frame smartcomp-frame head|halfbody|wholebody 10 ✅ Confirmed from capture
Gesture Zoom gesture-zoom on|off|toggle 39 ⚠️ No DeviceInfo readback

Presets

UI Feature CLI Command paramType validate.py Notes
Preset recall preset 0-19 ⚠️ preset ✅ Live tested; serial in field 4 (confirmed from capture)
Preset save preset-save 0-19 ⚠️ preset-save ✅ Live tested
Preset delete preset-delete 0-19 ⚠️ preset-delete ✅ Live tested; slot disappears from UI

Diagnostics

UI Feature CLI Command paramType validate.py Notes
Status status JSON dump of all DeviceInfo fields
Preflight preflight Checks process, USB, port, handshake
Port discovery discover lsof → priority ports → range scan

Platform coverage

Platform PTZ AI modes Image settings Preflight Tested
macOS ✓ WebSocket Yes
Windows ✓ WebSocket ✓ (tasklist/wmic) No — code written, never run on Windows
Linux ✓ v4l2-ctl ✗ (exit 4) ✗ (exit 4) Partial

Outstanding work

  • Windows validation (#2) — run preflight, status, and validate.py on a real Windows machine with the camera connected
  • Newer camera compatibility — unverified on Link 2, Link 2C, Link 2 Pro, Link 2C Pro

Resolved

  • Anti-flicker — paramType=27 confirmed; Auto="0", 50Hz="1", 60Hz="2" all eyeball-confirmed.
  • smartcomposition + smartcomp-frame — confirmed from tshark capture. paramType=11 (on/off), paramType=10 (head/halfbody/wholebody). Requires AI tracking to be active.
  • autofocus paramType — paramType=18 confirmed from tshark capture. No DeviceInfo readback; explicit on/off required.
  • Exposure compensation range — paramType=16 confirmed, value 0–100, validated in validate.py.
  • preset-save/preset/preset-delete — live tested: save moves camera to position, recall returns to it, delete removes slot from UI. Wire format confirmed from tshark capture — serial is in field 4 (not field 3 as the proto schema suggested).
  • Mirror/flip readbackDeviceInfo.mirror does not update when paramType=2 is sent. Accepted limitation; toggle defaults to on when state is unknown. Documented in API.md.
  • autofocus state readback — structurally impossible; DeviceInfo has no autofocus field. Requires explicit on|off. Documented.
  • WB temperature — paramType=21 confirmed from tshark; wb-temp <K> command added; validated in validate.py.
  • Gesture control zoom — paramType=39 confirmed from tshark; gesture-zoom on|off|toggle command added.
  • validate.py — 17/17 tests passing: zoom, track, overhead, deskview, whiteboard, hdr, brightness, contrast, saturation, sharpness, exposurecomp, autoexposure, awb, wb-temp, preset-save, preset, preset-delete.

Requirements

Requirement Notes
macOS or Windows (primary) Linux supported for PTZ via v4l2-ctl; AI/image commands require the desktop app
Insta360 Link Controller ≥ v2.2.1 Exposes the WebSocket server used by the mobile remote
Python ≥ 3.11
websockets ≥ 11 pip install websockets

Installation

# Homebrew (macOS)
brew tap csmarshall/link-ctl https://github.com/csmarshall/link-ctl
brew install link-ctl

# pipx — recommended for Python CLI tools
pipx install link-ctl

# pip
pip install link-ctl

No pipx? brew install pipx && pipx ensurepath


Quick Start

# Verify everything is working
link-ctl preflight

# Find the WebSocket port
link-ctl discover

# Show current device state
link-ctl status

# Center the camera
link-ctl center

# Enable AI tracking
link-ctl track on

# Zoom in
link-ctl zoom 200

# Enter privacy mode (lens points straight down)
link-ctl privacy on

Commands

PTZ Control

link-ctl pan <N>           # Absolute pan  (USB-direct only; not on WS)
link-ctl tilt <N>          # Absolute tilt (USB-direct only; not on WS)
link-ctl pan-rel <steps>   # Relative pan,  -30 .. 30 steps
link-ctl tilt-rel <steps>  # Relative tilt, -30 .. 30 steps
link-ctl zoom <value>      # Absolute zoom: 100 .. 400
link-ctl zoom-rel <delta>  # Relative zoom (e.g. 50 or -50)
link-ctl center            # Reset pan=0, tilt=0, zoom=100

Absolute pan/tilt is only available on the USB-direct path (macOS with tools/uvc-probe). The WebSocket API in Link Controller v2.2.1 is velocity-only; USB-direct uses the camera's standard UVC CT_PANTILT_ABSOLUTE_CONTROL (unit 1, selector 0x0D) which accepts an 8-byte pan_int32_le + tilt_int32_le payload. Approximate range is ±150000 on each axis.

AI Modes

All AI mode commands accept on, off, or toggle. Omitting the argument smart-toggles based on the current mode.

link-ctl track [on|off|toggle]      # Subject tracking
link-ctl deskview [on|off|toggle]   # DeskView (desk surface view)
link-ctl whiteboard [on|off|toggle] # Whiteboard mode
link-ctl overhead [on|off|toggle]   # Overhead / top-down
link-ctl normal                     # Return to standard mode (clears all AI modes)

Image Settings

Toggle commands accept on, off, or toggle. Omitting the argument smart-toggles based on current device state. Exceptions:

  • autofocus — requires explicit on or off; camera does not report autofocus state
  • mirror — toggle defaults to on when state is unknown (DeviceInfo mirror field is unreliable)
  • gesture-zoom — toggle defaults to on when state is unknown (no DeviceInfo readback)
link-ctl hdr [on|off|toggle]               # HDR
link-ctl autoexposure [on|off|toggle]      # Auto exposure
link-ctl awb [on|off|toggle]               # Auto white balance
link-ctl smartcomposition [on|off|toggle]  # Smart Composition (requires AI tracking on)
link-ctl smartcomp-frame head|halfbody|wholebody  # Smart Composition framing
link-ctl autofocus on|off                  # Auto focus (explicit on/off; no readback)
link-ctl anti-flicker auto|50hz|60hz      # Anti-flicker mode
link-ctl brightness <0-100>               # Brightness (default 50)
link-ctl contrast <0-100>                 # Contrast (default 50)
link-ctl saturation <0-100>               # Saturation (default 50; 0 = greyscale)
link-ctl sharpness <0-100>               # Sharpness (default 50)
link-ctl exposurecomp <0-100>            # Exposure compensation (50 = 0 EV; AE must be off)
link-ctl wb-temp <2800-10000>            # WB temperature in Kelvin (AWB must be off)
link-ctl mirror [on|off|toggle]          # Horizontal flip (toggle defaults to on)
link-ctl gesture-zoom [on|off|toggle]    # Gesture control zoom (toggle defaults to on)

Presets

Presets store the camera's (pan, tilt, zoom) in a local JSON file. On macOS this is USB-direct — no desktop app, no WebSocket. Up to 10 named slots (0-9). On Windows / Linux the commands fall back to the WebSocket PresetUpdateRequest path.

link-ctl preset-save <0-9> [name]    # Save current PTZ, name is optional
link-ctl preset-recall <0-9>         # Move camera back to saved position
link-ctl preset <0-9>                # Alias for preset-recall
link-ctl preset-rename <0-9> <name>  # Rename a saved preset
link-ctl preset-delete <0-9>         # Remove a saved preset
link-ctl preset-list [--json]        # Show all saved presets

Storage path: ~/.config/link-ctl/presets.json

Schema:

{
  "version": 1,
  "presets": {
    "0": {"name": "desk",   "pan": 0,     "tilt": 0,     "zoom": 100},
    "1": {"name": "window", "pan": 45000, "tilt": 60000, "zoom": 200}
  }
}

pan / tilt are signed 32-bit integers in the UVC Extension Unit's native units (roughly ±150000 full-range on each axis). zoom is an unsigned 16-bit integer in the range 100..400 (100 = 1×, 400 = 4×). The file is safe to hand-edit or generate from scripts; link-ctl reads it fresh on every command.

Interactive Joystick

link-ctl joystick

Full-screen curses UI with arrow keys for pan/tilt, +/- for zoom, and preset slot bindings. Requires tools/uvc-probe (macOS).

  Keys
    arrows        pan / tilt
    f             toggle fast mode (5× step)
    shift+arrow   one-shot fast step (iTerm2 / terminals that send modifiers)
    +/-           zoom in/out (step 10)
    0-9           recall preset slot N
    s             save current position to slot (prompts for slot + name)
    n             rename a slot (prompts for slot + new name)
    d             delete a slot
    c             center (pan=0, tilt=0, zoom=100)
    q             quit

Saves made in the joystick UI are stored in the same presets.json file as the preset-* CLI commands.

Diagnostics

link-ctl preflight          # Run all validation checks (process, USB, port, handshake)
link-ctl discover           # Find the WebSocket port and cache it
link-ctl discover --verbose # Show every port tried during scan
link-ctl status             # Dump full device info as JSON

Global Flags

-v, --verbose     Show current and new values on each change (default)
-q, --quiet       Suppress informational output; show errors only
-s, --silent      Suppress all output including errors (useful for scripts)
-d, --debug       Hex-dump every WebSocket frame sent/received (to stderr)
    --port N      Override port discovery; connect to port N directly
    --skip-preflight  Skip process/USB/port checks (use cached port)

By default (--verbose) every command prints a current → new line to stderr before applying, e.g. brightness: 50 → 80. Use --quiet for Stream Deck scripts where you only want errors, or --silent for fully silent automation.


Preflight Checks

Before every command, link-ctl validates:

  1. Controller runningpgrep -f "Insta360 Link Controller" (macOS) / tasklist (Windows)
  2. Camera USBioreg -p IOUSB (macOS) or wmic Win32_PnPEntity (Windows)
  3. Port discovery — cache → lsof → priority scan → range scan
  4. WebSocket handshake — connects and exchanges controlRequest/Response

Skip with --skip-preflight when you need maximum speed (e.g., rapid Stream Deck presses) and know the environment is already validated.


Port Discovery

The tool finds the WebSocket port in this order:

  1. Cached value in ~/.config/link-ctl/state.json (valid for 5 minutes)
  2. lsof by PID (macOS/Linux) or netstat -ano (Windows)
  3. Priority ports: 7878, 9090, 9091, 9000, 8080
  4. Range scan: 7000–7999, 9000–9099, 49900–49950

The discovered port is cached automatically.


State Cache

~/.config/link-ctl/state.json stores:

{
  "port": 7878,
  "timestamp": 1710000000.0,
  "deviceSerialNum": "AABBCCDD12345678",
  "zoom": 100
}

zoom is read from the device on each connection and used by zoom-rel to compute the new absolute value (on the WebSocket path).


USB-direct vs WebSocket

Every command inside link-ctl picks one of two paths at runtime based on platform and whether tools/uvc-probe is available:

Platform PTZ Presets AI modes / image Fallback
macOS + tools/uvc-probe present USB-direct USB-direct USB-direct
macOS without uvc-probe WS WS WS Link Controller app must be running
Windows WS WS WS Link Controller app must be running
Linux v4l2-ctl WS (if Link Controller is somehow running) WS image/AI only works with the app

The uvc-probe binary is compiled from tools/uvc-probe.m (Objective-C / IOKit, ~350 lines) and bundled with the Homebrew formula and the release tarballs. It uses the "never open the interface" technique from jtfrey/uvc-util so it needs no sudo, no com.apple.security.device.usb entitlement, and no codesigning step — just clang and run.

If you want to force the WebSocket path temporarily (e.g. to compare behaviour), rename tools/uvc-probe out of the way and re-run.


File Locations

Path Purpose
~/.config/link-ctl/state.json Discovered WS port cache + last-known zoom
~/.config/link-ctl/presets.json USB-direct preset definitions (10 slots, JSON)
~/Library/Application Support/Insta360 Link Controller/startup.ini Link Controller app's WS auth token (used only by the WS fallback)
tools/uvc-probe IOKit helper (macOS) — compile from tools/uvc-probe.m
tools/usb-suspend USB suspend helper for real camera-off; needs sudo (not used by default)

Using as a Python library

link_ctl.py is a single-module library — its top-level functions are the same ones the CLI dispatcher calls internally. You can import and use them from your own scripts:

import link_ctl as lc

pan, tilt = lc.read_pantilt()     # current position
zoom = lc.read_zoom()             # 100..400
lc.write_pantilt(0, 0)            # center
lc.write_zoom(200)                # 2× zoom
lc.write_ai_mode('track')         # enable AI tracking
lc.write_ai_mode('normal')        # back to normal

# Host-side presets (same JSON file as the CLI)
lc.usb_preset_save(3, name='desk')
info = lc.usb_preset_recall(3)    # → {'name': 'desk', 'pan': 0, 'tilt': 0, 'zoom': 100}
lc.usb_preset_list()              # all slots

All the USB-direct functions above require tools/uvc-probe to be reachable from the working directory. A clean link_ctl package (submodules for usb, ws, presets, cli) is planned for a future release — the current module-level API is stable for v2.x.


Exit Codes

Code Meaning
0 Success
1 Command error (bad args, out of range)
2 Preflight failed (controller not running, camera not found, port not found)
3 Connection error (WebSocket failed, timeout, rejected)
4 Command not supported on this platform

Stream Deck Setup

The streamdeck/ directory contains ready-to-use shell scripts.

Setup

  1. In Elgato Stream Deck software, add a System → Open or Multi Action button for each script, or use the Stream Deck Shell / KMtricks plugin to run shell scripts.
  2. Point the script path at the file, e.g.:
    /Users/you/work/link-ctl/streamdeck/track_on.sh
    
  3. All scripts are self-contained — they resolve link-ctl.py relative to their own location, so moving the directory keeps them working.

Available Scripts

Script Action
center.sh Reset pan, tilt, zoom to defaults
track_on.sh / track_off.sh Toggle AI tracking
deskview_on.sh / deskview_off.sh Toggle DeskView
whiteboard_on.sh / whiteboard_off.sh Toggle whiteboard mode
privacy_on.sh / privacy_off.sh Enter / exit privacy mode
zoom_in.sh Zoom in +50 (incremental)
zoom_out.sh Zoom out −50 (incremental)
normal.sh Return to standard mode

Performance Tips

  • First run after boot may take ~0.5 s while the port is discovered and cached.
  • Subsequent runs use the cached port and complete in well under 1 s.
  • If the Link Controller is restarted, the cache invalidates automatically on the next failed connection and re-discovery happens transparently.

Linux

PTZ commands fall back to v4l2-ctl automatically:

sudo apt install v4l-utils
link-ctl zoom 200      # → v4l2-ctl -d /dev/video0 --set-ctrl zoom_absolute=200
link-ctl pan-rel 5     # → v4l2-ctl -d /dev/video0 --set-ctrl pan_relative=5

Override the device with:

LINK_CTL_V4L2_DEVICE=/dev/video2 link-ctl zoom 150

AI modes and image settings exit with code 4 on Linux — they require the Link Controller app (macOS/Windows only).


Protocol Notes

Messages are binary Protocol Buffers sent over a local WebSocket. The tool uses manual wire encoding — no compiled protobuf library required, just websockets.

Tested against Link Controller v2.2.1.

Key message flow:

client → server: (connect to ws://localhost:PORT/?token=TOKEN)
server → client: connectionNotify { connectNum, inControl }
client → server: controlRequest  { token }
server → client: controlResponse { success: true }
server → client: deviceInfoNotify { devices: [...] }
client → server: <command>
client → server: (close)

All camera commands use ValueChangeNotification (outer field 7 + field 16). Preset recall uses PresetUpdateRequest (outer field 6 + field 15).

Confirmed paramType Map

paramType Feature Value
2 Horizontal flip "1" / "0"
3 Reset pan/tilt to center (no value)
4 Zoom "100""400"
5 AI mode "0"=normal "1"=tracking "4"=overhead "5"=deskview "6"=whiteboard
6 Joystick velocity (pan/tilt) float32 sub-message: pan_vel, tilt_vel (−1.0 to +1.0)
7 Joystick stop float32 sub-message: 0.0, 0.0
10 Smart composition framing "1"=Head "2"=HalfBody "3"=WholeBody
11 Smart composition on/off "1" / "0" (requires AI tracking on)
16 Exposure compensation "0""100" (default 50 = 0 EV)
17 Auto exposure "1" / "0"
18 Auto focus "1" / "0"
20 Auto white balance "1" / "0"
21 WB temperature "2800""10000" K (AWB must be off)
22 Brightness "0""100" (default 50)
23 Contrast "0""100" (default 50)
24 Saturation "0""100" (default 50; 0 = greyscale)
25 Sharpness "0""100" (default 50)
26 HDR "1" / "0"
27 Anti-flicker "0"=Auto "1"=50Hz "2"=60Hz
39 Gesture control zoom "1" / "0"

Note: All paramTypes above were confirmed by tshark capture or direct WS test (2026-03-10). The proto enum values in the app bundle have incorrect numbers for v2.2.1 — do not use them.

Preset recall uses PresetUpdateRequest: type=4 (RECALL), position=index (0-based), serial.

Reference: https://dt.in.th/Insta360LinkControllerWebSocketProtocol


Development

See DEVELOPMENT.md for reverse-engineering details, protocol internals, USB direct control documentation, and development tools.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

link_ctl-2.0.0.tar.gz (37.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

link_ctl-2.0.0-py3-none-any.whl (37.1 kB view details)

Uploaded Python 3

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page