Skip to content
Open
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
19 changes: 17 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ jobs:
target: daemon-mac
- os: macos-14
target: linkctl-cli-mac
# - os: windows-latest
# target: daemon-win
- os: windows-latest
target: panel-win
# - os: ubuntu-latest
# target: firmware

Expand Down Expand Up @@ -60,3 +60,18 @@ jobs:
with:
name: linkctl-${{ matrix.os }}
path: host/linkctl/build/linkctl

# --- linkctl-panel (Windows) — no external dependencies ---
- name: Build linkctl-panel (Windows)
if: matrix.target == 'panel-win'
working-directory: host/linkctl-panel
run: |
cmake -B build
cmake --build build --config Release

- name: Upload linkctl-panel artifact
if: matrix.target == 'panel-win'
uses: actions/upload-artifact@v4
with:
name: linkctl-panel-${{ matrix.os }}
path: host/linkctl-panel/build/Release/linkctl-panel.exe
38 changes: 34 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ jobs:
- os: macos-14
target: linkctl-cli-mac
arch: arm64
# - os: windows-latest
# target: daemon-win
# arch: x64
- os: windows-latest
target: panel-win
arch: x64

runs-on: ${{ matrix.os }}

Expand All @@ -30,6 +30,7 @@ jobs:

- name: Extract version from tag
id: version
shell: bash
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

# --- daemon-mac ---
Expand Down Expand Up @@ -92,6 +93,33 @@ jobs:
name: release-${{ matrix.target }}
path: ${{ steps.package-cli.outputs.ARCHIVE }}

# --- linkctl-panel (Windows) — no external dependencies ---
- name: Build linkctl-panel (Windows)
if: matrix.target == 'panel-win'
working-directory: host/linkctl-panel
run: |
cmake -B build
cmake --build build --config Release

- name: Package linkctl-panel release (Windows)
if: matrix.target == 'panel-win'
id: package-panel
shell: bash
run: |
STAGING="linkctl-panel-${{ steps.version.outputs.VERSION }}-windows-${{ matrix.arch }}"
mkdir -p "$STAGING"
cp host/linkctl-panel/build/Release/linkctl-panel.exe "$STAGING/"
cp host/linkctl-panel/README.md "$STAGING/"
7z a "${STAGING}.zip" "$STAGING"
echo "ARCHIVE=${STAGING}.zip" >> "$GITHUB_OUTPUT"

- name: Upload linkctl-panel artifact
if: matrix.target == 'panel-win'
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.target }}
path: ${{ steps.package-panel.outputs.ARCHIVE }}

create-release:
needs: build-release
runs-on: ubuntu-latest
Expand All @@ -108,5 +136,7 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: "*.tar.gz"
files: |
*.tar.gz
*.zip
generate_release_notes: true
66 changes: 54 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ ESP32-based joystick controller for Insta360 Link webcam. Controls pan/tilt/zoom
## Architecture

```
macOS:
[Insta360 Link] --USB/UVC--> [linkctl-daemon (C, libuvc)] --WiFi/WebSocket--> [ESP32 + Joystick]
--TCP/9001--------> [linkctl CLI]
Windows:
[Insta360 Link] --USB/UVC--> [linkctl-panel (C, Win32)] --WiFi/WebSocket--> [ESP32 + Joystick]
-- GUI control panel (same process)
mDNS: _insta360ctrl._tcp
Binds all interfaces
No auth required
Expand All @@ -19,6 +23,7 @@ ESP32-based joystick controller for Insta360 Link webcam. Controls pan/tilt/zoom
- **linkctl-daemon**: Mac-side C program that controls the camera directly via UVC extension units
- **ESP32 firmware**: Reads joystick, discovers daemon via mDNS, sends JSON commands over WebSocket
- **linkctl CLI**: Command-line client (pure POSIX C, zero dependencies), connects to daemon TCP socket on port 9001
- **linkctl-panel**: Windows-side GUI control panel (pure Win32 C, zero dependencies) that replaces both the daemon and the CLI on Windows: controls the camera via DirectShow/kernel streaming and runs the same WebSocket + mDNS interface for the ESP32. No TCP/9001 control socket on Windows.

## Hardware

Expand Down Expand Up @@ -83,11 +88,24 @@ make
sudo cmake --install . --prefix /usr/local
```

### Windows Control Panel

```powershell
cd host\linkctl-panel
cmake -B build
cmake --build build --config Release

.\build\Release\linkctl-panel.exe # Run the control panel
.\build\Release\linkctl-panel.exe --console # With a console for [camera]/[server] logs
```

Builds with MSVC or MinGW-w64 (CI cross-compiles with `x86_64-w64-mingw32-gcc` also works for syntax checking on Linux). No external libraries.

## Protocol

Two interfaces:
- **WebSocket** (port 9000): JSON text frames for ESP32 firmware
- **TCP** (port 9001): Newline-terminated text commands for `linkctl` CLI
Two interfaces on macOS, one on Windows:
- **WebSocket** (port 9000): JSON text frames for ESP32 firmware (daemon and linkctl-panel)
- **TCP** (port 9001): Newline-terminated text commands for `linkctl` CLI (macOS daemon only)

### Commands (ESP32 → Daemon)

Expand Down Expand Up @@ -133,6 +151,13 @@ Two interfaces:
- `dns_sd.h` - mDNS advertisement (built into macOS)
- `OpenSSL` - Required by libwebsockets headers (Homebrew)

**Windows panel (no external libraries — all built into Windows):**
- DirectShow (`strmiids`) - camera enumeration + `IAMCameraControl` zoom
- Kernel streaming (`IKsControl` via `KSP_NODE`) - XU pan/tilt/center (usbvideo.sys owns the device; raw USB transfers are not possible)
- SetupAPI + USB hub IOCTLs - reads the XU `guidExtensionCode` from USB descriptors at runtime
- Winsock (`ws2_32`) - hand-rolled RFC 6455 WebSocket server (own SHA-1/Base64, see `ws.c`)
- `dnsapi` (`DnsServiceRegister`) - mDNS advertisement, Windows 10 1809+

## File Structure

```
Expand All @@ -152,15 +177,24 @@ Two interfaces:
│ ├── linkctl/
│ │ ├── CMakeLists.txt # CLI build config
│ │ └── linkctl.c # CLI client (pure POSIX C, zero deps)
│ └── linkctl-daemon/
│ ├── CMakeLists.txt # Daemon build config
│ ├── main.c # Daemon entry point and event loop
│ ├── camera.c/h # IOKit USB camera control
│ ├── server.c/h # WebSocket server + JSON command dispatch
│ ├── control.c/h # TCP control socket (for CLI)
│ ├── mdns.c/h # mDNS service advertisement
│ ├── install.sh # Installation script
│ └── service.sh # launchd service management helper
│ ├── linkctl-daemon/
│ │ ├── CMakeLists.txt # Daemon build config
│ │ ├── main.c # Daemon entry point and event loop
│ │ ├── camera.c/h # IOKit USB camera control
│ │ ├── server.c/h # WebSocket server + JSON command dispatch
│ │ ├── control.c/h # TCP control socket (for CLI)
│ │ ├── mdns.c/h # mDNS service advertisement
│ │ ├── install.sh # Installation script
│ │ └── service.sh # launchd service management helper
│ └── linkctl-panel/ # Windows GUI control panel (pure Win32 C, zero deps)
│ ├── CMakeLists.txt # Panel build config (MSVC or MinGW)
│ ├── main.c # wWinMain: wires camera + server + mDNS + GUI
│ ├── gui.c/h # Win32 control panel (D-pad, sliders, status)
│ ├── camera.c/h # DirectShow/KS camera control + XU GUID discovery
│ ├── server.c/h # WebSocket server thread + JSON command dispatch
│ ├── ws.c/h # Minimal RFC 6455 codec (SHA-1, Base64, framing)
│ ├── json.c/h # Minimal JSON field extraction
│ └── mdns.c/h # DNS-SD advertisement (DnsServiceRegister)
└── docs/
└── 2026-03-29-direct-uvc-control-design.md # Design document
```
Expand All @@ -185,3 +219,11 @@ Two interfaces:
- **libwebsockets `lws_service()` blocks indefinitely** on macOS regardless of the timeout parameter when there are no pending events. Do not use it in any loop that needs to service other I/O. This is why the TCP control socket runs in its own thread.
- The control thread uses a `pthread_mutex` around all `camera_*` calls to serialize access with the WebSocket thread. Any new code calling camera functions from the control path must hold `camera_mutex`.
- After sending a `center` command, always follow with `stop` — the gimbal can drift after centering if not explicitly stopped.

## Windows Panel Gotchas

- `usbvideo.sys` owns the camera, so the macOS approach (raw class-specific USB control transfers) is impossible. XU controls must go through `IKsControl::KsProperty` with a `KSP_NODE` addressing the dev-specific topology node, and the property set GUID must be the XU's `guidExtensionCode` — not the unit ID. The GUID is read from the USB config descriptor via the parent hub (`IOCTL_USB_GET_DESCRIPTOR_FROM_NODE_CONNECTION`); don't hardcode it.
- `<usbioctl.h>` needs `<winioctl.h>` included first (for `CTL_CODE`/`FILE_DEVICE_*`), at least under MinGW.
- `ksproxy.h`/`vidcap.h` are not reliably C-compatible across SDKs — `camera.c` declares the `IKsControl`/`IKsTopologyInfo` vtables locally instead.
- All `camera_*` functions take an internal `CRITICAL_SECTION`; COM is initialized as MTA, and every thread that touches the camera must call `camera_thread_attach()` first (the server thread does this on startup).
- Cross-compiling with `x86_64-w64-mingw32-gcc` on Linux is the fastest way to syntax-check; the portable parts (`ws.c`, `json.c`) also compile natively for unit testing with `-D_strnicmp=strncasecmp`.
42 changes: 34 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@

The first-party control software for the [Insta360 Link](https://www.insta360.com/product/insta360-link) is (no offense) bloated garbage, even though the hardware is pretty solid. So, I took matters into my own hands.

This implementation controls the camera directly via USB/UVC extension units through `linkctl-daemon` a lightweight daemon — no Insta360 desktop app required. Interactions with the daemon can be driven by either:
This implementation controls the camera directly via USB/UVC extension units — no Insta360 desktop app required. On **macOS** the camera is driven by `linkctl-daemon`, a lightweight daemon, with either a CLI client (`linkctl`) or an ESP32-based joystick controller on top. On **Windows** a single native executable, `linkctl-panel`, provides a GUI control panel and speaks the same WebSocket protocol so the ESP32 controller works there too.

1. A CLI client `linkctl`
2. An ESP32-based joystick controller

This repo contains the ESP32 firmware, Mac daemon and CLI source codes, and KiCad PCB design files for a custom handheld controller.
This repo contains the ESP32 firmware, the Mac daemon and CLI, the Windows control panel, and KiCad PCB design files for a custom handheld controller.

```
[Insta360 Link] ──USB/UVC──> [linkctl-daemon (Mac)]
──WiFi/WebSocket──> [ESP32 + Joystick]
──CLI Client──────> [linkctl Interface]

[Insta360 Link] ──USB/UVC──> [linkctl-panel (Windows)]
── GUI control panel (built in)
──WiFi/WebSocket──> [ESP32 + Joystick]
```
## Opportunities For Future Work:
- [ ] **Windows Support**: this is on my todo list
- [x] **Windows Support**: `linkctl-panel` — native GUI control panel (MVP, no viewfinder yet)
- [ ] **Linux Support**: this is not on my todo list currently, but PRs are welcome :)
- [ ] **Keyframe Implementation**: as I've been using this, the ability to have orientation presets has become the *one* feature I've missed from the first-party software. This is on my todo list for after Windows support

Expand Down Expand Up @@ -82,6 +83,25 @@ sudo cmake --install . --prefix /usr/local

Or run directly from the build directory: `./build/linkctl <command>`.

### Windows: install `linkctl-panel` (instead of steps 1–2)

On Windows, a single executable replaces both the daemon and the CLI: a native GUI control panel that also runs the WebSocket + mDNS interface for the ESP32 controller. No dependencies, no installer. Requires Windows 10 1809+ (for built-in mDNS).

**Option A: Download the latest release**

Download `linkctl-panel-*-windows-*.zip` from the [latest release](https://github.com/jfwoods/insta360link-joystick-controller/releases/latest), extract it, and run `linkctl-panel.exe`.

**Option B: Build from source** (CMake + Visual Studio or MinGW-w64)

```powershell
cd host\linkctl-panel
cmake -B build
cmake --build build --config Release
.\build\Release\linkctl-panel.exe
```

See [host/linkctl-panel/README.md](host/linkctl-panel/README.md) for details.

### 3. Flash ESP32 firmware (if using joystick controller)

Requires [PlatformIO](https://platformio.org/).
Expand All @@ -107,9 +127,11 @@ connect

### 5. Use it

**CLI:** Run `linkctl status` to verify the daemon is running and the camera is connected, then use `linkctl jog` for interactive control.
**CLI (macOS):** Run `linkctl status` to verify the daemon is running and the camera is connected, then use `linkctl jog` for interactive control.

**Joystick:** Move the joystick to pan and tilt the camera. The ESP32 reads the analog input, converts it to direction and speed, and sends commands to the daemon over WebSocket. The ESP32 discovers the daemon automatically via mDNS — no IP configuration needed.
**Control panel (Windows):** Launch `linkctl-panel.exe` — hold the D-pad buttons (or arrow keys) to pan/tilt, drag the zoom slider or press `+`/`-`, press `C` or the button to center the gimbal.

**Joystick:** Move the joystick to pan and tilt the camera. The ESP32 reads the analog input, converts it to direction and speed, and sends commands to the host over WebSocket. The ESP32 discovers the daemon (or Windows panel) automatically via mDNS — no IP configuration needed.

## Dependencies

Expand All @@ -128,6 +150,10 @@ connect

No external dependencies — pure POSIX C.

### Windows control panel (`linkctl-panel`)

No external dependencies — pure Win32 C (DirectShow/kernel streaming for camera control, Winsock for the WebSocket server, built-in Windows DNS-SD for mDNS).

### ESP32 Firmware

| Dependency | Source | Purpose |
Expand Down
47 changes: 47 additions & 0 deletions host/linkctl-panel/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
cmake_minimum_required(VERSION 3.16)
project(linkctl-panel C)

if(NOT WIN32)
message(FATAL_ERROR "linkctl-panel is Windows-only. On macOS use host/linkctl-daemon + host/linkctl.")
endif()

set(CMAKE_C_STANDARD 11)

add_executable(linkctl-panel WIN32
main.c
gui.c
camera.c
server.c
ws.c
json.c
mdns.c
)

# DnsServiceRegister and friends need the Windows 10 1809+ API surface
target_compile_definitions(linkctl-panel PRIVATE
_WIN32_WINNT=0x0A00
UNICODE
_UNICODE
_CRT_SECURE_NO_WARNINGS
)

if(MSVC)
target_compile_options(linkctl-panel PRIVATE /W3)
target_link_options(linkctl-panel PRIVATE /ENTRY:wWinMainCRTStartup)
else()
target_compile_options(linkctl-panel PRIVATE -Wall -Wextra -municode)
target_link_options(linkctl-panel PRIVATE -municode -static)
endif()

target_link_libraries(linkctl-panel PRIVATE
ole32 # COM
oleaut32 # VARIANT/BSTR for the DirectShow property bag
strmiids # DirectShow CLSIDs/IIDs
uuid
setupapi # USB hub enumeration (XU GUID discovery)
ws2_32 # WebSocket server
dnsapi # DNS-SD (mDNS) advertisement
comctl32 # Trackbars, button subclassing
user32
gdi32
)
65 changes: 65 additions & 0 deletions host/linkctl-panel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# linkctl-panel

Native Windows control panel for the Insta360 Link. A single small `.exe`
with zero dependencies — no Insta360 desktop app, no installer, no
services. It is the Windows equivalent of `linkctl-daemon` + `linkctl`
combined: a GUI for local control, plus the same WebSocket + mDNS
interface so the ESP32 joystick controller works against it unchanged.

```
[Insta360 Link] --USB/UVC--> [linkctl-panel.exe] --WiFi/WebSocket--> [ESP32 + Joystick]
GUI control panel mDNS: _insta360ctrl._tcp
```

## Features

- **Pan/tilt**: hold-to-move D-pad buttons or arrow keys, with speed sliders
- **Zoom**: slider or `+`/`-` keys (1.0x–4.0x)
- **Center gimbal**: button or `C` key (centers, stops, resets zoom — same
sequence as the `linkctl center` CLI command)
- **Stop**: button, `Space`, or `Esc`
- **ESP32 support**: WebSocket server on port 9000 with the same JSON
protocol as the Mac daemon, advertised via mDNS (Windows' built-in
DNS-SD — no Bonjour install required, Windows 10 1809+)
- Auto-reconnects when the camera is unplugged/replugged

No viewfinder — this is a controller, not a capture app. Use the camera in
any other application (Teams, OBS, ...) while the panel drives it.

## How it works

Windows' `usbvideo.sys` owns the camera, so raw USB control transfers (the
macOS approach) aren't possible. Instead:

- **Pan/tilt/center** go through the camera's UVC extension unit via kernel
streaming (`IKsControl::KsProperty`). Windows addresses extension units
by GUID rather than unit ID, so at startup the panel reads the camera's
USB configuration descriptor through its parent hub and extracts the
`guidExtensionCode` for unit `0x09` — no hardcoded vendor GUID.
- **Zoom** uses the standard UVC Camera Terminal control via DirectShow's
`IAMCameraControl`.

## Build

Requires CMake and either Visual Studio (MSVC) or MinGW-w64. No libraries
to install.

```powershell
cd host\linkctl-panel
cmake -B build
cmake --build build --config Release
.\build\Release\linkctl-panel.exe
```

Run with `--console` to attach a console and see camera/server logs:

```powershell
.\linkctl-panel.exe --console
```

## Protocol

Identical to `linkctl-daemon` — see the repository root README. ESP32
clients discover the panel via mDNS (`_insta360ctrl._tcp`) and send JSON
commands (`pan_tilt`, `zoom`, `stop`, `center`, `status`) over WebSocket
on port 9000.
Loading
Loading