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.
233 lines
8.7 KiB
Python
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)
|