Files
brad f95853dc25 Add GPL-3.0 licensing and prepare for a public repository
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.
2026-10-13 20:54:23 -07:00

431 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 branderson
# SPDX-License-Identifier: GPL-3.0-or-later
"""Drive an Insta360 Link gimbal from a game controller.
Reads the kernel joystick device directly (``/dev/input/js*``) and issues
relative ``linkctl`` moves while a stick is held off centre, in the same
velocity style as the Plasma widget. Qt 6 removed QtGamepad and offers no
replacement, so the applet cannot do this itself.
Only the standard library is used: the joystick event format is eight bytes
and reading it needs no third-party package.
Discovering the mapping for your controller:
linkctl-gamepad --list # which devices exist
linkctl-gamepad --monitor # print events as you press things
Then run it, overriding any axis or button number that differs:
linkctl-gamepad --button 3="tracking toggle"
Defaults target a DualSense on the in-tree ``hid_playstation`` driver. Button
and axis numbering is driver-specific, so treat them as a starting point and
confirm with ``--monitor``.
"""
from __future__ import annotations
import argparse
import errno
import glob
import os
import select
import struct
import subprocess
import sys
import time
# struct js_event { __u32 time; __s16 value; __u8 type; __u8 number; }
JS_EVENT = struct.Struct("<IhBB")
JS_EVENT_SIZE = JS_EVENT.size # 8
JS_EVENT_BUTTON = 0x01
JS_EVENT_AXIS = 0x02
JS_EVENT_INIT = 0x80 # ORed into type on the initial sync
AXIS_MAX = 32767.0
DEFAULT_BUTTONS = {
0: "center",
1: "preset load home",
2: "tracking toggle",
}
# --------------------------------------------------------------------------
# Pure helpers. Kept free of I/O so tests/test_gamepad.py can exercise them.
# --------------------------------------------------------------------------
def decode_event(buf: bytes) -> tuple[int, int, int, int]:
"""Decode one joystick event into (time, value, type, number).
The JS_EVENT_INIT flag is stripped from the type and returned separately
by `is_init`, because callers almost always want the bare type.
"""
if len(buf) != JS_EVENT_SIZE:
raise ValueError(f"expected {JS_EVENT_SIZE} bytes, got {len(buf)}")
tstamp, value, etype, number = JS_EVENT.unpack(buf)
return tstamp, value, etype & ~JS_EVENT_INIT, number
def is_init(buf: bytes) -> bool:
"""Whether an event is part of the synthetic initial state burst.
Opening the device replays the current state of every axis and button.
Treating those as real presses would fire actions at startup.
"""
return bool(JS_EVENT.unpack(buf)[2] & JS_EVENT_INIT)
def normalize_axis(raw: int) -> float:
"""Scale a raw axis reading to -1.0..1.0."""
return max(-1.0, min(1.0, raw / AXIS_MAX))
def apply_deadzone(value: float, deadzone: float) -> float:
"""Zero out small readings and rescale the rest.
Rescaling matters: without it, motion jumps to `deadzone` worth of speed
the instant the stick leaves the dead area. With it, speed still starts
from zero at the edge of the deadzone.
"""
if deadzone < 0 or deadzone >= 1:
raise ValueError("deadzone must be in [0, 1)")
magnitude = abs(value)
if magnitude <= deadzone:
return 0.0
scaled = (magnitude - deadzone) / (1.0 - deadzone)
return scaled if value > 0 else -scaled
def movement_commands(
nx: float,
ny: float,
step: float,
invert_pan: bool = False,
invert_tilt: bool = False,
) -> list[list[str]]:
"""Build the linkctl argument lists for one tick.
`ny` is positive upwards. Each axis needs its own call because linkctl's
relative commands are single-axis, so a diagonal yields two. Returns an
empty list when nothing is worth sending.
"""
x = -nx if invert_pan else nx
y = -ny if invert_tilt else ny
out: list[list[str]] = []
dx = abs(x) * step
dy = abs(y) * step
# Below half a degree the camera's own 0.1 degree resolution makes the
# step pointless, and it would otherwise creep on a near-centred stick.
if dx >= 0.5:
out.append(["right" if x > 0 else "left", f"{dx:.1f}"])
if dy >= 0.5:
out.append(["up" if y > 0 else "down", f"{dy:.1f}"])
return out
def parse_button_spec(spec: str) -> tuple[int, str]:
"""Parse a ``N=linkctl args`` button mapping."""
number, sep, action = spec.partition("=")
if not sep:
raise argparse.ArgumentTypeError(
f"expected NUMBER=ACTION, got {spec!r}")
try:
index = int(number.strip())
except ValueError:
raise argparse.ArgumentTypeError(
f"button number must be an integer, got {number!r}") from None
action = action.strip()
if not action:
raise argparse.ArgumentTypeError(f"empty action for button {index}")
return index, action
# --------------------------------------------------------------------------
# Device discovery
# --------------------------------------------------------------------------
def device_name(path: str) -> str:
"""Read a joystick's advertised name from sysfs, or '' if unavailable."""
node = os.path.basename(path)
for candidate in (
f"/sys/class/input/{node}/device/name",
f"/sys/class/input/{node}/name",
):
try:
with open(candidate, "r") as handle:
return handle.read().strip()
except OSError:
continue
return ""
def list_devices() -> list[tuple[str, str]]:
return [(p, device_name(p))
for p in sorted(glob.glob("/dev/input/js*"))]
def pick_device(explicit: str | None, match: str | None) -> str | None:
"""Choose a joystick device, preferring an explicit path."""
if explicit:
return explicit if os.path.exists(explicit) else None
devices = list_devices()
if not devices:
return None
if match:
needle = match.lower()
for path, name in devices:
if needle in name.lower():
return path
return None
return devices[0][0]
# --------------------------------------------------------------------------
# Runner
# --------------------------------------------------------------------------
class Runner:
"""Invokes linkctl, with a shared base argument list."""
def __init__(self, binary: str, device: str | None, force: bool,
dry_run: bool, verbose: bool):
self.base = [binary]
if force:
self.base.append("--force")
if device:
self.base += ["--device", device]
self.base.append("--quiet")
self.dry_run = dry_run
self.verbose = verbose
def __call__(self, args: list[str]) -> None:
cmd = self.base + args
if self.dry_run:
print("would run:", " ".join(cmd), flush=True)
return
try:
# Blocking, with a timeout: these take milliseconds, and letting
# them overlap would queue conflicting moves. A hung linkctl
# costs one tick rather than wedging the daemon.
result = subprocess.run(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
timeout=2.0,
)
if result.returncode != 0 and self.verbose:
err = result.stderr.decode("utf-8", "replace").strip()
print(f"linkctl exited {result.returncode}: {err}",
file=sys.stderr, flush=True)
except subprocess.TimeoutExpired:
print("linkctl timed out", file=sys.stderr, flush=True)
except FileNotFoundError:
print(f"linkctl not found: {self.base[0]}",
file=sys.stderr, flush=True)
raise SystemExit(127) from None
def monitor(path: str) -> int:
"""Print events as they arrive, to discover axis and button numbers."""
print(f"Monitoring {path} ({device_name(path) or 'unknown'}).")
print("Move sticks and press buttons. Ctrl-C to stop.\n")
with open(path, "rb", buffering=0) as handle:
while True:
buf = handle.read(JS_EVENT_SIZE)
if not buf or len(buf) < JS_EVENT_SIZE:
return 0
_, value, etype, number = decode_event(buf)
tag = " (initial)" if is_init(buf) else ""
if etype == JS_EVENT_AXIS:
print(f"axis {number:>2} value {value:>6} "
f"({normalize_axis(value):+.2f}){tag}", flush=True)
elif etype == JS_EVENT_BUTTON:
state = "pressed" if value else "released"
print(f"button {number:>2} {state}{tag}", flush=True)
def run(args: argparse.Namespace, run_cmd: Runner) -> int:
buttons = dict(DEFAULT_BUTTONS) if args.default_buttons else {}
buttons.update(dict(args.button or []))
axes: dict[int, float] = {}
interval = args.interval / 1000.0
while True:
path = pick_device(args.js_device, args.match)
if path is None:
if not args.wait:
print("no joystick found; use --list to see devices, or "
"--wait to keep retrying", file=sys.stderr)
return 3
time.sleep(2.0)
continue
try:
handle = open(path, "rb", buffering=0)
except OSError as exc:
if exc.errno in (errno.EACCES, errno.EPERM):
print(f"cannot open {path}: permission denied. Add yourself "
"to the 'input' group.", file=sys.stderr)
return 6
time.sleep(2.0)
continue
print(f"Using {path} ({device_name(path) or 'unknown'})", flush=True)
axes.clear()
next_tick = time.monotonic() + interval
try:
with handle:
while True:
now = time.monotonic()
timeout = max(0.0, next_tick - now)
ready, _, _ = select.select([handle], [], [], timeout)
if ready:
chunk = handle.read(JS_EVENT_SIZE * 32)
if not chunk:
raise OSError(errno.ENODEV, "device went away")
for offset in range(0, len(chunk) - JS_EVENT_SIZE + 1,
JS_EVENT_SIZE):
buf = chunk[offset:offset + JS_EVENT_SIZE]
_, value, etype, number = decode_event(buf)
if etype == JS_EVENT_AXIS:
axes[number] = normalize_axis(value)
elif (etype == JS_EVENT_BUTTON and value
and not is_init(buf)):
action = buttons.get(number)
if action:
run_cmd(action.split())
elif args.verbose:
print(f"button {number} unmapped",
flush=True)
now = time.monotonic()
if now < next_tick:
continue
# Skip rather than catch up, so a slow linkctl cannot
# build a backlog of stale moves.
next_tick = now + interval
raw_x = axes.get(args.pan_axis, 0.0)
raw_y = axes.get(args.tilt_axis, 0.0)
nx = apply_deadzone(raw_x, args.deadzone)
# Sticks report positive downwards; the rest of the
# codebase treats positive as up.
ny = -apply_deadzone(raw_y, args.deadzone)
for cmd in movement_commands(nx, ny, args.step,
args.invert_pan,
args.invert_tilt):
run_cmd(cmd)
except KeyboardInterrupt:
return 0
except OSError as exc:
print(f"{path} closed ({exc.strerror or exc}); waiting for the "
"controller to come back", file=sys.stderr, flush=True)
if not args.wait:
return 8
time.sleep(2.0)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="linkctl-gamepad",
description="Drive an Insta360 Link gimbal from a game controller.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Run --monitor to discover your controller's axis and button "
"numbers, which are driver-specific.",
)
p.add_argument("--list", action="store_true",
help="list joystick devices and exit")
p.add_argument("--monitor", action="store_true",
help="print controller events and exit")
p.add_argument("--js-device", metavar="PATH",
help="joystick device (default: first match)")
p.add_argument("--match", metavar="TEXT", default="DualSense",
help="substring of the controller name to select "
"(default: %(default)s; use '' for any)")
p.add_argument("--wait", action="store_true",
help="keep retrying when the controller is absent, and "
"reconnect when it returns")
p.add_argument("--pan-axis", type=int, default=0, metavar="N",
help="axis for pan (default: %(default)s)")
p.add_argument("--tilt-axis", type=int, default=1, metavar="N",
help="axis for tilt (default: %(default)s)")
p.add_argument("--deadzone", type=float, default=0.15, metavar="F",
help="ignore deflection below this, 0..1 "
"(default: %(default)s)")
p.add_argument("--step", type=float, default=6.0, metavar="DEG",
help="degrees per tick at full deflection "
"(default: %(default)s)")
p.add_argument("--interval", type=int, default=160, metavar="MS",
help="milliseconds between ticks (default: %(default)s)")
p.add_argument("--invert-pan", action="store_true")
p.add_argument("--invert-tilt", action="store_true")
p.add_argument("--button", action="append", type=parse_button_spec,
metavar="N=ACTION",
help="map a button to linkctl arguments, repeatable, "
"e.g. --button 3=\"tracking toggle\"")
p.add_argument("--no-default-buttons", dest="default_buttons",
action="store_false",
help="do not apply the built-in button mappings")
p.add_argument("--linkctl", default="linkctl", metavar="PATH",
help="linkctl binary (default: %(default)s)")
p.add_argument("--device", metavar="PATH",
help="camera video node, passed to linkctl --device")
p.add_argument("--no-force", dest="force", action="store_false",
help="respect linkctl's idle-camera guard")
p.add_argument("--dry-run", action="store_true",
help="print commands instead of running them")
p.add_argument("-v", "--verbose", action="store_true")
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.deadzone < 0 or args.deadzone >= 1:
print("--deadzone must be in [0, 1)", file=sys.stderr)
return 2
if args.interval < 10:
print("--interval must be at least 10ms", file=sys.stderr)
return 2
if args.list:
devices = list_devices()
if not devices:
print("no joystick devices found under /dev/input")
return 3
for path, name in devices:
print(f"{path} {name or '(unknown)'}")
return 0
if args.monitor:
path = pick_device(args.js_device, args.match or None)
if path is None:
print("no matching joystick found; try --list or --match ''",
file=sys.stderr)
return 3
try:
return monitor(path)
except KeyboardInterrupt:
return 0
runner = Runner(args.linkctl, args.device, args.force,
args.dry_run, args.verbose)
try:
return run(args, runner)
except KeyboardInterrupt:
return 0
if __name__ == "__main__":
sys.exit(main())