Files
insta360-link-tools/tests/test_gamepad_loop.sh
T
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

115 lines
3.5 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# End-to-end test of linkctl-gamepad's read loop, without a controller.
#
# Feeds synthetic joystick events through a FIFO used in place of
# /dev/input/js*, and checks what the daemon would run. This is the only test
# that covers select/read/decode/tick/dispatch together; tests/test_gamepad.py
# covers the pure helpers.
#
# Run: tests/test_gamepad_loop.sh
set -uo pipefail
HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
readonly HERE
readonly GAMEPAD="$HERE/../bin/linkctl-gamepad"
failures=0
checks=0
OUT=$(mktemp)
FIFO=""
cleanup() { rm -f "$OUT" ${FIFO:+"$FIFO"}; }
trap cleanup EXIT
check() {
local label=$1 pattern=$2
checks=$((checks + 1))
if grep -qF -- "$pattern" "$OUT"; then
printf 'ok %s\n' "$label"
else
failures=$((failures + 1))
printf 'FAIL %s\n expected to find: %s\n' "$label" "$pattern"
fi
}
check_absent() {
local label=$1 pattern=$2
checks=$((checks + 1))
if grep -qF -- "$pattern" "$OUT"; then
failures=$((failures + 1))
printf 'FAIL %s\n should not have found: %s\n' "$label" "$pattern"
else
printf 'ok %s\n' "$label"
fi
}
# Run the daemon against a FIFO fed by the given python writer script.
run_scenario() {
local script=$1
FIFO=$(mktemp -u /tmp/linkctl-gamepad-test.XXXXXX)
mkfifo "$FIFO"
python3 -c "$script" "$FIFO" &
local writer=$!
sleep 0.1
timeout 5 "$GAMEPAD" --js-device "$FIFO" --match '' --dry-run \
--interval 200 --verbose > "$OUT" 2>&1
wait "$writer" 2>/dev/null
rm -f "$FIFO"
FIFO=""
}
readonly PRELUDE='
import struct, sys, time
AXIS, BUTTON, INIT = 0x02, 0x01, 0x80
def ev(value, etype, number):
return struct.pack("<IhBB", 0, value, etype, number)
f = open(sys.argv[1], "wb", buffering=0)
'
echo "=== scenario 1: full deflection, diagonal, button, disconnect ==="
run_scenario "$PRELUDE"'
# Initial-state burst. Button 0 reads as pressed; the daemon must not treat
# that as a real press and fire its mapped action.
f.write(ev(0, AXIS | INIT, 0)); f.write(ev(0, AXIS | INIT, 1))
f.write(ev(1, BUTTON | INIT, 0))
time.sleep(0.4)
f.write(ev(32767, AXIS, 0)) # pan hard right
time.sleep(0.5)
f.write(ev(-32767, AXIS, 1)) # tilt hard up (negative is up)
time.sleep(0.5)
f.write(ev(0, AXIS, 0)); f.write(ev(0, AXIS, 1))
time.sleep(0.3)
f.write(ev(1, BUTTON, 2)) # real press: tracking toggle
time.sleep(0.4)
f.close()
'
echo "--- output ---"; cat "$OUT"; echo "---"
check "selects the device" "Using "
check "pans right at full deflection" "--quiet right 6.0"
check "tilts up at full deflection" "--quiet up 6.0"
check "real button press fires" "--quiet tracking toggle"
check "handles disconnect" "waiting for the controller"
# Button 0 maps to `center` by default and appeared only in the INIT burst,
# so seeing it would mean INIT filtering is broken.
check_absent "ignores the initial-state burst" "--quiet center"
echo
echo "=== scenario 2: deflection inside the deadzone ==="
# 4000/32767 is about 0.12, under the 0.15 default deadzone, so the daemon
# must stay completely still.
run_scenario "$PRELUDE"'
f.write(ev(0, AXIS | INIT, 0)); f.write(ev(0, AXIS | INIT, 1))
time.sleep(0.3)
f.write(ev(4000, AXIS, 0))
time.sleep(0.8)
f.close()
'
echo "--- output ---"; cat "$OUT"; echo "---"
check_absent "deadzone suppresses movement entirely" "would run:"
printf '\n%d checks, %d failures\n' "$checks" "$failures"
[[ $failures -eq 0 ]]