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.
236 lines
8.8 KiB
Python
Executable File
236 lines
8.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 branderson
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
"""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)
|