Metadata-Version: 2.4
Name: link-ctl
Version: 2.1.4
Summary: CLI + pure-Python library for the Insta360 Link webcam — USB-direct on macOS via ctypes-to-IOKit, WebSocket fallback on Windows, v4l2 on Linux
License: MIT License
        
        Copyright (c) 2026 Charles Marshall
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Environment :: Console
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Home Automation
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: websockets>=11
Dynamic: license-file

# link-ctl

[![PyPI version](https://img.shields.io/pypi/v/link-ctl.svg)](https://pypi.org/project/link-ctl/)
[![Python](https://img.shields.io/pypi/pyversions/link-ctl.svg)](https://pypi.org/project/link-ctl/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-csmarshall-yellow?logo=buy-me-a-coffee)](https://buymeacoffee.com/cs_marshall)
[![Support the EFF](https://img.shields.io/badge/Support%20the%20EFF-donate-blue)](https://supporters.eff.org/donate/donate-eff-4)

## 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](https://www.reddit.com/r/Insta360/comments/1rmmpxh/og_insta360_link_doesnt_get_streamdeck_support/) —
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](https://www.eff.org/deeplinks/2019/10/adversarial-interoperability).

---

## What's new in v2.1

- **Pure-Python IOKit access.** The USB-direct backend on macOS is now
  `link_usb_macos.py` — a ~250-line ctypes module that calls `IOKit`
  directly from Python. **No external binary** is needed at runtime;
  `tools/uvc-probe` is kept as a CLI debugging tool and as a subprocess
  fallback, but it's no longer on the hot path.
- **12× faster.** Per-call latency went from ~6 ms (subprocess) to ~0.5 ms
  (in-process). The interactive `joystick` UI is visibly snappier and
  tight loops (e.g. `pan-rel` spam from a Stream Deck) no longer stutter.
- **Library-first.** `import link_ctl; link_ctl.write_pantilt(0, 0)` now
  does all its work in-process — no subprocess, no external dependency —
  making link-ctl viable as a real Python library, not just a CLI.

## 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](https://github.com/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](#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](#whats-new-in-v20) at top, or git history, for the full list.

---

## Table of Contents

- [What's new in v2.0](#whats-new-in-v20)
- [Feature Matrix](#feature-matrix)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Commands](#commands)
  - [PTZ Control](#ptz-control)
  - [AI Modes](#ai-modes)
  - [Image Settings](#image-settings)
  - [Presets](#presets)
  - [Interactive Joystick](#interactive-joystick)
  - [Diagnostics](#diagnostics)
  - [Global Flags](#global-flags)
- [USB-direct vs WebSocket](#usb-direct-vs-websocket)
- [File Locations](#file-locations)
- [Preflight Checks](#preflight-checks)
- [Port Discovery](#port-discovery)
- [State Cache](#state-cache)
- [Exit Codes](#exit-codes)
- [Stream Deck Setup](#stream-deck-setup)
- [Linux](#linux)
- [Protocol Notes](#protocol-notes)
- [Development](#development)

---

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](https://github.com/csmarshall/link-ctl/issues/5),
> [Link 2C](https://github.com/csmarshall/link-ctl/issues/6),
> [Link 2 Pro](https://github.com/csmarshall/link-ctl/issues/7), and
> [Link 2C Pro](https://github.com/csmarshall/link-ctl/issues/8)
> 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 |
| AI Tracking Speed | `track-speed <0-255>` | 20 (best-guess) | — | ⚠️ USB-direct R/W verified; OG Link behavioral semantics TBD |
| Noise Cancel | `noise-cancel on\|off\|toggle` | — (no proto entry) | — | ✅ USB-direct R/W verified; macOS only |
| Manual ISO | `iso <0-65535>` | 23 (best-guess) | — | ✅ USB-direct R/W verified; requires AE off |
| Manual Shutter | `shutter <0-65535>` | 24 (best-guess) | — | ✅ USB-direct R/W verified; requires AE off; units µs |
| 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 [option]` / `<option> status` | — | — | Per-option query (bool → exit 0/1) or full dump |
| 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](https://github.com/csmarshall/link-ctl/issues/2)) — run `preflight`, `status`, and `validate.py` on a real Windows machine with the camera connected
- [ ] **Newer camera compatibility** — unverified on [Link 2](https://github.com/csmarshall/link-ctl/issues/5), [Link 2C](https://github.com/csmarshall/link-ctl/issues/6), [Link 2 Pro](https://github.com/csmarshall/link-ctl/issues/7), [Link 2C Pro](https://github.com/csmarshall/link-ctl/issues/8)

### Resolved

- [x] **Anti-flicker** — paramType=27 confirmed; Auto=`"0"`, 50Hz=`"1"`, 60Hz=`"2"` all eyeball-confirmed.
- [x] **`smartcomposition` + `smartcomp-frame`** — confirmed from tshark capture. paramType=11 (on/off), paramType=10 (head/halfbody/wholebody). Requires AI tracking to be active.
- [x] **`autofocus` paramType** — paramType=18 confirmed from tshark capture. No DeviceInfo readback; explicit on/off required.
- [x] **Exposure compensation range** — paramType=16 confirmed, value 0–100, validated in `validate.py`.
- [x] **`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).
- [x] **Mirror/flip readback** — `DeviceInfo.mirror` does not update when paramType=2 is sent. Accepted limitation; `toggle` defaults to `on` when state is unknown. Documented in API.md.
- [x] **`autofocus` state readback** — structurally impossible; DeviceInfo has no autofocus field. Requires explicit `on|off`. Documented.
- [x] **WB temperature** — paramType=21 confirmed from tshark; `wb-temp <K>` command added; validated in `validate.py`.
- [x] **Gesture control zoom** — paramType=39 confirmed from tshark; `gesture-zoom on|off|toggle` command added.
- [x] **`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

```bash
# 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

```bash
# Verify everything is working
link-ctl preflight

# Find the WebSocket port
link-ctl discover

# Show current device state (table of every readable option)
link-ctl status

# Read one value; exit 0 if tracking is on, 1 otherwise
link-ctl track status
if link-ctl track status -q; then echo "tracking is on"; fi

# Center the camera
link-ctl center

# Enable AI tracking
link-ctl track on

# Zoom in
link-ctl zoom 200
```

---

## Commands

### PTZ Control

```bash
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`, `toggle`, or `status`. Omitting the
argument smart-toggles based on the current mode. `status` reads the current
mode without mutating and exits 0 if the named mode is active, 1 otherwise.

```bash
link-ctl track [on|off|toggle|status]      # Subject tracking
link-ctl deskview [on|off|toggle|status]   # DeskView (desk surface view)
link-ctl whiteboard [on|off|toggle|status] # Whiteboard mode
link-ctl overhead [on|off|toggle|status]   # Overhead / top-down
link-ctl normal                            # Return to standard mode (clears all AI modes)
link-ctl track-speed <0-255>               # AI tracking speed (default 2; OG Link semantics TBD)
```

The `track-speed` control persists any byte the firmware is given. The
default value on a fresh device is `2`. The mobile remote UI does not
expose this control, so the effective behavioral range on the OG Link is
not characterized — small values near the default are recommended until
someone runs a controlled subject-tracking comparison.

### Image Settings

Toggle commands accept `on`, `off`, `toggle`, or `status`. Omitting the
argument smart-toggles based on current device state; `status` reads the
current state and exits 0 (on) or 1 (off) without mutating. Exceptions:
- `autofocus` — requires explicit `on`, `off`, or `status`
- `mirror` — toggle defaults to `on` when state is unknown (DeviceInfo mirror field is unreliable; USB-direct read is authoritative)
- `gesture-zoom` — toggle defaults to `on` when state is unknown on the WS path

```bash
link-ctl hdr [on|off|toggle|status]               # HDR
link-ctl autoexposure [on|off|toggle|status]      # Auto exposure
link-ctl awb [on|off|toggle|status]               # Auto white balance
link-ctl smartcomposition [on|off|toggle|status]  # Smart Composition (requires AI tracking on)
link-ctl smartcomp-frame head|halfbody|wholebody|status  # Smart Composition framing
link-ctl autofocus on|off|status                  # Auto focus
link-ctl anti-flicker auto|50hz|60hz|status       # 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|status]            # Horizontal flip
link-ctl gesture-zoom [on|off|toggle|status]      # Gesture control zoom
link-ctl noise-cancel [on|off|toggle|status]      # Microphone noise cancellation (USB-direct only)
link-ctl iso <0-65535>                            # Manual ISO (autoexposure must be off)
link-ctl shutter <0-65535>                        # Manual shutter speed in µs (autoexposure must be off)
```

Scalar settings (brightness/contrast/etc.) are read via the top-level
`link-ctl status <option>` form — see Diagnostics below.

#### Manual exposure (iso / shutter)

`iso` and `shutter` are *AE-gated*: the firmware silently overrides any
write while autoexposure is on. The setter prints a warning if AE is on
and goes ahead anyway (so scripts can still automate the gating
themselves). Typical workflow:

```bash
link-ctl autoexposure off
link-ctl iso 200
link-ctl shutter 500   # microseconds; 1000 = 1 ms = 1/1000 s
link-ctl status iso
link-ctl status shutter
```

Read access is **not** gated — `link-ctl status iso` works regardless of
AE state and returns whatever the firmware currently reports.

### 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.

```bash
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:**

```json
{
  "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

```bash
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

```bash
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                     # Table of every readable option
link-ctl status --json              # Same, as JSON
link-ctl status <option>            # One value to stdout; exit 0/1 for bools
link-ctl status <option> -q         # Silent; exit code only
link-ctl status <option> --json     # {"option": ..., "value": ..., "is_on": ...}
```

`link-ctl status <opt>` and `link-ctl <opt> status` are equivalent for any
option that takes a state arg (track, hdr, mirror, etc.). Scalar settings
(brightness, contrast, zoom, …) are only readable via the top-level form.

**Exit codes for status:**
- Boolean options (`hdr`, `mirror`, `awb`, `autoexposure`, `autofocus`,
  `gesture-zoom`, `smartcomposition`) and AI-mode names (`track`,
  `deskview`, `whiteboard`, `overhead`): **0 if on, 1 if off**. Matches
  `systemctl is-active` / `grep` convention so you can write
  `if link-ctl track status; then …`.
- Enums (`anti-flicker`, `smartcomp-frame`, `mode`) and scalars
  (`brightness`, `zoom`, etc.): exit 0; current value to stdout.
- Read failure: exit 3. Option unavailable on this platform: exit 4.

### 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 running** — `pgrep -f "Insta360 Link Controller"` (macOS) / `tasklist` (Windows)
2. **Camera USB** — `ioreg -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:

```json
{
  "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](https://github.com/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:

```python
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:

```bash
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:
```bash
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](DEVELOPMENT.md) for reverse-engineering details, protocol internals,
USB direct control documentation, and development tools.
