Files
insta360-link-tools/tests/test_gamepad.py
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

233 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""Tests for linkctl-gamepad's pure helpers.
Run: python3 tests/test_gamepad.py
These cover what can be checked without a controller: decoding the kernel's
joystick event struct, deadzone rescaling, command construction and argument
parsing. Device discovery and the read loop need real hardware.
"""
import importlib.util
import os
import struct
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
TARGET = os.path.join(HERE, "..", "bin", "linkctl-gamepad")
# The script has no .py extension, so load it by path.
_spec = importlib.util.spec_from_loader(
"linkctl_gamepad",
importlib.machinery.SourceFileLoader("linkctl_gamepad", TARGET),
)
gp = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(gp)
def js_event(value, etype, number, tstamp=0):
"""Build a raw joystick event the way the kernel would."""
return struct.pack("<IhBB", tstamp, value, etype, number)
class TestDecode(unittest.TestCase):
def test_axis_event(self):
buf = js_event(-32767, gp.JS_EVENT_AXIS, 1)
_, value, etype, number = gp.decode_event(buf)
self.assertEqual(value, -32767)
self.assertEqual(etype, gp.JS_EVENT_AXIS)
self.assertEqual(number, 1)
self.assertFalse(gp.is_init(buf))
def test_button_event(self):
buf = js_event(1, gp.JS_EVENT_BUTTON, 3)
_, value, etype, number = gp.decode_event(buf)
self.assertEqual((value, etype, number), (1, gp.JS_EVENT_BUTTON, 3))
def test_init_flag_is_stripped_but_reported(self):
# Opening the device replays current state with INIT set. The type
# must still decode as a button, and is_init must flag it, or the
# daemon would fire mapped actions on startup.
buf = js_event(1, gp.JS_EVENT_BUTTON | gp.JS_EVENT_INIT, 0)
_, _, etype, _ = gp.decode_event(buf)
self.assertEqual(etype, gp.JS_EVENT_BUTTON)
self.assertTrue(gp.is_init(buf))
def test_init_axis(self):
buf = js_event(0, gp.JS_EVENT_AXIS | gp.JS_EVENT_INIT, 2)
_, _, etype, number = gp.decode_event(buf)
self.assertEqual((etype, number), (gp.JS_EVENT_AXIS, 2))
self.assertTrue(gp.is_init(buf))
def test_short_buffer_rejected(self):
with self.assertRaises(ValueError):
gp.decode_event(b"\x00" * 7)
def test_event_size_matches_kernel_struct(self):
self.assertEqual(gp.JS_EVENT_SIZE, 8)
class TestNormalize(unittest.TestCase):
def test_extremes_and_centre(self):
self.assertAlmostEqual(gp.normalize_axis(32767), 1.0)
self.assertAlmostEqual(gp.normalize_axis(-32767), -1.0)
self.assertAlmostEqual(gp.normalize_axis(0), 0.0)
def test_clamps_beyond_nominal_range(self):
# Some drivers report -32768, one past the nominal minimum.
self.assertAlmostEqual(gp.normalize_axis(-32768), -1.0)
self.assertAlmostEqual(gp.normalize_axis(40000), 1.0)
def test_midpoint(self):
self.assertAlmostEqual(gp.normalize_axis(16384), 0.5, places=3)
class TestDeadzone(unittest.TestCase):
def test_inside_deadzone_is_zero(self):
self.assertEqual(gp.apply_deadzone(0.1, 0.15), 0.0)
self.assertEqual(gp.apply_deadzone(-0.1, 0.15), 0.0)
self.assertEqual(gp.apply_deadzone(0.15, 0.15), 0.0)
def test_rescales_from_zero_at_the_edge(self):
# The point of rescaling: just past the edge must be near zero speed,
# not jump straight to 0.15 worth of it.
self.assertAlmostEqual(gp.apply_deadzone(0.16, 0.15), 0.01 / 0.85,
places=6)
def test_full_deflection_is_preserved(self):
self.assertAlmostEqual(gp.apply_deadzone(1.0, 0.15), 1.0)
self.assertAlmostEqual(gp.apply_deadzone(-1.0, 0.15), -1.0)
def test_sign_is_preserved(self):
self.assertLess(gp.apply_deadzone(-0.5, 0.15), 0)
self.assertGreater(gp.apply_deadzone(0.5, 0.15), 0)
def test_zero_deadzone_is_identity(self):
self.assertAlmostEqual(gp.apply_deadzone(0.42, 0.0), 0.42)
def test_invalid_deadzone_rejected(self):
for bad in (-0.1, 1.0, 1.5):
with self.assertRaises(ValueError):
gp.apply_deadzone(0.5, bad)
class TestMovementCommands(unittest.TestCase):
def test_centred_stick_sends_nothing(self):
self.assertEqual(gp.movement_commands(0.0, 0.0, 6.0), [])
def test_tiny_deflection_sends_nothing(self):
# 0.01 * 6 = 0.06 degrees, below the half-degree floor.
self.assertEqual(gp.movement_commands(0.01, 0.01, 6.0), [])
def test_cardinal_directions(self):
self.assertEqual(gp.movement_commands(1.0, 0.0, 6.0),
[["right", "6.0"]])
self.assertEqual(gp.movement_commands(-1.0, 0.0, 6.0),
[["left", "6.0"]])
self.assertEqual(gp.movement_commands(0.0, 1.0, 6.0),
[["up", "6.0"]])
self.assertEqual(gp.movement_commands(0.0, -1.0, 6.0),
[["down", "6.0"]])
def test_partial_deflection_scales_the_step(self):
self.assertEqual(gp.movement_commands(0.5, 0.0, 6.0),
[["right", "3.0"]])
def test_diagonal_yields_both_axes(self):
self.assertEqual(gp.movement_commands(1.0, 1.0, 6.0),
[["right", "6.0"], ["up", "6.0"]])
def test_off_axis_component_below_floor_is_dropped(self):
# A mostly-horizontal push must not also creep vertically.
self.assertEqual(gp.movement_commands(1.0, 0.05, 6.0),
[["right", "6.0"]])
def test_inversion(self):
self.assertEqual(gp.movement_commands(1.0, 0.0, 6.0, invert_pan=True),
[["left", "6.0"]])
self.assertEqual(gp.movement_commands(0.0, 1.0, 6.0, invert_tilt=True),
[["down", "6.0"]])
self.assertEqual(
gp.movement_commands(1.0, 1.0, 6.0, invert_pan=True,
invert_tilt=True),
[["left", "6.0"], ["down", "6.0"]])
def test_arguments_are_separate_list_items(self):
# They are passed to subprocess without a shell, so the degrees must
# not be glued onto the direction.
for cmd in gp.movement_commands(1.0, 1.0, 6.0):
self.assertEqual(len(cmd), 2)
class TestButtonSpec(unittest.TestCase):
def test_simple(self):
self.assertEqual(gp.parse_button_spec("0=center"), (0, "center"))
def test_multiword_action(self):
self.assertEqual(gp.parse_button_spec("1=preset load home"),
(1, "preset load home"))
def test_whitespace_tolerated(self):
self.assertEqual(gp.parse_button_spec(" 2 = tracking toggle "),
(2, "tracking toggle"))
def test_rejects_missing_separator(self):
import argparse
with self.assertRaises(argparse.ArgumentTypeError):
gp.parse_button_spec("center")
def test_rejects_non_numeric_button(self):
import argparse
with self.assertRaises(argparse.ArgumentTypeError):
gp.parse_button_spec("cross=center")
def test_rejects_empty_action(self):
import argparse
with self.assertRaises(argparse.ArgumentTypeError):
gp.parse_button_spec("0=")
class TestRunner(unittest.TestCase):
def test_base_command_assembly(self):
r = gp.Runner("linkctl", None, True, True, False)
self.assertEqual(r.base, ["linkctl", "--force", "--quiet"])
def test_device_and_no_force(self):
r = gp.Runner("/usr/bin/linkctl", "/dev/video0", False, True, False)
self.assertEqual(
r.base, ["/usr/bin/linkctl", "--device", "/dev/video0", "--quiet"])
def test_dry_run_does_not_execute(self):
import io
import contextlib
r = gp.Runner("linkctl", None, True, True, False)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
r(["left", "6.0"])
self.assertIn("linkctl --force --quiet left 6.0", buf.getvalue())
class TestCliParsing(unittest.TestCase):
def test_defaults(self):
a = gp.build_parser().parse_args([])
self.assertEqual((a.pan_axis, a.tilt_axis), (0, 1))
self.assertAlmostEqual(a.deadzone, 0.15)
self.assertTrue(a.force)
self.assertTrue(a.default_buttons)
def test_repeatable_buttons(self):
a = gp.build_parser().parse_args(
["--button", "3=tracking toggle", "--button", "4=center"])
self.assertEqual(a.button, [(3, "tracking toggle"), (4, "center")])
def test_rejects_bad_deadzone(self):
self.assertEqual(gp.main(["--deadzone", "1.5"]), 2)
def test_rejects_tiny_interval(self):
self.assertEqual(gp.main(["--interval", "1"]), 2)
if __name__ == "__main__":
unittest.main(verbosity=2)