brad eb303db316 Add keyboard, gamepad and directional-shortcut control
Three input methods for the gimbal, all sharing the widget's velocity model:
hold to move, release to stop.

Keyboard in the applet. WASD and the arrow keys feed the same tick timer the
pointer does, through a new vectorFromKeys helper. Opposing keys cancel and a
diagonal is normalised to length 1, so two keys do not move faster than one.
Auto-repeat is discarded, because it arrives as a press/release cycle and
would make the motion stutter. The held keys are cleared when the popup loses
focus: otherwise the matching release never arrives and the camera keeps
panning on its own. The buttons and zoom slider are set to Qt.NoFocus so the
arrow keys still reach the pad after one of them is clicked.

Gamepad support, as a separate process. Qt 6 removed QtGamepad and ships no
replacement, so the applet cannot read a controller itself. bin/linkctl-gamepad
reads /dev/input/js* directly using only the standard library: the event
struct is eight bytes and needs no third-party package. Defaults target a
DualSense on hid_playstation, but axis and button numbering comes from the
driver rather than the controller and varies between kernels and connection
modes, so --monitor prints live events for mapping and every number is
overridable. Opening a joystick replays the current state of every button with
JS_EVENT_INIT set; those are filtered, or a held button would fire its mapped
action at startup. --wait reconnects a wireless pad that sleeps.

Directional global shortcuts. Meta+Alt plus the arrow keys nudge the gimbal.
These are discrete steps by necessity: kglobalaccel fires once per keypress
and exposes no press-and-hold, so this cannot be smooth motion and the README
says so and points at the widget or a gamepad instead. NUDGE sets the degrees.

Tested: 145 checks across five suites, all passing, shellcheck clean, QML free
of syntax errors. 13 new node checks cover the key vector, including the
cancellation and diagonal-normalisation cases. 36 new python checks cover
event decoding, the INIT flag, deadzone rescaling and command construction.
A new FIFO-driven suite stands in for /dev/input/js* and plays scripted events
through the real select/read/decode/tick path, confirming diagonals emit both
axes, a centred stick goes quiet, a deadzone deflection moves nothing, a real
button press fires, the initial-state burst does not, and disconnect is
handled.

Not verified: no physical controller, camera or Plasma session was available,
so the synthetic events cannot confirm a real DualSense reports the assumed
axis and button numbers.
2026-10-07 22:16:52 -07:00

Insta360 Link 2 integration for OBS and KDE

Hotkey and OBS integration on top of linkctl, so AI tracking and gimbal presets are a keypress rather than a terminal command.

Several pieces, usable independently:

Path What it gives you
bin/linkctl-macro A wrapper that makes linkctl safe to bind to a hotkey: finds the binary in a desktop session's sparse PATH, passes --force, and reports the result as a desktop notification.
kde/install-shortcuts.sh Registers KDE Plasma global shortcuts, so the keys work from anywhere including under Wayland.
obs/obs-linkctl.lua OBS hotkeys, buttons, and scene automation: a scene switch can set tracking or load a preset on its own.
plasmoid/ A Plasma 6 widget with a virtual joystick, zoom slider, and Home/Center/Tracking buttons. Pointer or WASD/arrow keys.
bin/linkctl-gamepad Drives the gimbal from a game controller (DualSense and anything else the kernel exposes as a joystick).

Prerequisites

linkctl installed and working (linkctl status reports your camera), and a saved home preset:

linkctl preset save home     # point the camera where you want "home" first

Install

./kde/install-shortcuts.sh --dry-run    # review, changes nothing
./kde/install-shortcuts.sh

Default bindings, editable in the BINDINGS table at the top of that script:

Key Action
Meta+Alt+T toggle AI tracking
Meta+Alt+1 tracking on
Meta+Alt+0 tracking off
Meta+Alt+H preset load home
Meta+Alt+C recenter gimbal
Meta+Alt+←/→ pan 5 degrees
Meta+Alt+↑/↓ tilt 5 degrees

The directional ones are discrete nudges: kglobalaccel fires once per keypress and offers no press-and-hold, so holding the key will not pan smoothly. Use the widget or a gamepad for continuous motion. NUDGE=10 ./kde/install-shortcuts.sh changes the step.

Check them afterwards with ./kde/install-shortcuts.sh --list, which shows the registered key, the default from the desktop entry, and flags any value Plasma cannot parse. Or look in System Settings under Keyboard, Shortcuts, searching for "Insta360". Reverse the whole thing with --uninstall.

The entries are visible in the launcher by default, because Plasma skips NoDisplay services when building the Shortcuts list and they would otherwise not appear there at all. HIDE_FROM_MENU=1 ./kde/install-shortcuts.sh hides them at that cost.

Joystick widget

./plasmoid/install.sh

Then right-click a panel or the desktop, Add Widgets, and search for "Insta360". Pass --restart when reinstalling over a running copy, since Plasma otherwise keeps the old one loaded.

The joystick is a velocity control: how far you push sets how fast the camera moves, and it stops when you let go. Degrees per step, step interval, deadzone and axis inversion are all in the widget's settings, along with the linkctl path and an optional device.

WASD and the arrow keys work whenever the popup has focus, with the same velocity behaviour: hold to move, release to stop. Opposing keys cancel, and a diagonal moves at the same speed as a straight push rather than faster. The buttons and zoom slider are set to take no focus, so the arrow keys keep reaching the pad after you click one. If focus leaves the popup mid-press the held keys are cleared, so the camera cannot be left panning on its own.

It drives the gimbal with relative moves rather than absolute ones, because linkctl status reports no pan or tilt at all while the camera is idle, so there is no trustworthy position to aim from. Relative steps work either way and cannot drift out of sync, and linkctl clamps each one to the camera's own range, so holding the stick against a limit is harmless.

Game controller

bin/linkctl-gamepad --list       # which joystick devices exist
bin/linkctl-gamepad --monitor    # print events as you move sticks and press buttons
bin/linkctl-gamepad              # run it

Same velocity behaviour as the widget: push the left stick to move, release to stop. Defaults are a DualSense on the in-tree hid_playstation driver, left stick on axes 0 and 1, with buttons 0, 1 and 2 bound to recenter, home preset and tracking toggle.

Confirm your mapping with --monitor before trusting the defaults. Axis and button numbering comes from the driver, not the controller, so it varies between kernels and connection modes (a DualSense often enumerates differently over Bluetooth than over USB). --monitor prints the number for whatever you touch, and everything is overridable:

bin/linkctl-gamepad --pan-axis 0 --tilt-axis 1 --step 8 --deadzone 0.2     --button 3="tracking toggle" --button 9="preset load home"

It reads /dev/input/js* directly, using only the Python standard library. Qt 6 removed QtGamepad with no replacement, so the widget cannot do this itself and this runs as a separate process. You need to be in the input group to open the device.

--wait makes it tolerate the controller being absent and reconnect when it returns, which is what you want for a wireless pad that sleeps. There is a user service in contrib/linkctl-gamepad.service for running it that way; edit the ExecStart path before installing it.

OBS

Tools, Scripts, +, then pick obs/obs-linkctl.lua.

Set the linkctl binary path if it is not on OBS's PATH, then bind the hotkeys under Settings, Hotkeys (search "Insta360").

Scene automation. Press Insert current scene names to prefill the mapping box, then give the scenes you care about an action:

Talking Head = tracking on
Overhead     = preset load overhead
Slides       = tracking off

The left side is the scene name, the right side is any linkctl arguments. Blank actions and # comments are ignored.

Startup action runs when OBS finishes loading, and defaults to preset load home. This is the setting worth keeping: the Link parks its gimbal facing down when idle, so launching OBS puts it back on its own.

Under Wayland, OBS's own hotkeys only fire while OBS has focus. That is an OBS limitation, and the reason the KDE shortcuts exist; install both and use the KDE ones for anything you need mid-stream.

Configuration

Optional, at ~/.config/linkctl-macro.conf (see linkctl-macro.conf.example):

LINKCTL=/usr/local/bin/linkctl   # if not on PATH
LINKCTL_DEVICE=/dev/video0       # pin the camera if you have more than one
LINKCTL_FORCE=1                  # 0 respects linkctl's idle-camera guard
LINKCTL_NOTIFY=always            # always | error | never

--force is on by default because a deliberate keypress means "do it now", including when nothing currently holds the camera open. That is exactly the case where the gimbal is parked facing down.

Testing without a camera

lua tests/test_obs_linkctl.lua          # pure helpers: quoting, parsing, commands
lua tests/test_obs_lifecycle.lua        # the OBS script against a mock OBS API
node tests/test_joystick_logic.js       # widget command building, parsing, key vectors
python3 tests/test_gamepad.py           # gamepad event decoding and deadzone maths
tests/test_gamepad_loop.sh              # the gamepad read loop, fed through a FIFO
bin/linkctl-macro --print tracking on   # show the command without running it
bin/linkctl-gamepad --dry-run           # print moves instead of sending them
./kde/install-shortcuts.sh --dry-run    # show the KDE changes, apply nothing
./kde/install-shortcuts.sh --list       # diagnose what is actually registered
shellcheck bin/linkctl-macro kde/install-shortcuts.sh plasmoid/install.sh

The lifecycle suite drives the script the way OBS does, so it catches broken wiring (hotkeys not firing, scene actions not matching, the preset name being captured too early) without needing OBS or a camera.

test_gamepad_loop.sh substitutes a FIFO for /dev/input/js* and plays scripted events through it, so the select/read/decode/tick path is covered without a controller attached. It is also the test that pins down the initial-state burst: opening a joystick replays every button as currently pressed, and treating that as real input would fire mapped actions at startup.

Troubleshooting

Shortcut does nothing, and System Settings says "No default shortcuts". Run ./kde/install-shortcuts.sh --list. A _launch value containing commas is the Plasma 5 format: Plasma 6 passes that value straight to QKeySequence::fromString, so Meta+Alt+T,none,Some Name parses to nothing and binds no key. Re-running the installer rewrites it as a bare key sequence. Versions of this repo before that fix wrote the Plasma 5 form.

Shortcut still does nothing. Run the action directly: bin/linkctl-macro tracking on. A failure notification names the cause. If that works but the key does not, check for a conflict in System Settings, and confirm --list shows the entry as present.

Gamepad does nothing. Check bin/linkctl-gamepad --list sees it. If the device is listed but nothing moves, run --monitor and push the stick: no events means a permissions or driver problem, events with unexpected axis numbers mean you need --pan-axis/--tilt-axis. "permission denied" means adding yourself to the input group and logging back in.

Arrow keys change zoom instead of moving the camera. That would mean the slider has keyboard focus. The controls are set to Qt.NoFocus to prevent it; if you see it, say so, because it means the focus policy is not taking effect on your Plasma build.

tracking fails with exit 12. AI tracking is experimental in linkctl and goes through the camera's vendor extension unit. Pan, tilt, zoom and presets use standard UVC and are unaffected.

Permission denied (exit 6). Add yourself to the video group, or install the udev rule from linkctl's contrib/99-insta360-link.rules.

Nothing moves at all. The Link 2 rejects single-axis pan/tilt writes and needs both axes set in one VIDIOC_S_EXT_CTRLS call. linkctl does this correctly; raw v4l2-ctl needs --set-ctrl pan_absolute=0,tilt_absolute=0 as one argument.

Status

The helpers, the shell scripts and the gamepad read loop are tested offline, and the KDE installer is dry-runnable and reversible. Use --dry-run first.

Not verified against real hardware at the time of writing: no live camera, no running Plasma session, and no physical game controller. The gamepad bridge was tested by feeding synthetic kernel events through a FIFO, which exercises every line of the loop but cannot confirm that a DualSense reports the axis and button numbers assumed by the defaults. Treat --monitor as a required first step rather than a debugging aid.

S
Description
Tools for operating an Insta360 Link 2 camera on Linux
Readme GPL-3.0
130 KiB
Languages
Python 23.9%
QML 23%
Shell 21.5%
Lua 18.9%
JavaScript 12.7%