metadata.json claimed MIT while no LICENSE file existed, so the repository had no effective licence at all. It is now GPL-3.0-or-later: the full FSF text in LICENSE, the identifier in metadata.json, and REUSE-style SPDX-FileCopyrightText and SPDX-License-Identifier headers on all 33 source files, which is the convention KDE itself uses. Worth recording that GPL-3.0 is a preference here rather than an obligation. Nothing in the dependency graph forces it: linkctl is MIT and is invoked as a subprocess rather than linked, and the KDE QML modules the applet imports are LGPL. Removed two things that should not ship in a public repository. tests's QML linter had an absolute sandbox path, /tmp/pyside/PySide6/qmllint, baked into its search list; it now looks for Qt's qmllint and PySide6's pyside6-qmllint on PATH and honours a QMLLINT override, and says how to install one when it finds nothing. The systemd unit carried a Documentation= URL pointing at a host that does not exist, and its ExecStart silently assumed a particular checkout directory; the URL is gone and the path is now an obvious PATH/TO/CHECKOUT placeholder next to the comment telling you to edit it. Added a Credits section naming the work this is built on, since the project is mostly glue: linkctl does the camera control, schlarpc's firmware teardown documents the extension-unit protocol, and vrwallace's controller established that the Link 2 needs both pan and tilt axes in a single VIDIOC_S_EXT_CTRLS call, which is the detail the whole thing depends on. Also made the two Python tests executable, matching the other scripts. Audited for operator-specific detail: no credentials, tokens, private hostnames, IP addresses or absolute home paths in any tracked file or anywhere in the history's diffs. Three merge commits do carry Reviewed-on and Reviewed-by trailers naming a private forge, added by the forge at merge time; those are commit metadata rather than file content and are handled separately. Tested: 169 checks across seven suites plus the QML linter, all passing after the header insertion, which confirms it did not disturb the order-sensitive `.pragma library` line in logic.js or the qmldir module definitions. shellcheck clean.
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".
Reinstalling needs --restart. Plasma loads an applet's QML once and keeps
it. Pulling new code and restarting the desktop is not enough on its own,
because the installed copy is only replaced by install.sh. After updating
this repo, run ./plasmoid/install.sh --restart, or new behaviour will appear
to be missing while the old code carries on running.
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
tests/test_resolver.sh # binary resolution, run in a real shell
python3 tests/lint_qml.py # QML scope errors, with KDE types resolved
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.
tests/lint_qml.py is worth understanding before trusting a clean QML run.
Plain qmllint cannot resolve the KDE QML modules, so it reports every
identifier inside PlasmoidItem as "Unqualified access" and buries real
findings in roughly ninety lines of noise. tests/qmlstubs/ holds minimal
stand-ins for those modules so the types resolve and the scope analysis means
something; with them in place the only legitimate unqualified accesses are a
short allowlist, so anything else is a real finding. This exists because
filtering that noise wholesale once hid a ReferenceError: stick is not defined that disabled all camera movement.
If you add a configuration key to contents/config/main.xml, add it to
tests/qmlstubs/org/kde/plasma/plasmoid/Configuration.qml too, or the linter
will not catch a typo in the key name.
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.
The widget cannot control the camera, but linkctl works in a terminal.
Almost always PATH. Plasma runs applet commands through
KProcess::setShellCommand, which is /bin/sh -c inheriting plasmashell's
environment, and that environment never sources ~/.bashrc or a login
profile. An install that puts linkctl on PATH from a shell rc file, which
includes mise and asdf shims and ~/.local/bin on some distributions, is
therefore invisible to the widget. The widget now searches those locations
itself at startup and says so in its status line if it finds nothing; the
settings page accepts a full path to override it. Compare what the session
sees with what your shell sees:
systemctl --user show-environment | grep '^PATH'
command -v linkctl
The widget worked, then stopped after a restart or a replug. The Link's
/dev/videoN number is not stable. If the Video device field in the widget's
settings is set, clear it and let auto-detection run: a pinned node that has
since moved makes every command fail.
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.
Credits
This is glue around other people's reverse engineering, and none of it would exist without them:
illegalstudio/linkctl(MIT) does the actual camera control. Everything here shells out to it.schlarpc/insta360-link-firmware-redocuments the camera's UVC extension-unit protocol and internal parameter space from a firmware teardown. It is the reference for anything not already exposed bylinkctl.vrwallace/Insta360-Link-1-and-2-Controller-for-Linuxestablished that the Link 2 rejects single-axis pan/tilt writes and needs both axes in oneVIDIOC_S_EXT_CTRLScall, which is the detail that makes gimbal control work at all.
License
GPL-3.0-or-later. See LICENSE.
linkctl itself is MIT and separately licensed; this project invokes it as a
subprocess and does not include or link against it.
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.