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

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)